10.5 设计宏 (Macro Design)

撰写宏是一种独特的程序设计,它有着独一无二的目标与问题。能够改变编译器所看到的东西,就像是能够重写它一样。所以当你开始撰写宏时,你需要像语言设计者一样思考。

本节快速给出宏所牵涉问题的概要,以及解决它们的技巧。作为一个例子,我们会定义一个称为 ntimes 的宏,它接受一个数字 n 并对其主体求值 n 次。

  1. > (ntimes 10
  2. (princ "."))
  3. ..........
  4. NIL

下面是一个不正确的 ntimes 定义,说明了宏设计中的某些议题:

  1. (defmacro ntimes (n &rest body)
  2. `(do ((x 0 (+ x 1)))
  3. ((>= x ,n))
  4. ,@body))

这个定义第一眼看起来可能没问题。在上面这个情况,它会如预期的工作。但实际上它在两个方面坏掉了。

一个宏设计者需要考虑的问题之一是,不小心引入的变量捕捉 (variable capture)。这发生在当一个在宏展开式里用到的变量,恰巧与展开式即将插入的语境里,有使用同样名字作为变量的情况。不正确的 ntimes 定义创造了一个变量 x 。所以如果这个宏在已经有 x 作为名字的地方被调用时,它可能无法做到我们所预期的:

  1. > (let ((x 10))
  2. (ntimes 5
  3. (setf x (+ x 1)))
  4. x)
  5. 10

如果 ntimes 如我们预期般的执行,这个表达式应该会对 x 递增五次,最后返回 15 。但因为宏展开刚好使用 x 作为迭代变量, setf 表达式递增那个 x ,而不是我们要递增的那个。一旦宏调用被展开,前述的展开式变成:

  1. > (let ((x 10))
  2. (do ((x 0 (+ x 1)))
  3. ((>= x 5))
  4. (setf x (+ x 1)))
  5. x)

最普遍的解法是不要使用任何可能会被捕捉的一般符号。取而代之的我们使用 gensym (8.4 小节)。因为 read 函数 intern 每个它见到的符号,所以在一个程序里,没有可能会有任何符号会 eql gensym。如果我们使用 gensym 而不是 x 来重写 ntimes 的定义,至少对于变量捕捉来说,它是安全的:

  1. (defmacro ntimes (n &rest body)
  2. (let ((g (gensym)))
  3. `(do ((,g 0 (+ ,g 1)))
  4. ((>= ,g ,n))
  5. ,@body)))

但这个宏在另一问题上仍有疑虑: 多重求值 (multiple evaluation)。因为第一个参数被直接插入 do 表达式,它会在每次迭代时被求值。当第一个参数是有副作用的表达式,这个错误非常清楚地表现出来:

  1. > (let ((v 10))
  2. (ntimes (setf v (- v 1))
  3. (princ ".")))
  4. .....
  5. NIL

由于 v 一开始是 10 ,而 setf 返回其第二个参数的值,应该印出九个句点。实际上它只印出五个。

如果我们看看宏调用所展开的表达式,就可以知道为什么:

  1. > (let ((v 10))
  2. (do ((#:g1 0 (+ #:g1 1)))
  3. ((>= #:g1 (setf v (- v 1))))
  4. (princ ".")))

每次迭代我们不是把迭代变量 (gensym 通常印出前面有 #: 的符号)与 9 比较,而是与每次求值时会递减的表达式比较。这如同每次我们查看地平线时,地平线都越来越近。

避免非预期的多重求值的方法是设置一个变量,在任何迭代前将其设为有疑惑的那个表达式。这通常牵扯到另一个 gensym:

  1. (defmacro ntimes (n &rest body)
  2. (let ((g (gensym))
  3. (h (gensym)))
  4. `(let ((,h ,n))
  5. (do ((,g 0 (+ ,g 1)))
  6. ((>= ,g ,h))
  7. ,@body))))

终于,这是一个 ntimes 的正确定义。

非预期的变量捕捉与多重求值是折磨宏的主要问题,但不只有这些问题而已。有经验后,要避免这样的错误与避免更熟悉的错误一样简单,比如除以零的错误。

你的 Common Lisp 实现是一个学习更多有关宏的好地方。借由调用展开至内置宏,你可以理解它们是怎么写的。下面是大多数实现对于一个 cond 表达式会产生的展开式:

  1. > (pprint (macroexpand-1 '(cond (a b)
  2. (c d e)
  3. (t f))))
  4. (IF A
  5. B
  6. (IF C
  7. (PROGN D E)
  8. F))

函数 pprint 印出像代码一样缩排的表达式,这在检视宏展开式时特别有用。