31 KiB
生命周期与引用有效性
ch10-03-lifetime-syntax.md
commit d7a4e99554da53619dd71044273535ba0186f40a
当在第四章讨论引用时,我们遗漏了一个重要的细节:Rust 中的每一个引用都有其生命周期,也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的,正如大部分时候类型也是可以推断的一样。类似于当因为有多种可能类型的时候必须注明类型,也会出现引用的生命周期以多种不同方式向关联的情况,所以 Rust 需要我们使用泛型生命周期参数来注明他们的关系,这样就能确保运行时实际使用的引用绝对是有效的。
好吧,这有点不太寻常,而且也不同于其他语言中使用的工具。生命周期,从某种意义上说,是 Rust 最与众不同的功能。
生命周期是一个很广泛的话题,本章不可能涉及到它全部的内容,所以这里我们会讲到一些通常你可能会遇到的生命周期语法以便你熟悉这个概念。第十九章会包含生命周期所有功能的更高级的内容。
生命周期避免了悬垂引用
生命周期的主要目标是避免悬垂引用,它会导致程序引用了并非其期望引用的数据。考虑一下列表 10-16 中的程序,它有一个外部作用域和一个内部作用域,外部作用域声明了一个没有初值的变量r
,而内部作用域声明了一个初值为 5 的变量x
。在内部作用域中,我们尝试将r
的值设置为一个x
的引用。接着在内部作用域结束后,尝试打印出r
的值:
未初始化变量不能被使用
接下来的一些例子中声明了没有初始值的变量,以便这些变量存在于外部作用域。这看起来好像和 Rust 不允许存在空值相冲突。然而这是可以的,如果我们尝试在给它一个值之前使用这个变量,会出现一个编译时错误。请自行尝试!
当编译这段代码时会得到一个错误:
error: `x` does not live long enough
|
6 | r = &x;
| - borrow occurs here
7 | }
| ^ `x` dropped here while still borrowed
...
10 | }
| - borrowed value needs to live until here
变量x
并没有“存在的足够久”。为什么呢?好吧,x
在到达第 7 行的大括号的结束时就离开了作用域,这也是内部作用域的结尾。不过r
在外部作用域也是有效的;作用域越大我们就说它“存在的越久”。如果 Rust 允许这段代码工作,r
将会引用在x
离开作用域时被释放的内存,这时尝试对r
做任何操作都会不能正常工作。那么 Rust 是如何决定这段代码是不被允许的呢?
借用检查器
编译器的这一部分叫做借用检查器(borrow checker),它比较作用域来确保所有的借用都是有效的。列表 10-17 展示了与列表 10-16 相同的例子不过带有变量声明周期的注释:
我们将r
的声明周期标记为'a
而将x
的生命周期标记为'b
。如你所见,内部的'b
块要比外部的生命周期'a
小得多。在编译时,Rust 比较这两个生命周期的大小,并发现r
拥有声明周期'a
,不过它引用了一个拥有生命周期'b
的对象。程序被拒绝编译,因为生命周期'b
比生命周期'a
要小:引用者没有比被引用者存在的更久。
让我们看看列表 10-18 中这个并没有产生悬垂引用且可以正常编译的例子:
x
拥有生命周期 'b
,在这里它比 'a
要大。这就意味着r
可以引用x
:Rust 知道r
中的引用在x
有效的时候也会一直有效。
现在我们已经在一个具体的例子中展示了引用的声明周期位于何处,并讨论了 Rust 如何分析生命周期来保证引用总是有效的,接下来让我们聊聊在函数的上下文中参数和返回值的泛型生命周期。
函数中的泛型生命周期
让我们来编写一个返回两个字符串 slice 中最长的那一个的函数。我们希望能够通过传递两个字符串 slice 来调用这个函数,并希望返回一个字符串 slice。一旦我们实现了longest
函数,列表 10-19 中的代码应该会打印出The longest string is abcd
:
注意函数期望获取字符串 slice(如第四章所讲到的这是引用)因为我们并不希望longest
函数获取其参数的引用。我们希望函数能够接受String
的 slice(也就是变量string1
的类型)和字符串字面值(也就是变量string2
包含的值)。
参考之前第四章中的“字符串 slice 作为参数”部分中更多关于为什么上面例子中的参数正是我们想要的讨论。
如果尝试像列表 10-20 中那样实现longest
函数,它并不能编译:
将会出现如下有关生命周期的错误:
error[E0106]: missing lifetime specifier
|
1 | fn longest(x: &str, y: &str) -> &str {
| ^ expected lifetime parameter
|
= help: this function's return type contains a borrowed value, but the
signature does not say whether it is borrowed from `x` or `y`
提示文本告诉我们返回值需要一个泛型生命周期参数,因为 Rust 并不知道将要返回的引用是指向x
或y
。事实上我们也不知道,因为函数体中if
块返回一个x
的引用而else
块返回一个y
的引用。
虽然我们定义了这个函数,但是并不知道传递给函数的具体值,所以也不知道到底是if
还是else
会被执行。我们也不知道传入的引用的具体生命周期,所以也就不能像列表 10-17 和 10-18 那样通过观察作用域来确定返回的引用总是有效的。借用检查器自身同样也无法确定,因为它不知道x
和y
的生命周期是如何与返回值的生命周期相关联的。接下来我们将增加泛型生命周期参数来定义引用间的关系以便借用检查器可以进行相关分析。
生命周期注解语法
生命周期注解并不改变任何引用的生命周期的长短。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。生命周期注解所做的就是将多个引用的生命周期联系起来。
生命周期注解有着一个不太常见的语法:生命周期参数名称必须以撇号('
)开头。生命周期参数的名称通常全是小写,而且类似于泛型类型,其名称通常非常短。'a
是大多数人默认使用的名称。生命周期参数注解位于引用的&
之后,并有一个空格来将引用类型与生命周期注解分隔开。
这里有一些例子:我们有一个没有生命周期参数的i32
的引用,一个有叫做'a
的生命周期参数的i32
的引用,和一个也有的生命周期参数'a
的i32
的可变引用:
&i32 // a reference
&'a i32 // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime
生命周期注解本身没有多少意义:生命周期注解告诉 Rust 多个引用的泛型生命周期参数如何相互联系。如果函数有一个生命周期'a
的i32
的引用的参数first
,还有另一个同样是生命周期'a
的i32
的引用的参数second
,这两个生命周期注解有相同的名称意味着first
和second
必须与这相同的泛型生命周期存在得一样久。
函数签名中的生命周期注解
来看看我们编写的longest
函数的上下文中的生命周期。就像泛型类型参数,泛型生命周期参数需要声明在函数名和参数列表间的加括号中。这里我们想要告诉 Rust 关于参数中的引用和返回值之间的限制是他们都必须拥有相同的生命周期,就像列表 10-21 中在每个引用中都加上了'a
那样:
这段代码能够编译并会产生我们想要使用列表 10-19 中的main
函数得到的结果。
现在函数签名表明对于某些生命周期'a
,函数会获取两个参数,他们都是与生命周期'a
存在的一样长的字符串 slice。函数会返回一个同样也与生命周期'a
存在的一样长的字符串 slice。这就是我们告诉 Rust 需要其保证的协议。
通过在函数签名中指定生命周期参数,我们不会改变任何参数或返回值的生命周期,不过我们说过任何不坚持这个协议的类型都将被借用检查器拒绝。这个函数并不知道(或需要知道)x
和y
具体会存在多久,不过只需要知道一些可以使用'a
替代的作用域将会满足这个签名。
当在函数中使用生命周期注解时,这些注解出现在函数签名中,而不存在于函数体中的任何代码中。这是因为 Rust 能够分析函数中代码而不需要任何协助,不过当函数引用或被函数之外的代码引用时,参数或返回值的生命周期可能在每次函数被调用时都不同。这可能会产生惊人的消耗并且对于 Rust 来说经常都是不可能分析的。在这种情况下,我们需要自己标注生命周期。
当具体的引用被传递给longest
时,具体被'a
所替代的生命周期是x
的作用域与y
的作用域相重叠的那一部分。因为作用域总是嵌套的,所以换一种说法就是泛型生命周期'a
的具体生命周期等同于x
和y
的生命周期中较小的那一个。因为我们用相同的生命周期参数标注了返回的引用值,所以返回的引用值就能保证在x
和y
中较短的那个生命周期结束之前保持有效。
让我们如何通过传递拥有不同具体生命周期的引用来观察他们是如何限制longest
函数的使用的。列表 10-22 是一个应该在任何编程语言中都很直观的例子:string1
直到外部作用域结束都是有效的,string2
则在内部作用域中是有效的,而result
则引用了一些直到外部作用域结束都是有效的值。借用检查器赞同这些代码;它能够编译和运行,并打印出The longest string is long string is long
:
接下来,让我们尝试一个result
的引用的生命周期必须比两个参数的要短的例子。将result
变量的声明从内部作用域中移动出来,不过将result
和string2
变量的赋值语句一同放在内部作用域里。接下来,我们将使用result
的println!
移动到内部作用域之外,就在其结束之后。注意列表 10-23 中的代码不能编译:
如果尝试编译会出现如下错误:
error: `string2` does not live long enough
|
6 | result = longest(string1.as_str(), string2.as_str());
| ------- borrow occurs here
7 | }
| ^ `string2` dropped here while still borrowed
8 | println!("The longest string is {}", result);
9 | }
| - borrowed value needs to live until here
错误表明为了保证println!
中的result
是有效的,string2
需要直到外部作用域结束都是有效的。Rust 知道这些是因为(longest
)函数的参数和返回值都使用了相同的生命周期参数'a
。
以我们的理解string1
更长,因此result
会包含指向string1
的引用。因为string1
还未离开作用域,对于println!
来说string1
的引用仍然是有效的。然而,我们通过生命周期参数告诉 Rust 的是longest
函数返回的引用的生命周期应该与传入参数的生命周期中较短那个保持一致。因此,借用检查器不允许列表 10-23 中的代码,因为它可能会存在无效的引用。
请尝试更多采用不同的值和不同生命周期的引用作为longest
函数的参数和返回值的实验。并在开始编译前猜想你的实验能否通过借用检查器,接着编译一下看看你是否是正确的!
深入理解生命周期
指定生命周期参数的正确方式依赖函数具体的功能。例如,如果将longest
函数的实现修改为总是返回第一个参数而不是最长的字符串 slice,就不需要为参数y
指定一个生命周期。如下代码将能够编译:
Filename: src/main.rs
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
在这个例子中,我们为参数x
和返回值指定了生命周期参数'a
,不过没有为参数y
指定,因为y
的生命周期与参数x
和返回值的生命周期没有任何关系。
当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。如果返回的引用没有指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值,它将会是一个悬垂引用,因为它将会在函数结束时离开作用域。尝试考虑这个并不能编译的longest
函数实现:
Filename: src/main.rs
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}
即便我们为返回值指定了生命周期参数'a
,这个实现却编译失败了,因为返回值的生命周期与参数完全没有关联。这里是会出现的错误信息:
error: `result` does not live long enough
|
3 | result.as_str()
| ^^^^^^ does not live long enough
4 | }
| - borrowed value only lives until here
|
note: borrowed value must be valid for the lifetime 'a as defined on the block
at 1:44...
|
1 | fn longest<'a>(x: &str, y: &str) -> &'a str {
| ^
出现的问题是result
在函数的结尾将离开作用域并被清理,而我们尝试从函数返回一个result
的引用。无法指定生命周期参数来改变悬垂引用,而且 Rust 也不允许我们创建一个悬垂引用。在这种情况,最好的解决方案是返回一个有所有权的数据类型而不是一个引用,这样函数调用者就需要负责清理这个值了。
从结果上看,生命周期语法是关于如何联系函数不同参数和返回值的生命周期的。一旦他们形成了某种联系,Rust 就有了足够的信息来允许内存安全的操作并阻止会产生悬垂指针亦或是违反内存安全的行为。
结构体定义中的生命周期注解
目前为止,我们只定义过有所有权类型的结构体。也可以定义存放引用的结构体,不过需要为结构体定义中的每一个引用添加生命周期注解。列表 10-24 中有一个存放了一个字符串 slice 的结构体ImportantExcerpt
:
这个结构体有一个字段,part
,它存放了一个字符串 slice,这是一个引用。类似于泛型参数类型,必须在结构体名称后面的尖括号中声明泛型生命周期参数,以便在结构体定义中使用生命周期参数。
这里的main
函数创建了一个ImportantExcerpt
的实例,它存放了变量novel
所拥有的String
的第一个句子的引用。
生命周期省略
在这一部分,我们知道了每一个引用都有一个生命周期,而且需要为使用了引用的函数或结构体指定生命周期。然而,第四章的“字符串 slice”部分有一个函数,我们在列表 10-25 中再次展示它,没有生命周期注解却能成功编译:
这个函数没有生命周期注解却能编译是由于一些历史原因:在早期 1.0 之前的版本的 Rust 中,这的确是不能编译的。每一个引用都必须有明确的生命周期。那时的函数签名将会写成这样:
fn first_word<'a>(s: &'a str) -> &'a str {
在编写了很多 Rust 代码后,Rust 团队发现在特定情况下 Rust 程序员们总是重复地编写一模一样的生命周期注解。这些场景是可预测的并且遵循几个明确的模式。接着 Rust 团队就把这些模式编码进了 Rust 编译器中,如此借用检查器在这些情况下就能推断出生命周期而不再强制程序员显式的增加注解。
这里我们提到一些 Rust 的历史是因为更多的明确的模式将被合并和添加到编译器中是完全可能的。未来将会需要越来越少的生命周期注解。
被编码进 Rust 引用分析的模式被称为生命周期省略规则(lifetime elision rules)。这并不是需要程序员遵守的规则;这些规则是一系列特定的场景,此时编译器会会考虑,如果代码符合这些场景,就不需要明确指定生命周期。
这些规则并不提供完整的推断:如果 Rust 在明确遵守这些规则的前提下变量的生命周期仍然是模棱两可的话,它不会猜测剩余引用的生命周期应该是什么。在这种情况,编译器会给出一个错误,这可以通过增加对应引用之间相联系的生命周期注解来解决。
首先,介绍一些定义定义:函数或方法的参数的生命周期被称为输入生命周期(input lifetimes),而返回值的生命周期被称为输出生命周期(output lifetimes)。
现在介绍编译器用于判断引用何时不需要明确生命周期注解的规则。第一条规则适用于输入生命周期,而两条规则则适用于输出生命周期。如果编译器检查完这三条规则并仍然存在没有计算出生命周期的引用,编译器将会停止并生成错误。
-
每一个是引用的参数都有它自己的生命周期参数。话句话说就是,有一个引用参数的有一个生命周期参数:
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
的生命周期被赋予所有输出生命周期参数。这使得方法看起来更简洁。
假设我们自己就是编译器并来计算列表 10-25 first_word
函数的签名中的引用的生命周期。开始时签名中的引用并没有关联任何生命周期:
fn first_word(s: &str) -> &str {
接着我们(作为编译器)应用第一条规则,也就是每个引用参数都有其自己的生命周期。我们像往常一样称之为'a
,所以现在签名看起来像这样:
fn first_word<'a>(s: &'a str) -> &str {
对于第二条规则,因为这里正好只有一个输入生命周期参数所以是适用的。第二条规则表明输入参数的生命周期将被赋予输出生命周期参数,所以现在签名看起来像这样:
fn first_word<'a>(s: &'a str) -> &'a str {
现在这个函数签名中的所有引用都有了生命周期,而编译器可以继续它的分析而无须程序员标记这个函数签名中的生命周期。
让我们再看看另一个例子,这次我们从列表 10-20 中没有生命周期参数的longest
函数开始:
fn longest(x: &str, y: &str) -> &str {
再次假设我们自己就是编译器并应用第一条规则:每个引用参数都有其自己的生命周期。这次有两个参数,所有就有两个生命周期:
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
再来应用第二条规则,它并不适用因为存在多于一个输入生命周期。再来看第三条规则,它同样也不适用因为没有self
参数。然后我们就没有更多规则了,不过还没有计算出返回值的类型的生命周期。这就是为什么在编译列表 10-20 的代码时会出现错误的原因:编译器适用所有已知的生命周期省略规则,不过仍然不能计算出签名中所有引用的生命周期。
因为第三条规则真正能够适用的就只有方法签名,现在就让我们看看那种情况中的生命周期,并看看为什么这条规则意味着我们经常不需要在方法签名中标注生命周期。
方法定义中的生命周期注解
当为带有生命周期的结构体实现方法时,其语法依然类似列表 10-10 中展示的泛型类型参数的语法:包括声明生命周期参数的位置和以及生命周期参数是否与结构体字段或方法的参数与返回值相关联。
(实现方法时)结构体字段的生命周期必须总是在impl
关键字之后声明并在结构体名称之后被适用,因为这些生命周期是结构体类型的一部分。
impl
块里的方法签名中,引用可能与结构体字段中的引用相关联,也可能是独立的。另外,生命周期省略规则也经常让我们无需在方法签名中使用生命周期注解。让我们看看一些使用列表 10-24 中定义的结构体ImportantExcerpt
的例子。
首先,这里有一个方法level
。其唯一的参数是self
的引用,而且返回值只是一个i32
,并不引用任何值:
# struct ImportantExcerpt<'a> {
# part: &'a str,
# }
#
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
impl
之后和类型名称之后的生命周期参数是必要的,不过因为第一条生命周期规则我们并不必须标注self
引用的生命周期。
这里是一个适用于第三条生命周期省略规则的例子:
# struct ImportantExcerpt<'a> {
# part: &'a str,
# }
#
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {}", announcement);
self.part
}
}
这里有两个输入生命周期,所以 Rust 应用第一条生命周期省略规则并给予&self
和announcement
他们各自的生命周期。接着,因为其中一个参数是&self
,返回值类型被赋予了&self
的生命周期,这样所有的生命周期都被计算出来了。
静态生命周期
这里有一种特殊的生命周期值得讨论:'static
。'static
生命周期存活于整个程序期间。所有的字符串字面值都拥有'static
生命周期,我们也可以选择像下面这样标注出来:
let s: &'static str = "I have a static lifetime.";
这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。因此所有的字符串字面值都是'static
的。
你可能在错误信息的帮助文本中见过使用'static
生命周期的建议,不过将引用指定为'static
之前,思考一下这个引用是否真的在整个程序的生命周期里都有效(或者哪怕你希望它一直有效,如果可能的话)。大部分情况,代码中的问题是尝试创建一个悬垂引用或者可用的生命周期不匹配,请解决这些问题而不是指定一个'static
的生命周期。
结合泛型类型参数、trait bounds 和生命周期
让我们简单的看一下在同一函数中指定泛型类型参数、trait bounds 和生命周期的语法!
use std::fmt::Display;
fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
where T: Display
{
println!("Announcement! {}", ann);
if x.len() > y.len() {
x
} else {
y
}
}
这个是列表 10-21 中那个返回两个字符串 slice 中最长者的longest
函数,不过带有一个额外的参数ann
。ann
的类型是泛型T
,它可以被放入任何实现了where
从句中指定的Display
trait 的类型。这个额外的参数会在函数比较字符串 slice 的长度之前被打印出来,这也就是为什么Display
trait bound 是必须的。因为生命周期也是泛型,生命周期参数'a
和泛型类型参数T
都位于函数名后的同一尖括号列表中。
总结
这一章介绍了很多的内容!现在你知道了泛型类型参数、trait 和 trait bounds 以及 泛型生命周期类型,你已经准备编写既不重复又能适用于多种场景的代码了。泛型类型参数意味着代码可以适用于不同的类型。trait 和 trait bounds 保证了即使类型是泛型的,这些类型也会拥有所需要的行为。由生命周期注解所指定的引用生命周期之间的关系保证了这些灵活多变的代码不会出现悬垂引用。而所有的这一切,发生在运行时所以不会影响运行时效率!
你可能不会相信,这个领域还有更多需要学习的内容:第十七章会讨论 trait 对象,这是另一种使用 trait 的方式。第十九章会涉及到生命周期注解更复杂的场景。第二十章讲解一些高级的类型系统功能。不过接下来,让我们聊聊如何在 Rust 中编写测试,来确保代码的所有功能能像我们希望的那样工作!