- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 没有 GC 也能保证内存安全
- 所有权的三条规则
- 移动(move)语义 — 不是复制,而是移动
- E0382 — 读懂"移动后使用"错误
- 借用 — 不转移所有权也能访问
- 借用检查器 — &mut XOR & 规则
- E0502 — 不可变借用与可变借用冲突
- 生命周期 — 引用不能比原始值活得更久
- 生命周期省略 — 大多数情况下不需要写
- Copy 与 Clone — 不发生移动的类型
- 用手学会它
- 结语
- 参考资料
引言 — 没有 GC 也能保证内存安全
大多数语言用两种方式之一来获得内存安全。要么像 C、C++ 那样把一切都交给程序员(然后收获 use-after-free、二重释放、数据竞争),要么像 Java、Go、Python 那样交给垃圾回收器(然后收获运行时开销和不可预测的停顿)。
Rust 走的是第三条路。它在编译期追踪内存和资源的生命周期,让 use-after-free 和数据竞争在没有 GC 的情况下也根本编译不过。这套魔法背后是三组规则:所有权(ownership)、借用(borrowing)、生命周期(lifetimes)。理解了这三者,Rust 剩下的大部分内容都会自然地跟上来。反过来说,与这三者搏斗的那段时间,正是那个臭名昭著的"与借用检查器搏斗"。
这篇文章的目标,是尽快结束这场战斗。与其死记硬背规则,不如理解为什么会有这样的规则 — 这样一来,检查器为什么发火就开始看得清楚了。
所有权的三条规则
Rust 官方书籍用三句话总结了所有权。
- Rust 中的每个值都有一个所有者(owner)。
- 同一时刻只能有一个所有者。
- 当所有者离开作用域时,值会被丢弃(dropped)。
第三条规则正是取代 GC 的部分。在作用域结束的 } 处,Rust 会自动对该作用域拥有的值调用 drop。如果是堆内存就释放,如果是文件就关闭,如果是锁就释放。这被称为 RAII(Resource Acquisition Is Initialization),概念源自 C++,但 Rust 在语言层面强制执行它。
fn main() {
let s = String::from("hello"); // s拥有这个堆字符串
println!("{s}");
} // s 在这里离开作用域 → 自动调用 drop(s) → 堆内存被释放
String 把数据放在堆上,栈上则保留(指针、长度、容量)三个值。作用域结束时,栈上的三个值消失,而就在此之前 drop 会归还堆缓冲区。开发者不需要调用 free,GC 也不需要事后清理。释放的时机静态地固定在代码里。
移动(move)语义 — 不是复制,而是移动
这时候"只能有一个所有者"这条规则变得有意思起来。把一个值赋给另一个变量会发生什么?
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权“移动”给了 s2
println!("{s2}"); // OK
// println!("{s1}"); // 编译错误!s1 已经不再有效
}
如果你熟悉其他语言,大概会预期 s1 和 s2 是指向同一字符串的两个名字。但那样就违反了规则 2 — 会出现两个所有者。如果两者都有效,作用域结束时 drop 就会被调用两次,对同一个堆缓冲区做二重释放。这正是 C++ 里那个臭名昭著的 bug。
Rust 的解法是把赋值定义为移动。执行 let s2 = s1; 之后,s1 进入"已移动(moved-out)"状态,不能再使用。所有权正好落在一个变量(s2)上,drop 也只会被调用一次。移动本身只是复制栈上的三个值(指针、长度、容量)的浅操作,不会触碰堆数据。既快又安全。
函数调用也是一样。把值传给函数,所有权就会转移给函数。
fn consume(s: String) {
println!("{s}");
} // s 在这里被 drop
fn main() {
let s = String::from("hello");
consume(s); // 所有权转移给 consume
// println!("{s}"); // 错误!s 已经被移动
}
E0382 — 读懂"移动后使用"错误
把上面代码里的注释去掉,Rust 会这样报错。Rust 的错误信息是世界上最友好的之一,光是认真读一遍,大多数问题就能解决。
error[E0382]: borrow of moved value: `s`
--> src/main.rs:9:22
|
7 | let s = String::from("hello");
| - move occurs because `s` has type `String`,
| which does not implement the `Copy` trait
8 | consume(s);
| - value moved here
9 | // println!("{s}");
| ^ value borrowed here after move
拆开看这条消息。error[E0382] 是错误代码,可以用 rustc --explain E0382 查看详细说明。正文精确指出了三个位置:(1) s 被移动,是因为 String 没有实现 Copy;(2) 值被移动的位置是 consume(s);(3) 移动之后又试图使用值的位置。原因、移动点、再次使用点,一眼就能看清。
解决办法因情况而异。如果之后还要用这个值,可以 (a) 让 consume 借用它而不是拿走所有权(下一节会讲),(b) 用 s.clone() 传一份深拷贝,或者 (c) 让 consume 把值还回来。通常正确答案是 (a)。
借用 — 不转移所有权也能访问
如果每个函数都要拿走值的所有权,编程就会变成地狱 — 光是量一下字符串长度,都得先转移所有权再要回来。所以才有了借用(borrowing):不拥有值,只是暂时借一个引用。语法是取地址符(&)。
fn length(s: &String) -> usize { // &String:“借用”字符串
s.len()
} // s 只是一个引用,所以这里不会被 drop(原始值原封不动)
fn main() {
let s = String::from("hello");
let n = length(&s); // &s:把 s 借出去
println!("{s} has length {n}"); // s 依然有效!
}
引用不拥有值,所以即使引用离开作用域,原始值也不会被 drop。length 结束之后,main 里的 s 依然完好无损。这就是借用的核心:所有权原地不动,只是暂时把访问权限借出去。
借用分两种。
- 不可变引用(
&T):只读。只能查看值,不能修改。 - 可变引用(
&mut T):可读可写。可以修改借用的值。
fn push_world(s: &mut String) { // 以可变方式借用
s.push_str(" world"); // 修改原始值
}
fn main() {
let mut s = String::from("hello"); // 必须声明为 mut 才能可变借用
push_world(&mut s); // 传入可变引用
println!("{s}"); // "hello world"
}
借用检查器 — &mut XOR & 规则
现在轮到 Rust 的核心了。借用检查器只对引用强制一条规则。理解了这条规则,检查器 90% 的报错都能解释清楚。
在任意时刻,针对某个特定的值,你只能拥有一个可变引用(
&mut),或者任意多个不可变引用(&),二者只能选一个,不能同时拥有。
这通常被称为"共享 XOR 可变(shared XOR mutable)",或者"aliasing XOR mutation"。正如异或(XOR)这个名字所暗示的,共享(多个 &)和修改(&mut)是互斥的。多个读者在读的时候,没有人能写;有人在写的时候,没有人能读(包括它自己)。
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 不可变引用 1
let r2 = &s; // 不可变引用 2 — OK,允许多个不可变引用
println!("{r1} and {r2}");
let r3 = &mut s; // 可变引用 — 上面的不可变引用不再使用后就 OK
r3.push_str(" world");
println!("{r3}");
}
如果在不可变引用还存活的时候尝试创建可变引用,就会报错。
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 不可变借用开始
let r2 = &mut s; // 错误!不可变借用期间不能进行可变借用
println!("{r1} {r2}");
}
这条规则为什么是必需的?是为了在编译期阻止数据竞争和迭代器失效(iterator invalidation)。如果某个线程正在读一个 vector,同时另一个线程(甚至是同一个线程)往里面加元素,就可能触发重新分配,原来读取的引用就会指向虚空。在 C++ 里,这会表现为运行时崩溃或悄无声息的数据损坏;而在 Rust 里,这种代码从一开始就不会通过编译。"只要存在一个可变引用,除了通过这个引用之外没有人能改动这个值"这条保证,让数据竞争的定义本身("没有同步的并发访问中,至少有一个是写")在编译期就变得不可能成立。
E0502 — 不可变借用与可变借用冲突
把上面的冲突放到真实的集合类代码里看看,是这样的。这是最常遇到的错误之一。
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // 不可变借用(指向一个元素)
v.push(4); // 可变借用(修改整个 vector)
println!("{first}");
}
error[E0502]: cannot borrow `v` as mutable because it is also
borrowed as immutable
--> src/main.rs:4:5
|
3 | let first = &v[0];
| - immutable borrow occurs here
4 | v.push(4);
| ^^^^^^^^^ mutable borrow occurs here
5 | println!("{first}");
| ------- immutable borrow later used here
这个错误看起来很烦人,但它其实正在阻止一个真正的 bug。如果 vector 容量不够,v.push(4) 会在堆上重新分配一块更大的缓冲区,把现有元素复制过去,再释放旧缓冲区。这样一来,first 指向的地址就成了已经释放的内存。在 C++ 的 std::vector 里,这正是那个著名的迭代器失效 bug,通常表现为悄悄读到错误的值,或者直接段错误。Rust 知道 first 的不可变借用一直存活到 println! 那一行,所以拒绝了中间这次 push。解决办法是等用完 first 之后再 push,或者每次都通过索引来访问。
顺带一提,得益于 Rust 2018 引入的 NLL(Non-Lexical Lifetimes),引用的生命周期不是到作用域的 } 为止,而是到最后一次被使用的位置为止。所以如果把上面的 println!("{first}") 删掉,代码就能编译通过 — 因为 first 不再被使用,借用就被视为已经结束了。
生命周期 — 引用不能比原始值活得更久
借用检查器的第二项任务,是阻止悬垂引用(dangling reference)。引用不能比它指向的值活得更久 — 否则它就会指向已经释放的内存。
fn main() {
let r;
{
let x = 5;
r = &x; // 借用 x
} // x 在这里被 drop
// println!("{r}"); // 错误!r 指向一个已经死掉的 x
}
Rust 知道 r 想要比 x 活得更久,于是拒绝了这段代码(错误代码 E0597,"x does not live long enough")。到这一步为止,编译器都能自行推断出来。但一旦跨越函数边界,就会出现编译器自己无法判断的情况。这时候我们就需要用生命周期标注来明确指出这种关系。
// 返回两个字符串切片中较长的那一个
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
这里出现的 'a(生命周期参数,读作"tick a")就是生命周期标注。它表达的意思是:"返回的引用,至少和 x、y 中活得更短的那个一样有效。"标注不会改变引用的实际生命周期,它只是把多个引用生命周期之间的关系告诉编译器。编译器利用这层关系,在调用处检查返回值的使用时间是否超过了它的来源。
为什么需要这个?站在编译器的角度,不看函数体的话,它没法知道 longest 的返回值到底来自 x 还是 y(而且类型检查只看函数签名)。既然两个参数的生命周期可能不同,就需要一种方式来表达返回引用到底有多长时间有效。'a 正是这种表达方式。
生命周期省略 — 大多数情况下不需要写
读到这里,如果你担心"那是不是每个引用都得写上 'a",好消息是不用。编译器有一套生命周期省略规则(elision rules),会在常见模式下自动推断出标注。所以在实践中,显式写生命周期的场合比想象中要少得多。
// 明确写出来的话:
fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }
// 得益于省略规则,这样写也是一样的:
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(i) => &s[..i],
None => s,
}
}
省略规则大致是这样的:(1) 每个输入引用都各自获得一个生命周期;(2) 如果输入引用正好只有一个,这个生命周期就赋给所有输出引用;(3) 如果方法里有 &self 或 &mut self,self 的生命周期就赋给所有输出。只要这些规则能消除歧义,我们就不需要手写。只有规则无法决定的情况(比如 longest 这种有两个输入引用的情况),才需要自己明确标注。
'static 生命周期也值得了解一下。&'static str 是一种存活于整个程序运行期间的引用,最典型的例子就是字符串字面量("hello")。因为字面量固化在二进制文件里,所以始终有效。
Copy 与 Clone — 不发生移动的类型
前面我们看到,String 的赋值是移动。但整数不一样。
fn main() {
let x = 5;
let y = x; // 不是移动,而是复制(copy)
println!("{x} {y}"); // 两者都有效!x 没有被移动
}
像 i32 这样的类型实现了 Copy trait。Copy 类型在赋值时不会发生移动,而是进行按位复制,原始值依然有效。为什么整数没问题,而 String 不行?整数是完全存在于栈上的固定大小的值,复制它的位不会共享任何堆资源,所以没有二重释放的风险。而 String 内部持有一个堆指针,按位复制会让两个所有者指向同一块堆内存 — 所以它不能是 Copy。
属于 Copy 的类型包括:所有整数和浮点数(i32、u64、f64)、bool、char,以及只由 Copy 类型组成的元组和数组((i32, i32)、[u8; 4])。规则可以概括为"不拥有堆资源、也没有实现 Drop 的纯栈值"。
Clone 则是显式的深拷贝。如果说 Copy 是自动的、隐式的、廉价的复制,那么 Clone 就是手动的、显式的,而且通常代价不小(往往包含堆分配)。
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // 连堆数据也整体复制一份(新的分配)
println!("{s1} {s2}"); // 两者都有效 — s1 没有被移动
}
.clone() 是绕开移动错误的简便出口,但滥用它是性能上的坏味道。每次调用都克隆一次堆字符串的代码,大多可以换成借用(&str)。如果你在新手阶段靠 .clone() 安抚过检查器,等熟悉之后把这些地方重构成引用,是很好的下一步。
总结一下,赋值时的行为分三种。如果类型是 Copy,就是自动复制(原始值有效);如果显式调用 Clone,就是深拷贝(原始值有效);两者都不是,就是移动(原始值失效)。
用手学会它
所有权、借用、生命周期是"读懂"和"用出来"之间差距特别大的主题。检查器到底为什么拒绝,终究要靠被拒绝好几次才能真正体会。在 Rust 学习实验室 里亲自编译移动、借用、生命周期相关的场景,练习阅读报错信息,这篇文章里的规则会更快地被你掌握。
最后附上一条实战建议。当你发现自己在和检查器搏斗时,别急着到处改代码,先通过错误代码读懂原因。是 E0382(移动后使用)、E0502(借用冲突),还是 E0597(生命周期不够),对应的处方完全不同。Rust 的错误信息大多会准确告诉你违反了什么、在哪里违反的、为什么违反。读懂这份"自白",比瞎猜快上一百倍。
结语
所有权、借用、生命周期一开始感觉像是障碍,但实际上,它们是编译器替你提前抓住了那些原本会在运行时(或者生产环境凌晨 3 点)爆炸的 bug。use-after-free、二重释放、数据竞争、迭代器失效 — 这些全都被"只有一个所有者、共享 XOR 可变、引用不能比原始值活得更久"这三条规则,在静态层面排除掉了。
与借用检查器的战斗终会结束。一旦理解了规则背后的原因,检查器就不再是敌人,而是结对编程的搭档。从那一刻起,Rust 就不再是"一门挑剔的语言",而是"一门在编译期替我指出错误的语言"。
参考资料
- The Rust Programming Language,第 4 章 "Understanding Ownership": https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
- The Rust Programming Language,10.3 节 "Validating References with Lifetimes": https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html
- Rust Reference — Ownership and destructors: https://doc.rust-lang.org/reference/destructors.html
- rustc 错误索引(E0382、E0502、E0597): https://doc.rust-lang.org/error_codes/error-index.html
- Rust by Example — Ownership and moves: https://doc.rust-lang.org/rust-by-example/scope/move.html