十、泛型、Traits和生命周期
每种编程语言都有有效处理概念重复的工具。在Rust中,一个这样的工具就是泛型:具体类型或其他属性的抽象替身。我们可以表达泛型的行为,或者它们如何与其他泛型相关联,而不知道在编译和运行代码时它们的位置会是什么。
函数可以接受某种泛型类型的形参,而不是i32或String等具体类型的形参,这与函数接受带有未知值的形参以在多个具体值上运行相同代码的方式相同。事实上,我们已经在第6章使用了Option<T>
泛型,第8章使用了Vec<T>
和HashMap<K, V>
,第9章使用了Result<T, E>
泛型。在本章中,您将探索如何使用泛型定义自己的类型、函数和方法!
首先,我们将回顾如何提取函数以减少代码重复。然后,我们将使用相同的技术从两个仅在形参类型上不同的函数生成一个泛型函数。我们还将解释如何在结构和枚举定义中使用泛型类型。
然后,您将学习如何使用特征trait
以通用的方式定义行为。您可以将特征与泛型类型结合起来,以约束泛型类型只接受具有特定行为的类型,而不是只接受任何类型。
最后,我们将讨论生命周期:向编译器提供关于引用之间如何关联的各种泛型。生命周期允许我们向编译器提供关于借用值的足够信息,以便它能够确保引用在更多的情况下是有效的,而不是在没有我们帮助的情况下。
通过提取函数去除重复
泛型允许我们用表示多个类型的占位符替换特定类型,以消除代码重复。那么,在深入研究泛型语法之前,让我们先看看如何通过提取一个用表示多个值的占位符替换特定值的函数,以一种不涉及泛型类型的方式删除重复。然后我们将应用相同的技术来提取泛型函数!通过了解如何识别可以提取到函数中的重复代码,您将开始识别可以使用泛型的重复代码。
我们从清单10-1中的短程序开始,该程序查找列表中最大的数字。
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);
}
我们在变量number_list
中存储整数列表,并将列表中的第一个数字引用在一个名为最大变量的变量中。然后,我们遍历列表中的所有数字,如果当前数字大于存储在最大的数字,那么在这个变量中替换引用。然而,如果当前的数字小于或等于迄今所见的最大数字,变量就不会改变,代码就会进入列表中的下一个数字。在考虑了列表中的所有数字之后,最大的数字应该指最多的数字,在这个例子中是100。
我们现在的任务是在两个不同的数字列表中找到最大的数字。为此,我们可以选择在清单10-1中重复代码,并在程序中使用相同的逻辑,如清单10-2所示。
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-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);
}
largest
函数有一个名为list
的参数,它代表了i32
值的任何切片。因此,当我们调用函数时,代码运行于我们传递的特定值上。
综上所述,以下是我们对从清单10-2更改代码到清单10-3的步骤:
1.识别重复的代码。
2.将重复的代码提取到函数的主体中,并在函数签名中指定该代码的输入和返回值。
3.更新重复代码的两个实例来调用函数。
接下来,我们将使用这些相同的步骤来减少代码重复。同样,函数体可以在抽象list
上操作,而不是特定的值,泛型允许代码在抽象类型上操作。
例如,我们有两个函数:一个在i32
值的切片中找到最大的项,一个在一个char
值中找到最大的项。我们如何消除这种重复?让我们来看看!
10.1 泛型(generics)
我们使用泛型来为函数签名或结构体的项创建定义,然后我们可以使用许多不同的具体数据类型。让我们先来看看如何使用泛型定义函数、结构、枚举和方法。然后我们将讨论generics如何影响代码性能。
10.1.1 在函数中定义泛型
当定义一个使用泛型的函数时,我们将泛型放在函数的签名中,我们通常会指定参数和返回值的数据类型。这样做可以使我们的代码更加灵活,并为调用者提供更多的功能,同时防止代码重复。
继续使用我们largest
函数,清单10-4显示了两个函数,它们都找到了一个部分的最大值。然后我们将它们结合成一个使用泛型的单一函数。
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);
assert_eq!(*result, 100);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("The largest char is {}", result);
assert_eq!(*result, 'y');
}
larsig_i32
函数是我们在清单10-3中提取的,它在切片中找到了最大的i32
。largest_char
函数可以在切片中找到最大的char
。函数体具有相同的代码,因此,让我们通过在一个函数中引入泛型类型参数来消除重复。
为了在一个新的单个函数中参数化类型,我们需要命名类型参数,就像我们为函数的值参数所做的一样。您可以使用任何标识符作为一个类型参数名。但我们将使用T
,因为根据惯例,在Rust中类型参数名称是短的,通常只是一个字母,而Rust的类型命名约定是CamelCase。Short for “type”,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);
}
如果我们现在编译这个代码,我们会得到这个错误:
$ 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`
|
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` due to previous error
帮助文本提到std::cmp::PartialOrd
,这是一种trait,我们将在下一节讨论trait。现在,知道这个错误表明largest
不会为所有可能的 T
类型工作。因为我们想要比较T
类型的值,我们只能使用可以排序的值。为了使比较,标准库有你可以在类型上实现std::cmp::PartialOrd
特性(参见附录C更多的关于这个特性)。通过遵循帮助文本的建议,我们限制对T只适用于实现了PartialOrd
的类型,这个示例将编译,因为标准库在i32
和char
上实现了std::cmp::PartialOrd
。
10.1.2 在结构体中定义泛型
我们还可以定义structs,使用<>
语法在一个或多个字段中使用泛型类型参数。清单10-6定义了一个10-6
struct,以持有任何类型的x
和y
坐标值。
10-6
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 };
}
在struct定义中使用generics的语法与在函数定义中使用的语法相似。首先,我们在结构体的名称之后,声明了尖括号内的类型参数的名称。然后我们在struct定义中使用泛型类型,否则我们将指定具体的数据类型。
注意,因为我们只使用了一个通用的类型来定义点<T>
,这个定义说点Point<T>
struct在某些<T>
类型中是通用的,而字段x和y都是相同的类型,不管这种类型是什么。如果我们创建一个Point<T>
的实例,它有不同类型的值,如清单10-7中,我们的代码编译不通过。
10-7
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
为了定义一个Point
结构体,x和y都是泛型的,但可以有不同的类型,我们可以使用多个泛型类型参数。例如,在清单10-8中,我们改变了Point
的定义,在T
和U
的类型中是通用的,在x的类型中是T
和y的类型中是U
。
10-8
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 };
}
现在所有的Point
实例都被允许了!您可以根据您想要的定义在定义中使用许多泛型类型参数,但是使用多个使得您的代码难以读取。如果您发现代码中需要大量的泛型类型,则可以表明您的代码需要重组成更小的部分。
10.1.3 在Enum中定义泛型
正如我们在结构体中所做的那样,我们可以定义enums以在它们的变体中持有通用数据类型。让我们再看一看标准库提供的Option<T>
enum,我们在第六章中使用了它:
enum Option<T> {
Some(T),
None,
}
现在这个定义应该更容易理解了。如你所见 Option<T>
是一个拥有泛型 T
的枚举,它有两个成员:Some
,它存放了一个类型 T
的值,和不存任何值的None
。通过 Option<T>
枚举可以表达有一个可能的值的抽象概念,同时因为 Option<T>
是泛型的,无论这个可能的值是什么类型都可以使用这个抽象。
枚举也可以拥有多个泛型类型。第九章使用过的 Result
枚举定义就是一个这样的例子:
enum Result<T, E> {
Ok(T),
Err(E),
}
Result
枚举有两个泛型类型,T
和 E
。Result
有两个成员:Ok
,它存放一个类型 T
的值,而 Err
则存放
一个类型 E
的值。这个定义使得 Result
枚举能很方便的表达任何可能成功(返回 T
类型的值)也可能失败(返回 E
类型的值)的操作。实际上,这就是我们在示例 9-3 用来打开文件的方式:当成功打开文件的时候,T
对应的是 std::fs::File
类型;而当打开文件出现问题时,E 的值则是 std::io::Error
类型。
当你意识到代码中定义了多个结构体或枚举,它们不一样的地方只是其中的值的类型的时候,不妨通过泛型类型来避免重复。
10.1.4 在方法中的定义泛型
在为结构体和枚举实现方法时(像第五章那样),一样也可以用泛型。示例 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());
}
注意,我们必须在impl
之后声明T,
这样我们就可以使用T
来指定我们在类型Point<T>
上实现方法。通过在impl
后宣布T
为通用类型,Rust可以识别出尖括号中的类型是通用类型,而不是具体类型。我们可以选择这个泛型参数一个不同的名称,而不是在struct定义中声明的泛型参数,但是使用相同的名称是传统的。在一个impl
中编写的方法,它声明泛型类型将在任何类型的实例中定义,不管具体类型最终以通用类型代替。
我们还可以在定义类型的方法时指定对泛型类型的约束。例如,我们可以仅在Point<f32>
实例上实现方法,而不是在Point<T>
实例上实现。在清单10-10中,我们使用具体的f32
类型,这意味着我们不会在impl
之后声明任何类型。
10-10
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
这个代码意味着类型Point<f32>
将有一个remote ce_from_origin
方法;其他T
不是f32
的Point<T>
的不会有这样的方法定义。该方法测量了我们点从坐标(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);
}
本例的目的是演示一些泛型参数以impl
声明的情况,一些是用方法定义声明的。在这里,通用参数X1
和Y1
在impl
之后被声明,因为它们与struct定义有关。在mixup
后,一般参数X2
和Y2
被声明,因为它们只与该方法相关。
10.1.5 使用泛型的代码性能
在使用泛型类型参数时,您可能会想知道是否有运行时的成本。好消息是,使用泛型类型不会使程序运行速度比具体类型更慢。
Rust 通过在编译时进行泛型代码的 单态化
(monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。编译器所做的工作正好与示例 10-5 中我们创建泛型函数的步骤相反。编译器寻找所有泛型代码被调用的位置并使用泛型代码针对具体类型生成代码。让我们看看一个使用标准库中 Option
枚举的例子:
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);
}
通用选项< >
被编译器创建的特定定义替换。因为在每个实例中指定类型的代码被Rust编译成代码,因此我们不会为使用泛型而支付运行时成本。当代码运行时,就像我们用手重复了每个定义,它就会执行它。单态化的过程使Rust的泛型在运行时非常高效。
10.2 Trait:定义共同行为
trait
定义了一个特定类型的功能,可以与其他类型共享。我们可以使用trait
以抽象的方式定义共享行为。我们可以使用trait bounds
来指定一个通用类型可以是任何具有特定行为的类型。
注意:
trait
与其他语言中经常称为接口的特性相似,尽管有一些不同之处。
10.2.1 Defining a Trait
类型的行为包括我们可以调用的方法。如果我们可以在所有这些类型上调用相同的方法,则不同类型共享相同的行为。trait
定义是一种将签名组合在一起来定义一组实现目标所必需的行为的方法。
例如,这里有多个存放了不同类型和数量文本的结构体:结构体 NewsArticle
用于存放发生于世界各地的新闻故事,而结构体 Tweet
最多只能存放 280 个字符的内容,以及像是否转推或是否是对推友的回复这样的元数据。
我们想要创建一个名为 aggregator
的多媒体聚合库crate 用来显示可能储存在 NewsArticle
或 Tweet
实例中的数据的总结。每一个结构体都需要的行为是他们是能够被总结的,这样的话就可以调用实例的summarize
方法来请求总结。示例 10-12 中展示了一个表现这个概念的公有 Summary
trait 的定义:
pub trait Summary {
fn summarize(&self) -> String;
}
这里使用 trait
关键字来声明一个 trait,后面是 trait 的名字,在这个例子中是 Summary
。我们也声明 trait 为 pub
以便依赖这个 crate 的 crate 也可以使用这个 trait,正如我们见过的一些示例一样。在大括号中声明描述实现这个 trait 的类型所需要的行为的方法签名,在这个例子中是fn summarize(&self) −> String
。
**在方法签名之后,我们没有在大括号内提供一个实现,而是使用分号。**实现此特性的每个类型都必须为该方法的主体提供自己的自定义行为。编译器也会确保任何实现 Summary
trait 的类型都拥有与这个签名的定义完全一致的 summarize
方法。
一个trait
可以在有多个方法:方法签名被列为一行,每一行以分号结束。
10.2.2 在类型上实现一个trait
现在我们定义了 Summary
trait 的签名,接着就可以在多媒体聚合库中实现这个类型了。示例 10-13中展示了 NewsArticle
结构体上 Summary
trait 的一个实现,它使用标题、作者和创建的位置作为summarize
的返回值。对于 Tweet
结构体,我们选择将 summarize
定义为用户名后跟推文的全部文本
作为返回值,并假设推文内容已经被限制为 280 字符以内。
10-13
pub trait Summary {
fn summarize(&self) -> String;
}
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)
}
}
在类型上实现 trait
类似于实现与 trait
无关的方法。区别在于 impl
关键字之后,我们提供需要实现 trait
的名称,接着是 for
和需要实现 trait
的类型的名称。在 impl
块中,使用 trait 定义中的方法签名,不过不再后跟分号,而是需要在大括号中编写函数体来为特定类型实现 trait
方法所拥有的行为。
现在库在 NewsArticle
和 Tweet
上实现了Summary trait,crate 的用户可以像调用常规方法一样调用NewsArticle
和 Tweet
实例的 trait
方法了。唯一的区别是 trait
必须和类型一起引入作用域以便使用额外的 trait
方法。这是一个二进制 crate 如何利用 aggregator
库 crate 的例子:
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());
}
This code prints 1 new tweet: horse_ebooks: of course, as you probably already know, people
.
其他依赖 aggregator
crate 的 crate 也可以将 Summar
y 引入作用域以便为其自己的类型实现该 trait
。实现 trait 时需要注意的一个限制是,只有当至少一个 trait
或者要实现 trait
的类型位于 crate 的本地作用域时,才能为该类型实现 trait
。例如,可以为 aggregator
crate 的自定义类型 Tweet
实现如标准库中的 Display
trait,这是因为 Tweet
类型位于 aggregator
crate 本地的作用域中。类似地,也可以在aggregator
crate 中为 Vec<T>
实现 Summary
,这是因为 Summary
trait 位于 aggregator
crate 本地作用域中。
但是不能为外部类型实现外部 trait
。例如,不能在 aggregator
crate 中为 Vec<T>
实现 Display
trait。这是因为 Display
和 Vec<T>
都定义于标准库中,它们并不位于 aggregator
crate 本地作用域中。这个限制是被称为 相干性(coherence)的程序属性的一部分,或者更具体的说是 孤儿规则(orphan rule),其得名于父类型不存在。这条规则确保了其他人编写的代码不会破坏你代码,反之亦然。没有这条规则的话,两个 crate 可以分别对相同类型实现相同的 trait
,而 Rust 将无从得知应该使用哪一个实现。
10.2.3 默认实现
有时,对于某些或所有方法的默认行为是有用的,而不是在每一种类型上都需要实现所有方法的实现。然后,当我们在特定类型上实现特性时,我们可以保留或覆盖每个方法的默认行为。
示例 10-14 中展示了如何为 Summary
trait 的 summarize
方法指定一个默认的字符串值,而不是像示例 10-12 中那样只是定义方法签名:
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
如果想要对 NewsArticle
实例使用这个默认实现,而不是定义一个自己的实现,则可以通过 impl Summary for NewsArticle {}
指定一个空的 impl
块。
虽然我们不再直接为 NewsArticle
定义 summarize
方法了,但是我们提供了一个默认实现并且指定NewsArticle
实现 Summary
trait。因此,我们仍然可以对 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());
为 summarize
创建默认实现并不要求对示例 10-13 中 Tweet
上的 Summary
实现做任何改变。其原因是重写(overriding )一个默认实现的语法与实现没有默认实现的 trait 方法的语法一样。
默认实现允许调用相同 trait
中的其他方法,哪怕这些方法没有默认实现。如此,trait
可以提供很多有用的功能而只需要实现指定一小部分内容。例如,我们可以定义 Summary
trait,使其具有一个需要实现的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
,只需在实现 trait
时定义 summarize_author
即可:
impl Summary for Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
一旦定义了 summarize_author
,我们就可以对 Tweet 结构体的实例调用 summarize
了,而 summarize
的默认实现会调用我们提供的 summarize_author
定义。因为实现了 summarize_author
,Summary
trait 就提供了 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());
注意无法从相同方法的重载实现中调用默认方法。
10.2.4 trait 作为参数
知道了如何定义 trait
和在类型上实现这些 trait
之后,我们可以探索一下如何使用 trait
来接受多种不同类型的参数。
例如在示例 10-13 中为 NewsArticle
和 Tweet
类型实现了 Summary
trait。我们可以定义一个函数notify
来调用其参数 item 上的 summarize
方法,该参数是实现了 Summary
trait 的某种类型。为此可以使用 impl Trait
语法,像这样:
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
对于 item
参数,我们指定了 impl
关键字和trait
名称,而不是具体的类型。该参数支持任何实现了指定 trait
的类型。在 notify
函数体中,可以调用任何来自 Summary
trait 的方法,比如 summarize
。我
们可以传递任何 NewsArticle
或 Tweet
的实例来调用 notify
。任何用其它如 String
或 i32
的类型调用该函数的代码都不能编译,因为它们没有实现 Summary
。
Trait Bound 语法
impl Trait
语法适用于直观的例子,它实际上是一种较长形式语法的语法糖。我们称为 trait bound
,它看起来像:
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
这与之前的例子相同,不过稍微冗长了一些。trait bound
与泛型参数声明在一起,位于尖括号中的冒号后面。
impl Trait
很方便,适用于短小的例子。trait bound
则适用于更复杂的场景。例如,可以获取两个实现了 Summary
的参数。使用 impl Trait
的语法看起来像这样:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
这适用于 item1 和 item2 允许是不同类型的情况(只要它们都实现了 Summary)。不过如果你希望强制它们都是相同类型呢?这只有在使用 trait bound
时才有可能:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
泛型 T
被指定为 item1 和 item2 的参数限制,如此传递给参数 item1 和 item2 值的具体类型必须一致。
通过 + 指定多个 trait bound
如果 notify
需要显示 item
的格式化形式,同时也要使用 summarize
方法,那么 item 就需要同时实现两个不同的 trait:Display
和 Summary
。这可以通过 +
语法实现:
pub fn notify(item: &(impl Summary + Display)) {
+
语法也适用于泛型的 trait bound:
pub fn notify<T: Summary + Display>(item: &T) {
通过指定这两个 trait bound,notify 的函数体可以调用 summarize
并使用 {}
来格式化 item
。
通过 where 简化 trait bound
然而,使用过多的 trait bound 也有缺点。每个泛型有其自己的 trait bound,所以有多个泛型参数的函
数在名称和参数列表之间会有很长的 trait bound 信息,这使得函数签名难以阅读。为此,Rust 有另一个在函数签名之后的 where 从句中指定 trait bound 的语法。所以除了这么写:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
we can use a where
clause, like this:
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
这个函数签名就显得不那么杂乱,函数名、参数列表和返回值类型都离得很近,看起来跟没有那么多 trait bounds
的函数很像。
10.2.5 返回实现了 trait 的类型
也可以在返回值中使用 impl Trait
语法,来返回实现了某个 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
trait 的类型,但是不确定其具体的类型。在这个例子中 returns_summarizable
返回了一个Tweet
,不过调用方并不知情。
返回一个只是指定了需要实现的 trait
的类型的能力在闭包和迭代器场景十分的有用,第十三章会介绍它们。闭包和迭代器创建只有编译器知道的类型,或者是非常非常长的类型。impl Trait
允许你简单的指定函数返回一个 Iterator
而无需写出实际的冗长的类型。不过这只适用于返回单一类型的情况。例如,这段代码的返回值类型指定为返回 impl Summary
,但是返回了 NewsArticle
或 `Tweet 就行不通:
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,
}
}
}
这里尝试返回 NewsArticle 或 Tweet。这不能编译,因为 impl Trait 工作方式的限制。第十七章的 ” 为使用不同类型的值而设计的 trait 对象” 部分会介绍如何编写这样一个函数。
10.2.6 使用traits
来有条件地实现方法
通过使用与使用泛型类型参数的impl
块绑定的 trait bound,我们可以有条件地为实现指定trait
的类型实现方法。例如,清单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);
}
}
}
我们还可以有条件地为实现另一个trait
的任何类型实现一个trait
。满足trait bound
的任何类型上的trait
的实现称为全面实现(blanket implementations
),在Rust标准库中广泛使用。例如,标准库在实现Display
特征的任何类型上实现ToString
特征。标准库中的impl
块看起来类似于以下代码:
impl<T: Display> ToString for T {
// --snip--
}
因为标准库有这个覆盖实现,所以我们可以在实现Display
特性的任何类型上调用ToString
特性定义的to_string
方法。例如,我们可以像这样将整数转换为它们对应的String值,因为整数实现了Display:
let s = 3.to_string();
trait
和trait bound
使我们能够编写使用泛型类型参数来减少重复的代码,同时也向编译器指定我们希望泛型类型具有特定的行为。然后编译器可以使用特征绑定信息来检查与我们的代码一起使用的所有具体类型是否提供了正确的行为。在动态类型语言中,如果在没有定义方法的类型上调用方法,则会在运行时得到错误。但是Rust将这些错误转移到编译时,所以我们不得不在代码能够运行之前修复这些问题。此外,我们不必编写在运行时检查行为的代码,因为我们已经在编译时检查过了。这样做可以提高性能,而不必放弃泛型的灵活性。
10.3 用生命周期验证引用
生命周期是我们已经使用过的另一种通用形式。生命周期不是确保类型具有我们想要的行为,而是确保引用在我们需要时有效。
我们在第4章的“引用和借用”一节中没有讨论的一个细节是Rust中的每个引用都有一个生命周期(lifetime),也就是该引用有效的范围。大多数情况下,生命周期是隐式和推断的,就像大多数情况下,类型是推断的一样。只有当可能有多种类型时,才必须对类型进行注释。以类似的方式,当引用的生存期可以以几种不同的方式关联时,我们必须注释生存期。Rust要求我们使用通用的生命周期参数注释关系,以确保在运行时使用的实际引用肯定是有效的。
注释生命周期甚至不是大多数其他编程语言都有的概念,因此这可能会让人感到陌生。尽管我们不会在本章中完整地讨论生命周期,但我们将讨论您可能遇到的生命周期语法的常见方式,以便您能够熟悉这个概念。
10.3.1 用生命周期避免悬空引用(Dangling References)
生命周期的主要目的是防止悬空引用,悬空引用会导致程序引用的数据不是它打算引用的数据。考虑清单10-16中的程序,它有一个外部作用域和一个内部作用域。
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {}", r);
}
注意:清单10-16、10-17和10-23中的示例声明变量时没有给它们一个初始值,因此变量名存在于外部作用域中。乍一看,这似乎与Rust没有空值相冲突。但是,如果我们试图在给变量赋值之前使用它,我们将得到一个编译时错误,这表明Rust确实不允许空值。
外部作用域声明了一个没有初始值的名为r
的变量,内部作用域声明了一个名为x
的变量,初始值为5。在内部作用域内,我们尝试将r
的值设置为x
的引用。然后内部作用域结束,我们尝试打印r
中的值。这段代码无法编译,因为在我们尝试使用它之前,r
所引用的值已经超出了作用域。下面是错误信息:
变量x“活得不够长”。原因是当内部作用域在第7行结束时,x将超出作用域。但对于外部作用域,r仍然有效;因为它的范围更大,我们说它“寿命更长”。如果Rust允许这段代码工作,r将引用当x超出作用域时释放的内存,我们试图用r做的任何事情都不会正确工作。那么Rust是如何确定这段代码无效的呢?它使用借用检查器(borrow checker)。
10.3.2 借用检查器
Rust编译器有一个借用检查器,它比较范围以确定是否所有的借用都有效。清单10-17显示了与清单10-16相同的代码,但带有显示变量生命周期的注释。
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
}
在这里,我们用'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); // | |
// --+ |
}
在这里,x
的生命周期为'b
,在本例中大于'a
。这意味着r
可以引用x
,因为Rust知道当x
有效时,r
中的引用总是有效的。
现在您已经知道了引用的生存期在哪里,以及Rust如何分析生存期以确保引用始终有效,让我们在函数上下文中探索形参和返回值的泛型生命周期。
10.3.3 函数中的泛型生命周期
我们将编写一个函数,返回两个字符串切片中较长的那个。这个函数将接受两个字符串片,并返回一个字符串片。在我们实现了最长的函数之后,清单10-19中的代码应该打印出he longest string is abcd
。
10-19
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {}", result);
}
注意,我们希望函数接受字符串切片,这些字符串切片是引用,而不是字符串,因为我们不希望longest
函数获得其形参的所有权。
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
相反,我们得到了以下关于生命周期的错误:
帮助文本显示,返回类型需要一个通用的生命周期参数,因为Rust无法判断被返回的引用是指向x
还是y
。实际上,我们也不知道,因为这个函数体中的if块返回一个指向x
的引用,而else块返回一个指向y
的引用!
当我们定义这个函数时,我们不知道将被传递到这个函数中的具体值,所以我们不知道if
还是else
会执行。我们也不知道将传入的引用的具体生命周期,因此不能像清单10-17和10-18中那样查看作用域,以确定返回的引用是否总是有效。借款检查器也不能确定这一点,因为它不知道x和y的生存期与返回值的生存期之间的关系。要修复此错误,我们将添加定义引用之间关系的通用生命周期参数,以便借用检查器可以执行其分析。
10.3.4 生命周期注释语法
生命周期注释不会改变任何引用的生存时间。相反,它们描述了多个相互引用的生命周期之间的关系,而不影响生命周期。正如函数可以在签名指定泛型类型形参时接受任何类型一样,通过指定泛型生存期形参,函数也可以接受具有任何生存期的引用。
生命周期注释有一种稍微不同寻常的语法:生命周期注释参数的名称必须以撇号('
)开头,并且通常都是小写且非常短,就像泛型类型一样。大多数人使用'a
作为第一个生命周期注释。我们将生命周期参数注释放在引用的&
后面,使用空格将注释与引用的类型分隔开。