10.5 设计宏 (Macro Design)
撰写宏是一种独特的程序设计,它有着独一无二的目标与问题。能够改变编译器所看到的东西,就像是能够重写它一样。所以当你开始撰写宏时,你需要像语言设计者一样思考。
本节快速给出宏所牵涉问题的概要,以及解决它们的技巧。作为一个例子,我们会定义一个称为 ntimes
的宏,它接受一个数字 n 并对其主体求值 n 次。
> (ntimes 10
(princ "."))
..........
NIL
下面是一个不正确的 ntimes
定义,说明了宏设计中的某些议题:
(defmacro ntimes (n &rest body)
`(do ((x 0 (+ x 1)))
((>= x ,n))
,@body))
这个定义第一眼看起来可能没问题。在上面这个情况,它会如预期的工作。但实际上它在两个方面坏掉了。
一个宏设计者需要考虑的问题之一是,不小心引入的变量捕捉 (variable capture)。这发生在当一个在宏展开式里用到的变量,恰巧与展开式即将插入的语境里,有使用同样名字作为变量的情况。不正确的 ntimes
定义创造了一个变量 x
。所以如果这个宏在已经有 x
作为名字的地方被调用时,它可能无法做到我们所预期的:
> (let ((x 10))
(ntimes 5
(setf x (+ x 1)))
x)
10
如果 ntimes
如我们预期般的执行,这个表达式应该会对 x
递增五次,最后返回 15
。但因为宏展开刚好使用 x
作为迭代变量, setf
表达式递增那个 x
,而不是我们要递增的那个。一旦宏调用被展开,前述的展开式变成:
> (let ((x 10))
(do ((x 0 (+ x 1)))
((>= x 5))
(setf x (+ x 1)))
x)
最普遍的解法是不要使用任何可能会被捕捉的一般符号。取而代之的我们使用 gensym (8.4 小节)。因为 read
函数 intern
每个它见到的符号,所以在一个程序里,没有可能会有任何符号会 eql
gensym。如果我们使用 gensym 而不是 x
来重写 ntimes
的定义,至少对于变量捕捉来说,它是安全的:
(defmacro ntimes (n &rest body)
(let ((g (gensym)))
`(do ((,g 0 (+ ,g 1)))
((>= ,g ,n))
,@body)))
但这个宏在另一问题上仍有疑虑: 多重求值 (multiple evaluation)。因为第一个参数被直接插入 do
表达式,它会在每次迭代时被求值。当第一个参数是有副作用的表达式,这个错误非常清楚地表现出来:
> (let ((v 10))
(ntimes (setf v (- v 1))
(princ ".")))
.....
NIL
由于 v
一开始是 10
,而 setf
返回其第二个参数的值,应该印出九个句点。实际上它只印出五个。
如果我们看看宏调用所展开的表达式,就可以知道为什么:
> (let ((v 10))
(do ((#:g1 0 (+ #:g1 1)))
((>= #:g1 (setf v (- v 1))))
(princ ".")))
每次迭代我们不是把迭代变量 (gensym 通常印出前面有 #:
的符号)与 9
比较,而是与每次求值时会递减的表达式比较。这如同每次我们查看地平线时,地平线都越来越近。
避免非预期的多重求值的方法是设置一个变量,在任何迭代前将其设为有疑惑的那个表达式。这通常牵扯到另一个 gensym:
(defmacro ntimes (n &rest body)
(let ((g (gensym))
(h (gensym)))
`(let ((,h ,n))
(do ((,g 0 (+ ,g 1)))
((>= ,g ,h))
,@body))))
终于,这是一个 ntimes
的正确定义。
非预期的变量捕捉与多重求值是折磨宏的主要问题,但不只有这些问题而已。有经验后,要避免这样的错误与避免更熟悉的错误一样简单,比如除以零的错误。
你的 Common Lisp 实现是一个学习更多有关宏的好地方。借由调用展开至内置宏,你可以理解它们是怎么写的。下面是大多数实现对于一个 cond
表达式会产生的展开式:
> (pprint (macroexpand-1 '(cond (a b)
(c d e)
(t f))))
(IF A
B
(IF C
(PROGN D E)
F))
函数 pprint
印出像代码一样缩排的表达式,这在检视宏展开式时特别有用。