闭包是函数式语言里面很重要的部分,但是网上很多文章却只讲闭包的应用,而鲜有谈及其本质。
理解闭包的关键在于,知道它的出现是为了解决什么问题。
函数式语言有个重要的语言特性:让函数作为 first class 出现在语言中。而当函数作为参数或者返回值传递的时候,其实本质上传递的是闭包。那为什么不直接传递函数本身,而要传递闭包呢,这个“闭包”的本质又是什么呢?想要解决这些问题,就要求我们站在一个语言设计者的角度去看待。这并不难,我们只需要理解一些必要的概念就可以了。
Lexical Scoping 和 Dynamic Scoping
在对函数求值的时候,有时会有一些微妙的情况。当一个函数体内含有一些外部的变量的时候,这种变量我们称之为“自由变量”。但是不同作用域,对“自由变量”的理解却是不相同的。举个简单的例子讲下。
const x = 2
const f = (y) => x * y
{
const x = 4
f(3)
}
在函数 f 里面的 x 就是一个"自由变量",因为 x 不是在 f 里面定义的,所以我们必须在函数的外面找 x 的值。
但是在这段代码里面,有两个地方对 x 进行了绑定。这种情况下应该取哪个值呢?对于不同语言来说,这段代码可能有两种不同的结果。
之所以会出现两种不同结果,就是因为不同语言采取了不同的作用域策略。对于 JavaScript 来说,这段代码的结果是 6,而对于 Emacs Lisp (Emacs 编辑器内置的语言) 来说,这段代码的的结果却是 12。因为 JavaScript 采用了 Lexical scoping,而 Emacs Lisp 采用了 Dynamic scoping。
相比较而言,Lexical scoping 更好一些,因为它是在定义函数的时候确定“自由变量”,是更加符合直觉的。而 Dynamic scoping 则是在函数调用的时候确定“自由变量”。试想下上述代码中 f(3) 如果在距离 f 的定义几百行之外,甚至是在另一个 module,调用者根本不知道 f 引用了一个“自由变量” x,那么很可能会引来很多 bug。
幸运的是,几乎所有现代的函数式语言,都实现了 Lexical scoping,包括 JavaScript。
如何实现 Lexical Scoping
为了实现 Lexical scoping,我们必须把函数做成“闭包”(closure)。闭包只是一种存储结构,为了方便理解,你可以认为它是一个对象,里面存放了两样东西:
- 函数本身
- 这个函数涉及到的“自由变量”的定义(或者说,是函数定义时候的上下文)
通过将上面例子不准确地转换一下,就可以“显现”闭包了。
const x = 2
const f_closure = {
fn: function (y) { return this._x * y },
_x: x // 保存了自由变量 x
}
{
const x = 4
f_closure.fn(3) // 调用的是保存在闭包中的函数
}
这段代码虽然并不准确,但却清晰地描述了“闭包”是如何起作用的。当我们定义函数的时候,我们实际上把这个函数以及它需要的“自由变量”打包起来,放在闭包里面。而当我们调用函数的时候,只需要从闭包取出我们想要的一切元素就可以了。
闭包和面向对象的关系
闭包作为一种存储结构,本质上和对象是一样的。这也就不难解释为什么闭包能够存储状态了。为了更好说明这种情况,举个最常见的闭包的例子。
用闭包来实现一个计数器,是这样子的:
const counterCreator = () => {
let n = 0
return {
update: () => n++
}
}
const c1 = counterCreator()
const c2 = counterCreator()
c1.update() // 0
c1.update() // 1
c2.update() // 0
而用面向对象的方式来实现,则是这样子的:
class Counter {
constructor () {
this.n = 0
}
update () {
return this.n++
}
}
const c1 = new Counter()
const c2 = new Counter()
c1.update() // 0
c1.update() // 1
c2.update() // 0
这两种方式中,counterCreator 相当于面向对象中的类 Counter,本质上是一样的。所以写代码的时候不需要太过纠结用哪种方式,因为它们只是形式上的不同而已。
Reference:
关于 Lexical Scoping 和 Dynamic Scoping 的区别,参考了王垠的博文 怎样写一个解释器 。