Rust 学习笔记:高级类型

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,我们需要在某种指针后面使用它。在本例中,我们选择了一个引用。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

UestcXiye

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值