在 Lisp 社区中,一个常见的营销论点是:"我们的编辑器 / 系统是 100% Lisp 编写的,因此具有无与伦比的可扩展性。" 这种说法听起来很有吸引力 —— 如果整个系统都是用同一种语言编写的,理论上用户可以扩展任何部分。然而,这种 "100% Lisp" 可扩展性论点实际上是一个工程谬误,它忽略了现实世界中的复杂性和权衡。
"100% Lisp" 谬误的核心论点
最近一篇题为《Extensibility: The "100% Lisp" Fallacy》的文章深入探讨了这一现象。作者以 Lem 编辑器为例,该编辑器被宣传为 "100% Common Lisp" 编写,没有 C 核心,只有 Lisp 一路到底。这种说法暗示用户可以轻松定制或扩展编辑器的任何部分。
然而,现实要复杂得多。文章提出了几个关键问题:
-
平台特定功能的限制:即使编辑器本身是纯 Lisp 编写的,它仍然需要与操作系统交互、处理字体回退、输入法、屏幕阅读器等平台特定功能。这些部分通常无法用纯 Lisp 实现,或者即使实现了,也无法提供与原生 API 相同的可扩展性。
-
非纯 Lisp 部分的存在:以 Steel Bank Common Lisp(SBCL)为例,这个常见的 Common Lisp 运行时实际上只有大部分是用 Lisp 编写的。它的运行时包含 C 代码,用于提供线程原语、与操作系统接口或利用汇编代码。用户显然无法定制这些部分。
实际系统中的非纯 Lisp 部分
任何实际可用的系统都包含非纯 Lisp 的部分。这些部分对可扩展性构成了实际限制:
1. 运行时依赖
即使是 "纯 Lisp" 系统也依赖于底层的 Lisp 实现,而 Lisp 实现本身包含非 Lisp 代码。SBCL 的源码树中,src/runtime目录包含了大量的 C 代码,这些代码处理内存管理、线程、系统调用等底层操作。
2. 平台集成
图形编辑器需要处理:
- 字体回退和渲染
- 输入法集成
- 屏幕阅读器支持
- 窗口管理
这些功能通常通过平台特定的 API 实现,即使通过 FFI(外部函数接口)暴露给 Lisp,也无法提供与纯 Lisp 代码相同的可扩展性级别。
3. 性能关键部分
某些性能关键的部分可能用 C 或汇编编写,以获得更好的性能。虽然这些部分可以通过 API 暴露,但用户无法修改其内部实现。
工作区式扩展 vs "纯 Lisp" 扩展
文章区分了两种扩展方式:
工作区式扩展(Workaround-ish Extensibility)
这种扩展方式通过创造性的工作区来实现功能,即使系统本身不直接支持。例如:
-
Neovim 的滚动条:Neovim 和许多 TUI 编辑器没有原生滚动条支持,但通过使用
extmark和virt_text为最右侧列着色,完全可以显示漂亮的滚动条。 -
Emacs 的光标动画:标准 Emacs 不支持光标动画,但人们通过补丁或使用 Python 扩展 Lisp 代码(进而调用 PyQt 或合成器命令来控制覆盖窗口)来实现移动光标等效果。
-
Emacs 应用程序框架(EAF):允许通过先使用 PyQt 编写 GUI 程序,然后将其 "粘贴" 到 Emacs 窗口上来为 Emacs 编写 GUI 程序。
"纯 Lisp" 扩展的问题
相比之下,"纯 Lisp" 扩展可能既更容易又更困难。文章以 Emacs 中的org-export-get-reference函数为例:
当想要改变 Org-mode 导出 HTML 时生成随机 ID 锚点的默认行为时,理论上只需要覆盖org-export-get-reference函数。但实际上,有时 Org-mode 会直接调用org-html--reference,绕过覆盖。这意味着还需要重定向org-html--reference。
更复杂的是,Emacs Lisp 代码使用双破折号来告诉用户 "此函数是内部的"。通过自由扩展编辑器的任何部分,用户可以自由修改任何内部函数或状态,这可能在特定情况下有问题,并且代码可能在未来的任何更新中被破坏。
"空格键加热" 问题
文章引用了 xkcd 漫画 #1172("工作流"),也被称为 "空格键加热" 问题。漫画描述了一个复杂的工作流,其中空格键被重新映射来加热办公室。
这个比喻说明了过度可扩展性的危险:当系统的每个部分都可以被扩展时,任何更改都可能破坏某人的工作流。文章指出:
"通过使 ' 扩展编辑器的任何部分 ' 成为可能,你实际上是在使代码的任何部分都不可扩展,现在 ' 每个更改都会破坏某人的工作流。'"
可扩展性设计的工程原则
基于这些观察,我们可以提出几个可扩展性设计的工程原则:
1. 精心设计的 API 接口
可扩展性来自于精心设计的 API 接口,而不仅仅是语言一致性。良好的 API 应该:
- 提供稳定的抽象:隐藏实现细节,提供稳定的接口
- 支持版本控制:允许 API 演进而不破坏现有扩展
- 提供明确的扩展点:明确哪些部分可以扩展,哪些不应该扩展
2. 适当的封装级别
需要在过度封装和过度暴露之间找到平衡:
- 过度封装:最终某些东西无法定制
- 过度暴露:难以保持向后兼容性(作为系统维护者)或向前兼容性(作为进行修改的用户)
3. 跨语言隔离
Emacs 的成功部分归功于其跨语言隔离 / API。虽然不完美,但这种隔离允许:
- 核心稳定性:核心功能保持稳定
- 扩展灵活性:扩展可以在不破坏核心的情况下演进
- 渐进式采用:新功能可以作为扩展添加,而不是直接修改核心
4. 维护性考虑
可扩展性设计必须考虑长期维护:
- 补丁管理:如
el-patch包所示,需要工具来管理对内部代码的修改 - 验证机制:需要验证补丁是否仍然有效
- 文档和约定:需要明确的约定来标识内部 API
实际可落地的参数与清单
对于正在设计可扩展系统的工程师,以下是一个可落地的检查清单:
API 设计参数
- 扩展点识别:明确识别系统中哪些部分应该可扩展
- 接口稳定性:为每个扩展点定义稳定性承诺(稳定、实验性、内部)
- 版本策略:制定 API 版本控制策略
- 错误处理:定义扩展失败时的错误处理机制
性能权衡参数
- 编译时 vs 运行时扩展:确定哪些扩展应该在编译时处理,哪些在运行时
- 缓存策略:为扩展结果定义缓存策略
- 懒加载机制:实现扩展的懒加载以避免启动性能影响
维护性参数
- 向后兼容性窗口:定义支持多少个旧版本的扩展
- 弃用策略:制定 API 弃用和迁移策略
- 测试覆盖:确保扩展点有充分的测试覆盖
调试支持参数
- 扩展追踪:提供工具来追踪哪些扩展被加载和执行
- 性能分析:提供扩展性能分析工具
- 错误报告:改进扩展错误报告机制
现代语言的可扩展性模式
虽然 Lisp 的宏系统提供了独特的编译时扩展能力,但现代语言也发展出了自己的可扩展性模式:
1. Rust 的特征系统
Rust 的特征(trait)系统提供了类型安全的扩展机制,允许在不修改原始代码的情况下为类型添加功能。
2. Go 的接口
Go 的接口提供了隐式满足的扩展机制,允许代码以松耦合的方式扩展。
3. JavaScript/TypeScript 的装饰器
装饰器提供了声明式的扩展机制,允许在不修改原始函数的情况下添加功能。
4. 插件架构
许多现代系统采用插件架构,通过明确的插件 API 提供可扩展性。
结论
"100% Lisp" 可扩展性论点是一个诱人但过于简化的工程谬误。实际的可扩展性设计需要更细致的工程考虑:
- 接受非纯部分的存在:任何实际系统都包含无法用纯 Lisp 扩展的部分
- 设计而非默认:可扩展性来自于精心设计,而不是语言特性
- 平衡灵活性与稳定性:需要在过度暴露和过度封装之间找到平衡
- 考虑长期维护:可扩展性设计必须考虑系统的长期可维护性
最终,可扩展性不是二进制的是 / 否问题,而是一个连续谱。良好的工程实践是在这个谱上找到适当的位置,为特定用例提供适当的可扩展性级别,同时保持系统的稳定性和可维护性。
正如文章作者所指出的:"'100% Lisp' 论点是一种懒惰的营销。用 Lisp 编写 Lisp 扩展的编辑器不会立即使你的编辑器更具可扩展性。可扩展性来自于精心设计 API 接口,来自于从历史中学习,倾听用户需求,并且在所有这些之后,付出努力和时间实际编写接口代码。"
资料来源
- Kana. "Extensibility: The '100% Lisp' Fallacy." iroiro.party, 2026-01-01. https://kyo.iroiro.party/en/posts/100-percent-lisp/
- "Extensibility: The '100% Lisp' Fallacy - Hacker News Discussion." https://news.ycombinator.com/item?id=46460394
- Steel Bank Common Lisp (SBCL) Source Code. https://github.com/sbcl/sbcl/tree/master/src/runtime