泛型数据类型

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

在函数定义中

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

继续我们的 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

上面的问题是,当 largest 接受切片 &[T] 作为输入时,该函数无法对类型 T 做任何假设。它可以是 i32,可以是 String,可以是 File。但是,largest 要求 T 是可以与 > 比较的东西(即 T 实现了 PartialOrd,我们将在下一节讨论的 trait)。某些类型(如 i32String)是可比较的,但其他类型(如 File)是不可比较的。

在像 C++ 这样使用 模板 的语言中,编译器不会抱怨 largest 的实现,而是会抱怨尝试在例如文件切片 &[File] 上调用 largest。Rust 则要求你预先声明泛型类型的预期功能。如果 T 需要是可比较的,那么 largest 必须说明这一点。因此,此编译器错误表明,在 T 受到限制之前,largest 将无法编译。

此外,与 Java 等语言(其中所有对象都有一组核心方法,如 Object.toString())不同,Rust 中没有核心方法。在没有限制的情况下,泛型类型 T 没有功能:它不能被打印、克隆或改变(尽管它可以被 drop)。

在结构体定义中

我们还可以定义结构体,以使用 <> 语法在一个或多个字段中使用泛型类型参数。列表 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> 结构体,它保存类型为 Txy

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

请注意,由于我们仅使用一个泛型类型来定义 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 的方法,该方法将返回对类型为 Tx 字段的引用

在这里,我们在 Point<T> 上定义了一个名为 x 的方法,该方法返回对字段 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) 的点的距离,并使用仅适用于浮点类型的数学运算。

你不能同时以这种方式实现同名的特定泛型方法。例如,如果你为所有类型 T 实现了通用的 distance_from_origin,并为 f32 实现了特定的 distance_from_origin,那么编译器将拒绝你的程序:当你调用 Point<f32>::distance_from_origin 时,Rust 不知道使用哪个实现。更一般地说,Rust 没有类似于继承的机制来专门化方法,就像你可能在面向对象语言中找到的那样,但有一个例外(默认 trait 方法),将在下一节中讨论。

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

文件名: 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,它为 x 具有 i32 类型(值为 5),为 y 具有 f64 类型(值为 10.4)。p2 变量是一个 Point 结构体,它为 x 具有字符串切片类型(值为 "Hello"),为 y 具有 char 类型(值为 c)。使用参数 p2p1 调用 mixup 会得到 p3,由于 x 来自 p1,因此 p3x 将具有 i32 类型。由于 y 来自 p2,因此 p3 变量的 y 将具有 char 类型。println! 宏调用将打印 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 的泛型在运行时非常高效。