引用与借用:& 与 &mut 的规则
如果 move 语义是所有权的「默认状态」,引用就是它的「例外条款」:不拿走所有权也能临时用值。这篇解决的问题是:& 与 &mut 的使用规则是什么、为什么编译器总是拒绝「看起来没问题」的代码、如何把借用冲突的代码改对。借用检查器是 Rust 学习曲线最陡的那一段,本篇的目标是把规则讲清楚、把错误信息读明白、把修改方向给出来。
一、借用规则的完整表述
所有借用冲突都源自一条核心规则,官方书里叫「一可变或多个不可变」:在同一时间,你要么有一个可变引用,要么有任意多个不可变引用,二者不能并存。这条规则压缩到一张表里就是全部内容。
| 场景 | 编译结果 | 原因 |
|---|---|---|
| 两个不可变引用并存 | 通过 | 只读共享安全 |
| 一个可变引用独占 | 通过 | 唯一写者无竞争 |
| 可变与不可变并存 | 失败 | 读到中间状态 |
| 两个可变引用并存 | 失败 | 数据竞争 |
「引用必须始终有效」是另一条底层规则,它对应悬挂引用错误,归生命周期一章细讲。本篇专攻前一条。
为什么可变与不可变不能并存?想象不可变引用正在遍历一个 Vec,此时可变引用 push 了一条数据导致扩容,旧指针指向已被释放的旧缓冲区——这就是经典的迭代器失效,借用检查器在编译期直接禁掉这种组合 Rust 才安心。同样的理由也解释了为什么两个可变引用不能并存:若两处代码同时握有写权限,执行顺序的不确定性就会制造数据竞争,而 Rust 的承诺是编译期拒绝、运行期不赌运气。
二、不可变引用:只读借用
创建引用的动作叫借用,因为它不改变所有权。函数参数写 &String,调用方传 &s 即可:
fn measure(s: &String) -> usize {
s.len()
}
fn main() {
let s = String::from("你好,Rust");
let len = measure(&s);
println!("{} 有 {} 字节", s, len); // s 仍然可用
}
不可变引用可以同时存在任意多个,因为没有修改就没有数据竞争。引用作用域结束时自动归还,借用失效——这一切都在编译期完成,运行时零成本。顺带说明方法调用的便利:有了引用,你可以在不拿走所有权的情况下调用类型的方法,比如 s.len() 或 v.is_empty(),它们内部接收的都是 &self,这正是标准库 API 大量只读方法可以直接在 &T 上调用的原因。
写函数签名时的通用原则是「能收引用就不收所有权」。对外公开的接口尤其如此:收 &str 的函数对调用方最友好,收 String 等于把分配费用强加给每个人。把这条原则与 move 一章的决策表对照看,你会发现它们讲的是同一个故事的两面。
三、可变引用与使用区域
可变引用要求独占。最常见的编译冲突长这样:
fn main() {
let mut v = vec![1, 2, 3];
let r = &mut v; // 可变借用开始
r.push(4);
// let r2 = &mut v; // 冲突:cannot borrow as mutable more than once
println!("{:?}", r);
}
关键的细微之处在于「借用区域」而非「代码行」:引用从创建到最后一次使用之间是它的活跃区间。借用一旦不再使用就视为归还,哪怕词法作用域还没结束——这是 Rust 后期引入的非词法生命周期(NLL)带来的行为,让大量过去编译不过的代码变得合法:
fn main() {
let mut v = vec![1, 2, 3];
let r = &mut v;
r.push(4);
println!("{:?}", v); // r 在此之前已不再使用,合法
}
编译器会精确计算每个借用的最后使用点,错误信息里画出的作用域边界比肉眼判断的更可靠。看到 cannot borrow ... as mutable 时,不要和编译器较劲,按下面顺序排查:哪个引用用到了最后、它是不是可以提前结束、能不能把冲突的两段代码在时间上错开。
新手最容易忽略的一点是:借用冲突与数据是否真的被修改无关,只与「是否存在这样的可能性」有关。哪怕可变引用只是被传进一个空函数、从未真正写任何东西,编译器照样拒绝它与不可变引用并存,因为编译器做的是静态权限分析而不是运行时行为追踪。理解这一点有助于放下「我明明没改它为什么报错」的委屈——权限上的可能冲突,在 Rust 眼里就是冲突。
四、典型冲突形态与修法
实际项目里的借用冲突通常比教科书例子复杂,但拆开后都是几种固定形态。
| 形态 | 典型错误信息 | 常见解法 |
|---|---|---|
| 先读后写同一数据 | cannot borrow as mutable because it is also borrowed as immutable |
提前结束读借用 |
| 同一集合的多个可变引用 | cannot borrow as mutable more than once |
拆成多个语句块 |
| 引用与原变量混用 | cannot borrowvas mutable |
明确所有权流向 |
| 循环中边遍历边修改 | 同上 | 收集待改项再统一改 |
第四种形态值得展开:你拿着不可变引用遍历集合,循环体里又想原地修改元素,编译器必然拒绝。两种改法:先在遍历里收集要修改的索引或值,遍历结束后统一改;或者改用可变迭代器 iter_mut(),让每次迭代只持有单个元素的可变引用。第二种更地道也更高效,是日常首选。
另一个常见形态是「多字段借用形似冲突」:结构体的两个字段分别需要可变借用,看起来撞上了,实际上 Rust 支持结构体字段级别的拆分借用,把 &mut s.a 与 &mut s.b 同时拿到是合法的,因为它们指向不同内存。只有整借 &mut s 与字段借并存时才冲突。这个特性写解析器、游戏状态机时经常用到。
再补一种容易被忽略的形态:把可变引用存进集合或结构体后再新建借用。比如你把 &mut v 存进一个结构体字段,此后只要该结构体还活着,原变量 v 的所有其他借用都会被拒绝,因为借用的时长被拉长到了结构体的生命周期。对应的修法通常是缩短引用的存活范围:用完即还,而不是把它长期保存起来。记住借用的黄金习惯是「用时借、用完还」,把引用当作短命工具而非长期持有物,绝大多数冲突会自动消失。
五、让借用规则成为设计工具
度过阵痛期后你会发现,借用检查器不只是找茬的:它在反向塑造你的数据结构。经常出现借用冲突往往意味着代码组织方式有问题——全局可变状态、过长的函数、数据间循环依赖,这些设计在别的语言里表现为运行时 bug,在 Rust 里表现为编译失败。合格的应对不是 clone 买平安,而是重构:把数据按所有权边界拆开,让每段代码只持有它真正需要的部分。
一个高频的实际困惑:结构体里同时存了数据和对数据的引用。这通常直接违反借用规则,正确做法是把数据与使用它的方法放同一结构体、通过短生命周期参数传递引用,或者干脆重新规划所有权。借用与结构体生命周期的组合留到下一章详谈,这里记住方向:冲突是设计气味的信号。
六、借用与并发的连续性
借用规则看似只在单线程里找麻烦,其实它是 Rust 并发安全的地基。到多线程一章你会认识 Send 与 Sync 两个标记 trait,它们的核心逻辑就是借用规则在跨线程情境下的延伸:只读共享的数据天然可多线程读,独占写入的数据天然不可同时被两个线程碰。今天你和借用检查器搏斗学会的每一招,到多线程章节都会变成即战力。反过来说,如果一个单线程程序里都频繁出现借用冲突,它在并发版本里几乎必然出数据竞争——编译器提前拉响的警报往往是对的。
把错误当文档读、把冲突当设计反馈,这是度过借用阵痛期最有效的心态。命令行与解析类项目是练手的好战场,正则表达式精进 的示例就是一类「全程借用、几乎不克隆」的代码范本。若想把这套推理方式教给团队,Claude Code 使用指南 里有把编译错误转成可分享知识的方法。
延伸阅读
- 下一篇「生命周期」解释引用为什么不会变成悬挂指针,是借用规则的静态保障原理,读完这一章整套所有权模型就闭环了。
- 写命令行工具处理大文本时,正则表达式精进 的示例展示了零拷贝借用模式,把解析结果全部保持在引用层面。
- 若你发现借用冲突迫使你重写数据流,不必沮丧,这正是 Rust 在编译期帮你排除并发 bug 的价值所在。
