Rust编译器原理-第4章 生命周期:编译器如何推断引用的有效范围

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

第4章 生命周期:编译器如何推断引用的有效范围

"Lifetime is the compiler's proof that your references will never dangle."

生命周期(Lifetime)是 Rust 类型系统中最独特的概念。在编译器内部,它是 MIR 控制流图中一组程序点的集合------编译器用来证明引用有效性的数学工具。本章从编译器源码出发,完整还原生命周期从 Elision 自动补全到区域推断、约束求解与错误报告的全过程,并深入 NLL 和 Polonius 的实现机制。

:::tip 本章要点

  • 生命周期是编译期的注解,描述引用在多长时间内有效------它不占运行时开销
  • Lifetime Elision 的三条规则让 90% 以上的函数签名不需要手动标注
  • NLL(Non-Lexical Lifetimes)革命将生命周期从词法作用域扩展到基于控制流的精确分析
  • 在编译器内部,生命周期被表示为区域(Region)------MIR 程序点的集合
  • 区域推断通过 SCC(强连通分量)图上的传播算法求解约束
  • 'a: 'b 意味着区域 'a 包含区域 'b,长寿命引用可以用在短寿命的位置
  • HRTB(for<'a>)让函数对任意生命周期都成立,编译器在每个调用点独立推导
  • Variance 决定泛型参数中的子类型关系如何传播------协变、逆变、不变
  • Polonius 将借用检查建模为可达性问题,实现更精确的生命周期分析 :::

4.1 生命周期的本质:编译期的引用有效范围注解

4.1.1 生命周期不是运行时概念

生命周期完全是编译期的概念。 编译完成后,所有生命周期标注都会被擦除(erased),不会在机器码中留下任何痕迹。'a 不会让引用活得更长或更短,它只是告诉编译器输出引用与输入引用之间存在约束关系:

rust 复制代码
// 这两个函数在编译后生成完全相同的机器码
fn first_char<'a>(s: &'a str) -> &'a str { &s[..1] }
fn first_char_no_annotation(s: &str) -> &str { &s[..1] }

4.1.2 编译器内部的区域表示

在编译器内部,生命周期被统一表示为区域 (Region)。compiler/rustc_type_ir/src/region_kind.rs 中的 RegionKind 枚举定义了所有区域类型:

rust 复制代码
// compiler/rustc_type_ir/src/region_kind.rs
pub enum RegionKind<I: Interner> {
    ReEarlyParam(I::EarlyParamRegion),    // impl<'a> 中的 'a
    ReBound(BoundVarIndexKind, BoundRegion<I>), // for<'a> 中的 'a
    ReLateParam(I::LateParamRegion),       // 函数体内的晚期绑定参数
    ReStatic,                              // 'static
    ReVar(RegionVid),                      // 推断变量
    RePlaceholder(PlaceholderRegion<I>),    // 高阶类型验证用的占位符
    ReErased,                              // 擦除后(代码生成阶段)
    ReError(I::ErrorGuaranteed),           // 错误占位
}

Region 类型使用 interning 机制,所有相同的区域共享内存,通过指针比较即可判等:

rust 复制代码
// compiler/rustc_middle/src/ty/region.rs
pub struct Region<'tcx>(pub Interned<'tcx, RegionKind<'tcx>>);

4.1.3 区域的生命周期

flowchart TD A["源代码
fn foo<'a>(x: &'a T) -> &'a T"] --> B["HIR
ReEarlyParam / ReBound"] B --> C["MIR 构建
所有区域替换为 ReVar"] C --> D["借用检查 (NLL)
区域推断求解"] D --> E["约束满足?"] E -->|是| F["区域擦除 ReErased
代码生成"] E -->|否| G["错误报告 ReError"] style A fill:#3b82f6,color:#fff,stroke:none style B fill:#6366f1,color:#fff,stroke:none style C fill:#a855f7,color:#fff,stroke:none style D fill:#d946ef,color:#fff,stroke:none style E fill:#f59e0b,color:#fff,stroke:none style F fill:#10b981,color:#fff,stroke:none style G fill:#ef4444,color:#fff,stroke:none

进入 MIR 阶段后,nll.rs 中的 replace_regions_in_mir 将所有区域替换为推断变量 ReVar,为约束求解做准备:

rust 复制代码
// compiler/rustc_borrowck/src/nll.rs
pub(crate) fn replace_regions_in_mir<'tcx>(
    infcx: &BorrowckInferCtxt<'tcx>,
    body: &mut Body<'tcx>,
    promoted: &mut IndexSlice<Promoted, Body<'tcx>>,
) -> UniversalRegions<'tcx> {
    let universal_regions = UniversalRegions::new(infcx, body.source.def_id().expect_local());
    renumber::renumber_mir(infcx, body, promoted); // 替换为推断变量
    universal_regions
}

4.2 Lifetime Elision:编译器的自动推导规则

4.2.1 三条黄金规则

大多数 Rust 代码中的生命周期标注是不需要手写的,因为编译器会自动应用三条 Elision 规则 来补全它们。这三条规则在 resolve_bound_vars 阶段执行,发生在 HIR 层面:

规则一:每个引用参数获得独立的生命周期。

rust 复制代码
// 你写的
fn process(a: &str, b: &i32) -> ()

// 编译器补全后
fn process<'a, 'b>(a: &'a str, b: &'b i32) -> ()

规则二:如果只有一个输入生命周期参数,它被赋给所有输出引用。

rust 复制代码
// 你写的
fn first_word(s: &str) -> &str

// 编译器补全后
fn first_word<'a>(s: &'a str) -> &'a str

规则三:如果有 &self&mut self 参数,self 的生命周期被赋给所有输出引用。

rust 复制代码
// 你写的
impl Config {
    fn get(&self, key: &str) -> &str { ... }
}

// 编译器补全后
impl Config {
    fn get<'a, 'b>(&'a self, key: &'b str) -> &'a str { ... }
}

规则三的设计哲学值得深思。方法返回的引用通常引用自 self 的数据,而不是其他参数。这条规则将最常见的情况自动化了。

4.2.2 Elision 失败的场景

当三条规则无法确定所有输出引用的生命周期时,编译器会强制要求手动标注:

rust 复制代码
// 编译错误:两个输入生命周期,编译器不知道输出跟哪个绑定
fn longest(a: &str, b: &str) -> &str { ... }

// 修复:显式标注,告诉编译器输出的生命周期与两个输入相同
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() > b.len() { a } else { b }
}

这里的 'a 是一个约束:它告诉编译器返回值的有效期不超过 ab 中较短的那一个。编译器不会猜测这种关系------它要求你明确声明。

4.2.3 结构体中的生命周期

Elision 规则只适用于函数签名,不适用于结构体。结构体是 API 的组成部分,生命周期关系不应被隐式推导:

rust 复制代码
struct Parser<'input> {
    input: &'input str,  // 必须显式标注
}

4.2.4 常见的生命周期模式

rust 复制代码
// 'static:程序全生命周期
let s: &'static str = "hello";
fn spawn_task(task: impl FnOnce() + Send + 'static) { std::thread::spawn(task); }

// 返回位置:必须绑定到某个输入参数
fn choose_first<'a>(a: &'a str, _b: &str) -> &'a str { a }

// 结构体中的经典用法:Iterator 模式
struct Iter<'a, T> { slice: &'a [T], index: usize }
impl<'a, T> Iterator for Iter<'a, T> {
    type Item = &'a T;
    fn next(&mut self) -> Option<Self::Item> {
        self.slice.get(self.index).map(|item| { self.index += 1; item })
    }
}

4.3 NLL 革命:从词法到流敏感的生命周期

4.3.1 旧时代:词法生命周期的痛苦

在 Rust 2018 Edition 之前,编译器使用词法生命周期(Lexical Lifetimes)------引用的生命周期与其变量的词法作用域绑定。这意味着引用从声明开始直到变量离开作用域为止都被认为是"存活"的,即使你在中途就不再使用它了。

rust 复制代码
// 在旧的词法生命周期下,这段代码无法编译
fn old_style() {
    let mut data = vec![1, 2, 3];
    let first = &data[0];     // 不可变借用开始
    println!("{}", first);     // 最后一次使用 first
    data.push(4);              // 词法生命周期下失败!first 的作用域延伸到函数末尾
}
// 开发者不得不加额外花括号缩小作用域------丑陋且违反 Rust 哲学

4.3.2 NLL 的核心思想

NLL(Non-Lexical Lifetimes)的核心思想是:引用的生命周期应该从其创建点延伸到最后一次使用点,而不是延伸到词法作用域的末尾。

rust 复制代码
fn nll_style() {
    let mut data = vec![1, 2, 3];
    let first = &data[0];     // 不可变借用开始
    println!("{}", first);     // 最后一次使用 first------NLL 认为借用在这里结束
    data.push(4);              // 合法!此时 first 已经"死亡"
}
flowchart TD subgraph "词法生命周期 (旧)" A1["let first = &data[0]"] --> B1["println!(first)"] B1 --> C1["data.push(4) // 错误!"] C1 --> D1["} // first 的作用域在这里结束"] style A1 fill:#ef4444,color:#fff,stroke:none style B1 fill:#ef4444,color:#fff,stroke:none style C1 fill:#ef4444,color:#fff,stroke:none style D1 fill:#ef4444,color:#fff,stroke:none end subgraph "NLL 生命周期 (新)" A2["let first = &data[0]"] --> B2["println!(first) // first 最后使用"] B2 --> C2["data.push(4) // 合法!"] C2 --> D2["} // 函数结束"] style A2 fill:#10b981,color:#fff,stroke:none style B2 fill:#10b981,color:#fff,stroke:none style C2 fill:#6b7280,color:#fff,stroke:none style D2 fill:#6b7280,color:#fff,stroke:none end

4.3.3 NLL 在编译器中的实现

NLL 的实现核心在 compiler/rustc_borrowck/src/nll.rs 中。整个过程分为几个阶段:

第一步:区域变量替换。 replace_regions_in_mir 将所有生命周期替换为新的推断变量。

第二步:类型检查生成约束。 MIR 类型检查器遍历每条语句,生成区域之间的 outlives 约束。

第三步:计算 SCC 并求解。 compute_regions 函数是核心入口:

rust 复制代码
// compiler/rustc_borrowck/src/nll.rs (简化)
pub(crate) fn compute_regions<'tcx>(
    root_cx: &mut BorrowCheckRootCtxt<'tcx>,
    infcx: &BorrowckInferCtxt<'tcx>,
    body: &Body<'tcx>,
    // ...
) -> NllOutput<'tcx> {
    // 将约束降低为 SCC 图
    let lowered_constraints = compute_sccs_applying_placeholder_outlives_constraints(
        constraints,
        &universal_region_relations,
        infcx,
    );

    // 创建区域推断上下文
    let mut regioncx = RegionInferenceContext::new(
        infcx,
        lowered_constraints,
        universal_region_relations,
        location_map,
    );

    // 求解区域约束
    let (closure_region_requirements, nll_errors) =
        regioncx.solve(infcx, body, polonius_output.clone());

    NllOutput {
        regioncx,
        opt_closure_req: closure_region_requirements,
        nll_errors,
        // ...
    }
}

4.3.4 活跃性分析:NLL 的基石

NLL 依赖活跃性分析 (Liveness Analysis)------通过数据流分析计算每个区域变量在 CFG 中的哪些点是"活跃"的。LivenessValues 使用稀疏区间矩阵记录每个区域存活的程序点集合,区域值由三种元素组成:Location(CFG 点)、RootUniversalRegion(如 'a)、PlaceholderRegion(来自 for<'a>)。

具体例子:

rust 复制代码
fn demo() {
    let mut v = vec![1, 2, 3];   // bb0[0]
    let r = &v[0];                // bb0[1]: 创建区域 'r
    println!("{}", r);            // bb0[2]: 使用 'r(最后一次)
    v.push(4);                    // bb0[3]: 可变借用 v
    println!("{:?}", v);          // bb0[4]
}

NLL 的活跃性分析确定区域 'r 的值为 {bb0[1], bb0[2]}------从创建到最后使用。因此 bb0[3] 处的可变借用是合法的,因为此时 'r 已经不包含该程序点。

4.4 区域推断:编译器如何计算生命周期约束

4.4.1 约束的表示

compiler/rustc_borrowck/src/constraints/mod.rs 中,outlives 约束被表示为一对区域变量之间的关系:

rust 复制代码
// compiler/rustc_borrowck/src/constraints/mod.rs
pub struct OutlivesConstraint<'tcx> {
    /// 必须活得更长的区域(sup outlives sub)
    pub sup: RegionVid,

    /// 被 outlive 的区域
    pub sub: RegionVid,

    /// 约束产生的位置
    pub locations: Locations,

    /// 关联的源代码 span
    pub span: Span,

    /// 约束的类别(赋值、返回值、参数等)
    pub category: ConstraintCategory<'tcx>,

    /// Variance 诊断信息
    pub variance_info: VarianceDiagInfo<TyCtxt<'tcx>>,

    /// 是否从闭包需求传播而来
    pub from_closure: bool,
}

OutlivesConstraintSet 管理所有约束,并支持构建正向和反向约束图:

rust 复制代码
impl<'tcx> OutlivesConstraintSet<'tcx> {
    pub(crate) fn push(&mut self, constraint: OutlivesConstraint<'tcx>) {
        // 'a: 'a 这种自引用约束没有意义,直接跳过
        if constraint.sup == constraint.sub {
            return;
        }
        self.outlives.push(constraint);
    }

    // 构建正向图:约束 R1: R2 表示边 R1 -> R2
    pub(crate) fn graph(&self, num_region_vars: usize) -> NormalConstraintGraph { ... }

    // 构建反向图:约束 R1: R2 表示边 R2 -> R1
    pub(crate) fn reverse_graph(&self, num_region_vars: usize) -> ReverseConstraintGraph { ... }
}

注意 push 方法中的优化:'a: 'a 这种自引用约束会被直接丢弃,因为它不包含任何有用的信息。

4.4.2 RegionInferenceContext:推断的核心

RegionInferenceContext 是区域推断的核心,包含:区域变量定义(来源、universe)、活跃性约束(LivenessValues)、outlives 约束集和约束图、SCC 及其注解、每个 SCC 的推断值、类型约束(T: 'x),以及全称量化区域间的关系。

4.4.3 约束求解算法

区域推断的核心算法是基于 SCC(强连通分量)的约束传播。让我们逐步理解这个过程。

第一步:构建 SCC 图。

编译器将所有区域变量作为节点、outlives 约束作为有向边构建一张图。然后计算这张图的强连通分量。在同一个 SCC 内的所有区域变量必须有相同的值(因为它们互相 outlive)。

第二步:初始化。

每个全称量化的区域(如 'a'static)被初始化为包含整个 CFG 和该区域的 end 点。存在量化的区域从空集开始。

第三步:传播。

rust 复制代码
// compiler/rustc_borrowck/src/region_infer/mod.rs
fn propagate_constraints(&mut self) {
    // 遍历 SCC 的 DAG(有向无环图)
    for scc_a in self.constraint_sccs.all_sccs() {
        // 对于每个 SCC B,使得 A: B...
        for &scc_b in self.constraint_sccs.successors(scc_a) {
            // 将 B 的值加入 A 的值
            self.scc_values.add_region(scc_a, scc_b);
        }
    }
}

这个看似简单的三行代码,实现了一个优雅的不动点算法。因为 SCC 图是一个 DAG,按拓扑序遍历一次即可完成------不需要迭代到不动点。

第四步:验证。

传播完成后,solve 方法检查所有约束是否满足:

rust 复制代码
pub(super) fn solve(
    &mut self,
    infcx: &InferCtxt<'tcx>,
    body: &Body<'tcx>,
    polonius_output: Option<Box<PoloniusOutput>>,
) -> (Option<ClosureRegionRequirements<'tcx>>, RegionErrors<'tcx>) {
    // 传播约束值
    self.propagate_constraints();

    let mut errors_buffer = RegionErrors::new(infcx.tcx);

    // 检查类型约束(T: 'x)
    self.check_type_tests(infcx, outlives_requirements.as_mut(), &mut errors_buffer);

    // 检查全称量化区域之间的关系
    self.check_universal_regions(outlives_requirements.as_mut(), &mut errors_buffer);

    // ...
}
flowchart TD A["MIR 类型检查
生成 outlives 约束"] --> B["构建约束图
区域变量 = 节点
outlives = 有向边"] B --> C["计算 SCC
强连通分量"] C --> D["初始化 SCC 值
全称区域 = 完整 CFG
存在区域 = 空集"] D --> E["传播约束
按拓扑序遍历 SCC DAG
合并后继的值"] E --> F["验证约束"] F --> G{"所有约束
满足?"} G -->|是| H["推断成功
继续编译"] G -->|否| I["收集错误
生成诊断信息"] style A fill:#3b82f6,color:#fff,stroke:none style B fill:#6366f1,color:#fff,stroke:none style C fill:#8b5cf6,color:#fff,stroke:none style D fill:#a855f7,color:#fff,stroke:none style E fill:#d946ef,color:#fff,stroke:none style F fill:#f59e0b,color:#fff,stroke:none style G fill:#f97316,color:#fff,stroke:none style H fill:#10b981,color:#fff,stroke:none style I fill:#ef4444,color:#fff,stroke:none

4.4.4 一个完整的推断示例

让我们追踪一个具体函数的约束求解过程:

rust 复制代码
fn example<'a>(x: &'a Vec<i32>, flag: bool) -> &'a i32 {
    if flag {
        &x[0]
    } else {
        &x[1]
    }
}

约束生成:

  • 'borrow_0: 'a&x[0] 的借用必须活过返回值的生命周期)
  • 'borrow_1: 'a&x[1] 同理)
  • 'a: 'borrow_xx 的生命周期必须 outlive 返回值所需的 'a

SCC 计算:

如果没有循环依赖,每个区域变量是一个独立的 SCC。约束传播后:

  • 'borrow_0 的值包含 if 分支中的相关程序点加上 'a 的值
  • 'borrow_1 的值包含 else 分支中的相关程序点加上 'a 的值
  • 'a 的值由调用者决定

因为 'a 是全称量化的(它是函数参数),编译器验证:在函数体内部,'borrow_0'borrow_1 确实 outlive 'a。验证通过,编译继续。

4.4.5 SCC 代表与 Universe

SCC 内部需要选择"代表"区域,优先级:自由区域 > 占位符 > 存在量化变量。这影响错误报告------使用最有意义的名称生成诊断信息。

4.5 生命周期子类型:'a: 'b 的精确含义

4.5.1 Outlives 关系

'a: 'b 读作"'a outlives 'b",意味着区域 'a 的值是区域 'b 的值的超集:

<math xmlns="http://www.w3.org/1998/Math/MathML"> region ( ′ a ) ⊇ region ( ′ b ) \text{region}('a) \supseteq \text{region}('b) </math>region(′a)⊇region(′b)

直觉上:如果引用 &'a T 在所有 'b 存活的程序点上都存活,那么 &'a T 就可以安全地用在任何期望 &'b T 的地方。

rust 复制代码
fn use_shorter<'short>(r: &'short str) {
    println!("{}", r);
}

fn demonstrate<'long>(s: &'long str) {
    // 合法:'long: 'short,长寿命引用可以传给短寿命参数
    use_shorter(s);
}

4.5.2 'static 是最长的生命周期

'static 包含所有程序点,因此 'static: 'a 对任意 'a 都成立。这意味着 &'static T 可以传给任何期望 &'a T 的地方:

rust 复制代码
fn needs_ref<'a>(r: &'a str) -> &'a str { r }

// 'static: 'a,所以 &'static str 可以当 &'a str 用
let result = needs_ref("hello");  // "hello" 是 &'static str

4.5.3 子类型方向

长寿命是短寿命的子类型'static <: 'a <: 'b(当 'a: 'b 时)。&'static str 可以用在任何期望 &'a str 的地方------子类型 = 更具体 = 可用场合更多 = 活得更长。

4.6 Variance:泛型中的生命周期子类型传播

4.6.1 三种 Variance

当生命周期出现在泛型类型的参数中时,外层类型的子类型关系如何变化?这就是 **Variance(型变)**问题。Rust 定义了三种 Variance:

Variance 含义 直觉
协变(Covariant) 子类型关系保持 内层可替换 => 外层可替换
逆变(Contravariant) 子类型关系反转 接受更少的 => 接受更多的
不变(Invariant) 子类型关系消失 必须完全相同

实际还有第四种------双变(Bivariant),表示参数完全不被使用,不影响子类型关系。

&'a T'a 是协变的:

rust 复制代码
fn covariant_demo() {
    let static_str: &'static str = "hello";
    // &'static str 是 &'a str 的子类型(协变)
    let short_ref: &str = static_str;  // 合法
}

fn(&'a T)'a 是逆变的:

rust 复制代码
fn contravariant_demo() {
    // 接受短生命周期的函数可以当接受长生命周期的函数用
    let f: fn(&'static str) = |s| println!("{}", s);
    // fn(&'static str) 不能当 fn(&'a str) 用------因为调用者可能传入非 'static
    // 反过来,fn(&'a str) 可以当 fn(&'static str) 用------它能处理所有输入
}

&'a mut TT 是不变的:

rust 复制代码
fn invariant_demo<'a, 'b>(r: &'a mut &'b str) {
    // 如果 &mut T 对 T 是协变的,下面的代码就会编译通过:
    // let short_lived = String::from("short");
    // *r = &short_lived;  // 将短生命周期引用写入长生命周期位置
    // 这会导致 r 指向已释放的数据------灾难!
}

4.6.2 编译器如何计算 Variance

Variance 的计算在 compiler/rustc_hir_analysis/src/variance/ 模块中实现,分为三个阶段:

第一步:确定需要推断的参数 (terms.rs)

为每个泛型参数创建一个待推断的 Variance 变量。

第二步:收集约束 (constraints.rs)

遍历类型定义,根据参数在类型中的出现位置生成 Variance 约束。

第三步:求解到不动点 (solve.rs)

rust 复制代码
// compiler/rustc_hir_analysis/src/variance/solve.rs
fn solve(&mut self) {
    // 迭代直到不动点。最大迭代次数是 2C,
    // C 是约束数量(每个变量最多变化两次)
    let mut changed = true;
    while changed {
        changed = false;
        for constraint in &self.constraints {
            let Constraint { inferred, variance: term } = *constraint;
            let InferredIndex(inferred) = inferred;
            let variance = self.evaluate(term);
            let old_value = self.solutions[inferred];
            let new_value = glb(variance, old_value);
            if old_value != new_value {
                self.solutions[inferred] = new_value;
                changed = true;
            }
        }
    }
}

glb 函数计算 Variance 格(lattice)的最大下界:

rust 复制代码
fn glb(v1: ty::Variance, v2: ty::Variance) -> ty::Variance {
    // Variance 格的结构:
    //       * (Bivariant)
    //    -     +
    //       o (Invariant)
    match (v1, v2) {
        (ty::Invariant, _) | (_, ty::Invariant) => ty::Invariant,
        (ty::Covariant, ty::Contravariant) => ty::Invariant,
        (ty::Contravariant, ty::Covariant) => ty::Invariant,
        (ty::Covariant, ty::Covariant) => ty::Covariant,
        (ty::Contravariant, ty::Contravariant) => ty::Contravariant,
        (x, ty::Bivariant) | (ty::Bivariant, x) => x,
    }
}

关键洞察:如果一个参数同时出现在协变和逆变位置,它就变成不变的。Bivariant 是初始值(单位元),Invariant 是最终下界。

4.6.3 Variance 对 Polonius 的影响

在 Polonius 的实现中,Variance 直接影响约束的方向。在 compiler/rustc_borrowck/src/polonius/mod.rs 中定义了三种约束方向:

rust 复制代码
enum ConstraintDirection {
    /// 协变:正向边 O at P1 -> O at P2
    Forward,

    /// 逆变:反向边 O at P2 -> O at P1
    Backward,

    /// 不变:双向边 O at P1 <-> O at P2
    Bidirectional,
}

这意味着对于不变的类型参数,贷款(loan)可以在时间上"逆流"------这是 Polonius 能够精确分析不变类型的关键。

4.6.4 为什么 &mut T 对 T 不变

如果 &mut T 对 T 协变,&mut &'static str 就能当 &mut &'short str 用,然后通过 *r = &short_lived 写入短生命周期值,导致悬垂引用。不变性阻止了这种安全漏洞。

经验法则:可变性 = 不变性。 &mut TCell<T>UnsafeCell<T> 对 T 都是不变的;&TBox<T>Vec<T> 对 T 协变;fn(T) -> U 对 T 逆变、对 U 协变。

4.7 高阶 Trait 约束(HRTB):for<'a> 的编译器实现

4.7.1 为什么需要 HRTB

考虑以下场景:

rust 复制代码
fn apply_to_ref<'a>(f: fn(&'a str) -> usize, s: &'a str) -> usize {
    f(s)
}

这个函数的问题是:'a 在函数签名中已经固定。调用者必须提供一个 f,它恰好对那个特定的 'a 有效。但如果 fstr::len 呢?它对任意 生命周期的 &str 都有效!

HRTB 解决了这个问题:

rust 复制代码
fn apply_to_ref(f: for<'a> fn(&'a str) -> usize, s: &str) -> usize {
    f(s)
}

for<'a> fn(&'a str) -> usize 的意思是:这个函数对任意 生命周期 'a 都能工作。编译器在每个调用点独立地推导 'a 的具体值。

4.7.2 HRTB 与闭包

HRTB 最常见的用途是闭包参数。当你写 impl Fn(&str) -> &str 时,编译器自动将其脱糖为 impl for<'a> Fn(&'a str) -> &'a str

rust 复制代码
// 你写的
fn process(f: impl Fn(&str) -> &str) {
    let s = String::from("hello");
    let result = f(&s);
    println!("{}", result);
}

// 编译器看到的
fn process(f: impl for<'a> Fn(&'a str) -> &'a str) {
    let s = String::from("hello");
    let result = f(&s);  // 此处 'a 被推导为 s 的生命周期
    println!("{}", result);
}

4.7.3 编译器内部的 HRTB 处理

for<'a> 引入的区域用 ReBound 表示。子类型检查时,编译器将绑定区域实例化为 RePlaceholder(占位符),并引入新的 universe 。推断变量只能被解析为同一或更低 universe 中的区域------这确保了"对任意 'a 都成立"的语义。每个 RegionDefinition 都记录了其所属的 universe。

4.7.4 HRTB 的实际应用模式

rust 复制代码
// 高阶闭包参数
fn for_each_ref<F: for<'a> Fn(&'a String)>(items: &[String], f: F) {
    for item in items { f(item); }
}

// trait 对象与 HRTB
fn run(processor: &dyn for<'a> Fn(&'a str) -> String) {
    let data = String::from("input");
    println!("{}", processor(&data));
}

4.8 借用检查器的约束求解算法(与第3章的连接)

借用检查(第3章)和区域推断是同时发生的------每条 MIR 语句都可能产生新的 outlives 约束:

rust 复制代码
fn constraint_sources<'a>(x: &'a Vec<i32>, y: &'a Vec<i32>) -> &'a i32 {
    let r;                    // r 的类型中有待推断的区域 '_r
    if x.len() > y.len() {
        r = &x[0];            // 约束:'borrow_x: '_r
    } else {
        r = &y[0];            // 约束:'borrow_y: '_r
    }
    r                         // 约束:'_r: 'a
}

SCC 将互相 outlive 的区域('a: 'b'b: 'a)分组为相同值,然后在 DAG 上传播。综合两章内容,完整流程是:

  1. MIR 构建 -> 2. 类型检查生成约束 -> 3. 活跃性分析 -> 4. SCC 计算 -> 5. 约束传播 -> 6. 约束验证 -> 7. 借用验证 -> 8. 错误报告

4.9 生命周期错误信息:编译器如何生成诊断

4.9.1 错误分类与 blame 机制

ConstraintCategory 枚举决定了错误信息的措辞------"assignment"、"returning this value"、"argument"、"closure capture" 等。当约束不满足时,编译器通过 Trace 沿约束链回溯,找到最相关的源代码位置(blame 机制)。这就是为什么 Rust 的生命周期错误通常能精确指出是哪个赋值或函数调用导致了问题。

4.9.2 常见错误及其编译器视角

错误 1:返回局部变量的引用

rust 复制代码
fn dangling() -> &str {
    let s = String::from("hello");
    &s  // error[E0106]: missing lifetime specifier
}       // 更深层:即使加了生命周期,s 的 StorageDead 在函数退出时,
        // 区域约束 'borrow_s: 'return 无法满足

编译器的推理过程:&s 创建了一个借用,其区域 'borrow_s 最多延伸到 sStorageDead 点(函数末尾的清理代码)。但返回值要求 'borrow_s: 'return_lifetime------返回区域在函数退出后仍需有效。两个区域不兼容。

错误 2:两个可变引用冲突

rust 复制代码
fn conflict() {
    let mut v = vec![1, 2, 3];
    let r1 = &mut v;
    let r2 = &mut v;    // error[E0499]: cannot borrow `v` as mutable more than once
    r1.push(4);
}

这个错误看起来是借用检查的问题(第3章),但它也涉及生命周期:编译器判断 r1 的区域在 r1.push(4) 这一点仍然活跃,因此在 let r2 = &mut v 处存在两个活跃的可变借用。

错误 3:结构体生命周期不匹配

rust 复制代码
struct Container<'a> {
    data: &'a str,
}

fn create_container() -> Container<'static> {
    let s = String::from("hello");
    Container { data: &s }  // error: s does not live long enough
}

编译器生成约束 'borrow_s: 'static。但 'borrow_s 的区域仅限于函数体内------它不可能包含函数外的所有程序点。约束不可满足。

4.9.3 区域名称推断

编译器通过 region_name.rs 为匿名区域找名称:优先使用显式名称('a),然后参数位置信息,最后合成 '1'2 等。错误信息中的 "lifetime '1" 就是这样来的。

4.10 Polonius:下一代借用检查器

4.10.1 NLL 的局限性

尽管 NLL 已经比词法生命周期精确得多,但它仍然存在一些保守的判断。经典案例是条件返回场景:

rust 复制代码
fn get_or_insert<'a>(map: &'a mut HashMap<String, String>, key: &str) -> &'a String {
    // NLL 报错:map 的可变借用在 match 中仍然活跃
    match map.get(key) {
        Some(value) => value,
        None => {
            map.insert(key.to_string(), "default".to_string());
            map.get(key).unwrap()
        }
    }
}

NLL 认为 map.get(key) 产生的不可变借用在整个 match 表达式中都是活跃的,因此 None 分支中的 map.insert 是非法的。但实际上,None 分支只有在 get 返回 None 时才执行------此时那个不可变借用根本不存在。

4.10.2 Polonius 的核心思想

Polonius 将借用检查建模为可达性问题 。核心数据结构 PoloniusContext 包含局部化约束图、每个活跃区域的 variance 方向信息,以及用于诊断的辅助数据。

4.10.3 局部化约束

Polonius 的关键创新是局部化约束 (Localized Constraints)。NLL 使用全局的 outlives 约束('a: 'b),而 Polonius 将约束细化到具体的程序点:'a@P: 'b@P------"在程序点 P 处,'a outlives 'b"。

rust 复制代码
// compiler/rustc_borrowck/src/polonius/constraints.rs
/// 一个局部化的 outlives 约束将 CFG 位置编码到起源本身中,
/// 如同它们从点到点是不同的:从 a: b 变为 a@p: b@p
pub(super) struct LocalizedNode {
    pub region: RegionVid,
    pub point: PointIndex,
}

局部化约束图中有两种边:

  1. 区域间边 (同一程序点):a@p -> b@p,来自类型检查约束
  2. 程序点间边 (同一区域):a@p -> a@q,来自活跃性分析和控制流

边的方向由 Variance 决定:

  • 协变 -> 正向边
  • 逆变 -> 反向边
  • 不变 -> 双向边

4.10.4 贷款传播:可达性分析

算法步骤:1) 将 NLL 约束转换为局部化约束图 -> 2) 从每个贷款引入点做可达性搜索 -> 3) 记录可达的 (区域, 点) 对 -> 4) 计算每点的活跃贷款 -> 5) 检查非法访问。

4.10.5 Polonius 如何解决 NLL 的局限

回到之前 get_or_insert 的例子。Polonius 的分析过程是:

  1. map.get(key) 在程序点 P1 创建贷款 L1(不可变借用 map)
  2. match 在 P2 处分支
  3. Some 分支:L1 的区域在后续使用 value 的点上活跃
  4. None 分支:L1 的区域不再 活跃(没有对 get 返回值的使用)
  5. 因此 map.insertNone 分支中是合法的------此时没有活跃的不可变贷款

Polonius 通过精确追踪每个贷款在每个程序点的活跃性,避免了 NLL 的过度保守判断。

4.10.6 当前状态

Polonius 通过 -Zpolonius=next 启用,目标是最终替换 NLL 成为默认借用检查器,接受更多正确程序。

flowchart LR subgraph "NLL 方法" N1["全局 outlives 约束
'a: 'b"] --> N2["SCC 上传播
计算区域值"] N2 --> N3["逐点检查
贷款是否有效"] end subgraph "Polonius 方法" P1["局部化约束
'a@p: 'b@p"] --> P2["构建
区域+CFG 图"] P2 --> P3["可达性分析
贷款传播"] P3 --> P4["精确的
活跃贷款集"] end style N1 fill:#6366f1,color:#fff,stroke:none style N2 fill:#6366f1,color:#fff,stroke:none style N3 fill:#6366f1,color:#fff,stroke:none style P1 fill:#10b981,color:#fff,stroke:none style P2 fill:#10b981,color:#fff,stroke:none style P3 fill:#10b981,color:#fff,stroke:none style P4 fill:#10b981,color:#fff,stroke:none

4.11 深入案例:从源码到约束的完整追踪

rust 复制代码
struct Cache<'a> { data: &'a [u8], index: usize }

impl<'a> Cache<'a> {
    fn get(&self) -> &'a [u8] { &self.data[self.index..] }
    fn advance(&mut self, n: usize) { self.index += n; }
}

fn process<'data>(cache: &mut Cache<'data>, extra: &[u8]) -> &'data [u8] {
    let result = cache.get();  // 不可变借用
    cache.advance(1);          // 可变借用
    result
}

区域替换: '_1=cache 外层、'_2='data、'_3=extra、'_4=get 返回值、'_5=result。

约束生成: '_2: '_4(get 返回 &'a [u8])、'_4: '_5(赋值)、'_5: '_2(返回),形成 SCC:'_2 = '_4 = '_5

关键分析: cache.get() 借用 (*cache).data[..]advance 修改 (*cache).index------两个 place 不冲突。NLL 通过追踪借用路径(不只是变量),允许结构体不同字段的同时借用。

4.12 本章小结

生命周期系统的设计哲学:编译期收集信息、数学化证明安全、运行时完全擦除。从 Elision 的语法糖到区域推断的约束求解,从 NLL 的流敏感分析到 Polonius 的可达性模型,每一层都在追求更精确地区分安全与不安全的代码。理解编译器的推理过程,能帮助你写出更符合编译器期望的代码,减少与借用检查器"搏斗"的时间。

下一章我们将进入一个全新的领域------Rust 编译器如何决定一个类型在内存中的物理布局。

相关推荐
杨艺韬2 小时前
Rust编译器原理-第7章 Trait 静态分发:零成本抽象的编译器实现
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第18章 设计哲学与架构决策
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第12章 unsafe:安全抽象的逃生舱
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第17章 增量编译:让重编译只做必要的事
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第10章 Pin、Waker 与 Future:异步运行时的三大支柱
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第1章 编译管线全景:从源码到机器码的完整旅程
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第2章 所有权系统:编译期内存管理的核心机制
rust·编译器
杨艺韬2 小时前
Rust编译器原理-前言
rust·编译器
米丘8 小时前
Rust 初了解
rust