Rust 学习笔记——Rust 中的生命周期(Lifetime)
24 Dec 2025用以记载我对Rust类型系统中生命周期的学习笔记。
内容仅为一孔之见,如有错误,欢迎指正。
对于本文提到的概念和定义,均无证明,只起到一个形象上辅助理解的作用(花瓶?)。
想认真学习的可以直接跳到参考资料。
Rust的生命周期
1. Rust的所有权机制(Ownership)
首先我们先讨论Rust的所谓所有权机制。Rust的所有权机制具备“排他性”,即对于一个资源,对于在“同一时刻”只能拥有一个主人(owner),这个主人一般是某个变量。
即Rust在默认情况下采用“移动语义”进行赋值,Rust的移动语义在发生之后,被移动的对象,将不会再被调用析构函数,在Rust中这个函数也叫drop函数。
举例 1 :
let mut v = vec![1, 2, 3, 4, 5];
let moved_v = v;
println!("v[1] = {}", v[1]) // 出现错误,因为 v 已经被移动了
出现错误,因为 v 已经被移动了
举例 2 :
let mut v = vec![1, 2, 3, 4, 5];
let moved_v = v;
println!("v[1] = {}", moved_v[1]) // 正确编译,因为此时 moved_v 是有效的
正确编译,因为此时 moved_v 是有效的
举例 3 :
let mut v = vec![1, 2, 3, 4, 5];
let moved_v = v;
println!("v[1] = {}", moved_v[1])
drop(v) // 出现错误,因为 v 已经被移动了
出现错误,因为 v 已经被移动了
1.1 Rust的可变引用(Mutable Reference)
在Rust中,我们可以用&mut为变量创建可变引用,该可变引用可以更改原有的变量,具有读写权限。
举例 4 :
let mut v = vec![1, 2, 3, 4, 5];
let mut_ref_v = &mut v; // 所有权可变借用
mut_ref_v.push(6); // 原有的 v 已经被更改
println!("{:?}", v)
// 输出:[1, 2, 3, 4, 5, 6]
1.2 Rust的不可变引用(Immutable Reference)
在Rust中,我们可以用&为变量创建不可变引用,该不可变引用可以获取原有变量的值,只具有读权限。
举例 5 :
let mut v = vec![1, 2, 3, 4, 5];
let immut_ref_v = &v; // 所有权不可变借用
println!("{:?}", immut_ref_v);
// 输出:[1, 2, 3, 4, 5]
举例 6 :
let mut v = vec![1, 2, 3, 4, 5];
let immut_ref_v = &v;
println!("{:?}", immut_ref_v);
// 输出:[1, 2, 3, 4, 5]
immut_ref_v.push(6) // 出现错误,immut_ref_v 是不可变引用,不能更改原有的值
出现错误,immut_ref_v 是不可变引用,不能更改原有的值。
1.3 可变引用具有排他性
在Rust中,可变引用之间,可变引用与不可变引用之间,可变引用与主人之间存在排他性。
1.3.1 可变引用之间具备排他性。
举例 7 :
let mut v = vec![1, 2, 3, 4, 5];
let mut_ref_v1 = &mut v;
mut_ref_v1.push(6);
println!("{:?}", mut_ref_v1);
// 输出:[1, 2, 3, 4, 5, 6]
let mut_ref_v2 = &mut v;
mut_ref_v1.push(7); // 出现错误,可变引用之间具备排他性,创建了新的可变引用 mut_ref_v2 之后,原有的可变引用 mut_ref_v1 将会失效
println!("{:?}", mut_ref_v1);
出现错误,可变引用之间具备排他性,创建了新的可变引用 mut_ref_v2 之后,原有的可变引用 mut_ref_v1 将会失效。
不过,再引用(Re-borrowed)是合法的。
举例 8 :
let mut v = vec![1, 2, 3, 4, 5];
let mut_ref_v1 = &mut v;
mut_ref_v1.push(6);
let mut_ref_v2 = &mut *mut_ref_v1; // 通过 &mut * 语法创建再引用
mut_ref_v2.push(7);
mut_ref_v1.push(8); // 取回所有权
println!("{:?}", mut_ref_v1);
// 输出:[1, 2, 3, 4, 5, 6, 7, 8]
通过&mut *语法我们能够从一个已有的可变引用再创建一个引用,不重叠的使用这两个可变引用是合法的。
举例 9 :
let mut v = vec![1, 2, 3, 4, 5];
let mut_ref_v1 = &mut v;
mut_ref_v1.push(6);
let mut_ref_v2 = &mut *mut_ref_v1;
mut_ref_v2.push(7);
let mut_ref_v3 = &mut *mut_ref_v2;
mut_ref_v3.push(8);
let mut_ref_v4 = &mut *mut_ref_v3;
mut_ref_v4.push(9);
println!("{:?}", mut_ref_v1);
// 输出:[1, 2, 3, 4, 5, 6, 7, 8, 9]
这是合法的,没有发生所有权的重叠。
举例 10 :
let mut v = vec![1, 2, 3, 4, 5];
let mut_ref_v1 = &mut v;
let mut_ref_v2 = &mut *mut_ref_v1;
mut_ref_v1.push(6);
mut_ref_v2.push(7); // 出现错误,所有权使用重叠了。
println!("{:?}", mut_ref_v1);
出现错误,所有权使用重叠了。
1.3.2 可变引用与不可变引用之间也具备排他性。
举例 11 :
let mut v = vec![1, 2, 3, 4, 5];
let mut_ref_v1 = &mut v;
let immut_ref_v1 = &*mut_ref_v1; // 再引用创建不可变引用
mut_ref_v1.push(6); // 取回所有权
println!("{:?}", immut_ref_v1); // 出现错误,可变引用与不可变引用之间也具备排他性
出现错误,可变引用与不可变引用之间也具备排他性。
1.3.3 可变引用与主人之间也具备排他性
举例 11 :
let mut v = vec![1, 2, 3, 4, 5];
let mut_ref_v1 = &mut v;
mut_ref_v1.push(6);
println!("{:?}", v); // 取回所有权
mut_ref_v1.push(7); // 出现错误,可变引用与主人之间也具备排他性
出现错误,可变引用与主人之间也具备排他性。
1.4 不可变引用之间不具备排他性
与可变引用、主人之间的排他性相比,不可变引用之间不具备排他性,可以随意重叠使用
举例 12 :
let mut v = vec![1, 2, 3, 4, 5];
let immut_ref_v1 = & v;
let immut_ref_v2 = & immut_ref_v1;
let immut_ref_v3 = & immut_ref_v2;
println!("{:?}", immut_ref_v1);
// 输出:[1, 2, 3, 4, 5]
println!("{:?}", immut_ref_v2);
// 输出:[1, 2, 3, 4, 5]
println!("{:?}", immut_ref_v3);
// 输出:[1, 2, 3, 4, 5]
安全编译运行,不可变引用之间不存在排他性。
1.5 Rust 所有权机制小结
排他性:不允许重叠
可变引用之间不能重叠,可以再借用。可以简单的把主人看作一个原生的可变引用。
( 执行顺序 ↓ ) ( 执行顺序 ↓ )
+-------------------+ +-------------------+
| Owner | | Owner |
| +-------------+ | | +---------+ |
| | MutRef1 | | | | MutRef1 | | <---+
| | +-------+ | | /------------\ | | | | |
| | |MutRef2| | | <--- | valid | | +-|---------|-+ | | [ Invalid! ]
| | +-------+ | | \------------/ | | +---------+ | | |
| | MutRef1 | | | | MutRef2 | | <---+
| +-------------+ | | +-------------+ |
| Owner | | Owner |
+-------------------+ +-------------------+
[ 情况 A: 嵌套 ] [ 情况 B: 重叠 ]
不可变引用之间可以重叠,但是不可变引用不能和可变引用重叠。
( 执行顺序 ↓ ) ( 执行顺序 ↓ )
+--------------+ +--------------+
| Owner | | Owner |
| +----------+ | | +----------+ |
| | MutRef1 | | | | MutRef1 | |
| | +------+ | | | | +------+ | |
| | |MutRef| | | | | |Immut | | |
| | | 2 | | | | | | Ref1 | | |
| | +------+ | | | | +------+ | |
| | +------+ | | | | MutRef1 | |
| | |Immut | | | | +----------+ |
| | | Ref1 | | | | +----------+ |
| | +------+ | | | |ImmutRef1 | |
| | +------+ | | | +----------+ |
| | |Immut | | | | +----------+ |
| | | Ref2 | | | | |ImmutRef2 | |
| | +------+ | | | +----------+ |
| | | | | | MutRef2 | |
| | MutRef1 | | | +----------+ |
| +----------+ | | Owner |
| Owner | +--------------+
+--------------+
[ 情况 A: 合法 ] [ 情况 B: 不合法 ]
多个不可变引用可以嵌套 可变与不可变
在可变引用作用域内 不能重叠
2. Rust的引用与生命周期
为了实现以上Rust所有权机制中说阐述的功能,Rust的引用类型可以看作一个三元组:(可变性,生命周期,类型)。 其中“可变性”的取值范围为“可变”和“不可变”,“类型”的取值范围为“Rust中的合法类型”。 这里有关类型的定义有一点递归,因为生命周期是类型的一部分但是又可以从已有的类型创建新的引用类型。
题外话,rust的类型检查不是完全安全的。二者之间有没有关系我也不好下定论。我觉得是有的,因为这个生命周期是推导出来的,如果缺少用户生命周期要求标记的话rust会尽量推出一个能用的生命周期,而这个推导是会出错的。
生命周期可以分为两类:
- 命名生命周期,即通过语法
'lifetime来在Rust“可以声明泛型”的地方声明一个命名类型周期,静态生命周期'static作为保留字被提前声明。 - 引用生命周期,即通过语法
& T声明一个引用类型时引入的生命周期,也就是前面说的Rust的引用类型可以看作一个三元组中包含的生命周期。
2.1 程序点
生命周期是一个和程序点有关的概念。
生命周期周期可以看作程序中一个程序点的集合,并且规定一个命名生命周期的结尾也算一种程序点。
如静态生命周期'static包括程序所有的程序点,静态生命周期的结尾视作程序结束运行。
举例 13 :
1
let mut v = vec![1, 2, 3, 4, 5];
2
let mut_ref_v1 = &mut v;
3
mut_ref_v1.push(6);
4
let mut_ref_v2 = &mut *mut_ref_v1; // 通过 &mut * 语法创建再引用
5
mut_ref_v2.push(7);
6
mut_ref_v1.push(8); // 取回所有权
7
println!("{:?}", mut_ref_v1);
8
// 输出:[1, 2, 3, 4, 5, 6, 7, 8]
对于一个7行的程序,我们有8个程序点,包括从第一条语句开始前到最后一条语句结束后。
有关程序点还有控制流图的相关知识在这里推荐李樾和谭添老师的课程软件分析, 特别是中间表示层与数据流分析应用部分。其中内容对于理解生命周期在Rust检查机制中的角色很有帮助。
2.2 借用地点(Borrowed Place)
简单来说,借用地点可以直接理解为该引用从何而来。
举例 14:
let mut v = vec![1, 2, 3, 4, 5];
let mut_ref_v1 = &mut v; // mut_ref_v1 的借用地点为 v
let mut_ref_v2 = &mut *mut_ref_v1; // mut_ref_v2 的借用地点为 *mut_ref_v1
mut_ref_v2.push(6);
mut_ref_v1.push(6);
println!("{:?}", v);
在上述例子中,mut_ref_v1的借用地点为v,mut_ref_v2的借用地点为*mut_ref_v1。
在表达式&mut *mut_ref_v1中*操作符起到一个把一个指针风味类型的值转化为地点的作用,依据表达式上下文不具备读或者写的性质。
在Rust中我们可以写出以下无意义的*值化地点语句。
举例 15:
let mut v = vec![1, 2, 3, 4, 5];
let mut_ref_v1 = &mut v;
let _ = *mut_ref_v1;
举例15是能够通过编译的合法Rust语句。
2.3 基于生命周期的排他性
在引入生命周期的定义之后,简单的归纳可以认为,在一个程序点,对于同一个借用地点,只能有一个合法的可变借用。
举例 16 :
1
let mut v = vec![1, 2, 3, 4, 5];
2
let mut_ref_v1: &'L1 mut Vec<i32> = &mut v;
3
mut_ref_v1.push(6);
4
let mut_ref_v2 : &'L2 mut Vec<i32> = &mut *mut_ref_v1; // 通过 &mut * 语法创建再引用
5
mut_ref_v2.push(7);
6
mut_ref_v1.push(8); // 取回所有权
7
println!("{:?}", mut_ref_v1);
8
// 输出:[1, 2, 3, 4, 5, 6, 7, 8]
我们假设上述代码中的$1, 2, 3, 4, \ldots$代表了一系列的程序点,'L1、'L2分别为引用mut_ref_v1、mut_ref_v2的生命周期
(L1、L2的标记在这里是不合法的,用来辅助理解)。
引用mut_ref_v1的借用地点为v,生命周期'L1包括${3, 4, 5, 6, 7}$;
引用mut_ref_v2的借用地点为*mut_ref_v1,生命周期'L2包括${5}$。
所以在$7$之后,mut_ref_v2就不能再用了。
然而这是在合法的程序情况下的'L2生命周期范围。
下面我们考虑一个不合法的程序。
举例 17 :
1
let mut v = vec![1, 2, 3, 4, 5];
2
let mut_ref_v1: &'L1 mut Vec<i32> = & mut v;
3
mut_ref_v1.push(6);
4
let mut_ref_v2: &'L2 mut Vec<i32> = & mut *mut_ref_v1; // 通过 &mut * 语法创建再引用
5
mut_ref_v1.push(8); // 取回所有权
6
mut_ref_v2.push(7);
7
println!("{:?}", mut_ref_v1);
8
在这个例子里,生命周期'L1包括${3, 4, 5, 6, 7}$,生命周期'L2包括${5, 6}$,因为这是他们从被定义后到最后一次被使用的区间。
这里的问题在于语句5mut_ref_v1.push(8),该语句我们也可以看作Vec::push(&mut *mut_ref_v1, 7),创建了借用地点为*mut_ref_v1的临时引用。
设该临时引用的生命周期为'L3,
则'L3包括${5}$(其实我也不知道应该包括几,能引出矛盾就行)。
因此在程序点$5$同时出现了两个借用地点为*mut_ref_v1的引用,“排他性”无法保持,因此报错。
3. 生命周期的推导
对于一个生命周期'Lifetime,如何才能够知道'Lifetime包括哪些程序点呢?
首先生命周期是和引用绑定在一起的一个概念,每当出现一个引用类型的变量,或者一个引用类型的值,必然会引入一个生命周期。因此,在每一个引用类型的变量或者值出现的 程序点,该生命周期都必然包括该程序点。
其次,考虑两个引用类型的变量,v1和v2的类型如下——
let v1: (Mutable, 'L1, Vec<T>) = ...;
let v2:(Mutable, 'L2, Vec<T>) = ...;
在什么情况下,如下赋值是一条合法的Rust语句呢?
v2 = v1
这个问题看起来有点扯,抛开生命周期不谈,(Mutable, _, Vec<T>)和(Mutable, _, Vec<T>)不是一个类型吗?可惜抛不开。
变量v1具有类型(Mutable, 'L1, Vec<T>),变量v2具有类型(Mutable, 'L2, Vec<T>),最直观的条件那必然是当'L1 = 'L2的时候,两个变量的类型一致,
此时赋值语句合法性顺理成章。
但是生命周期又是一个和程序点相关的概念,假设只考虑使用的地点,v2的声明语句就比v1的声明语句落后一位,那么'L2就永远不能等于'L1,因此只考虑使用地点
肯定是不够的。Rust不是一门传统的具有继承机制的编程语言,但是Rust中是存在父类与子类的概念的。这个概念是原生的引入生命周期之间,然后通过变型(Variance)
概念来扩展到Rust中其他与生命周期有关的类型当中。
3.1 生命周期中子类关系
生命周期可以看作是程序当中程序点的集合,因此把集合的子集关系与类型的子类关系进行类比是自然而然的,事实上也确实如此。
考虑生命周期'L1、'L2,S('L1)和S('L2)分别为他们所对应的程序点的集合,当
'L1为'L2的子类时,S('L2)为S('L1)的子集,即
'L1 <: 'L2 => S('L2) ⊆ S('L1)。
举个例子,生命周期${1}$和${2}$是生命周期${1, 2}$,因为生命周期${1, 2}$“继承”了生命周期${1}$和${2}$的所有“行为”。 所以Rust中的这个“继承树”是倒过来画的。
static ----------+-----...------+ (greatest/sub type)
| | |
param regions | |
| | |
| | |
| | |
empty(root) placeholder(U1) |
| / |
| / placeholder(Un)
empty(U1) -- /
| /
... /
| /
empty(Un) -------- (smallest/super type)
静态生命周期'static是所有其他生命周期的子类,因为它“继承”了所有其他生命周期的“行为”。
3.2 生命周期与变型(Variance)
协变性讨论的是对于泛型类型F,考虑泛型参数Sub和Super,且Sub是Super的子类,F<Sub>与F<Super>之间的子父类关系。
- 如果
F对Sub和Super是协变的(Covariant),那么F<Sub>是F<Super>的子类。 - 如果
F对Sub和Super是逆变的(Contravariant),那么F<Sub>是F<Super>的父类。 - 如果
F对Sub和Super是不变的(Invariant),那么F<Sub>和F<Super>没有子父类关系。
在Rust Nomicon Subtyping中列举了如下变型规则:
| ‘a | T | U | |
|---|---|---|---|
&'a T |
covariant | covariant | |
&'a mut T |
covariant | invariant | |
Box<T> |
covariant | ||
Vec<T> |
covariant | ||
UnsafeCell<T> |
invariant | ||
Cell<T> |
invariant | ||
fn(T) -> U |
contravariant | covariant | |
*const T |
covariant | ||
*mut T |
invariant |
举个几个例子:
- 假设
'L1是'L2的子类,即S('L2) ⊆ S('L1),那么&'L1 T是&'L2 T的子类。 - 假设
'L1是'L2的子类,即S('L2) ⊆ S('L1),那么&'L1 mut T是&'L2 mut T的子类。 - 假设
&'L1 T是&'L2 T的子类,那么&'a mut &'L1 T和&'a mut &'L2 T没有子父类关系,当&'L1 T == &'L2 T时,&'a mut &'L1 T == &'a mut &'L2 T。 - 假设
&'L1 T时&'L2 T的子类,那么fn(&'L1 T)->U是fn(&'L2 T)->U的父类。这个例子会显得有点奇怪,简单的理解为函数是变量的消费者,函数变量中的参数变量的赋值方向与函数变量自己的赋值方向是反过来的。
回到生命周期推导一开始的问题——
在什么情况下,如下赋值是一条合法的Rust语句呢?
let v1: (Mutable, 'L1, Vec<T>) = ...;
let v2:(Mutable, 'L2, Vec<T>) = ...;
v2 = v1
我们已经知道(Mutable, 'L, Vec<T>)基于'L是协变的,我们又知道子类可以被赋值给父类,因此我们可以推断出,当'L1是'L2的子类的时候,
也就是S('L2) ⊆ S('L1), 此时(Mutable, 'L1, Vec<T>)是(Mutable, 'L2, Vec<T>)的子类,上述赋值语句将会合法。
因此上述代码应该重写为:
let v1: (Mutable, 'L1 : 'L2, Vec<T>) = ...; // 在这里我们用操作符`:`标记当`'lhs:'rhs`时,`'lhs`是`'rhs`的子类。
let v2:(Mutable, 'L2, Vec<T>) = ...;
v2 = v1
在这里我们用操作符:标记当'lhs:'rhs时,'lhs是'rhs的子类。
举例 18:
fn foo<'a: 'b, 'b, 'c>(mut x: &'c mut &'a i32, mut y: &'c mut &'b i32) {
y = x; // 可变引用基于类型是不变的(Invariant),因此无法通过编译
}
上述代码无法通过编译,因为可变引用基于类型是不变的,尽管根据子类标记'a:'b标明了'a是'b的子类,但是赋值x给y是不被允许的,该赋值在我们再添加
一个子类标记'b:'a时会通过编译,此时'a:'b且'b:'a,说明'a == 'b。
举例 19:
fn foo<'a: 'b, 'b:'a, 'c>(mut x: &'c mut &'a i32, mut y: &'c mut &'b i32) {
y = x; // 通过编译
}
举例 20:
fn bar<'a, 'b, T: Copy>(x: fn(&'b T) -> T) -> fn(&'a T) -> T {
x // 函数风味类型的基于参数生命周期泛型参数一般是逆协变(Contravariant)的,因此无法通过编译。
}
上述代码无法通过编译,函数风味类型的基于参数生命周期泛型参数一般是逆协变的,需要我们添加子类标记'a:'b标明'a是'b的子类使其通过编译,此时fn(&'b T)->T是
fn(&'a T)->T的子类。
举例 21:
fn bar<'a :'b, 'b, T: Copy>(x: fn(&'b T) -> T) -> fn(&'a T) -> T {
x // 通过编译
}
3.3 生命周期的子类关系与约束
在上述例子中,我们都是显式的标明了生命周期的子类关系,如果在实际编写代码时依旧如此那会非常麻烦。因此实际的情况往往反过来,Rust会通过赋值关系反过来推导子类关系。
当我们写下如下代码时——
let v1: (Mutable, 'L1, Vec<T>) = ...;
let v2:(Mutable, 'L2, Vec<T>) = ...;
v2 = v1
当我们希望(Mutable, 'L1, Vec<T>)是(Mutable, 'L2, Vec<T>)的子类时——即赋值成功,添加'L1:'L2的约束能够满足我们的心愿,所以Rust会添加
'L1:'L2这样一条子类约束。在添加这条子类约束后,我们重新给代码添加行号。
1
let v1: (Mutable, 'L1, Vec<T>) = ...;
2
let v2:(Mutable, 'L2, Vec<T>) = ...;
3
v2 = v1
4
通过代码本身,我们可以推断如下几点:
L1在${2 ,3}$之间被使用且没有被重定义(Redefined)。L2在${3}$之间被使用且没有被重定义。- 程序员希望
'L1:'L2,即S('L2) ⊆ S('L1)。
我们只需要做一个简单的检查就会发现 ${3} \subseteq {2, 3}$是成立的,计划通,该赋值合法。
考虑一个计划不那么通的例子。
举例 22 :
1
let mut v = vec![1, 2, 3, 4, 5];
2
let mut_ref_v1 :&'L1 mut Vec<_> = &mut v;
3
mut_ref_v1.push(6);
4
let mut_ref_v2 :&'L2 mut Vec<_> = &mut *mut_ref_v1;
5
mut_ref_v2.push(7);
6
let mut_ref_v3 :&'L3 mut Vec<_> = &mut *mut_ref_v2;
7
mut_ref_v3.push(8);
8
let mut_ref_v4 :&'L4 mut Vec<_> = &mut *mut_ref_v3;
9
mut_ref_v4.push(9);
10
println!("{:?}", mut_ref_v4);
11
// 输出:[1, 2, 3, 4, 5, 6, 7, 8, 9]
'L1在${3, 4}$之间被使用且没有被重定义。'L2在${5, 6}$之间被使用且没有被重定义。'L3在${7, 8}$之间被使用且没有被重定义。'L4在${9, 10}$之间被使用且没有被重定义。- 程序员希望
'L1:'L2,即S('L2) ⊆ S('L1)。 - 程序员希望
'L2:'L3,即S('L3) ⊆ S('L2)。 - 程序员希望
'L3:'L4,即S('L4) ⊆ S('L3)。
推导过程如下图所示:
+-----------------------------+
| {'L1} | Range: [3, 4, 5, 6, 7, 8, 9, 10]
+-----------------------------+
|
| 'L1 : 'L2
v
+-----------------------------+
| {'L2}. | Range: [5, 6, 7, 8, 9, 10]
+-----------------------------+
|
| 'L2 : 'L3
v
+-----------------------------+
| {'L3} | Range: [7, 8, 9, 10]
+-----------------------------+
|
| 'L3 : 'L4
v
+-----------------------------+
| {'L4} | Range: [9, 10]
+-----------------------------+
再考虑一个计划很不通的例子。
举例 23 :
1
fn condition_set(x: i32, y: i32, condition: bool, result1: &mut i32, result2: &mut i32) -> () {
2
let foo :&'foo i32 = &x;
3
let bar :&'bar i32 = &y;
4
let mut p: &'p i32;
5
p = foo;
6
if condition {
7
*result1 = *p;
8
p = bar; // 重定义(Redefine)
9
}
10
*result2 = *p;
11
}
12
'foo在${3, 4 ,5}$之间被使用且没有被重定义。'bar在${4, 5, 6, 7, 8}$之间被使用且没有被重定义。'p在${6, 7, 10}$之间被使用且没有被重定义。'p在${9, 10}$之间被使用且没有被重定义。- 程序员希望
'foo:'p,即S('p) ⊆ S('foo)。 - 程序员希望
'bar:'p,即S('p) ⊆ S('bar)。 - 这里
'foo和'bar之间并没有直接的关系。
推导过程如下图所示:
+-------------------------+ +-------------------------+
| {'foo}. | | {'bar} |
| Range: {3,4,5,6,7,9,10} | | Range: {4,5,6,7,8,9,10} |
+-------------------------+ +-------------------------+
| |
| 'foo : 'p | 'bar : 'p
+-----------------+----------------+
|
v
+-----------------------------+
| {'p} |
| Range: {6, 7, 9, 10} |
+-----------------------------+
在这里我们注意到,由于p被重定义了一次,所以'p不是连续的,所以'foo也不是连续的,也就是说生命周期确实只是程序点的集合,不要求是完全连续的片段。
简单来说,'p的计算方式就是从被定义点,在控制流图上做深度优先搜索,碰到重定义地点则停止,并记录下一路上的使用地点,从被定义点延伸到最后一次使用的地点。
并且考虑'p所有的被定义点,上述的3和4就是分别从两个定义点出发做深度优先搜索搜索出来的合法的程序使用地点集合。
3.4 生命周期的推导小结
- 对于一个引用其所绑定的生命周期,其使用约束(Liveness Constrains)来自从每一个被定义点出发在控制流图上做深度优先搜索,碰到另一个重定义地点则停止,并记录下一路上的使用地点,从起始被定义点延伸至最后一次使用的地点或者另一个被定义点。
- 考虑程序员在编写程序时出现的引用之间的赋值语句,根据子类与变型规则(Subtyping and Variance),添加生命周期的子类(命长)约束(Outlives Constrains),
'L1:'L2意味着S('L2) ⊆ S('L1)。 - 当然生命周期的子类约束还有其他来源,比如
&'a &'b就意味着S('a) ⊆ S('b),或者程序员也可以手动给命名生命周期添加子类约束。 - 简单来说生命周期的引入得保证被其引入的生命周期的有效性。
最后求解出能满足各个约束的的各个约束的最小集合——考虑最极端的情况给每一个生命周期都赋值程序里的所有程序点那就不用求解了。
有关程序点和这种集合约束求解的知识同样推荐李樾谭添老师的课程软件分析,特别是Anderson风格指针分析对于理解这里的生命周期范围约束求解有很大帮助。
真实的生命周期推导比我描述的复杂,能力有限,瞎扯至此。
3.5 命名生命周期与其结尾(the Ends of Named Lifetimes)
命名生命周期就是有名字的生命周期,比如'static,比如在写函数时在反省参数列表里添加的'a、'b。给命名证明周期额外添加一个“结尾”来标记其结束其实是
一个很自然的行为。
因为对于一个函数,其泛型参数列表里出现的命名生命周期的程序地点集合必然包含整个函数体,因为这是函数调用外部注入的,只有引用的情况下我们无法析构其主人,所以 他的生命周期横跨整个函数也是自然而然的。但是在分析一个函数内的命名周期时,如果每一个命名生命周期都是简单的包括整个函数体,那就没有意义了,因此得进行区分。
举例 24:
1
fn foo<'a: 'b, 'b, 'c>(mut x: &'c mut &'a i32, mut y: &'c mut &'b i32) {
2
y = x; // 可变引用基于类型是不变的(Invariant),因此无法通过编译
3
}
4
简单而言,命名生命周期'a、'b、'c的的初始集合为${2, 3,\mathrm{end(‘a)}}$,${2, 3,\mathrm{end(‘b)}}$,${2, 3,\mathrm{end(‘c)}}$。
考虑用户添加的生命周期子类约束'a: 'b,则$S(\mathrm{‘a}) = {2, 3, \mathrm{end(‘a)},\mathrm{end(‘b)}} $,
以此可将不同的命名生命周期周期区分开来。
4. 总结
写这篇笔记的出发点是因为在一开始学习Rust的时候,我愚蠢的认为Rust中的生命周期只起到了一个提示作用,类似于Python里的类型标记,没有实际意义。后续的学习下我 发现自己错了,错得很离谱。Rust的生命周期将Rust的类型系统在程序点尺度上变成了动态的,或者说根据生命周期信息能够在某个程序点判断出某个类型是否合法。生命周期 变量(类型?)(生命周期集合大小?)这些信息都是会实际计算一遍的,可以采用以下指令执行一下看看结果。
RUSTC_LOG=rustc_borrowck=debug rustc +stage1 ${source_file} -Zdump-mir=nll -Zmir-include-spans
这篇笔记有三个子章节——《Rust的所有权机制》、《Rust的引用与生命周期》和《生命周期的推导》——我认为《Rust的所有权机制》这一章其实也可以单独拿出来看,
抛开生命周期不谈也足以理解Rust给引用添加了哪些限制。基于严格“排他性”的所有权机制其实已经足以避免释放后使用、双重释放、内存泄漏等内存问题。不过严格的“排他性”实现会给程序员编写代码更多的编写束缚。在严格的排他性与代码编写便捷度上Rust也做了很多权衡与折衷,例如隐式的生命周期推导、双重借用(Two-Phase Borrowing)还有UnsafeCell类型包装提供的内部修改性等。
不过其实由于我有限的智力,这篇文章里我其实引入了很多乱七八糟的概念、定义还有一些记号,我不对这些概念、定义和记号的正确性负责,只在直觉上 辅助Rust代码编写。如果和rustc的实现有冲突是正常的,请以rustc的实现为准。
5. 参考资料
有关排他性的资料参考Stacked Borrows,Place Expressions。
有关生命周期推导的资料参考NLL,Rust Dev Guide,rust_borrowck, Oxide。
有关生命周期的子类规则资料参考Subtyping and Variance。
这些参考资料的权威性不为本篇学习笔记背书,严谨是他们的,不影响本篇笔记的不可靠性。
笔记中ASCII CHAR基本上是Gemini生成的,只有生命周期的继承树是从region_kind.rs偷的。