Lisp 中,宏的特性让你能用变换的方式定义操作符。宏定义在本质上,是能生成 Lisp 代码的函数 -- 一个能写程序的程序。这一小小开端引发了巨大的可能性,同时也伴随着难以预料的风险。
第 7-10 章将带你走入宏的世界。本章会解释宏如何工作,介绍编写和调试它们的技术,然后分析一些宏风格中存在的问题。
由于我们可以调用宏并得到它的返回值,因此宏往往被人们和函数联系在一起。宏定义有时和函数定义相似,而且不严谨地说,被人们称为 "内置函数" 的 do
其实就是一个宏。但如果把两者过于混为一谈,就会造成很多困惑。宏和常规函数的工作方式截然不同,并且只有知道宏为何不同,以及怎样不同, 才是用好它们的关键。一个函数只产生结果,而宏却产生表达式。当它被求值时,才会产生结果。
要入门,最好的办法就是直接看个例子。假设我们想要写一个宏 nil!
,它把实参设置为 nil
。让(nil! x)
和 (setq x nil)
的效果一样。我们完成这个功能的方法是:把 nil!
定义成宏,让它来把前一种形式的实例变成后一种形式的实例:
> (defmacro nil! (var)
(list "setq var nil))
NIL!
用汉语转述的话,这个定义相当于告诉 Lisp: "无论何时,只要看到形如 (nil!)
的表达式,请在求值之前先把它转化成 (setq nil)
的形式。"
宏产生的表达式将在调用宏的位置求值。宏调用是一个列表,列表的第一个元素是宏的名称。当我们把宏调用 (nil! x)
输入到 toplevel
的时候发生了什么? Lisp 首先会发觉 nil!
是个宏的名字,然后:
按照上述定义的要求构造表达式,接着,
- 在调用宏的地方求值该表达式。
构造新表达式的那一步被称为宏展开(macro expansion)。Lisp 查找 nil!
的定义,其定义展示了如何为宏调用构建一个即将取代它的表达式。和函数一样,nil!
的定义也应用到了宏调用传给它的表达式参数上。
它返回一个三元素列表,这三个元素分别是: setq
、作为参数传递给宏的那个表达式,以及 nil
。在本例中,nil!
的参数是 x
,宏展开式是 (setq x nil)
。
宏展开之后是第二步:求值(evaluation)。Lisp 求值宏展开式 (setq x nil)
时就好像是你原本就写在那儿的一样。求值并不总是立即发生在展开之后,不过在 toplevel
下的确是这样的。一个发生在函数定义里的宏调用将在函数编译时展开,但展开式 或者说它产生的对象代码, 要等到函数被调用时才会求值。
如果把宏的展开和求值分清楚,你遇到的和宏有关的困难,或许有很多就能避免。当编写宏的时候,要清楚哪些操作是在宏展开期进行的,而哪些操作是在求值期进行的,通常,这两步操作的对象截然不同。宏展开步骤处理的是表达式,而求值步骤处理的则是它们的值。
有些宏的展开过程比 nil!
的情况更复杂。nil!
的展开式只是调用了一下内置的 special form
,但往往一个宏的展开式可能会是另一个宏调用,就好像是一层套一层的俄罗斯套娃。在这种情况下,宏展开就会继续抽丝剥茧直到获得一个没有宏的表达式。这一步骤中可以经过任意多次的展开操作,一直到最终停下来。
尽管有许多语言也提供了某种形式的宏,但 Lisp 宏却格外强大。在编译 Lisp 文件时,解析器先读取源代码,然后将其输出送给编译器。这里有个天才的手笔:解析器的输出由 Lisp 对象的列表组成。通过使用宏,我们可以操作这种处于解析器和编译器之间的中间状态的程序。如果必要的话,这些操作可以无所不包。一个生成展开式的宏拥有 Lisp 的全部威力,可任其驱驰。事实上,宏是货真价实的 Lisp 函数 那种能返回表达式的函数。虽然 nil!
的定义中只是调用了一下 list
,但其他宏里可能会驱动整个子程序来生成其展开式。
有能力改变编译器所看到的东西,差不多等同于能够对代码进行重写。所以我们就可以为语言增加任何构造,只要用变换的方法把它定义成已有的构造。
反引用(backquote)是引用(quote)的特别版本,它可以用来创建 Lisp 表达式的模板。反引用最常见的用途之一是用在宏定义里。
反引用字符 得名的原因是:它和通常的引号
"` 相似,只不过方向相反。当单独把反引用作为表达式前缀的时候,它的行为和引号一样:
`(a b c) 等价于 "(a b c)
只有在反引用和逗号 ,
以及 comma-at ,@
一同出现时才变得有用。如果说反引用创建了一个模板,那么逗号就在反引用中创建了一个槽(slot) 。一个反引用列表等价于将其元素引用起来,调用一次 list
。也就是:
`(a b c) 等价于 (list "a "b "c).
在反引用的作用域里,逗号要求 Lisp: "把引用关掉" 。当逗号出现在列表元素前面时,它的效果就相当于取消引用,让 Lisp 把那个元素按原样放在那里。所以:
`(a ,b c ,d) 等价于 (list "a b "c d)
插入到结果列表里的不再是符号 b ,取而代之的是它的值。无论逗号在嵌套列表里的层次有多深,它都仍然有效:
> (setq a 1 b 2 c 3)
3
> `(a ,b c)
(A 2 C)
> `(a (,b c))
(A (2 C))
而且它们也可以出现在引用的列表里,或者引用的子列表里:
> `(a b ,c (",(+ a b c)) (+ a b) "c "((,a "b)))
(A B 3 ("6) (+ A B) "C "((1 "B)))
一个逗号能抵消一个反引用的效果,所以逗号在数量上必须和反引用匹配。如果某个操作符出现在逗号的外层,或者出现在包含逗号的那个表达式的外层,那么我们说该操作符包围了这个逗号。例如在`(,a ,(b ",c))
中,最后一个逗号就被前一个逗号和两个反引号所包围。通行的规则是:一个被n
个逗号包围的逗号必须被至少 n + 1
个反引号所包围。很明显,由此可知:逗号不能出现在反引用的表达式的外面。只要遵守上述规则,就可以嵌套使用反引用和逗号。下面的任何一个表达式如果输入到 toplevel 下都将造成错误:
,x `(a ,,b c) `(a ,(b ,c) d) `(,,"a)
嵌套的反引用只有在宏定义的宏里才可能会用到。第 16 章将讨论这两个主题。
反引用通常被用来创建列表【注 1】。任何用反引用生成的列表也都可以用 list
和普通的引用来实现。使用反引用的好处只是在于它改进了表达式的可读性,因为反引用的表达式和它将生成的表达式很相似。在前一章里我们把 nil!
定义成:
(defmacro nil! (var)
(list "setq var nil))
借助反引用,这个宏可以定义成:
(defmacro nil! (var)
`(setq ,var nil))
在本例中,是否使用反引用的差别还不算太大。不过,随着宏定义长度的增加,反引用也会变得愈加重要。
[示例代码 7.1] 包含了两个 nif
可能的定义,这个宏实现了三路数值条件选择。【注 2】
[示例代码 7.1] 一个使用和不使用反引用的宏定义
使用反引用:
(defmacro nif (expr pos zero neg)
`(case (truncate (signum ,expr))
(1 ,pos)
(0 ,zero)
(-1 ,neg)))
不使用反引用:
(defmacro nif (expr pos zero neg)
(list "case
(list "truncate (list "signum expr))
(list 1 pos)
(list 0 zero)
(list -1 neg)))
首先,第一个参数会被求值成数字。然后会根据这个数字的正负、是否为零,来决定第二、第三和第四个参数中哪一个将被求值:
> (mapcar #"(lambda (x)
(nif x "p "z "n))
"(0 2.5 -8))
(Z P N)
[示例代码 7.1] 中的两个定义分别定义了同一个宏,但是前者使用的是反引用,而后者则通过显式调用 list
来构造它的展开式。以 (nif x "p "z "n)
为例,从第一个定义中很容易就能看出来,这个表达式会展开成:
(case (truncate (signum x))
(1 "p)
(0 "z)
(-1 "n))
因为这个宏定义体的模样就和它生成的宏展开式差不多。要想理解不使用反引用的第二个版本,你将不得不在脑海中重演一遍展开式的构造过程。
comma-at
,即 ,@
,是逗号的变形,其行为和逗号相似,但有一点不同:comma-at
不像逗号那样仅仅把表达式的值插入到所在的位置,而是把表达式拼接进去。拼接这个操作可以这样理解:在插入的同时,剥去被插入对象最外层的括号:
> (setq b "(1 2 3))
(1 2 3)
> `(a ,b c)
(A (1 2 3) C)
> `(a ,@b c)
(A 1 2 3 C)
逗号导致列表 (1 2 3)
被插入到 b
所在的位置,而 comma-at
把列表中的元素插入到那里。对于comma-at
的使用,还另有限制:
为了确保其参数可以被拼接,comma-at
必须出现在序列(sequence)【注 3】 中。形如",@b
的写法是错误的,因为无处可供 b
的值进行拼接。
"(a ,@1)
将被求值成 (a . 1)
,但如果尝试将原子【注 4】(atom) 拼接到列表的中间位置,例如 "(a ,@1 b)
,将导致一个错误。comma-at
一般用在接受不确定数量参数的宏里,以及将这些参数传给同样接受不确定数量参数的函数和宏里。这一情况通常广泛用于实现隐式的块(block)。Common Lisp 提供几种将代码分组到块的操作符,包括 block
、tagbody
,以及 progn
。这些操作符很少直接出现在源代码里;它们一般不显山露水,而是藏身在宏的背后。
隐式块出现在任何一个带有表达式体的内置宏里。例如 let
和 cond
里都有隐式的 progn
存在。做这种事情的内建宏里,最简单的一个可能要算 when
了:
(when (eligible obj)
(do-this)
(do-that)
obj)
如果 (eligible obj)
返回真,那么其余的表达式将会被求值,并且整个 when
表达式会返回其中最后一个表达式的值。下面是一个使用 comma-at
的示例,它是 when
的一种可能的实现:
(defmacro our-when (test &body body)
`(if ,test
(progn
,@body)))
这一定义使用了一个 &body
参数(它和 &rest
功能相同,只有美观输出的时候不太一样)来接受可变数量的参数,然后一个 comma-at
将它们拼接到一个 progn
表达式里。在上述调用的宏展开式里,宏调用体里面的三个表达式将出现在单个 progn
中:
(if (eligible obj)
(progn (do-this)
(do-that)
obj))
多数需要迭代处理其参数的宏都采用类似方式拼接它们。
comma-at
的效果也可以不用反引用实现。例如,表达式:
`(a ,@b c)
就和:
(cons "a (append b (list "c)))
等价。之所以用上 comma-at
,只是为了改进这种由表达式生成的表达式的可读性。
宏定义(通常)生成列表。尽管宏展开式可以用函数 list
来生成,但反引用的列表模板可以令这一任务更为简单。用 defmacro
和反引用定义的宏,在形式上和用 defun
定义的函数非常相似。只要不被这种相似性误导,反引用就能让宏定义既容易书写也方便阅读。
由于反引用经常出现在宏定义里,以致于人们有时误以为反引用是 defmacro
的一部分。关于反引用的最后一件要记住的事情,是它有自己存在的意义,这跟它在宏定义中的角色无关。你可以在任何需要构造序列的场合使用反引用:
(defun greet (name)
`(hello ,name))
在编程领域,最快的学习方式通常是尽快地开始实践。完全理论上的理解可以稍后再说。因此本章介绍一种可以立即开始编写宏的方法。虽然该方法的适用范围很窄,但在这个范围内却可以高度机械化地实现。
(如果你以前写过宏,可以跳过本节。)
下面举个例子,让我们考虑一下如何写出 Common Lisp 内置函数 member
的变形。member
缺省用 eql
来判断等价与否。如果你想要用 eq
来判断是否等价,你就必须显式写成这样:
(member x choices :test #"eq)
如果常常这样做,那我们可能会想要写一个 member
的变形,让它总是使用 eq
。有些早期的 Lisp 方言就有这样的一个函数,叫做 memq
:
(memq x choices)
通常应该将 memq
定义为内联(inline) 函数,但为了举例子,我们会让它以宏的面目出现。
[示例代码 7.2] 用于写 memq 的图示
调用:
(memq x choices)
展开:
(member x choices :test #"eq)
方法如下:从你想要定义的这个宏的一次典型调用开始。先把它写在纸上,然后下面写上它应该展开成的表达式。[示例代码 7.2] 给出了两个这样的表达式。通过宏调用,构造出你这个宏的参数列表,同时给每个参数命名。这个例子中有两个实参,所以我们将会有两个形参,把它们叫做 obj 和 lst :
(defmacro memq (obj lst)
现在回到之前写下的两个表达式。对于宏调用中的每个参数,画一条线把它和它在展开式里出现的位置连起来。[示例代码 7.2] 中有两条并行线。为了写出宏的实体,把你的注意力转移到展开式。让主体以反引用开头。
现在,开始逐个表达式地阅读展开式。每当发现一个括号,如果它不是宏调用中实参的一部分,就把它放在宏定义里。所以紧接着反引用会有一个左括号。对于展开式里的每个表达式
如果没有线将它和宏调用相连,那么就把表达式本身写下来。
由于第一个元素 member
上没有连接,所以我们照原样使用 member
:
(defmacro memq (obj lst)
"(member
不过,x
上有一条线指向源表达式中的第一个实参,所以我们在宏的主体中使用第一个参数,带一个逗号:
(defmacro memq (obj lst)
"(member ,obj
以这种方式继续进行,最后完成的宏定义是:
[示例代码 7.3] 用于写 while 的图示
(defmacro memq (obj lst)
`(member ,obj ,lst :test #"eq))
(while hungry
(stare-intently)
(meow)
(rub-against-legs))
(do ()
((not hungry))
(stare-intently)
(meow)
(rub-against-legs))
到目前为止,我们写出的宏,其参数个数只能是固定的。现在假设我们打算写一个 while
宏,它接受一个条件表达式和一个代码体,然后循环执行代码直到条件表达式返回真。[示例代码 7.3] 含有一个描述猫的行为的 while
循环示例。
要写出这样的宏,我们需要对我们的技术稍加修改。和前面一样,先写一个宏调用作为毛坯。然后,以它为基础,构造宏的形参列表,其中,在想要接受任意多个参数的地方,以一个 &rest
或 &body
形参作结:
(defmacro while (test &body body)
现在,在宏调用的下面写出目标展开式,并且和之前一样,画线把宏调用的形参和它们在展开式中的位置连起来。然而,当你碰到一个系列形参,而且它们会被 &rest
或 &body
实参吸收时,就要把它们当成一组处理,并只用一条线来连接整个参数序列。[示例代码 7.3] 给出了最后的展示。
为了写出宏定义的主体,按之前的步骤处理表达式。在前面给出的两条规则之外,我们还要加上一条:
&rest
或 &body
实参记下来,在前面加上 comma-at
。于是宏定义的结果将是:
(defmacro while (test &body body)
`(do ()
((not ,test))
,@body))
要想构造带有表达式体的宏,就必须有参数充当打包装箱的角色。这里宏调用中的多个参数被串起来放到 body
里,然后在 body
被拼接进展开式时,再把它拆散开。
用本章所述的这个方法,我们能写出最简单的宏 这种宏只能在参数位置上做文章。但是宏可以比这做的多得多。第 7.7 节将会举一个例子,这个例子无法用简单的反引用列表表达,并且为了生成展开式,例子中的宏成为了真正意义上的程序。
宏写好了,那我们怎么测试它呢?像 memq
这样的宏,它的结构较简单,只消看看它的代码就能弄清其行为方式。而当编写结构更复杂的宏时,我们必须有办法检查它们展开之后正确与否。
[示例代码 7.4] 给出了一个宏定义和用来查看其展开式的两个方法。内置函数 macroexpand
的参数是个表达式,它返回这个表达式的宏展开式。把一个宏调用传给 macroexpand
,就能看到宏调用在求值之前最终展开的样子,但是当你测试宏的时候,并不是总想看到彻底展开后的展开式。如果有宏依赖于其他宏,被依赖的宏也会一并展开,所以完全展开后的宏有时是不利于阅读的。
从[示例代码 7.4] 给出的第一个表达式,很难看出 while
是否如愿展开,因为不仅内置的宏 do 被展开了,而且它里面的 prog
宏也展开了。我们需要一种方法,通过它能看到只展开过一层宏的展开结果。这就是内置函数 macroexpand-1
的目的,正如第二个例子所示。就算展开后,得到的结果仍然是宏调用,macroexpand-1
也只做一次宏展开就停手。
[示例代码 7.4] 一个宏和它的两级展开
> (defmacro while (test &body body)
`(do ()
((not ,test))
,@body))
WHILE
> (pprint (macroexpand "(while (able) (laugh))))
(BLOCK NIL
(LET NIL
(TAGBODY
#:G61
(IF (NOT (ABLE)) (RETURN NIL))
(LAUGH)
(GO #:G61))))
T
> (pprint (macroexpand-1 "(while (able) (laugh))))
(DO NIL
((NOT (ABLE)))
(LAUGH))
T
[示例代码 7.5] 一个用于测试宏展开的宏
(defmacro mac (expr)
`(pprint (macroexpand-1 ",expr)))
如果每次查看宏调用的展开式都得输入如下的表达式,这会让人很头痛:
(pprint (macroexpand-1 "(or x y)))
[示例代码 7.5] 定义了一个新的宏,它让我们有一个简单的替代方法:
(mac (or x y))
调试函数的典型方法是调用它们,同样的道理,对于宏来说就是展开它们。不过由于宏调用涉及了两次计算,所以它也就有两处可能会出问题。如果一个宏行为不正常,大多数时候你只要检查它的展开式,就能找出有错的地方。不过也有一些时候,展开式看起来是对的,所以你想对它进行求值以便找出问题所在。
如果展开式里含有自由变量,你可能需要先设置一些变量。在某些系统里,你可以复制展开式,把它粘贴到 toplevel 环境里,或者选择它然后在菜单里选 eval。在最坏的情况下你也可以把 macroexpand-1 返回的列表设置在一个变量里,然后对它调用 eval :
> (setq exp (macroexpand-1 "(memq "a "(a b c))))
(MEMBER (QUOTE A) (QUOTE (A B C)) :TEST (FUNCTION EQ))
> (eval exp)
(A B C)
最后,宏展开不只是调试的辅助手段,它也是一种学习如何编写宏的方式。Common Lisp 带有超过一百个内置宏,其中一些还颇为复杂。通过查看这些宏的展开过程你经常能了解它们是怎样写出来的。
解构(destructuring) 是用在处理函数调用中的一种赋值操作【注 5】的推广形式。如果你定义的函数带有多个形参:
(defun foo (x y z)
(+ x y z))
当调用该函数时:
(foo 1 2 3)
函数调用中实参会按照参数位置的对应关系,赋值给函数的形参:1
赋给 x
,2
赋给 y
,3
赋给 z
。和本例中扁平列表 (x y z)
的情形类似,解构(destructuring) 同样也指定了按位置赋值的方式,不过它能按照任意一种列表结构来进行赋值。
Common Lisp 的 destructuring-bind
宏(CLTL2 新增) 接受一个匹配模式,一个求值到列表的实参,以及一个表达式体,然后在求值表达式时将模式中的参数绑定到列表的对应元素上:
> (destructuring-bind (x (y) . z) "(a (b) c d)
(list x y z))
(A B (C D))
这一新操作符和其它类似的操作符构成了第 18 章的主题。
在宏参数列表里进行解构也是可能的。Common Lisp 的 defmacro
宏允许任意列表结构作为参数列表。当宏调用被展开时,宏调用中的各部分将会以类似 destructuring-bind 的方式被赋值到宏的参数上面。内置的 dolist
宏就利用了这种参数列表的解构技术。在一个像这样的调用里:
(dolist (x "(a b c))
(print x))
展开函数必须把 x
和 "(a b c)
从作为第一个参数给出的列表里抽取出来。这个任务可以通过给dolist
适当的参数列表隐式地完成【注 6】:
(defmacro our-dolist ((var list &optional result) &body body)
"(progn
(mapc #"(lambda (,var) ,@body)
,list)
(let ((,var nil))
,result)))
在 Common Lisp 中,类似 dolist
这样的宏通常把参数包在一个列表里面,而后者不属于宏体。由于 dolist
接受一个可选的 result
参数,所以它无论如何都必须把它参数的第一部分塞进一个单独的列表。但就算这个多余的列表结构是画蛇添足,它也可以让 dolist
调用更易于阅读。假设我们想要定义一个宏 when-bind
,它的功能和 when
差不多,除此之外它还能绑定一些变量到测试表达式返回的值上。这个宏最好的实现办法可能会用到一个嵌套的参数表:
(defmacro when-bind ((var expr) &body body)
"(let ((,var ,expr))
(when ,var
,@body)))
然后这样调用:
(when-bind (input (get-user-input))
(process input))
而不是原本这样调用:
(let ((input (get-user-input)))
(when input
(process input)))
审慎地使用它,参数列表解构技术可以带来更加清晰的代码。最起码,它可以用在诸如 when-bind
和 dolist
这样的宏里,它们接受两个或更多的实参,和一个表达式体。
关于 "宏究竟做了什么" 的形式化描述将是既拖沓冗长,又让人不得要领的。就算有经验的程序员也记不住这样让人头晕的描述。想象一下 defmacro
是怎样定义的,通过这种方式来记忆它的行为会更容易些。
[示例代码 7.6] 一个 defmacro
的草稿
(defmacro our-expander (name) "(get ,name "expander))
(defmacro our-defmacro (name parms &body body)
(let ((g (gensym)))
`(progn
(setf (our-expander ",name)
#"(lambda (,g)
(block ,name
(destructuring-bind ,parms (cdr ,g)
,@body))))
",name)))
(defun our-macroexpand-1 (expr)
(if (and (consp expr) (our-expander (car expr)))
(funcall (our-expander (car expr)) expr)
expr))
在 Lisp 里用这种方法解释概念已由来已久。早在1962年首次出版的 Lisp 1.5 Programmer"s Manual
,就在书中给出了一个用 Lisp 写的 eval
函数的定义作为参考。由于 defmacro
自身也是宏,所以我们可以依法炮制,如 [示例代码 7.6] 所示。这个定义里使用了几种我们尚未提及的技术,所以某些读者可能需要稍后再回过头来读懂它。
[示例代码 7.6] 中的定义相当准确地再现了宏的行为,但就像任何草稿一样,它远非十全十美。它不能正确地处理 &whole
关键字。而且,真正的 defmacro
为它第一个参数的 macro-function
保存的是一个有两个参数的函数,两个参数分别为:宏调用本身,和其发生时的词法环境。还好,只有最刁钻的宏才会用到这些特性。
就算你以为宏就是像 [示例代码 7.6] 那样实现的,在实际使用宏的时候,也基本上不会出错。例如,在这个实现下,本书定义的每一个宏都能正常运行。
[示例代码 7.6] 的定义里产生的展开函数是个被井号引用过的 λ表达式。那将使它成为一个闭包:宏定义中的任何自由符号应该指向 defmacro
发生时所在环境里的变量。所以下列代码是可行的:
(let ((op "setq))
(defmacro our-setq (var val)
(list op var val)))
上述代码对 CLTL2 来说没有问题。但在 CLTL1 里,宏展开器是在空词法环境里定义的【注 7】,所以在一些老的 Common Lisp 实现里,这个 our-setq
的定义将不会正常工作。
宏定义并不一定非得是个反引用列表。宏的本质是函数,它把一个表达式转换成另一个表达式。这个函数可以调用 list
来生成结果,但是同样也可以调用一整个长达数百行代码的子程序达到这个目的。
第 7.3 节给出了一个编写宏的简易方案。借助这一技术,我们可以写出这样的宏,让它的展开式包含的子表达式和宏调用中的相同。不幸的是,只有最简单的宏才能满足这一条件。现在举个复杂一些的例子,让我们来看看内置的宏 do
。要把 do
实现成那种只是把参数重新排列一下的宏是不可能的。在展开过程中,必须构造出一些在宏调用中没有出现过的复杂表达式。
关于编写宏,有个更通用的方法:先想想你想要使用的是哪种表达式,再设想一下它应该展开成的模样,最后写出能把前者变换成后者的程序。可以试着手工展开一个例子,分析在表达式从一种形式变换到另一种形式的过程中,究竟发生了什么。从实例出发,你就可以大致明白在你将要写的宏里将需要做些什么工作。
[示例代码 7.7] do 的预期展开过程
(do ((w 3)
(x 1 (1+ x))
(y 2 (1+ y))
(z))
((> x 10) (princ z) y)
(princ x)
(princ y))
应该被展开成如下的样子:
(prog ((w 3) (x 1) (y 2) (z nil))
foo
(if (> x 10)
(return (progn (princ z) y)))
(princ x)
(princ y)
(psetq x (1+ x) y (1+ y))
(go foo))
[示例代码 7.7] 显示了 do
的一个实例,以及它应该展开成的表达式。手工进行展开有助于理清你对于宏工作方式的认识。例如,在试着写展开式时,你就不得不使用 psetq
来更新局部变量,如果没有手工写过展开式,说不定就会忽视这一点。
内置的宏 psetq
(因 "parallel setq" 而得名) 在行为上和 setq
相似,不同之处在于:在做任何赋值操作之前,它所有的(第偶数个) 参数都会被求值。如果是普通的 setq
,而且在调用时有两个以上的参数,那么在求值第四个参数的时候,第一个参数的新值将是可见的。
> (let ((a 1))
(setq a 2 b a)
(list a b))
(2 2)
这里,因为先设置的是 a
,所以 b
得到了它的新值,即 2
。而调用 psetq
时,应该就好像参数的赋值操作是并行的一样:
> (let ((a 1))
(psetq a 2 b a)
(list a b))
(2 1)
所以这里的 b
得到的是 a
原来的值。这个 psetq
宏是特别为支持类似 do
这样的宏而提供的,后者需要并行地对它们的一些参数进行求值。(如果这里使用的是setq
,而非 psetq
,那么最后定义出来的就不是 do
而是 do*
了。)
仔细观察展开式,还可以看出另一个问题,我们不能真的把 foo
作为循环标签使用。如果 do
宏里的循环标签也是 foo
呢?第 9 章将会具体解决这个问题;至于现在,只要在宏展开里面,用gensym
生成一个专门的匿名符号,然后把 foo
换成这个符号就行了。
[示例代码 7.8] 实现 do
(defmacro our-do (bindforms (test &rest result) &body body)
(let ((label (gensym)))
`(prog ,(make-initforms bindforms)
,label
(if ,test
(return (progn ,@result)))
,@body
(psetq ,@(make-stepforms bindforms))
(go ,label))))
(defun make-initforms (bindforms)
(mapcar #"(lambda (b)
(if (consp b)
(list (car b) (cadr b))
(list b nil)))
bindforms))
(defun make-stepforms (bindforms)
(mapcan #"(lambda (b)
(if (and (consp b) (third b))
(list (car b) (third b))
nil))
bindforms))
为了写出 do
,我们接下来考虑一下需要做哪些工作,才能把 [示例代码 7.7] 中的第一个表达式变换成第二个。要完成这种变换,如果只是像以前那样,把宏的参数放在某个反引用列表中的适当位置,是不可能的了,我们要更进一步。紧跟着最开始的prog 应该是一个由符号和它们的初始绑定构成的列表,而这些信息需要从传给 do
的第二个参数里拆解出来。[示例代码 7.8] 中的函数make-initforms
将返回这样的一个列表。我们还需要为 psetq
构造一个参数列表,但本例中的情况要复杂一些,因为并非所有的符号都需要更新。在[示例代码 7.8] 中,make-stepforms
会返回 psetq
需要的参数。有了这两个函数,定义的其它部分就易如反掌了。
[示例代码 7.8] 中的代码并不完全是 do
在真正的实现里的写法。为了强调在宏展开过程中完成的计算,make-initforms
和 make-stepforms
被分离出来,成为了单独的函数。在将来,这样的代码通常会留在 defmacro
表达式里。
通过这个宏的定义,我们开始领教到宏的能耐了。宏在构造表达式时,可以使用Lisp 所有的功能。而用来生成展开式的代码,其自身就可以是一个程序。
对于宏来说,良好的风格有着不同的含义。风格既体现在阅读代码的时候,也体现在 Lisp 求值代码的时候。宏的引入,使阅读和求值在稍有些不一样的场合下发生了。
一个宏定义牵涉到两类不同的代码,分别是:展开器代码,宏用它来生成其展开式,以及展开式代码,它出现在展开式本身的代码中。编写这两类代码所遵循的准则各不相同。通常,好的编码风格要求程序清晰并且高效。两类宏代码在这两点上侧重的方面截然相反:展开器代码更重视代码的结构清晰可读,而展开式代码对效率的要求更高一些。
效率,只有在编译了的代码里才是最重要的,而在编译了的代码里宏调用已经被展开了。就算展开器代码很高效,它也只会使得代码的编译过程稍微快一些,但这对程序运行的效率没有任何影响。
由于宏调用的展开只是编译器工作中很小的一部分,那些可以高效展开的宏通常甚至不会在编译速度上产生明显的差异。
所以大多数时候,你大可不必字句斟酌,只要像写一个程序的快速初版那样,编写宏展开代码就可以了。如果展开器代码做了一些不必要的工作或者做了很多 cons
,那又能怎样呢?你的时间最好花在改进程序的其他部分上面。如果在展开器代码里,要在可读性和速度两者之间作一个选择,可读性当然应该胜出。
宏定义通常比函数定义更难以阅读,因为宏定义里含有两种表达式的混合体,它们将在不同的时刻求值。
如果可以牺牲展开器代码的效率,让宏定义更容易读懂,那这笔买卖还是合算的。
[示例代码 7.9] 两个等价于 and 的宏
(defmacro our-and (&rest args)
(case (length args)
(0 t)
(1 (car args))
(t "(if ,(car args)
(our-and ,@(cdr args))))))
(defmacro our-andb (&rest args)
(if (null args)
t
(labels ((expander (rest)
(if (cdr rest)
"(if ,(car rest)
,(expander (cdr rest)))
(car rest))))
(expander args))))
举个例子,假设我们想要把一个版本的and 定义成宏。由于:
(and a b c)
等价于:
(if a (if b c))
我们可以像 [示例代码 7.9] 中的第一个定义那样,用 if
来实现 and
。根据我们评判普通代码的标准,our-and
写得并不好。因为它的展开器代码是递归的,而且在每次递归里都要需要计算同一个列表的每个后继 cdr
的长度。
如果这个代码希望在运行期求值,最好像 our-andb
那样定义这个宏,它没有做任何多余的计算,就生成了同样的展开式。虽然如此,作为一个宏定义来说,our-and
即使算不上好,至少还过得去。尽管每次递归都调用 length
,这样可能会比较没效率,但是其代码的组织方式更加清晰地说明了其展开式跟 and
的连接词数量之间的依赖关系。
凡事都有例外。在 Lisp 里,对编译期和运行期的区分是人为的,所以任何依赖于此的规则同样也是人为的。
在某些程序里,编译期也就是运行期。如果你在编写一个程序,它的主要目的就是进行代码变换,并且它使用宏来实现这个功能,那么一切就都变了:展开器代码成为了你的程序,而展开式是程序的输出。很明显,在这种情况下,展开器代码应该写得尽可能高效。尽管如此,还是可以说大多数展开器代码:
(a) 只会影响编译速度,而且
(b) 也不会影响太多
换句话说,代码的可读性几乎总是应该放在第一位。
对于展开式代码来说,正好相反。对宏展开式来说,代码可读与否不太重要,因为很少有人会去读它,而别人读这种代码的可能性更是微乎其微。平时严禁使用的 goto
在展开式里可以网开一面,备受冷眼的 setq
也可以稍微抬起头来。
结构化编程的拥护者不喜欢源代码里的 goto
。他们心目中的洪水猛兽并非机器语言里的跳转指令 前提是这些跳转指令是通过更抽象的控制结构隐藏在源代码里的。在 Lisp 里,goto
之所以备受责难,其实是因为很容易把它藏起来:你可以改用 do
,而且就算你没有 do
可用,还可以自己写一个。很明显,如果你打算在 goto
的基础上构建新抽象,goto
一定会存在于某些地方。因而,在新的宏定义中使用 goto
未必不好,前提是它不能用现成的宏来写。
类似地,不推荐使用 setq
的理由是:它让我们很难弄清楚一个给定变量的值是在哪里获得的。虽然这样,但是考虑到会去读宏展开式代码的人不是很多,所以对宏展开式里创建的变量使用 setq
也问题不大。如果你查看一些内置宏的展开式,你会看到许多 setq
。
在某些场合下,展开式代码的清晰性更重要一些。如果你在编写一个复杂的宏,你可能最后还是得阅读它的展开式,至少在调试的时候。
同样,在简单的宏里,只有一个反引用用来把展开器代码和展开式代码分开,所以,如果这样的宏生成了难看的展开式,那么这种惨不忍睹的代码在你的源代码里将会一览无余。
尽管如此,就算对展开式代码的可读性有了要求,效率仍然应该放在第一位。效率于大多数运行时代码都至关重要。而对宏展开来说尤为如此,这里有两个原因:宏的普遍性和不可见性。
宏通常用于实现通用的实用工具,这些工具会出现在程序的每个角落。如此频繁使用的代码是无法忍受低效的。一个宏,虽然看上去小小的,安全无害,但是在所有对它的调用都展开之后,可能会占据你程序的相当篇幅。
这样的宏得到的重视应当比因为它们的长度所获得的重视更多才对。
特别是要避免 cons
。一个实用工具,如果做了不必要的 cons
,那就会毁掉一个原本高效的程序。
关注展开式代码效率的另一个原因就是它非常容易被忽视。倘若一个函数实现得不好,那么每次查看其定义时,它都会向你坦陈这一事实。宏就不是这样了。展开式代码的低效率在宏的定义里可能并不显而易见,这也就是需要更加关注它的全部原因。
如果你重定义了一个函数,调用它的函数会自动用上新的版本【注 8】。 不过,这个说法对宏来说可就不一定成立了。当函数被编译时,函数定义中的宏调用就会替换成它的展开式。如果我们在主调函数编译以后,重定义那个宏会发生什么呢?由于对最初的宏调用的无迹可寻,所以函数里的展开式无法更新。该函数的行为将继续反映出宏的原来的定义:
> (defmacro mac (x) "(1+ ,x))
MAC
> (setq fn (compile nil "(lambda (y) (mac y))))
#<Compiled-Function BF7E7E>
> (defmacro mac (x) "(+ ,x 100))
MAC
> (funcall fn 1)
2
如果在定义宏之前,就已经编译了宏的调用代码,也会发生类似的问题。CLTL2 这样要求,"宏定义必须在其首次使用之前被编译器看到"。各家实现对违反这个规则的反应各自不同。幸运的是,这两类问题都能很容易地避免。如果能满足下面两个条件,你就永远不会因为过时或者不存在的宏定义而烦心:
在调用宏之前,先定义它。
有些人建议将程序中所有的宏都放在一个单独的文件里,以便保证宏定义被首先编译。这样有点过头了。
我们建议把类似 while
的通用宏放在单独的文件里,不过无论如何,通用的实用工具都应该和程序其余的部分分开,不论它们是函数还是宏。
某些宏只是为了用在程序的某个特定部分而写的,自然,这种宏应该跟使用它们的代码放在一起。只要保证每个宏的定义都出现在任何对它们的调用之前,你的程序就可以正确无误地编译。仅仅因为它们是宏,所以就把所有的宏集中写在一起,这样做不会有任何好处,只会让你的代码更难以阅读。
本节将说明把函数转化成宏的方法。将函数转化为宏的第一步是问问你自己是否真的需要这么做。难道,你就不能干脆把函数声明成 inline
(第 2.9 节) 吗?
话又说回来,"如何将函数转化为宏" 这个问题还是有其意义的。当你刚开始写宏的时候,假想自己写的是个函数,希望有助于思考,这样做有时会有用 而用这种办法编出来的宏一般多少会有些问题,但这至少可以帮助你起步。关注宏与函数之间关系的另一个原因是为了了解它们究竟有何不同。最后,Lisp 程序员有时确实需要把函数改造成宏。
函数转化为宏的难度取决于该函数的一些特性。最容易转化的一类函数有下面几个特点:
其函数体只有一个表达式。
其参数列表只由参数名组成。
不创建任何新变量(参数除外)。
不是递归的(也不属于任何相互递归的函数组)。
每个参数在函数体里只出现一次。
有一个函数满足这些规定,它是 Common Lisp 的内置函数 second
,second
返回列表的第二个元素。它可以定义成:
(defun second (x) (cadr x))
如此这般,可见它满足上述的所有条件,因而可以轻而易举地把它转化成等价的宏定义。只要把一个反引用放在函数体的前面,再把逗号放在每一个出现在参数列表里的符号前面就大功告成了:
(defmacro second (x) "(cadr ,x))
当然,这个宏也不是在所有相同条件下都可以使用。它不能作为 apply
或者 funcall
的第一个参数,而且被它调用的函数不能拥有局部绑定。不过,对于普通的内联调用,second
宏应该能胜任second
函数的工作。
倘若函数体里的表达式不止一个,就要把这个技术稍加变通,因为宏必须展开成单独的表达式。所以无法满足条件1,你必须加上一个 progn
。
函数 noisy-second
:
(defun noisy-second (x)
(princ "Someone is taking a cadr!")
(cadr x))
的功能也可以用下面的宏来完成:
(defmacro noisy-second (x)
"(progn
(princ "Someone is taking a cadr!")
(cadr ,x)))
如果函数没能满足条件 2 的原因是,因为它有 &rest
或者 &body
参数,那么道理是一样的,除了参数的处理有所不同,这次不能只是把逗号放在前面,而是必须把参数拼接到一个 list
调用里。照此办理的话:
(defun sum (&rest args)
(apply #"+ args))
就变成了:
(defmacro sum (&rest args)
"(apply #"+ (list ,@args)))
不过上面的宏如果改成这样写会更好些:
(defmacro sum (&rest args)
"(+ ,@args))
当条件 3 无法满足,即在函数体里创建了新变量时,插入逗号的步骤必须改一下。这时不能在参数列表里的所有符号前面放逗号了,取而代之,我们只把逗号加在那些引用了参数的符号前面。例如在:
(defun foo (x y z)
(list x (let ((x y))
(list x z))))
最后两个 x 的实例都没有指向参数 x 。第二个实例根本就不求值,而第三个实例引用的是由 let 建立的新变量。所以只有第一个实例才会有逗号:
(defmacro foo (x y z)
"(list ,x (let ((x ,y))
(list x ,z))))
有时无法满足条件 4,5 和 6 的函数也能转化为宏。不过,这些话题将在以后的章节里分别讨论。其中,第 10.4 节会解决宏里递归引出的问题,而第 10.1 节和 10.2 节将会分别化解多重求值和求值顺序不一致造成的危险。
至于条件 7,用宏模拟闭包并非痴人说梦,有种技术或许可以做到,它类似 3.4 节中提到的错误。但是由于这个办法有些取巧,和本书中名门正派的作风不大协调,因此我们就此点到为止。
CLTL2 为 Common Lisp 引入了一种新型宏,即符号宏(symbol-macro)。普通的宏调用看起来好像函数调用,而符号宏 "调用" 看起来则像一个符号。
符号宏只能在局部定义。symbol-macrolet
的 special form
可以在其体内,让一个孤立符号的行为表现和表达式相似:
> (symbol-macrolet ((hi (progn (print "Howdy")
1)))
(+ hi 2))
"Howdy"
3
symbol-macrolet 主体中的表达式在求值的时候,效果就像每一个参数位置的 hi
在之前都替换成了 (progn (print "Howdy") 1)
。
从理论上讲,符号宏就像不带参数的宏。在没有参数的时候,宏就成为了简单的字面上的缩写。不过,这并非是说符号宏一无是处。它们在第 15 章和第 18 章都用到了,而且在以后的例子中同样不可或缺。
+【注 1】反引用也可以用于创建向量(vector),不过这个用法很少在宏定义里出现。
+【注 2】这个宏的定义稍微有些不自然,这是为了避免使用 gensym 。在第 11.3 节上有一个更好的定义。
+【注 3】译者注:序列 (sequence) 是 Common Lisp 标准定义的数据类型,它的两个子类型分别是列表(list)和向量(vector)。
+【注 4】译者注:原子(atom) 也是 Common Lisp 标准定义的数据类型,所有不是列表的 Lisp 对象都是原子,包括向量(vector) 在内。
+【注 5】解构通常用在创建变量绑定,而非do 那样的操作符里。尽管如此,概念上来讲解构也是一种赋值的方式,如果你把列表解构到已有的变量而非新变量上是完全可行的。就是说,没有什么可以阻止你用解构的方法来做类似setq 这样的事情。
+【注 6】该版本用一种奇怪的方式来写以避免使用 gensym ,这个操作符以后会详细介绍。
+【注 7】关于这一区别实际有影响的例子,请参见第 4 章的注释。
+【注 8】编译时内联(inline) 的函数除外,它们和宏的重定义受到相同的约束。
FreeRTOS(读作"free-arr-toss")是一个嵌入式系统使用的开源实时操作系统。FreeRTOS被设计为“小巧,简单,和易用”,能支持许多...
作者:Susan Potter,翻译:张吉原文:http://www.aosabook.org/en/git.html6.1 Git概述Git能够让不同的协作者通过一个点对点的...