第 10 章 其他的宏陷阱

2018-02-24 15:54 更新

第 10 章 其他的宏陷阱

编写宏需要格外小心。函数被隔离在它自己的词法世界中,但是宏就另当别论了,因为它要被展开成进调用方的代码,所以除非仔细编写,否则它将会给用户带来意料之外的不便。第 9 章详细说明了变量捕捉,它是这些不速之客中最常见的一个。本章将讨论在编写宏时需要避免的另外四个问题。

10.1 求值的次数


[示例代码 10.1] 控制参数求值

正确的版本:

(defmacro for ((var start stop) &body body)
  (let ((gstop (gensym)))
    '(do ((,var ,start (1+ ,var))
        (,gstop ,stop))
      ((> ,var ,gstop))
      ,@body)))

导致多重求值:

(defmacro for ((var start stop) &body body)
  '(do ((,var ,start (1+ ,var)))
    ((> ,var ,stop))
    ,@body))

错误的求值顺序:

(defmacro for ((var start stop) &body body)
  (let ((gstop (gensym)))
    '(do ((,gstop ,stop)
        (,var ,start (1+ ,var)))
      ((> ,var ,gstop))
      ,@body)))

在上一章中出现了几种错误的 for版本。[示例代码 10.1] 给出了另外两个,同时还带有一个正确的版本方便对比。

尽管第二个 for并不那么容易发生变量捕捉,但是它还是有个 bug。它将生成一个展开式,在这个展开式里,作为 stop 传递的 form 在每次迭代时都会被求值。在最理想的情况下,这只会让宏变得低效,重复做一些它本来可以只做一次的操作。如果 stop 有副作用,那么宏可能就会出人意料地产生错误的结果。例如,这个循环将永不终止,因为目标在每次迭代时都会倒退:

> (let ((x 2))
  (for (i 1 (incf x))
    (princ i)))
12345678910111213...

在编写类似 for的宏的时候,必须牢记:宏的参数是 form,而非值。取决于它们出现在表达式中位置的不同,它们可能会被求值多次。在这种情况下,解决的办法是把变量绑定到 stop form 的返回值上,并在循环过程中引用这个变量。

除非是为了迭代而有意为之,否则编写宏的时候,应该确保表达式在宏调用里出现的次数和表达式求值的次数一致。很明显,这个规则对有些情况并不适用:倘若参数总会被求值的话,Common Lisp 的 or 的用处就会大打折扣(那就成 Pascal 的 or 了)。但是在这种情况下用户知道他们期望的求值次数。对于第二个版本的 forv来说就不是这样了:用户没有理由会想要 stop form 被求值一次以上,而且事实上也不应该这样做。一个宏要是写成第二个版本的 forv那样,十有八九就是弄错了。

对基于 setf 的宏来说,无意的多重求值尤其难以处理。Common Lisp 提供了几个实用工具以便编写这样的宏。具体的问题,以及解决方案,将在第 12 章里讨论。

10.2 求值的顺序

表达式求值的顺序,虽然不像它们的求值次数那样重要,但有时先后次序也会成为问题。在 Common Lisp 的函数调用中,参数是从左到右求值的:

> (setq x 10)
10
> (+ (setq x 3) x)
6

对于宏来说,最好也这样处理。宏通常应该确保表达式求值的顺序和它们在宏调用中出现的顺序一致。

在 [示例代码 10.1] 中,第三个版本的 for同样有个难以觉察的 bug。参数 stop 将会在 start 前被求值,尽管它们在宏调用中出现的顺序和求值的顺序是相反的:

> (let ((x 1))
  (for (i x (setq x 13))
    (princ i)))
13
NIL

这个宏给人一种莫名其妙的错觉,就好像时间会倒退一样。尽管 start form 在代码里面出现在先,但 stop form 的求值操作却能影响 start form 的返回值。

正确版本的 for会确保其参数以它们出现的顺序被求值:

> (let ((x 1))
  (for (i x (setq x 13))
    (princ i)))
12345678910111213
NIL

这里,在 stop form 里设置 x 的值就不会影响到前一个参数的返回值了。

尽管上面的例子是杜撰的,但是这类问题确实还会时有发生,而且这种 bug 很难找出来。或许很少有人会写出这样的代码,让宏一个参数的求值影响到另一个参数的返回值,但是人们在无意中做的事情,有可能并非出自本心。尽管在有意这样用时,应当正常工作,但是这不是让 bug 藏身于实用工具的理由。如果有人写出的代码和前例相似,它很可能是误写成的,但 for 的正确版本将使错误更容易检测出来。

10.3 非函数式的展开器

Lisp 期望那些生成宏展开式的代码都是纯函数式的,就像第 3 章里说的那样。展开器代码除了作为参数传给它的 form 之外不应该有其他依赖,并且它影响外界的唯一渠道只能是它的返回值。

如 CLTL2(685 页)所述,可以确信,在编译代码中的宏调用将不会在运行期重新展开。另一方面,Common Lisp 对宏调用展开的时机,和展开的次数并没有作出保证。如果一个宏的展开式会因上面的两个因素而不同的话,那么就可以认为这个宏是有问题的。例如,假设我们想要统计某个宏的使用次数。我们不能直接对源文件搜索一遍了事,因为在由程序生成的代码里也可能会调用这个宏。所以,我们可能会这样定义这个宏:

(defmacro nil! (x) ; wrong
  (incf *nil!s*)
  '(setf ,x nil))

使用这个定义,使得每次展开 nil! 的调用时,全局的 \*nil!s\* 的值都会递增。然而,如果我们认为这个变量的值能告诉我们 nil! 被调用的次数,那就大错特错了。一个宏调用可以,并且经常会被展开不只一次。

例如,一个对你代码进行变换的预处理器在它决定是否变换代码之前,可能不得不展开表达式中的宏调用。

这是一条普适的规则,即:展开器代码除其参数外不应依赖其他任何东西。所以任何宏,比如说通过字符串来构造展开式的那种,应当小心不要对宏展开时所在的包作任何假设。下面的这个例子虽说简单,但相当有代表性,

(defmacro string-call (opstring &rest args) ; wrong
  '(,(intern opstring) ,@args))

它定义了一个宏,这个宏接受一个操作符的打印名称,并把它展开成对该操作符的调用:

> (defun our+ (x y) (+ x y))
OUR+
> (string-call "OUR+" 2 3)
5

对 intern 的调用接受一个字符串,并返回对应的符号。尽管如此,如果我们省略了可选的包参数,它将在当前包里寻找符号。该展开式将因此依赖于展开式生成时所在的包,并且除非 our+ 在那个包里可见,否则展开式将是一个对未知符号的调用。

展开式代码中的副作用有时会带来一些问题,Miller 和 Benson 在 <<Lisp StyleandDesign>> 一书中就为之举了一个非常丑陋的例子。CLTL2(78 页)提到,Common Lisp 并不保证绑定在&rest 形参上的列表是新生成的。

它们可能会和程序其他地方的列表共享数据结构。后果就是,你不能破坏性地修改 &rest 形参,因为你不知道你将会改掉其他什么东西。

这种可能性对于函数和宏都有影响。对于函数来说,问题出在使用 apply 的时候。在合格的 Common Lisp 实现中,将发生下面的事情。假设我们定义一个函数 et-al ,它会在它的参数列表末尾加上 'et 'al ,再返回它:

(defun et-al (&rest args)
  (nconc args (list 'et 'al)))

如果我们像平时那样调用这个函数,它看起来工作正常:

> (et-al 'smith 'jones)
(SMITH JONES ET AL)

然而,要是我们通过 apply 调用它,就会改动已有的数据:

> (setq greats '(leonardo michelangelo))
(LEONARDO MICHELANGELO)
> (apply #'et-al greats)
(LEONARDO MICHELANGELO ET AL)
> greats
(LEONARDO MICHELANGELO ET AL)

至少 Common Lisp 的正确实现应该会这样反应,虽然到目前为止没有一个是这样做的。

对宏来说就更危险了。如果一个宏会修改它的 &rest 形参,那它可能会因此改掉整个宏调用。这就是说,最终你可能写出一个难以察觉的自我重写的程序。这种危险也更有现实意义 -- 它实实在在地发生在现有的实现中。如果我们定义一个宏,它将某些东西 nconc 到它的 &rest 参数里: 【注 1】

(defmacro echo (&rest args)
  '',(nconc args (list 'amen)))

然后定义一个函数来调用它:

(defun foo () (echo x))

在一个广泛使用的 Common Lisp 中,则会观察到下面的现象:

> (foo)
(X AMEN AMEN)
> (foo)
(X AMEN AMEN AMEN)

不只是 foo 返回了错误的结果,它甚至每次返回的结果都不一样,因为每一次宏展开都替换了 foo的定义。

这个例子同时也阐述了之前提到的一个观点:一个宏可能会被展开多次。在这个实现里,第一次调用foo 返回的是含有两个 amen 的列表。出于某种原因,该实现在 foo 被定义时就做了一次宏展开,然后接下来每次调用时都会再展开一次。

将 foo 定义成这样会更安全一些:

(defmacro echo (&rest args)
  ''(,@args amen))

因为 comma-at 等价于 append 而非 nconc 。在重定义这个宏之后,foo 也需要重新定义一下,就算它没有编译也是一样,因为 echo 的前一个版本导致它把自己重写了。

对宏来说,受到这种危险威胁的不单单是 &rest 参数。任何宏参数只要是列表就应该单独对待。如果我们定义了一个会修改其参数的宏,以及一个调用该宏的函数,

(defmacro crazy (expr) (nconc expr (list t)))

(defun foo () (crazy (list)))

那么主调函数的源代码就有可能被修改,正如在一个实现里,我们首次调用时所看到的:

> (foo)
(T T)

和解释代码一样,这种情况在编译的代码里也会发生。

结论是,不要试图通过破坏性修改参数列表结构,来避免构造 consing 。这样得到的程序就算可以工作也将是不可移植的。如果你真想在接受变长参数的函数中避免consing ,一种解决方案是使用宏,由此将 consing 切换到编译期。对于宏的这种应用,可见第 13 章。

宏展开器返回的表达式含有引用列表的话,就应该避免对它进行破坏性的操作。就其本身而言,这不只是对于宏的限制,而是第 3.3 节中提出原则的一个实例。

10.4 递归

有时会自然而然地把一个函数定义成递归的。而有些函数天生就是递归的,如下:

(defun our-length (x)
  (if (null x)
    0
    (1+ (our-length (cdr x)))))

这样定义从某种程度来说,比等价的迭代形式看起来更自然一些(尽管可能也更慢一些):

(defun our-length (x)
  (do ((len 0 (1+ len))
      (y x (cdr y)))
    ((null y) len)))

一个既不递归,也不属于某个多重递归函数集合的函数,可以通过第 7.10 节描述的简单技术被转换为一个宏。然而,仅是插入反引用和逗号对递归函数是无效的。让我们以内置的 nth 为例。(为简单起见,这个版本的 nth 将不做错误检查。)[示例代码 10.2] 给出了一个将 nth 定义成宏的错误尝试。表面上看 nthb 似乎和 ntha 等价,但是一个包含对 nthb 调用的程序将不能编译,因为对该调用的展开过程无法终止。


[示例代码 10.2] 对递归函数的错误类比

这个可以工作:

(defun ntha (n lst)
  (if (= n 0)
    (car lst)
    (ntha (- n 1) (cdr lst))))

这个不能编译:

(defmacro nthb (n lst)
  '(if (= ,n 0)
    (car ,lst)
    (nthb (- ,n 1) (cdr ,lst))))

一般而言,是允许宏里含有对另一个宏的引用的,只要展开过程会最终停止就可以。nthb 的麻烦之处在于每次的展开都含有一个对其本身的引用。函数版本,ntha ,之所以会终止因为它在 n 的值上递归,这个值在每次递归中减小。但是宏展开式只能访问到 form,而不是它们的值。当编译器试图宏展开,比如说,(nthb x y) 时,第一次展开将得到:

(if (= x 0)
  (car y)
  (nthb (- x 1) (cdr y)))

然后又会被展开成:

(if (= x 0)
  (car y)
  (if (= (- x 1) 0)
    (car (cdr y))
    (nthb (- (- x 1) 1) (cdr (cdr y)))))

如此这般地进入无限循环。一个宏展开成对自身的调用是可以的,但不是这么用的。

像 nthb 这样的递归宏,其真正危险之处在于它们通常在解释器里工作正常。而当你最终将程序跑起来,接着想编译它的时候,它甚至无法通过编译。非但如此,常常还没有提示,告诉我们问题出自一个递归的宏; 相反,编译器只会陷入无限循环,让你来找出究竟哪里搞错了。

在本例中,ntha 是尾递归的。尾递归函数可以轻易转换成与之等价的迭代形式,然后用作宏的模型。一个像 nthb 的宏可以写成:

(defmacro nthc (n lst)
  '(do ((n2 ,n (1- n2))
      (lst2 ,lst (cdr lst2)))
    ((= n2 0) (car lst2))))

所以从理论上说,把递归函数改造成宏也并非不可能。但是,要转换更复杂的递归函数可能会比较困难,甚至无法做到。

这取决于你要宏做什么,有时候你可能会发现改成宏和函数的组合就够用了。[示例代码 10.3] 给出了两种方式,可用来生成表面上似乎递归的宏。第一种策略就在 nthd 里面,它直接让宏展开成为一个对递归函数的调用。

举个例子,如果你使用宏的目的,仅仅是希望帮助用户避免引用参数的麻烦,那么这种方法就可以胜任了。


[示例代码 10.3] 解决问题的两个办法

(defmacro nthd (n lst)
  '(nth-fn ,n ,lst))

(defun nth-fn (n lst)
  (if (= n 0)
    (car lst)
    (nth-fn (- n 1) (cdr lst))))

(defmacro nthe (n lst)
  '(labels ((nth-fn (n lst)
        (if (= n 0)
          (car lst)
          (nth-fn (- n 1) (cdr lst)))))
    (nth-fn ,n ,lst)))

如果你使用宏的目的,是想要将其展开式嵌入到宏调用的词法环境中,那么你更可能会采用 nthe 一例中的方案。其中,内置的 labels special form (见 2.7 节) 会创建一个局部函数定义。和nthd 每次展开都会调用全局定义的函数 nth-fn 不同,nthe 每个展开式里的函数都用的是该展开式自己定制的版本。

尽管你无法将递归函数直接转化成宏,你却可以写出一个宏,让它的展开式是递归生成的。宏的展开函数就是普通的 Lisp 函数,理所当然也是可以递归的。例如,如果我们想自己定义内置 or ,那么就会用到一个递归展开的函数。


[示例代码 10.4] 递归的展开函数

(defmacro ora (&rest args)
  (or-expand args))

(defun or-expand (args)
  (if (null args)
    nil
    (let ((sym (gensym)))
      '(let ((,sym ,(car args)))
        (if ,sym
          ,sym
          ,(or-expand (cdr args)))))))

(defmacro orb (&rest args)
  (if (null args)
    nil
    (let ((sym (gensym)))
      '(let ((,sym ,(car args)))
        (if ,sym
          ,sym
          (orb ,@(cdr args)))))))

[示例代码 10.4] 给出的两个 or 定义,它们的内部实现都是递归地展开函数。宏 ora 调用递归函数or-expand 来生成展开式。这个宏能正常工作,并且与之等价的 orb 也一样可以完成任务。尽管orb 是递归的,但它是在宏的参数个数上做递归(这在宏展开期可以得到),而不依赖于它们的值(这在宏展开期无法得到)。也许,初看之下它的展开式里应该有一个对 orb 自己的引用,其实不然,orb 宏的展开,将会需要多步才能完成。【注 2】

每一步宏展开都会生成一个对 orb 的调用,这个调用将在下一步展开时替换成一个 let ,最后表达式里得到的则是一层套一层的 let;(orb x y) 展开成的代码和下式等价:

(let ((g2 x))
  (if g2
    g2
    (let ((g3 y))
      (if g3 g3 nil))))

事实上,ora 和 orb 是等价的,具体使用哪种风格不过是个人的喜好。

备注:

【注 1】'',(foo) 和 '(quote ,(foo)) 等价。

【注 2】译者注:这里改掉一个原书错误,nthc 应为 nthd 。

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号