Rust 学习笔记:高级类型
Rust 类型系统有一些我们到目前为止已经提到但还没有讨论的特性。
我们将从一般地讨论 newtype 开始,并研究为什么 newtype 作为类型是有用的。然后,我们将继续讨论类型别名,这是一个类似于 newtypes 的特性,但在语义上略有不同。我们还将讨论 ! 类型和动态大小的类型。
使用 Newtype 模式实现类型安全和抽象
Newtype 模式可以静态地强制值永远不会混淆,并指示值的单位。
例如,有两个元组结构体:
struct Millimeters(u32);
struct Meters(u32);
Millimeters 和 Meters 结构体在 newtype 中包装了 u32 值。如果我们编写一个函数的形参类型是 Millimeters,那么我们就不能误传 Meters 或 u32 类型。
我们还可以使用 Newtype 模式抽象出类型的一些实现细节,以增强类型安全。新类型可以暴露与私有内部类型的 API 不同的公共 API。
Newtype 模式还可以隐藏内部实现。
例如,我们可以提供 People 类型来包装 HashMap<i32, String>,该 HashMap<i32, String> 存储与其姓名相关联的人员 ID。
struct People(HashMap<i32, String>);
使用 People 的代码将只与我们提供的公共 API 交互,无法直接访问 HashMap。代码不需要知道我们在内部给名称分配了一个 i32 类型的 ID。
Newtype 模式是实现封装以隐藏实现细节的一种轻量级方式。
使用类型别名创建类型同义词
Rust 提供了声明类型别名的能力,可以为现有类型赋予另一个名称。为此,我们使用 type 关键字。
例如,我们可以像这样为 i32 创建别名 Kilometers:
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
别名 Kilometers 是 i32 的同义词,而不是一个单独的新类型。因为 Kilometers 类型的值将被视为 i32 类型的值,所以可以将这两种类型的值相加,并且可以将 Kilometers 类型的值传递给带有 i32 形参的函数。
然而,使用这种方法,我们无法获得从前面讨论的 Newtype 模式中获得的类型检查好处。换句话说,如果我们在某个地方混淆了 Kilometers 和 i32 的值,编译器不会给我们一个错误。
类型同义词的主要用例是减少重复。例如,我们可能有一个像这样长的类型:
Box<dyn Fn() + Send + 'static>
在函数签名中编写这种冗长的类型,并在整个代码中作为类型注释编写这种类型可能会令人厌烦,而且容易出错。
类型别名通过减少重复使代码更易于管理,为类型别名选择一个有意义的名称也可以帮助传达你的意图。我们为该类型引入了一个名为 Thunk 的别名,之后就可以用更短的别名 Thunk 替换该类型的所有用法。
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--
}
类型别名通常也与 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>;
}
Result<…, Error> 重复了很多次,因此,std::io 有这样的类型别名声明:
type Result<T> = std::result::Result<T, std::io::Error>;
由于此声明位于 std::io 模块中,因此可以使用完全限定别名 std::io::Result<T>,即 Result<T, E>,其中 E 以 std::io::Error 形式填写。Write trait 函数签名最终看起来像这样:
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--
}
从不返回的函数称为发散函数。我们不能创建这种 ! 类型的值,所以 bar 函数永远不可能返回。
但是永远不能为其创建值的类型有什么用呢?
考虑以下代码:
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
匹配分支必须都返回相同的类型。因此,下面的代码不起作用:
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
这段代码中的 guess 类型必须是一个整数和一个字符串,Rust 要求 guess 只有一种类型。
Rust 为什么允许我们从一个分支返回 u32,并拥有以 continue 结尾的另一个分支?continue 返回的是什么呢?
你可能已经猜到了,continue 有一个 ! 值。也就是说,当 Rust 计算 guess 的类型时,它会查看两个匹配分支,前者的值为 u32,后者的值为 !。因为 ! 不能有值,Rust 决定 guess 的类型是 u32。
描述这种行为的正式方式是,类型 ! 的表达式可以被强制转换为任何其他类型。我们可以用 continue 结束这个匹配分支,因为 continue 没有返回值。相反,它将控制移回循环的顶部,所以在 Err 的情况下,我们从不给 guess 赋值。
never 类型对 panic! 宏也很有用。回想一下我们在 Option<T> 值上调用的 unwrap 函数,它产生一个值或者一个 panic:
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! 的类型是 !,所以整个匹配表达式的结果是 T。panic! 不会产生值,它结束了程序。在 None 的情况下,我们不会从 unwrap 返回值,所以这段代码是有效的。
最后,loop 有一个 ! 类型。
print!("forever ");
loop {
print!("and ever ");
}
循环永远不会结束,所以 ! 是表达式的值。然而,如果我们包含一个 break,这就不成立了,因为当循环到达 break 时就会终止。
动态大小类型和 Sized trait
Rust 需要知道关于其类型的某些细节,例如为特定类型的值分配多少空间。这使得它的类型系统中的动态大小类型有点令人困惑。这些类型有时被称为 DST 或 unsized 类型,它们允许我们使用只有在运行时才能知道大小的值来编写代码。
让我们深入研究 str 的动态大小类型的细节。没错,不是&str,而是 str ,它本身就是 DST。直到运行时才知道字符串的长度,这意味着我们不能创建 str 类型的变量,也不能接受 str 类型的参数。
考虑下面的代码,它不起作用:
let s1: str = "Hello there!";
let s2: str = "How's it going?";
Rust 需要知道为特定类型的任何值分配多少内存,并且一个类型的所有值必须使用相同数量的内存。这就是为什么不能创建包含动态大小类型的变量的原因。
那么我们该怎么办呢?在这种情况下,你已经知道答案了:我们将 s1 和 s2 类型设置为 &str 而不是 str。切片数据结构只是存储了切片的起始位置和长度。因此,尽管 &T 是存储 T 所在的内存地址的单个值,但 &str 是两个值:str 的地址和长度。因此,我们可以在编译时知道 &str 值的大小:它是 usize 类型大小的两倍。也就是说,我们总是知道 &str 的大小,不管它引用的字符串有多长。
一般来说,这就是 Rust 中使用动态大小类型的方式:它们有一个额外的元数据来存储动态信息的大小。
动态大小类型的黄金法则是,我们必须总是把动态大小类型的值放在某种类型的指针后面。
可以将 str 与各种指针组合:例如,Box<str>或 Rc<str>。
事实上,你以前已经见过这种情况,但使用的是不同的动态大小类型:trait。
每个 trait 都是一个动态大小的类型,我们可以通过 trait 的名字来引用它。之前我们提到要使用 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 约束的 trait 意味着“T 可能是也可能不是 Sized”,这个符号覆盖了泛型类型在编译时必须具有已知大小的默认值。具有此含义的 ?Trait 语法仅适用于 Sized,而不适用于任何其他 trait。
还请注意,我们将 t 形参的类型从 T 转换为 &T,因为该类型可能不实现 Sized,我们需要在某种指针后面使用它。在本例中,我们选择了一个引用。
844

被折叠的 条评论
为什么被折叠?



