Rust惑点启示系列(三):引用的生命期从来就不够长

发布时间:2024-10-14 13:54
最后更新:2024-10-14 15:14
所属分类:
Rust

代码被Rust编译器提示“引用的生命期不够长”是一件不管是刚开始学习Rust的新手还是Rust老手都经常能够碰到的事情。出现这种错误有方方面面的原因,而且不一定是我们的代码写错了。不过究根结底,这个问题还是跟生命期有关的。

本文是《Rust惑点启示笔记》系列文章中的第三篇。这个系列的文章主要计划对Rust语言使用过程中经常会出现的一些容易迷惑的惑点进行一个启发式的讨论和记录。

本系列专题还有以下文章:

  1. Rust惑点启示系列(一):避免随意使用Clone
  2. Rust惑点启示系列(二):从函数中返回一些东西
  3. Rust惑点启示系列(三):引用的生命期从来就不够长
  4. Rust惑点启示系列(四):到处都是的大括号
  5. Rust惑点启示系列(五):工具类型太多了
  6. Rust惑点启示系列(六):如何下手编写一个函数
  7. Rust惑点启示系列(七):使用全局变量和单例
  8. Rust惑点启示系列(八):奇形怪状的Rust闭包

Rust自动推断生命期

引用是Rust首要对抗的大敌之一,避免无效的引用存在,是保证Rust提出的内存安全的根本之一。

引用的生命期就是Rust编译器在编译时用来跟踪引用有效性的机制。通过对引用和被引用对象生命期的跟踪,Rust可以确保引用不会在被引用对象失效以后还可以继续使用。对于生命期的跟踪并不是一件简单的事情,如果在代码中引入生命期的编码,那么Rust代码教会致命的复杂无比。出于降低Rust编码负担的要求,Rust采用了自动推断生命期的机制。所以在大部分情况下,我们无需显式声明引用的生命期,Rust编译器会自动决定。

Rust编译器对于生命期的推断是基于以下几条规则的:

  1. 每个输入的引用都会获得一个独立的生命期参数。如果一个函数有多个输入的引用,那么编译器会为每一个引用分配一个生命期参数。
  2. 如果只有一个输入引用,编译器会=会将函数的输出引用的生命期设置为与输入引用相同。也就是说函数返回的引用类型和函数的引用参数是共享相同的生命期的。
  3. 如果函数是一个方法,并且带有&self或者&mut self参数,那么编译器会自动将函数返回值的生命期设置为self的生命期。

具体的解释一下这几个规则。例如以下简单示例中:

1
2
3
fn return_str(x: &str) -> &str {
  x
}

这个简单的示例中,x对于函数return_str来说是一个唯一的输入引用,所以根据第二条规则,函数返回值的生命期可以推断为与x是相同的。但是如果是有多个输入引用,Rust就没法推断了,例如:

1
2
3
fn return_str(x: &str, y: &str) -> &str {
  x
}

这个示例中函数的输入引用有两个,而且这两个输入的引用可能具备不同的生命期,Rust无法从上面所有规则中取得明确的推断,所以编译器就会抛出错误。

但是这里编译器抛出的错误并不是说我们的代码是存在错误的,只是编译器无法推断引用的生命期了而已,要解决这件事情,就需要使用生命期标注来告诉编译器它无法推断的生命期到底是什么样的。

生命期标注的作用

生命期标注实际上就是Rust本就具备的生命期手工跟踪语法,当看到它的时候,你就知道为什么Rust要提供生命期自动推断功能了。

生命期标注的写法和泛型类型参数是在一起的,都书写在<>里。但是需要首先明确的一点是,生命期标注只是对生命期的标注而已,并不是对生命期的定义,说白了,生命期标注的存在只是为了防止编译器犯傻而已。

上面那个会让编译器无法完成推断的示例,在加上生命期标注以后,就不会再报错了。加上生命期标注以后的示例是这样的。

1
2
3
fn return_str<'a>(x: &'a str, y: &str) -> &'a str {
  x
}

这个示例里使用'a声明了一个生命期标注,通过这个生命期标注,编译器可以获知函数返回的引用的生命期是与函数的参数x相关的。在确定了这个函数里所有引用的生命期以后,编译器就不会再报错了。

其实必须手工标注生命期的场景一般也是很固定的,比如:

  1. 函数有多个输入引用和一个输出引用。这就是上面的那个示例中的样子,函数接收了多个引用参数,又提供了一个引用返回值。
  2. 结构体中存在引用字段。编译器是无法获知结构体中的引用字段所引用的内容的生命期长度的,只能确定这些字段的生命期是与结构体实例相同的,所以就需要使用生命期标注,提示这些引用的生命期需要至少跟结构体的生命期一致。
  3. 存在跨多个作用域或者引用嵌套的情况下,自动推断也会失效,而且一般在这种情况下,手工进行生命期标注也是一件相当费劲而且痛苦的事情。

生命期的分隔界限

引用的生命期虽然不能声明,但是可以控制。这就要提到生命期是如何确定的了。

引用首先是受到作用域限制的,在其所属的作用域结束的时候,它就会被释放。此时生命期的分隔界限就是引用外面的那一对{}。这也是Rust语言中,一个独立的语句块存在的意义之一。

被引用内容的生命期也是会影响引用的生命期的,但是这种影响往往是以编译器报错的形式出现。例如:

1
2
3
4
fn i_reference() -> &i32 {
  let i = 42;
  &x
}

在这个示例里面,x是一个局部变量,它的生命期在函数运行结束的时候就结束了,那么此时返回的&x就变成了一个无效引用。编译器跟踪到这里,就会抛出一个错误,提示返回了无效的引用。

在比较复杂的情况下,即便是控制了生命期的分隔界限,也还是要通过生命期标注来显式确定生命期的,比如经典的多引用输入的情况。

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

在这个示例中,函数longest接受了两个引用作为参数,并且会返回其中的一个引用。在这个示例中,生命期参数'a是用来约束其所标注的引用的生命期的,解释出来就是函数返回的引用的生命期,必须在参数s1s2的有效范围之内。

注意,返回值的生命期不是与s1s2的生命期一致,而是要理解为最长跟s1s2一样。

生命期与内存区域的关系

分配在堆上的内容,其生命期可能会超出栈上引用的范围。这其实也很容易理解,在使用Box<T>Rc<T>等数据类型将被包装内容分配到堆上的时候,BoxRc的实例还是保存在栈里的。所以这些引用类型的生命期还是受到作用域的限制的,但是被它们所包装的内容T则是可能在栈上的BoxRc这些引用实例释放以后继续存在的,只要它们的所有权被转移到了一个新的引用里。

为什么会需要'static生命期

'static是一个非常特殊的生命期,它表示引用在程序的整个运行时都是有效的。一般说来,如果一个函数要求`‘static生命期,那么它可能往往会跟异步执行、多线程应用、全局数据共享等功能有关。

例如在使用tokio时,使用tokio::spawn在函数中创建的异步任务,往往在一个独立线程上运行,在超出创建它的作用域以后,还会继续执行,此时它就会要求传递进去的内容具有'static生命期。不过在这种情况下可以把数据放置在堆上,来获得异步任务和多线程任务所需的超长生命期。

如果使用的是thread::spawn创建的新线程,效果和使用tokio::spawn创建的异步任务是一样的。

另外在使用回调函数的时候,因为不清楚这些回调函数会再程序的哪个部分声明,所以一般也会要确保回调函数中的数据再其生命期内有效,这就会要求回调函数具备'static生命期,例如;

1
2
3
4
5
6
fn register_handler<F>(callback: F)
where
  F: Fn() + 'static,
{
  // 回调函数可能会在程序运行的任何时刻调用
}

在上面这个示例里,callback回调函数可能会在当前作用域外执行,所以就会要求其所以来的数据必须是有效的,又因为不能确定其具体运行时刻,那么就只能要求'static生命期。


索引标签
Rust
引用
生命期