高级类型
Rust 类型系统具有一些我们目前已经提到但尚未讨论的功能。我们将从一般性地讨论 newtypes 开始,因为我们研究了为什么 newtype 作为类型很有用。然后我们将继续讨论类型别名,这是一个类似于 newtypes 的功能,但语义略有不同。我们还将讨论 !
类型和动态大小的类型。
使用 newtype 模式实现类型安全和抽象
注意:本节假设您已经阅读了前面的部分 “使用
newtype 模式来实现 external trait
类型。
newtype 模式对于我们目前讨论的任务之外的任务也很有用,包括静态强制值永远不会混淆和指示值的单位。你在示例 19-15 中看到了一个使用 newtype 来表示单位的例子:回想一下 Millimeters
和 Meters
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); }
现在,别名 Kilometers
是 i32
的同义词;与毫米
不同
和 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
值,编译器不会给我们错误。
类型同义词的主要用例是减少重复。例如,我们可能有一个像这样的 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>
,其中 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>;
}
结果<...,错误>
重复了很多次。因此,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::Error
。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 返回的 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 字节的存储空间。这就是为什么无法创建包含动态大小类型的变量的原因。
那么我们该怎么办呢?在这种情况下,您已经知道答案:我们将 s1
和 s2
的类型设为 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 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
表示“T
可能被调整大小,也可能不被调整大小
”,并且此表示法覆盖了泛型类型在编译时必须具有已知大小的默认值。这 ?具有此含义的 Trait
语法仅适用于
大小
,没有任何其他特征。
另请注意,我们将 t
参数的类型从 T
切换到 &T
。因为类型可能不是 Sized
,所以我们需要在某种指针后面使用它。在本例中,我们选择了一个参考。
接下来,我们将讨论函数和闭包!