阶段一 · 生命周期与引用

引用与借用规则回顾

一句话总结

进入生命周期之前, 先把借用规则从"会用"升级到"会推理".
本篇深入借用检查器的思维方式: 它如何用 NLL 精确计算借用范围, 如何允许同时可变借用一个结构体的不同字段, 以及"重借用"为何能让 &mut 传来传去.
看懂这些, 生命周期就只剩最后一层窗户纸.

借用规则: 从规则到推理

基础篇记住了那条铁律--共享不可变, 可变不共享: 同一数据, 要么任意多个 &T, 要么唯一一个 &mut T, 二者不并存. 进阶篇要问的是: 编译器具体怎么判断两个借用是否冲突?

答案的核心是: 借用检查器关心的不是"写了几个 &", 而是"每个借用实际存活的范围是否重叠". 理解这一点, 很多"看似该报错却没报错"或"看似无害却报错"的现象就都说得通了.

把借用想象成图书馆的借阅记录: 规则不是"这本书总共被借过几次", 而是"在任意一个时间点, 是否存在冲突的借阅状态"--多人同时在馆内阅览(多个 &T)可以, 有人把书借走独占(&mut T)时就不能有别人.
借用检查器是那个只盯着"时间轴上有无冲突"的管理员.

NLL: 借用到"最后一次使用"为止

这是理解借用范围的关键机制, 叫非词法生命周期(NLL, Non-Lexical Lifetimes). 一个引用的存活范围, 不是从声明到作用域大括号结束, 而是从声明到它最后一次被使用为止. 之后它就"死了", 不再参与冲突判断.

let mut s = String::from("hello");

let r1 = &s; // 不可变借用开始
let r2 = &s;
println!("{r1} {r2}"); // r1, r2 最后一次使用 -- 它们的借用到此结束

let r3 = &mut s; // OK! 此刻 r1/r2 已"死", 不再与可变借用冲突
r3.push_str("!");

如果按"借用持续到作用域结束"的旧规则, 上面会报错(可变与不可变借用共存). NLL 让编译器看得更细: 既然 r1/r2 在 r3 出现前就用完了, 自然无冲突.

实践指导: 尽早用完引用

NLL 给出一条避免借用冲突的实用策略: 让引用的使用尽量集中, 尽早结束.

很多"cannot borrow as mutable"报错, 只要把前面那个不可变借用的使用提前, 或用内层作用域 { } 框住, 就能解决--本质上是在帮编译器看清"这个借用其实已经不需要了".

可变性分割: 同时借用不同字段

一个常被忽略, 却极实用的能力: 借用检查器能识别"借用的是结构体的不同字段", 从而允许同时持有它们的可变引用.

struct Point { x: i32, y: i32 }

let mut p = Point { x: 1, y: 2 };
let rx = &mut p.x; // 可变借用 x
let ry = &mut p.y; // 可变借用 y -- OK! x 和 y 是不同字段, 互不冲突
*rx += 10;
*ry += 20;

因为 p.xp.y 是内存上不相交的两块, 同时可变借用它们不会违反"可变不共享"--它们本就不是同一份数据.

但通过方法借用就不行

如果写的是 p.get_x_mut() 这样的方法, 编译器只能看到方法借用了整个 &mut self, 无法得知它内部只碰了 x. 于是同时调 get_x_mut()get_y_mut() 会冲突.

这就是为什么有时需要直接访问字段, 或用标准库的 split_at_mut 等专门手段来"分割"借用. 这背后的取舍, 在写复杂数据结构时会反复遇到.

重借用: &mut 为什么能传来传去

&mut T不可 Copy 的(它要保证独占). 那为什么下面的代码能正常工作?

fn add_one(n: &mut i32) {
*n += 1;
}

let mut x = 5;
let r = &mut x;
add_one(r); // 把 r 传进去
add_one(r); // 居然还能再用 r?

如果 &mutString 那样被移动, 第一次 add_one(r)r 就该失效了. 它没失效, 是因为发生了重借用(reborrow): 编译器自动把 add_one(r) 解释成 add_one(&mut *r)--临时从 r 再借一个新的可变引用传进去, 这个临时借用在函数返回时结束, r 的所有权从未离开.

重借用像"转借": 借来一本书(r), 把它转借给朋友看一会儿(add_one), 朋友看完还回来, 书又回到手上.
整个过程书的"主借阅人"始终是自己. 编译器自动安排了这场转借, 所以感觉不到.

为什么进阶篇要讲这些

NLL, 字段分割, 重借用, 平时多由编译器悄悄处理, 未必察觉. 但当代码变复杂, 报错变刁钻时, 正是这些机制在起作用.

理解它们, 才能从"为什么又报错了"的困惑, 走向"我知道编译器在想什么"的从容--这也是读懂下一篇生命周期标注的必要铺垫.

快速回顾

  • 从规则到推理: 借用检查器判断的是"借用存活范围是否重叠", 而非 & 的个数.
  • NLL: 借用到"最后一次使用"为止即结束; 尽早用完引用可化解多数冲突.
  • 字段分割: 可同时可变借用结构体的不同字段(内存不相交); 但经由 &mut self 的方法则不行.
  • 重借用: &mut 不可 Copy, 但传参时自动 &mut *r 临时转借, 原引用不失效.
  • 意义: 这些机制是读懂生命周期与复杂借用报错的地基.

动手练习

  1. 下面代码能编译吗? 如不能, 用 NLL 思路解释, 并修复.

    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4);
    println!("{first}");
参考答案

不能编译. first 是对 v 的不可变借用, 且它在最后一行 println! 才最后一次使用--所以借用一直存活到那时. 而中间的 v.push(4) 需要 &mut v, 与仍存活的不可变借用冲突(且 push 可能触发扩容, 使 first 悬空, 这正是编译器要拦的).

修复: 把 first 的使用移到 push 之前, 或干脆不持有引用:

let mut v = vec![1, 2, 3];
let first = v[0]; // 直接 Copy 出值(i32 是 Copy), 不再持有借用
v.push(4);
println!("{first}");
  1. 解释为什么下面能编译, 涉及哪个机制.

    struct Pair { a: String, b: String }
    let mut p = Pair { a: "x".into(), b: "y".into() };
    let ra = &mut p.a;
    let rb = &mut p.b;
    ra.push('!'); rb.push('?');
参考答案

能编译, 靠的是可变性分割(disjoint borrows). p.ap.b 是结构体中内存不相交的两个字段, 借用检查器能识别"它们不是同一份数据", 因此允许同时持有 &mut p.a&mut p.b, 不违反"可变不共享".

但如果改成通过 &mut self 的方法分别取 a, b 的可变引用, 就会冲突, 因为编译器只看到整个 self 被借走.

  1. 写一个函数 fn bump(n: &mut i32) 把值加一, 然后在 main 中对同一个 &mut 连续调用它两次, 解释为什么第二次调用时引用没有失效.
参考答案
fn bump(n: &mut i32) {
*n += 1;
}

fn main() {
let mut x = 0;
let r = &mut x;
bump(r); // 自动重借用为 bump(&mut *r)
bump(r); // r 仍有效, 再次重借用
println!("{r}"); // 2
}

因为发生了重借用: bump(r) 被编译器解释为 bump(&mut *r), 每次只是从 r 临时借出一个新的可变引用, 函数返回时该临时借用结束, r 的所有权从未被移走, 所以可以反复使用.