浅析Elisp中的compiler macro


#1

什么是 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 为 我们提供的函数优化器, 它没有自己独立的语义, 而是依附于函数而存在.

NOTE:

一个例外是(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)))

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

ELISP> (macroexpand-all '(mapcar #'cadr '((1 2) ((3 4)))))
(mapcar #'cadr
        '((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)))
ELISP> (cl-compiler-macroexpand '(if-let* ((a (cadr '(1 3)))) a))
(if-let*
    ((a
      (cadr
       '(1 3))))
    a)

对比

ELISP> (macroexpand-all '(if-let* ((a (cadr '(1 3)))) a))
(let*
    ((a
      (and t
           (car
            (cdr
             '(1 3))))))
  (if a a))

macrostep

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

%E5%9B%BE%E7%89%87

这里, 图中斜体字标记的时compiler macro, 下划线标记的是macro

如何编写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)))

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

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

当你的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))
(my-list* 1 2 3)                        ;=> '(1 2 . 3)
(my-list* 1 2 '(3))                     ;=> '(1 2 3)

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

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

你可能已经看出来,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)))

来尝试一下

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)))))))))

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

注意事项

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

(defun my-id (x) x)

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

;; 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

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

compiler macro in GV forms

由于macroexpand可以展开compiler macro, 因此setf也会打开GV form里的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))

我们用my-aref简单包裹了aref函数, 然而Emacs并不会进入我们的函数定义去查看我们 实际进行的动作,emacs只会去寻找my-aref的gv-setter, 并且在找不到的情况下使用了 setter的默认值(setf my-aref)(当然这里我们没有定义), 而使用了compiler macro后, 我们给编译器足够的提示, 成功my-aref被打开成对应的 aref Emacs已经定义了aref的gv-setteraset, 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)))
(my-aref-inline [1 2 3] 0) ;=> 1
(macroexpand-all '(setf (my-aref-inline arr 1) 3)) ;=> (let* ((v arr)) (aset v 1 3))

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)))))

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

inline-letevals

考虑函数

(defun pow2 (num)
  (* num num))

如何用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

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

(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

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

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

看起来我们的参数(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!"

展开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#))"

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

使用function+compiler-macro与直接使用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!"

使用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)))))))

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


#2

你 费心 了


#3

有事早奏, 无事退朝 :kissing_closed_eyes:


#4

大佬牛逼,紫薯布丁


#5

平时很少接触lisp这类语言的人,例如我,看到宏,真的好不习惯,


#6

其实你可以不用亲自写宏就能用lisp,不过不可能不用到宏,最简单的,lambda也是宏。

lambda is a macro defined in subr.el.gz.

Signature
(lambda ARGS [DOCSTRING] [INTERACTIVE] BODY)

Documentation
Return an anonymous function.

#7

只要写 (function (lambda () nil)) 就行了,对 emacs lisp functionquote 都沒多少区別。LISP 一开始 lambda exp 只要是 car 是 lambda atom 的 list 就是了。说实话用 macro 的都是弟弟行为。明明实际上用 CPS 包括所有的 primitive special form 都能只用 function 实現的。


#8

lambda用quote勉强有个function的名分而已,用quote的lambda妹有词法作用域加持。

ELISP> (let ((a 1))
         (funcall '(lambda () a)))
*** Eval error ***  Symbol’s value as variable is void: a
ELISP> (let ((a 1))
         (funcall #'(lambda () a)))
1 (#o1, #x1, ?\C-a)
ELISP> (byte-compile '(let ((a 1)) '(lambda () a)))
(progn 1
       '(lambda nil a))

ELISP> (byte-compile '(let ((a 1)) #'(lambda () a)))
(byte-code "\300\301\302\303\304\305!\306\"\300$\207"
           [1 make-byte-code 0 "\300\207" vconcat vector
              []]
           7)

对于symbol而言,的确你可以说用quote和function没什么区别,然而那是因为你用defun的时候已经给函数体用上了function。

BTW,lambda宏的意义我觉得是省了记住什么时候要用function包裹。这样对于求值或者不求值的位置,都可以(lambda ())一把梭


#9
ELISP> (funcall '(closure ((a . 1) t) () a)) ;; 开不开 `lexical-binding` 都有效
1 (#o1, #x1, ?\C-a)
;; OR
ELISP> ((closure ((a . 1) t) () a))
1 (#o1, #x1, ?\C-a)

除了在 car 以外都是该用 function 的。


#10

你这手写闭包我有什么办法?


#11

我只是为了证明

preliminary: (foo ...) 语义上等于 (funcall (function foo) ...)

即使对 lexical binding 也适用。

而且我的主要论点在

用 CPS 做 reflective tower 的话 lambda 的定义都用不上 function


#12

都说了byte-compiler会把(funcall #'foo) transform到 (foo)

妹有意义,你这是手动构造了闭包和他的环境,实际操作上没有人这么蠢写这玩意,不如交给编译器。

你这个主要论点缺乏论据啊喂


#13

elisp byte-compiler 和 elisp runtime/eval 的实现完全无关。说白了,用第三方 compiler 乃至手写 byte code 都行。

实际上如果编译器能实現几个相当传统的优化 pass 这种程度的质量都完全不该用到 compiler macro。用 compiler macro 是可以轻易破坏 referential transparency 的。单要做 inline 的话 elisp 的 defsubst 已經够了。


#14

那compiler macro一般用来做什么?


#15

現代都沒人用啊,纯粹的历史糟粕。当然比完全手写 single pass 好多了。


#16

所以Elisp这种残疾玩意才会有 :rofl: 那看了就笑一笑吧,defsubst define-inline其实也够用了

本来就是昨晚闲的无聊写的

顺带一提emacs里的cl-defsubst也是基于compiler macro实现的,但是实现有问题,所以已经不能用了

ELISP> (cl-defsubst my-test1 (x) (let ((y 5)) (+ x y)))
my-test1
ELISP> (macroexpand-all '(my-test1 y))
(let
    ((y 5))
  (+ y y))

这展开显然是有问题的


#17

The presence of a compiler macro definition for a function or macro indicates that it is desirable for the compiler to use the expansion of the compiler macro instead of the original function form or macro form. However, no language processor (compiler, evaluator, or other code walker) is ever required to actually invoke compiler macro functions, or to make use of the resulting expansion if it does invoke a compiler macro function.

from CLHS Sec 3.2.1.3.

cl-defsubst 的本意是像 cl-defun 之于 defun 那样比 defsubst 多出 complex lambda list 支持(Common Lisp 沒有 defsubst)。本來,ANSI CL 就规定可以完全忽略 compiler macro 的定义。


#18

等我看完camlp5 再来凑热闹

软件中的任何问题,都可以通过加一层来解决

macro在解析的时候加了一层,这个在调用的时候加了一层


#19

现在不是用皮皮虾(ppx)了吗


#20

ppx取代了camlp4