泛型数据类型
ch10-01-syntax.md
commit 55d9e75ffec92e922273c997026bb10613a76578
泛型用于通常我们放置类型的位置,比如函数签名或结构体,允许我们创建可以代替许多具体数据类型的结构体定义。让我们看看如何使用泛型定义函数、结构体、枚举和方法,并且在本部分的结尾我们会讨论泛型代码的性能。
在函数定义中使用泛型
定义函数时可以在函数签名的参数数据类型和返回值中使用泛型。以这种方式编写的代码将更灵活并能向函数调用者提供更多功能,同时不引入重复代码。
回到largest函数上,列表 10-4 中展示了两个提供了相同的寻找 slice 中最大值功能的函数。第一个是从列表 10-3 中提取的寻找 slice 中i32最大值的函数。第二个函数寻找 slice 中char的最大值:
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 numbers = vec![34, 50, 25, 100, 65];
let result = largest_i32(&numbers);
println!("The largest number is {}", result);
# assert_eq!(result, 100);
let chars = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&chars);
println!("The largest char is {}", result);
# assert_eq!(result, 'y');
}
Listing 10-4: Two functions that differ only in their names and the types in their signatures
这里largest_i32和largest_char有着完全相同的函数体,所以能够将这两个函数变成一个来减少重复就太好了。所幸通过引入一个泛型参数就能实现。
为了参数化要定义的函数的签名中的类型,我们需要像给函数的值参数起名那样为这类型参数起一个名字。这里选择了名称T。任何标识符抖可以作为类型参数名,选择T是因为 Rust 的类型命名规范是骆驼命名法(CamelCase)。另外泛型类型参数的规范也倾向于简短,经常仅仅是一个字母。T作为“type”是大部分 Rust 程序员的首选。
当需要再函数体中使用一个参数时,必须再函数签名中声明这个参数以便编译器能知道函数体中这个名称的意义。同理,当在函数签名中使用一个类型参数时,必须在使用它之前就声明它。类型参数声明位于函数名称与参数列表中间的尖括号中。
我们将要定义的泛型版本的largest函数的签名看起来像这样:
fn largest<T>(list: &[T]) -> T {
这可以理解为:函数largest有泛型类型T。它有一个参数list,它的类型是一个T值的 slice。largest函数将会返回一个与T相同类型的值。
列表 10-5 展示一个在签名中使用了泛型的统一的largest函数定义,并向我们展示了如何对i32值的 slice 或char值的 slice 调用largest函数。注意这些代码还不能编译!
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 numbers = vec![34, 50, 25, 100, 65];
let result = largest(&numbers);
println!("The largest number is {}", result);
let chars = vec!['y', 'm', 'a', 'q'];
let result = largest(&chars);
println!("The largest char is {}", result);
}
Listing 10-5: A definition of the largest function that uses generic type
parameters but doesn't compile yet
如果现在就尝试编译这些代码,会出现如下错误:
error[E0369]: binary operation `>` cannot be applied to type `T`
|
5 | if item > largest {
| ^^^^
|
note: an implementation of `std::cmp::PartialOrd` might be missing for `T`
注释中提到了std::cmp::PartialOrd,这是一个 trait。下一部分会讲到 trait,不过简单来说,这个错误表明largest的函数体对T的所有可能的类型都无法工作;因为在函数体需要比较T类型的值,不过它只能用于我们知道如何排序的类型。标准库中定义的std::cmp::PartialOrd trait 可以实现类型的排序功能。在下一部分会再次回到 trait 并讲解如何为泛型指定一个 trait,不过让我们先把这个例子放在一边并探索其他那些可以使用泛型类型参数的地方。
结构体定义中的泛型
同样也可以使用<>语法来定义拥有一个或多个泛型参数类型字段的结构体。列表 10-6 展示了如何定义和使用一个可以存放任何类型的x和y坐标值的结构体Point:
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 };
}
Listing 10-6: A Point struct that holds x and y values of type T
其语法类似于函数定义中的泛型应用。首先,必须在结构体名称后面的尖括号中声明泛型参数的名称。接着在结构体定义中可以指定具体数据类型的位置使用泛型类型。
注意Point的定义中是使用了要给泛型类型,我们想要表达的是结构体Point对于一些类型T是泛型的,而且无论这个泛型是什么,字段x和y都是相同类型的。如果尝试创建一个有不同类型值的Point的实例,像列表 10-7 中的代码就不能编译:
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
Listing 10-7: The fields x and y must be the same type because both have
the same generic data type T
尝试编译会得到如下错误:
error[E0308]: mismatched types
-->
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integral variable, found
floating-point variable
|
= note: expected type `{integer}`
= note: found type `{float}`
当我们将 5 赋值给x,编译器就知道这个Point实例的泛型类型T是一个整型。接着我们将y指定为 4.0,而它被定义为与x有着相同的类型,所以出现了类型不匹配的错误。
如果想要一个x和y可以有不同类型且仍然是泛型的Point结构体,我们可以使用多个泛型类型参数。在列表 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 };
}
Listing 10-8: A Point generic over two types so that x and y may be
values of different types
现在所有这些Point实例都是被允许的了!你可以在定义中使用任意多的泛型类型参数,不过太多的话代码将难以阅读和理解。如果你处于一个需要很多泛型类型的位置,这可能是一个需要重新组织代码并分隔成一些更小部分的信号。
枚举定义中的泛型数据类型
类似于结构体,枚举也可以在其成员中存放泛型数据类型。第六章我们使用过了标准库提供的Option<T>枚举,现在这个定义看起来就更容易理解了。让我们再看看:
enum Option<T> {
Some(T),
None,
}
换句话说Option<T>是一个拥有泛型T的枚举。它有两个成员:Some,它存放了一个类型T的值,和不存在任何值的None。标准库中只有这一个定义来支持创建任何具体类型的枚举值。“一个可能的值”是一个比具体类型的值更抽象的概念,而 Rust 允许我们不引入重复就能表现抽象的概念。
枚举也可以拥有多个泛型类型。第九章使用过的Result枚举定义就是一个这样的例子:
enum Result<T, E> {
Ok(T),
Err(E),
}
Result枚举有两个泛型类型,T和E。Result有两个成员:Ok,它存放一个类型T的值,而Err则存放一个类型E的值。这个定义使得Result枚举能很方便的表达任何可能成功(返回T类型的值)也可能失败(返回E类型的值)的操作。回忆一下列表 9-2 中打开一个文件的场景,当文件被成功打开T被放入了std::fs::File类型而当打开文件出现问题时E被放入了std::io::Error类型。
当发现代码中有多个只有存放的值的类型有所不同的结构体或枚举定义时,你就应该像之前的函数定义中那样引入泛型类型来减少重复。
方法定义中的枚举数据类型
可以像第五章介绍的那样来为其定义中带有泛型的结构体或枚举实现方法。列表 10-9 中展示了列表 10-6 中定义的结构体Point<T>。接着我们在Point<T>上定义了一个叫做x的方法来返回字段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());
}
Listing 10-9: Implementing a method named x on the Point<T> struct that
will return a reference to the x field, which is of type T.
注意必须在impl后面声明T,这样就可以在Point<T>上实现的方法中使用它了。
结构体定义中的泛型类型参数并不总是与结构体方法签名中使用的泛型是同一类型。列表 10-10 中在列表 10-8 中的结构体Point<T, U>上定义了一个方法mixup。这个方法获取另一个Point作为参数,而它可能与调用mixup的self是不同的Point类型。这个方法用self的Point类型的x值(类型T)和参数的Point类型的y值(类型W)来创建一个新Point类型的实例:
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 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);
}
Listing 10-10: Methods that use different generic types than their struct's definition
在main函数中,定义了一个有i32类型的x(其值为5)和f64的y(其值为10.4)的Point。p2则是一个有着字符串 slice 类型的x(其值为"Hello")和char类型的y(其值为c)的Point。在p1上以p2调用mixup会返回一个p3,它会有一个i32类型的x,因为x来自p1,并拥有一个char类型的y,因为y来自p2。println!会打印出p3.x = 5, p3.y = c。
注意泛型参数T和U声明于impl之后,因为他们于结构体定义相对应。而泛型参数V和W声明于fn mixup之后,因为他们只是相对于方法本身的。
泛型代码的性能
在阅读本部分的内容的同时你可能会好奇使用泛型类型参数是否会有运行时消耗。好消息是:Rust 实现泛型泛型的方式意味着你的代码使用泛型类型参数相比指定具体类型并没有任何速度上的损失。
Rust 通过在编译时进行泛型代码的单态化(monomorphization)来保证效率。单态化是一个将泛型代码转变为实际放入的具体类型的特定代码的过程。
编译器所做的工作正好与列表 10-5 中我们创建泛型函数的步骤相反。编译器寻找所有泛型代码被调用的位置并使用泛型代码针对具体类型生成代码。
让我们看看一个使用标准库中Option枚举的例子:
let integer = Some(5);
let float = Some(5.0);
当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给Option的值并发现有两种Option<T>:一个对应i32另一个对应f64。为此,它会将泛型定义Option<T>展开为Option_i32和Option_f64,接着将泛型定义替换为这两个具体的定义。
编译器生成的单态化版本的代码看起来像这样,并包含将泛型Option替换为编译器创建的具体定义后的用例代码:
Filename: src/main.rs
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 泛型在运行时极其高效的原因。