使用生命周期验证引用
生命周期是另一种我们已经使用的泛型。与其确保类型具有我们想要的行为,生命周期确保引用在我们需要的时间内一直有效。
在第 4 章的 “引用和借用” 部分中,我们没有讨论的一个细节是,Rust 中的每个引用都有一个 生命周期,即该引用有效的范围。大多数时候,生命周期是隐式的并且可以推断出来的,就像大多数时候类型可以推断出来一样。只有当存在多种可能的类型时,我们才需要注解类型。同样地,当引用的生命周期可能以几种不同的方式相关时,我们也必须注解生命周期。Rust 要求我们使用泛型生命周期参数注解这些关系,以确保在运行时实际使用的引用肯定有效。
标注生命周期不是大多数其他编程语言所具有的概念,所以这会让你感到不熟悉。虽然我们不会在本章中完全覆盖生命周期,但我们会讨论你可能会遇到的生命周期语法的常见方式,以便你能够熟悉这个概念。
使用生命周期防止悬垂引用
生命周期的主要目的是防止悬垂引用,这会导致程序引用非预期的数据。考虑列表10-16中的程序,该程序具有一个外部作用域和一个内部作用域。
注意:清单 10-16、10-17 和 10-23 中的示例声明变量时没有赋予初始值,因此变量名存在于外部作用域中。乍一看,这似乎与 Rust 没有空值相矛盾。然而,如果我们尝试在赋予变量值之前使用它,我们将得到一个编译时错误,这表明 Rust 确实不允许空值。
外部作用域声明了一个名为r
的变量,没有初始值,而内部作用域声明了一个名为x
的变量,初始值为5
。在内部作用域中,我们尝试将r
的值设置为x
的引用。然后内部作用域结束,我们尝试打印r
中的值。这段代码无法编译,因为在我们尝试使用r
之前,它所引用的值已经超出了作用域。这是错误信息:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
5 | let x = 5;
| - binding `x` declared here
6 | r = &x;
| ^^ borrowed value does not live long enough
7 | }
| - `x` dropped here while still borrowed
8 |
9 | println!("r: {r}");
| --- borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
错误消息说变量 x
“生命周期不够长。” 原因是 x
在第 7 行内部作用域结束时将超出作用域。 但 r
对于外部作用域仍然是有效的;因为它的作用域更大,我们说它 “生命周期更长。” 如果 Rust 允许这段代码运行,r
将会引用 x
超出作用域时被释放的内存,而我们尝试使用 r
做的任何事情都不会正确工作。那么 Rust 是如何确定这段代码是无效的?它使用了一个借用检查器。
借用检查器
Rust 编译器有一个 借用检查器,它通过比较作用域来确定所有借用是否有效。列表 10-17 显示了与列表 10-16 相同的代码,但带有注释显示变量的生命周期。
这里,我们用'a
注解了r
的生命周期,用'b
注解了x
的生命周期。如你所见,内部的'b
块比外部的'a
生命周期块小得多。在编译时,Rust会比较两个生命周期的大小,并发现r
的生命周期为'a
,但它引用的内存的生命周期为'b
。程序被拒绝是因为'b
比'a
短:引用的主题没有引用的生命周期长。
列表 10-18 修复了代码,使其没有悬垂引用,并且可以编译而不会出现任何错误。
这里,x
拥有生命周期 'b
,在这个例子中,'b
比 'a
更长。这意味着 r
可以引用 x
,因为 Rust 知道 r
中的引用在 x
有效时总是有效的。
现在您已经了解了引用的生命周期以及 Rust 如何分析生命周期以确保引用始终有效,让我们探讨一下在函数上下文中参数和返回值的泛型生命周期。
函数中的泛型生命周期
我们将编写一个返回两个字符串切片中较长的那个的函数。这个函数将接受两个字符串切片并返回一个字符串切片。在我们实现了longest
函数之后,清单10-19中的代码应该打印The longest string is abcd
。
请注意,我们希望该函数接受字符串切片,它们是引用,而不是字符串,因为我们不希望longest
函数获取其参数的所有权。有关为什么我们在示例10-19中使用这些参数的更多讨论,请参阅第4章的“字符串切片作为参数”部分。
如果我们尝试按照清单 10-20 中所示实现 longest
函数,它将无法编译。
相反,我们得到了以下关于生命周期的错误:Instead, we get the following error that talks about lifetimes:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named 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`
help: consider introducing a named lifetime parameter
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
帮助文本揭示了返回类型需要一个泛型生命周期参数,因为 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 多个引用的泛型生命周期参数是如何相互关联的。让我们 examine 如何在 longest
函数的上下文中理解生命周期注解之间的关系。
函数签名中的生命周期注解
要在函数签名中使用生命周期注解,我们需要在函数名称和参数列表之间的尖括号内声明泛型 生命周期 参数,就像我们对泛型 类型 参数所做的那样。
我们希望签名表达以下约束:返回的引用将在两个参数都有效的情况下有效。这是参数和返回值的生命周期之间的关系。我们将生命周期命名为'a
,然后将其添加到每个引用中,如清单10-21所示。
这段代码应该能够编译,并在我们使用它与清单 10-19 中的 main
函数时产生我们想要的结果。
函数签名现在告诉 Rust,对于某个生命周期 'a
,该函数接受两个参数,这两个参数都是字符串切片,它们的生命周期至少与生命周期 'a
一样长。函数签名还告诉 Rust,从函数返回的字符串切片的生命周期至少与生命周期 'a
一样长。实际上,这意味着 longest
函数返回的引用的生命周期与函数参数所引用的值的较小生命周期相同。这些关系就是我们希望 Rust 在分析此代码时使用的关系。
记住,当我们在这个函数签名中指定生命周期参数时,我们并没有改变任何传入或返回值的生命周期。相反,我们是指定借用检查器应该拒绝任何不符合这些约束的值。请注意,longest
函数不需要确切知道 x
和 y
将会存活多久,只需要某个作用域可以替代 'a
以满足此签名。
在函数中注解生命周期时,注解位于函数签名中,而不是函数体中。生命周期注解成为函数契约的一部分,就像签名中的类型一样。函数签名包含生命周期契约意味着 Rust 编译器可以进行更简单的分析。如果函数的注解方式或调用方式存在问题,编译器错误可以更精确地指向我们代码的某部分及其约束。相反,如果 Rust 编译器对生命周期关系进行更多推断,编译器可能只能指向我们代码的使用位置,而这些位置可能离问题的根源很远。
当我们传递具体的引用给longest
时,被替代的具体生命周期'a
是x
的作用域与y
的作用域重叠的部分。换句话说,泛型生命周期'a
将获得等于x
和y
生命周期中较短的那个具体生命周期。因为我们已经用相同的生命周期参数'a
注解了返回的引用,所以返回的引用也将有效,其有效期为x
和y
生命周期中较短的那个的长度。
让我们看看生命周期注解如何通过传递具有不同具体生命周期的引用限制 longest
函数。列表 10-22 是一个简单的例子。
在这个例子中,string1
在外部作用域结束前都是有效的,string2
在内部作用域结束前都是有效的,而 result
引用的内容在内部作用域结束前都是有效的。运行这段代码,你会看到借用检查器批准了;它将编译并打印 The longest string is long string is long
。
接下来,让我们尝试一个示例,该示例显示 result
中引用的生命周期必须是两个参数中较短的生命周期。我们将 result
变量的声明移到内部作用域之外,但将 result
变量的值赋值保留在与 string2
相同的作用域内。然后我们将使用 result
的 println!
移到内部作用域之外,在内部作用域结束之后。列表 10-23 中的代码将无法编译。
当我们尝试编译这段代码时,我们得到了这个错误:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
5 | let string2 = String::from("xyz");
| ------- binding `string2` declared here
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {result}");
| -------- borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
错误显示,为了使 result
在 println!
语句中有效,string2
需要一直有效到外部作用域的结束。Rust 知道这一点,是因为我们使用相同的生命周期参数 'a
注解了函数参数和返回值的生命周期。
作为人类,我们可以看到这段代码中的string1
比string2
长,因此result
将包含对string1
的引用。因为string1
还没有超出作用域,所以对string1
的引用在println!
语句中仍然有效。然而,编译器无法看到这个引用在此情况下是有效的。我们告诉Rust,由longest
函数返回的引用的生命周期与传入的引用中较短的那个相同。因此,借用检查器不允许列表10-23中的代码,因为它可能包含无效的引用。
尝试设计更多的实验,改变传递给longest
函数的引用的值和生命周期,以及返回的引用如何使用。在编译之前,先对你的实验是否能通过借用检查器做出假设;然后检查你是否正确!
从生命周期的角度思考
你需要指定生命周期参数的方式取决于你的函数在做什么。例如,如果我们改变了longest
函数的实现,使其总是返回第一个参数而不是最长的字符串切片,那么我们就不需要在y
参数上指定生命周期。以下代码将编译:
我们为参数 x
和返回类型指定了生命周期参数 'a
,但没有为参数 y
指定,因为 y
的生命周期与 x
的生命周期或返回值的生命周期没有关系。
当从函数返回引用时,返回类型的生命周期参数需要与其中一个参数的生命周期参数匹配。如果返回的引用不指向其中一个参数,那么它必须指向在此函数中创建的值。然而,这将是一个悬垂引用,因为该值将在函数结束时超出作用域。考虑这个无法编译的longest
函数的实现尝试:
这里,即使我们为返回类型指定了生命周期参数'a
,此实现也会因为返回值的生命周期与参数的生命周期完全无关而无法编译。这是我们得到的错误信息:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
--> src/main.rs:11:5
|
11 | result.as_str()
| ------^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `result` is borrowed here
For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
问题在于 result
在 longest
函数结束时超出作用域并被清理。我们还试图从函数中返回对 result
的引用。没有办法指定生命周期参数来改变悬垂引用,Rust 也不会让我们创建悬垂引用。在这种情况下,最好的解决方法是返回一个拥有数据类型而不是引用,这样调用函数就负责清理该值。
最终,生命周期语法是关于连接函数的各种参数和返回值的生命周期。一旦它们被连接起来,Rust 就有足够的信息来允许内存安全的操作,并禁止可能创建悬空指针或以其他方式违反内存安全的操作。
结构体定义中的生命周期注解
到目前为止,我们定义的所有结构体都持有所有权类型。我们可以定义持有引用的结构体,但这种情况下我们需要在结构体定义中的每个引用上添加生命周期注解。清单 10-24 有一个名为 ImportantExcerpt
的结构体,它持有一个字符串切片。
这个结构体有一个名为 part
的字段,它持有一个字符串切片,即一个引用。与泛型数据类型一样,我们在结构体名称后的尖括号内声明泛型生命周期参数的名称,这样我们就可以在结构体定义的主体中使用生命周期参数。这个注解意味着 ImportantExcerpt
的实例不能比其 part
字段中持有的引用活得更久。
main
函数在这里创建了一个 ImportantExcerpt
结构体的实例,该实例持有变量 novel
所拥有的 String
的第一句话的引用。在创建 ImportantExcerpt
实例之前,novel
中的数据就已经存在。此外,novel
一直存在到 ImportantExcerpt
实例超出作用域之后,因此 ImportantExcerpt
实例中的引用是有效的。
生命周期省略
您已经了解到每个引用都有一个生命周期,并且对于使用引用的函数或结构体,您需要指定生命周期参数。然而,我们在第 4 章的列表 4-9 中有一个函数,再次显示在列表 10-25 中,该函数在没有生命周期注解的情况下编译成功。
这个函数之所以能在没有生命周期注解的情况下编译,是因为历史原因: 在 Rust 的早期版本(1.0 之前),这段代码是无法编译的,因为 每个引用都需要一个显式的生命周期。在那个时期,函数签名会这样写:
fn first_word<'a>(s: &'a str) -> &'a str {
在编写了大量的 Rust 代码之后,Rust 团队发现 Rust 程序员在特定情况下反复输入相同的生命周期注解。这些情况是可以预测的,并遵循几个确定的模式。开发人员将这些模式编程到编译器的代码中,以便借用检查器可以在这些情况下推断生命周期,而不需要显式的注解。
这段 Rust 历史相关,因为可能会有更多确定性的模式出现并被添加到编译器中。在未来,甚至可能需要更少的生命周期注解。
编程到 Rust 引用分析中的模式称为 生命周期省略规则。这些规则不是程序员需要遵守的;它们是一组编译器会考虑的特定情况,如果您的代码符合这些情况,您就不需要显式地编写生命周期。
隐藏规则并不提供完全的推断。如果在 Rust 应用这些规则后,引用的生命周期仍然存在歧义,编译器不会猜测剩余引用的生命周期应该是什么。相反,编译器会给你一个错误,你可以通过添加生命周期注解来解决。
函数或方法参数上的生命周期称为输入生命周期,而返回值上的生命周期称为输出生命周期。
编译器使用三条规则来确定引用的生命周期,当没有显式注解时。第一条规则适用于输入生命周期,第二条和第三条规则适用于输出生命周期。如果编译器在三条规则之后仍然有无法确定生命周期的引用,编译器将停止并报错。这些规则适用于 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
的生命周期将被分配给所有输出生命周期参数。这条规则使得方法更易于阅读和编写,因为需要的符号更少。
让我们假装是编译器。我们将应用这些规则来确定列表 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 {
现在这个函数签名中的所有引用都有生命周期,编译器可以继续其分析,而不需要程序员在这个函数签名中注解生命周期。
让我们来看另一个例子,这次使用的是在 Listing 10-20 中开始使用的 longest
函数,当时它还没有生命周期参数:
fn longest(x: &str, y: &str) -> &str {
让我们应用第一条规则:每个参数都有自己的生命周期。这次我们有两个参数而不是一个,所以我们有两个生命周期:
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
你可以看到,第二条规则不适用,因为有多个输入生命周期。第三条规则也不适用,因为longest
是一个函数而不是方法,所以没有参数是 self
。在经过所有三条规则后,我们仍然没有弄清楚返回类型的生命周期是什么。这就是为什么我们在尝试编译清单 10-20 中的代码时遇到了错误:编译器经过了生命周期省略规则,但仍然无法弄清楚签名中所有引用的生命周期。
因为第三条规则实际上只适用于方法签名,我们将在接下来的方法签名上下文中查看生命周期,以了解为什么第三条规则意味着我们不必经常在方法签名中注解生命周期。
方法定义中的生命周期注解
当我们为带有生命周期的结构体实现方法时,我们使用与泛型类型参数相同的语法,如清单 10-11 所示。我们在何处声明和使用生命周期参数取决于它们是与结构体字段相关还是与方法参数和返回值相关。
结构字段的生命周期名称总是需要在 impl
关键字后声明,然后在结构体名称后使用,因为这些生命周期是结构体类型的一部分。
在 impl
块中的方法签名里,引用可能与结构体字段中引用的生命周期绑定,也可能独立。此外,生命周期省略规则通常使得方法签名中不需要生命周期注解。让我们来看一些使用我们在清单 10-24 中定义的名为 ImportantExcerpt
的结构体的例子。
首先我们将使用一个名为 level
的方法,其唯一参数是对 self
的引用,其返回值是一个 i32
,它不是对任何事物的引用:
struct ImportantExcerpt<'a> { part: &'a str, } impl<'a> ImportantExcerpt<'a> { fn level(&self) -> i32 { 3 } } impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part(&self, announcement: &str) -> &str { println!("Attention please: {announcement}"); self.part } } fn main() { let novel = String::from("Call me Ishmael. Some years ago..."); let first_sentence = novel.split('.').next().unwrap(); let i = ImportantExcerpt { part: first_sentence, }; }
impl
之后的生命周期参数声明及其在类型名称之后的使用是必需的,但根据第一条省略规则,我们不需要标注对 self
的引用的生命周期。
这里是一个第三个生命周期省略规则适用的例子:
struct ImportantExcerpt<'a> { part: &'a str, } impl<'a> ImportantExcerpt<'a> { fn level(&self) -> i32 { 3 } } impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part(&self, announcement: &str) -> &str { println!("Attention please: {announcement}"); self.part } } fn main() { let novel = String::from("Call me Ishmael. Some years ago..."); let first_sentence = novel.split('.').next().unwrap(); let i = ImportantExcerpt { part: first_sentence, }; }
有两个输入生命周期,所以 Rust 应用第一个生命周期省略规则
并给 &self
和 announcement
分配了各自的生命周期。然后,因为
其中一个参数是 &self
,返回类型获得了 &self
的生命周期,
并且所有生命周期都已考虑在内。
静态生命周期
一个我们需要讨论的特殊生命周期是 'static
,它表示受影响的引用 可以 在整个程序的生命周期内存在。所有字符串字面量都具有 'static
生命周期,我们可以如下标注:
#![allow(unused)] fn main() { let s: &'static str = "I have a static lifetime."; }
此字符串的文本直接存储在程序的二进制文件中,始终可用。因此,所有字符串字面量的生命周期为'static
。
你可能会在错误消息中看到建议使用'static
生命周期。但在为引用指定'static
生命周期之前,请考虑该引用是否确实存在于程序的整个生命周期中,以及你是否希望如此。大多数情况下,错误消息建议使用'static
生命周期是因为尝试创建悬空引用或生命周期不匹配。在这种情况下,解决方案是解决这些问题,而不是指定'static
生命周期。
泛型类型参数、特征边界和生命周期一起使用
让我们简要地看一下在一个函数中指定泛型类型参数、特征边界和生命周期的语法!
fn main() { let string1 = String::from("abcd"); let string2 = "xyz"; let result = longest_with_an_announcement( string1.as_str(), string2, "Today is someone's birthday!", ); println!("The longest string is {result}"); } 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 的 longest
函数,它返回两个字符串切片中较长的那个。但现在它有一个额外的参数 ann
,其类型为泛型 T
,可以由任何实现了 Display
特性的类型填充,如 where
子句所指定。这个额外的参数将使用 {}
打印,这就是为什么需要 Display
特性约束。因为生命周期是一种泛型,所以生命周期参数 'a
和泛型类型参数 T
的声明在函数名称后的尖括号内同一个列表中。
摘要
我们在本章中涵盖了大量内容!现在你已经了解了泛型类型参数、特质和特质边界,以及泛型生命周期参数,你已经准备好编写能够在多种不同情况下工作的、不重复的代码。泛型类型参数让你可以将代码应用于不同的类型。特质和特质边界确保即使类型是泛型的,它们也会具有代码所需的行为。你学会了如何使用生命周期注解来确保这种灵活的代码不会有任何悬垂引用。所有这些分析都在编译时发生,不会影响运行时性能!
不管你信不信,我们在本章讨论的主题还有更多内容可以学习:第18章讨论了特质对象,这是使用特质的另一种方式。还有一些涉及生命周期注解的更复杂场景,你只有在非常高级的情况下才需要;对于这些,你应该阅读Rust参考手册。但接下来,你将学习如何在Rust中编写测试,以确保你的代码按预期工作。