写点什么

Rust 从 0 到 1- 高级特性 - 类型进阶

用户头像
关注
发布于: 4 小时前
Rust从0到1-高级特性-类型进阶

Rust 中的类型有些方面我们曾经提到但没有讨论过。首先我们将从更广泛的角度讨论为什么“新类型”(newtype,前面我们介绍过“新类型”模式)和类型一样有用。接着我们会讨论类型别名(type aliases),它类似于“新类型”,但语义上稍有不同。最后我们会讨论类型 ! 和动态大小类型。

将“新类型”模式用于类型安全和抽象

“新类型”模式在之前我们讨论的场景之外仍然很有用,譬如通过类型名称区分数据的单位,从而避免混淆。我们在前面一个章节已经涉及到:Millimeters 和 Meters 都是利用该模式封装了一个 u32 类型的新类型。如果我们编写了一个以 Millimeters 类型做为参数的函数,那么在调用时传递 Meters 或 u32 类型的值是无法编译通过的,从而避免不小心传递错误的数据。

“新类型”模式的另一个应用是用于抽离类型的某些实现细节:譬如,新的类型可以暴露和其内部类型所不同的 API,以达到限制其功能的目的。

“新类型”还可以隐藏内部实现。譬如,我们可以提供一个 People 类型,其内部使用 HashMap<i32, String> 来储存人的 ID 及对应的姓名。使用 People 的代码只需使用我们提供的 API,譬如向集合中增加姓名的方法,而无需知道在内部我们同时为其赋予了一个 i32 类型的 ID 。“新类型”模式可以说是一种通过封装隐藏实现细节的轻量级方法(在介绍面向对象的章节我们介绍过相关概念)。

创建类型“同义词”

除了“新类型”模式,Rust 还为类型提供了别名(type alias)的功能。我们可以通过关键字 type 给类型赋予另外一个名字。参考下面的例子:

type Kilometers = i32;
复制代码

上例中 Kilometers 是类型 i32 的“同义词”(synonym)。和“新类型” Millimeters 和 Meters 不同,Kilometers 不是一个“新”的类型,Kilometers 类型的值会被完全当作 i32 类型来对待,参考下面的例子:

type Kilometers = i32;
let x: i32 = 5;let y: Kilometers = 5;
println!("x + y = {}", x + y);
复制代码

上例中 Kilometers 是 i32 的别名,因此它们是同一类型,可以将 i32 与 Kilometers 类型的值相加,也可以将 Kilometers 类型的值传递给以 i32 类型做为参数的函数。而正因为如此,通过这种方式我们无法享受到“新类型”模式所提供的类型检查的好处。

类型“同义词”的主要用途是减少重复。譬如,假设我们有个很长的类型:

Box<dyn Fn() + Send + 'static>
复制代码

在函数参数或变量的类型声明中每次写这么冗长的类型名字将是比较烦人也易于出错的。参考下面的代码:

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--}
复制代码

类型别名可以减少这些重复冗长的代码带来的不便。我们给予这个具有冗长名字的类型一个别名 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--}
复制代码

上例中的代码看上去更加易读和易写了。为别名选择一个有意义的名字可以帮助我们表达我们的意图(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>;}
复制代码

Result<..., Error> 出现了很多次。为此,在 std::io 中它有个别名:

type Result<T> = std::result::Result<T, std::io::Error>;
复制代码

因为其是在 std::io 模块中定义的,其全名是 std::io::Result<T>,即指定了 E 的具体类型为 std::io::Error 的 Result<T, E>。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<()>;}
复制代码

类型别名在两个方面有帮助:易于代码的编写和提供了一致的接口。正因为它是一个别名,只是另一个 Result<T, E>,我们可以使用任何 Result<T, E> 有关的语法,包括像 ? 这样的特殊语法。

“从不”类型

Rust 有一个特殊的类型 ! ,在理论术语中它被称为空类型(empty type),因为它没有任何值。在 Rust 中我们更倾向于称之为“从不”类型(never type),因为它用于在函数从不返回任何值得时候替代返回类型。参考下面的例子:

fn bar() -> ! {    // --snip--}
复制代码

上例中的代码意味着 “函数 bar 从不返回任何值”,而从不返回任何值的函数被称为发散函数(diverging functions)。我们无法创建 ! 类型的值,同样 函数 bar 也从不返回任何值。

但是这样一个没有对应值的类型有什么用呢?参考下面的代码:

let guess: u32 = match guess.trim().parse() {      Ok(num) => num,      Err(_) => continue,};
复制代码

在前面章节介绍 match 控制流的时候,我们提到过 match 的分支必须返回相同的类型。下面的代码是无法编译通过的:

let guess = match guess.trim().parse() {    Ok(_) => 5,    Err(_) => "hello",};
复制代码

上例中 guess 可能是整型也可能是字符串,而 Rust 要求 guess 只能是一个类型。那么 continue 返回什么呢?为什么会允许一个分支返回 u32 类型,另一个分支中以 continue 结束?

你可能已经猜到了,continue 的返回类型就是 ! 。当 Rust 要计算 guess 的类型时,它将查看这两个分支,前者是 u32 类型,后者是 ! 。因为 ! 没有对应的值,所以 Rust 最终计算出 guess 的类型是 u32。

用来描述上述行为比较正式的说法是 !  可以强制转换成任何类型。Rust 允许 match 分支中以 continue 结束是因为 continue 并不返回任何值;而是把控制权交回上层的循环,所以在 Err 分支中,我们从未对 guess 赋值。

“从不”类型对于 panic! 宏也很有用。以 Option<T> 上的 unwrap 函数为例:

impl<T> Option<T> {    pub fn unwrap(self) -> T {        match self {            Some(val) => val,            None => panic!("called `Option::unwrap()` on a `None` value"),        }    }}
复制代码

上面这段代码中 ,和前面的例子类似,val 是 T 类型,panic! 是 ! 类型,根据这些 Rust 计算出 match 表达式的结果是 T 类型。这段代码可以正常工作是因为 panic! 并不返回任何值,它会终止程序,因此对于 None ,unwrap 并不返回任何值,代码是正确的。

动态大小类型和 Sized trait

由于 Rust 需事先知道某些细节,譬如应该为某个类型的值分配多少空间,在其类型机制里有个概念可能会让我们产生迷惑:它就是动态大小类型(dynamically sized types)。有时也被称作 DST 或 unsized types,它允许我们在编码时使用那些只有在运行时才知道大小的值。

让我们通过贯穿本书多次使用过的类型 str 来深入讨论动态大小类型。注意,不是 &str,而是 str 自身。它是一个 DST,我们无法知道字符串的长度,直到运行时。这意味着我们不能创建 str 类型的变量,也不能把 str 类型作为参数。参考下面的例子(无法编译通过):

let s1: str = "Hello there!";let s2: str = "How's it going?";
复制代码

Rust 需要知道应该为特定类型的任何值分配多少内存,并且所有同一类型的值必须使用相同大小的内存。如果允许像上例那样编写代码,那么 s1 和 s2 就需要占用相同大小的空间。但是它们有着不同的长度,s1 需要 12 个字节,而 s2 需要 15 个字节大小的内存用于存储。这就是为什么不能创建一个动态大小类型的变量的原因。

那么我们应该怎么办呢?对于这个例子,我想你已经知道答案了:我们将变量 s1 和 s2 的类型声明为 &str 而不是 str。在介绍字符串切片的时候,我们提到过其数据结构中储存了开始位置和切片的长度。

所以对于固定大小的类型,&T 是仅储存了 T 的地址;而 &str 则存储了 str 的地址和其长度两个值。这样,我们在编译时就可以确定 &str 的大小:usize 长度的两倍。我们总是可以在编译时知道 &str 的大小,而无论其指向的字符串是多长。通常,Rust 就是按照这种方式处理动态大小类型:使用一些额外的元信息来储存动态的大小信息。动态大小类型的黄金法则是,我们必须使用指针指向动态大小类型的值。

我们可以将 str 与所有类型的指针组合使用:譬如 Box<str> 或 Rc<str>。事实上,之前我们已经见过类似用法,不过是用于另一种动态大小类型,trait。每个 trait 都是一个可以通过其名称引用的动态大小类型。在前面介绍 trait 对象的时候,我们提到了为了把 trait 做为 trait 对象使用,必须和指针一起使用,比如 &Trait 、 Box<Trait> 或 Rc<Trait> 。

为了处理动态大小类型,Rust 有一个专门用于确定某个类型的大小是否在编译时可以确定的 trait:Sized trait。所有在编译时就可以确定大小的类型都自动实现了这个 trait。另外,Rust 隐式的为泛型函数增加了 Sized bound。即对于如下泛型函数:

fn generic<T>(t: T) {    // --snip--}
复制代码

实际上被视为我们下面写的这样:

fn generic<T: Sized>(t: T) {    // --snip--}
复制代码

默认情况下,泛型函数只能用于在编译时可以确定大小的类型。但是我们可以使用以下语法来去除这个限制:

fn generic<T: ?Sized>(t: &T) {    // --snip--}
复制代码

?Sized 可以认为是和 Sized trait bound 相反的。我们可以理解为“T 可能是也可能不是 Sized”。注意,这个语法只能用于 Sized trait。

另外,注意我们将参数 t 的类型从 T 改为了 &T。因为其类型可能是动态大小的,所以需要和指针一起使用。

发布于: 4 小时前阅读数: 4
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-高级特性-类型进阶