RustLifeTimeKata学习记录

最近闲下来有点空,于是决定在玩 compose 之外重新看点 Rust 相关的内容,也因为我的 Rust 理解基本就是半吊子水平,一涉及到并发/协程 etc 就歇菜了。。。所以我决定从生命周期开始重新学起来,之后再看看 Tokio 的相关实现,这次学习的参考资料就是一本有关 Rust 生命周期实践的小册——《Rust LIfetimeKata》。除了一些概念上的讲解外,这个项目还有配套的一些习题,我也会把我的回答和理解放在这篇文章里。

开始之前的闲聊

在开始之前我们还是照例聊聊一些边角料,对于非 CPP 主力的大多数开发者来说,生命周期应该是一个相当陌生的东西,注意我说的不是业务层面的生命周期函数等,而是指引用等变量的存活时间段。

因为有 GC 兜底,无论是前端还是后端,都不用考虑到如此细枝末节的东西,Runtime 会帮助我们处理一切,真是太棒了!。。吗?

即使不考虑系统级编程或者量化交易等对性能有极端要求的场景,传统的后端业务在部分场景中也会因为 GC 的各种缺陷导致性能瓶颈[1],此时你就不得不用 Rust/CPP 去重写对应的实现。

但是没有 GC 的保护,去写内存安全的程序犹如在钢丝上行走,很多 CPP 开发的程序,尤其是那些没有引入 Modern CPP 的项目,就会很容易出现各种各样的内存安全问题。

生命周期是一种更加折中的方式,通过手动指定引用的生命周期约束的方式,编译器在大多数情况下可以正确的实现并获得与手动析构相同的性能,再配合各类智能指针,几乎可以覆盖大多数场景。

不过你真的需要生命周期吗?假设你所有的类型都使用值类型并保证不变性,再配合智能指针的使用,可以在保证一定性能的前提下同时避免生命周期的使用,但是如果这样玩的话,那还不如写 Erlang 呢😂。

生命周期标注

前几章的内容比较简单,这里就放下 exercise 的答案。

exercise

1
2
3
4
5
#[require_lifetimes(!)]
pub fn identity<'a>(number: &'a i32) -> &'a i32 {
    number
}
//没什么可说的,返回值和输入值有一样的生命周期
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[require_lifetimes(!)]
pub fn split<'a, 'b>(text: &'a str, delimiter: &'b str) -> Vec<&'a str> {
let mut last_split = 0;
let mut matches: Vec<&str> = vec![];
for i in 0..text.len() {
if i < last_split {
continue;
}
if text[i..].starts_with(delimiter) {
matches.push(&text[last_split..i]);
last_split = i + delimiter.len();
}
}
if last_split < text.len() {
matches.push(&text[last_split..]);
}

matches
}
// 这个相对有意思一些,delimiter 是分隔符,而返回的值的资源全部从 text 中获取,所以后两者应该拥有相同的生命周期
1
2
3
4
5
6
7
8
9
10
11
12
13
#[require_lifetimes(!)]
pub fn only_if_greater_hard<'a, 'b>(
number: &'a i32,
greater_than: &'b i32,
otherwise: &'a i32,
) -> &'a i32 {
if number > greater_than {
number
} else {
otherwise
}
}
// 函数要么返回 number 要么返回 otherwise,所以这两者应该拥有至少一样长的生命周期,另一个函数参数则独自拥有一个生命周期

函数的生命周期省略规则

在实际编写代码时,你可能会发现很多情况下并不需要为每个函数的引用参数都添加生命周期标注,这是因为 rust 编译器在这几年的演进过程中已经包含了很多对生命周期的改善,对于一些常见的模式,编译器可以自动推断出其生命周期约束。然而,这种自动推断有些时候会推导出完全不符合我们意图的生命周期。

参考相关文档,对于函数的生命周期省略规则如下。

  1. 函数参数中每个省略周期标注的参数都会被给予一个独立的生命周期参数。
  2. 如果参数中只使用了一个生命周期,则将其作为所有省略的输出生命周期。
  3. 对于方法签名, Self 引用的生命周期会被作为省略的输出生命周期。

exercise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use require_lifetimes::require_lifetimes;

#[require_lifetimes(!)]
pub fn example_a<'a>(_number: &'a i32) -> (&'a i32, &'a i32) {
unimplemented!()
}

#[require_lifetimes(!)]
pub fn example_b<'a, 'b, 'c, 'd>(
_first_arg: &'a i32,
_second_arg: &'b i32,
_third_arg: &'c Option<&'d i32>,
) {
unimplemented!()
}

#[require_lifetimes(!)]
pub fn example_c<'a>(_first_arg: &'a i32, _second_arg: &'a i32) -> &'a i32 {
unimplemented!()
}

#[require_lifetimes(!)]
pub fn example_d<'a, 'b>(_first_arg: &'a i32, _second_arg: &'b i32) -> &'a i32 {
unimplemented!()
}

可变引用和容器

其实可变引用这个词就很微妙,语义上说起是 mutable 是完全正确的,但是从实际应用角度来看,将其称之为独占引用会更恰当,与之相对的,常规的引用应该被称为共享引用。

之所以会这么称呼是因为 rust 限制一个变量的引用在同一时间只能有以下三种情况:

  1. 不存在任何引用
  2. 任意个共享引用
  3. 一个独占引用(mut reference)

所以 mut reference 相较于常规的 reference ,会有很多不同之处,尤其是在类型系统上的差异,这块的内容我们会在之后的章节讨论。

现在我们来看下列例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn insert_value<'a>(
my_vec: &'a mut Vec<&'a i32>,
value: &'a i32,
) {
my_vec.push(value)
}
fn main() {
let mut my_vec = vec![];
let val1 = 1;
let val2 = 2;

insert_value(&mut my_vec, &val1);
insert_value(&mut my_vec, &val2);

println!("{my_vec:?}");
}

这段代码看似没有什么问题,但是实际上没办法通过编译。

在这个例子中,函数签名将入参的所有生命周期都被约束到了 'a 一个生命周期上,这是一个非常严格的限制。

&val1&val2 在这个例子中生命周期一直持续到了 main 函数结束,第一次调用 insert_value 函数时,创建了一个 mut reference ,由于函数签名的约束,这几个入参至少有着同样长的生命周期,所以此处使用的临时引用也被约束至少活到 main 函数结束,第二次调用 insert_value 时也是同样的情况,此时就违背了引用的规则——同一时间只能有一个可变引用。

解决问题的方法同样也在函数签名之上,就像刚才提到的,所有入参都被约束到 'a 是一个极其严苛的约束,为了解决这个问题,我们首先来分析 insert_value 的意图。

这个函数通过一个类型为 &mut Vec<&i32> 的引用,并接受另一个类型为 &i32 的引用,并将其插入到前一个引用指向的 Vec 中。这里涉及到三类引用,对容器的引用,容器内的引用类型的值,以及一个待插入容器的引用,为了分析方便,我们分别将其称为 'r,'container 以及 'val

实际上这里在分析生命周期的时候还应该加上容器本身的生命周期,但这是多余的!因为容器的有效性依赖与其内部值的有效性,所以在这里,容器的生命周期至多与其所持有的值的生命周期一样长,也就是说,只要容器本身没被析构,其所包含的所有值都应该是有效的。所以,为了分析方便,我们在此处只需要考虑容器持有的值的共有最短生命周期 'container

在插入操作后,value 的生命周期同样也受到了原容器 my_vec 的关联,所以 'val 的生命周期肯定是比容器的生命周期以及容器的生命周期长的,其次,容器内的值都有着至少为 'container 的生命周期,所以有 'val: 'container 这层关系。也就是说我们可以将其简化到如下的函数签名。

1
2
3
4
5
fn insert_value<'r,'container,'val>(
my_vec: &'r mut Vec<&'container i32>,
value: &'val i32,
)
where 'val: 'container

where 子句的约束 'val: 'container 读作 'val 长于 'container

代码修改到这里其实已经可以通过编译了,但是其还可以进一步简化。

别忘了在插入后 value 已经成为容器所持有的值的一员,所以它们都有着至少一样长的生命周期!所以上述函数签名可以进一步修改如下。

1
fn insert_value<'r, 'val>(my_vec: &'r mut Vec<&'val i32>, value: &'val i32)

是不是更加清爽了?当然之所以能这么修改,是因为生命周期是泛型参数,也就是说其满足里氏替换规则,换言之就是编译时每个入参和返回值的生命周期满足函数签名中对类型的约束就完全没问题。

在这例子中 &val1 明显长于 &val2 ,但是其都满足修改后的 insert_value 对生命周期的约束 aka 对应生命周期是函数签名中对应部分的子类型。

关于生命周期和类型系统的联系,我们会在后续章节详细讨论。

exercise

1
2
3
4
5
6
7
8
9
10
11
12
13
#[require_lifetimes(!)]
pub fn vector_set<'container, 'val>(
vector: &'container mut Vec<&'val str>,
loc: usize,
new: &'val str,
) {
// TODO: You will need to write this code yourself.
// Don't worry, it's only one line long.
if let Some(e) = vector.get_mut(loc) {
*e = new;
}
}

生命周期与类型系统

注意,这一部分并不是来自于 LifetimeKata,但是我认为讨论类型系统对于理解生命周期的应用非常有必要,所以我额外插入了此章节。

在开始之前我们先介绍一些之后会用到的术语。

里氏替换原则:

子类可以替代基类工作,由于 Rust 没有常规面向对象的类设计,所以在这里我们指的是子类型。

型变:

此处指的是通过某些方式派生后的子类型相较派生后父类型的关系,可以参考Wik

好的,有了上述概念的支撑,我们可以继续来研究生命周期的问题了。

根据 Reference 的文档页面,对于常见的类型而言,其型变规则如下

Type Variance in 'a Variance in T
&'a T covariant covariant
&'a mut T covariant invariant
*const T covariant
*mut T invariant
[T] and [T; n] covariant
fn() -> T covariant
fn(T) -> () contravariant
std::cell::UnsafeCell<T> invariant
std::marker::PhantomData<T> covariant
dyn Trait<T> + 'a covariant invariant

其中 covariant 指的是协变,invariant 不变,contravariant 逆变。

在这张表格中,只要属性上是允许 mutable 的,对于 T 来说,其都是不变的,反之则是协变的,而函数的入参相较于 T 来说则是逆变的。

我们引用 Nomicon 的例子来说明这些规则。

1
2
3
4
5
6
7
8
9
10
11
12
fn debug<'a>(a: &'a str, b: &'a str) {
println!("a = {a:?} b = {b:?}");
}

fn main() {
let hello: &'static str = "hello";
{
let world = String::from("world");
let world = &world; // 'world has a shorter lifetime than 'static
debug(hello, world); // hello silently downgrades from `&'static str` into `&'world str`
}
}

所以对于一个 &'a T来说,接受一个 &'b T where 'b: 'a 是完全没有问题的,这完全符合直觉,因为我们使用了一个生命周期长于约束的引用。

不加证明的,&'a U 也能接受一个 &'a T ,其中 TU 的子类型(里氏替换规则)。

但是对于 &mut 'a T' ,情况就变得特殊起来了,我们来看下列例子。

1
2
3
4
5
6
7
8
9
10
11
12
fn assign<T>(input: &mut T, val: T) {
*input = val;
}

fn main() {
let mut hello: &'static str = "hello";
{
let world = String::from("world");
assign(&mut hello, &world);
}
println!("{hello}"); // use after free 😿
}

这段代码无法通过编译,很明显我们的 hello 变成了一个无效的悬垂引用,但是编译器是怎么知道的呢?

首先 assign 函数是没有任何问题的,这只是一个重新赋值的常见操作而已,问题应该发生在 main 函数的调用中。

对于

1
fn assign<T>(input: &mut T, val: T)

其在编译时被单例化成如下形式(&String 可以被  Deref 成 &str

1
fn assign(input: &mut &'static str, val: &'static str)

而我们传入的 input&mut &'static strval&'b str

但是显然在调用的时候不可能满足 'b: 'static,故无法编译。

还有另外一个角度来说明,但是我不确定这样解释是正确的,我们在此假设对于泛型函数,若其调用能通过编译检查则有 rustc 的单例化对参数顺序无关。

那么可以有以下单例化形式:

1
fn assign(input: &mut &'b str, val: &'b str)

其中不满足 'b: 'static,那么在调用时 input 的类型必须为其标注的类型或其子类型。

&mut T 对于 T 是不变的,所以 &mut &'static str 不是 &mut &'b str 的子类型,故编译无法通过。

到此我们已经初步讨论了生命周期和类型系统相结合的情形,但这是显然不够的,如果需要进一步的了解相关信息,可以看看下列资料。

  1. 《The Rustonomicon》 死灵书,the black art of unsafe rust 🦀
  2. 《Common Rust Lifetime Misconceptions》
  3. 《The Reference》 参考手册,大而全

复合类型上的生命周期

就像生命周期标注会出现在函数签名上,声明一个使用到引用的枚举或者结构体也会要求生命周期标注。

1
2
3
4
5
6
7
8
struct Sample<'a> {
data: &'a str
}

enum AnotherSample<'a> {
Some(&'a str),
None
}

这没什么稀奇的,就像编写带有常规泛型参数的结构体和枚举一样,不过变成了单引号开头的生命周期。

exercise

1
2
3
4
5
6
pub struct Difference<'a, 'b> {
first_only: Vec<&'a str>,
second_only: Vec<&'b str>,
}

pub fn find_difference<'a, 'b>(sentence1: &'a str, sentence2: &'b str) -> Difference<'a, 'b>

impl 块中的生命周期

当我们给复合类型加上了生命周期标注后,对应的 impl 语句也要带上对应的生命周期参数。

1
2
3
4
5
6
7
8
9
struct Sample<'a, T> {
data: &'a T,
}

impl<'a, T> Sample<'a, T> {
fn new(data: &'a T) -> Sample<'a, T> {
Sample { data: data }
}
}

上述例子还可以继续简化

1
2
3
4
5
impl<'a, T> Sample<'a, T> {
fn new(data: &'a T) -> Self {
Sample { data }
}
}

或者你可以让编译器帮你推断生命周期,但是我个人感觉这已经没什么必要了。。。

1
2
3
4
5
impl<'a, T> Sample<'_, T> {
fn new(data: &'a T) -> Sample<'_, T> {
Sample { data }
}
}

Exercise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// First, the struct:

/// This struct keeps track of where we're up to in the string.
struct WordIterator<'s> {
position: usize,
string: &'s str
}

impl WordIterator {
/// Creates a new WordIterator based on a string.
fn new(string: &str) -> WordIterator {
WordIterator {
position: 0,
string
}
}

/// Gives the next word. `None` if there aren't any words left.
fn next_word(&mut self) -> Option<&str> {
let start_of_word = &self.string[self.position..];
let index_of_next_space = start_of_word.find(' ').unwrap_or(start_of_word.len());
if start_of_word.len() != 0 {
self.position += index_of_next_space + 1;
Some(&start_of_word[..index_of_next_space])
} else {
None
}
}
}

fn main() {
let text = String::from("Twas brillig, and the slithy toves // Did gyre and gimble in the wabe: // All mimsy were the borogoves, // And the mome raths outgrabe. ");
let mut word_iterator = WordIterator::new(&text);

assert_eq!(word_iterator.next_word(), Some("Twas"));
assert_eq!(word_iterator.next_word(), Some("brillig,"));

}

Example 1

1
fn next_word<'borrow>(&'borrow mut self) -> Option<&'borrow str>

这个可以编译,但是不太符合实际需求。因为独占引用和返回值都有着同样的生命周期约束,所以在后者没有被 drop 掉之前你甚至都不能再次调用这个函数。

Example 2

1
fn next_word<'borrow>(&'borrow mut self) -> Option<&'lifetime str>

这个是正确的,返回值拥有和 self 一样的生命周期约束。

Example3

1
fn next_word(&mut self) -> Option<&'lifetime str>

在完整展开后这个和 Example2 的语义是一致的。

Example4

1
fn next_word(&mut self) -> Option<&str>

在展开后这个和 Example1 的语义是一致的。

特殊的生命周期们

'static

这是一个极其具有迷惑性的生命周期,因为按照其他语言的理解来看,static 就是静态的变量,所以 'static 就意味着始终有效。。。吗?

尽管通过 const 修饰的或者字面量等都满足 'static 的约并且在程序运行期间始终有效,但是这并不意味着满足上述约束的所有变量都是始终有效的,关于这个问题的详细分析,可以参考这篇文章,以及其对应的译文

'_

这个特殊的生命周期表示让编译器自动推断,在有些 impl 的实现中,若其不依赖于 self 之外的生命周期,通常可以标注 '_,另外在某些 trait object 的类型中也会用到,不过这块比较特殊,我们会在之后的章节中讨论。

Exercise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct UniqueWords {
sentence: &'static str,
unique_words: Vec<&'static str>,
}

impl UniqueWords {
fn new(sentence: &'static str) -> UniqueWords {
let unique_words = sentence
.split(' ')
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();

UniqueWords {
sentence,
unique_words,
}
}

fn get_sorted_words(&self) -> Vec<&'_ str> {
let mut unique_words = self.unique_words.clone();
unique_words.sort();
unique_words
}
}

trait object 的生命周期省略规则

与在 Rust 中我们遇到的其他情形不同,trait object 的生命周期有着特殊的规则。

在没有显式指定生命周期时,trait object 的默认生命周期为 'static,当使用 '_ 时,编译器使用常规的生命周期省略规则推断。

在将trait object作为泛型参数的一部分时。省略规则就变得更为复杂了,完整的解释和示例可以参考 Reference以及其译文
  1. 事实上这种场景非常罕见,我见到的例子只有 cloudflare 和 reddit 用 rust 重写自己相关业务用于获取性能提升的案例。 ↩︎

RustLifeTimeKata学习记录
https://ooj2003.github.io/2023/08/27/RustLifeTimeKata学习记录/
作者
OOJ2003
发布于
2023年8月27日
更新于
2023年9月10日
许可协议