HOME BLOG

emacs 的 macro

在某篇文章中介绍 debug 相关的内容后,我本应该直接开始学习并记录测试相关的知识,但在 emacs-china 上读到这个帖子1后,我觉得应该先学一下 emacs 的 byte compile。macro 和字节编译也是有关系的,所以在这篇文章中我们还是先谈谈 macro 吧。

宏可以用来定义新的控制结构和其他的语言特性。宏定义起来很像函数,但相比于函数是为了求值,宏是为了得到另一个 Lisp 表达式,它在随后才被用来求值。得到新表达式的过程被称为宏的 展开

光是宏本身的话其实没什么好谈的,不就是宏的定义与展开吗?但是弄清楚了这两点并不能让你迅速熟练宏的使用,毕竟 the best way to learn write macro is to write macro 。在本文中,除了向你介绍宏的基本使用方法外,我还会介绍一些简单的例子,做一些补充说明。

1. 宏与函数的区别

在 emacs 中,我们使用 defmacro 来定义宏,它的语法是:

(defmacro name args [doc] [declare] body)

除了说没有 interactive 部分外,其余部分和 defun 几乎一模一样。elisp 的宏对象是一张表,它的 car 部分是符号 macro ,cdr 部分是一个函数。就像这样:

(defmacro yymac (a) (list 'list a))
(symbol-function 'yymac)
=> (macro lambda (a) (list 'list a))

(yymac 'hello) => (hello)

既然宏的内部也是函数,那么它和函数的区别在哪里呢?这主要体现在两个方面。

  • 其一,宏的参数在被传递给宏体求值前不会像函数那样被求值,它会作为表达式而不是值参与宏展开,就比如上面的 yymac ,假如使用 value 为 1 的变量 a 作为参数调用,得到的 展开结果(list a) 而不是 (list 1)
  • 其二,宏体中的函数返回值应该是另一个 Lisp 表达式,Lisp 解释器会对这个表达式求值,来得到宏调用的结果。

简而言之,宏对参数做的不是求值而是替换。函数调用的返回值由对函数体求值而得到,宏调用的返回值则是由对宏展开后的表达式求值得到。

除了上面的那种使用 list 来构建表达式的方法,我们也可以使用 backquote (即 ` )来方便地构建 Lisp 表达式。它的方便体现在不用使用过多的 list 函数,以及使用 ,,@ 来进行插值。

` 的行为和 ' 几乎一致,除了说某些部分可以求值外。在以下例子中可以体现出这一点:

`(1 2 3) => (1 2 3)
`(1 2 ,(+ 1 2)) => (1 2 3)
'(1 2 (+ 1 2)) => (1 2 (+ 1 2))

`(1 2 ,@(list 1 2 3)) => (1 2 1 2 3)

在 Scheme 中它也被叫做 quasiquote ,意思是准引用。

backquote body 里面, ,,@ 的作用就是对表达式求值,然后将得到的值填充到其所在位置。上面的 defmacro 例子可以写成这样:

(defmacro yymac-2 (a) `(list ,a))
(yymac-2 1) => (1)
(macroexpand '(yymac-2 abc)) => (list abc)
(macroexpand '(yymac-2 (1 2 3))) => (list (1 2 3)) ;bad form
(macroexpand '(yymac-2 '(1 2 3))) => (list '(1 2 3))

我拿使用 ` 和不使用 ` 的宏对比一下,下面使用 ` 的代码可以少写 4 个 list

(defmacro mac-print (ls)
   (let ((a ls))
   `(progn
      (print ,(car a))
      (print ,(cadr a))
      (print ,(caddr a)))))

(defmacro mac-print-2 (ls)
   (let ((a ls))
      (list 'progn
            (list 'print (car a))
            (list 'print (cadr a))
            (list 'print (caddr a)))))
(macroexpand '(mac-print (1 2 3))) => (progn (print 1) (print 2) (print 3))
(macroexpand '(mac-print-2 (1 2 3))) => (progn (print 1) (print 2) (print 3))

由于 macro 接收参数时不求值的特性,在上面的 mac-print 宏展开中,将 表表达式 (1 2 3) 传递给宏后,它被成功绑定到了 ls 上,然后绑定到 let 变量 a 上(使用 let 仅仅是为了说明宏参数的值仅仅是个普通的表达式罢了),而没有出现类似 Debugger entered--Lisp error: (invalid-function 1) 的错误。

对于 Lisp 宏来说,它接受的参数类型只有两种,即 atomlist (是的,凡不是 list 的都算作 atom,比如 string vector 之类的)。下面的例子中,我对宏参数使用 predicate 来判断其类型:

(defmacro mac-val (x)
  (if (and (atom x) (symbolp x))
       `(progn (print ,x)
               (print (+ ,x 1)))
    nil))
(macroexpand '(mac-val yy)) => (progn (print yy) (print (+ yy 1)))
(macroexpand '(mac-val 1)) => nil
(macroexpand '(mac-val (quote yy))) => nil ;(quote yy) is a list

对于不同类型的宏参数,使用针对其类型的函数对其进行操作是可行的,比如我上面对 list 用到的 carcadrcaddr 。不过对于宽泛的 atom(不明确其具体类型)来说,可用的函数好像就 atom 一个,它用来判断某个对象是不是 atom。

需要注意的一点是,对于宏参数,最好不要使用带副作用的操作,这样可能会引发意想不到的后果。

2. 与宏相关的一些函数和宏

这里我只介绍四个函数,这也是在 elisp manual 上列出的四个函数。

  • macrop 用于判断它的参数是否为 macro,若是则返回 t,否则返回 nil
  • macroexpand 接受一个 form 和可选的 environment ,如果 form 是宏调用的话,它会将宏调用展开,如果展开结果还是宏调用则继续展开,直到结果不是宏调用为止。最后的结果就作为函数的返回值。需要注意的是,它不负责 form 子表达式中的宏调用
  • macroexpand-all 的参数表与 macroexpand 相同,但它除了处理 form 的宏调用外还会处理子表达式的宏调用。也就是说它会将整个宏调用完全展开,里面不会剩下任何的宏调用
  • macroexpand-1 的参数表和上面两个函数一致,它的行为与 macroexpand 相似,但是它的展开只进行一次。如果一次展开结果还是宏调用的话,它就直接返回结果而不进行进一步展开

以下例子可以说明 macroexpand 系列函数之间的区别:

(defmacro inc (var)
  (list 'setq var (list '1+ var)))
(defmacro inc2 (v1 v2)
  (list 'progn (list 'inc v1) (list 'inc v2)))

(macroexpand '(inc2 a b)) => (progn (inc a) (inc b))
(macroexpand-1 '(inc2 a b)) => (progn (inc a) (inc b))
(macroexpand-all '(inc2 a b)) => (progn (setq a (1+ a)) (setq b (1+ b)))

(defmacro yy-two (mac-name val)
  (list mac-name val))

(macroexpand '(yy-two inc a)) => (setq a (1+ a))
(macroexpand-1 '(yy-two inc a)) => (inc a)

关于 environment 参数,文档上是这样说的:

If environment is provided, it specifies an alist of macro definitions that shadow the currently defined macros. Byte compilation uses this feature.

具体的用法可以参考这个例子:

(defmacro inctri (var)
  (list 'setq var (list '+ var 3)))

(macroexpand-all '(inc2 a b) '((inc . (lambda (v) (list 'setq v (list '+ v 3))))))
=> (progn (setq a (+ a 3)) (setq b (+ b 3)))
(macroexpand-all '(inc2 a b) `((inc . ,(cdr (symbol-function 'inctri)))))
=> (progn (setq a (+ a 3)) (setq b (+ b 3)))

3. 宏的缩进

在阅读 emacs 的 elisp 源代码时,你可能会看到某些宏里面还有一条类似这样的声明: (declare (indent 1)) ,这是在声明宏调用的缩进方式。老实说,文档上关于 declare indent 的部分写的有点晦涩,我尽量多用些例子说明它的用法。

注意,这里说的缩进是指当你调用函数或宏的时候参数的缩进方式,不是函数或宏定义的缩进方式。至于为什么要把 indent 放到 macro 这一章来讲,应该是 indent 在宏中使用较多的缘故。

indent 接受的参数有以下几种形式:

  • nil 表示使用标准的缩进模式, (indent nil) 相当于不指定 indent
  • defun 使用 定义 结构的缩进,即将第二行作为 body 的开始
  • 整数 number ,它让前 number 个参数变成 distinguished arguments(这里译为分界参数),剩余的参数被当成表达式的 body 部分。表达式中的行会根据它的第一个参数是否为分界参数来决定它的缩进方式。如果参数不是分界参数,那么该行会使用 lisp-body-indent 加上当前所在表达式缩进来作为缩进列数;如果参数是分界参数且是第一个或第二个,那么它会进行两次缩进,即当前缩进加上两个 lisp-body-indent ;对于非第一第二的分界参数,会使用标准模式缩进,即对齐上一行的首参数
  • 一个符号 symbol ,它是一个函数名,函数被用来计算行的缩进。函数接受 posstate 两个参数, pos 是行的缩进开始位置, stateparse-partial-sexp 在解析完当前行后的返回值。函数的返回值可以是一个整数或一个包含整数的表。如果返回整数,当前行的下面的同层行会与它对齐;如果返回表,下面的行可以使用不同的缩进

nil 是最好理解的,就是不对 indent 进行设置,这种情况下的缩进是这样的:下一行的参数与上一行的首个参数保持缩进对齐:

(defun ind_nil (x y z a)
  (list x y z a))

(ind_nil
 1
 2
 3
 4)

(ind_nil 1
         2
         3
         4)

(ind_nil 1 2
         3
         4)

defun 就是类似 defun 的缩进,即将第二行当作 body,缩进一个 lisp-body-indent ,这与 nil 是不同的:

(defun ind_defun (a b c d)
  (declare (indent defun))
  (list a b c d))

(ind_defun 1
  2
  3
  4)

(ind_defun 1 2
  3 4)

指定 number 作为 indent 在宏里面好像是一种很常见的行为,它用来说明哪几个参数是需要特殊缩进的,就像这样:

(defun ind_num (a b c d e)
  (declare (indent 3))
  (list a b c d e))

(ind_num
    1
    2
    3
  4
  5)

(ind_num 1
    2
    3
  4
  5)

(ind_num 1 2
         3
  4
  5)

对于 symbol ,文档居然连个例子也没给。那只能自己摸索了,首先使用常函数作为缩进函数,然后 trace 它来观察它的行为:

(defun ind_ind (pos state) 1)
(defun ind_syn (a b c d)
  (declare (indent ind_ind))
  (list a b c d))
(trace-function-background 'ind_ind)

;; put these lines at the beginning of an empty buffer
(ind_syn
 1
 2
 3
 4)

我们在参数 1, 2, 3, 4 所在的行分别按下 tab,观察一下 buffer trace-output 中的输出:

======================================================================
1 -> (ind_ind 10 (1 1 2 nil nil nil 0 nil nil (1) nil))
1 <- ind_ind: 1
======================================================================
1 -> (ind_ind 13 (1 1 11 nil nil nil 0 nil nil (1) nil))
1 <- ind_ind: 1
======================================================================
1 -> (ind_ind 16 (1 1 14 nil nil nil 0 nil nil (1) nil))
1 <- ind_ind: 1
======================================================================
1 -> (ind_ind 19 (1 1 17 nil nil nil 0 nil nil (1) nil))
1 <- ind_ind: 1

可以看到,第一个参数 pos 确实是行首的 point ,但是第二参数是一张复杂的表,需要参考文档来了解表中各个元素的含义。使用 C-h f parse-partial-sexp 可以进行阅读,以下是返回值的说明:

  • 0. 括号的深度
  • 1. 最内层的包含列表的起始字符位置
  • 2. 最后一个完整 sexp 的起始位置
  • 3. 当在字符串内时非空
  • 4. 在注释内时非空
  • 5. 如果跟着 ' 则为非空
  • 6. 本次扫描遇到的最小括号深度
  • 7. 注释的样式,如果有的话
  • 8. 字符串或注释的起始字符位置,如果为 nil 则说明不存在
  • 9. 当前最外面的开括号的位置
  • 10. When the last position scanned holds the first character of a (potential) two character construct, the syntax of that position, otherwise nil. That construct can be a two character comment delimiter or an Escaped or Char-quoted character.(翻译不能)

根据 parse-patrial-sexp 返回值的含义,我们可以编写出一个使 body 的奇数行缩进四格,偶数行缩进两格的函数:

(defun ind_ind2 (pos state)
  (let ((current-line-delta (- (line-number-at-pos pos)
                                (line-number-at-pos (nth 1 state)))))
    (if (zerop (% current-line-delta 2)) '(2) '(4))))

(defun ind_syn2 (a b c d)
  (declare (indent ind_ind2))
  (list a b c d))

;; we get this
(ind_syn2
    1
  2
    3
  4)

我们甚至可以写出每增一行缩进加一的函数

(defun ind_ind3 (pos state)
  (let ((current-line-delta (- (line-number-at-pos pos)
                                (line-number-at-pos (nth 1 state)))))
    (list current-line-delta)))


(defun ind_syn3 (a b c d)
  (declare (indent ind_ind3))
  (list a b c d))

(ind_syn3
 1
  2
   3
    4)

上面的缩进函数的返回值我都使用了带括号的形式,如果不带括号的话那么下面的参数必须与上面的参数保持相同缩进。 parse-partial-sexp 的返回值我只用了一个,使用其他的值也许可以写出更加有趣的缩进函数,这里我就不进一步尝试了。

4. 宏与 lexical-binding

前面我也说到过,宏的结构就是 car 是 符号 macro ,cdr 是函数的序对。在 lexical-binding 为 nil 和 非 nil 的情况下对 macro 中的函数是有影响的:

(setq lexical-binding nil)
(defmacro foo-1 (x)
  (list 'list x))
(symbol-function 'foo-1) => (macro lambda (x) (list 'list x))

(setq lexical-binding t)
(defmacro foo-2 (x)
  (list 'list x))
(symbol-function 'foo-2) => (macro closure (t) (x) (list 'list x))

可以看到,此时 macro 的 cdr 是一个闭包而不是单纯的 lambda 函数。此后即便 lexical-binding 设置为 nil,调用这个宏也会按照词法作用域规则求值。

5. manual 中提到的注意事项

这一小节主要是 manual 上关于宏的需要注意的地方,我把它们综合了一下。

5.1. 宏与编译

You might ask why we take the trouble to compute an expansion for a macro and then evaluate the expansion. Why not have the macro body produce the desired results directly? The reason has to do with compilation.

当宏调用出现在将要被编译的 Lisp 程序中时,Lisp 编译器会像解释器一样调用宏定义,然后得到宏展开的结果。但与解释器不同的是,编译器不会继续求值,而是将展开结果插入到程序中。这样一来,编译后的代码可以完成宏的功能,又可以利用编译码的速度。如果宏展开期间有副作用的话是不能这样做的,因为编译器的副作用对运行时没有作用。

为了让宏调用的编译能够进行,在编译宏调用时被调宏必须事先定义。编译器的一个特性可以帮到你:如果文件中包含 defmacro 宏定义的话,这个宏在文件编译期间会被临时定义。

对文件进行字节编译时也会执行任何 top-level 的 require 调用,你可以通过 require 定义宏的文件来确保必要的宏定义在编译期间是可用的。要避免编译后对文件的 require 引入不必要的宏的话,可以使用 eval-when-compile 来处理只在编译器使用的 require ,就像这样:

(eval-when-compile
  (require 'some-compile-macro))

5.2. 编译时求值(eval)

在宏展开时,在宏体中对宏的参数表达式求值可能会带来一些问题。如果用户碰巧使用了和宏的形参名字相同的实参的话会出现问题,就像这样:

;;with lexical-binding set to nil
(defmacro foo (a)
  (list 'setq (eval a) t))

(setq x 'b)
(foo x) => (setq b t) => t

(setq a 'c)
(foo a) => (setq a t) => t ; but set a, not c

出现这个问题的原因在于,宏的形参 a 绑定了符号 'a,由于动态作用域的关系,a 对符号 c 的绑定在宏展开时暂时被遮蔽了,所以 (eval a) 会得到符号 a 而不是 c。(不过话又说回来,如果打开词法作用域的话就不会有问题了,因为词法作用域的绑定不会改变外面符号绑定的值,而 eval 又是在空环境(全局环境)中求值的,所以没有问题)

除了说上面的遮蔽问题外,展开时求值对宏的编译也是不利的。在编译时编译器会执行宏定义,但这时被 eval 的变量可能还不存在。

manual 上对此的建议是:不要在宏展开时对参数表达式进行求值,而是将求值的部分留到宏的展开结果中,这样就可以在运行时求值了。

5.3. 宏的多次展开

如果宏在展开过程中含有副作用的话,宏的行为可能会与宏的展开次数有关。你应该尽量避免宏展开中的副作用,除非你十分清楚你要做什么。

但并不是所有的副作用都能被避免的,比如在展开时构建 Lisp 对象。几乎所有的宏展开都包括构建表这一过程,这也是大多数宏的全部工作,它一般是很安全的。但有一种情况是你必须要注意的:当你构建的对象是宏展开中带引用常值的一部分时。

在大多数的 Lisp 代码中是不需要关心这个问题的。只要你在对宏定义中构建的对象进行带副作用操作时这个问题才变得重要。要避免这个问题,你最好在由宏定义构建的对象中避免任何的副作用操作。

下面的代码可以用来说明这个问题:

(defmacro empty-object ()
  (list 'quote (cons nil nil)))

(defun initialize (condition)
  (let ((object (empty-object)))
    (if condition
        (setcar object condition))
      object))

每当 initialize 被调用时,一个新的 (nil) 就会被创建。所以不用担心各个调用之间的副作用问题。但是如果上面的代码被编译了,那么 initialize 中的 (empty-object) 调用会被展开,并在之后所有对 initialize 调用过程中重复使用。这显然不是我们想要看到的。

避免这种病态情况的一种方法是将空对象视为常量(即 '(nil)),你不会在 '(nil) 之类的常量上使用 setcar,因此自然也不会在 (empty-object) 上使用它。

5.4. 其他建议

  • 请注意不要在宏展开期间完成运行时的工作,记得一定要返回一个表达式
  • 注意宏参数在展开表达式中的求值次数,写的好的宏会注意避免掉对某个表达式的重复求值
  • 注意名字冲突问题,使用 make-symbolgensym 等工具来解决问题

6. 一些补充

上面的几节基本上介绍完了 elisp manual 中宏一章的所有内容,文章到这里也本应该结束了。但是在偶然的一次搜索中我发现 backquote 居然是可以嵌套的,这又引发我去看了看 on lisplet over lambda ,这两本书颠覆了我对于 common lisp 和 lisp macro 的看法。下面我会介绍一下嵌套 backquote 的用法,以及一些简单的宏。当我了解过 common lisp 和它的宏后,我会用一篇文章来详尽地叙述与之相关的内容。

6.1. nested backquote

对于 `(,(+ 1 2)) 这样的表达式,我们一眼就可以看出它的结果是 (3) ,但是 ``(a ,,(+ 1 2)) 呢?这就涉及到嵌套 backquote 的求值方式了,在 stackoverflow 上的帖子2中是这样说的:

Common Lisp3

If the backquote syntax is nested, the innermost backquoted form should be expanded first. This means that if several commas occur in a row, the leftmost one belongs to the innermost backquote.

R5RS4

Quasiquote forms may be nested. Substitutions are made only for unquoted components appearing at the same nesting level as the outermost backquote. The nesting level increases by one inside each successive quasiquotation, and decreases by one inside each unquotation.

关于 Common lisp 的那段话的大意是:如果 backquote 是嵌套的,最内部的 backquote 首先被展开,如果几个 , 连续出现,最左边的那个属于最内部的 backquote 。而 R5RS 的那段的意思是:准引用是可以嵌套的, 只有 , 表达式的嵌套层次与最外层的 backquote 相同时表达式才会求值。嵌套层次随 ` 出现递增,随 , 出现递减。

按照 R5RS 的说明, ``(a ,,(+ 1 2)) 的求值是很好解释的:最外层的 backquote 对应第二个 , ,所以 (+ 1 2) 会被求值,从而得到 `(a ,3) 。但是 CL 那一段是什么意思?如果说最内层先展开的话,岂不是先使用第一个 , ?我不是太清楚 CL 中的 backquote 是怎么实现的,但是在 elisp 里面它就是一个宏,具体实现可以参考 backquote.el。下面我们以 CL 为准,来解释一下它的 backquote 嵌套规则。

参考 CLHS 中的说法, backquote 的求值方式是这样的:

  • `basic 等于 'basic,即 (quote basic)
  • `,form 等价于 form
  • `,@form 结果不定
  • `(x1 x2 x3 … xn . atom) 解释为 (append [x1] [x2] [x3] … [xn] (quote atom))其中
    • [form] 被解释为 (list `form),它包含一个在之后被解释的 backquote form
    • [,form] 被解释成 (list form)
    • [,@form] 被解释成 form
  • `(x1 x2 x3 … xn) 等价于 `(x1 x2 x3 … xn . nil)
  • `(x1 x2 x3 … xn . ,from) 被解释为 (append [x1] [x2] [x3] … [xn] form)
  • `(x1 x2 x3 … xn . ,@form) 结果未定义
  • `#(x1 x2 x3 … xn) 可以解释为 (apply #'vector `(x1 x2 x3 … xn))

根据上面的求值规则,以及嵌套 backquote 的求值规则,我们可以得到对 ``(a ,,(+ 1 2)) 的另一种解释:

首先,展开最里面的 backquote ,即 `(a ,,(+ 1 2)) 。根据规则容易得到结果:

(append (list `a)
        (list (list '\, (+ 1 2)))
        '())

上面的表达式求值得到 (a (\, 3)) ,再添外层的 ` 就可以得到 `(a (\, 3) 了,即 `(a ,3) 。上面的 ,(+ 1 2) 我给写成了 (list '\, (+ 1 2)) ,其一是因为 ,exp 等价于 (unquote exp) ,其二是因为在 elisp 中使用符号 \, 来表示 unquote。我们可以看看 macroexpand 给出的结果,由于我上面描述的规则是 CL 中的,所以展开的方式不一定完全一致,不过结果是一样的:

(macroexpand '``(a ,,(+ 1 2)))
->
(cons '\`
(list
 (list 'a
       (cons '\,
             (list
              (+ 1 2))))))

老实说,R5RS 中对嵌套引号的解释更加容易弄明白,你只需要数一下反引号和逗号就知道要对那个表达式求值了。关于嵌套 backquote 的讲解,在 stackoverflow 上还有一些帖子,可以看看。

这里最后再说一下几种二重嵌套 backquote,@',@,',@,,@,@,@ 。它们的意思可以通过下面的例子体现出来,具体的解释我就不过多说明了,以下代码可以在 SBCL 中运行。

(eval ``(,@',@'(1))) -> 1
(eval ``,',@'(1)) -> 1

``(,,@'((+ 1 2) (+ 2 3))) -> `(,(+ 1 2) ,(+ 2 3))
(eval ``(,,@'((+ 1 2) (+ 2 3)))) -> (3 5)
;; but elisp does this
``(,,@'((+ 1 2) (+ 2 3))) -> `((\, (+ 1 2) (+ 2 3)))

``(,@,@'((list 1 2) (list 2 3))) -> `(,@(LIST 1 2) ,@(LIST 2 3))
(eval ``(,@,@'((list 1 2) (list 2 3)))) -> (1 2 2 3)
;; but elisp does this
``(,@,@'((list 1 2) (list 2 3))) -> `((\,@ (list 1 2) (list 2 3)))

可以看到,后面两种嵌套在 SBCL 和 emacs 中的行为不一致,elisp 的 ,,@ 只会简单的展开,而 CL 中的 ,,@ 会应用到每一个元素上。

6.2. 一些宏例子

在这一小节开始前,我向你郑重推荐 on lisplet over lambda ,本节的一些宏例子就是从它们上面抄过来的。这一小节的目的仅仅是打开读者的视野(如果没有了解过的话),所以我不会做过多的说明和讲解。

如果代码没有用注释指明运行环境的话,在 CL 和 emacs 中都可以执行。

6.2.1. 解决变量捕获的问题(variable capture)

自然,使用 gensym 等函数可以确保某个宏内部使用的符号不会与外部冲突,但是把它包装一下会不会更好呢?

;;elisp
(defun g!-symbol-p (s)
  (and (symbolp s)
       (> (length (symbol-name s)) 2)
       (string-equal (substring (symbol-name s) 0 2) "g!")))

(defmacro defmacro/g! (name args &rest body)
  (declare (indent defun))
  (let ((syms (cl-remove-duplicates
               (cl-remove-if-not #'g!-symbol-p (flatten-list body)))))
    `(defmacro ,name ,args
       (let ,(mapcar
              (lambda (s)
                `(,s (gensym ,(cl-subseq
                               (symbol-name s)
                               2))))
              syms)
         ,@body))))

这一段宏是 let over lambda 第 3 章上的一个例子,我稍作修改以便在 elisp 中使用,以下是使用例:

;;elisp
(defmacro/g! yy-swap (a b)
  `(let ((,g!temp ,a))
     (setq ,a ,b)
     (setq ,b ,g!temp)))

(setq a 1)
(setq b 2)

(yy-swap a b)
(cons a b) -> (2 . 1)

(macroexpand '(yy-swap a b)) -> (let ((temp99 a)) (setq a b) (setq b temp99))

6.2.2. 避免参数多次求值

在宏展开中使用 let 变量绑定参数表达式,并在随后代码中使用变量可以让表达式只求值一次。 once-only 宏可以做到这一点:

;;common lisp approach
(defmacro once-only (names &rest body)
  (let ((gensyms (loop repeat (length names) collect (gensym))))
    `(let (,@(loop for g in gensyms collect `(,g (gensym))))
       `(let (,,@(loop for g in gensyms for n in names
                          collect ``(,,g ,,n)))
          ,(let (,@(loop for n in names for g in gensyms
                            collect `(,n ,g)))
             ,@body)))))

;; elisp approach
;; https://www.emacswiki.org/emacs/macro-utils.el
;; with some modifications
;; here is another one: https://github.com/mrkkrp/mmt
(defmacro with-gensyms (symbols &rest body)
  "Execute BODY in a context where the variables in SYMBOLS are bound to
fresh gensyms."
  (cl-assert (cl-every #'symbolp symbols))
  `(let ,(cl-mapcar #'list symbols '#1=((gensym) . #1#))
     ,@body))

(defmacro once-only (symbols &rest body)
  "Execute BODY in a context where the values bound to the variables in
SYMBOLS are bound to fresh gensyms, and the variables in SYMBOLS are bound
to the corresponding gensym."
  (declare (indent 1))
  (cl-assert (cl-every #'symbolp symbols))
  (let ((gensyms (cl-mapcar (lambda (x) (gensym)) symbols)))
    `(with-gensyms ,gensyms
                   (list 'let (cl-mapcar #'list (list ,@gensyms) (list ,@symbols))
                         ,(cl-list* 'let (cl-mapcar #'list symbols gensyms)
                                    body)))))

具体使用的话,可以用带有副作用的表达式来说明:

(defmacro yy-square (x)
  (once-only (x)
    `(* ,x ,x)))

(setq a 1)
(yy-square (setq a (+ a 1))) -> 4
a -> 2


(macroexpand '(yy-square x)) -> (let ((g156 x)) (* g156 g156))

除了使用 once-only 宏, let over lambda 也提供了一种更好的方法:

;;elisp
(defun o!-symbol-p (s)
  (and (symbolp s)
       (> (length (symbol-name s)) 2)
       (string-equal (substring (symbol-name s) 0 2)
                     "o!")))

(defun o!-symbol-to-g!-symbol (s)
  (intern (concat "g!"
                  (cl-subseq
                   (symbol-name s)
                   2))))

(defmacro defmacro! (name args &rest body)
  (let* ((os (cl-remove-if-not 'o!-symbol-p args))
         (gs (cl-mapcar 'o!-symbol-to-g!-symbol os)))
    `(defmacro/g! ,name ,args
       `(let ,(cl-mapcar 'list (list ,@gs) (list ,@os))
          ,(progn ,@body)))))

以下是使用例:

;; elisp
(defmacro! yy-squ (o!x)
  `(* ,g!x ,g!x))

(yy-squ (setq a (+ a 1))) -> 9
a -> 3

在参数表中将不想重复求值的变量写成 o!* 的形式,然后在返回表达式中写出对应名字的 g!* 就可以达到避免重复求值的目的了。使用 defmacro! 既可以避免变量捕获,又可以避免重复求值。

6.2.3. 在宏里面指定关键字

我在学习 dash 库的时候发现了 –each-while 这个宏,它是 dash.el 中一系列函数的基础。

;; from dash.el
(defmacro --each-while (list pred &rest body)
  "Evaluate BODY for each item in LIST, while PRED evaluates to non-nil.
Each element of LIST in turn is bound to `it' and its index
within LIST to `it-index' before evaluating PRED or BODY.  Once
an element is reached for which PRED evaluates to nil, no further
BODY is evaluated.  The return value is always nil.
This is the anaphoric counterpart to `-each-while'."
  (declare (debug (form form body)) (indent 2))
  (let ((l (make-symbol "list"))
        (i (make-symbol "i"))
        (elt (make-symbol "elt")))
    `(let ((,l ,list)
           (,i 0)
           ,elt it it-index)
       (ignore it it-index)
       (while (and ,l (setq ,elt (pop ,l) it ,elt it-index ,i) ,pred)
         (setq it ,elt it-index ,i ,i (1+ ,i))
         ,@body))))

它的用法如下:

(setq a '())
(--each-while '(1 3 5 7 -1 1 3 2) (not (cl-evenp it))
  (when (> it 1)
    (push (list it it-index) a)))
a -> ((3 6) (7 3) (5 2) (3 1))

可以看到,我在上面使用了 itit-index ,这两个名字并不是参数或者其他什么东西,它们是可以在 –each-while 范围内使用的关键字,用来表示当前元素和当前元素所在的位置。使用时要注意它们不要和变量名字冲突了。

7. 后记

本来我只是想记录一下 elisp manual 中 macro 的内容,谁知宏的内容居然如此丰富,果然还是自己见识过于短浅了。

直到写过 CL 宏后我才明白宏接受的参数是 S 表达式,即原子或者表,阻碍我认识到这一点的当属 Scheme 的 syntax-case。没有学 CL 之前,在 elisp 中我一直是通过这种方法来获取符号的:

(defmacro get-yy (s)
  (let ((a `',s))
    ...))

;;just do this ...
(defmacro get-yy-good (s))
  (let ((a s))
   ...))

这大概是受到了 datum->syntax 的影响……

CL 中的宏就是直观的从表达式到表达式的变换,但是 syntax-case 和 syntax-rules 还需要保证宏的卫生性,所以加上了一些额外的限制而导致不那么直观。 let over lambda 中是这样评价卫生宏和 Scheme 的:

As a professional macro programmer you will come into contact with many of these variable capture solutions. The current popular solution is to use so-called hygienic macros. These solutions try to limit or eliminate the impact of unwanted variable capture but unfortunately do so at the expense of wanted, desirable variable capture. Almost all approaches taken to reducing the impact of variable capture serve only to reduce what you can do with defmacro. Hygienic macros are, in the best of situations, a beginner's safety guard-rail; in the worst of situations they form an electric fence, trapping their victims in a sanitised, capture-safe prison. Furthermore, recent research has shown that hygienic macro systems like those specified by various Scheme revisions can still be vulnerable to many interesting capture problems

Still, calling gensym every single time we want a nameless symbol is clunky and inconvenient. It is no wonder that the Scheme designers have experimented with so-called hygienic macro systems to avoid having to type gensym all over the place. The wrong turn that Scheme took was to promote a domain specific language for the purpose of macro construction. While Scheme's mini-language is undeniably powerful, it misses the entire point of macros: macros are great because they are written in lisp, not some dumbed down pre-processor language.

我无意评判两种宏的好坏,不过 syntax-case 的难学我可是确实体会到了。CL 的宏变换是非常直接的,从表达式到表达式的。使用我上面提到的 defmacro! 是可以避免变量捕捉的(虽然宏的组合看上去实在是难以想象)

摸鱼结束,去干点正事吧。

8. 参考资料

let over lambda Doug Hoyte
on lisp Paul Graham
ansi common lisp Paul Graham

Footnotes: