泛型数据类型

我们使用泛型为函数签名或结构体等项创建定义,然后我们可以将它们用于许多不同的具体数据类型。让我们首先看看如何使用泛型定义函数、结构体、枚举和方法。然后我们将讨论泛型如何影响代码性能。

在函数定义中

在定义使用泛型的函数时,我们将泛型放在函数签名中,通常我们在那里指定参数和返回值的数据类型。这样做使我们的代码更灵活,并为函数的调用者提供更多功能,同时防止代码重复。

继续我们的 largest 函数,清单 10-4 展示了两个函数,它们都查找切片中的最大值。然后我们将把它们组合成一个使用泛型的函数。

文件名:src/main.rs
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'); }
清单 10-4:两个函数,它们仅在名称和签名中的类型上有所不同

largest_i32 函数是我们在清单 10-3 中提取的函数,它查找切片中最大的 i32largest_char 函数查找切片中最大的 char。函数体具有相同的代码,因此让我们通过在单个函数中引入泛型类型参数来消除重复。

要参数化新单个函数中的类型,我们需要命名类型参数,就像我们为函数的 value 参数所做的那样。你可以使用任何标识符作为类型参数名称。但我们将使用 T,因为按照惯例,Rust 中的类型参数名称很短,通常只有一个字母,并且 Rust 的类型命名约定是 UpperCamelCase。Ttype 的缩写,是大多数 Rust 程序员的默认选择。

当我们在函数体中使用参数时,我们必须在签名中声明参数名称,以便编译器知道该名称的含义。类似地,当我们在函数签名中使用类型参数名称时,我们必须在使用它之前声明类型参数名称。要定义泛型 largest 函数,我们将类型名称声明放在尖括号 <> 中,函数名称和参数列表之间,像这样

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

我们将此定义解读为:函数 largest 对于某些类型 T 是泛型的。此函数有一个名为 list 的参数,它是一个 T 类型值的切片。largest 函数将返回对相同类型 T 的值的引用。

清单 10-5 显示了使用泛型数据类型在其签名中组合的 largest 函数定义。该清单还显示了我们如何使用 i32 值或 char 值的切片来调用该函数。请注意,此代码尚无法编译,但我们将在本章稍后修复它。

文件名:src/main.rs
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}"); }
清单 10-5:使用泛型类型参数的 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` | 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)。通过遵循帮助文本的建议,我们将 T 的有效类型限制为仅实现 PartialOrd 的类型,并且此示例将编译,因为标准库在 i32char 上都实现了 PartialOrd

在结构体定义中

我们还可以定义结构体以在 一个或多个字段中使用泛型类型参数,使用 <> 语法。清单 10-6 定义了一个 Point<T> 结构体,用于保存任何类型的 xy 坐标值。

文件名:src/main.rs
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 }; }
清单 10-6:一个 Point<T> 结构体,它保存 T 类型的 xy

在结构体定义中使用泛型的语法与在函数定义中使用的语法类似。首先,我们在结构体名称之后的尖括号内声明类型参数的名称。然后在结构体定义中使用泛型类型,否则我们将在那里指定具体数据类型。

请注意,由于我们仅使用一个泛型类型来定义 Point<T>,因此此定义表示 Point<T> 结构体对于某些类型 T 是泛型的,并且字段 xy 是相同的类型,无论该类型是什么。如果我们创建一个具有不同类型值的 Point<T> 实例,如清单 10-7 所示,我们的代码将无法编译。

文件名:src/main.rs
struct Point<T> { x: T, y: T, } fn main() { let wont_work = Point { x: 5, y: 4.0 }; }
清单 10-7:字段 xy 必须是相同的类型,因为两者都具有相同的泛型数据类型 T

在此示例中,当我们为 x 分配整数值 5 时,我们让编译器知道对于此 Point<T> 实例,泛型类型 T 将是一个整数。然后,当我们为 y 指定 4.0 时,我们已将其定义为与 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 结构体,其中 xy 都是泛型的,但可以具有不同的类型,我们可以使用多个泛型类型参数。例如,在清单 10-8 中,我们将 Point 的定义更改为对于类型 TU 是泛型的,其中 x 的类型为 Ty 的类型为 U

文件名:src/main.rs
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 }; }
清单 10-8:一个 Point<T, U> 泛型,它跨越两种类型,以便 xy 可以是不同类型的值

现在,显示的所有 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 枚举对于两种类型 TE 是泛型的,并且具有两个变体: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 的方法。

文件名:src/main.rs
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<T> 结构体上实现一个名为 x 的方法,该方法将返回对 T 类型的 x 字段的引用

在这里,我们定义了一个名为 x 的方法,在 Point<T> 上,它返回对字段 x 中数据的引用。

请注意,我们必须在 impl 之后声明 T,以便我们可以使用 T 来指定我们正在对类型 Point<T> 实现方法。通过在 impl 之后将 T 声明为泛型类型,Rust 可以识别出 Point 中尖括号内的类型是泛型类型,而不是具体类型。我们可以为这个泛型参数选择与结构体定义中声明的泛型参数不同的名称,但是使用相同的名称是约定俗成的。如果您在声明泛型类型的 impl 中编写方法,则该方法将在类型的任何实例上定义,无论最终替换泛型类型的具体类型是什么。

在定义类型上的方法时,我们还可以指定对泛型类型的约束。例如,我们可以仅在 Point<f32> 实例上实现方法,而不是在具有任何泛型类型的 Point<T> 实例上实现方法。在清单 10-10 中,我们使用具体类型 f32,这意味着我们不在 impl 之后声明任何类型。

文件名:src/main.rs
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()); }
清单 10-10:一个 impl 块,它仅适用于具有特定具体类型的泛型类型参数 T 的结构体

此代码意味着类型 Point<f32> 将具有 distance_from_origin 方法;Point<T> 的其他实例,其中 T 不是 f32 类型,将没有定义此方法。该方法测量我们的点与坐标 (0.0, 0.0) 的点的距离,并使用仅适用于浮点类型的数学运算。

结构体定义中的泛型类型参数并不总是与您在同一结构体的方法签名中使用的泛型类型参数相同。清单 10-11 对 Point 结构体使用泛型类型 X1Y1,对 mixup 方法签名使用 X2 Y2,以使示例更清晰。该方法创建一个新的 Point 实例,其 x 值来自 self Point(类型为 X1),y 值来自传入的 Point(类型为 Y2)。

文件名:src/main.rs
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); }
清单 10-11:一种方法,它使用的泛型类型与结构体定义中的泛型类型不同

main 中,我们定义了一个 Point,它有一个 i32 用于 x(值为 5)和一个 f64 用于 y(值为 10.4)。p2 变量是一个 Point 结构体,它有一个字符串切片用于 x(值为 "Hello")和一个 char 用于 y(值为 c)。在 p1 上使用参数 p2 调用 mixup 会得到 p3,它将有一个 i32 用于 x,因为 x 来自 p1p3 变量将有一个 char 用于 y,因为 y 来自 p2println! 宏调用将打印 p3.x = 5, p3.y = c

此示例的目的是演示这样一种情况,其中一些泛型参数使用 impl 声明,而另一些泛型参数使用方法定义声明。在这里,泛型参数 X1Y1impl 之后声明,因为它们与结构体定义一起使用。泛型参数 X2Y2fn 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> 的泛型定义扩展为两个专门针对 i32f64 的定义,从而用特定的定义替换泛型定义。

单态化版本的代码看起来类似于以下内容(编译器使用的名称与我们在此处用于说明的名称不同)

文件名: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); }

泛型 Option<T> 被编译器创建的特定定义所取代。由于 Rust 将泛型代码编译为在每个实例中指定类型的代码,因此我们使用泛型不会产生运行时成本。当代码运行时,它的执行方式与我们手动复制每个定义的方式相同。单态化的过程使 Rust 的泛型在运行时非常高效。