从 Rust 扩展 Emacs 到多语言协同扩展 Emacs
Emacs
2022-10-03 2331字

今天看了 用 Rust 扩展 Emacs 功能 的文章, 实践了一下午, 分享一下我的感受。

给 Emacs 写 Rust 动态模块

优点:

动态模块, Rust 可以直接通过 env 访问 Emacs 的内存数据, 要比 RPC 的方式效率高一点,特别是传递 obarray 这种比较大的数据 Rust 的执行性能确实好, 甩 Elisp 不是一点半点 但是缺点更多:

  1. 开发效率低下, 需要经历 cargo build → rs-module/load → elisp code 的开发过程, 频繁的编译 + emacs-rs-module 的基础设施不完善( rust panic 后 rs-module/load 就废了, 需要重启 Emacs), 基本上没有 Elisp 这种随时执行 load-file 就热替换的爽, 当写代码频繁的被编译过程打断以后就没有乐趣了, 更谈不上生产力
  2. Rust 强类型的缺点: Rust 的作者肯定是大量借鉴了 Haskell/GHC 的特性, 强调编译器强类型检查, 虽然在内存安全领域确实是亮点, 但是我写过多年 Haskell 代码, 知道 Rust/Haskell 这类语言最大的缺点就是, 当系统本身很复杂, 就会导致编译器推导出来的表达式类型很复杂, 开发人员会大量时间和编译器做斗争, 保证类型正常可以编译, 如果大部分代码都是自己写的还好, 一旦用到很多库, 第三方作者定义的库有很多奇奇怪怪的类型, 甚至你需要先看库的类型定义弄懂以后才能继续写代码。 写代码最重要的是总体思路、灵感和流畅性, rust/haskell 这种语言不适合写应用层代码, 心智被打断的概率太大了
  3. 类型转换: 动态模块两种差别比较大的语言都会面临类型转换, 类型转换要考虑作用域、引用和内存分配, 一般动态模块开发都需要中间层的帮忙, 就像 haskell 的 c2hs 和 rust 的 emacs-rs-module, 中间层的健壮性和兼容性直接决定了动态模块的开发效率, 目前看, emacs-rs-module 的类型转换还比较麻烦 (比如我从 env.call 调用 obarray 数据到 Rust 里面后, 转换 obarray 并对 obarray rust 进行 loop 操作各种障碍, 也许我的 rust 还不熟练导致的),写 Rust 动态模块除了第二点的编译器斗争以外, 要随时随地做各种类型转换的代码, 导致代码看起来非常丑 我认为大家喜欢 Emacs 主要是因为 Elisp 这个语言的热替换带来的随心所欲 hacking 的快感, 但是 Rust 除了性能强悍以外, 真的毫无乐趣, 客观的说, 还不如 C++ 写动态模块再结合 Elisp 呢。

所以, 我通过实践告诉大家 remacs 这种的项目没有前途。

Emacs 多语言扩展思路

Emacs 的核心三大缺点: Elisp 性能慢、 缺乏真多线程支持、 图形绘制能力。

  1. 图形绘制能力和语言级别支持多线程其实是一体化的, 必须在语言级别就要支持多线程, 才能保证后台计算和前端代码可以基于线程通讯高效访问, 如果语言级别不支持真并发, 就会导致 Emacs 这么多年都在用外部工具生成图片, 再发图片回 Emacs 渲染的 hacking way。 在图形绘制每秒 30 帧的要求下, 外部进程计算再传递图片回 Emacs 就比直接绘制逻辑代码绘制窗口的性能低很多, 最简单的对比就是 doc-view 基于图片的方式和 EAF pdf-viewer 直接渲染的性能差距。 xwidget 的发展瓶颈是, Elisp 是单线程,一旦网页 JS 代码卡住就会导致 Emacs 卡住, 或者后台图形绘制数据性能要求高, Elisp 没法在 xwidget 绘制的时候提供足够的并发计算模型, 所以 Emacs 的图形增强也许只有 EAF 这一条路走。

  2. 撇开图形绘制能力, 单讲 Elisp 的执行性能和多线程能力, 其实 lsp-bridge 这种 “RPC + Python + Multi-Thread” 方案就是一种非常好的实践, 如果大家认真研究 Neovim 会发现, Neovim 和 lsp-bridge 的技术思路非常像。 通过外部进程和语言可以极大的解决 Elisp 性能不足和多线程没法支持的问题, 而且还可以借助外部语言的生态库来扩展 Emacs 的能力 (比如, orjson 这个 Python 库的性能就比 Emacs 29 本身的 JSON 解析性能高很多、 pygit2 就可以直接访问 libgit 的能力而不用 Elisp 去绑定 libgit 库, mupdf 这个库可以直接操作 PDF 而不需要写 Elisp 去解析 PDF 格式等等)。

  3. 针对第二点, 还有一种方式就是 Rust 这种动态模块, 但是从实践看 Rust 动态模块来扩展 Emacs 插件非常痛苦也缺乏效率。

所以, 我个人认为快速通过第三方语言扩展 Emacs 有几种路径:

  1. RPC + Python : 类似 EAF 和 lsp-bridge 的技术, 非常成熟, 特别是 lsp-bridge, 大家可以明显体验到代码补全流畅性的大幅提升
  2. RPC + JavaScript: 可以采用 RPC 对接 npm 或者 Deno 的方式来扩展 Emacs, 享受 V8 的性能和 JavaScript 库生态, 考虑到 npm 的依赖地狱, Deno 也许是一条不错的发展路径
  3. RPC + Lua: LuaJIT 本身性能非常快, Lua 的生态也很好, Lua 的缺陷是缺乏真多线程支持
  4. RPC + Clojure: Clojure 的语法很像 Lisp, 同时底层基于 JVM, 底层库也非常丰富, 缺点是要带一个 JVM 虚拟机, 不过在大内存的时代, LSP 和 TabNine 这些都可以忍受, JVM 反而不是那么扎眼

这是我摸索 EAF/lsp-bridge 过程中的一些想法分享给大家, RPC + 外部高性能脚本语言会是扩大 Emacs 能力的好方法。 按照实践看, 特别是 lsp-bridge 项目的完成, 充分说明这种方式的可行。

Emacs 本身只是工具

Emacs 本身只是工具, 面向场景的多种编程语言共存已经是现实, 我们应该面对现实, 采用 “他山之石为我所用” 的方式去扩展 Emacs。