引子

每当你在baidu.com里输入"闭包"二字并且按下回车键的时候,都能看到一堆关于"什么是闭包"的解释
百度闭包

让我们来分别看看这些所谓能够让你一篇理解闭包的文章里都讲了些什么,我从上到下来罗列出来其中心思想:

  1. 闭包就是能够读取其他函数内部变量的函数 (百度百科)
  2. 闭包指一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起
  3. 闭包函数:声明在一个函数中的函数,叫做闭包函数;内部函数总是可以访问其所在的外部函数中声明的参数和变量,即使在其外部函数被返回(寿命终结)了之后
  4. 定义在一个函数内部的函数
  5. 存在自由变量的函数就是闭包
  6. 闭包是指有权访问另外一个函数作用域中的变量的函数。可以理解为(能够读取另一个函数作用域的变量的函数)
  7. 包含变量的函数就是闭包,闭包是指可以访问其所在作用域的函数,闭包是指有权访问另一个函数作用域中的变量的函数,闭包是指在函数声明时的作用域以外的地方被调用的函数
  8. 闭包有三个特性:函数嵌套函数,函数内部可以引用函数外部的参数和变量,参数和变量不会被垃圾回收机制回收
  9. 内部函数引用外部函数的参数和局部变量并将其相关参数和变量都保存在返回的函数中,这叫做闭包
  10. 「函数」和「函数内部能访问到的变量」(也叫环境)的总和,就是一个闭包。

这是百度搜索最靠前的十条结果,在这其中仅仅有2(来自MDN),10(来自知乎)这两条的解释是精确且无歧义的,剩下的大部分都称闭包为"可以捕获外部变量的函数",这个定义是有本质上的错误的

可能一般很多人都会觉得闭包在JS里似乎就是这么个概念,是的,你在JS里很多时候的确可以把"捕获变量的匿名函数"和"闭包"混为一谈而且不影响你写代码。但是我觉得对于任何一个学科都要有一种求真的态度,使用体验上类似,不代表他们是一个东西,匿名函数就是匿名函数,捕获外部变量的匿名函数也仅仅就是匿名函数而已,他不是闭包,为了区分这两点,首先我要引入一部分关于λ演算的内容

λ演算简介

我们都知道计算机科学和数学是息息相关的,事实上的确如此,早在大部分人所知道的第一台计算机ENIAC出现(1946)之前,阿隆佐·邱奇就已经提出了λ演算——一种可以模拟任何通用图灵机的计算模型(著名的LISP也是受到λ演算的启发而出现的),在λ演算中有两种很简单的定义1

  • Abstraction(抽象):我们可以抽象出一个表达式,这个表达式中有一个变量,假设表达式是一块拼图,那么这个变量就像是这个拼图中残缺的一片,只有当你补上这一片的时候才能得到一个完整的拼图;如果上述比喻不太好理解,想象一下数学中常见的两个概念:

    1. 函数,对于函数f(x)=x^2,很明显只有当你知道x的值的时候,才能够确定x^2的值,在这个函数里,x^2就像是拼图,而x则是缺少的那一片
    2. 谓词逻辑,我们知道一个谓词逻辑需要变量(个体词)和谓词两部分组成,比如"可以飞起来"本身是一个谓词,我们不能从这个谓词中构造出一个命题,只有当这个谓词和某一个变量结合起来的时候才能够构成一个命题,在这个体系中,整个命题就是拼图,而变量就是缺少的那一片
  • Application(应用): 将一个λ表达式应用在另一个λ表达式上面
  • 如果我们使用λ表达式来表示f(x)=x^2,画风就是这样的\lambda x.x \ast x。这是一个合法的λ表达式,同时他由两部分组成,第一部分是x \ast x,这是一个λ表达式,而\lambda x中的x也是一个λ表达式,这两部分组合起来,前面加上一个希腊字母\lambda,就变成了一个"Abstraction(抽象)",实际上对于λ表达式,有形式化的定义如下(摘录自wikipedia,为保证准确性使用英文原文):

    1. a variable, \displaystyle x, is itself a valid lambda term.
    2. if \displaystyle t is a lambda term, and \displaystyle x is a variable, then {\displaystyle (\lambda x.t)} is a lambda term (called an abstraction).
    3. if \displaystyle t and \displaystyle s are lambda terms, then \displaystyle (t\ s) is a lambda term (called an application).

    我们还要给出β归约的定义:
    \beta-reduction: ((\lambda x.M) E)\rightarrow(M[x:=E])
    β归约实际上就是一步替换,他的意思是把M中所有的x都替换成E。可以很明显的看出,我们之前提到的\lambda x.x \ast x就属于这三条中的Abstraction,而Application相比之下就更类似于函数的调用,比如((\lambda x.x \ast x)\ 2)的意思就是把\displaystyle 2应用在\lambda x.x \ast x上,根据β归约,((\lambda x.x \ast x)\ 2)可以转换为(x \ast x)[x:=2],也就是2*2=4

现在我们通过以上的规则对比一下编程语言中的lambda表达式2,以Kotlin为例,同样是实现\lambda x.x \ast x,在Kotlin中我们需要这样写:

val lambda: (Int) -> Int = it * it

而在运算时该lambda函数对象同样会经过参数的替换与归约过程,比如我们对他进行Application需要写出这样的代码:

lambda(2)

这段代码将会使用2作为实际参数替换形参it,也就是最终会归约为2 * 2,并进一步得出结果4。不要忘了和上面所提到的几条规则比较,通过比较kotlin中的lambda表达式和上文的内容(实际上很多其他语言也是类似的),我们可以很明显的发现,编程语言中的lambda表达式就是邱奇λ演算的抽象,因此我们可以非常自信的说lambda的定义就是λ表达式在编程语言中的抽象,更准确的来说,在大部分语言中,它都以匿名函数的形式存在

λ演算中的闭包

解决了匿名函数的定义问题,我们回过头来看闭包,但是为了讲闭包,我们还需要暂时把目光转回λ演算

绑定变量与自由变量

在λ演算中存在这么两种变量:绑定变量与自由变量。形如\lambda x.x \ast x中的x,也就是Abstraction过程中那个需要在被执行Application的时候替换的变量,我们称其为绑定变量,在\lambda x.x \ast x中,我们除了绑定变量看不到任何其他的变量了,但是考虑这么一个λ表达式:\lambda x.x \ast y,在他其中除了绑定变量x,还有一个变量y,而y并没有被绑定,我们把λ表达式中类似y这种没有被绑定的变量叫做自由变量。类比程序中的未定义标识符会在编译时报错,除非我们给自由变量y一个确切的定义,否则是无法计算表达式\lambda x.x \ast y的值的

闭合λ表达式与非闭合的λ表达式

在引入了绑定变量和自由变量的概念之后,我们可以再往前走一步,把不包含任何自由变量的λ表达式称之为闭合(closed)的λ表达式;而诸如\lambda x.x \ast y这种包含了自由变量的λ表达式称之为非闭合(open)的λ表达式,非闭合的λ表达式需要我们对其中的自由变量提供确切的定义才能够被求值,要注意,虽然你认为这其中只有y一个自由变量,但是实际上如果把这个λ表达式作为一个独立的系统来看的时候,他有2个自由变量:\asty,我们在看的时候可能会因为太过于熟悉而忽视掉\ast,实际上对于这个独立的系统来说,我们并没有提供\ast的确切定义,因此它在这里也是一个自由变量

上下文

上一节中提到,非闭合的λ表达式需要对其中的自由变量提供确切的定义才能求值,我们该如何提供这个定义呢?众所周知,如果我们在程序中未经声明的使用一个变量,那么大部分语言的编译器都会报错,告诉你这是一个"undefined identifier"或者"cannot resolve symbol",解决它们的方法就是去声明这么一个变量,或者从别的地方导入这么一个变量,而我们把能够让这段代码运行的包含了所需的变量的整个环境抽象起来称之为"上下文(context)",同样的,非闭合的λ表达式也需要一个提供了其自由变量定义的上下文才能够被求值吗,如果我们对一个非闭合的λ表达式提供了这样的上下文,那么这个λ表达式就会成为一个闭合的λ表达式

根据上面三小节所分别给出的不同部分的定义,我们可以总结出λ演算中闭包的定义:闭包就是指一个由非闭合的λ表达式以及能使其闭合的上下文所构成的一个集合,注意,这其中的闭包是由两部分组成的,第一是非闭合的λ表达式,第二是能使该表达式闭合的上下文,记住这一个概念,在下文中要用到

编程语言中的闭包

编程语言中的闭包实际上与上文中的λ演算中的闭包的定义非常的类似,但是也有一些不同的地方,其中最主要的就是关于上下文的定义,在λ演算中上下文可以是一个非常抽象的东西,但是在编程语言中这一概念会从不同的角度被具象化,比如λ演算中所提到的"提供自由变量的上下文",在编程语言中可以被分为两种,第一,语言内建的标识符/运算符,比如类似加减乘除这种算术运算符,第二,词法作用域(Lexical Scope)

词法作用域

首先我们看一下什么是作用域,维基百科上是这么说的:"名称绑定x的作用域即为x有效的程序部分,在另一个作用域中名称绑定x可能指向不同的变量",名称绑定在这里可以简单理解为变量,把一个值赋给一个标识符就创建了一个绑定关系,即把值绑定给了一个标识符(名称);这句话的意思可以用代码形象的表示出来:

val x = 2
// access x here will result in x
val a = {
    val x = 3
    // access x here will result in a.x
    val b = {
        // access x here will result in a.b.x
        val k = 5
    }
}
val c = {
    // access x here will result in c.x
    val x = 6
}

现代的大部分语言都使用大括号在一定程度上"可视化"了作用域,在上述例子中,我们可以看到在不同位置访问变量x所得到的结果都是不同的,在最外层只能访问最外层的x,在a中可以访问a.x但是不能访问b.xx(因为a.x覆盖了x),同样在b中能访问b.ka.x,但是不能访问x(覆盖),c.x,而在c中则是类似的(也就是每一层可以访问自己父级的词法作用域,但是父级不能访问子级的词法作用域),我们把这种依据代码的结构来解析的作用域叫做词法作用域(Lexical Scope)(还有另外一种解释方法:如果一个变量仅仅在函数内部可见而外部不可见,那么它的词法作用域就是该函数体),词法作用域中变量的值总是取决于他的词法上下文(Lexical Context),说白了,在上面的例子里任意一个位置的词法上下文就是这个为止所最接近的一组大括号和所有外层大括号里面的东西。

结论:究竟什么是编程语言中的闭包?

知道了词法上下文以后,我们就可以直接直接把之前所学到的λ演算中的闭包的定义套过来,稍微替换一下,记得之前所提到的λ演算中的闭包的组成部分吗?非闭合的λ表达式,和能使该表达式闭合的上下文,在编程语言的闭包定义中,非闭合的λ表达式就是一个捕获了外部变量的函数/匿名函数,能使该表达式闭合的上下文就是该函数所有捕获变量的词法作用域的并集,回过头看最开始的第2条和第10条:"闭包指一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起"与"「函数」和「函数内部能访问到的变量」(也叫环境)的总和,就是一个闭包。",你就知道为什么说这两条是的对的了吧?在看一些有关闭包的"讲解"的时候,你肯定会看到诸如这样的例子来讲闭包的作用:

val counterGen: () -> () -> Int = {
    var x = 0
    { x++ }
}
...
val counter = counterGen()
println(counter()) // outputs 0
println(counter()) // outputs 1
println(counter()) // outputs 2

在了解了闭包究竟是什么以后,这短代码中的闭包就应该是良定义的了:既不是counterGen,也不是{ x++ },而是{ x++ }与其捕获的变量x的词法作用域的集合,如果要表现在源代码上的话,虽然不太精确,但是我们大致可以把它解释为

{
    var x = 0
    { x++ }
}

这一部分,因为这一部分包含了上述所说的两者,同时其本身也是一个合法的能够闭合{ x++ }的词法作用域

尾声

本来在写kotlin协程的文章的时候需要相关的资料,查的时候发现有人在讲的时候提到"闭包"的概念,之前有学过一段时间的非常肤浅的函数式编程所以对这个概念略知一二;看到网上大量讲"闭包就是捕获了变量的匿名函数"这种观点还能得到高票的回答不免有些生气,如果如果有一个人看见并且相信了这个答案,那么他就是对一个人的误导,如果有两人看到并相信了,那么就是对两个人的误导;"千丈之堤,以蝼蚁之穴溃,百尺之室,以突隙之烟焚",如果这种观点传的越来越广,谬误最终也会变成部分人眼中的真理,并且继续传播给其他不懂这些概念的人,最终形成一个恶性循环。我对于函数式编程与邱奇的λ演算的了解仅是冰山一角,九牛一毛,遇见不确定的概念的时候,去认真的查阅资料之后再发表意见算是理所应当的事情,但是许多自认为已经"掌握"了函数式编程的各种定义的人,还是希望在学习和发表意见的时候多多查证,而不是简单地"凭经验"发表,最终导致自己的高票错误回答被大量的人所信服。不过,在关于"闭包"的这个话题上,依据我今天写的时候查找到的问题上答主的说法,最大的问题出在各个语言的官方,好比kotlin的协程其实和线程完全不是一个概念,但是官方却还是拿它和线程比较;很多语言的官方选择去用"闭包"描述该语言提供的匿名函数和高阶函数的语法特性,实际上这些"匿名函数"和"高阶函数"和闭包一点关系都没有,真正的闭包是存在于编译期的,比如C#的一个函数中如果出现了lambda表达式,那么这个函数里的所有lambda表达式都会被转换成类似<函数名>b__<第n个lambda表达式>的函数并且连同捕获的变量一起存放到一个CLI生成的形如<>c__DisplayClass2_0的类里,然后构造出Delegate3,在这个过程中生成的<>c__DisplayClass2_0这个类才能被叫做闭包,而lambda本身与闭包并无关系,不少官方的这些做法在一定程度上大大促进了这些概念的混淆程度。
总而言之,我相信在任何事情上,求实求真的态度都是正确而且应当被发扬的,尤其是在自己要把知识传递给其他人的时候,更应当审慎的对待,切莫因为"概念不复杂"和"自己不需要负责"就去误导别人乃至自己,这么做不仅无法给予到别人任何帮助,甚至可能还会更深的误导自己,让自己在某些概念上同样陷入怪圈。因此我今天才翻了不少资料与wikipedia词条来写这篇文章,共勉。


  1. 更加具体的内容详见Wikipedia ↩︎
  2. 如无特指,下文中所有的"lambda表达式"都指编程语言中的lambda表达式,而"λ表达式"指λ演算中的λ表达式 ↩︎
  3. 有很多语言都是这么实现的,因为函数的本地变量存在于栈帧之上,随着栈帧的释放而销毁,因此创建一个新的数据结构以保存这些变量成为了延长变量生存周期的好方法 ↩︎

行成于思,毁于随