第 8 章 何时使用宏

2018-02-24 15:54 更新

第 8 章 何时使用宏

我们如何知道一个给定的函数是否真的应该是函数,而不是宏呢?多数时候,会很容易分清楚在哪种情况下需要用到宏,哪种情况不需要。缺省情况下,我们应该用函数,因为如果函数能解决问题,而偏要用上宏的话,会让程序变得不优雅。我们应当只有在宏能带来特别的好处时才使用它们。

什么情况下,宏能给我们带来优势呢?这就是本章的主题。通常这不是锦上添花,而是一种必须。大多数我们用宏可以做到的事情,函数都无法完成。第 8.1 节列出了只能用宏来实现的几种操作符。尽管如此,也有一小类(但很有意思的) 情况介于两者之间,对它们来说,不管把操作符实现成函数还是宏似乎都言之有理。对于这种情况,第 8.2 节给出了关于宏的正反两方面考量。最后,在充分考察了宏的能力后,我们在第 8.3 节里转向一个相关问题:人们都用宏干什么?

8.1 当别无他法时

优秀设计的一个通用原则就是:当你发现在程序中的几处都出现了相似的代码时,就应该写一个子例程,并把那些相似的语句换成对这个子例程的调用。如果也把这条原则用到 Lisp 程序上,就必须先决定这个 "子例程" 应该是函数还是宏。

有时,可以很容易确定应当写一个宏而不是函数,因为只有宏才能满足需求。一个像 1+ 这样的函数或许既可以写成函数也可以写成宏:

(defun 1+ (x) (+ 1 x))
(defmacro 1+ (x) '(+ 1 ,x))

但是来自第 7.3 节的 while ,则只能被定义成宏:

(defmacro while (test &body body)
  '(do ()
    ((not ,test))
    ,@body))

无法用函数来重现这个宏的行为。while 的定义里拼接了一个作为 body 传入 do 的主体里的表达式,它只有当 test 表达式返回 nil 时才会被求值。没有函数可以做到这一点;是因为在函数调用里,所有的参数在函数调用开始之前就会被求值。

当你需要用宏时,你看中了它哪一点呢?宏有两点是函数无法做到的:宏可以控制(或阻止) 对其参数的求值,并且它可以展开进入到主调方的上下文中。任何需要宏的应用,归根到底都是要用上述两个属性中的至少一个。

"宏不对其参数进行求值",这个非正式的说法不太准确。更确切的说法应该是,"宏能控制宏调用中参数的求值"。取决于参数在宏展开式中的位置,它们可以被求值一次,多次,或者根本不求值。宏的这种控制主要体现在四个方面:

1. 变换

Common Lisp 的 setf 宏就是这类宏中的一员,它们在求值前都会对传入的参数严加检查。内置的访问函数(access function) 通常都有一个对应的逆操作,其作用是对该访问函数所获取的对象赋值。car 的逆操作是 rplaca ,对于 cdr 来说是 rplacd ,等等。有了 setf ,我们就可以把对这些访问函数的调用当成变量赋值。(setf (car x) 'a) 就是个例子,这个表达式可以展开成 (progn (rplaca x 'a) 'a).

为了有这样的效果,setf 必须非常了解它的第一个参数。如果要知道上述的情况需要用到 rplaca ,

setf 就得清楚它的第一个参数是个以 car 开始的表达式。这样的话,setf 以及其他修改参数的操作符,就必须被写成宏。

2. 绑定

词法变量必须在源代码中直接出现。例如,由于 setq 的第一个参数是不求值的,所以,所有在setq 之上构建的东西都必须是展开到setq 的宏,而不能是调用它的函数。对于 let 这样的操作符也是如此,它的实参必须作为 lambda 表达式的形参出现,还有类似 do 这样展开到 let 的宏也是这样,等等。任何新操作符,只要它修改了参数的词法绑定,那么它就必须写成宏。

3. 条件求值

函数的所有参数都会被求值。在像 when 这样的结构里,我们希望一些参数仅在特定条件下才被求值。只有通过宏才可能获得这种灵活性。

4. 多重求值

函数的所有参数不但都会被求值,而且求值的次数都正好是一次。我们需要用宏来定义像 do 这样的结构,这样子,就可以对特定的参数多次求值。

也有几种方式可以利用宏产生的内联展开式带来的优势。这里必须强调一点,宏展开后生成的展开式将会出现在宏调用所在的词法环境之中,因为下列三种用法有两种都基于这个事实。它们是:

5. 利用调用方环境

宏生成的展开式可以含有这样的变量,变量的绑定来自宏调用的上下文环境。下面这个宏:

(defmacro foo (x)
  '(+ ,x y))

的行为将因 foo 被调用时 y 的绑定而不同。

这种词法交流通常更多地被视为瘟疫的传染源,而非快乐之源。一般来说,写这样的宏不是什么好习惯。函数式编程的思想对于宏也同样适用:与一个宏交流的最佳方式就是通过它的参数。事实上,需要用到调用方环境的情况极少,因此,如果出现了这样的用法,那十有八九就是什么地方出了问题。(见第9 章)

纵观本书中的所有宏,只有续延传递(continuation-passing)宏(第 20 章)和 ATN 编译器(第23 章)的一部分以这种方式利用了调用方环境。

6. 包装新环境

宏也可以使其参数在一个新的词法环境下被求值。最经典的例子就是let ,它可以用lambda 实现成宏的形式(见 11.1 节)。在一个 (let ((y 2)) (+ x y)) 这样的表达式里,y 将指向一个新的变量。

7. 减少函数调用

宏展开后,展开式内联地插入展开环境。这个设计的第三个结果是宏调用在编译后的代码中没有额外开销。到了运行期,宏调用已经替换成了它的展开式。(这个说法对于声明成inline 的函数也一样成立。)

很明显,如果不是有意为之,情形 5 和 6 将产生变量捕捉上的问题,这可能是宏的编写者所有担心的事情里面最头疼的一件。变量捕捉将在第 9 章讨论。

与其说有七种使用宏的方式,不如说有六个半。在理想的世界里,所有 Common Lisp 编译器都会遵守 inline 声明,所以减少函数调用将是内联函数的职责,而不是宏的。这个建立理想世界的重任就作为练习留给读者吧。

8.2 宏还是函数?

上一节解决了较简单的一类问题。一个操作符,倘若在参数被求值前就需要访问它,那么这个操作符就应该写成宏,因为别无他法。那么,如果有操作符用两种写法都能实现,那该怎么办呢?

比如说操作符 avg 。它返回参数的平均值。它可以定义成函数:

(defun avg (&rest args)
  (/ (apply #'+ args) (length args)))

但把它定义成宏也不错:

(defmacro avg (&rest args)
  '(/ (+ ,@args) ,(length args)))

因为每次调用 avg 的函数版本时,都毫无必要地调用了一次 length。在编译期我们可能不清楚这些参数的值,但却知道参数的个数,所以那是调用 length 最佳的时机。当我们面临这样的选择时,可以考虑下列几点:

利:

  1. 编译期计算。宏调用共有两次参与计算,分别是:宏展开的时候,以及展开式被求值的时候。一旦程序编译好,Lisp 程序中所有的宏展开也就完成了,而在编译期每进行一次计算,都帮助程序在运行的时候卸掉了一个包袱。如果在编写操作符时,可以让它在宏展开的阶段就完成一部分工作,那么把它写成宏将会让程序更加高效。因为只要是聪明的编译器无法自己完成的工作,函数就只能把这些事情拖到运行期做。第13章介绍一些类似avg 的宏,这些宏能在宏展开的阶段就完成一部分工作。

  2. 和Lisp 的集成。有时,用宏代替函数可以令程序和Lisp 集成得更紧密。解决一个特定问题的方法,可以是专门写一个程序,你也可以用宏把这个问题变换成另一个Lisp 已经知道解决办法的问题。如果可行的话,这种方法常常可以使程序变得更短小,也更高效:更小是因为Lisp 代劳了一部分工作,更高效则是因为产品级Lisp 系统通常比用户程序做了更多的优化。这一优势大多时候会出现在嵌入式语言里,而我们从第19章起会全面转向嵌入式语言。

  3. 免除函数调用。宏调用在它出现的地方直接展开成代码。所以,如果你把常用的代码片段写成宏,那么就可以每次在使用它的时候免去一次函数调用。在Lisp 的早期方言中,程序员借助宏的这个属性在运行期避免函数调用。而在Common Lisp 里,这个差事应该由声明成 inline 类型的函数接手了。

通过将函数声明成inline,你要求把这个函数就像宏一样,直接编译进调用方的代码。不过,理想和现实还是有距离的; 2(229 页) 说 "编译器可以随意地忽略该声明",而且某些 Common Lisp 编译器确实也是这样做的。

在某些情况下,效率因素和跟Lisp 之间紧密集成的组合优势可以充分证实使用宏的必要性。在第19章的查询编译器里,可以转移到编译期的计算量相当可观,这使我们有理由把整个程序变成一个独立的巨型宏。尽管效率是初衷,这一转移同时也让程序和Lisp 走得更近:在新版本里,能更容易地使用Lisp 表达式,比如说可以在查询的时候用Lisp 的算术表达式。

弊:

  1. 函数即数据,而宏在编译器看来,更像是一些指令。函数可以当成参数传递(例如用 apply),被函数返回,或者保存在数据结构里。但这些宏都做不到。

有的情况下,你可以通过将宏调用封装在 lambda 表达式里来达到目的。如果你想用 apply 或 funcall 来调用某些的宏,这样是可行的,例如:

> (funcall #'(lambda (x y) (avg x y)) 1 3)
2

不过这样做还是有些麻烦。而且它有时还无法正常工作:如果这个宏带有&rest 形参,那么就无法给它传递可变数量的实参,avg 就是个例子。

  1. 源代码清晰。宏定义和等价的函数定义相比更难阅读。所以如果将某个功能写成宏只能稍微改善程序,那么最好还是改成使用函数。

  2. 运行期清晰。宏有时比函数更难调试。如果你在含有许多宏的代码里碰到运行期错误,那么你在 backtrace 里看到的代码将包含所有这些宏调用的展开式,而它们和你最初写的代码看起来可能会大相径庭。

并且由于宏展开以后就消失了,所以它们在运行时是看不到的。你不是总能使用 trace 来分析一个宏的调用过程。如果 trace 真的奏效的话,它展示给你的只是对宏展开函数的调用,而非宏调用本身的调用。

  1. 递归。在宏里使用递归不像在函数里那么简单。尽管展开一个宏里的展开函数可能是递归的,但展开式本身可能不是。第 10.4 节将处理跟宏里的递归有关的主题。

在决定何时使用宏的时候需要权衡利弊,综合考虑所有这些因素。只有靠经验才能知道哪一个因素在起主导作用。尽管如此,出现在后续章节里的宏的示例涵盖了大多数对宏有利的情形。如果一个潜在的宏符合这里给出的条件,那么把它写成这样可能就是合适的。

最后,应该注意运行期清晰(观点 6) 很少成为障碍。调试那种用很多宏写成的代码并不像你想象的那样困难。如果一个宏的定义长达数百行,在运行期调试它的展开式的确是件苦差事。但至少实用工具往往出现在小而可靠的程序层次中。通常它们的定义长度不超过 15 行。所以就算你最终只得仔细检查一系列的 backtrace ,这种宏也不会让你云遮雾绕,摸不着头脑。

8.3 宏的应用场合

在了解了宏的十八般武艺之后,下一个问题是:我们可以把宏用在哪一类程序里?关于宏的用途,最正式的表述可能是:它们主要用于句法转换(syntactic transformations)。这并不是要严格限制宏的使用范围。由于 Lisp 程序从列表中生成,而列表是 Lisp 数据结构,"句法转换" 的确有很大的发挥空间。第 19-24 章展示的整个程序,其目的就可以说成 "句法转换",而且从效果上看,所有宏莫不是如此。

宏的种种应用一起织成了一条缎带,这些应用涵盖了从像 while 这样小型通用的宏,直到后面章节定义的大型、特殊用途的宏。缎带的一端是实用工具,它们和每个 Lisp 都内置的那些宏是一样的。它们通常短小、通用,而且相互独立。尽管如此,你也可以为一些特别类型的程序编写实用工具,然后当你有一组宏用于,比如说,图形程序的时候,它们看起来就像是一种专门用于图形编程的语言。在缎带的远端,宏允许你用一种和 Lisp 截然不同的语言来编写整个程序。以这种方式使用宏的做法被称为实现嵌入式语言。

实用工具是自底向上风格的首批成果。甚至当一个程序规模很小而不必分层构建时,它也仍然能够对程序的最底层,即 Lisp 本身加以扩充,并从中获益。nil! 将其参数设置为 nil ,这个实用工具只能定义成宏:

(defmacro nil! (x)
  '(setf ,x nil))

看到 nil! ,可能有人会说它什么都做不了,无非可以让我们少输入几个字罢了。是的,但是充其量,宏所能做的也就是让你少打些字而已。如果有人非要这样想的话,那么其实编译器的工作也不过是让人们用机器语言编程的时候可以少些。不可低估实用工具的价值,因为它们的功用会积少成多:几层简单的宏拉开了一个优雅的程序和一个晦涩的程序之间的差距。

多数实用工具都含有模式。当你注意到代码中存在模式时,不妨考虑把它写成实用工具。模式是计算机最擅长的。为什么有程序可以代劳,还要自己动手呢?假设在写某个程序的时候,你发现自己以同样的通用形式在很多地方做循环操作:

(do ()
  ((not <condition>))
  . <body of code>)

从列表中生成,是指列表作为编译器的输入。函数不再从列表中生成,虽然在一些早期的方言里的确是这样处理的。

当你在自己的代码里发现一个重复的模式时,这个模式经常会有一个名字。这里,模式的名字是 while 。如果我们想把它作为实用工具提供出来,那么只能以宏的形式,因为需要用到带条件判断的求值,和重复求值。倘若用第 7.4 节的定义实现 while ,如下:

(defmacro while (test &body body)
  '(do ()
    ((not ,test))
    ,@body))

就可以将该模式的所有实例替换成:

(while <condition>
  . <body of code>)

这样做使得代码更简短,同时也更清晰地表明了程序的意图。

宏的这种变换参数的能力使得它在编写接口时特别有用。适当的宏可以在本应需要输入冗长复杂表达式的地方只输入简短的表达式。尽管图形界面减少了为最终用户编写这类宏的需要,程序员却一直使用这种类型的宏。最普通的例子是 defun ,在表面上,它创建的函数绑定类似用 Pascal 或 C 这样的语言定义的函数。第 2 章提到下面两个表达式差不多具有相同的效果:

(defun foo (x) (* x 2))

(setf (symbol-function 'foo)
  #'(lambda (x) (* x 2)))

这样 defun 就可以实现成一个将前者转换成后者的宏。我们可以想象它会这样写:

(defmacro our-defun (name parms &body body)
  '(progn
    (setf (symbol-function ',name)
      #'(lambda ,parms (block ,name ,@body)))
    ',name))

像 while 和 nil! 这样的宏可以被视为通用的实用工具。任何 Lisp 程序都可以使用它们。但是特定的领

域同样也可以有它们自己的实用工具。没有理由认为扩展编程语言的唯一平台只能是原始的 Lisp。举个例子,如果你正在编写一个  程序,有时,最佳的实现可能会把它写成两层:一门专用于 CAD 程序的语言

(或者如果你偏爱更现代的说法,一个工具箱(toolkit)),以及在这层之上的,你的特定应用。

Lisp 模糊了许多对其他语言来说理所当然的差异。在其他语言里,在编译期和运行期,程序和数据,以及语言和程序之间具有根本意义上的差异。而在 Lisp 里,这些差异就退化成了口头约定。例如,在语言和程序之间就没有明确的界限。你可以根据手头程序的情况自行界定。因而,是把底层代码称作工具箱,还是称之为语言,确实不过是个说法而已。将其视为语言的一个好处是,它暗示着你可以扩展这门语言,就像你通过实用工具来扩展 Lisp 一样。

设想我们正在编写一个交互式的 2D 绘图程序。为了简单起见,我们将假定程序处理的对象只有线段,每条线段都表示成一个起点 和一个向量 。并且我们的绘图程序的任务之一是平移一组对象。

这正是 [示例代码 8.1] 中函数 move-objs 的任务。出于效率考虑,我们不想在每个操作结束后重绘整个屏幕 只画那些改变了的部分。因此两次调用了函数 bounds ,它返回表示一组对象的矩形边界的四个坐标(最小x ,最小y ,最大x ,最大y)。move-objs 的操作部分被夹在了两次对 bounds 调用的中间,它们分别找到平移前后的矩形边界,然后重绘整个区域。

函数 scale-objs 被用来改变一组对象的大小。由于区域边界可能随缩放因子的不同而放大或者缩小,这个函数也必须在两次 bounds 调用之间发生作用。随着我们绘图程序开发进度的不断推进,这个模式一次又一次地出现在我们眼前:在旋转,翻转,转置等函数里。

通过一个宏,我们可以把这些函数中相同的代码抽象出来。[示例代码 8.2] 中的宏with-redraw 给出了一个框架,

它是图 8.1 中几个函数所共有的。 这样的话,这些函数每一个的定义都缩减到了四行代码,如图 8.2 末尾


[示例代码 8.1] 最初的平移和缩放

(defun move-objs (objs dx dy)
  (multiple-value-bind (x0 y0 x1 y1) (bounds objs)
    (dolist (o objs)
      (incf (obj-x o) dx)
      (incf (obj-y o) dy))
    (multiple-value-bind (xa ya xb yb) (bounds objs)
      (redraw (min x0 xa) (min y0 ya)
        (max x1 xb) (max y1 yb)))))

(defun scale-objs (objs factor)
  (multiple-value-bind (x0 y0 x1 y1) (bounds objs)
    (dolist (o objs)
      (setf (obj-dx o) (* (obj-dx o) factor)
        (obj-dy o) (* (obj-dy o) factor)))
    (multiple-value-bind (xa ya xb yb) (bounds objs)
      (redraw (min x0 xa) (min y0 ya)
        (max x1 xb) (max y1 yb)))))

[示例代码 8.2] 骨肉分离后的平移和缩放

(defmacro with-redraw ((var objs) &body body)
  (let ((gob (gensym))
      (x0 (gensym)) (y0 (gensym))
      (x1 (gensym)) (y1 (gensym)))
    '(let ((,gob ,objs))
      (multiple-value-bind (,x0 ,y0 ,x1 ,y1) (bounds ,gob)
        (dolist (,var ,gob) ,@body)
        (multiple-value-bind (xa ya xb yb) (bounds ,gob)
          (redraw (min ,x0 xa) (min ,y0 ya)
            (max ,x1 xb) (max ,y1 yb)))))))

(defun move-objs (objs dx dy)
  (with-redraw (o objs)
    (incf (obj-x o) dx)
    (incf (obj-y o) dy)))

(defun scale-objs (objs factor)
  (with-redraw (o objs)
    (setf (obj-dx o) (* (obj-dx o) factor)
      (obj-dy o) (* (obj-dy o) factor))))

所示。通过这两个函数,这个新写的宏在简洁性方面作出的贡献证明了它是物有所值的。并且,一旦把屏幕重绘的细节部分抽象出来,这两个函数就变得清爽多了。

对 with-redraw ,有一种看法是把它视为一种语言的控制结构,这种语言专门用于编写交互式的绘图程序。

随着我们开发出更多这样的宏,它们不管从名义上,还是在实际上都会构成一门专用的编程语言,并且我们的程序也将开始表现出其不俗之处,这正是我们用特制的语言撰写程序所期望的效果。

宏的另一主要用途就是实现嵌入式语言。Lisp 在编写编程语言方面是一种特别优秀的语言,因为Lisp 程序可以表达成列表,而且Lisp 还有内置的解析器(read) 和编译器(compile) 可以用在以这种方式表达的程序中。多数时候甚至不用调用 compile ;你可以通过编译那些用来做转换的代码(第 2.9 节),让你的嵌入式语言在无形中完成编译。

这个宏的定义使用了下一章才出现的 gensym 。它的作用接下来就会说明。

与其说嵌入式语言是构建于 Lisp 之上的语言,不如说它是和Lisp 融为一体的,这使得其语法成为了一个 Lisp 和新语言中特有结构的混合体。实现嵌入式语言的初级方式是用Lisp 给它写一个解释器。有可能的话,一个更好的方法是通过语法转换实现这种语言:将每个表达式转换成 Lisp 代码,然后让解释器可以通过求值的方式来运行它。这就是宏大展身手的时候了。宏的工作恰恰是将一种类型的表达式转换成另一种类型,所以在编写嵌入式语言时,宏是最佳人选。

一般而言,嵌入式语言可以通过转换实现的部分越多越好。主要原因是可以节省工作量。举个例子,如果新语言里含有数值计算,那你就无需面对表示和处理数值量的所有细枝末节。如果 Lisp 的计算功能可以满足你的需要,那么你可以简单地将你的算术表达式转换成等价的Lisp 表达式,然后将其余的留给 Lisp 处理。

代码转换通常都会提高你的嵌入式语言的效率。而解释器在速度方面却一直处于劣势。当代码里出现循环时,通常每次迭代解释器都必须重新解释代码,而编译器却只需做一次编译。因此,就算解释器本身是编译的,使用解释器的嵌入式语言也会很慢。但如果新语言里的表达式被转换成了 Lisp,那么 Lisp 编译器就会编译这些转换出来的代码。这样实现的语言不需要在运行期承受解释的开销。要是你还没有为你的语言编写一个真正编译器,宏会帮助你获得最优的性能。事实上,转换新语言的宏可以看作该语言的编译器 -- 只不过它的大部分工作是由已有的 Lisp 编译器完成的。

这里我们暂时不会考虑任何嵌入式语言的例子,第19-25 章都是关于该主题的。第 19 章专门讲述了解释与转换嵌入式语言之间的区别,并且同时用这两种方法实现了同一种语言。

有一本 Common Lisp 的书断言宏的作用域是有限的,依据是:在所有 CLTL1 里定义的操作符中,只有少于 10% 的操作符是宏。这就好比是说因为我们的房子是用砖砌成的,我们的家具也必须得是。宏在一个 Common Lisp 程序中所占的比例多少完全要看这个程序想干什么。有的程序里可能根本没有宏,而有的程序可能全是宏。

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

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号