0%

Rust 权威指南(09):泛型、trait 和生命周期

泛型是指具体类型或其他属性的抽象替代。在编写代码时,可以直接描述泛型的行为,或者它与其他泛型产生的联系,而无须知晓它在编译和运行代码时采用的具体类型。在定义泛型时,使用 trait 可以将其限制为拥有某些行为的类型,而不是任意类型。生命周期可以向编译器提供引用之间的相互关系,它允许我们在借用值时通过编译器来确保这些引用的有效性。

泛型数据类型

泛型允许代码作用于抽象类型。可以在声明函数签名或者结构体等元素时使用泛型,并在随后搭配不同的具体类型来使用这些元素。

在函数中定义

当使用泛型来定义函数时,需要将泛型放置在函数签名中通常用于指定参数和返回值类型的地方。以这种方式编写代码更加灵活,可以在不引入重复代码的同时向函数调用者提供更多功能。

可以使用任何合法的标识符作为类型参数名称。在 Rust 中倾向于使用简短的泛型参数名称,通常仅仅是一个字母,T 作为 type 的缩写,通常用于命名类型参数。另外 Rust 采用驼峰命名法作为类型的命名规范。

类型名称的声明必须放置在函数名与参数列表之间的一对尖括号 <> 中。如下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn largest<T>(list: &[T]) -> T {
let mut largest = list[0];

for &item in list.iter() {
if item > largest {
largest = item;
}
}

largest
}


fn main() {
let number_list1 = vec![1, 2, 3, 4, 5];
println!("{}", largest(&number_list1));

let number_list2 = vec!['c', 'b', 'a'];
println!("{}", largest(&number_list2));
}

但是该代码会出现编译错误:这是因为 largest 函数中的代码不能适用于 T 所有可能的类型,因为函数体中相关语句需要比较类型 T 的值,该操作只能用于可排序的值类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# cargo run
Compiling max_num v0.1.0 (/root/code/private/rust/chapter_10/max_num)
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 `max_num` due to previous error

在结构体定义中

同样地,可以使用 <> 语法来定义在一个或多个字段中使用泛型的结构体。在结构名后的一对尖括号中声明泛型参数,就可以在结构体定义中使用泛型了。

1
2
3
4
5
6
7
8
9
struct Point<T> {
x: T,
y: T,
}

fn main() {
let integer = Point {x: 5, y: 10};
let float = Point {x: 5.1, y: 10.1};
}

这上面的代码中,无论具体的类型是什么,字段 x 和 y 都同时属于这个类型。如果使用不同类型的值来创建 Point<T> 实例,那么代码是无法通过编译的。为了在保持泛型状态的前提下,让 Point 结构体中的 x 和 y 能够实例化为不同的类型,可以使用多个泛型参数。

但是需要注意,过多的泛型参数会让代码难以阅读,通常来讲,当你需要在代码中使用很多泛型时,可能就意味着你的代码需要重构为更小的片段。

在枚举中定义

枚举定义也可以在变体中存放泛型数据,例如 Option<T> 的定义:

1
2
3
4
enum Option<T> {
Some(T),
None,
}

枚举同样可以使用多个泛型参数:

1
2
3
4
enum Rust<T, E> {
Ok(T),
Err(E),
}

当你意识到自己的代码拥有多个结构体或枚举定义,但仅仅只有值类型不同时,可以通过使用泛型来避免重复代码

在方法中定义

方法定义中也可以使用泛型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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,以便能够在实现方法时指定类型 Point<T>通过在 impl 关键字之后将 T 声明为泛型,Rust 能够识别出 Point 尖括号内的类型是泛型而不是具体类型。我们也可以专门为具体的类型定义方法,例如为单独的 Point<f32> 来实现方法,此时只有 Point<f32> 实例会拥有该方法,而其他的 Point<T> 实例则没有该方法的定义。

结构体定义中的泛型参数并不总是与我们在方法签名上使用的类型参数一致。在某些情况下,可能会有一部分泛型参数声明位于 impl 关键字后,而另一部分则声明于方法定义中。位于 impl 关键字因为他们是结构体定义的一部分,位于方法定义中是因为它们仅仅与方法本身相关

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct Point<T, U> {
x: T,
y: U,
}

impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}

fn main() {
let p = Point {x: 5, y: 10.1};
let q = Point {x: 'c', y: "test"};
let o = p.mixup(q);
println!("x={} y={}", o.x, o.y);
}

泛型代码的性能问题

Rust 实现泛型的方式决定了使用泛型的代码与使用具体类型的代码相比不会有任何速度上的差异。Rust 会在编译时执行泛型代码的单态化(monomorphization)。单态化是一个在编译期将泛型代码转换为特定代码的过程,它会将所有使用过的具体类型填入泛型参数从而得到具体类型的代码。它会寻找所有泛型代码被调用过的地方,并基于该泛型代码所使用的具体类型生成代码。

正是由于 Rust 会将每一个实例中的泛型代码编译为特定类型的代码,所以无需为泛型的使用付出任何运行时的代价

trait:定义共享行为

trait(特征)被用来向 Rust 编译器描述某些特定类型拥有的且能够被其他类型共享的功能,它使我们可以以一种抽象的方式来定义共享行为。还可以使用 trait 约束来将泛型参数指定为实现了某些特定行为的类型。

定义 trait

类型的行为由该类型本身可供调用的方法组成,当我们可以在不同类型上调用相同的方法时,就称这些类型共享了相同行为。trait 提供了一种将特定方法签名组合起来的途径,它定义了为达到某种目的所必需的行为集合

1
2
3
pub trait Summary {
fn summarize(&self) -> String;
}
  • 使用 trait 关键字来声明 trait,之后则是该 trait 的名字
  • 在其后的花括号中,声明了用于定义类型行为的方法签名
  • 一个 trait 可以包含多个方法,每个方法签名占据单独的一行并以分号结尾

任何想要实现这个 trait 的类型都要为上述方法提供自定义行为。编译器会确保每一个实现 Summary trait 的类型都定义了与这个签名完全一致的 summarize 方法。

为类型实现 trait

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
pub struct NewArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,

}

impl Summary for NewArticle {
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)
}
}

pub trait Summary {
fn summarize(&self) -> String;
}

为类型实现 trait 与普通方法的步骤类似,区别在于必须在 impl 关键字后提供我们想要实现的 trait 名,并紧跟 for 关键字及当前的类型名。在 impl 代码块内,实现 trait 中的方法。

一旦实现了 trait,就可以基于实例调用该 trait 的方法:

1
2
3
4
5
6
7
8
9
10
fn main() {
let tweet = Tweet {
username: String::from("jack"),
content: String::from("to be or not to be?"),
reply: false,
retweet: false,
};

println!("1 new tweet: {}", tweet.summarize());
}

注意,实现 trait 有一个限制:只有当 trait 或者类型定义于我们的库中时,才能为该类型实现对应的 trait。但是我们不能为外部类型实现外部 trait。这个限制称为孤儿规则(orphan rule)。这一规则也是程序一致性的组成部分,它确保了其他人所编写的内容不会破坏到你的代码,反之亦然。如果没有这条规则,那么两个库可以分别对相同类型实现相同的 trait,Rust 将无法确定应该使用哪一个版本。

默认实现

有时为 trait 中的某些或者所有方法都提供默认行为非常有用,它使得我们无需为每一个类型的实现都提供自定义行为。当我们在为某个特定类型实现 trait 时,可以选择保留或重载每个方法的默认行为。

如下为 Summary trait 中的 summarize 方法指定一个默认的字符串返回值:

1
2
3
4
5
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}

之后如果想在 NewsArticle 实例中使用默认实现而不是自定义实现,那么可以指定一个空的 impl 代码块:impl Summary for NewsArticle {}

我们可以在默认实现中调用相同 trait 中的其他方法,哪怕这些方法没有默认实现。基于这一分规则,trait 可以在只需要实现一小部分方法的前提下,提供许多有用的功能。

注意,我们无法在重载方法实现的过程中调用该方法的默认实现。

使用 trait 作为参数

接下来讨论如何使用 trait 来定义接收不同类型参数的函数。如下所示,定义了一个 notify 函数,可以调用其 item 参数的 summarize 方法,item 参数可以是任何实现了 Summary trait 的类型。为了达到这一目的,需要按照如下方式使用 impl Trait

1
2
3
pub fn notify(item: impl Summary) {
println!("Breaking news! {}", item.summarize());
}

这里使用关键字 impl 以及对应 trait 的名称,这一参数可以接收任何实现了指定 trait 的类型。在 notify 函数体内可以调用来自 Summary trait 的任何方法。

trait 约束

impl Trait 语法经常被用于一些较短的实例中,但它其实只是 trait 约束(trait bound)的一种语法糖。将泛型参数与 trait 约束同时放置在尖括号中,并使用冒号分隔impl Trait 更适用于短小的示例,而 trait 约束则适用于更加复杂的情形。

1
2
3
pub fn notify<T: Summary>(item: T) {
println!("Breaking news! {}", item.summarize());
}

可以使用 + 语法来指定多个 trait 约束:

1
2
3
4
5
6
7
pub fn notify(item: impl Summary + Display) {

}

pub fn notify<T: Summary + Display>(item: T) {

}

使用过多的 trait 约束也有一些缺点,每个泛型都拥有自己的 trait 约束,定义有多个泛型参数的函数可能会有大量的 trait 约束信息需要填写在函数名与参数列表之间。这使得函数签名变得难以理解。Rust 提供了一个替代语法,使我们可以在函数签名之后使用 where 从句来指定 trait 约束

1
2
3
4
5
6
7
8
9
fn some_fn<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 {

}

fn some_fn<T, U>(t: T, u: U) -> i32
where T: Display + Clone,
U: Clone + Debug {

}

可以在返回值中使用 impl Trait 语法,用于返回某种实现了 trait 的类型。但是需要注意,只能在返回一个类型时使用 impl Trait

使用 trait 约束来有条件地实现方法

通过在带有泛型参数的 impl 代码块 中使用 trait 约束,我们可以单独为实现了指定 trait 的类型编写方法。我们同样可以为实现了某个 trait 的类型有条件地实现另一个 trait。对满足 trait 约束的所有类型实现 trait 也被称为覆盖实现,这一机制被广泛用于 Rust 标准库中。

例如标准库对所有满足 Display trait 约束的类型实现了 ToString trait:

1
2
3
impl<T: Display> ToString for T {

}

trait 小结

通过 trait 和 trait 约束,可以使用泛型参数来消除重复代码的同时,向编译器指明自己希望泛型拥有的功能。而编译器则可以利用这些 trait 约束信息来确保代码中使用的具体类型提供了正确的行为。而且这些工作都是在编译期完成,这一机制在保留泛型灵活性的同时提升了代码的性能。

使用生命周期保证引用的有效性

Rust 中的每个引用都有自己的生命周期,它对应着引用保持有效性的作用域。在大多数时候,生命周期都是隐式且可以被推导出来的。但是当引用的生命周期可能以不同方式相互关联时,就必须手动标注生命周期。Rust 需要我们注明泛型生命周期参数之间的关系,来确保运行时实际使用的引用一定是有效的。

使用生命周期来避免悬垂引用

生命周期最主要的目标在于避免悬垂引用,进而避免程序引用到非预期的数据。Rust 编译器拥有一个借用检查器,它被用于比较不同的作用域并确定所有借用的合法性。

如下代码在编译过程中会触发生命周期的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}


fn main() {
let s1 = String::from("abcd");
let s2 = "xyz";

let result = longest(s1.as_str(), s2);
println!("The longest string is {}", result)
}

在这段代码中,我们需要给返回类型标注一个泛型生命周期参数,因为 Rust 并不知道返回的引用会指向 x 还是指向 y。借用检查其器也不知道 x 与 y 的生命周期是如何与返回值的生命周期相关联的。为了解决该问题,我们会添加一个泛型生命周期参数,并用它来定义引用之间的关系,进而使借用检查器可以正常地进行分析。

生命周期标注语法

生命周期标注并不会改变任何引用的生命周期长度。使用了泛型生命周期的函数可以接收带有任何生命周期的引用,在不影响生命周期的前提下,标注本身会被用于描述多个引用生命周期之间的关系。

生命周期标注使用了一种明显不同的语法:它们的参数名称必须以 ` 开始,且通常使用全小写字符。与泛型一样,它们的名称通常会很简短,'a' 被大部分开发者选择作为默认名称。将生命周期标注填写在 & 引用运算符之后,并通过一个空格符来将标注与引用类型区分开来。

如同泛型参数一样,我们需要在函数名与参数列表之间的尖括号内声明泛型生命周期参数。例如在下面的函数签名中,参数与返回值中的所有引用都必须拥有相同的生命周期:

1
2
3
4
5
6
7
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}

记住,当我们在函数签名中指定生命周期参数时,我们并没有改变任何传入值或返回值的生命周期。我们只是向借用检查器指出了一些可以用于检查非法调用的约束。

当我们把具体的引用传入 longest 时,被用于替代 'a 的具体生命周期就是作用域 x 与作用域 y 重叠的那一部分。也就是说,泛型生命周期 'a 会被具体化为 x 与 y 两者中生命周期较短的那一个。

深入理解生命周期

指定生命周期的方式往往取决于函数的具体功能。当函数返回一个引用时,返回类型的生命周期参数必须要与其中一个参数的生命周期参数相匹配。当返回的引用没有指向任何参数时,那么它只可能是指向了一个创建于函数内部的值,由于这个值会因为函数的结束而离开作用域,所以返回的内容就变成了垂悬引用。

从根本上来说,生命周期语法就是用来关联一个函数中不同参数以及返回值的生命周期的。一旦它们形成了某种关系,Rust 就获得了足够的信息来支持保障内存安全的操作,并阻止那些可能会导致垂悬指针或违反内存安全的行为。

结构体定义中的生命周期标注

我们可以在结构体中存储引用,不过需要为结构体定义中的每个引用都添加生命周期标注。为了在结构体定义中使用生命周期参数,我们需要在结构体名称后的尖括号内声明泛型生命周期参数的名字。

1
2
3
4
5
6
7
8
9
10
struct ImportantExcept<'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 = ImportantExcept { part: first_sentence };
}

这个标注意味着 ImportantExcept 实例的存活时间不能超过存储在 part 字段字段中引用的存活时间。

生命周期省略

任何引用都有一个生命周期,并且需要为使用引用的函数或者结构体指定生命周期参数。但是有些场景是可预测的,这些模式是明确的。Rust 团队将这些模式直接写入编译器代码中,使得借用检查器在这些情况下可以自动对生命周期进行推导而无需显式标注。

这些被写入 Rust 引用分析部分的模式即所谓的 生命周期省略规则。这些规则不需要程序员去遵守:它们只是指明了编译器会考虑的某些场景,当你的代码符合这些场景时,就无需再显式为代码标注相关生命周期了。

函数参数或方法参数中的生命周期被称为输入生命周期,而返回值的生命周期则被称为输出生命周期。在没有显式标注的情况下,编译器目前使用了 3 种规则来计算引用的生命周期。当编译器检查完这 3 条规则后仍有无法计算出生命周期的引用时,编译器就会停止运行并抛出错误。这些规则不但对 fn 定义生效,也对 impl 代码块生效。

  • 规则 1:每个引用参数都会拥有自己的生命周期参数,例如双参数函数拥有两个不同的生命周期参数
  • 规则 2:当只存在一个输入生命周期参数时,这个生命周期会被赋予给所有输出的生命周期参数
  • 规则 3:当拥有多个输入生命周期参数,而其中一个是 &self 或者 &mut self 时,self 的生命周期会赋予给所有的输出生命周期参数

第三条规则实际上只适用于方法签名。当我们需要为某个拥有生命周期的结构体实现方法时,声明和使用生命周期参数的位置取决于它们是结构体字段相关,还是与方法参数、返回值相关。

  • 结构体字段中的生命周期名字总是需要被声明在 impl 关键字之后,并被用于结构体名称之后,因为这些生命周期是结构体类型的一部分,声明在 impl 及类型名称之后的生命周期是不能省略的
  • 在 impl 代码块的方法签名中,引用可能是独立的,也可能会与结构体中的引用的生命周期相关联
  • 生命周期省略规则在大部分情况下可以帮助我们免去方法签名中的生命周期标注

静态生命周期

Rust 中还有一种特殊的生命周期 'static,它表示整个程序的执行期。所有的字符串字面量都拥有 'static 生命周期。

同时使用泛型参数、trait 约束与生命周期

如下代码在单个函数中同时指定泛型参数、trait 约束及生命周期的语法:

1
2
3
4
5
6
7
8
9
10
11
12
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
}
}

因为生命周期也是泛型的一种,所以生命周期参数 'a 以及泛型参数 T 都被放置到了函数名后的尖括号列表中。