发布于

Rust的宏(二)

Authors
  • avatar
    Name
    Charly
    Twitter

Rust的宏(二)

前言

前一篇文章梳理了一下Rust编译管线, 分词(Token)在管线中担任的角色, 以及Rust两种宏(声明宏以及过程宏)如何以不同的方式处理TokenStream, 相信大家已经对宏有了一个初步的认识。这篇文章继续讲一下Rust过程宏中的派生宏 (Derive macros)

派生宏(derive macros)

派生宏是指可以通过#[derive] 属性为enum, struct, union 做一些通用的traits的复用,比如我们最常见到的Debug,Clone, Copy 等trait, 下面是为Point结构体添加debug trait。

#[derive(Debug)]
struct Point {
	x: i32,
	y: i32
}

其中#[derive(Debug)]中的Debug是派生宏的名称,这句话便捷的为Point结构体实现了Debug trait。当然我们也可以不用派生宏,想要实现相同的功能,那可能就要手写一遍Point struct 的 Debug trait 的实现,代码如下:

impl std::fmt::Debug for Point {
	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
		f.debug_struct("point")
		.field("x", &self.x)
		.field("y", &self.y)
		.finish()
	}
}

Rust代码中会频繁出现使用struct做数据封装,如果想实现debug功能,都要这样手写一遍Debug trait的实现,想想都让人头疼,好在我们有派生宏!

通过上一篇文章我们知道过程宏操作的是TokenStream, TokenStream 其实是对Rust语言的抽象, 就像Trait 是对行为的抽象,泛型是对类型的抽象,所有的抽象其实是为了更好的做代码维护和复用。

派生宏实践

实践派生宏 还是使用 dtolnay 大神分享 proc-macro-workshop 库里的CustomDebug 题目, 描述如下: 实现派生宏CustomDebug,和标准库中Debug类似,特殊之处是可以为结构体内的属性(field)做字符串格式化样式说明。

use derive_debug::CustomDebug;

#[derive(CustomDebug)]
pub struct Field {
    name: String,
    #[debug = "0b{:08b}"]
    bitmask: u8,
}
// 打印后输出样式
// Field { name: "st0", bitmask: 0b00011100 }

#[debug = "0b{:08b}"] 称作派生宏辅助属性(Derive macro helper attributes),可以为它后面的Field添加额外信息,在实现派生宏的时候,获取这个信息做特殊处理

#[debug = "ob{:08b}"] 这种属性的写法是 MetaNameValueStr 样式,当然还有其他书写方式比如:#[link(name = "CoreFoundation", kind = "framework")]MetaListNameValueStr样式, 还有其他样式不在这里列举,可以参考The Rust Reference

我们先在lib.rs中定义一下过程宏函数(如果读者已经clone proc-macro-workshop 库,可以在 debug 目录lib.rs查看已经定义好的初始结构)代码如下:

#[proc_macro_derive(CustomDebug)]
pub fn derive(input: TokenStream) -> TokenStream {
	TokenStream::default()
}

proc_macro_derive 是派生宏的固定标识,后面括号中的CustomDebug就是宏的名称。 在开始实现功能之前,我们先整体梳理一下如果实现这个功能需要做哪些工作:

  1. 使用syn 中的DeriveInput取我们关心的数据

通过题目中的输出可以知道我们需要: 结构体的名称,Field的名称, 以及Field格式化输出的样式属性,以及Field的值。 需要注意的是 Field值需要从结构体的实例里去取

  1. 使用quote! 宏 为结构体实现std::fmt::Debug trait, 在 fmt 方法中取Field的值,然后拼接格式化输出字符串

先把我们关心的数据放到DebugContext结构体里, 由于结构体不止有一个Field,为了方便管理,为Field也定义了一个Fd结构体, 代码如下:

#[derive(Debug)]
struct Fd {
	// field 属性
    attr_val: Option<String>,
	// field 名称
    ident: Option<Ident>,
}
#[derive(Debug)]
pub struct DebugContext {
	// 结构体名称
    name: Ident,
    fields: Vec<Fd>,
}

然后为DebugContext添加new方法用来分析DeriveInput并做自身实例化

impl DebugContext {
	pub fn new (derive_input: &DeriveInput) -> Self {

	}
}

DeriveInput是通过对TokenStream 分析生成的对 Struct, Enum, Union等Derive类型的抽象(或者说数据结构),可以类比TokenStream就像一串字符,具体这个字符是如何组成单词和谁组成单词是syn 帮我们处理的。而DeriveInput就是syn为我们生成的“单词”

接下来从DeriveInput里读取我们关心的数据(结构体名称,Fields, 以及每个Field的名称和属性信息)

impl DebugContext {
    pub fn new (derive_input: &DeriveInput) -> Self {
        let name = &derive_input.ident;
        let fields = if let DeriveInput { data: Data::Struct(DataStruct{ fields: Fields::Named(FieldsNamed { named, ..}), ..}) ,..} = derive_input {
            named
        } else {
            panic!("can not get field")
        };
	}
}

这时我们已经获取了Field的集合 fields 变量它是&Punctuated<Field, Comma> 类型,(Punctuated结构相关可以参考接口文档)通过遍历fields 我们可以获取题目结构体的所有field信息, Field结构如下:

pub struct Field {
    pub attrs: Vec<Attribute>,
    pub vis: Visibility,
    pub ident: Option<Ident>,
    pub colon_token: Option<Colon>,
    pub ty: Type,
}

其中 attrs, ident 存储的是当前field 的属性, 名称。这时我们需要的数据基本都有了,下面可以对new 方法进一步完善一下:

impl DebugContext {
    pub fn new (derive_input: &DeriveInput) -> Self {
		...
        let fds = fields.iter().map(|f| {
            let mut debug_value:Option<String> = None;
            if f.attrs.len() > 0 {
                debug_value = if let Ok(str) = get_debug_value(f) {
                    Some(str)
                } else {
                    None
                };
            }
            Fd {
                attr_val: debug_value,
                ident: f.ident.to_owned()
            }
        }).collect();
    
        DebugContext {
            name: name.to_owned(),
            fields: fds
        }
    }
}

接下来还需要实现一下get_debug_value 函数,输入field 获取当前field 的属性值。解析MetaNameValueStr样式的属性可以直接使用syn::Attributeparse_meta 方法。 代码如下:

fn get_debug_value(field: &Field) -> Result<String, &str> {
    if field.attrs.len() <= 0 {
        return Err("The field's attr is Empty");
    }
    match field.attrs[0].parse_meta() {
        Ok(syn::Meta::NameValue(MetaNameValue { lit: Lit::Str(lit_str), ..})) => {Ok(lit_str.value())},
        _ => Err("Not support meta type"),
    }
}

上面的代码写得不够严谨没有考虑多个不同attrs的情况,直接取的field.attrs[0] 在这里只是为了抛转引玉。另外处理属性 darling 是一个不错的库。

接下来为DebugContext实现一个render方法, 通过quote!为结构体实现 Debug trait。

    pub fn render (&self) -> TokenStream {
        let struct_name = &self.name;
        let fields = &self.fields;
        // 把field 组装成格式化时需要的结构 fieldname1: {} 或者 fieldName2:{attr_value}
        let format_strs: Vec<String> = fields.iter().map(|f| {
            if let Fd {attr_val: Some(d), .. } = f {
                return format!("{}: {}", f.ident.as_ref().unwrap(), d);
            } else {
                return format!("{}: {}", f.ident.as_ref().unwrap(), "{}");
            }
        }).collect();
        // 获取所有field名称 为quote! 处理方便遍历
        let format_v_key: Vec<&Ident> = fields.iter().map(|f| {
            return f.ident.as_ref().unwrap();
        }).collect();
        // 组装最后需要字符串结构
        let format_str = format!("{} {{{{ {} }}}}", struct_name, format_strs.join(" "));

		quote! {
            impl std::fmt::Debug for #struct_name {
                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                    std::write!(f, #format_str, #(self.#format_v_key),*)
                }
            }
		}
    }

一开始想在quote!里遍历fields组装需要格式化的field, 但是编译器不允许的。所以在实现quote!语句块的时候,我们把数据转为直接可以让它拿来用的 变量或集合,这样在quote! 语句块里直接通过# 或者#()*来使用变量或集合。

格式化输出结构体的时候由于需要打印大括号,在使用format!write! 时都需要用两层大括号转义一下, 所以在调用 format! 时使用了四层大括号

到此DebugContext结构体已经准备好了(为了代码的可读性和lib.rs 文件简洁,建议把DebugContext写到独立的文件里),下面我们在派生宏的函数里做一下引入 代码如下:

// 追加辅助属性
#[proc_macro_derive(CustomDebug, attributes(debug))]
pub fn derive(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let context = DebugContext::new(&input);
    // proc_macro2 to proc_macro
    context.render().into()
}

由于CustomDebug 派生宏支持辅助属性,所以在定义的时候还要追加一下attributes(debug) 不然的话编译器是不会识别debug属性。

值得注意的是quote!宏返回的是 proc_macro2TokenStream 需要调用一下into 转为proc_macroTokenStream。对于新手这里可能会有疑惑, 其实proc_macro2 并不是 proc_macro 的升级版本, proc_macro2是对proc_macro 的封装,实现了可以在任意上下文环境调用过程宏的api, 以及可以使过程宏支持单元测试。

参考文档

Module std::fmt proc_macro2