什么是生命周期
借用检查(Borrow Check)的核心理念是:当内存处于借用状态时,其本身不能被修改、移动或释放。为了追踪所有者和借用的关系,Rust 提出了生命周期(Lifetimes)的概念:每次借用,编译器都会为生成的引用赋予一个生命周期,它对应着引用可能被访问的代码区间。编译器会通过推断算法,确定一个能覆盖所有引用使用的最小生命周期。
引用的生命周期不是生存期,后者对应值从创建到释放的时间跨度。为了区分,我们将这个时间跨度称为值的作用域。
生命周期分析基于以控制流图(CFG)表示的 MIR,而非以抽象语法树(AST)表示的 HIR。具体来说,生命周期被定义为 MIR 控制流图中一系列节点的集合,如果生命周期包含点 P
,说明引用在 P
点处有效;在下文中,我们会进一步扩展这个定义,以涵盖 "Skolemized" 生命周期 —— 即函数定义中的具名生命周期。
生命周期出现在 MIR 的以下位置:
- 持有引用的变量或临时变量,其类型中包含生命周期
- 每个借用表达式都有一个指定的生命周期
下例的伪代码会产生三个生命周期,我们将其命名为 'p
、'foo
和 'bar
:
|
|
如你所见,'p
是变量 p
类型的一部分,它表示在控制流图的哪些部分可以安全地对 p
进行解引用;生命周期 'foo
和 'bar
来自借用表达式,它们分别表示 foo
和 bar
被借用的有效时段。
借用表达式的生命周期是借用检查的基石。在本例中,编译器对 'foo
'bar
涵盖的控制流施加限制:
- 在对应生命周期结束前,
foo
和bar
不能被移动或释放 - 由于
&foo
&bar
均为共享借用,借用检查器将阻止在对应生命周期内修改foo
和bar
;若为可变借用,借用检查器将阻止在对应生命周期内访问foo
和bar
生命周期推断
为了推断 'p
'foo
'bar
,编译器将示例代码转换为控制流图。其中节点 A0
B2
C0
分别对应 p = &foo
p = &bar
print(*p)
:
|
|
基于活性的约束
借用检查器首先计算变量的活性(Liveness):若某个变量当前持有的值可能在后续程序中被使用,我们则称该变量处于存活状态。变量 p
在 A0
处被赋值,在 B2
处重新赋值,在 B0
和 C0
处被使用。关键在于,p
在 B1
处持有的值 &foo
,后续不再使用,所以 p
在 B1
处为死亡状态。特别注意,变量赋值后才持有值,因此 p
在 A0
B2
处同样被视为死亡状态,这个设定在求解生命周期约束时很有用。
接着基于活性计算生命周期:若变量 p
在点 P
处存活,且生命周期 'p
出现在 p
的类型中,则 'p
包含点 P
。
于是得到:
|
|
对应到源代码:
|
|
MIR 还包含一个析构变量的操作 DROP(variable)
,它同样会导致变量活性的延长。有趣的是,这种情况下变量的存活不一定扩大对应生命周期的范围。例如 &'a T
&'a mut T
的析构是空操作,'a
有效与否并不重要,在此类情况下,我们称生命周期 'a
在析构时可以悬垂;而对于实现了 Drop
的类型 F<'a>
,'a
在析构时通常不能悬垂。
具体来说,RFC 1327 定义了哪些生命周期在析构时可以悬垂。因而在计算生命周期时,我们再追加一条规则:即使变量当前持有的值在未来可能被 DROP
,其类型中被规定为"可以悬垂"的生命周期也不必包含当前节点。
由此看出,和词法作用域相比,生命周期要灵活得多,甚至可以存在“空洞”(不连续的代码区间),因此 RFC 2094 称其为非词法生命周期(Non-lexical lifetimes)。
生命周期 'foo
和 'bar
未出现在任何变量类型中,故不存在(直接)存活的节点。
子类型化约束
在编程语言理论中,子类型化(Subtyping)是一种类型多态的形式,它允许用子类型(Subtype)替换相应的父类型(Supertype)。也就是说,针对父类型对象进行的操作,相应的子类型对象也适用。Wikipedia 对其有如下解释:
If
Sub
is a subtype ofSuper
, the subtyping relation (written asSub
<:Super
) means that any term of typeSub
can safely be used in any context where a term of typeSuper
is expected.
子类型化常见于支持继承的语言(C#/Java),例如 Cat
继承自 Animal
,那么直觉上很容易想到,任何需要 Animal
类型的表达式,都可以用 Cat
去替换,所以说 Cat
是 Animal
的子类型。Rust 没有继承,它只对生命周期采用子类型化。
Rustonomicon 对生命周期父子关系的解释:
当且仅当
'a
包含(outlives)'b
时,我们定义'a
是'b
的子类型,写作'a: 'b
。
乍看上去有点反直觉,但正如 Cat
拥有 Animal
的属性和方法,'a
也包含了 'b
定义的节点:子类型是在父类型的基础上拓展得来,它比父类型具有更多的“内涵”。
特别说明,死灵书的解释其实并不严谨,非词法生命周期的子类型化实际是位置敏感的(location-aware) —— 判定时需要考虑子类型化的具体位置。例如,在程序点 A0
处,借用表达式 &foo
生成一个 &'foo T
类型的引用,该引用被赋予 &'p T
类型的变量 p
。因此,我们需要确保 &'foo T
是 &'p T
的子类型。然而,因为 p
的新值在 A1
才首次可见,这种子类型关系只需在赋值发生点的后继节点(如 A1
)成立,A0
及之前的节点无关紧要。
于是得到如下子类型约束:
|
|
依据型变规则,它们被转换为生命周期约束:
|
|
重借用约束
还有一类约束的来源是重借用。
为定义重借用约束,我们首先引入 Supporting prefixes 的概念。左值(lvalue)的 Supporting prefixes 通过剥离解引用与字段构成,直到得到共享引用的解引用时停止剥离。以下列举若干支持性前缀的示例:
|
|
然后考虑对表达式 lvalue
的借用;
|
|
在此情形下,我们计算 lvalue
的 Supporting prefixes 集合,并寻找集合中所有解引用 *lv
(让我们称 lv
的生命周期为 'a
);然后添加生命周期约束 ('a: 'b) @ P
,其中 P
为借用开始生效的节点。
关于解引用约束的更多示例见Reborrow constraints。
约束求解
约束条件生成后,编译器通过定点迭代法求解这些约束:每个生命周期初始化,随后遍历约束条件并不断扩展生命周期范围,直至满足所有约束。
形如 ('a: 'b) @ P
的约束条件意味着:从点 P
出发,生命周期 'a
必须包含 'b
中所有可从 P
到达的点。具体实现时,编译器从 P
点开始对 'b
进行深度优先搜索,通过遍历 CFG 中可能的代码路径,将搜索到的每个有效节点添加至 'a
集合中;若搜索过程超出生命周期 'b
范围,则退出该条路径的搜索。例如,本例中从 A1
可以到达 B0
和 C0
,却不能到达 B3
B4
(因为 'p
存在空洞,搜索到 B1
节点时退出 if
路径)。
求解上述约束得到:
|
|
具名生命周期
截至目前,我们仅讨论了函数作用域内的借用,编译器可以自动推理相关的生命周期。当跨越函数的边界传递引用时,需要开发者显式标注生命周期。除 'static
外,Rust 只允许以 <'r>
语法声明泛型生命周期。每次函数调用或类型实例化,'r
都会单态化(monomorphization)为一个具体的代码区间。
特别的,函数定义涉及的具名生命周期(如 'r
)被定义为至少包含以下要素的集合:
- 当前函数 CFG 的全部节点
end('r)
—— 函数返回后调用者(或调用者的调用者...)的某些节点
然后调整生命周期约束的定义,以涵盖具名生命周期。具体而言,('a: 'b) @ P
的语义被扩展为:当 'b
可从 P
到达当前函数 CFG 的终点时,将 'b
包含的所有 end('_)
添加至 'a
。考虑以下示例:
|
|
根据泛型生命周期的定义,我们得到:
|
|
返回的表达式 x
要求 &'a u32
<: &'b u32
,从而产生一个生命周期约束 'a: 'b
,这要求我们将 end('b)
加入 'a
,得到 'a = {F, end('a), end('b)}
。
最后,编译器执行检查:若某个泛型生命周期 'a
包含元素 end('b)
,则必须有 where
子句或隐含约束说明 'a: 'b
,否则报错。
借用检查不会跨函数进行分析,它分别检查函数定义和函数调用,考虑下例:
|
|
我们希望 Rust 拒绝这个程序,理由是共享引用 x
指向 data
的一个子集,而我们试图对 data
本身借可变引用。但借用检查器并不理解“子集”的概念,它对索引操作解糖后看到的是一个 Index::index
函数调用:
|
|
x
的生命周期被推断为 {L3, L4}
,根据 Index::index
函数签名和子类型化约束,借用表达式 &data
的生命周期也被推断为 {L3, L4}
,这与生命周期为 L3
的 &mut data
冲突,于是报错。
关于编译器内置的静态生命周期 'static
,它的语义是到程序结束保持有效。与 'a
类似,'static
包括一个表示为 end('static)
的元素,对应当前函数返回后程序执行的剩余部分。
字符串字面值在程序运行期间始终有效,所以它的引用类型可以是 &'static str
。这里存在一种误解,认为 'static
引用的对象必须在编译时创建且不可变。但以内存泄漏为代价,我们可以得到指向运行时内存的 &'static mut T
引用:
|
|
'static
是任何生命周期的子类型,你可以将 s
赋值给 t
,因为总是满足约束 ('static: 'a) @ L3
:
|
|
型变
生命周期的子类型化引入了一个新的问题:若 'sub
<: 'super
,那么对于生命周期构造出的类型 F<'_>
,也应该有 F<'sub>
:< F<'super>
吗?回答这个问题,首先要理解型变的概念。
型变(Variance)是类型构造器(Type constructor)具有的一个属性,用来说明简单类型的父子关系如何决定复合类型的父子关系。类型构造器是一个表示为 F<T>
的泛型类型,例如 Vec
接受一个泛型 T
的输入,返回 Vec<T>
;&
&mut
接受泛型 'a
和 T
的输入,返回 &'a T
&'a mut T
。Rust 中有三种型变,给定 Sub
是 Super
的子类型:
F<T>
协变(Covariant),如果F<Sub>
是F<Super>
的子类型(子类型关系被传递)F<T>
逆变(Contravariant),如果F<Super>
是F<Sub>
的子类型(子类型关系被反转)F<T>
不变(Invariant),如果F<Sub>
与F<Super>
不存在子类型关系
为了兼顾安全与灵活,Rust 语言团队设计了一套型变规则:
F<'a, T, U> | 'a | T | U |
---|---|---|---|
&'a T | 协变 | 协变 | |
&'a mut T | 协变 | 不变 | |
*const T | 协变 | ||
*mut T | 不变 | ||
UnsafeCell<T> | 不变 | ||
[T] 和 [T; n] | 协变 | ||
Box<T> | 协变 | ||
PhantomData<T> | 协变 | ||
fn(T) -> U | 逆变 | 协变 | |
dyn Trait<T> + 'a | 协变 | 不变 |
某些类型的型变规则,可参照其他类型简单阐明:
Vec<T>
和其它容器类型遵循与Box<T>
一致的型变逻辑*const T
*mut T
分别遵循&T
&mut T
的型变逻辑UnsafeCell<T>
具有内部可变性,因而其型变规则与&mut T
相同Cell<T>
和其它内部可变类型遵循与UnsafeCell<T>
一致的型变逻辑
&'a T
&'a mut T
对 'a
协变,这符合我们的直觉:任何需要一个短生命周期引用 &'short T
的地方,传入一个有效期更长的引用 &'long T
总是安全的。可为什么 &mut T
对 T
是不变的?
若将其视为对 T
协变,由于 &mut T
对 T
是可写的,导致我们可以把一个短生命周期的引用写入长生命周期的引用,从而引发安全问题。考虑以下代码:
|
|
assign
通过对 ptr
借可变引用,使它重新指向 world
,然而 ptr
的生命周期是 'static
,在 world
被释放后打印 ptr
导致了未定义行为。为避免这种情况,&mut &'static str
不能是 &mut &'a str
的子类型。
类似的,由于 &mut T
对 T
是可读的,若对 T
逆变会扩张 T
的有效期,也会导致悬垂引用。而 &T
对 T
是只读的,因此可以对 T
协变,不能对 T
逆变。
然而 &mut T
和 Box<T>
都是指向 T
的可读写指针,为什么后者对 T
可以是协变?因为子类型化发生时,所有权机制使旧的 Box<T>
在移动后失效,并保证它拥有的值不被第三者借用。这是 Rust 的优势,同样的设计在其他语言中是不安全的。
下面的示例很好地说明了这一点:
|
|
assign
调用后,我们销毁了唯一记得 'static
生命周期的 boxed_ptr
,于是再也不会把它误用。
最后一个要消灭的敌人是函数指针。
思考这样一个函数指针类型 fn() -> &'a str
。该类型的函数实例被调用时,会返回具有某个生命周期 'a
的引用,因此可以用 fn() -> &'static str
类型的函数实例进行替换。毕竟,如果期望返回父类型 &'a str
,实际返回子类型 &'static str
是安全的,所以函数指针 fn(T) -> U
对 U
应该协变。
同样的逻辑对函数参数并不适用,fn(&'a str)
的函数实例可以接收具有 'a
或更长生命周期的引用,而 fn(&'static str)
的函数实例只能接收 'static
生命周期的引用。显然可以用前者去替换后者,却不能用后者替换前者,所以函数指针 fn(T) -> U
对 T
应该逆变。
Rust 语言中唯一的逆变是函数的参数,这解释了为何在实践中逆变并不常见。要触发逆变,需要使用函数指针,这些指针接收具有特定生命周期的引用,而非“任意生命周期” —— 后者涉及高阶的生命周期机制,独立于上述规则。
至此,我们已经讨论了标准库提供的类型,结构体、枚举和联合体类型的型变性取决于其字段的型变性。如果一个泛型参数被用于具有不同型变性的字段,那么该参数只能是不变的。例如,以下结构体对于 'a
和 T
是协变的,而对于 'b
、'c
和 U
则是不变的:
|
|
T: 'a 和 use<'a>
Rust 还可以用生命周期约束泛型类型,对应语法为 T: 'a
。语义要求类型 T
在 'a
范围内保持有效,这和 &'a T
的语义类似 —— 引用 &T
对 'a
有效。
什么样的 T
对 'a
有效?如果类型 T
包含引用,那么这些引用的生命周期必须 outlive 'a
,以保证在 'a
内不会出现悬垂引用;如果类型 T
不含引用(如 i32
Box<i32>
String
),则自动满足约束 —— 使用它们永远不必担心悬垂引用。特别的,T: 'static
要求 T
不能包含任何非 'static
引用。
按上述规则,所有 &'a T
类型都满足 &'a T: 'a
,同时 &'a T
隐含了 T: 'a
约束:如果 T
不能保证对 'a
有效,那么其引用也不能保证对 'a
有效。例如,若有一个指向引用的引用 &'a &'b T
,我们会得到:'b: 'a
;反过来说,编译器不允许构造一个 &'static Ref<'a, T>
。
T: 'a
约束还可用于抽象返回类型 impl Trait
,考虑下例报错代码:
|
|
若不显式指明 use<>
块,抽象返回类型会隐式捕获当前范围的的泛型参数:编译器自动为 impl Fn(&'a str)
类型添加 use<'a>
。use<'a>
与 + 'a
的语义不同,前者表示返回类型捕获了泛型生命周期 'a
,后者相当于对返回类型施加 T: 'a
约束。对本例来说,这种区别不影响程序运行。然而当存在多个生命周期参数时,情况截然不同:
|
|
constraint
编译失败,返回值 (a, b)
不满足 T: 'b
和 T: 'a
;capture
则编译成功,它捕获了必要的生命周期 'a
和 'b
。相应的,constraint
返回的类型对 'a
和 'b
的并集有效,而 capture
返回类型只对 'a
和 'b
的交集有效。
言归正传,假设实例 phi
捕获的生命周期为 'f
,两个借用的生命周期分别为 'x
'y
。根据变量的活性推断,'f
应包含 L2 L3 L4
节点,而 phi
的类型被视为 impl Fn(&'f str)
,导致 'x
和 'y
持续到 L4
,于是编译报错。
一个解决方案是为返回类型添加约束:impl Fn(&'a str) + 'static
。静态生命周期约束确保 phi
的类型不包含 'f
(即使它被自动捕获),那么 'f
只存在于子类型化约束:('x: 'f) @ L2
('y: 'f) @ L3
,编译器推断 'f
'x
'y
为空集。
另一个解决方案是显式添加 use<T>
,以避免捕获不必要的 'a
—— 实际返回的闭包类型并没有使用 'a
,这可以通过编译。
更优雅的方法是删掉生命周期 'a
:fn say_some(name: String) -> impl Fn(&str)
,如此 Fn(&str)
会解糖为 for<'a> Fn(&'a str)
,从而使 phi
能接收任意生命周期的引用。
高阶特型约束
高阶特型约束(HRTBs)的全名是 Higher-Ranked Trait Bounds,语法形如 for<'a> Trait<'a>
,语义是“对任意生命周期 'a
实现了 Trait<'a>
”。for<'a>
的意义在于引入一个独立的、上下文无关的高阶生命周期,从而与当前环境解耦。让我们试着描述两个 say_some
的语义:
fn say_some<'a>(name: String) -> impl Fn(&'a str)
:输入任意生命周期'a
和任意实例name: String
,返回一个实例phi = say_some::<'a>(name)
,其类型只对输入的这个'a
实现了Fn(&'a str)
,所以phi
只能接收特定的&'a str
。fn say_some(name: String) -> impl for<'a> Fn(&'a str)
:输入任意实例name: String
,返回一个实例phi = say_some(name)
,其类型对任意生命周期'a
都实现了Fn(&'a str)
,所以phi
能接收不同的&'a str
。
以上从语义角度解释了 HRTBs。别忘了,所有的泛型最终都会单态化为一个确定的“值”。for<'a>
的实质,就是将 'a
的单态化从 say_some
的调用推迟到 phi
的调用!
Fn(A) -> B
实际是 Fn<(A,), B>
的语法糖,考虑到 Fn*
特型的格式将来可能改变,Rust 要求使用语法糖形式。
for<'a>
还可用于函数指针与特型对象。高阶类型适用另一种子类型规则,它们是那些通过替换其高阶生命周期所得到的类型的子类型:
|
|
紧随而至的问题是,哪些类型能满足高阶特型约束 for<'a> Trait<'a>
?我们以函数类型和 Fn*
特型为例,来说明这个问题:
Rust 中,每个函数定义都对应一个实现了 Fn*
的零大小类型(ZST),即所谓函数项类型(Function item type)。考虑一个带有泛型 <'a, T>
的函数:
|
|
foo
对应的函数项类型及其 Fn
实现大致如下(省略了 FnMut
/FnOnce
特征):
|
|
可以看到,函数项类型 FooFnItem<T>
上只定义了泛型类型 T
,没有定义泛型生命周期 'a
。函数项实例化会推断 T
的“值”:
|
|
实例 phi
的类型是 FooFnItem<String>
,根据 impl
代码,FooFnItem<String>
对任意的 'a
都实现了特型 Fn(&'a String) -> &'a String
,所以 phi
可以传入 want_hrtb
:
|
|
实际上,对任何 T
都满足 FooFnItem<T> : for<'a> Fn(&'a T) -> &'a T
约束。
实例调用 phi(&String::new())
被解糖为 phi.call(&String::new())
,每次调用编译器都会确定一个独立的 'a
。对函数 foo
而言,T
和 'a
单态化的时间不同,前者发生于 foo
的实例化,称为早绑定(Early bound),后者发生于 foo
的调用,称为晚绑定(Late bound)。显然,只有晚绑定的生命周期才能转换为高阶生命周期。
早绑定的泛型参数可以使用 Turbofish 语法指定,晚绑定则不行,因为函数项类型没有对应的“位置”:
|
|
编译器生成函数项类型时,按照以下规则处理函数涉及的泛型参数:
- 所有泛型类型参数
T
被视为早绑定 - 泛型生命周期参数
'a
被视为晚绑定,除非:- 出现在
where
子句中:fn foo<'a: 'a>() {}
或fn bar<'a, T: 'a>() {}
- 只用于返回类型:
fn foo<'a>() -> &'a String {}
- 函数定义位于
impl
块,且生命周期由impl<'a>
声明
- 出现在
关于此的更多解释见 Rust Compiler Dev Guide。
最后将早晚绑定的概念由 Fn*
推广到一般情况:
|
|
借用检查的新进展 — Polonius
现有的借用检查器,利用子类型系统和泛型参数,先分析引用本身的生命周期,然后通过类型匹配的方式,倒推“借用发生点”所对应的生命周期,从而检查并发现“悬垂引用”与“可变共享矛盾”两类问题。然而,当下借用检查的实现还存在一些缺陷,为了解决这些问题,Rust 正在开发新的借用检查器 Polonius。
著名的 cve-rs 与此无关,它本质上是 Trait solver 的 BUG。简单来说,处理高阶生命周期的子类型化时,Trait solver 会误擦除一些信息,然后它处理后的代码进行借用检查。由于缺失了必要的信息,借用检查器才会无法检测出悬垂引用。解决这个问题,要等到下一代 Trait solver 的发布。
新借用检查器 Polonius 改变了检查方法。它首先在借用发生点构造一个“借贷关系”,记录了“访问路径”、“相关引用集合”与“可变-共享操作”。然后每次通过“访问路径”进行访问或修改操作时,都会检查所有与该“访问路径”及其父路径有关的“借贷关系”,检查它们在检查点是否存在任一“相关引用”(即活跃性),检查它们的“可变-共享操作”是否被“违反”,若借贷关系存活且被违反则触发编译错误。