上一篇: 09-错误处理
每种编程语言都有有效处理概念重复的工具。在 Rust 中,泛型就是这样一种工具:具体类型或其他属性的抽象替身。我们可以表达泛型的行为或它们与其他泛型的关系,而不需要知道在编译和运行代码时它们的位置。
函数可以接受一些泛型类型的参数,而不是像 i32 或 String 这样的具体类型,就像函数接受未知值的参数一样,在多个具体值上运行相同的代码。事实上,我们已经在第 6 章的 Option<T> 、第 8 章的 Vec<T> 和 HashMap<K, V> 以及第 9 章的 Result<T, E> 中使用了泛型。本章将探讨如何使用泛型定义自己的类型、函数和方法!
首先,我们将回顾如何提取函数以减少代码重复。然后,我们将使用相同的技术,从两个仅在参数类型上有所不同的函数中创建一个泛型函数。我们还将讲解如何在结构体和枚举定义中使用泛型。
然后,您将学习如何使用特性以泛型方式定义行为。您可以将特质与泛型结合起来,限制泛型只接受具有特定行为的类型,而不是任何类型。
最后,我们将讨论生命周期:生命周期是泛型的一种,它为编译器提供了关于引用如何相互关联的信息。生命周期允许我们为编译器提供足够多的关于借用值的信息,这样编译器就能确保引用在更多情况下有效,而不需要我们的帮助。
通过提取函数消除重复
泛型允许我们用代表多种类型的占位符替换特定类型,从而消除代码重复。在深入研究泛型语法之前,让我们先看看如何通过提取一个函数,用一个代表多个值的占位符替换特定值,从而以一种不涉及泛型类型的方式消除重复。然后,我们将运用同样的技术提取一个泛型函数!通过观察如何识别可以提取到函数中的重复代码,你将开始识别可以使用泛型的重复代码。
我们从清单 10-1 中查找列表中最大数字的简短程序开始。
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 应指向最大的数字,在本例中就是 100。
我们现在的任务是找出两个不同数字列表中最大的数字。为此,我们可以复制清单 10-1 中的代码,在程序的两个不同位置使用相同的逻辑,如清单 10-2 所示。
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 值的任何其他列表中使用该函数。
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 的步骤:
①. 识别重复代码。
②. 将重复代码提取到函数体中,并在函数签名中指定代码的输入和返回值。
③. 更新两个重复代码实例,以调用该函数。
接下来,我们将在泛型中使用相同的步骤来减少代码的重复。与函数体可以对抽象 list 而不是具体值进行操作一样,泛型允许代码对抽象类型进行操作。
例如,我们有两个函数:一个用于查找 i32 值片段中最大的项目,另一个用于查找 char 值片段中最大的项目。我们该如何消除重复呢?让我们来看看!
10.1 通用数据类型
我们使用泛型为函数签名或结构体等项目创建定义,然后将其用于多种不同的具体数据类型。首先,我们来看看如何使用泛型定义函数、结构体、枚举和方法。然后,我们将讨论泛型如何影响代码性能。
10.1.1 在函数定义中
在定义使用泛型的函数时,我们通常会将泛型放在函数签名中指定参数和返回值数据类型的地方。这样做可以使我们的代码更加灵活,为函数的调用者提供更多的功能,同时防止代码重复。
继续我们的 largest 函数,清单 10-4 显示了两个函数,它们都能找到切片中的最大值。然后,我们将把这两个函数合并为一个使用泛型的函数。
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 的类型命名惯例是大写。 T 是 "类型 "的简称,是大多数 Rust 程序员的默认选择。
当我们在函数体中使用参数时,必须在签名中声明参数名,以便编译器知道该参数名的含义。同样,当我们在函数签名中使用类型参数名时,也必须在使用前声明类型参数名。要定义泛型 largest 函数,请将类型名声明放在函数名和参数列表之间的角括号 <> 内,如下所示:
fn largest<T>(list: &[T]) -> &T {
我们把这个定义理解为:函数 largest 是某个类型 T 的泛型。该函数有一个名为 list 的参数,它是 T 类型值的片段。 largest 函数将返回一个同类型值的引用 T 。
清单 10-5 显示了在签名中使用通用数据类型的组合 largest 函数定义。该清单还显示了我们如何使用 i32 值的片段或 char 值调用该函数。请注意,这段代码还不能编译,但我们将在本章稍后部分解决这个问题。
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.exe build
Compiling generic_type v0.1.0 (E:\rustProj\generic_type)
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`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `generic_type` (bin "generic_type") due to previous error
帮助文本中提到了 std::cmp::PartialOrd ,它是一个特质,我们将在下一节讨论特质。现在,我们要知道,这个错误说明 largest 的正文不能用于 T 可能是的所有类型。因为我们要在正文中比较 T 类型的值,所以只能使用其值可以排序的类型。为了实现比较,标准库提供了 std::cmp::PartialOrd 特性,可以在类型上实现该特性(有关该特性的更多信息,请参见附录 C)。按照帮助文档的建议,我们将 T 的有效类型限制为只实现了 PartialOrd 的类型,这个示例就可以编译了,因为标准库在 i32 和 char 上都实现了 PartialOrd 。
10.1.2 在结构定义中
我们还可以使用 <> 语法定义结构体,以便在一个或多个字段中使用通用类型参数。清单 10-6 定义了一个 Point<T> 结构,用于保存任何类型的 x 和 y 坐标值。
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:保存 x 和 y 类型值的 Point<T> 结构 T)
在结构体定义中使用泛型的语法与在函数定义中使用的语法类似。首先,我们在结构体名称后的角括号内声明类型参数的名称。然后,我们在结构体定义中使用泛型,否则就会指定具体的数据类型。
请注意,由于我们只使用了一种泛型类型来定义 Point<T> ,因此该定义表示 Point<T> 结构是某个类型 T 的泛型,而字段 x 和 y 都是同一类型,无论该类型是什么。如果我们创建的 Point<T> 实例具有不同类型的值,如清单 10-7 所示,我们的代码将无法编译。
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 时(我们已将 定义为与 x 具有相同的类型),就会出现如下类型不匹配错误:
cargo.exe build
Compiling generic_type v0.1.0 (E:\rustProj\generic_type)
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 `generic_type` (bin "generic_type") due to previous error
要定义一个 Point 结构,其中 x 和 y 都是泛型,但可能有不同的类型,我们可以使用多个泛型类型参数。例如,在清单 10-8 中,我们修改了 Point 的定义,使其成为 T 和 U 类型的泛型,其中 x 属于 T 类型,而 y 属于 U 类型。
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:两个类型的 Point<T, U> 泛型,使 x 和 y 可以是不同类型的值)
现在可以使用 Point 的所有实例了!您可以在定义中使用任意多的泛型参数,但使用多了会使代码难以阅读。如果你发现你的代码中需要大量的泛型类型,这可能表明你的代码需要重组成更小的片段。
10.1.3 在枚举定义中
与结构体一样,我们可以定义枚举来保存通用数据类型的变体。让我们再看看标准库提供的 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 类型。
如果您发现代码中存在多个结构或枚举定义,而这些结构或枚举的不同之处仅在于其所持值的类型,那么您可以使用泛型来避免重复。
10.1.4 在方法定义中
我们可以在结构体和枚举上实现方法(正如我们在第 5 章中所做的),也可以在它们的定义中使用泛型类型。清单 10-9 显示了我们在清单 10-6 中定义的 Point<T> 结构,以及在该结构上实现的名为 x 的方法。
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> 实例上实现方法,而不是在具有任何泛型类型的 Point<T> 实例上实现方法。在清单 10-10 中,我们使用了具体类型 f32 ,这意味着我们在 impl 之后没有声明任何类型。
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
(清单 10-10: impl 代码块,仅适用于通用类型参数为特定具体类型的结构体 T)
这段代码意味着 Point<f32> 类型将有一个 distance_from_origin 方法; Point<T> 的其他实例,如果 T 不属于 f32 类型,则不会定义此方法。该方法用于测量我们的点与坐标 (0.0, 0.0) 处的点之间的距离,并使用只有浮点类型才有的数学运算。
结构体定义中的泛型参数并不总是与同一结构体的方法签名中使用的参数相同。为使示例更清晰,清单 10-11 在 Point 结构中使用了通用类型 X1 和 Y1 ,在 mixup 方法签名中使用了通用类型 X2 Y2 。该方法使用 self Point 的 x 值(类型为 X1 )和传入的 Point 的 y 值(类型为 Y2 )创建一个新的 Point 实例。
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 ,其中 x 将有一个 i32 ,因为 x 来自 p1 。 p3 y 变量的值为 char ,因为 y 来自 p2 。 println! 宏调用将打印 p3.x = 5, p3.y = c 。
本例的目的是演示这样一种情况:一些泛型参数与 impl 一起声明,而另一些则与方法定义一起声明。在这里,泛型参数 X1 和 Y1 在 impl 之后声明,因为它们与结构体定义一起声明。泛型参数 X2 和 Y2 声明在 fn mixup 之后,因为它们只与方法相关。
10.1.5 使用泛型的代码性能
您可能想知道,使用泛型类型参数是否会产生运行时成本。好消息是,使用泛型不会使程序运行速度比使用具体类型慢。
Rust 通过在编译时使用泛型对代码进行单形态化来实现这一点。单态化是指通过填充编译时使用的具体类型,将泛型代码转化为特定代码的过程。在这个过程中,编译器所做的与我们在清单 10-5 中创建泛型函数的步骤相反:编译器会查看所有调用泛型代码的地方,并为泛型代码所调用的具体类型生成代码。
让我们使用标准库的通用 Option<T> 枚举来看看如何实现这一功能:
let integer = Some(5);
let float = Some(5.0);
Rust 编译这些代码时,会执行单态化处理。在此过程中,编译器会读取 Option<T> 实例中使用的值,并识别出两种 Option<T> :一种是 i32 ,另一种是 f64 。因此,编译器会将 Option<T> 的通用定义扩展为两个专门针对 i32 和 f64 的定义,从而用特定定义取代通用定义。
代码的单态化版本如下(编译器使用的名称与我们在此使用的名称不同,仅供参考):
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 的泛型在运行时极为高效。
10.2 Traits(特质):定义共同行为
特质定义了一个特定类型所具有的、并能与其他类型共享的功能。我们可以使用traits以抽象的方式定义共享行为。我们可以使用traits边界来指定泛型可以是任何具有特定行为的类型。
10.2.1 定义特质
一个类型的行为包括我们可以在该类型上调用的方法。如果我们可以在所有类型上调用相同的方法,那么不同的类型就共享相同的行为。特质定义是一种将方法签名组合在一起的方法,用于定义实现某些目的所需的一系列行为。
例如,假设我们有多个结构体,用于保存不同类型和数量的文本:一个 NewsArticle 结构体用于保存在特定位置归档的新闻报道,一个 Tweet 结构体最多可保存 280 个字符,并附带元数据,以显示这是一条新推文、一条转发推文,还是对另一条推文的回复。
我们想制作一个名为 aggregator 的媒体聚合库 crate,它可以显示可能存储在 NewsArticle 或 Tweet 实例中的数据摘要。为此,我们需要每种类型的摘要,并通过调用实例的 summarize 方法来请求摘要。清单 10-12 显示了表达这种行为的公共 Summary 特性的定义。
pub trait Summary {
fn summarize(&self) -> String;
}
(清单 10-12:由 summarize 方法提供的行为组成的 Summary 特质)
在这里,我们使用 trait 关键字声明一个特质,然后再声明特质的名称,在本例中就是 Summary 。我们还将该特质声明为 pub ,这样依赖于该特质的板条箱也可以使用该特质,我们将在几个示例中看到这一点。在大括号中,我们声明了方法签名,这些签名描述了实现该特质的类型的行为,在本例中是 fn summarize(&self) -> String 。
在方法签名后,我们使用了分号,而不是在大括号内提供实现。实现此特征的每个类型都必须为方法的主体提供自己的自定义行为。编译器会强制要求任何具有 Summary 特质的类型使用该签名定义的方法 summarize 。
一个特质的主体中可以有多个方法:方法签名每行列出一个,每行以分号结束。
10.2.2 在类型上实现特质
现在我们已经定义了 Summary 特征方法的所需签名,可以在媒体聚合器中的类型上实现该特征了。清单 10-13 显示了 Summary trait 在 NewsArticle 结构上的实现,它使用标题、作者和位置来创建 summarize 的返回值。对于 Tweet 结构,我们将 summarize 定义为用户名,然后是推文的全文,假设推文内容已限制为 280 个字符。
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 Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
(清单 10-13:在 NewsArticle 和 Tweet 类型上实现 Summary 特性)
在类型上实现特质与实现常规方法类似。不同的是,在 impl 之后,我们要输入要实现的特质名称,然后使用 for 关键字,再指定要实现特质的类型名称。在 impl 代码块中,我们要写上特质定义的方法签名。我们不在每个签名后添加分号,而是使用大括号,并在方法正文中填写我们希望特质的方法针对特定类型的具体行为。
既然库已在 NewsArticle 和 Tweet 上实现了 Summary 特性,那么板块的用户就可以像调用常规方法一样,在 NewsArticle 和 Tweet 的实例上调用特性方法。唯一的区别是,用户必须将特质和类型同时纳入作用域。下面是一个二进制crate如何使用 aggregator 库板条箱的示例:
use aggregator::{Summary, Tweet};
fn main() {
let tweet = Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
};
println!("1 new tweet: {}", tweet.summarize());
}
该代码可打印: 1 new tweet: horse_ebooks: of course, as you probably already know, people 。
依赖于 aggregator 的其他板块也可以将 Summary 特性纳入作用域,在自己的类型上实现 Summary 。需要注意的一个限制是,只有在特质或类型中至少有一个是我们的板块本地的情况下,我们才能在类型上实现特质。例如,我们可以在 Tweet 这样的自定义类型上实现 Display 这样的标准库特征,作为我们 aggregator crate 功能的一部分,因为 Tweet 类型是我们 aggregator crate 的本地类型。我们也可以在 aggregator crate 中的 Vec<T> 上实现 Summary ,因为特征 Summary 是我们 aggregator crate 的本地特征。
但我们不能在外部类型上实现外部特质。例如,我们不能在 aggregator crate 中实现 Vec<T> 上的 Display 特性,因为 Display 和 Vec<T> 都是在标准库中定义的,并不在 aggregator crate 中。这一限制是一致性属性的一部分,更确切地说,是孤儿规则的一部分。该规则确保其他人的代码不会破坏你的代码,反之亦然。如果没有这条规则,两个create可以为同一类型实现相同的特质,而 Rust 却不知道该使用哪种实现。
10.2.3 默认实现
有时,为特质中的部分或全部方法设置默认行为,而不是要求在每种类型中实现所有方法,是非常有用的。这样,当我们在特定类型上实现特质时,就可以保留或覆盖每个方法的默认行为。
在清单 10-14 中,我们为 Summary 特质的 summarize 方法指定了一个默认字符串,而不是像清单 10-12 中那样只定义方法签名。
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
(清单 10-14:使用 summarize 方法的默认实现定义 Summary 特质)
要使用默认实现来汇总 NewsArticle 的实例,我们用 impl Summary for NewsArticle {} 指定一个空的 impl 块。
尽管我们不再直接在 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 中 Summary on Tweet 的任何实现。原因是覆盖默认实现的语法与实现没有默认实现的特质方法的语法相同。
默认实现可以调用同一特质中的其他方法,即使这些方法没有默认实现。这样,一个特质可以提供大量有用的功能,而只需要实现者指定其中的一小部分。例如,我们可以在 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 Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
定义 summarize_author 后,我们可以在 Tweet 结构的实例上调用 summarize ,而 summarize 的默认实现将调用我们提供的 summarize_author 的定义。因为我们已经实现了 summarize_author ,所以 Summary 特质为我们提供了 summarize 方法的行为,而不需要我们编写更多代码。
let tweet = Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
};
println!("1 new tweet: {}", tweet.summarize());
这段代码将打印 1 new tweet: (Read more from @horse_ebooks...) 。
请注意,从同一方法的重载实现中调用默认实现是不可能的。
10.2.4 特质作为参数
现在您已经知道如何定义和实现特质,我们可以探索如何使用特质来定义接受多种不同类型的函数。我们将使用清单 10-13 中在 NewsArticle 和 Tweet 类型上实现的 Summary 特性来定义一个 notify 函数,该函数会调用 item 参数上的 summarize 方法,而 参数的类型实现了 Summary 特性。为此,我们使用 impl Trait 语法,如下所示:
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
在 item 参数中,我们指定的不是具体类型,而是impl 关键字和特质名称。该参数可接受任何实现指定特质的类型。在 notify 的正文中,我们可以调用 item 上来自 Summary 特征的任何方法,如 summarize 。我们可以调用 notify 并传入 NewsArticle 或 Tweet 的任何实例。使用其他类型(如 String 或 i32 )调用函数的代码将无法编译,因为这些类型没有实现 Summary 。
10.2.4.1 特质绑定语法
impl Trait 语法适用于简单的情况,但实际上是一种称为特质绑定的较长形式的语法糖;它看起来像这样:
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 参数传递的值的具体类型必须相同。
10.2.4.2 使用 + 语法指定多个特质界限
我们还可以指定多个特性绑定。假设我们希望 notify 和 summarize 一样在 item 上使用显示格式:我们在 notify 定义中指定 item 必须同时实现 Display 和 Summary 。我们可以使用 + 语法这样做:
pub fn notify(item: &(impl Summary + Display)) {
+ 语法也适用于泛型的特质界限:
pub fn notify<T: Summary + Display>(item: &T) {
在指定了两个特质边界后, notify 的主体可以调用 summarize ,并使用 {} 来格式化 item 。
10.2.4.3 where 条款的特征界限更清晰
使用过多的特性边界也有其弊端。每个泛型都有自己的 trait bounds,因此带有多个泛型参数的函数可能会在函数名和参数列表之间包含大量 trait bounds 信息,导致函数签名难以阅读。因此,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,
{
这个函数的签名没有那么杂乱:函数名、参数列表和返回类型紧挨着,类似于一个没有大量特质边界的函数。
10.2.5 返回实现特质的类型
我们还可以在返回位置使用 impl Trait 语法,返回实现了特质的某种类型的值,如下所示:
fn returns_summarizable() -> impl Summary {
Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
}
}
通过使用 impl Summary 作为返回类型,我们指定 returns_summarizable 函数返回某种实现 Summary 特性的类型,而不指明具体类型。在本例中, returns_summarizable 返回一个 Tweet ,但调用该函数的代码并不需要知道这一点。
在闭包和迭代器的上下文中,仅通过其实现的性状来指定返回类型的能力尤其有用,我们将在第 13 章中介绍这一点。闭包和迭代器会创建只有编译器知道的类型,或者指定起来非常冗长的类型。 impl Trait 语法可以让你简洁地指定函数返回某个实现了 Iterator 特性的类型,而不需要写出很长的类型。
但是,只有在返回单一类型时才能使用 impl Trait 。例如,返回 NewsArticle 或 Tweet 的代码,如果返回类型指定为 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 {
Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
}
}
}
由于编译器在实现 impl Trait 语法时受到限制,因此不允许返回 NewsArticle 或 Tweet 。我们将在第 17 章 "使用允许不同类型值的特质对象 "一节中介绍如何编写具有这种行为的函数。
10.2.6 使用特质界限有条件地实现方法
通过将特质与使用通用类型参数的 impl 块绑定,我们可以有条件地为实现指定特质的类型实现方法。例如,清单 10-15 中的 Pair<T> 类型总是实现 new 函数,以返回 Pair<T> 的新实例(请回顾第 5 章 "定义方法 "部分, Self 是 impl 代码块类型的类型别名,在本例中是 Pair<T> )。但在下一个 impl 代码块中,只有当 Pair<T> 的内部类型 T 实现了实现比较的 PartialOrd 特性和实现打印的 Display 特性时,它才会实现 cmp_display 方法。
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:根据特质边界有条件地在泛型上实现方法)
我们还可以有条件地为任何实现了另一个特质的类型实现特质。在任何满足特质边界的类型上对特质的实现都被称为空白实现,在 Rust 标准库中被广泛使用。例如,标准库在任何实现了 Display 特性的类型上实现了 ToString 特性。标准库中的 impl 代码块与此代码类似:
impl<T: Display> ToString for T {
// --snip--
}
由于标准库具有这种一揽子实现,我们可以在任何实现 Display 特质的类型上调用 ToString 特质定义的 to_string 方法。例如,我们可以像这样将整数转化为相应的 String 值,因为整数实现了 Display :
let s = 3.to_string();
一揽子实现会出现在 "实现者 "部分的特质文档中。
特质和特质边界让我们可以编写使用泛型参数的代码,以减少重复,同时也向编译器说明我们希望泛型具有特定的行为。然后,编译器就可以使用特质绑定信息来检查与我们的代码一起使用的所有具体类型是否提供了正确的行为。在动态类型语言中,如果我们在没有定义方法的类型上调用方法,就会在运行时出错。但 Rust 将这些错误转移到了编译时,因此我们不得不在代码运行之前就解决这些问题。此外,我们不必编写在运行时检查行为的代码,因为我们已经在编译时检查过了。这样做既能提高性能,又不必放弃泛型的灵活性。
10.3 用生命周期验证引用
生命周期是我们已经使用过的另一种泛型。生命周期不是确保类型具有我们想要的行为,而是确保引用在我们需要的时间内有效。
我们在第 4 章 "引用和借用 "一节中没有讨论的一个细节是,Rust 中的每个引用都有一个生命周期,也就是引用有效的范围。大多数情况下,生命周期是隐式和推断的,就像大多数情况下,类型是推断的一样。只有在可能存在多种类型时,我们才必须注解类型。同样,当引用的生命周期可能以多种不同方式相关联时,我们也必须注释生命周期。Rust 要求我们使用通用生命周期参数注解这些关系,以确保运行时使用的实际引用肯定有效。
注解生命周期并不是大多数其他编程语言都有的概念,因此会让人感觉很陌生。虽然我们不会在本章中全面介绍生命周期,但我们会讨论你可能会遇到的生命周期语法的常见方式,以便你能适应这个概念。
10.3.1 用生命周期防止悬空引用
生命周期的主要目的是防止悬挂引用,因为悬挂引用会导致程序引用它想要引用的数据以外的数据。请看清单 10-16 中的程序,它有一个外作用域和一个内作用域。
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {}", r);
}
(清单 10-16:试图使用其值已超出作用域的引用)
外作用域声明了一个没有初始值的名为 r 的变量,内作用域声明了一个初始值为 5 的名为 x 的变量。在内部作用域中,我们尝试将 r 的值设置为对 x 的引用。然后内作用域结束,我们尝试打印 r 中的值。这段代码无法编译,因为 r 所引用的值在我们尝试使用它之前已经超出了作用域。下面是错误信息:
cargo.exe build
Compiling life_times v0.1.0 (E:\rustProj\life_times)
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 `life_times` (bin "life_times") due to previous error
变量 x 的 "寿命 "不够长。原因是当内作用域在第 7 行结束时, x 将退出作用域。但 r 对外部作用域仍然有效;因为它的作用域更大,所以我们说它 "活得更长"。如果 Rust 允许这段代码工作,那么 r 就会引用在 x 退出作用域时被重新分配的内存,我们试图用 r 所做的任何事情都将无法正常工作。那么 Rust 是如何判断这段代码无效的呢?它使用了借用检查器。
10.3.2 借用检查器
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 如何分析生命周期以确保引用始终有效,下面让我们在函数的上下文中探讨参数和返回值的通用生命周期。
10.3.3 函数中的通用寿命
我们将编写一个函数,返回两个字符串片中较长的一个。该函数将接收两个字符串片段并返回一个字符串片段。实现 longest 函数后,清单 10-19 中的代码应打印 The longest string is abcd 。
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:调用 longest 函数查找两个字符串片段中较长字符串的 main 函数)
请注意,我们希望函数使用字符串片段(即引用)而不是字符串,因为我们不希望 longest 函数拥有其参数的所有权。请参阅第 4 章中的 "作为参数的字符串片段 "一节,了解更多关于为什么清单 10-19 中使用的参数是我们想要的参数的讨论。
如果我们尝试实现清单 10-20 中所示的 longest 函数,它将无法编译。
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
(清单 10-20: longest 函数的实现,返回两个字符串片段中较长者,但尚未编译)
相反,我们会收到以下关于生命周期的错误信息:
cargo.exe build
Compiling life_times v0.1.0 (E:\rustProj\life_times)
error[E0106]: missing lifetime specifier
--> src\main.rs:1:33
|
1 | 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
|
1 | 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 `life_times` (bin "life_times") due to previous error
帮助文本显示,返回类型需要一个通用生命周期参数,因为 Rust 无法判断返回的引用是指向 x 还是 y 。实际上,我们也不知道,因为该函数主体中的 if 代码块返回的是对 x 的引用,而 else 代码块返回的是对 y 的引用!
在定义此函数时,我们不知道将传入此函数的具体值,因此不知道是执行 if 情况还是执行 else 情况。我们也不知道将传入的引用的具体生命周期,因此无法像在列表 10-17 和 10-18 中那样查看作用域,以确定我们返回的引用是否始终有效。借用检查程序也无法确定这一点,因为它不知道 x 和 y 的生命周期与返回值的生命周期之间的关系。为了解决这个错误,我们将添加通用生命周期参数,定义引用之间的关系,以便借用检查器进行分析。
10.3.4 生命周期注释语法
生命周期注解不会改变任何引用的生命周期。相反,它们在不影响生命周期的情况下描述了多个引用之间的生命周期关系。正如当签名指定了通用类型参数时,函数可以接受任何类型,通过指定通用生命周期参数,函数也可以接受任何生命周期的引用。
生命周期注解的语法略有不同:生命周期参数的名称必须以撇号( ' )开头,而且通常都是小写,非常简短,就像通用类型一样。大多数人使用 'a 作为第一个生命周期注解的名称。我们将生命周期参数注释放在引用的 & 之后,用空格将注释与引用的类型分开。
下面是一些示例:对 i32 的引用不带生命周期参数,对 i32 的引用带有名为 'a 的生命周期参数,对 i32 的可变引用也带有生命周期 'a 。
&i32 // a reference
&'a i32 // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime
一个 lifetime 注释本身并没有太大意义,因为这些注解旨在告诉 Rust 多个引用的通用 lifetime 参数如何相互关联。让我们来看看在 longest 函数的上下文中,lifetime 注解是如何相互关联的。
10.3.5 函数签名中的生命周期注解
要在函数签名中使用生命周期注解,我们需要在函数名和参数列表之间的角括号内声明泛型生命周期参数,就像使用泛型类型参数一样。
我们希望签名能表达以下约束:只要两个参数都有效,返回的引用就有效。这就是参数的生命周期与返回值之间的关系。我们将把生命周期命名为 'a ,然后将其添加到每个引用中,如清单 10-21 所示。
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,函数返回的字符串片段的生命周期至少与 lifetime 相同 'a 。实际上,这意味着 longest 函数返回的引用的生命周期与函数参数引用的值的生命周期中较小者相同。我们希望 Rust 在分析这段代码时使用这些关系。
请记住,当我们在此函数签名中指定生命周期参数时,我们并没有改变任何传入或返回值的生命周期。相反,我们是在指定借用检查器应拒绝任何不符合这些约束条件的值。请注意, longest 函数并不需要确切知道 x 和 y 的生命周期,只需要知道可以替换 'a 的某个作用域满足此签名即可。
在函数中注解生命周期时,注解应放在函数签名中,而不是函数体中。生命周期注解会成为函数契约的一部分,就像签名中的类型一样。让函数签名包含生命周期契约,意味着 Rust 编译器所做的分析可以更简单。如果函数的注解方式或调用方式有问题,编译器的错误可以更精确地指出我们代码中的这部分内容和约束。相反,如果 Rust 编译器更多地推断我们希望生命周期之间的关系是什么,那么编译器可能只能指出我们代码中的某个用法与问题的起因相去甚远。
当我们向 longest 传递具体引用时,取代 'a 的具体生命周期是 x 的作用域与 y 的作用域重叠的部分。换句话说,通用生命周期 'a 将得到的具体生命周期等于 x 和 y 的生命周期中较小的生命周期。由于我们用相同的生命周期参数 'a 对返回的引用进行了注解,因此返回的引用在 x 和 y 的生命周期中较小的生命周期内也是有效的。
让我们看看生命周期注解如何通过传递具有不同具体生命周期的引用来限制 longest 函数。清单 10-22 是一个简单明了的示例。
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 中的代码将无法编译。
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.exe build
Compiling life_times v0.1.0 (E:\rustProj\life_times)
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 `life_times` (bin "life_times") due to previous error
该错误表明,要使 result 对 println! 语句有效, string2 需要在外层作用域结束前有效。Rust 知道这一点,因为我们使用了相同的生命周期参数 'a 来注解函数参数和返回值的生命周期。
作为人类,我们可以查看这段代码,发现 string1 比 string2 长,因此 result 将包含对 string1 的引用。由于 string1 尚未超出范围,因此对 string1 的引用对 println! 语句仍然有效。但是,在这种情况下,编译器看不到引用是有效的。我们已经告诉 Rust,由 longest 函数返回的引用的生命周期与传入的引用的生命周期中较小者相同。因此,借用检查器认为清单 10-23 中的代码可能存在无效引用,因此不允许使用。
尝试设计更多实验,改变传入 longest 函数的引用的值和生命周期,以及如何使用返回的引用。在编译之前,假设你的实验是否能通过借用检查器;然后检查你的假设是否正确!
10.3.6 生命周期的思考
指定生命周期参数的方式取决于函数的实现。例如,如果我们改变 longest 函数的实现,使其始终返回第一个参数,而不是最长的字符串片段,那么我们就不需要为 y 参数指定生命周期。下面的代码可以编译:
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
我们为参数 x 和返回类型指定了生命周期参数 'a ,但没有为参数 y 指定生命周期,因为 y 的生命周期与 x 或返回值的生命周期没有任何关系。
从函数返回引用时,返回类型的生命周期参数必须与其中一个参数的生命周期参数相匹配。如果返回的引用不指向其中一个参数,则必须指向在该函数中创建的值。然而,这将是一个悬空引用,因为该值将在函数结束时退出作用域。请看这个无法编译的 longest 函数的尝试实现:
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}
在这里,即使我们为返回类型指定了生命周期参数 'a ,这个实现也会编译失败,因为返回值的生命周期与参数的生命周期完全无关。下面是我们收到的错误信息:
PS E:\rustProj\life_times> cargo.exe build
Compiling life_times v0.1.0 (E:\rustProj\life_times)
--> src\main.rs:13:5
|
13 | 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`.
warning: `life_times` (bin "life_times") generated 2 warnings
error: could not compile `life_times` (bin "life_times") due to previous error; 2 warnings emitted
问题是 result 会超出范围,并在 longest 函数结束时被清理。我们还试图从函数中返回对 result 的引用。我们无法指定可以改变悬空引用的生命周期参数,Rust 也不允许我们创建悬空引用。在这种情况下,最好的解决方法是返回自有数据类型而不是引用,这样调用函数就有责任清理该值。
归根结底,生命周期语法就是将函数的各种参数和返回值的生命周期连接起来。一旦它们连接起来,Rust 就有足够的信息来允许内存安全的操作,并禁止会产生悬空指针或违反内存安全的操作。
10.3.7 结构体定义中的生命周期注解
到目前为止,我们定义的结构体都持有自有类型。我们可以定义结构体来保存引用,但在这种情况下,我们需要在结构体定义中的每个引用上添加生命周期注解。清单 10-24 中有一个名为 ImportantExcerpt 的结构,用于保存字符串切片。
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt {
part: first_sentence,
};
}
(清单 10-24:持有引用的结构,需要使用生命周期注解)
该结构体只有一个字段 part ,用于保存字符串片段,即引用。与泛型数据类型一样,我们在结构体名称后的角括号内声明了泛型生命周期参数的名称,这样我们就可以在结构体定义的正文中使用生命周期参数。该注解意味着 ImportantExcerpt 的实例不会超过它在 part 字段中持有的引用。
此处的 main 函数创建了 ImportantExcerpt 结构体的一个实例,该实例持有对变量 novel 所拥有的 String 第一句话的引用。 novel 中的数据在 ImportantExcerpt 实例创建之前就已存在。此外,在 ImportantExcerpt 退出作用域之前, novel 不会退出作用域,因此 ImportantExcerpt 实例中的引用是有效的。
10.3.8 生命周期的消除
我们已经了解到,每个引用都有生命周期,因此需要为使用引用的函数或结构体指定生命周期参数。然而,在第 4 章中,我们在清单 4-9 中使用了一个函数,在清单 10-25 中再次显示了该函数,它在编译时没有使用生命周期注解。
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 中定义的一个函数,虽然参数和返回类型都是引用,但编译时没有使用生命周期注解)
这个函数之所以不使用 lifetime 注释就能编译,是有历史原因的:在 Rust 的早期版本(1.0 之前),这段代码无法编译,因为每个引用都需要明确的 lifetime。当时,函数签名应该是这样写的:
fn first_word<'a>(s: &'a str) -> &'a str {
在编写了大量 Rust 代码后,Rust 团队发现 Rust 程序员在特定情况下会重复输入相同的 lifetime 注释。这些情况是可以预测的,并遵循一些确定性模式。开发人员将这些模式编入编译器的代码中,这样借用检查器就可以推断出这些情况下的生命周期,而不需要显式注解。
这段 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 {
现在这个函数签名中的所有引用都有生命周期,编译器可以继续进行分析,而不需要程序员在这个函数签名中注释生命周期。
让我们来看另一个例子,这次使用的是 longest 函数,在清单 10-20 中开始使用该函数时,它没有生命周期参数:
fn longest(x: &str, y: &str) -> &str {
让我们应用第一条规则:每个参数都有自己的生命周期。这次我们有两个参数,而不是一个,所以我们有两个生命周期:
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
我们可以看到,第二条规则并不适用,因为输入的生命周期不止一个。第三条规则也不适用,因为 longest 是一个函数而不是方法,所以没有一个参数是 self 。在研究了所有三条规则后,我们仍然没有弄清楚返回类型的生命周期是什么。这就是我们在编译清单 10-20 中的代码时出错的原因:编译器执行了生命周期消除规则,但仍无法确定签名中引用的所有生命周期。
因为第三条规则实际上只适用于方法签名,所以接下来我们将在这种情况下查看生命周期,以了解为什么第三条规则意味着我们不必经常在方法签名中注解生命周期。
10.3.9 方法定义中的生命周期注解
当我们在结构体上实现具有生命周期的方法时,使用的语法与清单 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 的生命周期,所有生命周期都已计算在内。
10.3.10 静态生命周期
我们需要讨论的一个特殊生命周期是 'static ,它表示受影响的引用可以在程序的整个持续时间内存在。所有字符串字面量都有 'static 的生命周期,我们可以将其注释如下:
let s: &'static str = "I have a static lifetime.";
该字符串的文本直接存储在程序的二进制文件中,随时可用。因此,所有字符串文字的生命周期都是 'static 。
你可能会在错误信息中看到使用 'static 生命周期的建议。但是,在指定 'static 作为引用的生命周期之前,请考虑一下您所引用的引用是否在程序的整个生命周期中都有效,以及您是否希望它在整个生命周期中都有效。大多数情况下,提示 'static 生命周期的错误信息是由于试图创建悬空引用或可用生命周期不匹配造成的。在这种情况下,解决办法是解决这些问题,而不是指定 'static 生命周期。
10.3.11 将通用类型参数、特质界限和生命周期结合在一起
让我们简单了解一下在一个函数中指定泛型参数、特质边界和生命周期的语法!
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 ,可以由 where 子句指定的任何实现 Display 特性的类型来填充。这个额外的参数将使用 {} 打印,这就是为什么需要使用 Display 特性绑定的原因。由于生命周期是泛型的一种类型,因此生命周期参数 'a 和泛型类型参数 T 的声明应放在同一个列表中,置于函数名后的角括号内。
本章讨论的主题还有更多值得学习的地方:第 17 章将讨论特质对象,这是使用特质的另一种方法。还有一些涉及生命周期注解的更复杂的场景,只有在非常高级的场景中才会用到;关于这些,你应该阅读《Rust Reference》。接下来,你将学习如何在 Rust 中编写测试,这样就能确保你的代码以应有的方式运行。
下一篇: 11-编写自动化测试