2.6 闭包
有一次,我参加了一个叫做“Ruby 集训”的活动,那是一个由想学习 Ruby 的年轻人参加的,历时 5 天 4 夜的 Ruby 编程学习活动,对参加者来说是一次非常宝贵的经验。第 1 天是入门培训,第 2 天将 Ruby 系统学习一遍,然后第 3 天到第 4 天分组各自制作一个相当规模游戏,并在最后一天进行展示,可以说是一次十分军事化的集训活动。我只到现场去了大概两天,不过那些勇于向高难度课题发起挑战的年轻人还是给我留下了深刻的印象。
在那次集训活动中,有一位参加者问:“闭包是什么?”担任讲师的是我的学生,不过他也没有做出准确的理解,因此借这个机会,我想仔细给大家讲一讲关于闭包的话题。
函数对象
有一些编程语言中提供了函数对象这一概念,我知道有些人把这个叫做闭包(Closure),但其实这种理解是不准确的,因为函数对象不一定是闭包。不过话说回来,要理解闭包,首先要理解函数对象,那么我们先从函数对象开始讲起吧。
所谓函数对象,顾名思义,就是作为对象来使用的函数。不过,这里的对象不一定是面向对象中所指的那个对象,而更像是编程语言所操作的数据这个意思。
例如,C 语言中,我们可以获取一个函数的指针,并通过指针间接调用该函数。这就是 C 语言概念中的对象(图 1)。
1| #include <stdio.h> 2| int two(int x) {return x*2;} 3| int three(int x) {return x*3;} 4| 5| int main(int argc, char **argv) 6| { 7| int (*times)(int); 8| int n = 2; 9| 10| if (argc == 1) times = two; 11| else times = three; 12| printf("times(%d) = %d\n", n, times(n)); 13| }图 1 C 语言的函数对象
一般的 C 语言程序员应该不大会用到函数指针,因此我们还是讲解一下吧。
第 7 行,main 函数的开头有个不太常见的写法:
int (*times)(int);这是对指针型变量 times 的声明,它的意思是:变量 times,是指向一个拥有一个 int 型参数,并返回 int 值的函数的指针。
第 10 行开始的 if 语句,意思是当传递给程序的命令行参数为零个时。当参数为零个时,将函数 two(的指针)赋值给变量 times;当存在一个以上的参数时,则将函数 three 的指针赋值给 times。
综上所述,当程序没有命令行参数时,则输出:
times(2) = 4有命令行参数时,则输出:
times(2) = 6到这里,大家应该对 C 语言中的函数指针有所了解了吧?
高阶函数
重要的是,这种函数对象对我们的编程有什么用。如果什么用都没有的话,那就只能是语言设计上的一种玩具罢了。
函数对象,也就是将函数作为值来利用的方法,其最大的用途就是高阶函数。所谓高阶函数,就是用函数作为参数的函数。光这样说大家可能不太明白,我们来通过例子看一看。
我们来设想一个对数组进行排序的函数。这个函数是用 C 语言编写的,在 API 设计上,应该写成下面这样:
void sort(int *a, size_t size);函数接受一个大小为 size 的整数数组 a,并对其内容进行排序。
不过,这个 sort 函数有两个缺点。第一,它只能对整数数组进行排序;第二,排序条件无法从外部进行指定。例如,我们希望对整数数组进行逆序排序,或者是希望对一个字符串数组按 abc 顺序、辞典顺序进行排序等等,用这个函数就无法做到。也就是说,这个 sort 函数是缺乏通用性的。
用函数参数提高通用性
另一方面,在 C 语言标准库中,却提供了一个具有通用性的排序函数,它的名字叫 qsort,API 定义如图 2 所示。
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));图 2 qsort 函数
那么,这个通用排序函数 qsort 是如何克服上述两个缺点的呢?秘密就隐藏在 qsort 函数的参数中。
首先,我们来看看第 1 个参数 base,它的类型是 void*。sort 的第 1 个参数是限定为整数数组的,相比之下,qsort 的参数则表示可以接受任何类型的数组。这样就避免了对数组类型的限制。
接下来,第 2、第 3 个参数表示数组的大小。在 sort 中只传递了数组的大小(元素的数量),而 qsort 中的第 2 个参数 nmemb 表示元素数量,第 3 个参数 size 则表示每个元素的大小。这样一来,相对于只能对整数数组进行排序的 sort 函数来说,qsort 则可以对任何数据类型的数组进行排序。
不过,还有一个重要的问题,那就是如何对任意类型数组中的元素进行比较呢?要解决这个问题,就要靠 qsort 函数的第 4 个参数 compar 了。
compar 是指向一个带两个参数的函数的指针。这个函数接受数组中两个元素的指针,并以整数的形式返回比较结果。当两个元素相等时,返回 0,当 a 比 b 大时返回正整数,当 a 比 b 小时返回负整数。
qsort 的实际应用例如图 3 所示。在这里我们定义了一个名为 icmp 的函数,它可以对整数进行逆序比较。结果,qsort 函数就会将数组中的元素按降序(从大到小)排序。
#include <stdio.h> #include <stdlib.h> int icmp(const void *a, const void *b) { int x = *(int*)a; int y = *(int*)b; if (x == y) return 0; if (x > y) return -1; return 1; } int main(int argc, char **argv) { int ary[] = {4,7,1,2}; const size_t alen = sizeof(ary)/sizeof(int); size_t i; for (i=0; i<alen; i++) { printf("ary[%d] = %d\n", i, ary[i]); } qsort(ary, alen, sizeof(int), icmp); for (i=0; i<alen; i++) { printf("ary[%d] = %d\n", i, ary[i]); } }图 3 qsort 函数的应用实例
大家现在应该已经明白了,qsort 函数是通过将另一个函数作为参数使用,来实现通用排序功能的。高阶函数这样的方式,通过将一部分处理以函数对象的形式转移到外部,从而实现了算法的通用化。
函数指针的局限
好,关于(C 语言的)函数指针以及将其用作参数的高阶函数的强大之处,我们已经讲过了,下面我们来讲讲它的局限吧。
作为例题,我们来设想一下,对结构体构成的链表(Linked list)及对遍历处理,用高阶函数来进行抽象化。
图 4 是用一般的循环和高阶函数两种方式对链表进行遍历的程序。图 4 的程序由于 C 语言性质的缘故显得很长,其本质的部分是从 main 函数第 38 行开始的。
1| #include <stdio.h> 2| #include <stdlib.h> 3| 4| struct node { /* 结构体定义 */ 5| struct node *next; 6| int val; 7| }; 8| 9| typedef void (*func_t)(int); /* 函数指针类型 */ 10| 11| void /* 循环用函数 */ 12| foreach(struct node *list, func_t func) 13| { 14| while (list) { 15| func(list->val); 16| list = list->next; 17| } 18| } 19| 20| void /* 循环主体函数 */ 21| f(int n) 22| { 23| printf("node(?) = %d\n", n); 24| } 25| 26| main() /* main函数 */ 27| { 28| struct node *list = 0, *l; 29| int i; 30| /* 准备开始 */ 31| for (i=0; i<4; i++) { /* 创建链表 */ 32| l = malloc(sizeof(struct node)); 33| l->val = i; 34| l->next = list; 35| list = l; 36| } 37| 38| i = 0; l = list; /* 例题主体 */ 39| while (l) { /* while循环 */ 40| printf("node(%d) = %d\n", i++, l->val); 41| l = l->next; 42| } 43| foreach(list, f); /* foreach循环 */ 44| }图 4 高阶函数循环
从第 39 行开始的 while 语句没有使用高阶函数,而是直接用循环来实现的。受过良好训练的 C 语言程序员可能觉得没什么,不过要看懂 41 行的
l = l->next等写法,需要具备关于链表内部原理的知识,其实这些涉及底层的部分,最好能够隐藏起来。
另一方面,第 43 行开始用到 foreach 函数的部分,则是非常清晰简洁的。只不过,受到 C 语言语法的制约,这个函数必须在远离循环体的地方单独进行定义,这是 C 语言函数指针的第一个缺点。大多数语言中,函数都可以在需要调用的地方当场定义,因此这个缺点是 C 语言所固有的。
不过和另一个重要的缺点相比,这第一个缺点简直算不上是缺点。如果运行这个程序的话,结果会是下面这样的。
node(0) = 3 node(1) = 2 node(2) = 1 node(3) = 0 node(?) = 3 node(?) = 2 node(?) = 1 node(?) = 0前面 4 行是 while 语句的输出结果,后面 4 行是 foreach 的输出结果。while 语句的输出结果中,可以显示出索引,而 foreach 的部分则只能显示“?”。这是因为和 while 语句不同,foreach 的循环实际上是在另一函数中执行的,因此无法从函数中访问位于外部的局部变量 i。当然,如果变量 i 是一个全局变量就不存在这个问题了,不过为了这个目的而使用副作用很大的全局变量也并不是一个好主意。因此,“对外部(局部)变量的访问”是 C 语言函数指针的最大弱点。
作用域:变量可见范围
现在我们已经了解了 C 语言提供的函数指针的缺点,于是,为了克服这些缺点而出现的功能,就是本次的主题——闭包。
我想现在大家已经理解了函数对象,下面我们来讲解一下闭包。话说,要讲解闭包,必须使用一种支持闭包的语言才行,因此在这里我们用 JavaScript 来讲解。肯定有人会问,为什么不用 Ruby 呢?关于这一点,我们稍后再讲。
首先,为了帮助大家理解闭包,我们先来介绍两个术语:作用域(Scope)和生存周期(Extent)。
作用域指的是变量的有效范围,也就是某个变量可以被访问的范围。在 JavaScript 中,保留字 var 所表示的变量声明所在的最内侧代码块就是作用域的单位(图 5),而没有进行显式声明的变量就是全局变量。作用域是嵌套的,因此位于内侧的代码块可以访问以其自身为作用域的变量,以及以外侧代码块为作用域的变量。
图 5 JavaScript 中的作用域
另外,大家别忘了创建匿名函数对象的语法。在 JavaScript 中是通过下面的语法来创建函数对象的:
function () {...}图 5 中我们将匿名函数赋值给了一个变量,如果不赋值而直接作为参数传递也是可以的。当然,这个函数对象也有自己的作用域。
由于 JavaScript 中可以直接定义函数对象,因此像图 4 那样应用 foreach 的程序,用 JavaScript 就可以更加直接地编写出来。将图 4 的本质部分用 JavaScript 来改写的程序如图 6 所示。
function foreach(list, func) { // 循环高阶函数 while (list) { func(list.val); list = list.next; } } var list = null; // 变量声明 for (var i=0; i<4; i++) { // list初始化 list = {val: i, next: list}; } var i = 0; // i初始化 // 从函数对象中访问外部变量 foreach(list, function(n){console.log("node("+i+") = "+n);i++;});图 6 高阶函数循环
这里值得注意的是,作为 foreach 参数的函数对象,是可以访问在外部声明的变量 i 的。结果, C 语言版的 foreach 函数无法实现的索引显示功能,在这里就可以实现了。因此,从函数对象中能够对外部变量进行访问(引用、更新),是闭包的构成要件之一。
按照作用域的思路,可能大家觉得上述闭包的性质也是理所当然的。不过,如果我们加上另外一个概念——生存周期,结果可能就会出乎意料了。
生存周期:变量的存在范围
所谓生存周期,就是变量的寿命。相对于表示程序中变量可见范围的作用域来说,生存周期这个概念指的是一个变量可以在多长的周期范围内存在并被能够被访问。要搞清楚这个概念,我们还是得看看实例。
图 7 的例子是一个返回函数对象的函数,即 extent 这个函数的返回值是一个函数对象。函数对象会对 extent 中的一个局部变量 n 进行累加,并显示它的值。
function extent() { var n = 0; // 局部变量 return function() { n++; // 对n的访问 console.log("n="+n); } } f = extent(); // 返回函数对象 f(); // n=1 f(); // n=2图 7 变量的生存周期
那么,这个程序实际运行的情况会如何呢?
extent() 执行后会返回函数对象,我们将其赋值给一个变量。这个函数变量在每次被执行时,局部变量就会被更新,从而输出逐次累加的结果。
咦?这里不觉得有点怪吗?
局部变量 n 是在 extent 函数中声明的,而 extent 函数已经执行完毕了啊。变量脱离了作用域之后不是应该就消失了吗?不过,就这个运行结果来看,即便在函数执行完毕之后,局部变量 n 貌似还在某个地方继续存活着。
这就是生命周期。也就是说,这个从属于外部作用域中的局部变量,被函数对象给“封闭”在里面了。闭包(Closure)这个词原本就是封闭的意思。被封闭起来的变量的寿命,与封闭它的函数对象寿命相等。也就是说,当封闭这个变量的函数对象不再被访问,被垃圾回收器回收掉时,这个变量的寿命也就同时终结了。
现在大家明白闭包的定义了吧。在函数对象中,将局部变量这一环境封闭起来的结构被称为闭包。因此,C 语言的函数指针并不是闭包,JavaScript 的函数对象才是闭包。
闭包与面向对象
在图 7 的程序中,当函数每次被执行时,作为隐藏上下文的局部变量 n 就会被引用和更新。也就是说,这意味着函数(过程)与数据结合起来了。
“过程与数据的结合”是形容面向对象中的“对象”时经常使用的表达。对象是在数据中以方法的形式内含了过程,而闭包则是在过程中以环境的形式内含了数据。即,对象和闭包是同一事物的正反两面。所谓同一事物的正反两面,就是说使用其中的一种方式,就可以实现另一种方式能够实现的功能1。例如图 7 的程序,如果用 JavaScript 的面向对象功能来实现的话,就成了图 8 中的样子。
1 准确地说,对象可以拥有多个过程,而闭包只能拥有一个。但是,闭包中可以将相当于过程名的符号作为参数进行传递,通过内部分支,实际上也可以提供过程的功能。(原书注)
function extent() { return {val: 0, call: function() { this.val++; console.log("val="+this.val); }}; } f = extent(); // 返回对象 f.call(); // val=1 f.call(); // val=2图 8 通过面向对象来实现
Ruby 的函数对象
到此为止,我们在例子中使用的语言都是 JavaScript,那为什么不用我最擅长的 Ruby 语言呢?下面我来说说理由吧。
最大的一个理由是,Ruby 语言中是没有函数这个概念的。作为纯粹面向对象的语言,Ruby 中的一切过程都是从属于对象的方法,而并不存在独立于对象之外的函数。但是,Ruby 有具备和函数对象相同功能的 Proc(过程)对象,在实际应用上和函数对象的用法是差不多的。不过,这样一来讲解就会变得很麻烦,因此我们便采用了具备简单函数对象功能的 JavaScript。
为了向大家演示一下 Ruby 也能实现和 JavaScript 相同的功能,我们将图 7 的程序用 Ruby 改写了一下,如图 9 所示。
def extent n = 0 # 局部变量 lambda { # 过程对象表达式 n+=1 # 对n的访问 printf "n=%d\n", n } end f = extent(); # 返回函数对象 f.call(); # n = 1 f.call(); # n = 2图 9 Ruby 的变量生存周期
将图 7 和图 9 对比一下,值得注意的是,在 Ruby 中创建过程对象需要使用 lambda{…} 表达式,且调用过程对象不能只加上一对括号,而是必须通过 call 方法进行显式调用。
在 Ruby 1.9 中,为了对函数型编程提供支持,lambda 可以用 -> 表达式来替代,此外 call 方法的调用也可以省略成 f.() 的形式,只不过 f 后面的那个圆点还必须要写,这一点挺遗憾的。
Ruby 与 JavaScript 的区别
从函数这个角度来看,Ruby 和 JavaScript 的区别还是很大的,关于这一点我们来详细说说吧。
正如之前所讲过的,Ruby 中只有方法而没有函数,而过程对象是可以用类似函数的方式来使用的。由于过程对象并不是函数,因此需要调用 call 方法,但除此之外,像闭包等其他语言的函数对象所具备的性质,过程对象也都具备。另一方面,JavaScript 中有函数,自然可以作为对象来引用(图 7)。但是,JavaScript 中方法与函数的区别很模糊,同样一个函数,在作为通常函数调用时,和作为对象的方法调用时,this 的值会发生变化(图 10)。
f = function() { console.log(this); }# 直接调用f f(); # this为global上下文 obj = {foo: f}; # 将f变为方法 # 将f作为方法来调用 obj.foo(); # this为obj图 10 JavaScript 的 this
Lisp-1 与 Lisp-2
Ruby 和 JavaScript 的区别还有一点,那就是访问方法成员的行为方式。例如,假设 Ruby 和 JavaScript 的程序中都有一个名为 obj 的对象,两者都拥有一个名为 m 的方法。这时,同样是访问:
obj.mRuby 和 JavaScript 的行为是有很大差异的。在 Ruby 中,这行代码表示对 m 方法进行无参数调用,而在 JavaScript 中则表示返回实现 m 方法的函数对象,而如果要进行无参数调用的话,括号是不能省略的,如:
obj.m()也就是说,JavaScript 中由圆点所引导的访问代表对属性的引用,将函数作为属性值返回的就是方法,而加上括号就可以对其进行调用。
另一方面,Ruby 中圆点所引导的访问只不过是对方法的调用而已,加不加括号,是不影响方法调用这一行为的。在 Ruby 中,如果要获取实现该方法的过程对象,则需要使用 method 方法(表 1)。
表1 Ruby和JavaScript的方法访问
Ruby
JavaScript
方法调用(无参数)
obj.m
obj.m()
方法调用(有参数)
obj.m(1)
obj.m(1)
方法获取
obj.method(:m)
obj.m
光从这张表来看,会给人一种 JavaScript 整体上比较简洁的印象,而实际上,JavaScript 对获取方法实现这一不会频繁执行的操作,反而赋予了一种较简短的记法,却无法像 Ruby 一样省略方法调用时的括号,因此很难说 JavaScript 的这种模式就一定比较好(当然,这里面也有本作者的私心)。
从整体来看,作为纯粹面向对象的语言,Ruby 将对方法的调用放在中心位置;相对而言,JavaScript 的面向对象功能,是由函数对象这一概念发展而来的。
Python 也采用了和 JavaScript 相同的手法。如果对一种原本并非为面向对象设计的语言添加面向对象功能的话,这是一种十分有效的手法。
类似这样的设计思想的差异,在 Lisp 中早就存在,这两种做法分别叫做 Lisp-1(JavaScript 风格)和 Lisp-2(Ruby 风格)。
在 Lisp 的方言中,Scheme 等是属于 Lisp-1 的,函数和变量的命名空间是相同的。Lisp-1 这个名称,貌似就是从命名空间唯一这一概念而来的。在 Scheme 中,函数就是一个存放对函数对象的引用的变量而已。
因此,可以像这样:
(display "hello world") (define d display) (d "hello world")仅通过赋值操作就可以为函数定义别名。
此外,Lisp 的另一个方言 EmacsLisp 中,变量和函数分别拥有各自的命名空间。如果执行这样的赋值操作:
(echo "hello world") (setq e echo)就会产生一个错误:
undefined variable echo这是由于虽然存在名为 echo 的函数,却不存在名为 echo 的变量。如果要在 EmacsLisp 上实现和上述 Scheme 的例子相同的操作,就需要这样写:
(fset 'e (symbol-function 'echo)) (e "foo")symbol-function 用来通过名称获取函数实体,而 fset 将名称与函数实体进行关联。虽然 Scheme 的风格看上去比较简单,但获取函数实体这种操作,一般人是不会去做的,因此没有必要将这种操作定义得这么简单。当然,用 Lisp 的本来就不是一般人了吧,这一点我们就当没看见吧。
现在大家应该明白了,通过闭包,可以实现更加高度的抽象化。刚才我们介绍了 C、JavaScript、Ruby、Lisp 等各种语言中函数对象的实现手法,希望大家能够通过上面的介绍,对这些语言的设计者在设计语言时的思路有一个大致的理解。
“编程语言的过去、现在和未来”后记
在正文中,我对未来的编程语言进行了预测,认为对云计算和多核的支持是编程语言未来发展的趋势,作为计算的进化方向,让多个计算机(核心)协同工作这一点我认为是毫无疑问的。本书中也对多核环境下的编程(第 6 章“多核时代的编程”),以及在服务器端对多台计算机的编程(第 4 章“云计算时代的编程”)等话题进行了阐述。
然而,谈到编程语言的进化方向,老实说我也是有点雾里看花的感觉。今后到底是出现一种对多核和云计算在设计上就进行积极支持的语言,然后这种语言逐步流行起来呢,还是在现存语言的基础上,以库的形式不断添加对上述环境的支持呢?虽然自诩为编程语言方面的专家,但对我来说这依然是一个很难预测的话题。
例如,Erlang 是一种对并行、分散编程提供积极支持的语言,是由瑞典爱立信公司于 20 世纪 80 年代后半期开始开发的。这种语言的风格,如:
· 受 Prolog 的影响
· 动态,函数型语言
· 单一赋值,无循环
· 基于 Actor 的消息传递
· 高容错性
与以往的语言都有很大差别,但却趁着近来的发展趋势迅速走红。此外,无需显式指定就能够在内部实现并行计算可能性的 Haskell 等语言也值得关注。
但是,尽管 Erlang 和 Haskell 获得了广泛的关注,和当前多核、云计算的发展速度相比,它们的走红也只是一时的。这其中的原因,可能是因为在现有语言上增加一些功能就足够了,不需要全新的语言,也可能是因为 Erlang 和 Haskell 所提供的与以往不同的范式和编程模型,一般的程序员还无法适应。总之,现在这个时点是很难做出判断的。
因此,这个领域在今后还是非常值得关注的。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论