Rust编译器原理-第3章 借用检查器:编译器如何证明内存安全

《Rust 编译器原理》完整目录

第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:当一个值被移动或释放后,所有指向它的引用都会失效。

rust 复制代码
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 函数:

rust 复制代码
// 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:零个或多个投影操作,依次从局部变量向下访问
rust 复制代码
// MIR 中的 Place 示例
_1                    // 局部变量 _1
(*_1)                 // 解引用 _1
(*_1).field0          // 解引用后访问第 0 个字段
(*_1).field0[_2]      // 再索引

编译器中 Place 的定义(在 rustc_middle::mir 中):

rust 复制代码
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 的借用视图,避免不必要的复制:

rust 复制代码
pub struct PlaceRef<'tcx> {
    pub local: Local,
    pub projection: &'tcx [PlaceElem<'tcx>],
}

Place 的冲突判定 是借用检查器最精密的部分之一。两个 Place 是否冲突,决定了两个借用是否可以共存。编译器在 places_conflict.rs 中实现了这个判定逻辑。核心思路是:从两个 Place 的根部开始,逐步比较每一层投影:

rust 复制代码
// 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。这是借用检查器的"原材料"。

rust 复制代码
// 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 中:

rust 复制代码
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 三个子数据流分析

编译器不是用单一分析完成所有检查,而是组合了三个独立的数据流分析:

rust 复制代码
// 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)求解,然后组合成一个统一的结果:

rust 复制代码
// 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 控制流分析:追踪分支与循环中的借用

数据流分析的核心挑战是处理控制流。在分支处,活跃借用集合需要分裂;在汇合处,需要合并。在循环中,需要通过不动点迭代来收敛。

考虑这个例子:

rust 复制代码
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 上的所有活跃借用,检测是否存在冲突:

rust 复制代码
// 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------只检查同一局部变量上的借用,而不是遍历所有借用:

rust 复制代码
// 只检查同一局部变量的借用,极大减少搜索空间
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)------引用的生命周期从声明开始,到所在作用域结束为止。

rust 复制代码
fn lexical_problem() {
    let mut data = vec![1, 2, 3];
    let first = &data[0];      // 不可变借用开始
    println!("{}", first);      // 最后一次使用 first
    data.push(4);               // 旧编译器:错误!first 的借用还没结束
}                                // 旧编译器认为 first 在这里才结束

NLL 的核心洞察是:生命周期不需要匹配词法作用域。一个借用应该在最后一次使用后立即结束,而不是等到作用域结束。

rust 复制代码
// 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:

rust 复制代码
// 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>>,
    // ...
}

约束求解的过程是:

  1. 收集约束 :MIR 类型检查阶段(type_check)生成两种约束:

    • 活跃性约束 :如果区域 'a 在程序点 P 被使用,则 'a ⊇ {P}
    • Outlives 约束 :如果存在 'a: 'b 关系,则 region('a) ⊇ region('b)
  2. 计算 SCC :将约束图中互相 outlives 的区域('a: 'b 且 'b: 'a)合并为一个强连通分量。

  3. 不动点迭代 :初始化每个区域为空集,反复传播约束直到收敛。目标是找到满足所有约束的最小区域------区域越小,借用的存活范围越短,越不容易和其他操作冲突。

  4. 冲突检测:求解完成后,检查是否有区域被迫包含了不该包含的程序点------如果一个通用区域(universal region)被约束为必须 outlive 它不应该 outlive 的区域,那就是一个错误。

rust 复制代码
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 问题的提出

考虑这段代码:

rust 复制代码
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 枚举表示:

rust 复制代码
// 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 函数中,编译器判断一个两阶段借用在给定位置是否已激活:

rust 复制代码
// 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 引用:

rust 复制代码
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 的一大贡献是三段式错误信息:

rust 复制代码
fn error_example() {
    let mut s = String::from("hello");
    let r = &s;
    s.push_str(" world");
    println!("{}", r);
}
rust 复制代码
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 的函数时,编译器并不会移动原始的可变引用,而是创建一个新的、更短生命周期的借用:

rust 复制代码
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) 被转换为:

scss 复制代码
_temp = &mut (*r);       // 再借用:从 r 创建一个新的 &mut
process(move _temp);      // 传递新的引用
// _temp 被释放,r 重新可用

再借用遵循一个重要的不变量:在再借用期间,原始引用被"冻结" 。这确保了在任何时刻仍然只有一个 &mut 是"活跃"的。再借用结束后,原始引用重新可用。再借用也可以"降级"------从 &mut T 创建 &T。

3.9.2 split_at_mut:再借用的极限

标准库的 split_at_mut 利用 unsafe 实现了借用检查器无法自动证明的再借用拆分:

rust 复制代码
// 标准库的简化实现
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> 实现------它是唯一一个允许通过共享引用修改内容的原语。

rust 复制代码
// 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,释放时自动解锁。
rust 复制代码
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 无法精确推断:

rust 复制代码
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/ 目录:

rust 复制代码
// 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 表述:

prolog 复制代码
% 规则 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); } 为例:

  1. 降低为 MIR:三条语句------共享借用(bb00)、使用(bb01)、可变借用+push(bb02)
  2. 构建 BorrowSet :B0(共享 &(*_1)[0])、B1(可变 &mut *_1)
  3. 生成区域约束:'r0 ⊇ {bb00, bb01},'r1 ⊇ {bb02}
  4. NLL 求解:'r0 = {bb00, bb01},'r1 = {bb02}------两个区域不重叠
  5. 数据流分析:bb02 活跃借用 = {B1}(B0 已结束)
  6. 冲突检测:无冲突,编译通过

如果把 println! 移到 data.push 之后,bb02 的活跃借用变为 {B0, B1},共享和可变冲突------编译器生成三段式错误信息。

3.14 总结

借用检查器是 Rust 编译器中最精密、最具创新性的组件。它的设计体现了一个深刻的工程哲学:通过增加编译期的约束来减少运行时的错误。

从工程实现的角度,有几个关键的架构决策值得铭记:

  1. 运行在 MIR 上:利用控制流图的特性进行精确的数据流分析,而非在树形结构上做粗糙的近似。

  2. NLL 的约束求解:将借用检查建模为约束满足问题,通过不动点迭代求解最小解,最大化接受的合法程序。

  3. 三个子分析的组合:Borrows + MaybeUninitializedPlaces + EverInitializedPlaces,各司其职又相互配合。

  4. 两阶段借用:在保持安全性的前提下,用编译器的精细分析来放宽规则,减少误报。

  5. 诊断系统:约束违反的逆向追溯 + 启发式命名 + 三段式信息,让编译器错误成为学习工具。

  6. Polonius 的方向:从"区域包含哪些点"翻转为"在某点哪些贷款活跃",用声明式逻辑替代命令式算法。

理解借用检查器的工作原理,不仅能帮助你写出让编译器满意的代码,更能让你理解 Rust 为什么能在没有垃圾回收的情况下保证内存安全。下一章我们将深入探讨生命周期推导------编译器如何推导 lifetime,何时需要手动标注,以及 lifetime subtyping 和 variance 在类型系统中的角色。

相关推荐
小哈里19 小时前
【编译器】从计算机系统原理到 LLM 编译器与算子优化
大模型·编译器·1024程序员节·计算机系统原理·算子优化·推理平台
小熊吃保安19 小时前
Windows也能用 Apple Watch 解锁
windows·rust·开源
离歌漠2 天前
《我用 Tauri 做了一个本地开发工具箱》
rust·typescript·vue
小小龙学IT2 天前
Python Polars 高性能数据处理引擎深度解析
开发语言·python·rust·1024程序员节
zzu123zsw2 天前
为什么 Python 做 Agent 总在并发中崩溃?拆解 Blockcell 在 Rust 下的 Host-Cell 隔离与自愈状态机
开发语言·python·rust·大模型·ai agent
hsg772 天前
简述:基于Ubuntu24编译发布rust开发的html+web后端服务的方法
rust·1024程序员节
zzu123zsw2 天前
当 GPU 加速渲染遇上无头组件:拆解 Yororen UI 在 GPUI 生态下的状态解耦与 XML 模板实践
xml·ui·rust·gui·gpui
传奇开心果编程2 天前
【Rust入门练中学】第6课:结构体与方法
开发语言·rust·结构体·编程语言理论·结构体方法·编程通用语言
本地化文档2 天前
cutile-rs-book-l10n
rust·gpu·nvidia·cuda·sphinx·crowdin
qq_406981032 天前
大白话rust系列09 函数调用两次有什么错?
开发语言·python·rust