Emacs Lisp Readable Closures
Table of Contents
我之前说过,Emacs Lisp 的特性之一就是它的闭包是可读的。闭包可以被 printer 列表化 以后被 readr 读入。我不知道有其他哪些编程语言能这样做。事实上这是 Elisp 字节码编 译的必需之一,因为字节码编译本质上就是读入源代码后把字节码 S 表达式写入字节码文 件。
1 打印列表
Lisp 系语言因 S 表达式而具有同像性。由于编译器/解释器在程序运行时是可用的,读入 输入和打印输出成为了 Lisp 的基本功能。一个数值传递给 printer 后会作为字符串被序 列化成 S 表达式。之后 reader 将 S 表达式解析为等价数值。
相比之下,原本 JavaScript 只这有一半的功能。JavaScript 有 JSON 可以方便定义关联 列表。=eval= 函数可作为一个危险的 reader 来从 JSON 格式字符串读取数值。直到 =JSON.stringfy()=成为标准,开发者需要自行实现 printer。Lisp S 表达式明显更强大而 复杂,同时能维护标识和环形列表。
不是所有数值都能读取。在 Common Lisp 中,不能被读取的值 (在 *print-readbly*
为
nil
时)会以特殊语法给 reader 传错误信号: #<
。举例,Emacs Lisp 的 buffer 不
能被序列化,所以用如下语法:
(prin1-to-string (current-buffer)) ;; => "#<buffer *scratch*>"
⻆括号里是什么不重要,它甚至不需要配对,因为 reader 在遇到 #<
格式时立刻就会报错。
2 无所不读,无所不印
Elisp 定义了一系列数据类型。其中可读的有:
- 整数 integer
1024, ?a
- 浮点数 float
1.7
- CONS/列表 cons/list
(...)
- 向量 vector (一维)
[...]
- 布尔向量 bool-vector
#&n"...")
- 字符串 string
"..."
- 特征标表 char-table
#^[...]
- 哈希表 hash-table (Emacs 23.3 之后可读)
#s(hash-table ...)
- 字节码物件 function object
#[...]
- 符号 symbol
以下是所有不可读的类型,每个都有合理的原因。
- 缓冲区 buffer
- 迸程 process (外部状态)
- 帧 frame (用户界面元素)
- 标记 marker (动态刷新)
- 叠层 overlay (buffer 子元素)
- 内建函数 built-in functions (字节码)
- 用户外部结构 user-ptr (Emacs 25 dynamic modules 的不透明指针)
这就是了。其它所有 Elisp 数值都是基于以上原始类型中的一种或多秉种,包括 键位 keymap,函数 functions,宏 macros,语法表 syntax tables,数据结构 defstruct,面 向对象物件 EIEIO objects。这意味着只要这些物件未引用不可读的值就能被打印出来。
值得注意的一点是,不像 Common Lisp Object System,EIEIO 的物件是默认可读的:它本 质上还是 ELisp 向量。CLOS 物件需要通过定义每个类的打印方法才能被读取。
3 Elisp 闭包
2012 年 6 月发布 Emacs 24 之后 Elisp 增加了词法作用域,成为极少数同时具有动态/词
法作用域的语言之一。同 Common Lisp, defvar
定义的变量仍保留动态作用域。为确保
向后兼容,词法作用域默认关闭,需通过设置当前文件/缓冲区 lexical-bing
为真以启
用。
词法作用域中,匿名函数成为强大的函数式编程原型:闭包,即带有捕捉的环境变量的函数。 静态作用域亦具性能优势。据本人测试,启用静态作用域字节编译后的 Elisp 速度能提升 约10% 至 15%。
Emacs Lisp 中的闭包长什么样?这取决于闭包是否被字节编译。举例,如下函数 foo
取
两个参数并返回一个返回第一个参数的闭包。
;; -*- lexical-binding: t; -*- (defun foo (x y) (lambda () x)) (foo :bar :ignored) ;; => (closure ((y . :ignored) (x . :bar) t) () x)
未编译的闭包是个以 closure
符号开头的列表。第二个元素是词法环境,其余便是
lambda
的参数。这里我们可看出 x
与 y
均被捕捉。这显得有些粗糙,因为 y
并
未被使用。捕获未使用的变量主要会造成:
- 闭包占用更多空间
- 更长回收时间
- 影响读入(之后会讲)
可喜可贺的是,Elisp 编译器有足够的能力对此作出优化。编译 foo
以证明:
(foo :bar :ignored) ;; => #[0 "\300\207" [:bar] 1]
返回的是用 #[...]
语法的字节码物件,具有如下元素:
- 参数列表(0个参数)
- 单字节字符串形式的字节码
- 常数向量
- 所需栈空间
可见词法环境定义在常数向量中,而 :ignored
并未被编译器捕捉。
对于那些好奇字节码原理的人,简要地讲,字符串中的字节码是八迸制,表示指令 192
和 135
,Elisp 的字节码解释器是基于栈的。 192
(constant 0) 将向量第一个常数
入栈。 135
将取出栈顶元素并返回之。
(coerce "\300\207" 'list) ;; => (192 135)
4 可读闭包捕获
因闭包皆可读字节码物件,你能在闭包中捕捉环境,将其序列化以后读入求值。这意味着闭 包是可用于传递环境的。这个特性被用在 Elnode, Async 等多任务处理中。
而捕捉的问题是𣎴可读数值易被意外捕获,尤其是 缓冲区。如下函数 bar
用一临时缓冲
区建立字符串,返回一返回结果的闭包。(有点诡异,但这只是个例子)
(defun bar (n) (with-temp-buffer (let ((standard-output (current-buffer))) (loop for i from 0 to n do (princ i)) (let ((string (buffer-string))) (lambda () string)))))
编译的版本看起来没问题,
(bar 3);; 译者注:原文是 (foo 3),疑为 Typo,下同 ;; => #[0 "\300\207" ["0123"] 1]
然而未编译的版本由于 with-temp-buffer
静默绑定了变量导致抽象泄漏。
(bar 3) ;; => (closure ((string . "0123") ;; (temp-buffer . #<killed buffer>) ;; (n . 3) t) ;; () string)
临时缓冲区被误捕获使闭包不可读,但编译之后却无问题。这产生了一个必须被编译成字节 码才能够得到正确的结果的特例。