Rust 核心解密:驯服“野牛”!一文搞懂借用与引用的生死规则

摘要:为什么明明只是打印一个变量,编译器却报错?为什么可变引用不能复制?本文将深入 Rust 的借用机制,揭开不可变引用与可变引用的“互斥”真相,带你真正驯服这头内存安全的野牛。

在上节课中,我们认识了 Rust 的灵魂——所有权(Ownership)。我们知道,当所有权发生转移(Move)时,原变量就会失效。但这带来了一个巨大的麻烦:如果我想把数据传给函数用一下,但之后还想继续用它,难道每次都要把所有权传进去再传出来吗?

这太繁琐了。为了解决这个问题,Rust 引入了**借用(Borrowing)**的概念。

今天,我们将深入探讨 Rust 中最具挑战性、也最精妙的设计:引用(Reference)与借用规则。准备好你的键盘,我们要开始驯服这头“野牛”了。

01. 什么是借用?

在现实生活中,如果你有一本书,朋友想看看,你可以借给他。书还是你的,他只是暂时持有。

在 Rust 中:

  • 借用(Borrowing):是行为。你把资源借给别人用。
  • 引用(Reference):是工具。别人手里拿到的那个“借条”,就是引用。

在 Rust 代码中,使用 & 符号创建引用:

1
2
let a = 10u32;
let b = &a; // b 是 a 的引用

引用的本质

引用本身也是一个,而且是一个固定尺寸的值(通常等于机器字长,如 64 位)。因此,引用可以被随意复制和赋值,而不会触发所有权的移动。

1
2
3
4
let s1 = String::from("Hello");
let s2 = &s1;
let s3 = &s1;
let s4 = s2; // 引用之间的赋值,只是复制了指针,没有移动字符串所有权

此时,s1 依然拥有字符串的所有权,s2, s3, s4 只是指向它的“观察者”。

02. 不可变引用 vs 可变引用

Rust 将引用分为两类,这与变量的可变性一脉相承:

  1. 不可变引用 (&T):默认类型。只能读,不能写。就像借书给朋友,规定“只能看,不能涂改”。
  2. 可变引用 (&mut T):需要显式声明。既能读,也能写。就像借书给朋友,允许他“做笔记”。

如何使用可变引用?

要修改通过引用指向的值,必须满足两个条件:

  1. 原始变量必须声明为 mut
  2. 引用必须声明为 &mut
  3. 解引用时使用 * 操作符。
1
2
3
4
5
6
fn main() {
let mut a = 10u32; // 1. 变量可变
let b = &mut a; // 2. 可变引用
*b = 20; // 3. 解引用修改
println!("{}", a); // 输出: 20
}

03. 借用检查器的三条铁律

这是 Rust 初学者最容易撞墙的地方。Rust 编译器(借用检查器)为了保证内存安全和数据竞争自由,强制执行以下三条规则:

规则一:同一时刻,要么有多个不可变引用,要么只有一个可变引用

不可变引用可以共存

1
2
3
let s = String::from("Hello");
let r1 = &s;
let r2 = &s; // ✅ 合法,大家只读不写,很安全

可变引用具有排他性

1
2
3
let mut s = String::from("Hello");
let r1 = &mut s;
let r2 = &mut s; // ❌ 错误!cannot borrow `s` as mutable more than once at a time

不可变与可变不能共存

1
2
3
4
let mut s = String::from("Hello");
let r1 = &s; // 不可变引用
let r2 = &mut s; // ❌ 错误!cannot borrow `s` as mutable because it is also borrowed as immutable
println!("{}", r1);

💡 为什么?
如果允许同时存在不可变和可变引用,当可变引用修改了数据时,不可变引用持有的数据就变成了“脏数据”或“悬空指针”,导致未定义行为。Rust 在编译期就杜绝了这种数据竞争(Data Race)。

规则二:引用的作用域是从定义处到最后一次使用处

这是 Rust 1.31 版本引入的非词法生命周期(NLL, Non-Lexical Lifetimes)特性。引用的作用域不再仅仅看花括号,而是看你最后在哪里用了它

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let mut s = String::from("Hello");

let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
// r1 和 r2 在这里最后一次使用,它们的作用域到此结束

let r3 = &mut s; // ✅ 合法!因为 r1, r2 已经“死”了,不再占用借用
println!("{}", r3);
}

如果没有 NLL,上面的代码在传统词法作用域下会报错,因为 r1r2 的作用域被认为一直延伸到块末尾。现在,Rust 足够聪明,知道它们用完就可以释放借用了。

规则三:只要有引用存在,所有者就不能直接修改数据

1
2
3
4
let mut a = 10;
let r = &a;
a = 20; // ❌ 错误!cannot assign to `a` because it is borrowed
println!("{}", r);

即使 r 是不可变引用,只要它还存在,编译器就不允许你通过所有者 a 去修改数据,以防止 r 看到不一致的状态。

04. 可变引用的“移动”语义

这是一个容易混淆的点:不可变引用可以 Copy,但可变引用只能 Move。

1
2
3
4
5
6
let mut a = 10;
let r1 = &mut a;
let r2 = r1; // r1 的所有权(独家代理权)移动给了 r2

println!("{}", r2); // ✅
println!("{}", r1); // ❌ 错误!value borrowed here after move

可变引用被视为资源的独家代理。既然同一时刻只能有一个可变引用,那么当你把 r1 赋值给 r2 时,实际上是把“独家修改权”移交给了 r2r1 随即失效。

05. 多级引用与解引用

Rust 支持多级引用,但在修改时需要小心解引用的层数。

1
2
3
4
5
6
let mut a = 10;
let b = &mut a;
let c = &mut b; // c 是对 b 的可变引用

**c = 30; // ✅ 需要两级解引用才能修改到 a
println!("{}", c); // 自动解引用,输出 30

注意:如果引用链中任何一环是不可变引用,则最终无法通过该链修改数据。

1
2
3
4
let mut a = 10;
let b = &mut a;
let c = &b; // c 是对 b 的不可变引用
// ***c = 30; // ❌ 错误!cannot assign through a & reference

06. 用引用优化函数参数

回到开头的问题,使用引用可以让函数签名更清晰,性能更高效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 接收不可变引用,承诺不修改
fn print_length(s: &String) {
println!("Length: {}", s.len());
}

// 接收可变引用,承诺可能修改
fn append_world(s: &mut String) {
s.push_str(" World");
}

fn main() {
let mut s = String::from("Hello");
print_length(&s); // 传入不可变引用
append_world(&mut s); // 传入可变引用
println!("{}", s); // Hello World
}

这种方式避免了所有权的转移,调用者依然保留对 s 的控制权。

07. 总结与心法

Rust 的借用规则初看繁琐,实则严谨。请记住以下心法:

  1. 读写分离:要么大家一起读(多个 &),要么一个人写(单个 &mut),绝不能混用。
  2. 尽早释放:引用的作用域取决于最后一次使用。尽早结束引用的使用,可以给后续的可变借用腾出空间。
  3. 独占代理:可变引用是独家的,赋值即移动。
  4. 安全第一:所有这些限制,都是为了在编译期消除数据竞争和悬空指针,让你写出无 Bug 的并发代码。