首页 > 代码库 > Ruby之父讲什么是闭包

Ruby之父讲什么是闭包

有一次,我参加了一个叫做“Ruby集训”的活动,那是一个由想学习Ruby的年轻人参加的,历时54夜的Ruby编程学习活动,对参加者来说是一次非常宝贵的经验。第1天是入门培训,第2天将Ruby系统学习一遍,然后第3天到第4天分组各自制作一个相当规模的游戏,并在最后一天进行展示,可以说是一次十分军事化的集训活动。我只到现场去了大概两天,不过那些勇于向高难度课题发起挑战的年轻人还是给我留下了深刻的印象。在那次集训活动中,有一位参加者问:“闭包是什么?”担任讲师的是我的学生,不过他也没有做出准确的理解,因此借这个机会,我想仔细给大家讲一讲关于闭包的话题。

函数对象

有一些编程语言中提供了函数对象这一概念,我知道有些人把这个叫做闭包(Closure),但其实这种理解是不准确的,因为函数对象不一定是闭包。不过话说回来,要理解闭包,首先要理解函数对象,那么我们先从函数对象开始讲起吧。

所谓函数对象,顾名思义,就是作为对象来使用的函数。不过,这里的对象不一定是面向对象中所指的那个对象,而更像是编程语言所操作的数据这个意思。

    例如,C语言中,我们可以获取一个函数的指针,并通过指针间接调用该函数。这就是C语言概念中的对象(代码1)

#include <stdio.h> 
int two(int x) {return x*2;} 
int three(int x) {return x*3;} 
 
int main(int argc, char **argv) 
{ 
    int (*times)(int); 
    int n = 2; 
 
    if (argc == 1) times = two;
    else times = three;
    printf("times(%d) = %d\n", n, times(n));
}

一般的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语言标准库中,却提供了一个具有通用性的排序函数,它的名字叫qsortAPI定义如代码2所示。

void qsort(void *base, size_t nmemb, size_t size,int (*compar)(const void *, const void *));

那么,这个通用排序函数qsort是如何克服上述两个缺点的呢?秘密就隐藏在qsort函数的参数中。

首先,我们来看看第1个参数base,它的类型是void*sort的第1个参数是限定为整数数组的,相比之下,qsort的参数则表示可以接受任何类型的数组。这样就避免了对数组类型的限制。

接下来,第2、第3个参数表示数组的大小。在sort中只传递了数组的大小(元素的数量),而qsort中的第2个参数nmemb表示元素数量,第3个参数size则表示每个元素的大小。这样一来,相对于只能对整数数组进行排序的sort函数来说,qsort则可以对任何数据类型的数组进行排序。

不过,还有一个重要的问题,那就是如何对任意类型数组中的元素进行比较呢?要解决这个问题,就要靠qsort函数的第4个参数compar了。

compar是指向一个带两个参数的函数的指针。这个函数接受数组中两个元素的指针,并以整数的形式返回比较结果。当两个元素相等时,返回0,当ab大时返回正整数,当ab小时返回负整数。

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]);  
    }
}

大家现在应该已经明白了,qsort函数是通过将另一个函数作为参数使用,来实现通用排序功能的。高阶函数这样的方式,通过将一部分处理以函数对象的形式转移到外部,从而实现了算法的通用化。

函数指针的局限

好,关于(C语言的)函数指针以及将其用作参数的高阶函数的强大之处,我们已经讲过了,下面我们来讲讲它的局限吧。

作为例题,我们来设想一下,对结构体构成的链表(Linkedlist)及对遍历处理,用高阶函数来进行抽象化。

代码4是用一般的循环和高阶函数两种方式对链表进行遍历的程序。图4的程序由于C语言性质的缘故显得很长,其本质的部分是从main函数第38行开始的。

从第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语句的输出结果中,可以显示出索引,而fore-ach的部分则只能显示“?”。这是因为和while语句不同,fore-ach的循环实际上是在另一函数中执行的,因此无法从函数中访问位于外部的局部变量i。当然,如果变量i是一个全局变量就不存在这个问题了,不过为了这个目的而使用副作用很大的全局变量也并不是一个好主意。因此,“对外部(局部)变量的访问”是C语言函数指针的最大弱点。

#include <stdio.h> 
#include <stdlib.h>
struct node {                   /* 结构体定义 */ 
    struct node *next;
    int val;
}; 
 
typedef void (*func_t)(int);    /* 函数指针类型 */
 
void                            /* 循环用函数 */
foreach(struct node *list, func_t func)
{
    while (list) {
        func(list->val);
        list = list->next;
    }
}
 
void                            /* 循环主体函数 */
f(int n)
{
    printf("node(?) = %d\n", n);
}
 
main()                          /* main函数 */
{
    struct node *list = 0, *l;
    int i;
                                      /* 准备开始 */
    for (i=0; i<4; i++) {         /* 创建链表 */
        l = malloc(sizeof(struct node));
        l->val = i;
        l->next = list;
        list = l;
    }
 
    i = 0; l = list;              /* 例题主体 */
    while (l) {                   /* while循环 */
        printf("node(%d) = %d\n", i++, l->val);
        l = l->next;
    }
    foreach(list, f);             /* foreach循环 */
}

作用域:变量可见范围

现在我们已经了解了C语言提供的函数指针的缺点,于是,为了克服这些缺点而出现的功能,就是本次的主题——闭包。

我想现在大家已经理解了函数对象,下面我们来讲解一下闭包。话说,要讲解闭包,必须使用一种支持闭包的语言才行,因此在这里我们用JavaScript来讲解。肯定有人会问,为什么不用Ruby呢?关于这一点,我们稍后再讲。

首先,为了帮助大家理解闭包,我们先来介绍两个术语:作用域(Scope)和生存周期(Extent)。

作用域指的是变量的有效范围,也就是某个变量可以被访问的范围。在JavaScript中,保留字var所表示的变量声明所在的最内侧代码块就是作用域的单位(图5),而没有进行显式声明的变量就是全局变量。作用域是嵌套的,因此位于内侧的代码块可以访问以其自身为作用域的变量,以及以外侧代码块为作用域的变量。

另外,大家别忘了创建匿名函数对象的语法。在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++;});

这里值得注意的是,作为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

那么,这个程序实际运行的情况会如何呢?extent()执行后会返回函数对象,我们将其赋值给一个变量。这个函数变量在每次被执行时,局部变量就会被更新,从而输出逐次累加的结果。

咦?这里不觉得有点怪吗?

局部变量n是在extent函数中声明的,而extent函数已经执行完毕了啊。变量脱离了作用域之后不是应该就消失了吗?不过,就这个运行结果来看,即便在函数执行完毕之后,局部变量n貌似还在某个地方继续存活着。

这就是生命周期。也就是说,这个从属于外部作用域中的局部变量,被函数对象给“封闭”在里面了。闭包(Closure)这个词原本就是封闭的意思。被封闭起来的变量的寿命,与封闭它的函数对象寿命相等。也就是说,当封闭这个变量的函数对象不再被访问,被垃圾回收器回收掉时,这个变量的寿命也就同时终结了。

现在大家明白闭包的定义了吧。在函数对象中,将局部变量这一环境封闭起来的结构被称为闭包。因此,C语言的函数指针并不是闭包,JavaScript的函数对象才是闭包。

闭包与面向对象

在代码7的程序中,当函数每次被执行时,作为隐藏上下文的局部变量n就会被引用和更新。也就是说,这意味着函数(过程)与数据结合起来了。

“过程与数据的结合”是形容面向对象中的“对象”时经常使用的表达。对象是在数据中以方法的形式内含了过程,而闭包则是在过程中以环境的形式内含了数据。即,对象和闭包是同一事物的正反两面。所谓同一事物的正反两面,就是说使用其中的一种方式,就可以实现另一种方式能够实现的功能。例如代码7的程序,如果用JavaScript的面向对象功能来实现的话,就成了代码8中的样子。

function extent() {
    return {val: 0,
            call: function() {
                this.val++;
                console.log("val="+this.val);
                }
            };
}
f = extent();                   // 返回对象
f.call();                       // val=1
f.call();                       // val=2


《完》~

Ruby之父讲什么是闭包