Rust 2018 中的过程宏

2018 年 12 月 21 日 · Alex Crichton

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

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

什么是过程宏?

过程宏在 RFC 1566 中首次定义,距今已有两年多,通俗地说,过程宏是一个函数,它在编译时接受一段语法,并生成一段新的语法。Rust 2018 中的过程宏有三种类型

  • #[derive] 模式宏 实际上从 Rust 1.15 开始就已稳定,并将 #[derive(Debug)] 的所有优点和易用性带给了用户定义的 trait,例如 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。当它首次添加时,你所能做的就是使用 to_string()parse()TokenStream 转换为字符串或从字符串转换。从 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

我们可以看到,尽管我们生成了全新的语法,但编译器可以保留跨度信息,以继续提供关于我们编写的代码的针对性诊断。

现实中的过程宏

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

  • synquoteproc-macro2 是编写过程宏的首选库。它们使定义自定义解析器、解析现有语法、创建新语法、使用旧版本的 Rust 以及更多功能变得容易!

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

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

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

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

这仅仅是过程宏强大功能和当前生态系统中一些示例用法的“一瞥”。距离过程宏在稳定版中首次发布仅 6 周,因此我们肯定只是触及了皮毛!我非常期待看到我们如何通过赋予语言各种轻量级添加和扩展来利用过程宏来提升 Rust!