本章内容包括:
- 理解派生宏和属性宏之间的区别
- 在抽象语法树中查找字段信息
- 使用匹配提取字段
- 使用自定义结构体提取字段
- 使用自定义结构体和解析实现提取字段
- 在
quote中添加多个输出 - 使用日志语句调试宏
- 理解 no-panic crate
Rust 喜欢隐藏信息。函数、结构体或枚举默认是私有的,结构体的字段也是如此。这是非常合理的,尽管有时当你有一个字段很多的结构体时,稍微有点烦人,因为这些字段最好是公开的。数据传输对象(DTOs)就是一个经典的例子,这在许多编程语言中是一个常见的模式,用于在系统之间或单一系统的不同部分之间传输信息。由于它们只是信息的简单封装,因此不应包含任何业务逻辑。而“信息隐藏”,保持结构体/类字段私有的主要原因,在这种情况下并不适用,因为它的唯一价值就是暴露字段中包含的信息。
为了进行实验,我们将展示如何通过几行代码改变默认行为。我们将创建一个宏,一旦将其添加到结构体中,就会使结构体及其所有字段变为公共。这与我们在上一章中面对的挑战非常不同。那时,我们是在为现有代码添加内容,而现在我们需要修改已经存在的内容。因此,派生宏不适用了,属性宏才是正确的选择。
属性宏得名于它们定义了一个新的属性。当你使用该自定义属性注解结构体或枚举时,宏会被触发。否则,编写属性宏的库和代码与创建派生宏非常相似。但也有区别:属性宏还会接收一个包含附加属性(如果有的话)的 TokenStream。更重要的是,在本章中,属性宏的输出令牌将替代输入。这听起来像是能够解决问题的东西。
4.1 属性宏项目的设置
让我们逐步完成本章的设置过程,这与上一章非常相似:
- 创建一个新目录(
make-public),在其中再创建一个目录(make-public-macro)。 - 在嵌套的
make-public-macro目录内,运行cargo init --lib来初始化我们的宏。 - 添加
syn和quote依赖项(通过cargo add syn quote),并将lib设置为proc-macro = true。 - 在外部的
make-public目录中运行cargo init并将我们的库作为依赖项添加。
Listing 4.1 make-public-macro 目录中的 Cargo.toml 文件部分内容:
[dependencies]
quote = "1.0.33"
syn = "2.0.39"
[lib]
proc-macro = true
Listing 4.2 make-public 目录中的 Cargo.toml 文件部分内容:
[dependencies]
make-public-macro = { path = "./make-public-macro" }
4.2 属性宏与派生宏的比较
完成设置后,我们从简单的步骤开始,在嵌套目录的 lib.rs 文件中添加一些代码。我们定义了一个公共函数,生成一个 TokenStream。它接受一个 item 参数,并将其解析成抽象语法树(AST)。这样我们就能够像之前一样从原始代码中提取所需的内容。与之前一样,输出是通过 quote! 宏创建的。
Listing 4.3 初始设置:
extern crate core;
use quote::quote;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_attribute] #1
pub fn public(_attr: TokenStream, item: TokenStream) #2
-> TokenStream {
let _ast = parse_macro_input!(item as DeriveInput); #3
let public_version = quote! {}; #4
public_version.into()
}
#1 注释告诉 Rust 这是一个属性宏。 #2 属性宏的名称由函数名决定(这里是“public”)。它接受两个 TokenStream 作为参数。 #3 由于我们目前还没有使用 AST,这里前面加了下划线。 #4 如之前一样,我们的初始实现接受输入但不生成输出。
虽然代码看起来很熟悉,但与上一章相比,还是有一些不同之处。首先,我们使用了不同的属性:#[proc_macro_attribute],而不是 #[proc_macro_derive]。而且,与之前不同的是,我们不在括号内指定宏的名称(#[proc_macro_derive(Hello)])。相反,函数的名称决定了属性的名称。所以,在我们的例子中,这个宏会创建一个自定义的属性 #[public]。你还可以看到,我们接收了一个额外的 TokenStream(我们暂时会忽略它),它包含了关于我们属性的信息(见图 4.1)。
我们代码的起点与上一章相同:一个不生成任何输出的宏。但这一次,通过不返回任何内容,我们确实对现有代码产生了影响。试着将以下代码添加到你的应用程序 main.rs 中并运行 cargo expand。
Listing 4.4 使用这个 main.rs 运行 cargo expand
use make_public_macro::public;
#[public]
struct Example {}
fn main() {}
现在,Example 结构体已经消失了!有点神秘吧。
Listing 4.5 我们的结构体消失的表现
#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;
use make_public_macro::public;
fn main() {}
正如我们之前所说,Rust 期望一个属性宏的输出能够替换输入,这使得事情比之前稍微复杂了一些。
4.3 公共可见性的第一步
回到我们手头的任务:我们现在可以向结构体添加属性,使其更具具体性。
Listing 4.6 我们的示例结构体
#[public]
struct Example {
first: String,
pub second: u32,
}
我们希望宏能够输出以下更改后的结构体:结构体本身应该是公共的,first 应该变为公共的,而 second 应该保持公共。
我们可以像之前一样处理这个问题。因此,我们从最简单的实现开始,这个实现仅适用于这个特定的结构体。
Listing 4.7 硬编码的实现
extern crate core;
use quote::quote;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_attribute]
pub fn public(_attr: TokenStream, item: TokenStream) -> TokenStream {
let _ast = parse_macro_input!(item as DeriveInput); #1
let public_version = quote! { #2
pub struct Example {
pub first: String,
pub second: u32,
}
};
public_version.into()
}
#1 仍然没有使用 AST!
#2 我们将 Example 结构体以之前定义的方式硬编码返回。
显然,这个方法是有效的。接下来,作为下一步,我们之前学到如何获取输入结构体的名称。通过在这里应用这些知识,我们将能够接受任何具有与示例相同字段的结构体。
Listing 4.8 使用从输入中获取的结构体名称的硬编码属性
#[proc_macro_attribute]
pub fn public(_attr: TokenStream, item: TokenStream) -> TokenStream {
let ast = parse_macro_input!(item as DeriveInput);
let name = ast.ident;
let public_version = quote! {
pub struct #name {
pub first: String,
pub second: u32,
}
};
public_version.into()
}
通过应用我们已经知道的知识,我们获取结构体的名称并将其传递给 quote。我们终于开始使用我们的 AST,并且在使结构体更具通用性方面又迈出了小小的一步。很好。接下来我们希望做的是获取并使用传入的字段。它们与名称来自同一个地方:我们的 AST。
4.4 获取并使用字段
如前所述,参数 item 包含所有相关代码,这意味着整个结构体都可以通过 ast 变量访问。该变量的类型是 DeriveInput,它表示我们可能从派生宏接收到的输入类型。是的,我们正在编写一个属性宏。但这个类型是有效的,因为这两个宏共享一些重要的输入目标:结构体、枚举和联合体。虽然属性宏也可以针对特征和函数,但目前对我们来说并不重要。如果你更喜欢一个更合适的类型,syn::ItemStruct 应该可以直接替换,但它的缺点是隐藏在“full”特性标志下。因此,我们需要修改 syn 的导入来使用它。
DeriveInput 包含了什么?这是源代码的内容(参考链接):
pub struct DeriveInput { #1
pub attrs: Vec<Attribute>, #2
pub vis: Visibility, #3
pub ident: Ident, #4
pub generics: Generics, #5
pub data: Data, #6
}
#1 DeriveInput 是一个表示派生宏输入的结构体。
#2 它包含项目的属性。
#3 它的可见性修饰符。
#4 结构体的标识符。
#5 任何泛型。
#6 其他信息(内容)。
你了解 Rust,因此你可以猜到这些属性的含义:
attrs包含结构体上定义的任何属性,例如#[derive(Debug)]。vis包含我们结构体的可见性(公共的、在 crate 中可见的,或者只是私有的,即“继承的”)。ident包含我们结构体的名称(标识符)。generics包含有关结构体是否是泛型的信息(例如Example<T>)。但在这里并不是这种情况。data是我们可以找到结构体内部数据的详细信息的地方。
data 对我们来说似乎最有用,它包含了什么?深入研究 syn,你会发现 Data 是一个枚举,有三个选项:Struct、Enum 和 Union。这很有意义,因为 DeriveInput 是专门为派生宏创建的,而派生宏不同于属性宏,它只能用于这三种目标:
pub enum Data {
Struct(DataStruct), #1
Enum(DataEnum),
Union(DataUnion),
}
#1 Data 是一个有三个变体的枚举。
Struct 是我们需要的变体。那么,嵌套在 DataStruct 中的内容是什么?再深入一点,你会看到它有一个 struct_token,它包含 struct 关键字(不太有用);semi_token,它是一个可选的分号(不太感兴趣);以及结构体的字段(见图 4.2):
pub struct DataStruct {
pub struct_token: Token![struct],
pub fields: Fields,
pub semi_token: Option<Token![;]>,
}
现在我们知道在哪里可以找到字段,让我们从数据中提取它们。目前,我们只想让它在结构体中工作,因此我们在匹配时将忽略枚举和联合体。我们还将使用匹配来深入提取命名字段——具有名称和类型的字段,例如 first: String。以下代码中的“..”帮助我们忽略 DataStruct 和 FieldsNamed 中我们不关心的属性,包括未命名的字段,这些我们将在后面讨论。如果你忘记添加这两个点,你会得到类似“结构体模式未提及字段 someField 和 anotherField”的错误。这是 Rust 告诉你在匹配中缺少了一些字段:
let fields = match ast.data {
Struct(
DataStruct {
fields: Named(
FieldsNamed {
ref named, ..
}), ..
}
) => named, #1
_ => unimplemented!( #2
"only works for structs with named fields"
),
};
#1 匹配使得我们可以轻松地从输入中提取命名字段。
#2 当我们没有接收到带有命名字段的结构体时,我们会引发 panic。
现在我们有了这样的命名字段类型:Punctuated<Field, Token![,]>。Punctuated 可以是任何具有标点符号的类型。在这种情况下,标点符号的泛型表明字段由逗号 Token 分隔。结构体中的字段确实是由逗号分隔的。值得知道的是,Punctuated 实现了 IntoIterator,所以我们可以对它进行迭代。深入挖掘,你会看到我们将迭代第一个泛型 Field,这也很有意义。毕竟,逗号本身并不是很有趣的迭代对象。
我们有了所有的结构体字段,并且可以对它们进行循环。接下来的问题是:Field 中包含什么?再次查看源代码,我们看到它包含了许多熟悉的东西:attrs、vis 和 ident,以及一个名为 ty 的属性,它包含字段的类型(见图 4.3)。
现在,我们的目标是什么?我们希望将结构体重新填充其属性,但现在需要加上 pub 可见性修饰符。对于我们的 Example 结构体,我们希望使用 quote 将 pub first: String 添加进去。完成后,我们还希望添加 pub second: u32。因此,我们所需要做的就是获取字段的名称和类型。其他信息——包括可见性(因为我们始终设置为 pub)——都是无关紧要的。
注意 我们忽略了结构体可能具有的其他属性,这意味着其他宏可能无法按预期工作。稍后我们会看到如何修复这个问题。
在以下代码中,你可以看到这个想法的实现。我们对字段进行迭代,并使用 map 来提取标识符和类型。通过 quote,我们生成一个包含公共前缀、名称和类型的 TokenStream:
let builder_fields = fields.iter().map(|f| {
let name = &f.ident; #1
let ty = &f.ty;
quote! { pub #name: #ty } #2
});
#1 现在,我们在 map 中有了单个字段,可以获取字段的名称(标识符)和类型。
#2 使用 quote,我们返回一个 TokenStream,其中包含输入的内容,并为每个字段加上 pub 前缀。
到目前为止,我们知道,任何不是字面量的内容都应该在输出中加上井号(#)前缀。这样,quote 知道它需要将给定值替换为同名变量中的值。请注意,quote 无法访问变量的属性,因此像 quote! { pub #f.ident: #f.ty } 这样的写法只会导致混乱的错误,因为 #f 会被“解析”并将字面值 .ident 添加到输出中。另一个有趣的地方是,我们不需要收集 map 的输出——因为 quote 知道如何处理生成 TokenStream 的 map。不过,如果你想简化函数签名,仍然可以调用 collect 来获取一个 Vec<TokenStream> 输出。稍后我们将看到此的示例。
现在,我们所需要做的就是将这些流添加到我们要返回的结构体中。我们还需要告诉 Rust,这些流应该用逗号分隔,否则 Rust 会抱怨语法错误。quote 为此提供了一个便利,它看起来非常类似于你在声明宏中处理多个元素的方式:#(#name-of-your-variable,)*(即,拿这个变量,它包含零个或多个值,并在每个提取的元素后加上逗号)。请注意,quote 中的重复需要两个井号。如果你忘记外面的井号,你会遇到这个错误:“the trait ToTokens is not implemented for Map...”。
Listing 4.9 我们的公共字段宏
use quote::quote;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput, DataStruct, FieldsNamed};
use syn::Data::Struct;
use syn::Fields::Named;
#[proc_macro_attribute]
pub fn public(_attr: TokenStream, item: TokenStream) -> TokenStream {
let ast = parse_macro_input!(item as DeriveInput);
let name = ast.ident;
let fields = match ast.data {
Struct(
DataStruct { #1
fields: Named(
FieldsNamed {
ref named, ..
}), ..
}
) => named,
_ => unimplemented!(
"only works for structs with named fields"
),
};
let builder_fields = fields.iter().map(|f| {
let name = &f.ident; #2
let ty = &f.ty;
quote! { pub #name: #ty }
});
let public_version = quote! {
pub struct #name {
#(#builder_fields,)* #3
}
};
public_version.into()
}
#1 如果我们有一个带有命名字段的结构体,我们会提取字段。
#2 从这些字段中,我们获取字段的名称和类型,并返回一个 TokenStream,其中包含 pub、名称和类型。
#3 在我们的输出中,我们告诉 quote,builder_fields 是一个元素列表,应该用逗号分隔并添加到结构体中。
如你所见,quote 和我们需要的功能一样多才多艺。我们可以用 #name 添加一个简单的 TokenStream,或者一次添加多个元素。在更复杂的宏中,输出通常是通过将多个变量组合在一起得到的,最终形成一个输出。这些变量可能也是通过 quote 的组合生成的,复杂度不确定。无论如何,得益于之前的代码,现在像 Example 这样的简单结构体已经将所有字段设置为公共的。隐私正式消失了!
4.5 可能的扩展
现在,我们有很多方法可以扩展我们的宏,使它变得更有用。例如,我们可以在接收到枚举时,不再触发 panic,而是编写代码来处理它。请记住,枚举的变体在枚举本身是公共时会自动变为公共,这意味着直接在枚举上添加 pub 可能比使用自定义宏更简单。
我们还忽略了未命名的字段,然而这些字段在像 struct Example(u32, String); 这样的结构体中是存在的。如果我们像这样编写结构体,就会进入未实现的匹配分支。那么,我们该如何处理这些结构体呢?第一步是提取未命名字段,这将非常类似于我们获取命名字段的方式。我们可以在额外的匹配分支中完成这一点。一旦我们得到了字段,就可以按照正确的样式输出它们——例如,pub struct Example(pub u32, pub String);。最后,你需要区分命名结构体和未命名结构体,并决定返回哪种类型的输出。幸运的是,这是一个简单的二元选择:结构体要么是命名的,要么是未命名的。
有些令人惊讶的是,我们的代码能够正确处理空结构体,但仅当它们后面跟着方括号或花括号时。如果结构体没有这些符号,比如 struct Empty;,它将没有任何字段,因此会进入我们的未实现分支。
还有一些潜在的问题(除了没有处理联合体)。例如,我们的宏不会将嵌套结构体的字段设置为公共。幸运的是,只需将 #[public] 添加到嵌套项上即可轻松解决。
一个更大的问题是,当我们有其他宏对结构体进行注解时,它们会被我们的宏删除。试着在我们的注解下方添加一个 #[derive(Debug)],然后运行 cargo expand。你会看到额外的注解消失得无影无踪吗?我们真是擅长让事情消失。由于宏是按顺序执行的,从上到下,解决方法是将 derive 放在 #[public] 之上。这样一切都会按预期工作。除非——请不要这样做。相反,应以正确的方式检索并重新附加所有可用的属性。
此外,虽然我们保留了命名字段的泛型,但我们正在重新创建结构体的签名,这意味着它的泛型会消失。解决这个问题需要使用并重新添加 DeriveInput 中的泛型字段。你可以在稍后的练习中尝试这些扩展。
4.6 多种解析流的方法
前面描述的方法展示了一种创建过程宏的方式。它使用 syn 来解析你的标记,使用 quote 来创建新的标记,使用匹配和函数来处理中间的所有内容。然而,如果你不喜欢通过函数将所有内容拼接在一起,还有其他替代方案,比如更“结构体聚焦”的方法。作为一个示例,我们将重新审视我们的 public 宏,并展示一些选择。
4.6.1 将任务委派给自定义结构体
首先,我们可以将获取和输出字段的任务委派给一个 StructField 结构体。首先,添加一个对 proc-macro2 的依赖,我们很快就会用到它。
Listing 4.10 在我们的库 toml 文件中添加了两个熟悉的依赖项和一个新的依赖项
[dependencies]
quote = "1.0.33"
syn = "2.0.39"
proc-macro2 = "1.0.69"
现在来看一下新的实现。这里有两个主要的变化。我们不再直接使用 syn 的原始类型,而是让我们的结构体收集和组织所有需要的信息(即,名称和类型)。按照惯例,名为 new 的方法应该用于创建结构体,因此我们创建一个,并在遍历字段时调用它。map(StructField::new) 是一个便捷的简写,也叫做无点风格(point-free style),它等价于 map(|f| StructField::new(f))。
Listing 4.11 使用结构体来完成大部分拼接的实现
use proc_macro::TokenStream;
use quote::{quote, ToTokens};
// 之前的导入
struct StructField { #1
name: Ident,
ty: Type,
}
impl StructField {
fn new(field: &Field) -> Self { #2
Self {
name: field.ident.as_ref().unwrap().clone(),
ty: field.ty.clone(),
}
}
}
impl ToTokens for StructField {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { #3
let n = &self.name;
let t = &self.ty;
quote!(pub #n: #t).to_tokens(tokens)
}
}
#[proc_macro_attribute]
pub fn public(_attr: TokenStream, item: TokenStream) -> TokenStream {
// 未更改:获取字段
let builder_fields = fields.iter().map(StructField::new); #4
// 未更改:使用 quote 输出
}
#1 这个结构体包含我们需要的每个字段的数据。
#2 我们的 new 方法包含了从字段中获取名称和类型的代码。
#3 ToTokens 输出一个带有公共前缀的标记流。因为它是 quote 库的一部分,它使用了 proc_macro2::TokenStream。
#4 在遍历过程中,我们通过调用 new 将每个 Field 映射为 StructField。
builder_fields 包含了我们的自定义结构体。那 quote 如何将其转换为 TokenStream 呢?它不能,因为它不知道如何将一个随机结构体转换为一堆标记。解决方案之一是编写一个第二个方法,将结构体转换为 TokenStream 并在遍历时调用它。但这里有一个现成的解决方案!ToTokens(来自 quote)可以将实现者转换为 TokenStream,因此我们将使用这个特性。
to_tokens 中的代码与我们之前在映射中所做的类似,唯一不同的是,to_tokens 要求我们将所有新标记传递给类型为 proc_macro2::TokenStream 的可变参数,正如我们简要提到的,proc_macro2::TokenStream 是一个社区构建的标记流,它包装了内置流并添加了附加功能。quote 在内部使用它,因此每次返回标记时,我们必须对 quote 的结果加上 into。现在我们显式地在 impl 块的签名中使用它,所以我们需要将它添加到项目的依赖中。另一个可行的方式是使用 quote::__private::TokenStream,但我们选择使用更简洁的方法。通过这个方式,我们的 builder_fields 自动转化为 TokenStream,而我们的最终 quote 代码无需更改。
这比之前的代码更多—也更复杂—but 也有一些积极的方面。我们实现了关注点的分离:我们让一个结构体负责字段的获取和输出。这使得代码更具可复用性,并可能更加易于阅读和结构化。在这个基本示例中,可能有些过度设计,但在更大的过程宏中,将数据组织为结构体可能会证明是有用的。
4.6.2 实现 Parse 特性
我们可以更进一步,结合 syn 中的一个内置特性——Parse 特性,它用于将 TokenStream 转换为结构体或枚举。在下一个代码示例中,我们添加了该特性的实现及其方法 parse,该方法接受 syn::parse::ParseStream 作为输入。你可以将其视为一种特殊的 TokenStream。
Listing 4.12 使用 parse 替代 new
// imports
struct StructField {
name: Ident,
ty: Ident, #1
}
// ToTokens 实现保持不变
impl Parse for StructField { #2
fn parse(input: ParseStream) -> Result<Self, syn::Error> {
let _vis: Result<Visibility, _> = input.parse(); #3
let list = Punctuated::<Ident, Colon>::parse_terminated(input)
.unwrap(); #4
Ok(StructField {
name: list.first().unwrap().clone(), #5
ty: list.last().unwrap().clone(),
})
}
}
#[proc_macro_attribute]
pub fn public(_attr: TokenStream, item: TokenStream) -> TokenStream {
// 未更改
let builder_fields = fields.iter() #6
.map(|f| {
syn::parse2::<StructField>(f.to_token_stream())
.unwrap()
});
// 未更改
}
#1 ty 现在是一个 Ident,而不是 Type,因为这正是我们从 Parse 中接收到的。
#2 new 方法已经被 parse 替代。
#3 我们尝试将可见性解析为一个 Result<Visibility, _> 类型的变量,以便将指针移到下一个标记。
#4 没有可见性的字段是一个 Punctuated 类型,因此我们调用 parse_terminated 来解析字段的其余部分。
#5 第一个元素应该是名称,类型应该是最后一个。因为我们将字段解析为由冒号分隔的标识符,所以 ty 现在是 Ident。
#6 遍历字段并使用 parse2。Result 可能是任何类型,因此我们需要声明我们期望的是 StructField。
在我们的 parse 实现中,我们期望接收到一个 ParseStream 表示单个字段。这意味着我们首先接收到的是一个标记,指示字段的可见性。我们对这个值不感兴趣,但现在处理它会让剩下的解析变得更容易。因此,我们调用 parse 方法并告诉它,我们想将这个 Visibility 绑定到一个变量 _vis。parse 可能会失败,因此 Visibility 被包装在 Result 中,我们不会展开它,因为这只是为了移除该值。现在我们的 ParseStream 不再包含字段的可见性修饰符,我们可以继续处理接下来的部分(见图 4.4)。
注意 也许你认为可见性是字段定义中的一个可选部分,确实,我以前也是这么认为的。但回顾一下 AST 图和源代码:ParseStream 始终包含一个 Visibility。默认情况下,它只是 Inherited,根据文档,“通常意味着私有(private)”。
在我们的下一步中,Punctuated::<Ident, Colon> 告诉 Rust 我们期待的是由标识符和冒号(:)分隔的内容。毕竟,字段声明的样子就是 field_name: FieldType,没有它的可见性。而且,正如我们的示例所示,第一个元素应该是名称,最后一个元素是类型。所以我们可以将它们添加到结构体中。请注意,由于 Punctuated,ty 现在被解析为 Ident,这完全是有效的。
如果此时可见性仍然是我们流的一部分,Rust 会因为它期望一个标识符列表而得到一个 Visibility 类型时抱怨并报错:“Error("expected identifier")”。但是,尽管我们避免了这个错误,这仍然不是一个生产就绪的宏。我们现在只期望简单的、未注解的、命名的字段。其他的都不考虑。而这只是结构体可能包含的内容范围的一部分,尽管是一个重要部分。不过,现阶段这个方法已经足够了。
剩下的就是将字段标记传递给解析。我们可以使用 syn::parse2 来完成这个任务。它接受一个 TokenStream 并返回任何实现了 Parse 的类型。但由于有很多东西实现了 Parse,我们必须告诉函数我们想要什么返回类型。在我们的例子中,返回类型是 StructField。parse2::<StructField> 可能是传递这些信息最优雅的方式。一个笨拙的替代方法是:map(|f| { let field: StructField = parse2(f.to_token_stream()).unwrap(); field })。
注意 为什么是 parse2?parse 也存在,并且非常相似,但它接受普通的 TokenStream,而 parse2 接受 proc_macro2 变体(因此有了这个名字)。顺便说一句,parse_macro_input! 只是 parse 的语法糖。
.map(|f| parse2::<StructField>(f.to_token_stream()) 不是很理想。我们已经有了从 syn 解析出来的漂亮数据,现在又把它转换回 TokenStream。你可以通过例如将所有字段(或整个输入)解析为自定义结构体,而不是解析每个单独的字段,来避免这种丑陋的写法。
4.6.3 使用 cursor 进行低级控制
作为最后一个示例,我们也可以选择使用 cursor 来进行对解析的低级控制:
impl Parse for StructField {
fn parse(input: ParseStream) -> Result<Self, syn::Error> {
let first = input.cursor().ident().unwrap();
let res = if first.0.to_string().contains("pub") {
let second = first.1.ident().unwrap();
let third = second.1.punct().unwrap().1.ident().unwrap();
Ok(StructField {
name: second.0,
ty: third.0,
})
} else {
let second = first.1.punct().unwrap().1.ident().unwrap();
Ok(StructField {
name: first.0,
ty: second.0,
})
};
let _: Result<proc_macro2::TokenStream, _> = input.parse();
res
}
}
我们在输入参数上调用 cursor 并告诉它,首先遇到的应该是一个标识符。cursor 不区分名称、类型、可见性等,因此它会捕获字段的可见性或名称。如果名称包含字符串 "pub",我们就得到了可见性,这意味着下一个标识符是名称,我们通过调用 first.1.ident() 并解包结果来获取它。我们的变量 second 应该包含名称和剩余的数据。我们的类型位于冒号后面,因此我们期望找到标点符号(punct()),后面跟着标识符。现在我们已经得到了名称和类型,并将它们传递给 StructField。另一个条件分支类似。当我们没有显式的可见性修饰符时,我们已经得到了名称,只需要获取类型。
不过,最终的 let _: Result<proc_macro2::TokenStream, _> = input.parse(); 是比较麻烦的。cursor 给我们提供了对现有流的不可变访问,这很好——除了调用解析方法后,parse2 会进行检查,确保流中没有未处理的内容。在我们的例子中,我们没有——甚至不能——更改任何内容,所以我们会得到一个混乱的错误 Error("unexpected token")。因此,我们只需要调用 parse 并让它返回一个 Result,我们可以忽略它。这有点像我们在之前的示例中忽略可见性(在 Listing 4.12 中)。
你应该偏好哪种风格?
这取决于情况。函数和匹配相对简单,似乎非常适合较小的宏或编写概念验证。结构体可以为你的解决方案提供额外的结构,并提供一种很好的方式来委派责任:每个结构体处理部分解析和输出。当你传递给宏的内容不是标准的 Rust 代码,比如 DSL 时,结构体特别有用。默认的解析器显然不适合处理看起来随机的输入,这些输入只有在特定领域中才有意义。因此,你可以编写自己的结构体并捕获相关信息。与此同时,cursor 提供了低级控制和很大的能力,但它是冗长的,使用起来不太方便。因此,它可能不是你首选的方式。
在本书中,许多章节侧重于将函数作为构建宏的粘合剂,因为这种风格对于简短的示例非常方便。但因为结构体通常在“实际应用”中使用,我们也会提供使用这种风格的示例。
4.7 更多开发和调试方式
在之前的章节中,我们讨论了开发和调试工具,如 cargo expand。在本章中,我们也深入研究了源代码,这帮助我们了解了当 syn 解析输入时,哪些数据可供使用。另一个值得提及的有用工具是——请不要把书合上——打印到控制台,因为通过类型查看 AST 的结构很有用,但打印它可以告诉你里面实际包含了什么,这在你不确定自己收到的是什么时非常有帮助。
有两点需要注意:首先,要调试并打印 DeriveInput 和 syn 中的其他类型,你需要激活 syn 的 extra-traits 特性:
syn = { version = "2.0.39", features=["extra-traits"] }
其次,有很大的可能性标准输出会被捕获并不会显示在你的控制台中。简单的解决方法是使用错误打印,它应该总是能够回到你那里:
let ast = parse_macro_input!(item as DeriveInput);
eprintln!("{:#?}", &ast);
接下来,当我们在宏中添加 eprintln 命令时,你可以看到该命令的简略输出。里面有很多信息,学习如何阅读它需要一些时间。但是你应该能够识别我们在本章中讨论的许多类型——如 Ident、Struct、DataStruct、FieldsNamed 等:
DeriveInput {
vis: Inherited,
ident: Ident {
ident: "Example",
span: #0 bytes(67..74),
},
...
data: Struct(
DataStruct {
struct_token: Struct,
fields: Named(
FieldsNamed {
named: [
Field {
attrs: [],
vis: Inherited,
ident: Some(
Ident {
ident: "first",
span: #0 bytes(81..86),
},
),
colon_token: Some(
Colon,
),
...
},
),
}
4.8 来自现实世界的示例
许多库使用属性宏来实现有趣的功能。例如,广泛使用的 Rust 异步运行时库 Tokio 使用其 #[tokio::main] 宏将你的 main 函数转换为可以处理异步调用的形式。这是源代码入口点:
#[proc_macro_attribute]
pub fn main(args: TokenStream, item: TokenStream) -> TokenStream {
entry::main(args, item, true)
}
等一下,只有 main 吗?那么为什么所有的代码示例都显示 #[tokio::main]?这可能是为了让读者明确这是 Tokio 的 main 宏。但由于 :: 前面的部分仅表示我们从哪个 crate 获取宏,所以使用 tokio::main,加上 #[main] 也是完全有效的(越知道的越多……)。
还有许多库使用 Parse 和 ToTokens 特性。以下是来自 Rocket(一个 Web 框架,使用许多宏来生成端点等功能)的一些示例。想要响应一个 GET 调用吗?只需添加自定义的 #[get("/add/endpoint")] 注解:
impl Parse for Invocation {
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
Ok(Invocation {
ty_stream_ty: (input.parse()?, input.parse::<syn::Token![,]>()?).0,
stream_mac: (input.parse()?, input.parse::<syn::Token![,]>()?).0,
stream_trait: (input.parse()?, input.parse::<syn::Token![,]>()?).0,
input: input.parse()?,
})
}
}
impl ToTokens for UriExpr {
fn to_tokens(&self, t: &mut TokenStream) {
match self {
UriExpr::Uri(uri) => uri.to_tokens(t),
UriExpr::Expr(e) => e.to_tokens(t),
}
}
}
如你所见,Parse 实现比我们的代码有更好的错误处理(它使用 ? 而不是直接解包)。对于生产级的宏来说,良好的错误处理是推荐的,因此我们将在稍后的章节中回到这一点。尽管如此,偶尔你会看到宏中出现 panic。Yew,一个用于编写和验证 HTML 的 Web 框架,确实在两个地方出现了 unimplemented!("only structs are supported")。
作为最终示例,看看 no-panic(GitHub 链接),这是一个让编译器证明某个给定的函数永远不会 panic 的宏——这非常神奇。它是如何做到的呢?考虑以下代码:
struct Dropper {}
impl Drop for Dropper {
fn drop(&mut self) { #1
println!("Dropping!")
}
}
fn some_fun() {
let d = Dropper {};
panic!("panic"); #2
core::mem::forget(d); #3
}
fn main() {
some_fun();
}
#1 为我们的 Dropper 结构体实现 Drop
#2 在创建 Dropper 实例后触发 panic!
#3 core::mem::forget(d) 让 Rust 忘记 d,所以如果它运行,drop 方法不会被调用。
我们有一个叫做 Dropper 的结构体,它实现了 Drop,这是 Rust 中用于在需要自定义资源清理时使用的特性。当你运行代码时,some_fun 会触发一个 panic,接着堆栈将开始展开。此时,Dropper 的 Drop 被调用来清理结构体。
但如果我们注释掉 panic,那么下一行会触发 forget。这会让 Rust 忘记 Dropper 结构体和它的 Drop 实现。在这种情况下,Drop 将不会被调用。(记住,当遇到 panic 时,我们停止执行。所以在第一个例子中,我们永远不会到达 forget。但至少在默认情况下,Rust 确实会在 panic 时清理资源。)这有一个有趣的含义。如果编译器能够“证明” some_fun 永远不会 panic,那么合乎逻辑的结论是,由于 forget 的调用,Drop 永远不会被调用!因此,Rust 可以安全地优化掉 Drop 实现,因为它从未被使用。
这个库和其他类似的库做了一个巧妙的操作:它们创建了一个结构体,实现 Drop 特性,并在实现中调用一个不存在的 C 函数,随后添加了一个 forget 调用。意味着如果代码没有被优化掉,我们将会遇到链接错误。而当 panic 发生在 forget 调用之前时,代码不会被删除,因为在这种情况下,Rust 可能仍然需要调用 Drop。很巧妙吧?
struct __NoPanic;
extern "C" {
#[link_name = "does_not_exist"]
fn trigger() -> !;
}
impl Drop for __NoPanic {
fn drop(&mut self) {
unsafe {
trigger();
}
}
}
库本身的代码非常简短(大约 150 行代码)。以下是一个经过注解的部分示例。为了简化,我移除了一些错误处理,因为我们尚未讨论过这部分。并且,目前你只需要知道 parse_quote 类似于常规的 quote 宏:
#[proc_macro_attribute]
pub fn no_panic(args: TokenStream, input: TokenStream) -> TokenStream {
let expanded = match parse(args, input.clone()) { #1
Ok(function) => expand_no_panic(function),
// 错误处理
};
TokenStream::from(expanded)
}
fn parse(args: TokenStream2, input: TokenStream2) -> Result<ItemFn> {
let function: ItemFn = syn::parse2(input)?; #2
let _: Nothing = syn::parse2::<Nothing>(args)?;
Ok(function)
}
fn expand_no_panic(mut function: ItemFn) -> TokenStream2 { #3
// ...
let stmts = function.block.stmts;
let message = format!(
"\n\nERROR[no-panic]: detected panic in function `{}`\n",
function.sig.ident,
);
function.block = Box::new(parse_quote!({
struct __NoPanic;
extern "C" { #4
#[link_name = #message]
fn trigger() -> !;
}
impl core::ops::Drop for __NoPanic {
// ...
}
let __guard = __NoPanic;
let __result = (move || #ret {
#move_self
#(
let #arg_pat = #arg_val;
)*
#(#stmts)*
})();
core::mem::forget(__guard);
__result
}));
quote!(#function)
}
#1 这是宏的入口点。调用自定义的 parse 函数,并将 Ok 结果转发到 expand_no_panic。
#2 ItemFn 是 syn 中内建的用于解析函数的类型,类似于 DeriveInput 帮助我们处理枚举和结构体。
#3 这个函数做了大量的工作。在 ItemFn 类型中,function.block 包含了函数的主体。__NoPanic 相关的内容被添加到这个主体中。
#4 这个函数做了大量的工作。function.block 包含了函数的主体,__NoPanic 相关的内容被添加到该主体。
你应该能在这段代码中认出许多熟悉的元素。我们首先使用属性宏声明了宏。因为函数名决定了宏的名称,所以我们知道使用这个宏时需要写 #[no_panic]。我们接收两个 TokenStream 作为参数,并使用 parse2 将输入解析为 ItemFn,这是 syn 中内建的解析函数的类型,稍后我们将在其他章节中详细讨论。args 没有被使用,因此它被解析成了一个被忽略的 Nothing 类型变量。如果 TokenStream 中有任何内容,解析到 Nothing 将会失败(因此命名为 Nothing)。顺便提一下,双重指定类型(Nothing 和 syn::parse2::<Nothing>)是冗余的,使用其中一个即可。
expand_no_panic 包含了我们之前讨论的大部分内容。它向函数主体添加了伪链接的 C 函数,并尝试使用 forget 忘记它。作为额外的功能,伪 C 函数的 link_name 被用作报告检测到的 panic 的消息,当 Drop 没有被优化掉时。实现部分包含在 function.block.stmts 中(stmts 代表“语句”),它被添加到我们的新实现中,覆盖了原有的实现。通过这段新代码,最终的表达式(quote!(#function))返回部分覆盖过的函数作为 TokenStream。
总结
- 与派生宏类似,属性宏可以用于枚举、结构体和联合体。此外,它们也可以添加到特征和函数中。
- 与派生宏不同,属性宏会覆盖其输入,从而使得修改现有代码成为可能。
- 属性宏的名称来源于定义一个新的自定义属性。
- 我们可以使用
syn解析结果(即我们的 AST)来获取各种信息,例如结构体的字段。 quote允许我们将多个标记流组合成一个单一的输出。- 我们可以使用匹配和函数将解析和输出结合起来。
- 对于更复杂和更大的宏,你可能会考虑创建自定义结构体,并将解析和输出的责任委派给它们。