05. References & Borrowing — 引用与借用
在上一章中,我们学习了所有权和移动语义——值在赋值时转移所有权,源变量失效。但如果我们只是「看一下」某个值,并不想拿走所有权该怎么办?答案就是引用(reference):一种不拥有值的指针类型。创建引用称为借用(borrowing)——你借走使用,但最终必须归还给所有者。
引用是 Rust 在不牺牲性能的前提下实现「多处访问同一数据」的核心机制。没有引用,我们只能通过不断移动所有权来传递数据——代码会变得极其繁琐。
两种引用
Rust 提供了两种引用,对应两种访问模式:
| 引用类型 | 语法 | 访问权限 | 允许多个 | 是否 Copy |
|---|---|---|---|---|
| 共享引用 | &T | 只读 | 是 | 是 |
| 可变引用 | &mut T | 读写 | 否(独占) | 否 |
共享引用 &T
共享引用允许多个地方同时「看」同一个值,但谁都不能改:
let v = vec![1, 2, 3, 4, 5];
let r1 = &v; // 共享引用
let r2 = &v; // 可以同时存在多个共享引用
let r3 = &v;
println!("{:?} {:?} {:?}", r1, r2, r3); // 全部 OK
println!("{:?}", v); // v 仍可用,因为引用没有拿走所有权创建共享引用使用 & 运算符。因为是 Copy 类型,赋值共享引用时执行按位复制:
let r1 = &v;
let r2 = r1; // r1 被拷贝(不是移动),r1 仍可用
println!("{:?}", r1);可变引用 &mut T
可变引用提供对值的独占读写访问。同一时间只能存在一个可变引用:
let mut v = vec![1, 2, 3];
let r = &mut v; // 可变引用
r.push(4); // 通过引用修改 v
// let r2 = &v; // 错误:已有可变引用时不能创建共享引用
// let r3 = &mut v; // 错误:不能同时有两个可变引用
println!("{:?}", r); // [1, 2, 3, 4]可变引用不是 Copy——赋值时转移所有权(移动):
let mut x = 42;
let r1 = &mut x;
let r2 = r1; // r1 移动到 r2
// println!("{}", r1); // 错误:r1 已被移动借用规则
Rust 借用检查器的核心规则只有两条,但这两条规则消除了几乎所有内存安全问题:
- 共享访问是只读的访问。 在共享引用的生命周期内,它的引用对象(以及从引用对象可达的所有值)保持只读——不能赋值、不能移动。
- 可变访问是独占的访问。 在可变引用的生命周期内,它是访问引用对象的唯一路径——没有其他引用、没有所有者直接访问。
这两条规则等价于经典的「多读单写」约束:N 个读者或 1 个写者,不能同时存在。
编译器在编译时静态检查这些规则:
let mut v = vec![1, 2, 3];
// 规则 2 的例子:可变引用是独占的
let r = &mut v;
r.push(4);
// println!("{:?}", v); // 错误:v 已被可变借用,不能通过所有者直接访问
// 规则要求:在 r 的生命周期内,v 不可用
println!("{:?}", r); // OK:r 的生命周期在这里结束
println!("{:?}", v); // OK:r 已不在作用域内编译器的报错信息清晰而精确:
error[E0502]: cannot borrow `v` as immutable because it is also borrowed as mutable
--> src/main.rs:5:22
|
4 | let r = &mut v;
| ------ mutable borrow occurs here
5 | println!("{:?}", v);
| ^ immutable borrow occurs here
6 | println!("{:?}", r);
| - mutable borrow later used here借用冻结整个所有权树
共享引用不仅冻结被引用的值本身,还冻结从它可达的所有值:
struct Book {
title: String,
pages: u32,
}
let mut library: Vec<Book> = vec![
Book { title: String::from("Rust Book"), pages: 500 },
];
let first = &library[0]; // 共享引用 library[0]
// library[0].title = String::from("New Title"); // 错误:不能修改被冻结的值
// library[0].pages = 600; // 错误:同样被冻结
// library.push(Book { ... }); // 错误:library 本身也被冻结
// 因为 push 可能触发重新分配,使 first 悬垂
println!("{}", first.title); // OK这与 C 的 const 指针有本质区别:C 的 const int *p 只阻止通过 p 写入,但其他指针仍可修改同一内存。Rust 的 &T 保证在整个引用生命周期内,没有任何路径能修改该值。
与 C++ 引用的对比
| 特性 | Rust 引用 | C++ 引用 |
|---|---|---|
| 创建语法 | &x | 类型声明 int& r = x; |
| 解引用 | 显式 *r 或 . 自动解引用 | 隐式自动解引用 |
| 重新绑定 | r = &y 改变引用指向 | 不能重新绑定,赋值写入目标 |
| 可为空 | 否(可用 Option<&T>) | 否(但实践中可为空) |
| 编译期安全 | 借用检查器保证 | 无保证 |
| 默认可变性 | 不可变(需 &mut) | 可变的(需 const&) |
生命周期
生命周期(lifetime)是 Rust 编译器用来追踪引用有效范围的编译期概念——没有运行时表示,不影响生成的机器码。每个引用都有一个生命周期,编译器确保引用不会比它指向的值活得更久。
借用局部变量
最基本的生命周期约束:
{
let r;
{
let x = 1;
r = &x; // 错误:x 的生命周期不够长
}
// println!("{}", r); // r 在这里使用,但 x 已经不存在了
}编译错误:
error[E0597]: `x` does not live long enough
--> src/main.rs:5:13
|
4 | let x = 1;
| - binding `x` declared here
5 | r = &x;
| ^^ borrowed value does not live long enough
6 | }
| - `x` dropped here while still borrowed
7 | // println!("{}", r);
| - borrow later used here编译器对每个引用分配了一个生命周期,并验证两个约束:
- 引用的生命周期不能超过引用对象的生命周期。
- 存储引用的变量的生命周期必须被引用的生命周期覆盖。
当这两个约束不能同时满足时,编译失败。
引用作为函数参数
当函数接受引用参数时,编译器需要推断参数的生命周期:
// 这个函数编译不过
// fn f(p: &i32) {
// static mut STASH: &i32 = &128;
// unsafe { STASH = p; } // p 的生命周期可能不够长
// }函数签名揭示了其行为。上面的函数试图将参数存储到 static 变量中——这要求参数的生命周期至少和整个程序一样长。修复方式:
static mut STASH: &i32 = &128;
fn f(p: &'static i32) { // 签名表明:p 必须活到程序结束
unsafe { STASH = p; }
}'static 是特殊的生命周期名称,表示「整个程序执行期间」。&'static str 类型的字符串字面量(如 "hello")就属于此列。
生命周期参数
多个引用之间的生命周期关系通过生命周期参数表达:
// 返回两个字符串切片中较短的那个
fn smallest<'a>(v: &'a [String]) -> &'a String {
let mut s = &v[0];
for r in &v[1..] {
if r.len() < s.len() {
s = r;
}
}
s
}<'a> 声明了一个生命周期参数。v: &'a [String] 表示 v 的生命周期为 'a,-> &'a String 表示返回值的生命周期也为 'a——这意味着返回值从参数借用,不能比参数活得更久。
这个签名排除了悬垂引用:
let s;
{
let v = vec![String::from("hello"), String::from("world")];
s = smallest(&v);
// 错误:s 借用了 v,但 v 即将被释放
}
// s 在这里使用——但 v 已经不存在了生命周期省略(Lifetime Elision)
Rust 允许在明确的情况下省略生命周期标注。省略规则只有三条:
- 每个引用参数获得一个独立的生命周期。
- 如果只有一个输入生命周期,则它被赋给所有输出生命周期。
- 如果有多个输入生命周期,但一个是
&self或&mut self,则self的生命周期赋给所有输出生命周期。
这三条规则覆盖了绝大多数常见模式:
fn first(s: &str) -> &str { ... } // 规则 2:输出 = 输入
fn find(s: &str, c: char) -> Option<usize>; // 规则 1:无输出引用
impl Foo { fn get(&self) -> &Bar { ... } } // 规则 3:输出 = self包含引用的结构体
如果结构体包含引用字段,必须显式声明生命周期参数:
// 编译错误:缺少生命周期标注
// struct S {
// r: &i32
// }
// 正确:显式声明生命周期
struct S<'a> {
r: &'a i32,
}
// 使用
{
let x = 42;
let s = S { r: &x }; // s 的生命周期不能超过 x
println!("{}", s.r);
}生命周期参数使结构体的借用关系对读者和编译器都显式可见。
不同的生命周期参数
当结构体包含多个引用时,应该用不同的生命周期参数,而不是强制它们相同:
// 过于严格:强制 x 和 y 生命周期相同
// struct S<'a> { x: &'a i32, y: &'a i32 }
// 正确:x 和 y 可以有独立的生命周期
struct S<'a, 'b> {
x: &'a i32,
y: &'b i32,
}
let x = 10;
{
let y = 20;
let s = S { x: &x, y: &y };
println!("{} {}", s.x, s.y);
} // y 在这里释放
// x 仍然活着,s 在这里不能再使用了(因为 y 已被释放)这条原则同样适用于函数签名。
使用引用
引用的引用
Rust 允许引用链——. 运算符会自动「穿透」多层引用:
struct Point { x: i32, y: i32 }
let p = Point { x: 10, y: 20 };
let r = &p;
let rr = &r;
let rrr = &rr;
// . 运算符自动解引用多层
assert_eq!(rrr.x, 10); // 等价于 (***rrr).x
assert_eq!(rrr.y, 20);在 C++ 中,你需要 rrr->x 或者多次 * 解引用。Rust 的 . 运算符帮你「看穿」任意深度的引用。
比较引用
比较运算符同样「看穿」引用。你比较的是值,而非地址:
let x = 10;
let y = 10;
let rx = &x;
let ry = &y;
assert!(rx == ry); // 比较值:true(都是 10)
assert!(rx < &20); // 比较值:true
assert!(rx == &&10); // 多层引用穿透比较
// 如果要比较地址,使用 std::ptr::eq
assert!(!std::ptr::eq(rx, ry)); // 不同地址引用永不为空
Rust 引用不能为 null。如果需要「可能没有值」的语义,使用 Option<&T>:
fn find_item(items: &[String], target: &str) -> Option<&String> {
for item in items {
if item == target {
return Some(item);
}
}
None
}
// 编译器强制你处理 Option
match find_item(&items, "apple") {
Some(item) => println!("Found: {}", item),
None => println!("Not found"),
}在底层,Option<&T> 被优化为和裸指针相同的大小:None 就是全零的 null 指针,Some(r) 就是非零地址。Rust 保证引用永远不会为空,这个优化是安全的。
借用任意表达式的引用
Rust 允许对任意左值表达式取引用,编译器会创建匿名变量来存储中间结果:
let v = vec![1, 2, 3];
let r = &v[1]; // 借用 v 的第二个元素
// 编译器为 v[1] 创建了一个匿名变量,其生命周期与 r 相同
// 也可以借用临时值
let num = &(1 + 2); // 创建匿名变量存储 3,取它的引用
assert_eq!(*num, 3);胖指针:切片和 Trait 对象的引用
对切片的引用是胖指针(fat pointer)——包含起始地址和长度:
let v = vec![1, 2, 3, 4, 5];
let slice: &[i32] = &v[1..4]; // 胖指针:(地址指向 v[1], 长度 3)
// 占用两个 word:指针 + 长度
println!("size of &[i32]: {}", std::mem::size_of::<&[i32]>()); // 16(64位系统上)
println!("size of &i32: {}", std::mem::size_of::<&i32>()); // 8对 Trait 对象的引用也是胖指针——包含数据指针和虚表(vtable)指针。这些内容将在后续章节详细介绍。
引用与函数式编程
借用让 Rust 的函数式风格代码变得自然。以处理表格数据为例:
use std::collections::HashMap;
type Table = HashMap<String, Vec<String>>;
// 接受共享引用——不获取所有权,只是「看一下」
fn show(table: &Table) {
for (artist, works) in table {
// works 自动成为 &Vec<String>
println!("works by {}:", artist);
for work in works {
// work 自动成为 &String
println!(" {}", work);
}
}
}
// 接受可变引用——需要修改数据
fn sort_works(table: &mut Table) {
for (_artist, works) in table.iter_mut() {
works.sort();
}
}
fn main() {
let mut table = Table::from([
("Chopin".to_string(),
vec!["Nocturne No.2".to_string(), "Ballade No.1".to_string()]),
("Liszt".to_string(),
vec!["La Campanella".to_string(), "Hungarian Rhapsody".to_string()]),
]);
show(&table); // 共享借用
sort_works(&mut table); // 可变借用
show(&table); // 可变借用结束后,可以再次共享借用
}注意 show() 接收 &Table 而非 Table——调用者保留所有权,可以在 show() 之后继续使用 table。如果 show() 签名为 fn show(table: Table),调用后 table 就会被消耗。
与所有权系统的协同
借用和所有权共同构成了一套完整的资源管理方案。考虑以下场景:
// 无法从共享借用的 Vec 中移出元素
let v = vec![String::from("a"), String::from("b")];
let r = &v;
// let s = r[0]; // 错误:不能通过共享引用移出值
// let t = v[0]; // 错误:v 已被共享借用,不能直接访问
println!("{}", r[0]); // OK:只读访问共享引用阻止了所有对引用对象的写操作——包括移动。这保证了在共享引用存在期间,数据不会被意外修改或销毁。
Rust 让你远离的对象之海
很多 GC 语言鼓励「对象之海」架构——对象互相引用形成复杂的网状图结构。这在 Rust 中几乎不可能(或极其困难):
// GC 语言常见模式(在 Rust 中很难实现)
// 对象 A → 对象 B → 对象 C
// ↑ ↓ |
// └─────────┘ ←───────┘
// (循环引用!)Rust 强制你选择更清晰的所有权结构——通常是树:
// Rust 推荐的所有权树
// 根对象
// ├── 子对象 1
// │ ├── 叶子 A
// │ └── 叶子 B
// └── 子对象 2
// └── 叶子 C数据流按单方向从根到叶。如果需要循环引用,必须使用 Rc<RefCell<T>>(引入运行时检查),成本明显。这不是 Rust 的缺陷——它迫使你在设计阶段就理清数据关系,而非在未来调试悬垂指针和循环泄漏。
小结
- 两种引用:
&T共享只读(Copy),&mut T可变独占(非 Copy)。借用规则等价于「N 个读者或 1 个写者」。 - 借用检查器在编译时静态验证所有引用。共享引用冻结整个所有权树,可变引用独占访问路径。
- 生命周期是编译期概念,无运行时开销。编译器确保引用的生命周期不超过引用对象的生命周期。
- 生命周期省略三条规则覆盖了绝大多数场景,只在结构体持有引用或函数在不同输入间选择输出时需要显式标注。
- 引用永不为空,用
Option<&T>表示可空语义,存储上与裸指针等大。 - 胖指针:切片引用携带地址+长度,Trait 对象引用携带地址+虚表指针。
- Rust 用借用+所有权引导你走向清晰的树状所有权结构,而非难以追踪的对象网状图。