19.5 高级类型
19.5.1 使用 newtype 模式实现类型安全和抽象
在 19.2. 高级 trait:关联类型、默认泛型参数和运算符重载、完全限定语法、supertrait 和 newtype 中,我们已经使用 newtype 模式为 Vec 实现了 Display(更具体地说,是在 19.2.6,使用 newtype 模式在外部类型上实现外部 trait)。
在 19.2. 高级 trait:关联类型、默认泛型参数和运算符重载、完全限定语法、supertrait 和 newtype(19.2.2「默认泛型参数和运算符重载」)中,我们还写过 Millimeters 和 Meters 结构体,分别存储毫米和米的值。因为这两个值不能直接加减,这就避免了单位被误混用。
我们还可以用 newtype 模式抽象其它特性:
- 新类型可以暴露与其私有内部类型不同的公共 API
- 新类型可以隐藏内部实现(如 17.1. Rust的面向对象的编程特性「封装」中提到的)
19.5.2 类型别名
Rust 提供了声明类型别名的能力,从而给现有类型另一个名字,这有点像泛型。
要使用类型别名,使用 type 关键字。例如:
#![allow(unused)]
fn main() {
type Kilometers = i32;
}
我们把 Kilometers 称为 i32 的同义词。你可以像使用 i32 一样使用 Kilometers:
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
- 因为
Kilometers和i32是相同类型,所以我们可以把这两种类型的值相加。
类型同义词的主要用例是减少重复。例如,我们可能有这样一个很长的类型:
#![allow(unused)]
fn main() {
Box<dyn Fn() + Send + 'static>
}
在整个代码库的函数签名和类型标注中反复写这么长的类型,既繁琐又容易出错。例如:
#![allow(unused)]
fn main() {
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// ...
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// ...
}
}
类型别名通过减少重复,让这段代码更容易管理,而且有意义的名字更能表达意图。我们可以把上面的代码改写成:
#![allow(unused)]
fn main() {
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi"));
fn takes_long_type(f: Thunk) {
// ...
}
fn returns_long_type() -> Thunk {
// ...
}
}
类型别名也经常与 Result<T, E> 一起使用,以减少重复。例如:
#![allow(unused)]
fn main() {
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>;
}
}
I/O 操作通常返回 Result<T, E> 来处理失败。std::io::Error 表示所有可能的 I/O 错误。std::io 中的许多函数返回 Result<T, E>,其中 E 是 std::io::Error。
Result<..., Error> 重复了很多次,所以 std::io 使用了类型别名:
#![allow(unused)]
fn main() {
type Result<T> = std::result::Result<T, std::io::Error>;
}
于是 Write trait 的方法签名看起来像这样:
#![allow(unused)]
fn main() {
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>的任何方法,以及像?运算符这样的特殊语法(在 9.3. Result枚举与可恢复的错误 Pt.2:传播错误、问号运算符与链式调用「?运算符」中讨论过)。
19.5.3 Never 类型
Rust 有一个特殊类型叫 !。在类型理论中,它被称为空类型,因为它没有值。我们更喜欢称它为 never 类型,因为它出现在函数的返回类型位置。
例如:
#![allow(unused)]
fn main() {
fn bar() -> ! {
}
}
这段代码的含义是:“函数 bar 永不返回。”永不返回的函数叫做发散函数。
那么 never 类型有什么用?我们用 2.4. 猜数游戏Pt.4 循环询问 中的一段代码:
#![allow(unused)]
fn main() {
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
}
这样写没问题。但如果我们写成这样呢?
#![allow(unused)]
fn main() {
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
这段代码会失败,因为 match 的两个分支返回了不同类型。Rust 是强类型语言,所以必须知道值的确切类型。guess 可能是 i32 或 &str,但 Rust 要求 guess 只能有一种类型。
换句话说,在这种形式下,match 的所有分支都必须返回相同类型。
再回看正确的代码:Ok 分支返回 u32,那 Err 分支中的 continue 返回什么类型?如果它是单元类型 (),也就是没有返回值,Rust 就无法判断 guess 究竟是 u32 还是 ()。
这就是 never 类型发挥作用的地方:continue 的返回类型是 !。换句话说,当 Rust 检查 guess 的类型时,它会查看两个 match 分支。第一个分支返回 u32,第二个分支返回 !。因为 ! 永远不会产生值,Rust 就知道 guess 是 u32。
never 类型对 panic! 宏的作用方式相同。看看 unwrap 的定义:
#![allow(unused)]
fn main() {
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
}
Rust 看到 val 的类型是 T,而 panic! 的类型是 !,所以整个 match 表达式的返回值是 T。之所以可行,是因为 panic! 不返回值;它会结束程序。
实际上,loop 也是 !,因为无限循环永不结束,所以永远不会产生返回值。然而,如果包含 break,情况就不再如此,因为循环到达 break 时会终止。
19.5.4 动态大小类型与 Sized trait
Rust 需要了解其类型的某些细节,例如为特定类型的值分配多少空间。这使得动态大小类型这个概念有点令人困惑。它们有时被称为 DST 或 unsized types。这些类型让我们可以编写处理大小仅在运行时才知道的值的代码。
我们可以用 str(不是 &str,也不是 String)作为动态大小类型的例子:
#![allow(unused)]
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
在运行时之前我们无法知道字符串有多长,这意味着我们无法创建类型为 str 的变量,所以上面的代码无法工作。
Rust 需要知道为特定类型的任何值分配多少内存,并且同一类型的所有值必须使用相同数量的内存。如果 Rust 允许我们写上面的代码,那么这两个 str 值就必须占用相同空间。但它们长度不同:s1 需要 12 字节存储,而 s2 需要 15 字节。这就是我们无法创建存储动态大小类型的变量的原因。
那该怎么办?一般来说,把 s1 和 s2 的类型改成 &str 而不是 str,就能解决问题:
#![allow(unused)]
fn main() {
let s1: &str = "Hello there!";
let s2: &str = "How's it going?";
}
切片数据结构只存储切片的起始位置和长度。所以虽然 &T 是包含一个内存地址的单个值,但 &str 是两个值(如 4.5. 切片(Slice) 中讨论的):
str的地址(usize)str的长度(usize)
因此,我们可以在编译时知道 &str 值的大小:它是 usize 大小的两倍。换句话说,无论它引用的字符串有多长,我们总是知道 &str 的大小。
一般来说,在 Rust 中使用动态大小类型的最佳方式,是给它们额外的元数据来存储动态大小信息。动态大小类型的黄金法则是:我们必须始终把它们放在某种指针后面。
我们可以把 str 与各种指针组合,例如 Box<str> 或 Rc<str>。Trait 实际上也是动态大小类型。为了处理动态大小类型,Rust 提供了 Sized trait 来判断类型的大小在编译时是否已知。所有在编译时大小已知的类型都会自动实现这个 trait。此外,Rust 会隐式给每个泛型函数加上 Sized trait。
这意味着像这样的泛型函数:
#![allow(unused)]
fn main() {
fn generic<T>(t: T) {
// ...
}
}
实际上被写成:
#![allow(unused)]
fn main() {
fn generic<T: Sized>(t: T) {
// ...
}
}
默认情况下,泛型函数只适用于编译时大小已知的类型。 但我们可以用特殊的 ?Sized 语法放宽这个限制:
#![allow(unused)]
fn main() {
fn generic<T: ?Sized>(t: &T) {
// ...
}
}
?Sized表示“T可能实现也可能不实现Sizedtrait”,也就是说T可能是也可能不是动态大小类型。这个记号移除了“泛型类型在编译时必须有已知大小”这一默认要求。?Trait语法只适用于Sizedtrait,不适用于其它 trait。- 我们把参数
t的类型从泛型T改成了&T。因为该类型可能没有实现Sized,也就是可能是动态大小类型,所以我们需要用指针包裹动态大小类型。
使用动态大小类型的最佳场景是与 trait 配合。有时我们希望某些数据实现某些 trait 或某个生命周期,但不知道具体类型,这时就可以使用指针包裹的动态类型。例如:
#![allow(unused)]
fn main() {
type Job = Box<dyn FnOnce() + Send + 'static>;
}
这个例子同时使用了类型别名和指针包裹的动态类型。Job 可以是任何实现了 FnOnce()、Send 以及 'static 生命周期的类型。