进阶 Steve Klabnik / Carol Nichols 著,KaiserY 中译 2026-09-13 14:34:38 · 0 阅读

第20章 高级特性

现在你已经学会了 Rust 编程语言中最常用的那些部分。在第二十一章再做一个项目之前,我们先来看语言中的一些方面:你可能偶尔会遇到它们,但未必会天天使用。你可以把这一章当作在遇到陌生内容时的参考。本章介绍的这些特性只会在非常特定的场景下派上用场。虽然你可能不会经常用到它们,但我们仍希望确保你了解 Rust 所提供的全部特性。

本章将涉及如下内容:

  • 不安全 Rust:如何选择退出 Rust 的某些保证,并负责手动维持这些保证
  • 高级 trait:关联类型、默认类型参数、完全限定语法、超 trait,以及与 trait 相关的 newtype 模式
  • 高级类型:关于 newtype 模式的更多内容、类型别名、never 类型以及动态大小类型
  • 高级函数和闭包:函数指针和返回闭包
  • 宏:在编译时定义“用来定义更多代码的代码”的方式

这是一组包罗万象的 Rust 特性,总有一些内容会对你有用!让我们开始吧!

不安全 Rust

不安全 Rust

目前为止讨论过的代码都有 Rust 在编译时会强制执行的内存安全保证。然而,Rust 还隐藏有第二种语言,它不会强制执行这类内存安全保证:这被称为 不安全 Rustunsafe Rust)。它与常规 Rust 代码无异,但是会提供额外的超能力。

不安全 Rust 之所以存在,是因为静态分析本质上是保守的。当编译器尝试确定一段代码是否支持某个保证时,拒绝一些合法的程序比接受无效的程序要好一些。这必然意味着有时代码可能是合法的,但如果 Rust 编译器没有足够的信息来确定,它将拒绝该代码。在这种情况下,可以使用不安全代码告诉编译器,“相信我,我知道自己在干什么。” 不过千万注意,使用不安全 Rust 风险自担:如果不安全代码出错了,比如解引用空指针,可能会导致不安全的内存使用。

另一个 Rust 存在不安全一面的原因是底层计算机硬件固有的不安全性。如果 Rust 不允许进行不安全操作,那么有些任务则根本完成不了。Rust 需要能够进行像直接与操作系统交互甚至于编写你自己的操作系统这样的底层系统编程。底层系统编程也是 Rust 语言的目标之一。让我们看看不安全 Rust 能做什么,和怎么做。

执行不安全的超能力

要切换到 unsafe Rust,可以使用 unsafe 关键字,然后开启一个包含不安全代码的新块。这里有五类可以在不安全 Rust 中进行而不能用于安全 Rust 的操作,它们称之为不安全的超能力unsafe superpowers)。这些超能力包括:

  • 解引用裸指针
  • 调用不安全的函数或方法
  • 访问或修改可变静态变量
  • 实现不安全 trait
  • 访问 union 的字段

有一点很重要,unsafe 并不会关闭借用检查器或禁用任何其他 Rust 安全检查:如果在不安全代码中使用引用,它仍会被检查。unsafe 关键字只是提供了那五个不会被编译器检查内存安全的功能。你仍然能在不安全块中获得某种程度的安全。

再者,unsafe 不意味着块中的代码就一定是危险的或者必然导致内存安全问题:其意图在于作为程序员,你将会确保 unsafe 块中的代码以有效的方式访问内存。

人难免出错,错误总会发生,不过通过要求这五类不安全操作必须位于标记为 unsafe 的块中,就能够知道任何与内存安全相关的错误必定位于 unsafe 块内。保持 unsafe 块尽可能小;如此当之后调查内存 bug 时就会感谢你自己了。

为了尽可能隔离不安全代码,最好将不安全代码封装进一个安全的抽象并提供安全 API,当我们学习不安全函数和方法时会讨论到。标准库的一部分被实现为在被评审过的不安全代码之上的安全抽象。这个技术防止了 unsafe 泄露到所有你或者用户希望使用由 unsafe 代码实现的功能的地方,因为使用其安全抽象是安全的。

让我们按顺序依次介绍上述五类超能力,同时我们会看到一些提供不安全代码的安全接口的抽象。

解引用裸指针

回到第四章的“悬垂引用”一节,那里提到了编译器会确保引用总是有效的。不安全 Rust 有两个被称为 裸指针raw pointers)的类似于引用的新类型。和引用一样,裸指针是不可变或可变的,分别写作 *const T*mut T。这里的星号不是解引用运算符;它是类型名称的一部分。在裸指针的上下文中,不可变 意味着指针解引用之后不能直接赋值。

裸指针与引用和智能指针的区别在于

  • 允许忽略借用规则,可以同时拥有不可变和可变的指针,或多个指向相同位置的可变指针
  • 不保证指向有效的内存
  • 允许为空
  • 不能实现任何自动清理功能

通过去掉 Rust 强加的保证,你可以放弃安全保证以换取性能或使用另一个语言或硬件接口的能力,此时 Rust 的保证并不适用。

示例 20-1 展示了如何创建一个不可变裸指针和一个可变裸指针。

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}

示例 20-1: 通过引用创建裸指针

注意这段代码中没有引入 unsafe 关键字。可以在安全代码中创建裸指针;只是不能在不安全块之外解引用裸指针,稍后便会看到。

我们通过使用裸指针借用操作符(raw borrow operators)创建裸指针:&raw const num 会创建一个 *const i32 的不可变裸指针。因为由于我们是直接从一个局部变量创建它们的,因此可以确定这些特定的裸指针是有效的,但是不能对任何裸指针都做出如此假设。

为了演示这一点,接下来我们将创建一个有效性无法确定的裸指针,使用 as 进行类型转换而不是使用裸指针借用操作符。示例 20-2 展示了如何创建一个指向任意内存地址的裸指针。尝试使用任意内存是未定义行为:此地址可能有数据也可能没有,编译器可能会优化掉这个内存访问,或者程序可能因段错误(segmentation fault)而终止。通常在有裸指针借用操作符可用的情况下,没有充分的理由编写这样的代码,但这确实是可行的。

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}

示例 20-2: 创建指向任意内存地址的裸指针

记得我们说过可以在安全代码中创建裸指针,但不能 解引用 裸指针和读取其指向的数据。示例 20-3 中,我们在裸指针上使用了解引用运算符 *,该操作需要一个 unsafe 块:

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}

示例 20-3: 在 unsafe 块中解引用裸指针

创建一个指针不会造成任何危害;只有当访问其指向的值时才有可能遇到无效的值。

还需注意示例 20-1 和 20-3 中创建了同时指向相同内存位置 num 的裸指针 *const i32*mut i32。相反如果尝试同时创建 num 的不可变和可变引用,代码将无法通过编译,因为 Rust 的所有权规则不允许在拥有任何不可变引用的同时再创建可变引用。通过裸指针,就能够同时创建同一地址的可变指针和不可变指针,若通过可变指针修改数据,则可能造成潜在数据竞争。请多加小心!

既然存在这么多的危险,为何还要使用裸指针呢?一个主要的应用场景便是调用 C 代码接口,这在下一部分 “调用不安全函数或方法” 中会讲到。另一个场景是构建借用检查器无法理解的安全抽象。让我们先介绍不安全函数,接着看一看使用不安全代码的安全抽象的示例。

调用不安全函数或方法

第二类可以在不安全块中进行的操作是调用不安全函数。不安全函数和方法与常规函数方法十分类似,除了其开头有一个额外的 unsafe。在此上下文中,关键字 unsafe 表示该函数具有调用时需要满足的要求,而 Rust 不会保证满足这些要求。通过在 unsafe 块中调用不安全函数,表明我们已经阅读过此函数的文档并对其是否满足函数自身的契约负责。

如下是一个没有做任何操作的不安全函数 dangerous 的例子:

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

必须在一个单独的 unsafe 块中调用 dangerous 函数。如果尝试不使用 unsafe 块调用 dangerous,则会得到一个错误:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

通过 unsafe 块,我们向 Rust 断言我们已经阅读过函数的文档,理解如何正确使用它,并核实我们履行了该函数的契约。

在不安全函数的函数体内部执行不安全操作时,同样需要使用 unsafe 块,就像在普通函数中一样,如果忘记了,编译器会发出警告。这有助于将 unsafe 块保持得尽可能小,因为不安全操作未必需要覆盖整个函数体。

创建不安全代码的安全抽象

仅仅因为函数包含不安全代码并不意味着整个函数都需要标记为不安全的。事实上,将不安全代码封装进安全函数是一种常见的抽象方式。作为一个例子,了解一下标准库中的函数 split_at_mut,它需要一些不安全代码,让我们探索可以如何实现它。这个安全函数定义于可变 slice 之上:它获取一个 slice 并从给定的索引参数开始将其分割为两个 slice。示例 20-4 展示了如何使用 split_at_mut

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}

示例 20-4: 使用安全的 split_at_mut 函数

这个函数无法只通过安全 Rust 实现。一个尝试可能看起来像示例 20-5,它不能编译。出于简单考虑,我们将 split_at_mut 实现为函数而不是方法,并只处理 i32 值而非泛型 T 的 slice。

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

示例 20-5: 尝试只使用安全 Rust 来实现 split_at_mut

此函数首先获取 slice 的长度,然后通过检查参数是否小于或等于这个长度来断言参数所给定的索引位于 slice 当中。该断言意味着如果传入的索引比要分割的 slice 的索引更大,此函数在尝试使用这个索引前 panic。

之后我们在一个元组中返回两个可变的 slice:一个从原始 slice 的开头直到 mid 索引,另一个从 mid 直到原 slice 的结尾。

如果尝试编译示例 20-5 的代码,会得到一个错误:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

Rust 的借用检查器无法理解我们要借用这个 slice 的两个不同部分:它只知道我们借用了同一个 slice 两次。本质上借用 slice 的不同部分是可以的,因为这两段 slice 不会重叠,不过 Rust 还没有智能到能够理解这些。当我们知道某些事是可以的而 Rust 不知道的时候,就是触及不安全代码的时候了

示例 20-6 展示了如何使用 unsafe 块,裸指针和一些不安全函数调用来实现 split_at_mut

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

示例 20-6: 在 split_at_mut 函数的实现中使用不安全代码

回忆第四章的“Slice 类型” 部分,slice 是一个指向一些数据的指针,并带有该 slice 的长度。可以使用 len 方法获取 slice 的长度,使用 as_mut_ptr 方法访问 slice 的裸指针。在这个例子中,因为有一个 i32 值的可变 slice,as_mut_ptr 返回一个 *mut i32 类型的裸指针,并将其存储在 ptr 变量中。

我们保持索引 mid 位于 slice 中的断言。接着是不安全代码:slice::from_raw_parts_mut 函数获取一个裸指针和一个长度来创建一个 slice。这里使用此函数从 ptr 中创建了一个有 mid 个项的 slice。之后在 ptr 上调用 add 方法并使用 mid 作为参数来获取一个从 mid 开始的裸指针,使用这个裸指针并以 mid 之后项的数量为长度创建另一个 slice。

slice::from_raw_parts_mut 函数是不安全的因为它获取一个裸指针,并必须确信这个指针是有效的。裸指针上的 add 方法也是不安全的,因为其必须确信此地址偏移量也是有效的指针。因此必须将 slice::from_raw_parts_mutadd 放入 unsafe 块中以便能调用它们。通过观察代码,和增加 mid 必然小于等于 len 的断言,我们可以说 unsafe 块中所有的裸指针将是有效的 slice 中数据的指针。这是一个可以接受的 unsafe 的恰当用法。

注意无需将 split_at_mut 函数的结果标记为 unsafe,并可以在安全 Rust 中调用此函数。我们创建了一个不安全代码的安全抽象,其代码以一种安全的方式使用了 unsafe 代码,因为其只从这个函数访问的数据中创建了有效的指针。

与此相对,示例 20-7 中的 slice::from_raw_parts_mut 在使用 slice 时很有可能会崩溃。这段代码获取任意内存地址并创建了一个长度为一万的 slice:

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}

示例 20-7: 通过任意内存地址创建 slice

我们并不拥有这个任意地址的内存,也不能保证这段代码创建的 slice 包含有效的 i32 值。试图使用臆测为有效的 values 会导致未定义的行为。

使用 extern 函数调用外部代码

有时你的 Rust 代码可能需要与其他语言编写的代码交互。为此 Rust 有一个关键字,extern,有助于创建和使用 外部函数接口Foreign Function Interface,FFI)。外部函数接口是一个编程语言用以定义函数的方式,其允许不同(外部)编程语言调用这些函数。

示例 20-8 展示了如何集成 C 标准库中的 abs 函数。extern 块中声明的函数在 Rust 代码中通常是不安全的因此 extern 块本身也必须标注 unsafe。之所以如此,是因为其他语言不会强制执行 Rust 的规则,Rust 也无法检查这些约束,因此程序员有责任确保调用的安全性。

文件名:src/main.rs

unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}

示例 20-8: 声明并调用另一个语言中定义的 extern 函数

unsafe extern "C" 块中,我们列出了希望能够调用的另一个语言中的外部函数的签名和名称。"C" 部分定义了外部函数所使用的 应用二进制接口application binary interface,ABI) —— ABI 定义了如何在汇编语言层面调用此函数。"C" ABI 是最常见的,并遵循 C 编程语言的 ABI。有关 Rust 支持的所有 ABI 的信息请参见 the Rust Reference

unsafe extern 中声明的任何项都隐式地是 unsafe 的。然而,一些 FFI 函数可以安全地调用。例如,C 标准库中的 abs 函数没有任何内存安全方面的考量并且我们知道它可以使用任何 i32 调用。在类似这样的例子中,我们可以使用 safe 关键字来表明这个特定的函数即便是在 unsafe extern 块中也是可以安全调用的。一旦我们做出这个修改,调用它不再需要 unsafe 块,如示例 20-9 所示。

文件名:src/main.rs

unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
}

fn main() {
    println!("Absolute value of -3 according to C: {}", abs(-3));
}
示例 20-9:在 `unsafe extern` 块中显式地标记一个函数为 `safe` 并安全地调用它

将一个函数标记为 safe 并不会固有地使其变得安全!相反,这像是一个对 Rust 的承诺表明它安全的。确保履行这个承诺仍然是你的责任!

从其它语言调用 Rust 函数

也可以使用 extern 来创建一个允许其它语言调用 Rust 函数的接口。不同于创建整个 extern 块,就在 fn 关键字之前增加 extern 关键字并为相关函数指定所用到的 ABI。还需增加 #[no_mangle] 注解来告诉 Rust 编译器不要 mangle 此函数的名称。Mangling 指编译器将我们命名的函数名更改为包含更多供其他编译过程使用的信息的名称,不过可读性较差。每一个编程语言的编译器都会以稍微不同的方式 mangle 函数名,所以为了使 Rust 函数能在其他语言中指定,必须禁用 Rust 编译器的 name mangling。这是不安全的因为在没有内置 mangling 的时候在库之间可能有命名冲突,所以确保所选的名称可以不用 mangling 地安全导出是我们的责任。

在如下的例子中,一旦其编译为动态库并从 C 语言中链接,call_from_c 函数就能够在 C 代码中访问:

#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

这种 extern 用法只在属性中需要 unsafe,而不需要在 extern 块本身使用 unsafe

访问或修改可变静态变量

在本书中,我们尚未讨论过 全局变量global variables),Rust 确实支持它们,不过这对于 Rust 的所有权规则来说是有问题的。如果有两个线程访问相同的可变全局变量,则可能会造成数据竞争。

全局变量在 Rust 中被称为 静态static)变量。示例 20-10 展示了一个拥有字符串 slice 值的静态变量的声明和使用:

文件名:src/main.rs

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("value is: {HELLO_WORLD}");
}

示例 20-10: 定义和使用一个不可变静态变量

静态(static)变量类似于第三章 “常量” 部分讨论的常量。通常静态变量的名称采用 SCREAMING_SNAKE_CASE 写法。静态变量只能储存拥有 'static 生命周期的引用,这意味着 Rust 编译器可以自己计算出其生命周期而无需显式标注。访问不可变静态变量是安全的。

常量与不可变静态变量的一个微妙的区别是静态变量中的值有一个固定的内存地址。使用这个值总是会访问相同的地址。另一方面,常量则允许在任何被用到的时候复制其数据。另一个区别在于静态变量可以是可变的。访问和修改可变静态变量都是 不安全 的。示例 20-11 展示了如何声明、访问和修改名为 COUNTER 的可变静态变量:

文件名:src/main.rs

static mut COUNTER: u32 = 0;

/// SAFETY: 同时在多个线程调用这个方法是未定义的行为,所以你*必须*保证同一时间只
/// 有一个线程在调用它。
unsafe fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    unsafe {
        // SAFETY: 它只在 `main` 这一个线程被调用。
        add_to_count(3);
        println!("COUNTER: {}", *(&raw const COUNTER));
    }
}

示例 20-11: 读取或修改一个可变静态变量是不安全的

就像常规变量一样,我们使用 mut 关键字来指定可变性。任何读写 COUNTER 的代码都必须位于 unsafe 块中。这段代码可以编译并如期打印出 COUNTER: 3,因为这是单线程的。拥有多个线程访问 COUNTER 则可能导致数据竞争,所以这是未定义行为。因此,我们需要将整个函数标记为 unsafe,并在文档注释中说明其安全性限制,以便调用者明确哪些操作是安全的、哪些是不安全的。

每当我们编写一个不安全函数,惯常做法是编写一个以 SAFETY 开头的注释并解释调用者需要做什么才可以安全地调用该方法。同理,当我们进行不安全操作时,惯常做法是编写一个以 SAFETY 开头并解释安全性规则是如何维护的。

另外,编译器不会允许你创建一个可变静态变量的引用。你只能通过用裸指针解引用操作符创建的裸指针访问它。这包括引用的创建是不可见的情况,例如这个代码示例中用于 println! 的情况。可变静态变量只能通过裸指针创建的要求有助于确保使用它们的安全要求更为明确。

拥有可以全局访问的可变数据,难以保证不存在数据竞争,这就是为何 Rust 认为可变静态变量是不安全的。在任何可能的情况下,请优先使用第十六章讨论的并发技术和线程安全智能指针,这样编译器就能检测不同线程间的数据访问是否是安全的。

实现不安全 trait

我们可以使用 unsafe 来实现一个不安全 trait。当 trait 中至少有一个方法中包含编译器无法验证的不变式(invariant)时该 trait 就是不安全的。可以在 trait 之前增加 unsafe 关键字将 trait 声明为 unsafe,同时 trait 的实现也必须标记为 unsafe,如示例 20-12 所示:

unsafe trait Foo {
    // 方法在这里
}

unsafe impl Foo for i32 {
    // 方法实现在这里
}

fn main() {}

示例 20-12: 定义并实现不安全 trait

通过 unsafe impl,我们承诺将保证编译器所不能验证的不变式。

作为一个例子,回忆第十六章 “使用 SyncSend trait 的可扩展并发” 部分中的 SyncSend 标记 trait:如果我们的类型完全由实现了 SendSync 的其他类型组成,编译器会自动为其实现这些 trait。如果我们定义的类型包含某些未实现 SendSync 的类型,例如裸指针,但又想将该类型标记为 SendSync,就必须使用 unsafe。Rust 不能验证我们的类型保证可以安全地跨线程发送或在多线程间访问,所以需要我们自己进行检查,并通过 unsafe 表明这一点。

访问联合体中的字段

最后一个只能在 unsafe 块中执行的操作是访问(union)中的字段。unionstruct 类似,但是在一个实例中同时只能使用一个已声明的字段。联合体主要用于和 C 代码中的联合体进行交互。访问联合体的字段是不安全的,因为 Rust 无法保证当前存储在联合体实例中数据的类型。可以查看 the Rust Reference 了解有关联合体的更多信息。

使用 Miri 检查不安全代码

当编写不安全代码时,你可能会想要检查编写的代码是否真的安全正确。最好的方式之一是使用 Miri,一个用来检测未定义行为的 Rust 官方工具。鉴于借用检查器是一个在编译时工作的静态工具,Miri 是一个在运行时工作的动态工具。它通过运行程序,或者测试集来检查代码,并检测你是否违反了它理解的 Rust 应该如何工作的规则。

使用 Miri 要求使用 nightly 版本的 Rust(我们在附录 G:Rust 是如何开发的与 “Nightly Rust”中有更多讨论)。你可以通过输入 rustup +nightly component add miri 来同时安装 nightly 版本的 Rust 和 Miri。这并不会改变你项目正在使用的 Rust 版本;它只是为你的系统增加了这个工具所以你可以在需要的时候使用它。你可以通过输入 cargo +nightly miri run or cargo +nightly miri test 在项目中使用 Miri。

作为一个它是如何有用的例子,考虑一下对示例 20-11 运行它时会发生什么。

$ cargo +nightly miri run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `/Users/chris/.rustup/toolchains/nightly-aarch64-apple-darwin/bin/cargo-miri runner target/miri/aarch64-apple-darwin/debug/unsafe-example`
warning: creating a shared reference to mutable static is discouraged
  --> src/main.rs:14:33
   |
14 |         println!("COUNTER: {}", COUNTER);
   |                                 ^^^^^^^ shared reference to mutable static
   |
   = note: for more information, see <https://doc.rust-lang.org/nightly/edition-guide/rust-2024/static-mut-references.html>
   = note: shared references to mutable statics are dangerous; it's undefined behavior if the static is mutated or if a mutable reference is created for it while the shared reference lives
   = note: `#[warn(static_mut_refs)]` on by default

COUNTER: 3

Miri 正确地警告我们:我们正在把一个整数转换成指针,这可能是个问题。不过,Miri 无法判断这里是否一定有问题,因为它不知道这个指针最初是如何产生的。随后,Miri 又在我们取消引用该值的地方返回了一个错误。借助 Miri,我们现在至少知道这里存在未定义行为的风险,于是就可以进一步思考如何让这段代码变得安全。在某些情况下,Miri 甚至能够检测出真正的错误,也就是确定有问题的代码模式,并给出如何修复这些错误的建议。

Miri 并不能捕获编写不安全代码时可能出现的所有错误。Miri 是一个动态分析工具,因此它只能捕获代码实际运行时出现的问题。这意味着需要将其与良好的测试技术相结合以增强你对所编写的不安全代码的信心。Miri 也不能覆盖代码所有的不可靠的地方。

换句话说:如果 Miri 捕获到了一个问题,那你就知道这里确实有个 bug;但仅仅因为 Miri 没有捕获某个 bug,并不意味着这里就没有问题。不过,它确实能发现很多问题。你可以试着对本章中的其他不安全代码示例运行 Miri,看看它会给出什么结果!

你可以在 Miri 的 GitHub 仓库了解更多信息。

正确使用不安全代码

使用 unsafe 来进行这五个操作(超能力)之一是没有问题的,甚至是不需要深思熟虑的,不过使得 unsafe 代码正确也实属不易,因为编译器不能帮助保证内存安全。当有理由使用 unsafe 代码时,是可以这么做的,通过使用显式的 unsafe 标注可以更容易地在错误发生时追踪问题的源头。每当编写不安全代码时,都可以借助 Miri 来更加自信地验证所写代码是否遵循 Rust 的规则。

若想更深入地了解如何高效使用不安全 Rust,请阅读 Rust 关于该主题的官方指南 Rustonomicon

高级 trait

高级 trait

在第十章 “使用 trait 定义共享行为” 部分,我们第一次接触 trait,不过当时还没有讨论更高级的细节。现在你对 Rust 已经了解更多了,我们可以来深入探究这些细枝末节。

关联类型

关联类型associated types)将一个类型占位符与 trait 相关联,使得该 trait 的方法定义可以在签名中使用这些占位符类型。该 trait 的实现者会为每个具体实现指定要使用的具体类型来替代占位符类型。这样,我们就能在定义 trait 时使用占位符类型,而无需预先知道这些类型的具体内容,直到实现该 trait 时再进行指定。

我们之前提到,本章所讨论的大多数高级特性都很少需要。关联类型则比较适中:它们的使用频率低于本书其他部分讲解的特性,但又高于本章中许多其他特性。

一个带有关联类型的 trait 的例子是标准库提供的 Iterator trait。它有一个叫做 Item 的关联类型来替代遍历的值的类型。Iterator trait 的定义如示例 20-13 所示:

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

示例 20-13: Iterator trait 的定义中带有关联类型 Item

Item 是一个占位符类型,同时 next 方法的定义表明它返回 Option<Self::Item> 类型的值。Iterator trait 的实现者会指定 Item 的具体类型,于是 next 方法就会返回一个包含该具体类型值的 Option

关联类型可能看起来与泛型类似,后者允许我们在定义函数时不必指定它可以处理的类型。为了体现这两者的区别,我们来看一个名为 Counter 的类型上的 Iterator trait 实现,其中指定 Item 的类型为 u32

文件名:src/lib.rs

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

这种语法看起来与泛型类似。那么为什么不直接像示例 20-14 那样,用泛型来定义 Iterator trait 呢?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

示例 20-14: 一个使用泛型的 Iterator trait 假想定义

区别在于当如示例 20-14 那样使用泛型时,则不得不在每一个实现中标注类型;这是因为我们也可以实现为 Iterator<String> for Counter,或任何其他类型,这样就可以有多个针对 CounterIterator 的实现。换句话说,当 trait 有泛型参数时,可以多次实现这个 trait,每次都使用不同的具体泛型参数类型。当我们在 Counter 上调用 next 方法时,就必须通过类型注解来指明要使用哪一个 Iterator 的实现。

使用关联类型后,则无需标注类型,因为不能对同一个类型多次实现该 trait。在示例 20-13 中使用关联类型的定义里,我们只能为 Item 选择一次具体类型,因为只能有一个 impl Iterator for Counter。当调用 Counternext 时不必每次指定我们需要 u32 值的迭代器。

关联类型也会成为 trait 契约的一部分:trait 的实现必须提供一个类型来替代关联类型占位符。关联类型通常以它的用途来命名,在 API 文档中对关联类型进行说明也是一种良好实践。

使用默认泛型类型参数和运算符重载

当使用泛型类型参数时,可以为泛型指定一个默认的具体类型。如果默认类型就足够的话,这消除了为具体类型实现 trait 的需要。为泛型类型指定默认类型的语法是在声明泛型类型时使用 <PlaceholderType=ConcreteType>

这种技术的一个很好的示例是 运算符重载 (operator overloading),即在特定情况下自定义运算符(比如 +)行为的操作。

Rust 并不允许创建自定义运算符或重载任意运算符,但可以通过实现 std::ops 中列出的运算符相关 trait 来重载它们。例如,在示例 20-15 中我们重载 + 运算符来将两个 Point 实例相加。我们通过在 Point 结构体上实现 Add trait 来实现这一点。

文件名:src/main.rs

use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}

示例 20-15: 实现 Add trait 重载 Point 实例的 + 运算符

add 方法将两个 Point 实例的 x 值和 y 值分别相加来创建一个新的 PointAdd trait 有一个叫做 Output 的关联类型,它用来决定 add 方法的返回值类型。

这里默认泛型类型位于 Add trait 中。这里是其定义:

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}
}

这些代码看来应该很熟悉:一个带有一个方法和一个关联类型的 trait。新增的部分是 Rhs=Self:这个语法叫做 默认类型参数default type parameters)。Rhs 是一个泛型类型参数(“right-hand side” 的缩写),它用于定义 add 方法中的 rhs 参数。如果实现 Add trait 时不指定 Rhs 的具体类型,Rhs 的类型将默认为 Self,即正在实现 Add 的类型。

当为 Point 实现 Add 时,使用了默认的 Rhs,因为我们希望将两个 Point 实例相加。让我们看看一个实现 Add trait 时希望自定义 Rhs 类型而不是使用默认类型的例子。

这里有两个存放不同单元值的结构体,MillimetersMeters。这种将现有类型简单封装进另一个结构体的方式被称为 newtype 模式newtype pattern),之后的“使用 newtype 模式在外部类型上实现外部 trait”部分会做详细介绍。我们希望能够将毫米值与米值相加,并让 Add 的实现正确处理单位转换。可以为 Millimeters 实现 Add 并以 Meters 作为 Rhs,如示例 20-16 所示。

文件名:src/lib.rs

use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}

示例 20-16: 在 Millimeters 上实现 Add,以便能够将 MillimetersMeters 相加

为了使 MillimetersMeters 能够相加,我们指定 impl Add<Meters> 来设定 Rhs 类型参数的值而不是使用默认的 Self

默认参数类型主要用于如下两个方面:

  • 扩展类型而不破坏现有代码。
  • 在大部分用户都不需要的特定情况进行自定义。

标准库的 Add trait 就是第二个目的的一个例子:大部分时候你会将两个相似的类型相加,但 Add trait 也提供了自定义额外行为的能力。在 Add trait 定义中使用默认类型参数意味着大部分时候无需指定额外的参数。换句话说,一小部分实现的样板代码是不必要的,这样使用 trait 就更容易了。

第一个目的与第二个相似但方向相反:如果需要为现有 trait 增加类型参数,为其提供一个默认类型将允许我们在不破坏现有实现代码的基础上扩展 trait 的功能。

在同名方法之间消歧义

Rust 既不能避免一个 trait 与另一个 trait 拥有相同名称的方法,也不能阻止为同一类型同时实现这两个 trait。同时还可以直接在类型上实现一个与 trait 方法同名的方法。

当调用这些同名方法时,需要告诉 Rust 我们想要使用哪一个。考虑一下示例 20-17 中的代码,这里我们定义了两个 trait,PilotWizard,它们都拥有名为 fly 的方法。接着在一个本身已经实现了名为 fly 方法的类型 Human 上实现这两个 trait。每一个 fly 方法都进行了不同的操作:

文件名:src/main.rs

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {}

示例 20-17: 两个 trait 定义为拥有 fly 方法,并在直接定义有 fly 方法的 Human 类型上实现这两个 trait

当调用 Human 实例的 fly 时,编译器默认调用直接实现在该类型上的方法,如示例 20-18 所示。

文件名:src/main.rs

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    person.fly();
}

示例 20-18: 调用一个 Human 实例的 fly

运行这段代码会打印出 *waving arms furiously*,这表明 Rust 调用了直接实现在 Human 上的 fly 方法。

为了能够调用 Pilot trait 或 Wizard trait 的 fly 方法,需要使用更明确的语法来指定具体要调用的 fly 方法。示例 20-19 演示了这种语法。

文件名:src/main.rs

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

示例 20-19: 指定我们希望调用哪一个 trait 的 fly 方法

在方法名前指定 trait 名称可让 Rust 明确我们想调用哪个 fly 实现。也可以选择写成 Human::fly(&person),这等同于示例 20-19 中的 person.fly(),不过如果无需消歧义的话这么写就有点冗长了。

运行这段代码会打印出如下内容:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*

因为 fly 方法获取一个 self 参数,如果有两个类型都实现了同一 trait,Rust 可以根据 self 的类型计算出应该使用哪一个 trait 实现。

然而,关联函数中非方法的函数不带有 self 参数。当存在多个类型或者 trait 定义了相同函数名的非方法函数时,Rust 就不总是能计算出我们期望的是哪一个类型,除非使用 完全限定语法fully qualified syntax)。例如示例 20-20 中的创建了一个希望将所有小狗叫做 Spot 的动物收容所的 trait。Animal trait 有一个关联非方法函数 baby_name。结构体 Dog 实现了 Animal,同时又直接提供了关联非方法函数 baby_name

文件名:src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}

示例 20-20: 一个带有关联函数的 trait 和一个带有同名关联函数并实现了此 trait 的类型

Dog 类型上定义的关联函数 baby_name 中,我们实现了将所有小狗命名为 Spot 的功能。Dog 类型还实现了 Animal trait,它描述了所有动物所共有的特征。小狗被称为 puppy,这表现为 DogAnimal trait 实现中与 Animal trait 相关联的函数 baby_name

main 调用了 Dog::baby_name 函数,它直接调用了定义于 Dog 之上的关联函数。这段代码会打印出:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
A baby dog is called a Spot

这不是我们想要的输出。我们希望调用的是 DogAnimal trait 实现那部分的 baby_name 函数,这样能够打印出 A baby dog is called a puppy。我们在示例 20-19 中使用的指定 trait 名称的技巧在这里不起作用;如果将 main 改为示例 20-21 中的代码,就会得到编译错误:

文件名:src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}

示例 20-21: 尝试调用 Animal trait 的 baby_name 函数,不过 Rust 并不知道该使用哪一个实现

因为 Animal::baby_name 没有 self 参数,而且可能有其他类型实现了 Animal trait,Rust 无法确定我们想调用哪一个 Animal::baby_name 的实现。此时会得到如下编译错误:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
 2 |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
   |                                           +++++++       +

For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` (bin "traits-example") due to 1 previous error

为了消歧义并告诉 Rust 我们希望使用的是 DogAnimal 实现而不是其它类型的 Animal 实现,需要使用完全限定语法。示例 20-22 演示了如何使用完全限定语法:

文件名:src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}

示例 20-22: 使用完全限定语法来指定我们希望调用的是 DogAnimal trait 实现中的 baby_name 函数

我们在尖括号中向 Rust 提供了类型注解,这表明我们希望在此次函数调用中将 Dog 类型视为 Animal,从而调用在 Dog 上实现的 Animal trait 中的 baby_name 方法。现在这段代码将打印出我们期望的结果:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
A baby dog is called a puppy

通常,完全限定语法定义为如下:

<Type as Trait>::function(receiver_if_method, next_arg, ...);

对于不是方法的关联函数,并没有一个 receiver:故只会有其他参数的列表。可以选择在任何函数或方法调用处使用完全限定语法。然而,允许省略任何 Rust 能够从程序中的其他信息中计算出的部分。只有当存在多个同名实现而 Rust 需要帮助以便知道我们希望调用哪个实现时,才需要使用这个较为冗长的语法。

使用超 trait

有时我们可能会需要编写一个依赖另一个 trait 的 trait 定义:对于一个实现了第一个 trait 的类型,你希望要求这个类型也实现了第二个 trait。如此就可使 trait 定义使用第二个 trait 的关联项。这个所需的 trait 是我们实现的 trait 的 超(父)traitsupertrait)。

例如我们希望创建一个带有 outline_print 方法的 trait OutlinePrint,它会将给定的值格式化为带有星号框。也就是说,给定一个实现了标准库 Display trait 的并返回 (x, y)Point,当我们对一个 x1y3Point 实例调用 outline_print 时,它应该打印出如下内容:

**********
*        *
* (1, 3) *
*        *
**********

outline_print 的实现中,我们希望使用 Display trait 的功能。因此,需要说明 OutlinePrint trait 仅适用于那些同时实现了 Display 并提供 OutlinePrint 所需功能的类型。可以通过在 trait 定义中指定 OutlinePrint: Display 来做到这一点。这种技术类似于为 trait 增加 trait 约束。示例 20-23 展示了一个 OutlinePrint trait 的实现:

文件名:src/main.rs

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

fn main() {}

示例 20-23: 实现 OutlinePrint trait,它要求来自 Display 的功能

因为我们已经指定 OutlinePrint 需要 Display trait,因而可以使用自动为任何实现了 Display 的类型提供的 to_string 方法。如果我们在没有在 trait 名称后添加冒号并指定 Display trait 的情况下尝试使用 to_string,就会出现错误,提示在当前作用域中未为类型 &Self 找到名为 to_string 的方法。

让我们看看如果尝试在一个没有实现 Display 的类型上实现 OutlinePrint 会发生什么,比如 Point 结构体:

文件名:src/main.rs

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

这样会得到一个错误说 Display 是必须的而未被实现:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ the trait `std::fmt::Display` is not implemented for `Point`
   |
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
 3 | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:24:7
   |
24 |     p.outline_print();
   |       ^^^^^^^^^^^^^ the trait `std::fmt::Display` is not implemented for `Point`
   |
note: required by a bound in `OutlinePrint::outline_print`
  --> src/main.rs:3:21
   |
 3 | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
 4 |     fn outline_print(&self) {
   |        ------------- required by a bound in this associated function

For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors

为了修复这个问题,我们在 Point 上实现 Display 并满足 OutlinePrint 要求的限制,比如这样:

文件名:src/main.rs

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

那么在 Point 上实现 OutlinePrint trait 就能成功编译,并可以在 Point 实例上调用 outline_print 将其显示在由星号组成的边框内。

使用 newtype 模式在外部类型上实现外部 trait

在第十章的 “为类型实现 trait” 部分,我们提到了孤儿规则(orphan rule),它规定只有当 trait 或类型至少有一方或两者都对于当前 crate 是本地时,才能在该类型上实现该 trait。一个绕开这个限制的方法是使用 newtype 模式newtype pattern),它涉及到在一个元组结构体(第五章 “使用元组结构体创建不同的类型” 部分介绍了元组结构体)中创建一个新类型。这个元组结构体带有一个字段作为希望实现 trait 的类型的简单封装。由于这个封装类型对于 crate 是本地的,这样就可以在这个封装上实现 trait。Newtype 是一个源自 Haskell 编程语言的概念。使用这个模式没有运行时性能惩罚,这个封装类型在编译时就被省略了。

例如,如果想要在 Vec<T> 上实现 Display,而孤儿规则阻止我们直接这么做,因为 Display trait 和 Vec<T> 都定义于我们的 crate 之外。可以创建一个包含 Vec<T> 实例的 Wrapper 结构体,接着可以如示例 20-24 那样在 Wrapper 上实现 Display 并使用 Vec<T> 的值:

文件名:src/main.rs

use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {w}");
}

示例 20-24: 创建 Wrapper 类型封装 Vec<String> 以便能够实现 Display

Display 的实现使用 self.0 来访问其内部的 Vec<T>,因为 Wrapper 是元组结构体而 Vec<T> 是结构体总位于索引 0 的项。接着就可以使用 WrapperDisplay 的功能了。

这种做法的缺点在于因为 Wrapper 是一个新类型,它并不具备其所封装值的方法。必须直接在 Wrapper 上实现 Vec<T> 的所有方法,这样就可以代理到self.0 上,这就允许我们完全像 Vec<T> 那样对待 Wrapper。如果希望新类型拥有其内部类型的每一个方法,为封装类型实现 Deref trait(第十五章 “将智能指针视作常规引用” 部分讨论过)并返回其内部类型是一种解决方案。如果不希望封装类型拥有所有内部类型的方法 —— 比如为了限制封装类型的行为 —— 则只需自行实现所需的方法即可。

甚至当不涉及 trait 时 newtype 模式也很有用。现在让我们将关注点转移到一些与 Rust 类型系统交互的高级方式上来吧。

高级类型

高级类型

Rust 的类型系统有一些我们曾经提到但尚未讨论过的特性。首先我们将从一般意义上讨论 newtype 并探讨它们作为类型为何有用。接着会转向类型别名(type aliases),一个类似于 newtype 但有着稍微不同的语义的功能。我们还会讨论 ! 类型和动态大小类型。

使用 newtype 模式实现类型安全与抽象

本小节假设你已经阅读了之前的 “使用 newtype 模式在外部类型上实现外部 trait” 部分。

newtype 模式还可用于我们到目前为止尚未讨论的其他任务,包括静态地确保值不会混淆以及标注值的单位。你在示例 20-16 中已经看到了一个使用 newtype 来表示单位的例子:MillimetersMeters 结构体都在 newtype 中封装了 u32 值。如果编写了一个有 Millimeters 类型参数的函数,不小心使用 Meters 或普通的 u32 值来调用该函数的程序是不能编译的。

newtype 模式也可以用于抽象掉某个类型的部分实现细节:新的类型可以暴露与其私有内部类型不同的共有 API。

newtype 模式还可以隐藏内部实现。例如,可以提供一个封装了 HashMap<i32, String>People 类型,用来储存人名以及相应的 ID。使用 People 的代码只需与我们提供的公有 API 交互即可,比如向 People 集合增加名字字符串的方法;这样这些代码就无需知道在内部我们将一个 i32 ID 赋予了这个名字了。newtype 模式是一种实现第十八章 “封装隐藏了实现细节” 中讨论的隐藏实现细节的轻量级封装方法。

类型同义词与类型别名

Rust 提供了声明 类型别名type alias)的能力,使用 type 关键字为现有类型赋予另一个名字。例如,可以像这样创建 i32 的别名 Kilometers

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

这意味着 Kilometersi32同义词synonym);不同于示例 20-16 中创建的 MillimetersMeters 类型。Kilometers 并不是一个新的、单独的类型。Kilometers 类型的值将被完全当作 i32 类型值来对待:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

因为 Kilometersi32 的别名,它们是同一类型,可以将 i32Kilometers 相加,也可以将 Kilometers 传递给获取 i32 参数的函数。但通过这种手段无法获得上一部分讨论的 newtype 模式所提供的类型检查的好处。换句话说,如果在某处混用 Kilometersi32 的值,编译器也不会给出一个错误。

类型别名的主要用途是减少重复。例如,可能会有这样很长的类型:

Box<dyn Fn() + Send + 'static>

在函数签名和类型注解中到处书写这个冗长的类型既乏味又容易出错。想象一下有一个项目,到处都是像 Listing 20-25 那样的代码。

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(|| ())
    }
}

示例 20-25: 在很多地方使用名称很长的类型

类型别名通过减少重复使代码更易于管理。在示例 20-26 中,我们为这个冗长的类型引入了名为 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(|| ())
    }
}

示例 20-26: 引入类型别名 Thunk 来减少重复

这样阅读和编写代码都容易多了!为类型别名选择一个好名字也可以帮助你表达意图(单词 thunk 表示会在之后被计算的代码,所以这是一个存放闭包的合适的名字)。

类型别名也经常与 Result<T, E> 结合使用来减少重复。考虑一下标准库中的 std::io 模块。I/O 操作通常会返回一个 Result<T, E> 来处理操作失败的情况。标准库中的 std::io::Error 结构体代表了所有可能的 I/O 错误。std::io 中的许多函数都会返回 Result<T, E>,其中 Estd::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 有这个类型别名声明:

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::ErrorWrite trait 中的函数最终看起来像这样:

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 type

Rust 有一个叫做 ! 的特殊类型。在类型理论术语中被称为 empty type,因为它没有值。我们更倾向于称之为 never type。这个名字描述了它的作用:在函数从不返回的时候充当返回值。下面是一个示例:

fn bar() -> ! {
    // --snip--
    panic!();
}

这段代码可以读作 “函数 bar 从不返回”,而从不返回的函数被称为 发散函数diverging functions)。不能创建 ! 类型的值,所以 bar 也不可能返回值。

不过一个不能创建值的类型有什么用呢?回想一下示例 2-5 中猜数字游戏的代码;我们在示例 20-27 中重现了其中的一小部分:

use std::cmp::Ordering;
use std::io;

use rand::Rng;

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;
            }
        }
    }
}

示例 20-27: match 语句和一个以 continue 结束的分支

当时我们略过了这段代码中的一些细节。在第六章 match 控制流结构” 部分,我们讨论过:match 的各个分支必须返回相同的类型。所以下面的代码是行不通的:

fn main() {
    let guess = "3";
    let guess = match guess.trim().parse() {
        Ok(_) => 5,
        Err(_) => "hello",
    };
}

这里的 guess 必须既是整型 也是 字符串,而 Rust 要求 guess 只能是一个类型。那么 continue 返回了什么呢?为什么在示例 20-27 中,一个分支返回 u32,而另一个分支却以 continue 结束呢?

正如你可能猜到的,continue 的值是 !。也就是说,当 Rust 要计算 guess 的类型时,它会查看这两个分支。前者是 u32 值,而后者是 ! 值。因为 ! 类型永远不会有值,Rust 决定 guess 的类型是 u32

描述这种行为的正式方式是,类型为 ! 的表达式可以被强制转换为任意其他类型。之所以允许 match 分支以 continue 结束是因为 continue 并不真正返回值;相反它把控制权交回上层循环,所以在 Err 的情况,事实上并未对 guess 进行赋值。

never type 在 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"),
        }
    }
}

这里与示例 20-27 中的 match 发生了相同的情况:Rust 知道 valT 类型,panic!! 类型,所以整个 match 表达式的结果是 T 类型。这能工作是因为 panic! 并不产生一个值;它会终止程序。对于 None 的情况,unwrap 并不返回一个值,所以这些代码是有效的。

最后一个有着 ! 类型的表达式是 loop

fn main() {
    print!("forever ");

    loop {
        print!("and ever ");
    }
}

这里,循环永远也不结束,所以此表达式的值是 !。但是如果引入 break 这就不为真了,因为循环在执行到 break 后就会终止。

动态大小类型和 Sized trait

Rust 需要知道有关类型的某些细节,例如为特定类型的值需要分配多少空间。这便是起初留下的一个类型系统中令人迷惑的角落:即 动态大小类型dynamically sized types)的概念。这有时被称为 “DST” 或 “unsized types”,它们让我们能够编写使用那些只有在运行时才能知道大小的值的代码。

让我们深入研究我们在整本书中一直在使用的动态大小类型 str 的细节。没错,不是 &str,而是单独的 str 就是一个 DST。直到运行时我们都不知道字符串有多长。我们无法在编译时知道字符串的长度,这意味着我们无法创建 str 类型的变量,也不能获取 str 类型的参数。考虑一下这些代码,它们不能工作:

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust 需要知道应该为特定类型的值分配多少内存,同时所有同一类型的值必须使用相同数量的内存。如果允许编写这样的代码,也就意味着这两个 str 需要占用完全相同大小的空间。不过它们有着不同的长度:s1 需要 12 字节存储,而 s2 需要 15 字节。这也就是为什么不可能创建一个存放动态大小类型的变量的原因。

那么该怎么办呢?在这种情况下,你已经知道答案:s1s2 的类型是 &str 而不是 str。如果你回想第四章 “字符串 slice” 中提到,slice 数据结构仅仅储存了开始位置和 slice 的长度。所以虽然 &T 是一个储存了 T 所在的内存位置的单个值,&str 则是两个值:str 的地址和其长度。这样,&str 就有了一个在编译时可以知道的大小:它是 usize 长度的两倍。也就是说,无论所引用的字符串多长,我们总是知道 &str 的大小。一般来说,这就是 Rust 使用动态大小类型的方式:它们有一些额外的元信息来储存动态信息的大小。这引出了动态大小类型的黄金法则:必须将动态大小类型的值置于某种指针之后。

可以将 str 与各种指针类型组合使用:例如 Box<str>Rc<str>。事实上,你以前已经见过这种做法,不过对象换成了另一种动态大小类型:trait。每个 trait 本身也是一种动态大小类型,我们可以通过 trait 的名字来引用它。在第十八章 “使用 trait object 来抽象出共享行为” 一节中,我们提到过:为了把 trait 用作 trait 对象,必须把它放在某种指针之后,比如 &dyn TraitBox<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 的,所以需要将其置于某种指针之后。在这个例子中选择了引用。

接下来,我们将讨论函数和闭包!

高级函数与闭包

高级函数与闭包

本部分将探索一些有关函数和闭包的高级特性,这包括函数指针以及返回闭包。

函数指针

我们讨论过了如何向函数传递闭包;也可以将普通函数传递给函数!这个技术在我们希望传递已经定义的函数而不是重新定义闭包作为参数时很有用。函数会被强制转换为 fn 类型(小写的 f),不要与闭包 trait 的 Fn 相混淆。fn 被称为 函数指针function pointer)。通过函数指针允许我们使用函数作为其它函数的参数。

指定参数为函数指针的语法类似于闭包,如示例 20-28 所示,这里定义了一个 add_one 函数用于将其参数加一。do_twice 函数获取两个参数:一个指向任何获取一个 i32 参数并返回一个 i32 的函数指针,和一个 i32 值。do_twice 函数传入 arg 参数调用 f 函数两次,接着将两次函数调用的结果相加。main 函数使用 add_one5 作为参数调用 do_twice

文件名:src/main.rs

fn add_one(x: i32) -> i32 {
    x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(add_one, 5);

    println!("The answer is: {answer}");
}

示例 20-28: 使用 fn 类型接受函数指针作为参数

这段代码会打印出 The answer is: 12do_twice 中的 f 被指定为一个接受一个 i32 参数并返回 i32fn。接着就可以在 do_twice 函数体中调用 f。在 main 中,可以将函数名 add_one 作为第一个参数传递给 do_twice

不同于闭包,fn 是一个类型而不是一个 trait,所以直接指定 fn 作为参数而不是声明一个带有 Fn 作为 trait 约束的泛型参数。

函数指针实现了所有三个闭包 trait(FnFnMutFnOnce),所以总是可以在调用期望闭包的函数时传递函数指针作为参数。倾向于编写使用泛型和闭包 trait 的函数,这样它就能接受函数或闭包作为参数。

尽管如此,一个只期望接受 fn 而不接受闭包的情况的例子是与不存在闭包的外部代码交互时:C 语言的函数可以接受函数作为参数,但 C 语言没有闭包。

作为一个既可以使用内联定义的闭包又可以使用命名函数的例子,让我们看看一个标准库中 Iterator trait 提供的 map 方法的应用。使用 map 函数将一个数字 vector 转换为一个字符串 vector,就可以使用闭包,如示例 20-29 所示:

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(|i| i.to_string()).collect();
}
示例 20-29:使用闭包和 `map` 方法将数字转换为字符串

或者,也可以把一个函数作为 map 的参数来代替闭包。示例 20-30 展示了这种写法。

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(ToString::to_string).collect();
}
示例 20-30:使用 `String::to_string` 函数配合 `map` 方法将数字转换为字符串

注意这里必须使用 “高级 trait” 部分讲到的完全限定语法,因为存在多个叫做 to_string 的函数。

这里使用了定义于 ToString trait 的 to_string 函数,标准库为所有实现了 Display 的类型实现了这个 trait。

回忆一下第六章 “枚举值” 部分中定义的每一个枚举成员也变成了一个构造函数。我们可以使用这些构造函数作为实现了闭包 trait 的函数指针,这意味着可以指定构造函数作为接受闭包的方法的参数,如示例 20-31 所示:

fn main() {
    enum Status {
        Value(u32),
        Stop,
    }

    let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
}
示例 20-31:使用枚举构造函数和 `map` 方法从数字创建 `Status` 实例

这里,我们通过 Status::Value 的初始化函数,对 map 所作用的范围内每个 u32 值创建 Status::Value 实例。一些人倾向于函数式风格,一些人喜欢闭包。它们会编译成相同的代码,因此请选择对你来说更清晰的那一种。

返回闭包

闭包表现为 trait,这意味着不能直接返回闭包。对于大部分需要返回 trait 的场景中,可以使用实现了期望返回的 trait 的具体类型来替代函数的返回值。但是这不能用于闭包,因为它们没有一个可返回的具体类型;例如,当闭包从其作用域捕获任何值时,就不允许使用函数指针 fn 作为返回类型。

相反,可以正常地使用第十章所学的 impl Trait 语法。可以使用 FnFnOnceFnMut 返回任何函数类型。例如,示例 20-32 中的代码就可以正常工作。

#![allow(unused)]
fn main() {
fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}
}
示例 20-32:使用 `impl Trait` 语法从函数返回闭包

然而,正如我们在第十三章 “推断和注解闭包类型” 一节中提到的,每个闭包也都有自己独特的类型。如果你需要处理多个签名相同但实现不同的函数,就需要为它们使用 trait 对象。来看一下,如果写出类似示例 20-33 的代码,会发生什么。

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
    move |x| x + init
}

示例 20-33:创建一个由返回 impl Fn 的函数定义的闭包的 Vec<T>

这里有两个函数,returns_closurereturns_initialized_closure,它们都返回 impl Fn(i32) -> i32。注意它们返回的闭包是不同的,即使它们实现了相同的类型。如果尝试编译这段代码,Rust 会告诉我们这不可行:

$ cargo build
   Compiling functions-example v0.1.0 (file:///projects/functions-example)
error[E0308]: mismatched types
  --> src/main.rs:2:44
   |
 2 |     let handlers = vec![returns_closure(), returns_initialized_closure(123)];
   |                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
...
 9 | fn returns_closure() -> impl Fn(i32) -> i32 {
   |                         ------------------- the expected opaque type
...
13 | fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
   |                                              ------------------- the found opaque type
   |
   = note: expected opaque type `impl Fn(i32) -> i32`
              found opaque type `impl Fn(i32) -> i32`
   = note: distinct uses of `impl Trait` result in different opaque types

For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions-example` (bin "functions-example") due to 1 previous error

错误信息告诉我们每当返回一个 impl Trait Rust 会创建一个独特的不透明类型opaque type),这是一个无法看清 Rust 为我们构建了什么细节的类型。所以即使这些函数都返回了实现了相同 trait( Fn(i32) -> i32)的闭包,Rust 为我们生成的不透明类型也是不同的。这类似于 Rust 如何为不同的异步代码块生成不同的具体类型,即使它们有着相同的输出类型,如第十七章 Pin 类型与 Unpin trait” 所示。我们已经多次看到这个问题的解决方案:我们可以使用 trait 对象,如示例 20-34 所示。

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}

fn returns_initialized_closure(init: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + init)
}

示例 20-34:创建一个由返回 Box<dyn Fn> 的函数定义的闭包的 Vec<T> 以便它们有相同的类型

这段代码可以顺利编译。关于 trait 对象的更多内容,请回顾第十八章 “使用 trait 对象抽象出共享行为” 一节。

接下来让我们学习宏!

我们已经在本书中使用过像 println! 这样的宏了,不过尚未深入探讨什么是宏以及它是如何工作的。Macro)指的是 Rust 中一系列的功能:使用 macro_rules!声明宏declarative macro),和三种 过程宏procedural macro):

  • 自定义 #[derive] 宏,用于在结构体和枚举上通过添加 derive 属性生成代码
  • 类属性宏,定义可用于任意项的自定义属性
  • 类函数宏,看起来像函数,但操作的是作为其参数传递的 token

我们会依次讨论每一种宏,不过首要的是,让我们看看为什么已经有了函数还需要宏呢?

宏和函数的区别

从根本上来说,宏是一种为写其他代码而写代码的方式,即所谓的 元编程metaprogramming)。在附录 C 中会探讨 derive 属性,其生成各种 trait 的实现。我们也在本书中一直使用 println! 宏和 vec! 宏。所有的这些宏以 展开 的方式来生成比你所手写出的更多的代码。

元编程对于减少大量编写和维护的代码是非常有用的,它也扮演了函数所扮演的角色。但宏有一些函数所没有的附加能力。

一个函数签名必须声明函数参数的数量和类型。相比之下,宏能够接收可变数量的参数:用一个参数调用 println!("hello") 或用两个参数调用 println!("hello {}", name) 。而且,宏可以在编译器解析代码前展开,例如,宏可以在一个给定类型上实现 trait。而函数则不行,因为函数是在运行时被调用,而 trait 需要在编译时实现。

实现宏的缺点是与函数的定义相比宏的定义更复杂,因为你正在编写生成 Rust 代码的 Rust 代码。由于这样的间接性,宏定义通常要比函数定义更难阅读、理解和维护。

宏和函数的最后一个重要的区别是:在一个文件里调用宏 之前 必须定义它,或将其引入作用域,而函数则可以在任何地方定义和调用。

macro_rules! 编写用于通用元编程的声明宏

Rust 最常用的宏形式是 声明宏declarative macros)。它们有时也被称为 “macros by example”、“macro_rules! 宏” 或者就是 “macros”。其核心概念是,声明宏允许我们编写一些类似 Rust match 表达式的代码。正如在第六章讨论的那样,match 表达式是一种控制结构,其接收一个表达式,与表达式的结果进行模式匹配,然后根据模式匹配执行相关代码。宏也将一个值和包含相关代码的模式进行比较:此种情况下,该值是传递给宏的 Rust 源代码字面值;模式用于和前面提到的源代码字面值进行比较,一旦匹配成功,每个模式的相关代码会替换传递给宏的代码。所有这一切都发生于编译时。

可以使用 macro_rules! 来定义宏。让我们通过查看 vec! 宏定义来探索如何使用 macro_rules! 结构。第八章讲述了如何使用 vec! 宏来生成一个给定值的新 vector。例如,下面的宏用三个整数创建一个 vector:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

也可以使用 vec! 宏来构造两个整数的 vector 或五个字符串 slice 的 vector。但却无法使用函数做相同的事情,因为我们无法预先知道参数值的数量和类型。

在示例 20-35 中展示了 vec! 宏的一个稍微简化的定义。

文件名:src/lib.rs

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

示例 20-35: 一个 vec! 宏定义的简化版本

注意:标准库中实际定义的 vec! 包括预分配正确数量内存的代码。这部分为代码优化,为了让示例简化,此处并没有包含在内。

#[macro_export] 注解表明只要导入了定义这个宏的 crate,该宏就应该是可用的。如果没有该注解,这个宏不能被引入作用域。

接着使用 macro_rules! 和宏名称开始宏定义,且所定义的宏并 不带 感叹号。名字后跟大括号表示宏定义体,在该例中宏名称是 vec

vec! 宏的结构和 match 表达式的结构类似。此处有一个分支模式 ( $( $x:expr ),* ) ,后跟 => 以及和模式相关的代码块。如果模式匹配,该相关代码块将被展开。鉴于这个宏只有一个模式,那就只有一个有效匹配方式,其他任何模式方向(译者注:不匹配这个模式)都会导致错误。更复杂的宏会有不止一个分支。

宏定义中有效模式语法和在第十九章提及的模式语法是不同的,因为宏模式所匹配的是 Rust 代码结构而不是值。回过头来检查下示例 20-29 中模式片段什么意思。有关完整的宏模式语法,请查阅 Rust 参考

首先,一对括号包含了整个模式。我们使用美元符号($)在宏系统中声明一个变量来包含匹配该模式的 Rust 代码。美元符号明确表明这是一个宏变量而不是普通 Rust 变量。之后是一对括号,其捕获了符合括号内模式的值用以在替代代码中使用。$() 内则是 $x:expr ,其匹配 Rust 的任意表达式,并将该表达式命名为 $x

$() 之后的逗号表示在每个与 $() 内代码匹配的实例之间必须出现一个字面量逗号分隔符。紧随逗号之后的 * 说明该模式匹配零个或更多个 * 之前的任何模式。

当以 vec![1, 2, 3]; 调用宏时,$x 模式与三个表达式 123 对应进行了三次匹配。

现在让我们来看看与此分支模式相关联的代码块中的模式:在 $()* 部分,temp_vec.push($x) 会针对模式中每次匹配到 $() 的部分,生成零次或多次,取决于模式匹配到多少次。$x 由每个与之相匹配的表达式所替换。当以 vec![1, 2, 3]; 调用该宏时,替换该宏调用所生成的代码会是下面这样:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

我们已经定义了一个宏,其可以接收任意数量和类型的参数,同时可以生成能够创建包含指定元素的 vector 的代码。

要了解更多关于如何编写宏的信息,请查阅在线文档或其他资源,如由 Daniel Keep 发起、Lukas Wirth 继续维护的 “The Little Book of Rust Macros”

用于从属性生成代码的过程宏

第二种形式的宏被称为 过程宏procedural macros),因为它们更像函数(一种类型的过程)。过程宏接收 Rust 代码作为输入,在这些代码上进行操作,然后产生另一些代码作为输出,而非像声明式宏那样匹配对应模式然后以另一部分代码替换当前代码。有三种类型的过程宏,自定义派生(derive),类属性和类函数,它们的工作原理都类似。

创建过程宏时,其定义必须驻留在它们自己的具有特殊 crate 类型的 crate 中。这么做出于一些复杂的技术原因,将来我们希望能够消除这些限制。在示例 20-36 中展示了如何定义过程宏,其中 some_attribute 是一个使用特定宏变体的占位符。

文件名:src/lib.rs

use proc_macro::TokenStream;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

示例 20-36: 一个定义过程宏的例子

定义过程宏的函数接收一个 TokenStream 作为输入并生成 TokenStream 作为输出。TokenStream 是定义于 proc_macro crate 里代表一系列 token 的类型,Rust 默认携带了proc_macro crate。这就是宏的核心:宏所处理的源代码组成了输入 TokenStream,宏生成的代码是输出 TokenStream。函数上还有一个属性;这个属性指明了我们创建的过程宏的类型。在同一 crate 中可以有多种的过程宏。

让我们看看不同种类的程序宏。我们将从一个自定义的派生宏开始,然后解释使其他形式不同的小差异。

自定义 derive

让我们创建一个 hello_macro crate,其包含名为 HelloMacro 的 trait 和关联函数 hello_macro。不同于让用户为其每一个类型实现 HelloMacro trait,我们将会提供一个过程式宏以便用户可以使用 #[derive(HelloMacro)] 注解它们的类型来得到 hello_macro 函数的默认实现。该默认实现会打印 Hello, Macro! My name is TypeName!,其中 TypeName 为定义了 trait 的类型名。换言之,我们会创建一个 crate,使程序员能够写类似示例 20-37 中的代码。

文件名:src/main.rs

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}

示例 20-37: 我们 crate 的用户所写的能够使用过程式宏的代码

运行该代码将会打印 Hello, Macro! My name is Pancakes! 第一步是像下面这样新建一个库 crate:

$ cargo new hello_macro --lib

接下来,会定义 HelloMacro trait 以及其关联函数:

文件名:src/lib.rs

pub trait HelloMacro {
    fn hello_macro();
}

示例 20-38: 一个我们会用于 derive 宏的简单 trait

现在有了一个 trait 及其相关函数。此时,crate 用户可以像示例 20-39 那样实现该 trait 来达到期望的功能,像这样:

文件名:src/main.rs

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}

示例 20-39: 如果用户手动编写了一个 HelloMacro trait 实现看起来如何

然而,他们需要为每一个想要与 hello_macro 一同使用的类型编写实现的代码块。我们希望免去他们的这份工作。

另外,我们也无法为 hello_macro 函数提供一个能够打印实现了该 trait 的类型的名字的默认实现:Rust 没有反射能力,因此其无法在运行时获取类型名。我们需要一个在编译时生成代码的宏。

下一步是定义过程宏。在编写本部分时,过程式宏必须在其自己的 crate 内。该限制最终可能被取消。crate 及其宏 crate 的结构惯例如下:对于一个名为 foo 的 crate,其自定义 derive 过程宏 crate 通常命名为 foo_derive。让我们在 hello_macro 项目中,新建一个名为 hello_macro_derive 的 crate。

$ cargo new hello_macro_derive --lib

由于两个 crate 紧密相关,因此在 hello_macro 包的目录下创建过程式宏的 crate。如果改变在 hello_macro 中定义的 trait,同时也必须改变在 hello_macro_derive 中过程宏的实现。这两个包需要分别发布,编程人员如果使用这些包,则需要同时添加这两个依赖并将其引入作用域。我们也可以只用 hello_macro 包而将 hello_macro_derive 作为一个依赖,并重导出过程式宏的代码。但现在我们组织项目的方式使编程人员在无需 derive 功能时也能够单独使用 hello_macro

我们需要声明 hello_macro_derive crate 为过程宏 (proc-macro) crate。我们还需要 synquote crate 中的功能,正如你即将看到的,需要将它们加到依赖中。将下面的代码加入到 hello_macro_deriveCargo.toml 文件中。

文件名:hello_macro_derive/Cargo.toml

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

为定义一个过程式宏,请将示例 20-40 中的代码放在 hello_macro_derive crate 的 src/lib.rs 文件里面。注意这段代码在我们添加 impl_hello_macro 函数的定义之前是无法编译的。

文件名:hello_macro_derive/src/lib.rs

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // 将 Rust 代码构建成我们可以操作的语法树。
    let ast = syn::parse(input).unwrap();

    // 生成 trait 的实现。
    impl_hello_macro(&ast)
}

示例 20-40: 大多数过程式宏处理 Rust 代码时所需的代码

注意我们将代码分成了 hello_macro_deriveimpl_hello_macro 两个函数,前者负责解析 TokenStream,后者负责转换语法树:这使得编写过程宏更加方便。几乎你看到或者创建的每一个过程宏的外部函数(这里是 hello_macro_derive)中的代码都跟这里是一样的。你放入内部函数(这里是 impl_hello_macro)中的代码根据你的过程宏的设计目的会有所不同。

现在,我们已经引入了三个新的 crate:proc_macrosynquote。Rust 自带 proc_macro crate,因此无需将其加到 Cargo.toml 文件的依赖中。proc_macro crate 是编译器提供的 API,让我们能够在自己的代码中读取并操作 Rust 代码。

syn crate 将字符串中的 Rust 代码解析成为一个可以操作的数据结构。quote crate 则将 syn 解析的数据结构转换回 Rust 代码。这些 crate 让解析任何我们所要处理的 Rust 代码变得更加简单:为 Rust 编写完整的解析器并不是一件简单的工作。

当用户在一个类型上指定 #[derive(HelloMacro)] 时,hello_macro_derive 函数将会被调用。我们已使用 proc_macro_derive 注解该函数并指定名称 HelloMacro,该名称与我们的 trait 名称相匹配;这是大多数过程宏遵循的惯例。

该函数首先将来自 TokenStreaminput 转换为一个我们可以解释和操作的数据结构。这正是 syn 派上用场的地方。syn 中的 parse 函数获取一个 TokenStream 并返回一个表示解析出的 Rust 代码的 DeriveInput 结构体。示例 20-41 展示了从字符串 struct Pancakes; 中解析出来的 DeriveInput 结构体的相关部分:

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}

示例 20-41: 解析示例 20-37 中带有宏属性的代码时得到的 DeriveInput 实例

该结构体的字段表明:我们解析出的 Rust 代码是一个类单元结构体,它的 ident(标识符,也就是名称)是 Pancakes。这个结构体里还有更多字段,用来描述各种 Rust 代码;更多信息请参见 syn 中 DeriveInput 的文档

很快我们将定义 impl_hello_macro 函数,其用于构建所要包含在内的 Rust 新代码。但在此之前,注意其输出也是 TokenStream。所返回的 TokenStream 会被加到我们的 crate 用户所写的代码中,因此,当用户编译他们的 crate 时,他们会通过修改后的 TokenStream 获取到我们所提供的额外功能。

你可能也注意到了,当调用 syn::parse 函数失败时,我们用 unwrap 来使 hello_macro_derive 函数 panic。在错误时 panic 对过程宏来说是必须的,因为 proc_macro_derive 函数必须返回 TokenStream 而不是 Result,以此来符合过程宏的 API。这里选择用 unwrap 来简化了这个例子;在生产代码中,则应该通过 panic!expect 来提供关于发生何种错误的更加明确的错误信息。

现在我们有了将注解的 Rust 代码从 TokenStream 转换为 DeriveInput 实例的代码,让我们来创建在注解类型上实现 HelloMacro trait 的代码,如示例 20-42 所示。

文件名:hello_macro_derive/src/lib.rs

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let generated = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    generated.into()
}

示例 20-42: 使用解析过的 Rust 代码实现 HelloMacro trait

我们会得到一个 Ident 结构体实例,其中包含了注解类型的名字(标识符),这个值来自 ast.ident。示例 20-41 中的结构体表明:当 impl_hello_macro 函数作用于示例 20-37 中的代码时,ident 字段的值就是 "Pancakes"。因此,在示例 20-42 中,name 变量会包含一个 Ident 结构体实例;当它被打印出来时,就是字符串 "Pancakes",也就是示例 20-37 中那个结构体的名称。

quote! 宏能让我们编写希望返回的 Rust 代码。quote! 宏执行的直接结果并不是编译器所期望的所以需要转换为 TokenStream。为此需要调用 into 方法,它会消费这个中间表示(intermediate representation,IR)并返回所需的 TokenStream 类型值。

这个宏还提供了一套很方便的模板机制;我们可以写 #name,然后 quote! 会把它替换成名为 name 的变量的值。你甚至还可以像常规宏那样做一些重复代码生成。完整介绍请参见 quote crate 的文档

我们期望我们的过程式宏能够为通过 #name 获取到的用户注解类型生成 HelloMacro trait 的实现。该 trait 的实现有一个函数 hello_macro ,其函数体包括了我们期望提供的功能:打印 Hello, Macro! My name is 和注解的类型名。

此处所使用的 stringify! 为 Rust 内置宏。其接收一个 Rust 表达式,如 1 + 2 ,然后在编译时将表达式转换为一个字符串常量,如 "1 + 2" 。这与计算表达式并接着将结果转换为 Stringformat!println! 不同。有一种可能的情况是,所输入的 #name 可能是一个需要打印的表达式,因此我们用 stringify!stringify! 也能通过在编译时将 #name 转换为字符串字面值来节省一次内存分配。

此时,cargo build 应该都能成功编译 hello_macrohello_macro_derive 。我们将这些 crate 连接到示例 20-31 的代码中来看看过程宏的行为!在 projects 目录下用 cargo new pancakes 命令新建一个二进制项目。需要将 hello_macrohello_macro_derive 作为依赖加到 pancakes 包的 Cargo.toml 文件中去。如果你正将 hello_macrohello_macro_derive 的版本发布到 crates.io 上,它们将是常规依赖;否则,则可以像下面这样将其指定为 path 依赖:

hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

把示例 20-37 中的代码放在 src/main.rs ,然后执行 cargo run:其应该打印 Hello, Macro! My name is Pancakes!。其包含了该过程宏中 HelloMacro trait 的实现,而无需 pancakes crate 实现它;#[derive(HelloMacro)] 增加了该 trait 实现。

接下来,让我们探索一下其他类型的过程宏与自定义 derive 宏有何区别。

类属性宏

类属性(Attribute-Like)宏与自定义 derive 宏相似,不同之处在于它们不是为 derive 属性生成代码,而是允许你创建新的属性。它们也更为灵活;derive 只能用于结构体和枚举;属性还可以用于其它的项,比如函数。作为一个使用类属性宏的例子,可以创建一个名为 route 的属性用于注解 web 应用程序框架(web application framework)的函数:

#[route(GET, "/")]
fn index() {

#[route] 属性将由框架本身定义为一个过程宏。其宏定义的函数签名看起来像这样:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

这里有两个 TokenStream 类型的参数;第一个用于属性内容本身,也就是 GET, "/" 部分。第二个是属性所标记的项:在本例中,是 fn index() {} 和剩下的函数体。

除此之外,类属性宏与自定义派生宏工作方式一致:创建 proc-macro crate 类型的 crate 并实现生成所希望的代码的函数!

类函数宏

类函数(Function-like)宏的定义看起来像函数调用的宏。类似于 macro_rules!,它们比函数更灵活;例如,可以接受未知数量的参数。然而 macro_rules! 宏只能使用之前 “使用 macro_rules! 的声明宏用于通用元编程” 介绍的类匹配的语法定义。类函数宏获取 TokenStream 参数,其定义使用 Rust 代码操纵 TokenStream,就像另两种过程宏一样。一个类函数宏例子是可以像这样被调用的 sql! 宏:

let sql = sql!(SELECT * FROM posts WHERE id=1);

这个宏会解析其中的 SQL 语句并检查其是否是句法正确的,这是比 macro_rules! 可以做到的更为复杂的处理。sql! 宏会被类似于像这样定义:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

这类似于自定义 derive 宏的签名:获取括号中的 token,并返回希望生成的代码。

总结

呼!现在你的工具箱中有了一些 Rust 特性,虽然你可能不会经常使用它们,但在非常特定的情况下你会知道它们可用。我们介绍了几个复杂的主题,以便当你在错误信息建议或他人代码中遇到它们时,能够识别这些概念和语法。本章可作为查找解决方案的参考。

接下来,我们将再开始一个项目,将本书所学的所有内容付诸实践!

评论 (0)