【AI开发之Rust】第 5 课:引用与生命周期 —— 借用能活多久?

第 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

这节课要让你做到两件事:

  1. 搞懂这个"活多久"的判断机制(生命周期);
  2. 掌握唯一需要你亲手写生命周期标注的场景,以及剩下 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/(写作时同步给出)。本课练习重点是"先猜、再跑、再对照",每个题尽量先写出你的预判。

  1. E0597 复现:写出 5.1 的悬垂示例并记录报错信息,用"返回 String 而不是引用"修正。
  2. longest 推演 :把 5.4.1 的 longest 抄下来跑通;再删掉 <'a> 标注,记录编译器给的 missing lifetime specifier 报错与它的修复建议原文。
  3. 结构体练习 :定义一个 struct Borrowed<'a> { part: &'a str },写 fn show(b: &Borrowed) 打印;构造时故意让被借数据先离开作用域,观察报错。
  4. 猜结果练习:不看运行结果,判断下面每段代码能否通过编译并说明理由(再运行验证):
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);                    // 元组结构体带生命周期,合法吗?
  1. 真实修复题:下面的代码想实现"返回两个字符串中字典序较大者",修好它并加注释:
rust 复制代码
fn max_str(x: &str, y: &str) -> &str {
    if x > y { x } else { y }
}
  1. 综合练习(重点) :实现 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 的穷尽匹配。从这课起,我们开始搭自己的"领域模型"。

相关推荐
码农哈丁3 小时前
从 JVM 到 Cargo:把整个 Kotlin 项目移植到 Rust 的踩坑实录
jvm·rust·kotlin
PC2005-cloud3 小时前
Rust学习笔记:所有权、借用与生命周期——Rust的核心机制
笔记·学习·rust
Amos_Web4 小时前
Rspack 源码解析(四):从 ModuleGraph 到 ChunkGraph
rust·源码阅读
传奇开心果编程6 小时前
【xilem0.4基础语法学与练】第45课 xilem_core
学习·rust·前端框架
芒鸽7 小时前
把 Agent 运行时做成插件系统:agent-harness(openJiuwen Rust版) 的实践
开发语言·后端·rust
传奇开心果编程19 小时前
【Xilem基础语法学与练】第7课:Xilem与Masonry底层构建技术解析
学习·rust·前端框架
孙启超1 天前
【AI开发之Rust】第 3 课:字符串与复合类型 —— 数据怎么放
开发语言·人工智能·后端·rust·llm·transformer