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

借用:不转移,只出借

上一章结尾我们卡在一个笨拙处:只想读一下值,却要把所有权整个交出去、再倒手要回来。借用就是为解决这个笨拙而生的 —— 它让你「看一眼」甚至「改一下」,而不夺走所有权。代价是接受一条铁律:要么很多人一起读,要么一个人独自写,二者不可兼得。这一章讲清这条铁律,为什么它天经地义。

&T&mut T共享 XOR 可变别名

先解决那个笨拙

回想上一章:想让函数读一下字符串又不夺走所有权,得让它「拿走再还回」,倒手一次。有了借用,就干净了 —— 参数写成 &String(或更常见的 &str),函数着看,看完自动还,所有权始终在调用者手里:

fn length(s: &String) -> usize {   // 借一下,不拿走
    s.len()
}                                  // 借用在这里结束,什么都不 drop

fn main() {
    let book = String::from("借还");
    let n = length(&book);         // 把 &book 借出去
    println!("{} 有 {} 字节", book, n);   // ✓ book 还是我的,随便用
}

&book 读作「book 的引用」。它是一个指向 book 的指针,但带着一个编译期的承诺:我只是借看,不拥有,不负责释放。所以借用结束时什么都不会被 drop —— 你不能 drop 一本不属于你的书。

两种借法

借用分两种,区别只在「能不能改」:

  • &T 共享借用:只读。你可以看,不能改。可以同时有很多个 —— 一堆人围着同一本书一起读,互不干扰。
  • &mut T 可变借用:可读可写。但同一时刻只能有一个,而且它独占期间,连别的只读借用都不许有。要改,要拿 &mut;要拿 &mut,就得让被借的值声明成 mut
fn append(s: &mut String) {        // 借来改
    s.push_str("(第 2 版)");
}

fn main() {
    let mut book = String::from("借还");   // 要被可变借用,得是 mut
    append(&mut book);                     // 借出可变引用
    println!("{}", book);                  // 借还(第 2 版)
}

那条铁律,与它背后的道理

把两种借法的限制合起来,就是贯穿全书的那一句:

◆ 借用铁律(共享 XOR 可变)

任一时刻,对同一个值,你只能拥有以下两者之一:

· 任意多个 &T 共享借用(大家一起读)
· 或者恰好一个 &mut T 可变借用(一个人独自写)。

不能同时有共享和可变,也不能同时有两个可变。读可以扎堆,写必须独处。

为什么?很多人以为这是 Rust 强加的洁癖,其实它在防一件非常具体的祸事:一边有人读、一边有人改。

设想一个经典场景:你正在遍历一个列表(这是「读」),循环体里往列表里 push 一个元素(这是「改」)。push 可能触发扩容 —— 底层缓冲区搬家到新地址,旧地址失效。而你手里那个遍历用的指针,还指着旧地址。下一步解引用,就是访问一块已经作废的内存。

let mut v = vec![1, 2, 3];
for x in &v {           // 借出共享引用来遍历(读)
    if *x == 2 {
        v.push(4);      // ✗ 想在读的同时改 —— 编译器当场拦下
    }
}

这个 bug 有名字:迭代器失效。Java 里它是运行时的 ConcurrentModificationException,C++ 里它是不声不响的未定义行为(可能崩、可能读到垃圾、可能看起来正常然后某天崩)。而在 Rust 里,它连编译都过不了 —— 因为遍历借走了 v&,此刻你不可能再拿到改它所需的 &mut「共享 XOR 可变」就是把「读时不许改」这条安全律,钉进了类型系统。

更妙的是:这条律同时也是数据竞争的定义。多线程数据竞争的本质,就是「两个线程同时访问一块内存,至少一个在写,且没有同步」。而「至少一个在写」= 存在 &mut,「同时访问」= 别名。禁掉「共享与可变并存」,就从根上禁掉了数据竞争 —— 这就是为什么到了卷 V,Rust 敢叫「无畏并发」:并发安全不是另一套规则,就是这一条借用律的直接后果。先记住这个伏笔。

借用不能比主人活得久

借用还有第二条约束,同样天经地义:引用不能比它指向的值活得久。你不能借一本书,然后在书被销毁之后还拿着借条去看它 —— 那借条指向的是一块已经不存在的东西(悬垂引用)。

let r;
{
    let x = String::from("临时的");
    r = &x;            // r 借了 x
}                      // ✗ x 在这里没了,可 r 还想指着它
println!("{}", r);     // error[E0597]: `x` does not live long enough

这条约束,展开来就是让无数人头疼的生命周期。但你看,它本身一点都不神秘 —— 它只是「借条不能比书活得久」这句大白话。第 8 章我们会用一台专门的 demo,把它彻底画清楚。

方法的三种接收者

你会在方法定义里反复见到 self 的三种写法,现在正好一次看懂 —— 它们就是「移动 / 共享借 / 可变借」的方法版:

写法含义调用后原对象
&self共享借用自己(只读方法,最常见)还能用
&mut self可变借用自己(要改状态的方法)还能用
self拿走自己的所有权(消费型方法,如 into_xxx用不了了

所以当你看到某个方法签名是 fn into_bytes(self),你立刻就知道:调它会消耗掉这个对象。所有权、借用的那套心智,在方法这里原样复用。

⚠ &String 能用的地方,几乎都该写 &str

上面为了讲解写了 &String,但真实代码里你几乎总该写 &str。原因第 9 章讲 —— 简单说,&str 更通用(字符串字面量、String 都能传给它),限制更少。现在先有个印象:参数要「只读地看一段字符串」,默认写 &str这是 Rust 里最常见的地道写法之一。

回流

⟲ 回流 · 别的语言里,一切默认都是「共享可变」

Java / Kotlin / Python / JS:你传一个 ArrayList 给方法,方法拿到的是同一个列表的引用,它能随便改,改了你这边也变 —— 这就是「共享 + 可变」同时存在,Rust 眼里的头号危险,在这些语言里却是默认行为。所以这些语言里才有那么多「防御性拷贝」「不可变集合」「别把内部 list 直接返回出去」的最佳实践 —— 它们是在手动、靠自觉地维护 Rust 编译器免费强制的那条铁律。

C++:const T& 大致对应 &TT& 对应 &mut T。但 C++ 不检查别名:你可以同时握着一个 const T& 和一个 T& 指向同一个对象,读到写到一半的状态,编译器不吭声。Rust 把「共享 XOR 可变」变成强制的,于是也顺手打开了别的优化空间 —— 编译器知道 &mut 是独占的,可以放心地做别名分析和优化(这正是 C 的 restrict 关键字苦苦想表达的东西)。

结论还是那句:Rust 没发明新麻烦,它只是把你本来就该守、却没人帮你守的规矩,变成了编译器守。

这一章的一句话

借用让你不夺走所有权就能读或改;代价是那条铁律 —— 读可以扎堆,写必须独处。它不是洁癖,它是「读时不许改」和「无数据竞争」的同一个源头。

规则讲清了。但规则要长在手上,得靠亲手撞。下一章 —— 本书的招牌 —— 是一台真的借用检查器,你改代码,它当场判,还告诉你为什么。是时候去和它过几招了。