泛型数据类型
我们使用泛型来为函数签名或结构体等项创建定义,然后我们可以用这些定义与许多不同的具体数据类型一起使用。首先,让我们看看如何使用泛型定义函数、结构体、枚举和方法。然后我们将讨论泛型如何影响代码性能。
在函数定义中
当定义一个使用泛型的函数时,我们在函数签名中放置泛型,通常在这里指定参数和返回值的数据类型。这样做使我们的代码更加灵活,并为调用我们函数的用户提供更多功能,同时防止代码重复。
继续我们的largest
函数,列表10-4展示了两个函数,它们都用于在切片中找到最大的值。然后,我们将这些合并为一个使用泛型的函数。
largest_i32
函数是在列表 10-3 中提取的,用于在切片中找到最大的 i32
。largest_char
函数则用于在切片中找到最大的 char
。这两个函数的函数体代码相同,因此我们可以通过在单个函数中引入泛型类型参数来消除重复。
为了在新函数中参数化类型,我们需要命名类型参数,就像我们为函数的值参数命名一样。你可以使用任何标识符作为类型参数名。但我们将使用T
,因为按照惯例,Rust 中的类型参数名较短,通常只是一个字母,而 Rust 的类型命名约定是 UpperCamelCase。作为 type 的缩写,T
是大多数 Rust 程序员的默认选择。
当我们在一个函数的主体中使用参数时,我们必须在签名中声明参数名称,以便编译器知道该名称的含义。同样,当我们在函数签名中使用类型参数名称时,我们必须在使用之前声明类型参数名称。为了定义泛型largest
函数,我们在函数名称和参数列表之间使用尖括号<>
内放置类型名称声明,如下所示:
fn largest<T>(list: &[T]) -> &T {
我们读这个定义为:函数 largest
是泛型的,类型为 T
。这个函数有一个参数,名为 list
,它是一个 T
类型值的切片。函数 largest
将返回一个指向相同类型 T
值的引用。
列表 10-5 显示了使用泛型数据类型在其签名中的 largest
函数定义的组合。该列表还显示了我们如何可以使用 i32
值或 char
值的切片来调用该函数。请注意,此代码尚无法编译,但我们在本章后面会修复它。
如果我们现在编译这段代码,我们会得到这个错误:
$ 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` (bin "chapter10") due to 1 previous error
帮助文本提到了 std::cmp::PartialOrd
,这是一个 特征,我们将在下一节中讨论特征。目前,需要知道这个错误表明 largest
的主体不能适用于 T
可能是的所有可能类型。因为我们在主体中想要比较类型为 T
的值,所以我们只能使用可以排序的类型的值。为了启用比较,标准库提供了 std::cmp::PartialOrd
特征,你可以在类型上实现它(有关此特征的更多信息,请参阅附录 C)。通过遵循帮助文本的建议,我们将 T
有效的类型限制为仅实现 PartialOrd
的类型,这个示例将编译,因为标准库在 i32
和 char
上都实现了 PartialOrd
。
在结构体定义中
我们还可以使用 <>
语法在结构体的一个或多个字段中定义泛型类型参数。列表 10-6 定义了一个 Point<T>
结构体来保存任何类型的 x
和 y
坐标值。
在结构体定义中使用泛型的语法与在函数定义中使用的语法相似。首先我们在结构体名称后面的尖括号内声明类型参数的名称。然后我们在结构体定义中使用泛型类型,否则将指定具体的數據类型。
请注意,因为我们只使用了一种泛型类型来定义 Point<T>
,这个定义说明 Point<T>
结构体是泛型的,对于某种类型 T
,字段 x
和 y
都是 同一种类型,无论该类型是什么。如果我们创建一个具有不同类型的值的 Point<T>
实例,如清单 10-7 所示,我们的代码将无法编译。
在这个例子中,当我们把整数值5
赋给x
时,我们让编译器知道泛型类型T
在这个Point<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
。
现在所有显示的 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
的方法。
这里,我们定义了一个名为 x
的方法在 Point<T>
上,该方法返回 x
字段中数据的引用。
请注意,我们必须在 impl
之后声明 T
,这样我们才能使用 T
来指定我们正在为类型 Point<T>
实现方法。通过在 impl
之后声明 T
为泛型类型,Rust 可以识别 Point
角括号中的类型是泛型类型而不是具体类型。我们可以选择与结构体定义中声明的泛型参数不同的名称,但使用相同的名称是惯例。在声明泛型类型的 impl
中编写的方法将定义在该类型的任何实例上,无论最终用什么具体类型替代泛型类型。
我们还可以在定义类型的方法时指定泛型类型的约束。例如,我们可以只为 Point<f32>
实例而不是任何泛型类型的 Point<T>
实例实现方法。在清单 10-10 中,我们使用了具体的类型 f32
,这意味着我们在 impl
之后不声明任何类型。
这段代码意味着类型Point<f32>
将有一个distance_from_origin
方法;其他T
不是f32
类型的Point<T>
实例将不会定义此方法。该方法测量我们的点距离坐标(0.0, 0.0)的点有多远,并使用仅对浮点类型可用的数学运算。
在结构体定义中的泛型类型参数并不总是与该结构体方法签名中使用的相同。清单 10-11 使用泛型类型 X1
和 Y1
用于 Point
结构体,X2
Y2
用于 mixup
方法签名,以使示例更清晰。该方法创建一个新的 Point
实例,使用来自 self
Point
(类型为 X1
)的 x
值和来自传入的 Point
(类型为 Y2
)的 y
值。
在 main
中,我们定义了一个 Point
,它有一个 i32
类型的 x
(值为 5
)
和一个 f64
类型的 y
(值为 10.4
)。变量 p2
是一个 Point
结构体,
它有一个字符串切片类型的 x
(值为 "Hello"
)和一个 char
类型的 y
(值为 c
)。在 p1
上调用 mixup
并传入参数 p2
会给我们 p3
,
它将有一个 i32
类型的 x
,因为 x
来自 p1
。变量 p3
将有一个 char
类型的 y
,因为 y
来自 p2
。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
的定义,从而用具体的定义替换了泛型定义。
单态化版本的代码看起来类似于以下内容(编译器使用的名称与我们这里用于说明的名称不同):
通用的 Option<T>
被编译器创建的具体定义所替换。因为 Rust 会将泛型代码编译成每个实例都指定类型的代码,所以我们使用泛型时不会产生运行时开销。当代码运行时,它的表现就像我们手动复制了每个定义一样。单态化的过程使得 Rust 的泛型在运行时极其高效。