第三章 借用与引用:不想过户,只想借一下

你有没有遇到过这种尴尬:朋友问你借辆车,你又不想把车过户给他——毕竟过完户车就不是你的了,可你只是想让他开两天去接个机。Rust 里也一样:第二章我们学会了”所有权过户”(move),可现实是,绝大多数时候你只是想”让别人临时看一眼、或者用一下”这块数据,根本不想把它送出去。这时候怎么办?

墨叔跟你说句实在话:如果 Rust 只有 move、没有借用,那这语言基本没法写。每次调个函数都得把东西送出去、再让人家还回来,代码会丑到亲妈都不认识。所以这一章讲的”借用(borrowing)与引用(reference)“,本质是给 move 这把”只有过户”的锤子,配上一把”可以借”的螺丝刀。把这件事讲透,你才算真正握住了 Rust 内存模型的第二根支柱。

先抛结论,免得你后面迷路:引用是”我指着那块数据、但我不是它的主人”;借用规则保证同一时刻,要么一堆人同时只读,要么只有一个人能写,二者绝不同时发生。编译器靠这套规则,在编译期就把悬垂引用和数据竞争摁死。

一、引用是个什么东西:一张写着”地址”的便签

先回到生活。你同事问你:“那个接口的联调文档在哪?“你没必要把整份文档打印出来塞给他(那等于 clone 一份,费纸),你只要告诉他”在共享盘 /doc/api.md,自己去读”就行。你给他的不是文档本身,而是一个”指向文档的位置”。

Rust 里的引用 &T 就是这个”位置便签”。它不拥有数据,只是指向数据。

let s = String::from("hello");
let r = &s;          // r 是 &String,指向 s 的数据,但不拥有它
println!("{}", r);   // 通过 r 读到 s 的内容
// s 依然是主人;r 离开作用域,啥也不 drop

注意看:r 离开作用域时,Rust 不会去释放 s 的堆内存——因为 r 不是所有者,它没那个资格,也没那个责任。真正的释放,永远只在主人 s 离开作用域时发生。这就是引用和所有权的根本关系:引用借用了主人的数据,但绝不接管释放责任。

那 & 这符号怎么读?&s 读作”取 s 的引用”,或者生活点说”开个 s 的借阅证”。你拿到的不是书,是”此刻可以看这本书的许可”。

这里有个心智要重装的地方:如果你写过 C/C++,& 在你脑子里是”取地址符”,是个干巴巴的指针运算。但在 Rust 里,&T 是一个带着编译器担保的安全指针——它担保”我指的那块数据,在我用它期间一定还活着、且没人偷偷改它(对不可变引用而言)“。这不是语法糖,是安全网。你过去在 C 里手写指针时提心吊胆的那堆事,Rust 把它焊进了类型系统。

二、借用规则第一条:要么多读,要么一写,互斥

现在进入本章最硬核的部分。Rust 的借用检查器(borrow checker)只认一条铁律,但这一条够你琢磨半天:

在同一作用域内、针对同一块数据,借用要么全是不可变借用(&T,随便几个人同时读),要么只有一个可变借用(&mut T,独自写)。读和写不能共存,多个写也不能共存。

听起来像图书馆的规矩:要么一堆人安静看同一本书(多个 &T),要么只有一个人把书借去批改批注(一个 &mut T)。绝不允许”有人正批改着,另一群人凑过来同时读”,因为读者会看到半改半不改的混乱页面;也绝不允许”两个人同时拿红笔改同一本”,因为改完谁也不知道最终长啥样。

let mut s = String::from("hello");
 
let r1 = &s;        // 不可变借用
let r2 = &s;        // 再来一个不可变借用,没问题,读的人可以很多
println!("{} {}", r1, r2);
 
let w = &mut s;     // 到这里,若 r1/r2 还活着,编译器会拒绝——
// 因为"还有人在读"的时候,不能开"可写"的口子
w.push_str(" world");

你会问:凭什么读和写不能共存?墨叔给你拆。假设允许”一边读一边写”:线程 A 正遍历一个数组,线程 B(或同一线程的另一段代码)同时往里插元素,数组一扩容,A 手里的索引全错位了——经典的”迭代器失效”。C++ 里这种 bug 你只能靠经验躲;Rust 选择在编译期直接说”不行,你俩不能同时占着”。

那可变借用为啥只能有一个?因为多个 &mut 指向同一块数据,等于把”唯一写权”拆给了好几个人。任何人都能改,改的时候别人也在改,结果不可预测——这就是数据竞争(data race)的温床。Rust 干脆把可变借用做成”独占许可”:同一时刻只有一张可写借阅证发出去,持证人独占修改权。

关键洞察:这条规则在编译期就查,运行时零成本。 它不插任何运行时代码、不加锁、不计数,纯粹是编译器在类型检查阶段做的静态分析。你没付运行时一分钱,白拿了内存安全的保障。

三、编译器如何消灭悬垂引用

悬垂引用(dangling reference)是个老名词:你手里攥着一张指向某块内存的引用,但那块内存已经被释放了,你再读它——读到的就是垃圾,甚至直接崩。C/C++ 里这是高发事故。Rust 怎么保证它不发生?

答案还是”借用规则 + 作用域”。编译器强制一条更细的约束:引用的生命周期,绝对不能超过它所指数据的生命周期。 换句话说,借来的东西,必须比借阅证活得久。

let r;
{
    let s = String::from("临时");
    r = &s;     // 想把 s 的引用借出去
} // ← s 在这里 drop,堆内存归还
// 此时 r 还指着已释放的内存 —— 悬垂!
println!("{}", r); // 编译器直接拒绝:强行拦下

上面这段代码,Rust 编译器会报错,大意是 “s 活得不够久(s does not live long enough),你却想让 r 借它。注意错误发生在 r = &s 这一行,而不是在 println! 那行——因为编译器在”产生借用”的那一刻就发现了”被借的东西活不到引用消失”,当场否决。它根本不给你机会把悬垂引用留到运行时。

你品这个机制的美:它不需要运行时去跟踪”这块内存还有没有人指”,而是在编译期就通过作用域分析,证明”任何引用指向的数据,在引用死亡前一定还活着”。证明不了?编译失败。所以 Rust 里你写不出悬垂引用——不是很难写,是语法层面、类型层面就不让你存在这种值。

打个比方:图书馆规定”借书证的过期时间,必须早于书被退回馆藏的时间”。如果系统发现你办的证会比书还在馆的时间还长,它当场拒办,根本不印那张证。于是馆里流通的每一张证,背后都有一本确实还在的书。悬垂?从根上没机会发生。

四、借用和 move 的关系:不想过户,才借

现在把第二章和第三章串起来,这是很多人绕不清的点。什么时候用 move,什么时候用借用?

铁律一句话:当你”不想失去所有权”的时候,用借用;当你”真心要把东西送出去”的时候,才用 move。

函数传参是最典型的场景。第二章说过,把 String 传进函数,默认是 move,函数结束时值被 drop,你外面就没了。但绝大多数函数只是”读一下你的数据、做个计算”,根本不需要拥有它。这种时候,传引用就对了:

fn 计算长度(s: &String) -> usize {  // 收的是 &String,借一下
    s.len()                         // 只读,不动所有权
}                                   // 函数结束,s(引用)消失,原主人毫发无伤
 
let my = String::from("hello");
let n = 计算长度(&my);              // 传 &my,my 依然是主人
println!("{} 长度是 {}", my, n);    // my 还能用,完美

你看,&my 把”借阅证”递给函数,函数用完证作废,书还是你的。对比 move 版本:你得把 my 送进去、再 return 出来,绕一大圈。借用让”只读不拿”这件事变得自然、便宜、安全。

再深一层:可变借用 &mut T 解决的是”我想让别人临时改一下我的数据、但改完还是我的”这种需求。你既不想过户(move),又想让人代劳修改,于是发一张”可写借阅证”:

fn 追加(target: &mut String) {
    target.push_str("!");   // 通过 &mut 改了主人的数据
}
 
let mut msg = String::from("hi");
追加(&mut msg);             // 传可写借阅证
println!("{}", msg);        // 输出 hi!,msg 还是主人,只是内容被改了

所以 move、不可变借用、可变借用,是同一件事的三档:送出去 / 借来读 / 借来改。你根据”到底要不要保留所有权”来选档位。理解了这一层,第二章那个”传参还得 return 真麻烦”的抱怨,就烟消云散了——因为你压根不该用 move,你该用借用。

五、可变借用的独占性,为什么能防数据竞争

最后把本章收口到一个大问题上:数据竞争(data race)。它是并发编程里最臭名昭著的 bug,特征是”多个线程同时访问同一块数据、至少一个在写、且没有同步”,结果不确定、且极难复现。

Rust 在单机、单线程层面就埋下了防数据竞争的钉子:可变借用的独占性。回忆规则——同一时刻最多一个 &mut,且它存在时没有任何 &。这意味着什么?意味着”只要有人在写,就绝没有第二个人(无论读还是写)能碰到这块数据”。共享可变状态这个数据竞争的前提,被借用规则从物理上拆掉了。

到了多线程(第十三章细讲),这套规则升级成:&T 可以安全地 Send 给多个线程(因为只读,怎么共享都不会乱),而 &mut T 想跨线程?编译器要求它必须被 Mutex 这类同步原语包住,强制”写之前先抢锁”。于是”多写共存”在类型层面就被禁止了。

你看这条因果链多漂亮:

  1. 借用规则禁止”读+写共存”、“写+写共存”(编译期静态保证)。
  2. 于是任何单一时刻,对一块数据的访问要么是纯读、要么是独占写。
  3. 这个保证在单线程消灭了迭代器失效、悬垂修改;
  4. 在多线程,它成为”消灭数据竞争”的地基——因为数据竞争需要的”无同步的多写共存”,从语言规则层面就不被允许存在。

所以墨叔常说:Rust 的借用检查器,表面看是在管你”能不能多借几次书”,实际上是在替你守着整个并发安全的大门。你今天为”为啥不能既读又写”跟它吵的每一架,都是未来多线程 bug 的一次提前消弭。

六、一个你马上会踩的坑:遍历时修改

讲完理论,说个实战里最高频的借用冲突,几乎人人踩一次。你想边遍历一个 Vec 边往里加元素:

let mut v = vec![1, 2, 3];
for x in &v {        // 这里借了 v(不可变)
    v.push(*x);      // 同时又要可变借用 v → 编译拒绝
}

编译器会拦你:不能在持有 &v 期间又拿 &mut v。道理已在第五节讲过——迭代器正指着 v 的内部,你却要往里塞东西改变结构,两借用撞车。解法要么先收集新元素再统一 push,要么用索引(并想清越界),要么 iter().copied().collect() 生成新向量。踩一次,你就真懂”借用规则守护的是数据结构本身不被偷偷改”了。

【思考题】

  1. 墨叔说”不可变借用可以有很多个、可变借用只能有一个,二者互斥”。请你自己推一遍:如果 Rust 允许”一个 &mut 和十个 &T 同时活着”,会发生什么具体坏事?举一个单线程场景(比如遍历数组同时往里插元素)把它讲清楚,而不是只说”会不安全”。

  2. 悬垂引用在 C 里是运行时事故,Rust 里是编译错误。请你解释:编译器到底是靠”什么信息”判断出某次借用会产生悬垂的?它在你写 r = &s 那一刻,检查的是 r 和 s 的什么关系?为什么它能在编译期就拦下,而不是等运行?

  3. 第二章讲 move 时说”函数传参是 move,传进去就没了”。这一章你说”传 &T 就不用还回来了”。请你把这两种写法放在一起,设计一个小场景(比如一个读配置、一个改配置),说明什么时候该用 move、什么时候该用不可变借用、什么时候该用可变借用。讲清你的取舍理由。

【参考答案】

  1. 推具体坏事:想象一个 Vec<i32>,你拿着它的不可变引用 r 正在 for 循环里逐个读元素;与此同时另一个 &mut 往同一个 Vec 里 push 了一个数。Vec 底层是堆上数组,容量满了会重新分配一块更大的内存、把老数据搬过去、释放老块。这一搬,r 指向的老地址上的数据已经 frees 了,r 瞬间变成悬垂,循环再读就是读垃圾甚至段错误。更隐蔽的是:即使没触发扩容,push 也会让”逻辑长度”变了,你遍历时读到的元素个数和预期对不上,逻辑直接错乱。这就是 C++ 里臭名昭著的”迭代器失效”。所以”读+写共存”不是抽象的不安全,而是会精确地制造”读的人看到半新半旧、甚至已释放的内存”这种 concrete 灾难。Rust 选择互斥,就是要把这个窗口在编译期焊死。

  2. 编译器靠的是”生命周期(lifetime)与作用域”的静态关系,不是靠运行时的引用计数。具体说:在 r = &s 这一行,编译器分别算出 r 的作用域终点(最后一次用 r 的地方)和 s 的作用域终点(s 所在的 })。它检查的是——“被借用的数据 s 的存活范围,是否覆盖引用 r 的整个存活范围”。如果发现 s 会比 r 先死(即 s 的作用域在 r 还在使用前就结束),它就判定:借用会让 r 在 s 已死后仍指向它,构成悬垂,于是报错 s does not live long enough。它能在编译期拦下,是因为作用域和生命周期都是编译期就能确定的纯静态信息,完全不需要跑程序。这也解释了为什么 Rust 的悬垂防护是零运行时成本的——它只是一道编译期的”纸面证明”,证明不了就拒编。

  3. 场景设计:假设有个 Config 结构。情况一,函数只是”读配置算个超时时间”,它根本不需要拥有配置,传 &Config 即可,调用方读完还能继续用配置——这是不可变借用的典型用法。情况二,函数要”根据用户指令动态改配置里的一个字段”,你不想把整个 Config 送出去再要回来,传 &mut Config,函数改完,调用方手里还是那个 Config、只是内容变了——这是可变借用。情况三,函数要”把配置打包序列化后存盘并永久接管这份配置的生命周期管理”(比如移交给定一个长期持有的全局管理器),这时你真要把所有权交出去,用 move,传 Config 本身,原调用方此后不再持有。取舍一句话:只想看 → &T;想临时改且改完还是我的 → &mut T;真心要把东西送走、从此归别人管 → move。三档对应”保留所有权的程度”从高到低,你按”我还要不要这块数据”来选,而不是按习惯乱用。

小结

借用是”指而不拥”的引用,规则只有一条却够硬:同一时刻要么多读要么一写、二者互斥。编译器靠作用域分析在编译期消灭悬垂引用,又靠可变借用的独占性把数据竞争的地基从根上拆掉——不想过户时才借,这才是 move 与借用的真正关系。