Skip to content
Published on

Rust 所有权、借用与生命周期完全指南

分享
Authors

引言 — 没有 GC 也能保证内存安全

大多数语言用两种方式之一来获得内存安全。要么像 C、C++ 那样把一切都交给程序员(然后收获 use-after-free、二重释放、数据竞争),要么像 Java、Go、Python 那样交给垃圾回收器(然后收获运行时开销和不可预测的停顿)。

Rust 走的是第三条路。它在编译期追踪内存和资源的生命周期,让 use-after-free 和数据竞争在没有 GC 的情况下也根本编译不过。这套魔法背后是三组规则:所有权(ownership)借用(borrowing)生命周期(lifetimes)。理解了这三者,Rust 剩下的大部分内容都会自然地跟上来。反过来说,与这三者搏斗的那段时间,正是那个臭名昭著的"与借用检查器搏斗"。

这篇文章的目标,是尽快结束这场战斗。与其死记硬背规则,不如理解为什么会有这样的规则 — 这样一来,检查器为什么发火就开始看得清楚了。

所有权的三条规则

Rust 官方书籍用三句话总结了所有权。

  1. Rust 中的每个值都有一个所有者(owner)。
  2. 同一时刻只能有一个所有者。
  3. 当所有者离开作用域时,值会被丢弃(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 已经不再有效
}

如果你熟悉其他语言,大概会预期 s1s2 是指向同一字符串的两个名字。但那样就违反了规则 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 依然有效!
}

引用不拥有值,所以即使引用离开作用域,原始值也不会被 droplength 结束之后,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")就是生命周期标注。它表达的意思是:"返回的引用,至少和 xy 中活得更短的那个一样有效。"标注不会改变引用的实际生命周期,它只是把多个引用生命周期之间的关系告诉编译器。编译器利用这层关系,在调用处检查返回值的使用时间是否超过了它的来源。

为什么需要这个?站在编译器的角度,不看函数体的话,它没法知道 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 的类型包括:所有整数和浮点数(i32u64f64)、boolchar,以及只由 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 就不再是"一门挑剔的语言",而是"一门在编译期替我指出错误的语言"。

参考资料