泛型数据类型
我们使用泛型为函数签名或结构体等项目创建定义,然后就可以将这些定义用于许多不同的具体数据类型。首先看看如何使用泛型定义函数、结构体、枚举和方法,然后讨论泛型对代码性能的影响。
在函数定义中
定义使用泛型的函数时,我们会将泛型放在函数签名中通常指定参数和返回值数据类型的位置。这样可以让代码更灵活,为函数调用者提供更多功能,同时避免代码重复。
继续看 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}");
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');
}
largest_i32 函数就是我们在清单 10-3 中提取的函数,用于找出切片中的最大 i32。largest_char 函数则找出切片中的最大 char。这两个函数的函数体代码相同,因此我们可以引入泛型类型参数,将它们合并为一个函数,从而消除重复。
要在一个新函数中将类型参数化,我们需要像为函数的值参数命名那样,为类型参数命名。类型参数名可以使用任意标识符。不过我们会使用 T,因为按照惯例,Rust 中的类型参数名较短,通常只有一个字母,并遵循 UpperCamelCase 命名规范。T 是 type(类型)的首字母,也是大多数 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}");
}
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,它是一个 trait;下一节会讨论 trait。目前只需知道,这个错误说明 largest 的函数体并不适用于 T 可能代表的所有类型。由于我们要在函数体中比较 T 类型的值,因此只能使用其值可以排序的类型。标准库提供了 std::cmp::PartialOrd trait,可由类型实现以支持比较(关于此 trait 的更多内容请参见附录 C)。要修复清单 10-5,我们可以按照帮助文本的建议,将 T 限定为仅能使用实现了 PartialOrd 的类型。这样清单中的代码就能编译,因为标准库为 i32 和 char 都实现了 PartialOrd。
在结构体定义中
我们也可以使用 <> 语法,在结构体的一个或多个字段中使用泛型类型参数。清单 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 };
}
T 类型 x 和 y 值的 Point<T> 结构体在结构体定义中使用泛型的语法与在函数定义中的用法类似。首先,我们在结构体名称后面的尖括号中声明类型参数名。然后,在结构体定义中原本要指定具体数据类型的位置使用泛型类型。
请注意,由于定义 Point<T> 时只使用了一个泛型类型,这表示 Point<T> 结构体对某种类型 T 是泛型的,并且字段 x 和 y 都是同一种类型,无论该类型具体是什么。如果像清单 10-7 那样创建一个包含不同类型值的 Point<T> 实例,代码就无法编译。
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
x 和 y 必须是同一类型,因为它们使用相同的泛型数据类型 T。在这个例子中,将整数值 5 赋给 x 时,我们就告知编译器:这个 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
如果要定义一个 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 };
}
Point<T, U>,因此 x 和 y 可以是不同类型的值现在,前面展示的所有 Point 实例都可以使用了!定义中可以使用任意数量的泛型类型参数,但参数过多会降低代码的可读性。如果发现代码需要许多泛型类型,可能说明应该将代码重新拆分为更小的部分。
在枚举定义中
与结构体类似,我们也可以定义枚举,让其变体保存泛型数据类型。再来看看标准库提供的 Option<T> 枚举,它曾在第 6 章中出现:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
现在这个定义应该更容易理解了。如你所见,Option<T> 枚举对类型 T 是泛型的,并有两个变体:Some 保存一个 T 类型的值,None 则不保存任何值。使用 Option<T> 枚举可以表达“可选值”这一抽象概念;由于 Option<T> 是泛型的,无论可选值是什么类型,我们都可以使用这一抽象。
枚举也可以使用多个泛型类型。我们在第 9 章用过的 Result 枚举就是一个例子:
#![allow(unused)]
fn main() {
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 方法。
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());
}
Point<T> 结构体实现名为 x 的方法,该方法返回一个引用,指向 T 类型的 x 字段这里,我们为 Point<T> 定义了一个名为 x 的方法,它返回对 x 字段中数据的引用。
请注意,我们必须紧跟在 impl 后声明 T,这样才能用 T 指定我们正在为 Point<T> 类型实现方法。在 impl 后将 T 声明为泛型类型后,Rust 就能识别出 Point 尖括号中的类型是泛型,而非具体类型。我们可以为此泛型参数选择不同于结构体定义中所用的名称,不过使用相同名称是惯例。如果你在 impl 中声明了泛型类型,那么无论最终由哪种具体类型替代该泛型,此方法都会定义在该类型的所有实例上。
定义类型的方法时,我们也可以为泛型类型指定约束。例如,我们可以只为 Point<f32> 实例实现方法,而不是为任意泛型类型的 Point<T> 实例实现。在清单 10-10 中,我们使用具体类型 f32,因此不需要在 impl 后声明类型。
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
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)。
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);
}
在 main 中,我们定义了一个 Point:x 是值为 5 的 i32,y 是值为 10.4 的 f64。变量 p2 是另一个 Point 结构体:x 是值为 "Hello" 的字符串切片,y 是值为 c 的 char。在 p1 上调用 mixup 并传入参数 p2 后,我们得到 p3。因为 x 来自 p1,所以 p3 的 x 是 i32;因为 y 来自 p2,所以 p3 的 y 是 char。println! 宏调用会打印 p3.x = 5, p3.y = c。
这个示例旨在说明:某些泛型参数在 impl 后声明,另一些则在方法定义中声明。这里,泛型参数 X1 和 Y1 在 impl 后声明,因为它们对应结构体定义。泛型参数 X2 和 Y2 在 fn mixup 后声明,因为它们只与该方法相关。
使用泛型的代码的性能
你可能会想,使用泛型类型参数是否会带来运行时开销。好消息是,使用泛型类型不会让程序比使用具体类型时运行得更慢。
Rust 会在编译时对使用泛型的代码执行单态化来实现这一点。单态化是指在编译时填入所用的具体类型,将泛型代码转换为特定代码的过程。在这个过程中,编译器执行的步骤与我们在清单 10-5 中创建泛型函数时相反:编译器会检查所有调用泛型代码的位置,并为调用时使用的具体类型生成代码。
我们通过标准库的泛型 Option<T> 枚举来看看这是如何实现的:
#![allow(unused)]
fn main() {
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 泛型在运行时极为高效。