# 用 Rust 扩展 Emacs 功能
# 摘要
在 Emacs 25 中,Emacs 引入了『动态模块』(dynamic module)的机制,允许开发者使用 C 语言规定的动态链接库格式扩展 Emacs 本身所缺乏的能力。
本文阐释了使用 Rust 开发动态模块相对于 C 语言的优势,并介绍讲解如何使用 ubolonto 开发的 emacs-module-rs 简化 Rust 动态模组开发 流程。
# 为什么选择 Rust 而不是 C
C 语言作为一门上世纪 70 年代发明的编程语言,其历史局限性十分严重。可以认为,C 语言的 一些缺陷,阻碍了动态模块这一扩展方式的流行。
- 语言本身过于底层,缺乏表达能力,需要浪费精力在内存管理上。对聚焦于代码逻辑本身 的快速开发不够方便。
- 缺乏良好的类型约束,容易写出内存不安全的代码。
- 缺少中心化的包分发管理系统,获取构建依赖十分麻烦。
- 不同用户上的 C 语言编译器和 C 语言运行时不同,在用户构建使用时容易出现难以复现的 BUG
Rust 作为一门新时代的,聚焦于内存安全,底层效率以及并发的语言,正好完美解决了 C 语 言带来的这些痛点。
- Rust 本身支持最常用的三大操作系统,代码和构建系统无须做过多调整即可跨平台使用。
- Rust 与 C 之间有良好的互操作性,即使需要调用 C 语言轮子也不会过于繁琐。
- Rust 使得开发者在编写动态模组时无需像 C 语言一样为了处理内存细节而烦恼。可 以集中精神在代码逻辑上。
- Emacs module 的 Rust 绑定具有良好的抽象封装,避免了直接与
emacs_module.h
里定义的 底层函数交互的繁琐细节。 - Rust 具有优秀的社区生态,可以复用大量开源社区的轮子,节约了开发时间。
# 目标读者群
本文假定你有一定的 Rust 和 Elisp 编程经验。如果你没有,可以先从这里开始
# 环境
本节记录写下本文所用的操作系统以及相关软件的版本
Archlinux
Emacs 26.3
rustc 1.41.1 (f3e1a954d 2020-02-24)
clang 9.0.1
llvm 9.0.1
# 初试牛刀
# 创建项目
执行下列命令创建一个空白 Rust 项目
cargo init --lib greeting
cd greeting
2
修改 Cargo.toml
文件
[package]
name = "greeting"
version = "0.1.0"
edition = "2018"
# 我们通常希望把 Emacs 相关的 package 发布在 ELPA 而不是 crates.io 上
publish = false
[lib]
# 重要,否则 Rust 不会生成兼容 C ABI 的动态库
crate-type = ["cdylib"]
[dependencies]
# 重要,创建 Emacs 动态模块的关键依赖
emacs = "0.13"
2
3
4
5
6
7
8
9
10
11
12
13
14
# 编码
src/lib.rs
use emacs::{defun, Env, Result, Value};
// 定义一个 C int 类型的名为 plugin_is_GPL_compatible 的全局变量,否则 Emacs 会拒绝加
// 载模块 :)
emacs::plugin_is_GPL_compatible!();
// 定义模块入口,大部份情况我们不需要它做任何事。
#[emacs::module]
fn init(_: &Env) -> Result<()> {
Ok(())
}
/// 输出一条你好信息
#[defun]
fn say_hello(env: &Env, name: String) -> Result<Value<'_>> {
env.message(&format!("Hello, {}!", name))
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 编译
cargo build
# 对于名为 libxxx 的动态库文件,须更名为 crate 的命名,以保证 emacs-module-rs 生
# 成的 provide form 提供的 feature 与文件名相同
# Linux
ln -sv target/debug/libgreeting.so greeting.so
# Mac
ln -sv target/debug/libgreeting.dylib greeting.so
# Windows
# Windows10 默认禁止一般用户创建软链接,你可以参照
# https://docs.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links
# 为你自己启用软链接创造权限,这里使用硬链接作为替代。
mklink target/debug/greeting.dll greeting.dll
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 加载运行
(add-to-list 'load-path "<path/to/your/project/>")
(require 'greeting)
(greeting-say-hello "Emacs")
2
3
若一切顺利,minibuffer 会弹出信息 Hello, Emacs!
# emacs::Value
emacs::Value
是在 Rust 表示 Elisp 值的代理类型,每个 Value
代表一个对 Elisp
值的引用。由于具体的内存分配实际上是 Elisp VM 的 GC 在管理, Value
在 Rust 里的拷贝和
移动是十分轻量的(因此实现了 Copy
trait)。
Rust 无法直接利用一个 Value
,只有 emacs-module.h
提供了转换函数的的基本类型可
以用于转换。在底层函数之上, emacs-module-rs
又提供了 IntoLisp
和 FromLisp
trait 用于抽象 Rust 类型到 Value
之间的转换。
下列类型实现了这些 traits。你也可以为自定义类型实现这些 traits 来实现复杂数据的转换。
Value
对应自己 😛i64
对应一个 Elisp fixnum(64 位有符号整型f64
对应一个 Elisp float(64 位双精度浮点)String
对应一个 Elisp 字符串(不含 text property)emacs::Vector
对应一个 Elisp 向量()
对应 Elisp 的nil
Option<T>
对应一个可为nil
的 Elisp 值,具体类型由T
决定
# #[defun]
emacs::defun
是 emacs-module-rs
最主要的魔法,这个属性宏修饰一个 Rust 的函数
定义,使其转变为可以在 Elisp VM 里调用的模块函数。
被修饰的函数可以接收任意数量的所有权类型,且类型实现了 FromLisp
trait 的参数,最终返回
emacs::Result<T>
其中 T
实现 IntoLisp
trait。简化了返回和取用 Elisp 值时手动转型的
模板代码。
/// 一个愚蠢但是简单的例子,这个注释会被导入 Emacs 的帮助系统中,
/// 通过 M-x describe-function 查阅。
#[defun]
fn add(lhs: i64, rhs: i64) -> emacs::Result<i64> {
Ok(lhs + rhs)
}
2
3
4
5
6
# 命名修饰
Elisp 缺乏命名空间系统,每个包都拥有自己的命名前缀来防止产生命名冲突。
emacs-module-rs
导出的 Elisp 函数的命名修饰格式为 <包名>[相对于crate的Rust模块路 径名]<函数名>
Rust 的 snake_case 风格命名会被转换成 kebab-case 的 Lisp 风格命名,表示路径的
::
也会被转换为 Lisp 风格的 -
# 调用 Elisp 函数
# 调用全局静态函数
emacs::Env
类型上静态挂载了常用的 Elisp 函数,这些函数通常都是 emacs-module.h
直接暴露出来的。
env.intern("defun")?;
env.message("Hello")?;
env.type_of(5.into_lisp(env)?)?;
env.provide("my-module")?;
env.list((1, "str", true))?;
2
3
4
5
6
7
8
9
要捕捉一个 Env
来调用这些函数,请在 defun
修饰的函数定义里额外加入一个类型为
&Env
的参数。
/// 是的,这函数将一个字符串 intern 为 Elisp symbol。但是你为什么不直接在 Elisp 里
/// 用 intern 呢
#[defun]
fn silly_intern<'e>(env: &'e Env, s: String) -> emacs::Result<Value<'e>> {
env.intern(s.as_ref())
}
2
3
4
5
6
# 调用任意函数
emacs::Env::call(func, args)
可以调用任意 Elisp 函数,其中
func
可为标识函数名的字符串(调用对应的symbol-function
),或者可调用的Value
(如闭包)args
可为一个&[Value]
类型,或者一个包含了 1-12(受限于实现,暂时上限 12 个) 个元素,且每个元素都实现了IntoLisp
trait 的元组
// (list "str" 2)
env.call("list", ("str", 2))?;
let list = env.intern("list")?;
// (symbol-function 'list)
let subr = env.call("symbol-function", [list])?;
// (funcall 'list "str" 2)
env.call(list, ("str", 2))?;
// (funcall (symbol-function 'list) "str" 2)
env.call(subr, ("str", 2))?;
subr.call(("str", 2))?; // 简写
// (add-hook 'text-mode-hook 'variable-pitch-mode)
env.call("add-hook", [
env.intern("text-mode-hook")?,
env.intern("variable-pitch-mode")?,
])?;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// 将一个 Elisp Vector 转换为 Elisp List
#[defun]
fn listify_vec(vector: Vector) -> Result<Value> {
let mut args = vec![];
for i in 0..vector.size()? {
args.push(vector.get(i)?)
}
vector.0.env.call("list", &args)
}
2
3
4
5
6
7
8
9
10
# 向 Elisp VM 嵌入 Rust 复合数据类型
将一个 Rust 的复合数据类型完整转换为 Elisp 里的数据类型往往很困难。另一方面,在 Rust
线程和 Elisp VM 间反复进行数据转换会降低模块执行的效率。因此,Elisp 提供 user-ptr
类型用于承载对 Elisp 不透明的外部类型实例。
提示
为了使导出的自定义类型有意义,你还需要额外编写操作该数据类型的模块函数。
user-ptr
类型的释放由 Elisp VM 的 GC 托管,比起一般 Rust 复合数据类型有额外约束:
user-ptr
类型实例必须位于堆上user-ptr
类型具体生命周期编译时不可推断,被移动进 Elisp VM 的复合数据类型 必须满足'static
生命周期约束- 由于无法重新获得所有权,Rust 只能用不可变引用访问
user-ptr
类型,常常需要使用 内部可变性(单线程RefCell
,多线程锁机制,原子类型等)
在非多 Rust 线程的情况下, emacs-module-rs
的 #[defun]
宏可以简化大部份样板代
码。
use std::collections::HashMap;
use emacs::{defun, Env, Result, Value};
#[emacs::module(name = "rs-hash-map", separator = "/")]
fn init(env: &Env) -> Result<()> {
type Map = HashMap<String, String>;
// 标记函数返回一个用 RefCell 包裹的 user-ptr 类型
// #[defun] 宏会自动将其包裹成 Box<RefCell<Map>>
#[defun(user_ptr)]
fn make() -> Result<Map> {
Ok(Map::new())
}
// 在 #[defun] 的参数中,引用类型总表示由 #[defun(user-ptr)] 嵌入的 user-ptr
// 的实例的引用
#[defun]
fn get(map: &Map, key: String) -> Result<Option<&String>> {
Ok(map.get(&key))
}
#[defun]
fn set(map: &mut Map, key: String, value: String) -> Result<Option<String>> {
Ok(map.insert(key,value))
}
Ok(())
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
(let ((m (rs-hash-map/make)))
(rs-hash-map/get m "a") ; -> nil
(rs-hash-map/set m "a" "1") ; -> nil
(rs-hash-map/get m "a") ; -> "1"
(rs-hash-map/set m "a" "2") ; -> "1"
(rs-hash-map/get m "a")) ; -> "2"
2
3
4
5
6
7
8
# 错误处理
# 在 Rust 中处理 Elisp 错误
警告
强烈不建议在 Rust 代码中处理 Elisp 代码的错误! 在 Rust 代码中处理可能
的 Elisp 的错误繁琐且容易造成内存不安全(涉及 unsafe 代码)。请直接使用 ?
操作符向
上抛出在 Rust 代码中调用 Elisp 代码时可能的抛错。这些错误最终会以 rust-error
的错误
类型暴露给 Elisp VM。
# 在 Elisp 代码中处理 Rust 错误
emacs-module-rs
暴露了两种错误类型供用户在 Elisp 端用 condition-case
处理。
rust-error
所有由 Rust 制造的错误。rust-wrong-type-user-ptr
当尝试把一个 lisp 值转换为一个 user-ptr 在 Rust 里的值时, 若类型不匹配,则抛出该错误。该错误是rust-error
的子类型错误。
// 如果 value 内含有的不是 RefCell<HashMap<String, String>>,或者甚至不是一个
// user-ptr,Elisp 端会抛出一个 rust-wrong-type-user-ptr 错误。
let r: &RefCell<HashMap<String, String>> = value.into_rust()?;
2
3
提示
如果你需要在 Rust 代码里抛出类型更精确的 Elisp 错误,可以尝试在 Rust 里调用 Elisp 函数
signal
。
#[emacs::module(name = "test")]
fn init(_: &Env) -> Result<()> {
#[defun]
fn signal_wrong_type(e: &Env) -> Result<()> {
let s = "Mismatched type!".to_owned();
e.call("signal", (e.intern("wrong-type-argument")?, e.list((s,))?))?;
unreachable!();
}
Ok(())
}
2
3
4
5
6
7
8
9
10
(condition-case e
(test-signal-wrong-type)
(wrong-type-argument
(message (error-message-string e)))) ;; => "Wrong type argument: \"Mismatched type!\""
2
3
4
# 在 Elisp 中处理 Rust panic
警告
跨越 FFI 边界的 unwind 是一个 未定义行为。 emacs-module-rs
会尝试用
catch_unwind
捕捉 panic,遗憾的是这并不总奏效--有的 panic 根本不使用 unwind 实现!
catch_unwind
可以捕捉到的 panic 会变成一个 Elisp 错误,而无法捕捉的错误其行为则
依赖于具体实现(如调用 abort 实现 panic 会把你的 Emacs 连带崩掉 😛)
panic 常代表不可恢复的错误,表明程序的逻辑出了问题。尽管有办法在 Elisp 中捕捉一个 Rust panic,但我不会在这里教你怎么做--比起掩盖错误的发生,更应该找出错误的原 因。
# Rust 错误之间的互相处理
emacs-module-rs
本身使用
failure 库处理错误。如果你恰好
也使用了 failure 处理错误,可以直接使用 ?
操作符抛出你的错误,
emacs-module-rs
会将其统一处理为 rust-error
类型的 Elisp 错误抛出给 Elisp VM。
其他类型的错误,在转换为 failure::Error
后也可以按同样的方式抛出给 Elisp VM。
# 延伸阅读
本文对编程动态模块中不常见的细节做了部分省略以保证作为入门读物的可读性,要完整的
理解 emacs-module-rs
,请移步 emacs-module-rs's user guide(英
文)
要更深入理解 Rust 语言以及 emacs-module-rs
背后的魔法,阅读 Rust 官网提供的教程是
不错的选择。
下列是一些用 Rust 构建动态模块的 Emacs 插件列表,也许可以在你写新模块的时候 帮助到你。