为什么 Rust 可以不要 GC:所有权、借用与生命周期,到底在折腾什么?
第一次写 Rust 的人,多半在前十分钟就被编译器骂了个狗血淋头。你写个函数想把字符串传进去,它说「move 了,不能再用」;你想留个引用,它说「这里活得比它短」。
然后你会习惯它,然后你会爱上它,然后你回到别的语言,发现自己开始手写 Clone 了——这大概是 Rust 最大的副作用。
这篇不背《Rust 程序设计语言》,我们只干一件事:把 Rust 那套「所有权」模型到底在解决什么问题讲透,以及它怎么用编译期检查换掉运行时 GC。
先说结论:Rust 的所有权不是「编译器小气不让传」,而是一套把资源生命周期在编译期画清楚的静态协议。你和编译器是一伙的,只是它比较轴。
一、GC 解决了什么,又带来什么
Java、Go、Python 都靠垃圾回收器回答一个问题:内存是谁释放的?
你在 Java 里写 new User("x"),这个对象什么时候死?没人说得清。JVM 只能靠可达性分析——从 GC Roots 扫一遍,扫不到就回收。这套机制可靠,但有两个绕不开的代价:
- 停顿:STW 暂停哪怕只有几毫秒,在高 QPS 链路上就是一次抖动。
- 不确定:对象什么时候死、内存什么时候降下来,是运行期才知道的,你没法靠读代码回答「这块内存活多久」。
更麻烦的是,GC 只管内存,不管别的资源。文件句柄、数据库连接、锁、socket——在 Java 里你得写 try-with-resources,在 Go 里你得 defer,忘了关就是泄漏。GC 一概不管。
Rust 的思路是:既然「谁负责释放」本质上是所有权问题,那就别让运行期去猜,让编译期算清楚。
二、三条规则,就这么简单
Rust 的所有权规则只有三条:
- 每个值有且只有一个所有者(owner)。
- 所有者离开作用域,值被丢弃(drop),资源随之释放。
- 所有权可以移动(move),移动之后原所有者失效。
let s1 = String::from("hello");
let s2 = s1;
// println!("{}", s1); // 编译错误:borrow of moved value: `s1`
println!("{}", s2); // s2 拿到了所有权
注意 String::from("hello") 不是栈上的字面量,而是堆上一块缓冲区的「胖指针」三件套(指针、长度、容量)。移动操作做的事非常朴素:把三件套复制一份给 s2,再把 s1 的作废。堆上那块内存从头到尾没动过、没被复制——所以移动是 O(1) 的,零成本。
这和 Java 的引用复制有本质区别。Java 里 User b = a; 之后 a 和 b 指向同一个对象,谁也不知道最后一个引用何时消失。Rust 里「两个所有者指向同一份内存」这种状态从类型系统层面被消灭了。
三、借用:不想转移,那就「借」
实际写代码时,你八成不想把一个 String 的所有权传出去。比如你只是想读一眼它的长度:
fn print_len(s: &String) {
println!("{}", s.len());
}
let s = String::from("hello");
print_len(&s);
println!("{}", s); // s 依然可用,因为只是"借"了
&s 是「借用 s」。函数拿到一个「看」的权限,没有「拿走」的权限,所以返回后 s 毫发无损。
借用规则只有一条,但初学者容易懵:同一时刻,要么有任意多个只读引用(&T),要么有且只有一个可变引用(&mut T),二者不能共存。
翻译成人话:要么大家都能看,要么只有一个人能改,而且改的时候别人都别看。
这条规则直接消灭了数据竞争。数据竞争的定义是「两个线程同时访问同一块内存,至少一个是写,且没有同步」。在 Rust 里,&mut T 存在的那一刻就保证没有别的引用能同时存在;&T 的存在则保证没人能同时写。编译器把数据竞争变成了编译错误。
这也是 Rust 的
Send/Sync标记在多线程场景里特别值钱的原因:把类型丢进thread::spawn之前,编译器会帮你检查它是否真能安全跨线程。不是运行期探测,是类型检查。
四、生命周期:那个最让人抓狂的参数
编译器有一类经典报错:
fn longest(a: &str, b: &str) -> &str {
if a.len() > b.len() { a } else { b }
}
// 错误信息:缺少生命周期标注
意思是:返回的引用可能来自 a 也可能来自 b,编译器不知道是哪个,没法证明返回值的有效期。
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
读法是:「存在一个生命周期 'a,a 和 b 的引用至少活到 'a,返回值的引用也活到 'a」。这约束由调用方提供,函数本身不创造生命周期,只承诺「我返回的东西不会比传进来的活得久」。
多数时候编译器能自己推断(生命周期省略规则)。真正需要手写,通常是结构体持有引用的情况:
struct Excerpt<'a> {
part: &'a str,
}
fn first_word(s: &str) -> Excerpt<'_> {
let word = s.split_whitespace().next().unwrap();
Excerpt { part: word }
}
别被源码吓到:<'a> 是「这个结构体借了外面的数据,'a 是它的债主」,'_ 是「生命周期由编译器推断,我懒得写」。
五、真实翻车场景:连接池里的借用
假设你写了个连接池,想把连接借出去用一下:
fn with_conn<F, R>(&self, f: F) -> R
where
F: FnOnce(&mut Conn) -> R
{
let mut conn = self.pool.get();
f(&mut conn)
}
这里没有生命周期参数,因为 FnOnce(&mut Conn) -> R 里的 &mut Conn 是匿名生命周期,编译器推断它只在 f 调用期间有效。这正是我们想要的——连接借出去用完就还,不许你揣兜里带走。
而如果接口设计成返回 &mut Conn,编译器会立刻报错:返回的借用活得比 &self 还长。在 C++ 里,这个 bug 可能三年后才在某个罕见路径上触发,变成一段凌晨两点的 core dump。在 Rust 里,它是 error[E0499],一行都不会发出去。
六、别和编译器较劲,但要跟它合作
关于「Rust 学习曲线陡」,我的观点有点不客气:陡的不是语言,是它逼你把以前没想清楚的事情想清楚。
你以前写 Go/Java 时,函数返回的那个 map 是谁管的?接口变了由谁负责?回调里捕获的指针会不会悬垂?多线程里这个字段有没有被同时改?——这些问题你以前「不用想」,因为语言替你兜住了。现在语言不兜了,你就得自己想。
几条实操建议:
- 别跟生命周期标注死磕。先让它报错,看它的建议,能加标注就加。八成时候你要的只是 Clone,或者调一下参数顺序。
- 搞懂 move 和 clone 的区别。clone 是深拷贝有成本,move 是零成本转移指针。性能敏感的地方别乱 clone。
- 借用冲突时优先想「结构设计对不对」。把
&mut self满天飞改成「传数据进去、返回新数据出来」的功能式风格,冲突往往自动消失。 - 用 clippy。它比编译器还能唠叨,但每条建议基本都在帮你写地道 Rust。
七、Rust 到底适合什么
这套所有权模型不是银弹,成本边界很明确:
- 适合:对延迟和稳定性敏感的系统组件、高并发网络服务、被「偶发 core dump」折磨过的团队。
- 不适合:业务逻辑快速迭代、需求天天变还要求一周上线、团队还没建立起类型思维的项目——用 Rust 会把时间全花在跟编译器搏斗上,业务反而没时间写。
这不是语言优劣问题,是成本收益问题。Rust 把「内存安全 bug」这个类别从运行期搬到了编译期,代价是编译期你得多花点力气。愿意付这笔钱的项目值得,不愿意的,Go 依然很好,Java 也依然很好。
八、写在最后
我很喜欢 Rust 的一点是:它没有假装「内存管理可以完全不用想」,它只是把这件必须想的事交还给了写代码的人,而且给了你一个极其较真但极其可靠的助手。
写 Go 的时候,我常怀念 Rust 的编译器。你写错了,它立刻告诉你哪儿错了、为什么错、怎么改。这世上免费的东西里,这是最贵的那种。
所以下次被借用检查器骂的时候,别急着骂它,也别急着 unsafe 绕过去。坐下来读一遍错误信息——它说的每一句话,都是对的。
「用 Rust 三个月后我再也回不去写 Java 了。」——每个 Rust 程序员都会说的话,尽管每个 Java 程序员都不信。
祝编码愉快,少被编译器骂(虽然不可能不骂)。