高级类型


Rust 类型系统具有一些我们目前已经提到但尚未讨论的功能。我们将从一般性地讨论 newtypes 开始,因为我们研究了为什么 newtype 作为类型很有用。然后我们将继续讨论类型别名,这是一个类似于 newtypes 的功能,但语义略有不同。我们还将讨论 类型和动态大小的类型。


使用 newtype 模式实现类型安全和抽象


注意:本节假设您已经阅读了前面的部分 “使用 newtype 模式来实现 external trait 类型。


newtype 模式对于我们目前讨论的任务之外的任务也很有用,包括静态强制值永远不会混淆和指示值的单位。你在示例 19-15 中看到了一个使用 newtype 来表示单位的例子:回想一下 MillimetersMeters structs 将 u32 值包装在 newType 中。如果我们编写一个参数为 Millimeters 的函数,则无法编译一个意外尝试使用 Meters 类型或普通 u32 的值调用该函数的程序。


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


newtypes 还可以隐藏内部实现。例如,我们可以提供 人员键入以包装 HashMap<i32, String>,该 ID 存储与其姓名关联的人员 ID。使用 People 的代码只会与我们提供的公共 API 进行交互,例如向 People 添加名称字符串的方法 收集;该代码不需要知道我们在内部为名称分配了 i32 ID。newtype 模式是一种实现封装以隐藏实现细节的轻量级方法,我们在“封装 隐藏实现 详情” 第 17 章的章节。


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


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

fn main() {
    type Kilometers = i32;

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

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


现在,别名 Kilometersi32同义词;与毫米不同 和 Meters 类型,Kilometers 不是一个单独的新类型。类型为 Kilometers 的值将与 i32 类型的值相同:

fn main() {
    type Kilometers = i32;

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

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


因为 Kilometersi32 是相同的类型,所以我们可以添加这两种类型的值,并且可以将 Kilometers 值传递给接受 i32 的函数 参数。但是,使用这种方法,我们没有获得类型检查的好处 我们从前面讨论的 newType 模式中得到。换句话说,如果我们 在某个地方混淆 Kilometersi32 值,编译器不会给我们错误。


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

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


在函数签名中编写这个冗长的类型,并在整个代码中编写类型注释可能会很烦人且容易出错。想象一下,有一个充满示例 19-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(|| ())
    }
}


示例 19-24:在许多地方使用 long 类型


类型别名通过减少重复性使此代码更易于管理。在示例 19-25 中,我们为 verbose 类型引入了一个名为 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(|| ())
    }
}


示例 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>;
}


结果<...,错误> 重复了很多次。因此,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 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 返回的 Never 类型


Rust 有一个名为 的特殊类型,在类型论术语中称为 empty 类型,因为它没有值。我们更喜欢将其称为 never 类型 因为它位于 return 类型的位置,而函数永远不会 返回。下面是一个示例:

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


此代码被读取为“函数返回 never”。返回 never 的函数称为 diverging 函数。我们不能创建类型 所以 bar 永远不可能回来。


但是,您永远无法为其创建值的类型有什么用呢?回想一下示例 2-5 中的代码,这是猜数字游戏的一部分;我们在示例 19-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;
            }
        }
    }
}


示例 19-26:一个 arm 结尾为 继续


当时,我们跳过了这段代码中的一些细节。在第 6 章的“, 火柴控制流作员” 部分中,我们讨论了 match arms 必须都返回相同的类型。因此,例如,以下代码不起作用:

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


这段代码中的 guess 类型必须是一个整数和一个字符串,而 Rust 要求 guess 只有一种类型。那么,什么会继续 返回?在示例 19-26 中,我们是如何允许从一个 arm 返回一个 u32 并让另一个 arm 以 continue 结尾的呢?


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


描述此行为的正式方式是,类型 的表达式可以强制转换为任何其他类型。我们被允许以 continue ,因为 continue 不返回值;相反,它将控制权移回循环的顶部,因此在 Err 情况下,我们永远不会将值分配给 猜猜


never 类型对 panic! 宏也很有用。撤回 unwrap 函数,我们在 Option<T> values 上调用它来产生一个值,或者用这个定义产生一个 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"),
        }
    }
}


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


类型 的最后一个表达式是一个循环

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

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


在这里,循环永远不会结束,所以 是表达式的值。但是,如果我们包含一个 break,则不会如此,因为循环会在到达 break 时终止。


动态大小的类型和 Sized 特征


Rust 需要知道有关其类型的某些详细信息,例如为特定类型的值分配多少空间。这使得它的类型系统的一个角落起初有点令人困惑:动态大小类型的概念。有时称为 DST未调整大小的类型,这些类型允许我们使用只能在运行时知道其大小的值编写代码。


让我们深入研究一下名为 str 的动态大小的类型的详细信息,我们在整本书中一直在使用该类型。没错,不是 &str,而是 str 本身,是一个 DST。在 runtime 之前,我们无法知道字符串有多长,这意味着我们不能创建 str 类型的变量,也不能接受 str 类型的参数 str 的请考虑以下代码,该代码不起作用:

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


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


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


我们可以将 str 与各种指针结合使用:例如,Box<str>Rc<str>.事实上,您以前已经见过这种情况,但使用不同的动态大小类型:traits。每个 trait 都是一个动态大小的类型,我们可以使用 trait 的名称来引用。在第 17 章的“使用 trait 对象 允许不同的值 Types“部分,我们提到要将 trait 用作 trait 对象,我们必须将它们放在指针后面,例如 &dyn TraitBox<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 表示“T 可能被调整大小,也可能不被调整大小”,并且此表示法覆盖了泛型类型在编译时必须具有已知大小的默认值。这 ?具有此含义的 Trait 语法仅适用于 大小,没有任何其他特征。


另请注意,我们将 t 参数的类型从 T 切换到 &T。因为类型可能不是 Sized,所以我们需要在某种指针后面使用它。在本例中,我们选择了一个参考。


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