Rust 编译器与运行时揭秘
第4章 生命周期:编译器如何推断引用的有效范围
生命周期是编译器留下的一份证明:你的引用不会悬垂。
生命周期(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<'a>(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, aR1: R2constraint 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 的值的超集:
直觉上:如果引用 &'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 上传播。综合两章内容,完整流程是:
- 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,
}
局部化约束图中有两种边:
- 区域间边(同一程序点):
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 在 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 | — |
两条值得记住的物理事实——
- 借用检查器 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 的"友好错误信息"是付出真金白银工程量换来的 - 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 的可达性模型,每一层都在追求更精确地区分安全与不安全的代码。理解编译器的推理过程,能帮助你写出更符合编译器期望的代码,减少与借用检查器"搏斗"的时间。