闭包起源
学习东西首先要抓住重点, 用最少的精力获取最大成果, 其余的就是搂草打兔子, 顺便学了。
本文是我学习闭包以来的总结和体会, 错误或者不当之处还请读者指出, 以免误导后学。
如果转载请在正文开头注明文章来源, 以便读者可以看到及时的更新。
JavaScript 的重点和难点之一就是闭包。
下面是一些关于闭包的描述, 摘录权威性的文献或资料:
<<JavaScript权威指南>>(第六版) p183
从技术角度来讲, 所有的JavaScript函数都是闭包:它们都是对象,它们都关联到作用域链。
<<JavaScript权威指南>>(第六版) p183
函数变量可以被隐藏于作用域链中, 因此看起来是函数将变量“包裹”了起来.
<<JavaScript高级程序设计(第三版)>> p178
闭包是指有权访问另一个函数作用域中的变量的函数。
You Don't Know JS: Scope & Closures
Closure is when a function is able to remember and access its lexical scope
even when that function is executing outside its lexical scope.
A closure is the combination of a function and the lexical environment within which that function was declared.
In programming languages, a closure (also lexical closure or function closure) is a technique
for implementing lexically scoped name binding in a language with first-class functions.
Operationally, a closure is a record storing a function together with an environment.
The environment is a mapping associating each free variable of the function
(variables that are used locally, but defined in an enclosing scope) with the value or reference
to which the name was bound when the closure was created.
A closure—unlike a plain function—allows the function to access those captured variables
through the closure's copies of their values or references, even when the function is invoked outside their scope.
这几种描述中, MDN 和 Wikipedia 的描述比较相近。
个人而言, 我更喜欢 Wikipedia 的闭包定义。
对闭包的定义不同, 那么对其认识也不同。 到底哪种定义更加全面, 更加接近闭包的本质?
要明白某个名词, 寻纠它的起源是搞明白其含义的妙招。
闭包到底是怎么来的, 这种概念是怎么出现的, 闭包到底解决了什么问题?
首先明确一点, 闭包并不是 JavaScript 所独有的概念, 其他语言也有其实现。
在 JavaScript 中, 函数是 一等公民 : 函数可以作为参数传递, 可以从函数返回, 可以修改, 可以赋值给变量。
在支持 函数是一等公民的编程语言中, 要面临一个问题—— funarg problem。
当函数要处理 自由变量 (变量既不是此函数的参数, 也不是局部变量)时, 这个问题就会发生。(注意: 真正意义上的闭包, 必须有自由变量的使用)
funarg problem 分 upwards funarg problem 和 downwards funarg problem 两种。
upwards funarg problem 发生在函数将其嵌套函数作为返回值返回时(嵌套函数使用了自由变量)。
downwards funarg problem 发生在将函数作为参数传入函数时。
举例来说, 下面是 downwards funarg problem
let x = 10;
function foo() {
console.log(x);
}
function bar(funArg) {
let x = 20;
funArg(); // 10, 不是 20
}
// 将 `foo` 作为参数传给 `bar`.
bar(foo);
对于函数 foo 来说, x 就是其自由变量。 函数 foo 内的 变量 x 应该解析到全局环境中值为 10 的 x (即采用静态作用域)还是 bar 函数中值为 20 的 x (即采用动态作用域)?
JavaScript 解决这个问题, 通过采用静态作用域(或者说词法作用域, 函数作用域是在定义函数时就确定的, 而不是运行时。)。
还有个 upwards funarg problem:
function foo() {
var a = { x: 1, y: 2 }; // 对象
var b = 10; // 基本数据类型
function bar(param) {
return param+ b;
}
return bar;
}
var b = 20;
var func = foo();
console.log(func(1));
这个例子是我们在 JS 中经常见到的例子,虽然看起来很简单, 其中却大有学问。
我们知道, 通常来说, 在基于栈的函数内存分配范式中, 调用函数时, 会将其参数和局部变量保存在栈的栈帧(或者活动记录)上, 函数调用结束后, 保存其参数和局部变量的栈帧就会从调用栈中弹出。
(在 JavaScript 中, 一般来说, 基本数据类型会保存在栈上, 引用数据类型的实体会保存在堆上, 对实体的引用会保存在栈上。然而, 在具体的引擎实现中,会有些许的不同, 尤其是在涉及到闭包的处理上时。例如:在 v8 引擎中, null, undefined, true, false 是保存在堆上的。 没有被闭包捕获的基本类型保存在栈上, 被闭包捕获的基本类型保存在堆上 )。
上述例子中, bar 函数执行时, foo 函数的栈帧已经从调用栈中弹出,如果没有某种机制, 其局部变量 a 和 b 就都不存在了, bar 根本不可能获取到变量 b 的值。
怎么办?
一个想法就是, 有外部引用引用 变量 b 时, 禁止函数 foo 的栈帧从栈中弹出,但是这打破了函数基于栈的内存分配范式(函数调用完毕后, 应该将其栈帧从栈中弹出)
怎么能够既可以使函数遵循基于栈的内存分配, 还可以使 bar 在 foo 返回后仍然可以获取到 b 的值呢?
方案一:
在堆而不是栈上分配所有栈帧,当栈帧不再需要时, 依赖某种形式的 垃圾回收 或者 引用计数 来释放栈帧。由于在堆上管理栈帧远远没在栈上管理来的高效, 这种策略可能严重降低性能。而且, 因为在通常的程序中大多数函数并不会创建 upwards funargs(当函数作为参数时, 该函数就叫 funarg), 很多这种损耗是不必要的。
方案二:
一些考虑性能的编译器会采用混合方式: 如果编译器通过 静态程序分析 推断出函数没有创建 upwards funargs, 那么 函数的栈帧就会在栈上分配, 否则的话, 在堆上分配栈帧。
方案三:
利用闭包。 闭包创建时, 将变量的值拷贝进闭包。
JavaScript 正是采用了第三种方案, 利用闭包机制。
那么闭包到底是怎么实现的呢?
在 ES3 规范下, 闭包为函数代码 + 该函数的所有父作用域(也就是函数内部属性[[scope]]), 可以用伪码表示:
Closure = {
functionCode: <pointerToFunctionCode>,
[[scope]]: []
}
而在 ES5 及以后的规范中, 闭包是通过词法环境(lexical environment) 实现的。
Wikipedia 有段描述, 摘录翻译如下:
闭包通常是由一种特殊的数据结构实现的, 该数据结构包括指向函数代码的指针, 以及闭包创建时函数词法环境(换句话说, 可获取到的变量集)的表示。 引用的环境在闭包创建时将非局部名称(也就是自由变量)绑定到对应的变量, 此外, 将它们的生存期扩展到至少和闭包的生存期一样长。 稍后进入闭包时可能是在不同的词法环境中, 当函数执行时使用的非本局部变量将会引用闭包捕获的变量,而不是当前环境的变量。
就上述 upwards funarg problem 中的例子来说, 对 Closure(bar) 的表示大致是这样子的:
Closure = {
functionCode: <pointerToFunctionCode>
lexicalEnvironment: {
b: 10
}
}
小注: 词法环境(lexical environment) 这个术语是比较通用的理论, ECMAScript 中这个词的出现是在 ES5中
lexicalEnvironment 中保存了 所有捕捉到的自由变量, 眼见为实
这是 谷歌浏览器中调试到的结果, 可以看出:
(一) bar 执行时, Closure(foo) 中只包含了 bar 使用的自由变量 b, 没有包含 foo 的局部变量 a。(明显这是经过了优化, 只会保留自由变量。如果未优化的话, Closure(foo) 中会有变量 a 、b、bar、arguments)
(二) 调用栈中, 只有 (anonymous) 和 bar, 没有 foo, bar 执行时 foo 函数已经从调用栈中弹出(网上仍然有很多博客错误的认为闭包函数执行时, 定义该函数的上下文并没有出栈)
小注: call stack 这个概念比较通用, 在 ECMAScript中, execution context stack 就是 call stack。 类似于钱这个概念, 各国都有钱, 在我们中国, 钱就是人民币, 在美国就是美元。
最后 一个小小的验证:
not-closure.png函数 bar 处于 foo 中 且 foo 已把 bar 返回, 但是 bar 执行的时候并没有产生闭包。 由此可以看来, 没有自由变量存在的话, 不会有闭包, 而这同样是 v8 进行优化过后的结果。( 如果涉及到 v8 的话, <<JavaScript 高级程序设计>> 中所讲的 “闭包是指有权访问另一个函数作用域中的变量的函数” 是不是不是很确切,个人认为, 有权且访问了 更合适。)
another-closure.png这里 虽然 foo 没有把 bar 返回在词法作用域之外执行, V8 依旧认为生成了闭包。(这个例子, 在<<You don't know JS>> 中并不认为是闭包)
综合 v8 的具体调试实践, 个人认为 Wikipedia 上有关闭包的描述是最准确的, 或者说 v8 这个EcmaScript 的具体实现, 其中关于闭包的实现和 Wikipedia 的描述最为符合。
结论:
广义上来讲, 由于作用域链机制的存在, JavaScript 中 所有的函数(函数声明、函数表达式、命名函数、匿名函数)都是闭包。
狭义上来讲, 闭包是保存函数代码和定义函数的环境的记录, 此环境存储着闭包创建时函数的每个自由变量和其值或引用的映射。
学习闭包机制时, 个人建议参照 新版的 ECMAScript 规范 和 V8 引擎 学习, <<JavaScript 高级程序设计>> 中讲解的是 ES3 规范的一些模型, ES3 有点过时了。如果对照<<JavaScript 高级程序设计>> 中所讲述的模型理论和现代的 JavaScript 引擎(比如 v8), 会发现很多不相符的地方。
参考资料: