My Profile Photo

wlnirvana


Youth is not a time of life, it is a state of mind.


Closure

学习函数式编程、程序语言理论以及不少现代编程语言,一个绕不过去的概念就是closure。而closure又和高阶函数、lexical scope等相互关联、甚至纠缠不清,导致一些初学者不易快速理解。那么究竟什么才是closure呢?以下是我自己的一些学习笔记。

TL;DR

closure一点也没什么神秘的,(几乎)就是函数!唯一一点不同,closure的函数体里允许出现自由变量。可这样一来函数调用就遇到了麻烦:自由变量该取什么值来参与计算呢?为了解决这个问题,closure需要一个变量表,一般称之为环境。具体是哪个环境又是一个新问题,但暂且按下不表。总而言之,closure变成了一个二元组(f, env),也就是一个传统的函数f加上一个环境env。在env中找到自由变量的值,f就可以进行运算了。

例子

先来看一个典型的函数式语言中的closure。下面是一段Scheme代码:

(define (make-adder x)
    (lambda (y) (+ x y)))

(define add-ten (make-adder 10))        ;; <-- the closure add-ten

;; (add-ten 1)
;; -> 11

在这个例子中,add-ten就是一个closure。如果把add-ten看作一个函数,会发现它的只有一个参数y,可是它的函数体(+ x y)里却用到了变量x。需要指出的是,Scheme中lambda表达式的“函数体”部分直到应用这个lambda表达式时才会被求值。通俗点讲,就是说虽然定义好了add-ten,但它只是(+ x y)而非(+ 10 y)。只有进行类似(add-ten 1)这样的函数调用时,解释器才会去查找x的值。到哪去找呢?答案就是add-ten作为一个closure所包含的那个环境。

再来看个JavaScript的例子:

$(function() {
    var answer = 0;
    $("#increment").click(

        function() {                                   // <-- anonymous closure
            var message = "the latest answer is ";     // <-- anonymous closure
            answer++;                                  // <-- anonymous closure
            alert(message + answer);                   // <-- anonymous closure
        }

    );
});

代码用到了jQuery库,所做的大体是为increment按钮添加一个监听函数,当按钮被点击时,就将答案加1并弹出一个对话框告知用户。添加的那个函数其实就是一个closure:用到了变量answer,而answer并不是函数的形参,需要到环境中去查找。

环境,或者说是scope

对于究竟什么是closure我们应该已经有了一个直观的感受,现在是时候回去解决“按下不表”的环境问题了。一个closure的函数部分往往很容易找到,毕竟函数名(也就是closure名)、形参列表、函数体都一般紧挨着嘛。可是closure的环境该如何确定呢?

环境的概念听起来简单,就是一张储存了变量名与变量值对应关系的表格。但对大多数程序员而言,这是个颇有些虚无缥缈的概念:想想自己平常写代码,即便是函数式风格的代码,我们什么时候关心过表达式求值的环境吗?恐怕是没有。这是为什么呢?原来,环境更多的是程序在编译和运行时用到的一个工具,对程序员几乎是不可见的。但当closure的函数体里有自由变量时,环境的选择就变得重要了。

在使用closure时,为了求值自由变量,大体上有两个环境可供选择:函数定义的环境与函数执行时的环境。以下面的代码为例

(define one 1)                      ; <-- should we use this one?

(define (add-one x)
    (display (+ x one))
    (newline))

(define (interesting-add-one x)
    (define one -1)                 ; <-- should we use this one?
    (add-one x))

(interesting-add-one 0)

最后一行调用了(interesting-add-one 0),会返回多少呢?第一反应可能是1,因为add-one的作用就是把xone相加,x0one1,结果自然是1。可是真的只有这一种可能吗?如果我们仔细观察interesting-add-one的执行过程,会发现在它调用add-one之前,刚刚为one绑定了一个新的值-1。按照这个新的绑定,最后返回的结果就应该是-1了!

一般而言,程序语言研究者把前一种语义称为lexical scope,后一种称为dynamic scope。应该说,这两种语义的选择是个开放的问题,没有绝对的对与错。但dynamic scope会导致不少违反直觉的结果,所以绝大多数现在的编程语言都选择了lexical scope。

稍微扯一点词源上的八卦。dynamic scope还好理解,但我个人一直对lexical这个字眼有些困惑。一方面,从dynamic来说,和它相对的不应该是static吗?另一方面,从lexical来说,最直接相关的使用应该是编译器的词法分析(lexical analysis)了吧,可词法分析还远远决定不了变量的scope。为什么最后选择了lexical这个词呢?

根据维基百科,lexical scope最早可以追溯到1967年LISP 2的设计文档。我简单翻看了这篇文档,貌似也是直接给出了定义,而没有解释对lexical这个词的选择。看来要成为PLT历史未解之谜了,有点遗憾:)

closure的scope

扯了这么多,跟closure究竟有啥关系呢?主要是我曾经受了这个页面的误导,以为closure还有不同的种类,比如所谓“dynamic closure”,结果迷惑了好一阵子。事实上,根据Moses(1970)的解释,术语closure从诞生之日起所用的就是lexical scope:

FUNCTION acts as a closed or nonporous covering (hence the term “closure” used by Landin)

所以,从scope回到环境,closure包含的总是函数定义时的环境。至于所谓的“dynamic closure”,在我看来,纯粹是因为LISP 1.5中用define绑定的普通lambda表达式压根就不是closure,而是简单的字符串替换而已。

为什么要用closure

明白了什么是closure,自然而然就会想问,closure究竟比普通函数好在哪里?为什么有的语言提供了closure而有的却没有?在那些支持closure的语言里,什么情况下应该使用closure?

坦白讲,对这些问题我也还没有找到一个系统的、清晰的答案。不过,上面的两个例子似乎刚好体现了closure常见的两种用法。

在Scheme的例子中,由于lambda表达式求值的结果是一个绑定了变量x的closure,我们可以利用这个特性轻易地创造出功能相近的函数,从而减少重复代码。例如:

(define add-5 (make-adder 5))
(define add-7 (make-adder 7))
(define sub-1 (make-adder -1))
;; ...

如果是在类似C那种不能随意创造closure的语言中,我们可能真的要写好多遍加法表达式了。

而JavaScript的例子里,closure起到了很好的封装效果:变量answer其实成为了click匿名监听函数的一个私有变量,外界无法直接使用它。但和局部变量message又有所不同,answer贯穿在匿名函数的多次调用中,并不会每次都分配一个新的answer

熟悉C/C++/Java语言的朋友,有没有嗅出一点static的味道?没错,其实C语言中,有static变量的函数完全可以被看作一个closure。事实上,不仅是C,closure的“变种”早已渗透到当今各种语言中了。

广义的closure

从更抽象的层面看待前面的分析,其实closure无非就是封装在一起的代码+状态;只不过状态对于外界是不可见的,有点closed的意思。可变的状态加上固定的代码,就能演化出各种不同功能。Scheme支持高阶函数,于是同样的代码配上不同的状态(变量绑定),closure就可以轻松构造功能类似的函数。JavaScript的例子没有用到高阶函数,代码与状态的互动在于前者改变、读取后者,实现了一个有“记忆”功能的函数。遵循这种思路,如上文所言,我们完全可以用static在C中实现一个closure,例如下面这段往银行账户存钱的代码:

int deposit(int val) {
    static int balance = 0;

    balance += val;
    printf("the current balance is %d\n", balance);
}

事实上,在现代编程实践当中,通过static变量来实现银行账户已经过时了。更流行的建模方式乃是OOP,例如下面的C++代码:

class Account {
private:
    int balance;
public:
    Account() { balance = 0; }

    void deposit(int val) {
        balance += val;
        printf("the current balance is %d\n", balance);
    }
}

封装在一起的状态balance和代码deposit,难道不也是closure吗?

历史

在查资料的时候,我心中一直有个疑问:为什么代码+状态,或者是狭义的函数+环境,会被叫做closure呢?下了不小的一番功夫,才有了个大概的眉目。

从词源学上讲,closure作为函数+环境含义的出现,似乎最早可追溯到Landin于1964年发表的The mechanical evaluation of expressions。为啥要叫做closure他没仔细讲,反倒是上面引用过的Moses文章做了些解释,大致因为是函数中的自由变量已经被封住了不能“外逃”。不过Landin提到了

Closures are roughly the same as McCarthy’s “FUNARG” lists and Dijkstra’s PARD’s

PARD's是什么我没有仔细研究,而这里的FUNARG指是LISP 1.5。由于LISP 1.5是被实现了的的语言,所以应该大致可以认为,closure的本体比closure这个术语的出现还要早一些,起码可以追溯到大约1962年的LISP 1.5。

在物理实现之前,有没有谁做过关于closure理论上的探索呢?我没有详细地梳理文献。考虑到LISP的基础是λ演算,我粗略地扫了一眼Church (1941)最初的论文。果不其然,其中递归地定义何为“自由变量”的部分,其实已经暗含了closure的思想。

总结一下,closure一词由Landin引入,最初指函数及其定义时的lexical environment。以此为分界,更早的史前阶段,λ演算中已经有了closure的影子,LISP 1.5中更是给出了一种实现;此后的发展中,基本上所有的函数式语言都提供了对closure的支持;而在更广泛的意义上,OOP的对象也可理解为一种closure。

comments powered by Disqus