- 发布于
Rust的宏(二)
- Authors

- Name
- Charly
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就是宏的名称。 在开始实现功能之前,我们先整体梳理一下如果实现这个功能需要做哪些工作:
- 使用
syn中的DeriveInput取我们关心的数据
通过题目中的输出可以知道我们需要: 结构体的名称,Field的名称, 以及Field格式化输出的样式属性,以及Field的值。 需要注意的是 Field值需要从结构体的实例里去取
- 使用
quote!宏 为结构体实现std::fmt::Debugtrait, 在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::Attribute 的 parse_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_macro2的TokenStream需要调用一下into转为proc_macro的TokenStream。对于新手这里可能会有疑惑, 其实proc_macro2并不是proc_macro的升级版本,proc_macro2是对proc_macro的封装,实现了可以在任意上下文环境调用过程宏的api, 以及可以使过程宏支持单元测试。