每一个编程语言都有高效处理重复概念的工具。在 Rust 中其工具之一就是 泛型(generics)。泛型是具体类型或其他属性的抽象替代。
本章会探索如何使用泛型定义我们自己的类型、函数和方法!
首先,我们将回顾一下提取函数以减少代码重复的机制。
接下来,我们将使用相同的技术,从两个仅参数类型不同的函数中创建一个泛型函数。
我们也会讲到结构体和枚举定义中的泛型。
之后,我们讨论如何使用 trait的常见的方法来定义行为。trait 可以与泛型结合来将泛型限制为拥有特定行为的类型,而不是任意类型。
最后介绍 生命周期(lifetimes),它是一类泛型,允许我们向编译器提供引用之间如何相互关联的信息。Rust 的生命周期功能允许我们在很多场景下借用值的同时仍然使编译器能够检查这些引用的有效性。
//我们可以创建一层抽象,在这个例子中将表现为一个获取任意整型列表作为参数并对其进行处理的函数。
fn largest(list: &[i32]) -> i32 {
let mut largest = list[0];
for &item in list {
if item > largest {
largest = item;
}
}
largest
}
我们可以使用泛型为像函数签名或结构体这样的项创建定义,这样它们就可以用于多种不同的具体数据类型。让我们看看如何使用泛型定义函数、结构体、枚举和方法,然后我们将讨论泛型如何影响代码性能。
当使用泛型定义函数时,本来在函数签名中指定参数和返回值的类型的地方,会改用泛型来表示。采用这种技术,使得代码适应性更强,从而为函数的调用者提供更多的功能,同时也避免了代码的重复。
fn largest_i32(list: &[i32]) -> i32 {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> char {
let mut largest = list[0];
for &item in list.iter() {
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);
}
因为两者函数体的代码是一样的,我们可以定义一个函数,再引进泛型参数来消除这种重复。
为了参数化新函数中的这些类型,我们也需要为类型参数取个名字,道理和给函数的形参起名一样。
任何标识符都可以作为类型参数的名字。
这里选用 T,因为传统上来说,Rust 的参数名字都比较短,通常就只有一个字母,同时,Rust 类型名的命名规范是骆驼命名法(CamelCase)。T 作为 “type” 的缩写是大部分 Rust 程序员的首选。
如果要在函数体中使用参数,就必须在函数签名中声明它的名字,好让编译器知道这个名字指代的是什么。同理,当在函数签名中使用一个类型参数时,必须在使用它之前就声明它。
为了定义泛型版本的 largest 函数,类型参数声明位于函数名称与参数列表中间的尖括号 <>
中,像这样:
fn largest<T>(list: &[T]) -> T {
可以这样理解这个定义:函数 largest 有泛型类型 T。它有个参数 list,其类型是元素为 T 的 slice。largest 函数的返回值类型也是 T。
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_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-6 展示了如何定义和使用一个可以存放任何类型的 x 和 y 坐标值的结构体 Point:
struct Point {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
和结构体类似,枚举也可以在成员中存放泛型数据类型。
enum Option<T> {
Some(T),
None,
}
现在这个定义应该更容易理解了。如你所见 Option
是一个拥有泛型 T 的枚举,它有两个成员:
Some,它存放了一个类型 T 的值,
None,它不存放任何值。
通过 Option
枚举可以表达有一个可能的值的抽象概念,同时因为 Option 是泛型的,无论这个可能的值是什么类型都可以使用这个抽象。
枚举也可以拥有多个泛型类型。第九章使用过的 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-9 中展示了示例 10-6 中定义的结构体 Point
,以及在其上实现的名为 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 结构体上实现方法 x,它返回 T 类型的字段 x 的引用
这里在 Point
上定义了一个叫做 x 的方法来返回字段 x 中数据的引用:
注意必须在 impl
后面声明 T
,这样就可以在 Point
上实现的方法中使用它了。在 impl 之后声明泛型 T ,这样 Rust 就知道 Point 的尖括号中的类型是泛型而不是具体类型。
我们可以,比如,选择只为 Point
实例实现方法,而不是在泛型 Point
实例上用任何泛型类型。示例 10-10 展示了一个没有在 impl 之后(的尖括号)声明泛型的例子,这里使用了一个具体类型,f32:
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
示例 10-10:构建一个只用于拥有泛型参数 T 的结构体的具体类型的 impl 块
这段代码意味着 Point
类型会有一个方法 distance_from_origin,而其他 T 不是 f32 类型的 Point 实例则没有定义此方法。这个方法计算点实例与坐标 (0.0, 0.0) 之间的距离,并使用了只能用于浮点型的数学运算符。
在阅读本部分内容的同时,你可能会好奇使用泛型类型参数是否会有运行时消耗。好消息是:Rust 实现了泛型,使得使用泛型类型参数的代码相比使用具体类型并没有任何速度上的损失。
Rust 通过在编译时进行泛型代码的 单态化(monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。
编译器所做的工作正好与示例 10-5 中我们创建泛型函数的步骤相反:编译器寻找所有泛型代码被调用的位置,并在泛型代码调用的地方生成具体类型的代码。
让我们看看一个使用标准库中 Option
枚举的例子:
let integer = Some(5);
let float = Some(5.0);
当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给 Option
的值并发现有两种 Option
:一个对应 i32 另一个对应 f64。为此,它会将泛型定义 Option
展开为 Option_i32
和 Option_f64
,接着将泛型定义替换为这两个具体的定义。
编译器生成的单态化版本的代码看起来像这样,泛型 Option
被替换为编译器创建的具体定义后的代码:
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 泛型在运行时极其高效的原因。
trait 告诉 Rust编译器某个特定类型具有的功能,以及可以与其他类型共享的功能。可以通过 trait 以一种抽象的方式定义共享的行为。可以使用 trait bounds 指定泛型可以是具有特定行为的任何类型。
注意:trait 类似于其他语言中的常被称为 接口(interfaces)的功能,虽然有一些不同。
pub trait Summary {
fn summarize(&self) -> String;
}
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
//trait 作为参数
//该参数是实现了 Summary 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。
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 值的具体类型必须一致。
如果 notify 需要 item 使用display的格式化形式,同时也要使用 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。
然而,使用过多的 trait bound 也有缺点。每个泛型有其自己的 trait bound,所以有多个泛型参数的函数在名称和参数列表之间会有很长的 trait bound 信息,这使得函数签名难以阅读。为此,Rust 有另一个在函数签名之后的 where 从句中指定 trait bound 的语法。所以除了这么写:
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
{
这个函数签名就显得不那么杂乱,函数名、参数列表和返回值类型都离得很近,看起来类似没有很多 trait bounds 的函数。
也可以在返回值中使用 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,
}
}
让我们回到实例 10-5 修复使用泛型类型参数的 largest 函数定义。
将 largest 的签名修改为如下:
fn largest<T: PartialOrd>(list: &[T]) -> T {
即:
fn largest<T: PartialOrd>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
但是如果编译代码的话,会出现一些不同的错误:
error[E0508]: cannot move out of type `[T]`, a non-copy slice
--> src/main.rs:2:23
|
2 | let mut largest = list[0];
| ^^^^^^^
| |
| cannot move out of here
| help: consider using a reference instead: `&list[0]`
error[E0507]: cannot move out of borrowed content
--> src/main.rs:4:9
|
4 | for &item in list.iter() {
| ^----
| ||
| |hint: to prevent move, use `ref item` or `ref mut item`
| cannot move out of borrowed content
错误的核心是 cannot move out of type [T], a non-copy slice
,对于非泛型版本的 largest 函数,我们只尝试了寻找最大的 i32 和 char。
正如第四章 “只在栈上的数据:拷贝” 部分讨论过的,像 i32 和 char 这样的类型是已知大小的并可以储存在栈上,所以他们实现了 Copy trait。当我们将 largest 函数改成使用泛型后,现在 list 参数的类型就有可能是没有实现 Copy trait 的。这意味着我们可能不能将 list[0] 的值移动到 largest 变量中,这导致了上面的错误。
为了只对实现了 Copy 的类型调用这些代码,可以在 T 的 trait bounds 中增加 Copy
!
示例 10-15 中展示了一个可以编译的泛型版本的 largest 函数的完整代码,只要传递给 largest 的 slice 值的类型实现了 PartialOrd
和 Copy
traits,例如 i32 和 char:
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
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-15:一个可以用于任何实现了 PartialOrd 和 Copy trait 的泛型的 largest 函数
如果并不希望限制 largest 函数只能用于实现了 Copy trait 的类型,我们可以在 T 的 trait bounds 中指定 Clone 而不是 Copy。并克隆 slice 的每一个值使得 largest 函数拥有其所有权。使用 clone 函数意味着对于类似 String 这样拥有堆上数据的类型,会潜在的分配更多堆上空间,而堆分配在涉及大量数据时可能会相当缓慢。
另一种 largest 的实现方式是返回在 slice 中 T 值的引用。如果我们将函数返回值从 T 改为 &T 并改变函数体使其能够返回一个引用,我们将不需要任何 Clone 或 Copy 的 trait bounds 而且也不会有任何的堆分配。尝试自己实现这种替代解决方式吧!
通过使用带有 trait bound 的泛型参数的 impl 块,可以有条件地只为那些实现了特定 trait 的类型实现方法。
例如,示例 10-16 中的类型 Pair
总是实现了 new 方法,不过只有那些为 T 类型实现了 PartialOrd trait (来允许比较) 和 Display trait (来启用打印)的 Pair
才会实现 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-16:根据 trait bound 在泛型上有条件的实现方法
也可以对任何实现了特定 trait 的类型有条件地实现 trait。对任何满足特定 trait bound 的类型实现 trait 被称为 blanket implementations,他们被广泛的用于 Rust 标准库中。例如,标准库为任何实现了 Display trait 的类型实现了 ToString trait。这个 impl 块看起来像这样:
impl<T: Display> ToString for T {
// --snip--
}
因为标准库有了这些 blanket implementation,我们可以对任何实现了 Display trait 的类型调用由 ToString 定义的 to_string 方法。例如,可以将整型转换为对应的 String 值,因为整型实现了 Display:
let s = 3.to_string();
blanket implementation 会出现在 trait 文档的 “Implementers” 部分。
trait 和 trait bound 让我们使用泛型类型参数来减少重复,并仍然能够向编译器明确指定泛型类型需要拥有哪些行为。因为我们向编译器提供了 trait bound 信息,它就可以检查代码中所用到的具体类型是否提供了正确的行为。在动态类型语言中,如果我们尝试调用一个类型并没有实现的方法,会在运行时出现错误。Rust 将这些错误移动到了编译时,甚至在代码能够运行之前就强迫我们修复错误。另外,我们也无需编写运行时检查行为的代码,因为在编译时就已经检查过了,这样相比其他那些不愿放弃泛型灵活性的语言有更好的性能。
这里还有一种泛型,我们一直在使用它甚至都没有察觉它的存在,这就是 生命周期(lifetimes)。不同于其他泛型帮助我们确保类型拥有期望的行为,生命周期则有助于确保引用在我们需要他们的时候一直有效。让我们学习生命周期是如何做到这些的。
当在第四章讨论 “引用和借用” 部分时,我们遗漏了一个重要的细节:Rust 中的每一个引用都有其 生命周期(lifetime),也就是引用保持有效的作用域。