卷 II · 所有权CH 07深度 07/24

借用检查器现场:一写多读,不可兼得

规则听懂了,不等于长在手上。这一章没有太多要读的字 —— 因为主角是一台真的借用检查器,它就在下面跑。你要做的是动手改代码,看它怎么判、猜它会怎么判。当你能提前预测它的判决,那种「和机器人搏斗」的感觉就消失了。你就过墙了。

借用检查器NLLE0502 / E0499 / E0382动手

它是怎么判的:一句话的规则,逐行执行

借用检查器做的事,说穿了就是逐行走一遍代码,维护一张「谁正借着谁」的账,每遇到一次借用或移动,就核对那条铁律。它盯着三样东西:

  • 每个值当前的所有者是谁(有没有被移走);
  • 每个值当前有哪些活跃的借用(几个 &、有没有 &mut);
  • 每个借用的活跃区间 —— 从它诞生,到它最后一次被用为止。

下面这台检查器,把这三样全跟踪了。它是真写出来的:你敲的每一行都被它当场分析,报出的每个错误代码和行号,都是它算的,不是我填的。

▶ 现在就折腾它

先点五个预设各跑一遍,看诊断和「生命线」。然后动手改:在报错的例子里加个 .clone()、把冲突的两行拆到不同作用域、删掉一句 println!…… 看判决怎么变。生命线图里,锈色是「持有」,青色是 & 共享借用,紫色是 &mut 可变借用。

五个预设,逐一说透

玩过一轮后,我们把这五个例子背后的道理点破,它们覆盖了你日后会撞到的绝大多数借用错误。

① 一写多读 —— E0502

let r = &v; 借出了共享读,紧接着 let m = &mut v; 想借可变写,而 r 后面还要用。此刻同一个 v 上,既有共享借用又想要可变借用 —— 违反「读时不许改」,编译器喊 E0502:不能借为可变,因为它同时被借为不可变。

② 两个可变借用 —— E0499

let a = &mut s; 之后再 let b = &mut s;,两个可变借用并存 —— 违反「写必须独处」,E0499:不能把 s 借为可变超过一次。这条挡下的正是「两个地方同时改同一份数据」。

③ 移动后使用 —— E0382

这是卷 II 的老朋友,也是借用检查器管的另一半 —— 移动。let t = s; 把所有权移走,后面 println!("{}", s) 又用 sE0382:使用了已被移出的值。它甚至告诉你值是在哪一行被移走的。

④ 改不可变绑定 —— E0384

let x = 5; 是不可变绑定,x = 6; 想改它 —— E0384:不能给不可变绑定二次赋值。提示你把 let x 改成 let mut x。这提醒你:Rust 里默认不可变,要变得声明 mut

⑤ 读完就放 —— 这个才是精髓

第二个预设「NLL」是唯一一个通过的:

let mut v = vec![1, 2, 3];
let r = &v;
println!("{:?}", r);    // r 的最后一次使用,就在这行
let m = &mut v;         // 到这里 r 已经「用完」,&mut 于是合法
m.push(4);

它和第一个预设几乎一模一样,唯一的差别是:r 在借 &mut 之前就用完了。这就引出借用检查器最容易让人产生「玄学」错觉、也最值得你搞懂的一个机制。

NLL:借用活到「最后一次用」,不是活到花括号

早年的 Rust 有个更笨的规则:一个借用一直活到它所在作用域的结尾(词法作用域)。这让很多明明安全的代码被拒,写起来很憋屈。2018 版起,Rust 换成了 NLL(非词法生命周期,Non-Lexical Lifetimes)

◆ NLL:一个借用,从诞生活到它最后一次被用

借用的活跃区间,在它最后一次被使用后就结束了,不用等到作用域的花括号。所以「读完就放」—— r 用完的那一刻,它对 v 的借用就失效了,v 重新自由,可以再被 &mut 借走。

这解释了为什么上面两个几乎相同的例子,一个报错一个通过 —— 差别全在 r最后一次使用落在 &mut 之前还是之后。回到上面的 demo,把「NLL」那个例子里的 println!("{:?}", r) 挪到最后一行m.push(4) 之后),你会看到它当场从「通过」变成 E0502 —— 因为 r 的活跃区间被拉长了,又和 &mut 撞上了。看生命线图上那根青色横条随之伸长、压到紫色横条上,你就彻底懂 NLL 了。

这台 demo 里的检查器,NLL 是真实现的:它为每个借用算「最后一次使用在第几行」,再据此判活跃区间是否重叠。你在别处读到的「Rust 借用检查器为什么这时候放行、那时候不放」,答案几乎都是这四个字:读完就放。

撞了怎么办:四种常见修法

知道它为什么拦你,修法就有章法。按优先级:

  1. 缩短借用(首选)。让借用早点用完,或用 { } 把它圈进一个小作用域。很多冲突一「让读的先读完」就没了 —— NLL 常常已经帮你做了,你只要别把借用拖太久。
  2. 调整顺序 / 拆分。先把要读的读完、算出结果,再去改。或者把一个大结构的不同字段分开借(Rust 支持字段级的「分割借用」)。
  3. 真的需要两份数据 → .clone()接受一次拷贝换取解耦。别滥用,但它有时就是最简单的正解。
  4. 真的需要「多个主人 + 能改」→ 换工具。这说明你的需求超出了「一个所有者」,该上 Rc<RefCell<T>> 之类了 —— 那是卷 IV 的「逃生口」,不是逃避,是另一套显式的权衡。
⚠ 别急着跳到第 3、4 步

新手一遇借用错误就 clone 或上 Rc<RefCell>,是把「学不会」花钱买单。请养成先试第 1、2 步的习惯:问自己「这里能不能让读和写错开时间」。八成能。真正需要共享可变的场景,比你以为的少得多。你越是硬着头皮用「缩短借用 / 调整顺序」解决,你的所有权直觉长得越快。

回流

⟲ 回流 · 这台检查器,是一把编译期的读写锁

并发里的 RwLock(读写锁):「任意多个读者,或者一个写者,二者不可兼得」—— 这条规则你可能眼熟,因为它逐字就是读写锁的语义。区别只在于:读写锁在运行时用加锁 / 解锁来强制它,有开销、还可能死锁;而借用检查器在编译期就把它验证掉,零运行时成本、也不可能死锁。Rust 相当于给每个值都配了一把「编译期读写锁」。到卷 V 你会看到,当共享真的跨了线程、编译期算不了了,你才需要掏出运行时的那把真锁(Mutex / RwLock)—— 而它遵循的,是同一条规则。

Java 的 ConcurrentModificationException:它是「读时被改」在运行时被抓的样子 —— 抛异常,把崩溃时机推迟到某次真的触发。借用检查器是同一类问题在编译期被抓的样子 —— 根本不给你编译。同一个 bug,一个事后报警,一个事前拦门。

这一章的一句话

借用检查器逐行走一遍你的代码,维护「谁正借着谁」的账,用「共享 XOR 可变」核对每一步;借用活到最后一次使用就结束(NLL)—— 挪一句代码判决就变,因为你挪的是某个借用的活跃区间。

如果你在上面的 demo 里已经能先猜后验、而且猜得八九不离十 —— 恭喜,卷 II 最硬的一关,你过了。接下来两章是它的两个直接推论:借用「不能活得太久」展开成生命周期(下一章),「拥有 vs 借看」展开成 String 与 &str(第 9 章)。