借用检查器现场:一写多读,不可兼得
规则听懂了,不等于长在手上。这一章没有太多要读的字 —— 因为主角是一台真的借用检查器,它就在下面跑。你要做的是动手改代码,看它怎么判、猜它会怎么判。当你能提前预测它的判决,那种「和机器人搏斗」的感觉就消失了。你就过墙了。
它是怎么判的:一句话的规则,逐行执行
借用检查器做的事,说穿了就是逐行走一遍代码,维护一张「谁正借着谁」的账,每遇到一次借用或移动,就核对那条铁律。它盯着三样东西:
- 每个值当前的所有者是谁(有没有被移走);
- 每个值当前有哪些活跃的借用(几个
&、有没有&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) 又用 s,E0382:使用了已被移出的值。它甚至告诉你值是在哪一行被移走的。
④ 改不可变绑定 —— 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):
借用的活跃区间,在它最后一次被使用后就结束了,不用等到作用域的花括号。所以「读完就放」—— r 用完的那一刻,它对 v 的借用就失效了,v 重新自由,可以再被 &mut 借走。
这解释了为什么上面两个几乎相同的例子,一个报错一个通过 —— 差别全在 r 的最后一次使用落在 &mut 之前还是之后。回到上面的 demo,把「NLL」那个例子里的 println!("{:?}", r) 挪到最后一行(m.push(4) 之后),你会看到它当场从「通过」变成 E0502 —— 因为 r 的活跃区间被拉长了,又和 &mut 撞上了。看生命线图上那根青色横条随之伸长、压到紫色横条上,你就彻底懂 NLL 了。
这台 demo 里的检查器,NLL 是真实现的:它为每个借用算「最后一次使用在第几行」,再据此判活跃区间是否重叠。你在别处读到的「Rust 借用检查器为什么这时候放行、那时候不放」,答案几乎都是这四个字:读完就放。
撞了怎么办:四种常见修法
知道它为什么拦你,修法就有章法。按优先级:
- 缩短借用(首选)。让借用早点用完,或用
{ }把它圈进一个小作用域。很多冲突一「让读的先读完」就没了 —— NLL 常常已经帮你做了,你只要别把借用拖太久。 - 调整顺序 / 拆分。先把要读的读完、算出结果,再去改。或者把一个大结构的不同字段分开借(Rust 支持字段级的「分割借用」)。
- 真的需要两份数据 →
.clone()。接受一次拷贝换取解耦。别滥用,但它有时就是最简单的正解。 - 真的需要「多个主人 + 能改」→ 换工具。这说明你的需求超出了「一个所有者」,该上
Rc<RefCell<T>>之类了 —— 那是卷 IV 的「逃生口」,不是逃避,是另一套显式的权衡。
新手一遇借用错误就 clone 或上 Rc<RefCell>,是把「学不会」花钱买单。请养成先试第 1、2 步的习惯:问自己「这里能不能让读和写错开时间」。八成能。真正需要共享可变的场景,比你以为的少得多。你越是硬着头皮用「缩短借用 / 调整顺序」解决,你的所有权直觉长得越快。
回流
并发里的 RwLock(读写锁):「任意多个读者,或者一个写者,二者不可兼得」—— 这条规则你可能眼熟,因为它逐字就是读写锁的语义。区别只在于:读写锁在运行时用加锁 / 解锁来强制它,有开销、还可能死锁;而借用检查器在编译期就把它验证掉,零运行时成本、也不可能死锁。Rust 相当于给每个值都配了一把「编译期读写锁」。到卷 V 你会看到,当共享真的跨了线程、编译期算不了了,你才需要掏出运行时的那把真锁(Mutex / RwLock)—— 而它遵循的,是同一条规则。
Java 的 ConcurrentModificationException:它是「读时被改」在运行时被抓的样子 —— 抛异常,把崩溃时机推迟到某次真的触发。借用检查器是同一类问题在编译期被抓的样子 —— 根本不给你编译。同一个 bug,一个事后报警,一个事前拦门。
这一章的一句话
借用检查器逐行走一遍你的代码,维护「谁正借着谁」的账,用「共享 XOR 可变」核对每一步;借用活到最后一次使用就结束(NLL)—— 挪一句代码判决就变,因为你挪的是某个借用的活跃区间。
如果你在上面的 demo 里已经能先猜后验、而且猜得八九不离十 —— 恭喜,卷 II 最硬的一关,你过了。接下来两章是它的两个直接推论:借用「不能活得太久」展开成生命周期(下一章),「拥有 vs 借看」展开成 String 与 &str(第 9 章)。