《Rust 编译器原理》完整目录
- 前言
- 第1章 编译管线全景:从源码到机器码的完整旅程
- 第2章 所有权系统:编译期内存管理的核心机制
- 第3章 借用检查器:编译器如何证明内存安全(当前)
- 第4章 生命周期:编译器如何推断引用的有效范围
- 第5章 内存布局:编译器如何排列数据
- 第6章 单态化:泛型的编译期展开
- 第7章 Trait 静态分发:零成本抽象的编译器实现
- 第8章 Trait Object 与虚表:运行时多态的内存布局
- 第9章 async/await:状态机的编译器变换
- 第10章 Pin、Waker 与 Future:异步运行时的三大支柱
- 第11章 闭包:匿名函数的编译器实现
- 第12章 unsafe:安全抽象的逃生舱
- 第13章 FFI:与 C 世界的桥梁
- 第14章 宏系统:编译期的元编程引擎
- 第15章 MIR 优化:编译器的中间表示与优化管线
- 第16章 LLVM 代码生成:从 MIR 到机器码
- 第17章 增量编译:让重编译只做必要的事
- 第18章 设计哲学与架构决策
第3章 借用检查器:编译器如何证明内存安全
"借用检查器不是你的敌人,它是唯一一个在编译期就替你找出数据竞争的队友。"
:::tip 本章要点
- Rust 的两条借用规则如何在编译期消除数据竞争、悬垂指针和 use-after-free
- 借用检查运行在 MIR(中层中间表示)之上,而非源码或 HIR
- Place 和 PlaceRef:编译器如何精确表示每一个内存位置
- BorrowSet:编译器在每个程序点维护的活跃借用集合
- NLL(Non-Lexical Lifetimes)的核心洞察——生命周期不必匹配词法作用域
- 两阶段借用(Two-Phase Borrows)让
vec.push(vec.len())合法编译 - 借用检查器如何生成带有修复建议的高质量错误信息
- 再借用(Reborrowing)机制:
&mut如何被临时"拆分" - 内部可变性:Cell、RefCell、Mutex 如何绕过静态借用检查
- Polonius:基于 Datalog 的下一代借用检查器 :::
3.1 两条借用规则:内存安全的数学基础
Rust 的整个内存安全保证建立在两条看似简单的规则之上:
规则一:同一时刻,要么只有一个可变引用(&mut T),要么只有任意数量的不可变引用(&T),二者不能共存。
规则二:引用的生命周期不能超过被引用数据的生命周期——即引用必须始终有效。
这两条规则合在一起,在编译期消除了三类最危险的内存错误:
消除数据竞争:规则一直接消除了数据竞争的必要条件——如果你持有 &mut T,编译器保证没有任何其他引用指向同一数据。消除悬垂指针:规则二保证引用永远指向有效内存,函数不能返回指向局部变量的引用。消除 use-after-free:当一个值被移动或释放后,所有指向它的引用都会失效。
fn safety_example() {
let mut v = vec![1, 2, 3];
let first = &v[0]; // 不可变借用
// v.clear(); // 编译错误!v 被不可变借用,不能调用 &mut self 方法
println!("{}", first); // 使用借用
v.clear(); // 借用已结束,现在合法
}
这些规则是充分条件而非必要条件。编译器可能拒绝一些实际安全的代码(假阳性),但绝不会放过任何不安全的代码(假阴性)。这种保守性是 Rust 内存安全保证的根基。
3.2 借用检查运行在 MIR 上
一个常见的误解是借用检查器直接分析源代码。实际上,借用检查运行在 MIR(Mid-level Intermediate Representation,中层中间表示)之上——这是 Rust 编译器在降低 HIR 之后、生成 LLVM IR 之前的中间表示。
graph LR
A["源代码"] --> B["AST"]
B --> C["HIR<br/>(高层 IR)"]
C --> D["MIR<br/>(中层 IR)"]
D --> E["借用检查<br/>rustc_borrowck"]
E --> F["LLVM IR"]
F --> G["机器码"]
style D fill:#3b82f6,color:#fff,stroke:none
style E fill:#ef4444,color:#fff,stroke:none
为什么选择 MIR 而不是源代码或 HIR?原因有三:
第一,MIR 是控制流图(CFG),而非树形结构。 源代码和 HIR 都是树形的——if-else、match、loop 层层嵌套。但借用分析需要追踪所有可能的执行路径,包括循环的回边(back edge)和提前返回。MIR 将代码展平为基本块(Basic Block)和边(Edge),天然适合数据流分析。
第二,MIR 消除了语法糖。 for 循环、? 运算符、async/await——这些高层语法在 MIR 中都被还原为基本操作。借用检查器不需要理解每种语法的特殊语义,只需要分析统一的 MIR 操作。
第三,MIR 让临时值显式化。 源代码中 foo().bar() 的中间结果是隐式的,但在 MIR 中每个临时值都有明确的局部变量和生命周期,使得精确的借用分析成为可能。
在编译器源码中,借用检查的入口是 rustc_borrowck/src/lib.rs 中的 mir_borrowck 函数:
// compiler/rustc_borrowck/src/lib.rs
fn mir_borrowck(
tcx: TyCtxt<'_>,
def: LocalDefId,
) -> Result<&FxIndexMap<LocalDefId, ty::DefinitionSiteHiddenType<'_>>, ErrorGuaranteed> {
let (input_body, _) = tcx.mir_promoted(def);
let input_body: &Body<'_> = &input_body.borrow();
if let Some(guar) = input_body.tainted_by_errors {
Err(guar)
} else if input_body.should_skip() {
Ok(tcx.arena.alloc(Default::default()))
} else {
let mut root_cx = BorrowCheckRootCtxt::new(tcx, def, None);
root_cx.do_mir_borrowck();
root_cx.finalize()
}
}
注意 tcx.mir_promoted(def) 这一行——它获取的是已经降低为 MIR 的函数体,而非 HIR 或源代码。
3.3 Place 和 PlaceRef:精确表示内存位置
借用检查器的核心任务是追踪"谁借用了什么"。在 MIR 中,"什么"用 Place 表示——它精确描述了一个内存位置。
一个 Place 由两部分组成:
- local:一个局部变量(
_1、_2等) - projection:零个或多个投影操作,依次从局部变量向下访问
// MIR 中的 Place 示例
_1 // 局部变量 _1
(*_1) // 解引用 _1
(*_1).field0 // 解引用后访问第 0 个字段
(*_1).field0[_2] // 再索引
编译器中 Place 的定义(在 rustc_middle::mir 中):
pub struct Place<'tcx> {
pub local: Local,
pub projection: &'tcx List<PlaceElem<'tcx>>,
}
pub enum PlaceElem<'tcx> {
Deref, // *place
Field(FieldIdx, Ty<'tcx>), // place.field
Index(Local), // place[index]
ConstantIndex { .. }, // 常量索引
Subslice { .. }, // 切片
Downcast(Option<Symbol>, VariantIdx), // 枚举变体
OpaqueCast(Ty<'tcx>), // 不透明类型转换
Subtype(Ty<'tcx>), // 子类型
}
PlaceRef 是 Place 的借用视图,避免不必要的复制:
pub struct PlaceRef<'tcx> {
pub local: Local,
pub projection: &'tcx [PlaceElem<'tcx>],
}
Place 的冲突判定是借用检查器最精密的部分之一。两个 Place 是否冲突,决定了两个借用是否可以共存。编译器在 places_conflict.rs 中实现了这个判定逻辑。核心思路是:从两个 Place 的根部开始,逐步比较每一层投影:
// compiler/rustc_borrowck/src/places_conflict.rs
// 比较过程示例:
// BORROW: (*x1[2].y).z.a
// ACCESS: (*x1[i].y).w.b
//
// 逐步比较:
// x1 | x1 -- 相同
// x1[2] | x1[i] -- 可能相等也可能不相交
// x1[2].y | x1[i].y -- 可能相等也可能不相交
// *x1[2].y | *x1[i].y -- 可能相等也可能不相交
// (*x1[2].y).z | (*x1[i].y).w -- 不相交!不需要继续检查
PlaceConflictBias 枚举控制了在无法确定时(如 a[i] vs a[j])是假设冲突(Overlap,用于借用检查)还是假设不冲突(NoOverlap)——体现了借用检查器"宁可误报不漏报"的保守性原则。
3.4 BorrowSet:活跃借用的全量记录
在开始数据流分析之前,编译器首先遍历整个 MIR,收集所有的借用操作,构建 BorrowSet。这是借用检查器的"原材料"。
// compiler/rustc_borrowck/src/borrow_set.rs
pub struct BorrowSet<'tcx> {
/// 核心映射:Location -> BorrowData
/// 每个借用由其在 MIR 中的位置唯一标识
pub(crate) location_map: FxIndexMap<Location, BorrowData<'tcx>>,
/// 激活映射:哪些位置会激活两阶段借用
pub(crate) activation_map: FxIndexMap<Location, Vec<BorrowIndex>>,
/// 局部变量映射:每个局部变量上的所有借用
pub(crate) local_map: FxIndexMap<mir::Local, FxIndexSet<BorrowIndex>>,
/// 局部变量在函数退出时的状态
pub(crate) locals_state_at_exit: LocalsStateAtExit,
}
每个借用的详细信息存储在 BorrowData 中:
pub struct BorrowData<'tcx> {
/// 借用的预留位置(两阶段借用的起点)
pub(crate) reserve_location: Location,
/// 借用的激活位置(两阶段借用的终点)
pub(crate) activation_location: TwoPhaseActivation,
/// 借用种类:共享 / 可变 / 伪借用
pub(crate) kind: mir::BorrowKind,
/// 借用关联的区域(region)
pub(crate) region: RegionVid,
/// 被借用的 Place
pub(crate) borrowed_place: mir::Place<'tcx>,
/// 借用结果被赋值到的 Place
pub(crate) assigned_place: mir::Place<'tcx>,
}
BorrowKind 不仅仅是"共享"和"可变":除了 Shared(&T)和 Mut(&mut T),还有 Fake(伪借用,出现在 match 中,防止模式匹配期间修改被匹配的值)。MutBorrowKind 进一步区分了 Default(普通可变借用)、TwoPhaseBorrow(两阶段借用)和 ClosureCapture(闭包捕获的唯一借用)。
3.5 MIR 上的借用检查算法
有了 BorrowSet,编译器执行完整的借用检查流程。这个流程涉及多个子分析的组合:
graph TD
A["MIR 函数体"] --> B["收集借用信息<br/>构建 BorrowSet"]
B --> C["MIR 类型检查<br/>生成区域约束"]
C --> D["NLL 区域推断<br/>计算区域值"]
D --> E["数据流分析<br/>三个子分析"]
E --> F["逐语句检查<br/>检测借用冲突"]
F --> G{"冲突?"}
G -->|"是"| H["生成诊断信息"]
G -->|"否"| I["借用检查通过"]
style A fill:#64748b,color:#fff,stroke:none
style B fill:#3b82f6,color:#fff,stroke:none
style C fill:#8b5cf6,color:#fff,stroke:none
style D fill:#10b981,color:#fff,stroke:none
style E fill:#f59e0b,color:#fff,stroke:none
style F fill:#ef4444,color:#fff,stroke:none
style H fill:#ef4444,color:#fff,stroke:none
style I fill:#10b981,color:#fff,stroke:none
3.5.1 三个子数据流分析
编译器不是用单一分析完成所有检查,而是组合了三个独立的数据流分析:
// compiler/rustc_borrowck/src/dataflow.rs
pub(crate) struct Borrowck<'a, 'tcx> {
pub(crate) borrows: Borrows<'a, 'tcx>, // 活跃借用
pub(crate) uninits: MaybeUninitializedPlaces<'a, 'tcx>, // 可能未初始化的位置
pub(crate) ever_inits: EverInitializedPlaces<'a, 'tcx>, // 曾经初始化过的位置
}
- Borrows:追踪哪些借用在每个程序点是活跃的。一个借用在创建时变为活跃,在其关联的区域结束、被存储的地方被覆盖、或者存储的局部变量执行
StorageDead时变为不活跃。 - MaybeUninitializedPlaces:追踪哪些 Place 可能尚未初始化。用于检测对未初始化变量的使用和移动后使用。
- EverInitializedPlaces:追踪哪些 Place 曾经被初始化过。用于区分"从未初始化"和"曾初始化但已被移动"。
这三个分析各自通过不动点迭代(iterate_to_fixpoint)求解,然后组合成一个统一的结果:
// compiler/rustc_borrowck/src/lib.rs
fn get_flow_results<'a, 'tcx>(
tcx: TyCtxt<'tcx>,
body: &'a Body<'tcx>,
move_data: &'a MoveData<'tcx>,
borrow_set: &'a BorrowSet<'tcx>,
regioncx: &RegionInferenceContext<'tcx>,
) -> Results<'tcx, Borrowck<'a, 'tcx>> {
let borrows = Borrows::new(tcx, body, regioncx, borrow_set)
.iterate_to_fixpoint(tcx, body, Some("borrowck"));
let uninits = MaybeUninitializedPlaces::new(tcx, body, move_data)
.iterate_to_fixpoint(tcx, body, Some("borrowck"));
let ever_inits = EverInitializedPlaces::new(body, move_data)
.iterate_to_fixpoint(tcx, body, Some("borrowck"));
// 组合三个子分析的结果
let analysis = Borrowck {
borrows: borrows.analysis,
uninits: uninits.analysis,
ever_inits: ever_inits.analysis,
};
// ...
}
3.5.2 控制流分析:追踪分支与循环中的借用
数据流分析的核心挑战是处理控制流。在分支处,活跃借用集合需要分裂;在汇合处,需要合并。在循环中,需要通过不动点迭代来收敛。
考虑这个例子:
fn control_flow_example(flag: bool) {
let mut x = 5;
let r;
if flag {
r = &x; // 分支 A:创建借用
println!("{}", r); // 使用借用
} else {
// 分支 B:没有借用 x
}
x = 10; // 这行是否合法?
}
flowchart TD
BB0["bb0: switchInt(flag)"] -->|true| BB1["bb1: r = &x<br/>println!(r)<br/>活跃借用: {r -> &x}"]
BB0 -->|false| BB2["bb2: (空)<br/>活跃借用: {}"]
BB1 --> BB3["bb3: x = 10<br/>活跃借用: {} ∪ {} = {}"]
BB2 --> BB3
style BB0 fill:#64748b,color:#fff,stroke:none
style BB1 fill:#3b82f6,color:#fff,stroke:none
style BB2 fill:#10b981,color:#fff,stroke:none
style BB3 fill:#10b981,color:#fff,stroke:none
在 bb3(汇合点),编译器需要合并两个分支的活跃借用集合。分支 A 中 r 的借用在 println! 之后结束(NLL),分支 B 中没有借用。合并结果为空集,所以 x = 10 是合法的。
但如果 r 在 bb3 之后还被使用(println!("{}", r)),那两个分支的活跃借用都传播到汇合点,x = 10 就会被拒绝。
Borrowck 的 Analysis trait 为每条 MIR 语句定义了两个效果阶段——apply_early_statement_effect(语句执行前)和 apply_primary_statement_effect(语句执行后)。三个子分析在每个阶段并行更新各自的状态,使得编译器能精确处理语句内部的借用关系。
3.5.3 逐路径借用冲突检测
在 path_utils.rs 中,each_borrow_involving_path 函数是冲突检测的核心。它接受一个 Place 和访问深度,遍历该 Place 上的所有活跃借用,检测是否存在冲突:
// compiler/rustc_borrowck/src/path_utils.rs
pub(super) fn each_borrow_involving_path<'tcx, F, I, S>(
s: &mut S,
tcx: TyCtxt<'tcx>,
body: &Body<'tcx>,
access_place: (AccessDepth, Place<'tcx>),
borrow_set: &BorrowSet<'tcx>,
is_candidate: I,
mut op: F,
)
一个关键优化是 local_map——只检查同一局部变量上的借用,而不是遍历所有借用:
// 只检查同一局部变量的借用,极大减少搜索空间
let Some(borrows_for_place_base) = borrow_set.local_map.get(&place.local)
else { return };
3.6 Non-Lexical Lifetimes:NLL 的核心洞察
3.6.1 从词法生命周期到 NLL
在 Rust 2018 之前,借用检查使用词法生命周期(Lexical Lifetimes)——引用的生命周期从声明开始,到所在作用域结束为止。
fn lexical_problem() {
let mut data = vec![1, 2, 3];
let first = &data[0]; // 不可变借用开始
println!("{}", first); // 最后一次使用 first
data.push(4); // 旧编译器:错误!first 的借用还没结束
} // 旧编译器认为 first 在这里才结束
NLL 的核心洞察是:生命周期不需要匹配词法作用域。一个借用应该在最后一次使用后立即结束,而不是等到作用域结束。
// NLL 下的分析
fn nll_solution() {
let mut data = vec![1, 2, 3];
let first = &data[0]; // 不可变借用开始
println!("{}", first); // 最后一次使用 first → 借用在此处结束
data.push(4); // OK!此时没有活跃的不可变借用
}
graph TD
subgraph lexical["词法生命周期"]
direction TB
L1["let first = &data[0]"] --- L2["println!(first)"]
L2 --- L3["data.push(4)"]
L3 --- L4["} // 作用域结束"]
L1 -.- |"first 的借用范围"| L4
end
subgraph nll["NLL"]
direction TB
N1["let first = &data[0]"] --- N2["println!(first)"]
N2 --- N3["data.push(4) ✓"]
N3 --- N4["} // 作用域结束"]
N1 -.- |"first 的借用范围"| N2
end
style L3 fill:#ef4444,color:#fff,stroke:none
style N3 fill:#10b981,color:#fff,stroke:none
3.6.2 NLL 的区域推断
NLL 不是简单地"缩短借用的生命周期"。它构建了一个完整的约束系统,然后求解满足所有约束的最小区域。在编译器中,NLL 的计算入口是 nll::compute_regions,它的三个核心步骤是:(1) 计算约束图的强连通分量(SCC);(2) 构建 RegionInferenceContext;(3) 调用 regioncx.solve() 求解。
3.6.3 约束系统与不动点求解
区域推断的核心数据结构是 RegionInferenceContext:
// compiler/rustc_borrowck/src/region_infer/mod.rs
pub struct RegionInferenceContext<'tcx> {
/// 每个区域变量的定义
pub(crate) definitions: Frozen<IndexVec<RegionVid, RegionDefinition<'tcx>>>,
/// 活跃性约束:由 MIR 中的使用点生成
liveness_constraints: LivenessValues,
/// Outlives 约束集合:'a: 'b 形式的约束
constraints: Frozen<OutlivesConstraintSet<'tcx>>,
/// 约束图:便于遍历某个区域相邻的约束
constraint_graph: Frozen<NormalConstraintGraph>,
/// 强连通分量(SCC):将互相包含的区域合并
constraint_sccs: ConstraintSccs,
/// 每个 SCC 的最终推断值
scc_values: RegionValues<'tcx, ConstraintSccIndex>,
/// 类型测试:需要在求解后验证的约束
type_tests: Vec<TypeTest<'tcx>>,
// ...
}
约束求解的过程是:
-
收集约束:MIR 类型检查阶段(
type_check)生成两种约束:- 活跃性约束:如果区域
'a在程序点 P 被使用,则'a ⊇ {P} - Outlives 约束:如果存在
'a: 'b关系,则region('a) ⊇ region('b)
- 活跃性约束:如果区域
-
计算 SCC:将约束图中互相 outlives 的区域(
'a: 'b且'b: 'a)合并为一个强连通分量。 -
不动点迭代:初始化每个区域为空集,反复传播约束直到收敛。目标是找到满足所有约束的最小区域——区域越小,借用的存活范围越短,越不容易和其他操作冲突。
-
冲突检测:求解完成后,检查是否有区域被迫包含了不该包含的程序点——如果一个通用区域(universal region)被约束为必须 outlive 它不应该 outlive 的区域,那就是一个错误。
fn region_example() {
let mut x = 5;
let r = &x; // 创建区域 'r,生成约束:'r ⊇ {P1}
if condition() {
println!("{}", r); // 生成约束:'r ⊇ {P2}
} else {
println!("{}", r); // 生成约束:'r ⊇ {P3}
}
x = 10; // 如果 'r ⊇ {P4},则冲突
}
// 求解结果:'r = {P1, COND, P2, P3}(不包含 P4)
// 因此 x = 10 在 P4 合法
3.7 两阶段借用(Two-Phase Borrows)
3.7.1 问题的提出
考虑这段代码:
let mut v = vec![1, 2, 3];
v.push(v.len()); // 这应该合法吗?
表面上看,v.push(...) 需要 &mut v(可变借用),而 v.len() 需要 &v(共享借用)。按照规则,可变借用和共享借用不能同时存在。但这段代码在语义上是安全的——v.len() 在 push 实际修改 v 之前就已经计算完了。
3.7.2 两阶段借用的机制
NLL 引入了两阶段借用来处理这种情况。在 borrow_set.rs 中,两阶段借用的状态用 TwoPhaseActivation 枚举表示:
// compiler/rustc_borrowck/src/borrow_set.rs
pub enum TwoPhaseActivation {
NotTwoPhase, // 普通借用,非两阶段
NotActivated, // 已预留但尚未激活
ActivatedAt(Location), // 在指定位置激活
}
可变借用被分为两个阶段:
graph LR
A["v.push(v.len())"] --> B["阶段 1:预留<br/>&mut v(Reserved)<br/>此时 &mut 尚未生效<br/>允许共享借用 &v"]
B --> C["计算 v.len()<br/>创建 &v(共享借用)<br/>读取长度后释放"]
C --> D["阶段 2:激活<br/>&mut v(Activated)<br/>此时 &mut 真正生效<br/>禁止任何其他借用"]
D --> E["执行 push<br/>修改 v"]
style B fill:#f59e0b,color:#fff,stroke:none
style C fill:#3b82f6,color:#fff,stroke:none
style D fill:#ef4444,color:#fff,stroke:none
style E fill:#10b981,color:#fff,stroke:none
在 path_utils.rs 的 is_active 函数中,编译器判断一个两阶段借用在给定位置是否已激活:
// compiler/rustc_borrowck/src/path_utils.rs
pub(super) fn is_active<'tcx>(
dominators: &Dominators<BasicBlock>,
borrow_data: &BorrowData<'tcx>,
location: Location,
) -> bool {
let activation_location = match borrow_data.activation_location {
TwoPhaseActivation::NotTwoPhase => return true, // 非两阶段,始终活跃
TwoPhaseActivation::NotActivated => return false, // 永不激活
TwoPhaseActivation::ActivatedAt(loc) => loc, // 有激活点
};
// 在预留点和激活点之间,借用不活跃
// 其他位置,借用活跃
// ...
}
3.7.3 两阶段借用的限制
两阶段借用只适用于隐式的方法调用接收者(self 参数),不适用于显式创建的 &mut 引用:
let mut v = vec![1, 2, 3];
// 合法:push 的 &mut self 自动获得两阶段借用
v.push(v.len());
// 不合法:显式 &mut 不会获得两阶段借用
let r = &mut v;
let len = v.len(); // 错误!显式 &mut v 已经存在
r.push(len);
这是因为在 MIR 中,方法调用的接收者借用被标记为 MutBorrowKind::TwoPhaseBorrow,而用户显式创建的 &mut 是 MutBorrowKind::Default。
3.8 错误诊断:从约束违反到人类可读的解释
Rust 编译器的错误信息之所以优秀,很大程度上归功于借用检查器的诊断系统。当检测到冲突时,编译器不是简单地说"借用冲突"——它会逆向追溯约束链,找出导致冲突的根本原因。
诊断代码在 diagnostics/ 目录下,按类型分为 conflict_errors.rs(借用冲突)、move_errors.rs(移动错误)、mutability_errors.rs(可变性)、region_errors.rs(生命周期)、explain_borrow.rs(解释借用原因)等模块。
3.8.1 三段式错误信息
NLL 的一大贡献是三段式错误信息:
fn error_example() {
let mut s = String::from("hello");
let r = &s;
s.push_str(" world");
println!("{}", r);
}
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let r = &s;
| -- immutable borrow occurs here ← (1) 借用创建点
4 | s.push_str(" world");
| ^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here ← (2) 冲突点
5 | println!("{}", r);
| - immutable borrow later used here ← (3) 后续使用点
第三行——"immutable borrow later used here"——是 NLL 特有的。旧编译器只能指出前两个位置。NLL 能额外告诉你"为什么借用还没有结束",这对修复错误极其有帮助:如果你在冲突点之前最后一次使用 r,这段代码就是合法的。
3.8.2 explain_borrow 与区域命名
explain_borrow.rs 实现了 BorrowExplanation 类型,追溯一个借用为何在某个点仍然活跃——"因为后面还在用"(UsedLater)、"因为循环中还在用"(UsedLaterInLoop)、"因为 drop 时还需要"(UsedLaterWhenDropped)、"因为需要满足生命周期约束"(MustBeValidFor)。
region_name.rs 则实现了启发式区域命名系统,将晦涩的内部 '_#1r 转换为人类可读的 "the anonymous lifetime defined here" 或 "the anonymous lifetime in the return type"。
3.9 再借用(Reborrowing)
3.9.1 什么是再借用
再借用是 Rust 中一个经常被忽视但极其重要的机制。当你将 &mut T 传递给一个接受 &mut T 的函数时,编译器并不会移动原始的可变引用,而是创建一个新的、更短生命周期的借用:
fn process(data: &mut Vec<i32>) {
data.push(1);
}
fn main() {
let mut v = vec![1, 2, 3];
let r = &mut v;
process(r); // 再借用:创建 &mut *r,而不是移动 r
r.push(2); // r 仍然可用!
}
在 MIR 中,process(r) 被转换为:
_temp = &mut (*r); // 再借用:从 r 创建一个新的 &mut
process(move _temp); // 传递新的引用
// _temp 被释放,r 重新可用
再借用遵循一个重要的不变量:在再借用期间,原始引用被"冻结"。这确保了在任何时刻仍然只有一个 &mut 是"活跃"的。再借用结束后,原始引用重新可用。再借用也可以"降级"——从 &mut T 创建 &T。
3.9.2 split_at_mut:再借用的极限
标准库的 split_at_mut 利用 unsafe 实现了借用检查器无法自动证明的再借用拆分:
// 标准库的简化实现
pub fn split_at_mut<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
let len = slice.len();
let ptr = slice.as_mut_ptr();
unsafe {
(
std::slice::from_raw_parts_mut(ptr, mid),
std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
借用检查器无法理解"两个切片不重叠"这个语义——它只看到从同一个 &mut [T] 创建了两个 &mut [T]。这个安全性由 unsafe 块的作者(标准库)保证。
3.10 内部可变性:绕过静态借用检查
3.10.1 UnsafeCell:内部可变性的基石
Rust 的借用规则是在编译期静态检查的,但有些场景需要在运行时动态管理借用。内部可变性(Interior Mutability)模式通过 UnsafeCell<T> 实现——它是唯一一个允许通过共享引用修改内容的原语。
// UnsafeCell 的核心:它告诉编译器"这个值可能通过 &self 被修改"
pub struct UnsafeCell<T: ?Sized> {
value: T,
}
impl<T: ?Sized> UnsafeCell<T> {
pub fn get(&self) -> *mut T {
// 通过 &self(共享引用)返回 *mut T(可变裸指针)
// 这是整个内部可变性体系的基础
&raw mut self.value
}
}
从编译器的角度看,UnsafeCell 是一个特殊的类型——在进行别名分析和优化时,编译器知道 &UnsafeCell<T> 不能假设内容不变。
3.10.2 Cell、RefCell、Mutex
基于 UnsafeCell,标准库提供了三种安全封装:
Cell<T>:适用于Copy类型,零运行时开销,通过get()/set()访问。不实现Sync,仅限单线程。RefCell<T>:运行时借用检查。borrow()返回Ref<T>(共享),borrow_mut()返回RefMut<T>(可变)。如果运行时违反借用规则则 panic。Mutex<T>/RwLock<T>:跨线程版本。通过操作系统级锁保证借用规则,lock()返回MutexGuard,释放时自动解锁。
use std::cell::RefCell;
fn refcell_example() {
let data = RefCell::new(vec![1, 2, 3]);
data.borrow(); // 运行时:共享借用
data.borrow_mut().push(4); // 运行时:可变借用(要求无活跃共享借用)
}
3.10.3 内部可变性与借用检查器的关系
从借用检查器的角度,使用内部可变性类型的代码是合法的——所有借用都是 &T(共享引用),不存在 &mut T,因此不会违反两条借用规则。真正的可变性被"内化"到类型内部,由类型自身的实现保证安全性。
这是一种重要的类型系统级抽象:编译器负责静态检查的部分(共享引用不能和可变引用共存),而动态检查的部分(运行时是否有冲突的借用)由库代码负责。
3.11 常见的借用检查器模式与绕过技巧
HashMap 的 entry 模式:match map.get_mut(key) { Some(v) => ..., None => map.insert(...) } 会被拒绝,因为 get_mut 的借用在 None 分支中未结束。entry API 把查找和插入合并为一个操作来绕过。
提前克隆:先收集需要的信息(打断借用关系),再修改集合。
拆分结构体借用:编译器能分析同一函数内不同字段的独立借用(self.buffer[self.index]),但不能跨方法分析(self.get_buffer() 和 self.get_index() 都需要借用 self)。解决方案是直接访问字段或拆分结构体。
索引代替引用:当借用检查器阻止你持有集合元素的引用时,用索引代替——索引是 Copy 的,不创建借用关系。
3.12 Polonius:下一代借用检查器
3.12.1 NLL 的局限性
当前的 NLL 借用检查器存在一个根本性的限制:它的分析是位置不敏感的(location-insensitive)对于条件返回的引用。具体来说,当一个函数可能返回引用也可能不返回时,NLL 无法精确推断:
fn get_or_insert(map: &mut HashMap<u32, String>, key: u32) -> &String {
// NLL 无法编译这段代码
// if let Some(v) = map.get(&key) {
// return v; // 返回了从 map 的不可变借用获得的引用
// }
// map.insert(key, String::new()); // 需要可变借用 map
// &map[&key]
// 必须用 entry API 绕过
map.entry(key).or_insert_with(String::new)
}
NLL 的问题在于:当 map.get(&key) 返回 None 时,不可变借用理应结束,但 NLL 无法证明这一点——因为从 get 获得的引用和函数的返回生命周期关联,而 NLL 不能按分支推断"如果返回了则生命周期延伸,如果没返回则生命周期结束"。
3.12.2 Polonius 的核心思想
Polonius 是 Rust 团队正在开发的下一代借用检查器。它的核心创新是将借用检查建模为 Datalog 查询——一种声明式逻辑编程语言。
传统的 NLL 问题是"区域包含哪些程序点"(region-based),而 Polonius 翻转为"在某个程序点,哪些借用是活跃的"(loan-based)。这个视角翻转让 Polonius 能处理 NLL 无法处理的场景。
在编译器源码中,Polonius 的实现位于 rustc_borrowck/src/polonius/ 目录:
// compiler/rustc_borrowck/src/polonius/mod.rs
/// Polonius 将流敏感的借用检查建模为图上的可达性问题。
///
/// 贷款(loan)的传播被视为从贷款引入点到给定程序点的
/// 可达性问题。类型检查产生的约束让贷款在同一 CFG 点
/// 从一个区域流向另一个区域。活跃性约束让贷款在不同
/// 的 CFG 点之间流动,沿着活跃区域传播。
pub(crate) struct PoloniusContext {
/// 局部化约束图
graph: Option<LocalizedConstraintGraph>,
/// 每个活跃区域的方差信息
live_region_variances: BTreeMap<RegionVid, ConstraintDirection>,
/// NLL 认为"无聊"的局部变量(类型只包含 outlives 自由区域的区域)
pub(crate) boring_nll_locals: FxHashSet<Local>,
}
3.12.3 Datalog 规则
Polonius 的核心规则可以用 Datalog 表述:
% 规则 1:如果在某点创建了贷款,该贷款在该点有效
loan_live_at(Loan, Point) :-
loan_issued_at(Loan, Point).
% 规则 2:贷款通过区域关系传播
loan_live_at(Loan, Point) :-
loan_live_at(Loan, PrevPoint),
cfg_edge(PrevPoint, Point),
region_live_at(Region, Point),
loan_in_region(Loan, Region).
% 规则 3:如果活跃贷款在某点被失效操作命中,报告错误
error(Loan, Point) :-
loan_live_at(Loan, Point),
loan_invalidated_at(Loan, Point).
这些规则声明式地描述了借用检查的逻辑,而不是命令式地编码算法。Datalog 引擎负责高效地计算这些规则的不动点。
3.12.4 方差感知的活跃性约束
Polonius 的一个关键改进是方差感知的活跃性约束。ConstraintDirection 枚举根据类型的方差决定贷款的传播方向:协变(Forward)、逆变(Backward)、不变(Bidirectional)。这让 Polonius 能更精确地追踪贷款在控制流图中的传播路径。
3.12.5 当前状态
Polonius 在编译器中有两个版本:Legacy Polonius(-Zpolonius=legacy,基于外部 polonius-engine crate)和 Polonius Next(-Zpolonius=next,直接集成到 rustc_borrowck,将逻辑重新表述为约束图上的可达性问题)。后者是最终要合并到 stable 的版本,其架构设计已经趋于成熟。
3.13 借用检查的完整流程回顾
以 fn f(data: &mut Vec<i32>) { let first = &data[0]; println!("{}", first); data.push(42); } 为例:
- 降低为 MIR:三条语句——共享借用(bb0[0])、使用(bb0[1])、可变借用+push(bb0[2])
- 构建 BorrowSet:B0(共享
&(*_1)[0])、B1(可变&mut *_1) - 生成区域约束:'r0 ⊇ {bb0[0], bb0[1]},'r1 ⊇ {bb0[2]}
- NLL 求解:'r0 = {bb0[0], bb0[1]},'r1 = {bb0[2]}——两个区域不重叠
- 数据流分析:bb0[2] 活跃借用 = {B1}(B0 已结束)
- 冲突检测:无冲突,编译通过
如果把 println! 移到 data.push 之后,bb0[2] 的活跃借用变为 {B0, B1},共享和可变冲突——编译器生成三段式错误信息。
3.14 总结
借用检查器是 Rust 编译器中最精密、最具创新性的组件。它的设计体现了一个深刻的工程哲学:通过增加编译期的约束来减少运行时的错误。
从工程实现的角度,有几个关键的架构决策值得铭记:
-
运行在 MIR 上:利用控制流图的特性进行精确的数据流分析,而非在树形结构上做粗糙的近似。
-
NLL 的约束求解:将借用检查建模为约束满足问题,通过不动点迭代求解最小解,最大化接受的合法程序。
-
三个子分析的组合:Borrows + MaybeUninitializedPlaces + EverInitializedPlaces,各司其职又相互配合。
-
两阶段借用:在保持安全性的前提下,用编译器的精细分析来放宽规则,减少误报。
-
诊断系统:约束违反的逆向追溯 + 启发式命名 + 三段式信息,让编译器错误成为学习工具。
-
Polonius 的方向:从"区域包含哪些点"翻转为"在某点哪些贷款活跃",用声明式逻辑替代命令式算法。
理解借用检查器的工作原理,不仅能帮助你写出让编译器满意的代码,更能让你理解 Rust 为什么能在没有垃圾回收的情况下保证内存安全。下一章我们将深入探讨生命周期推导——编译器如何推导 lifetime,何时需要手动标注,以及 lifetime subtyping 和 variance 在类型系统中的角色。