我们在本书中多次使用过 println! 这样的宏,但尚未完全探究宏是什么以及它是如何工作的。术语 在 Rust 中指代一组特性:使用 macro_rules! 的*声明宏*以及三种*过程宏*

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

我们将依次讨论这些宏,但首先,让我们看看当我们已经有了函数时,为什么还需要宏。

宏与函数的区别

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

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

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

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

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

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

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

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

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

我们也可以使用 vec! 宏来创建一个包含两个整数的 vector 或一个包含五个字符串切片的 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-35vec! 宏定义的一个简化版本

注意:标准库中 vec! 宏的实际定义包含预先分配正确内存量的代码。该代码是一种优化,为了使示例更简单,我们在此未包含。

#[macro_export] 注解表明,只要定义了此宏的 crate 被引入作用域,就应该使此宏可用。没有此注解,宏就无法引入作用域。

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

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

宏定义中的有效模式语法与第 19 章介绍的模式语法不同,因为宏模式是匹配 Rust 代码结构而不是值。让我们逐步了解列表 20-29 中模式片段的含义;有关完整的宏模式语法,请参阅 Rust 引用

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

紧跟 $() 的逗号表示在匹配 $() 内代码的每个实例之间必须出现一个字面逗号分隔符。* 指定模式匹配紧邻 * 前内容的零个或多个实例。

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

现在让我们看看与此分支关联的代码体中的模式:$()* 中的 temp_vec.push() 会针对模式中匹配 $() 的每个部分生成零次或多次,具体取决于模式匹配的次数。$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 继续维护的 “Rust 宏小书”

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

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

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

文件名:src/lib.rs
use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
列表 20-36:定义过程宏的示例

定义过程宏的函数接受一个 TokenStream 作为输入,并生成一个 TokenStream 作为输出。TokenStream 类型由 Rust 附带的 proc_macro crate 定义,表示一系列 token。这是宏的核心:宏所操作的源代码构成输入的 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 编写如列表 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:用户使用我们的过程宏时可以编写的代码

完成后,这段代码将打印 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:一个简单的 trait,我们将与 derive 宏一起使用

我们有一个 trait 及其函数。此时,我们的 crate 用户可以实现该 trait 来实现所需的功能,如列表 20-39 所示。

文件名: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 的目录中创建过程宏 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 = "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 {
    // 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)
}
列表 20-40:大多数过程宏 crate 处理 Rust 代码所需的代码

请注意,我们将代码拆分为负责解析 TokenStreamhello_macro_derive 函数和负责转换语法树的 impl_hello_macro 函数:这使得编写过程宏更加方便。外部函数(本例中为 hello_macro_derive)中的代码对于你看到或创建的几乎所有过程宏 crate 来说都是相同的。内部函数(本例中为 impl_hello_macro)体中指定的代码将根据你的过程宏的目的而有所不同。

我们引入了三个新的 crate:proc_macrosynquoteproc_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 函数首先将输入的 TokenStream 转换为我们可以解释和操作的数据结构。这就是 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 代码。但在那之前,请注意我们的 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 的代码,如列表 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

我们使用 ast.ident 获得包含带注解类型名称(标识符)的 Ident 结构体实例。列表 20-33 中的结构体显示,当我们在列表 20-31 的代码上运行 impl_hello_macro 函数时,获得的 ident 将具有值为 "Pancakes"ident 字段。因此,列表 20-34 中的 name 变量将包含一个 Ident 结构体实例,打印时将是字符串 "Pancakes",也就是列表 20-37 中结构体的名称。

quote! 宏允许我们定义要返回的 Rust 代码。编译器期望得到与 quote! 宏执行的直接结果不同的东西,所以我们需要将其转换为 TokenStream。我们通过调用 into 方法来完成此操作,该方法会消耗这种中间表示并返回所需 TokenStream 类型的值。

quote! 宏还提供了一些非常酷的模板机制:我们可以输入 #namequote! 就会将其替换为变量 name 中的值。你甚至可以像普通宏那样进行一些重复。有关全面的介绍,请查阅 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 转换为字符串字面量来节省一次分配。

此时,cargo build 应该在 hello_macrohello_macro_derive 中都能成功完成。让我们将这些 crate 连接到列表 20-31 中的代码,看看过程宏的实际效果!在你的 projects 目录中使用 cargo new pancakes 创建一个新的二进制项目。我们需要将 hello_macrohello_macro_derive 添加到 pancakes crate 的 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 宏有何不同。

类属性宏

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

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

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

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

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

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

类函数宏

类函数宏定义看起来像函数调用的宏。与 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 宏的签名类似:我们接收括号内的 token,并返回我们想要生成的代码。

总结

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

接下来,我们将把本书中讨论的所有内容付诸实践,再做一个项目!