前文我们介绍了宏及其分类等基础知识,以及编写宏常用的依赖包等相关内容。在本节中,你将学习如何编写派生宏。我们通过实际示例由浅入深进行,让你逐步掌握自定义派生宏。
如何声明派生宏
假设有应用需要能够将结构体转换成HashMap,它的键和值都使用String类型。这意味着它应该适用于任何结构体,其中所有字段都可以使用Into trait转换为String类型。明确了需求,先命名为IntoStringHashMap派生宏。再次提醒,如果没有阅读《精通Rust系统教程-过程宏入门》,请先阅读了解前置知识和宏项目的基本配置和依赖包。
你可以通过创建函数来声明宏,并使用属性宏来注释该函数,这些属性宏告诉编译器将该函数视为宏声明。现在你的lib.rs文件还是空的,首先需要声明proc-macro2作为一个外部crate:
// my-app-macros/src/lib.rs
extern crate proc_macro;
use proc_macro::TokenStream;
#[proc_macro_derive(IntoStringHashMap)]
pub fn derive_into_hash_map(item: TokenStream) -> TokenStream {
todo!()
}
我们在这里所做的就是用标识符IntoStringHashMap声明该宏为派生宏。注意,函数名在这里并不重要,重要的是传递给proc_macro_derived属性宏的标识符。
让我们立即看看如何使用它-我们稍后会回来完成实现:
// my-app/src/main.rs
use my_app_macros::IntoStringHashMap;
#[derive(IntoStringHashMap)]
pub struct User {
username: String,
first_name: String,
last_name: String,
age: u32,
}
fn main() {
}
你可以像使用其他派生宏一样使用你的宏,使用你为它声明的标识符(在本例中是IntoStringHashMap)。如果你尝试在此阶段编译代码,您应该看到以下编译错误:
Compiling my-app v0.1.0
error: proc-macro derive panicked
--> src/main.rs:3:10
|
3 | #[derive(IntoHashMap)]
| ^^^^^^^^^^^
|
= help: message: not yet implemented
error: could not compile `my-app` (bin "my-app") due to 1 previous error
这清楚地证明了我们的宏是在编译阶段执行的,因为如果您不熟悉todo!() 宏,那么在执行时就会出现help: message: not implemented。
这意味着我们的宏声明和它的使用都有效。现在我们可以继续实际实现这个宏。
如何解析宏输入
首先,使用syn将输入标记流解析为DeriveInput, 它可以表示使用了该派生宏的任何目标:
let input = syn::parse_macro_input!(item as syn::DeriveInput);
syn为我们提供了parse_macro_input宏,该宏使用某种自定义语法作为参数。你向它提供输入变量的名称、as关键字和syn中的数据类型,以便它将输入标记流解析为(在我们的示例中是一个DeriveInput)。
如果你跳转到DeriveInput的源代码,你会看到它给了我们以下信息:
ast_struct! {
/// Data structure sent to a `proc_macro_derive` macro.
#[cfg_attr(docsrs, doc(cfg(feature = "derive")))]
pub struct DeriveInput {
pub attrs: Vec<Attribute>,
pub vis: Visibility,
pub ident: Ident,
pub generics: Generics,
pub data: Data,
}
}
-
attrs:它包含了一系列属性(
Attribute
)的向量。在 Rust 中,属性用于为代码元素(如结构体、枚举、函数等)添加额外的元信息。这些属性可以影响编译器的行为或者被其他工具(如代码生成宏)所使用。-
示例:例如,可能会有
#[derive(Debug)]
这样的属性用于自动为结构体派生Debug
trait,这个属性就可能会被包含在attrs
向量中,以便在后续处理派生宏的过程中识别并执行相应的操作来实现Debug
功能的添加。
-
示例:例如,可能会有
-
vis:此类型声明的可见性说明符。它表示所定义的类型(在这个上下文中,通常是与
DeriveInput
相关的结构体或枚举等类型)的可见性。在 Rust 中,可见性决定了代码中的其他部分是否能够访问该类型以及它的成员。常见的可见性设置有pub
(公开的,可在其他模块中访问)、pub(crate)
(在当前 crate 内公开)、pub(super)
(在父模块公开)和没有任何修饰的(默认是私有的,只能在当前模块内访问)。 -
ident:类型的标识符(名称)。
Ident
通常用于表示一个标识符,在这里它代表了正在被派生宏处理的类型的名称。比如,如果有一个结构体定义为struct MyStruct {}
,那么ident
就会存储"MyStruct"
这个字符串作为标识符,以便在派生宏的实现过程中能够准确地引用该类型并根据其名称进行一些特定的代码生成或处理操作。 -
generics:关于此类型使用的泛型参数的信息,包括生存期。这个属性用于处理泛型相关的信息。在 Rust 中,泛型允许编写能够适用于多种不同具体类型的代码,而无需为每种类型都重复编写相同的逻辑。
Generics
结构体(这里假设它是一个自定义的用于处理泛型信息的结构体)会包含关于类型参数、生命周期参数等泛型相关的详细内容,比如有哪些泛型参数、它们的约束条件等。-
示例:对于泛型结构体
struct GenericStruct<T: Debug> { value: T }
,这里的generics
属性就会存储关于类型参数T
以及它的约束条件(T: Debug
)等信息,以便在派生宏处理过程中,如果需要针对泛型情况进行特殊的代码生成(比如为不同的具体类型参数实现不同的行为),就可以依据这些泛型信息来进行操作。
-
示例:对于泛型结构体
-
data:一个枚举,描述目标是结构体、枚举还是联合,并为每种类型提供更多信息。它包含了关于被派生宏处理的类型的内部数据结构的信息。对于结构体来说,这可能包括结构体的各个字段的类型、名称等信息;对于枚举来说,会包含枚举变量的相关信息等。具体的
Data
结构体的定义可能会根据所处理的类型的不同而有所不同,但总体上它是用于在派生宏中深入了解要处理的类型的具体构成情况,以便能够准确地根据其数据结构来生成合适的代码。-
示例:如果有一个结构体
struct Person { name: String, age: u32 }
,那么data
属性就会存储关于name
字段(类型是String
)和age
字段(类型是u32
)的信息,在派生宏实现某些功能(比如生成一个打印结构体所有字段值的方法)时,就可以从data
中获取这些字段信息来准确地生成相应的代码。
-
示例:如果有一个结构体
这些字段名及其类型(除了数据字段)在syn支持的目标(如函数、枚举等)中是非常标准的。
如果你进一步进入Data枚举的声明,特别是进入DataStruct,将看到它提供了名为fields的字段。这是这个结构体的所有字段的集合,你可以使用它来遍历它们。这正是我们构建哈希映射所需要的!
这个宏的完整实现是这样的:
// my-app/my-app-macros/lib.rs
extern crate proc_macro2;
use proc_macro::TokenStream;
use quote::quote;
use syn::Data;
#[proc_macro_derive(IntoHashMap)]
pub fn into_hash_map(item: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(item as syn::DeriveInput);
let struct_identifier = &input.ident;
match &input.data {
Data::Struct(syn::DataStruct { fields, .. }) => {
let mut implementation = quote!{
let mut hash_map = std::collections::HashMap::<String, String>::new();
};
for field in fields {
let identifier = field.ident.as_ref().unwrap();
implementation.extend(quote!{
hash_map.insert(stringify!(#identifier).to_string(), String::from(value.#identifier));
});
}
quote! {
#[automatically_derived]
impl From<#struct_identifier> for std::collections::HashMap<String, String> {
fn from(value: #struct_identifier) -> Self {
#implementation
hash_map
}
}
}
}
_ => unimplemented!()
}.into()
}
这里有不少需要解释,下面我们继续。
如何判断宏的目标
let struct_identifier = &input.ident;
这里将结构标识符存储到单独的变量中,以便后续可以轻松地使用它。
match &input.data {
Data::struct(syn::DataStruct { fields, .. }) => { ... },
_ => unimplemented!()
}
匹配来自DeriveInput的解析数据字段。如果它的类型是DataStruct (Rust结构),那么继续,否则panic,因为该宏没有为其他类型实现。
- 这里首先检查
input.data
是否是Data
类型中的结构体类型(假设Data
是枚举类型,其中一个变量表示结构体的情况)。 - 当匹配到是结构体类型时,进一步通过解构
syn::DataStruct
结构体来提取其中的fields
字段(这里使用了{ fields,.. }
的解构语法,意味着只提取fields
字段,而忽略syn::DataStruct
中的其他可能字段)。syn
库通常用于处理 Rust 的语法树相关的操作,所以这里的syn::DataStruct
表示从语法树中解析出来的结构体信息。
如何构建输出代码
让我们来看看当你有数据结构体时匹配分支的实现:
let mut implementation = quote!{
let mut hash_map = std::collections::HashMap::<String, String>::new();
};
这里使用quote创建新的TokenStream。这个TokenStream与标准库提供的TokenStream不同,所以不要将它与标准库混淆。这需要是可变的,因为我们将很快向这个TokenStream添加更多的代码。
TokenStream基本上是AST的反向表示。你向quote宏提供实际的Rust代码,它给我们提供“标识流”,就像你之前对源代码所说的那样。
这个TokenStream可以转换为宏的输出类型,也可以使用quote提供的方法(如extend)进行操作。我们继续,
for field in fields {
let identifier = field.ident.as_ref().unwrap();
implementation.extend(quote!{
hash_map.insert(
stringify!(#identifier).to_string(),
String::from(value.#identifier)
);
});
}
循环遍历所有字段。在每次迭代中,首先创建变量标识符来保存字段的名称,以供以后使用。然后在我们之前创建的TokenStream上使用extend方法向其添加额外的代码。
extend方法只需要另一个TokenStream,它可以很容易地使用quote宏生成。对于扩展,只需编写代码将新条目插入到将在宏输出中创建的hash_map中。
这里value
不要有疑惑,继续往下看,因为我们正在构建要生成的代码,后面会看到给对应结构体增加实现,value是传入的参数。
让我们仔细看看:
hash_map.insert(
stringify!(#identifier).to_string(), String::from(value.#identifier)
);
我们知道insert方法接受一个键值对,你已经告诉编译器键和值都是String类型。stringify是标准库中的内置宏,可将任何Ident类型转换为其等效的&str类型。这里使用它将字段标识符转换为实际的&str,然后调用to_string() 方法将其转换为String类型。
但是#标识符代表什么呢?
quote为你提供了使用#前缀在TokenStream外部声明的任何变量的能力。可以把它看作format函数参数中的{}。在这种情况下,#identifier被替换为我们在扩展调用外声明的字段标识符。因此,基本上可以直接在字段标识符上调用stringify!()宏。
类似地,你可以使用熟悉的struct_variable访问字段的值。Field_name语法,但使用标识符变量而不是字段名。当你将值传递给insert语句时,你所做的就是:String::from(value.#identifier)。
如果您仔细查看了代码,就会意识到value
来自哪里,它只是trait实现方法声明的输入参数。一旦你为结构体中的每个字段使用for循环构建了你的实现,你就有了TokenStream,为了更好理解,这里列举下生成的代码:
let mut hash_map = std::collections::HashMap::<String, String>::new();
hash_map.insert("username".to_string(), String::from(value.username));
hash_map.insert("first_name".to_string(), String::from(value.first_name));
hash_map.insert("last_name".to_string(), String::from(value.last_name));
继续最后宏生成的输出:
quote! {
impl From<#struct_identifier> for std::collections::HashMap<String, String> {
fn from(value: #struct_identifier) -> Self {
#implementation
hash_map
}
}
}
在这里,您首先使用quote创建另一个TokenStream。在这个块中编写From trait的实现。
下面一行再次使用了我们刚才看到的#前缀语法,根据结构体的标识符声明trait实现应该针对目标结构体。在这种情况下,如果将派生宏应用于User结构,则此标识符将被替换为User。
impl From<#struct_identifier> for std::collections::HashMap<String, String> {}
最终实际方法体为:
fn from(value: #struct_identifier) -> Self {
#implementation
hash_map
}
如你所见,可以使用相同的#语法轻松地将TokenStreams嵌套到其他TokenStreams中,从而允许在quote宏中使用外部变量。在这里,声明hash_map实现应该作为函数的前几行插入。然后返回相同的hash_map。这就完成了trait的实现。
作为最后一步,在match块的返回类型上调用.into(),它返回quote宏调用的输出。这将quote使用的TokenStream类型转换为来自标准库的TokenStream类型,也是编译器期望从宏中返回的类型。
如果我把它逐行分解,让你难以理解,你可以看看下面这段完整但加了注释的代码:
// 声明该函数是派生宏,标识符为 `IntoHashMap`.
#[proc_macro_derive(IntoHashMap)]
// 声明函数,输入为 `TokenStream` ,输出也为 `TokenStream`.
pub fn into_hash_map(item: TokenStream) -> TokenStream {
// 使用syn包的方法解析参数 `DeriveInput`
let input = syn::parse_macro_input!(item as syn::DeriveInput);
// 存储结构体标识符,供后面代码使用
let struct_identifier = &input.ident;
//匹配目标数据类型
match &input.data {
// 匹配目标数据类型为 struct, 然后从中解构出 `fields` 字段信息.
Data::Struct(syn::DataStruct { fields, .. }) => {
// 声明新的 quote 代码块.
// 其中声明hash_map并再后续插入键值对
let mut implementation = quote!{
// 这里输入需要输出的代码,我们这里先创建了hash_map
let mut hash_map = std::collections::HashMap::<String, String>::new();
};
// 迭代目标结构体的所有字段
for field in fields {
// 创建变量存储结构体字段的名称
let identifier = field.ident.as_ref().unwrap();
// 扩展 `implementation` 代码块,完善输出代码
// 把每个字段插入至hash_map中
implementation.extend(quote!{
// 使用 `stringify!` 宏转换字段标识符为字符串作为键,
// 使用 `value.#identifier`, 这里 `#identifier` 再输出代码中会被实际字段名替换
hash_map.insert(stringify!(#identifier).to_string(), String::from(value.#identifier));
});
}
// 创建最终输出代码块
quote! {
impl From<#struct_identifier> for std::collections::HashMap<String, String> {
// 这里声明需要实现的方法,再次引用 `#struct_identifier` 变量
fn from(value: #struct_identifier) -> Self {
// 包括之前 `quote!`创建的 `implementation` 代码块 作为方法体,`quote`支持*嵌套
#implementation
// 返回 hash_map.
hash_map
}
}
}
}
// 如何目标是其他类型,直接 panic.
_ => unimplemented!()
// 使用quote类型`TokenStream`转换为 标准库的 `TokenStream`类型
}.into()
}
就是这样。你已经用Rust写了你的第一个过程宏!现在是享受劳动成果的时候了。
如何使用派生宏
回到你的 my-app/main.rs
,让我们调试打印使用实现的宏创建的哈希映射。你的main.rs代码应该是这样的:
// my-app/src/main.rs
use std::collections::HashMap;
use my_app_macros::IntoHashMap;
#[derive(IntoHashMap)]
pub struct User {
username: String,
first_name: String,
last_name: String,
}
fn main() {
let user = User {
username: "username".to_string(),
first_name: "First".to_string(),
last_name: "Last".to_string(),
};
let hash_map = HashMap::<String, String>::from(user);
dbg!(hash_map);
}
执行 cargo run ,应该可以看到终端输出为:
[src/main.rs:20:5] hash_map = {
"last_name": "Last",
"first_name": "First",
"username": "username",
}
啊哈,终于完成了,给自己点赞!