通用数据类型

Generic Data Types

这里会使用泛型,来创建诸如函数签名或结构体等的定义,随后咱们便可以将这些定义,用于许多不同的具体数据类型。首先咱们来看看,怎样运用泛型特性来定义函数、结构体、枚举及方法等。接下来就会讨论到,泛型如何影响到代码性能。

函数定义方面

In Function Definitions

在定义用到泛型的函数时,就要把泛型放在咱们通常于其中,指明参数与返回值数据类型的函数签名中。这样做就会在阻止代码重复的同时,令到代码更为灵活,同时提供到更多功能给咱们函数的调用者。

继续之前的 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! ("极大数为 {}", result);

    let char_list = vec! ['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println! ("极大字符为 {}", result);
}

清单 10-4:两个只是名字与签名中类型不同的函数

其中的 largest_i32 函数,即为在清单 10-3 中所提取出的那个,找出某个切片中最大的 i32 函数。而这里的 largest_char 函数则是找出某个切片中的极大 char。由于这两个函数体有着同样代码,因此这里就要通过在单个函数中,引入泛型参数来消除重复。

为将新单一函数中的类型参数化,咱们需要给类型参数命名,就如同咱们对某个函数的那些实参(值参数),the value parameters,所做的那样。可将任意标识符,用作类型参数名字。不过咱们将使用 T,这是因为根据约定,Rust 中的参数名字都是简短的,通常只有一个字母,还因为 Rust 的类型命名约定为驼峰式大小写命名规则(CamelCase)。而 T 作为 “type” 的简写,其便是大多数 Rust 程序员的默认选择了。

当咱们要在函数体中,用到某个参数时,咱们必须在函数签名中声明出这个参数,如此编译器便知道那个名字表示什么。与此类似,当咱们要在函数签名中,用到某个类型参数名字时,在使用该类型参数之前,咱们必须声明出这个类型参数。要定义这个泛型的 largest 函数,就要把类型名字声明,放在尖括号(<>)里,于函数名字与参数列表之间,如下所示:

#![allow(unused)]
fn main() {
fn largest<T>(list: &<T>) -> &T {
}

咱们把这个定义读作:函数 largest 对某个类型 T 通用(the function largest is generic over some type 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! ("极大数为 {}", result);

    let char_list = vec! ['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println! ("极大字符为 {}", result);
}

清单 10-5:使用泛型参数的 largest 函数;此代码尚不会编译

现在编译此代码,将得到如下错误信息:

$ cargo run                                                                                      lennyp@vm-manjaro
   Compiling generics_demo v0.1.0 (/home/lennyp/rust-lang/generics_demo)
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 `generics_demo` due to previous error

帮助文本消息提到 std::cmp::PartialOrd,其是个 特质(trait),而咱们在下一小节就会讲到特质。至于现在,明白这个报错指出了,largest 函数体不会对所有 T 可能的类型工作就行。由于咱们是要在该函数体中,比较两个类型 T 的值,那么咱们就只能使用值可被排序的类型。为能进行比较,标准库便有这个咱们可在类型上应用的 std::cmp::PartialOrd 特质(请参阅附录 C 了解该特质的更多信息)。按照该帮助信息的建议,咱们就要把对 T 有效的类型,限制为仅那些实现了 PartialOrd 的类型,而由于标准库在 i32char 上,均实现了 PartialOrd 特质,那么这个示例就会编译了。

结构体定义方面

In Struct Definitions

咱们也可使用这种 <> 语法,将结构体定义为在其一个或多个字段中使用泛型参数。清单 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:保存类型 Txy 值的 Point<T> 结构体

在结构体定义中使用泛型特性的语法,与在函数定义中用到的类似。首先,在紧接着结构体名字之后,咱们于尖括号内部,声明了类型参数的名字。随后咱们在原本指明具体类型的结构体定义中,用到了那个泛型。

请注意由于咱们只使用了一个泛型来定义 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,因此他们必须为同一类型

在此示例中,当咱们把整数值 5 赋值给 x 时,咱们就让编译器明白,这个 Point<T> 实例的泛型 T 将是个整数。随后在咱们把 4.0 指定给那个已被咱们定义为与 x 有着同一类型的 y 时,咱们将得到一个下面这样的类型不匹配错误:

$ cargo run                                                                                      lennyp@vm-manjaro
   Compiling generics_demo v0.1.0 (/home/lennyp/rust-lang/generics_demo)
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 `generics_demo` due to previous error

要定义出其中 xy 同时为泛型,又可以有着不同类型的 Point 结构体,咱们可使用多个泛型参数。比如,在下面清单 10-8 中,就将 Point 的定义,修改为了对类型 TU 通用,其中 x 为类型 T,而 y 则是类型 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 实例,便都是允许的了!咱们可在某个定义中,使用咱们想要泛型参数个数,不过用多了就会令到代码难于阅读。若发现代码中需要很多泛型,那就可能表示咱们的代码,需要重新组织架构为更小的片段了。

枚举定义方面

In Enum Definitions

如同咱们在结构体下所做的那样,咱们可定义出在其变种中,保存一些通用数据类型的枚举。咱们来换个角度看看,咱们在第 6 章中曾使用过的,标准库所提供的 Option<T>

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

对咱们来说,这个定义现在应有着更多意涵了。可以看到,Option<T> 枚举对类型 T 是通用的,并有着两个变种:保存着一个类型 T 值的 Some,与一个不保存任何值的 None 变种。经由使用这个 Option<T> 枚举,咱们便可表达出可选值,an optional value,的抽象概念,而由于 Option<T> 是通用的,因此咱们就可以在无关乎该可选值为何种类型下,用到这个抽象。

枚举也可以使用多个泛型。在第 9 章中用到的 Result 枚举定义,就是一个示例:

#![allow(unused)]
fn main() {
enum Result <T, E> {
    Ok(T),
    Err(E),
}
}

Result 枚举对 TE 两种类型通用,并有着两个变种:保存了一个类型 T 值的 Ok,与保存了一个类型 E 值的 Err。这个定义使得在某个操作可能成功(便返回某种类型 T 的一个值),或失败(便返回一个某种类型 E 的值)的地方,使用 Result 枚举方便起来。事实上,这正是咱们在清单 9-3 中,打开某个文件时所用到的,在文件被成功打开时,其中的 T 就以 std::fs::File 给填上了,而当打开那个文件时,若存在某些问题,那么其中的 E 就会被 std::io::Error 填充。

当咱们认识到咱们的代码中,有着仅在其所保存值类型方面有区别的多个结构体或枚举的情况时,咱们就可以通过使用泛型避免代码重复。

方法定义方面

In Method Definitions

咱们可以在结构体与枚举上实现方法(正如在第 5 章中咱们所做的),并也可以在他们定义中使用泛型。下面清单 10-9 展示了于其上实现了名为 x 方法的,咱们曾在清单 10-6 中定义的 Point<T> 结构体。

文件名:src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    fn x(&self) -> &T {
        &self.x
    }

    fn y(&self) -> &U {
        &self.y
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println! ("{}, {}", p.x(), p.y());
}

清单 10-9:在 Point<T, U> 结构体上,实现将返回到类型 Tx 字段的引用的一个名为 x 的方法

这里已在 Point<T, U> 上,定义了名为 x 的、返回到字段 x 中数据引用的一个方法。经由在 impl 后,将 T 声明为泛型,Rust 就可以识别出,Point 中尖括号(<>) 里的类型是个泛型而非具体类型。对于这个泛型参数,咱们可以选择不同于前面结构体定义中,所声明的泛型参数名字,但使用同一个名字是依照惯例的。在声明了泛型的 impl 里编写的方法,不论泛型最终将以何种具体类型所代替,这些方法都将定义在该类型的所有实例上。

当咱们在类型上定义方法时,咱们还可以在泛型上指定约束条件。比如,只在 Point<f32> 的实例,而非任意泛型的 Point<T> 实例上实现方法。在下面清单 10-10 中,咱们使用了具体类型 f32,意味着在 impl 之后咱们没有声明任何类型。

文件名:src/main.rs

#![allow(unused)]
fn main() {
impl Point<f32, f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}
}

清单 10-10:只适用于有着特定具体类型泛型参数 <T, U> 的结构体的一个 impl 代码块

此代码表示类型 Option<f32, f32> 将有一个 distance_from_origin 方法;其中 T, U 不是 f32 的其他 Option<T, U> 实例,就不会被定义这个方法。该方法度量了咱们的点与坐标 (0.0, 0.0) 处点的距离,并使用了只对浮点数类型可行的数学运算。

结构体定义中的泛型参数,并不总与咱们在同一结构体方法签名中,所使用的那些泛型参数相同。为让示例更明确,下面清单 10-11 对 Point 结构体,使用了泛型 TU,而对 mixup 方法签名则使用了 X Y。 这个方法使用来自 self Pointx 值(类型为 T),与来自传入的那个 Point 值的 y (类型为 Y),创建出一个新的 Point

文件名:src/main.rs

#[derive(Debug)]
struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    fn mixup<X, Y>(self, other: Point<X, Y>) -> Point<T, Y> {
        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 函数中,咱们定义了一个有着 xi32 (值为 5),及 yf64 (值为 10.4)的 Point。变量 p2 是个有着 x 为字符串切片(值为 Hello),同时 ychar (值为 c)的 Point 结构体。以参数 p2 调用 p1 上的 mixup,就给到咱们 p3,由于 p3x 来自于 p1,因此将有一个 i32x。而由于这个变量 p3y 来自于 p2, 因此他将有一个 chary。那个 println! 宏调用,将打印 p3.x = 5, p3.y = c

此示例的目的,是要对其中有些泛型参数是以 impl 来声明,而另一些泛型参数则是以方法定义来声明的情形,加以演示。由于这里的泛型参数 TU 与结构体定义在一起,因此他们是在 impl 后声明的。而其中的泛型参数 XY,则由于他们只与方法 mixup 有关,所以他们就被声明在了 fn mixup 之后。

用到泛型代码的性能问题

Performance of Code Using Generics

咱们或许想知道,在运用了泛型参数时,是否有着运行时的开销。好消息就是,相比于使用具体类型,使用泛型不会令到咱们的程序运行得更慢。

Rust 通过在编译时,完成那些使用了泛型代码的单态化,performing monomorphization of the code using generics,达成这个目的。所谓 单态化,monomorphization,是指通过把编译后用到的具体类型,填入到泛型位置,而将通用代码转换为具体代码的过程。在此过程中,编译器会执行与清单 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 的泛型在运行时极为高效。

Last change: 2023-11-30, commit: fd9c605