关于界面的价值观与方法论

作用域链 词法作用域 与 闭包(一)

19November2008

什么叫闭包?我花了很长时间来弄明白这个概念,但每次以为弄明白的时候,却又会发现其实没搞清楚。

本来是在JS里遇到这个概念,与它相伴出入的还有作用域链、词法作用域两个概念。我原本以为这三个概念是彼此等价的。但事实上,最近才发现,它们彼此不怎么相干,只是恰好同时存在与JS这种语言当中。

这次就先说说作用域链。犀牛书中应该有对作用域链的很形象的描述,这里就不赘述它的含义了。作用域链中有一个很重要的概念,叫调用对象。

调用对象可以理解为当一个函数如f()形式被调用执行的时候,引擎会为它创建一个对象,其中包含了函数的参数(亦即arguments对象),然后再按照函数定义中定义的各个变量名,在调用对象中开辟相应的变量作为调用对象的属性。最后将这个调用对象放到作用域链的头部。

所以一个函数被调用执行的时候,引擎大致会经历如下的步骤:

1. 根据调用参数,创建调用对象,创建参数变量
2. 根据函数定义,创建函数内部定义的变量
3. 把调用对象挂到作用域链的头部
4. 执行函数,返回结果

插一句,“调用对象(the call object)”这个名称其实很诡异。它的名字容易让它被误解为两个东东:
1. 作为方法调用这个函数的对象,如o.f()。
2.调用这个函数的函数,如function g() {f();}。
事实上两个都不是。第1个可以在函数中通过this引用,第2个通过Function对象的caller属性引用。但它们都不是“调用对象”。有一篇文章提到“执行环境(Excution Context),一定程度上可以把调用对象看做是函数的执行环境,它是函数自身运行的空间,而不是函数外部的东西。

这里有个很有趣的问题就是,我上面写的函数调用时的4个步骤中,第3个步骤“把调用对象挂到作用域链的头部”,其实并没有指出,挂在哪个作用域的头部。事实上这也是为什么我之前一直很迷惑的地方。好多文章,包括犀牛书,都没有讲明调用对象该挂到哪一个作用域的头部。

在这里引用一段JS代码,是犀牛书中提到的,我把它稍微改造了一下:

function f(x) {
    var g = function () { return x; }
    return g;
}
var g1 = f(1);
var g2 = f(2);
alert(g1());  //输出 1
alert(g2());  //输出 2

这段代码其实描述了一件很值得注意的事。函数f的功能是返回一个函数,这个函数的定义就是 function() { return x; }。所以对于后来的g1,它接受到的是这个函数,而g2接收到的也是这个函数。但它们执行出来的结果就不一样。也就是说,g1和g2在执行的时候,它们的调用对象挂在了不同的作用域链上

犀牛书对此的解释是,JS采用的是“词法作用域”,亦即,一个函数的调用对象该挂在哪个作用域,是由它的定义语句决定,而不是由它的调用语句决定。其实这样说,仍然会很迷惑。我具体解释一下这两者的区别:

1. 如果作用域由“调用语句”决定。上例中,g1()这个调用,是写在alert()里的,那么它的作用域就应该按照调用语句的顺序:g1的调用对象 -> alert的调用对象 ->全局对象。同样,g2的调用形式和g1完全相同,所以它的作用域链也应该是:g2的调用对象 -> alert的调用对象 ->全局对象。如此看来,g1和g2中的x,都应该是undefined,因为沿着它们的作用域链中,都没有x的定义。

2. 如果作用域由“定义语句”决定。上例中,g1这个函数是在调用f(1)的时候定义的。g2这个函数,是在调用f(2)这个函数时定义的。所以,当执行g1()的时候,会将它的调用对象挂在f(1)的作用域链头部,而f(1)的作用域链中,x被赋值为1,所以g1中的x也是1。同理,g2的x是2。

所以从结果看,JS采用的是第2种方式。

这里还有一个容易混淆的地方:“由定义语句决定”。g1和g2这两个函数,并不是在写function() { return x; }的时候定义的哦!也就是说,虽然写了function语句,但不代表这个函数就马上被定义了。这点很关键!因为只有当f(x)被调用执行的时候,这个function语句才会被执行,这个函数才会被定义!其实这里的问题在于,“定义”的概念。在JS中,“定义一个函数”,意味着要“执行function语句”。很不巧,function语句无法写在循环或条件语句中,否则,这一点很容易可以得到证实(犀牛书说JS1.5的Netscape允许在条件语句中定义函数,大家可以试试看)。

至于说,g1()在执行的时候,还能记得它当初是在f(1)中定义的,并把调用函数挂在f(1)的作用域链上。并且,此时f(1)的作用域链居然还存在(它的调用执行已经结束了),这究竟是为什么,我目前还不清楚。但据说ECMA262标准给出了具体的实现方法的,在函数定义时生成一个叫[[scope]]的内部属性。我没体力看了,交给大家吧。

前一篇
后一篇

  1. 大仙:

    “闭包”我都听说过,真是遗憾。
    现在能在博主这里了解到,倍感荣幸。

  2. 大仙:

    现在才发现,原来我的留言的网址写错了。

  3. hax:

    closure还是比较容易理解的,无非是内部函数可以访问外部的变量。之所以能比较容易的支持closure,也在于JS是使用垃圾回收的,因此不存在函数局部变量生存期的问题——被回收的只是局部变量符号,真正所引用的对象只要还有任何一个地方引用它——无论是直接引用还是像closure那样间接引用,都不会被回收。当然,closure的间接引用也加剧了IE中内存泄露问题,此乃题外话,不赘述。

    closure(闭包)、scope chain(作用域链)、lexical scope(词法作用域),三者当然不是一个概念,但是确实是有紧密联系的。具体来说,JS语言是以scope chain的方式来实现lex scope和closure的。所以就算搞不懂scope chain,也不妨碍你使用JS。

  4. 小麦:

    “具体来说,JS语言是以scope chain的方式来实现lex scope和closure的。”

    这个说法是有点问题的。因为词法作用域跟作用域链彼此并不相干,也没有紧密联系。这个专题的(二)里我会详细解释,到时候和你讨论一下。

  5. kwyjibo:

    期待下一讲。对 Closure 的概念理解也是模模糊糊的。
    看了下面的介绍也还是搞不懂。
    http://martinfowler.com/bliki/Closure.html

  6. hax:

    1. scope chain是一种实现手段,具体来说,它是一种name lookup的检索机制。
    2. lexical scope是一种作用域机制,它很好理解。因为lex scope取决于源代码,所以通常编译器可以进行静态分析来确定每个标识符实际的引用。实际上lexical scope因此也称为static scope。
    3. 其实JS并非完全的lexical scope。因为有with和eval这两个特例。所以说JS是lexical scope实际上是说它的scope机制非常接近于lexical scope。
    4. 因此JS引擎通常不使用静态分析,而且只使用静态分析是无法实现with的语义的!JS使用类似dynamic scope的技术,区别在于通常dynamic scope的bindings堆栈是全局的,而JS为每个execution context都单独设置一个bindings堆栈,也就是所谓的scope chain。
    5. 结论:可以把JS的scope看做一个用scope chain机制实现的近似lexical scope。

  7. hax:

    关于with/eval对于lexical scope的影响,可以看这位用scheme实现JS引擎的同志的说法(http://calculist.blogspot.com/2008/10/updated-javascriptplt.html):

    …But the real subtlety comes in with the with (dynamically add a computed object value to the runtime environment as a new environment frame) and eval constructs…
    … So outside of a with, you get normal, efficient lexical scope; inside a with, you get stupid, slow, dynamic scope…

  8. 小麦:

    嗯,我觉得你的解释挺清楚的。

    我觉得问题的关键是,就你的说法,词法作用域的定义是要在编译时确定作用域,也就是说,在声明一个函数的时候,就已经为它建立运行环境空间。

    而JS是在调用时才建立运行环境,因此以上面的定义,它只能算是一种由动态作用域链的机制“模拟”出的词法作用域。

    但我之前的理解是,词法作用域这个概念,只是规定作用域是跟函数声明有关即可。

    因此JS是否属于词法作用域,只在于动态绑定作用域链时,该选择定义环境的作用域链,还是调用环境的作用域链。这只是个链头选择的问题,而跟作用域链机制本身无关。

    不知道你明白我的逻辑没?

  9. jay:

    很复杂的东西,往往是很简单的,看下chrome的源码实现不就明白了

  10. belltoy:

    小麦上面有个地方说得不对。犀牛书中并不是说“由它的定义语句决”,它说的是在函数定义的时候决定的,而不是在执行的时候决定的。这里“定义语句”和“定义的时候”是不同的概念。
    犀牛书乱第五版里的原文是:
    Functions in JavaScript are lexically rather than dynamically scoped. This means that they run in the scope in which they are defined, not the scope from which they are executed.

  11. 小麦:

    嗯,没错,你讲的是正确的。

    所以所谓的“定义的时候”,其实也就是指将此函数实例化的时候。

  12. 2008年终总结 | Belltoy's blog:

    [...] 注1:参见 小麦的文章《作用域链 词法作用域 与 闭包(一)》以及我的评论。 [...]

  13. fornever:

    谢谢小麦的这篇文章,终于对调用对象有了更清晰的认识,有些事情也更明朗了。

  14. vapour:

    对lexical scope懂了点

  15. pbqi:

    非常感谢

  16. Javascript 的词法作用域、调用对象和闭包 - 鳯鸣志:

    [...] 作用域链 词法作用域 与 闭包(一) [...]