Rust 编译器与运行时揭秘

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

作者 杨艺韬 · 9,432 字 · 发布于 · 更新于

生命周期是编译器留下的一份证明:你的引用不会悬垂。

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

本章要点

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

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

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

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

// 这两个函数在编译后生成完全相同的机器码
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 枚举定义了所有区域类型:

// compiler/rustc_type_ir/src/region_kind.rs:138
pub enum RegionKind<I: Interner> {
    ReEarlyParam(I::EarlyParamRegion),    // impl<'a> 中的 'a
    ReBound(BoundVarIndexKind, BoundRegion<I>), // for<'a> 中的 'a
                                           // :155-157 特别强调:类型里的 bound region
                                           // **不能**被擦除——它影响 trait 选择与 TypeId,
                                           // `for<'a> fn(&'a ())` 和 `fn(&'static ())` 是不同类型
    ReLateParam(I::LateParamRegion),       // 函数体内的晚期绑定参数
    ReStatic,                              // 'static
    ReVar(RegionVid),                      // 推断变量
    RePlaceholder(PlaceholderRegion<I>),    // 高阶类型验证用的占位符
    ReErased,                              // 擦除后(代码生成阶段)
    ReError(I::ErrorGuaranteed),           // 错误占位
}

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

// compiler/rustc_middle/src/ty/region.rs:17
pub struct Region<'tcx>(pub Interned<'tcx, RegionKind<'tcx>>);

4.1.3 区域的生命周期

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

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

// compiler/rustc_borrowck/src/nll.rs:56
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 规则来补全它们。这三条规则在名称解析阶段执行,发生在 AST 层面(compiler/rustc_resolve/src/late.rs:2404 的 resolve_fn_signature 与 :2457 的 resolve_fn_params);HIR 层面的 resolve_bound_vars 接手的是已经解析好的生命周期,负责区分早期/晚期绑定、处理 trait 对象的默认生命周期:

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

// 你写的
fn process(a: &str, b: &i32) -> ()

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

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

// 你写的
fn first_word(s: &str) -> &str

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

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

// 你写的
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 失败的场景

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

// 编译错误:两个输入生命周期,编译器不知道输出跟哪个绑定
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 是一个约束:它告诉编译器返回值的有效期不超过 a 和 b 中较短的那一个。编译器不会猜测这种关系——它要求你明确声明。

4.2.3 结构体中的生命周期

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

struct Parser<'input> {
    input: &'input str,  // 必须显式标注
}

4.2.4 常见的生命周期模式

// '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)——引用的生命周期与其变量的词法作用域绑定。这意味着引用从声明开始直到变量离开作用域为止都被认为是"存活"的,即使你在中途就不再使用它了。

// 在旧的词法生命周期下,这段代码无法编译
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)的核心思想是:引用的生命周期应该从其创建点延伸到最后一次使用点,而不是延伸到词法作用域的末尾。

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:#dc2626,color:#fff,stroke:none
        style B1 fill:#dc2626,color:#fff,stroke:none
        style C1 fill:#dc2626,color:#fff,stroke:none
        style D1 fill:#dc2626,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:#333,stroke:none
        style B2 fill:#10b981,color:#333,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 函数是核心入口:

// compiler/rustc_borrowck/src/nll.rs:113 (简化)
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>)。

具体例子:

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]}——从创建到最后使用(这里的位置编号是示意;实际 MIR 里 vec! 和 println! 会展开成多个基本块)。因此 bb0[3] 处的可变借用是合法的,因为此时 'r 已经不包含该程序点。

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

4.4.1 约束的表示

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

// compiler/rustc_borrowck/src/constraints/mod.rs:65
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 管理所有约束,并支持构建正向和反向约束图:

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 定义在 compiler/rustc_borrowck/src/region_infer/mod.rs:79-121,一共 10 个字段,每个字段都对应推断算法里一个具体职责。把它当成"生命周期检查的黑板系统"——构造时收集全部输入信息,求解时在这块黑板上反复写写算算,结束时上面写着每条生命周期的最终区域值。

pub struct RegionInferenceContext<'tcx> {
    definitions:          Frozen<IndexVec<RegionVid, RegionDefinition<'tcx>>>,
    liveness_constraints: LivenessValues,
    constraints:          Frozen<OutlivesConstraintSet<'tcx>>,
    constraint_graph:     Frozen<NormalConstraintGraph>,
    constraint_sccs:      ConstraintSccs,
    scc_annotations:      IndexVec<ConstraintSccIndex, RegionTracker>,
    universe_causes:      FxIndexMap<ty::UniverseIndex, UniverseInfo<'tcx>>,
    scc_values:           RegionValues<'tcx, ConstraintSccIndex>,
    type_tests:           Vec<TypeTest<'tcx>>,
    universal_region_relations: Frozen<UniversalRegionRelations<'tcx>>,
}

十个字段分三组:不可变输入(被 Frozen<_> 包住的几条)、可变中间态(没包 Frozen)、求解后才校验的约束(type_tests)。

输入组(Frozen——构造后不再变):

  • definitions: IndexVec<RegionVid, RegionDefinition>——每个区域变量的身份证。RegionDefinition 有三个字段:origin: NllRegionVariableOrigin("我是谁生的"——来自借用表达式?函数签名?闭包?)、universe: UniverseIndex(量词嵌套深度,for<'a> 会开一个新 universe)、external_name: Option<ty::Region>(是否有用户可见的名字,错误消息需要)。索引是 RegionVid——整个借用检查内部对区域的唯一句柄。
  • constraints: OutlivesConstraintSet——上一节 4.4.1 讨论的原始 outlives 约束列表。来自 MIR typeck 阶段收集。
  • constraint_graph: NormalConstraintGraph——上面同一套约束以图形式存一份。节点是 RegionVid,A: B 的约束在 Normal 方向下对应有向边 A → B(compiler/rustc_borrowck/src/constraints/graph.rs:28-31 的注释写道 "In normal mode, a R1: R2 constraint results in an edge",边即 R1 -> R2,并说明构造 SCC 做推断时用的就是这个方向);注意边的方向和值的流向相反——算法是"把 B 的值并进 A",所以沿出边收集后继的值。反向图(Reverse)才把 A: B 存成 B → A,用于活跃性计算。并存两份是为了访问模式不同——constraints 便于枚举,constraint_graph 便于按节点查邻居。
  • universal_region_relations: UniversalRegionRelations——函数签名里那批全称量化生命周期('a、'b、'static)彼此之间的固定偏序关系。这一组在单次函数借用检查中永远不变。

中间态组(推断算法反复在上面改):

  • liveness_constraints: LivenessValues——每个区域变量"在 MIR 上哪些 point 活着"的集合。活跃性分析(4.3.4 讲的)直接写进这里。初始态:全称区域被填满整个 CFG + 各自的 end() 点;存在区域从空集开始。
  • constraint_sccs: ConstraintSccs——把 constraint_graph 跑 Tarjan SCC 算法得到的强连通分量划分。同一 SCC 内的区域变量必然等值(因为它们互相 outlive),所以推断以 SCC 为单位进行,不是以单个变量。
  • scc_annotations: IndexVec<ConstraintSccIndex, RegionTracker>——给每个 SCC 挂一个"注解"。RegionTracker(compiler/rustc_borrowck/src/handle_placeholders.rs:88)记录这个 SCC 的代表区域 representative,以及它能触及的 placeholder 和可命名的最大 universe——错误报告时用代表区域的名字来指人("'a 活得不够长"比"region #37 活得不够长"友好得多)。
  • universe_causes——当遇到 for<'a> 类 HRTB 约束时需要创建新 universe,这张表记录"为什么创建了 universe X"——供之后推断失败时追溯用户代码里的哪一处触发了 universe 扩展。
  • scc_values: RegionValues<ConstraintSccIndex>——推断结果的真身。它是一张"每个 SCC 包含哪些 MIR point + 哪些全称区域 + 哪些 placeholder"的大位图(region_infer/values.rs:255 的 points / free_regions / placeholders 三个矩阵),算法完结时这里就是答案。

求解后校验组:

  • type_tests: Vec<TypeTest>——类型层面的 outlives(T: 'x、<T as Trait>::Assoc: 'x)。这类约束不参与 SCC 推断——它们不决定任何区域的值,只在求解结束后被检查是否满足。TypeTest.verify_bound 是一个"如果区域值满足某个条件则该约束成立"的断言树(VerifyBound),支持析取——对应文档注释第 179 行"these type tests can involve disjunction"。这是为什么类型 outlives 要走独立通道:它们可能有多种成立路径,不是单值闭包。

这个分组解释了 solve 函数的执行形状:先 propagate_constraints 在"中间态组"里把约束传播完(对 SCC DAG 按拓扑序扫描一遍即可,不做不动点迭代),再 check_type_tests 拿推断出来的区域值去验求解后校验组的 TypeTest,最后 check_universal_regions 验证函数签名层面的全称区域关系没被违反。十个字段的分组大体对应这三步(constraint_sccs、universe_causes 等是各步共用的辅助结构)。

4.4.3 约束求解算法

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

第一步:构建 SCC 图。

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

第二步:初始化。

每个 SCC 的初值先取其中各区域的活跃性约束(compiler/rustc_borrowck/src/region_infer/mod.rs:325-328 的 merge_liveness):存在量化的区域只含活跃性分析算出的存活点;全称量化的区域(如 'a、'static)再被补成包含整个 CFG 和该区域的 end 点(同文件 init_free_and_bound_regions)。

第三步:传播。

// compiler/rustc_borrowck/src/region_infer/mod.rs:551 (简化)
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 按拓扑序传播一遍即可完成(region_infer/mod.rs:551-572),不需要反复迭代。

第四步:验证。

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

// compiler/rustc_borrowck/src/region_infer/mod.rs:493 (简化)
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 类型检查<br/>生成 outlives 约束"] --> B["构建约束图<br/>区域变量 = 节点<br/>outlives = 有向边"]
    B --> C["计算 SCC<br/>强连通分量"]
    C --> D["初始化 SCC 值<br/>全称区域 = 完整 CFG<br/>存在区域 = 活跃点"]
    D --> E["传播约束<br/>按拓扑序遍历 SCC DAG<br/>合并后继的值"]
    E --> F["验证约束"]
    F --> G{"所有约束<br/>满足?"}
    G -->|是| H["推断成功<br/>继续编译"]
    G -->|否| I["收集错误<br/>生成诊断信息"]
    style A fill:#2563eb,color:#fff,stroke:none
    style B fill:#6366f1,color:#fff,stroke:none
    style C fill:#7c3aed,color:#fff,stroke:none
    style D fill:#9333ea,color:#fff,stroke:none
    style E fill:#c026d3,color:#fff,stroke:none
    style F fill:#f59e0b,color:#333,stroke:none
    style G fill:#f97316,color:#333,stroke:none
    style H fill:#10b981,color:#333,stroke:none
    style I fill:#dc2626,color:#fff,stroke:none

4.4.4 一个完整的推断示例

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

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_0、'a: 'borrow_1(再借用约束:经由 x 借出的新引用不能比 x 自身的 'a 活得更久,见 compiler/rustc_borrowck/src/type_check/mod.rs:2302 的 add_reborrow_constraint)

SCC 计算:

'borrow_0: 'a 与 'a: 'borrow_0 互相 outlive,构成一个环,'borrow_1 同理——三者落进同一个 SCC,值完全相同:'a 作为全称区域的初始值(整个函数体的程序点加上 end('a))。用 -Zdump-mir=nll 实测(rustc 1.96.0,nightly 选项需 RUSTC_BOOTSTRAP=1),这个函数的各个借用区域推断出的值都是「全部程序点 + 'a」。

因为 'a 是全称量化的(它是函数参数),编译器最后验证:这个 SCC 的值里除了 'a 自己没有其他全称区域,没有要求 'a 活得比签名承诺的更长。验证通过,编译继续。

4.4.5 SCC 代表与 Universe

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

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

4.5.1 Outlives 关系

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

region(′a)⊇region(′b)\text{region}('a) \supseteq \text{region}('b)

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

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 的地方:

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 是协变的:

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

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

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 T 对 T 是不变的:

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)

// compiler/rustc_hir_analysis/src/variance/solve.rs:64
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)的最大下界:

// compiler/rustc_hir_analysis/src/variance/solve.rs:16
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 中定义了三种约束方向:

enum ConstraintDirection {
    /// 协变:正向边 O at P1 -> O at P2
    Forward,

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

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

这意味着贷款(loan)可以沿反向边在时间上"逆流":不变参数的双向边、逆变参数的 Backward 边都会这样。模块文档(polonius/mod.rs:17-18)说的正是这一点:双向边用来编码不变关系,贷款可以 "back in time" 流动,以便穿过 CFG 中更早产生的约束。

4.6.4 为什么 &mut T 对 T 不变

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

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

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

4.7.1 为什么需要 HRTB

考虑以下场景:

fn apply_to_ref<'a>(f: fn(&'a str) -> usize, s: &'a str) -> usize {
    f(s)
}

这个函数的问题是:'a 由调用者选定,在函数签名中已经固定。函数体内只能拿活满 'a 的引用去调 f,想把函数内部临时借出的更短引用交给它就会报错(比如在函数体里 let local = String::from("x"); f(&local),实测 E0597)——哪怕传进来的 f 是 str::len 这种对任意生命周期的 &str 都有效的函数。

HRTB 解决了这个问题:

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:

// 你写的
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 的实际应用模式

// 高阶闭包参数
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章)建立在区域推断之上:先由 compute_regions 求出每个区域的值,再用这些值判断借用冲突。约束本身来自 MIR 类型检查——每条 MIR 语句都可能产生新的 outlives 约束:

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:返回局部变量的引用

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

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

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

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:结构体生命周期不匹配

struct Container<'a> {
    data: &'a str,
}

fn create_container() -> Container<'static> {
    let s = String::from("hello");
    Container { data: &s }  // error[E0515]: cannot return value referencing local variable `s`
}

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

4.9.3 区域名称推断

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

4.10 Polonius:下一代借用检查器

4.10.1 NLL 的局限性

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

use std::collections::HashMap;

fn get_or_insert<'a>(map: &'a mut HashMap<String, String>, key: &str) -> &'a String {
    // NLL 报错 E0502:map.get 的借用流入了返回值的 'a,None 分支里也被视为活跃
    match map.get(key) {
        Some(value) => value,
        None => {
            map.insert(key.to_string(), "default".to_string());
            map.get(key).unwrap()
        }
    }
}

Some 分支把 value 作为返回值,于是 map.get(key) 那次借用的区域必须 outlive 返回类型里的 'a;而 NLL 的 outlives 约束是位置不敏感的(见 §3.12.1),'a 又是覆盖整个函数体的全称区域,结果这次借用在 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"。

// compiler/rustc_borrowck/src/polonius/constraints.rs:36
/// 一个局部化的 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.insert 在 None 分支中是合法的——此时没有活跃的不可变贷款

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

4.10.6 当前状态

Polonius 在 1.96.0 默认关闭,只能在 nightly 上通过 -Zpolonius=next 启用;目标是最终替换 NLL 成为默认借用检查器,接受更多正确程序。

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

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

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.advance(1) 都是方法调用,借用的都是整个 *cache。关键在 get 的返回类型是 &'a [u8]:它携带的是 'data,与 &self 这次共享借用的区域无关,所以对 *cache 的共享借用在调用返回后就不再活跃,advance 的可变借用不与它冲突。若把签名改成 fn get(&self) -> &[u8](省略规则三把返回值绑到 &self),同样的调用顺序(advance 之后再用 result)就会报 E0502。

4.12 实测:rustc 借用检查器 34458 行——Rust 类型系统的物理重量

把本章贯穿讨论的 rustc_borrowck crate 实测(口径与 §1.18 相同:find rust-1.96.0/compiler/rustc_borrowck -name '*.rs' | xargs cat | wc -l,共 59 个 .rs 文件,不含 Cargo.toml)——

路径 行 角色
compiler/rustc_borrowck/src/diagnostics/conflict_errors.rs 4756 本目录最大——借用冲突错误诊断("cannot borrow as mutable because...")
compiler/rustc_borrowck/src/lib.rs 2722 入口 + 主流程
compiler/rustc_borrowck/src/type_check/mod.rs 2578 MIR 类型检查(含 §4.5 Outlives 关系验证)
compiler/rustc_borrowck/src/region_infer/mod.rs 1925 §4.4 主角——RegionInferenceContext + 约束求解算法
compiler/rustc_borrowck/src/diagnostics/mutability_errors.rs 1868 可变性错误诊断
compiler/rustc_borrowck/src/diagnostics/mod.rs 1564 错误诊断顶层 + blame 机制(§4.9.1)
compiler/rustc_borrowck/src/diagnostics/region_errors.rs 1207 区域错误(§4.9 主角)
compiler/rustc_borrowck/src/diagnostics/region_name.rs 1130 §4.9.3 区域名称推断
compiler/rustc_borrowck/src/diagnostics/move_errors.rs 1039 move 错误
compiler/rustc_borrowck/src/polonius/ 10 文件 2417 §4.10 Polonius 实现——顶层 4 文件 1169(constraints 324 + dump 497 + liveness_constraints 159 + mod 189)+ legacy/ 6 文件 1248(旧 datalog fact 导出)
compiler/rustc_borrowck/src/region_infer/values.rs 543 区域值表示
其余(universal_regions / diagnostics/explain_borrow / region_infer/opaque_types(含 member_constraints)/ type_check 子文件 / dataflow 等) 约 12700 —
rustc_borrowck 合计 34458 —

Variance 计算独立子系统(compiler/rustc_hir_analysis/src/variance/)——

文件 行 角色
constraints.rs 497 §4.6.2 Variance 约束生成
mod.rs 227 入口
solve.rs 151 约束求解
terms.rs 150 Variance 项表示
dump.rs 55 调试 dump
合计 1080 —

两条值得记住的物理事实——

  1. 借用检查器 34458 行 = rustc 中最大的 type-system pass 之一——与同样按整 crate 统计的 rustc_mir_transform(34654 行,§1.18)相当——conflict_errors.rs 单文件 4756 行约是 NLL 主算法 region_infer/mod.rs 1925 行的 2.5 倍——整个 diagnostics/ 目录 13926 行,也多于 region_infer/ 与 type_check/ 两个目录合计的 9556 行,错误诊断的代码比检查算法本身更多——这是 §4.9 "生命周期错误信息:编译器如何生成诊断"在源码层的具体证据;Rust 的"友好错误信息"是付出真金白银工程量换来的
  2. Polonius 子目录 2417 行——是 §4.10 讨论的"下一代借用检查器"——相对 NLL 的 region_infer 1925 行加上 type_check 2578 = 4503 行,约为其一半。这里要分清两段:顶层 4 个文件(constraints / dump / liveness_constraints / mod)1169 行是新路径本身,legacy/ 下 6 个文件 1248 行是旧的 datalog fact 导出——把后者算进来才是 Polonius 在这棵树上的全部工程量。无论按哪一段看,结论方向不变:Polonius 的代码量明显少于 NLL 核心,与 §4.10.6 所说它仍需 -Zpolonius=next 显式开启的状态相符

Variance 子系统 1080 行——§4.6.2 所讲推断算法的全部源码——含 constraints.rs 497 行约束生成 + solve.rs 151 行求解——它推断的是用户定义类型(结构体、枚举等)泛型参数的型变;§4.6.4 的「&mut T 对 T 不变」本身是内建规则,直接写在类型关系代码里(compiler/rustc_type_ir/src/relate.rs:447-451 按 Mutability::Mut 取 ty::Invariant),variance/constraints.rs:487 在 &mut T 出现于结构体字段时沿用这条规则。

口径说明:本节的 34458 行是整个 rustc_borrowck crate 的统计,只能与同样按整 crate 统计的数字比较,例如 §1.18 表里的各 crate、MIR 相关 4 个目录约 80,000 行(ch15 §15.13.5)、codegen 相关 crate 78306 行(ch16 §16.15)。其他各章(ch05、ch06、ch09、ch10、ch11 等)的实测表是按本章主题自选的若干文件,覆盖范围各不相同,既不能与本节相加,也不能用来计算占比。

4.13 本章小结

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