《Rust 程序设计语言》第十章:泛型类型、特性与生命周期
本文介绍了Rust中的泛型编程概念,包括泛型类型、特性约束和生命周期管理。主要内容包括: 泛型类型:通过使用类型参数T来编写可重用代码,避免重复。示例展示了如何定义泛型函数和结构体。 特性(trait):定义共享行为的接口,通过impl Trait语法或特性约束(T: Trait)来限制泛型类型。介绍了默认实现和特性继承。 生命周期:解释引用有效期的概念,通过生命周期标注('a)确保引用安全性。说
泛型类型、特性与生命周期
每种编程语言都有高效处理概念重复的工具。在 Rust 中,泛型就是这样一种工具:它是具体类型或其他属性的抽象替代。我们可以在编译和运行代码时,不用知道泛型具体代表什么,就能表达泛型的行为或它们与其他泛型的关系。
函数可以接受某种泛型类型的参数,而不是像 i32 或 String 这样的具体类型,就像函数接受未知值的参数,以便对多个具体值运行相同的代码一样。其实,我们在第 6 章已经用过泛型 Option<T>,第 8 章用过 Vec<T>和 HashMap<K, V>,第 9 章用过 Result<T, E>。在本章中,我们将探讨如何用泛型来定义自己的类型、函数和方法!
首先,我们会回顾如何提取函数来减少代码重复。然后,我们会用同样的技巧,从两个仅参数类型不同的函数中创建一个泛型函数。我们还会解释如何在结构体和枚举定义中使用泛型类型。
接下来,你会学习如何用特性(trait)以泛型方式定义行为。你可以将特性与泛型类型结合起来,限制泛型类型只能接受那些具有特定行为的类型,而不是任何类型都可以。
最后,我们会讨论生命周期(lifetimes):它是一种特殊的泛型,用于告诉编译器引用之间的关系。生命周期让我们能向编译器提供足够的关于借用值的信息,这样编译器就能在更多情况下确保引用有效,而不是在没有我们帮助时那样受限。
通过提取函数消除重复代码
泛型允许我们用一个占位符来替代具体类型,这个占位符可以代表多种类型,从而消除代码重复。在深入泛型语法之前,我们先看看如何不使用泛型类型,而是通过提取函数(用占位符替代具体值)来消除重复代码。然后我们会用同样的技巧提取一个泛型函数!通过了解如何识别可以提取到函数中的重复代码,你就能开始识别可以使用泛型的重复代码了。
我们从清单 10-1 中的简短程序开始,这个程序用于查找列表中最大的数。
// 文件名: src/main.rs
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("The largest number is {largest}");
}
清单 10-1:在数字列表中查找最大的数
我们把整数列表存储在变量 number_list 中,并把列表中第一个数的引用放在名为 largest 的变量中。然后我们遍历列表中的所有数,如果当前数大于 largest 中存储的数,就用当前数的引用替换 largest 中的引用。不过,如果当前数小于或等于目前为止看到的最大数,变量就不会改变,代码会继续处理列表中的下一个数。考虑完列表中的所有数后,largest 应该指向最大的数,在这个例子中是 100。
现在我们的任务是在两个不同的数字列表中找到最大的数。为此,我们可以选择复制清单 10-1 中的代码,并在程序的两个不同位置使用相同的逻辑,如清单 10-2 所示。
// 文件名: src/main.rs
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("The largest number is {largest}");
let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("The largest number is {largest}");
}
清单 10-2:在两个数字列表中查找最大数的代码
虽然这段代码能工作,但复制代码既繁琐又容易出错。而且,当我们想修改代码时,还得记住在多个地方进行更新。
为了消除这种重复,我们将通过定义一个函数来创建抽象,这个函数可以对任何作为参数传入的整数列表进行操作。这个解决方案使我们的代码更清晰,并且让我们能够抽象地表达在列表中查找最大数的概念。
在清单 10-3 中,我们把查找最大数的代码提取到一个名为 largest 的函数中。然后我们调用这个函数来查找清单 10-2 中两个列表的最大数。将来如果我们有其他 i32 值的列表,也可以使用这个函数。
// 文件名: src/main.rs
fn largest(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {result}");
let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];
let result = largest(&number_list);
println!("The largest number is {result}");
}
清单 10-3:用于在两个列表中查找最大数的抽象代码
largest 函数有一个名为 list 的参数,它代表我们可能传入函数的任何具体的 i32 值切片。因此,当我们调用这个函数时,代码会对我们传入的特定值进行操作。
总之,我们将代码从清单 10-2 改为清单 10-3 的步骤如下:
- 识别重复代码。
- 把重复代码提取到函数体中,并在函数签名中指定该代码的输入和返回值。
- 把两处重复的代码更新为调用该函数。
接下来,我们将用同样的步骤使用泛型来减少代码重复。就像函数体可以对抽象的列表而不是具体的值进行操作一样,泛型允许代码对抽象的类型进行操作。
例如,假设我们有两个函数:一个用于查找 i32 值切片中的最大项,另一个用于查找 char 值切片中的最大项。我们该如何消除这种重复呢?让我们一探究竟!
泛型数据类型
我们使用泛型来创建函数签名或结构体等项的定义,然后可以将这些定义用于许多不同的具体数据类型。我们首先看看如何使用泛型来定义函数、结构体、枚举和方法。然后我们会讨论泛型对代码性能的影响。
在函数定义中使用泛型
定义使用泛型的函数时,我们将泛型放在函数签名中通常指定参数和返回值数据类型的位置。这样做使我们的代码更灵活,能为函数的调用者提供更多功能,同时还能防止代码重复。
继续以我们的 largest 函数为例,清单 10-4 展示了两个都用于查找切片中最大值的函数。之后我们会将它们合并成一个使用泛型的函数。
// 文件名: src/main.rs
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> &char {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest_i32(&number_list);
println!("The largest number is {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("The largest char is {result}");
}
清单 10-4:两个仅名称和签名中的类型不同的函数
largest_i32 函数是我们在清单 10-3 中提取的,用于查找切片中的最大 i32 值。largest_char 函数用于查找切片中的最大 char 值。这两个函数的函数体代码相同,所以我们将通过在一个函数中引入泛型类型参数来消除重复。
要在一个新的单一函数中参数化类型,我们需要给类型参数命名,就像给函数的值参数命名一样。你可以使用任何标识符作为类型参数名。但我们会使用 T,因为按照惯例,Rust 中的类型参数名要短,通常只有一个字母,而且 Rust 的类型命名规范是驼峰式(CamelCase)。T 是 “type” 的缩写,是大多数 Rust 程序员的默认选择。
当我们在函数体中使用参数时,必须在签名中声明参数名,以便编译器知道这个名称的含义。同样,当我们在函数签名中使用类型参数名时,必须在使用它之前声明类型参数名。要定义泛型的 largest 函数,我们将类型名声明放在尖括号 <> 中,位于函数名和参数列表之间,像这样:
fn largest<T>(list: &[T]) -> &T {
我们可以这样理解这个定义:函数 largest 是泛型的,它有一个类型参数 T。这个函数有一个名为 list 的参数,它是 T 类型值的切片。largest 函数将返回一个与 T 类型相同的值的引用。
清单 10-5 展示了使用泛型数据类型作为签名的合并后的 largest 函数定义。这个清单还展示了我们如何用 i32 值切片或 char 值切片来调用这个函数。注意,这段代码目前还不能编译。
// 文件名: src/main.rs
// 这段代码无法编译!
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {result}");
}
清单 10-5:使用泛型类型参数的 largest 函数;这段代码目前无法编译
如果我们现在编译这段代码,会得到如下错误:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
帮助文本提到了 std::cmp::PartialOrd,这是一个特性,我们将在下一节讨论特性。现在,我们只需知道这个错误表明 largest 函数体不能适用于 T 可能的所有类型。因为我们想在函数体中比较 T 类型的值,所以我们只能使用那些值可以排序的类型。为了支持比较,标准库中有 std::cmp::PartialOrd 特性,你可以在类型上实现这个特性(关于这个特性的更多信息参见附录 C)。要修复清单 10-5,我们可以按照帮助文本的建议,将 T 的有效类型限制为那些实现了 PartialOrd 的类型。这样清单就能编译了,因为标准库在 i32 和 char 上都实现了 PartialOrd。
在结构体定义中使用泛型
我们也可以使用 <> 语法来定义在一个或多个字段中使用泛型类型参数的结构体。清单 10-6 定义了一个 Point<T>结构体,用于存储任何类型的 x 和 y 坐标值。
// 文件名: src/main.rs
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
清单 10-6:一个 Point<T>结构体,用于存储 T 类型的 x 和 y 值
在结构体定义中使用泛型的语法与在函数定义中使用泛型的语法相似。首先,我们在结构体名称后面的尖括号中声明类型参数的名称。然后,我们在结构体定义中原本要指定具体数据类型的地方使用泛型类型。
注意,因为我们只用了一个泛型类型来定义 Point<T>,所以这个定义表明 Point<T>结构体是泛型的,它有一个类型 T,并且字段 x 和 y 都是同一个类型 T,不管这个类型是什么。如果我们创建一个 Point<T>的实例,其中包含不同类型的值,如清单 10-7 所示,我们的代码将无法编译。
// 文件名: src/main.rs
// 这段代码无法编译!
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
清单 10-7:字段 x 和 y 必须是相同的类型,因为它们都使用相同的泛型数据类型 T
在这个例子中,当我们给 x 赋值整数 5 时,我们让编译器知道对于这个 Point<T>实例,泛型类型 T 将是整数。然后当我们给 y 指定 4.0 时(我们已经定义 y 与 x 的类型相同),我们会得到一个类型不匹配的错误,如下所示:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point number
For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
要定义一个 x 和 y 都是泛型但可以是不同类型的 Point 结构体,我们可以使用多个泛型类型参数。例如,在清单 10-8 中,我们将 Point 的定义改为泛型类型 T 和 U,其中 x 是 T 类型,y 是 U 类型。
// 文件名: src/main.rs
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}
清单 10-8:一个泛型类型为 T 和 U 的 Point<T, U>,这样 x 和 y 可以是不同类型的值
现在,所有显示的 Point 实例都是允许的!你可以在一个定义中使用任意多个泛型类型参数,但使用太多会使代码难以阅读。如果你发现你的代码需要很多泛型类型,这可能意味着你的代码需要重构为更小的部分。
在枚举定义中使用泛型
和结构体一样,我们可以定义枚举来在它们的变体中存储泛型数据类型。让我们再看看标准库提供的 Option<T>枚举,我们在第 6 章中使用过:
enum Option<T> {
Some(T),
None,
}
现在这个定义对你来说应该更有意义了。如你所见,Option<T>枚举是泛型类型 T 的,它有两个变体:Some,用于存储一个 T 类型的值;None,不存储任何值。通过使用 Option<T>枚举,我们可以表达可选值的抽象概念,而且因为 Option<T>是泛型的,所以无论可选值的类型是什么,我们都可以使用这个抽象。
枚举也可以使用多个泛型类型。我们在第 9 章中使用的 Result 枚举的定义就是一个例子:
enum Result<T, E> {
Ok(T),
Err(E),
}
Result 枚举是泛型类型 T 和 E 的,它有两个变体:Ok,用于存储一个 T 类型的值;Err,用于存储一个 E 类型的值。这个定义使得在任何可能成功(返回某种 T 类型的值)或失败(返回某种 E 类型的错误)的操作中使用 Result 枚举都很方便。事实上,这就是我们在清单 9-3 中用于打开文件的方式,当文件成功打开时,T 被填充为 std::fs::File 类型,当打开文件出现问题时,E 被填充为 std::io::Error 类型。
当你发现在你的代码中,多个结构体或枚举的定义只是它们所存储的值的类型不同时,你可以使用泛型类型来避免重复。
在方法定义中使用泛型
我们可以在结构体和枚举上实现方法(就像我们在第 5 章中做的那样),也可以在它们的定义中使用泛型类型。清单 10-9 展示了我们在清单 10-6 中定义的 Point<T>结构体,在它上面实现了一个名为 x 的方法。
// 文件名: src/main.rs
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
清单 10-9:在 Point<T>结构体上实现一个名为 x 的方法,该方法将返回 x 字段的引用,类型为 T
在这里,我们在 Point<T>上定义了一个名为 x 的方法,它返回字段 x 中数据的引用。
注意,我们必须在 impl 后面声明 T,这样我们才能使用 T 来指定我们正在为 Point<T>类型实现方法。通过在 impl 后面声明 T 作为泛型类型,Rust 可以确定 Point 中的尖括号中的类型是泛型类型,而不是具体类型。我们可以为这个泛型参数选择一个与结构体定义中声明的泛型参数不同的名称,但使用相同的名称是惯例。如果你在一个 impl 中编写的方法声明了泛型类型,那么无论用什么具体类型来替代泛型类型,这个方法都将在该类型的任何实例上定义。
我们还可以在为类型定义方法时对泛型类型指定约束。例如,我们可以只为 Point<f32>实例而不是任何泛型类型 T 的 Point<T>实例实现方法。在清单 10-10 中,我们使用具体类型 f32,这意味着我们不在 impl 后面声明任何类型。
// 文件名: src/main.rs
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
清单 10-10:一个仅适用于泛型类型参数 T 为特定具体类型的结构体的 impl 块
这段代码意味着 Point<f32>类型将有一个 distance_from_origin 方法;而 T 不是 f32 类型的其他 Point<T>实例将没有这个方法的定义。这个方法用于测量我们的点到坐标 (0.0, 0.0) 的距离,它使用了只有浮点类型才有的数学运算。
结构体定义中的泛型类型参数并不总是与同一个结构体的方法签名中使用的泛型类型参数相同。清单 10-11 对 Point 结构体使用了泛型类型 X1 和 Y1,对 mixup 方法签名使用了 X2 和 Y2,以使示例更清晰。这个方法创建了一个新的 Point 实例,它的 x 值来自 self Point(类型为 X1),y 值来自传入的 Point(类型为 Y2)。
// 文件名: src/main.rs
struct Point<X1, Y1> {
x: X1,
y: Y1,
}
impl<X1, Y1> Point<X1, Y1> {
fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c' };
let p3 = p1.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
清单 10-11:一个使用与其结构体定义不同的泛型类型的方法
在 main 函数中,我们定义了一个 Point,它的 x 是 i32 类型(值为 5),y 是 f64 类型(值为 10.4)。p2 变量是一个 Point 结构体,它的 x 是字符串切片(值为 "Hello"),y 是 char 类型(值为 c)。在 p1 上调用 mixup 方法,并传入 p2 作为参数,得到 p3,p3 的 x 将是 i32 类型,因为 x 来自 p1。p3 变量的 y 将是 char 类型,因为 y 来自 p2。println! 宏调用将打印 p3.x = 5, p3.y = c。
这个例子的目的是展示一种情况:一些泛型参数在 impl 中声明,另一些在方法定义中声明。在这里,泛型参数 X1 和 Y1 在 impl 后面声明,因为它们与结构体定义相关。泛型参数 X2 和 Y2 在 fn mixup 后面声明,因为它们只与该方法相关。
使用泛型的代码的性能
你可能想知道使用泛型类型参数是否会有运行时成本。好消息是,使用泛型类型不会使你的程序比使用具体类型运行得更慢。
Rust 通过在编译时对使用泛型的代码执行单态化(monomorphization)来实现这一点。单态化是通过填充编译时使用的具体类型,将泛型代码转换为特定代码的过程。在这个过程中,编译器做的事情与我们在清单 10-5 中创建泛型函数的步骤相反:编译器查看调用泛型代码的所有地方,并为泛型代码被调用时使用的具体类型生成代码。
让我们通过使用标准库的泛型 Option<T>枚举来看看这是如何工作的:
let integer = Some(5);
let float = Some(5.0);
当 Rust 编译这段代码时,它会执行单态化。在这个过程中,编译器读取在 Option<T>实例中使用的值,并识别出两种 Option<T>:一种是 i32 类型,另一种是 f64 类型。因此,它将 Option<T>的泛型定义扩展为两个专门针对 i32 和 f64 的定义,从而用具体的定义替换泛型定义。
代码的单态化版本看起来类似于以下内容(为了说明,编译器使用的名称与我们在这里使用的名称不同):
// 文件名: src/main.rs
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
泛型的 Option<T>被编译器创建的具体定义所取代。因为 Rust 将泛型代码编译为在每个实例中指定类型的代码,所以我们使用泛型不会有任何运行时成本。当代码运行时,它的表现就像我们手动复制每个定义一样。单态化过程使 Rust 的泛型在运行时非常高效。
特性:定义共享行为
特性(trait)定义了特定类型具有的并且可以与其他类型共享的功能。我们可以使用特性以抽象的方式定义共享行为。我们可以使用特性约束来指定泛型类型可以是任何具有特定行为的类型。
注意:特性类似于其他语言中通常称为接口(interfaces)的特性,尽管存在一些差异。
定义特性
一个类型的行为由我们可以在该类型上调用的方法组成。如果我们可以在所有这些类型上调用相同的方法,那么不同的类型就共享相同的行为。特性定义是一种将方法签名组合在一起的方式,用于定义完成某项任务所需的一组行为。
例如,假设我们有多个结构体,它们存储各种类型和数量的文本:一个 NewsArticle 结构体,用于存储在特定位置发布的新闻报道;一个 SocialPost 结构体,它最多可以有 280 个字符,以及指示它是新帖子、转发还是对另一个帖子的回复的元数据。
我们想制作一个名为 aggregator 的媒体聚合器库 crate,它可以显示可能存储在 NewsArticle 或 SocialPost 实例中的数据摘要。为此,我们需要每个类型都提供一个摘要,我们将通过在实例上调用 summarize 方法来获取这个摘要。清单 10-12 展示了一个公共的 Summary 特性的定义,它表达了这种行为。
// 文件名: src/lib.rs
pub trait Summary {
fn summarize(&self) -> String;
}
清单 10-12:一个 Summary 特性,由 summarize 方法提供的行为组成
在这里,我们使用 trait 关键字声明一个特性,然后是特性的名称,在这个例子中是 Summary。我们还将这个特性声明为 pub,这样依赖于这个 crate 的 crate 也可以使用这个特性,我们将在几个例子中看到这一点。在花括号内,我们声明了描述实现这个特性的类型的行为的方法签名,在这个例子中是 fn summarize (&self) -> String。
在方法签名之后,我们使用分号,而不是在花括号内提供实现。每个实现这个特性的类型都必须为方法体提供自己的自定义行为。编译器将确保任何具有 Summary 特性的类型都定义了具有这个确切签名的 summarize 方法。
一个特性在它的主体中可以有多个方法:方法签名每行一个,每行以分号结束。
在类型上实现特性
现在我们已经定义了 Summary 特性的方法的所需签名,我们可以在我们的媒体聚合器中的类型上实现它。清单 10-13 展示了在 NewsArticle 结构体上实现 Summary 特性,它使用标题、作者和位置来创建 summarize 的返回值。对于 SocialPost 结构体,我们将 summarize 定义为用户名后跟帖子的全部文本,假设帖子内容已经限制在 280 个字符以内。
// 文件名: src/lib.rs
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
清单 10-13:在 NewsArticle 和 SocialPost 类型上实现 Summary 特性
在类型上实现特性类似于实现常规方法。不同之处在于,在 impl 后面,我们放置我们想要实现的特性的名称,然后使用 for 关键字,再指定我们想要为其实现特性的类型的名称。在 impl 块中,我们放置特性定义中已经定义的方法签名。我们不在每个签名后面添加分号,而是使用花括号,并为特性的方法在特定类型上的具体行为填充方法体。
现在,这个库已经在 NewsArticle 和 SocialPost 上实现了 Summary 特性,这个 crate 的用户可以像调用常规方法一样在 NewsArticle 和 SocialPost 的实例上调用特性方法。唯一的区别是,用户必须将特性以及类型引入作用域。下面是一个二进制 crate 如何使用我们的 aggregator 库 crate 的例子:
use aggregator::{SocialPost, Summary};
fn main() {
let post = SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
};
println!("1 new post: {}", post.summarize());
}
这段代码打印 1 new post: horse_ebooks: of course, as you probably already know, people。
依赖于 aggregator crate 的其他 crate 也可以将 Summary 特性引入作用域,以便在它们自己的类型上实现 Summary。需要注意的一个限制是,我们只能在以下情况之一时在类型上实现特性:特性或类型(或两者)是我们的 crate 本地的。例如,我们可以在我们的 aggregator crate 功能中,为像 SocialPost 这样的自定义类型实现标准库特性,如 Display,因为 SocialPost 类型是我们的 aggregator crate 本地的。我们也可以在我们的 aggregator crate 中为 Vec<T>实现 Summary 特性,因为 Summary 特性是我们的 aggregator crate 本地的。
但是我们不能在外部类型上实现外部特性。例如,我们不能在我们的 aggregator crate 中为 Vec<T>实现 Display 特性,因为 Display 和 Vec<T>都是在标准库中定义的,而不是我们的 aggregator crate 本地的。这个限制是称为一致性(coherence)的属性的一部分,更具体地说是孤儿规则(orphan rule),之所以这样命名是因为父类型不存在。这个规则确保其他人的代码不会破坏你的代码,反之亦然。如果没有这个规则,两个 crate 可能会为同一个类型实现同一个特性,而 Rust 将不知道使用哪个实现。
默认实现
有时,为特性中的一些或所有方法提供默认行为是很有用的,而不是要求在每个类型上实现所有方法。然后,当我们在特定类型上实现特性时,我们可以保留或覆盖每个方法的默认行为。
在清单 10-14 中,我们为 Summary 特性的 summarize 方法指定了一个默认字符串,而不是像在清单 10-12 中那样只定义方法签名。
// 文件名: src/lib.rs
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
清单 10-14:定义一个具有 summarize 方法默认实现的 Summary 特性
要使用默认实现来总结 NewsArticle 的实例,我们只需指定一个空的 impl 块:impl Summary for NewsArticle {}。
尽管我们不再直接在 NewsArticle 上定义 summarize 方法,但我们已经提供了一个默认实现,并指定 NewsArticle 实现了 Summary 特性。因此,我们仍然可以在 NewsArticle 的实例上调用 summarize 方法,像这样:
let article = NewsArticle {
headline: String::from("Penguins win the Stanley Cup Championship!"),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
};
println!("New article available! {}", article.summarize());
这段代码打印 New article available! (Read more...)。
创建默认实现并不要求我们改变清单 10-13 中 SocialPost 上 Summary 的实现。原因是覆盖默认实现的语法与实现一个没有默认实现的特性方法的语法相同。
默认实现可以调用同一特性中的其他方法,即使那些其他方法没有默认实现。通过这种方式,一个特性可以提供很多有用的功能,而只要求实现者指定其中的一小部分。例如,我们可以定义 Summary 特性有一个 summarize_author 方法(其实现是必需的),然后定义一个 summarize 方法,它有一个调用 summarize_author 方法的默认实现:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
要使用这个版本的 Summary,我们只需要在为一个类型实现特性时定义 summarize_author:
impl Summary for SocialPost {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
在我们定义了 summarize_author 之后,我们可以在 SocialPost 结构体的实例上调用 summarize,而 summarize 的默认实现将调用我们提供的 summarize_author 的定义。因为我们已经实现了 summarize_author,所以 Summary 特性在不需要我们编写更多代码的情况下就为我们提供了 summarize 方法的行为。如下所示:
let post = SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
};
println!("1 new post: {}", post.summarize());
这段代码打印 1 new post: (Read more from @horse_ebooks...)。
注意,不可能从同一个方法的覆盖实现中调用默认实现。
作为参数的特性
现在你知道了如何定义和实现特性,我们可以探索如何使用特性来定义接受多种不同类型的函数。我们将使用我们在清单 10-13 中在 NewsArticle 和 SocialPost 类型上实现的 Summary 特性来定义一个 notify 函数,该函数在其 item 参数上调用 summarize 方法,item 参数是某种实现了 Summary 特性的类型。为此,我们使用 impl Trait 语法,像这样:
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
对于 item 参数,我们没有使用具体类型,而是指定了 impl 关键字和特性名称。这个参数接受任何实现了指定特性的类型。在 notify 的主体中,我们可以调用 item 上任何来自 Summary 特性的方法,例如 summarize。我们可以调用 notify 并传入 NewsArticle 或 SocialPost 的任何实例。用任何其他类型(如 String 或 i32)调用这个函数都不会编译,因为这些类型没有实现 Summary。
特性约束语法
impl Trait 语法适用于简单情况,但它实际上是一种称为特性约束(trait bound)的更长形式的语法糖;它看起来像这样:
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
这个更长的形式与上一节中的例子等效,但更冗长。我们将特性约束与泛型类型参数的声明一起放在冒号后面和尖括号内。
impl Trait 语法很方便,在简单情况下使代码更简洁,而更完整的特性约束语法可以在其他情况下表达更复杂的内容。例如,我们可以有两个实现了 Summary 的参数。用 impl Trait 语法来做是这样的:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
如果我们希望这个函数允许 item1 和 item2 具有不同的类型(只要这两种类型都实现了 Summary),使用 impl Trait 是合适的。但是,如果我们想强制两个参数具有相同的类型,我们必须使用特性约束,像这样:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
指定为 item1 和 item2 参数类型的泛型类型 T 约束了这个函数,使得作为 item1 和 item2 参数传入的值的具体类型必须相同。
使用 + 语法指定多个特性约束
我们也可以指定多个特性约束。假设我们希望 notify 对 item 使用显示格式化以及 summarize:我们在 notify 的定义中指定 item 必须同时实现 Display 和 Summary。我们可以使用 + 语法来做到这一点:
pub fn notify(item: &(impl Summary + Display)) {
+ 语法也适用于泛型类型上的特性约束:
pub fn notify<T: Summary + Display>(item: &T) {
指定了这两个特性约束后,notify 的主体可以调用 summarize 并使用 {} 来格式化 item。
使用 where 子句使特性约束更清晰
使用太多特性约束有其缺点。每个泛型都有自己的特性约束,所以具有多个泛型类型参数的函数在函数名称和参数列表之间可能包含很多特性约束信息,使得函数签名难以阅读。出于这个原因,Rust 有一种替代语法,用于在函数签名后的 where 子句中指定特性约束。因此,我们可以不这样写:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
而是使用 where 子句,像这样:
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
这个函数的签名不那么混乱:函数名称、参数列表和返回类型靠得很近,类似于没有很多特性约束的函数。
返回实现了特性的类型
我们也可以在返回位置使用 impl Trait 语法来返回某种实现了特性的值,如下所示:
fn returns_summarizable() -> impl Summary {
SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
}
}
通过使用 impl Summary 作为返回类型,我们指定 returns_summarizable 函数返回某种实现了 Summary 特性的类型,而不指定具体类型。在这种情况下,returns_summarizable 返回一个 SocialPost,但调用这个函数的代码不需要知道这一点。
仅通过特性来指定返回类型的能力在闭包和迭代器的上下文中特别有用,我们将在第 13 章介绍。闭包和迭代器创建的类型只有编译器知道,或者是很长的指定类型。impl Trait 语法让你可以简洁地指定一个函数返回某种实现了 Iterator 特性的类型,而不需要写出很长的类型。
但是,你只能在返回单个类型时使用 impl Trait。例如,下面这段代码返回一个 NewsArticle 或一个 SocialPost,并将返回类型指定为 impl Summary,这是行不通的:
// 这段代码无法编译!
fn returns_summarizable(switch: bool) -> impl Summary {
if switch {
NewsArticle {
headline: String::from(
"Penguins win the Stanley Cup Championship!",
),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
}
} else {
SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
}
}
}
由于 impl Trait 语法在编译器中的实现限制,不允许返回 NewsArticle 或 SocialPost。我们将在第 18 章的 “使用允许不同类型值的特性对象” 部分介绍如何编写具有这种行为的函数。
使用特性约束有条件地实现方法
通过在使用泛型类型参数的 impl 块中使用特性约束,我们可以有条件地为实现了指定特性的类型实现方法。例如,清单 10-15 中的 Pair<T>类型总是实现 new 函数,以返回 Pair<T>的新实例(回想一下第 5 章的 “定义方法” 部分,Self 是 impl 块类型的别名,在这种情况下是 Pair<T>)。但在下一个 impl 块中,Pair<T>只有在其内部类型 T 实现了启用比较的 PartialOrd 特性和启用打印的 Display 特性时,才实现 cmp_display 方法。
// 文件名: src/lib.rs
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
清单 10-15:根据特性约束有条件地在泛型类型上实现方法
我们也可以有条件地为任何实现了另一个特性的类型实现一个特性。为满足特性约束的任何类型实现特性的实现称为 blanket 实现,在 Rust 标准库中被广泛使用。例如,标准库为任何实现了 Display 特性的类型实现了 ToString 特性。标准库中的 impl 块看起来类似于这段代码:
impl<T: Display> ToString for T {
// --snip--
}
因为标准库有这个 blanket 实现,我们可以在任何实现了 Display 特性的类型上调用 ToString 特性定义的 to_string 方法。例如,我们可以将整数转换为它们对应的 String 值,因为整数实现了 Display:
let s = 3.to_string();
blanket 实现在特性的文档的 “Implementors” 部分中列出。
特性和特性约束让我们可以编写使用泛型类型参数来减少重复的代码,同时也向编译器指定我们希望泛型类型具有特定的行为。然后编译器可以使用特性约束信息来检查所有用于我们代码的具体类型是否提供了正确的行为。在动态类型语言中,如果我们在一个没有定义方法的类型上调用方法,我们会在运行时得到一个错误。但 Rust 将这些错误移到了编译时,所以我们必须在代码能够运行之前修复这些问题。此外,我们不需要编写在运行时检查行为的代码,因为我们已经在编译时检查过了。这样做在不放弃泛型灵活性的情况下提高了性能。
使用生命周期验证引用
生命周期是我们已经在使用的另一种泛型。与确保类型具有我们想要的行为不同,生命周期确保引用在我们需要它们的时间内是有效的。
我们在第 4 章的 “引用和借用” 部分没有讨论的一个细节是,Rust 中的每个引用都有一个生命周期,即该引用有效的作用域。大多数时候,生命周期是隐式的和可推断的,就像大多数时候类型是可推断的一样。只有当可能有多种类型时,我们才需要标注类型。同样,当引用的生命周期可能以几种不同的方式相关时,我们必须标注生命周期。Rust 要求我们使用泛型生命周期参数来标注这些关系,以确保运行时使用的实际引用肯定是有效的。
标注生命周期并不是大多数其他编程语言都有的概念,所以这会让人感到陌生。虽然在本章中我们不会全面涵盖生命周期,但我们会讨论你可能遇到的常见生命周期语法,以便你能熟悉这个概念。
使用生命周期防止悬垂引用
生命周期的主要目的是防止悬垂引用,悬垂引用会导致程序引用的数据不是它想要引用的数据。考虑清单 10-16 中的程序,它有一个外部作用域和一个内部作用域。
// 这段代码无法编译!
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {r}");
}
清单 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 相同的代码,但带有标注变量生命周期的注释。
// 这段代码无法编译!
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
清单 10-17:r 和 x 的生命周期注释,分别命名为 'a 和 'b
在这里,我们用 'a 标注了 r 的生命周期,用 'b 标注了 x 的生命周期。如你所见,内部的 'b 块比外部的 'a 生命周期块小得多。在编译时,Rust 比较这两个生命周期的大小,发现 r 的生命周期是 'a,但它引用的内存的生命周期是 'b。程序被拒绝,因为 'b 比 'a 短:引用的对象存在的时间不如引用长。
清单 10-18 修复了代码,使其没有悬垂引用,并且可以无错误地编译。
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {r}"); // | |
// --+ |
} // ----------+
清单 10-18:一个有效的引用,因为数据的生命周期比引用长
在这里,x 的生命周期是 'b,在这种情况下比 'a 大。这意味着 r 可以引用 x,因为 Rust 知道 r 中的引用在 x 有效时总是有效的。
现在你知道了引用的生命周期在哪里,以及 Rust 如何分析生命周期以确保引用始终有效,让我们在函数的上下文中探索参数和返回值的泛型生命周期。
函数中的泛型生命周期
我们将编写一个返回两个字符串切片中较长者的函数。这个函数将接受两个字符串切片并返回一个字符串切片。在我们实现了 longest 函数之后,清单 10-19 中的代码应该打印 The longest string is abcd。
// 文件名: src/main.rs
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
清单 10-19:一个 main 函数,它调用 longest 函数来查找两个字符串切片中较长的一个
注意,我们希望这个函数接受字符串切片(它们是引用),而不是字符串,因为我们不希望 longest 函数获取其参数的所有权。更多关于为什么我们在清单 10-19 中使用这些参数的讨论,请参考第 4 章的 “作为参数的字符串切片”。
如果我们尝试如清单 10-20 所示实现 longest 函数,它将无法编译。
// 文件名: src/main.rs
// 这段代码无法编译!
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
清单 10-20:longest 函数的一个实现,它返回两个字符串切片中较长的一个,但目前无法编译
相反,我们得到以下关于生命周期的错误:
$ 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 i32 // 一个带有显式生命周期的引用
&'a mut i32 // 一个带有显式生命周期的可变引用
单个生命周期标注本身没有太多意义,因为标注的目的是告诉 Rust 多个引用的泛型生命周期参数之间的关系。让我们在 longest 函数的上下文中检查生命周期标注如何相互关联。
函数签名中的生命周期标注
要在函数签名中使用生命周期标注,我们需要在函数名称和参数列表之间的尖括号内声明泛型生命周期参数,就像我们对泛型类型参数所做的那样。
我们希望签名表达以下约束:返回的引用将在两个参数都有效的情况下有效。这是参数的生命周期和返回值的生命周期之间的关系。我们将生命周期命名为 'a,然后将它添加到每个引用中,如清单 10-21 所示。
// 文件名: src/main.rs
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
清单 10-21:longest 函数定义指定签名中的所有引用必须具有相同的生命周期 'a
当我们将这段代码与清单 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 是一个简单的例子。
// 文件名: src/main.rs
fn main() {
let string1 = String::from("long string is long");
{
let string2 = String::from("xyz");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {result}");
}
}
清单 10-22:使用 longest 函数,传入对具有不同具体生命周期的 String 值的引用
在这个例子中,string1 在外部作用域结束前都是有效的,string2 在内部作用域结束前都是有效的,result 引用的是在内部作用域结束前有效的东西。运行这段代码,你会看到借用检查器批准了它;它将编译并打印 The longest string is long string is long。
接下来,让我们尝试一个例子,这个例子显示 result 中的引用的生命周期必须是两个参数中较小的那个的生命周期。我们将 result 变量的声明移到内部作用域之外,但将给 result 变量赋值的操作留在包含 string2 的作用域内。然后我们将使用 result 的 println! 移到内部作用域之外,在内部作用域结束之后。清单 10-23 中的代码将无法编译。
// 文件名: src/main.rs
// 这段代码无法编译!
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {result}");
}
清单 10-23:尝试在 string2 超出作用域后使用 result
当我们尝试编译这段代码时,我们得到这个错误:
$ 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 参数上指定生命周期。以下代码将编译:
// 文件名: src/main.rs
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
我们为参数 x 和返回类型指定了生命周期参数 'a,但没有为参数 y 指定,因为 y 的生命周期与 x 的生命周期或返回值的生命周期没有任何关系。
当从函数返回一个引用时,返回类型的生命周期参数需要与其中一个参数的生命周期参数匹配。如果返回的引用不引用任何一个参数,它必须引用在这个函数内部创建的值。但是,这将是一个悬垂引用,因为这个值将在函数结束时超出作用域。考虑 longest 函数的这个尝试实现,它不会编译:
// 文件名: src/main.rs
// 这段代码无法编译!
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}
在这里,即使我们为返回类型指定了生命周期参数 '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 的结构体,它持有一个字符串切片。
// 文件名: src/main.rs
struct ImportantExcerpt<'a> {
part: &'a str,
}
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,
};
}
清单 10-24:一个持有引用的结构体,需要生命周期标注
这个结构体有一个单独的字段 part,它持有一个字符串切片,这是一个引用。与泛型数据类型一样,我们在结构体名称后面的尖括号中声明泛型生命周期参数的名称,这样我们就可以在结构体定义的主体中使用生命周期参数。这个标注意味着 ImportantExcerpt 的实例不能比它在 part 字段中持有的引用存在更长时间。
这里的 main 函数创建了一个 ImportantExcerpt 结构体的实例,它持有对由变量 novel 拥有的 String 的第一句的引用。novel 中的数据在创建 ImportantExcerpt 实例之前就存在了。此外,novel 直到 ImportantExcerpt 超出作用域之后才会超出作用域,所以 ImportantExcerpt 实例中的引用是有效的。
生命周期省略
你已经了解到每个引用都有一个生命周期,并且你需要为使用引用的函数或结构体指定生命周期参数。但是,我们有一个在清单 4-9 中的函数,如清单 10-25 再次所示,它没有生命周期标注也能编译。
// 文件名: src/lib.rs
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
清单 10-25:我们在清单 4-9 中定义的一个函数,即使参数和返回类型是引用,也没有生命周期标注却能编译
这个函数能在没有生命周期标注的情况下编译的原因是历史性的:在 Rust 的早期版本(1.0 之前),这段代码不会编译,因为每个引用都需要显式的生命周期。当时,函数签名会写成这样:
fn first_word<'a>(s: &'a str) -> &'a str {
在编写了大量 Rust 代码之后,Rust 团队发现 Rust 程序员在特定情况下反复输入相同的生命周期标注。这些情况是可预测的,并且遵循一些确定性的模式。开发人员将这些模式编入编译器代码中,这样借用检查器就可以在这些情况下推断生命周期,而不需要显式标注。
这段 Rust 历史是相关的,因为可能会出现更多的确定性模式,并被添加到编译器中。将来,可能需要更少的生命周期标注。
Rust 对引用分析的模式称为生命周期省略规则(lifetime elision rules)。这些不是程序员要遵循的规则;它们是编译器会考虑的一组特定情况,如果你的代码符合这些情况,你就不需要显式地编写生命周期。
省略规则不提供完全的推断。如果在 Rust 应用规则之后,仍然不清楚引用的生命周期应该是什么,编译器不会猜测剩余引用的生命周期。相反,编译器会给出一个错误,你可以通过添加生命周期标注来解决。
函数或方法参数上的生命周期称为输入生命周期,返回值上的生命周期称为输出生命周期。
当没有显式标注时,编译器使用三个规则来确定引用的生命周期。第一个规则适用于输入生命周期,第二个和第三个规则适用于输出生命周期。如果编译器在三个规则结束时仍然有无法确定生命周期的引用,编译器将停止并报错。这些规则适用于 fn 定义以及 impl 块。
第一个规则是,编译器为每个作为引用的参数分配一个生命周期参数。换句话说,一个有一个参数的函数得到一个生命周期参数:fn foo<'a>(x: &'a i32);一个有两个参数的函数得到两个单独的生命周期参数:fn foo<'a, 'b>(x: &'a i32, y: &'b i32);依此类推。
第二个规则是,如果有 exactly 一个输入生命周期参数,那么这个生命周期被分配给所有输出生命周期参数: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 {
你可以看到第二个规则不适用,因为有多个输入生命周期。第三个规则也不适用,因为 longest 是一个函数而不是一个方法,所以没有参数是 self。在应用了所有三个规则之后,我们仍然没有弄清楚返回类型的生命周期是什么。这就是为什么我们尝试编译清单 10-20 中的代码时会得到一个错误:编译器执行了生命周期省略规则,但仍然无法弄清楚签名中引用的所有生命周期。
因为第三个规则实际上只适用于方法签名,接下来我们将看看在这种情况下的生命周期,以了解为什么第三个规则意味着我们不需要经常在方法签名中标注生命周期。
方法定义中的生命周期标注
当我们在具有生命周期的结构体上实现方法时,我们使用与泛型类型参数相同的语法,如清单 10-11 所示。我们声明和使用生命周期参数的位置取决于它们是否与结构体字段或方法参数和返回值相关。
结构体字段的生命周期名称总是需要在 impl 关键字之后声明,然后在结构体名称之后使用,因为这些生命周期是结构体类型的一部分。
在 impl 块内的方法签名中,引用可能与结构体字段中的引用的生命周期相关联,或者它们可能是独立的。此外,生命周期省略规则通常使得方法签名中不需要生命周期标注。让我们看一些使用我们在清单 10-24 中定义的 ImportantExcerpt 结构体的例子。
首先,我们将使用一个名为 level 的方法,它唯一的参数是对 self 的引用,其返回值是 i32,它不是对任何东西的引用:
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
impl 后面的生命周期参数声明和类型名称后面的使用是必需的,但由于第一个省略规则,我们不需要标注对 self 的引用的生命周期。
这是第三个生命周期省略规则适用的例子:
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 生命周期,我们可以这样标注:
let s: &'static str = "I have a static lifetime.";
这个字符串的文本直接存储在程序的二进制文件中,它总是可用的。因此,所有字符串字面量的生命周期都是'static。
你可能会在错误消息中看到使用'static 生命周期的建议。但在为引用指定'static 作为生命周期之前,要考虑你拥有的引用是否实际上在程序的整个生命周期中存在,以及你是否希望它这样。大多数时候,建议使用'static 生命周期的错误消息是由于试图创建悬垂引用或可用生命周期不匹配造成的。在这种情况下,解决方案是修复这些问题,而不是指定'static 生命周期。
泛型类型参数、特性约束和生命周期一起使用
让我们简要看看在一个函数中同时指定泛型类型参数、特性约束和生命周期的语法!
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,T 可以被任何实现了 Display 特性的类型填充,如 where 子句所指定的。这个额外的参数将使用 {} 来打印,这就是为什么需要 Display 特性约束的原因。因为生命周期是一种泛型,生命周期参数 'a 和泛型类型参数 T 的声明放在函数名称后面的尖括号内的同一个列表中。
总结
我们在本章涵盖了很多内容!现在你了解了泛型类型参数、特性和特性约束以及泛型生命周期参数,你已经准备好编写没有重复且在许多不同情况下都能工作的代码了。泛型类型参数让你可以将代码应用于不同的类型。特性和特性约束确保即使类型是泛型的,它们也会有代码所需的行为。你学习了如何使用生命周期标注来确保这种灵活的代码不会有任何悬垂引用。所有这些分析都发生在编译时,这不会影响运行时性能!
信不信由你,关于我们在本章讨论的主题还有很多要学习的:第 18 章讨论特性对象,这是使用特性的另一种方式。还有一些涉及生命周期标注的更复杂的场景,你只在非常高级的场景中才需要;对于这些,你应该阅读《Rust 参考手册》。但接下来,你将学习如何在 Rust 中编写测试,以确保你的代码按预期工作。
更多推荐
所有评论(0)