当前位置:嗨网首页>书籍在线阅读

14-派生宏

  
选择背景色: 黄橙 洋红 淡粉 水蓝 草绿 白色 选择字体: 宋体 黑体 微软雅黑 楷体 选择字体大小: 恢复默认

9.12 派生宏

我们已经知道可以在任何结构体、枚举或联合类型上编写#[derive(Copy, Debug)]属性来获取为其实现的Copy和Debug特征,但是这个自动派生特性仅适用于几个内置在编译器中的特征。对派生宏或者宏 1.1 来说,你可以在任何结构体、枚举或联合类型上派生用户自定义特征,从而减少开发者手动编写模板代码的数量。这可能看上去是一个不错的用例,但过程宏是最常见的应用形式,其中诸如serde和diesel这样高性能的程序库都采用了它。派生宏仅适用于结构体、枚举及联合等数据类型。创建用于在类型上实现特征的自定义派生宏需要以下步骤。

1.首先,需要你的类型和要在类型上实现的特征。它们可以来自任何程序库,既可以是本地定义的,也可以是第三方软件包,前提是其中一个程序库必须由你来定义,因为它需要遵循孤儿规则。

2.然后,我们需要通过Cargo.toml文件创建一个新的软件包,其中的proc-macro属性需要设置为true。这样做是因为根据当前的实现,过程宏必须存在于它们自己的程序库中。不过,这种作为单独程序库的隔离措施在将来可能会发生变化。

3.接下来,在程序库内部,我们需要创建一个用proc_macro_derive属性注释的函数。对于proc_macro_derive 属性,我们将特征名称Foo作为参数传递。当我们在任何结构体、枚举及联合上编写#[derive(Foo)]属性时,会调用此函数。

140.png 提示

仅允许将具有proc_macro_derive属性的函数从此程序库中导出。

但是,我们在实际的代码中看到它之前,所有这些都有些模糊。所以,我们可构建一个自己的派生宏程序库。我们将要构建的宏能够把任何给定的结构体转换为键和值的动态映射,例如BTreeMap<String, String>。选择BTreeMap只是为了获得对其中字段的有序迭代访问,而HashMap不支持这一点,不过你也可以使用HashMap。

我们还将使用两个软件包,即syn和quote,这允许我们将代码解析为一种简便的数据结构,以便我们检查和操作。我们将为这个项目创建3个软件包。首先,我们通过运行cargo new into_map_demo命令创建一个二进制软件包,它会调用我们的程序库软件包和派生宏软件包。以下是我们的Cargo.toml文件中的依赖项:

# into_map_demo/Cargo.toml
[dependencies]
into_map = { path = "into_map" }
into_map_derive = { path = "into_map_derive" }

上述into_map和into_map_derive软件包被指定为该软件包的本地路径依赖项。但是我们还没有这两个软件包,所以让我们运行下列命令在同一目录下创建它们。

  • cargo new into_map:这个软件包将作为独立的库程序引入我们的特征中。
  • cargo new into_map_derive:这是我们的派生宏软件包。

现在,让我们检查一下main.rs,其中包含以下初始代码:

// into_map_demo/src/main.rs
use into_map_derive::IntoMap;
#[derive(IntoMap)]
struct User {
    name: String,
    id: usize,
    active: bool
}
fn main() {
    let my_bar = User { name: "Alice".to_string(), id: 35, active: false };
    let map = my_bar.into_map();
    println!("{:?}", map);
}

在上述代码中,我们的User结构体使用了#[derive(IntoMap)]属性。#[derive(IntoMap)]将调用来自into_map_derive软件包的过程宏。由于我们尚未实现IntoMap派生宏,因此无法编译代码。但是,这从用户的角度向我们展示了该如何使用该宏。接下来,让我们看看into_map软件包中的lib.rs文件中有些什么:

// into_map_demo/into_map/src/lib.rs
use std::collections::BTreeMap;
pub trait IntoMap {
    fn into_map(&self) -> BTreeMap<String, String>;
}

我们的lib.rs文件中只包含一个IntoMap特征定义,其中包含一个名为into_map的方法,该方法将一个self引用作为参数,并返回一个BTreeMap<String, String>。我们希望通过派生宏为我们的User结构体派生IntoMap特征。

接下来让我们看看into_map_derive软件包。对于该软件包,我们的Cargo.toml文件指定了如下依赖项:

# into_map_demo/into_map_derive/src/Cargo.toml
[lib]
proc-macro = true
[dependencies]
syn = { version = "0.15.22", features = ["extra-traits"] }
quote = "0.6.10"
into_map = { path="../into_map" }

如前所述,我们在[lib]部分将proc-macro属性设置为true。同时还用到了syn和quote软件包,因为它们可以帮助我们解析标记流实例中的Rust代码。syn软件包创建一个名为AST的内存数据结构,它表示一段Rust代码。然后我们可以使用此结构来检查源代码,并以编程方式提取信息。quote软件包是syn软件包的有益补充,它允许用户在其中的quote!中生成Rust代码,还允许用户替换syn数据类型中的值。我们还需要用到into_map软件包,通过它,我们将Into_Map特征引入宏定义的作用域。

我们希望此宏生成的代码如下所示:

impl IntoMap for User {
    fn into_map(&self) -> BTreeMap<String, String> {
        let mut map = BTreeMap::new();
        map.insert("name".to_string(), self.name.to_string());
        map.insert("id".to_string(), self.id.to_string());
        map.insert("active".to_string(), self.active.to_string());
        map
    }
}

我们想在User结构体上实现into_map方法,不过希望它为我们自动实现该方法。对于包含大量字段的结构体,手动构造代码将会非常烦琐。在这种情况下,派生宏将会非常有用。接下来让我们考察一个实现。

在较高层面,into_map_derive软件包的代码生成分为两个阶段。在第一阶段,我们遍历结构体的字段,并收集用于将元素插入BTreeMap的代码。生成的插入代码标记如下所示:

map.insert(field_name, field_value);

这将被收集到一个vector中。在第二阶段,我们获取所有生成的插入代码标记,并将它们扩展为另一个标记序列,这就是User结构体的impl代码块。

让我们首先来看看lib.rs中的实现:

// into_map_demo/into_map_derive/src/lib.rs
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields};
#[proc_macro_derive(IntoMap)]
pub fn into_map_derive(input: TokenStream) -> TokenStream {
    let mut insert_tokens = vec![];
    let parsed_input: DeriveInput = parse_macro_input!(input);
    let struct_name = parsed_input.ident;
    match parsed_input.data {
        Data::Struct(s) => {
            if let Fields::Named(named_fields) = s.fields {
                let a = named_fields.named;
                for i in a {
                let field = i.ident.unwrap();
                let insert_token = quote! {
                    map.insert(
                        stringify!(#field).to_string(),
                        self.#field.to_string()
                    );
                };
                insert_tokens.push(insert_token);
            }
        }
    }
    other => panic!("IntoMap is not yet implemented for: {:?}", other),
}

上述代码看上去有点奇怪,让我们逐行对它们进行解释。首先,我们有附带#[proc_ macro_derive(IntoMap)]属性的into_map_derive函数。不过我们可以给该函数指定其他名称。该函数接收标记流作为输入,这将是我们的User结构体声明。然后我们创建了insert_tokens列表,用于存储输入标记,这是实际代码生成的部分内容。稍后我们将会对它进行解释。

然后我们调用来自syn软件包的parse_macro_input!宏,传递输入标记流。这使我们在parsed_input变量中获得一个DeriveInput实例。parsed_input将我们的User结构体定义为标记数据结构。从那里,我们使用parsed_input.ident字段提取结构体名称。接下来,我们匹配parsed_input.data字段中的内容,该字段返回元素的种类:结构体、枚举或联合。

为了简化我们的实现,我们只为结构体实现了IntoMap特征,所以只有当我们的parsed_ input.data字段值属于Data::Struct(s)时才会匹配成功。其中的s表示一个结构体,Data::Struct(s)表示构成结构体定义的元素。我们对s包含哪些字段感兴趣,特别是一些具名字段,因此我们可以使用if let语句对其进行特定的匹配。在if代码块内,我们获得对该结构体所有字段的引用,然后遍历访问它们。对于每个字段,我们使用quote软件包中的quote!宏为我们的btree映射生成插入代码:

map.insert(
    stringify!(#field).to_string(),
    self.#field.to_string()
);
insert_tokens.push(insert_token);

注意#field符号,在quote!宏中,我们可以在生成的代码中使用模板变量的值来替换它们。在这种情况下,#field会被结构体中存在的任何字段替换。首先,我们使用stringify!宏将#field转换为字符串文字,它是syn软件包中的Ident类型。然后我们可以将这个生成的代码块推送到insert_tokens中。

接下来,我们将进入代码生成的最后阶段:

    let tokens = quote! {
        use std::collections::BTreeMap;
        use into_map::IntoMap;
        impl IntoMap for #struct_name {
            /// 将给定结构体转换为动态映射
            fn into_map(&self) -> BTreeMap<String, String> {
                let mut map = BTreeMap::new();
                #(#insert_tokens)*
                map
            }
        }
    };
    proc_macro::TokenStream::from(tokens)
}

在这里,我们为结构体生成了最终的impl代码块。在quote!宏内部,无论我们编写什么都会按照规则精确地生成,包括缩进和代码注释。首先,我们对BTreeMap类型和IntoMap特征进行导入。其次,我们有了IntoMap实现。在其中,我们创建相关的映射,并扩展了在代码生成的第一阶段收集的insert_tokens列表。在这里,外部的重复器#()*告知quote!宏重复相同的代码零次或多次。对于可迭代访问的元素,例如insert_tokens,将重复其中的所有元素,这将用于将结构体中的字段名称和字段值插入映射中的代码。最后,我们将整个实现代码存储到tokens变量中,并通过调用TokenStream::from(tokens)将其作为标记流返回。让我们在main.rs中尝试调用该宏:

// into_map_demo/src/main.rs
use into_map_derive::IntoMap;
#[derive(IntoMap)]
struct User {
    name: String,
    id: usize,
    active: bool
}
fn main() {
    let my_bar = User { name: "Alice".to_string(), id: 35, active: false };
    let map = my_bar.into_map();
    println!("{:?}", map);
}

运行cargo run命令后,我们得到以下输出结果:

{"active": "false", "id": "35", "name": "Alice"}

由上述结果可知,该宏能够按照预期运行。接下来让我们看看该如何调试宏。