《Rust 编译器原理》完整目录
- 前言
- 第1章 编译管线全景:从源码到机器码的完整旅程
- 第2章 所有权系统:编译期内存管理的核心机制
- 第3章 借用检查器:编译器如何证明内存安全
- 第4章 生命周期:编译器如何推断引用的有效范围(当前)
- 第5章 内存布局:编译器如何排列数据
- 第6章 单态化:泛型的编译期展开
- 第7章 Trait 静态分发:零成本抽象的编译器实现
- 第8章 Trait Object 与虚表:运行时多态的内存布局
- 第9章 async/await:状态机的编译器变换
- 第10章 Pin、Waker 与 Future:异步运行时的三大支柱
- 第11章 闭包:匿名函数的编译器实现
- 第12章 unsafe:安全抽象的逃生舱
- 第13章 FFI:与 C 世界的桥梁
- 第14章 宏系统:编译期的元编程引擎
- 第15章 MIR 优化:编译器的中间表示与优化管线
- 第16章 LLVM 代码生成:从 MIR 到机器码
- 第17章 增量编译:让重编译只做必要的事
- 第18章 设计哲学与架构决策
第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 区域的生命周期
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 是一个约束:它告诉编译器返回值的有效期不超过 a 和 b 中较短的那一个。编译器不会猜测这种关系------它要求你明确声明。
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);
// ...
}
生成 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_x(x的生命周期必须 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 T 对 T 是不变的:
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 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
考虑以下场景:
rust
fn apply_to_ref<'a>(f: fn(&'a str) -> usize, s: &'a str) -> usize {
f(s)
}
这个函数的问题是:'a 在函数签名中已经固定。调用者必须提供一个 f,它恰好对那个特定的 'a 有效。但如果 f 是 str::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 上传播。综合两章内容,完整流程是:
- 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 最多延伸到 s 的 StorageDead 点(函数末尾的清理代码)。但返回值要求 '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,
}
局部化约束图中有两种边:
- 区域间边 (同一程序点):
a@p -> b@p,来自类型检查约束 - 程序点间边 (同一区域):
a@p -> a@q,来自活跃性分析和控制流
边的方向由 Variance 决定:
- 协变 -> 正向边
- 逆变 -> 反向边
- 不变 -> 双向边
4.10.4 贷款传播:可达性分析
算法步骤:1) 将 NLL 约束转换为局部化约束图 -> 2) 从每个贷款引入点做可达性搜索 -> 3) 记录可达的 (区域, 点) 对 -> 4) 计算每点的活跃贷款 -> 5) 检查非法访问。
4.10.5 Polonius 如何解决 NLL 的局限
回到之前 get_or_insert 的例子。Polonius 的分析过程是:
map.get(key)在程序点 P1 创建贷款 L1(不可变借用 map)match在 P2 处分支- 在
Some分支:L1 的区域在后续使用value的点上活跃 - 在
None分支:L1 的区域不再 活跃(没有对get返回值的使用) - 因此
map.insert在None分支中是合法的------此时没有活跃的不可变贷款
Polonius 通过精确追踪每个贷款在每个程序点的活跃性,避免了 NLL 的过度保守判断。
4.10.6 当前状态
Polonius 通过 -Zpolonius=next 启用,目标是最终替换 NLL 成为默认借用检查器,接受更多正确程序。
'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 编译器如何决定一个类型在内存中的物理布局。