第 1 篇的结尾是一个案发现场:let t = s; 之后跟着一个错误——borrow of moved value。你用过的任何其他语言都会运行这段代码。C++ 会悄悄复制一个指针,埋下一颗 double-free 的地雷。Python 会给它起个别名,让你在生产环境里发现。Rust 在编译期拦住了你,并写了一段话解释为什么。本篇讲的就是那段话——它背后的三条规则。这三条规则不是 Rust 的一个功能,它们差不多就是 Rust 的全部意义。
真正学会它们,一件出人意料的事会发生:生命周期(第 3 篇)、智能指针(第 8 篇)、无畏并发(第 9 篇)不再是三个独立话题,而是同一个思想穿着不同的衣服。
规则一:每个值有且只有一个所有者
一个 String 在栈上是三样东西——指针、长度、容量——加上堆上真正的字节。当你写 let t = s;,Rust 做了便宜的那步(复制栈上那三个词),然后做了没有其他主流语言会做的事:它让 s 失效。所有权移动了。仍然只有一个指针将来会释放那些字节,于是清理保持确定性:未来恰好有一次 Drop,不是零次,也不是两次。
要内化的是这个反转:在 Rust 里,赋值默认是移动,不是拷贝。例外有三类:
- Copy 类型——整数、浮点、布尔、字符,以及由它们组成的元组和数组。小到拷贝就是便宜且正确的行为,所以
let t = s;直接可用,两个都保持有效。 .clone()——当你真的想要两个独立的String,你大声说出来,并显式为堆拷贝付费。成本在源码里看得见,这正是整个哲学:昂贵的东西必须看起来昂贵。- 借用——真正的常见情况,也是规则二的工作。
所有权还有镜像的一面:当所有者离开作用域,值被丢弃——确定性地,在编译器知道的位置。这就是 RAII,也是 Rust 拿到 GC 的安全性和 C 的可预测性的方式:没有 GC 线程、没有停顿、没有会忘掉的 free(),而文件、socket、锁都以同样的方式自己清理自己。
规则二:自由借用——多个读者,或一个写者
如果每次使用都要移动,你把所有权传进函数读个字符串,就再也拿不回来了。引用(&T 用于读,&mut T 用于写)让你把值借出去而不送出去——而借阅法则是整个系列最重要的一句话:
任意时刻,你可以拥有任意多个共享引用,或者恰好一个可变引用——永不同时。
停下来想想这一条规则为什么能买到两份奖品。在单线程代码里,它消灭了迭代器失效和释放后使用:当一个指向 vector 内部的引用存在时,你无法修改它,因为编译器盯着这段重叠。在多线程代码里(第 9 篇),同一条规则让数据竞争成为编译错误——数据竞争的定义恰好是“两个线程、一个在写、没有同步”,这正是这条规则禁止的重叠。借用检查器不知道什么是线程,它也不需要知道。
函数签名是这条规则从限制变成设计工具的地方。fn render(name: &str) 是借用——调用者保留自己的字符串。fn consume(name: String) 拿走所有权——调用者在调用点* visibly 地放弃了它*。一个 API 的契约直接从类型上读出来,而编译器执行契约的两半。
规则三:借用在最后一次使用时结束(NLL)
借用检查器过去比现在笨。在非词法生命周期(2018)之前,一个借用活到块尾,完全合理的代码也会被拒。今天的分析是基于使用的:借用从创建活到最后一次使用,所以这段代码能编译:
let mut s = String::from("hi");
let r = &s; // 共享借用出生
println!("{r}"); // ……在这里死亡(最后一次使用)
let m = &mut s; // 写者没问题——没有重叠
m.push('!');
演进的方向一直在变好。2024 edition 消灭了围绕 impl Trait 返回类型的一整类令人困惑的错误(它们现在默认捕获输入生命周期,并有 + use<...> 语法精确声明哪些可以逃逸——第 7 篇有细节)。而 Polonius——下一代借用检查器——已经在地平线上:更精确的分析,能接受今天的检查器仍然误杀的模式。两者都不改规则;两者都在缩小“计算机对一个明知正确的程序说不”的场景集合。
如何停止与借用检查器搏斗
第 1 篇说的搏斗是一个阶段,而这四个模式是你的毕业方式:
- 早借用,晚克隆。 参数默认用
&T。只有当所有权真的必须存在两份时才克隆——学习期间,.clone()是完全可接受的泄压阀,而之后花一个下午删掉这些辅助轮克隆,是很享受的重构。 - 收
&str,不收&String。 惯用的只读字符串参数是&str——它同时接受String、&String、字符串字面量和切片。同一家族:&[T]优于&Vec<T>,&Path优于&PathBuf。 - 边界处持有,内部借用。 struct 拥有自己的字段;函数借用它们。所有权坐在系统的边缘——数据库行、配置、连接——然后以引用的形式向内流动。
- 让编译器把话说完。 Rust 的错误会指出被移动的值、冲突的借用,通常还有修法。你开始把错误信息从头读到尾的那一天,就是搏斗结束的那天——它们是辅导,不是指控(第 1 篇的承诺,现在兑现)。
练习,以及第 3 篇
- 写三个函数:一个收
String,一个收&String,一个收&str。用同一个变量各调用两次,观察哪些调用还能编译。然后永远删掉&String那个版本——它是博物馆藏品。 - 故意触发读者-写者冲突:拿着
let r = &v[0];然后尝试v.push(2);。读完整的错误信息。然后通过在 push 之前结束r的使用来修复——感受 NLL 在r死去那一刻放行。 - 找一个你“为了让它编译”而克隆的地方(或者现在就写一个),通过把参数换成引用来删掉那次克隆。注意编译器正在教你:哪一份所有权其实是不必要的。
第 3 篇正面回答本篇小心避开的问题:当一个引用必须活得比创建它的函数更久时怎么办——生命周期、标注的真实含义(剧透:它们是描述,不是指令),以及为什么 'static 几乎从不是你以为的那个意思。