ECMA-262-3 详解:6、闭包

879 阅读15分钟

从来没有深入了解ECMA,网上找了一下,发现早在2010年就有大佬 Dmitry Soshnikov 总结了ECMA中的核心内容,我这里只是翻译记录,加深自己的印象。文章原文来自 ECMA-262-3 in detail. Chapter 6. Closures.

介绍

这篇文章中,我们将要讨论与Javascript相关的讨论最多的主题之一 — 关于闭包。这个主题 — 事实上 — 不是新的,而是被讨论很多次了的。但是我们将尝试从理论的角度来讨论并且了解它,也会看看在ECMAScript中闭包是怎么实现的。

建议先阅读之前关于作用域链变量对象的两章,因为这一章中我们将使用到之前提到的主题。

一般理论

在直接讨论ECMAScript闭包之前,有必要从函数式编程的一般理论中指定一些定义。

众所周知,函数式语言(ECMAScript支持这种范例与格式)中,函数是数据,他们可以赋值给变量,作为参数传递给其他函数,作为函数的返回为等等。这些函数有特殊的名字与结构。

定义

函数是参数(Funarg) — 是一个值是函数的参数。

举个🌰:

function exampleFunc(funArg) {
 funArg();
} 

exampleFunc(function () {
 console.log('funArg');
});

这个例子中与 funarg 相关的实际参数是传递给 exampleFunc 的匿名函数。

反过来,一个函数接收另一个函数(作为参数)的形式成为高阶函数(HOF)。

一个HOF的另一个名字是函数式(functional),或者更接近一个操作运算。上面的例子中, exampleFunc 函数就是一个HOF。

如前所述,一个函数不仅仅可以作为参数传递,也可以作为另一个函数的返回值。

一个函数返回另一个函数的形式成为具有函数值的函数(函数值函数)。

(function functionValued() {
  return function () {
    console.log('returned function is called');
  };
})()();

可以作为普通数据参与其中的函数,例如,作为参数传递,接收函数式参数或者作为函数的返回值的形式都称为一级函数(first-class functions)。

在ECMAScript中,所有的函数都是一级的。

一个函数接收自己作为一个参数称之为自应用函数(auto-applicative (or self-applicative) function):

(function selfApplicative(funArg) {
 
  if (funArg && funArg === selfApplicative) {
    console.log('self-applicative');
    return;
  }
 
  selfApplicative(selfApplicative);
 
})();

一个函数返回自身的形式成为自复制函数((or self-replicative) function)。有时,自复制这个名字被用于文献中:

(function selfReplicative() {
  return selfReplicative;
})();

自复制函数的一种有趣的模式与使用集合的单个参数而不是使用集合自身的声明形式:

// imperative function
// which accepts collection
 
function registerModes(modes) {
  modes.forEach(registerMode, modes);
}
 
// usage
registerModes(['roster', 'accounts', 'groups']);
 
// declarative form using
// self-replicating function
 
function modes(mode) {
  registerMode(mode); // register one mode
  return modes; // and return the function itself
}
 
// usage: we just *declare* modes
 
modes
  ('roster')
  ('accounts')
  ('groups')

但是,在实践中,使用集合自身可以更加高效和直观。

在函数式参数传递的时候被定义的局部变量当然也是可以在激活此函数的时候访问,因为上下文中存储数据的变量对象(环境)在每次进入上下文的时候都会(重新)被创建:

function testFn(funArg) {
 
  // activation of the funarg, local
  // variable "localVar" is available
 
  funArg(10); // 20
  funArg(20); // 30
 
}
 
testFn(function (arg) {
 
  let localVar = 10;
  console.log(arg + localVar);
 
});

然而,在第四章我们已经知道,在ECMAScript中函数可以被父级函数包裹并且使用来自父级上下文的变量。与这个功能相关的就是所谓的 funarg 问题。

Funarg 问题

面向堆栈的编程语言(stack-oriented programming languages)中,函数的每一次调用,它的局部变量与函数参数被存放在一个被推入(pushed)这些变量的栈中。

当从一个函数返回的时候这些变量从这个栈中推出(popped)。这种模型对于将函数用作函数值(即从父级函数返回他们)有很大的缺陷。当函数使用自由变量的时候将会出现大量这种问题。

自由变量是一个被函数使用但不是参数也不是函数的局部变量的变量。

🌰:

function testFn() {
 
  let localVar = 10;
 
  function innerFn(innerParam) {
    console.log(innerParam + localVar);
  }
 
  return innerFn;
}
 
let someFn = testFn();
someFn(20); // 30

在这个例子中, localVar 变量对于 innerFn 函数来说就是自由的。

在这个系统中将会使用面向堆栈的模式来存放局部变量,这就意味着在 testFn 函数返回的时候它的所有的局部变量都将从栈中移除。然后这将导致从外部激活 innerFun 函数时出错。

而且,在这种特殊情况下,在面向堆栈的实现中,根本不可能返回 innerFn 函数,因为对于 testFn 而言 innerFn 也是局部的,那么在 testFn 返回的时候也会被移除。

另一个函数对象的问题与在一个动态作用域实现的系统中函数作为参数传递有关。

🌰(伪代码,这里的代码虽说是用js写的,但是不要认为这是js代码,理解为面向堆栈编程语言的代码):

let z = 10;
 
function foo() {
  console.log(z);
}
 
foo(); // 10 – with using both static and dynamic scope
 
(function () {
 
  let z = 20;
  // NOTE: always 10 in JS!
  foo(); // 10 – with static scope, 20 – with dynamic scope
 
})();
 
// the same with passing foo
// as an arguments
 
(function (funArg) {
 
  let z = 30;
  funArg(); // 10 – with static scope, 30 – with dynamic scope
 
})(foo);

我们看到在动态作用域的系统中,变量解析是通过动态(活动)变量栈管理的。因此,自由变量是在当前激活的动态链(函数被调用的地方)中被搜索而不是在函数创建时候的静态(此法)作用域。

这可能导致歧义。因此,即使 z 存在(与前面的例子对比,在该实例中,局部变量将从堆栈中移除),仍然存在一个问题:在这么多的 foo 函数的调用中, z 的值是哪个(即 z 来自哪个上下文,哪个作用域)?

所描述的情况是 funarg 问题的两种情况 — 取决于我们是处理一个函数返回的函数值,又或者是传递个函数的函数参数。

为了解决这个问题(以及他的子类问题),***闭包***的概念被提了出来。

闭包

闭包是代码块和代码块创建时候的上下文中数据的结合。(A closure is a combination of a code block and data of a context in which this code block is created.)

我们用一段伪代码来看一下:

let x = 20;

function foo() {
 console.log(x); // 自由变量 x == 20
}

let fooClosure = {
 code: foo, // 函数引用
 enviroment: {x: 20} // 查找自由变量的上下文
}

上面的例子中, fooClosure 是伪代码,因为在ECMAScript中, foo 函数已经捕获了创建在上下文中的词法环境。

“此法”一词经常被隐式假定与省略 — 在这个例子中,集中注意闭包将父级变量保存在源代码的词法位置中,即:函数定义的地方。在下次激活函数的时候,在这个保存的(关闭的)上下文中查找自由变量,正如此,我们看到上面的例子中,变量 z 在ECMAScript中始终被赋值为 10

定义中我们使用了一个广义的概念 — “代码块”,但通常使用的是“函数(function)”。因此,并非在所有实现中,闭包仅与函数相关:例如,在Ruby编程语言中,闭包可以表现为过程对象,lambda表达式或者代码块。

关于实现,为了在销毁上下文之后存储局部变量,基于堆栈的实现不再适用(因为它与基于栈的结构的定义相矛盾)。因此,在这种情况下,使用垃圾收集器(GC)将捕获的环境被存储在动态的内存中(在堆上,即基于堆的实现)。这种系统在速度上不如基于堆栈的系统有效。但是,实现总是可以进行不同的优化的,例如如果未关闭该数据,则不要再堆上分配数据。

ECMAScript闭包实现

讨论了主题后,我们终于在ECMAScript中直接接触闭包了。这里要提醒一点,ECMAScript只使用静态(词法)作用域(在某些语言中,例如在Perl中,变量可以使用静态作用域或这动态作用域声明)。

let x = 10;

function foo() {
 console.log(x);
}

(function (funArg) {
 let x = 20;
 
 // funArg 的变量 x 被静态保存在被创建的那个静态的上下文中

 funArg(); // 10 而不是 20
})(foo);

技术上,父级环境存放这函数的内部 [[Scope]] 属性。所以如果我们完全理解了第四章详细讨论的 [[Scope]] 与作用域链,理解ECMAScript中闭包的问题将会水到渠成。

引用函数创建算法,我们可以看到ECMAScript中所有的函数都是闭包,因为他们在创建的时候都保存了父级上下文中作用域链。这里重要的时刻是无论函数是否将在之后被调用,在创建的时候,父级作用域就已经被捕获。

let x = 10;
 
function foo() {
  console.log(x);
}
 
// foo is a closure
foo: <FunctionObject> = {
  [[Call]]: <code block of foo>,
  [[Scope]]: [
    global: {
      x: 10
    }
  ],
  ... // other properties
};

正如我们提到的,为了实现优化目的,当函数不在使用自由变量的时候,实现将不会在保存父级作用域链。但是,在ECMAScript规范中没有提到这一点,因此,形式上(通过技术算法),所有的函数在创建时都在 [[Scope]] 属性上保存了作用域链。

一些实现允许直接访问闭合的作用域,例如在Rhino中,对于函数的 [[Scope]] 属性,对于非标准的属性 __parent__,我们在有关变量对象的章节中对此进行了讨论:

var global = this;
var x = 10;
 
var foo = (function () {
 
  var y = 20;
 
  return function () {
    console.log(y);
  };
 
})();
 
foo(); // 20
console.log(foo.__parent__.y); // 20
 
foo.__parent__.y = 30;
foo(); // 30
 
// we can move through the scope chain further to the top
console.log(foo.__parent__.__parent__ === global); // true
console.log(foo.__parent__.__parent__.x); // 10

One [[Scope]] value for “them all”

有必要注意到在ECMAScript中,对于在此父级上下文中创建的多个内部函数,他们的闭包[[Scope]] 都是相同的对象。这就意味着从一个闭包修改闭合的变量,会影响另一个闭包里面的变量。

即,所有的内部函数共享一个父级环境。

let firstClosure;
let secondClosure;
 
function foo() {
 
  let x = 1;
 
  firstClosure = function () { return ++x; };
  secondClosure = function () { return --x; };
 
  x = 2; // affection on AO["x"], which is in [[Scope]] of both closures
 
  console.log(firstClosure()); // 3, via firstClosure.[[Scope]]
}
 
foo();
 
console.log(firstClosure()); // 4
console.log(secondClosure()); // 3

与它的特点有关的普遍错误。程序员经常得到意料之外的值。在一个循环中创建函数,尝试将每一个函数与循环的计数变量联系起来,期待着每一个函数都会保存它自己需要的值。

var data = [];

for (var k = 0; k < 3; k++) {
 data[k] = function () {
  console.log(k);
 }
}

data[0](); // 3 而不是 0
data[1](); // 3 而不是 1
data[2](); // 3 而不是 2

上面的例子解释了这种表现 — 创建函数的上下文的作用域对于所有的三个函数都是相同的。每一个函数通过 [[Scope]] 属性引用它,并且变量父级作用域链上的 k 很容易改变。

伪代码:

activeObject.Scope = [
 ... // 更高阶的变量对象
 {data: [..], k: 3} // 活动对象
];

data[0].[[Scope]] === Scope;
data[1].[[Scope]] === Scope;
data[2].[[Scope]] === Scope;

因此,在函数激活的时刻,变量 k 最后一个被分配的值是 3

这涉及以下实施:所有变量在代码执行之前即进入上下文时创建。此行为也成为“托管”。

创建其他的闭合上下文可以帮助解决此问题:

var data = [];
 
for (var k = 0; k < 3; k++) {
  data[k] = (function _helper(x) {
    return function () {
      console.log(x);
    };
  })(k); // pass "k" value
}
 
// now it is correct
data[0](); // 0
data[1](); // 1
data[2](); // 2

我们看看这里发生了什么。

首先,函数 _helper 被创建并立即传递参数 k 调用激活。

然后, _helper 函数的返回值也是一个函数,并且将其准确保存到了 data 数组相应的元素中。

这个技术具有以下影响:被激活时, _helper 每一此创建一个含有参数 x 的新的活动对象,并且这个参数的值时传递的变量 k 的值。

因此,返回的函数的 [[Scope]] 如下:

data[0].[[Scope]] === [
  ... // higher variable objects
  AO of the parent context: {data: [...], k: 3},
  AO of the _helper context: {x: 0}
];
 
data[1].[[Scope]] === [
  ... // higher variable objects
  AO of the parent context: {data: [...], k: 3},
  AO of the _helper context: {x: 1}
];
 
data[2].[[Scope]] === [
  ... // higher variable objects
  AO of the parent context: {data: [...], k: 3},
  AO of the _helper context: {x: 2}
];

现在函数的 [[Scope]] 属性拥有了需要的值的引用 — 通过附加创建作用域捕获的变量 x

注意,那种形式下,返回函数中我们当然也是可以引用变量 k — 对所有函数都相同的值 3

通常JavaScript闭包不会完全减少到上面显示的模式 — 通过创建附加的函数来捕获需要的值。从实践的角度来看,这种模式确实时已知的,但是,从我们注意到的理论的角度来看,ECMAScript中所有的函数都是闭包。

所述的模式虽然不是唯一的。例如,可以使用以下方法来获取所需要的变量 k 值。

var data = [];
 
for (var k = 0; k < 3; k++) {
  (data[k] = function () {
    console.log(arguments.callee.x);
  }).x = k; // save "k" as a property of the function
}
 
// also everything is correct
data[0](); // 0
data[1](); // 1
data[2](); // 2

注意,ES6中提出块级作用域的标准,变量声明使用 let 或者是 const 关键词也可以达到目的。

let data = [];
 
for (let k = 0; k < 3; k++) {
  data[k] = function () {
    console.log(k);
  };
}
 
// Also correct output.
data[0](); // 0
data[1](); // 1
data[2](); // 2

Funarg 与 return

另一个特点时从闭包返回。在ECMAScript中,闭包的 return 语句将控制流返回到调用上下文(调用者)。在其他语言中,例如 Ruby,各种形式的闭包 return 语句处理不同也是可能的:可能返回一个调用者,或者其他情况下 — 完全退出活动上下文。

ECMAScript标准 return 表现:

function getElement() {
 
  [1, 2, 3].forEach(element => {
 
    if (element % 2 == 0) {
   // 从 forEach 函数返回而不是从 getElement 函数返回
      console.log('found: ' + element); // found: 2
      return element;
    }
 
  });
 
  return null;
}
 
console.log(getElement()); // null, but not 2

虽是这样,但是在ECMAScript中,在这类情况下,抛出或者捕获一些特殊的 “break” 异常可能会有帮助:

const $break = {};
 
function getElement() {
 
  try {
 
    [1, 2, 3].forEach(element => {
 
      if (element % 2 == 0) {
        // "return" from the getElement
        console.log('found: ' + element); // found: 2
        $break.data = element;
        throw $break;
      }
 
    });
 
  } catch (e) {
    if (e == $break) {
      return $break.data;
    }
  }
 
  return null;
}
 
console.log(getElement()); // 2

理论版本

正如我们指出的那样,开发者通常不完全将父级上下文返回内部函数理解为闭包。

再次提醒,所有函数与他们的类型无关:匿名的,有名的,函数表达式,或者函数声明,因为作用域链机制,属于闭包。

这个规则的例外就是通过Function构造器创建的函数,它的 [[Scope]] 只有全局对象。

为了澄清这个问题,我们提供关于ECMAScript的两个正确的闭包版本:

在ECMAScript中的闭包是:

  • 从理论出发:所有函数,因为他们所有都保存在父级上下文的创建变量中。即使是一个简单的全局函数,引用全局变量也指向自由变量。因此,使用了通用作用域链机制。
  • 从实践的角度出发:这些函数很有趣:
    • 父级上下文结束的时候依旧存在,例如,从父级函数返回一个内部函数;
    • 使用自由变量。

闭包的实际使用

实际中,闭包可能创建出高雅的设计,从而运行自定义“funarg”,定义各种计算。数组的 sort 方法的一个示例,他接受 sort-condition 函数作为参数:

[1, 2, 3].sort((a, b) => {
 ... // sort conditions
})

或者,例如,数组的 map 方法的映射功能,通过函数参数的条件来映射一个新的数组。

[1, 2, 3].map(element => {
  return element * 2;
}); // [2, 4, 6]

通常,通过使用函数参数定义几乎不限搜索条件来实现函数搜索是很方便的。

someCollection.find(element => {
  return element.someProperty == 'searchCondition';
});

同样,我们也注意到将函数用作例如 forEach 方法,该方法将函数应用于元素数组:

[1, 2, 3].forEach(element => {
  if (element % 2 != 0) {
    console.log(element);
  }
}); // 1, 3

顺便一提,函数对象的 applycall 方法,也起源于应用函数式编程。我们已经在关于 [this](https://juejin.cn/post/6844904163596304397) 的文章中讨论过了,这里,我们看到了他们在应用函数中的作用 — 函数应用于参数( apply 中是一个参数列表(数组), call 中是固定的参数)

(function (...args) {
  console.log(args);
}).apply(this, [1, 2, 3]);

闭包的另一个重要应用是延时调用:

let a = 10;
setTimeout(() => {
  console.log(a); // 10, after one second
}, 1000);

或者是回调函数:

...
let x = 10;
// only for example
xmlHttpRequestObject.onreadystatechange = function () {
  // callback, which will be called deferral ,
  // when data will be ready;
  // variable "x" here is available,
  // regardless that context in which,
  // it was created already finished
  console.log(x); // 10
};
...

又或者创建封装的模块作用域以隐藏实现细节:

// initialization
const M = (function () {
 
  // Private data.
  let x = 10;
   
  // API.
  return {
    getX() {
      return x;
    },
  };
})();
 
console.log(M.getX()); // get closured "x" – 10

本文使用 mdnice 排版