Skip to content

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

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

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

本章要点

  • 生命周期是编译期的注解,描述引用在多长时间内有效——它不占运行时开销
  • 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 区域的生命周期

进入 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 已经"死亡"
}

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);

    // ...
}

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 的值的超集:

$$\text{region}('a) \supseteq \text{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 成为默认借用检查器,接受更多正确程序。

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 编译器如何决定一个类型在内存中的物理布局。

基于 VitePress 构建