浅谈闭包-以 Swift 为例
2015-07-22
本文讨论闭包的相关概念,大部分代码使用Swift编写。Swift对闭包有着良好的支持。这是因为,Swift被设计成一门一定程度上支持函数式编程范式的编程语言。而函数式编程和闭包有着紧密的联系。本文着重讨论的也是函数式编程和闭包之间的关系。
变量,约束,环境和函数
在讨论闭包之前,需要先明晰一些简单的概念。
变量
计算机程序语言中必不可少的一部分是它需要提供一种通过名字去使用计算对象的方式。也就是,我们需要能为计算对象标识一个名字。名字标识符就是我们常说的变量,而它的值就是它所对应的那个对象。如果要在编程语言中使用这些变量,我们就需要有将值和变量名关联起来,和在需要时又可以将值提取出来的能力。这就意味着编程语言需要提供某种存储能力,将变量名和值的对应关系存储下来,以便需要时使用。
约束
将变量名关联于对应的值,就构成了一个约束。任何变量至多只能有一个约束。这很容易理解,因为使用变量名取数据时,你当然希望它指明的是明确而且唯一的值。这也是为何把变量名和值的对应关系称为约束的原因。
环境
一系列这种名字和值对应关系(约束)的存储,就可以称之为环境。环境对于程序语句是至关重要的,因为它确定了每个表达式的上下文。甚至,我们可以说环境决定了表达式的含义。因为,即便是确定像(1 + 1)
这么简单的语句的具体含义,也有赖于环境来确定+
是表达加法的运算符号。我们可以假定程序的运行时拥有一个全局环境,这个环境里包含了所有关联于基本过程的符号的值。例如,符号+
就在全局环境中被约束到基本的加法运算。
函数
函数,是大部分编程语言都存在的概念。在不同语言中这个概念存在着细微的区别。在面向对象编程语言中称之为“方法”,在函数式编程语言中称之为“过程”。无论被称为什么名字,它们都拥有的共同基本含义是:它是编程语言的一种基本的抽象手段,使我们可以将一组操作作为一个单元组合起来,并为这组操作命名。这样我们就可以通过一个简单的名字操作一组复杂的操作。而对于不同的编程语言中“函数”这一实体所存在的细微差别,我们会在后文中通过对“闭包”的探讨加以说明。
闭包
在说闭包之前,需要先清楚“自由变量”的概念。在某个作用域中,如果使用未在本作用域中声明的变量,对于此作用域来说,该变量就是一个自由变量。
闭包,是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使离开了创造它的环境也不例外。另一种说法认为闭包并不是函数,而是由函数和与其相关的引用环境组合而成的实体。这是因为,闭包在运行时可以有多个实例,不同的引用环境和相同的函数组合可以产生不同的实例。而函数只会有一个实例。这两种定义对闭包的看法并没有不同,只是对函数的定义不同。前者对函数的定义更宽松,后者则更为严格。
Scheme 中的闭包
最早实现闭包的程序语言是Scheme。Scheme是一种函数式编程语言。从这也可以看出闭包和函数式编程语言联系紧密。我们用Scheme实现一个简单的自增器的例子,这个例子会在后文中以Swift版本再次出现:
(define make_counter
(lambda ()
(let ((count 0))
(lambda ()
(set! count (+ 1 count))
count))))
这段代码做了如下事情:
lambda
定义了一个过程(或者说函数)。define
将第一个lambda
定义的过程命名为make_counter
。let
创建了一个作用域,这个作用域内,创建了变量count
,并初始化为0(在环境中添加了一条count到0的约束)。- 第二个
lambda
也创建了一个过程。该过程将count
加一后再赋给count
(取得环境中count到某个值的约束,加一后更新这条约束),并将count
作为返回值返回。
第二个lambda
其实创建了一个我们常说的闭包。我们可以发现,Scheme中的闭包并没有区别于其他过程的特殊语法标识。无论是否是闭包,过程都用lambda
定义。但在Objective-C(或者说C语言,因为Objective-C的block
就是来源于C语言的一种闭包扩展)和Java中,定义一个函数和定义一个闭包使用的语法并不相同。这是因为闭包性被认为Scheme的过程本应具有的特性。而Objectiive-C和Java中的闭包,都是在创建语言多时之后,迫于编程语言的发展趋势而添加的特性。由于面向对象编程对于函数的看法具有局限性,在Objective-C和Java这样比较纯粹的面向对象编程语言中,闭包的实现会比较困难,即使实现了,语法也难以优雅,看起来就很像一个补丁。
至于为何第二个过程是一个闭包,将会在下一小节使用Swift代码讲解。后文也将会看到,在闭包语法简洁性这一点上,Swift更接近于Scheme。
高阶函数
高阶函数,指将其他函数作为参数或者返回结果的函数。
Swift中的函数可以成为高阶函数,这和Scheme,Scala,Haskell一样。与此对照的是,Java不支持高阶函数(在Java 7支持闭包之前)。Java中的方法没法单独存在,方法总是需要和类捆绑在一起。当需要将一个方法作为参数传递给另外一个方法时,你会发现必须以类作为载体来运送方法。这也是Java中监听器(Listener)的做法。
一般支持闭包的语言都是一定程度上支持函数式编程的编程语言。若非如此,则其闭包实现一般都晦涩复杂。这是因为高阶函数需要函数先成为闭包。而函数式编程语言的函数都是高阶函数。所以,函数式编程语言支持闭包就理所当然了。其实在很多函数式编程语言中,闭包的概念并不明显。因为,在函数式编程语言中,这是函数本身就应该具备的能力(绑定其引用的自由变量)。函数式编程中,函数也被视为对象,拥有多个实例也就理所当然。而在命令式编程语言中,对函数看法更为局限,视函数为语言的一种特殊构造(和类并不相同),它只是一系列语句的集合。在这种情况下,函数如果需要操作自由变量,程序员就需要自己保障自由变量是可以被访问的,并且存储着有意义的值。在C语言中,程序员有很大一部分精力用于关注这个问题(野指针)。
我们再来看看,为何高阶函数需要函数先成为闭包。举一个高阶函数的例子:
func makeCounter() -> (() -> Int) {
var count = 0
func inc() -> Int {
count += 1
return count
}
return inc
}
在这个简单的例子中,我们定义了一个生产自增器的函数makeCounter
。由makeCounter
返回一个函数,这个函数就是一个自增器。每次调用自增器就会增加一。很明显,makeCounter是高阶函数,因为它返回了另外一个函数。
makeCounter
首先定义了一个变量var count = 0
(在环境中添加了一条count到0的约束)。函数本身创造了一个作用域,这个函数内部定义变量count
的作用域就是函数开始直到函数返回。也就是说,函数返回后,就不在变量count
的作用域中了,变量的生命周期也就结束了。
然后,makeCounter
中又定义了一个新函数inc
。inc
引用了自由变量count
,并将count
加一后再赋给count
(取得环境中count到某个值的约束,加一后更新这条约束),最后将count
作为返回值返回。而后makeCounter
又将inc
作为返回值返回。
在函数makeCounter
返回后,由于count
已经不在其作用域内,这看起来应该是无法正确执行的。所以,为了让程序正确执行,inc
需要绑定其引用的自由变量,使得即使在makeCounter
返回后,count
也不会消失。用命令式编程的观点来看,也就是说,inc
被返回时,就创造了一个特殊的函数,该函数带着它定义时引用的自由变量的上下文环境,这其实就是闭包。
一等函数
一等函数,进一步扩展了函数的使用范围,使得函数成为语言中的“头等公民”。这意味函数可在任何其他语言结构(比如变量)出现的地方出现。一等函数是更严格的高阶函数。Swift中的函数都是一等函数。
我们在上一小节发现,成为高阶函数,需要函数本身就具备闭包性。这一节,我们会发现更严格的一等函数和闭包的也仍然有着紧密的关系。
我们可以这样使用上例中的自增器:
let inc1 = makeCounter()
inc1() // 1
inc1() // 2
let inc2 = makeCounter()
inc2() // 1
inc2() // 2
上一节已经讲过,调用makeCounter()
返回的是闭包。这段代码中,我们将makeCounter()
返回的闭包函数赋给变量inc1
和inc2
,这样我们可以通过两个变量来调用函数。从运行结果中,我们可以发现每次调用makeCounter()
其实是创建了一个新的对象,inc1
和inc2
并不一样。它们各自绑定了自己的count值,这是一种闭包性。由于各自绑定了自由变量,它们可以和对象一样,有多个实例,可以被赋给变量。这使得该函数和Swift中结构体,类等其他语言构造并没有不同。而这种函数就是一等函数。
我们再对比一下Java中的“函数”(方法)就能发现明显的区别。Java中的方法没法单独存在,方法总是需要和类捆绑在一起。Java中的方法是类的一种附属构造。方法只是一系列语句的集合,一种用于操作对象的途径。在这种定位下,你无法(也无需)把方法赋给某个变量。因为,你只能通过对象来调用方法(或者通过类调用静态方法);你无法(也无需)为方法绑定自由变量。因为,Java中方法绑定在所属类上,所以,你只需要把变量绑定到类上(成为实例变量,或者静态变量),就可以为属于该类的方法所用。
Python社区有一种描述对比了对象和闭包:“对象是附有行为的数据,而闭包是附有数据的行为。”这个说法揭示了,在函数式编程中,函数成为闭包之后,就取得了和类相同的地位。这就是一等函数的意义。
匿名函数和闭包
我们再顺着上例进一步探求Swift中函数和闭包的关系。其实,我们可以发现inc
这个内部函数除了被返回之外,并没有其他作用。它其实并不需要名字,可被定义为一个匿名函数。那么我们再试试用Swift来定义一个匿名函数。会发现,只能定义为如下形式:
func makeCounter() -> (() -> Int) {
var count = 0
return { () -> Int in
count += 1
return count
}
}
返回的那部分仍然是一个闭包。Swift中并没有特殊的匿名函数语法构造。如果,你想写一个传统意义上的匿名函数,你就只能给出一个闭包。也就是说Swift中匿名函数和闭包的定义方法是一样的。可以看出在Swift中,闭包和函数的分野也并不显著,起码匿名函数和闭包并无区分。或者说Swift的函数都具有绑定自由变量的能力,也就是闭包性。前例中的自增器的函数写法,和匿名函数写法(或者说是闭包写法),返回的都是闭包。这是函数式编程语言的特点。这一点,在Swift中也体现出来了。
如果再做个比较,会发现Swift中的闭包语法比Objective-C简单清晰很多。这是因为,Swift是以支持函数式编程思想为设计基础的编程语言。闭包性是Swift这类支持函数式编程的编程语言的语言特性。而Objective-C这种较纯的面向对象编程语言则需在后期添加闭包这种特性。这时常带来困难,以及语法上的晦涩。在对闭包的支持这一点上,Swift更接近Scheme。
另一种闭包
上文中使用的闭包的概念,并不被SICP的作者认可。他们认为使用“闭包”这个名词表示带有自由变量的过程的实现技术,是一件很不幸的事情。他们更认可“闭包”本来的意义:在抽象代数中,一集合元素称为在某个运算(操作)之下闭合,如果将该运算应用于这一集合中的元素,产生出的仍然是该集合里的元素。
对应于计算机语言中的概念。一般说,某种组合数据对象的操作满足闭包性,那就是说,通过它组合起来的数据对象得到的结果本身还可以通过同样的操作再进行组合。比如,如果我们可以创建数组元素也是数组的数组,那么我们就说创建数组的操作具有闭包性质。因为创建出来的数组仍然可以作为数组的元素,用于创建新数组。诸多编程语言的数据组合机制都不满足这一性质,或者使得其中的闭包性质很难使用。Fortan,Basic里,组合数据的一种典型方法是将它们放入数组,但却不能将数组放入数组。Pascal和C允许结构的元素又是结构,却要求程序员显式地操作指针,并限制性地要求结构的每个域都只能包含预先定义好形式的元素。
Swift的集合类型的操作满足这一意义上的闭包性质。Swift中可以将数组作为另外一个数组的元素。也可以将字典作为字典的值。Scheme可以说是一种弱类型编程语言,序对(pair)是其构造复合数据的基本结构,Scheme中对于序对中存储什么完全没有限制,所以当然可以建立序对的序对,这也是Scheme的表(list,更接近于链表的一种数据结构)的构成方式。而Swift是一种强类型编程语言。在声明数组,字典时,需要提供元素的类型信息。可能是作为一种补偿,Swift提供的集合类型大都支持泛型。在强类型的安全和弱类型的灵活之间取得了平衡。