泛型、trait 与生命周期 - 泛型数据类型

优质
小牛编辑
124浏览
2023-12-01

泛型用于通常我们放置类型的位置,比如函数签名或结构体,允许我们创建可以代替许多具体数据类型的结构体定义。让我们看看如何使用泛型定义函数、结构体、枚举和方法,并且在本部分的结尾我们会讨论泛型代码的性能。

定义函数时可以在函数签名的参数数据类型和返回值中使用泛型。以这种方式编写的代码将更灵活并能向函数调用者提供更多功能,同时不引入重复代码。

回到 函数上,示例 10-4 中展示了两个提供了相同的寻找 slice 中最大值功能的函数。第一个是从示例 10-3 中提取的寻找 slice 中 i32 最大值的函数。第二个函数寻找 slice 中 char 的最大值:

文件名: src/main.rs

示例 10-4:两个只在名称和签名中类型有所不同的函数

这里 largest_i32largest_char 有着完全相同的函数体,所以如果能够将这两个函数变成一个来减少重复就太好了。所幸通过引入一个泛型参数就能实现!

为了参数化要定义的函数的签名中的类型,我们需要像给函数的值参数起名那样为这类型参数起一个名字。这里选择了名称 T。任何标识符都可以作为类型参数名,选择 T 是因为 Rust 的类型命名规范是骆驼命名法(CamelCase)。另外泛型类型参数的规范也倾向于简短,经常仅仅是一个字母。T 作为 “type” 的缩写是大部分 Rust 程序员的首选。

当需要在函数体中使用一个参数时,必须在函数签名中声明这个参数以便编译器能知道函数体中这个名称的意义。同理,当在函数签名中使用一个类型参数时,必须在使用它之前就声明它。类型参数声明位于函数名称与参数列表中间的尖括号中。

我们将要定义的泛型版本的 largest 函数的签名看起来像这样:

  1. fn largest<T>(list: &[T]) -> T {

这可以理解为:函数 largest 有泛型类型 T。它有一个参数 list,它的类型是一个 T 值的 slice。largest 函数将会返回一个与 T 相同类型的值。

示例 10-5 展示一个在签名中使用了泛型的统一的 largest 函数定义,并向我们展示了如何对 i32 值的 slice 或 char 值的 slice 调用 largest 函数。注意这些代码还不能编译!

文件名: src/main.rs

  1. fn largest<T>(list: &[T]) -> T {
  2. let mut largest = list[0];
  3. for &item in list.iter() {
  4. if item > largest {
  5. largest = item;
  6. }
  7. }
  8. largest
  9. }
  10. fn main() {
  11. let number_list = vec![34, 50, 25, 100, 65];
  12. let result = largest(&number_list);
  13. println!("The largest number is {}", result);
  14. let char_list = vec!['y', 'm', 'a', 'q'];
  15. let result = largest(&char_list);
  16. println!("The largest char is {}", result);
  17. }

示例 10-5:一个还不能编译的使用泛型参数的 largest 函数定义

如果现在就尝试编译这些代码,会出现如下错误:

  1. error[E0369]: binary operation `>` cannot be applied to type `T`
  2. |
  3. 5 | if item > largest {
  4. | ^^^^
  5. |
  6. note: an implementation of `std::cmp::PartialOrd` might be missing for `T`

注释中提到了 std::PartialOrd,这是一个 trait。下一部分会讲到 trait,不过简单来说,这个错误表明 largest 的函数体不能适用于 T 的所有可能的类型;因为在函数体需要比较 T 类型的值,不过它只能用于我们知道如何排序的类型。标准库中定义的 std::PartialOrd trait 可以实现类型的比较功能。在下一部分会再次回到 trait 并讲解如何为泛型指定一个 trait,不过让我们先把这个例子放在一边并探索其他那些可以使用泛型类型参数的地方。

同样也可以使用 <> 语法来定义拥有一个或多个泛型参数类型字段的结构体。示例 10-6 展示了如何定义和使用一个可以存放任何类型的 xy 坐标值的结构体 Point

  1. struct Point<T> {
  2. x: T,
  3. y: T,
  4. }
  5. fn main() {
  6. let integer = Point { x: 5, y: 10 };
  7. let float = Point { x: 1.0, y: 4.0 };
  8. }

示例 10-6:Point 结构体存放了两个 T 类型的值 xy

其语法类似于函数定义中使用泛型。首先,必须在结构体名称后面的尖括号中声明泛型参数的名称。接着在结构体定义中可以指定具体数据类型的位置使用泛型类型。

注意 Point 的定义中只使用了一个泛型类型,我们想要表达的是结构体 Point 对于一些类型 T 是泛型的,而且字段 xy 都是 相同类型的,无论它具体是何类型。如果尝试创建一个有不同类型值的 Point 的实例,像示例 10-7 中的代码就不能编译:

文件名: src/main.rs

示例 10-7:字段 xy 必须是相同类型,因为他们都有相同的泛型类型 T

尝试编译会得到如下错误:

  1. error[E0308]: mismatched types
  2. -->
  3. |
  4. 7 | let wont_work = Point { x: 5, y: 4.0 };
  5. | ^^^ expected integral variable, found
  6. floating-point variable
  7. |
  8. = note: expected type `{integer}`
  9. = note: found type `{float}`

当我们将 5 赋值给 x,编译器就知道这个 Point 实例的泛型类型 是一个整型。接着我们将 y 指定为 4.0,而它被定义为与 x 有着相同的类型,所以出现了类型不匹配的错误。

如果想要定义一个 xy 可以有不同类型且仍然是泛型的 Point 结构体,我们可以使用多个泛型类型参数。在示例 10-8 中,我们修改 Point 的定义为拥有两个泛型类型 TU。其中字段 xT 类型的,而字段 yU 类型的:

文件名: src/main.rs

  1. struct Point<T, U> {
  2. x: T,
  3. y: U,
  4. }
  5. fn main() {
  6. let both_integer = Point { x: 5, y: 10 };
  7. let both_float = Point { x: 1.0, y: 4.0 };
  8. let integer_and_float = Point { x: 5, y: 4.0 };
  9. }

示例 10-8:使用两个泛型的 Point,这样 xy 可能是不同类型

现在所有这些 Point 实例都是被允许的了!你可以在定义中使用任意多的泛型类型参数,不过太多的话代码将难以阅读和理解。如果你处于一个需要很多泛型类型的位置,这可能是一个需要重新组织代码并分隔成一些更小部分的信号。

类似于结构体,枚举也可以在其成员中存放泛型数据类型。第六章我们使用过了标准库提供的 Option<T> 枚举,现在这个定义看起来就更容易理解了。让我们再看看:

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

换句话说 Option<T> 是一个拥有泛型 T 的枚举。它有两个成员:Some,它存放了一个类型 T 的值,和不存在任何值的None。标准库中只有这一个定义来支持创建任何具体类型的枚举值。“一个可能的值” 是一个比具体类型的值更抽象的概念,而 Rust 允许我们不引入重复代码就能表现抽象的概念。

枚举也可以拥有多个泛型类型。第九章使用过的 Result 枚举定义就是一个这样的例子:

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

Result 枚举有两个泛型类型,TEResult 有两个成员:Ok,它存放一个类型 T 的值,而 Err 则存放一个类型 E 的值。这个定义使得 Result 枚举能很方便的表达任何可能成功(返回 T 类型的值)也可能失败(返回 E 类型的值)的操作。回忆一下示例 9-2 中打开一个文件的场景:当文件被成功打开 T 被放入了 std::File 类型而当打开文件出现问题时 E 被放入了 std::Error 类型。

当发现代码中有多个只有存放的值的类型有所不同的结构体或枚举定义时,你就应该像之前的函数定义中那样引入泛型类型来减少重复代码。

文件名: src/main.rs

示例 10-9:在 Point<T> 结构体上实现方法 x,它返回 T 类型的字段 x 的引用

注意必须在 impl 后面声明 T,这样就可以在 Point<T> 上实现的方法中使用它了。在 impl 之后声明泛型 T ,这样 Rust 就知道 Point 的尖括号中的类型是泛型而不是具体类型。例如,可以选择为 Point<f32> 实例实现方法,而不是为泛型 Point 实例。示例 10-10 展示了一个没有在 impl 之后(的尖括号)声明泛型的例子,这里使用了一个具体类型,f32

  1. # struct Point<T> {
  2. # x: T,
  3. # y: T,
  4. # }
  5. #
  6. impl Point<f32> {
  7. fn distance_from_origin(&self) -> f32 {
  8. (self.x.powi(2) + self.y.powi(2)).sqrt()
  9. }

示例 10-10:构建一个只用于拥有泛型参数 T 的结构体的具体类型的 impl

这段代码意味着 Point<f32> 类型会有一个方法 distance_from_origin,而其他 T 不是 f32 类型的 Point<T> 实例则没有定义此方法。这个方法计算点实例与另一个点坐标之间的距离,它使用了只能用于浮点型的数学运算符。

结构体定义中的泛型类型参数并不总是与结构体方法签名中使用的泛型是同一类型。示例 10-11 中在示例 10-8 中的结构体 Point<T, U> 上定义了一个方法 mixup。这个方法获取另一个 Point 作为参数,而它可能与调用 mixup 的 是不同的 Point 类型。这个方法用 selfPoint 类型的 x 值(类型 T)和参数的 Point 类型的 y 值(类型 W)来创建一个新 Point 类型的实例:

文件名: src/main.rs

  1. struct Point<T, U> {
  2. x: T,
  3. y: U,
  4. }
  5. impl<T, U> Point<T, U> {
  6. fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
  7. Point {
  8. x: self.x,
  9. y: other.y,
  10. }
  11. }
  12. }
  13. fn main() {
  14. let p1 = Point { x: 5, y: 10.4 };
  15. let p2 = Point { x: "Hello", y: 'c'};
  16. let p3 = p1.mixup(p2);
  17. println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
  18. }

示例 10-11:方法使用了与结构体定义中不同类型的泛型

main 函数中,定义了一个有 i32 类型的 x(其值为 5)和 f64y(其值为 10.4)的 Pointp2 则是一个有着字符串 slice 类型的 x(其值为 "Hello")和 char 类型的 y(其值为c)的 Point。在 p1 上以 p2 作为参数调用 mixup 会返回一个 p3,它会有一个 i32 类型的 x,因为 x 来自 p1,并拥有一个 char 类型的 y,因为 y 来自 p2println! 会打印出 p3.x = 5, p3.y = c

注意泛型参数 TU 声明于 impl 之后,因为他们与结构体定义相对应。而泛型参数 VW 声明于 fn mixup 之后,因为他们只是相对于方法本身的。

在阅读本部分的内容的同时你可能会好奇使用泛型类型参数是否会有运行时消耗。好消息是:Rust 实现泛型的方式意味着你的代码使用泛型类型参数相比指定具体类型并没有任何速度上的损失!

Rust 通过在编译时进行泛型代码的 单态化monomorphization)来保证效率。单态化是一个将泛型代码转变为实际放入的具体类型的特定代码的过程。

编译器所做的工作正好与示例 10-5 中我们创建泛型函数的步骤相反。编译器寻找所有泛型代码被调用的位置并使用泛型代码针对具体类型生成代码。

让我们看看一个使用标准库中 Option 枚举的例子:

  1. let integer = Some(5);
  2. let float = Some(5.0);

当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给 Option 的值并发现有两种 Option<T>:一个对应 i32 另一个对应 f64。为此,它会将泛型定义 Option<T> 展开为 Option_i32Option_f64,接着将泛型定义替换为这两个具体的定义。

编译器生成的单态化版本的代码看起来像这样,并包含将泛型 Option 替换为编译器创建的具体定义后的用例代码:

  1. enum Option_i32 {
  2. Some(i32),
  3. None,
  4. }
  5. enum Option_f64 {
  6. Some(f64),
  7. None,
  8. }
  9. fn main() {
  10. let integer = Option_i32::Some(5);
  11. let float = Option_f64::Some(5.0);
  12. }

我们可以使用泛型来编写不重复的代码,而 Rust 将会为每一个实例编译其特定类型的代码。这意味着在使用泛型时没有运行时开销;当代码运行,它的执行效率就跟好像手写每个具体定义的重复代码一样。这个单态化过程正是 Rust 泛型在运行时极其高效的原因。