6. XEMACS PERIOD
Lucid Emacs 的重点是提供一个合适的图形用户界面。因此,Lucid Emacs/XEmacs 中对 Emacs Lisp 所做的大部分更改是为了支持从基于 TTY 的纯文本模型到图形模型的转变。在 2.3 节描述的 Lucid 的最初需求导致了额外的库特性和数据类型的创建,例如事件、keymap,字符和 extents,但对核心语言或其实现没有直接影响。因此,描述这些变化的本节内容相对较少。Lucid Emacs/XEmacs 中对实现的众多改进是为了改进编辑器和编程体验的更一般性的努力,这已经在第 5 节讲过了。
6.1. Event and Keymap Representation
与 Emacs 相比,Lucid Emacs 的一个重要的不同之处在于 keymap 的表示。keymap 将一系列按键映射到要调用的函数。许多的 Emacs 按键设定(key assignment)由按键序列组成,例如 Control-X Control-S
用于保存当前 buffer。keymap 通过嵌套 keymap 来表示这一点。例如, Control-X
绑定并不是一个函数,而是嵌套的 keymap,位于该 keymap 中的 Control-S
被绑定到 save-buffer
函数。此外,keymap 可以从其他 keymap 继承键绑定。
迄今为止,Emacs 仍然使用透明的 S-表达式作为 keymap 表示。在早期的 Emacs 中,keymap 就是由两个元素组成的列表,其 car 是 keymap 的符号,第二个元素可以是按字符码索引的向量或关联列表。Emacs 当前的 keymap 表示更加丰富,但基本上遵循相同的设计。以下是一个示例[^44]:
(keymap
(3 keymap
;; C-c C-z
(26 . run-lisp))
(27 keymap
;; ‘C-M-x’, treated as ‘<ESC> C-x’
(24 . lisp-send-defun))
;; This part is inherited from ‘lisp-mode-shared-map’.
keymap
;; <DEL>
(127 . backward-delete-char-untabify)
(27 keymap
;; ‘C-M-q’, treated as ‘<ESC> C-q’
(17 . indent-sexp)))
从顶部开始,3 表示 ASCII 中的 Control-C
,26 表示 ASCII 中的 Control-Z
。键位映射中的嵌套表示 Control-C Contorl-Z
序列将运行 run-lisp
函数。接下来,27 表示 ASCII 中的 Escape
,而在 Emacs 中,它同时也是 Meta
修饰键。因此, Escape Control-X
和 Meta-Control-X
都会触发 lisp-send-defun
。类似地,127 表示 ASCII 中的 Delete
,它会触发 backward-delete-char-untabify
。最后, Escape Control-Q
和 Meta-Control-Q
都会触发 indent-sexp
。
这种透明的表示方式在软件演进中会带来问题:虽然 Emacs 提供了用于构造和修改 keymap 的工具,但 Emacs 代码原则上可以直接使用用于创建 S 表达式的工具 —— 如 cons
, rplaca
, rplacd
等(译注,这意味着如果 keymap 的表示方式发生了改变,使用基础函数的方式可能会失效)。Emacs 19 试图通过仅扩展先前的列表表示来在某种程度上保持这种代码的可用性。然而,随着表示形式的变化,这样的代码最终会出现问题,虽说不会立即触发错误。此外,对继承的支持在 Emacs 24.1 之前基本上存在缺陷,直到该版本中才扩展为多重继承,但代价是对代码进行了相当重大和复杂的重写。
Lucid Emacs 的开发人员(指 Richard Mlynarik)预见到了这些问题,因此将 keymap 作为一种不透明的数据类型,禁止使用 S 表达式原语进行操作,并赋予自己更大的实现自由度[^36]。这使得 Lucid Emacs 能够更快地演进 keymap 的表示形式,以适应更丰富的输入事件,包括鼠标事件等。
这反映了一种普遍的哲学差异(4.5 节,5.4 节和 5.5 节)。Lucid Emacs 还对 case table(大小写映射表)和输入事件使用了自定义的不透明数据类型,而在 Emacs 中,这些数据类型至今仍保持透明表示。
6.2. Character Representation
在 XEmacs 20 发布时,出现了另一个表示方式的变化,这是第一个支持 MULE(Multi-Lingual Emacs,多语言 Emacs)的 XEmacs 版本。
先前的 Emacs 和 XEmacs 版本与字符的 8 位表示密切相关。此外,它们不仅使用字符串表示文本,还使用字符串表示按键序列。在字符串中,第 8 位表示 Meta,基本上将 Emacs 限制在了 ASCII 字符集。字符的值可以在高位具有比字符串中存储的字符更多的修饰符。
当多语言支持引入 XEmacs 时,这种情况已经无法继续。关于 MULE 的工作[^45]早于 Unicode 的广泛采用,当 XEmacs 采用 MULE(大约在 1994 年)时,许多其他的文本编码仍在被使用。MULE 字符表示基于 ISO-2022,将字符编码为一个分为两部分的整数:一部分表示国家字符集,另一部分表示该字符集中的关联码点。
为了强制字符与其关联编码之间的分离,XEmacs 20 将字符作为独立的数据类型。XEmacs 提供了用于在字符和其数值表示之间进行转换的函数(即 make-char
和 make-int
)。一般来说,Emacs Lisp 允许程序将文本主要作为字符串处理,并避免直接操作数值表示。将字符设置为不透明的类型进一步阻止了这种做法。
6.3. C FFI
由于 Emacs Lisp 运行时是 C 写的,因此始终可以向系统添加用 C 编写的新 Emacs Lisp 函数。这些 C 函数也可以调用 Emacs Lisp 函数。
然而,使用 C 编写的函数缺乏 Emacs Lisp 的动态特性,因为它们必须静态链接到 Emacs 可执行文件中。从 1998 年开始,J.Kean Johnston 为 XEmacs 添加了功能(在 1999 年的 21.2 版本中发布),允许 Emacs Lisp 代码构建和动态加载以 C 编写的共享库(称为模块, module )到运行的编辑器中,并调用其中定义的函数。从 2002 年开始,XEmacs 21.5.5 版本开始分发了许多这样的模块,其中包括现有 C 库的绑定,如 Zlib、Ldap 和 PostgreSQL。
即使有了模块,开发人员仍然需要创建包装器来使现有的 C 库能够在 Emacs Lisp 中访问。2005 年,Zajcev Evgeny 为 SXEmacs☨[^46](XEmacs 的一个分支)编写了一个 FFI。这个 FFI 允许直接加载和调用现有的 C 库,无需中间包装器,只需在 Emacs Lisp 中声明 C 函数的类型签名。例如,FFI 允许这样使用 Curl:
(ffi-load "libcurl.so")
(setq curl:curl_escape
(ffi-defun '(function c-string c-string int) "curl_escape"))
(let* ((url "http://foo.org/please escape this<$!=3>")
(str (ffi-create-fo 'c-string url))
(len (ffi-create-fo 'int (length url)))
(result (ffi-call-function curl:curl_escape str len))
(ffi-get result))
由于担心这将打开一个后门,使开发人员能够合法地绕过 GNU 通用公共许可证(GPL),从而将 Emacs 自身的代码与不符合其许可条款的代码进行链接,RMS 拒绝将类似 XEmacs 的 FFI 功能纳入到 Emacs 中[^47]。
经过多年的压力(不仅仅是 Emacs 项目内部的压力,因为这还影响了其他几个 GNU 项目,尤其是 GCC),达成了一个解决方案,即实现一个 FFI,只能加载带有特殊符号的库,以证明该库与 GPL 兼容。因此,在经过漫长的等待后,2016 年终于发布了带有加载外部库功能的 Emacs 25.1,它有点类似于 XEmacs 的 FFI。这项功能受到了极大的期待,但目前使用还不是很广泛,可能部分原因是使用这种功能的包与 Emacs Lisp 包不同,不能直接从源安装,而是需要终端用户在其计算机上拥有 C 编译器,这可能成为分发的一个重要障碍。
☨: 2004 年年底,Steve Youngs 基于对 XEmacs 代码质量和稳定性问题的认识,以及为了能够进行比 XEmacs 当前所能承受的更大的变更,从 XEmacs 21.4.16 分支出了 SXEmacs。
6.4. Aliases
在开发 XEmacs 19.12(即 1995 年)期间,Emacs 19 的第一个正式版本 Emacs 19.28 发布了。Emacs 已经实现了一些 XEmacs 的功能,尤其是对多开图形用户界面窗口的支持。XEmacs 称这些窗口为“screens”,而 Emacs 称其为“frames”。与 Emacs 的兼容性是当时 XEmacs 开发者的重要目标。因此,他们对相关功能进行了重新命名。
为了保持与先前版本编写的 Emacs Lisp 代码的兼容性,XEmacs 引入了 define-obsolete-functional-alias
和 define-obsolete-variable-alias
这两个形式。如果使用了这些别名,字节码编译器会发出警告,但仍然编译该代码。
Emacs 很早就有一个 defalias
形式来声明函数别名,可以基于此形式实现 define-obsolete-function-alias
功能☨。XEmacs 19.12 添加了一个对应的原语来定义变量别名,即 defvaralias
,并添加了 variable-alias
和 indirect-vairable
函数来检查别名链。
直到 2007 年,这些补充才在十多年后合并到了 Emacs 22.1 中。
☨: 有趣的是, defalias
在 1986 年被从 Emacs 代码库中删除,然后在 1993 年重新引入
7. EMACS/XEMACS CO-EVOLUTION
Emacs Lisp 的某些方面在 Emacs 和 XEmacs 中都有发展,二者都从对方那里借鉴了设计和代码。
7.1. Performance Improvements
Emacs 和 XEmacs 都进行了各种性能改进,其中大部分改进在两者之间相互合并。
Jamie Zawinski 和 Hallvard Furuseth 为 Lucid Emacs 编写了一个新的优化字节码编译器,在最初有些阻力后,于 1992 年合并到 Emacs 代码库中。
大约在 1992 年,在 Emacs 19 的早期开发阶段,字节码解释器的实现被重写,并且被应用于 Emacs 和 XEmacs。在这次重写中,引入了一个新的对象类型来表示字节编译的 Emacs Lisp 函数。在此之前,字节编译函数的结构如下所示:
(lambda (..ARGS..) (byte-code "..." ...))
在 Emacs 19.29 中,为了加快加载 Emacs Lisp 包的速度并减少 Emacs 进程的内存使用,添加了一种机制,可以延迟从编译的 Emacs Lisp 文件中获取文档字符串和字节码。如果在 Emacs 运行时修改了文件,这可能会引发问题,因此尽管此功能始终用于 docstring,但很少用于字节码。
在 1989 年,Martin Buchholz 向 XEmacs 字节码解释器添加了一个 JIT 优化 pass。这个 pass 会提前执行一些有效性检查(以便在执行中省略它们),预先计算堆栈使用情况,将字节码跳转设为相对跳转(节省一个寄存器),并对具有短偏移量的相对跳转进行优化。这实际上创建了一种替代的字节码方言,XEmacs 会根据需要将其转换回“标准”表示形式。在 XEmacs 19 周期和大部分 XEmacs 20 周期中,开发人员避免改变字节码格式,以便在 Emacs 和 XEmacs 之间交换字节码文件。虽然 XEmacs 开发人员有意避免对字节码格式进行修改,但这并不是 Emacs 的目标,两种指令集最终发生了漂移,并变得不兼容。
在 2009 年末,Tom Tromey 修改了 Emacs 字节码编译器,在可用时利用 GCC 的 computed goto 特性实现 token threading[^48]☨。早在 2004 年 5 月,Jaeyoun Chung 就提交了这个功能的补丁,但当时并没有测量到速度的提升,因此并没有引起太大的热情。Tom 的实现并没有更好,速度提升仅为 5%。尽管如此,他坚持将其纳入,直到 2012 年的 Emacs 24.3 版本才实现了这一目标。为什么速度提升有些令人失望并没有得到深入调查,但普遍共识是字节码解释器并没有经过充分优化,因此 switch
的相对成本并不像可能(或者说是应该)的那样高。
☨: 译注,token threading 是一种优化技术,用于提高字节码解释器的执行速度。每个字节码指令都被分配一个唯一的 token,解释器不使用条件分支或 switch
语句来进行指令分派,而是直接跳转到 token 关联的下一条指令的内存地址。
7.2. Custom Library
Emacs 最初设计的原则之一是通过 Emacs Lisp 来进行定制。例如,是否启用通过按下 Shift
和移动键来选择一个区域这个功能在 Emacs 中是由一个名为 shift-select-mode
的 Emacs Lisp 变量来控制的。如果用户想要更改由变量控制的默认行为,他们可以将适当的 Emacs Lisp 代码放入一个被称作“初始化文件”(init file)的,随 emacs 启动而默认加载的文件中,如下所示:
(setq shift-select-mode nil)
期望用户使用 Emacs Lisp 进行定制创造了很高的门槛。作为应对,Per Abrahamsen 在 1996 年贡献了两个名为 Custom
和 Widget
的库,使用户可以通过界面而不是通过 Emacs Lisp 来定制变量的值。 Custom
首次随 Emacs 20.1、Xemacs 20.1 和 XEmacs 19.15 一起发布(XEmacs 19.15 在 XEmacs 20.0 发布之后)。图 1 显示了 shift-select-mode
的界面。
Custom
最初是为了定制 Gnus 新闻阅读器而创建的。随着其被整合到 Emacs 和 XEmacs 中, Custom
也获得了 Emacs Lisp 的编程接口。通过 defvar
原始方式进行的 shift-select-mode
的声明如下:
(defvar shift-select-mode t
"When non-nil, shifted motion keys activate the mark momentarily.
...")
Custom
库提供了 defcustom
,它支持以下声明:
(defcustom shift-select-mode t
"When non-nil, shifted motion keys activate the mark momentarily.
..."
:type 'boolean
:group 'editing-basics)
这个声明启用了用于定制 shift-select-mode
的界面。作为布尔值的 :type
声明将 Custom
显示为一个 Toggle
按钮。Emacs Lisp 程序员可以进一步将多个 defcustom
变量组合为 group 来创建层次结构, Custom
也会将它转换为导航 API。 :type
声明可以简洁地描述复杂的结构,如下所示:
(defcustom cc-other-file-alist
'(("\\.cc\\'" (".hh" ".h")) ...)
"Alist of extensions to find given the current file's extension.
..."
:type '(repeat (list regexp (choice (repeat string) function))))
根据这个 :type
声明, Custom
将生成一个适当的界面来操作该值,它是一个元素列表,每个元素是一个正则表达式和一个字符串列表或函数组成的序对。这个 :type
声明还可以起到文档的作用。 Custom
现在在 Emacs Lisp 代码中被广泛使用,并且将编程语言和界面紧密联系在一起。
7.3. Unicode
随着 Unicode[^49] 的普遍采用,Emacs 和 XEmacs 都支持这一标准。Emacs 21.1 通过将其作为另一个“国家”(national)字符集添加到系统中,增加了对 utf-8 编码系统的支持。这意味着来自 utf-8 文件的“é”与来自 latin-9 文件的“é”被认为是不同的字符。这种区别在之前就已经存在,例如在 latin-1 和 latin-9(以及许多其他字符集)之间,但在过渡到 utf-8 期间许多的用户同时使用了两种编码系统,从而接触到这个问题。因为这个原因,2001 年,Emacs 22.1 引入了一种有限的字符集统一形式。XEmacs 也在 2001 年的 21.4 版本做了同样的改进。
随着 Unicode 成为通用的文本表示形式并取代了许多早期的编码方式,Emacs 和 XEmacs 都开始努力使用 Unicode 替代内部的 MULE 表示。这一变化出现在 Emacs 23(2007 年)和 XEmacs 21.5(从大约 2010 年开始的一个独立分支)。因此,无论是在 Emacs 还是 XEmacs 中,字符的整数表示现在是其 Unicode 标量值。
7.4. Bignums
令人惊讶的是,作为一种 Lisp 语言,Emacs Lisp 多年来都没有对任意大的整数(bignum)提供支持。整数范围受限于底层机器的字长,随着时间的推移,表示方式的改变也影响了 Emacs Lisp 中整数的确切可用范围(5.4 节)。因此,处理超出 fixnum 范围的数字的各种函数不得不实现解决方案。值得注意的是 file-attributes
和 current-time
。前者可以使用两个 fixnum 组成的序对表示 inode 号、设备号、用户 ID 和组 ID,并可以使用浮点数表示文件大小。后者返回一个数字列表来编码时间。整数范围有限还引发了另一个问题,即它限制了可以编辑的文件的最大大小。
此外,Emacs Lisp 在文本编辑之外的应用中越来越多,也必须实现解决方案。Calc 是一个高级计算器和计算机代数工具,自 2001 年起随 Emacs 发行,由于 Emacs Lisp 不支持 bignum 而不得不在 Lisp 中实现大数算术。
Jerry James 在 2004 年的 XEmacs 21.5.18 版本中使用 GMP 库[^50]添加了大整数支持。在 Emacs 中,Gerd Möllmann 于 2001 年 10 月左右开始通过 GMP 添加对 bignum 的支持,但从未完成。直到 2018 年 8 月,Tom Tromey 在 Paul Eggert 和其他几位开发人员的帮助下,最终再次使用 GMP 将 bignum 支持添加到了 Emacs 中。
XEmacs 中对 bignum 的支持包括任意精度的整数、有理数和浮点数,这在构建时是可选的。因此,虽然它相当完整,但 XEmacs 的 Emacs Lisp 程序仍然不能依赖于 bignum 支持。因此, file-attributes
、 current-time
和 Calc
仍未利用 bignum。
相比之下,Emacs 目前只支持任意精度的整数,但该功能通过在 Emacs 中捆绑 mini-gmp 库来无条件提供,以供在未安装 GMP 的系统上使用。不支持有理数和任意精度浮点数只是因为对这些功能缺乏兴趣。为了简化程序员的系统,支持被设置为无条件的:代码不需要为没有可用的 bignum 时保留替代的代码路径。因此, file-attributes
和 Calc 已被修改以使用本地 bignum。
有趣的是,在 Emacs 中,bignum 一直被视为可取的,但从未被视为足够重要,以克服需要 GMP 或其他多精度库的麻烦。改变权衡的决定性因素可能是到了 Emacs 25 时,大多数 Emacs 版本都链接了 GNUtls 库,该库包含 GMP 以进行加密操作(HTTPS 支持需要 GNUtls)。
引入 bignum 对 Emacs Lisp 提出了一些设计问题,因为以前整数始终是非封装的(unboxed)。这意味着快速的 eq
操作在浮点数上的行为与 eql
不同。因此,某些 Emacs Lisp 代码假设如果两个整数表示相同的数字, eq
操作将返回 true。bignum 是在堆上分配的,所以对于两个 bignum 来说,这个假设不一定成立。在 XEmacs 中,如果情况是这样, eq
操作可能会返回 nil,而这似乎并没有引起严重的问题。这个问题经过长时间的讨论也没有达成一致意见,Emacs 的维护者们采用了 XEmacs 的设计,因为让 eq
操作的行为像 eql
操作被认为成本太高,而且这是一个可以在以后轻松更改的决策,没有引入任何重大 bug 的风险,而反过来则不成立。
7.5. Terminal-Local and Frame-Local Variables, Specifiers
在 1995 年,Emacs 19.29 添加了同时在多个不同的 X11 服务器上拥有窗口的功能。XEmacs 也有类似的演进。这导致了一项要求,即显示(display)的某些方面应该局限于一个窗口或一个输出设备。Emacs 将 GUI 窗口称为 frame ,与 buffer 类似,Emacs 会维护对当前 frame( current frame )的引用,该引用确定了 GUI 操作的隐式目标。XEmacs 也通过显示上下文(display context)维护设备( device ),以区分不同的 TTY 和不同的 X11 服务器。
由于 buffer-local 变量已经允许根据上下文设置不同的值,Emacs 进一步加强了这种类比,引入了 terminal-local 变量的概念。 terminal-local 变量是根据当前 frame 所属的 X11 服务器(终端)而具有不同值的变量。terminal-local 变量的数量很少,而且在 C 代码中预定义;它们主要在内部使用,用于跟踪键盘状态等事情;Emacs Lisp 程序无法创建其他 terminal-local 变量。
XEmacs 选择了一条不同的路径:从 1995 年开始,Ben Wing(在 Chuck Thompson 的原型基础上)实现了 specifier ,这是一种管理依赖于广义 display context 的属性的对象[^51]。第一个原型实现与 XEmacs 19.12 一起发布。specifier 的值(它的实例)取决于其 locale ,可以是 buffer、window、frame、设备或 MULE 字符集,或者这些对象的某些属性。
Emacs 20 在 1998 年增加了将变量设置为 frame-local 值的功能。与 terminal-local 变量相反,任何变量都可以被设置为 frame-local 变量,此外,一个变量可以同时既是 frame-local 的又是 buffer-local 的。
在 2008 年,在开发 Emacs 23.1 期间,开发者发现并修复了几个在 let 绑定、buffer-local 变量和 frame-local 变量之间的边界情况交互中的一些错误(例如,在进入 let 绑定和离开之间将变量设置为 buffer-local 变量),并在随后决定变量不应同时既是 buffer-local 的又是 frame-local 的。
在解决这些错误的过程中,开发者明显发现 buffer-local 变量和 frame-local 变量的实现过于复杂,因此在 2010 年对实现进行了改进,以使代码中的不同可能状态更加明确。也就是在这个时候开发者决定逐步弃用 frame-local 变量。至于 buffer-local 变量,它们在 Emacs Lisp 中被广泛使用,而且用显式访问 buffer 对象的字段或属性替换它们会使 Emacs Lisp 代码变得复杂。相比之下,frame-local 变量并没有被广泛使用,而且可以很容易地通过传统的访问器访问 frame 属性来替代,因此很难在实现中证明其额外复杂性的合理性。因此,在 2012 年发布的 Emacs 24.1 中,不再允许对 frame-local 变量进行 let 绑定,在 2018 年发布的 Emacs 26.1 中,frame-local 变量已被完全移除。
8. POST-XEMACS PERIOD
在 1991 年到 2001 年期间,与 XEmacs 相比,Emacs 的改进速度相对较慢。但从 2001 年开始,Emacs 的步伐再次加快。在 2008 年,Richard Stallman 再次辞去 Emacs 的维护者职务,新的维护者们更热衷于推动 Emacs Lisp 的发展,而 XEmacs 在 2010 年左右开始失去了动力。
本节讨论了 2010 年之后 Emacs Lisp 设计中一些显著的进展,而这些进展在 XEmacs 中迄今为止尚未出现。
8.1. Lexical Scoping
当 Richard Stallman 开始开发 Emacs Lisp 时,词法作用域在 Lisp 家族中(包括 Common Lisp 和 Scheme)已成为确立的标准。自然,将词法作用域添加到 Emacs Lisp 中的问题已经被提出了很多次。
第一个实现出现得相当早,以 lexical-let
宏的形式出现,该宏是 Dave Gillespie 在 1993 年引入的新的 cl.el 的一部分。 lexical-let
宏执行了一种局部的闭包转换(closure-conversion)。虽然这个宏在许多包中被使用,但从来没有被认为是一个好的提供词法作用域的解决方案。这个有些冗长的名称可能是一个因素。此外,宏生成的代码比等效的动态作用域代码效率低,并且更难调试,因为基于回溯的调试器会显示宏展开的讨厌细节(gory details)而不是对应的源代码。因此, lexical-let
只在那些词法作用域真正有益的特定情况下使用。
动态作用域在实践中主要有两个缺点:
-
缺少闭包 。一些包通过使用类似
`(lambda (x) (+ x ',y))
的方式动态构建 lambda 表达式绕过了缺乏闭包的问题,但是这种方法存在各种问题,比如在闭包中的宏展开太晚,它的代码无法被字节码编译器看到。Emacs 23.1 引入了 curry 操作符 apply-partially
以覆盖类似的用例而不具有这些缺点。在所有这些情况下,程序员必须手动指定从环境中捕获哪些变量,在检测这方面的错误上,工具几乎没什么用。
-
变量名全局可见 。全局可见的变量名要求在选择本地变量名时更加小心。使用特定包前缀来命名所有全局变量的约定很好地避免了名称冲突:它不仅避免了全局变量之间的冲突,还避免了本地变量与全局变量之间的冲突,因为本地变量没有这样的前缀。唯一可能的冲突仅发生在不同函数的本地变量之间。在 Emacs Lisp 中,这些冲突往往只会在存在高阶函数的情况下发生。例如在以下代码中:
(let ((lst (some-list)))
(cl-every (lambda (x) (memq x lst)) elements))
如果在调用其第一个参数之前 cl-every
恰好绑定了一个名为 lst
的本地变量,就会发生变量捕获。名字冲突在另一种特殊情况下也会成为问题,即字节码编译器:为了发出关于使用未声明变量的警告,字节码编译器只是检查该变量是否已为 Emacs 所知,对于那些在调用栈中的函数(例如字节码编译器本身的函数)局部绑定的变量,该检查总是返回 true。因此,为了不干扰其他局部绑定,字节码编译器中的一些代码使用了较长的局部变量名称,使得代码变得更加丑陋。更糟糕的是,这些“解决方案”从来都不是真正完善的。
对于希望在 Emacs Lisp 中实现词法作用域的唯一完全令人满意的解决方案是,所有绑定构造默认使用词法作用域,并提供一种简单的方式来让某些变量使用动态作用域,就像在 Common Lisp 中一样。但与此同时必须坚决保持与现有 Emacs Lisp 代码的兼容性,尽管可以容忍某些罕见情况下的有限破坏。
绝大多数现有的 Emacs Lisp 代码对使用的作用域类型并不关心,无论是动态作用域还是词法作用域,在几乎所有情况下都会得到相同的结果。早期的 Emacs Lisp 代码就是如此,随着字节码编译器开始警告对未声明变量的引用,这一点变得更加明显。关于未使用变量的警告可能会促使更多的 Emacs Lisp 代码保持不关心作用域类型。但无论如何,尽管存在上述情况,很明显大多数 Emacs Lisp 包在某个地方依赖于动态作用域。因此,虽然有希望能够将 Emacs Lisp 切换到使用词法作用域,但如何找到需要使用动态作用域的少数位置以避免过多破坏现有代码这一点仍然不清楚。
在 2001 年,Matthias Neubauer 实现了一种代码分析工具,该工具并不试图找出需要使用动态作用域的地方,而是试图找出那些在词法作用域下不会改变结果语义的绑定情况[^52]。这个工具可以被用来自动地将 Emacs Lisp 包转换为一个具有词法作用域的 Emacs Lisp 版本,同时保持语义不变。采用这种方法的计划是为最终将 Emacs Lisp 代码迁移到 Scheme 中提供便利,但整个项目过于庞大,无法实现。
在 2001 年末,Miles Bader 开始在一个称为 lexbind 的 Emacs 分支上工作,以支持词法作用域,该分支最终在 Emacs 24.1 中被纳入。他采用的解决方案是使用两种语言:一个是带有动态作用域的 Emacs Lisp,用于向后兼容;另一个是具有类似 Common Lisp 的作用域规则的语言:默认为词法作用域,除了那些被声明为动态作用域的变量(实际上,这些都是全局变量)。每个文件都被标记以指示要使用的语言,默认为向后兼容模式。相应地,每个函数值都带有它所使用的语言的标记,因此使用动态作用域的函数可以无缝调用使用词法作用域的函数,反之亦然。这样,旧代码仍然可以像以前一样正常工作,而任何希望从词法作用域中受益的新代码只需在文件开头添加相应的 ;; -*- lexical-binding:t -*-
注释。
这两种语言非常相似,以至于新的词法作用域变体只需要对现有解释器进行一些微小的更改。但是,为了在字节码编译器中支持这种新语言需要进行更多的修改,这导致这个分支的进展较慢。这个分支与主要的 Emacs 开发保持同步,但是对字节码编译器的修改从未完成。
最终,在 2010 年,Igor Kuzmin 在 Stefan Monnier 的指导下进行了一个暑期项目,在该项目中他尝试以不同的方式向字节码编译器添加词法作用域:他没有直接在单 pass 的字节码编译器代码中添加对词法作用域和闭包的支持(这需要对代码进行重大改动,并且由于需要在单 pass 中完成,使得代码变得更加复杂),而是实现了一个单独的 pass (它自身分为两个步骤)来执行传统的闭包转换。这种方法使闭包转换摆脱了单 pass 字节码编译器设计所施加的限制,使得实现变得更加容易,并且它还显著减少了字节码编译器中所需的改动量,从而降低了引入退化(regression)的风险。
两年后,Emacs 24.1 发布了,其中包含了基于 Miles Bader 的 lexbind 分支和 Igor 的闭包转换的词法作用域支持。那时的主要关注点是:
- 最小化对现有代码的更改,以限制与现有 Emacs Lisp 包的不兼容性;
- 确保现有代码的性能不受新特性的明显影响;
- 提供可靠的对新词法作用域模式的支持,尽管不一定具有最佳性能。
字节码的更改是作为词法作用域特性的一部分引入的,该特性于 2012 年在 Emacs 24.1 中出现,但实际上早在 2003 年左右就已经开发了:在引入词法作用域之前,基于堆栈的字节码只以最简单的方式使用堆栈,并且没有包含除 pop/dup/exch
之外的任何堆栈操作,为了更好地支持在堆栈上存储词法变量的词法作用域,开发者添加了几个字节码用于直接在堆栈中索引、修改堆栈槽和一次性丢弃多个堆栈元素。
除了与 catch
、 condition-case
和 unwind-protect
原语的交互之外,新的词法作用域模式的性能与动态作用域模式相比表现出了竞争力。这些原语的底层字节码与词法作用域不匹配,需要在运行时构建 Emacs Lisp 代码,以将词法上下文传递到这些构造的主体中。因此,在 Emacs 24.4 中引入了新的字节码,并修改了字节码编译器以能够利用它们。现在,使用词法作用域编译的代码通常预期比使用动态作用域编译的代码略快。
Emacs 24.1 引入词法绑定的过程非常顺利,对用户而言只造成了少数不向后兼容的改变,部分原因是 Emacs 自身的文件中很少使用词法绑定。将现有代码转换为使用新语言通常很容易(主要是添加几个变量声明,并借助字节码编译器的警告),但这并不是自动化的过程,有时可能需要进行较大的努力,特别是对于那些大量或创造性地使用动态作用域的包而言。如今,大多数新的包选择使用新的词法作用域语言,大约一半的维护良好的包已经进行了转换。然而,仍然有大量的代码使用旧的语言,这可能是因为它们仍然想要支持早于 Emacs 24.1 的 Emacs 版本,或者是因为转换需要的工作量。例如,截至 Emacs 26,只有三分之一的 Emacs 自身的 Lisp 代码已经转换为使用词法作用域。
8.2. Eager Macro-Expansion
在 Emacs Lisp 中宏的展开时间从未被明确指定。直到 Emacs 24 版本,解释执行代码中的宏展开通常尽可能晚地进行,而对于字节编译的代码,宏展开总是在字节码编译期间进行,除了一些特殊情况,其中代码对字节码编译器是“隐藏的”。在字节码编译器中,宏展开也是“延迟”(lazily)进行的,即在编译的单次 pass 中动态地进行扩展。
为了在 Emacs 24 中实现单独的闭包转换阶段,这种行为必须进行更改,以便在实际的闭包转换和字节码编译之前使用新的 macroexpand-all
函数对代码进行独立的宏展开。这导致某些边缘情况下出现了一些可见的差异,之前在宏展开之前被优化消除的一些宏调用现在被展开了。在实际中这并没有引起任何严重的退化。
在 Emacs 24.3 中,对新的 macroexpand-all
函数的使用更加普遍,当加载非编译文件时会应用该函数。这意味着宏展开现在在加载文件时会立即进行,而不是在 Emacs 实际运行代码时惰性展开。这种激进(eager)宏展开有时会遇到依赖关系的问题(通常是在从未编译过的文件中),因此它会进行优雅的失败处理:如果在加载文件时的宏展开过程中发生错误,Emacs 会中止宏展开并继续处理非展开的代码,就像过去一样,尽管会适当地向用户通知问题所在。
Emacs 25.1 还根据 Common Lisp HyperSpec[^28] 的 3.2.3.1 节对这些宏展开阶段进行了微调(无论是加载文件时还是编译文件时),以改进展开为定义且使用这些定义的宏的处理方式。
8.3. Pattern Matching
在开发 lexical-binding 特性的过程中,Stefan Monnier 对用于遍历抽象语法树的代码形式感到越来越沮丧。相比于静态类型的函数式语言中使用的代数数据类型,这些代码中的 car、cdr 函数提供的信息太少了。
因此,他开始着手开发一种受这些语言启发的模式匹配构造。在开始这个项目之前,他搜索了现有的提供此类功能的库,发现了许多适用于 Common Lisp 和 Scheme 的库,但没有一个符合他的期望:要么生成的代码效率不够高,要么代码似乎难以移植到 Emacs Lisp,要么接受的模式集合过于有限且不易扩展。
于是,pcase.el 包诞生了,首次作为 Emacs 24.1 的一部分发布,并在提供对词法作用域支持的字节码编译器的部分中广泛使用。
除了提供类似于 Common Lisp 的 case
宏的超集 pcase
宏外,该包还提供了 pcase-let
宏,它使用相同的机制并支持相同的模式来解构对象,但它允许假设模式匹配成功,因此可以跳过所有测试,只保留提取数据的操作。
在 Emacs 24.1 发布后,Stefan Monnier 意识到了 Racket 的 match
构造[^53],这个构造在他早先搜索现有的模式匹配宏时不知何故被忽略了,它的设计使得定义新模式变得容易。Racket 的 match
实现无法在 Emacs Lisp 中轻松重用,因为它过于依赖 Racket 对局部定义函数和尾调用的高效处理,但 pcase.el 得以改进以采用 Racket 的 match
设计的一部分。新版本出现在 Emacs 25.1 中,主要的创新点是引入了 pcase-defmacro
,可以使用模块化的方式定义新模式。
8.4. CL-Lib
多年来,Emacs Lisp 的核心演变缓慢,而其他 Lisp 方言(主要是 Scheme 和 Common Lisp)的演进却在不断进行,这对 Emacs Lisp 增加各种语言扩展提出了压力。事实上,Emacs Lisp 可以近似地看作 Common Lisp 的子集,因此在 1986 年,Cesar Quiroz 已经编写了一个名为 cl.el 的包,该包通过宏提供了各种 Common Lisp 的功能。1988 年的 Emacs 18.51 版本是第一个内置 cl.el 的 Emacs 版本。在 5 年后的 Emacs 19.18 中,Dave Gillespie 贡献了一个新版本的 cl.el,它更详细地模拟了 Common Lisp,并添加了一些扩展功能。
RMS 从来不希望 Emacs Lisp 逐渐演变为(morph into)Common Lisp,但他认识到提供这样的功能的价值,所以 cl.el 在 Emacs 中相对较早地被包含了进来,并成为最受欢迎的包之一,被大部分 Emacs Lisp 包所使用。然而,RMS 并不想强迫任何 Emacs 用户使用 cl.el。因此,他制定了一项政策来限制在 Emacs 中使用 cl.el 的范围:与 Emacs 捆绑在一起的 Emacs Lisp 包只能以一种在正常编辑会话中无需加载 cl.el 的方式使用 cl.el。具体来说,这意味着在 Emacs 中只能使用 cl.el 的宏和内联函数这些特性。
RMS 不希望使用 cl.el 并不想让 Emacs Lisp 变成 Common Lisp 的原因并不完全清楚,但以下因素似乎是其中的一部分动机:
- 那个时候,Common Lisp 被认为是一门非常庞大的语言,因此要使 Emacs Lisp 真正成为一个合理完整的 Common Lisp 实现很可能需要付出相当大的努力。
- Common Lisp 的某些设计方面可能会带来显著的开销,而 Emacs 本身已经被认为太庞大了(在那个八兆字节仍然是相当大的内存容量的年代,vi 的支持者们用贬低性的称谓(derogatory epithet)“eight megabytes and constantly swapping”来嘲笑 Emacs 的内存占用情况)。因此,避免让 Emacs Lisp 的效率变得更糟糕是有充分理由的。
- 许多 Common Lisp 设计的方面是由多数人而非共识决定的,这可以从 Common Lisp 和 Scheme 之间的分歧中看出。RMS 不喜欢 Common Lisp 设计中的几个方面,比如在像是
mapcar
的低级原语中使用关键字参数[^54]。
- 保持 Emacs Lisp 的简洁意味着用户可以参与其中的开发,而无需学习所有 Common Lisp 的知识。当讨论是否包含 Common Lisp 特性时,RMS 通常会指出其带来的成本,包括需要更多、更复杂的文档[^55]。
- cl.el 的实现具有相当的入侵性,会重新定义一些核心的 Emacs Lisp 函数。
- 最后,将 Emacs Lisp 变成 Common Lisp 意味着失去了控制权,因为 Emacs 在很大程度上将受到 Common Lisp 发展的约束,必须遵守 Common Lisp 设计者在大多数方面的决策。
多年来,前两点的重要性在一定程度上有所减弱。同时,cl.el 包的流行度以及 Emacs 贡献者不断要求更多 Common Lisp 特性的压力也减少了第四点的相关性。
XEmacs 在这方面采取了简单的方法,从 1996 年的 XEmacs 19.14 开始将 cl.el 加载到标准 XEmacs 映像中。Emacs 则选择了一条更长的道路,经过多年的筛选发现 cl.el 中的一些宏和函数足够清晰且受欢迎,以至于有理由将它们移植到 Emacs Lisp 中:
- 1997 年发布的 Emacs 20.1 版本移植了
when
和 unless
宏以及 caar
、 cadr
、 cdar
和 cddr
函数。
- 2001 年,Emacs 的维护者 Gerd Möllmann 对 Common Lisp 有了更积极的看法。因此,Emacs 21.1 版本包括了散列表函数,这些函数经过了 C 语言的重新实现和扩展。它还采用了 Common Lisp 的关键字符号概念,以便更方便地支持 cl.el 以及其他使用这些符号的包。关键字符号是以冒号开头的符号;它们是自引用的字面量,表达式
:foo
的求值结果是 :foo
本身。这使得关键字符号在指定关键字参数时在记法上很方便。 此外还添加了宏 dolist
、 dotimes
、 push
和 pop
到 Emacs Lisp 中,尽管它们引入了一些困难:在 cl.el 中,这些宏包括了额外的功能,它们依赖于核心 Emacs Lisp 中不需要的 cl.el 部分,特别是 block/return
和广义变量。因此,添加到 Emacs Lisp 中的宏并没有真正取代 cl.el 中的宏;相反,当加载 cl.el 时,它会用自己的版本覆盖原始的宏。
- 2007 年,Emacs 22.1 版本添加了
delete-dups
函数,它提供了 cl.el 中 delete-duplicates
的一个子集。
- 2012 年,Emacs 24.1 版本添加了
macroexpand-all
和词法作用域,这使得 cl.el 中的 lexical-let
形式过时了。
- 2013 年,Emacs 24.3 版本添加了编译器宏、
setf
和广义变量。
- 2018 年,Emacs 26.1 版本在 Emacs 20.1 中引入的
cXXr
函数的基础上,添加了剩余的 cXXXr
函数。对这些函数的抵制主要是出于代码风格的原因,因为它们往往导致难以阅读的代码。
这里的细节并不重要,而重要的是从 cl.el 到 Emacs Lisp 逐渐渗入了一系列功能,并且在许多情况下,这并不仅仅是将代码从 cl.el 移动过来,而是重新实现它,通常会有略微不同的语义。
在开发 Emacs 24.3 期间,更好地整合 cl.el 包的问题再次出现。主要的压力点是希望在与 Emacs 捆绑的包中使用 cl.el 函数 。RMS 仍然持反对态度,但这次找到了一个折中方案:用一个名为 cl-lib.el 的新包替换 cl.el 包,该包提供相同的功能,但所有的名称都使用 cl-
前缀。这样,cl-lib.el 包不会将 Emacs Lisp 变成 Common Lisp,而是在自己的命名空间下提供 Common Lisp 功能,使 Emacs Lisp 可以自由地按照自己的方式发展[^56]。
cl-lib 的实现主要是为 cl.el 中的定义添加了 cl-
前缀,同时实现了一个新的 cl.el,它只是一个非常薄的包装器,将 cl-lib 的定义重新导出为它们的旧名称。这个薄包装器非常简单,既不会增加太多维护成本,也不会影响性能。cl.el 的一些实现细节也经过重新调整,变得更加非侵入性。特别地,cl.el 曾经用自己的实现全面重新定义了 Emacs 的宏展开,其中包括对编译器宏、 lexical-let
、 flet
和 symbol-macrolet
的支持。标准的宏展开代码经过调整,以便 cl-lib.el 可以更清晰地提供这些功能。
为了鼓励适配这个新的库,一个用于旧版 Emacs 和 XEmacs 的 cl-lib.el 的向前兼容版本与 Emacs 24.3 同时发布了。尽管使用 cl-
前缀可能会引起一些对这个新库的抵触,但从新包中使用 cl-lib.el 而不是 cl.el 的比例来看,这个改动令人惊讶地成功了。
如今,Emacs 中同时捆绑了 cl-lib.el 和 cl.el,并提供支持,但 cl.el 不再被 Emacs 自身的 Lisp 代码使用,预计在 Emacs 27 中将被宣布为过时。但由于它在多年来非常受欢迎,因此在真正废弃它之前还需要很长时间。
8.5. Generalized Variables
为了方便过渡到 cl-lib.el,一些在 cl.el 中经常使用的功能被直接移植到 Emacs Lisp 中。其中最显著的是对广义变量(generalized variables)的支持,也被称为 places
、 generalized references
或 lvalues
。广义变量是一种既可以作为表达式又可以作为可更新引用的形式。这个概念源自 Common Lisp,在 Emacs 的实现最初是 cl.el 的一部分。在 Common Lisp 和 Emacs 中,一些特殊形式接受广义变量作为操作数。例如, (setf PLACE VALUE)
将广义变量视为引用并设置其值。
在 Common Lisp 中,宏可以调用 get-setf-expansion
将一个 place
转换为包含五个元素的列表:
(VARS VALS STORE-VAR STORE-FORM ACCESS-FORM)
VARS
和 VALS
共同构成一个绑定列表,用于执行访问该 place
所需的计算,这些计算只应该被执行一次(出于性能或副作用的考虑); ACCESS-FORM
读取该 place
的当前值; STORE-VAR
和 STORE-FORM
指定如何 set
该 place
(通过将 STORE-VAR
绑定到所需的值,然后执行 STORE-FORM
)。
例如,宏 (push EXP PLACE)
将 EXP
添加到存储在 PLACE
中的列表的头部,可以定义为展开为:
(let ((v EXP))
(setf PLACE (cons v PLACE)))
但是这样会重复使用 PLACE
,它可能执行耗时的操作并具有副作用。因此,该宏使用 get-setf-expansion
以展开成以下形式的代码:
(let ((v EXP))
(let* (VARS = VALS)
(let ((STORE-VAR (cons v ACCESS-FORM)))
STORE-FORM)))
这种方式强加了一个相当刻板的结构,虽然足够通用以适应大多数需求,但会带来负担并导致繁琐的代码和大量的冗余,不论是在实现 place
的过程中还是在接受 place
作为参数的宏的实现中。
原始的 cl.el 代码遵循了这种 Common Lisp 的设计。但是在实现 Emacs Lisp 中的 setf
和相关功能时采用了全新的实现概念。采用新实现的原因有以下几点:
- cl-lib 的实现者 Stefan Monnier 发现现有的代码很难理解,可以说是 Stefan 本人的一种“NIH 综合症”(not-invented-here syndrome,指一种心理现象,即对外部或已有的解决方案持怀疑态度,更倾向于自行开发或重新实现相似的解决方案,就是造轮子综合征)。
- 之前的代码使用了 cl.el 中的内部辅助函数,而维护者们不想将其移植到核心的 Emacs Lisp 中,因此仍然需要进行一些重大调整。
- Stefan Monnier 认为这部分 Common Lisp 的设计很丑陋。
因此,新的实现采用了不同的设计:与 Common Lisp 的五元组相比,新的函数 gv-get-place-function
将一个 place
转换为一个单独的高阶函数。这个高阶函数以一个接受两个参数( ACCESS-FORM
和 STORE-FUNCTION
)的函数作为其唯一参数,该函数应返回我们要在该 place
上执行的代码。例如, push
宏可以这样实现:
(defmacro push (EXP PLACE)
`(let ((x ,EXP))
,(funcall (gv-get-place-function PLACE)
(lambda (ACCESS-FORM STORE-FUNCTION)
(funcall STORE-FUNCTION `(cons x ,ACCESS-FORM))))))
这种设计一般会带来更清晰、更简单的代码,并且我们可以很容易地为大多数 Common Lisp 原语提供向后兼容的包装函数。
将一个 Common Lisp 风格的 place
表达式转换为相应的高阶函数是很容易的。然而,反过来却不成立,因此这种设计不兼容 Common Lisp 和 cl.el 的 get-setf-expansion
,后者必须产生上述五个值。当然,破坏与 get-setf-expansion
的兼容性是一个不足之处,但实际上这个函数几乎从未在 cl.el 之外被使用过,所以很少有包受到这种不兼容性的影响。
8.6. Object-Oriented Programming
虽然 cl.el 在早期就提供了与 Common Lisp 的 defstruct
的兼容性(4.5 节),包括定义新的结构作为其他结构的扩展/子类型,从而提供了有限的继承形式,但在 Emacs 中,对面向对象编程的实际支持,例如方法调度,在历史上一直有限。
向这个方向迈出的第一步是由 Eric Ludlam 在 1995 年底至 1996 年初开发的 EIEIO。它的正式名称“Enhanced Implementation of Emacs Interpreted Objects”暗示了早期存在某个“Emacs Interpreted Objects”包,但实际上首字母缩写在选择其扩展名称之前就已存在,因为这与童谣的滑稽引用有关☨。EIEIO 最初是一个尝试在 Emacs 中使用对象系统的实验,最初遵循类似于 C++ 的模型,但很快转向了受 CLOS 启发的模型。
8.6.1. CLOS
EIEIO 是 CLOS(Common Lisp Object System)的一个子集实现[^27]。CLOS 是一个略显不寻常的对象系统,其中方法不附加到对象或类上;相反,它提供了通用函数( generic function )的概念,这些函数由一组方法实现,每个方法通过为其参数添加一个特化器( specializer )来指示其何时可用,通常是一个表示该方法仅适用于某类型参数的类型。这种基于类型的分派不仅限于第一个参数,还可以应用于任意数量的参数,这个特性被称为多分派(multiple-dispatch)。此外,当有多个方法可以应用时,程序员可以控制方法的组合( combine ),例如通过添加限定词( qualifier ,如 :after
或 :before
)。除了通用函数,CLOS 还提供了 defclass
宏,用于通过列举其父类、字段和各种其他属性来定义新类型。
以下是一个 CLOS 代码的示例,它定义了一个新的 point3d
类作为现有 point2d
类的子类,并向通用函数 point-distance
添加了一个新的方法,但仅当两个参数都是 point3d
的子类型时才适用:
(defclass point3d (point2d)
(z :documentation "Third dimension"))
(defmethod point-distance ((p1 point3d) (p2 point3d))
(let ((distance2d (call-next-method))
(distancez (- (slot-value p1 'z) (slot-value p2 'z))))
(sqrt (+ (* distance2d distance2d)
(* distancez distancez)))))
8.6.2. EIEIO
就像 Emacs Lisp 一样,EIEIO 的发展主要是根据实际需求而不是为了自身而存在:最初的动机是尝试用于对象请求代理(object request broker,ORB,是一种中间件,充当对象之间的中介,协调请求和响应之间的通信),然后是 widget 工具包,后来切换到为 CEDET 包提供支持,CEDET 是一个提供类似 IDE 功能的包[^57]。EIEIO 包括对大多数 CLOS 的 defclass
的支持,以及对 defmethod
的子集的支持,但仅限于基于第一个参数的单分派方法。此外,它只能基于 defclass
对象的类型进行分派。它对方法组合的支持也不完整,只允许使用 :before
和 :after
方法,而不支持 :around
或任何用户定义的其他限定词。
在 2010 年与 CEDET 的大部分一起被集成到 Emacs 23.2 中之前,EIEIO 在大部分时间都作为 CEDET 包的一部分存在。在 Emacs 内部使用 EIEIO 的情况相对有限,部分原因是惯性,但也因为 EIEIO 遇到了与 cl.el 类似的问题,即它不是“命名空间清洁”的。
8.6.3. CL-Generic
在 2014 年底,Stefan Monnier 开始清理 EIEIO,以便能够在 Emacs 的更多部分中使用它。主要意图是像 cl-lib 一样添加一个 cl-
前缀(而不是 eieio-
前缀,被认为太冗长而不太受欢迎),同时改进 defmethod
以支持 :around
方法和针对除了使用 defclass
定义的类型之外的其他类型的分派。但很快就显而易见的是,方法分派的实现需要进行彻底的改进:与典型的 CLOS 实现的前期构建组合方法并记住结果不同,EIEIO 的方法分派和 call-next-method
在运行时动态执行所有工作,依赖全局变量来保留状态。这种方法不仅不稳定和低效,而且难以扩展来使用 :around
方法。
因此,与其改进 EIEIO 的 defmethod
,不如在新的 cl-generic.el 包中实现完全新的 CLOS 的 defmethod
版本,它在 Emacs 25.1 中出现。最直接的缺点是在此过程中忘记了清理 EIEIO
的其他部分(其中实现了 defclass
对象)。该实现并未过度优化,但已经比 EIEIO
中的先前版本快几倍。该包提供了与 CLOS 的 defmethod
基本相同的功能集,但存在一些重要的区别:
- 方法组合无法像 CLOS 那样针对每个方法进行指定,而是只能通过向
cl-generic-combine-methods
添加适当的方法来全局添加新的方法组合。这个实现非常简单,但基本上无法使用,正如事实所证明的,在目前没有人使用这个功能,甚至在内部也没有人使用。
- 支持的 specializer 并非硬编码。在 CLOS 中,specializer 可以是类型(表示当参数是该类型时该方法适用)或形如
(eql VAL)
的形式,表示该方法仅适用于参数等于 VAL
的情况。Emacs Lisp 对此进行了扩展,可以通过 generalizer 的概念以模块化的方式定义新的 specializer,这个概念受到了 Rhodes 等人的一篇论文的启发[^58]。这在内部被使用(用于定义所有标准的 specializer),以及在一些外部包中被使用,最值得注意的是在 EIEIO 中用于支持对 defclass
类型的调度。
上述第一个区别的主要动机是因为 CLOS 对方法组合的支持看起来过于复杂:实现的成本无法与预期的功能用途进行合理的权衡,因此被一个更简单的机制所替代。
第二个区别是必需的,因为即使 cl-generic.el 不能依赖于 EIEIO,方法需要在 EIEIO 对象上进行调度。然而,还有其他的动机:能够定义新的 specialiazer 显然是可取的,而且它也使主要 specializer 的实现更加清晰,最重要的是,这似乎是一个解决起来很有趣的问题。
一些现有的 Emacs Lisp 函数似乎是使用这个机制将它们拆分为独立方法的好候选,但需要根据上下文信息(即当前状态)进行分派,而不仅仅是根据参数。因此,cl-generic.el 还为其 cl-defmethod
添加了对形式为 &context (EXP SPECIALIZER)
的伪参数的支持。这意味着当 EXP
求值为满足 SPECIALIZER
约束的值时,该方法是可用的。这用于只在特定上下文中适用的方法,例如特定的 major mode 或使用特定类型的 GUI 的 frame。
8.6.4. Overall Support for Classes
cl-generic.el 的实现伴随着对在线帮助系统的扩展,以便能够以类型为起点提供关于 Emacs Lisp 变量、函数、face 和其他类型的命名元素的信息。为了配合这一点, cl-defstruct
的实现得到了改进,以更好地保留有关类型层次结构的信息,以便可以使用在线帮助系统进行浏览。这最初是为了尝试将 EIEIO 的功能适应于 cl-generic.el,以便可以交互地浏览 EIEIO 对象和方法,但现在更模块化并且与 Emacs 的其他在线帮助系统更好地集成在一起。
在 Emacs 26 中,Emacs Lisp 中的对象支持分为四个部分:由 cl-lib.el 提供的旧的 cl-defstruct
,它允许定义新的对象类型并支持单继承;由 EIEIO 提供的 defclass
,它提供类似的功能,还支持多重继承和一些其他的优点,但以创建对象和访问字段的速度较慢为代价;由 cl-generic.el 提供的 cl-defmethod
,它允许定义方法,并对 cl-defstruct
对象和 defclass
对象提供相同的支持;最后是 defmethod
,它已被重新实现为对新的 cl-defmethod
的包装(这个向后兼容的库已被弃用,并且比直接使用 cl-defmethod
效率略低,但仍比早期实现更高效,并且它完全使用了 cl-defmethod
的文档特性实现,因此不会引入任何性能或维护问题)。在可预见的将来,Emacs 可能会继续同时使用 cl-defstruct
和 defclass
。
☨: 译注,E~I~E~I~O,出自经典英语儿歌 Old MacDonald Had a Farm
Old MacDonald Had a Farm - Wikipedia
8.7. Actual Objects
尽管 Emacs 25 的 cl-generic.el 为 Emacs Lisp 引入了面向对象编程的功能,但对象(无论是通过 cl-lib 的 cl-defstruct
还是 EIEIO 的 defclass
定义)仍然被表示为向量,因此无法可靠地与向量区分开来,例如进行漂亮的打印。
在 Emacs 26 中,通过引入 make-record
原语和相应的新对象类型(4.5 节)解决了这个问题。记录(records)的实现方式与先前使用的向量完全相同,只是它们的标记(tag)表明应将它们视为记录而不是向量,并且按照约定,记录的第一个字段应该包含一个类型描述符,它可以是一个符号。
这种变化引入的主要复杂性是需要一种新的语法来打印和读取这些新对象,以及使用旧的基于向量的编码和使用新编码的对象的打印表示之间的不兼容性。
8.8. Generators
随着 Python 和 JavaScript 的迭代器和生成器的成功,一些 Emacs 用户觉得 Emacs Lisp 在抽象方面缺乏一些功能。因此,在 2015 年,Daniel Colascione 开发了 generator.el,并在 Emacs 25.1 中加入了该功能。它通过使用宏 iter-lambda
和 iter-yield
使编写生成器变得简单和方便。它的实现基于一种局部转换为延续传递风格(CPS),因此依赖于词法作用域的使用,以解决 Emacs Lisp 不直接提供类似 call/cc
的访问底层延续的问题。它只处理 Emacs Lisp 的一个相对较大的子集,因为在 Emacs Lisp 中一般无法定义类似 unwind-protect
[^59]形式的 CPS 转换。
8.9. Concurrency
Emacs Lisp 是一种基本顺序的语言,并且在很大程度上依赖于对全局状态的副作用。然而,由于其在交互式程序中的使用,必然会希望引入并发性以尝试提高响应能力。并发性在很早之前就出现了:自从 Emacs 16.56 版本起,Emacs 就包含了对异步进程的支持,即执行独立程序并在 Emacs Lisp 执行引擎空闲等待下一次用户输入时,通过所谓的进程过滤器( process filter )处理其输出。
虽然这种非常有限的合作式并发性在 1994 年的 Lucid Emacs 19.9 和 1996 年的 Emacs 19.31 中通过添加对定时器的本地支持得到了一些改进(定时器先前以异步进程的形式在需要的时间向 Emacs 发送输出),但它一直是 Emacs 大部分生命周期中唯一可用的并发形式。
由于现有的 Emacs Lisp 代码广泛依赖共享状态,向 Emacs Lisp 添加真正的共享内存并发是有问题的。在某些情况下,共享状态不是一个问题,可以通过异步编程来模拟并发:当程序等待一个操作(比如外部程序)时,它会注册一个延续(continuation)回调并将控制权返回给主事件循环。然而,许多 Emacs Lisp 包会阻塞,因为通过回调来切片执行(slicing the execution)意味着实际上要以 CPS 编写代码,这在 Emacs Lisp 中没有得到很好的支持, CPS 与动态作用域交互很差,并且需要对现有代码进行重大改动。
因此,共享内存并发在很大程度上被认为不适用于 Emacs Lisp。即便如此,在 2008 年 11 月,Giuseppe Scrivano 发布了首个尝试向 Emacs Lisp 添加线程的 naive 版本。这个努力没有取得太大的进展,但它激发了 Tom Tromey 试试自己的运气。在 2010 年,他开始致力于向 Emacs 添加共享内存协作并发原语,比如 make-thread
。与基于全局状态以提高速度的动态作用域实现的交互需要尝试各种方法。正确处理 buffer-local 绑定和 frame-local 绑定而无需进行完全重写是非常困难的,大多数方法都仅仅因为难以将它们与不断发展的 Emacs 代码库保持同步而被放弃。
最终,一种可行的方法在 2018 年作为 Emacs 26.1 的一部分发布了。上下文切换仍然只发生在几个已知的 Emacs Lisp 空闲点(或通过显式调用 thread-yield
)。当前实现中,上下文切换的时间与当前堆栈深度成正比,因为需要保存和删除旧线程的动态绑定,然后需要恢复新线程的动态绑定。早期的实现方法尝试通过增加全局变量查找的开销来避免这种昂贵的上下文切换方式,但这需要对现有代码进行更广泛和精细的更改。因此,尽管这种方法可能在未来被重新考虑,但当前的实现更倾向于采用更简单和更安全的方法。
主要维护人员之间对于包含这种共享状态并发形式的讨论非常激烈。他们都同意 Emacs Lisp 需要发展并发和并行性,以利用日益增多的 CPU 核心数量,特别是因为单核性能不再显著提高。但他们也达成共识,共享内存对于当前的 Emacs Lisp 环境来说非常不适合。最终只有 Tom Tromey 的补丁被接受,这是因为它不具有侵入性,并且有一种认识到有必要做些 什么 的感觉存在。
这仍然是一个相当实验性的功能,在出现两年后其使用似乎仍然局限于一些实验性补丁,例如 Gnus MUA 等少数几个包。可以说到目前为止,主要的结果是暴露了一些包中异步处理方面的潜在错误。
多年来,其他关于并发性和并行性的方法已经以 Emacs Lisp 包的形式开发出来,最显著的是 async.el 包[^60],它于 2012 年开发,可以在单独的 Emacs 子进程中并行运行 Emacs Lisp 代码。它的适用性受到限制,因为 buffer 内容需要在两个进程之间显式地按需发送,从而导致并行非常粗粒度。此外,不能保证子进程的配置与主进程一致 —— 在某些情况下,子进程甚至可能是 Emacs 的另一个版本。尽管如此,一些第三方包有限地使用了 async.el。
8.10. Inline Functions
在 Emacs Lisp 中,函数调用是相当昂贵的,并且它们的语义涉及在全局命名空间中查找当前的定义,因此函数内联在性能和语义上都很重要。
因此,在开发 Emacs 19 的新字节码编译器期间添加了一个新的 defsubst
宏,它的工作方式类似于 defun
,但是会对函数进行标注,告诉字节码编译器尽可能内联它。这种内联化相当 naive,但适用于编译和非编译函数(通过将函数体内联到源代码或内联到调用者的字节码中)。
Dave Gillespie 在 1993 年引入的新包 cl.el 中,通过 defsubst*
宏引入了一种新的内联方式。从外部来看它与 defsubst
几乎相同,只是包含了对关键字参数等 Common Lisp 扩展的支持,但它的实现通过替换实际参数来生成更高效的代码(并不完全保持 Emacs Lisp 函数调用的通常语义)。
在 Emacs Lisp 中实现可内联的函数的第三种方式是将其定义为宏,尽管这带来一个缺点,即它不定义一个真正的函数。
2014 年末,Stefan Monnier 在将 cl-lib 库适应 EIEIO 和 cl-defstruct
对象的变化过程中对 cl-typep
的定义和编译器宏之间的冗余感到受挫。 cl-typep
函数接受两个参数,并检测第一个参数是否是由第二个参数指定的类型的值。这个函数定义处理了类型参数只在运行时才知道的一般情况。编译器宏允许进行优化 —— 例如,它将 (cl-typep x 'integer)
转换为 (integerp x)
。类型说明也可以采用 (not TYPE)
的形式以表示“除 TYPE
之外的任何类型”、 (eql VAL)
表示包含特定值的类型、 (satisfies PRED)
表示给定谓词返回 true 的值的类型。
因此,Stefan Monnier 开发了新的宏 define-inline
,并在 Emacs 25.1 中加入了这个功能。它允许程序员使用单个表达式定义一个普通函数和相应的优化编译器宏。图 2 展示了当前版本使用 define-inline
的 cl-typep
的骨架:它通过从函数体中删除 inline-
前缀来解释函数定义,同时定义编译器宏。前者在运行时执行 pcase
分派,后者在编译时执行。这样,如果 type
静态已知,则内联相应的分支;如果 type
静态未知,那么 inline-const-val
会检测到该问题,并完全放弃内联。目前,在 Emacs 发行版中有将近 50 个函数使用 define-inline
进行定义。
8.11. Module System
虽然 Emacs Lisp 从一开始就被设计为一种真正的编程语言,而不仅仅是一个小型的特定扩展语言,但它并没有为“大规模编程”而设计,这可以从缺乏模块或命名空间系统中看出。
然而,Emacs 的 Emacs Lisp 部分现在已经成为一个相当庞大的系统,为了避免名称冲突,Emacs Lisp 使用了一种简陋的命名空间系统。如 8.1 节所提到的。在这种系统中,代码遵循一种约定,属于包 <pkg>
的全局函数和变量的标识符以 <pkg>-
前缀开头(对于内部定义的标识符,则使用 <pkg>--
前缀)。
有过很多通过提供对某种形式的命名空间的支持来纠正这种情况的尝试:
- 在 2011 年 5 月,Christopher Wellons 开发了
fakespaces
包,它允许定义私有变量和函数,并防止它们逸出到全局命名空间。非私有定义仍然依赖于通常的包前缀命名约定,以避免冲突。
- 在 2012 年 10 月,Chris Barrett 开发了
Namespaces
包,它提供了一套广泛的新宏来定义命名空间,在这些命名空间中定义函数和变量,并从其他命名空间中使用它们。
- 在 2013 年 3 月,Wilfred Hughes 开发了
with-namespace
宏的概念验证版本,该宏只是将指定的命名空间前缀添加到其主体中定义的所有元素中。
- 与此同时,Yann Hodique 开发了概念验证的
Codex
包,该包试图提供类似于 Common Lisp 的 packages 功能,其中每个包都有自己的 obarray 。
- 2014 年初,Artur Malabarba 开发了
Names
包,该包采用了 with-namespace
的方法,但在与代码操作工具(如 autoload 声明生成器)正确交互以及使源代码级调试器能够对单个声明进行插装方面做的更加彻底。
- 2015 年,同样是 Artur Malabarba 开发了
Nameless
包,它采用了完全不同的方法:它不提供任何新的 Emacs Lisp 构造,而是专注于使 Emacs 在处理代码时 隐藏 用户的包前缀。
迄今为止,Emacs 尚未整合任何命名空间功能,其他软件包中也很少使用。最后一次关于这个主题的(激烈)讨论是在 2013 年,Nic Ferrier 在博客中发布了一篇文章[^61]。在这一点上开发者没有形成共识,但除了惯性外,维持现状的主要论点之一是 Emacs Lisp 的简陋手工命名空间只是一个轻微的烦恼,而换来的是对于初学者和交叉引用的便利[^62]:可以使用任何简单的文本搜索来查找任何全局函数或变量的定义和使用,包括全局文件系统搜索甚至网络搜索,而其他所有替代方案都引入了需要相对于上下文进行解释的名字,这迫使我们在浏览代码时依赖于能理解特定命名空间的 IDE。