第 14 章 指代宏
第 9 章只是把变量捕捉视为一种问题 某种意料之外,并且只会捣乱的负面因素。本章将显示变量捕捉 也可以被有建设性地使用。如果没有这个特性,一些有用的宏就无法写出来。
在 Lisp 程序里,下面这种需求并不鲜见:希望检查一个表达式的返回值是否为非空,如果是的话,使用这个值做某些事。倘若求值表达式的代价比较大,那么通常必须这样做:
(let ((result (big-long-calculation)))
(if result
(foo result)))
难道就不能简单一些,让我们像英语里那样,只要说:
(if (big-long-calculation)
(foo it))
通过利用变量捕捉,我们可以写一个 if
,让它以这种方式工作。
14.1 指代的种种变形
在自然语言里,指代(anaphor) 是一种引用对话中曾提及事物的表达方式。英语中最常用的代词可能要
算 "it" 了,就像在 "Get the wrench and put it on the table(拿个扳手,然后把它放在桌上)" 里那样。指代给日常语言带来了极大的便利 试想一下没有它会发生什么 但它在编程语言里却很少见。这在很大程度上是为了语言着想。指代表达式常会产生歧义,而当今的编程语言从设计上就无法处理这种二义性。
尽管如此,在 Lisp 程序中引入一种形式非常有限的代词,同时避免歧义,还是有可能的。代词,实际上是一种可捕捉的符号。我们可以通过指定某些符号,让它们充当代词,然后再编写宏有意地捕捉这些符号,用这种方式来使用代词。
在新版的 if
里,符号 it
就是那个我们想要捕捉的对象。Anaphoricif
,简称 aif
,其定义如下:
(defmacro aif (test-form then-form &optional else-form)
'(let ((it ,test-form))
(if it ,then-form ,else-form)))
并如前例中那样使用它:
(aif (big-long-calculation)
(foo it))
当你使用 aif
时,符号 it
会被绑定到测试表达式返回的结果。在宏调用中,it
看起来是自由的,但事实上,在 aif
展开时,表达式 (foo it)
会被插入到一个上下文中,而 it
的绑定就位于该上下文:
(let ((it (big-long-calculation)))
(if it (foo it) nil))
这样一个在源代码中貌似自由的符号就被宏展开绑定了。本章里所有的指代宏都使用了这种技术,并加以变化。
[示例代码 14.1] 包含了一些 Common Lisp 操作符的指代变形。aif
下面是 awhen
,很明显它是 when
的指代版本:
原书勘误:(acond (3))将返回 nil 而不是 3。后面的 acond2 也有同样的问题。
[示例代码 14.1] Common Lisp 操作符的指代变形
(defmacro aif (test-form then-form &optional else-form)
'(let ((it ,test-form))
(if it ,then-form ,else-form)))
(defmacro awhen (test-form &body body)
'(aif ,test-form
(progn ,@body)))
(defmacro awhile (expr &body body)
'(do ((it ,expr ,expr))
((not it))
,@body))
(defmacro aand (&rest args)
(cond ((null args) t)
((null (cdr args)) (car args))
(t '(aif ,(car args) (aand ,@(cdr args))))))
(defmacro acond (&rest clauses)
(if (null clauses)
nil
(let ((cl1 (car clauses))
(sym (gensym)))
'(let ((,sym ,(car cl1)))
(if ,sym
(let ((it ,sym)) ,@(cdr cl1))
(acond ,@(cdr clauses)))))))
(awhen (big-long-calculation)
(foo it)
(bar it))
aif
和 awhen
都是经常会用到的,但 awhile
可能是这些指代宏中的唯一一个,被用到的机会比它的正常版的同胞兄弟 while
(定义于 7.4 节) 更多的宏。一般来说,如果一个程序需要等待(poll) 某个外部数据源的话,类似 while
和 awhile
这样的宏就可以派上用场了。而且,如果你在等待一个数据源,除非你想做的仅是静待它改变状态,否则你肯定会想用从数据源那里获得的数据做些什么:
(awhile (poll *fridge*)
(eat it))
aand 的定义和前面的几个宏相比之下更复杂一些。它提供了一个 and
的指代版本;每次求值它的实参,it 都将被绑定到前一个参数返回的值上。 在实践中,aand
倾向于在那些做条件查询的程序中使用,例如这里:
(aand (owner x) (address it) (town it))
它返回 x
的拥有者(如果有的话) 的地址(如果有的话) 所属的城镇(如果有的话)。如果不使用 aand
,该表达式就只能写成:
(let ((own (owner x)))
(if own
(let ((adr (address own)))
(if adr (town adr)))))
尽管人们喜欢把 and
和 or
相提并论,但实现指代版本的 or
没有什么意义。一个 or
表达式中的实参只有当它前面的实参求值到 nil
才会被求值,所以 aor
中的代词将毫无用处。
从 aand
的定义可以看出,它的展开式将随宏调用中的实参的数量而变。如果没有实参,那么 aand
,将像正常的 and
那样,应该直接返回 t
。否则会递归地生成展开式,每一步都会在嵌套的 aif
链中产生一层:
(aif <first argument>
<expansion for rest of arguments>)
aand
的展开必须在只剩下一个实参时终止,而不是像大多数递归函数那样继续展开,直到 nil
才停下来。
倘若递归过程一直进行下去,直到消去所有的合取式,那么最终的展开式将总是下面的模样:
(aif <C>
.
.
.
(aif <Cn>
t)...)
这样的表达式会一直返回 t
或者 nil
,因而上面的示例将无法正常工作。
第 10.4 节曾警告过:如果一个宏总是产生包含对其自身调用的展开式,那么展开过程将永不终止。虽然 aand
是递归的,但是它却没有这个问题,因为在基本情形里它的展开式没有引用 aand
。
最后一个例子是 acond
,它用于 cond
子句的其余部分想使用测试表达式的返回值的场合。(这种需求非常普遍,以至于 Scheme 专门提供了一种方式来使用 cond
子句中测试表达式的返回值。)
在 acond
子句的展开式里,测试结果一开始时将被保存在一个由 gensym
生成的变量里,目的是为了让符号 it
的绑定只在子句的其余部分有效。当宏创建这些绑定时,它们应该总是在尽可能小的作用域里完成这些工作。这里,要是我们省掉了这个 gensym
,同时直接把 it
绑定到测试表达式的结果上,就像这样:
(defmacro acond (&rest clauses) ; wrong
(if (null clauses)
nil
(let ((cl1 (car clauses)))
'(let ((it ,(car cl1)))
(if it
(progn ,@(cdr cl1))
(acond ,@(cdr clauses)))))))
那么it 绑定的作用域也将包括后续的测试表达式。
[示例代码 14.2] 更多的指代变形
(defmacro alambda (parms &body body)
'(labels ((self ,parms ,@body))
#'self))
(defmacro ablock (tag &rest args)
'(block ,tag
,(funcall (alambda (args)
(case (length args)
(0 nil)
(1 (car args))
(t '(let ((it ,(car args)))
,(self (cdr args))))))
args)))
[示例代码 14.2] 有一些更复杂的指代变形。宏 alambda
是用来字面引用递归函数的。不过什么时候会需要字面引用递归函数呢?我们可以通过带 #'
的 λ表达式来字面引用一个函数:
#'(lambda (x) (* x 2))
但正如第 2 章里解释的那样,你不能直接用λ–表达式来表达递归函数。代替的方法是,你必须借助 labels
定义一个局部函数。下面这个函数(来自 2.8 节)
(defun count-instances (obj lsts)
(labels ((instances-in (lst)
(if (consp lst)
(+ (if (eq (car lst) obj) 1 0)
(instances-in (cdr lst)))
0)))
(mapcar #'instances-in lsts)))
接受一个对象和列表,并返回一个由列表中每个元素里含有的对象个数所组成的数列:
> (count-instances 'a '((a b c) (d a r p a) (d a r) (a a)))
(1 2 1 2)
通过代词,我们可以将这些代码变成字面递归函数。alambda
宏使用 labels
来创建函数,例如,这样就可以用它来表达阶乘函数:
(alambda (x) (if (= x 0) 1 (* x (self (1- x)))))
使用 alambda
我们可以定义一个等价版本的 count-instances
,如下:
(defun count-instances (obj lists)
(mapcar (alambda (list)
(if list
(+ (if (eq (car list) obj) 1 0)
(self (cdr list)))
0))
lists))
alambda
与 [示例代码 14.1] 和 14.2 节里的其他宏不一样,后者捕捉的是 it,而 alambda
则捕捉 self
。alambda
实例会展开进一个 labels
表达式,在这个表达式中,self
被绑定到正在定义的函数上。alambda
表达式不但更短小,而且看起来很像我们熟悉的 lambda
表达式,这让使用 alambda
表达式的代码更容易阅读。
这个新宏被用了在 ablock
的定义里,它是内置的 block special form
的一个指代版本。在 block
里面,参数从左到右求值。在 ablock
里也是一样,只是在这里,每次求值时变量 it
都会被绑定到前一个表达式的值上。
这个宏应谨慎使用。尽管很多时候 ablock
用起来很方便,但是它很可能会把本可以被写得优雅漂亮的函数式程序弄成命令式程序的样子。下面就是一个很不幸的反面教材:
> (ablock north-pole
(princ "ho ")
(princ it)
(princ it)
(return-from north-pole))
ho ho ho
NIL
如果一个宏,它有意地使用了变量捕捉,那么无论何时这个宏被导出到另一个包的时候,都必须同时导出那些被捕捉了的符号。例如,无论 aif
被导出到哪里,it
也应该同样被导出到同样的地方。否则出现在宏定义里的it 和宏调用里使用的 it
将会是不同的符号。
14.2 失败
在 Common Lisp 中符号 nil
身兼三职。它首先是一个空列表,也就是
> (cdr '(a))
NIL
除了空列表以外,nil 被用来表示逻辑假,例如这里
> (= 1 0)
NIL
最后,函数返回 nil 以示失败。例如,内置 find-if
的任务是返回列表中第一个满足给定测试条件的元素。
如果没有发现这样的元素,find-if 将返回 nil :
> (find-if #'oddp '(2 4 6))
NIL
不幸的是,我们无法分辨出这种情形:即 find-if 成功返回,而成功的原因是它发现了 nil :
> (find-if #'null '(2 nil 6))
NIL
在实践中,用 nil 来同时表示假和空列表并没有招致太多的麻烦。事实上,这样可能相当方便。然而,用nil 来表示失败却是一个痛处。因为它意味着一个像 find-if 这样的函数,其返回的结果可能是有歧义的。
对于所有进行查找操作的函数,都会遇到如何区分失败和 nil 返回值的问题。为了解决这个问题,Common Lisp 至少提供了三种方案。在多重返回值出现之前,最常用的方法是专门返回一个列表结构。例如,区分 assoc 的失败就没有任何麻烦;当执行成功时它返回成对的问题和答案:
> (setq synonyms '((yes . t) (no . nil)))
((YES . T) (NO))
> (assoc 'no synonyms)
(NO)
按照这个思路,如果担心 find-if 带来的歧义,我们可以用 member-if ,它不单单返回满足测试的元素,而是返回以该元素开始的整个 cdr:
(member-if #'null '(2 nil 6)) (NIL 6)
自从多重返回值诞生之后,这个问题就有了另一个解决方案:用一个值代表数据,而用第二个值指出成功还是失败。内置的gethash 就以这种方式工作。它总是返回两个值,第二个值代表是否找到了什么东西:
> (setf edible (make-hash-table)
(gethash 'olive-oil edible) t
(gethash 'motor-oil edible) nil)
NIL
> (gethash 'motor-oil edible)
NIL
T
如果你想要检测所有三种可能的情况,可以用类似下面的写法:
(defun edible? (x)
(multiple-value-bind (val found?) (gethash x edible)
(if found?
(if val 'yes 'no)
'maybe)))
这样就可以把失败和逻辑假区分开了:
> (mapcar #'edible? '(motor-oil olive-oil iguana))
(NO YES MAYBE)
Common Lisp 还支持第三种表示失败的方法:让访问函数接受一个特殊对象作为参数,一般是用个 gensym,然后在失败的时候返回这个对象。这种方法被用于 get ,它接受一个可选参数来表示当特定属性没有找到时返回的东西:
> (get 'life 'meaning (gensym))
#:G618
如果可以用多重返回值,那么 gethash 用的方法是最清楚的。我们不愿意像调用 get 那样,为每个访问函数都再传入一个参数。并且和另外两种替代方法相比,使用多重返回值更通用;可以让 find-if 返回两个值,而 gethash 却不可能在不做 consing 的情况下被重写成返回无歧义的列表。这样在编写新的用于查询的函数,或者对于其他可能失败的任务时,通常采用gethash 的方式会更好一些。
[示例代码 14.3] 多值指代宏
(defmacro aif2 (test &optional then else)
(let ((win (gensym)))
'(multiple-value-bind (it ,win) ,test
(if (or it ,win) ,then ,else))))
(defmacro awhen2 (test &body body)
'(aif2 ,test
(progn ,@body)))
(defmacro awhile2 (test &body body)
(let ((flag (gensym)))
'(let ((,flag t))
(while ,flag
(aif2 ,test
(progn ,@body)
(setq ,flag nil))))))
(defmacro acond2 (&rest clauses)
(if (null clauses)
nil
(let ((cl1 (car clauses))
(val (gensym))
(win (gensym)))
'(multiple-value-bind (,val ,win) ,(car cl1)
(if (or ,val ,win)
(let ((it ,val)) ,@(cdr cl1))
(acond2 ,@(cdr clauses)))))))
在 edible? 里的写法不过相当于一种记帐的操作,它被宏很好地隐藏了起来。对于类似 gethash 这样的访问函数,我们会需要一个新版本的 aif ,它绑定和测试的对象不再是同一个值,而是绑定第一个值,并测试第二个值。这个新版本的 aif ,称为 aif2 ,由 [示例代码 14.3] 给出。使用它,我们可以将 edible? 写成:
(defun edible? (x)
(aif2 (gethash x edible)
(if it 'yes 'no)
'maybe))
[示例代码 14.3] 还包含有 awhen ,awhile ,和 acond 的类似替代版本。作为一个使用a cond2 的例子,见 18.4 节上 match 的定义。通过使用这个宏,我们可以用一个 cond 的形式来表达,否则函数将变得更长并且缺少对称性。
内置的 read 指示错误的方式和 get 同出一辙。它接受一个可选参数来说明在遇到eof 时是否报错,如果不报错的话,将返回何值。[示例代码 14.4] 中给出了另一个版本的 read ,它用第二个返回值指示失败。read2 返回两个值,分别是输入表达式和一个标志,如果碰到eof 的话,这个标志就是nil 。它把一个 gensym 传给 read ,万一遇到 eof 就返回它,这免去了每次调用 read2 时构造 gensym 的麻烦,这个函数被定义成一个闭包,闭包中带有一个编译期生成的 gensym 的私有拷贝。
[示例代码 14.4] 文件实用工具
(let ((g (gensym)))
(defun read2 (&optional (str *standard-input*))
(let ((val (read str nil g)))
(unless (equal val g) (values val t)))))
(defmacro do-file (filename &body body)
(let ((str (gensym)))
'(with-open-file (,str ,filename)
(awhile2 (read2 ,str)
,@body))))
[示例代码 14.4] 中还有一个宏,它可以方便地遍历一个文件里的所有表达式,这个宏是用 awhile2 和 read2 写成的。举个例子,借助 do-file ,我们可以这样实现 load :
(defun our-load (filename)
(do-file filename (eval it)))
14.3 引用透明(Referential Transparency)
有时认为是指代宏破坏了引用透明,Gelernter 和Jagannathan 是这样定义引用透明的:
一个语言是引用透明的,如果 (a) 任意一个子表达式都可以替换成另一个子表达式,只要后者和前者的值相等,并且 (b) 在给定的上下文中,出现不同地方的同一表达式其取值都相同。
注意到这个标准针对的是语言,而不是程序。没有一个带赋值的语言是引用透明的。在下面的表达式中:
(list x
(setq x (not x))
x)
第一个和最后一个 x 带有不同的值,因为被一个 setq 干预了。必须承认,这是丑陋的代码。这一事实意味着 Lisp 不是引用透明的。
Norvig 提到,倘若把 if 重新定义成下面这样将会很方便:
(defmacro if (test then &optional else)
'(let ((that ,test))
(if that ,then ,else)))
但 Norvig 否定它的理由,也正是因为这个宏破坏了引用透明。
尽管如此,这里的问题在于:上面的宏重定义了内置操作符,而不是因为它使用了代词。上面定义中的 (b) 条款要求一个表达式 "在给定的上下文中" 必须总是返回相同的值。如果是在这个 let 表达式中就没问题了,
(let ((that 'which))
...)
符号 that 表示一个新变量,因为 let 就是被用于创建一个新的上下文。
上面那个宏的错误在于,它重定义了 if,而 if 的本意并非是被用来创建新的上下文的。如果我们给指代宏取个自己的名字,问题就迎刃而解。(根据 CLTL2,重定义 if 总是非法的。) 由于 aif 定义的一部分就是建立一个新的上下文,并且在这个上下文中,it 是一个新变量,所以这样一个宏并没有破坏引用透明。
现在,aif 确实违背了另一个原则,它和引用透明无关:即,不管用什么办法,新建立的变量都应该在源代码里能很容易地分辨出来。前面的那个 let 表达式就清楚地表明 that 将指向一个新变量。可能会有反对意见,说:一个 aif 里面的 it 绑定就没有那么明显。尽管如此,这里有一个不大站得住脚的理由:aif 只创 建了一个变量,并且创建这个变量是我们使用 aif 的唯一理由。
Common Lisp 自己并没有把这个原则奉为不可违背的金科玉律。CLOS 函数 call-next-method 的绑定依赖上下文的方式和 aif 函数体中符号 it 的绑定方式是一样的。(关于 call-next-method 应如何实现的一个建议方案,可见 25.2 节上的 defmeth 宏。) 在任何情况下,这类原则的最终目的只有一个:提高程序的可读性。并且代词确实让程序更容易阅读,正如它们让英语更容易阅读那样。