第二章:闭包
2.1 面向闭包编程
我们得到的一个结论是:在编程语言中,“对象” 没必要作为原语;
只需用可赋值单元和好的老式的 lambda 表达式就可以构建对象及其行为。
—— Guy Steele 在设计 Scheme 时说的话
这种 lambda 表达式有时叫做 闭包 (1) ,有时叫做已保存的词法环境。或者,我们更喜欢的叫法, let over lambda 。无论怎么称呼,掌握闭包的概念是成为专业 lisp 程序员的第一步。事实上,这项技能对于正确使用许多现代编程语言至关重要,即便是那些没有明确包含 let 或 lambda 的语言,例如 Perl 或 Javascript 。
Note
(1) 闭包 closure
闭包是少有的奇怪概念之一,因为它们太简单了,而与之自相矛盾的是,掌握起来很难。一旦程序员习惯了用复杂的方法来解决问题时,用简单的方法解决同样的问题时就会感到不完整和不舒服。但是,我们很快就会看到,对于如何组织数据和代码,闭包比对象更简单、更直接。比简单更重要的是,闭包是在构造宏时的更好的抽象——这也是本书的主题。
我们可以使用闭包原语构建对象和类,这一事实并不意味着对于 lisp 程序员来说,对象系统毫无用处。恰恰相反。事实上, COMMON LISP 具有有史以来最强大的对象系统: CLOS ,即 COMMON LISP 对象系统。虽然我对 CLOS 的灵活性和特性印象深刻,但我很少发现需要使用其更高级的特性 【1】 ,这要归功于可赋值的储值空间单元和好的旧有的 lambda 表达式。
Hint
【1】 CLOS 对COMMON LISP 如此重要,以至于在 COMMON LISP 中没有它几乎不可能编程。
虽然本书的大部分内容都假定你具有合理的 lisp 技能水平,但本章试图从最基础的角度教授闭包的理论和使用,并提供将在本书其余部分中使用的闭包通用术语。本章还检查了闭包的效率影响,并考虑了现代编译器对它们的优化程度。
2.2 环境及拓展
Guy Steele 所说的可赋值单元的意思是一种用于存储指向数据的指针的环境,该环境受制于被 叫做 不限定范围 (2)的事物。这是一种花哨的说法,我们可以在未来的任何时候继续参照这一环 境。一旦分配了这个环境,它和它的引用在需要用到时就会一直存在。考虑以下的 C 语言函数:
Note
(2) 不限定范围 indfinite extent.
1#include <stdlib.h>
2
3int *environment_with_indefinite_extent(int input) {
4 int *a = malloc(sizeof(int));
5 *a = input;
6 return a;
7}
在我们调用这个函数并接收到它返回的指针之后,我们(就)可以继续无限期地引用分配的内存。在 C 语言中,调用函数时会创建新环境,但 C 语言程序员知道在将所需的内存返回给函数外使用时 malloc() 。
相比之下,下面的例子是有缺陷的。 C 语言程序员认为在函数返回时会自动回收 a ,因为环境是在 堆栈 (3.1) 上分配的。换句话说,从 lisp 程序员的角度来看, a 被分配了 临时范围 (3.2)。
Note
(3) 堆栈 stack; 临时范围 temporary extent.
1int *environment_with_temporary_extent(int input) {
2 int a = input;
3 return &a;
4}
C 语言环境和 lisp 环境之间的区别在于,除非在 lisp 中显式定义,否则它总是假定你意图使用不 限定范围。换句话说, lisp 总是像上面那样调用 malloc() 。可以肯定,这本质上比使用临时范围效率低,但收益几乎总是超过边际性能成本。更重要的是, lisp 通常可以确定何时能安全地在堆栈上分配数据,并且会自动执行此操作。甚至可以使用 声明 (4) 来告诉 lisp 显式地执行此操作。我们将在 第七章:宏的效率 中更详细地讨论如何声明。
Note
(4) 声明 declarations
但是由于 lisp 的动态特性,它不像 C 语言那样具有显式的指针值或类型。如果你作为 C 语言程序员,习惯于 映射指针和值来指示类型,这可能会令人困惑。Lisp 对这一切的看法略有不同。在 lisp 中,一个方便的口头禅如下:
变量无类型,仅仅值有类型
(5)
Note
(5) Variables don’t have types. Only values have types
所以,必须返回某个结构保存指针。在 lisp 中有许多可以存储指针的数据结构。 lisp 程序员 最喜欢的一种结构是种简单的结构: cons 单元 (6) 。每个 cons 结构格恰好包含两个指针,亲切地称为 car 和 cdr 。当 environment-with-indefinite-extent 被调用时,将返回一个 cons 结构,其中 car 指向作为 input 输入的内容,而 cdr 指向 nil 。而且,最重要的是,这个 cons 结构(以及指向输入的指针)具有不限定范围,因此可以在需要时继续引用它:
Note
(6) cons 单元 cons cell
1(defun environment-with-indefinite-extent (input)
2 (cons input nil))
随着 lisp 最新编译技术水平的提高,不限定范围的效率劣势正在接近无关紧要。环境和范围与闭包密切相关,本章将详细介绍它们。
2.3 词法作用域与动态作用域
变量作用范围叫做 作用域 (7.1) 。现代的编程语言中比较通用的作用域是—— 词法 (7.2) 作用域。 当一段代码被一个变量的词法绑定包围时,那么该变量就被称为是处于绑定的词法作用域中。最 为通用的创建绑定的的关键词 —— let ,可以来介绍这些词法作用域中的变量:
Note
(7) 作用域 scope; 词法 lexical
1* (let ((x 2))
2 x)
3
42
上面 let 形式体中的变量 x 就是通过词法作用域来访问的。同样的,由 lambda 和 defun 定义的函数的形参也是函数定义文本中的词法绑定变量。词法变量是只能通过上下 文语境中出现的代码来访问,比如说上面的 let 形式体。由于词法作用域是这样一种限定变量 访问范围的、符合直觉的方式,所以词法作用域看起来似乎是唯一选择。那么是否还有其他界定范围的可 能性?
尽管不确定范围和词法作用域的组合非常有用,但直到现在它还没有在主流编程语言中得到很好 的应用。它第一次是由 Steve Russell 设计的 Lisp 1.5实现的, 随后直接设计成了 Algol60 、 Scheme 和 Common Lisp 。尽管有着悠久而丰富的历史,词法作用域的许 多优点也仅仅被其他的许多 Blub 语言缓慢地吸收。
尽管由 C 语言一类的语言提供的作用域方法是有限制的, C 语言一类的编程语言的程序员仍需要进 行跨越不同环境进行编程。为了做到这点,他们通常会用 作用域指针 (8) 来定义粗略的的作用范 围。作用域指针以调试困难,安全风险高以及某些人为原因的低效率而知名。指针作用域背后的 原理是通过定义一种特定域领语言,用来控制冯诺伊曼式计算机(现在大部分的 CPU[PAIP- PIX] )中的寄存器和内存,然后使用这种语言,通过对正在运行程序的 CPU 的相当直接的命令,来访问和操作数据结构。在没有较好的 lisp 编译器之前,如果需要考虑性能的话,指针作用域是很有必要的,但如今却成了现代编程语言的问题,而不是特性。
Note
(8) 作用域指针 pointer scope
尽管 lisp 程序员很少考虑指针,但有关指针作用域的理解对构造高效的 lisp 代码是很有有价值的。在 7.4 指针作用域 中,我们将会研究在某些需要通知编译器生成特定的代码的特殊情况中实现指针作用域。目前我们 只需要讨论它的机制。在 C 语言中,有时需要访问定义在函数之外的变量,如 pointer_scope_test 函数:
1#include <stdio.h>
2
3void pointer_scope_test() {
4 int a;
5 scanf("%d", &a);
6}
在上面的函数中,我们使用 C 语言中的 & 操作符将本地变量 a 在内存中的实际地址提供给 scanf 函数,这样 scanf 就知道将输入的数据写到哪里。 lisp 中的词法作用域禁止我们直接实 现这种操作。在 lisp 中,我们可能会将一个匿名函数传递给假设的 scanf 函数,其中 scanf 可以对词法变量 a 进行赋值,即便 scanf 定义在词法作用域外:
1(let (a)
2 (scanf "%d" (lambda (v) (setf a v))))
词法作用域是使闭包能够可行的特性。实际上,词法作用域通常更具体地被叫做 词法闭包 (9) ,用来区分于别的闭包,就是因 为这两者概念很相近。除特别注明外,书中所说的闭包都是词法闭包。
Note
(9) 词法闭包 lexical closures
除词法作用域外, COMMON LISP 还有 动态作用域 (10.1) 。动态作用域是 lisp 方言中临时作用域和全局作用域的 组合。动态作用域是 lisp 特有的作用域类型,它提供了一种和词法作用域非常不同的表现方式,但却共享一个完全相同的语法。在 COMMON LISP 中,我们特意通过称呼他们为 特殊变量 (10.2) 来提醒(阅读代码的人)注意变量会使用动态作用域。这些特殊的变量可以用 defvar 定义。一 些程序员遵循用星号作为前缀和后缀的特殊变量名的约定,比如 *temp-special* 。这被叫做 耳罩 (10.3) 约定。基于 3.7 语法二义性 (33) 中阐述的原因,本书不会采用耳罩式命名约定, 因此特殊变量声明如下:
Note
(10) 动态作用域 dynamic scope;特殊变量 special variables;耳罩 earmuff
1(defvar temp-special)
当这样定义时,temp-special 就被指明是一个特殊 【2】 变量,但没有用值进行初始化。在这种情况下,一个特殊变量被称为 没有被绑定的 。只有全局变量可以不被绑定——词法 变量总是被绑定的,因此总是具有值。另一种思考方式是,默认情况下,所有符号都表示是词汇上未绑定的变量。与词 法变量一样,我们可以使用 *setq* ** 或 ***setf* 为特殊变量赋值。有些 Lisp ,如 Scheme ,没有动态作用域。其他的,如 EuLisp [SMALL-PIECES-P46],使用不同的语法来访问词法变量和全局变量。但在 Common Lisp 中,词法变量和特殊变量的语法是共享的。许多 lisper 认为这是一个特性。以下是给特殊变量 temp-special 赋值:
Hint
【2】 我们还可以通过使用“声明”使变量局部特殊,来表示变量的特殊性
Note
(11) 没有被绑定的 unbound 动态作用域 dynamic scope;特殊变量 special variables;耳罩 earmuff
1(setq temp-special 1)
目前看起来,这个特殊的变量看不是那么的特殊。似乎就是其他的变量,在某些全局的命名空间中绑定了。这是因为我们只对它进行了一次的绑定——默认的特殊的全局绑定。特殊变量当它在新的环境中被重新绑定或 覆盖 (12) 时就会更有趣了。假设现在定义一个简单的函数,该函数简单求值并返回 temp-special :
Note
(12) 覆盖 shadowed
1(defun temp-special-returner ()
2 temp-special)
当被调用的时刻,这个函数可以被用来检查lisp求解 temp-special 时的值。
1* (temp-special-returner)
21
这有时被称作在 空词法作环境 (13) 中求值。空词法环境显 然不包含任何词法绑定。在这里 temp-sepcial 变量返回的是它全局变量的值——1。但是如果我们在非空词法环 境中(一个包含对我们特殊变量的绑定)对其求值, temp-sepcial 的特殊性就显现出来了 【3】 。
Hint
【3】 因为当创建一个动态绑定时,并没有实质创建一个词法的环境,看起来就是如此
Note
(13) 空词法作环境 null lexical environment
1* (let ((temp-special 2))
2 (temp-special-returner))
3
42
以上执行结果返回的是 2,这代表着 emp-special 绑定的是 let 作用域中的值,而不是全局特殊的值。如果这还不够有趣的话,看看这段 Blub 伪代码,就知道在大多数其他传统编程语言中是如何无法做到这一点的:
1int global_var = 0;
2
3function whatever() {
4 int global_var = 1;
5 do_stuff_that_uses_global_var();
6}
7
8function do_stuff_that_uses_global_var() {
9 // global_var is 0
10}
虽然内存位置或词法绑定的寄存器分配在编译时 【4】 是已知的,但在某种意义上,特殊变量绑定是在运行时确定的。由于一个巧妙的技巧,特殊变量并不是看起来那么 低效。特殊变量实际上总是指向内存中的相同位置。在用 let 绑定全局变量时,实际上是在编译代 码,这些代码将会存储变量的副本,用一个新值覆盖内存位置,在 let 主体中对形式体求值,最后从副本 中恢复原始值。
Hint
【4】 这也是词法作用域有时也被叫做静态作用域的原因
特殊变量总是与命名它的符号相关联。特殊变量指向的内存中的位置被叫做符号的 symbol-value 单元格。这与词法 变量形成了直接的对比。词法变量仅在编译时用符号表示。因为词法变量只能从其绑定的词法范围内访问,所 以编译器甚至没有理由记住用来引用词法变量的符号,因此编译器会在编译后的代码中删除这些符号。我们将 在 6.7 潘多拉宏 中来详细的证明这一点。
尽管 COMMON LISP 的确是提供了动态范围的宝贵特性,但是词法变量是最常见的。动态作用域曾经是 lisp 中的一个定义 的特性,但在 Common Lisp 之后,动态作用域几乎完全被词法作用域取代。因为词法作用域支持词法闭包 (稍后我们将对此进行讨论)以及更有效的编译器优化,所以动态作用域的取代通常被视为一件好事。然而, COMMON LISP 的设计者给我们留下了一个非常透明的窗口,让我们可以看到动态作用域的世界,现在承认它真的:特殊。
2.4 Let 是 Lambda (14)
Note
(14) 原标题为:Let It Be Lambda。有的不翻译直接作为标题。直译可以是“让它成为 lambda ”。个人理解翻译成“ Let 是 Lambda ”更合适,因为在一些没有 let 操作符的 lisp 方言中,可以使用 lambda 自定义 let 以供使用。
Let 是 Lisp 中特殊的形式,用于创建一个环境,将(符号)名称(绑定)初始化到相应形式体的求解结果。这些(符号)名称对于 let 主体中的代码是可用的,同时连续求解它的形式体,返回形式体中最后一个形式的结果。虽然 let 做了什么很明确, 但具体是怎么做的却没指明。 let 做什么与如何做是分开的。出于某种原因, let 需要提供一个数 据结构来存储指向值的指针。
正如前面所见, cons 的结构适用于存储指针,这点毋庸置疑。同时,还有很多其他的结构可以用来 存储指针。在 lisp 中存储指针的最佳方法之一就是使用 let 结构。在 let 结构中,你只需 要给指针命名就好,之后 lisp 会找出怎样最好地存储指针。有时,可以通过声明的形式提供额外的信 息给编译器,用来提高编译器的效率,如下代码所示:
1(defun register-allocated-fixnum ()
2 (declare (optimize (speed 3) (safety 0)))
3 (let ((acc 0))
4 (loop for i from 1 to 100 do
5 (incf (the fixnum acc)
6 (the fixnum i)))
7 acc))
例如,在 register-allocated-fixnum 中,我们向编译器提供了一些提示,让其可以高效地将 1 到 100 的整数相加。编译后,此函数将在寄存器中分配数据,完全不需要指针。尽管我们似乎已经要求 lisp 创建一个不限定范围的环境来保存 acc 和 i ,但 lisp 编译器将能够通过仅将值存储在 CPU 寄存器中来优化此函数。结果可能是以下的机器代码:
1; 090CEB52: 31C9 XOR ECX, ECX
2; 54: B804000000 MOV EAX, 4
3; 59: EB05 JMP L1
4; 5B: L0: 01C1 ADD ECX, EAX
5; 5D: 83C004 ADD EAX, 4
6; 60: L1: 3D90010000 CMP EAX, 400
7; 65: 7EF4 JLE L0
注意,地址 4 中存储的是 1 , 400 中存储的是 100 ,因为在编译后的代码中, fixnums 移动了两位。这与 标记 (15) 有关,这是一种假装某些东西是指针但实际上在其中存储数据的方 法。 lisp 编译器的标记方案有一个很好的好处,即不需要发生移位来索引字对齐的内存 [DESIGN-OF-CMUCL] 。 我们将在 第七章:宏的效率 中深入了解 lisp 编译器。
Note
(15) 标记 tagging
但是,如果 lisp 确定之后可能要引用此环境,则它必须使用比寄存器更短暂的东西。在环境中存储指针的 常见结构是一个数组。如果每个环境都有一个数组,并且包含在那个环境中的所有变量引用都只是对这个数组的引用, 那么就有一个具有潜在不限定范围的高效环境。
如上所述, let 将返回其主体中最后一个条语句执行的结果。这对很多 lisp 特殊形式和宏来说很常 见,因此这种模式通常被称为 隐式 progn (16) ,因为 progn 特殊形式就是为此目的设计的 【5】 。有时让 let 形式最有价值的地方是它返回一个匿名函数,这利用了 let 形式提供的词法环 境。为了在 lisp 中创建这些(匿名)函数,就要用到 lambda 。
Hint
【5】 实际上, Progn 对于集群形式体也很有用,可以为它们提供所有顶层行为
Note
(16) 隐式 progn :implicit progn
Lambda 是一个简单的概念,但因其灵活性和重要性而令人生畏。 lisp 和 scheme 的 lambda 源于 Alonzo Church 的逻辑系统,但已经发展并适应成自己的 lisp 规范。 Lambda 是一种简洁的方式,可 以重复地分配临时名称(或绑定)给到其值,这些值是其特定词法上下文的值,并且 Lambda 是构成 lisp 函数概念的基础。 lisp 函数与 Church 心目中的数学函数描述非常不同。这是因为 lambda 在几代 lisp 程序员的手中已经发展成为一种 强大而实用的工具,扩展它已经远远超出了早期逻辑学家所能预见的范围。
尽管 lisp 程序员对 lambda 表示敬意,但该助记符号本身并没有什么特别之处。正如我们即将看到的, lambda 只是用来表达这种变量命名的许多可能方式之一。特别是,我们将看到宏允许我们以在其他编程语言实际上不 可能的方式,来对变量的重命名进行自定义。但是在探索了这一点之后,我们将回到 lambda ,并发现 lambda 是非常接近于表达 这类命名的最佳符号。这绝非偶然。 Church 在我们的现代编程环境中可能看起来过时且无关紧要,但他确 实在做某事。他的数学符号,以及它在几代 lisp 专业人士手中的众多改进,已经发展成为一种灵活、通用 的工具 【6】 。
Hint
【6】 宏的经典示例是用 lambda 形式体实现 let ,本书中,我不会用这些内容来惹烦你。
Lambda 非常有用,就像许多 lisp 的特性一样,大多数现代语言都开始将 lisp 的思想导入到自己的系 统中。一些语言设计者觉得 lambda 太冗长,而使用 fn 或其他一些缩写。另一方面,有些人认为 lambda 是个很基本的概念,以至于用较小的名称来掩盖它几乎是异端邪说。在本书中,虽然我们将描述和探索 lambda 的许多变体,但我们很高兴地称它为 lambda ,就像我们之前的几代 lisp 程序员一样。
但是 lisp 的 lambda 是什么? 首先,与 lisp 中的所有名称一样, lambda 是个 符号 (17.1) 。我们可以引用 它,比较它,将它存储在列表中。 Lambda 仅在作为列表的第一个元素出现时才具有特殊含义。当它出现在 那里时,该列表被称为 lambda 形式体 (17.2) 或 函数指示符 (17.3) 。但是这种结构不是函数。这种结构是一个列表数据结 构,可以用 function 关键词将其转换为函数:
Note
(17) 符号 symbol;lambda 形式体: lambda form ;函数指示符 function designator
1* (function '(lambda (x) (+ 1 x))) ;;译者注:此代码在 sbcl 中应为: (function (lambda (x) (+ 1 x)))
2
3#<Interpreted Function>
COMMON LISP 使用 #’ (井号加单引号)读取宏为我们提供了一个方便的快捷方式。为了达到同样的效果, 可以使用这个快捷方式,而不是像上面那样写出 function 操作符:
1* #'(lambda (x) (+ 1 x))
2
3#<Interpreted Function>
作为一个很方便的特性,lambda 也被定义为一个宏,它扩展为对上述特殊形式的函数的调用。COMMON LISP ANSI 标准需要 [ANSI-CL-ISO-COMPATIBILITY] 一个 lambda 宏,定义如下:
1(defmacro lambda (&whole form &rest body)
2 (declare (ignore body))
3 `#',form) ; 译者注:因 lisp 的防止系统代码被篡改机制, lambda 符号被锁定,上述代码编译时会报错,需要在出错调试时选择 ignore 。
目前先忽略 ignore 声明处的具体代码 【7】 。这个宏只是一种将函数特殊形式自动应用于函数指示符的简单方法。这个宏允许我们 执行函数指示符以创建函数,因为它们被展开成 #’ 形式:
Hint
【7】 一个 U-语言 声明
1* (lambda (x) (+ 1 x))
2
3#<Interpreted Function>
因为 lambda 宏,几乎没有理由在 lambda 结构前面加上 #’ 。因为本书没有努力支持 ANSI 之前的 COMMON LISP 环境,所以很容易有理由拒绝向后兼容。但是风格方面的反对观点呢? Paul Graham 在 ANSI COMMON LISP[GRAHAM-ANSI-CL] 中认为这个宏,连同它的简洁优点,“充其量是一种似是而非的优雅”。Graham 的反对意见似乎是,由于仍然需要对符号引用的函数进行 #’ ,因此系统似乎是不对称的。但 是,我相信不带有 #’ 的 lambda 结构实际上是一种风格上的改进,因为它突出了第二个命名空间规范中存在 的不对称性。对符号使用 #’ 是为了引用第二个命名空间,而由 lambda 形式创建的函数当然是没有函数名称 的。
甚至无需调用 lambda 宏,我们就可以用 lambda 结构作为函数调用中的第一个参数。就像在这个位置找 到一个符号且 lisp 假设我们正在引用该符号的 symbol-function 结构一样,如果找到 lambda 结构,则假设它表示一个匿名函数:
1* ((lambda (x) (+ 1 x)) 2)
2
33
但注意,正如不能调用函数来动态返回要在常规函数调用中使用的符号一样,也不能在一个函数位置调用函数以返 回一个 lambda 形式体。对于这两种情况,需要使用 funcall 或 apply 。
lambda 表达式在很大程度上与 C 语言和其他语言中的函数无关,它的好处是 lisp 编译器通常可以将它们完 全优化为不存在。例如,尽管 compiler-test 看起来像是对 2 应用了一个递增函数并返回结 果,但一个像样的编译器会足够聪明到知道该函数总是返回 3 ,且会仅仅直接返回该数字,该过程中不会调用函数。这叫 做 lambda 折叠 (18) :
Note
(18) lambda 折叠 lambda folding
1(defun compiler-test ()
2 (funcall
3 (lambda (x) (+ 1 x))
4 2))
一个重要的有效观察是:一个编译后的 lambda 结构是个常量结构。这意味着在程序编译后,对该函数的所有引用都 只是仅仅指向一块机器代码的指针。该指针可以从函数返回并嵌入到新环境中,所有这些都没有函数创建开销。在编 译程序时吸收了开销。换句话说,返回另一个函数的函数将仅仅是个常量时间指针返回函数:
1(defun lambda-returner ()
2 (lambda (x) (+ 1 x)))
这与 let 结构形成鲜明对比,后者旨在在运行时创建一个新的环境,因此通常不是个恒定的操作,因为 词法闭包隐含的垃圾收集开销是不限定范围的。
1(defun let-over-lambda-returner ()
2 (let ((y 1))
3 (lambda (x)
4 (incf y x))))
每次调用 let-over-lambda-returner 时,它必须创建一个新环境,将指向 lambda 结构表示的 代码的常量指针嵌入到这个新环境中,然后返回结果 闭包 (19)。可以用 time 来看看这个环境有多小:
Note
(19) 闭包 closure
1* (progn
2 (compile 'let-over-lambda-returner)
3 (time (let-over-lambda-returner)))
4
5; Evaluation took:
6; ...
7; 24 bytes consed.
8;
9#<Closure Over Function>
如果你尝试在闭包上调用 compile ,将得到一个错误消息,指出无法编译在非空词法环境中定义的函 数 [CLTL2-P677]。你不能编译闭包,只能编译创建闭包的函数。当编译一个创建闭包的函数时,它创建的 闭包也将被编译[ON-LISP-P25]。
使用上述封闭 lambda 的 let 非常重要,我们将在本章的剩余部分中讨论它的模式和变体。
2.5 Let Over Lambda (20)
Note
(20) 可理解为封闭 Lambda 的 Let 形式体
Let over lambda 是词法闭包的昵称。 Let over lambda 比大多数术语更清晰地反映用于创建闭包 的 lisp 代码。在 let over lambda 场景中, let 语句返回的最后一个结构是一个 lambda 表达式。它看起来就像 let 封闭在 lambda 之外:
1* (let ((x 0))
2 (lambda () x))
3
4#<Interpreted Function>
回顾一下, let 结构返回计算其主体内最后一个结构的结果,这就是为什么计算这个 let over lambda 结构会产生一个函数。但是, let 中的最后一个结构有特别之处。它是个以 x 作为 自 由变量 (21) 的 lambda 结构。 Lisp 很聪明,针对这个产生的函数, lisp 可以确定自由变量 x 应该引用什么:即来自由 let 结构创建的周围词法环境的 x 。而且,因为在 lisp 中,默认情况下一切都是不限定范围的,一旦需要,该函数就可以使用这个环境。
Note
(21) 自由变量 free variable
因此,词法作用域是一种工具,用于准确指定对变量的引用在何处有效,以及引用所指的确切内容。一个简单 的闭包示例是一个“计数器”,一个在环境中存储整数并在每次调用时递增并返回其值的闭包。下面是使用 let over lambda 技术的典型的实现:
1(let ((counter 0))
2 (lambda () (incf counter)))
这个闭包在第一次被调用时返回 1,随后返回 2,以此类推。考虑闭包的一种方式是它们是具有 状态 (22.1) 的函 数。这些函数不是数学函数,而是程序,每个都有一点自己的内存。有时将代码和数据捆绑在一起的数据结构 称为 对象 (22.2) 。对象是程序和一些相关状态的集合。由于对象与闭包密切相关,因此它们通常可以被认为是一回 事。闭包就像个只有一个方法的对象,这个方法是( funcall )。一个对象就像一个闭包,你可以使用 funcall ,通过多种方式调 用它。
Note
(22) 状态 state;对象 objects
尽管闭包总是单个函数及其封闭环境,但对象系统的多个方法、内部类和静态变量都有其对应的闭包。模拟多 个方法的一种可能方式是简单地从同一词法作用域内返回多个 lambda :
1(let ((counter 0))
2 (values
3 (lambda () (incf counter))
4 (lambda () (decf counter))))
这个 let over two lambdas 范例将返回两个函数,这两个函数都访问相同的封闭计数器变量。第一 个增加它,第二个减少它。还有许多其他方法可以实现这一点。其中一个便是 5.7 Dlambda 中进行了讨 论的 dlambda 。基于后续会解释的一些原因,本书中的代码将使用闭包而不是对象来构造所有数据。提示: 它必然与宏有关。
2.6 Lambda Over Let Over Lambda (23)
Note
(23) 可理解为封闭“封闭 Lambda 形式体的 Let ”的 Lambda 形式体
在一些对象系统中,对象、具有关联状态的过程集合和类(用于创建对象的数据结构)之间存在明显区别。闭 包不存在这种区别。我们看到了可以执行以创建闭包的结构示例,其中大多数遵循 lambda 模式,但是我们的程序 如何根据需要创建这些对象?
答案非常简单。如果我们可以在 REPL 中执行它们,也就可以在函数中执行它们。如果我们创建一个函数,其核心意图是执行一个 let over lambda 并返回结果呢? 因为我们使用 lambda 来表示函数,所以它 看起来像这样:
1(lambda ()
2 (let ((counter 0))
3 (lambda () (incf counter))))
当调用 lambda over let over lambda 时,将创建并返回一个包含计数器绑定的新闭包。记住, lambda 表达式是常量:仅仅是指向机器代码的指针集。这个表达式是一段简单的代码,它创建新的环境来封闭内部的 lambda 表达式(这个 lambda 表达式本身是一个常量,编译后的形式),就像我们在 REPL 中所做的那样。
对于对象系统,创建对象的一段代码称为类。但是 lambda over let over lambda 与许多语言的类略 有不同。虽然大多数语言都需要命名类,但这种模式完全避免了命名。 Lambda over let over lambda 形式可以称为 匿名类 (24) 。
Note
(24) 匿名类 anonymous classes
尽管匿名类通常很有用,但我们通常会命名类。给它们命名的最简单方法是认识到这些类是常规函数。我们通 常如何命名函数? 当然是用 defun 关键字了。命名后,上面的匿名类就变成了:
1(defun counter-class ()
2 (let ((counter 0))
3 (lambda () (incf counter))))
第一个 lambda 去哪儿了? defun 在其主体中的结构周围提供了个 隐式 lambda (25) 。当用 defun 编写常规函数时,实际上还是 lambda 结构,但这个事实隐藏在 defun 语法之下了。
Note
(25) 隐式 lambda:implicit lambda
不幸的是,大多数 lisp 编程书籍都没有提供闭包使用的实际示例,给读者留下了一种不准确的印象,即 闭包只适用于像计数器这样的玩具示例。这与事实相去甚远。闭包是 lisp 的基石。环境,在这些环境中定 义的函数,以及像 defun 这样方便使用的宏,是建模任何问题都需要的。本书旨在阻止那些习惯 面向对象语言的初级 lisp 程序员按照他们的直觉去接触如 CLOS 这样的系统。虽然 CLOS 的确为专业 的 lisp 程序员提供某些东西,但当 lambda 够用时就不要使用 CLOS 。
1(defun block-scanner (trigger-string)
2 (let* ((trig (coerce trigger-string 'list))
3 (curr trig))
4 (lambda (data-string)
5 (let ((data (coerce data-string 'list)))
6 (dolist (c data)
7 (if curr
8 (setq curr
9 (if (char= (car curr) c)
10 (cdr curr) ; next char
11 trig)))) ; start over
12 (not curr))))) ; return t if found
为了鼓励使用闭包,给出了一个现实的例子: block-scanner 。 block-scanner 解决的问题 是,对一些结构的数据传输,数据以大小不定的组(块)形式传递。这些大小通常对底层系统很方便,但对应 用层程序员却不方便,通常由操作系统缓冲区、硬盘驱动器块或网络数据包等因素决定。扫描特定序列的数据 流需要的不仅仅是扫描每个块,因为它带有常规的无状态过程。需要在每个块的扫描之间保持状态,因为我们 正在扫描的序列可能会被分成两个(或更多)块。
在现代语言中实现这种存储状态的最直接、最自然的方法是闭包。基于闭包的块扫描器的初始草图是给出的 block-scanner 。像所有 lisp (程序)开发一样,创建闭包是一个迭代过程。我们可能从 block-scanner 中给出的代码开始,并决定通过避免将字符串强制转换为列表来提高其效率,或者可 能通过计数序列出现的次数来改进收集的信息。
尽管 block-scanner 是个尚待改进的初始实现,但它仍然是使用 lambda over let over lambda 的很好演示。下面是它使用,假装是某种留意特定的单词 jihad 的通信磁带:
1* (defvar scanner
2 (block-scanner "jihad"))
3
4SCANNER
5* (funcall scanner "We will start ")
6
7NIL
8# (funcall scanner "the ji")
9
10NIL
11* (funcall scanner "had tomorrow.")
12
13T
2.7 Let Over Lambda Over Let Over Lambda (26)
Note
(26) 可理解为封闭“封闭“封闭 Lambda 形式体的 Let ”的 Lambda 形式体”的 Let 。译者建议掰开来理解,这样可体会其内涵,即形式体最外围是 let 形式体还是 lambda 形式体。在具体应用时用户会体会到细微的差别。例如下面代码
1CL-USER> (defvar x 4)
2X
3CL-USER> (let ((x 1))
4 (defun add-x (y)
5 (+ x y)))
6ADD-X
7CL-USER> (add-x 3)
88
9CL-USER> (add-x 3)
108
11CL-USER> (add-x 3)
128
13CL-USER> (defun addx (y)
14 (let ((x 1))
15 (+ x y)))
16ADDX
17CL-USER> (addx 3)
184
19CL-USER> (addx 3)
204
对象系统的用户将他们希望在某个类的所有对象之间共享的值存储到所谓的 类变量或静态变量 (27) 中 【8】 。在 lisp 中,闭包之间共享状态的概念由环境处理,就像闭包本身存储状态一样。由于环境可以无限访问,只要仍然 可以引用到它,就能保证它在需要时可用。
Hint
【8】 术语静态是最重载的编程语言术语之一。类的所有对象共享的变量在 Java 等语言中称为静态变量,这与 C 中静态的含义有很大关系
Note
(27) 类变量或静态变量 class variables or static variables
如果想为所有计数器维护一个全局方向, up 向上递增, down 向下递减,那么可能想要使用 let over lambda over let over lambda 模式:
1(let ((direction 'up))
2 (defun toggle-counter-direction ()
3 (setq direction
4 (if (eq direction 'up)
5 'down
6 'up)))
7
8 (defun counter-class ()
9 (let ((counter 0))
10 (lambda ()
11 (if (eq direction 'up)
12 (incf counter)
13 (decf counter))))))
在上面的例子中,我们扩展了上节的 counter-class 。现在调用使用 counter-class 创建 的闭包将增加或减少其计数器绑定,这取决于所有计数器之间共享的参数 direction 绑定的值。注意,这里还创建一个 名为 toggle-counter-direction 的函数来利用参数 direction 环境中的另一个 lambda ,该函数更改所有 计数器的当前参数 direction 值。
虽然 let 和 lambda 的这种组合非常有用,以至于其他语言以类或静态变量的形式将它采纳,但 还有其他的 let 和 lambda 组合,允许用没有直接类似物的方式,在对象系统中构造代码和状态 【9】 。对象系统是 let 和 lambda 组合子集的形式化,有时带有类似 继承 (28) 的噱头 【10】 。正因为如此, lisp 程序员 通常不会考虑类和对象。 Let 和 lambda 是基本的; 对象和类是衍生物。正如 Steele(人名)所说,“对象”不必是 编程语言中的原始概念。一旦可分配的值单元和好的旧有 lambda 表达式是可用的,对象系统充其量只是偶尔有用 的抽象,并且,最糟糕的特殊情况(对象系统)是冗余的。
Hint
【9】 但这些类似物有时可以建立在对象系统之上
Hint
【10】 拥有宏比拥有继承更重要
Note
(28) 继承 inheritance