借用规则

共享引用与可变引用如何在不转移所有权的前提下安全访问值,以及怎样读懂并解决借用冲突。

难度 进阶 时长 标准深度约 10分钟
版本 Rust 1.98
what

借用让代码通过引用临时访问值,而不取得值的所有权; &T 用于共享读取,&mut T 用于独占修改。

trap

只要已有引用之后还会使用,同一内存区域上的冲突借用就会被拒绝; 集合扩容还会让元素引用失效。

fix

先明确每个函数是观察、修改还是取得所有权,再缩短借用的实际使用区间; 需要同时修改不重叠区域时使用安全的拆分 API。

是什么,为什么存在

借用(borrowing) 是通过引用访问值、但不接管其所有权的机制。 引用本身不负责释放被引用的值; 所有者离开作用域时,值仍按所有权规则销毁。 借用因此适合“暂时使用,之后归还控制权”的函数接口。

Rust 区分 共享引用(shared reference) &T 可变引用(mutable reference) &mut T。 共享引用允许多个读取者并存,但不能通过它写入普通数据。 可变引用允许读写,却要求对相应内存区域拥有独占访问权。

这些限制解决的是引用有效性与别名写入问题。 若一个集合扩容后仍使用旧元素指针,指针可能已悬空; 若一个读取者观察数据时另一个别名同时写入,读取结果可能失去一致性。 借用检查器(borrow checker) 在编译时拒绝这些重叠关系,安全 Rust 因而不会把检查推迟到偶然触发的运行时路径。

你会在函数参数、切片、迭代器、模式匹配和方法调用中不断遇到借用。 接口接收 &str&[T],通常表示只观察调用方的数据; 接收 &mut T,表示可在调用期间修改同一个值; 接收 T,则可能消费或转移所有权。

工作原理

对同一个值或重叠的内存区域,借用规则可以压缩成两个约束:任何会被继续使用的引用都必须有效; 在一次访问发生时,要么存在一个或多个共享引用,要么存在一个可变引用,不能让两类有效访问发生冲突。 “共享”与“独占”描述的是访问权限,而不是变量是否写了 mut

let view = &value 创建共享借用。 你可以复制共享引用,也可以从它继续取得更短的共享借用。 共享引用不能直接写入普通 T,但 CellRefCell、锁和原子类型等内部可变性机制有各自的检查规则; 因此,“有 &T 时底层数据绝不会改变”并不是普遍成立的表述。

let edit = &mut value 创建可变借用,前提是绑定允许修改,而且相关区域没有冲突访问。 &mut T 表达独占权限,所以它不像 &T 那样实现 Copy。 把它直接赋给另一个变量可能移动引用; 需要暂时把权限借给较短操作时,可以显式重新借用(reborrow)。

借用检查器分析的是位置表达式(place expression)及其可能重叠的区域。 它能直接看出结构体的不同字段互不重叠,却通常不会证明两个切片索引一定不同。 标准库的 split_at_mut 等 API 在内部建立安全边界,再把两个不重叠区域作为独立的可变切片交给调用方。

借用的持续时间由引用之后的实际使用与控制流决定,而不一定延伸到包围它的右花括号。 这个模型叫作 非词法生命周期(non-lexical lifetimes,NLL) 。 引用最后一次使用之后,如果所有路径都不再读取它,后续代码通常可以开始一个原本会冲突的借用。

下面的访问表把接口意图与调用方仍可做的事放在一起。 它只讨论普通安全引用; 内部可变性与并发同步在后面的边界部分单独说明。

参数形态被调用方可以做什么调用期间的核心约束
&T观察 T不得发生与该读取冲突的写入
&mut T观察并修改 T对相应区域保持独占访问
T拥有并可能消费 T调用方通常在移动后不能再用原绑定

编译错误中的 “borrowed as immutable”“borrowed as mutable” 和 “borrow later used here” 应连起来读。 先定位第一次借用,再定位最后一次使用,最后检查中间哪项访问需要相反权限。 错误标注的是一个冲突区间,不是在宣判某个变量永远不能再用。

示例

多个共享读取者

第一个例子让结账逻辑和审计逻辑同时查看同一个价格向量。 两个引用都只读取数据,因此可以共存; order_total 接收切片,使它既能处理 Vec<u32>,也能处理数组或其他切片。

shared_borrows.rs
fn order_total(prices: &[u32]) -> u32 {
    prices.iter().sum()
}

fn main() {
    let prices = vec![1_250, 2_750, 500];

    let checkout = &prices;
    let audit = &prices;

    println!("total: {}", order_total(checkout));
    println!("items: {}, audit: {:?}", checkout.len(), audit);
}
total: 4500
items: 3, audit: [1250, 2750, 500]

checkoutaudit 都借用 prices,没有取得向量及其缓冲区的所有权。 main 结束前,所有者 prices 仍然存在,所以两个引用都有效。

参数使用 &[u32],而不是 &Vec<u32>,把接口限制在真正需要的能力上:读取一段连续元素。 借用规则不仅防止错误,也能帮助 API 表达权限。

先共享读取,再独占修改

第二个例子先打印共享快照,再修改余额。 snapshot 的最后一次使用发生在调用 debit 之前,因此 NLL 允许后续创建可变借用,无需额外花括号。

mutable_borrow.rs
fn debit(balance: &mut u32, amount: u32) {
    *balance = balance.saturating_sub(amount);
}

fn main() {
    let mut balance = 5_000;

    let snapshot = &balance;
    println!("before: {snapshot}");

    debit(&mut balance, 750);
    println!("after debit: {balance}");

    let correction = &mut balance;
    *correction += 200;
    println!("through mutable borrow: {correction}");

    println!("final: {balance}");
}
before: 5000
after debit: 4250
through mutable borrow: 4450
final: 4450

debit 的签名说明它可以修改余额,但不会保留余额的所有权。 调用返回后,可变借用结束,main 仍拥有并能读取 balance

correction 最后一次用于第三行输出。 之后读取 balance 不与它冲突。 若在最终输出之后再次使用 correction,两次访问的区间就会重叠,编译器会拒绝直接读取 balance

拆分后修改两个元素

同时取得 stock[from]stock[to] 的可变引用会被保守地视为可能重叠,因为两个索引可能相等。 split_at_mut(2) 把切片拆成边界已证明不重叠的两部分,随后可以从左右两侧各借用一个元素。

split_inventory.rs
fn move_one(source: &mut u32, destination: &mut u32) -> bool {
    if *source == 0 {
        return false;
    }

    *source -= 1;
    *destination += 1;
    true
}

fn main() {
    let mut stock = [4, 1, 0, 3];

    let (west, east) = stock.split_at_mut(2);
    let moved = move_one(&mut west[1], &mut east[0]);

    println!("moved: {moved}");
    println!("stock: {stock:?}");
}
moved: true
stock: [4, 0, 1, 3]

west 覆盖原数组的前两个元素,east 覆盖后两个元素。 安全 API 的返回类型仍是普通 &mut [u32],调用方不需要使用裸指针或 unsafe

左右切片在 move_one 调用后不再使用,因此最终可以整体读取 stock。 如果后面仍要使用 westeast,整体借用就会继续有效,直接读取原数组会产生冲突。

返回输入中的视图

引用可以作为结果返回,但结果必须来自活得足够久的输入。 first_word 的生命周期省略规则把输出引用关联到唯一的输入引用; 它不会复制单词,也不会让 label 获得更长的生命周期。

borrowed_view.rs
fn first_word(text: &str) -> &str {
    text.split_once(' ')
        .map_or(text, |(first, _)| first)
}

fn main() {
    let mut label = String::from("priority order");

    let word = first_word(&label);
    println!("first: {word}");

    label.push_str(" ready");
    println!("label: {label}");
}
first: priority
label: priority order ready

word 指向 label 的缓冲区,所以在打印 word 之前不能调用可能改变该缓冲区的 push_str。 打印完成后不再使用 word,NLL 便允许开始修改。

若函数试图返回函数内部新建的 String 的切片,所有者会在返回时销毁,结果将悬空。 正确接口应返回拥有所有权的 String,或像这里一样让引用来自调用方提供的输入。

陷阱

修复方法: 在修改前提取真正需要的拥有型数据,或保存索引并在修改后重新借用。 若业务需要稳定地址,应选择提供相应保证的数据结构,而不是靠一次运行中没有扩容来推断安全。

修复方法: 先写清接口意图。 只读文本优先接收 &str,只读序列优先接收 &[T]; 只有结果确实需要独立拥有数据时才克隆,并在代码审查中指出这项所有权决定。

修复方法: 返回拥有型值,或者让返回引用明确来自某个输入。 把生命周期理解为引用之间的关系约束,而不是延长分配时间的指令。

修复方法: 只需临时转交权限时创建较短的重新借用,例如 let short = &mut *original。 还要让 short 的最后一次使用尽早发生,再继续通过 original 工作。

修复方法: 按编译错误标出的首次借用、冲突访问与后续使用追踪数据流。 能重排读取与写入就重排; 需要两个区域就安全拆分; 长期需要共享修改时,再选择合适的内部可变性或同步类型。

深入 生命周期描述关系

生命周期描述关系

生命周期标注描述引用之间必须满足的有效性关系。 fn choose<'a>(left: &'a str, right: &'a str) -> &'a str 表示结果不能比两个输入共同有效的范围更久; 它没有要求两个实参从创建到销毁拥有完全相同的词法作用域。

编译器会在每次调用处选择满足约束的具体区域。 若一个输入只在较短范围内有效,返回引用也不能越过那个范围使用。 标注没有改变任何对象何时销毁,只让函数签名公开编译器无法从函数体外猜出的关系。

当输出只有一个可能的输入来源时,生命周期省略规则通常足够。 fn first_word(text: &str) -> &str 等价地表达输出借自 text。 若多个输入引用都可能成为输出,编译器需要显式关系,或需要 API 用枚举、拥有型结果等方式消除歧义。

'static 引用必须能在程序剩余执行期间持续有效,但类型里写 'static 不会赋予数据这种存活能力。 字符串字面量通常可以产生 &'static str; 函数内创建的 String 不可以。 把 'static 当作“让编译器别检查”的按钮,是生成代码中尤其危险的误解。

重新借用保留后续权限

可变引用代表一份可转交的独占权限。 直接移动它,接收方就取得这份权限; 从它重新借用,则在更短区间内暂时限制原引用,短借用结束后原引用可以继续使用。

函数调用经常自动创建重新借用。 例如,一个变量 editor: &mut String 传给接收 &mut String 的函数后,通常还能在调用返回后继续使用 editor。 编译器依据期望类型插入短借用,而不是永久移动调用方的引用。

复杂泛型、模式解构或显式赋值可能让这个意图不够清楚。 此时 &mut *editor 能明确表达重新借用。 不要为了绕过错误改用裸指针; 先确认需要的是移动权限、短暂借出,还是把数据拆成不重叠部分。

两个阶段不是两种引用

某些方法调用使用两阶段借用(two-phase borrow):可变接收者的借用先保留,计算其他实参后再激活。 这就是 values.push(values.len()) 能通过检查的原因之一,因为 len() 的共享读取发生在独占写入激活之前。

两阶段借用是对特定隐式可变借用的检查细节,不会普遍放宽规则。 先把 &mut values 存入变量,再同时读取 values,通常不能依赖同样待遇。 API 设计和代码审查仍应以清晰、不重叠的访问阶段为准。

借用检查的边界

编译器必须在不运行程序的情况下证明安全,因此会保守拒绝某些人能看出互不重叠的索引操作。 对切片而言,两个任意索引可能相等; split_at_mut 把运行时边界检查与不重叠保证封装在一个经过审查的安全 API 中。

结构体字段的情况更直接。 编译器通常能同时借用 record.namerecord.status,因为字段布局让它们确定不重叠。 把所有状态藏进一个集合或一个返回整个 &mut self 的辅助方法,会丢失这种局部信息并扩大冲突范围。

借用检查器保证的是安全 Rust 中引用的有效性和访问规则,不保证业务事务正确。 两个阶段分别取得可变借用仍可能产生丢失更新,缩短锁守卫也可能破坏原子性。 编译通过只是内存安全证据,不是领域不变量的完整证明。

内部可变性改变检查位置

Cell<T>RefCell<T> 允许通过共享外壳修改内部值,但没有取消借用规则。 RefCell<T> 把共享与独占借用检查移到运行时; 重叠调用 borrow_mut 或在可变借用期间调用 borrow 会触发 panic。

并发代码中的 Mutex<T>RwLock<T> 通过守卫控制访问。 它们还引入阻塞、锁顺序、中毒处理和临界区大小等问题,不能只因为静态借用难写就机械套上一层 Arc<Mutex<_>>

选择内部可变性时,应写下为何静态权限不足、运行时失败如何处理,以及共享范围由谁拥有。 单线程共享对象可考虑 CellRefCell; 跨线程共享需要满足线程安全约束并选择同步原语。 具体模式属于 rust/refcell-cell 与并发相关主题。

读懂冲突而不是对抗编译器

解决错误时先把诊断转换成时间线:引用从哪里创建,在哪些分支使用,哪个操作需要冲突权限,引用的最后使用在哪里。 这个方法也适用于闭包捕获、迭代器和异步状态机,因为它们可能把借用保持得比表面上一行表达式更久。

然后选择最小的语义调整:重排阶段、缩小返回类型、拆分数据,或把短暂观察结果转换为拥有型值。 只有业务确实需要共享所有权或运行时可变性时,才引入引用计数和内部可变性。 让类型反映真正的所有权关系,通常比逐个压掉编译错误更稳定。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?