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 函数上提供轻量级注解,这些注解会在编译时对代码执行语法转换。

这三种形式的宏都可以在 crate 的 manifest 中 指定 proc-macro = true 来定义。使用时,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 中的其他任何条目一样被导入和命名空间化!

好处不仅限于叹号风格的 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 中的 token。

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

  • Ident 是任何标识符,例如 foobar。这还包括关键字,如 selfsuper
  • Literal 包括像 1"foo"'b' 这样的内容。所有字面量都是一个 token,代表程序中的常量值。
  • Punct 代表某种非分隔符的标点符号。例如,在访问字段 foo.bar 时,. 是一个 Punct token。多字符标点符号,例如 =>,表示为两个 Punct token,一个代表 =,一个代表 >,而 Spacing 枚举表明 => 相邻。
  • Group 是“树”这个术语最相关的地方,因为 Group 代表一个有分隔符的子 token 流。例如,(a, b) 是一个使用圆括号作为分隔符的 Group,其内部的 token 流是 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 语法,但就像输入一样,它只是我们需要以某种方式构建的 token 列表。

从技术上讲,创建 TokenStream 的唯一方法是通过其 FromIterator 实现,这意味着我们必须逐个创建每个 token 并将其收集到 TokenStream 中。这相当繁琐,所以让我们看看 syn 的姊妹 crate:quote

quote crate 是一个用于 Rust 的准引用(quasi-quoting)实现,它主要提供了一个方便的宏供我们使用

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 的大部分繁琐工作,并允许快速地将各种语法片段拼凑成一个返回值。

Token 与 Span

或许 Rust 2018 中过程宏最棒的功能是定制和使用每个 token 的 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 token foo 来自文件 bar.rs,第 4 行,第 5 列,长度为 3 字节”之类的信息。这些信息主要由编译器的诊断系统用于生成警告和错误消息。

在 Rust 2018 中,每个 TokenTree 都有一个与之关联的 Span。这意味着如果你将所有输入 token 的 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 的 derive 宏可能是生态系统中使用最广泛的宏。它们支持 令人印象深刻的配置选项,是展示小巧的注解如何强大威力的绝佳范例。

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

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

  • Rocket 框架 最近已经切换到使用过程宏,并展示了一些仅在 nightly 版本中提供的过程宏特性,例如自定义诊断、自定义 span 创建等等。预计这些功能将在 2019 年稳定!

这只是过程宏强大威力以及当前生态系统中一些用例的浅尝。从过程宏在稳定版上首次发布至今只有 6 周时间,所以我们肯定也只是触及了皮毛!我非常期待看到通过过程宏赋能各种轻量级的语言添加和扩展,我们将能把 Rust 带到何处!