高级类型

Rust 类型系统有一些我们迄今为止提到但尚未讨论的特性。我们将从讨论新类型(newtypes)开始,探讨新类型作为类型为何有用。然后,我们将讨论类型别名,这是一种与新类型相似但语义略有不同的特性。我们还将讨论 ! 类型和动态大小类型。

使用 Newtype 模式进行类型安全和抽象

注意:本节假设您已阅读了前面的部分 “使用 Newtype 模式在外部类型上实现外部特征。”

新类型模式对于我们迄今为止讨论的任务之外的任务也非常有用,包括静态强制值永不混淆和指示值的单位。您在清单 20-15 中看到了使用新类型来指示单位的示例:MillimetersMeters 结构体将 u32 值包装在新类型中。如果我们编写了一个参数类型为 Millimeters 的函数,我们就不能编译一个意外尝试用类型为 Meters 或普通 u32 的值调用该函数的程序。

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

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

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

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

fn main() {
    type Kilometers = i32;

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

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

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

fn main() {
    type Kilometers = i32;

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

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

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

类型别名的主要用例是减少重复。例如,我们可能有一个像这样的冗长类型:

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

在函数签名和类型注释中写这样 lengthy 的类型,以及在整个代码中到处写,可能会很累人且容易出错。想象一下,有一个项目充满了像清单 20-24 中那样的代码。

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(|| ())
    }
}
Listing 20-24: Using a long type in many places

类型别名通过减少重复使这段代码更易于管理。在列表 20-25 中,我们引入了一个名为 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(|| ())
    }
}
Listing 20-25: Introducing a type alias Thunk to reduce repetition

这段代码更容易阅读和编写!为类型别名选择一个有意义的名称也有助于传达您的意图(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特质中的这些函数:

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>;也就是说,一个 Result<T, E>,其中 E 被填充为 std::io::ErrorWrite 特性函数签名最终看起来像这样:

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 有一个特殊类型 !,在类型理论术语中被称为 空类型,因为它没有值。我们更喜欢称它为 永不返回类型,因为它在函数永远不会返回时用作返回类型。这里是一个例子:

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

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

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

use rand::Rng;
use std::cmp::Ordering;
use std::io;

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

    let secret_number = rand::thread_rng().gen_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;
            }
        }
    }
}
Listing 20-26: A match with an arm that ends in continue

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

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

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

正如你可能已经猜到的,continue 有一个 ! 值。也就是说,当 Rust 计算 guess 的类型时,它会查看两个匹配臂,前者有一个 u32 值,后者有一个 ! 值。因为 ! 永远不会有值,Rust 决定 guess 的类型是 u32

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

从不类型与 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-26 中的 match 一样:Rust 看到 val 有类型 Tpanic! 有类型 !,因此整个 match 表达式的结果是 T。这段代码可以工作,因为 panic! 不会产生值;它会结束程序。在 None 情况下,我们不会从 unwrap 返回值,所以这段代码是有效的。

一个最终表达式,其类型为!,是一个loop

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

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

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

动态大小类型和Sized特征

Rust 需要知道其类型的一些详细信息,例如为特定类型的值分配多少空间。这使得其类型系统的一个角落一开始有点令人困惑:即 动态大小类型 的概念。有时称为 DSTs未定大小类型,这些类型让我们可以编写使用值的代码,而这些值的大小我们只能在运行时知道。

让我们深入探讨一个称为 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 字节。这就是为什么无法创建一个持有动态大小类型的变量。

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

我们可以将str与各种指针结合使用:例如,Box<str>Rc<str>。事实上,你之前已经见过这种情况,但使用的是不同的动态大小类型:特质。每个特质都是一个可以通过特质名称引用的动态大小类型。在第18章的“使用允许不同类型的值的特质对象”部分,我们提到,要将特质用作特质对象,我们必须将它们放在指针后面,例如&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 上的特征约束意味着 “T 可能是或可能不是 Sized”,并且此标记会覆盖泛型类型在编译时必须具有已知大小的默认要求。这种含义的 ?Trait 语法仅适用于 Sized,不适用于任何其他特征。

还请注意,我们将 t 参数的类型从 T 更改为 &T。因为该类型可能不是 Sized,所以我们需要在某种指针后面使用它。在这种情况下,我们选择了一个引用。

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