rust入坑小记-08-生命周期
13 生命周期
生命周期用来保证所有的引用都是有效的,它实际上是另一类泛型。一个变量的生命周期在它创建的时候开始,在它销毁的时候结束。
在引用小节中我们遗漏了一个重要的细节:rust中的每一个引用都有其生命周期,也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的,正如大部分时候类型也是可以推断的一样。类似于当因为有多种可能类型的时候必须注明类型,也会出现引用的生命周期以一些不同方式相关联的情况,所以rust需要我们使用泛型生命周期参数来注明他们的关系,这样就能确保运行时实际使用的引用绝对是有效的。
13.1 悬垂引用
生命周期的主要目标是避免悬垂引用(dangling references):
1 | fn main() { |
上面的代码中:
- 看似使用
let r声明了没有初始值的变量,这些变量存在于外部作用域。这乍看之下好像和rust不允许存在空值相冲突。然而如果尝试在给它一个值之前使用这个变量,会出现一个编译时错误,这就说明了rust确实不允许空值。 - 在内部作用域中,我们尝试将
r的值设置为一个x的引用。接着在内部作用域结束后,尝试打印出r的值。这段代码不能编译因为r引用的值在尝试使用之前就离开了作用域。 - 此处
r就是一个悬垂指针,它引用了提前被释放的变量x,在错误信息中也可以看到borrowed value does not live long enough的提示。告诉我们变量x并没有 “存在的足够久”。
作用域越大我们就说它 “存在的越久”。如果rust允许这段代码工作,r 将会引用在 x 离开作用域时被释放的内存,这时尝试对 r 做任何操作都是未知的。rust是如何决定这段代码是不被允许的呢?这得益于借用检查器。
13.2 借用检查器
rust编译器有一个借用检查器(borrow checker),它用来确保所有的借用都是有效的:
1 | fn main() { |
这里将 r 的生命周期标记为 'a 并将 x 的生命周期标记为 'b。如你所见,内部的 'b 块要比外部的生命周期 'a 小得多。在编译时,rust比较这两个生命周期的大小,并发现 r 拥有生命周期 'a,不过它引用了一个拥有生命周期 'b 的对象。程序被拒绝编译,因为生命周期 'b 比生命周期 'a 要小:被引用的对象比它的引用者存在的时间更短。
下面我们尝试修复它:
1 | fn main() { |
这里 x 拥有生命周期 'b,比 'a 要大。这就意味着 r 可以引用 x:rust知道 r 中的引用在 x 有效的时候也总是有效的。或者说,从r被创建到销毁,都可以保证它的引用是有效的。
接下来让我们看看在函数的上下文中参数和返回值的泛型生命周期。
13.3 函数中的泛型生命周期
首先来编写一个返回两个字符串切片中较长者的函数。这个函数获取两个字符串切片并返回一个字符串切片。
1 | fn main() { |
注意这个函数获取作为引用的字符串切片,而不是字符串,因为我们不希望 longest 函数获取参数的所有权。
下面就是实现这个longest函数,一旦我们实现了该函数,运行应该会打印出 The longest string is abcd:
1 | fn longest(x: &str, y: &str) -> &str { |
可惜这并不能编译通过,你会收到如下错误:
1 | | |
提示返回值需要一个泛型生命周期参数,这是因为在函数内无法确定到底返回x的引用还是y的引用。当我们定义这个函数的时候,并不知道传递给函数的具体值,所以也不知道到底是 if 还是 else 会被执行。我们也不知道传入的引用的具体生命周期,所以也不能通过观察作用域来确定返回的引用是否总是有效,借用检查器自身同样也无法确定,因为它不知道 x 和 y 的生命周期是如何与返回值的生命周期相关联的。为了修复这个错误,我们将增加泛型生命周期参数来定义引用间的关系以便借用检查器可以进行分析。也就是说,需要我们手动地为编译器标注出生命周期。
13.4 生命周期注解语法
生命周期注解并不改变任何引用的生命周期的长短。相反它们描述了多个引用生命周期相互的关系,而不影响其生命周期。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。
生命周期注解有着一个不太常见的语法:生命周期参数名称必须以撇(')开头,其名称通常全是小写,类似于泛型其名称非常短。大多数人使用 'a 作为第一个生命周期注解。生命周期参数注解位于引用的 & 之后,并有一个空格来将引用类型与生命周期注解分隔开。
1 | &i32 // 引用 |
单个的生命周期注解本身没有多少意义,生命周期注解告诉rust编译器多个引用的泛型生命周期参数如何相互联系的。比如,如果函数有一个生命周期 'a 的 i32 的引用的参数 first。还有另一个同样是生命周期 'a 的 i32 的引用的参数 second。这两个生命周期注解意味着引用 first 和 second 必须与这泛型生命周期存在得一样久。
13.5 函数签名中的生命周期注解
为了在函数签名中使用生命周期注解,需要在函数名和参数列表间的尖括号中声明泛型生命周期参数,就像泛型类型参数一样。我们希望函数签名表达如下限制:也就是这两个参数和返回的引用存活的一样久。两个参数和返回的引用的生命周期是相关的。
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { |
这个longest函数可以通过编译,函数签名表明对于某些生命周期 'a,函数会获取两个参数,他们都是与生命周期 'a 存在的一样长的字符串切片,函数会返回一个同样也与生命周期 'a 存在的一样长的字符串切片。它的实际含义是 longest 函数返回的引用的生命周期与函数参数所引用的值的生命周期的较小者一致。这些关系就是我们希望rust分析代码时所使用的。
通过在函数签名中指定生命周期参数,我们并没有改变任何传入值或返回值的生命周期,而是指出任何不满足这个约束条件的值都将被借用检查器拒绝。注意 longest 函数并不需要知道 x 和 y 具体会存在多久,而只需要知道有某个可以被 'a 替代的作用域将会满足这个签名。
当在函数中使用生命周期注解时,这些注解出现在函数签名中,而不存在于函数体中的任何代码中。生命周期注解成为了函数约定的一部分,非常像签名中的类型。让函数签名包含生命周期约定意味着rust编译器的工作变得更简单了。如果函数注解有误或者调用方法不对,编译器错误可以更准确地指出代码和限制的部分。如果不这么做的话,rust编译会对我们期望的生命周期关系做更多的推断,这样编译器可能只能指出距离出现问题地方很多步之外的代码。
让我们看看如何通过传递拥有不同具体生命周期的引用来限制 longest 函数的使用:
1 | fn main() { |
在这个例子中,string1 直到外部作用域结束都是有效的,string2 则在内部作用域中是有效的,而 result 则引用了一些直到内部作用域结束都是有效的值。代码能够编译和运行,并打印出 The longest string is long string is long。
以下代码将 result 变量的声明移动出内部作用域,但是将 result 和 string2 变量的赋值语句一同留在内部作用域中。接着,使用了变量 result 的 println! 也被移动到内部作用域之外。该例子可以看出result 的引用的生命周期必须是两个参数中较短的那个。
1 | fn main() { |
错误表明为了保证 println! 中的 result 是有效的,string2 需要直到外部作用域结束都是有效的。rust知道这些是因为longest函数的参数和返回值都使用了相同的生命周期参数 'a。
从人的角度来说这可能有点反直觉,因为很明显string1 更长,longest函数会返回string1的引用,因为 string1 尚未离开作用域,对于 println! 来说 string1 的引用仍然是有效的。然而,我们通过生命周期参数告诉rust的是longest 函数返回的引用的生命周期应该与传入参数的生命周期中较短那个保持一致。因此,借用检查器不允许代码编译通过。
13.6 深入理解生命周期
指定生命周期参数的正确方式依赖函数实现的具体功能。例如,如果将 longest 函数的实现修改为总是返回第一个参数而不是最长的字符串切片,就不需要为参数 y 指定一个生命周期。如下代码将能够编译:
1 | fn longest<'a>(x: &'a str, y: &str) -> &'a str { |
我们为参数 x 和返回值指定了生命周期参数 'a,不过没有为参数 y 指定,因为 y 的生命周期与参数 x 和返回值的生命周期没有任何关系。
当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。如果返回的引用没有指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值。然而它将会是一个悬垂引用,因为它将会在函数结束时离开作用域。比如:
1 | fn longest<'a>(x: &str, y: &str) -> &'a str { |
这显然无法通过编译,因为即便我们为返回值指定了生命周期参数 'a,result 在 longest 函数的结尾将离开作用域并被清理,而我们尝试从函数返回一个 result 的引用,但result存活得不够长。
无法通过指定生命周期参数来改变悬垂引用,而且rust也不允许我们创建一个悬垂引用。在这种情况最好的解决方案是返回一个有所有权的数据类型而不是一个引用,这样函数调用者就需要负责清理这个值了。
综上,生命周期语法是用于将函数的多个参数与其返回值的生命周期进行关联的。一旦他们形成了某种关联,rust就有了足够的信息来允许内存安全的操作并阻止会产生悬垂指针亦或是违反内存安全的行为。
13.7 结构体定义中的生命周期注解
在之前结构体的例子中,字段中并没有涉及到引用,而是使用了自身拥有所有权的类型(比如String)。这是因为在结构体中使用引用涉及到生命周期的概念,下面就来详细介绍。
要定义包含引用的结构体,需要为结构体定义中的每一个引用添加生命周期注解。
1 | struct ImportantExcerpt<'a> { |
这个结构体有唯一一个字段 part,它存放了一个字符串切片,这是一个引用。类似于泛型参数类型,必须在结构体名称后面的尖括号中声明泛型生命周期参数,以便在结构体定义中使用生命周期参数。这个注解意味着 ImportantExcerpt 的实例不能比其 part 字段中的引用存在得更久。
这里的 main 函数创建了一个 ImportantExcerpt 的实例(第8行),它存放了变量 novel 所拥有的 String 的第一个句子的引用。novel 的数据在 ImportantExcerpt 实例创建之前就存在(第6行)。另外,直到 ImportantExcerpt 离开作用域之后 novel 都不会离开作用域,所以 ImportantExcerpt 实例中的引用是有效的。
如果将代码修改一下:
1 |
|
就无法通过编译了,因为结构体比它引用的字符串存在得更久,引用字符串在作用域结束被释放后(第14行),println! 依然在外面使用了该结构体,因此会导致无效的引用。
13.8 生命周期消除(Lifetime Elision)
现在我们已经知道了每一个引用都有一个生命周期,而且我们需要为那些使用了引用的函数或结构体指定生命周期。但是,下面的代码没有对生命周期标注也可以编译成功:
1 | fn first_word(s: &str) -> &str { |
这里定义了一个没有使用生命周期注解的函数,即便其参数和返回值都是引用。这个函数没有生命周期注解却能编译是由于一些历史原因:在早期版本(pre-1.0)的rust中,这的确是不能编译的。每一个引用都必须有明确的生命周期。那时的函数签名将会写成这样:
1 | fn first_word<'a>(s: &'a str) -> &'a str {} |
在编写了很多rust代码后,rust团队发现在特定情况下rust程序员们总是重复地编写一模一样的生命周期注解。这些场景是可预测的并且遵循几个明确的模式。接着rust团队就把这些模式编码进了rust编译器中,如此借用检查器在这些情况下就能推断出生命周期而不再强制程序员显式的增加注解。
被编码进rust引用分析的模式被称为生命周期省略规则(lifetime elision rules)。这并不是需要程序员遵守的规则;这些规则是一系列特定的场景,此时编译器会考虑,如果代码符合这些场景,就无需明确指定生命周期。换句话说,符合这些规则的场景下,编译器会自动处理生命周期,简化了程序员工作。在未来,这些规则可能会变化,也许以后只需要更少的生命周期注解。
函数或方法的参数的生命周期被称为输入生命周期(input lifetimes),而返回值的生命周期被称为输出生命周期(output lifetimes)。
编译器采用三条规则来判断引用何时不需要明确的注解。第一条规则适用于输入生命周期,后两条规则适用于输出生命周期。如果编译器检查完这三条规则后仍然存在没有计算出生命周期的引用,编译器将会停止并生成错误,此时就需要程序员手动标注了。这些规则适用于 fn 定义,以及 impl 块。
- 第一条规则是编译器为每一个是引用参数都分配了一个生命周期参数。换句话说就是,有一个引用参数的函数有一个生命周期参数:
fn foo<'a>(x: &'a i32),有两个引用参数的函数有两个不同的生命周期参数,fn foo<'a, 'b>(x: &'a i32, y: &'b i32),依此类推。 - 第二条规则是如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数:
fn foo<'a>(x: &'a i32) -> &'a i32。 - 第三条规则是如果方法有多个输入生命周期参数并且其中一个参数是
&self或&mut self,说明是个对象的方法,那么所有输出生命周期参数被赋予self的生命周期。第三条规则使得方法更容易读写,因为只需更少的符号。
下面模拟编译器如何根据这些规则计算生命周期,开始时签名中的引用并没有关联任何生命周期:
1 | fn first_word(s: &str) -> &str { |
接着编译器应用第一条规则,也就是每个引用参数都有其自己的生命周期。我们像往常一样称之为 'a,所以现在签名看起来像这样:
1 | fn first_word<'a>(s: &'a str) -> &str { |
对于第二条规则,因为这里正好只有一个输入生命周期参数所以是适用的。第二条规则表明输入参数的生命周期将被赋予输出生命周期参数,所以现在签名看起来像这样:
1 | fn first_word<'a>(s: &'a str) -> &'a str { |
现在这个函数签名中的所有引用都有了生命周期,这部分代码可以编译通过,且用户无需手动去标注生命周期,
再看另一个例子:
1 | fn longest(x: &str, y: &str) -> &str { |
应用第一条规则:每个引用参数都有其自己的生命周期。这次有两个参数,所以就有两个(不同的)生命周期:
1 | fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str { |
应用第二条规则,因为函数存在多个输入生命周期,它并不适用于这种情况。
再来看第三条规则,它同样也不适用,因为没有 self 参数。
应用了三个规则之后编译器还没有计算出返回值类型的生命周期,这就是为什么编译器将会停止并生成错误的原因了,编译器使用所有已知的生命周期省略规则,仍不能计算出签名中所有引用的生命周期。
13.9 方法定义中的生命周期注解
生命周期就是泛型,因此为具有生命周期的结构体实现方法时,语法与泛型语法相同;impl 块里的方法签名中,引用可能与结构体字段中的引用相关联,也可能是独立的。另外,生命周期省略规则也经常让我们无需在方法签名中使用生命周期注解:
1 | struct ImportantExcerpt<'a> { |
结构体字段的生命周期必须总是在 impl 关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。这里有一个方法 level。其唯一的参数是 self 的引用,而且返回值只是一个 i32,并不引用任何值。因为第一条生命周期规则我们并不必须标注 self 引用的生命周期。
下面的例子展示了第三规则应用的场景:
1 | impl<'a> ImportantExcerpt<'a> { |
这里有两个输入生命周期,所以rust应用第一条生命周期省略规则并给予 &self 和 announcement 他们各自的生命周期:
1 | impl<'a> ImportantExcerpt<'a> { |
接着,因为其中一个参数是 &self,根据第三条规则,返回值类型被赋予了 &self 的生命周期,
1 | impl<'a> ImportantExcerpt<'a> { |
这样所有的生命周期都被计算出来了。
13.10 静态生命周期
这里有一种特殊的生命周期值得讨论:'static,其生命周期能够存活于整个程序期间。所有的字符串字面值都拥有 'static 生命周期,我们也可以选择像下面这样标注出来:
1 | let s: &'static str = "I have a static lifetime."; |
这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。因此所有的字符串字面值都是 'static 的。
在错误信息的帮助文本中可能会有使用 'static 生命周期的建议,不过将引用指定为 'static 之前,思考一下这个引用是否真的在整个程序的生命周期里都有效,以及你否希望它存在得这么久。大部分情况下,推荐 'static 生命周期的错误信息都是尝试创建一个悬垂引用或者可用的生命周期不匹配的结果。在这种情况下的解决方案是修复这些问题而不是指定一个 'static 的生命周期。
另外,特征对象的生命周期也是'static,见特征对象。