编写强大的 Rust 宏——一个“Hello, World”程序性宏本章内容 设置程序性宏 通过解析令牌流获取结构体的名
本章内容
- 设置程序性宏
- 通过解析令牌流获取结构体的名称
- 生成硬编码的输出
- 在生成的代码中使用变量
- 使用
cargo expand
检查生成的代码 - 在没有
syn
和quote
帮助的情况下编写宏 - 理解 Rust 的内部宏是如何特别的
现在我们进入本书的核心内容:程序性宏。正如我们之前解释的,程序性宏和声明式宏都是元编程的形式,允许你操作和扩展代码,但它们的实现方式有所不同。声明式宏提供了一种领域特定语言(DSL),通过匹配器和转录器的组合来生成代码。而程序性宏则处理更底层的信息。它们接收一个包含你要操作的代码所有细节的令牌流。
在我看来,声明式宏和程序性宏的区别——以及何时使用它们——有点类似于 SQL 和通用编程语言在查询数据库时的区别。SQL 强大、表达力丰富且用户友好,应该是查询的首选。然而,在某些复杂程度和某些任务的层面上,它会变得复杂且难以阅读和扩展。到那个时候,使用通用编程语言替代 SQL 可能是值得的。查询可能需要更多的努力和设置,但你会有更多的选项和能力。因此,这里的建议是从声明式宏开始。它们简单且强大,仅需要最小的设置,并且具有更好的 IDE 支持。如果你需要做一些声明式宏做不到的事情(例如,操作现有的结构体),你应该转向程序性宏。
3.1 程序性宏项目的基本设置
我们从一个简单的例子开始:一个宏,它向一个结构体或枚举中添加一个“Hello, World”打印方法(见图 3.1 了解项目设置)。向结构体添加新功能是派生宏的一个良好用例。另一方面,如果我们想要修改现有代码,就需要另寻他法,因为这些宏无法做到这一点。派生宏通过在代码中添加 #[derive]
注解来激活,将宏的名称放在方括号中。毫无疑问,你在想要向代码中添加 Debug
(#[derive(Debug)]
) 或 Clone
(#[derive(Clone)]
) 功能时,曾经遇到过这些注解。
创建一个程序性宏需要一些工作,因此请耐心等待我们完成设置。首先,我们需要一个名为 hello-world
的目录,在这个目录下创建另一个名为 hello-world-macro
的子目录。在 hello-world-macro
中,我们将放置宏项目,而在根目录中,我们将添加一个应用程序(见图 3.2)。
你可能已经习惯使用 cargo init
创建新的 Rust 项目。然而,对于我们的宏,我们需要的是一个库,而不是应用程序。开发者想要使用我们的宏时,会将我们的库作为依赖导入。因此,在项目的 hello-world-macro
子目录中运行 cargo init --lib
。
我们需要修改生成的 Cargo.toml
文件。最重要的更改是添加 lib
部分,并将 proc-macro
属性设置为 true
。这告诉 Rust 这个库将暴露一个或多个程序性宏,并会为我们提供一些工具。此外,我们还希望添加 quote
和 syn
作为依赖项。虽然这两个依赖项不是严格必需的,但它们会使我们的工作更轻松。
清单 3.1 hello-world-macro
的 lib
部分和依赖项
[package]
name = "hello-world-macro"
version = "0.1.0"
edition = "2021"
[dependencies]
quote = "1.0.33"
syn = "2.0.39"
[lib]
proc-macro = true
注意:我们不需要 Cargo 工作区来使其工作,虽然我们将在后面的章节中看到这种设置的示例。
在自动生成的 lib.rs
文件中,我们添加一个基本的实现,该实现尚未真正执行任何操作。我们将在下一节讨论代码。现在,让我们继续我们的设置。
清单 3.2 hello-world-macro
库的初始 lib.rs
文件
use quote::quote;
use proc_macro::TokenStream;
#[proc_macro_derive(Hello)]
pub fn hello(_item: TokenStream) -> TokenStream {
let add_hello_world = quote! {};
add_hello_world.into()
}
库现在已经准备好。在外部的 hello-world
目录中,我们将使用 cargo init
设置一个 Rust 示例应用程序。这一次,我们将依赖于刚创建的宏库,使用相对路径添加依赖。依赖项的名称很重要,应与您要导入的包的名称匹配(路径和目录名称可以不同)。尝试将依赖项名称更改为 foo-bar
并运行 cargo run
,你将得到类似 error: no matching package named foo-bar found
的错误。由于我们的应用程序位于宏目录的上一级项目根目录中,因此路径是 ./hello-world-macro
。某些后续章节将有一个嵌套目录用于应用程序和库。在这种情况下,我们需要向上移动一级(路径 = ../填入你的宏名称
)。
清单 3.3 外部 Rust 应用程序的 Cargo.toml
文件
[package]
name = "hello-world"
version = "0.1.0"
edition = "2021"
[dependencies]
hello-world-macro = { path = "./hello-world-macro" }
最后,修改默认的 main.rs
文件。现在你应该能够使用 cargo run
编译并运行你的应用程序。它目前不会打印任何内容(除了一个警告,指出 Example
从未构造)。
清单 3.4 外部应用程序的初始 main.rs
文件
#[macro_use]
extern crate hello_world_macro;
#[derive(Hello)]
struct Example;
fn main() {}
注意:如果你不喜欢这种设置或所有手动工作,你可以使用 cargo generate
(github.com/cargo-gener…),它有一个生成宏设置的模板(github.com/waynr/proc-…)。为了学习目的,建议至少手动设置一次。但是一旦掌握了它,你可以使用代码库中的 util/create_setup.sh
脚本(github.com/VanOvermeir…),它可以自动设置本书中使用的各种项目样式。
3.2 分析过程宏的设置
现在让我们分析一下设置。包含 lib.rs
的嵌套目录是我们真正需要的唯一代码部分:
use quote::quote;
use proc_macro::TokenStream;
#[proc_macro_derive(Hello)] #1
pub fn hello(_item: TokenStream) -> TokenStream {
let add_hello_world = quote! {}; #2
add_hello_world.into() #3
}
#1 声明这个函数是一个名为“Hello”的派生宏。
#2 使用 quote
宏生成一个新的(当前为空的)TokenStream
。
#3 使用 Into
特性将这个 TokenStream
转换为标准库中的 TokenStream
,两者名称相同。
在依赖项的导入部分下方,我们有 #[proc_macro_derive(Hello)]
。这是一个属性,作为元数据通知 Rust 需要执行某些操作。在我们的例子中,它告诉 Rust 这个函数是一个派生宏的入口点(见图 3.3)。这个注解需要在括号中指定一个名称——在我们的例子中是“Hello”——这个名称将在调用宏时使用:#[derive(Hello)]
。另一方面,函数的名称不会被外部使用。所以,你可以随意选择一个名称。
高层概述
这里是 Rust 在编译应用程序时所做的高层概述(我对细节不是专家)。涉及两个步骤:词法分析和解析。词法分析(也称为标记化)是第一步,用来将你的代码从原始文本流转换成 token 流。例如,将表达式 1 + 11
传递给宏,结果流会是这样的(暂时忽略跨度和后缀):
TokenStream [Literal { kind: Integer, symbol: "1" }, Punct { ch: '+', spacing: Alone }, Literal { kind: Integer, symbol: "11" }]
我们的原始文本已经被转换成了三个 tokens(两个数字和一个 + 符号)。
解析将这些信息转换为抽象语法树(AST;抽象语法树的介绍),这是你程序中所有相关数据的树状表示。这棵 AST 使得 Rust 编译器(以及其他语言的编译器)更容易工作(即,创建计算机可以理解的可执行文件)。在之前的例子中,AST 可能将加号解释为根节点,将两个字面量(数字)作为其分支。同时,跨度用于链接回原始文本,这对于错误消息等功能非常有用。一旦 AST 构建完成,宏就可以被编译器处理。
虽然这些是有趣的细节,但你不需要了解这些过程的细节就能编写过程宏。最重要的是,Rust 给我们提供了经过解析的 token。作为回报,我们将以相同的格式返回信息。Rust 知道如何使用这些 tokens。毕竟,所有的“普通”代码也会被转换成 tokens,这意味着你可以轻松地将 tokens 转换成额外的应用代码。
你可以在 Rust 编译器开发指南、《The Little Book of Rust Macros》 和 Rust 参考 中找到更多信息。
这将我们引入到函数体部分,我们在其中调用了同名的 quote
宏。我们没有传递任何参数给 quote
,这意味着我们生成了一个空的 TokenStream
。因为 quote
实际上使用的是 proc_macro2
的 TokenStream
(这是对内置类型的封装),我们仍然需要使用 Into
特性将其转换为“正常”的 proc_macro
TokenStream
:add_hello_world.into()
。所以最终结果是一个生成什么都不做的宏。市场推广者会为这个版本的库宣传零运行时开销。但宏并不一定需要生成代码,所以这在 Rust 中是可以接受的。
现在看看 main.rs
:
#[macro_use]
extern crate hello_world_macro; #1
#[derive(Hello)] #2
struct Example; #3
fn main() {}
#1 使 hello_world_macro
目录中的宏可以使用。
#2 为 Example
结构体添加了“Hello”派生宏。
#3 将宏应用到我们空的 Example
结构体上。
前两行告诉 Rust 我们将使用这个依赖中的宏,类似于我们如何导入声明式宏。(这次我们需要使用下划线来指定依赖。在我们的 Cargo.toml
中,我们使用了连字符。)这是导入过程宏的旧方式。现在,你应该用 use
一行代码来导入它们(例如,use hello_world_macro::Hello;
)。在本章中,我们主要使用旧的方式,它仍然在很多 crate 中使用,因此仍值得了解。但在后续章节中,我们将切换到更新的(Rust 2018)风格。
接下来,#[derive(Hello)]
属性告诉 Rust 对 Example
结构体运行我们名为“Hello”的派生宏,将该结构体作为类型为 TokenStream
的输入。生成的(目前为空的)代码将被添加到我们的 main.rs
文件中。
之前我们提到外部应用程序并非绝对必要。它是一种便利——一个“消费者”,帮助我们验证代码是否编译。这很有用,因为 Rust 的编译器不会捕获我们库生成的代码中的错误。为了使这一点更具体,假设我们在添加派生宏函数的参数时打错了字,将 TokenStream
写成了 TokenStrea
。如果我们运行 cargo check
,Rust 会指出 TokenStrea
不存在。错误被阻止了!但是如果我们在 quote
宏调用中写入无效的 Rust 代码:
#[proc_macro_derive(Hello)]
pub fn hello(item: TokenStream) -> TokenStream {
let add_hello_world = quote! {
fn this should not work () {}
};
add_hello_world.into()
}
在我们的宏库中运行 cargo check
仍然会成功!但当我们在外部应用程序中检查时,我们会得到一个错误:
error: expected one of `(` or `<`, found `should`
--> src/main.rs:4:10
|
4 | #[derive(Hello)]
| ^^^^^ expected one of `(` or `<`
|
= note: this error originates in the derive macro `Hello`
显然,Rust 认为 fn this
意味着我们想创建一个名为 this
的函数。函数及其名称后面跟着的是括号(包含参数)或泛型(用 < >
包围)。而 should
这个词出现了。这里的教训是,cargo check
在你的过程宏库内部只会检查库代码中的错误,而不会检查生成的代码中的错误。
这很有意义,因为在库内部我们并不使用生成的代码。Rust 只关心我们声明 TokenStream
为函数的返回类型。只要我们返回任何 token 流,即使是无效的,它也会满足。即使它关心,编译器也无法知道生成的代码将在什么上下文中使用,这使得检查变得困难。因此,我们使用一个简单的应用程序和基本的使用示例来生成代码,强制编译器停止懒惰并进行一些工作。在后续章节中,我们将转向测试,以验证宏的正确工作。
3.3 生成输出
那实际生成一些代码怎么样?首先,将以下内容添加到 lib.rs
中:
// 之前的导入
#[proc_macro_derive(Hello)]
pub fn hello(_item: TokenStream) -> TokenStream {
let add_hello_world = quote! {
impl Example { #1
fn hello_world(&self) {
println!("Hello, World")
}
}
};
add_hello_world.into()
}
#1 返回一个硬编码的实现块
我们仍然没有处理传入的 tokens。但是我们使用 quote
来生成新代码。目前,这段新代码是为我们在 main.rs
中添加的 Example
结构体生成的硬编码实现块。这意味着我们现在可以调用这个方法,运行以下代码。请记住,cargo run
的目标应该是应用程序,否则会出现 “bin target must be available for cargo run” 错误,因为库无法被执行。
// 宏导入
#[derive(Hello)]
struct Example;
fn main() {
let e = Example {};
e.hello_world(); #1
}
#1 执行时打印 "Hello, World"
唯一的问题是,我们的代码仅适用于名为 Example
的结构体。重命名结构体,Rust 会抱怨在这个作用域中找不到 Example
类型,因为它无法将实现块匹配到现有类型。解决方案是从传入的 tokens 中获取结构体的名称,并在我们生成的 impl
中使用它。在宏的术语中,无论是声明式宏还是过程宏,这个名称都是一个标识符。由于我们的 tokens 表示我们装饰的代码(一个结构体及其内容),我们需要最上层的标识符。一旦我们得到名称,我们可以将其与 quote
结合,生成更有趣的输出。
use quote::quote;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Hello)]
pub fn hello(item: TokenStream) -> TokenStream {
let ast = parse_macro_input!(item as DeriveInput); #1
let name = ast.ident; #2
let add_hello_world = quote! {
impl #name { #3
fn hello_world(&self) { #4
println!("Hello, World")
}
}
};
add_hello_world.into()
}
#1 将传入的 TokenStream
解析为更友好的 AST。
#2 获取顶层标识符。在我们的例子中,这将是注解的 Example
结构体的名称。
#3 使用 quote
提供的特殊语法将其添加到我们的输出中。
#4 其他部分可以保持硬编码。
我们首先通过使用 syn
crate 提供的 parse_macro_input
将输入 tokens 解析为 AST。syn
提供了许多工具来帮助你解析 Rust tokens,这个声明式宏是它工具箱中的一个工具。as DeriveInput
是一种定制的语法糖。在幕后,as
后面的参数用于确定将生成的类型。我们选择了 DeriveInput
。顾名思义,这是一种你在编写派生宏时可能会得到的输入。因此,它基本上包含一个枚举或结构体。
一旦我们得到了 DeriveInput
,我们可以通过获取顶层的 ident
(标识符),并将其保存在一个名为 name
的变量中,来获取结构体的名称。如果我们想在输出中使用 name
,我们需要一种特殊的语法来告诉 quote
这不是一个字面值,而是应该被替换为具有匹配名称的变量的内容。你可以通过在变量名之前加上一个井号(即 #name
)来实现这一点,在我们的例子中,这将被替换为标识符 Example
。正如你所见,使用 quote
生成输出是非常简单的(见图 3.4)。
再次运行代码,它应该会产生与之前相同的输出方法,即使你更改了结构体的名称。
3.4 实验我们的代码
现在让我们进行一些实验。我们的宏是否可以用于枚举?可以!将以下代码添加到 main.rs
。
// 之前的代码
#[derive(Hello)]
enum Pet {
Cat,
}
fn main() {
// 之前的代码
let p = Pet::Cat;
p.hello_world();
}
那么函数呢?不幸的是,这行不通,因为 impl
不能用于函数,而且 derive
也不允许用于函数。错误信息非常明确:derive
只能应用于结构体、枚举和联合体。
注意:尽管联合体是一个有效的派生目标,但在本书中没有涉及,主要是因为它们不如结构体和枚举普遍,几乎仅用于与 C 通过外部函数接口(FFI)的兼容性。
我们生成的代码会覆盖现有的实现块吗?幸运的是,不会。正如我们在前面的章节提到的,Rust 支持多个 impl
块。因此,下面的代码是有效的。
// 之前的代码
impl Example {
fn another_function(&self) {
println!("Something else");
}
}
fn main() {
let e = Example {};
e.hello_world();
e.another_function();
// 其他代码
}
但其他事情可能仍会出错。例如,如果你定义了一个额外的 hello_world
函数,你将会得到错误 duplicate definitions for hello_world
。你最好希望没有宏的用户会想要添加同名的函数!名称重叠是一个实际的风险——我们将在本书后续章节中讨论这个问题。
3.5 cargo expand
在我们前面的章节中,我们介绍了几种调试宏并查看它们展开内容的方法。但我们跳过了一个非常有用的工具:cargo expand
(github.com/dtolnay/car…),这个工具曾经不稳定,但现在可以与稳定版 Rust 一起使用。你可以通过 cargo install cargo-expand
安装它,然后在应用程序目录或 src
文件夹的根目录下运行 cargo expand
。这将打印出宏展开后的应用程序代码。在清单 3.10 中,你可以看到所有宏,包括 println!
,在我们运行 cargo expand
后都被展开了。(不过,format_args!
没有被展开。这是 Rust 的一个问题,github.com/dtolnay/car…,正在进行修复。)
#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*; #1
// imports
struct Example; #2
impl Example { #3
fn hello_world(&self) {
{
::std::io::_print( #4
format_args!("Hello, World\n")
);
}
}
}
// Pet 枚举及其展开后的代码
// main 函数
#1:正如我们之前看到的,前置导入包含了全局可用的函数、特性等。Rust 添加了注解和导入来使这些内容可用。例如,Clone
在没有导入的情况下也能工作。 #2:derive
注解已经消失。 #3:展开的 #[derive(Hello)]
代码。 #4:展开后的 println!
。
我们的 derive
注解已经消失,因为在宏展开完成后,它不再需要了。取而代之的是,宏生成的代码已被添加。
cargo expand
是一个用于视觉检查我们代码的有用工具,即使输出无效,它仍然可以运行,这使它在调试编译问题时很有用。假设我在 quote
宏调用中错拼了 self
。我会得到一个编译错误和以下输出,显示函数参数有问题。如果错误仍不清楚,我可以将输出重定向到文件(cargo expand > somefile.rs
),然后让我的 IDE 帮助我跟踪问题。或者,我可以临时替换 main.rs
,通过 cargo check
得到指向不正确行的提示:
impl ExampleStruct {
fn hello_world(sel: ()) {
{
::std::io::_print(format_args!("Hello, World\n"));
}
}
}
你也可以在 hello-world-macro
库中使用 expand
,但那只会显示你使用的宏——而不是你为他人创建的宏——如何展开,类似于 check
只会指出你代码中的错误,而不是生成的代码中的错误。因此,大多数情况下,你会希望在使用你库及其宏的应用程序中使用 expand
命令。
3.6 同样的宏——不使用 syn
和 quote
quote
和 syn
库非常有用,但在编写宏时并非严格必要。清单 3.11 是没有这些库的相同应用程序。为了获取名称,我们遍历输入流并取出第二个元素,即 nth(1)
。该项是一个 TokenTree
,包含结构体或枚举的名称。第一个元素,包含在 nth(0)
中,有类型(即结构体或枚举),在这种情况下不相关——所以我们跳过它。通过 ident_name
函数,我们获取树并返回名称标识符,如果找不到则抛出错误。
定义:TokenTree
位于 TokenStream
和简单的标记之间。基本上,TokenStream
是 TokenTree
的序列,TokenTree
本身(递归地)由更多的树和/或标记组成。这就是为什么我们可以遍历流,选择一个元素,并保证 Rust 认为类型是 TokenTree
。在声明式宏中,我们遇到的 tt
片段说明符也是 TokenTree
。
为了生成输出,我们使用 format
宏将名称变量注入到字符串中。对于从字符串到 TokenStream
的转换,我们可以使用 parse
。我们期望这能工作,所以我们直接在解析返回的 Result
上使用 unwrap
。这种方法看起来非常可行。我们去除了两个依赖库,代价是多写了一点代码。但即使在这样一个基本的示例中,我们也需要付出努力来获取标识符并输出新代码。对于更复杂的示例,复杂性和额外的工作负担只会增加。
use proc_macro::{TokenStream, TokenTree};
#[proc_macro_derive(Hello)]
pub fn hello_alt(item: TokenStream) -> TokenStream {
fn ident_name(item: TokenTree) -> String {
match item {
TokenTree::Ident(i) => i.to_string(), #1
_ => panic!("no ident")
}
}
let name = ident_name(item.into_iter().nth(1).unwrap()); #2
format!("impl {} {{ fn hello_world(&self) \
{{ println!("Hello, World\") }} }} ", name
).parse()
.unwrap() #3
}
#1 如果我们有一个包含标识符的 `TokenTree`,我们将其作为字符串返回。
#2 作为先前函数的输入,我们提供 `TokenStream` 的第二个元素,即名称。
#3 我们使用 `format!` 将获取的名称添加到 `impl` 块的字符串表示中。然后我们解析将其转换为 `TokenStream`。这只有在我们犯错误时才会失败,此时 `unwrap` 触发恐慌是可以接受的。
尽管如此,编译速度是你可能想要放弃使用 `syn` 的一个理由。虽然它是一个非常强大的库,但它也很大并且编译速度较慢。因此,如果我们的示例是一个真实的宏,并且我们只需要结构体/枚举的名称,我们的简单示例会更快编译。许多库试图提供 `syn` 的轻量级替代品,例如 venial([https://github.com/PoignardAzur/venial](https://github.com/PoignardAzur/venial))。
清单 3.12 显示了使用该库的宏。不要忘记在依赖项中添加 `venial = "0.5.0"`。代码看起来与之前非常相似。我们使用 `parse_declaration`,它返回一个 `Declaration` 枚举。通过模式匹配,我们从该枚举中检索名称。
```rust
use quote::quote;
use proc_macro::TokenStream;
use venial::{parse_declaration, Declaration, Struct, Enum};
#[proc_macro_derive(Hello)]
pub fn hello(item: TokenStream) -> TokenStream {
let declaration = parse_declaration(item.into()).unwrap();
let name = match declaration { #1
Declaration::Struct(Struct { name, .. }) => name,
Declaration::Enum(Enum { name, .. }) => name,
_ => panic!("only implemented for struct and enum")
};
let add_hello_world = quote! {
impl #name {
fn hello_world(&self) {
println!("Hello, World")
}
}
};
add_hello_world.into()
}
#1 如果我们接收到一个枚举或结构体,则检索名称;在其他情况下抛出恐慌。
即使在这个简单的示例中,使用 cargo build --timings
测量的构建时间从我的机器上的 3.1 秒降至 1.8 秒。然而,在本书中,我们将使用 syn
,因为它非常知名、广泛使用且功能强大。此外,一旦你熟悉了它如何处理 TokenStream
解析,切换到轻量级的替代品应该不会太难:许多解析概念始终是相同的。
3.7 现实世界中的应用
我们将把进一步的库探索留到后续章节,目前仅限于一些观察。首先,Rocket 的开发者(rocket.rs/)实际上很乐意教你宏可以通过两种方式导入(我们在本章中描述的方式):
//! 并通过 `#[macro_use]` 在 crate 根目录中导入所有宏、属性和派生:
//!
//! ```rust
//! #[macro_use] extern crate rocket;
//! # #[get("/")] fn hello() { }
//! # fn main() { rocket::build().mount("/", routes![hello]); }
//! ```
//!
//! 或者,选择性地从顶层范围导入:
//!
//! ```rust
//! # extern crate rocket;
//!
//! use rocket::{get, routes};
//! # #[get("/")] fn hello() { }
//! # fn main() { rocket::build().mount("/", routes![hello]); }
//! ```
其次,你可能还会好奇标准库是如何解析和输出宏的,因为它不能使用像 syn
和 quote
这样的外部库。相反,标准库使用类似概念和名称的内置功能。例如,Rust 的抽象语法树 rustc_ast
用于解析输入。输出代码是通过 rustc_expand
完成的。rustc_span
包含像 Ident
和 Span
这样的工具。它在使用 syn
和 quote
时既熟悉又陌生,但并不打算供外部使用。
最后,由于过程宏必须放在库中,在 crate 根目录(否则你会得到标记为 #[proc_macro_derive]
的函数必须当前存在于 crate 根目录中的错误),lib.rs
是探索其他人过程宏代码的一个很好的起点。你将看到他们有哪些宏,并在需要时进行深入研究。
转载自:https://juejin.cn/post/7406139000266162239