设计闭包(Closure)的初衷是为了解决什么问题?

设计闭包(Closure)的初衷是为了解决什么问题?

许多老师的错误教学,或是一些教科书或是网上错误教程带给了学生的一个错误观点,就是“闭包”是函数式语言这种编程范式(Programming paradigm)带来的特性,而这实际上是自70年代提出的“词法作用域”在当设计“函数内可以定义内部函数并将之返回”的时候的一种体现,没有所谓的“设计闭包的初衷”,要谈就谈设计“词法作用域”的目的,一言以蔽之:

词法作用域是对函数自由变量取值的一种策略

这里的重点,是函数的“自由变量”,“自由变量”就是函数中没有被形式参数约束,同时没有被局部定义或赋值的变量。比如:

这里的x是形式参数,也称约束变量,y是内部定义的局部变量,而z就是自由变量,你从这个函数定义中无法知晓它的值是多少,所以在这个函数被调用的时候,针对自由变量z有不止一套取值的策略。

有一种做法就是不允许出现自由变量,这种语言很少,比如Coq / Agda / Lean,这些是特定领域证明助手的语言,其它人用不到。我们不考虑。(经网友提示,这些语言也陆续有词法作用域的实现,但对未捕捉到的变量依然有一定程度上不支持)

一种对付自由变量的方法叫做“动态作用域”,此时自由变量的值在函数调用时,按照调用链(动态调用上下文)向上查找,绑定到调用栈上最近的变量定义。这是早期编程语言的采取的办法,因为它最方便被实现。比如(下列我用Python代码,但假设“动态作用域”)

调用foo()的时候是在函数bar被调用的栈帧上,那x的取值就从这个栈帧开始查找,找到x的值是10,所以就会显示10。

但是这样的话,自由变量很难预测和推理,所以提出了“词法作用域”,初衷就是:使程序的含义仅由其源代码结构决定。它的作用很多,举一两个好处:它可以让编译器在编译期间静态确定变量绑定,以及便于静态分析和形式验证,因为如果把设计的函数作为API给别的程序使用,你肯定不会希望自由变量由调用时的环境决定;以及:使得函数定义时的环境封闭,生成闭包。它的取值策略如下:

词法作用域,又叫静态作用域,一个函数的自由变量,由在定义此函数的环境决定。还是上面的python例子,

由于全局环境始终存在,而一个函数的调用环境在函数调用结束的时候就没了,所以内部定义的函数就会把这个环境中需要用到的变量“保存起来”,也就生成了“闭包”。

我们现在说的闭包是John McCarthy于1960年论文中对词法作用域第一次设计是提出的闭包捕捉自由变量的方式,后世有人称之为环境闭包。和C++和Java的lambda所用的捕捉变量的值(以及C++捕捉变量的引用不同)。C++和Java实际上是用了类或是结构体模拟了闭包,现在也被视为闭包的一种,但是和最初设计已经不同了。

定义闭包的时候捕获自由变量指的是捕获“变量”和“变量求值的上下文”,没有捕获这个自由变量的值,自由变量的值是在外部函数被调用的时候决定的,比如:

这里定义bar的时候x是10,但是bar的闭包变量x此时并没有被捕获成10,直到调用外部函数需要返回的时候,两个内部函数的闭包变量的上下文决定了两个x取值为20。 这在你调用foo()[0]()的时候会看到。在循环或是列表解析中,很多人就会范这个错误,比如很多python教科书都会提到的著名“错误”(不能说是错误,只是和很多人想的不一样):

严格来说,在全局环境中被求值的自由变量也可以看作闭包变量,但是全局环境不会消失,所以没有这么做的必要。但是某一函数的栈帧会消失,所以要有一个机制把它关“闭”起来打“包”,也就是“保存”起来。

回到你一开始的问题,“设计闭包解决了什么问题?”,我的回答是,闭包是“词法作用域”带来的特性之一,设计“词法作用域”的初衷,有:

局部性与可控性

提高可预测性与可维护性

支持闭包与高阶函数

优化编译与静态分析

有了词法作用域,函数式范式就能更好地发展。早期Lisp也是函数式,它能返回函数,但是没有闭包。

至于说你要比较有闭包的函数范式和OOP的优劣,那就是另一个话题了。但是词法作用域是七十年代就有的东西,设计的时候是不会拿来和OOP比较的。难道你认为这是九十年代的新新玩意儿,拿来和OOP抗衡的?那你想多了。

最后插一句,作为目前视为基于lambda演算发展的语言,早期Lisp是没有闭包的,如果要实现λx.λy.x+y就要当参数传入,或者定义宏,例如在早期MacLisp中:

这样它不是一个函数调用,而是宏展开。

而在更早的LISP1中,连动态作用域都没有,自由变量的会在全局中查找,这源自于LISP设计者McCarthy于1960的论文“Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I”。

现代语言中,有一种可以看到这种设计,以及上文第一种“不允许自由变量”的设计,以及最常用的“词法作用域设计”的语言,就是Rust。它语法上区分局部函数和闭包,而局部函数对于非静态变量采取“不允许自由变量”的设计,比如:

但是Rust更复杂,又涉及对于没有实现Copy trait的变量默认以借用形式捕获,需要加move 才是转移所有权,但所有权转移却在定义闭包时发生。所以严格来说处理自由变量Rust就有多种不同方式,默认是引用,有copy的捕捉的是值,有move的转移了所有权,如果move的是RC<T>则共享了所有权,相当于捕捉上下文。

另一种混合动态和词法作用域的语言是Common Lisp,同时闭包没有捕获外部函数的变量(不活了就符合词法作用域),自由变量不会在全局上下文中找,而是顺着调用链找,也就是动态作用域。

当你搞清楚这一切的来龙去脉,就不会把闭包和OOP放在一起对比并产生疑惑了。设计“词法定义域”的初衷是解决“动态定义域”的弊端和不便,其中之一(注意是“之一”)的用途是实现闭包,从而实现lambda演算。