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

9 阅读24分钟

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

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-elsematchloop 层层嵌套。但借用分析需要追踪所有可能的执行路径,包括循环的回边(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>),              // 子类型
}

PlaceRefPlace 的借用视图,避免不必要的复制:

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>>,
    // ...
}

约束求解的过程是:

  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 的区域,那就是一个错误。

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.rsis_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,而用户显式创建的 &mutMutBorrowKind::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); } 为例:

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

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

3.14 总结

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

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

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

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

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

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

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

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

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