# 浅析 emacs-lisp 中的 compiler-macro

# 什么是 compiler macro

在 Elisp 里,我们通常会使用两种构造抽象的方式,macro 和 function. 这两者的区别相信读 者都已经了然于胸 (如果你不清楚,建议查阅 Elisp Reference Manual).

简言之,compiler macro 可以在字节码编译时用特定的规则展开行如 (func arg1 arg2 arg3) 的函数调用,宏调用不会被 compiler macro 展开,因为你可以在宏体里直接指定代码变换的 方式,无需借助 compiler maro 的威力 :​).

注意:通常来说只有标准形式 (func arg1 arg2) 的函数调用才会被 compiler macro 打开优 化,类似 mapcar 或者 apply funcall 的高阶函数调用,compiler macro 将无能为力

是不是听着很熟悉?是的,compiler macro 可以做到内联优化,尽管 Elisp 编译器很笨拙,但 所幸他给我们这些性能狂人留下了很多手动操作的空间.

compiler macro 并不能算作与 macro 和 function 类比的组织抽象的方式,只能算作 Elisp 为 我们提供的函数优化器,它没有自己独立的语义,而是依附于函数而存在.

警告

一个例外是 (funcall #'func 1 2) 或者 (apply #'func '(1 2 3 4)) , byte-compiler 会消除掉这种 funcall 或者 apply , 然后交给 compiler macro 展开.

# 如何察看 compiler macro 的展开结果

# macroexpand

macroexpand macroexpand-1 macroexpand-all 可以打开直接 compiler macro.

ELISP> (macroexpand-all '(cadr '(1 3)))
(car
 (cdr
  '(1 3)))
1
2
3
4

这里用 cadr 举例子,cadr 可以取出列表中第二个元素,等效于取列表的 cdrcar . 可以看出来 compiler macro 直接把 cadr 展开成 (car (cdr x))

ELISP> (macroexpand-all '(mapcar #'cadr '((1 2) ((3 4)))))
(mapcar #'cadr
        '((1 2)
          ((3 4))))
1
2
3
4

在这里, cadr 作为 mapcar 的参数使用,compiler macro 就无能为力了.

# cl-compiler-macroexpand

但是有时候你可能希望不要打开常规宏,只打开 compiler macro, 这时可以使用 cl-lib 中提 供的 cl-compiler-macroexpand

ELISP> (cl-compiler-macroexpand '(cadr '(1 2)))
(car
 (cdr
  '(1 2)))
1
2
3
4
ELISP> (cl-compiler-macroexpand '(if-let* ((a (cadr '(1 3)))) a))
(if-let*
    ((a
      (cadr
       '(1 3))))
    a)
1
2
3
4
5
6

对比

ELISP> (macroexpand-all '(if-let* ((a (cadr '(1 3)))) a))
(let*
    ((a
      (and t
           (car
            (cdr
             '(1 3))))))
  (if a a))
1
2
3
4
5
6
7
8

# macrostep

macrostep 可以展开 compiler macro, 在 macrostep 里,compiler macro 和 macro 会用不同的 face 来标记

# 如何编写 compiler macro

# compiler macro 的定义

compiler-macro 的定义和常规宏一样,是一个返回一个 s 表达式的 lambda, 这个 lambda 接收 的参数数量视用户调用 compiler macro 对应的函数时传入的数量而定。第一个参数固定为要展开的 form, 其余的参数依次为用户传入函数调用的参数.

# compiler-macro symbol property

为了使 compiler macro 生效,你需要将你定义的 compiler macro 设置为目标函数 symbol 的 compiler-macro property 上.

(defalias 'my-list 'list)
(put 'my-list 'compiler-macro
            (lambda (form &rest args) (message "Form: %S, ARGS: %S" form args)
            form))
(cl-compiler-macroexpand '(my-list '(1 2 3 4)))
1
2
3
4
5

当我们展开 compiler macro 时,会得到信息

Form: (my-list 1 2 3 4), ARGS: (1 2 3 4)
1

当你的 compiler macro 只用于一个函数,一般可以忽略掉 form 直接用 args.

# declare form

在 Emacs 24.4 或更高版本,你可以直接在函数的 declare form 中指定 compiler macro,

详见 manual

# 实战

# my-list* 函数

考虑函数 my-list* , 它接受任意数量的参数,将他们从头用 cons 连接到尾部.

我们的初版函数是这样的.

(defun my-list* (&rest args)
  (let* ((rargs (reverse args))
         (result (car rargs)))
    (dolist (arg (cdr rargs))
      (push arg result))
    result))
1
2
3
4
5
6
(my-list* 1 2 3)                        ;=> '(1 2 . 3)
(my-list* 1 2 '(3))                     ;=> '(1 2 3)
1
2

我们可以用高阶函数把它变得更简洁

(require 'cl-lib)
(defun my-list* (&rest args)
  (cl-reduce #'cons args :from-end t))
1
2
3

你可能已经看出来, my-list* 等效于手写 (cons arg1 (cons arg2 (cons arg3 ...))) . 另一方面,我们都知道打开的循环比循环更高效,但是我们完全没有必要为了性能而手写展 开形式,更何况 my-list* 接受任意数量的参数,根本无法直接手写展开.

这时候可以利用 compiler macro 生成 sexp 优化我们的 my-list* 函数

(put 'my-list* 'compiler-macro
     (lambda (_form &rest args)
       ;; 这里可以用`nreverse', 因为每次调用`(my-list* 1 2 3)'都会生成
       ;; 新的args, 而`(apply #'my-list* something)' 不会触发compiler macro
       (let* ((rargs (nreverse args))
              (head (pop rargs))
              (result head))
         (dolist (arg rargs)
           (setq result `(cons ,arg ,result)))
         result)))
1
2
3
4
5
6
7
8
9
10

来尝试一下

ELISP> (cl-compiler-macroexpand '(my-list* 1 2 3 4 5 6 6 7 8 9))
(cons 1
      (cons 2
            (cons 3
                  (cons 4
                        (cons 5
                              (cons 6
                                    (cons 6
                                          (cons 7
                                                (cons 8 9)))))))))

1
2
3
4
5
6
7
8
9
10
11

可以看到我们写的 compiler macro 已经如愿展开了.

注意

在 compiler macro 中,你可以随意修改函数展开的方式,如果操作不当,很可能会导 致直接调用函数与 funcall 调用函数时函数的行为不一致!

(defun my-id (x) x)

(put 'my-id 'compiler-macro
     (lambda (_ arg)
       `(list ,arg))) ; Malformed compiler macro

;; Compiler macro不会在解释运行时展开, 这里使用`my-id'的原始定义.
(my-id 1)                               ;=> 1

(eval (byte-compile '(my-id 1)))        ;=> (1) ???

(eval (byte-compile '(let ((f #'my-id)) (funcall f 1)))) ;=> 1
1
2
3
4
5
6
7
8
9
10
11
12

这里利用 funcall 规避了 compiler macro 展开,由于我们的 compiler macro 不规范,导致 (my-id 1)(funcall f 1) 造成了不一致的结果,请使用 compiler macro 的时候务必 注意,小心不要造成 undefined behaviour.

# 广义变量展开中的 compiler macro

由于 macroexpand 可以展开 compiler macro, 因此 setf 也会打开广义变量里的 compiler macro

(defun my-aref (arr idx)
  (aref arr idx))

(macroexpand-all '(setf (my-aref arr 1) 3))
;; => (let* ((v arr)) (\(setf\ my-aref\) 3 v 1))

(put 'my-aref 'compiler-macro
     (lambda (_ arr idx)
       `(aref ,arr ,idx)))

(macroexpand-all '(setf (my-aref arr 1) 3))
;; => (let* ((v arr)) (aset v 1 3))
1
2
3
4
5
6
7
8
9
10
11
12

我们用 my-aref 简单包裹了 aref 函数,然而 Emacs 并不会进入我们的函数定义去查看我们 实际进行的动作,emacs 只会去寻找 my-aref 的 gv-setter, 并且在找不到的情况下使用了 setter 的默认值 (setf my-aref) (当然这里我们没有定义), 而使用了 compiler macro 后,我们给编译器足够的提示,成功 my-aref 被打开成对应的 aref Emacs 已经定义了 aref 的 gv-setter aset , setf 就可以直接使用 aset 作为 my-aref 的 gv-setter

# define-inline

为了更好的利用 compiler macro, Emacs 25 提供了 inline.el , 作为 compiler 的上层包装, 协助用户更好更简单写出安全的内联函数.

# inline-quote

define-inline 定义函数类似于定义一个宏,不过用 inline-quote 代替 `inline-quote 中,你只能使用 , 而不能使用 ,@ , 这是为了防止生成的 compiler macro 意外的破坏函数语义.

define-inline 重新定义刚才提到的 my-aref

(require 'inline)
(define-inline my-aref-inline (arr idx)
  (inline-quote (aref ,arr ,idx)))
1
2
3
(my-aref-inline [1 2 3] 0) ;=> 1
(macroexpand-all '(setf (my-aref-inline arr 1) 3)) ;=> (let* ((v arr)) (aset v 1 3))
1
2

macroexpand-all 直接打开我们定义 my-aref-inline 的过程,得到

(progn
  (defun my-aref-inline
      (arr idx)
    (declare
     (compiler-macro my-aref-inline--inliner))
    (aref arr idx))
  :autoload-end
  (eval-and-compile
    (defun my-aref-inline--inliner
        (inline--form arr idx)
      (ignore inline--form)
      (catch 'inline--just-use
        (list 'aref arr idx)))))
1
2
3
4
5
6
7
8
9
10
11
12
13

可以看出来底层还是用的 compiler macro 的机制.

# inline-letevals

考虑函数

(defun pow2 (num)
  (* num num))
1
2

如何用 define-inline 定义其内联版本?

尝试直接用 inline-quote

(define-inline pow2-inline (num)
  (inline-quote (* ,num ,num)))

(pow2-inline 3)                         ;=> 9
(pow2-inline 2)                         ;=> 4
(eval (byte-compile '(pow2-inline 2)))  ;=> 4
1
2
3
4
5
6

看起来没有问题,继续测试

(defun my-side-effect-2 ()
  "Do a message, and return 2."
  (message "Side effect!")
  2)

(eval (byte-compile '(pow2-inline (my-side-effect-2)))) ;=> 4
1
2
3
4
5
6

这里,message "Side effect!" 被发送了两次,我们用 macroexpand 打开 pow2-inline 看 看

(*
 (my-side-effect-2)
 (my-side-effect-2))
1
2
3

看起来我们的参数 (my-side-effect-2) 被直接内联到了 * 的两个参数位置里,造成 my-side-effect-2 被求值两次,这显然不是我们想要的结果.

对于这种情况, define-inline 为我们提供了 inline-letevals 来控制一个表达式只被 计算一次

(define-inline pow2-inline-2 (num)
  (inline-letevals (num)
    (inline-quote
     (* ,num ,num))))

(eval (byte-compile '(pow2-inline-2 (my-side-effect-2)))) ;=> 只有一次"Side Effect!"
1
2
3
4
5
6

展开 pow2-inline-2 的调用

(let ((print-gensym t)
      (print-circle t))
  (prin1-to-string (macroexpand-all '(pow2-inline-2 (my-side-effect-2)))))
;; => "(let* ((#1=#:num (my-side-effect-2))) (* #1# #1#))"

1
2
3
4
5

可以看出来 inline-letevals 类似与我们编写 macro 时用 let 和 gensym 保护 expression 的方 式.

# FAQ

# 为什么不直接使用 macro?

从语法上看 macro 不能作为高阶函数的参数使用 (当然你可以拐着弯用 lambda 包裹 macro). 而 function 可以.

compiler macro 和 macro 一样,会被 Emacs 的 Eager macroexpansion 机制打开.

在老版本的 Emacs 中,有人喜欢用宏替代内联函数,这在新版本中的 Emacs 完全没有必要,函 数和宏完全是两种不同语义的东西,如果你需要内联函数优化,请使用 compiler macro, 或 者 define-inline 这种上层包装.

# 用 compiler macro 做内联和使用 defsubst 的内联有什么区别?

defsubst 是另一种定义内联函数的方式,对比 compiler macro, defsubst 内联的方式更 为保守.

defsubst 无法用 macroexpand 展开,因此 defsubst 定义的 inline function 不能作为 setf 的 form.

比如, defsubst 会保持 lisp function 对 argument 严格从左到右求值的逻辑。同样也会建 立函数专有的变量作用域。比如上文用来举例的 pow2-inline , 用 defsubst 可以直接定 义为

(defsubst pow2-subst (num)
  (* num num))
  
(eval (byte-compile '(pow2-subst (my-side-effect-2)))) ;只有一次"Side Effect!"
1
2
3
4

使用 compiler macro 时,求值策略是由用户自行决定的.

(defun say-is (somthing type)
  (message "%s is %s" something type))

(define-inline simple-case (something type)
  (inline-letevals (something)
    (inline-quote
     (cl-case ,something
       ((donkey (say-is ,something ,type)))
       ((rabbit (say-is ,something ,type)))))))
1
2
3
4
5
6
7
8
9

这里我们没有用 inline-letevals 保护 type 变量,因为我们知道 cl-case 的两个分支不可 能同时执行,而 type 被作为函数 say-is 的参数, say-is 会将其 eval. 这样我们就达成了 类似 lazy evaluation 的效果

# 相关讨论

见 emacs-china 论坛