什么是闭包?
闭包并不是什么新奇的概念,它早在高级语言开始发展的年代就产生了。闭包(Closure)是词法闭包(Lexical Closure)的简称。对闭包的具体定义有很多种说法,这些说法大体可以分为两类:
- 一种说法认为闭包是符合一定条件的函数,比如参考资源中这样定义闭包:闭包是在其词法上下文中引用了自由变量(指除局部变量以外的变量)的函数。
- 另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。有这样的的定义:在实现深约束(英文原词是 binding,也有人把它翻译为绑定)时,需要创建一个能显式表示引用环境的东西,并将它与相关的子程序捆绑在一起,这样捆绑起来的整体被称为闭包。
这两种定义在某种意义上是对立的,一个认为闭包是函数,另一个认为闭包是函数和引用环境组成的整体。虽然有些咬文嚼字,但可以肯定第二种说法更确切。闭包只是在形式和表现上像函数,但实际上不是函数。函数是一些可执行的代码,这些代码在函数被定义后就确定了,不会在执行时发生变化,所以一个函数只有一个实例。闭包在运行时可以有多个实例,不同的引用环境和相同的函数组合可以产生不同的实例。所谓引用环境是指在程序执行中的某个点所有处于活跃状态的约束所组成的集合。其中的约束是指一个变量的名字和其所代表的对象之间的联系。那么为什么要把引用环境与函数组合起来呢?这主要是因为在支持嵌套作用域的语言中,有时不能简单直接地确定函数的引用环境。这样的语言一般具有这样的特性:
- 函数是一阶值(First-class value),即函数可以作为另一个函数的返回值或参数,还可以作为一个变量的值。
- 函数可以嵌套定义,即在一个函数内部可以定义另一个函数。
这些概念上的解释很难理解,显然一个实际的例子更能说明问题。Lua 语言的语法比较接近伪代码,我们来看一段 Lua 的代码:
function make_counter()
local count = 0 function inc_count() count = count + 1 return count end return inc_countendc1 = make_counter()c2 = make_counter()print(c1())print(c2())
在这段程序中,函数 inc_count 定义在函数 make_counter 内部,并作为 make_counter 的返回值。变量 count 不是 inc_count 内的局部变量,按照最内嵌套作用域的规则,inc_count 中的 count 引用的是外层函数中的局部变量 count。接下来的代码中两次调用 make_counter() ,并把返回值分别赋值给 c1 和 c2 ,然后又依次打印调用 c1 和 c2 所得到的返回值。
这里存在一个问题,当调用 make_counter 时,在其执行上下文中生成了局部变量 count 的实例,所以函数 inc_count 中的 count 引用的就是这个实例。但是 inc_count 并没有在此时被执行,而是作为返回值返回。当 make_counter 返回后,其执行上下文将失效,count 实例的生命周期也就结束了,在后面对 c1 和 c2 调用实际是对 inc_count 的调用,而此处并不在 count 的作用域中,这看起来是无法正确执行的。
上面的例子说明了把函数作为返回值时需要面对的问题。当把函数作为参数时,也存在相似的问题。下面的例子演示了把函数作为参数的情况。
function do10times(fn)
for i = 0,9 do fn(i) end end sum = 0 function addsum(i) sum = sum + i end do10times(addsum) print(sum)
这里我们看到,函数 addsum 被传递给函数 do10times,被并在 do10times 中被调用10次。不难看出 addsum 实际的执行点在 do10times 内部,它要访问非局部变量 sum,而 do10times 并不在 sum 的作用域内。这看起来也是无法正常执行的。
这两种情况所面临的问题实质是相同的。在这样的语言中,如果按照作用域规则在执行时确定一个函数的引用环境,那么这个引用环境可能和函数定义时不同。要想使这两段程序正常执行,一个简单的办法是在函数定义时捕获当时的引用环境,并与函数代码组合成一个整体。当把这个整体当作函数调用时,先把其中的引用环境覆盖到当前的引用环境上,然后执行具体代码,并在调用结束后恢复原来的引用环境。这样就保证了函数定义和执行时的引用环境是相同的。这种由引用环境与函数代码组成的实体就是闭包。当然如果编译器或解释器能够确定一个函数在定义和运行时的引用环境是相同的(一个函数中没有自由变量时,引用环境不会发生变化),那就没有必要把引用环境和代码组合起来了,这时只需要传递普通的函数就可以了。现在可以得出这样的结论:闭包不是函数,只是行为和函数相似,不是所有被传递的函数都需要转化为闭包,只有引用环境可能发生变化的函数才需要这样做。
再次观察上面两个例子会发现,代码中并没有通过名字来调用函数 inc_count 和 addsum,所以他们根本不需要名字。以第一段代码为例,它可以重写成下面这样:
function make_counter()
local count = 0 return function() count = count + 1 return count end end c1 = make_counter() c2 = make_counter() print(c1()) print(c2())
这里使用了匿名函数。使用匿名函数能使代码得到简化,同时我们也不必挖空心思地去给一个不需要名字的函数取名字了。
一个编程语言需要哪些特性来支持闭包呢,下面列出一些比较重要的条件:
- 函数是一阶值;
- 函数可以嵌套定义;
- 可以捕获引用环境,并把引用环境和函数代码组成一个可调用的实体;
- 允许定义匿名函数;
这些条件并不是必要的,但具备这些条件能说明一个编程语言对闭包的支持较为完善。另外需要注意,有些语言使用与函数定义不同的语法来定义这种能被传递的”函数”,如 Ruby 中的 Block。这实际上是语法糖,只是为了更容易定义匿名函数而已,本质上没有区别。
借用一个非常好的说法来做个总结:对象是附有行为的数据,而闭包是附有数据的行为。
闭包的表现形式
虽然建立在相似的思想之上,各种语言所实现的闭包却有着不同的表现形式,下面我们来看一下闭包在一些常用语言中的表现形式。
JavaScript 中的闭包
JavaScript(ECMAScript)不是通用编程语言,但却拥有较大的用户群体,而 Ajax 的流行也使更多的人关注 JavaScript。虽然在进行 DOM 操作时容易引发循环引用问题,但 JavaScript 语言本身对闭包的支持还是很好的,下面是一个简单的例子:
function addx(x) {
return function(y) {return x+y;}; } add8 = addx(8); add9 = addx(9); alert(add8(100)); alert(add9(100));
Ruby 中的闭包
随着 Ruby on Rails 的走红,Ruby 无疑是时下炙手可热的语言之一,Ruby 吸取了很多其他语言的优点,是非常优秀的语言,从这一点来看,很难说清是 Rails 成就了 Ruby 还是 Ruby 成就了 Rails。
Ruby 使用 Block 来定义闭包,Block 在 Ruby 中十分重要,几乎到处都可以看到它的身影,下面的代码就展示了一个 Block:
sum = 0
10.times{|n| sum += n}
print sum
10.times 表示调用对象10的 times 方法(在Ruby中一切都是对象,数字也是对象),紧跟在这个调用后面的大括号里面的部分就是Block。所谓 Block 是指紧跟在函数调用之后用大括号或 do/end 括起来的代码,Block 的开始部分(左大括号或 do)必须和函数调用在同一行。Block 也可以接受参数,参数列表必须用两个竖杠括起来放在最前面。Block 会被作为它前面的函数调用的参数,而在这个函数中可以使用关键字 yield 来调用该 Block。在这个例子中,10.times 会以数字0到9为参数调用 Block 10次。
Block 实际上就是匿名函数,它可以被调用,可以捕获上下文。由于语法上要求 Block 必须出现在函数调用的后面,所以 Block 不能直接作为函数的的返回值。要想从一个函数中返回 Block,必须使用 proc 或 lambda 函数把 Block 转化为对象才行。
Python 中的闭包
Python 因其简单易学、功能强大而拥有很多拥护者,很多企业和组织在使用这种语言。Python 使用缩进来区分作用域的做法也十分有特点。下面是一个 Python 的例子:
def addx(x):
def adder (y): return x + y return adder add8 = addx(8) add9 = addx(9) print add8(100) print add9(100)
在 Python 中使用 def 来定义函数时,是必须有名字的,要想使用匿名函数,则需要使用lambda 语句,像下面的代码这样:
def addx(x):
return lambda y: x + y add8 = addx(8) add9 = addx(9) print add8(100) print add9(100)
Perl 中的闭包
Perl 是老牌文本处理语言了,在 WEB 开发方面也有一席之地。不过 Perl6 的开发进行比较慢,也许一些用户开始转投其它语言了。下面是一个 Perl 的例子:
sub addx {
my $x = shift; return sub { shift() + $x }; } $add8 = addx(8); $add9 = addx(9); print $add8->(100); print $add9->(100);
闭包的应用
闭包可以用优雅的方式来处理一些棘手的问题,有些程序员声称没有闭包简直就活不下去了。这虽然有些夸张,却从侧面说明闭包有着强大的功能。下面列举了一些闭包应用。
加强模块化
闭包有益于模块化编程,它能以简单的方式开发较小的模块,从而提高开发速度和程序的可复用性。和没有使用闭包的程序相比,使用闭包可将模块划分得更小。比如我们要计算一个数组中所有数字的和,这只需要循环遍历数组,把遍历到的数字加起来就行了。如果现在要计算所有元素的积呢?要打印所有的元素呢?解决这些问题都要对数组进行遍历,如果是在不支持闭包的语言中,我们不得不一次又一次重复地写循环语句。而这在支持闭包的语言中是不必要的。
抽象
闭包是数据和行为的组合,这使得闭包具有较好抽象能力,下面的代码通过闭包来模拟面向对象编程。函数 make_stack 用来生成 stack 对象,它的返回值是一个闭包,这个闭包作为一个 Dispatcher,当以 “push” 或 “pop” 为参数调用时,返回一个与函数 push 或 pop 相关联的闭包,进而可以操作 data 中的数据。
简化代码
我们来考虑一个常见的问题。在一个窗口上有一个按钮控件,当点击按钮时会产生事件,如果我们选择在按钮中处理这个事件,那就必须在按钮控件中保存处理这个事件时需要的各个对象的引用。另一种选择是把这个事件转发给父窗口,由父窗口来处理这个事件,或是使用监听者模式。无论哪种方式,编写代码都不太方便,甚至要借助一些工具来帮助生成事件处理的代码框架。用闭包来处理这个问题则比较方便,可以在生成按钮控件的同时就写下事件处理代码。
更多
闭包的应用远不止这些,这里列举的只能算是冰山一角而已,并且更多的用法还不断发现中。要想了解更多的用法,多看一些代码应该是个不错的选择。
总结
闭包能优雅地解决很多问题,很多主流语言也顺应潮流,已经或将要引入闭包支持。相信闭包会成为更多人爱不释手的工具。闭包起源于函数语言,也许掌握一门函数语言是理解闭包的最佳途径,而且通过学习函数语言可以了解不同的编程思想,有益于写出更好的程序。
闭包(closure)是函数式编程的重要的语法结构。函数式编程是一种编程范式 (而面向过程编程和面向对象编程也都是编程范式)。在面向过程编程中,我们见到过函数(function);在面向对象编程中,我们见过对象(object)。函数和对象的根本目的是以某种逻辑方式组织代码,并提高代码的可重复使用性(reusability)。闭包也是一种组织代码的结构,它同样提高了代码的可重复使用性。
不同的语言实现闭包的方式不同。Python以函数对象为基础,为闭包这一语法结构提供支持的 (我们在特殊方法与多范式中,已经多次看到Python使用对象来实现一些特殊的语法)。Python一切皆对象,函数这一语法结构也是一个对象。在函数对象中,我们像使用一个普通对象一样使用函数对象,比如更改函数对象的名字,或者将函数对象作为参数进行传递。
函数对象的作用域
和其他对象一样,函数对象也有其存活的范围,也就是函数对象的作用域。函数对象是使用def语句定义的,函数对象的作用域与def所在的层级相同。比如下面代码,我们在line_conf函数的隶属范围内定义的函数line,就只能在line_conf的隶属范围内调用。
1
2
3
4
5
6
7
|
def line_conf():
def line(x):
return 2*x+1
print(line(5)) # within the scope
line_conf()
print(line(5)) # out of the scope
|
line函数定义了一条直线(y = 2x + 1)。可以看到,在line_conf()中可以调用line函数,而在作用域之外调用line将会有下面的错误:
NameError: name ‘line’ is not defined
说明这时已经在作用域之外。
同样,如果使用lambda定义函数,那么函数对象的作用域与lambda所在的层级相同。
闭包
函数是一个对象,所以可以作为某个函数的返回结果。
1
2
3
4
5
6
7
|
def line_conf():
def line(x):
return 2*x+1
return line # return a function object
my_line = line_conf()
print(my_line(5))
|
上面的代码可以成功运行。line_conf的返回结果被赋给line对象。上面的代码将打印11。
如果line()的定义中引用了外部的变量,会发生什么呢?
1
2
3
4
5
6
7
8
9
|
def line_conf():
b = 15
def line(x):
return 2*x+b
return line # return a function object
b = 5
my_line = line_conf()
print(my_line(5))
|
我们可以看到,line定义的隶属程序块中引用了高层级的变量b,但b信息存在于line的定义之外 (b的定义并不在line的隶属程序块中)。我们称b为line的环境变量。事实上,line作为line_conf的返回值时,line中已经包括b的取值(尽管b并不隶属于line)。
上面的代码将打印25,也就是说,line所参照的b值是函数对象定义时可供参考的b值,而不是使用时的b值。
一个函数和它的环境变量合在一起,就构成了一个闭包(closure)。在Python中,所谓的闭包是一个包含有环境变量取值的函数对象。环境变量取值被保存在函数对象的__closure__属性中。比如下面的代码:
1
2
3
4
5
6
7
8
9
10
|
def line_conf():
b = 15
def line(x):
return 2*x+b
return line # return a function object
b = 5
my_line = line_conf()
print(my_line.__closure__)
print(my_line.__closure__[0].cell_contents)
|
__closure__里包含了一个元组(tuple)。这个元组中的每个元素是cell类型的对象。我们看到第一个cell包含的就是整数15,也就是我们创建闭包时的环境变量b的取值。
下面看一个闭包的实际例子:
1
2
3
4
5
6
7
8
|
def line_conf(a, b):
def line(x):
return ax + b
return line
line1 = line_conf(1, 1)
line2 = line_conf(4, 5)
print(line1(5), line2(5))
|
这个例子中,函数line与环境变量a,b构 成闭包。在创建闭包的时候,我们通过line_conf的参数a,b说明了这两个环境变量的取值,这样,我们就确定了函数的最终形式(y = x + 1和y = 4x + 5)。我们只需要变换参数a,b,就可以获得不同的直线表达函数。由此,我们可以看到,闭包也具有提高代码可复用性的作用。
如果没有闭包,我们需要每次创建直线函数的时候同时说明a,b,x。这样,我们就需要更多的参数传递,也减少了代码的可移植性。利用闭包,我们实际上创建了泛函。line函数定义一种广泛意义的函数。这个函数的一些方面已经确定(必须是直线),但另一些方面(比如a和b参数待定)。随后,我们根据line_conf传递来的参数,通过闭包的形式,将最终函数确定下来。
闭包与并行运算
闭包有效的减少了函数所需定义的参数数目。这 对于并行运算来说有重要的意义。在并行运算的环境下,我们可以让每台电脑负责一个函数,然后将一台电脑的输出和下一台电脑的输入串联起来。最终,我们像流 水线一样工作,从串联的电脑集群一端输入数据,从另一端输出数据。这样的情境最适合只有一个参数输入的函数。闭包就可以实现这一目的。
并行运算正称为一个热点。这也是函数式编程又 热起来的一个重要原因。函数式编程早在1950年代就已经存在,但应用并不广泛。然而,我们上面描述的流水线式的工作并行集群过程,正适合函数式编程。由 于函数式编程这一天然优势,越来越多的语言也开始加入对函数式编程范式的支持。