第 5 课:引用与生命周期 ------ 借用能活多久?
预计时间:75-90 分钟 | 难度:⭐⭐⭐ | 前置:第 4 课(所有权与借用)
5.1 这节课解决什么问题
第 4 课讲了"谁拥有数据、谁能借用",但留了一个悬案:借用检查器凭什么敢保证"引用不会指向已释放的内存"? 内存里最容易出的一种 bug 叫悬垂引用(dangling reference)------数据已经没了,手上还捏着它的指针。
rust
fn main() {
let r;
{
let s = String::from("hello");
r = &s; // r 借用了 s
} // ← s 在这里被释放
// println!("{r}"); // ❌ 如果放行:r 指向已释放的内存 = 悬垂引用
}
在 C/C++ 里这种代码能编译、能运行、然后随机崩溃或读脏数据;在 Rust 里,它在编译期就被拒绝:
ini
error[E0597]: `s` does not live long enough
--> src/main.rs:6:13
|
6 | r = &s;
| ^^ borrowed value does not live long enough
7 | }
| - `s` dropped here while still borrowed
这节课要让你做到两件事:
- 搞懂这个"活多久"的判断机制(生命周期);
- 掌握唯一需要你亲手写生命周期标注的场景,以及剩下 95% 场景下编译器替你省事的三条省略规则。
先给结论------大多数 Rust 代码根本不需要写生命周期标注;你只是需要能读懂、能修报错。
5.2 借用检查器的工作逻辑
编译器为每个引用都记录一个隐含的"寿命区间":从它被创建,到最后一次被使用(NLL,见第 4 课)。判断的核心是三条递进问题:
① 引用要活得比它的所有者久吗?
是 → 拒绝(E0597,悬垂)
② 同一数据上,只读引用与可变引用冲突吗?
冲突 → 拒绝(E0502)
③ 可变引用同时存在多个吗?
是 → 拒绝(E0499)
生命周期标注('a)本质上就是给编译器一个名字,用来描述"这几个引用之间的寿命关系",方便它完成上面 ① 的检查。它不改变程序行为,只影响编译是否通过。
💡 一个比喻:借用检查器像装修监理------它不看你怎么干活(运行时),只审查图纸上"这堵墙拆了之后,楼上那根梁还挂在哪儿"。它发现梁悬空了就拒绝开工。
5.3 为什么必须禁止悬垂:一段"非法但可能编译通过"的推演
rust
// 以下为"Rust 若允许会怎样"的推演(实际编译不过):
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s // 返回 s 的引用 ------ 但函数一结束 s 就被释放
}
若放行,调用者会拿到一个指向"已释放内存"的引用,之后任何读写都是未定义行为。Rust 的解法很直接:"返回引用" = "承诺引用寿命不超过某个由输入决定的范围" 。编译器无法单独证明 &s 能安全逃出函数,于是报错。
修正也直白:把 String 本身返回(所有权随返回值移出),引用自然就指向由调用者持有的数据:
rust
fn no_dangle() -> String {
let s = String::from("hello");
s // 移动所有权,不是返回引用
}
5.4 生命周期语法:<'a> 入门
5.4.1 第一次见:两个入参,返回其中一个的引用
看这个最常见的场景------"从两个字符串里挑更长的那个返回":
rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.chars().count() >= y.chars().count() { x } else { y }
}
fn main() {
let a = String::from("abcdef");
let result;
{
let b = String::from("ab");
result = longest(&a, &b); // result 借自 a 或 b ------ 编译器要保证它俩都活着
println!("更长的是 {result}");
} // b 在这里释放
// println!("{result}"); // ❌ E0597:result 可能还引用着 b
}
逐段读这行签名:
rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str
// │ │ │ │
// │ │ │ └─ 返回值也是一个引用,寿命也 ≤ 'a
// │ │ └─ y 的寿命至少是 'a
// │ └─ x 的寿命至少是 'a
// └─ 声明一个生命周期参数 'a,代表"x、y 中较短的那个寿命"
'a只是名字,换成'b、'life都行(惯例用'a起步);- 它的含义是:返回的引用,寿命不会超过 x 和 y 中较短者------因为编译器会假设任意一次调用可能返回任一入参;
- 这样 main 里"result 在 b 被释放后还用"就会被编译器抓住(E0597),"结果在 b 活着时用"就放行。
5.4.2 寿命与实参如何匹配(写写看)
调用 longest(&a, &b) 时,'a 被实例化为"a、b 两个引用寿命中较短的那个"。为什么取较短?因为保守安全:返回值来源不确定,就得按最坏情况(较短者)来算。调用点的数据若比这个"较短寿命"死得早,编译就报错------于是悬垂被杜绝。
⚠️ 很多人初学时把
'a理解成"某个具体的时间长度"而困惑。正确心智:它是一条"不等式约束"(返回值寿命 ≤ min(x,y) 寿命),不是一个固定时长。同一个函数不同调用可以实例化出不同的实际寿命。
5.5 三条省略规则:95% 场景不用写标注
如果每个函数都要标 <'a>,代码会丑到没法看。Rust 为此内置了生命周期省略(elision),编译器按三条规则自动补标注:
rust
省略规则(编译器自动执行,输入位置与输出位置):
规则1:每个入参引用,各分配一个独立生命周期
fn f(x: &T, y: &U) → fn f<'a, 'b>(x: &'a T, y: &'b U)
规则2:如果只有一个入参生命周期,则它赋给所有输出引用
fn f(x: &T) -> &U → fn f<'a>(x: &'a T) -> &'a U
规则3:方法中 &self / &mut self 的寿命赋给输出(第 6 课 impl 时再遇)
所以你见过的绝大多数签名其实是被省略过的:
rust
fn first_word(s: &str) -> &str // 单入参 → 规则1+2 → fn first_word<'a>(s: &'a str) -> &'a str
fn len(s: &str) -> usize // 返回的不是引用 → 不需要标注
而像 longest 这种多个入参 + 返回其中一个引用 的函数,编译器没法猜"返回谁",省略规则覆盖不了,才必须手写。判定一句话:省略规则失效就自己写,生效就放心不写。
💡 报错信息里经常会看到编译器提示
missing lifetime specifier(E0106),这就是它想让你补上<'a>的时刻。
5.6 结构体里的生命周期:数据与结构体同生共死
结构体可以持有引用,但必须声明引用的寿命,保证"结构体不会比它借用的数据活得更久":
rust
struct Quote<'a> {
text: &'a str, // Quote 实例存在期间,text 必须一直有效
speaker: &'a str,
}
fn main() {
let author = String::from("鲁迅");
let q = Quote {
text: "其实地上本没有路",
speaker: &author, // q 借用了 author
};
println!("{}:{}", q.speaker, q.text);
// author 必须活得比 q 久,否则编译器报 E0597
}
工程经验:结构体优先持有自己的数据(String/Vec),尽量别存引用 ------否则每个方法都得带 <'a>,代码像藤蔓一样缠满生命周期。除非明确需要零拷贝视图(解析大文件/网络缓冲区的高性能场景),才让结构体借数据。实战篇 17 课建模时我们会执行这条约定:领域模型全部用拥有的类型。
5.7 'static:最长的寿命
'static 指"存活到程序结束",常见两个场合:
rust
fn main() {
// ① 字符串字面量就是 'static:写死在程序里,程序结束才"死"
let s: &'static str = "我是字面量"; // 不需要标注,编译器默认它 'static
// ② 想得到一份"永不销毁"的拥有型数据:Box::leak 主动泄漏给它
let leaked: &'static str = Box::leak("堆上常驻".to_string().into_boxed_str());
println!("{s} {leaked}");
}
但别把 'static 当"永久有效"乱贴 ------很多泛型约束里出现的 T: 'static 实际含义是"T 不借用任何局部数据"(允许拥有所有权的类型),而不是"这个值真的活到程序结束"。第 8 课 trait 与第 13 课 async(tokio::spawn 要求 'static)会反复遇到,先有个印象:凡是需要"送入后台线程/异步任务"的数据,多半要求不依赖局部变量 → 要用拥有的类型而不是引用。
5.8 一个经典例子的逐行推演(多引用共存)
把本课所有规则集中到一段"会报错→修对"的实例上,动手推一遍:
rust
fn pick<'a>(cond: bool, first: &'a str, second: &'a str) -> &'a str {
if cond { first } else { second }
}
fn main() {
let cond = true;
let a = String::from("A_long_data");
let result;
{
let b = String::from("B");
result = pick(cond, &a, &b); // 'a 实例化为 min(&a, &b 的寿命) = &b 的寿命
println!("{result}"); // ✅ 在 b 活着时用,安全
}
// b 释放。若此后还用 result,编译器就会报 E0597。
// println!("{result}"); // ❌(取消注释可看报错)
}
推演要点:pick 返回的可能引用 &b,所以 result 的寿命被约束到 &b 之内;一旦 b 出作用域,result 随之"不可用"。编译器不是运行时检测,而是把所有潜在路径都检查一遍------这就是"静态"的威力。
💡 小技巧:rust-analyzer 在 VS Code 里对
<'a>相关代码有 hover 提示,会显示"lifetime of this borrow",帮助你看清每个引用的推断寿命。
5.9 📝 动手练习
参考实现放 code/05-lifetimes/(写作时同步给出)。本课练习重点是"先猜、再跑、再对照",每个题尽量先写出你的预判。
- E0597 复现:写出 5.1 的悬垂示例并记录报错信息,用"返回 String 而不是引用"修正。
- longest 推演 :把 5.4.1 的
longest抄下来跑通;再删掉<'a>标注,记录编译器给的missing lifetime specifier报错与它的修复建议原文。 - 结构体练习 :定义一个
struct Borrowed<'a> { part: &'a str },写fn show(b: &Borrowed)打印;构造时故意让被借数据先离开作用域,观察报错。 - 猜结果练习:不看运行结果,判断下面每段代码能否通过编译并说明理由(再运行验证):
rust
// (a)
fn f(x: &i32, y: &i32) -> i32 { *x + *y } // 无输出引用,能省略吗?
// (b)
// fn g(x: &str, y: &str) -> &str { x } // 两个入参,输出引用?省略规则覆盖不了
// (c)
fn h(s: &str) -> &str { &s[..2] } // 单入参 → ?
// (d)
struct Holder<'a>(&'a str); // 元组结构体带生命周期,合法吗?
- 真实修复题:下面的代码想实现"返回两个字符串中字典序较大者",修好它并加注释:
rust
fn max_str(x: &str, y: &str) -> &str {
if x > y { x } else { y }
}
- 综合练习(重点) :实现
fn trim_prefix<'a>(s: &'a str, prefix: &str) -> &'a str------若 s 以 prefix 开头,返回去掉前缀的切片(提示strip_prefix),否则原样返回。调用两次:一次借用String,一次直接字面量,均需编译通过。
验收门禁 :能用自己的话解释 E0597 是什么问题;能说清省略规则的触发条件;能判断"什么时候必须写 <'a>"(多入参引用 + 返回其中一个引用、结构体存引用);能解释 'static 在字面量与 Box::leak 两种场景的含义。
✅ 本节小结
- 借用检查器:用"寿命区间"静态证明引用不会悬垂------不需要、也没法在运行时兜底;
- 生命周期标注
<'a>是描述引用寿命关系的"约束",不是固定时长;多入参返回其一引用时必须手写; - 省略规则 :单入参自动匹配输出、无输出引用不用标、
&self场景自动继承------95% 代码无需手写; - 结构体持引用 :要带
<'a>;工程上优先存拥有的String/Vec; 'static:程序级寿命;泛型约束里的T: 'static意思是"不借用局部数据";- 读报错:E0597(借的比所有者活得久)、E0106(缺生命周期标注)。
下一课预告 :第 6 课《结构体、枚举与方法》------终于开始自己定义类型。struct 的三种形态、impl 方法与 &self/&mut self/self 三种接收者、enum 带数据变体、以及最重要的两个枚举 Option<T> 与 Result<T, E> 的完全拆解与 match 的穷尽匹配。从这课起,我们开始搭自己的"领域模型"。