Rust 2018 中的过程宏

2018 年 12 月 21 日 · Alex Crichton

也许 Rust 2018 版本中我最喜欢的功能是过程宏。过程宏在 Rust 中有着悠久的历史(并且未来还将继续发展!),现在可能是参与其中的最佳时机之一,因为 2018 版本极大地改善了定义和使用它们时的体验。

在这里,我想探讨一下什么是过程宏,它们的功能,值得注意的新功能,以及一些过程宏的有趣用例。我甚至可能会说服你,这也是 Rust 2018 最好的功能!

什么是过程宏?

过程宏最早在两年前的 RFC 1566 中定义,用外行的话来说,它是一个函数,在编译时获取一段语法并产生一段新的语法。Rust 2018 中的过程宏有三种形式

  • #[derive] 模式宏实际上自 Rust 1.15 版本以来就已稳定,并将 #[derive(Debug)] 的所有优点和易用性也带到了用户定义的特征,例如 Serde#[derive(Deserialize)]

  • 类函数宏是 2018 版本中新加入的稳定功能,允许在基于 crates.io 的库中定义类似于 env!("FOO")format_args!("...") 的宏。你可以将它们视为类固醇上的“macro_rules! 宏”。

  • 属性宏,我最喜欢的宏,也是 2018 版本中的新功能,允许你为 Rust 函数提供轻量级注释,这些注释在编译时对代码执行语法转换。

这些宏的每一种形式都可以在清单中指定 proc-macro = true 的 crate 中定义。使用时,过程宏会由 Rust 编译器加载,并在调用展开时执行。这意味着 Cargo 可以控制过程宏的版本控制,并且你可以像使用其他 Cargo 依赖项一样轻松地使用它们!

定义过程宏

这三种类型的过程宏都以略有不同的方式定义,在这里我们将挑选属性宏。首先,我们将标记 Cargo.toml

[lib]
proc-macro = true

然后在 src/lib.rs 中,我们可以编写我们的宏

extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro_attribute]
pub fn hello(attr: TokenStream, item: TokenStream) -> TokenStream {
    // ...
}

然后我们可以在 tests/smoke.rs 中编写一些单元测试

#[my_crate::hello]
fn wrapped_function() {}

#[test]
fn works() {
    wrapped_function();
}

... 就是这样!当我们执行 cargo test 时,Cargo 将编译我们的过程宏。之后,它将编译我们的单元测试,该测试在编译时加载宏,执行 hello 函数并编译生成的语法。

马上我们就可以看到过程宏的一些重要属性

  • 输入/输出是我们稍后将详细讨论的这种奇特的 TokenStream 类型
  • 我们正在编译时执行任意代码,这意味着我们可以做几乎任何事情!
  • 过程宏与模块系统结合在一起,这意味着它们可以像其他任何名称一样导入。

在我们查看实现过程宏之前,让我们先深入研究一下其中的一些要点。

宏和模块系统

首次在 Rust 1.30 中稳定(注意到 1.15 的趋势了吗?),宏现在已与 Rust 中的模块系统集成。这主要意味着在导入宏时,你不再需要笨拙的 #[macro_use] 属性!代替这个

#[macro_use]
extern crate log;

fn main() {
    debug!("hello, ");
    info!("world!");
}

你可以这样做

use log::info;

fn main() {
    log::debug!("hello, ");
    info!("world!");
}

与模块系统的集成解决了历史上关于宏的最令人困惑的部分之一。现在,它们像 Rust 中的任何其他项一样被导入和命名空间!

好处不仅限于 bang 风格的 macro_rules 宏,因为你现在可以转换看起来像这样的代码

#[macro_use]
extern crate serde_derive;

#[derive(Deserialize)]
struct Foo {
    // ...
}

变成

use serde::Deserialize;

#[derive(Deserialize)]
struct Foo {
    // ...
}

你甚至不需要在 Cargo.toml 中显式依赖 serde_derive!你只需要

[dependencies]
serde = { version = '1.0.82', features = ['derive'] }

TokenStream 内部是什么?

这个神秘的 TokenStream 类型来自编译器提供的 proc_macro crate。当它首次添加时,你可以使用 TokenStream 所做的全部事情就是使用 to_string()parse() 将其转换为字符串或从字符串转换。从 Rust 2018 开始,你可以直接对 TokenStream 中的标记执行操作。

TokenStream 实际上“只是” TokenTree 的迭代器。Rust 中的所有语法都属于以下四个类别之一,即 TokenTree 的四个变体

  • Ident 是任何标识符,如 foobar。它还包含诸如 selfsuper 之类的关键字。
  • Literal 包括诸如 1"foo"'b' 之类的东西。所有字面量都是一个标记,表示程序中的常量值。
  • Punct 表示某种形式的标点符号,它不是分隔符。例如,.foo.bar 字段访问中的 Punct 标记。像 => 这样的多字符标点符号表示为两个 Punct 标记,一个用于 =,另一个用于 >,并且 Spacing 枚举表示 => 相邻。
  • Group 是术语“树”最相关的地方,因为 Group 表示带分隔符的子标记流。例如,(a, b) 是一个以括号作为分隔符的 Group,内部标记流是 a, b

尽管这在概念上很简单,但这听起来好像我们无法用它做太多事情!例如,我们如何从 TokenStream 解析函数尚不清楚。但是,TokenTree 的最小化对于稳定至关重要。稳定 Rust AST 是不可行的,因为这意味着我们永远无法更改它。(想象一下,如果我们不能添加 ? 运算符会怎样!)

通过使用 TokenStream 与过程宏通信,编译器可以添加新的语言语法,同时还能够编译和使用较旧的过程宏。但是,现在让我们看看我们如何实际从 TokenStream 中获取有用的信息。

解析 TokenStream

如果 TokenStream 只是一个简单的迭代器,那么从它到实际解析的函数还有很长的路要走。尽管代码已经为我们进行了词法分析,但我们仍然需要编写一个完整的 Rust 解析器!值得庆幸的是,社区一直在努力确保在 Rust 中编写过程宏尽可能顺利,因此你无需再寻找其他,只需使用 syn crate 即可。

使用 syn crate,我们可以将任何 Rust AST 解析为单行代码

#[proc_macro_attribute]
pub fn hello(attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = syn::parse_macro_input!(item as syn::ItemFn);
    let name = &input.ident;
    let abi = &input.abi;
    // ...
}

syn crate 不仅具有解析内置语法的功能,而且还可以轻松为自己的语法编写递归下降解析器。syn::parse 模块包含有关此功能的更多信息。

生成 TokenStream

我们不仅需要以过程宏作为输入来获取 TokenStream,而且还需要生成一个 TokenStream 作为输出。此输出通常需要是有效的 Rust 语法,但是像输入一样,它只是我们需要以某种方式构建的标记列表。

从技术上讲,创建 TokenStream 的唯一方法是通过其 FromIterator 实现,这意味着我们必须逐个创建每个标记并将其收集到 TokenStream 中。但这非常繁琐,因此让我们看一下 syn 的兄弟 crate:quote

quote crate 是 Rust 的准引用实现,它主要为我们提供了一个方便的宏来使用

use quote::quote;

#[proc_macro_attribute]
pub fn hello(attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = syn::parse_macro_input!(item as syn::ItemFn);
    let name = &input.ident;

    // Our input function is always equivalent to returning 42, right?
    let result = quote! {
        fn #name() -> u32 { 42 }
    };
    result.into()
}

quote!允许你编写大部分 Rust 语法,并使用 #foo 从环境中快速插入变量。这消除了逐个创建 TokenStream 标记的许多繁琐工作,并允许快速将各种语法片段拼凑成一个返回值。

标记和 Span

Rust 2018 中过程宏的最大功能也许是能够自定义和使用每个标记上的Span信息,从而使我们能够从过程宏中获得惊人的语法错误消息

error: expected `fn`
 --> src/main.rs:3:14
  |
3 | my_annotate!(not_fn foo() {});
  |              ^^^^^^

以及完全自定义的错误消息

error: imported methods must have at least one argument
  --> invalid-imports.rs:12:5
   |
12 |     fn f1();
   |     ^^^^^^^^

Span 可以被认为是回到原始源文件的指针,通常会说“Ident 标记 foo 来自文件 bar.rs,第 4 行,第 5 列,长度为 3 个字节”。此信息主要由编译器的诊断与警告和错误消息一起使用。

在 Rust 2018 中,每个 TokenTree 都与一个 Span 相关联。这意味着,如果你将所有输入标记的 Span 保留到输出中,那么即使你生成了全新的语法,编译器的错误消息仍然是准确的!

例如,像这样的一个小宏

#[proc_macro]
pub fn make_pub(item: TokenStream) -> TokenStream {
    let result = quote! {
        pub #item
    };
    result.into()
}

当调用为

my_macro::make_pub! {
    static X: u32 = "foo";
}

是无效的,因为我们正在从应返回 u32 的函数返回一个字符串,并且编译器将有助于诊断该问题,如下所示

error[E0308]: mismatched types
 --> src/main.rs:1:37
  |
1 | my_macro::make_pub!(static X: u32 = "foo");
  |                                     ^^^^^ expected u32, found reference
  |
  = note: expected type `u32`
             found type `&'static str`

error: aborting due to previous error

在这里我们可以看到,尽管我们正在生成全新的语法,但编译器可以保留 span 信息,以继续提供有关我们编写的代码的定向诊断。

实际应用中的过程宏

到目前为止,我们已经对过程宏的功能以及它们在 2018 版本中的各种能力有了相当不错的了解。作为一个期待已久的功能,生态系统已经开始利用这些新功能了!如果您有兴趣,以下是一些值得关注的项目:

  • synquoteproc-macro2 是您编写过程宏的首选库。它们使定义自定义解析器、解析现有语法、创建新语法、处理旧版本的 Rust 等变得容易!

  • Serde 及其用于 SerializeDeserialize 的派生宏可能是生态系统中最常用的宏。它们拥有令人印象深刻的配置量,并且是小型注解如何如此强大的一个很好的例子。

  • wasm-bindgen 项目使用属性宏来轻松定义 Rust 中的接口并从 JS 导入接口。 #[wasm_bindgen] 轻量级注解使理解输入和输出的内容变得容易,同时也消除了大量的转换样板代码。

  • gobject_gen!是 GNOME 项目的一个实验性 IDL,用于在 Rust 中安全地定义 GObject 对象,避免手动编写所有必要的粘合代码来与 C 通信并与 Rust 中的其他 GObject 实例进行交互。

  • Rocket 框架最近切换到了过程宏,并展示了一些过程宏的 nightly-only 功能,例如自定义诊断、自定义跨度创建等。预计这些功能将在 2019 年稳定下来!

这仅仅是过程宏的功能以及当今整个生态系统中一些示例用法的冰山一角。距离过程宏在稳定版上首次发布只有 6 周时间,因此我们肯定也只是触及了皮毛!我非常兴奋地看到,通过赋予语言各种轻量级的添加和扩展,我们可以用过程宏将 Rust 带向何方!