emacs 的 byte compilation

More details about this document
Create Date:
Publish Date:
Update Date:
2024-06-19 18:00
Creator:
Emacs 30.0.50 (Org mode 9.6.15)
License:
This work is licensed under CC BY-SA 4.0

在之前的文章中我介绍了 emacs 的 macro,这是出于两个目的,其一是学习 macro 的基本用法,其二是作为本文的前置文章,提供一些与 macro 相关的知识。本文我会参考 manual 简单介绍 emacs 中字节编译的使用,在内容上基本就是一篇笔记。下面的两段话是我从 manual 开头翻译过来的。

elisp 有一个能把 Lisp 函数翻译为字节码的编译器,编译器会将函数定义替换为字节码,字节码的执行效率更好。当字节码函数被调用时,它的定义会被字节码解释器求值。因为字节码是由解释器执行而不是直接由机器硬件执行,它可以在不同机器上使用而无需重新编译。但是它也没有真正的编译码快。

一般来说,任何版本的 emacs 都可以运行早先版本 emacs 生成的字节码,但是反过来不一定行得通。

1. 如何编译函数

凡和字节编译相关的函数大多以 byte-compile 作为前缀,这一小节我会介绍几个相关的函数。

byte-compile 接受一个 symbol 作为参数,它会对 symbol 的函数定义进行字节编译,并使用编译得到的结果替换符号的函数定义。需要注意的是,它要求 symbol 的 function cell 必须是一个有效的函数对象,而且它不会进行 function indirection 。该函数的返回值是编译后的字节函数对象:

(defun factor (n)
  "computer fact(n)"
  (or (and (= 1 n) 1) (* n (factor (1- n)))))

(byte-compile 'factor)
=>
#[257 "xxxxxxxxxxxxxxxx" [1 factor] 4 "computer fact(n)

(fn N)"]

;; do it again
(byte-compile 'factor)
=> *message* Function factor is already compiled
#[257 "xxxxxxxxxxxxxxxx" [1 factor] 4 "computer fact(n)

(fn N)"]

从上面的结果可以看到,如果已经对某个函数进行了字节编译,那么再次对它使用 byte-compile 时, *message* buffer 中会提示你该函数已经被编译。这里的行为与文档中描述的不完全一致,文档指出,当 symbol 的函数对象已编译时, byte-compile 会返回 nil,而上面的结果是编译后的函数对象。不论如何,一切以实际情况为准即可。

除了说接受符号外,该函数也可接受 lambda 表达式,不过这种情况下函数只会返回编译后的函数,如果丢弃返回值的话相当于什么也没做。

compile-defun 会读取当前位置的定义并编译,然后求值得到结果。如果你使用它而不是 eval-defun 的话,实际上就相当于得到了函数的编译后版本。在 lisp-interaction-mode 的 key-binding 中我没有找到它,也许它用的并不怎么多。想要体验一下的话,可以考虑临时给它加上一个快捷键,比如这样:

(global-set-key (kbd "<f5>") 'compile-defun)

compile-defun 的返回值就是函数的符号名字。

byte-compile-file 是对一个文件中的所有定义进行字节编译,并将编译后的函数定义输出到新的文件中。它接受一个字符串作为文件名,并接受一个可选参数 load 。新文件的名字是将旧文件后缀由 ".el" 替换为 ".elc"。如果原文件不以 .el 结尾的话,新文件的名字就是在原文件名后面加上 .elc

文件的编译过程大致是这样的:每次读取文件中的一个 form ,如果它是函数或宏的定义的话,它的编译结果会写出到新文件。其他的 form 会打包在一起(batched together),然后每条 batch 会被编译并写入新文件,这样它们的编译码会在文件被读取时执行。在读取文件时,所有的注释都会被忽略掉。

如果编译过程没有问题的话,这条命令会返回 t,否则返回 nil。如果 load 为非空的话,在编译完成后该命令还会载入编译后得到的文件。使用 M-x 方式调用时可以使用 C-u 前缀来指定它。

关于 batched together ,以下例子有助于你对它的理解。将非定义表达式打包到一起的意思就是:挨在一起的表达式被合并为一条编译码

;; cp.el
(print 1)
(print 2)
(print 3)

(defun yy1 (x) (+ x 1))
(defun yy2 (x) (- x 1))

(print 4)

(defun yy3 (x y) (+ x y))

(print 5)

;; cp.elc
;ELC
;;; Compiled
;;; in Emacs version 27.1
;;; with all optimizations.

;;; This file uses dynamic docstrings, first added in Emacs 19.29.

;;; This file does not contain utf-8 non-ASCII characters,
;;; and so can be loaded in Emacs versions earlier than 23.

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;


(byte-code "\300\301!\210\300\302!\210\300\303!\207" [print 1 2 3] 2)
(defalias 'yy1 #[(x) "^HT\207" [x] 1])
(defalias 'yy2 #[(x) "^HS\207" [x] 1])
(print 4)
(defalias 'yy3 #[(x y) "^H	\\\207" [x y] 2])
(print 5)

可以看到,(print 1) (print 2) (print 3) 三条表达式在编译结果中成为了一条 batch ,但是 (print 4) 和 (print 5) 没有,因为它们中间隔了一个 yy3 的定义。

byte-recompile-directory 接受 directory 作为参数,以及 flagforce 作为可选参数。它会对所有在 directory 目录下的需要重编译的 .el 文件进行重编译。关于是否需要重编译的标准是这样的:.el 文件对应的 .elc 文件存在且时间戳老于 .el 文件。

如果 .el 文件没有对应的 .elc 文件的话,参数 flag 将决定该命令的行为,若 flag 为 nil,该命令会忽略掉这些文件,若 flag 为 0,它会编译这些文件,若 flag 的值不是这两个中的一个,它会询问用户是否对文件进行编译。这个询问是递归式的,子目录中的文件也会被询问。

如果 force 为非 nil 值,该命令会重编译所有拥有 .elc 的 .el 文件。

除了上面提到的四个函数,还有一个叫做 batch-byte-compile 的函数,它调用 byte-compile-file 来对文件进行编译。该函数只能在执行 batch 时使用,在完成后它会关闭 emacs。当某一文件出现错误时不会影响其他文件的处理,但它也不保证一定会有输出文件。出现错误后 emacs 会终止并返回非零状态值。

batch-byte-compile 接受一个可选参数 noforce ,如果它为非空的话,该函数不会重编译已有足够新的 .elc 的 .el 文件。

2. 编译与文档字符串(documentation string)

当 emacs 从 .elc 文件中载入函数和变量的时候,它通常不会将文档字符串载入内存中。只有在需要的时候,文档字符串才会从 .elc 文件中被动态载入。这样可以节约内存和加快载入时间,毕竟跳过了字符串的处理。

这个特性也有缺点,如果你删除,移动或修改了编译文件(比如重编译得到更新的文件),emacs 可能不能访问之前载入的函数或变量的文档字符串。这种情况一般只会出现在你自己编译 emacs 的时候,或者是编辑或重编译 emacs 的 lisp 源文件时。要解决这个问题,只需要重新载入重编译后的文件即可。

是否使用这个特性是在编译期间决定的,可以使用 byte-compile-dynamic-docstrings 这个选项来控制该行为。默认情况下它的值是 t ,也就是使用动态载入。

如果想要在编译文件时指定禁用该特性,可以使用文件局部变量来做到这一点:

;; -*- byte-compile-dynamic-docstrings: nil; -*-

3. 编译时求值

在上面的例子中你也看到了,我由 cp.el 得到了 cp.elc,cl.el 中的代码被编译并写入到了对应的 elc 文件中。在该过程中 cp.el 的代码并没有执行,如果我们想让其中的某些代码执行,以此达到某种目的的话,我们可以使用下面介绍的方法。

eval-and-compile 是一个 special form,在它里面的表达式可以在你编译代码和运行代码时执行。要达到相似的效果,你可以在需要编译的文件中加上 require,然后把需要执行的代码放到 require 的那个文件中,当需要执行的代码很多的时候这样做是比较推荐的。

eval-when-compile 可以让它里面的表达式在编译时被求值,但在载入 elc 文件时不被求值。它的求值结果会以常值的形式出现在得到的编译文件中。如果你载入 el 文件而不是 elc 文件的话, eval-when-compile 中的表达式会被正常求值。

如果你需要一个在编译时经过某些计算才能得到的常值,那么你可以这样做:

(defvar my-regexp
  (eval-when-compile (regexp-opt '("aaa" "aba" "abb"))))

如果你需要用到其他的 package,但只想使用里面的宏,那么可以这样做:

(eval-when-compile
  (require 'my-macro-package))

在编译你的文件后,宏已经展开了,在运行时就不需要使用到其他的 package 了。对于宏和 defsubst 也可以这样,如果只在该 package 内部使用的话,也可以放到 eval-when-compile 里面:

(eval-when-compile
  (unless (fboundp 'some-new-thing)
    (defmacro 'some-new-thing ()
      (compatibility code))))

文档中是这样描述 eval-and-compile 的: Most use of eval-and-compile are fairly sophisticated 。所以就我来说可能是用不上了。文档中给出了两种使用场景,一是用在宏的辅助函数上,二是函数在程序执行过程中定义(defined programmatically)。下面我们对它们进行稍微详细一点的解读。

3.1. 用于辅助函数

我在之前的文章中提到过关于 elisp macro 的编译展开行为,这里我们再简单介绍一下。当宏调用出现在需要被编译的 lisp 代码中时,lisp 编译器会像解释器一样展开宏调用,但不同的是编译器会将展开结果插入原宏调用位置并继续编译,而不是对展开结果求值。因此,如果要编译含有宏调用的代码,你需要先让需要的宏先被定义。不过编译器的一个特性可以免除这个麻烦,如果宏定义存在于要被编译的文件中,那么这些宏在编译时会暂时被定义。

在宏中是可能用到辅助函数的,比如展开过程中需要调用某函数并使用函数的返回值之类的,就像这样:

(defmacro yy-he (x)
  (let ((x-type (yy-get-type x)))
    (cl-case x-type
      ((int) `(+ ,x 1))
      ((flt) `(+ ,x 1.0))
      ((sym) `(cons ',x 'a))
      ((t) x))))

(defun yy-get-type (x)
  (cond
   ((symbolp x) 'sym)
   ((integerp x) 'int)
   ((floatp x) 'flt)
   (t t)))

在上面的函数与宏中, yy-get-type 的返回值为 yy-he 所用。如果在宏展开时辅助函数 yy-get-type 还没有定义的话,会出现 void-function 的错误的。

若宏只在 package 内(这里理解为同一待编译文件即可)使用的话,在文件编译前定义该辅助函数或使用 eval-when-compile 即可,即把 yy-get-type 放到 eval-when-compile 的 body 里面。宏由于编译器特性可以直接放在文件的 top-level。

但如果在 package 外还要用到宏的话,那么辅助函数对外也必须是可见的,载入 elc 文件时需要载入辅助函数。这个时候就要使用到 eval-and-compile 了:

(eval-and-compile
  (defun yy-get-type (x)
    (cond
     ((symbolp x) 'sym)
     ((integerp x) 'int)
     ((floatp x) 'flt)
     (t t))))

3.2. 程序执行过程中定义函数

所谓执行中定义函数,指的是执行代码过程中才定义函数(听君一席话,胜听一席话),而不是在代码执行前就定义好函数。这就是说函数可能根据不同情况来选择不同的函数定义。举例来说的话就是这样:

(if (= 1 1) (fset 'yy-me (lambda (x) (+ x 1)))
  (fset 'yy-me (lambda (x) (+ x 2))))
(defun yy-i (x) (yy-me x))

(yy-i 1) => 2

如果我们仅仅需要在编译时使用这个函数,把它放到 eval-when-compile 中就可以了,但如果在 package 外我们也想要使用它,那就不得不放到 eval-and-compile 里面了。

需要说明的是,这一段只是我对文档描述的一种理解,文档原文如下:

If functions are defined programmatically (with fset say), then eval-and-compile can be used to have that done at compile-time as well as run-time, so calls to those functions are checked (and warnings about “not known to be defined” suppressed).

至于 defined programmatically 是不是还包括了其他的情况,我就不是太清楚了。如果你有什么看法欢迎和我交流。

4. 字节码对象

字节编译后的函数有一种特别的类型:它们是字节码函数对象(byte-code function object)。emacs 使用字节码解释器来执行这些字节码。

字节码函数的内部表示很像向量,它的元素可以使用 aref 访问。它的打印表示和向量也很像,不过在 '[' 前面有 '#' 作为前缀。它至少拥有四个元素,最多元素数量没有限制,但是一般只会用到前六个,它们分别是:

argdesc 是参数的描述符。它可以是参数表,或者是参数个数的整数编码值。对于后者,描述符的第 0 位到第 6 位用来指定最小参数个数,第 8 位到第 14 位用来指定参数的最大个数,如果参数表使用了 &rest ,那么第 7 位会被置位,否则会被清除。

如果 argdesc 是表形式的话,在调用字节码之前实参会被动态绑定到它上面。如果 argdesc 是整数的话,在执行函数字节码前,参数会被压入字节码解释器的堆栈中。

这里对于数字形式的 argdesc 做一下解释,如果函数的参数表是 (a b) 的话,根据规则,0 - 6 位用来存放最少参数个数, 8 - 14 位用来存放最多参数个数,由于没有 &optional 选项,最少和最多参数数量应该相同,而且由于没有使用 &rest ,第 7 位应该置 0,所以得到的结果应该是:

14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
0 0 0 0 0 1 0 0 0 0 0 0 0 1 0

得到的结果就是 1000000010 ,换算一下就是 514,我们可以使用以下代码来证实:

(defun pp-yy1 (x y) (+ x y))
(byte-compile 'pp-yy1)
=>

#[514 "^A^A\\\207" [] 4 "

(fn X Y)"]

上面的例子中,如果我使用三参列表, argdesc 会直接变成参数表的形式。我猜 emacs 只会将参数数量较少的函数编译为 argdesc 使用整数。

包含字节码指令的字符串

由字节码使用的 lisp 对象向量,这包括作为函数名和变量名的符号。

堆栈的最大大小

文档字符串,如果没有的话就是 nil。这个值可以是一个数字或表,这就表明文档字符串是存储在文件中。通过 documentation 函数可以获取真正的文档字符串。

函数的 interactive 声明。它可以是字符串或 lisp 表达式,如果函数不是 interactive 的话,这个值为 nil。

在文档的最后还提到了一个叫做 make-byte-code 的函数,使用它可以生成字节码函数。但是作为用户的我们不应该使用它来生成字节码。如果出现了某些差错,emacs可能会在你调用该函数时直接崩溃。你应该总是使用字节码编译器来生成这些对象。

5. 字节码的反汇编

老实说这一节的意义不大,文档中是这样描述的: People do not write byte-code; that job is left to the byte compiler. But we provide a disassembler to satify a cat-like curiosity. (笑)这个反汇编器可以将字节码转变成人类可读的形式。

字节码解释器是使用简单的堆栈机器实现的。它将值压入栈中,并在计算时弹栈用来计算,随后将得到的值重新压入栈中。当字节码函数返回时,它将值弹出并将它作为函数的返回值。

要进行反汇编的话,需要使用 disassemble 函数,它接受一个字节码对象,并返回汇编伪代码。它接受 buffer-or-name 来作为可选参数,若该参数为 nil,该函数会将结果输出到叫做 *Dsiassemble* 的 buffer 中。如果为非 nil 的话,那么它的值必须是一个已存在的 buffer 或 buffer 名,随后函数会输出到 buffer 中。

我们以一个简单的斐波那契函数作为例子看看它的输出:

(defun fib (n)
  (or (and (= n 0) 0)
      (and (= n 1) 1)
      (+ (fib (1- n)) (fib (1- (1- n))))))

(disassemble (symbol-function 'fib))
byte code:
  args: (n)
0	varref	  n
1	constant  0
2	eqlsign
3	goto-if-nil 1
6	constant  0
7	return
8:1	varref	  n
9	constant  1
10	eqlsign
11	goto-if-nil 2
14	constant  1
15	return
16:2	constant  fib
17	varref	  n
18	sub1
19	call	  1
20	constant  fib
21	varref	  n
22	sub1
23	sub1
24	call	  1
25	plus
26	return

我可看不懂这段汇编伪代码,看个乐子算了。不过相比起各种缩写词的汇编,它的可读性还是不错的。关于各指令的意思,如果觉得难猜的话可以直接取读读 elisp manual。

6. 编译错误

以下大体是 elisp manual 在 Compiler Errors 一节的翻译。

在编译过程中出现的警告和错误会打印到叫做 *Compile-log* 的 buffer 中。这些消息包括了出现的问题所在文件的文件名和行号。当错误是由语法引起的时候,字节编译器可能不是很清楚出问题的准确位置。找位置的一种方法是在 *Compiler Input* buffer 中找到出现问题的点。

很常见的一种警告是使用了未定义的函数或变量。这样的问题报告的行号是文件的末尾而不是使用未定义变量或函数的位置。要找到这些位置,你必须自己动手。如果你确定这些警告是多余的,你可以通过以下方法来忽略掉它们:

(if (fboundp 'func) ...(func ...)...)
(if (boundp 'variable) ...variable...)

除了上面的方法,你也可是使用下面的两个 sepcial form 来忽略掉某些警告:

with-suppressed-warnings 接受 wariningsbody... 作为参数, warnings 是一张 alist,每个项的 car 部分是警告类型,cdr 部分是产生警告的函数或变量的符号。例如,如果你想要忽略 obsolete 警告,你可以这样做:

(with-suppressed-warnings ((obsolete foo))
   (foo ...))

相比于 with-supported-warningswith-no-warnings 提供了一个更为粗粒度的方法。编译器不会对 body 内的任何警告进行提示。

7. 字节码的速度

这里我们使用上面定义的 fib 函数来测试一下字节码和非字节码函数的执行速度,为了使差别更加明显,这里通过计算 (fib 40) 来说明速度:

(defun fib (n)
  (or (and (= n 0) 0)
      (and (= n 1) 1)
      (+ (fib (1- n)) (fib (1- (1- n))))))

(let ((t1 (float-time)))
  (fib 40)
  (- (float-time) t1))
=> 70.61351799964905

(byte-compile 'fib)

(let ((t1 (float-time)))
  (fib 40)
  (- (float-time) t1))
=> 31.948441982269287

就结果上来说,经过字节编译的 fib 比原始版本快了一倍多。但这并不能说明编译函数一定会带来如此之大的性能提升,毕竟大多数函数不会这么简单,而且调用次数不会这么多(fib 的递归调用次数是指数增长的)。至于有没有必要把自己的配置文件编译一遍,我感觉是没有必要的。这样做带来的性能提升应该不会很多,不断地更新 elc 文件反而成了麻烦。