在 LabEx 中探索 Rust 宏

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到实验。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,我们将探索 Rust 中宏的概念,包括使用 macro_rules! 的声明式宏,以及三种过程宏:自定义 #[derive] 宏、类属性宏和类函数宏。

在本书中,我们已经使用过像 println! 这样的宏,但我们还没有全面探讨宏是什么以及它是如何工作的。术语“宏”指的是 Rust 中的一系列特性:使用 macro_rules!声明式宏,以及三种过程式宏:

  • 自定义 #[derive] 宏,它指定为结构体和枚举上使用的 derive 属性添加的代码
  • 类属性宏,它定义可用于任何项的自定义属性
  • 类函数宏,它看起来像函数调用,但对作为其参数指定的标记进行操作

我们将依次讨论这些内容,但首先,让我们看看在已经有函数的情况下,为什么我们还需要宏。

宏与函数的区别

从根本上讲,宏是一种编写代码的方式,它可以编写其他代码,这被称为元编程。在附录 C 中,我们讨论了 derive 属性,它会为你生成各种 trait 的实现。在本书中,我们还使用了 println!vec! 宏。所有这些宏都会展开,生成比你手动编写的代码更多的代码。

元编程有助于减少你需要编写和维护的代码量,这也是函数的作用之一。然而,宏具有一些函数所没有的额外功能。

函数签名必须声明函数所具有的参数数量和类型。另一方面,宏可以接受可变数量的参数:我们可以使用一个参数调用 println!("hello"),或者使用两个参数调用 println!("hello {}", name)。此外,宏在编译器解释代码含义之前就会展开,因此例如,宏可以在给定类型上实现一个 trait。函数则不能,因为它是在运行时被调用的,而 trait 需要在编译时实现。

使用宏而不是函数的缺点是,宏定义比函数定义更复杂,因为你编写的是生成 Rust 代码的 Rust 代码。由于这种间接性,宏定义通常比函数定义更难阅读、理解和维护。

宏和函数之间的另一个重要区别是,在文件中调用宏之前,你必须先定义宏或将其引入作用域,这与函数不同,函数可以在任何地方定义并在任何地方调用。

使用 macro_rules! 的声明式宏进行通用元编程

Rust 中使用最广泛的宏形式是声明式宏。这些宏有时也被称为“示例宏”、“macro_rules! 宏”或简称为“宏”。从核心上来说,声明式宏允许你编写类似于 Rust match 表达式的东西。如第 6 章所述,match 表达式是一种控制结构,它接受一个表达式,将表达式的结果值与模式进行比较,然后运行与匹配模式相关联的代码。宏也会将一个值与和特定代码相关联的模式进行比较:在这种情况下,值是传递给宏的字面 Rust 源代码;模式与该源代码的结构进行比较;当匹配时,与每个模式相关联的代码会替换传递给宏的代码。这一切都在编译期间发生。

要定义一个宏,你可以使用 macro_rules! 结构。让我们通过查看 vec! 宏的定义方式来探索如何使用 macro_rules!。第 8 章介绍了我们如何使用 vec! 宏来创建一个包含特定值的新向量。例如,以下宏创建一个包含三个整数的新向量:

let v: Vec<u32> = vec![1, 2, 3];

我们也可以使用 vec! 宏来创建一个包含两个整数的向量或一个包含五个字符串切片的向量。我们无法使用函数来完成相同的操作,因为我们事先不知道值的数量或类型。

清单 19 - 28 展示了 vec! 宏的一个稍微简化的定义。

文件名:src/lib.rs

1 #[macro_export]
2 macro_rules! vec {
  3 ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
          4 $(
              5 temp_vec.push(6 $x);
            )*
          7 temp_vec
        }
    };
}

清单 19 - 28:vec! 宏定义的简化版本

注意:标准库中 vec! 宏的实际定义包括预先分配正确数量内存的代码。为了使示例更简单,我们在这里没有包含那段代码。

#[macro_export] 注释 [1] 表示,只要定义该宏的 crate 被引入作用域,这个宏就应该可用。没有这个注释,宏就不能被引入作用域。

然后我们使用 macro_rules! 和我们正在定义的宏的名称(不带感叹号)来开始宏定义 [2]。在这种情况下,名称是 vec,后面跟着表示宏定义主体的花括号。

vec! 主体中的结构类似于 match 表达式的结构。这里我们有一个带有模式 ( $( $x:expr ),* ) 的分支,后面跟着 => 和与这个模式相关联的代码块 [3]。如果模式匹配,相关联的代码块就会被发出。鉴于这是这个宏中唯一的模式,所以只有一种有效的匹配方式;任何其他模式都会导致错误。更复杂的宏会有多个分支。

宏定义中的有效模式语法与第 18 章介绍的模式语法不同,因为宏模式是与 Rust 代码结构而不是值进行匹配。让我们逐步分析清单 19 - 28 中的模式部分是什么意思;有关完整的宏模式语法,请参阅 Rust 参考文档 https://doc.rust-lang.org/reference/macros-by-example.html

首先,我们使用一组括号来包围整个模式。我们使用美元符号($)在宏系统中声明一个变量,该变量将包含与模式匹配的 Rust 代码。美元符号清楚地表明这是一个宏变量,而不是普通的 Rust 变量。接下来是一组括号,它捕获与括号内模式匹配的值,以便在替换代码中使用。在 $() 内部是 $x:expr,它匹配任何 Rust 表达式,并将该表达式命名为 $x

紧跟在 $() 后面的逗号表示在与 $() 中的代码匹配的代码之后,可能会出现一个字面逗号分隔符。* 表示该模式匹配零个或多个在 * 之前的任何内容。

当我们使用 vec![1, 2, 3]; 调用这个宏时,$x 模式会与三个表达式 123 匹配三次。

现在让我们看看与这个分支相关联的代码主体中的模式:在 $()* 内的 temp_vec.push() [5] 在 [4] 和 [7] 处,会根据模式匹配的次数,为与模式中 $() 匹配的每个部分零次或多次生成。$x [6] 会被替换为每个匹配的表达式。当我们使用 vec![1, 2, 3]; 调用这个宏时,替换这个宏调用生成的代码将如下所示:

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

我们已经定义了一个宏,它可以接受任意数量、任意类型的参数,并可以生成代码来创建一个包含指定元素的向量。

要了解更多关于如何编写宏的知识,请查阅在线文档或其他资源,例如由 Daniel Keep 发起并由 Lukas Wirth 继续维护的 https://veykril.github.io/tlborm 上的《Rust 宏小手册》。

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

宏的第二种形式是过程宏,它的行为更像一个函数(并且是一种过程)。过程宏接受一些代码作为输入,对该代码进行操作,并生成一些代码作为输出,而不像声明式宏那样与模式进行匹配并用其他代码替换该代码。三种过程宏分别是自定义 derive、类属性和类函数,它们的工作方式类似。

在创建过程宏时,定义必须位于具有特殊 crate 类型的自己的 crate 中。这是出于一些复杂的技术原因,我们希望在未来消除这些原因。在清单 19 - 29 中,我们展示了如何定义一个过程宏,其中 some_attribute 是使用特定宏类型的占位符。

文件名:src/lib.rs

use proc_macro::TokenStream;

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

清单 19 - 29:定义过程宏的示例

定义过程宏的函数以 TokenStream 作为输入并生成 TokenStream 作为输出。TokenStream 类型由 Rust 附带的 proc_macro crate 定义,表示一系列标记。这是宏的核心:宏所操作的源代码构成输入 TokenStream,宏生成的代码是输出 TokenStream。该函数还附加了一个属性,用于指定我们正在创建的过程宏的类型。我们可以在同一个 crate 中有多种过程宏。

让我们看看不同类型的过程宏。我们将从自定义 derive 宏开始,然后解释使其他形式不同的小差异。

如何编写自定义 derive 宏

让我们创建一个名为 hello_macro 的 crate,它定义了一个名为 HelloMacro 的 trait,以及一个名为 hello_macro 的关联函数。我们不会让用户为他们的每个类型实现 HelloMacro trait,而是提供一个过程宏,这样用户就可以用 #[derive(HelloMacro)] 来注释他们的类型,从而获得 hello_macro 函数的默认实现。默认实现将打印 Hello, Macro! My name is TypeName!,其中 TypeName 是定义该 trait 的类型的名称。换句话说,我们将编写一个 crate,使其他程序员能够使用我们的 crate 编写类似于清单 19 - 30 的代码。

文件名:src/main.rs

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

#[derive(HelloMacro)]
struct Pancakes;

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

清单 19 - 30:使用我们的过程宏时,我们的 crate 用户能够编写的代码

当我们完成后,这段代码将打印 Hello, Macro! My name is Pancakes!。第一步是创建一个新的库 crate,如下所示:

cargo new hello_macro --lib

接下来,我们将定义 HelloMacro trait 及其关联函数:

文件名:src/lib.rs

pub trait HelloMacro {
    fn hello_macro();
}

我们有了一个 trait 及其函数。此时,我们的 crate 用户可以通过实现该 trait 来实现所需的功能,如下所示:

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

然而,他们需要为每个想要与 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 的目录中创建过程宏 crate。如果我们在 hello_macro 中更改 trait 定义,我们也必须在 hello_macro_derive 中更改过程宏的实现。这两个 crate 需要分别发布,使用这些 crate 的程序员需要将它们都作为依赖项添加并引入作用域。或者,我们可以让 hello_macro crate 使用 hello_macro_derive 作为依赖项,并重新导出过程宏代码。然而,我们构建项目的方式使得即使程序员不想要 derive 功能,也可以使用 hello_macro

我们需要将 hello_macro_derive crate 声明为一个过程宏 crate。我们还需要 synquote crate 的功能,稍后你会看到,所以我们需要将它们作为依赖项添加。将以下内容添加到 hello_macro_deriveCargo.toml 文件中:

文件名:hello_macro_derive/Cargo.toml

[lib]
proc-macro = true

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

要开始定义过程宏,将清单 19 - 31 中的代码放入 hello_macro_derive crate 的 src/lib.rs 文件中。请注意,在我们为 impl_hello_macro 函数添加定义之前,这段代码不会编译。

文件名:hello_macro_derive/src/lib.rs

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

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // 将 Rust 代码表示为一个语法树,以便我们可以操作
    let ast = syn::parse(input).unwrap();

    // 构建 trait 实现
    impl_hello_macro(&ast)
}

清单 19 - 31:大多数过程宏 crate 处理 Rust 代码所需的代码

请注意,我们将代码分成了 hello_macro_derive 函数和 impl_hello_macro 函数,前者负责解析 TokenStream,后者负责转换语法树:这使得编写过程宏更加方便。外部函数(在这种情况下是 hello_macro_derive)中的代码对于你看到或创建的几乎每个过程宏 crate 来说都是相同的。你在内部函数(在这种情况下是 impl_hello_macro)主体中指定的代码将根据你的过程宏的目的而有所不同。

我们引入了三个新的 crate:proc_macrosyn(可从 https://crates.io/crates/syn 获得)和 quote(可从 https://crates.io/crates/quote 获得)。proc_macro crate 随 Rust 一起提供,所以我们不需要将其添加到 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 注释了 hello_macro_derive 函数,并指定了名称 HelloMacro,它与我们的 trait 名称相匹配;这是大多数过程宏遵循的约定。

hello_macro_derive 函数首先将 inputTokenStream 转换为一个数据结构,然后我们可以对其进行解释和操作。这就是 syn 发挥作用的地方。syn 中的 parse 函数接受一个 TokenStream,并返回一个表示解析后的 Rust 代码的 DeriveInput 结构体。清单 19 - 32 展示了从解析 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
            )
        }
    )
}

清单 19 - 32:解析清单 19 - 30 中带有宏属性的代码时得到的 DeriveInput 实例

这个结构体的字段表明我们解析的 Rust 代码是一个单元结构体,其 ident标识符,即名称)为 Pancakes。这个结构体还有更多字段用于描述各种 Rust 代码;有关 DeriveInput 的更多信息,请查看 https://docs.rs/syn/1.0/syn/struct.DeriveInput.html 上的 syn 文档。

很快我们将定义 impl_hello_macro 函数,在那里我们将构建我们想要包含的新 Rust 代码。但在我们这样做之前,请注意我们的 derive 宏的输出也是一个 TokenStream。返回的 TokenStream 会被添加到我们 crate 用户编写的代码中,所以当他们编译他们的 crate 时,他们将获得我们在修改后的 TokenStream 中提供的额外功能。

你可能已经注意到我们在这里调用 unwrap,以便如果对 syn::parse 函数的调用失败,hello_macro_derive 函数会 panic。我们的过程宏在出错时必须 panic,因为 proc_macro_derive 函数必须返回 TokenStream 而不是 Result,以符合过程宏 API。我们在这个例子中使用 unwrap 进行了简化;在生产代码中,你应该使用 panic!expect 提供更具体的关于出错原因的错误消息。

现在我们有了将带注释的 Rust 代码从 TokenStream 转换为 DeriveInput 实例的代码,让我们生成在带注释的类型上实现 HelloMacro trait 的代码,如清单 19 - 33 所示。

文件名:hello_macro_derive/src/lib.rs

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

清单 19 - 33:使用解析后的 Rust 代码实现 HelloMacro trait

我们使用 ast.ident 获取一个包含带注释类型的名称(标识符)的 Ident 结构体实例。清单 19 - 32 中的结构体表明,当我们对清单 19 - 30 中的代码运行 impl_hello_macro 函数时,我们得到的 ident 将具有一个 ident 字段,其值为 "Pancakes"。因此,清单 19 - 33 中的 name 变量将包含一个 Ident 结构体实例,打印时将是字符串 "Pancakes",即清单 19 - 30 中结构体的名称。

quote! 宏让我们定义想要返回的 Rust 代码。编译器期望的与 quote! 宏执行的直接结果不同,所以我们需要将其转换为 TokenStream。我们通过调用 into 方法来做到这一点,该方法消耗这个中间表示并返回所需的 TokenStream 类型的值。

quote! 宏还提供了一些非常酷的模板机制:我们可以输入 #namequote! 会将其替换为变量 name 中的值。你甚至可以进行一些类似于普通宏工作方式的重复操作。有关详细介绍,请查看 https://docs.rs/quote 上的 quote crate 文档。

我们希望我们的过程宏为用户注释的类型生成 HelloMacro trait 的实现,我们可以通过使用 #name 来实现。trait 实现有一个函数 hello_macro,其主体包含我们想要提供的功能:打印 Hello, Macro! My name is,然后是带注释类型的名称。

这里使用的 stringify! 宏是 Rust 内置的。它接受一个 Rust 表达式,例如 1 + 2,并在编译时将该表达式转换为一个字符串字面量,例如 "1 + 2"。这与 format!println! 宏不同,后者会计算表达式,然后将结果转换为一个 String#name 输入有可能是一个要按字面量打印的表达式,所以我们使用 stringify!。使用 stringify! 还通过在编译时将 #name 转换为字符串字面量节省了一次分配。

此时,hello_macrohello_macro_derive 中的 cargo build 应该都能成功完成。让我们将这些 crate 与清单 19 - 30 中的代码连接起来,看看过程宏的实际运行情况!使用 cargo new pancakes 在你的 project 目录中创建一个新的二进制项目。我们需要在 pancakes crate 的 Cargo.toml 中将 hello_macrohello_macro_derive 添加为依赖项。如果你要将你的 hello_macrohello_macro_derive 版本发布到 https://crates.io,它们将是常规依赖项;如果不是,你可以如下指定它们为 path 依赖项:

[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

将清单 19 - 30 中的代码放入 src/main.rs,然后运行 cargo run:它应该打印 Hello, Macro! My name is Pancakes! 过程宏对 HelloMacro trait 的实现被包含进来,而 pancakes crate 无需实现它;#[derive(HelloMacro)] 添加了 trait 实现。

接下来,让我们探讨其他类型的过程宏与自定义 derive 宏有何不同。

类属性宏

类属性宏类似于自定义 derive 宏,但它们不是为 derive 属性生成代码,而是允许你创建新的属性。它们也更灵活:derive 仅适用于结构体和枚举;属性还可以应用于其他项,例如函数。下面是一个使用类属性宏的示例。假设你有一个名为 route 的属性,用于在使用 Web 应用程序框架时注释函数:

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

这个 #[route] 属性将由框架定义为一个过程宏。宏定义函数的签名如下所示:

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

在这里,我们有两个 TokenStream 类型的参数。第一个用于属性的内容:即 GET, "/" 部分。第二个是属性所附加到的项的主体:在这种情况下,是 fn index() {} 以及函数主体的其余部分。

除此之外,类属性宏的工作方式与自定义 derive 宏相同:你创建一个具有 proc-macro crate 类型的 crate,并实现一个生成你想要的代码的函数!

类函数宏

类函数宏定义的宏看起来像函数调用。与 macro_rules! 宏类似,它们比函数更灵活;例如,它们可以接受数量不定的参数。然而,macro_rules! 宏只能使用我们在“使用 macro_rules! 的声明式宏进行通用元编程”中讨论的类似 match 的语法来定义。类函数宏接受一个 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 宏的签名:我们接收括号内的标记并返回我们想要生成的代码。

总结

恭喜你!你已经完成了实验「宏」。你可以在 LabEx 中练习更多实验来提升你的技能。