如果你写过 javascript,应该听说过 变量提升 (hoisting),如果你自诩“Life is short, I use Python”,那么多多少少会用过 global
、 nonlocal
这两个关键字。无论新手还是老手,遇到这些时都会觉得很别扭,稍不留神就会出现意想不到的 bug,如果你仔细观察就会发现,它们其实是一个问题:变量作用域的问题。
其次,随着函数式编程的日趋火热, 闭包 逐渐成为了 buzzword,但我相信没几个人(希望你是那少数人)能够准确概括出闭包的精髓,而其实闭包这一概念也是解决变量作用域问题。
这篇文章首先介绍作用域相关的知识,主要是比较 dynamic scope 与 static(或lexical) scope 语言的优劣势;然后分析 Python 中为什么需要 global
和 nonlocal
,Javascript 为什么有 变量提升
,我这里不仅仅是介绍what,更重要的是why,要知道这两门语言的设计者都是深耕CS领域多年的老手,不会轻易犯错的,肯定有“不为人知”的一面,但遗憾的是网上大部分文章就是解释what,很少有涉及到why的,希望我这篇文章能够填充这一空缺;最后介绍闭包这一重要概念。
简单来说,作用域限定了程序中变量的查找范围。
在编程语言中有子过程(subroutine,也称为函数、过程)之前,所有的变量都在一个称为“global”的环境中,现在来看这当然是非常不合理,所以在之后有子过程的大部分静态语言(变量的类型不可变)里面,不同的 block(像if、while、for、函数等),具有不同的环境。例如:
#include <stdio.h> intmain() { if(1==1) { inti =1; } else{ inti =2; } printf("i =%d/n", i);//error:useof undeclared identifier'i' return0; }
上面代码片段是一简单的 C 语言程序,在执行 if 代码块时,会新创建一个环境(称为E1,其外围环境为全局环境E0。见下图),然后在 E1 中定义变量 i
,在 if 代码块结束后,E1 这个环境就会被删除,这时 main 函数后面的程序就无法访问 if 代码块的变量了。
上面这一做法符合我们的直观印象,也是比较合理的设计。但是在一些动态语言(变量的类型可以任意改变)中,并没有变量声明与使用的区别,而是在第一次使用时去声明这个变量,像下面这个 Python 示例:
if1==1: i =1 else: i =2 print i # 输出 1
在 Python 中,执行 if 代码块时不会去创建新的环境,而是在 if 代码块所处的环境中去执行。
根据我目前所了解到的:
首先声明一点,这里的dynamic与static是指的变量的作用域,不是指变量的类型,与动态语言与静态语言要区分开。
在上面我们了解到,所有的高级语言都具有函数作用域。我们一般是这样使用函数的,先声明再使用,也就是说函数的声明与使用是分开的,这就涉及到一个问题,函数作用域的外围环境是声明时的还是运行时的呢?不同的外围环境对应不同的语言:
运行时
声明时
看下面这个 Python 示例:
# foo.py s = "foo" def foo(): print s # bar.py from foo import foo s = "bar" foo() # 输出 foo
上面的示例包括两个文件: foo.py
、 bar.py
,在 bar.py
中调用 foo.py
的 foo
函数,因为 Python 属于 static scope 的语言,所以这时的环境是这样的:
static scope 是比较符合正常思维的,也是比较正确的实现方式,否则我们在使用第三份类库时,很容易就会发生变量冲突或覆盖的情况。采用 dynamic scope 的语言都是比较古老的,现在还比较常见的是 Shell,想想大家在写 Shell 时是多痛苦就知道 dynamic scope 是多么反人类了。
就像前面说的,Javascript 具有 function level 的 static scope,但是这里有一个常见的问题,具体代码:
varlist =document.getElementById("list"); for(vari =1; i <=5; i++) { varitem =document.createElement("li"); item.appendChild(document.createTextNode("Item "+ i)); item.onclick = function(ev){ alert("Item "+ i +" is clicked."); }; list.appendChild(item); }
你也许会想当然的认为依次单击时每个Item会依次显示1,2,3…,但其实这里无论你单击那个Item,都只会显示6,你可以去 JSFiddle 测试下。
究其原因,就是因为每个item click 所对应的回调函数的声明与执行是分开的,而且 Javascript 中只有 function level 的作用域,所以在单击Item时的环境是这样的:
i
的值为6,又因为Javascript 中只有 function level 的作用域,所有这里的 i
被定义在了 E0 中。 为了解决这个问题,ES6 引入了 let
,使用 let
定义的变量具有 block level 的作用域,所以如果把上面的代码片段中的 var
换成 let
,环境会变成下面的形式:
先看一个比较典型的例子:
var foo = 1; function bar() { if (!foo) { var foo = 10; } alert(foo); } bar();
你也许知道,这里弹出的值是10,而不是1,因为javascript会把所有的变量提前(hositing),也就是说,上面的代码等价于:
var foo = 1; function bar() { var foo; if (!foo) { foo = 10; } alert(foo); } bar();
上面这个例子就简单演示了什么是变量提升,下面重点讲述为什么要这么设计?首先看下面一段代码:
functionisEven(n){ if(n ==0) { returntrue; } returnisOdd(n -1); } functionisOdd(n){ if(n ==0) { returnfalse; } returnisEven(n -1); } alert(isEven(2));// true
上面的代码出现了 间接的递归调用
,按照正常思维,在定义 isEven
函数时, isOdd
函数还没定义,所以在定义 isEven
时会出错,但事实却是可以运行,为什么呢?
SICP 4.1.6 小节讲了这个问题,介绍了一种解决方式:将所有的变量名提前。这样同一环境中的其他地方就能够使用所有的定义了。需要注意的是,这里只是将变量名提前,赋值的动作不变,显然,Javascript 采用了这一思想。
;; SICP 书中的示例代码 (lambda<vars> (defineu <e1>) (definev <e2>) <e3>) ;; 转为下面的形式 (lambda<vars> (let((u'*unassigned*) (v'*unassigned*)) (set!u <e1>) (set!v <e2>) <e3>))
其实,如果你了解 Javascript 早期的历史,就会知道 Brendan Eich 在创造这门世界级语言时, 一开始就打算用 Scheme 的思想,而且但是 Brendan 也是看了 SICP 这本书。有兴趣的可以看看 Brendan 的自述文章:
准确来说,Python 里面有三种作用域: function
, module
,和 global
作用域。由于 Python 不区分变量的声明,所以在第一次初始化变量时(必须为赋值操作)将变量加入当前环境中。如果在没对变量进行初始化的情况下使用该变量就会报运行时异常,但如果仅仅是访问(并不赋值)的情况下,查找变量的顺序会按照 LEGB 规则 (Local, Enclosing, Global, Built-in)。
s = "hello" deffoo(): s += "world" returns foo() # UnboundLocalError: local variable 's' referenced before assignment
由于在函数 foo 中在没有对 s 初始化的情况下使用了该值,所以这里会报异常,解决的办法就是使用 global 关键字:
s = "hello" deffoo(): globals s += " world" returns foo() # return "hello world"
但由于 global 关键字只能限定在 global
作用域内查找变量,在有嵌套定义的时候就有问题了,比如:
deffoo(): s = "hello" defbar(): globals# NameError: global name 's' is not defined s += " world" returns returnbar foo()()
Python 3 中引入了 nonlocal
关键字来解决这个问题,:
deffoo(): s = "hello" defbar(): nonlocals s += " world" returns returnbar foo()() # return "hello world"
在 Python 2 中,我们可以通过引入一可变容器解决(其实就是绕过直接修改 s
的值)
deffoo(): s = ["hello"] defbar(): s[0] +=" world" returns[0] returnbar foo()() # return "hello world"
可以看到,Python 在试图省略掉变量声明的同时,反而造成了更复杂的情况,相关讨论在 Python mail-list 里面讨论也很火热,有兴趣的读者可以参考:
还是 Javascript 中的例子:
defis_even(n): ifn ==0: returnTrue else: returnis_odd(n-1) defis_odd(n): ifn ==1: returnTrue else: returnis_even(n-1) is_even(2)# 返回 True
这里也能正常的返回结果,但是 Python 中没有像 Javascript 中采用 hosit 机制,那是怎么做到的呢?
其实 Python 的做法和其他大多数语言类似,是采用 Forward_declaration 技术。
网上没有关于 Forward_declaration 实现相关的细节,我这里觉得 Javascript 中的 hosit 应该是属于 Forward_declaration 的一种实现,而且这种实现比较彻底,会把所有变量,无论是普通变量还是函数都放到最前面,但是 Python 就不是这样的,它只会把需要的放到前面,而且这种安排是用户察觉不到的。
后面了解更多实现细节时再来补充。
还是先看一个例子:
functionadd(x){ returnfunction(y){ returnx + y; } } varadd3 = add(3); alert(add3(4));// return 7
这里的 add3
就是一闭包对象,它包括两部分,一个 函数
与声明函数时的 环境
。这就是闭包的核心,没有任何神奇的地方,闭包就是解决自由变量变量作用域的问题。