Rust 高级类型实践

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到高级类型。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,我们将讨论 Rust 类型系统中的新类型、类型别名、! 类型和动态大小类型。

高级类型

Rust 类型系统有一些我们之前提到过但尚未讨论的特性。我们将首先总体讨论新类型,探究新类型为何作为类型很有用。然后我们会继续讨论类型别名,这是一种与新类型类似但语义略有不同的特性。我们还将讨论 ! 类型和动态大小类型。

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

注意:本节假设你已阅读前文“使用新类型模式实现外部 trait”。

新类型模式在我们目前讨论的任务之外也很有用,包括静态确保值不会混淆以及指明值的单位。你在清单 19-15 中看到了使用新类型指明单位的示例:回忆一下,MillimetersMeters 结构体将 u32 值包装在一个新类型中。如果我们编写一个带有 Millimeters 类型参数的函数,那么当意外尝试使用 Meters 类型的值或普通 u32 值调用该函数时,程序将无法编译。

我们还可以使用新类型模式来抽象类型的一些实现细节:新类型可以公开一个与私有内部类型的 API 不同的公共 API。

新类型还可以隐藏内部实现。例如,我们可以提供一个 People 类型来包装一个 HashMap<i32, String>,该 HashMap 存储与人员姓名相关联的人员 ID。使用 People 的代码只会与我们提供的公共 API 进行交互,比如向 People 集合中添加姓名字符串的方法;该代码无需知道我们在内部为姓名分配了一个 i32 ID。新类型模式是一种实现封装以隐藏实现细节的轻量级方法,我们在“隐藏实现细节的封装”中讨论过这一点。

使用类型别名创建类型同义词

Rust 提供了声明类型别名的功能,为现有类型赋予另一个名称。为此我们使用 type 关键字。例如,我们可以像这样为 i32 创建别名 Kilometers

type Kilometers = i32;

现在,别名 Kilometersi32同义词;与我们在清单 19-15 中创建的 MillimetersMeters 类型不同,Kilometers 不是一个单独的新类型。具有 Kilometers 类型的值将被视为与 i32 类型的值相同:

type Kilometers = i32;

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

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

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

类型同义词的主要用例是减少重复。例如,我们可能有一个很长的类型,如下所示:

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

在函数签名中以及在整个代码中作为类型注释编写这个冗长的类型既麻烦又容易出错。想象一下有一个项目充满了像清单 19-24 那样的代码。

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

清单 19-24:在多处使用长类型

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

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

清单 19-25:引入类型别名 Thunk 以减少重复

这段代码读写起来要容易得多!为类型别名选择一个有意义的名称也有助于传达你的意图(thunk 是指稍后要计算的代码,所以它是存储闭包的合适名称)。

类型别名也常用于 Result<T, E> 类型以减少重复。考虑标准库中的 std::io 模块。I/O 操作通常返回一个 Result<T, E> 来处理操作失败的情况。这个库有一个 std::io::Error 结构体来表示所有可能的 I/O 错误。std::io 中的许多函数将返回 Result<T, E>,其中 Estd::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 有这样的类型别名声明:

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

因为这个声明在 std::io 模块中,我们可以使用完全限定的别名 std::io::Result<T>;也就是说,一个 Result<T, E>,其中 E 被填充为 std::io::ErrorWrite trait 的函数签名最终看起来像这样:

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> 的方法,以及像 ? 运算符这样的特殊语法。

永远不会返回的空类型

Rust 有一个特殊的类型 !,在类型理论术语中被称为空类型,因为它没有值。我们更愿意称它为永远类型,因为当一个函数永远不会返回时,它会出现在返回类型的位置。这里有一个例子:

fn bar() ->! {
    --snip--
}

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

但是一个你永远无法创建值的类型有什么用呢?回忆一下清单 2-5 中的代码,这是数字猜谜游戏的一部分;我们在清单 19-26 中重现了其中的一部分。

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

清单 19-26:一个以 continue 结尾的 match 分支

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

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

这段代码中 guess 的类型必须既是整数又是字符串,而 Rust 要求 guess 只能有一种类型。那么 continue 返回什么呢?在清单 19-26 中,我们怎么能从一个分支返回 u32,而另一个分支以 continue 结束呢?

你可能已经猜到了,continue 有一个 ! 值。也就是说,当 Rust 计算 guess 的类型时,它会查看两个 match 分支,前一个分支的值是 u32,后一个分支的值是 !。因为 ! 永远不可能有值,所以 Rust 决定 guess 的类型是 u32

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

永远类型在 panic! 宏中也很有用。回忆一下我们在 Option<T> 值上调用的 unwrap 函数,它用于产生一个值或者根据这个定义引发恐慌:

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

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

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

print!("forever ");

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

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

动态大小类型与 Sized 特性

Rust 需要了解其类型的某些细节,例如为特定类型的值分配多少空间。这使得其类型系统的一个角落一开始有点令人困惑:动态大小类型的概念。这些类型有时也被称为DST未指定大小类型,它们允许我们使用只有在运行时才能知道其大小的值来编写代码。

让我们深入探讨一种名为 str 的动态大小类型的细节,我们在整本书中都一直在使用它。没错,不是 &str,而是单独的 str 本身就是一个 DST。直到运行时我们才知道字符串有多长,这意味着我们不能创建 str 类型的变量,也不能接受 str 类型的参数。考虑以下无法正常工作的代码:

let s1: str = "Hello there!";
let s2: str = "How's it going?";

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

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

我们可以将 str 与各种指针结合使用:例如,Box<str>Rc<str>。实际上,你之前已经见过这种情况,但使用的是不同的动态大小类型:trait。每个 trait 都是一个动态大小类型,我们可以通过 trait 的名称来引用它。在“使用允许不同类型值的 trait 对象”中,我们提到要将 trait 用作 trait 对象,我们必须将它们放在指针后面,比如 &dyn TraitBox<dyn Trait>Rc<dyn Trait> 也可以)。

为了处理 DST,Rust 提供了 Sized 特性来确定一个类型的大小在编译时是否已知。这个特性会自动为所有在编译时大小已知的类型实现。此外,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,我们需要在某种指针后面使用它。在这种情况下,我们选择了引用。

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

总结

恭喜你!你已经完成了高级类型实验。你可以在 LabEx 中练习更多实验来提升你的技能。