📚 Rust 课程系列

  1. 课程概览
  2. 基础语法
  3. 函数与输入输出
  4. 所有权、借用与生命周期(本文)
  5. 结构体、枚举与模式匹配
  6. 类型系统:泛型、trait 与多态
  7. 集合与容器
  8. 错误处理与 Panic 恢复
  9. 模块、属性与宏
  10. 智能指针、迭代器与闭包
  11. 并发与异步编程
  12. Unsafe Rust 与常用 trait 详解
  13. 工具链、Cargo 与外部 crate
  14. 最佳实践、性能与调试

所有权(ownership)、借用(borrowing)与生命周期(lifetime)是 Rust 最核心、也最与众不同的特性。它们让 Rust 在没有垃圾回收、也不需要手动 free 的前提下,于编译期保证内存安全与无数据竞争。本章先建立所有权的直觉,再讲清借用检查器的规则,最后引入生命周期注解——这是理解后续智能指针、并发、异步等章节的基础。

所有权与借用

所有权的基本规则

规则说明示例
唯一所有者每个值有且仅有一个所有者,离开作用域时自动释放let s = String::from("hi");
移动语义赋值/传参时所有权转移,原变量失效let s2 = s1; —— s1 不再可用
复制语义Copy 类型赋值时按位复制,原变量仍有效let y = x;x: i32
1
2
3
4
5
6
let s1 = String::from("hello");
let s2 = s1; // s1 失效——所有权移动
// println!("{}", s1); // 错误:s1 已移动

let x = 5;
let y = x; // i32 实现了 Copy,x 仍有效

💡 提示:为什么 String 赋值后原变量失效?String 在栈上存放指针/长度/容量,堆上存放实际数据。let s2 = s1; 只复制了栈上部分——若两者同时有效,就会指向同一块堆内存,离开作用域时 double free。Rust 用移动语义在编译期杜绝此问题,零运行时开销。

🔄 对比:Rust 的移动语义 ≈ C++ 的 std::unique_ptr 语义,但 Rust 编译器强制执行——你无法意外复制。Go/Java 的赋值总是复制引用(或值),不存在"移动"概念。

💡 提示:判断能否 Copy 的简单规则——若类型销毁时需要特殊清理(如 String 要释放堆内存),就不能 Copy;标量、不可变引用、全是 Copy 字段的结构体可以 Copy

函数与所有权

传参和返回同样遵循移动/复制语义,让「谁负责释放」始终清晰:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn takes_ownership(s: String) { /* s 在此释放 */ }
fn makes_copy(n: i32) { /* n 是 Copy,无事发生 */ }
fn gives_ownership() -> String { String::from("hello") }

let s = String::from("hello");
takes_ownership(s); // s 移动进函数,此后 s 不可用
// println!("{}", s); // 错误

let x = 5;
makes_copy(x); // x 是 Copy,仍可用
println!("{}", x); // 5

let s2 = gives_ownership(); // 返回值移动给 s2

💡 提示:如果函数只是需要「读一下」而不想拿走所有权,每次都先拿走再返回很笨重——这就是借用的意义。

借用

使用 & 创建不可变引用,&mut 创建可变引用。引用不拥有数据,只是「指向」某个值:

1
2
3
4
5
6
fn calculate_length(s: &String) -> usize { s.len() }
// s 离开作用域,但不拥有所有权,什么都不发生

let s = String::from("hello");
let len = calculate_length(&s);
println!("'{}' 的长度是 {}", s, len); // s 依然可用

要修改借用的值,用 &mut T,但原变量本身也得是 mut

1
2
3
4
5
fn push_world(s: &mut String) { s.push_str(", world"); }

let mut s = String::from("hello");
push_world(&mut s);
println!("{}", s); // hello, world

🔄 对比&T ≈ C++ 的 const T&&mut TT&。但 Rust 编译器额外保证:同一时间要么有多个 &T,要么有且仅有一个 &mut T,从源头杜绝数据竞争。

借用检查器规则

规则总结

  1. 任何借用必须在其引用的值的生命周期内有效
  2. 可以同时存在多个不可变借用(&T
  3. 不能同时存在可变借用(&mut T)和任何其他借用
1
2
3
4
5
6
7
let mut s = String::from("hello");
let r1 = &s; // 不可变借用
let r2 = &s; // 另一个不可变借用 — 允许
// let r3 = &mut s; // 错误:不能同时有可变借用
println!("{} {}", r1, r2);
// r1 和 r2 在这里不再使用
let r3 = &mut s; // 现在允许 — 之前的借用已结束

💡 提示:上面 r1r2println! 之后就「不再使用」了,因此 r3 = &mut s 可以通过。这说明 Rust 的借用并非严格按词法作用域结束,而是采用 非词法生命周期(NLL):引用的「活跃区间」只到它最后一次被使用为止。

⚠️ 注意:在持有不可变借用时试图修改数据是典型报错场景:

1
2
3
4
let mut v = vec![1, 2, 3];
let first = &v[0]; // 不可变借用 v
v.push(4); // 错误:v 的可变借用与 first 的不可变借用冲突
println!("{}", first); // first 还要用,所以借用没结束

原因:push 可能触发扩容并把堆数据搬到新地址,此时 first 指向的旧地址就悬空了。修复方式是把不可变借用的使用提前到可变操作之前:

1
2
3
4
let mut v = vec![1, 2, 3];
let first = v[0]; // 直接拷贝出值,不再借用 v
v.push(4); // 没问题
println!("{}", first);

可变引用还有一个限制:同一时间只能有一个可变引用,从源头杜绝数据竞争:

1
2
3
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // 错误:不能同时有两个可变借用

⚠️ 注意:遍历容器同时修改(如 for item in &vec { vec.push(...); })会被编译器拒绝——这是借用冲突最常见的场景之一。优先收集修改操作到遍历之后,或使用索引遍历。

💡 提示:函数参数设计原则——不需要修改用 &T,需要修改用 &mut T,需要获得所有权(如构建返回值)传 T。对大型结构频繁 clone() 有性能开销,优先考虑借用或 Rc/Arc

非词法生命周期(进阶)

NLL 自 Rust 2018 edition 起成为默认行为。在旧版词法生命周期(lexical lifetimes)下,引用的存活区间等于整个作用域——即使引用在作用域中途就不再使用,借用检查器也会认为它一直活跃到作用域末尾。NLL 将引用的存活区间缩短到最后一次使用点,使很多在词法模型下被拒的代码合法化:

1
2
3
4
5
// NLL 允许,词法生命周期会拒绝
let mut s = String::from("hello");
let r = &s;
println!("{}", r); // r 最后一次使用
s.push_str(", world"); // r 已不再活跃,可变借用 OK

🔬 进阶:NLL 的实现基于「生命周期即约束」模型——编译器先收集所有约束('a: 'b 表示 'a 至少和 'b 一样长),再求解最小可行生命周期,而非简单地将引用绑定到词法作用域。

生命周期

为什么需要生命周期

引用必须保证它指向的数据在引用使用期间一直有效,否则就是「悬垂引用」。大多数时候编译器能自动推断,但当函数返回引用时,编译器无法仅凭签名判断返回的引用和哪个参数有关联——这时就需要生命周期注解。

生命周期注解用 'a 表示,它不改变引用的实际存活时间,只是描述多个引用之间的约束关系:「返回的引用至少和入参 xy 中较短的那个一样长」。

1
2
3
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}

'a 表示:返回值的生命周期等于 xy 生命周期中较短的那个。调用方据此就知道返回值不能比 xy 活得更久。

⚠️ 注意:不要返回对局部变量的引用——编译器会直接拒绝:

1
2
3
4
fn dangle() -> &String { // 错误:缺少生命周期注解,且会悬垂
let s = String::from("hello");
&s
} // s 在这里被释放,&s 指向的内存已失效

正确做法是直接返回所有权:fn no_dangle() -> String { String::from("hello") }

💡 提示:当生命周期签名复杂时,考虑返回拥有所有权的类型(如 String)或使用 Arc/Cow 来简化 API。在接口设计中尽量让调用方持有所有权或明确借用关系,减少不必要的生命周期注解暴露。

生命周期省略规则

Rust 编译器会自动应用三条规则来省略生命周期注解:

规则说明
规则 1每个引用参数都有自己的生命周期参数
规则 2如果只有一个输入生命周期参数,它被赋予所有输出生命周期参数
规则 3如果有多个输入生命周期参数,但其中一个是 &self&mut self,则 self 的生命周期被赋予所有输出生命周期参数
1
2
3
4
5
6
7
8
9
10
// 编译器可以自动推断,无需显式生命周期注解
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}

按规则 1,s: &str 被看作 s: &'a str;按规则 2,唯一的输入生命周期 'a 被赋给输出,于是签名等价于 fn first_word<'a>(s: &'a str) -> &'a str,无需手写。当三条规则都无法确定输出生命周期时,编译器才要求你显式标注(如 longest 有两个输入引用却没有 self,规则 3 不适用,故必须手写)。

结构体中的生命周期

结构体若持有引用,必须标注生命周期,表明该引用的数据必须比结构体活得久:

1
2
3
4
5
6
7
8
9
struct ImportantExcerpt<'a> {
part: &'a str,
}

let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt { part: first_sentence };
// i 不能比 novel 活得久,否则 part 会悬垂
println!("{}", i.part);

🔄 对比:C++ 中类持有引用不需要显式标注生命周期,但可能产生悬垂引用(运行时 UB);Rust 在编译期就强制你声明约束关系。

静态生命周期

1
let s: &'static str = "I have a static lifetime.";

'static 表示引用在程序整个运行期间都有效。所有字符串字面量都是 &'static str,因为它们直接被编译进二进制的只读数据段。'static 是所有生命周期的「上界」:'static 引用可以满足任意 'a 的约束,因此常出现在需要「任意生命周期」的地方。

⚠️ 注意:不要为了消除编译错误而随手给参数标 'static,这往往掩盖了真正的设计问题(本该返回拥有所有权的 String 却返回了引用)。真正需要 'static 的场景包括:全局常量、std::thread::spawn 闭包的默认约束等。

生命周期与型变(variance)、'static 约束的更深入讨论,见《类型系统》一章。