更深入的理解生命期

发布时间:2022-10-17 09:01
最后更新:2024-06-20 22:39
所属分类:
Rust

在入门Rust以后,会发现生命期(Lifetime)是一个比所有权机制更难以理解和掌握的机制。当我们磕磕绊绊的解决了程序里的所有权转移以后,就会发现Rust编译开始报出生命期的编译错误了。所以生命期机制是Rust中第二个劝退点,顺利攻克生命期的概念是使用和进入Rust编程大门的一个必经之路。

如何理解生命期

生命期虽然是一个Rust引入的新词汇,但是它所代表的概念却不是新的。在这里首先提出究竟应该如何理解生命期这个概念。

所谓生命期,实际上可以用作用域来理解。每个生命期名称就是对变量所在的作用域起的别名。

生命期的目的

根据Rust的定义,生命期是用来帮助编译器执行“任何引用本身都不能比它所引用的对象存活的更久”的规则的,也就是说生命期是用来帮助编译器消除悬垂指针的。

只有在使用引用的时候,才会使用到生命期标注。

从上面的提示中又引入了一个新词——生命期标注,这才是生命期在Rust中的表现的目的。生命期实际上在我们使用的时候就是一个标注,针对引用的标注,而这个标注是被编译器用来对被引用目标的作用域进行约束的。如果引用的生命期大于被引用目标的生命期(存活作用域)那么编译将失败,因为在被引用目标结束生存以后,引用还依旧会存在,这样就会形成悬垂指针。

生命期都指代什么

Rust中的生命期不容易被理解的一个原因就是生命期这个词在不同饿情况下会被用来指代不同的内容。综合起来主要有以下这几种:

  1. 被引用变量的实际生命期。
  2. 引用的生命期约束。
  3. 生命期标注。

这三种东西在许多教程和文章中都直接被称为生命期,但是它们指代的又的确是不同的内容,所以对于生命期的理解就变困难了。被引用变量的实际生命期是比较好理解的,这一条指代的实际上就是变量的作用域,这也是学习编程的时候首先要接触到的东西。在Rust中,变量的作用域是非常好确定的,就是变量被声明的那一对大括号里,这个是非常明显和明确的。

生命期约束

那么接下来一个不容易理解的概念就是生命期约束了。在Rust里,每一个变量都有自己的一个生命期,而每一个引用除了拥有自己的生命期以外,还携带了一个生命期的约束条件,这个约束条件非常简单,就是被引用目标的生命期必须比引用自己的生命期长。例如下面的两个示例。

1
2
3
4
5
6
7
8
{
  let r;
  {
    let x = 1;
    r = &x;  // 借用x
  }  // 变量x在这里将被销毁,其生命期就结束在这了
  assert_eq!(*r, 1);  // 引用r所借用的变量x的值在这里已经不存在了,所以Rust将拒绝这条语句通过编译。
} // 引用r的生命期会结束在这里

在上面这个示例中,引用r的生命期要比被引用的变量x的生命期要长,所以违反了生命期约束的原则。如果两个变量换一下位置,情况就不一样了,比如下面这个示例。

1
2
3
4
5
6
7
{
  let x = 1;
  {
    let r = &x; // 直接借用x
    assert_eq!(*r, 1);
  } // 引用r的生命期在这里结束,其生命期是短于被引用目标x的,所以Rust将通过编译。
} // 被引用目标x的生命期在这里结束。
生命期的约束是不会改变生命期的。

生命期标注

随着程序变得越来越复杂,编译器就会开始要求开发人员对程序中出现的生命期进行标注。这些标注主要出现在函数、结构体等会使用引用的位置。生命期标注的主要用途就是显式声明此处所需要的生命期约束,在大部分情况下,我们是不需要对生命期约束进行标注的,编译器会自动的推断和生成生命期约束。

例如在之前文章中所使用过的示例。

1
2
3
4
5
6
7
8
9
fn largest(arr: &[i32]) -> &i32 {
  let mut s = &arr[0];
  for r in &arr[1..] {
    if *r > *s {
      s = r;
    }
  }
  s
}

在这个实例中,作为开发者没有标注任何生命期约束,但是Rust编译器会在编译的时候,为其生成相应的生命期约束,也就变成了下面的样子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn largest<'a>(arr: &'a [i32]) -> &'a i32 {
  'b: {
    let mut s = &'a arr[0];
    for r in &arr[1..] {
      'c: {
        if *r > *s {
          s = r;
        }
      }
    }
    s
  }
}

整个代码看起来很怪的样子,这是因为Rust在代码中增加了生命期标注。可以看出来,'b'c是添加进来纯粹用来标记生命期的,看目前的代码形态,是不是生命期跟作用域的概念基本上是一致的?

现在来仔细分析一下这个示例。

  1. 在函数的类型参数列表中<'a>,声明的是函数largest()所要使用的生命期,这个生命期并不是函数largest()的生命期,而是这个生命期也是函数的一个参数,是需要在函数被调用的时候确定的。
  2. 这个生命使用的生命期'a在参数列表中被使用了,用于标注参数arr,这就说明函数largest()所声明使用的生命期'a就是传入参数arr的生命期。
  3. 函数largest()的返回值也是一个引用,也是使用生命期'a标注的,它的意义直接通知编译器,函数返回的引用就是从arr中借来的。它所表达的意思是返回引用的生命期来源,而不是返回的引用所具备的生命期。
  4. 引用s所属的生命期是'b,它所引用的内容arr[0]所属的生命期是'a,因为生命期'a是函数largest()的声明使用的生命期,所以引用s的生命期是短于arr[0]的,满足生命期约束的要求。
  5. 引用r的生命期实际上是'c,所以也比arr'a要短,所以也是满足生命期约束的要求的。
  6. 唯一容易令人迷惑的是返回值s,因为s是在生命期'b中定义的,但是函数缺直接返回了s。这是因为函数实际返回的是s所携带的引用内容,s所引用的内容实际上来自于参数arr,是具有'a的生命期的,所以也是满足返回引用的约束条件的。

在这个示例中,函数largest()的类型参数<'a>就是Rust中的生命期标注语法,归根结底,它还是一个参数,一个特殊的参数,用于为函数创建生命期约束。

在函数声明中使用生命期标注只是为函数构建生命期约束,编译器在这里的工作是为标注的生命期找到并确定满足其约束的具体生命期。
函数内部变量的生命期都将比函数声明使用的生命期要短,而且在标注函数所使用的生命期时,应该尽量缩短函数所要求的生命期。例如函数声明需要使用'static全局生命期,那么函数在使用的时候就会受到很大的限制,而不是更方便。

生命期的推断

编译器对于生命期的推断与开发者的推断过程基本上是相同的。编译器会首先将代码分解为前面章节中的详尽生命期标注形式,然后再将实参的生命期代入函数,来形成函数的生命期标注。在大部分情况下这种推断方法是十分可靠有效的,但是这并不能覆盖所有的生命期组合形式。

例如以下示例,在复杂情况下,编译器就不能推断其生命期标注。

1
2
3
4
5
6
fn max(a: &i64, b: &i64) -> &i64 {
  if *a >= *b {
    return a;
  }
  b
}

在这个示例中,函数max()有两个类型为引用的参数,而且还会返回其中的一个。在这种情况下,参数a和参数b可能分别来自两个不同的作用域,这样编译器就不能确定函数的返回值所应该归属的生命期了。如果编译器不能自动推断生命期标注的时候,就会终端编译过程,提示开发者需要开发者手动对函数所使用到的生命期进行标注。

没有必要直接对所有的函数都进行生命期标注,要充分利用Rust编译器的推断功能。解决和标注生命期也是一个思考程序设计合理性的过程。

生命期省略

生命期省略指的是省略书写生命期标注,能够省略书写生命期标注的条件主要有以下两个。

  1. 函数只有一个输入参数的时候。此时输入参数的生命期将被赋予所有的输出参数引用。就像是本文中largest()函数示例一样。
  2. 当传入多个参数,但第一个参数是&self或者&mut self的时候。这种情况主要是用于函数作为结构体的方法存在的情况下。此时输入的第一个参数的生命期将被赋予所有的输出参数引用。例如:fn process(&self) -> &str会被解析为fn process<'a>(&'a self) -> &'a str

控制生命期

从其他编程语言迁移到Rust语言以后,所要面临的一个很大的问题就是要适应生命期约束。在Rust语言中,之前那些语言中积累的优化经验都还是可以使用的,但是会受到比较大的限制。但是想要更快的熟悉Rust语言,就必须要学会控制生命期。

其实对生命期的控制主要就是对程序中所使用的引用进行控制。我们在使用引用的时候,必须要牢记我们为什么要使用指针和引用。在使用引用的位置,对于引用的使用要再三提出疑问。

引用的存在目的

在程序中使用指针和引用的目的,主要就是为了避免占内存体积较大的内容进行复制的开销。如果使用复制来传递这些内容,那么在传递的过程中,运行时就必须将这些体积较大的内容在内存中复制一份,这样一个内容就会占据两份内存空间,对于内存的使用是比较不划算的。但是如果传递的是一个内存地址(指针和引用保存的都是目标对象的内存地址),那么体积就小很多的,即便是复制一份,也不会占据多大的空间。

所以引用存在的目的主要是为了节省内存空间,提高内存空间的使用效率。

不要返回引用

我们在很多语言中都已经习惯在函数中返回函数中生成的值的引用了,比如C++、Go,其实Java语言中从函数中返回的对象也是引用的形式。像Go和Java这样的语言是拥有GC机制的,运行时会在一定时间内回收不再使用的内存空间,所以在一定程度上说是可以肆无忌惮的使用引用的。C++要稍微小心一些,因为内存的分配和回收都是开发者手工控制的,但是也还是有回收策略的。

Rust完全没有GC的机制,一个内存区块超出生命期(或者叫作用域)就是不用了,直接就会被释放。所以直接从其他语言迁移过来的时候,会很不适应。但是首先想一想,我们为什么要在函数中返回一个内存区块的引用?

为了提升内存使用效率?

完全不是,这只是我们的习惯。Rust有更加有效率的办法:转移所有权。所有权的转移比复制和传递引用更有效率,所有权的转移不涉及任何复制,内存区域直接就会被转交。所以如果是在函数中生成一些内容供外部使用,为什么不直接生成可以转移所有权的实例呢?

什么时间返回引用?

如果一个函数返回的内容是对从外部传入的内容进行加工,但是还依旧保存在原来的内存区域的,才适合从函数中直接返回引用。

但是需要注意的是,这种情况下函数返回的引用,其生命期基本上都与传入的参数一致。

结论
如果需要从函数中生成新的内容,或者是基于给定的参数生成新的内容,还是要优先返回可转移所有权的实例,尽量不要在传入的内存区域上直接进行更改。

善用Cow

如果不可避免的需要从函数中返回引用,或者无法确定函数返回了什么,那么就可以选择让函数返回一个Cow枚举的实例。std::borrow::Cow是标准库中提供的一个智能指针,从它的定义就可以看出来它的威力。

1
2
3
4
5
6
7
pub enum Cow<'a, B>
where
  B: 'a + ToOwned + 'a + ?Sized,
{
  Borrowed(&'a B),
  Owned(<B as ToOwned>::Owned),
}

它提供了一个Borrowed和一个Owned成员,所以可以看出它可以用来携带一个借用,或者携带一个拥有所有权的内容。Cow提供的是一个写时复制的功能,可以根据其内部携带的内容的不同,在使用的时候自动选择其内容的保存和获取方式。所以根据Cow的这个特性,我们就可以利用Cow从函数中返回生命期可能短于函数的内容,或者函数中使用的引用。

使用Cow返回一个引用了函数中内部变量的引用依旧是不可以的,被引用的对象必须能够保证其自身的存在。

以下是一个简单的使用示例。

1
2
3
4
5
6
7
8
9
fn generate_uuid<'a>() -> Cow<'a, uuid::Uuid> {
  let new_uuid = uuid::Uuid::new_v4();
  Cow::Owned(new_uuid)
}

fn main() {
  let new_id = generate_uuid();
  println!("{}", new_id);
}
从函数中返回内容的时候,Cow所需的生命期定义是不可少的,必须显式指明。

不要使用引用序列

我们可能非常习惯构建类似于Vec<&Path>之类的引用序列,用来保存一系列相关的引用。但是在Rust中虽然不会阻止你这样用,但是在实际使用的时候就会发现,这样用起来有非常大的困难,尤其是会面临被提示目标值不能被引用的问题。

其实这样的引用序列是存在很大的问题的,你永远也不能完全确定序列中的每一个引用都是有效的。

类似于Vec这样的序列,其中最优的选择就是让序列持有其元素的所有权,而不是对实际元素的引用的所有权。所以在习惯性写出这样的语句的时候,还是换个写法,想想怎么收拾出一个可以让序列拥有的所有权。

一个小提示
看看元素有没有实现Clone特征或者ToOwned特征,试试Clone特征和ToOwned特征提供的方法。

HRTBs

在熟悉一段时间的Rust以后,就会看到这个词语。根据Rust自己的描述,HRTBs算是控制生命期的一个黑魔法,其全称是高阶特征界限(Higher-Ranked Trait Bounds)。HRTBs可以被用来解决闭包作为函数参数时,其参数生命期的确定问题。

以下这个示例是无法通过编译的。

1
2
3
4
5
6
7
fn call_on_zero<F>(f: F)
where
  F: Fn(&i32, &i32) -> &i32
{
  let zero = 0;
  f(&zero, &zero);
}

在这个示例中,编译器无法确定闭包所返回的是哪一个引用,那么根据前文的经验,我们可以使用生命期标注来尝试帮编译器确定闭包所返回引用的生命期。

1
2
3
4
5
6
7
fn call_on_zero<'a, F>(f: F)
where
  F: Fn(&'a i32, &'a i32) -> &'a i32
{
  let zero = 0;
  f(&zero, &zero);
}

好了,现在看起来应该可以正常编译了,但是Rust编译器依旧没能通过编译,理由是zero的生命期不能满足闭包中'a的要求。看起来变量zero还是存活的时间不够长。在这种情况下,就需要HRTBs出场了,接下来使用HRTBs来进行生命期的标注。

1
2
3
4
5
6
7
fn call_on_zero<'a, F>(f: F)
where for<'a>
  F: Fn(&'a i32, &'a i32) -> &'a i32
{
  let zero = 0;
  f(&zero, &zero);
}

看起来只是在where关键字后面加了一个for<'a>就通过编译了。的确,这个for<'a>就是HRTBs的标注,它的含义是“对于所选择的生命期'a动态的产生对应的引用”,它会生成闭包F所必须满足的无限的生命期界限列表。除了可以用在标注闭包上,for<'a>还可以使用在任何需要进行生命期标注的位置,例如一个拥有一个指定特征的分配在堆上的对象Box<dyn for<'a> SomeTrait<&'a isize>>,这个对象既可以使用生命期长于它的内容,也可以使用生命期短于它的内容。


索引标签
Rust
生命期
引用