泛型数据类型

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

在函数定义中

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

继续我们的largest函数,列表10-4展示了两个函数,它们都用于在切片中找到最大的值。然后,我们将这些合并为一个使用泛型的函数。

Filename: 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');
}
Listing 10-4: Two functions that differ only in their names and in the types in their signatures

largest_i32 函数是在列表 10-3 中提取的,用于在切片中找到最大的 i32largest_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 值的切片来调用该函数。请注意,此代码尚无法编译,但我们在本章后面会修复它。

Filename: 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}");
}
Listing 10-5: The largest function using generic type parameters; this doesn’t compile yet

如果我们现在编译这段代码,我们会得到这个错误:

$ 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 的类型,这个示例将编译,因为标准库在 i32char 上都实现了 PartialOrd

在结构体定义中

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

Filename: 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 };
}
Listing 10-6: A Point<T> struct that holds x and y values of type T

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

请注意,因为我们只使用了一种泛型类型来定义 Point<T>,这个定义说明 Point<T> 结构体是泛型的,对于某种类型 T,字段 xy 都是 同一种类型,无论该类型是什么。如果我们创建一个具有不同类型的值的 Point<T> 实例,如清单 10-7 所示,我们的代码将无法编译。

Filename: src/main.rs
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.

在这个例子中,当我们把整数值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 结构体,其中 xy 都是泛型但可以有不同的类型,我们可以使用多个泛型类型参数。例如,在列表 10-8 中,我们将 Point 的定义更改为对类型 TU 泛型,其中 x 的类型为 Ty 的类型为 U

Filename: 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 };
}
Listing 10-8: A Point<T, U> generic over two types so that x and y can be values of different types

现在所有显示的 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 的方法。

Filename: 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());
}
Listing 10-9: Implementing a method named x on the Point<T> struct that will return a reference to the x field of type T

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

请注意,我们必须在 impl 之后声明 T,这样我们才能使用 T 来指定我们正在为类型 Point<T> 实现方法。通过在 impl 之后声明 T 为泛型类型,Rust 可以识别 Point 角括号中的类型是泛型类型而不是具体类型。我们可以选择与结构体定义中声明的泛型参数不同的名称,但使用相同的名称是惯例。在声明泛型类型的 impl 中编写的方法将定义在该类型的任何实例上,无论最终用什么具体类型替代泛型类型。

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

Filename: 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());
}
Listing 10-10: An impl block that only applies to a struct with a particular concrete type for the generic type parameter T

这段代码意味着类型Point<f32>将有一个distance_from_origin方法;其他T不是f32类型的Point<T>实例将不会定义此方法。该方法测量我们的点距离坐标(0.0, 0.0)的点有多远,并使用仅对浮点类型可用的数学运算。

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

Filename: 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);
}
Listing 10-11: A method that uses generic types different from its struct’s definition

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 来自 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 的定义,从而用具体的定义替换了泛型定义。

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

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);
}

通用的 Option<T> 被编译器创建的具体定义所替换。因为 Rust 会将泛型代码编译成每个实例都指定类型的代码,所以我们使用泛型时不会产生运行时开销。当代码运行时,它的表现就像我们手动复制了每个定义一样。单态化的过程使得 Rust 的泛型在运行时极其高效。