本文作者 Rust 小白(别的语言也了解的不多),如有错误务必指正,本文目的在于以小白的视角讲述未来 Rust 类型系统的一些演变方向。类型系统的概念往往是跨语言的,通过了解不同语言的特点,能够帮助我们更好掌握当下在用的工具。
1. 对象应当如何被使用?------ Substructural Type System
首先 Substructural Type System 这个词可能可以被翻译为"子结构类型系统"或"亚结构类型系统",但不管是什么,其名字听起来都很玄学,事实上它的核心是:通过限制对象的使用方式,来增强代码的安全性,并能更好的管理内存。
提到"亚结构"肯定有人要问:那"结构类型系统"(Structural Type System)是什么呢?,事实上,它们几乎没有关系,与"结构类型系统"对应的是"标明类型系统"(Nominal Type System):
- 标明类型系统:名字叫啥就是啥类型,比较的是类型本身。
- 结构类型系统:即 Duck Typing,只要类型成员均一致,那么就是相同类型,本质上类型检查只检查 Shape,即类型定义的约束条件。
但 Substructural Typing System 其实是完全不同的一个概念,其关注的是"对象应该如何被使用"。(而非具体是什么结构)下面给出了其具体分类(从宽松到严格):
| 亚结构类型名 | 使用规则 | 内存语义 | 代表语言/特性 |
|---|---|---|---|
| Normal(普通) | 随便用几次都行 | 值可以随意复制、随意丢弃 | js、java、py 等多数主流语言 |
| Affine(仿射) | 最多用一次 | 值只能移动(所有权转移),可以不用直接 drop | 当前 Rust |
| Linear(线性) | 恰好用一次 | 值必须被显式消费,不能 drop 也不能 leak | Rust 未来的 !Forget |
| Ordered(有序) | 恰好一次,且在原地 | 值必须被消费,且内存地址从创建到销毁不变 | Rust 未来的 !Move |
Substructural Type System 通过控制几条"结构规则"来定义不同强度的类型约束:
- 弱化规则(Weakening):允许引入一个变量但不使用它。即:值可以被随意丢弃。
- 收缩规则(Contraction):允许同一个变量被使用多次。在 Rust 中,只有可 Copy 的才可以这样。
- 有序规则(Ordered):不仅恰好使用一次,还必须按照引入顺序使用。在实践中,这意味着值有稳定的内存地址,永远不会被移动。
通过组合"有/无弱化"、"有/无收缩"、"有/无序",我们也可以上述的几种类型系统。
JavaScript、Java 等这些以引用和 GC 为核心的语言,通常都是 Normal 类型:let y = x 只是复制引用,x 仍然可用,GC 负责回收,程序员完全不用操心值的生命周期。
Rust 选择了 Affine 类型系统,这是 Rust 内存安全的根基。这里我也提到了一些 Rust 未来可能的语言设计(其实是我菜,没用过其它有这些特性的语言),后文你将会看到它们如何发挥作用。
2. 为什么 Rust 需要线性类型?
2.1 Rust 的仿射类型(Affine)
Rust 目前是一个仿射类型系统(Affine):
- 有弱化规则 :Rust 的 RAII 机制(
Droptrait)会在值离开作用域时自动析构它。你也可以用mem::forget直接泄露它。 - 受限的收缩规则 :只有实现了
Copy的类型才能隐式复制,其他类型需要显式clone()。不实现Copy的类型遵循移动语义。
Rust 中非 Copy 类型的值遵循移动语义,每次赋值或传参都是所有权转移,原变量立即失效:
rust
let x = String::from("hello");
let y = x; // x 的所有权转移给了 y
// println!("{x}"); // 编译错误!x 已经被"用掉"了
println!("{y}"); // OK
// 复制必须显式 clone
let a = String::from("hello");
let b = a.clone(); // a 仍然可用
println!("{a} {b}"); // OK
这套机制让编译器能在编译时保证:没有 use-after-free,没有 double-free,没有数据竞争。
但仿射类型有一个漏洞:值可以不用就直接丢弃 (mem::forget / 隐式 drop),这在某些场景下会出问题。这正是线性类型要解决的,编译器要强制你必须消费这个值。
2.2 为什么"可以丢掉"是个问题?
在 Rust 中,mem::forget 是一个安全函数 (不需要 unsafe),它可以让任意值"凭空消失"而不运行析构函数:
rust
use std::mem;
let important_handle = acquire_critical_resource();
mem::forget(important_handle); // 析构函数不会运行,资源可能泄露
对于大多数类型来说,这只是内存泄露,这不算什么大事。但对于某些类型(比如持有系统资源的 handle、需要 join 的线程 guard),析构函数不运行可能导致未定义行为。
如果我们能让某些类型变成线性类型,编译器就会在编译时保证:这个值必须被消费,不能悄悄丢掉。
不过,在开始 Rust 的线性类型之前,我其实很疑惑,为什么 mem::forget 的安全的?为什么 Rust 允许析构函数不被调用?
2.3 mem::forget 为什么是安全的?
就如同上面的例子,这个行为看起来很危险,为什么它不是 unsafe 的?
设计原因 :事实上,Rust 的核心立场是 内存泄露不是未定义行为(UB) 。内存泄露固然不好,但它不会导致悬垂指针、数据竞争、读取未初始化内存等真正的安全问题。因此 mem::forget 本身是"安全"的。
实际用途 :mem::forget 最常见的用法是在 unsafe 代码中转移资源所有权 。例如 Vec::into_raw_parts 在把 buffer 指针交给调用者后,需要 forget 掉原来的 Vec,否则 Vec 的析构函数会释放那块已经交出去的内存:
rust
let v = vec![1, 2, 3];
let (ptr, len, cap) = v.into_raw_parts();
// 内部: mem::forget(v),防止 v 的析构函数释放 ptr 指向的内存
// 现在调用者负责管理 ptr
在 safe Rust 中直接调用 mem::forget 比较少见。但问题是,即使你不直接调用它,还有其他方式可以达到同样效果 ,比如通过 Rc/Arc 配合 RefCell 实现的内部可变性、丢弃线程 JoinHandle 导致 move 的 val 永不销毁等。《Rust 程序设计语言》官方文档也有提及这一点:rustwiki.org/zh-CN/book/...
因此,正是因为存在很多"safe 泄露"的途径,Rust 团队最初做出的决定就是:接受所有类型都可能被泄露,不在类型系统中区分"可泄露"和"不可泄露"的类型 。这个决定让当时的 Rust 语言本身保持了简洁性,但也关上了一扇门:任何依赖析构函数一定运行才能保证安全性的 API,在当前 Rust 中,不一定是完全 safe 的。
2.4 Drop 的五个结构性缺陷
即便析构函数能保证运行,Drop trait 自身的设计也有严重局限:
rust
pub trait Drop {
fn drop(&mut self); // 这就是它的签名,非常受限
}
缺陷一:不能返回值 。drop 返回 (),无法返回 Result 报告清理过程中的错误。如果文件关闭失败、数据库回滚失败,析构函数只能默默 panic 或吞掉错误:
rust
impl Drop for DatabaseConnection {
fn drop(&mut self) {
// 如果断开连接失败怎么办?
// 不能返回 Err(...),只能 panic 或 log
if let Err(e) = self.disconnect() {
eprintln!("disconnect failed: {e}"); // 只能这样
}
}
}
缺陷二:不能接受参数 。有些资源的清理需要额外信息,比如事务需要一个连接对象来 commit/rollback,但 drop(&mut self) 没有地方传入这些参数:
rust
// 理想中我们想写这样的代码,但 Drop 不支持
impl Drop for Transaction {
fn drop(&mut self, conn: &Connection) { // 编译错误:签名不对
self.rollback(conn);
}
}
缺陷三:只有一种 Drop。一个类型只能有一个析构函数,不能有"commit-析构"和"rollback-析构"两种变体。
缺陷四:不能是 async 的 。在 async 上下文中,清理操作可能需要 .await(比如发送一条断开连接的消息),但 drop 是同步的。目前没有 async Drop。
缺陷五:drop 只接受 &mut self,不接受 self 。因为 drop 拿的是可变引用而非所有权,它不能把自己的字段"移出来",只能借用访问。这限制了类型本身析构逻辑的表达能力。
还有很多,正是因为上面这些原因,导致了诸如 DMA 访问、事务、网络、数据库等一系列操作选择了使用自定义 drop 函数 + mem::forget 的逻辑。甚至还有一种典型的 workaround:"析构炸弹"模式:
rust
impl Drop for CriticalResource {
fn drop(&mut self) {
// 如果正常消费了这个值,应该已经调用过 mem::forget
// 走到这里说明值被异常丢弃了
std::process::abort(); // "析构炸弹":直接崩溃
}
}
impl CriticalResource {
fn consume(self) {
// 资源应该走自定义逻辑,正常销毁:做真正的清理工作
do_cleanup(&self);
mem::forget(self); // 阻止析构炸弹触发
}
}
但这是一种动态检查手段,运行时才能发现问题,而 Rust 用户更想要编译时静态保证。
2.5 线性类型如何解决这些问题
关键在于思维转变:不再依赖 Drop trait 做清理,而是让"清理"变成一个普通方法调用,但编译器强制你必须调用它。(当然,对于无泄露风险的常规对象,也可以直接让编译器添加 drop)
我们假想一个只实现了 Move(不实现 Destruct 和 Forget)的类型:
- 只能被移动(传递所有权)
- 不能被
mem::forget(没有实现Forget) - 不能被隐式 drop(没有实现
Destruct,编译器不会在作用域结束时自动析构它)
这意味着你拿到这个值之后,唯一能做的就是把它传给某个接受 self 的方法,而这个方法就是你定义的"消费函数"。既然消费函数是普通方法,Drop 的所有限制都不存在了:
-
解决"不能返回值":消费函数是普通函数,返回类型任意。
ruststruct FileHandle { fd: RawFd } impl Move for FileHandle {} impl FileHandle { pub fn close(self) -> io::Result<()> { let FileHandle { fd } = self; match unsafe { libc::close(fd) } { 0 => Ok(()), // 关闭失败?返回 Err _ => Err(io::Error::last_os_error()), } } } fn work() -> io::Result<()> { let f = FileHandle::open("data.txt")?; f.write_all(b"hello")?; f.close()?; // 编译器强制调用;close 失败会被 ? 传播 Ok(()) // 如果忘了 close,编译错误,不是运行时泄露 } -
解决"不能接受参数":消费函数签名完全自由。
ruststruct Transaction { data: Vec<u8> } impl Move for Transaction {} impl Transaction { pub fn commit(self, conn: &Connection) -> Result<(), Error> { let Transaction { data } = self; conn.send(&data) } pub fn rollback(self, reason: &str) { let Transaction { data } = self; log::warn!("rollback: {reason}"); // data 被 drop(Vec<u8> 实现了 Destruct) } }当前的 Rust 中,你只能把
Connection存在Transaction的字段里以便drop时能访问,或者用全局/thread-local 变量绕过,都很丑陋。 -
解决"只有一种 Drop" :你可以定义任意多个消费方法,编译器只要求你调用其中一个。
rustfn process(conn: &Connection) -> Result<(), Error> { let tx = Transaction::begin(conn)?; if validate(&tx)? { tx.commit(conn)?; // 路径 A:提交 } else { tx.rollback("invalid"); // 路径 B:回滚 } // 两条路径都消费了 tx,编译通过 // 如果某个分支遗漏了,编译错误 Ok(()) }理论上,编译器也应当去做控制流分析,保证在每个控制流分支中,
tx都必须被恰好消费一次。 -
解决"不能是 async" :消费函数可以是
async fn,这意味着这个类型只能在 async 上下文中被析构(被.await消费),如果你试图在 sync 代码中 drop 它,编译器会报错。ruststruct AsyncConnection { stream: TcpStream } impl Move for AsyncConnection {} impl AsyncConnection { pub async fn close(self) -> io::Result<()> { let AsyncConnection { mut stream } = self; stream.write_all(b"QUIT\r\n").await?; // 发送断开消息 stream.shutdown().await?; // 等待关闭完成 Ok(()) } }今天
async Drop之所以设计困难,根本原因是Drop::drop的签名是fn drop(&mut self)同步的、无返回值的、固定签名。但如果清理逻辑不再走Drop,而是走普通的async fn close(self),"async drop"问题就消失了,它从一个语言特性问题变成了一个普通的 API 设计问题。 -
解决"
drop只接受&mut self" :消费函数接受self(所有权),可以解构、移出字段。rustimpl Transaction { pub fn into_parts(self) -> (Vec<u8>, Metadata) { let Transaction { data, meta } = self; // 解构,拿到每个字段的所有权 (data, meta) // 自由地把字段移出来返回 } }今天的
Drop::drop(&mut self)里,你不能写let Transaction { data, .. } = self;,因为你只有&mut self,不能移出字段(除非用mem::take?)
因此,线性类型的核心思路是"把析构从隐式的编译器行为,变成显式的用户方法调用"。Drop 之所以有这么多限制,是因为它的调用时机和方式由编译器控制,编译器需要在任何可能的退出路径上插入 drop 调用,所以签名必须固定。而线性类型把这个控制权还给了用户:你来决定什么时候、怎么消费这个值,编译器只负责检查你确实消费了。
2.6 历史的遗憾
2015 年,Rust 1.0 发布前几个月,thread::scoped API 被发现不安全 (Related Issue),用户可以通过 mem::forget 泄露 JoinGuard,跳过 join,导致 use-after-free。Rust 团队在时间压力下决定:不添加 Leak trait,接受所有类型都可以被泄露的现实,社区中被称为 "Leakpocalypse" 大事件,直接决定了 Rust 的设计哲学。这存在几个历史原因:
- 时间紧迫:当时距离 1.0 发布仅剩几个月,并且部分因素是因为 Mozilla 管理层给 Rust 团队施加了巨大的 1.0 发布压力。引入 Leak trait 需要对整个标准库和编译器类型系统进行大规模重构。
- 复杂性爆炸: 如果引入 Leak,几乎所有的泛型代码都需要考虑 T: Leak 约束。这会极大增加语言的学习成本和 API 的复杂性。
- 无法覆盖所有路径: 即使没有 mem::forget,通过 Rc 循环引用、故意制造死锁,依然可以导致析构函数不运行。
Rust 的一些设计者后来坦言:如果能回到 2015 年,他会同时添加 Move 和 Leak,而现在想补上这个遗憾极其困难,核心原因是向后兼容性,有这么几种可能的方案:
Leak作为 auto trait (像Send):所有类型默认实现Leak,不想被泄露的类型用impl !Leak。其问题在于:给mem::forget加上T: Leak约束是 breaking change,因为今天所有写mem::forget(x)的泛型函数都没有Leakbound。Leak作为?Trait(像Sized):默认所有泛型参数隐含Leak,想支持线性类型的函数写?Leak。但问题可能更大:标准库中所有 stable 的关联类型都无法加?Leakbound,也是 breaking change。
社区目前也在探索更好的方案,比如通过 opt-in 的方式逐渐向后兼容,在不破坏现有代码的前提下逐步引入新约束。
3. Pin
3.1 Pin 解决了什么问题
当 Rust 引入 async/await 时,遇到了一个核心问题:async 函数的状态机可能是自引用的(包含指向自身数据的引用)。自引用结构不能被移动,否则内部指针会失效。
rust
async fn self_reference() {
let mut array = [1, 2, 3];
let ptr = &array[0]; // ptr 引用了同函数内的 array
some_async_op().await; // 挂起,状态机必须保存 array 和 ptr
// 倘若 array 已经被移动掉了,那么 ptr 指向的地址就会失效
println!("{}", *ptr);
}
// 状态机内部实现
struct AsyncStateMachine {
array: [i32; 3],
// 指针指向上方 array 字段
ptr: *const i32,
}
由于没有 Move trait,Rust 用 Pin<&mut T> 来表达"这个值已经被固定,不能再移动了":
rust
pub trait Future {
type Output;
// Pin<&mut Self> 表示 self 被"钉住"了
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
虽然 Pin 能用,但它带来了显著的复杂度:
-
概念复杂 。
!Unpin(双重否定)表示"不能 unpin"即"被固定的类型"。很难理解Pin<&mut T>只在T: !Unpin时才有实际约束这种逻辑。 -
Pin Projection 需要宏 。要安全地从
Pin<&mut MyStruct>访问一个字段的Pin<&mut Field>,需要使用pin-project等宏。 -
在 trait 签名中传染 。因为
impl Trait语法无法表达"当 T 被 Pin 时实现某个 trait"的约束(无法在 bound 的左侧使用Pin),Pin必须直接出现在方法签名中:rust// 因为 -> impl Future 无法表达 "Pin<&mut T>: Future" 的约束 // 所以 Future::poll 必须把 Pin 写进签名 trait Future { fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } -
trait 大量重复 如果想让 Iterator 支持自引用迭代器,就需要一个全新的
PinnedIteratortrait。对于std::io模块,可能需要:Read/PinnedRead、Write/PinnedWrite、Seek/PinnedSeek
3.2 Pin vs Move
Pin 把不可移动性视为 val 的属性,即一个值在被 Pin 之前可以移动,之后不行。而 Move 把不可移动性视为类型本身的属性,某些类型天生就不能移动。
rust
// Pin:不可移动性是"val"的属性
fn foo<T: Unpin>(t: &mut T); // 没有 Pin,可以移动
fn foo<T: !Unpin>(t: &mut T); // 没有 Pin,仍然可以移动!
fn foo<T: !Unpin>(t: Pin<&mut T>); // 有 Pin,才不能移动
// Move:不可移动性是"类型"的属性
fn foo<T: Move>(t: T); // T 可以移动
fn foo<T: !Move>(t: &mut T); // T 不能移动,但可以借用
Move 作为 auto-trait,可以像 Send/Sync 一样与任何其他 trait 自由组合,不需要重复定义 trait,Rust 社区也倾向于用 Move 替代 Pin。
4. 构建线性类型系统
4.1 多层级类型系统定义:Forget / Destruct / Move / Pointee
社区提出了一个较为完整的 trait 层次结构,称为"Controlled Destruction":
rust
trait Pointee {} // 可以被指针引用
trait Move: Pointee {} // 可以被"移动"(重定位)
trait Destruct: Move {} // 可以被"析构"(drop)
trait Forget: Destruct {} // 可以被"遗忘"(泄露)
默认情况下,泛型参数拥有 Forget 约束(即所有当前行为不变)。通过降低约束级别可以获得更强的类型保证:
rust
// 默认,等同于今天的 Rust
fn anything<T: Forget>(a: T, b: T, c: T) {
std::mem::drop(a); // OK:可以析构
std::mem::forget(b); // OK:可以遗忘
let x = c; // OK:可以移动
}
// 只有 Destruct,不能 forget
fn must_cleanup<T: Destruct>(a: T, b: T, c: T) {
std::mem::drop(a); // OK
std::mem::forget(b); // 错误!T: Forget 未满足
let x = c; // OK
}
// 只有 Move,不能 drop 也不能 forget
fn must_consume<T: Move>(a: T) -> T {
// 不能 drop,不能 forget,只能移动
a // 必须返回它
}
Pointee(可被指针引用):!Move的极端形式,值从出生到死亡都在同一个内存位置,甚至不能放在栈上(因为栈会 pop)。适用于建模 video RAM 或 MMIO 寄存器等Move(可被移动):值可以从一个内存位置移动到另一个。!Move的类型有稳定的内存地址,永远不会被移动。Destruct(析构函数保证运行):值不能被mem::forget,但可以被正常 drop。这是线性类型的核心。Forget(当前默认行为):值可以被mem::forget安全跳过析构,这是今天 Rust 中所有类型都隐式拥有的。
4.2 控制流的挑战
线性类型的"恰好使用一次"在有控制流(if/while/panic)时变得复杂。目前有三种强度的检查思路:
- 最弱规则:变量在作用域内出现一次即可(不管控制流),但不安全,因为某个分支可能没有消费值。
- 中间规则 :变量在每个控制流分支中都恰好使用一次。
- 最强规则 :在程序的所有可能执行路径中都恰好使用一次。
rust
// 最弱规则通过,但实际上不安全
fn bad(linear: Linear) -> Option<Linear> {
if undecidable() {
Some(linear) // linear 被使用了
} else {
None // linear 没被使用!但 else 分支会自动析构它
}
}
// 中间规则下,需要每个分支都消费 linear
fn good(linear: Linear) -> Option<Linear> {
if undecidable() {
Some(linear)
} else {
linear.consume(); // 显式消费
None
}
}
// 最强规则下,还需要考虑程序所有可能的运行状态:
// 包括无限循环、panic、甚至 IO 故障等情况
fn handle_file(f: File) {
while some_infinite_process() {
// 这里在做一些永不停止的事情
}
f.close();
}
panic 是最大的难题,如果函数调用可能 panic,线性值的作用域内就存在一条隐式的"提前退出"路径。在这条路径上,值会被 RAII 析构,但这可能不满足线性类型的安全条件。对于这种情况的解决方案是引入不会 panic 的函数标注(即后面的"函数效果"):
rust
#[no_panic]
fn safe_operation() {
// 保证不会 panic
}
fn handle_file(f: File) {
// 编译器知道这一行一定不会失败
safe_operation()
f.close();
}
5. Effects、Refinement Types、Pattern Types
5.1 Effect Types
Rust 已经有几种"函数效果":
rust
const fn foo() {} // 编译时可求值
async fn bar() {} // 异步
// nightly:
gen fn baz() {} // 生成器
try fn qux() {} // 可失败
函数的效果声明了函数本身包含的特性和约束条件(如 async 只能在 async 函数中调用),当然,除此以外,Rust 还可以有更多的效果,如:
no_panic: 保证函数不会出现 panic,线性类型系统可以依照这一依据来对控制流进行分析(见上文)no_io:保证不会因为调用 IO 产生副作用。更进一步,可以有no_host保证不会去调用宿主主机 API
5.2 Refinement Types 更精确的类型
Pattern types 用 pattern 语法给已有类型附加约束,说实话,有点开始像 TypeScript 了:
rust
// 用 pattern 约束定义 NonZeroUsize,不再需要特殊的编译器优化
type NonZeroUsize = usize is 1..;
// 更多例子
type Percentage = u8 is 0..=100;
type AsciiChar = u8 is 0..=127;
5.3 View Types
View types 让编译器理解"不重叠的部分借用",从而能够更方便的分散持有可变引用:
rust
struct Foo { a: String, b: String }
// 现在不行,编译器不知道 &mut self.a 和 &mut self.b 不冲突
// 有了 view types,可以同时持有两个不同字段的可变引用
fn process(foo: &mut {a} Foo, bar: &mut {b} Foo) {
// 分别处理 a 和 b
}
5.4 类型系统各元素关系
这些类型系统特性之间存在关联,它们都是为了保证在编译期获取到更丰富的信息来支撑 Rust 的安全性。
- Effect types 回答这个函数能做什么(能 panic 吗?能做 IO 吗?能异步吗?)
- Substructural types 回答这个值能怎么用(能丢弃吗?能移动吗?能复制吗?),如,线性类型。
- Refinement types 让编译器知道这个类型有什么约束(非零?在某个范围内?)
- View Types 让编译器知道"部分借用",能够更好的进行 RAII 推断。
参考资料
- Changing the Rules of Rust
- Substructural type system - wikipedia
- 如果 Rust 有 linear type 会怎么样
- A Grand Vision for Rust
- The Pain of Real Linear Types in Rust
- Move, Destruct, Forget, and Rust
- Immobile types and guaranteed destructors
- Why Pin is a part of trait signatures (and why that's a problem)
- Ergonomic Self-Referential Types for Rust
- Linear Types One-Pager
- Forget marker trait (RFC #3782)
- Forgettable auto trait (RFC #3867)