Rust_Chapter_10_生命周期

Rust_Chapter_10_生命周期

一开始学习 Rust 时,生命周期(Lifetime)是一个过不去的坎,它看起来和其他语言常说的生命周期类似,但因为它其实实际上也是 Rust 类型系统的一部分,并不是那么易读的

悬垂指针和生命周期

生命周期诞生的主要目的就是为了放置悬垂指针,避免引用空悬。通过 Rust 编译器检查生命周期,从语言层面上限制程序员,基本上可以在编译期就保证内存安全

像下面的这段代码

1
2
3
4
5
6
7
8
9
10
{
let r;

{
let x = 5;
r = &x;
}

println!("r: {}", r);
}

编译时会报下面的错误

1
2
3
4
5
6
7
8
9
10
error[E0597]: `x` does not live long enough // `x` 活得不够久
--> src/main.rs:7:17
|
7 | r = &x;
| ^^ borrowed value does not live long enough // 被借用的 `x` 活得不够久
8 | }
| - `x` dropped here while still borrowed // `x` 在这里被丢弃,但是它依然还在被借用
9 |
10 | println!("r: {}", r);
| - borrow later used here // 对 `x` 的借用在此处被使用

我们在别的语言里会说 x 的作用域在第二个括号末就结束了,所以这是一个典型的可能导致引用空悬的代码,我们可以用 'a'b 这样的标签来标记这么一个生命周期,如下

1
2
3
4
5
6
7
8
9
10
{
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
} // ---------+

我们说 r 这个变量的生命周期是 'a,而 x 这个变量的生命周期是 ‘b,从图示上可以明显看出生命周期 'b'a 小很多。

在编译期,Rust 会比较两个变量的生命周期,结果发现 r 明明拥有生命周期 'a,但是却引用了一个小得多的生命周期 'b,在这种情况下,编译器会认为我们的程序存在风险,因此拒绝运行。

在这里,编译器自动就知道了生命周期,但是这不是时时刻刻都能成的:在存在多个引用时,编译器有时会无法自动推导生命周期,此时就需要我们手动去标注,通过为参数标注合适的生命周期来帮助编译器进行借用检查的分析。

关于 Rust 类型系统

在了解这些之前,我们先要介绍一下由 Rust 生命周期而来的类型系统,详细需要观看如下的一篇文章
[SAST.Mathematics SIG / Subtyping variance and its application] https://sast-mathematics-sig.github.io/posts/subtyping_variance_and_its_application/

我们有一个基本的事实,即 Bot = &'static T <: &'b T <: &'a T ,没那么严谨的说法是,当生命周期标签的范围越大,那这个类型就是越小的子类型

而如果 S 是 T 的子类型,意思是在任何需要使用 T 类型对象的环境中,都可以安全地使用 S 类型的对象。

这暗含的含义就是:变量本身内蕴的生命周期标签大的可以被借用给生命周期小的,而且一个类型为 &'b T 的变量它的类型其实也满足 &'a T

为一些特殊的函数标注生命周期

1
2
3
4
5
6
7
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}

可以看到生命周期标注和泛型标注很相似,且有了上面的铺垫,我们理解了为什么在这里需要标注生命周期(实际上,如果你这个函数不标记生命周期就会收获一个编译报错)

该函数签名表明对于某些生命周期 'a,函数的两个参数都至少跟 'a 活得一样久,同时函数的返回引用也至少跟 'a 活得一样久。实际上,这意味着返回值的生命周期与参数生命周期中的较小值一致:虽然两个参数的生命周期都是标注了 'a,但是实际上这两个参数的真实生命周期可能是不一样的(上面的讨论说明了生命周期 'a 不代表生命周期等于 'a,而是大于等于 'a)

结构体中的生命周期

定义包含引用的结构体时,需要为结构体定义中的每一个引用添加生命周期注解

1
2
3
4
5
6
7
8
9
10
11
struct ImportantExcerpt<'a> {
part: &'a str,
}

fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
}

生命周期消除

并非所有时候使用引用都需要标注生命周期,历史为我们提供了一些语法糖帮助我们消除生命周期

消除规则不是万能的,若编译器不能确定某件事是正确时,会直接判为不正确,那么你还是需要手动标注生命周期

函数或者方法中,参数的生命周期被称为输入生命周期,返回值的生命周期被称为输出生命周期

三条消除规则

编译器使用三条消除规则来确定哪些场景不需要显式地去标注生命周期。其中第一条规则应用在输入生命周期上,第二、三条应用在输出生命周期上。若编译器发现三条规则都不适用时,就会报错,提示你需要手动标注生命周期。

  1. 每一个引用参数都会获得独自的生命周期
    例如一个引用参数的函数就有一个生命周期标注: fn foo<'a>(x: &'a i32),两个引用参数的有两个生命周期标注:fn foo<'a, 'b>(x: &'a i32, y: &'b i32), 依此类推。
  2. 若只有一个输入生命周期(函数参数中只有一个引用类型),那么该生命周期会被赋给所有的输出生命周期,也就是所有返回值的生命周期都等于该输入生命周期
    例如函数 fn foo(x: &i32) -> &i32x 参数的生命周期会被自动赋给返回值 &i32,因此该函数等同于 fn foo<'a>(x: &'a i32) -> &'a i32
  3. 若存在多个输入生命周期,且其中一个是 &self&mut self,则 &self 的生命周期被赋给所有的输出生命周期
    拥有 &self 形式的参数,说明该函数是一个 方法,该规则让方法的使用便利度大幅提升。

方法定义中的生命周期注解

当为带有生命周期的结构体实现方法时,其语法依然类似泛型类型参数的语法。我们在哪里声明和使用生命周期参数,取决于它们是与结构体字段相关还是与方法参数和返回值相关。

(实现方法时)结构体字段的生命周期必须总是在 impl 关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。

impl 块里的方法签名中,引用可能与结构体字段中的引用相关联,也可能是独立的。另外,生命周期省略规则也经常让我们无需在方法签名中使用生命周期注解。

静态生命周期

这里有一种特殊的生命周期值得讨论:'static,其生命周期能够存活于整个程序期间。所有的字符串字面值都拥有 'static 生命周期,我们也可以选择像下面这样标注出来:

1
let s: &'static str = "I have a static lifetime.";

这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。因此所有的字符串字面值都是 'static 的。

泛型中的生命周期

因为生命周期也是泛型,所以生命周期参数 'a 和泛型类型参数 T 都位于函数名后的同一尖括号列表中

至此,匆匆忙忙把 Rust 一些很基础的语法介绍完毕了,接下来这一个小系列也告一段落了