生命周期

Rust 通过生命周期来执行相关的规则。生命周期是指一个引用必须有效的代码区域,这些区域可能相当复杂,因为它们对应着程序中的执行路径。这些执行路径中甚至可能存在空洞(译者注: 空洞是指一个引用的生命周期可能不是一个连续的代码区域,中间可能有跳跃),因为我们可能会先使一个引用失效,之后再重新初始化并使用它。包含引用(或假装包含)的类型也可以用生命周期来标记,这样 Rust 就可以防止它们也被失效。

在我们大多数例子中,生命周期将与作用域重合,这是因为我们的例子很简单。下面将介绍它们不重合的更复杂的情况。

在一个函数体中,Rust 通常不需要你明确地命名所涉及的生命周期。这是因为一般来说,在本地环境中谈论生命周期是没有必要的;Rust 拥有所有的信息,并且可以尽可能地以最佳方式解决所有问题。Rust 还会引入许多匿名作用域和临时变量, 你不必显式写出它们, 代码也可以跑通。

然而,一旦你跨越了函数的边界,你就需要开始考虑生命周期了。生命周期是用撇号表示的:'a'static。为了尝试使用生命周期,我们将假装我们被允许用生命周期来标记作用域,并尝试手动解一下本章开头例子的语法糖。

我们之前的例子使用了一种激进的语法糖——甚至是高果糖玉米糖浆——因为明确地写出所有东西是非常繁琐的。所有的 Rust 代码都依赖于积极的推理和对“显而易见”的东西的删除。

一个特别有趣的语法糖是,每个let语句都隐含地引入了一个作用域。在大多数情况下,这其实并不重要。然而,这对那些相互引用的变量来说确实很重要。作为一个简单的例子,让我们对这段简单的 Rust 代码进行完全解糖:

  1. let x = 0;
  2. let y = &x;
  3. let z = &y;

借用检查器总是试图最小化生命周期的范围,所以它很可能会脱糖为以下内容:

  1. // NOTE: `'a: {` 和 `&'b x` 不是有效的语法,这里只是为了说明 lifetime 的概念
  2. 'a: {
  3. let x: i32 = 0;
  4. 'b: {
  5. // y 的生命周期为 'b,因为这已经足够好
  6. let y: &'b i32 = &'b x;
  7. 'c: {
  8. // 'c 同上所示
  9. let z: &'c &'b i32 = &'c y;
  10. }
  11. }
  12. }

哇,这真是……太可怕了!让我们花点时间感谢 Rust 让这一切变得简单。

实际上,传递一个引用到外部作用域将导致 Rust 推断出一个更大的生命周期。

  1. let x = 0;
  2. let z;
  3. let y = &x;
  4. z = y;
  1. 'a: {
  2. let x: i32 = 0;
  3. 'b: {
  4. let z: &'b i32;
  5. 'c: {
  6. // y 的生命周期一定为 'b,因为对 x 的引用被传递到了 'b 这个作用域
  7. let y: &'b i32 = &'b x;
  8. z = y;
  9. }
  10. }
  11. }

例子:超出所有者生命周期的引用

让我们看看之前的那些例子:

  1. fn as_str(data: &u32) -> &str {
  2. let s = format!("{}", data);
  3. &s
  4. }

解语法糖后:

  1. fn as_str<'a>(data: &'a u32) -> &'a str {
  2. 'b: {
  3. let s = format!("{}", data);
  4. return &'a s;
  5. }
  6. }

as_str的这个签名接收了一个具有某个生命周期的 u32 的引用,并返回一个可以存活同样长的 str 的引用。我们已经大致能猜到为什么这个函数签名可能是个麻烦了,这意味着我们要找的那个 str 要在 u32 的引用所处的作用域上,或者甚至在更大的作用域上。这要求有点高。

然后我们继续计算字符串s,并返回它的一个引用。由于我们的函数的契约规定这个引用必须超过'a,这就是我们推断出的引用的生命周期。不幸的是,s被定义在作用域'b中,所以唯一合理的方法是'b包含'a,这显然是错误的,因为'a必须包含函数调用本身。因此,我们创建了一个引用,它的生命周期超过了它的引用者,这正是我们所说的引用不能做的第一件事。编译器理所当然地直接报错。

为了更清楚地说明这一点,我们可以扩展这个例子:

  1. fn as_str<'a>(data: &'a u32) -> &'a str {
  2. 'b: {
  3. let s = format!("{}", data);
  4. return &'a s
  5. }
  6. }
  7. fn main() {
  8. 'c: {
  9. let x: u32 = 0;
  10. 'd: {
  11. // 这里引入了一个匿名作用域,因为借用不需要在整个 x 的作用域内生效,
  12. // 这个函数必须返回一个在函数调用之前就存在的某个字符串的引用,事实显然不是这样
  13. println!("{}", as_str::<'d>(&'d x));
  14. }
  15. }
  16. }

当然,这个函数的正确写法是这样的:

  1. fn to_string(data: &u32) -> String {
  2. format!("{}", data)
  3. }

我们必须在函数里面产生一个拥有所有权的值才能返回! 我们唯一可以返回一个&'a str的方法是,它在&'a u32的一个字段中,但显然不是这样的。

(实际上我们也可以直接返回一个字符串字面量,作为一个全局的字面量可以被认为是在堆栈的底部;尽管这对我们的实现有一点限制)。

示例:别名一个可变引用

来看另一个例子:

  1. let mut data = vec![1, 2, 3];
  2. let x = &data[0];
  3. data.push(4);
  4. println!("{}", x);
  1. 'a: {
  2. let mut data: Vec<i32> = vec![1, 2, 3];
  3. 'b: {
  4. // 'b 这个生命周期范围如我们所愿地小(刚好够 println!)
  5. let x: &'b i32 = Index::index::<'b>(&'b data, 0);
  6. 'c: {
  7. // 这里有一个临时作用域,我们不需要更长时间的 &mut 借用
  8. Vec::push(&'c mut data, 4);
  9. }
  10. println!("{}", x);
  11. }
  12. }

这里的问题更微妙、更有趣。我们希望 Rust 拒绝这个程序,理由如下:我们有一个存活的共享引用xdata的一个子集,当我们试图把data的可变引用传给push时。这将创建一个可变引用的别名,而这将违反引用的第二条规则。

然而,这根本不是 Rust 认为这个程序有问题的原因。Rust 不理解x是对data的一个子集的引用。它根本就不理解Vec。它看到的是,x必须在'b范围内保持存活才能被打印;接下来,Index::index的签名要求我们对data的引用必须在'b范围内存活。当我们试图调用push时,它看到我们试图构造一个&'c mut data。Rust 知道'c包含在'b中,并拒绝了我们的程序,因为&'b data必然还存活着!

在这里我们看到,和我们真正想要保证的引用规则语义相比,生命周期系统要粗略得多。在大多数情况下,这完全没问题,因为它使我们不用花整天的时间向编译器解释我们的程序。然而,这确实意味着有部分程序对于 Rust 的真正的语义来说是完全正确的,但却被拒绝了,因为 lifetime 太傻了。

生命周期所覆盖的区域

一个引用(有时称为borrow)从它被创建到最后一次使用都是存活的。被 borrow 的值的生命周期只需要超过引用的生命周期就行。这看起来很简单,但有一些微妙之处。

下面的代码可以成功编译,因为在打印完x之后,它就不再需要了,所以它是悬空的还是别名的都无所谓(尽管变量x技术上一直存活到作用域的最后):

  1. let mut data = vec![1, 2, 3];
  2. let x = &data[0];
  3. println!("{}", x);
  4. // 这是可行的,因为不再使用 x,编译器也就缩短了 x 的生命周期
  5. data.push(4);

然而,如果该值有一个析构器,析构器就会在作用域的末端运行。而运行析构器被认为是一种使用——显然是最后一次使用。所以,这将会编译报错:

  1. #[derive(Debug)]
  2. struct X<'a>(&'a i32);
  3. impl Drop for X<'_> {
  4. fn drop(&mut self) {}
  5. }
  6. let mut data = vec![1, 2, 3];
  7. let x = X(&data[0]);
  8. println!("{:?}", x);
  9. data.push(4);
  10. // 编译器会在这里自动插入 drop 函数,也就意味着我们会访问 x 中引用的变量,因此编译失败

让编译器相信x不再有效的一个方法是在data.push(4)之前使用drop(x)

此外,可能会有多种最后一次的引用使用,例如在一个条件的每个分支中:

  1. # fn some_condition() -> bool { true }
  2. let mut data = vec![1, 2, 3];
  3. let x = &data[0];
  4. if some_condition() {
  5. println!("{}", x); // 这是该分支中最后一次使用 x 这个引用
  6. data.push(4); // 因此在这里 push 操作是可行的
  7. } else {
  8. // 这里不存在对 x 的使用,对于这个分支来说,
  9. // x 创建即销毁
  10. data.push(5);
  11. }

生命周期中可以有暂停,或者你可以把它看成是两个不同的借用,只是被绑在同一个局部变量上。这种情况经常发生在循环周围(在循环结束时写入一个变量的新值,并在下一次迭代的顶部最后一次使用它)。

  1. let mut data = vec![1, 2, 3];
  2. // x 是可变的(通过 mut 声明),因此我们可以修改 x 指向的内容
  3. let mut x = &data[0];
  4. println!("{}", x); // 最后一次使用这个引用
  5. data.push(4);
  6. x = &data[3]; // x 在这里借用了新的变量
  7. println!("{}", x);

Rust 曾经一直保持着借用的生命,直到作用域结束,所以这些例子在旧的编译器中可能无法编译。此外,还有一些边界条件,Rust 不能正确地缩短借用的有效部分,即使看起来应该这样做,也不能编译。这些问题将随着时间的推移得到解决。