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

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

22November2008

这次讲词法作用域。这个概念其实跟JS没太大关系,它一定程度上属于更广泛的层次:程序设计语言。

词法作用域又被成为静态作用域,它的关键特性就是“作用域由定义语句决定”。而和它相对的“作用域由调用语句决定”的,被成为动态作用域。这几个概念,实际上就是在描述构建作用域的规则。

程序设计语言-实践之路》中说,“在采用静态(词法)作用域的语言里,名字与对象的约束可以在编译时通过检查程序正文确定,完全不需要考虑运行时的控制流。”这句话看起来揭露了为什么“词法作用域”被称为静态作用域。

但是仔细回忆“作用域链 词法作用域 与 闭包(一)”中所讲,JS的一个函数的作用域链事实上是在它被调用时才建立的。这里就有困惑了。既然JS的作用域其实是在调用时才建立,为啥又说它是遵循静态作用域规则呢?

更让人困惑的是,之前不是说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

g1的作用域链,确实是在调用它的时候才建立起来的。这个作用域链的内容是:g1的调用对象 -> f(1)的调用对象 -> 全局对象。注意哦,你会发觉,其实这个作用域链中,只有第一个是g1()调用时新建的,剩下的部分其实是f(1)的作用域链。

事实上呢,虽然g1()的作用域链“在它被调用时才建立”,但是,由于除了这个作用域链头部以外,其他部分都定死了,所以这个作用域链的内容也就被“决定”了:无论它在何处被调用,调用几次,即使每次调用时都新建一个调用对象,它的作用域也肯定是“g1的调用对象 -> f(1)的调用对象 -> 全局对象”这个形式。

OK,《程序设计语言-实践之路》的作者Michael L.Scott会说,“可以在编译时通过检查程序正文确定,完全不需要考虑运行时的控制流”呢?按理说,是在调用f(1)时,定义了g1。而f(1)何时何处如何被调用,仍然可能是在编译时无法预知的。

如果要在编译时,就能确定作用域,只有一种可能性,在函数定义时就为函数开辟一个唯一的执行环境。每次调用此函数时,把变量值放到这个执行环境中运行即可。

我试着以这样的假设来重新解释上面那段JS运行时的步骤:

1. 解析到function f(x)时,为其创建一个调用对象F,在该调用对象中定义x变量。
2. 解析到function () { return x; },再创建一个调用对象G,将其加在F的头部,形成作用域链。
3. 执行f(1)的时候,将F的x变量赋值为1,并返回调用对象G的指针。
4. G的指针被赋给g1。
5. 执行g1()的时候,会通过指针找到G,再通过作用域链找到F中的x,返回x。

这样看来,调用对象在解析函数的function定义语句时就生成,好像也是合理的。但问题是,例子中,f(1)执行后紧接着执行f(2)。如果它们俩只用一个调用对象的话,在接下来执行g1()的时候,x就应该是2,而不是1。由此可看出,JS确实是“动态的创建作用域”的。

换句话说,将JS称为是“静态作用域”语言,确实有点问题!

而将JS称为“词法作用域”语言,却不无道理。虽然说,JS是动态的创建作用域链。但由于g1的定义是写在f的执行语句中的,因此,从代码上看,g1只有在f被调用执行的时候,才会被定义。换句话说,g1的调用对象建立的时候,相应的f的调用对象肯定已经被建立了。因此它的作用域结构一开始就由代码结构定死了。(当然,我个人觉得,或许叫“语法作用域”更恰当。词法(Lexical)和语法(Syntax)的区别,请见编译原理教材)。

对此我确实提出几个质疑:
1. 或许Scott对“静态作用域”的解释有误,静态作用域的语言,并非都可以在编译时确定作用域。
2. 或许,他文中说能够确定的是“名字与对象的约束”,这句话并非是指具体的作用域,而是作用域的结构。
3. 如果上面两个质疑都不对,那么“词法作用域”和“静态作用域”不应该是同一概念。至少不应该叫“静态作用域”。

最后,值得一提的是,“词法作用域”和“作用域链”,彼此并不相干。很简单,采用“词法作用域”,也就是“静态作用域”的语言,不一定有“作用域链”。比如C语言,采用的就是“静态作用域”。何以证明?C语言中存在函数指针的概念,因此,一个函数g可能会在另一个函数f中被调用。而无论g在哪里被调用,它的作用域都不会包括调用它的f的作用域,因此,C语言函数的作用域并不是“由调用环境决定的”,它是“静态作用域”。但是C语言不存在作用域链的概念,原因很简单:C语言的作用域被严格限定在函数体内,未在函数体内被声明的变量是不允许使用的。所以它不需要作用域链的概念。

此外,有作用域链(或类似概念)的语言,未必是“静态作用域”的。Perl就是一个例子。

由此看来,是否有作用域链,取决于是否允许使用本代码块中未定义的变量。这种变量被称为自由变量。而自由变量,则是闭包概念中的关键要素。这个下次再讲。

海角七号

20November2008

吃晚饭时路过买碟小摊,瞥到一眼海角七号,回来和kaki聊到,就到网上找来看了。

这是一部神奇的电影。它讲述了失落的一群人如何冲破现实的阻挠,将自己的理想升华。

讲述了60年前对友子的一份深切爱恋,如何成为历史的尘埃。

讲述了带着这份爱恋的7封情书,60年后如何跨越遥远的时空,最终静静的落在友子的身旁。

讲述了阿嘉如何面对一个原本快要擦肩而过的人,最终鼓起勇气对她说,留下来,或者我跟你走。

和阿达谈起日据的台湾,忽然意识到,在过去一百年里,台湾只有4年时间和大陆有联系。而正是这个在太平洋西岸漂泊多年的小岛,给我们带来如此多的故事。

其实除了情节本身,让我更感慨的是那段历史。这部电影的配乐,名叫“1945”。1945对日本来说,是战败的屈辱;对台湾来说,是光复的喜悦;对我们这些后人来说,是教科书上的一段文字。

而对友子,和那位深爱着她却不辞而别的日本教师而言,1945不需要背负那么多沉重的意义,它只是一道狠狠刻在自己心中的痕。而那来自1945的7封信,被阿嘉轻轻的放在友子婆婆的身旁,而她却没有察觉。直到做完一天的活,轻轻回头,看见这个盒子。当她拿起信的时候,镜头就结束了。

我很喜欢电影如此轻轻的带过了这个场景。7封来自大洋彼岸的信,在60年后最终送到了友子婆婆的面前。它并不需要什么结果,只不过告诉对方,在这一个甲子的轮回后,我一直在。

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

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]]的内部属性。我没体力看了,交给大家吧。