Rust过程宏系列教程 | Proc Macro Workshop 之 Debug (下篇)

314 阅读14分钟

【接 上篇

第四关

第四关视频版:www.bilibili.com/video/BV1S6…

从这一关开始,我们接触一下泛型参数的处理,有点小激动~~

在前几关中我们生成的Debug Trait的代码是没有泛型参数的,因此,对于形如下面的带有泛型参数的结构体:

struct GeekKindergarten<T> {
    blog: T,
    ideawand: i32,
    com: bool,
}

我们生成的代码应该是形如

impl<T> Debug for GeekKindergarten<T> {
    // .....
}

但由于我们在代码模板中只是用了结构体的标识符,也就是GeekKindergarten这一部分,而没有使用泛型参数信息,也就是丢掉了<T>这一部分,因此我们生成的代码会是下面这个样子:

impl Debug for GeekKindergarten {
//  ^---- 这里丢掉了泛型参数 -----^
}

第四关的提示文档里,给出了泛型参数语法树节点的链接:

这个语法树节点提供了一个工具函数,可以帮助我们把泛型参数切分成三个用于生成impl块的片段,这个函数是:

此外,他还给出了另一个示例程序的代码库地址,里面演示了如何处理泛型参数,推荐大家去看一下,不过毕竟这个链接只是纯代码,没什么讲解,所以大家还是要先读完我的文章,关注一下我的微信公众号【极客幼稚园】~ 示例项目地址:

我们重点来看一下split_for_impl()这个工具函数的用法,比如说我们有这样一个泛型结构体,泛型参数TU分别受到BlogIdeaWandCom这三个Trait Bound的限制:

struct GeekKindergarten<T, U> where T: Blog + IdeaWand, U: Com {}

那么,我们生成的Debug Trait的形式应该是下面这样的:

impl<T,U> Debug for GeekKindergarten<T, U> where T: Blog + IdeaWand + Debug, U: Com + Debug {
 // ^^^^^                           ^^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^          ^^^^^^
 //  |                                 |            |                           |   
 //  |                                 |            +---------第三部分 ----------+
 //  |                                 +--第二部分
 //  +------第一部分
}

split_for_impl()这个工具函数就是用来帮助我们生成上面这三个代码片段的,上面的限定在还出现了Debug,这个是我们要后面再手动加上去的,并不是split_for_impl()能帮我们生成的,所以我没有把他们标出来。

好了,我们总结一下第四关需要做的事情,然后给出示例代码:

  • DeriveInput语法树节点获取泛型参数信息
  • 为每一个泛型参数都添加一个Debug Trait限定
  • 使用split_for_impl()工具函数切分出用于模板生成代码的三个片段
  • 修改impl块的模板代码,使用上述三个片段,加入泛型参数信息
  • 此外由于目前generate_debug_trait()函数已经较为冗长,我们也对代码的结构进行微调,将其拆分为两个函数。

接下来上代码,首先是拆分出的新函数:

fn generate_debug_trait_core(st :&syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let fields = get_fields_from_derive_input(st)?;
    let struct_name_ident = &st.ident;
    let struct_name_literal = struct_name_ident.to_string();
    let mut fmt_body_stream = proc_macro2::TokenStream::new();

    fmt_body_stream.extend(quote!(
        fmt.debug_struct(#struct_name_literal) 
    ));
    for field in fields.iter(){
        let field_name_idnet = field.ident.as_ref().unwrap();
        let field_name_literal = field_name_idnet.to_string();
        
        let mut format_str = "{:?}".to_string();
        if let Some(format) = get_custom_format_of_field(field)? {
            format_str = format;
        } 
        // 这里是没有指定用户自定义的格式
        fmt_body_stream.extend(quote!(
            .field(#field_name_literal, &format_args!(#format_str, self.#field_name_idnet))
        ));
    }

    fmt_body_stream.extend(quote!(
        .finish()
    ));
    return Ok(fmt_body_stream)
}

然后是我们生成impl块的代码,详细阅读一下其中的注释,添加Debug Trait限定的代码一开始我也不知道怎么写,是参考了第四关提示中的heapsize项目的范例:

fn generate_debug_trait(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {

    let fmt_body_stream = generate_debug_trait_core(st)?;

    let struct_name_ident = &st.ident;

    // 从输入的派生宏语法树节点获取被修饰的输入结构体的泛型信息
    let mut generics_param_to_modify = st.generics.clone();
    // 我们需要对每一个泛型参数都添加一个`Debug` Trait限定
    for mut g in generics_param_to_modify.params.iter_mut() {
        if let syn::GenericParam::Type(t) = g {
            t.bounds.push(parse_quote!(std::fmt::Debug));
        }
    }

    // 使用工具函数把泛型抽取成3个片段
    let (impl_generics, type_generics, where_clause) = generics_param_to_modify.split_for_impl();

    let ret_stream = quote!(
        // 注意下面这一行是如何使用三个与泛型参数有关的代码片段的
        impl #impl_generics std::fmt::Debug for #struct_name_ident #type_generics #where_clause {
            fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
                #fmt_body_stream
            }
        }
    );

    return Ok(ret_stream)
}

第五关

第五关视频版:www.bilibili.com/video/BV1v4…

这一关的关卡说明非常长,信息量不少,我们来仔细看看:

首先抛出了一个问题,例如对于下面这个结构体:

pub struct GeekKindergarten<T> {
    ideawand: PhantomData<T>,
}

这个结构体中使用了PhantomData这个类型,而PhantomData类型本身在标准库中实现了Debug Trait,如下所示:

impl<T: ?Sized> Debug for PhantomData<T> {...}

在这种情况下,我们没有必要限定TDebug的。面对这类本身已经实现了Debug的泛型结构体,我们有一种应对办法,即我们在生成Trait限定时,不是针对每一个泛型参数去限制,而是对结构体中每一个字段的类型来添加限制,还是以下面这个结构体为例来说:

pub struct GeekKindergarten<T, U> {
    blog: Foo<U>,
    ideawand: PhantomData<T>,
}

原来我们代码生成的限定条件是:

  • T: Debug, U: Debug 而我们现在应该生成的是:
  • Foo<U>: Debug, PhantomData<T>: Debug

但是,这样的限定会有很大的副作用,这些副作用在后续的关卡中大家会遇到,所以,解题提示中给我们指出了另一个方法:

  • 因为PhantomData类型的使用太常见了,所以我们就把PhantomData这个类型作为一个特例,我们检测是不是有PhantomData类型的字段,如果有,我们看看它所使用的泛型参数是不是只在PhantomData中出现过,如果是,我们就不为它添加Debug限定了。
  • 在后续关卡中,我们会为PhantomData之外的情况,提供一个“逃生出口”(escape hatch),用来标记某一个字段的类型限定。

在给出上述提示之后,出题人还给我们介绍了一下Rust过程宏的一些设计上的取舍:

  • 在Rust过程宏中,你不可能获得到一个完全正确的Trait限定,这是因为假设要实现这个功能,就要在展开过程宏时做命名解析。而这样做会导致rust编译器复杂度急剧上升
  • Rust核心团队认为这样的取舍带来的收益非常大,因此没有任何计划打算在后续支持宏展开时的命名解析
  • 使用escape hatch来解决问题是一种常用手段
  • 另一种更加常见的手段是,通过Rust的Trait机制,将命名解析的执行时间推后到真正的编译阶段去处理
    • 特别注意一下本关的测试用例代码,看看过程宏的调用是如何能够在不知道S指代的是String类型的情况下,产生出可以调用String类型的DebugTrait实现的。

第五关的解题提示到这里就分析完了,在我们开始写代码之前,我们先看看下面新增代码的主要逻辑,例如有这样一个泛型结构体,则我们的过程宏的行为应该是:

struct GeekKindergarten<T, U, V, W> {
    blog: T,
    ideawand: PhantomData<U>,
    com: U,
    foo: PhantomData<V>,
    bar: Baz<W>,
}
  • 对T,由于没有出现在PhantomData中,则需要对T增加Debug限定
  • 对U,虽然出现在PhantomData中,但因为其同时直接作为com字段的类型,所以仍然需要加Debug限定
  • 对于V,满足这一关设定的特殊条件,不添加Debug限定
  • 对于W,因为其不在PhantomData的泛型参数中,所以需要加Debug限定

可以看到,想实现上面的逻辑,我们需要获取<>之前的类型名称,以及<>内部的类型名称,剩下的就是各种判断这些类型名字对应的字符串是不是满足各种组合条件了。

下面开始撸代码,先定义一个获取PhantomData泛型参数名字的函数,这个函数的作用是把PhantomData<X>里面的X作为字符串提取出来:

fn get_phantomdata_generic_type_name(field: &syn::Field) -> syn::Result<Option<String>> {
    if let syn::Type::Path(syn::TypePath{path: syn::Path{ref segments, ..}, ..}) = field.ty {
        if let Some(syn::PathSegment{ref ident, ref arguments}) = segments.last() {
            if ident == "PhantomData" {
                if let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments{args, ..}) = arguments {
                    if let Some(syn::GenericArgument::Type(syn::Type::Path( ref gp))) = args.first() {
                        if let Some(generic_ident) = gp.path.segments.first() {
                            return Ok(Some(generic_ident.ident.to_string()))
                        }
                    }
                }
            }
        }
    }
    return Ok(None)
}

然后再定义一个函数,用于把结构体定义中foo: XXXfoo:XXX<YYY>这种形式中,XXX所在位置的类型名字(即不包括泛型参数)作为字符串返回:

fn get_field_type_name(field: &syn::Field) -> syn::Result<Option<String>> {
    if let syn::Type::Path(syn::TypePath{path: syn::Path{ref segments, ..}, ..}) = field.ty {
        if let Some(syn::PathSegment{ref ident,..}) = segments.last() {
            return Ok(Some(ident.to_string()))
        }
    }
    return Ok(None)
}

然后我们来修改generate_debug_trait()函数的代码,请仔细阅读其中的注释:

fn generate_debug_trait(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {

    let fmt_body_stream = generate_debug_trait_core(st)?;

    let struct_name_ident = &st.ident;

    
    let mut generics_param_to_modify = st.generics.clone();

    // 以下代码构建两个列表,一个是`PhantomData`中使用到的泛型参数,另一个是输入结构体中所有字段的类型名称
    let fields = get_fields_from_derive_input(st)?;
    let mut field_type_names = Vec::new();
    let mut phantomdata_type_param_names = Vec::new();
    for field in fields{
        if let Some(s) = get_field_type_name(field)? {
            field_type_names.push(s);
        }
        if let Some(s) = get_phantomdata_generic_type_name(field)? {
            phantomdata_type_param_names.push(s);
        }
    }

    for mut g in generics_param_to_modify.params.iter_mut() {
        if let syn::GenericParam::Type(t) = g {
            let type_param_name = t.ident.to_string();
            // 注意这个判断条件的逻辑,精华都在这个条件里了,自己试着看看能不能把上面的4种情况整理成这个条件
            // 如果是PhantomData,就不要对泛型参数`T`本身再添加约束了,除非`T`本身也被直接使用了
            if phantomdata_type_param_names.contains(&type_param_name) && !field_type_names.contains(&type_param_name) {
                continue;
            }
            t.bounds.push(parse_quote!(std::fmt::Debug));
        }
    }

    // <省略未修改代码> ............
    
}

在结束第五关之前,我们再来回想一下出题人给我们留下的思考题:

在第五关的测试用例中,过程宏的调用是如何能够在不知道S指代的是String类型的情况下,产生出可以调用String类型的DebugTrait实现的?

其实,这个问题很简单,大家只要记住一件事,过程宏,其实就是在玩“字符串替换拼接”的游戏,在过程宏执行的时候,虽然我们把它解析成了语法树,但语法也只是一种字符串排列形式上的约束,并没有类型的概念。你只要能生成出符合rust语法的字符串排列组合即可。真正的符号消解、类型检验等等,是在后面的编译阶段完成的。

第六关

这一关展示了第五关被舍弃掉的一个解决方案所存在的问题,比较绕,有兴趣的同学可以自己看看。我们的代码不需要修改就可以通过第六关。

第七关

第七关视频版:www.bilibili.com/video/BV1Gq…

第七关我们要处理关联类型的问题。从第七关的提示里,我们了解到需要做的一个主要工作是寻找出同时满足如下要求的类型为syn::TypePath的语法树节点:

  • 其Path长度大于等于2
  • 其Path的第一项为泛型参数列表中某一个

根据Rust的语法,我们面临的关联类型可以有如下的形式:

pub trait Trait {
    type Value;    // 定义一个关联类型
}

pub struct GeekKindergarten<T:Trait> {
    blog: T::Value,
    ideawand: PhantomData<T::Value>,
    com: Foo<Bar<Baz<T::Value>>>,
}

也就是说,我们要寻找的形如T::Value的代码片段,可能嵌套在很深的地方,根据前面的经验,我们可能要写一个嵌套了几层if条件的递归函数来在整个语法树中遍历,有没有更优雅的写法呢,幸好syn库为我们提供了visit模式来访问语法树中你感兴趣的节点。

默认情况下,Visit访问模式在syn库中是没有开启的,根据syn官方文档首页中的描述,我们需要在cargo.toml里添加visit这个特性后才可以使用。所以我们首先需要更新一下cargo.toml。

Visit模式的使用说明可以参阅官方文档:docs.rs/syn/1.0.64/…

Visit模式的核心原理是,其定义了一个名为Visit的Trait,这个Trait中包含了上百个类型的语法树节点各自对应的回调函数,当其遍历语法树时,每遍历到一个类型的语法树节点,就会调用相应的回调函数。在第七关中,由于我们只希望筛选出所有syn::TypePath类型的节点,所以我们只需要实现这个节点对应的回调函数,然后在其中判断当前节点是否满足上述要求即可。大家可以看一下官方文档给出的实例,这里我就直接给出相关代码实现:

首先是Visitor的定义:

use syn::visit::{self, Visit};

// 定义一个用于实现`Visit` Trait的结构体,结构体中定义了一些字段,用于存储筛选条件以及筛选结果
struct TypePathVisitor {
    generic_type_names: Vec<String>,  // 这个是筛选条件,里面记录了所有的泛型参数的名字,例如`T`,`U`等
    associated_types: HashMap<String, Vec<syn::TypePath>>,  // 这里记录了所有满足条件的语法树节点
}

impl<'ast> Visit<'ast> for TypePathVisitor {
    // visit_type_path 这个回调函数就是我们所关心的
    fn visit_type_path(&mut self, node: &'ast syn::TypePath) {
        
        if node.path.segments.len() >= 2 {
            let generic_type_name = node.path.segments[0].ident.to_string();
            if self.generic_type_names.contains(&generic_type_name) {
                // 如果满足上面的两个筛选条件,那么就把结果存起来
                self.associated_types.entry(generic_type_name).or_insert(Vec::new()).push(node.clone());
            }
        }
        // Visit 模式要求在当前节点访问完成后,继续调用默认实现的visit方法,从而遍历到所有的
        // 必须调用这个函数,否则遍历到这个节点就不再往更深层走了
        visit::visit_type_path(self, node);
    }
}

然后是我们初始化Visitor然后执行遍历访问,最终返回筛选结果的函数:

fn get_generic_associated_types(st: &syn::DeriveInput) -> HashMap<String, Vec<syn::TypePath>> {
    // 首先构建筛选条件
    let origin_generic_param_names: Vec<String> = st.generics.params.iter().filter_map(|f| {
        if let syn::GenericParam::Type(ty) = f {
            return Some(ty.ident.to_string())
        }
        return None
    }).collect();

    
    let mut visitor = TypePathVisitor {
        generic_type_names: origin_generic_param_names,  // 用筛选条件初始化Visitor
        associated_types: HashMap::new(),
    };

    // 以st语法树节点为起点,开始Visit整个st节点的子节点
    visitor.visit_derive_input(st);
    return visitor.associated_types;
}

例如对于下面这样的关联类型和结构体:


pub trait TraitA {
    type Value1;
    type Value2;
}

pub trait TraitB {
    type Value3;
    type Value4;
}

pub struct GeekKindergarten<T: TraitA, U: TraitB> {
    blog: T::Value1,
    ideawand: PhantomData<U::Value3>,
    com: Foo<Bar<Baz<T::Value2>>>,
}

则我们上面函数将会返回这样一个结构,之所以用了一个字典,是为了后续检索方便,而字典的值又是一个列表的原因是,一个Trait里面可能有多个关联类型:

{
    "T": [T::Value1, T::Value2],
    "U": [U::Value3],
}

筛选出所有的关联类型后,我们再更新一下impl块的生成代码,与之前不同的是,对于关联类型的限定,只能放在where子句中,代码如下:

fn generate_debug_trait(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {

    // <此处省略没有修改的代码> ..........

    // 下面这一行是第七关新加的,调用函数找到关联类型信息
    let associated_types_map = get_generic_associated_types(st);
    for mut g in generics_param_to_modify.params.iter_mut() {
        if let syn::GenericParam::Type(t) = g {
            let type_param_name = t.ident.to_string();        
            
            if phantomdata_type_param_names.contains(&type_param_name) && !field_type_names.contains(&type_param_name){
                continue;
            }

            // 下面这3行是本次新加的,如果是关联类型,就不要对泛型参数`T`本身再添加约束了,除非`T`本身也被直接使用了
            if associated_types_map.contains_key(&type_param_name) && !field_type_names.contains(&type_param_name){
                continue
            }

            t.bounds.push(parse_quote!(std::fmt::Debug));
        }
    }

    // 以下6行是第七关新加的,关联类型的约束要放到where子句里
    generics_param_to_modify.make_where_clause();
    for (_, associated_types) in associated_types_map {
        for associated_type in associated_types {
            generics_param_to_modify.where_clause.as_mut().unwrap().predicates.push(parse_quote!(#associated_type:std::fmt::Debug));
        }
    }

    // <此处省略没有修改的代码> ..........
}

第八关

第八关视频版:www.bilibili.com/video/BV1vV…

这一关要实现的是之前提到的“逃生出口“(escape hatch),由于前面介绍过的Rust过程宏展开机制的缺陷,在一些边界情况下我们无法正确推断出泛型的Trait限定,这时候,我们就需要提供一个人为干预的后门。本关分为两部分,一部分是必答题,提供一个全局的干预方式,还有一个是选做题,精确到对每个字段进行控制。因为这篇文章已经很长了,所以我们就只做必答题,选做题留给大家自己去实现了。

首先是要解析一个全局的属性标签,属性标签我们已经解析过很多次了,这次就直接给大家代码了:

fn get_struct_escape_hatch(st: &syn::DeriveInput) -> Option<String> {
    if let Some(inert_attr) = st.attrs.last() {
        if let Ok(syn::Meta::List(syn::MetaList { nested, .. })) = inert_attr.parse_meta() {
            if let Some(syn::NestedMeta::Meta(syn::Meta::NameValue(path_value))) = nested.last() {
                if path_value.path.is_ident("bound") {
                    if let syn::Lit::Str(ref lit) = path_value.lit {
                        return Some(lit.value());
                    }
                }
            }
        }
    }
    None
}

然后,我们拿到了用户输入的干预指令,其实就是一小段Rust的代码,我们要把这一小段Rust代码解析为语法树的节点后插入到where子句对应的节点中。解析用户的输入可以使用syn::parse_str()这个函数来实现。好了,直接上代码:

fn generate_debug_trait(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
   
    // <此处省略没有修改的代码> ..........


    // 判定是否设置了限定条件干预,如果设定了,则不进行推断,直接使用用户给出的限定条件放到where子句中
    if let Some(hatch) = get_struct_escape_hatch(st) {
        generics_param_to_modify.make_where_clause();
        generics_param_to_modify
                    .where_clause
                    .as_mut()
                    .unwrap()
                    .predicates
                    .push(syn::parse_str(hatch.as_str()).unwrap());
    } else {
        // 原来位于此处的代码,全部移动到else分支里面,其他不变,省略 ..........
    }

    let (impl_generics, type_generics, where_clause) = generics_param_to_modify.split_for_impl();

    // <此处省略没有修改的代码> ..........


}

最后,需要承认的一点是,上面写的这些代码肯定是不严谨的,或者说是漏洞百出的。一方面是这只是为了通过测试用例,并没有充分考虑测试用例没有覆盖到的场景;另一方面,大家也应该充分认识到,Rust的过程宏就是一个复杂的“字符串拼接”过程,他没有类型校验,我们通过字符串匹配来关联一些“类型”,因此你完全可以通过构造一些冲突的命名来迷惑我们的代码。这就是Rust的过程宏,充满了Trick。