键盘快捷键

按 ← 或 → 在章节之间导航

按 S 或 / 在书中搜索

按 ? 显示此帮助

按 Esc 隐藏此帮助

高级类型

Rust 类型系统有一些我们此前提到过但尚未详细讨论的特性。我们将首先总体上讨论 newtype,并探究它们作为类型为何有用。接着我们会转向类型别名(type alias),这是一个与 newtype 类似但语义略有不同的特性。我们还会讨论 ! 类型和动态大小类型(dynamically sized types)。

使用 Newtype 模式实现类型安全与抽象

本节假设你已经阅读了之前的章节“使用 Newtype 模式实现外部 trait”。newtype 模式还可用于一些我们尚未讨论的任务,包括静态地确保值永远不会被混淆,以及标示值的单位。你在示例 20-16 中已经见过使用 newtype 标示单位的例子:回想一下,Millimeters 和 Meters 结构体将 u32 值包装在 newtype 中。如果我们编写了一个参数类型为 Millimeters 的函数,那么一个不小心试图用 Meters 类型或普通 u32 类型的值调用该函数的程序将无法编译通过。

我们还可以使用 newtype 模式来抽象掉类型的某些实现细节:新类型可以暴露与其私有内部类型不同的公有 API。

Newtype 也可以隐藏内部实现。例如,我们可以提供一个 People 类型来包装存储人员 ID 及其姓名的 HashMap<i32, String>。使用 People 的代码只需与我们提供的公有 API 交互,比如向 People 集合添加姓名字符串的方法;这些代码不需要知道我们在内部为姓名分配了 i32 ID。newtype 模式是实现封装以隐藏实现细节的一种轻量级方式,我们曾在第 18 章的“隐藏实现细节的封装”一节中讨论过这一点。

类型同义词与类型别名

Rust 提供了声明类型别名(type alias)的能力,可以为已有类型赋予另一个名字。为此我们使用 type 关键字。例如,我们可以像下面这样为 i32 创建别名 Kilometers:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

现在,别名 Kilometers 是 i32 的同义词;与我们在示例 20-16 中创建的 Millimeters 和 Meters 类型不同,Kilometers 不是一个独立的新类型。具有 Kilometers 类型的值将被当作 i32 类型的值一样对待:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

因为 Kilometers 和 i32 是相同的类型,我们可以将两种类型的值相加,也可以将 Kilometers 值传递给接收 i32 参数的函数。然而,使用这种方式,我们无法获得前文讨论的 newtype 模式带来的类型检查优势。换句话说,如果我们在某处混淆了 Kilometers 和 i32 的值,编译器不会报错。

类型同义词的主要用途是减少重复。例如,我们可能有这样一个冗长的类型:

Box<dyn Fn() + Send + 'static>

在整个代码中的函数签名和类型注解里反复书写这个冗长的类型会很乏味且容易出错。想象一下,如果项目里到处都是像示例 20-25 那样的代码。

fn main() {
    let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

    fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
        // --snip--
    }

    fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
        // --snip--
        Box::new(|| ())
    }
}
示例 20-25:在多处使用冗长的类型

类型别名通过减少重复使这段代码更易于管理。在示例 20-26 中,我们为冗长的类型引入了一个名为 Thunk 的别名,并可以用更短的别名 Thunk 替换该类型的所有使用之处。

fn main() {
    type Thunk = Box<dyn Fn() + Send + 'static>;

    let f: Thunk = Box::new(|| println!("hi"));

    fn takes_long_type(f: Thunk) {
        // --snip--
    }

    fn returns_long_type() -> Thunk {
        // --snip--
        Box::new(|| ())
    }
}
示例 20-26:引入类型别名 Thunk 以减少重复

这段代码更易于阅读和书写了!为类型别名选择一个有意义的名字也有助于传达你的意图(thunk 指的是稍后再求值的代码,因此它是存储闭包的一个恰当名字)。

类型别名也常与 Result<T, E> 类型配合使用以减少重复。考虑一下标准库中的 std::io 模块。I/O 操作通常会返回 Result<T, E> 来处理操作失败的情况。该库有一个 std::io::Error 结构体,表示所有可能的 I/O 错误。std::io 中的许多函数会返回 Result<T, E>,其中 E 是 std::io::Error,比如 Write trait 中的这些函数:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

Result<..., Error> 重复出现了很多次。因此,std::io 中有如下的类型别名声明:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

由于该声明位于 std::io 模块中,我们可以使用完全限定别名 std::io::Result<T>;也就是 E 被填充为 std::io::Error 的 Result<T, E>。Write trait 的函数签名最终看起来像这样:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

类型别名在两个方面有所帮助:它使代码更易于编写,并且为整个 std::io 提供了一致的接口。因为它是一个别名,本质上就是另一个 Result<T, E>,这意味着我们可以在其上使用任何适用于 Result<T, E> 的方法,以及像 ? 运算符这样的特殊语法。

永不返回的 Never 类型

Rust 有一个名为 ! 的特殊类型,在类型理论术语中被称为空类型(empty type),因为它没有任何值。我们更倾向于称它为 never 类型,因为它在函数永不返回时充当返回类型。下面是一个例子:

fn bar() -> ! {
    // --snip--
    panic!();
}

这段代码读作“函数 bar 返回 never”。返回 never 的函数被称为发散函数(diverging functions)。我们无法创建 ! 类型的值,所以 bar 永远不可能返回。

但一个永远无法创建值的类型有什么用呢?回想一下示例 2-5 中猜数字游戏的代码;我们在示例 20-27 中复现了其中一小部分。

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
示例 20-27:一个以 continue 结尾的 match 分支

当时,我们跳过了这段代码中的一些细节。在第 6 章的“match 控制流结构”一节中,我们讨论过 match 的所有分支必须返回相同的类型。所以,例如下面的代码就无法工作:

fn main() {
    let guess = "3";
    let guess = match guess.trim().parse() {
        Ok(_) => 5,
        Err(_) => "hello",
    };
}

这段代码中 guess 的类型必须既是整数又是字符串,而 Rust 要求 guess 只能有一个类型。那么,continue 返回什么呢?在示例 20-27 中,我们为什么可以允许一个分支返回 u32,而另一个分支以 continue 结尾呢?

正如你可能猜到的那样,continue 的值是 !。也就是说,当 Rust 计算 guess 的类型时,它会查看两个 match 分支:前者是 u32 类型的值,后者是 ! 类型的值。因为 ! 永远不可能有值,所以 Rust 判定 guess 的类型是 u32。

描述这种行为的正式说法是:类型为 ! 的表达式可以被强转为任意其他类型。我们被允许以 continue 结束这个 match 分支,因为 continue 不返回值;相反,它将控制权交回循环顶部,所以在 Err 的情况下,我们从未给 guess 赋值。

never 类型与 panic! 宏配合时也很有用。回想一下我们对 Option<T> 值调用的 unwrap 函数,它产生一个值或触发 panic,其定义如下:

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

在这段代码中,发生的情况与示例 20-27 中的 match 相同:Rust 看到 val 的类型是 T,而 panic! 的类型是 !,所以整个 match 表达式的结果是 T。这段代码能工作是因为 panic! 不产生值;它会终止程序。在 None 的情况下,我们不会从 unwrap 返回任何值,所以这段代码是有效的。

最后一个具有 ! 类型的表达式是 loop:

fn main() {
    print!("forever ");

    loop {
        print!("and ever ");
    }
}

这里,循环永远不会结束,所以该表达式的值是 !。然而,如果我们包含一个 break,情况就不同了,因为循环在遇到 break 时会终止。

动态大小类型与 Sized Trait

Rust 需要了解其类型的某些细节,比如要为特定类型的值分配多少空间。这使其类型系统的一个角落起初有些令人困惑:动态大小类型(dynamically sized types)的概念。这些类型有时被称为 DST 或不定长类型(unsized types),它们让我们能够编写使用只有在运行时才能知道大小的值的代码。

让我们深入研究一个贯穿本书一直在使用的动态大小类型 str 的细节。没错,不是 &str,而是 str 本身就是一个 DST。在许多情况下,比如存储用户输入的文本时,我们直到运行时才能知道字符串有多长。这意味着我们无法创建 str 类型的变量,也不能接收 str 类型的参数。考虑下面这段无法工作的代码:

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust 需要知道为特定类型的任意值分配多少内存,并且一个类型的所有值必须使用相同大小的内存。如果 Rust 允许我们编写这样的代码,这两个 str 值就需要占用相同大小的空间。但它们的长度不同:s1 需要 12 字节的存储空间,而 s2 需要 15 字节。这就是为什么不可能创建持有动态大小类型的变量。

那么该怎么办呢?在这种情况下,你已经知道答案了:我们把 s1 和 s2 的类型设为字符串切片(&str)而不是 str。回想一下第 4 章“字符串切片”一节,切片数据结构只存储切片的起始位置和长度。所以,虽然 &T 是存储 T 所在内存地址的单个值,但字符串切片是两个值:str 的地址和它的长度。因此,我们可以在编译时知道字符串切片值的大小:它是 usize 长度的两倍。也就是说,无论字符串切片引用的字符串有多长,我们总是知道字符串切片的大小。一般来说,这就是动态大小类型在 Rust 中的使用方式:它们带有一段额外的元数据来存储动态信息的大小。动态大小类型的黄金法则是,我们必须始终将动态大小类型的值置于某种指针之后。

我们可以将 str 与各种指针组合使用:例如 Box<str> 或 Rc<str>。实际上,你之前已经见过这种情况,不过是另一种动态大小类型:trait。每个 trait 都是一个可以通过 trait 名称引用的动态大小类型。在第 18 章的“使用 trait 对象抽象共享行为”一节中,我们提到要将 trait 用作 trait 对象,必须将它们置于指针之后,比如 &dyn Trait 或 Box<dyn Trait>(Rc<dyn Trait> 也可以)。

为了处理 DST,Rust 提供了 Sized trait 来判断一个类型的大小是否在编译时已知。对于编译时大小已知的一切类型,都会自动实现该 trait。此外,Rust 会隐式地为每个泛型函数添加 Sized 约束。也就是说,一个像这样的泛型函数定义:

fn generic<T>(t: T) {
    // --snip--
}

实际上会被当作我们写了下面这样的代码来处理:

fn generic<T: Sized>(t: T) {
    // --snip--
}

默认情况下,泛型函数只能作用于编译时大小已知的类型。然而,你可以使用如下特殊语法来放宽这一限制:

fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

?Sized 上的 trait 约束意味着“T 可能是也可能不是 Sized”,这种写法覆盖了泛型类型必须在编译时具有已知大小的默认要求。具有这种含义的 ?Trait 语法仅适用于 Sized,不适用于任何其他 trait。

另外请注意,我们将 t 参数的类型从 T 换成了 &T。因为该类型可能不是 Sized,所以我们需要在某种指针之后使用它。在这个例子中,我们选择了引用。

接下来,我们将讨论函数和闭包!