EMACS-DOCUMENT

=============>集思广益

Emacs Lisp Readable Closures

我之前说过,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 的参数。这里我们可看出 xy 均被捕捉。这显得有些粗糙,因为 y 并 未被使用。捕获未使用的变量主要会造成:

  • 闭包占用更多空间
  • 更长回收时间
  • 影响读入(之后会讲)

可喜可贺的是,Elisp 编译器有足够的能力对此作出优化。编译 foo 以证明:

(foo :bar :ignored)
;; => #[0 "\300\207" [:bar] 1]

返回的是用 #[...] 语法的字节码物件,具有如下元素:

  1. 参数列表(0个参数)
  2. 单字节字符串形式的字节码
  3. 常数向量
  4. 所需栈空间

可见词法环境定义在常数向量中,而 :ignored 并未被编译器捕捉。

对于那些好奇字节码原理的人,简要地讲,字符串中的字节码是八迸制,表示指令 192135Elisp 的字节码解释器是基于栈的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)

临时缓冲区被误捕获使闭包不可读,但编译之后却无问题。这产生了一个必须被编译成字节 码才能够得到正确的结果的特例