上一章:库
上一章实现了只定义了一个函数的库 newbie.el。事实上,这个函数可以不用定义成函数,定义成宏也可以,而且能让调用代码的执行效率微乎其微地更高一些。因为,调用函数,就像是去车站乘坐客车,而调用宏,犹如乘坐自家的私家车。这是一个不是很准确的比喻,所以它仅仅是个比喻。
定义宏
先定义一个什么也干不了的宏,
(defmacro foo ())
在形式上,定义宏,似乎跟定义函数差不多,只是 defun
换成了 defmacro
。
调用一个宏,也跟调用一个函数差不多,例如调用上述定义的什么也干不了的宏 foo
;
(foo)
对于这个宏调用,Elisp 的求值结果是 nil
。为什么是 nil
呢?因为 Elisp 解释器遇到宏调用语句,会用宏的定义替换它,此即宏的展开。上述 (foo)
语句会被替换为
就是什么都没有。什么都没有,就是 nil
。
倘若是让 foo
的定义有点什么,例如
(defmacro foo ()
t)
那么宏调用语句的展开结果就是 t
。
宏也可以像函数那样拥有参数,例如
(defmacro foo (x)
x)
宏调用 (foo "Hello world!")
的展开结果便是 "Hello world!"
。
像构造数据一样构造程序
宏的定义,展现的是 Lisp 语言的一个很重要的特性,在程序里可以像构造数据一样地构造程序。例如
(defmacro foo ()
(list '+ 1 2 3))
Elisp 解释器会对宏定义里的表达式予以求值。上述宏定义里的 (list '+ 1 2 3)
,求值结果就是 (+ 1 2 3)
。因此,宏调用语句 (foo)
会被 Elisp 解释器展开为 (+ 1 2 3)
,然后 Elisp 解释器会对宏的展开结果继续进行求值,因此 (foo)
的求值结果是 6。利用 Elisp 解释器对宏的定义和调用的处理机制,便可以在程序里像构造数据一样地构造程序。
由于 (list '+ 1 2 3)
与 '(+ 1 2 3)
近乎等价,因此上述宏定义可简化为
(defmacro foo ()
'(+ 1 2 3))
在宏的定义里使用引号构造程序要注意引号会屏蔽 Elisp 解释器对参数的处理。例如
(defmacro foo (x y z)
'(+ x y z))
这个宏的定义是合法的,但是若像下面这样调用它
(foo 1 2 3)
并不会被展开为 (+ 1 2 3)
,而是会被展开为 (+ x y z)
。因为 Elisp 在对宏定义求值时,认为宏定义里的 '(+ x y z)
只是一个字面意义上的列表,其中的 x
,y
, z
并非宏的参数值。因此,在宏的定义里,需要清楚,哪些是字面上的数据,哪些是变量或函数调用。对于上例,需要用回 list
,即
(defmacro foo (x y z)
(list '+ x y z))
如此,(foo 1 2 3)
便会被展开为
(+ 1 2 3)
反引号
宏定义
(defmacro foo (x y z)
(list '+ x y z))
与
(defmacro foo (x y z)
`(+ ,x ,y ,z))
同义。
引号 '
可以让一个列表整体变成字面意义上的列表,而反引号(通常在键盘上与 ~
位于同一键位)也可以让一个列表变成字面意义上的列表,但是倘若前面由 ,
修饰的符号,例如宏的参数,Elisp 解释器便不再将其视为字面意义上的符号了。
在反引号作用的列表里,,@
可将一个列表里的元素提升到外层列表,例如
`(1 ,@(list 2 3) 4)
和
`(1 ,@'(2 3) 4)
以及
`(1 ,@`(2 3) 4)
的求值结果皆为 (1 2 3 4)
。
利用这些奇怪的符号,在宏定义里像构造构造程序会更为便捷。
print! 宏
以下代码定义的宏
(defmacro print! (x)
`(progn
(princ ,x)
(princ "\n")))
可代替 newbie.el 里的 princ\'
,例如
(print! "Hello world!")
变量捕获
有些时候,需要在宏的定义里使用局部变量。例如
(defmacro bar (x y a)
`(let (z)
(if (< ,x ,y)
(setq z ,x)
(setq z ,y))
(+ ,a z)))
这个宏可将其参数 x
和 y
中较小者与 a
相加。例如
(bar 2 3 1)
求值结果为 3。
bar
的调用如果出现在一些巧合的环境里,例如
(let ((z 1))
(bar 2 3 z))
求值结果为 4,而不是 3。之所以会出现这种不符合预期的结果,是因为上述宏调用语句被展开为
(let ((z 1))
(let (z)
(if (< 2 3)
(setq z 2)
(setq z 3))
(+ z z)))
之所以会出现这样的展开结果,是因为 Elisp 解释器不会对宏参数进行求值,而是将其原样传入宏的定义,用它们去替换宏的参数。(bar 2 3 z)
的第三个参数是 z
,Elisp 解释器将这个参数原样传入 bar
的定义后,后者的参数 a
就被换成了 z
,但是 bar
的定义里有一个局部变量 z
,在最后的 (+ z z)
表达式里,第一个 z
本应是我传给 bar
的参数,但是 Elisp 解释器在这种情况下,会认为它是 bar
的局部变量,于是,计算结果便不符合我的预期了。
卫生宏
能保证宏定义里的局部变量不与宏展开环境里外部变量产生混淆的宏,称为「卫生宏」。Elisp 的宏不卫生。同为 Lisp 方言的 Scheme 语言提供了卫生宏。近年来,新兴的 Rust 语言也支持卫生宏。不过,Elisp 可以利用体制外(Uninterned)的符号模拟卫生宏。
Elisp 解释器在对程序解释执行的过程中,会维护一些存储着符号的表,这些符号要么是绑定了数据,要么是绑定了函数,要么是绑定了宏。出现在这些表里的符号,就是体制内的(Interned),没出现在这个表里的符号,就是体制外的。使用 Elisp 函数 make-symbol
可以创建体制外的符号。例如
(setq z 3)
(setq other-z (make-symbol "z"))
第一个表达式里的 z
是绑定到数字 3 的符号,它是体制内的,而 make-symbol
创建的符号也叫 z
,但它是体制外的,我用一个体制内的符号 other-z
绑定了这个体制外的也叫 z
的符号。利用这个 other-z
绑定的体制外的 z
符号,便可以令上一节定义的宏 bar
变得卫生,即
(defmacro bar (x y a)
(let ((other-z (make-symbol "z")))
`(progn
(if (< ,x ,y)
(setq ,other-z ,x)
(setq ,other-z ,y))
(+ ,a ,other-z))))
bar
的新定义再也不怕变量捕捉了。试试看,
(let ((other-z 1))
(bar 2 3 other-z))
在上述调用 bar
的语句里,虽然第三个参数与 bar
定义里的局部变量 other-z
同名,但是不会再发生变量捕捉的情况了,因而上述代码的求值结果为 3。
重新定义的 bar
是如何避免变量捕捉的呢?要理解这一切,就要对 Elisp 如何对宏的定义进行求值有深刻的理解。首先,Elisp 解释器会对宏定义里的任何一个表达式进行求值,倘若想禁止它对某个表达式求值,那就需要用引号。用引号修饰的表达式,Elisp 解释器会将其视为常量。但是,通过反引号以及逗号,可以在 Elisp 视为常量的表达式里开辟一些可变之处,后者便是重新定义的 bar
能避免变量捕捉的关键,因为 Elisp 对宏定义的常量部分不会求值,但是常量里可变的地方会进行求值。这就相当于,在宏定义里,可以让一段代码处于「静止」的状态,而让这段代码里的部分区域是可以被 Elisp 解释器修改成我们需要的结果。
bar
的定义里会原本会发生变量捕捉的语句是
(+ ,a ,other-z)
由于 other-z
已经是在 let
表达式的开头将其绑定到一个体制外的符号 z
了,所以 Elisp 解释器在对宏定义求值时,会认为所有的 ,other-z
视为(或求值为)这个体制外的符号 z
,亦即等 bar
调用语句被 Elisp 展开后,符号 other-z
已经不是 other-z
了,而是那个体制外的 z
。在 bar
的定义里,作为局部变量的 other-z
绝无可能再与外部同名的变量产生混淆了。这就是 Elisp 语言构造卫生宏的办法。
事实上,在上述 bar
的定义里,我根本没必要使用 other-z
,完全可以像下面这样定义 bar
:
(defmacro bar (x y a)
(let ((z (make-symbol "z")))
`(progn
(if (< ,x ,y)
(setq ,z ,x)
(setq ,z ,y))
(+ ,a ,z))))
在上述代码的 let
表达式里,体制内的符号 z
绑定到体制外的符号 z
,然后在后续的代码里,,z
皆会被 Elisp 解释器求值为体制外的符号 z
,如此一来,以下宏调用语句
(let ((z 1))
(bar 2 3 z))
求值结果符合预期,为 3。
体制外的,有助于卫生建设。
结语
本章仅介绍了 Elisp 宏最为浅显的知识,它真正的用武之地是为 Elisp 语言定义新的语法(这种方式通常称为元编程),而非定义 print!
这种原本就可以用函数轻易实现的东西。