高级类型
Rust 类型系统有一些我们目前提到但尚未讨论的功能。我们将首先讨论通常的 newtype,并研究为什么 newtype 作为类型是有用的。然后我们将转到类型别名,它是一个类似于 newtype 但语义略有不同的功能。我们还将讨论 !
类型和动态大小类型。
使用 Newtype 模式进行类型安全和抽象
注意:本节假设您已阅读前面的章节 “使用 Newtype 模式在外部类型上实现外部特征”。
newtype 模式对于超出我们迄今为止讨论的任务也很有用,包括静态地强制值永远不会混淆以及指示值的单位。您在示例 19-15 中看到了使用 newtype 指示单位的示例:回想一下,Millimeters
和 Meters
结构体在 newtype 中包装了 u32
值。如果我们编写一个具有 Millimeters
类型参数的函数,我们将无法编译意外尝试使用 Meters
类型或普通 u32
值调用该函数的程序。
我们还可以使用 newtype 模式来抽象类型的某些实现细节:新类型可以公开一个与私有内部类型的 API 不同的公共 API。
Newtype 还可以隐藏内部实现。例如,我们可以提供一个 People
类型来包装一个 HashMap<i32, String>
,该类型存储与人名相关联的人员 ID。使用 People
的代码只会与我们提供的公共 API 交互,例如将名称字符串添加到 People
集合的方法;该代码不需要知道我们在内部将 i32
ID 分配给名称。newtype 模式是一种轻量级的方式来实现封装以隐藏实现细节,我们在 “封装隐藏实现细节”第 17 章中的章节讨论过。
使用类型别名创建类型同义词
Rust 提供了声明类型别名的能力,以给现有类型另一个名称。为此,我们使用 type
关键字。例如,我们可以创建一个 Kilometers
作为 i32
的别名,如下所示
fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }
现在,别名 Kilometers
是 i32
的同义词;与我们在示例 19-15 中创建的 Millimeters
和 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
值,编译器不会给我们错误。
类型同义词的主要用例是减少重复。例如,我们可能会有一个像这样的冗长类型
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:在许多地方使用长类型
类型别名通过减少重复使此代码更易于管理。在示例 19-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(|| ()) } }
示例 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
特征中的这些函数
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::Error
。Write
特征函数签名最终如下所示
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 有一个名为 !
的特殊类型,在类型理论术语中称为空类型,因为它没有值。我们更喜欢称其为never 类型,因为它在函数永远不会返回时充当返回类型的位置。这是一个例子
fn bar() -> ! {
// --snip--
panic!();
}
这段代码被读取为“函数 bar
永远不会返回”。返回 never 的函数称为发散函数。我们无法创建 !
类型的值,因此 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:一个以 continue
结尾的 match
当时,我们跳过了此代码中的一些细节。在第 6 章中的 “match
控制流运算符”部分中,我们讨论了 match
分支必须全部返回相同的类型。因此,例如,以下代码不起作用
fn main() {
let guess = "3";
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
这段代码中 guess
的类型必须是整数和字符串,并且 Rust 要求 guess
只有一个类型。那么 continue
返回什么?我们如何允许从一个分支返回 u32
,并在示例 19-26 中有另一个以 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"),
}
}
}
在此代码中,发生了与示例 19-26 中的 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 个字节。这就是为什么无法创建保存动态大小类型的变量的原因。
那么我们该怎么办呢?在这种情况下,您已经知道答案:我们将 s1
和 s2
的类型设为 &str
而不是 str
。回想一下 “字符串切片”第 4 章中的部分,切片数据结构只是存储切片的起始位置和长度。因此,尽管 &T
是一个存储 T
所在内存地址的单个值,但 &str
是两个值:str
的地址及其长度。因此,我们可以在编译时知道 &str
值的大小:它是 usize
长度的两倍。也就是说,我们始终知道 &str
的大小,无论它引用的字符串有多长。一般来说,这是在 Rust 中使用动态大小类型的方式:它们具有一个额外的元数据位,用于存储动态信息的大小。动态大小类型的黄金法则是,我们必须始终将动态大小类型的值放在某种指针后面。
我们可以将 str
与各种指针组合使用:例如,Box<str>
或 Rc<str>
。实际上,您以前见过这种情况,但使用了一种不同的动态大小类型:特征。每个特征都是一个动态大小的类型,我们可以使用特征的名称来引用它。在第 17 章的 “使用允许不同类型值的特征对象”部分中,我们提到要使用特征作为特征对象,我们必须将其放在指针后面,例如 &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
的 trait 约束意味着 “T
可能 Sized
也可能不是 Sized
”,并且此表示法会覆盖泛型类型必须在编译时具有已知大小的默认行为。具有此含义的 ?Trait
语法仅适用于 Sized
,而不适用于任何其他 trait。
还要注意,我们将 t
参数的类型从 T
更改为了 &T
。因为该类型可能不是 Sized
,所以我们需要使用某种指针来使用它。在这种情况下,我们选择了引用。
接下来,我们将讨论函数和闭包!