Ch 3 · Borrowing 与 Lifetime —— 编译器是结对程序员
"Lifetimes are the most distinctive feature of Rust. They're also the most misunderstood."
Ch 2 我们说 ownership 是"每个值有一个 owner,owner 出作用域时 drop"。这一章我们处理一个直接派生的问题:如果某个函数想暂时使用一个值,但不想接管所有权,怎么办?
答案是 borrowing(借用)。但借用不是免费的——为了保证安全,Rust 引入了一组借用规则,和一个叫 lifetime 的概念。
读完这章,你应该能:
- 说清楚
&T和&mut T为什么必须互斥 - 看到
'a标注时不再恐慌——你知道它在告诉编译器什么 - 读懂 Rust 编译器最常见的 5 种借用报错
- 给一个函数签名,判断它的生命周期标注是否合理
- 知道
'static的真实含义,不再误用
3.1 为什么需要借用?
先看一个看似无害的需求。我们有一个大 Vec<String>,想写个函数计算其中字符串的总长度:
fn total_length(strs: Vec<String>) -> usize { strs.iter().map(|s| s.len()).sum() } fn main() { let v = vec![ String::from("hello"), String::from("world"), ]; let n = total_length(v); println!("{}", n); // println!("{:?}", v); // ❌ v 已经被 move 进 total_length }
这个签名 fn total_length(strs: Vec<String>) 有问题——它接管了所有权。调用一次就消费一次,调用方再也用不了 v 了。
但语义上,total_length 只是想读一下,根本不需要拥有这个 Vec。这正是借用要解决的事情:
fn total_length(strs: &Vec<String>) -> usize { strs.iter().map(|s| s.len()).sum() } fn main() { let v = vec![ String::from("hello"), String::from("world"), ]; let n = total_length(&v); println!("{}", n); println!("{:?}", v); // ✅ v 还能用 }
&Vec<String> 是一个共享借用——函数借一下看看,不影响所有权。
(回顾 Ch 2:这里更地道的写法是 &[String] 而不是 &Vec<String>。但本章为了讲清借用本身,我们暂时用后者(&Vec<String>)。)
借用 ≠ 指针
重要心智迁移:
如果你来自 Go 或 C,可能想把 &T 理解成"指针"。表面看是,但 Rust 的引用比 C 指针多一组编译期约束:
| 维度 | C 指针 T* | Rust 引用 &T / &mut T |
|---|---|---|
| 可以为 NULL | ✅ | ❌(永远指向有效值) |
| 可以悬垂(指向已释放内存) | ✅ | ❌(编译期保证不悬垂) |
| 可以同时存在多个写指针 | ✅ | ❌(&mut T 必须独占) |
| 可以从越界数组偏移 | ✅(undefined behavior) | ❌ |
| 编译期类型检查 | 弱 | 强 |
Rust 的引用是"加了护栏的指针"。在汇编层面,&T 就是一个指针;在源码层面,它带着一组编译期保证。
这一节的关键 takeaway
借用是 ownership 的补丁——让函数可以"暂时使用"一个值而不接管所有权。&T 是共享借用,&mut T 是独占借用。下面我们详细说这两个为什么必须互斥。
3.2 借用规则:Aliasing XOR Mutability
Rust 的借用规则,标准表述是:
在任意时刻,一个值要么:
- 有任意多个共享借用
&T(可以读,不能写)- 或者有且仅有一个独占借用
&mut T(可以读写)二者不能并存。
学术界把这条规则叫 "Aliasing XOR Mutability"——别名(多个引用指向同一个值)和可变性,二者只能取其一。
为什么这条规则存在?
不是 Rust 设计者的洁癖。这条规则解决三个真实的工程问题。
问题 1:数据竞争(data race)
如果一个值可以被多个线程同时读写,就有 data race。data race 是 undefined behavior,可能产生任何错误结果。
// Go 里的 data race(运行时才能检测)
var counter int = 0
go func() { counter++ }()
go func() { counter++ }()
// 两个 goroutine 同时写 counter,结果未定义
Rust 的借用规则在编译期就拦住:你不可能同时持有两个 &mut counter。要跨线程共享可变状态,必须用 Mutex 或 Atomic,把同步显式化。
问题 2:迭代失效(iterator invalidation)
C++ 经典 bug:
std::vector<int> v = {1, 2, 3};
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it == 2) {
v.push_back(4); // 可能触发 vector 扩容,it 失效
}
// 下一次循环用 it,可能崩溃
}
Rust 的借用规则直接拦掉这种代码:
let mut v = vec![1, 2, 3];
for x in v.iter() { // 这里有一个 &v 借用
if *x == 2 {
v.push(4); // ❌ 错误:cannot borrow v as mutable because it is also borrowed as immutable
}
}
v.iter() 持有 &v,在 for 循环内一直存活;v.push(4) 需要 &mut v,违反规则,编译期拦下。
问题 3:别名优化(aliasing-based optimization)
现代 CPU 的优化假设"如果两个指针类型不同,它们指向的内存不重叠"。C 编译器有 restrict 关键字让你声明"这个指针没有别名,放心优化"。但程序员经常用错,产生 undefined behavior。
Rust 的借用规则等于天然的 restrict:&mut T 保证整个值在那段时间只通过这一个引用访问,编译器可以激进优化。
借用规则的实际形态
多个共享借用 OK
#![allow(unused)] fn main() { let s = String::from("hello"); let r1 = &s; let r2 = &s; let r3 = &s; println!("{} {} {}", r1, r2, r3); // ✅ 都是只读 }
一个独占借用 OK
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r = &mut s; r.push_str(" world"); println!("{}", r); // ✅ }
共享 + 共享 OK,共享 + 独占 NO
let mut s = String::from("hello");
let r1 = &s; // 共享借用
let r2 = &mut s; // ❌ 错误:cannot borrow as mutable because it is also borrowed as immutable
println!("{} {}", r1, r2);
独占 + 独占 NO
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // ❌ 错误:cannot borrow as mutable more than once
println!("{} {}", r1, r2);
NLL:借用提前结束
早期 Rust 的借用作用域是"词法作用域"——借用一直活到所在的 {} 结束。这导致很多本应合法的代码被拒绝。Rust 2018 引入 NLL(Non-Lexical Lifetimes),借用的实际作用域是"最后一次使用之后立刻结束"。
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; println!("{} {}", r1, r2); // r1, r2 在此最后使用 // NLL 后:r1, r2 的借用在此结束 let r3 = &mut s; // ✅ 现在可以独占借用 r3.push_str(" world"); println!("{}", r3); }
如果没有 NLL,这段代码会被拒绝(r1/r2 的借用被认为活到 } 结束)。NLL 之后这是合法代码。
工程含义:Rust 编译器比你想的聪明。很多"借用冲突"其实可以靠重排代码顺序解决,而不是放弃 borrow 改成 clone。
这一节的关键 takeaway
借用规则不是约束,是定理——它让一大类并发 bug、迭代失效、别名问题在编译期就消失。你接受了这条规则,获得的是 fearless concurrency 和零成本优化。
3.3 Lifetime:'a 在标注什么?
到现在为止,我们写的借用都是 &T / &mut T,没有 'a。但 Rust 标准库里到处是这样的签名:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { /* ... */ }
这个 'a 是什么?
Lifetime 是关于"引用何时失效"的标注
简单事实:任何引用都有一个有效区间——从引用创建,到引用最后一次使用。这个区间就叫这个引用的 lifetime。
编译器的工作是检查:引用使用时,被引用的值是否还活着?
考虑这段代码:
fn main() {
let r;
{
let x = 5;
r = &x;
} // x 在这里 drop
println!("{}", r); // ❌ r 引用了一个已被 drop 的值
}
编译器报:
error[E0597]: `x` does not live long enough
|
4 | r = &x;
| ^^ borrowed value does not live long enough
5 | }
| - `x` dropped here while still borrowed
6 | println!("{}", r);
| - borrow later used here
编译器的推理:r 的使用要求 x 还活着,但 x 在更内层的作用域结束就 drop 了。冲突。
单个函数内,编译器自动推导
上面的例子,编译器自己就能推导出生命周期。你不用写 'a。
那 'a 什么时候必须写?
'a 在跨函数边界时显现
考虑这个函数:
#![allow(unused)] fn main() { fn first_word(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &b) in bytes.iter().enumerate() { if b == b' ' { return &s[..i]; } } s } }
返回值 &str 是从参数 s 派生出来的——它指向 s 内部的一段。返回的引用必须活得不久于 s,否则就是悬垂引用。
但编译器看到这个签名时不知道这个事实。它怎么知道返回的 &str 应该跟 s 关联,而不是凭空冒出来的?
答案是:生命周期标注。完整签名应该是:
fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }
读法:"对于任意生命周期 'a,这个函数接受一个生命期为 'a 的 &str,返回一个生命期同为 'a 的 &str。"
这个签名告诉调用方:返回的引用不能比 s 活得久。
Lifetime elision:大多数情况你不用写
但你刚才看到 first_word 的版本里,我们写的是 fn first_word(s: &str) -> &str,没写 'a,也能编译。
这是 Rust 的 lifetime elision rules(生命周期省略规则)在帮你。规则有三条:
- 每个引用参数有它自己的 lifetime
- 如果只有一个输入 lifetime,所有输出 lifetime 都跟它一致
- 如果有多个输入 lifetime,但其中一个是
&self或&mut self,所有输出 lifetime 跟self一致
first_word(s: &str) -> &str 满足规则 2,自动推导成 <'a>(s: &'a str) -> &'a str。
什么时候规则覆盖不到、必须手动写?
必须手动写 'a 的场景
多个引用参数,返回值跟其中一个绑定
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
编译器拒绝:返回值应该跟 x 还是 y 的 lifetime 绑定? 编译器不知道。
显式标注:
#![allow(unused)] fn main() { fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } }
'a 是 x 和 y 中较短的那个 lifetime——编译器取交集。返回值不会比 x 或 y 中任何一个活得久。
struct 含引用字段
#![allow(unused)] fn main() { struct Excerpt<'a> { part: &'a str, } }
含引用的 struct 必须标注 lifetime。意思是"这个 Excerpt 不能比 part 引用的字符串活得久"。
返回值需要不同于输入的 lifetime
少见,但存在:
#![allow(unused)] fn main() { fn longest_with_announcement<'a, 'b>( x: &'a str, y: &'a str, ann: &'b str, ) -> &'a str { println!("Announcement! {}", ann); if x.len() > y.len() { x } else { y } } }
这里 ann 的生命周期跟 x/y 无关,所以用了不同的 'b。
实操:看一个 lifetime 错误并修复
fn longest_owned(x: &str, y: &str) -> &str { // 缺 lifetime
if x.len() > y.len() { x } else { y }
}
编译器报:
error[E0106]: missing lifetime specifier
|
1 | fn longest_owned(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
= help: this function's return type contains a borrowed value, but the signature does not
say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
1 | fn longest_owned<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
Rust 的报错带着修复建议,直接照着改就行。这是 Rust 编译器的一个温柔——错误消息是设计过的,不是抛出去就完事。
这一节的关键 takeaway
'a 不是"我手动管理生命周期",是"我告诉编译器引用之间的关系"。绝大多数情况编译器自己推导。需要手动写 'a 的场景就那么几种,见多了就熟了。
3.4 'static Lifetime:不是"永远活着"
'static 是个特殊 lifetime,意思字面看是"静态的、永远活着"。但工程上有两个含义,经常混淆。
含义 1:&'static T —— 引用本身指向永久数据
#![allow(unused)] fn main() { let s: &'static str = "hello world"; // 字符串字面量是 'static }
字符串字面量被嵌入二进制,程序运行期间一直存在,所以它们的引用是 'static。
#![allow(unused)] fn main() { static GREETING: &str = "hello"; // 也是 'static }
static 关键字定义的全局变量,引用类型也是 'static。
含义 2:T: 'static —— 类型不持有任何短于 'static 的引用
这个更微妙。T: 'static 是一个 trait bound(其实是 lifetime bound),表示"类型 T 不持有任何借用,或者持有的所有借用都是 'static 的"。
哪些类型满足 T: 'static?
| 类型 | 是 'static 吗? | 为什么 |
|---|---|---|
i32 / bool | ✅ | 没有引用 |
String | ✅ | 持有 owned heap 数据,没有借用 |
Vec<i32> | ✅ | 同上 |
&'static str | ✅ | 持有的引用本身是 'static |
&'a str(其中 'a 不是 'static) | ❌ | 持有非 'static 引用 |
Box<dyn Trait> | ❌(可能) | 取决于 trait object 的生命周期 |
Box<dyn Trait + 'static> | ✅ | 显式标注 |
为什么这个区别重要?因为线程边界要求 T: 'static:
fn spawn_task<T: Send + 'static>(t: T) { /* ... */ }
std::thread::spawn 和 tokio::spawn 都要求传入的 closure 是 'static——因为线程可能比当前函数活得久,closure 内部不能引用任何短期的栈变量。
fn main() { let s = String::from("hello"); std::thread::spawn(move || { println!("{}", s); // ✅ s 被 move 进 closure,closure 是 'static }); let s2 = String::from("world"); let r = &s2; // std::thread::spawn(|| { // println!("{}", r); // ❌ closure 持有 &s2,不是 'static // }); }
误用警告
新手看到编译器要求 'static,经常的反应是"那我把所有东西都标 'static 不就行了"。
错。
'static 是限制,不是好东西。代码里 'static 越多,你的程序就越没法做"作用域共享"——所有跨边界的数据都得是 owned 或全局的。
正确做法:默认不写 'static,只在必须的地方写(主要是 thread/task spawn)。
这一节的关键 takeaway
'static 字面是"永远活着",工程上是"不持有任何短期借用"。它经常出现在线程边界,但不要滥用——能用更短的 lifetime 就别用 'static。
3.5 Rust 借用错误的 5 种典型模式
借用检查器报错看起来神秘,实际上 90% 的报错可以归为 5 类。看清模式后,debug 就快了。
模式 1:"cannot borrow X as mutable because it is also borrowed as immutable"
let mut v = vec![1, 2, 3];
let first = &v[0]; // 共享借用
v.push(4); // ❌ 要 &mut v,冲突
println!("{}", first);
根本原因:你在持有共享借用时尝试可变操作。
修复方向:
- 重排顺序:在
first最后一次使用之后再 push - clone:
let first = v[0].clone()(对 Copy 类型直接let first = v[0]) - 限制 first 作用域:
{ let first = &v[0]; println!("{}", first); }然后 push
模式 2:"X does not live long enough"
let r;
{
let x = 5;
r = &x;
}
println!("{}", r);
根本原因:你引用了一个比引用本身活得短的值。
修复方向:
- 把被引用的值移到外层作用域
- 改成 owned(把
&x改成x.clone(),如果 x 不是 Copy) - 重新审视设计——可能你压根不需要长生命周期的引用
模式 3:"cannot move out of borrowed content"
fn take_first(v: &Vec<String>) -> String {
v[0] // ❌ v 是借用,不能从中 move 出来
}
根本原因:你借了别人的东西,但想拿走其中一部分。借的就是借的,不能拆。
修复方向:
- clone:
v[0].clone() - 返回引用而非 owned:
&v[0],签名改成fn take_first<'a>(v: &'a Vec<String>) -> &'a String - 改成接管所有权:
fn take_first(mut v: Vec<String>) -> String
模式 4:"borrowed value does not live long enough"(返回引用类)
fn first_char_ref() -> &str {
let s = String::from("hello");
&s // ❌ s 即将被 drop
}
根本原因:你想返回一个指向函数内局部变量的引用。函数返回时局部变量 drop,引用会悬垂。
修复方向:
- 返回 owned:
fn first_word_owned() -> String然后s.chars().next().unwrap().to_string() - 接受输入并返回输入的子借用:
fn first_char(s: &str) -> &str
模式 5:"closure may outlive borrowed value"
fn main() {
let s = String::from("hello");
let f = || println!("{}", s); // closure 借用了 s
drop(s); // ❌ 不能 drop,closure 还要用
f();
}
更常见在 thread 场景:
fn main() {
let s = String::from("hello");
std::thread::spawn(|| { // ❌ closure 借用 s,但 thread 可能比 main 活得久
println!("{}", s);
});
}
修复方向:
- 用
move关键字:std::thread::spawn(move || { ... }),closure 接管所有权 - 用
Arc<T>跨线程共享所有权
编译器报错的"读法"
Rust 的报错信息有固定格式:
error[E0XXX]: <短描述>
--> file.rs:line:col
|
N | <错误代码行>
| <下划线指出问题位置>
N+1| <相关上下文>
= help: <修复建议>
= note: <补充说明>
读报错的顺序:
- 先看
help:那行——通常直接告诉你怎么改 - 看下划线位置,理解编译器认为问题在哪
- 看
note:,补充信息 - 最后才看错误码
E0XXX(如果还需要,可以rustc --explain E0XXX查详细解释)
工程经验:Rust 报错是设计资产,不是噪音。花时间读懂它们,几周后你大部分错误一眼就能定位。
3.6 实战:实现一个不 clone 的字符串切分迭代器
把这章的概念串起来,我们做个练习:实现一个返回字符串切片的 word iterator。
需求
let s = String::from("hello world rust");
let mut iter = WordIter::new(&s);
assert_eq!(iter.next(), Some("hello"));
assert_eq!(iter.next(), Some("world"));
assert_eq!(iter.next(), Some("rust"));
assert_eq!(iter.next(), None);
要求:不 clone,不 copy 字符串。每个 next() 返回的是 &str,指向原 s 的某段。
实现
struct WordIter<'a> { remaining: &'a str, } impl<'a> WordIter<'a> { fn new(s: &'a str) -> Self { WordIter { remaining: s.trim_start() } } } impl<'a> Iterator for WordIter<'a> { type Item = &'a str; fn next(&mut self) -> Option<&'a str> { if self.remaining.is_empty() { return None; } match self.remaining.find(char::is_whitespace) { Some(i) => { let word = &self.remaining[..i]; self.remaining = self.remaining[i..].trim_start(); Some(word) } None => { let word = self.remaining; self.remaining = ""; Some(word) } } } } fn main() { let s = String::from("hello world rust"); let mut iter = WordIter::new(&s); while let Some(word) = iter.next() { println!("{}", word); } }
解读
这个例子用到本章每一个概念:
- struct 含引用字段:
WordIter<'a>必须有 lifetime 参数,因为remaining: &'a str是借用 new(s: &'a str)->Self:输入借用的 lifetime 跟 struct 的 lifetime 一致Iterator::Item = &'a str:每次 next 返回的也是'a生命周期的引用fn next(&mut self) -> Option<&'a str>:返回的引用跟 self 内部的remaining来自同一个'a,即原始字符串
整个迭代器没有任何 clone 或分配,纯指针运算。这就是 Rust 零成本抽象的真容——一个看似复杂的迭代器,编译后跟手写的字符串扫描循环性能一致。
对比:如果用 owned
如果你不会 lifetime,可能会写成:
#![allow(unused)] fn main() { struct WordIterOwned { words: Vec<String>, index: usize, } impl WordIterOwned { fn new(s: &str) -> Self { let words = s.split_whitespace().map(|w| w.to_string()).collect(); WordIterOwned { words, index: 0 } } } impl Iterator for WordIterOwned { type Item = String; fn next(&mut self) -> Option<String> { if self.index >= self.words.len() { return None; } let w = self.words[self.index].clone(); self.index += 1; Some(w) } } }
能跑,但每个单词 clone 两次(构造时 to_string,next 时 clone)。100 个单词就是 200 次 heap 分配。借用版本是 0 次。
这就是为什么"会用 lifetime"是工程进阶——它让你能写出真正零成本的代码。
3.7 章末小结与习题
本章核心概念回顾
- 借用
&T/&mut T:让函数暂时使用值而不接管所有权 - 借用规则(Aliasing XOR Mutability):任意时刻要么多个共享借用,要么一个独占借用
- NLL:借用作用域在最后一次使用后立刻结束,不必等到
} - Lifetime
'a:不是手动管理生命周期,是给编译器看的引用关系标注 - Lifetime elision:大多数情况编译器自动推导,你不用写
'static的两个含义:&'static T是引用指向永久数据;T: 'static是类型不持有非 'static 借用- 5 种典型借用错误:认得出模式,debug 就快
- 零成本迭代器:用 lifetime 设计返回引用的 Iterator,无 clone 无分配
习题
习题 3.1(简单)
下面代码有借用错误,用三种不同方式修复:
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{}", first);
}
提示:三种方向是 (1) 重排顺序、(2) 改成 Copy、(3) 限制借用作用域
习题 3.2(中等)
写一个函数,签名:
fn find_pair<'a>(v: &'a [i32], target: i32) -> Option<(&'a i32, &'a i32)>;
返回 v 中两个元素的引用,使它们的和等于 target。如果找不到,返回 None。要求不 clone、不 copy。
习题 3.3(中等)
下面代码有问题,只看签名能不能判断出问题:
fn dangle<'a>() -> &'a str {
let s = String::from("hello");
&s
}
为什么编译器拒绝?如果我们硬要返回一个 &str,有什么合法的方法?
习题 3.4(困难)
实现 LineIter,类似 3.6 节的 WordIter,但按换行符切分。要求:
let s = "line1\nline2\nline3";
let mut iter = LineIter::new(s);
assert_eq!(iter.next(), Some("line1"));
assert_eq!(iter.next(), Some("line2"));
assert_eq!(iter.next(), Some("line3"));
assert_eq!(iter.next(), None);
注意空行、末尾换行符等边界情况。
习题 3.5(开放)
找一段你写的或读过的 Rust 代码,标出每个 & 引用的 lifetime 来源:它从哪里借来,什么时候失效?
如果某个引用让你看不懂,把它改写成 owned + clone 看看,问自己:这个 clone 是必要的吗?
下一章预告
Ch 4 我们离开"内存"这个维度,进入"类型设计"。Rust 的 enum 不是 C 那种 enum,是真正的 sum type。配合 pattern matching,你能写出比 Go 简洁得多的领域模型代码。
我们会用一个具体例子贯穿:重新设计一个 Session 状态机,从你熟悉的"struct + status 字段"风格,变成 Rust 风格的"enum 即状态机"。
本章一句话总结
借用检查器不是路障,是结对程序员。它在你提交代码之前,把一类并发 bug、迭代失效、悬垂引用的问题都筛掉了。学会读它的报错,是学会 Rust 的关键转折。
📝 学习记录
| 项 | 内容 |
|---|---|
| 起始日期 | |
| 完成日期 | |
| 卡点 | |
| 关键收获 | |
| 配套代码仓库链接 |