引用与借用:& 与 &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 的价值所在。