首页
Javascript
Html
Css
Node.js
Electron
移动开发
小程序
工具类
服务端
浏览器相关
前端收藏
其他
关于
公司注册

ECMA-262-3 in detail——第六章:闭包

2015年04月28日 发布 阅读(1716) 作者:Jerman

介绍 | Introduction

在这一章中我们来谈谈Javascript中被讨论最多的话题之一——关于闭包(closures)。事实上这个主题并不是新鲜的。然而我们在这里将试着更多从理论的角度去分析和理解它,然后我们还会看一下ECMAScript内关于闭包的内容。
前面章节中关于变量对象和作用域链中的一些知识将会作为我们接下来讨论中的一些基础。

一般理论 | General theory

在直接讨论ECMAScript中的闭包之前,需要提到一些关于函数式编程语言基本理论中的一些定义。
众所周知,在函数式语言(ECMAScript支持这种范式和文体)中,函数是数据,换句话说,它们可以赋值给变量,作为其他函数的传递参数,作为其他函数的返回值,等等。这些函数都有独特的名称和结构。

定义 | Definition

函数式参数(functional arguments, “Funarg”),是一种参数,其值为一个函数。
例如:

  1. function exampleFunc(funarg) {
  2. funarg();
  3. }
  4. exampleFunc(function() {
  5. alert("funarg");
  6. });

在这个例子中,和“funarg”对应的实参是传入exampleFunc中的匿名函数。
反过来,接受另一个函数作为其参数的函数被称为高阶函数(higher-order function, HOF)。
HOF的另一个名称是功能式的,或者更数学化的,叫运算符(operator)。上面的例子中,exampleFunc就是一个高阶函数。
当然,函数不仅可以作为传入参数,它也可以作为另一个函数的返回值。
那些将另一个函数作为返回值的函数,称为函数式值的函数(functions with functional value, or function valued functions)。

  1. (function functionValued() {
  2. return function() {
  3. alert("return function called.");
  4. };
  5. })()();

那些可以作为普通数据的函数,换句话说,可以作为传递的参数的函数,或接受函数式参数的函数,或作为函数式返回值的函数,称为第一级函数(first-class functions)。
在ECMAScript中,所有的函数都是第一级函数。
有一种函数将自身作为参数,这类函数称为自应用函数(auto-applicative functions, or self-applicative functions)。

  1. (function selfApplicative(funArg) {
  2. if (funArg && funArg === selfApplicative) {
  3. alert("self-applicative");
  4. return;
  5. }
  6. selfApplicative(selfApplicative);
  7. })();

有一种函数将自身作为返回值,这类函数称为自复制函数(auto-replicative functions, or self-replicative functions)。有时候在书籍中也被称作自我复制(self-reproducing)。(译者按,是返回函数体对象,而不是调用自身,注意和递归的区别)。

  1. (function selfReplicative() {
  2. return selfReplicative;
  3. })();

自复制函数的一个有趣的应用模式是用它看做一种声明形式(declarative form)来逐个处理集合中的单一元素而不是集合本身的时候。例如:

  1. //简单的打印函数
  2. function registerMode(m){
  3. console.log(m);
  4. }
  5. // 一般的处理函数,将集合作为参数
  6. function registerModes(modes) {
  7. modes.forEach(registerMode, modes);
  8. }
  9. registerModes(['roster', 'accounts', 'groups']);
  10. //
  11. // 自复制函数,用来“声明”自身
  12. function modes(mode) {
  13. registerMode(mode); // 处理单个元素
  14. return modes; // 然后继续返回函数以便处理下一个
  15. }
  16. // 用例
  17. modes
  18. ('roster')
  19. ('accounts')
  20. ('groups');

当然,在实际运用中处理集合本身还是更有效和直观的。
当函数作为参数传入时,定义在函数式参数中的局部变量可以在这个函数活化时访问到,因为储存上下文中数据的变量对象在每次进入上下文时创建:

  1. function testFunc(funArg) {
  2. funArg(10);
  3. funArg(20);
  4. }
  5. testFunc(function(arg) {
  6. var localVar = 10;
  7. alert(arg + localVar);
  8. }); // 20, 30

然而,正如我们从第四章中了解到的,ECMAScript中的函数可能包括在父函数中并使用父上下文中的变量。与这个特性相关的就会有一个称为函数式参数问题(funarg problem)的问题。

函数式参数的问题 | Funarg problem

在面向栈的编程语言中,函数的局部变量储存在栈(stack)内,每当函数调用时,这些变量和函数参数就push进栈内。当从函数中返回时,那些变量也从栈中移除。
这个模式对于使用函数作为返回值(即从父函数中返回它们)有很大的限制。最主要的问题是出现在,当一个函数使用自由变量(free variables)时。
自由变量(free variable)是函数中使用的一种变量,但它既不是函数的参数,也不是函数的局部变量。
例如:

  1. function testFn() {
  2. var localVar = 10;
  3. function innerFn(innerParam) {
  4. alert(innerParam + localVar);
  5. }
  6. return innerFn;
  7. }
  8. var someFn = testFn();
  9. someFn(20); // 30

在这个例子中,对于函数innerFn而言loacalVar是一个自由变量。
如果这个系统使用的是面向栈的模式来储存局部变量,那么就意味着,当从函数testFn中返回时,它的所有局部变量将从栈中移除。而这将导致在innerFn在外部活化时产生一个错误(找不到局部变量)。
而且,对于这个特殊的例子,在面向栈的实现器中,根本不可能返回innerFn,因为innerFn也是testFn的局部数据,因此也会在从testFn中返回时移除。
另一个和函数式对象相关的问题是在使用动态作用域(dynamic scope)系统的实现器中以函数作为传入参数的情况。例如(伪代码):

  1. var z = 10;
  2. function foo() {
  3. alert(z);
  4. }
  5. foo(); // 静态作用域和动态作用域下都是10
  6. (function() {
  7. var z = 20;
  8. foo(); // 静态作用域下是10,动态作用域下是20
  9. })();
  10. // 函数作为传入参数时也一样
  11. (function(funArg) {
  12. var z = 30;
  13. funArg(); // 静态作用域下是10,动态作用域下是30
  14. })(foo);

我们看到,在使用动态作用域的系统中,变量解析是由一个储存变量的动态栈管理的。就是说,自由变量在当前活化的动态链中(即在函数调用时的位置上,而不是在函数创建时的静态作用域链中)查找。
而这将产生歧义。那就是,即使这种情况下z是存在的(和上一个例子中的局部变量localVar在函数返回时从栈中移除了),这里的问题是:不同的foo函数的调用中使用的究竟是哪一个z的值(换句话说,哪一个上下文中的z,在哪一个作用域中)?
上面描述的情况就是函数式参数的两类问题——区别在于我们处理的是函数作为另一个函数的返回值(向上的函数式参数 | upward funarg),还是将函数作为另一个函数的传入参数(向下的函数式参数 | downward funarg)。
为了解决这个问题(和它的亚型),人们提出了闭包(closure)的概念。

闭包 | Closure

闭包(Closure)是一个代码块和创建它的上下文中的数据的组合。
让我们用一个伪代码的示例来说明:

  1. var x = 10;
  2. function foo() {
  3. alert(x); //自由变量x == 10
  4. }
  5. // foo的闭包
  6. fooClosure = {
  7. call: <reference to function>, //函数foo的引用
  8. lexicalEnvironment: {x: 10} //用于搜索自由变量的上下文
  9. }

在上面的例子中,fooClosure当然是一个伪代码,在ECMAScript中foo函数已经有了一个储存创建它的上下文的作用域链的内部属性:[[Scope]]。
伪代码中的”lexical”(”词汇的”)常常被省略掉,因为这是不言而喻的。在这个例子中写上是为了让我们注意到,闭包存储的是源代码中词汇位置(the lexical place of source code)——即函数定义的位置处的父变量。当这个函数活化时,自由变量是在这个储存后的(闭包的)上下文中查找,因此,在ECMAScript中,在上一节的最后一个例子中查询到的变量z始终为10。
在定义中,我们使用的是一个广泛的概念——“代码块(code block)”,虽然通常我们指的都是另一个术语“函数”。然而,并不是所有的实现器中的闭包都只和函数相关:例如,在Ruby中,一个闭包可能是:一个程序物件(procedure object)、一个lambda表达式或者一个代码块。
从实现器的角度上说,为了储存那些上下文销毁后的局部变量,基于栈(stack-based)的实现就不再适用了(因为这种储存和基于栈的结构的定义相矛盾)。因此在这种情况下,父上下文的闭包的数据储存在动态内存分配(dynamic memory allocation)中(在“堆” (“heap”)中,换句话说,是基于堆(heap-based)的实现),这个实现使用垃圾收集器(garbage collector, GC)和引用计数(references counting)。这种系统的速度相对于基于栈的系统而言是效率低的。然而,实现器可能总是会优化它:在解析阶段首先判断自由变量是否在函数中被使用,然后根据判断的结果选择将数据放入栈(stack)中还是放入堆(heap)中。

ECMAScript中的闭包实现 | ECMAScript closures implementation

在讨论过理论之后,我们终于来到了和ECMAScript直接相关的闭包的部分。这里需要注意的是,ECMAScript只使用静态(词汇的)作用域(static / lexical scope)(而其他语言,比如Perl中,变量可以声明为静态的或者动态的)。

  1. var x = 10;
  2. function foo() {
  3. alert(x);
  4. }
  5. (function (funArg) {
  6. var x = 20;
  7. //函数式参数的变量'x'是在函数创建时的上下文中“静态”储存的
  8. funArg(); // 10,而不是20
  9. })(foo);

从技术上说,函数父上下文中的变量储存在函数的内部属性[[Scope]]中。因此,如果完全理解了第四章中的[[Scope]]和作用域链内容,理解ECMAScript中的闭包是不难的。
根据函数创建的算法,我们发现ECMAScript中的所有函数都是闭包的,因为它们都会在创建时保存父上下文的作用域链。这里的重点是,无论函数是否在之后会被活化,父作用域已经在它创建时保存到它的属性中了:

  1. var x = 10;
  2. function foo() {
  3. alert(x);
  4. }
  5. o
  6. //foo是一个闭包,它拥有代码块和上下文中的数据
  7. foo: <FunctionObject> = {
  8. [[Call]]: <code block of foo>,
  9. [[Scope]]: [
  10. global: {x: 10}
  11. ],
  12. ... //其他属性
  13. }

我们之前提到过,出于优化的考虑,当一个函数不使用自由变量时,实现器可能不会保存它的父作用域链。然而,在ECMA-262-3规范中并没有任何关于这一点的内容;因此,正式情况下(并且根据函数创建的算法)——所有的函数在创建时都将保存它的父作用域链到属性[[Scope]]上。
一些实现器允许直接访问闭包的作用域。例如在Rhino中,在第二章.变量对象中我们讨论过一个对应于函数的[[Scope]]属性的非标准属性 parent

  1. var global = this;
  2. var x = 10;
  3. var foo = (function() {
  4. var y = 20;
  5. return function() {
  6. alert(y);
  7. };
  8. })();
  9. foo(); // 20
  10. alert(foo.__parent__.y); // 20
  11. foo.__parent__.y = 30;
  12. foo(); // 30
  13. alert(foo.__parent__.__parent__ === global); // true
  14. alert(foo.__parent__.__parent__.x); // 10

一对多的[[Scope]]值 | one [[Scope]] value for them all

需要注意的是,在ECMAScript中,同一个父上下文创建的若干个内部函数的闭包的[[Scope]]是同一个对象。这就意味着,在一个闭包中改变被闭包的变量(译者按:自由变量)将会影响到它在其他闭包中的值。
就是说,所有的内部函数共用相同的父作用域链。

  1. var firstClosure, secondClosure;
  2. function foo() {
  3. var x = 1;
  4. firstClosure = function() { return ++x; };
  5. secondClosure = function() { return --x; };
  6. x = 2; 影响AO['x'],AO同时储存在上面两个函数[[Scope]]中
  7. alert(firstClosure());
  8. }
  9. foo(); //3, AO['x'] == 3
  10. alert(firstClosure()); // 4, AO['x'] == 4
  11. alert(secondClosure()); // 3

关于这个特性有一个常见的错误。那就是,当人们在一个循环中创建函数,试图将每一个函数分别与循环的计数变量关联,以使得每一个函数都有它自己的值,但结果常常并不是像期望的那样。

  1. var data = [];
  2. for(var i = 0; i < 3; i++) {
  3. data[i] = function() {
  4. alert(i);
  5. };
  6. }
  7. data[0](); // 3, not 0
  8. data[1](); // 3, not 1
  9. data[2](); // 3, not 2

上一个例子解释了这个结果——对于这3个函数而言,创建函数的上下文的作用域是相同的。每一个函数都通过[[Scope]]属性引用它,而这个父作用域内的变量i很容易就被改变了。
示意图:

  1. activeContext.Scope = [
  2. ... // 更高的变量对象
  3. {data: [...], i: 3}
  4. ];
  5. data[0].[[Scope]] === Scope;
  6. data[1].[[Scope]] === Scope;
  7. data[2].[[Scope]] === Scope;

因此,在函数活化时,使用的是变量i的最后赋值3。
这一点和变量创建的阶段有关,即,变量创建是在代码执行阶段之前的进入上下文阶段中发生的。
创建额外的封闭的上下文可以解决这一问题:

  1. var data = [];
  2. for(var i = 0; i < 3; i++) {
  3. data[i] = (function _helper(k) {
  4. return function() {
  5. alert(k);
  6. };
  7. })(i);
  8. }
  9. data[0](); // 0
  10. data[1](); // 1
  11. data[2](); // 2
  12. //译者按:上面的循环也可以写作下面的形式,本质都是添加额外的封闭作用域
  13. for(var i = 0; i < 3; i++) {
  14. (function _helper(k) {
  15. data[k] = function() {
  16. alert(k);
  17. };
  18. })(i);
  19. }
  20. //此外,这两种形式下for循环的大括号可以省略掉

让我们来看看这种情况下发生了什么。
首先,函数_helper被创建并马上活化,同时传入参数i。
然后,函数_helper的返回值也是一个函数,并且这个函数被保存到data数组的相应元素中。
这个技术提供了以下效果:_helper函数在每一次活化时都创建了新的拥有不同参数k的活化对象,而这个参数的值是传入的变量i的值。
也就是说,返回函数的[[Scope]]如下:

  1. data[0].[[Scope]] === [
  2. ...
  3. AO of parent context: {data: [...], i: 3},
  4. AO of _helper context: {k: 0}
  5. ];
  6. data[1].[[Scope]] === [
  7. ...
  8. AO of parent context: {data: [...], i: 3},
  9. AO of _helper context: {k: 1}
  10. ];
  11. data[2].[[Scope]] === [
  12. ...
  13. AO of parent context: {data: [...], i: 3},
  14. AO of _helper context: {k: 2}
  15. ];

我们看到,现在函数的[[Scope]]属性有了所需要的值的引用——通过额外创建的作用域中的变量k。
注意,从返回的函数中我们仍然可以获得变量i的引用——值仍然都是3。
Javascript中的闭包常常被不完整地认为只体现在上面的模式中——通过创建额外的函数来捕获所需要的值。从实际的角度上说,这个模式确实很有名,然而从理论的角度上说,ECMAScript中的所有函数都是闭包的。
上面的模式并不是得到正确的计数变量i的唯一方式,例如,也可以通过下面的方式:

  1. var data = [];
  2. for(var i = 0; i < 3; i++) {
  3. (data[i] = function _helper() {
  4. alert(arguments.callee.k);
  5. }).k = i; //将i保存到函数的属性上
  6. }
  7. data[0](); // 0
  8. data[1](); // 1
  9. data[2](); // 2

Funarg and return

另一个特性是关于闭包中的返回值。在ECMAScript中,一个return语句将控制流从一个闭包中返回到一个调用上下文中(a caller)。在别的语言中,比如Ruby,不同形式的闭包对return语句的处理也可能各不相同:可能返回到调用者,也可能是从一个激活的上下文中完全离开。
ECMAScript中标准的return行为:

  1. function getElement() {
  2. [1, 2, 3].forEach(function(elm) {
  3. if (elm === 2) {
  4. alert("found " + elm); // found 2
  5. // 返回到forEach函数,而不是返回到getElement
  6. return elm;
  7. }
  8. });
  9. return null;
  10. }
  11. alert(getElement()); // null

(译者按:由于上面的情况是从forEach循环函数中的其中一次中返回值,因此不能简单地将循环函数赋值给变量然后返回变量。而要用到下面的方法)当然,如果需要在上面的例子中获得循环函数中的返回值,那么可以使用throw和try-catch语句来捕获这个特殊的“异常”:

  1. var $break = {};
  2. function getElement() {
  3. try {
  4. [1, 2, 3].forEach(function(elm) {
  5. if (elm === 2) {
  6. alert("found " + elm); // found 2
  7. $break.data = elm;
  8. throw $break;
  9. }
  10. });
  11. } catch (e) {
  12. if ($break == e) {
  13. return $break.data;
  14. }
  15. }
  16. return null;
  17. }
  18. alert(getElement()); // 2

理论的不同版本 | Theory versions

正如我们注意到的,程序员们常常不完整地将闭包理解为只是指从父上下文中返回内部函数。甚至只是把它理解为匿名函数。
让我们再来重申一下:所有的函数,无关它们的类型——匿名的、命名的、函数表达式、或者函数声明,由于作用域链的机制,它们都是闭包。
这里有一个例外,那就是通过函数类构造式(Function(…))创建的函数,这类函数的[[Scope]]中只包含全局对象。
为了澄清这个问题,我们提供两种版本的关于ECMAScript中闭包的正确表述。
ECMAScript中的闭包是:
从理论的角度上:所有的函数,由于它们都在创建时保存了父上下文中的变量。即使是一个简单的全局函数,它也能通过一般作用域链的机制引用全局上下文中的变量(自由变量)。
从实际的角度上:是下面两种情况下的函数:

在父上下文结束后仍然存在的函数。比如作为父函数返回值的内部函数。
使用自由变量的函数。

闭包的实际应用 | Practical usage of closures

在实际中,闭包常常用来创建优雅设计,通过使用函数式参数(“funarg”)来实现各种自定义计算。例如,数组的sort方法允许传入排序条件函数作为参数:

  1. [1, 2, 3].sort(function(a, b) {
  2. ... //排序条件
  3. });

或者例如,数组的map方法中传入函数式参数作为条件:

  1. [1, 2, 3].map(function(elm) {
  2. return elm * 2;
  3. }); // [2, 4, 6]

它也常常用来方便地实现搜索函数,通过将函数式参数作为搜索条件:

  1. someCollection.find(function(elm) {
  2. return elm.someProperty == "searchCondition";
  3. });

同样,它也用来应用(apply)函数,例如,在forEach方法中对数组应用一个函数:

  1. [1, 2, 3].forEach(function(elm) {
  2. if (elm % 2 != 0) {
  3. alert(elm);
  4. }
  5. }); // 1, 3

顺便说一句,函数对象的方法call和apply,也是起源于函数式编程语言中的应用函数(applying functionals)。我们在第三章谈到this关键字时已经讨论过这两种方法;这里,我们看到的是它们扮演着应用函数的角色——函数应用为参数(在apply中是参数列表,在call中是具体位置的参数):

  1. (function() {
  2. alert( [].join.call(arguments, ";") ); // 1;2;3
  3. }).apply(this, [1, 2, 3]);

闭包的另一个重要的应用是延迟调用(deferred calls):

  1. var a = 10;
  2. setTimeout(function() {
  3. alert(a);
  4. }, 1000);

还有回调函数:

  1. ...
  2. var x = 10;
  3. xmlHttpRequestObject.onreadystatechange = function() {
  4. alert(x);
  5. }

或者,创建封装的作用域以便于隐藏辅助对象:

  1. var foo = {};
  2. (function(object) {
  3. var x =10;
  4. object.getX = function() {
  5. return x;
  6. };
  7. })(foo);
  8. alert(foo.getX()); // 10

总结 | Conclusion

这一章里,相比ECMAScript的部分而言理论的部分更多一些,然而,我想这些一般理论能更有助于澄清关于闭包和ECMAScript中函数的一些方面。下一章开始是面向对象。

版权声明:本站文章除特别声明外,均采用署名-非商业性使用-禁止演绎 4.0 国际 许可协议,如需转载,请注明出处
  • ECMA-262-3 in detail——第七章:OOP(第二部分:ECMAScript实现)

    这一章的第二部分是关于EMCAScript中的面向对象编程。在第一部分中我们讨论了OOP的基本理论并勾画出和ECMAScript的相似之处。在阅读第二部分之前,如果有必要,我还是建议首先阅读这一章的第一部分.基本理论,因为后面将会用到其中的一些术语。

    发布:2015-05-26 阅读(1949)

  • ECMA-262-3 in detail——第七章:OOP(第一部分:一般理论)

    这一章我们讨论ECMAScript中面向对象编程(object-oriented programming)的几个主要方面。由于这一主题已经在许多文章中谈论过,本章并不打算“老调重弹”,而是试图更多地着眼于这些过程内在的理论方面。尤其是,我们将研究对象创建的算法,看看对象间的关系(包括最基本的关系——继承)是如何实现的,并且给出一些讨论中将用到的准确定义(我希望这样能够打消一些术语和思路上的疑惑以及一些关于Javascript文章中OOP部分的常见的混淆)。

    发布:2015-05-06 阅读(1705)

  • ECMA-262-3 in detail——第六章:闭包

    在这一章中我们来谈谈Javascript中被讨论最多的话题之一——关于闭包(closures)。事实上这个主题并不是新鲜的。然而我们在这里将试着更多从理论的角度去分析和理解它,然后我们还会看一下ECMAScript内关于闭包的内容。

    发布:2015-04-28 阅读(1716)

  • ECMA-262-3 in detail——第五章:函数

    在这章里我们讨论ECMAScript中的一个基本对象——函数。我们将会看到不同类型的函数如何影响一个上下文中的变量对象,以及这些函数的作用域链中都包含什么。我们将会回答像下面这样经常被问到的问题:“下面这两种创建函数的方式有什么区别吗(如果有的话,区别是什么呢)?”

    发布:2015-04-17 阅读(1855)

  • ECMA-262-3 in detail——第四章:作用域链

    正如我们从第二章.变量对象中了解到的,执行上下文的数据(变量,函数声明,函数形参)以变量对象的属性的方式储存。

    发布:2015-04-07 阅读(1650)

  • ECMA-262-3 in detail——第三章:this关键字

    许多程序员习惯于认为在编程语言中,this关键字是与面向对象编程紧密相关的,而且引用的是由构造式最新创建的对象。在ECMAScript中,这个概念也被实现了,然而我们将看到,在这里它不仅仅指向已创建的对象。

    发布:2015-03-25 阅读(1859)

  • ECMA-262-3 in detail——第二章:变量对象

    在程序中我们总是声明变量和函数然后用它们来搭建我们的系统。但是解释器(interpreter)是在哪里和以什么方式来找到我们的数据(函数,变量)的呢?

    发布:2015-03-24 阅读(1963)

  • ECMA-262-3 in detail——第一章:执行上下文

    第1章:在这一章里,我们将会讨论ECMAScript中的执行上下文(execution context)以及与它们相关的可执行代码(executable code)的类型。

    发布:2015-01-10 阅读(1908)