高级类型

Rust 的类型系统有一些我们到目前为止提到过但尚未讨论的特性。我们将首先讨论一般的新类型 (newtype),因为我们要研究为什么新类型作为类型是有用的。然后我们将继续讨论类型别名,这是一种类似于新类型但语义略有不同的特性。我们还将讨论 ! 类型和动态大小类型。

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

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

newtype 模式对于超出我们目前讨论过的任务也很有用,包括静态地强制值永远不会混淆以及指示值的单位。您在 Listing 20-16 中看到了使用 newtype 指示单位的示例:回想一下 MillimetersMeters 结构体将 u32 值包装在一个 newtype 中。如果我们编写了一个带有 Millimeters 类型参数的函数,我们将无法编译一个意外尝试使用 Meters 类型或普通 u32 类型的值调用该函数的程序。

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

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

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

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

fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }

现在,别名 Kilometersi32同义词;与我们在 Listing 20-16 中创建的 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 参数的函数。但是,使用这种方法,我们不会获得从前面讨论的 newtype 模式中获得的类型检查好处。换句话说,如果我们在某处混淆了 Kilometersi32 值,编译器不会给我们报错。

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

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

在函数签名中以及作为代码中各处的类型注释编写这种冗长的类型可能既乏味又容易出错。想象一下,有一个项目充满了 Listing 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(|| ()) } }
Listing 20-25:在许多地方使用长类型

类型别名通过减少重复使此代码更易于管理。在 Listing 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(|| ()) } }
Listing 20-26:引入类型别名 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 特征中的这些函数

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 提供了Consistent的接口。因为它是一个别名,所以它只是另一个 Result<T, E>,这意味着我们可以使用任何适用于 Result<T, E> 的方法,以及像 ? 运算符这样的特殊语法。

永不返回的 Never 类型

Rust 有一个名为 ! 的特殊类型,在类型理论中被称为空类型,因为它没有值。我们更喜欢称其为 never 类型,因为它在函数永远不会返回时充当返回类型的位置。这是一个例子

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

此代码被解读为“函数 bar 永不返回”。返回 never 的函数称为发散函数。我们无法创建 ! 类型的值,因此 bar 永远不可能返回。

但是,您永远无法为其创建值的类型有什么用呢?回想一下 Listing 2-5 中的代码,这是数字猜谜游戏的一部分;我们在 Listing 20-27 中重新生成了其中的一部分。

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-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 返回什么呢?在 Listing 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"), } } }

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

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

fn main() { print!("forever "); loop { print!("and ever "); } }

在这里,循环永远不会结束,因此 ! 是表达式的值。但是,如果我们包含 break,情况就不是这样了,因为循环会在到达 break 时终止。

动态大小类型和 Sized 特征

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

让我们深入研究一个名为 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 引用的字符串有多长,我们始终知道 &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,所以我们需要在某种指针后面使用它。在这种情况下,我们选择了一个引用。

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