或许我在 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
中标记
[]
= true
然后在 src/lib.rs
中我们可以编写我们的宏
extern crate proc_macro;
use TokenStream;
然后我们可以在 tests/smoke.rs
中编写一些单元测试
……就这样!当我们执行 cargo test
时,Cargo 会编译我们的过程宏。之后,它会编译我们的单元测试,单元测试在编译时加载宏,执行 hello
函数并编译生成的语法。
我们可以立即看到过程宏的一些重要特性:
- 输入/输出是这个我们稍后会详细讨论的酷炫的
TokenStream
类型 - 我们可以在编译时执行任意代码,这意味着我们可以做几乎任何事情!
- 过程宏与模块系统集成在一起,这意味着它们可以像其他任何名字一样被导入。
在了解如何实现一个过程宏之前,让我们先深入探讨其中一些要点。
宏与模块系统
首次在 Rust 1.30 中稳定(注意到了和 1.15 的趋势了吗?),宏现在已集成到 Rust 的模块系统中。这主要意味着在导入宏时不再需要笨拙的 #[macro_use]
属性了!不再是这样:
extern crate log;
你可以这样做:
use info;
与模块系统的集成解决了历史上关于宏最令人困惑的部分之一。它们现在就像 Rust 中的其他任何条目一样被导入和命名空间化!
好处不仅限于叹号风格的 macro_rules
宏,因为你现在可以将如下所示的代码转换成
extern crate serde_derive;
这样:
use Deserialize;
你甚至不需要在 Cargo.toml
中明确依赖 serde_derive
!你只需要:
[]
= { = '1.0.82', = ['derive'] }
TokenStream
中有什么?
这个神秘的 TokenStream
类型来自 编译器提供的 proc_macro
crate。刚加入时,对 TokenStream
唯一能做的就是使用 to_string()
或 parse()
将其转换成字符串或从字符串解析。截至 Rust 2018,你可以直接操作 TokenStream
中的 token。
一个 TokenStream
实际上“只是”一个 TokenTree
的迭代器。Rust 中的所有语法都属于以下四类之一,即 TokenTree
的四种变体:
Ident
是任何标识符,例如foo
或bar
。这还包括关键字,如self
和super
。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
syn
crate 不仅提供了解析内置语法的能力,你还可以轻松地为你自己的语法编写一个递归下降解析器。syn::parse
模块 提供了关于此功能的更多信息。
TokenStream
生成 过程宏不仅接收 TokenStream
作为输入,我们还需要生成 TokenStream
作为输出。这个输出通常要求是有效的 Rust 语法,但就像输入一样,它只是我们需要以某种方式构建的 token 列表。
从技术上讲,创建 TokenStream
的唯一方法是通过其 FromIterator
实现,这意味着我们必须逐个创建每个 token 并将其收集到 TokenStream
中。这相当繁琐,所以让我们看看 syn
的姊妹 crate:quote
。
quote
crate 是一个用于 Rust 的准引用(quasi-quoting)实现,它主要提供了一个方便的宏供我们使用
use quote;
quote!
宏 允许你编写大部分是 Rust 语法的代码,并使用 #foo
从环境中快速插入变量。这消除了逐个创建 TokenStream
的大部分繁琐工作,并允许快速地将各种语法片段拼凑成一个返回值。
Span
Token 与 或许 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
信息保留到输出中,那么即使你生成了全新的语法,编译器的错误消息仍然是准确的!
例如,一个像这样的简单宏
当这样调用时:
! make_pub
是无效的,因为我们从一个应该返回 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 版中的各种能力有了相当好的了解。作为一项备受期待的功能,生态系统已经在使用这些新能力了!如果你感兴趣,以下是一些值得关注的项目:
-
syn
、quote
和proc-macro2
是编写过程宏的首选库。它们使得定义自定义解析器、解析现有语法、创建新语法、与旧版本 Rust 配合使用等等变得很容易! -
Serde 及其用于
Serialize
和Deserialize
的 derive 宏可能是生态系统中使用最广泛的宏。它们支持 令人印象深刻的配置选项,是展示小巧的注解如何强大威力的绝佳范例。 -
wasm-bindgen
项目 使用属性宏轻松地在 Rust 中定义接口并从 JS 导入接口。#[wasm_bindgen]
轻量级注解使得理解输入和输出变得容易,同时也消除了大量的转换样板代码。 -
gobject_gen!
宏 是 GNOME 项目的一个实验性 IDL,用于在 Rust 中安全地定义 GObject 对象,避免了手动编写所有与 C 交互以及在 Rust 中与其他 GObject 实例对接所需的胶水代码。 -
Rocket 框架 最近已经切换到使用过程宏,并展示了一些仅在 nightly 版本中提供的过程宏特性,例如自定义诊断、自定义 span 创建等等。预计这些功能将在 2019 年稳定!
这只是过程宏强大威力以及当前生态系统中一些用例的浅尝。从过程宏在稳定版上首次发布至今只有 6 周时间,所以我们肯定也只是触及了皮毛!我非常期待看到通过过程宏赋能各种轻量级的语言添加和扩展,我们将能把 Rust 带到何处!