Rust 宏魔法系列 - 派生宏

简介

宏是一种生成代码的手段,常用于 DSL 设计, 比如 jsx 本质上就是一种 DSL , 通过 babel 等工具转换成 js 再运行, 目的是为了降低编码的复杂度

派生宏 是 Rust 宏的一种, 用来于生成额外的代码, 例如 #[derive(Clone)] 里面的 Clone 就是一种派生宏, 会自动生成 impl Clone for XXX 这样的模板代码

本文通过一个例子来介绍如何编写派生宏

写一个派生宏

手写一个派生宏 Optional

将结构体的所有字段转换成可选的字段,生成一个 Optional<Struct>

例如原本的结构体如下所示

rust 复制代码
struct Picea {
    id: usize,
    subs: Option<Vec<Picea>>,
    descriptions: String,
}

期望的派生宏形式是这样的

rust 复制代码
#[derive(Optional)]
struct Picea {
    id: usize,
    subs: Option<Vec<Picea>>,
    descriptions: String,
}

加上派生宏之后,生成如下的代码

rust 复制代码
struct OptionalPicea {
    id: Option<usize>,
    subs: Option<Vec<Picea>>,
    descriptions: Option<String>,
}

还可以加上一点属性,例如 skip, rename

rust 复制代码
#[derive(Optional)]
struct Picea {
    #[optional(rename = value)]  // 属性名改成 value
    id: usize,
    subs: Option<Vec<Picea>>,
    #[optional(skip)] // 有了 skip, description  就不会被 Option
    descriptions: String,
}

会生成这样的代码

rust 复制代码
struct OptionalPicea {
    value: Option<usize>,
    subs: Option<Vec<Picea>>,
    descriptions: String,
}

开始编码

准备工作

先创建一个lib, 在 cargo.toml 里面加入

toml 复制代码
[lib]
proc-macro = true

[dependencies]
quote = "1.0.35"
syn = "2.0.52"

lib.rs 的内容替换成如下所示

rust 复制代码
use proc_macro::TokenStream;

#[proc_macro_derive(Optional, attributes(optional))]
pub fn optional(input: TokenStream) -> TokenStream {
    input.clone()
}

可以用自己编写 test 文件进行最终的测试, 或者在另外一个项目里面引入该 crate 进行测试, 下面是测试的代码

rust 复制代码
// 测试的代码
#[derive(Optional)]
struct Picea {
    #[optional(rename = value)]
    id: usize,
    subs: Option<Vec<Picea>>,
    #[optional(skip)]
    descriptions: String,
}

实现部分

所谓的 TokenStream 就是 rust 的代码,可以理解成一个数组, 每个元素都是一个树, 里面包含TokenTree, 不过我们不会直接操作 TokenStream, 一般都通过 synquote 来进行解析和展开的工作

synquote 的详细用法可以直接去看他们的文档

首先通过 syncinput 进行解析, 得到一个 DeriveInput

rust 复制代码
use syn::{parse_macro_input, DeriveInput};

    let input = parse_macro_input!(input as DeriveInput);

其中 DeriveInput 包含了 struct 的所有信息, 包括 ident(结构体名字) 和里面的字段 , 派生宏只能被用于 结构体,枚举,Union

rust 复制代码
use syn::{parse_macro_input, Data, DeriveInput};

    let ident = input.ident; // 结构体的名字, 在这里就是 "Picea"
    match input.data {
        Data::Struct(data) => {} // 结构体
        Data::Enum(data) => {} // 枚举
        Data::Union(data) => {} // Union
    };

因为这里我们只处理 Struct ,不处理其他类型, 我们可以只关心 Data::Struct, 其他情况直接报错

拼接我们要生成的结构体名, 这里生成一个新的 ident (OptionalPicea)

rust 复制代码
    let optional_struct_name = &format!("Optional{}", ident); // OptionalPicea
    let optional_struct_ident = Ident::new(optional_struct_name, ident.span());

使用 quote 进行代码展开,转换成 TokenStream

rust 复制代码
 quote::quote! {
    struct #optional_struct_ident {

    }
 }

上面这段代码会将 optional_struct_ident 替换成 OptionalPicea, 现在看一下整体的代码

rust 复制代码
use proc_macro::TokenStream;
use syn::{parse_macro_input, Data, DeriveInput, Ident};

#[proc_macro_derive(Optional, attributes(optional))]
pub fn optional(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    let ident = input.ident;

    let Data::Struct(data_struct) = input.data else {
        // 不接受除了结构体之外的类型
        return syn::Error::new(ident.span(), "optional can only be applied to structs")
            .into_compile_error()
            .into();
    };

    let optional_struct_name = &format!("Optional{}", ident); // OptionalPicea
    let optional_struct_ident = Ident::new(optional_struct_name, ident.span());

    quote::quote! {
        struct #optional_struct_ident {

        }
    }
    .into()
}

上面的代码,会为 Picea 生成一个名为OptionalPicea的结构体, 我们下面迭代该结构体的每一个属性生成新的属性

rust 复制代码
    let fields: Vec<_> = data_struct
        .fields
        .iter()
        .map(|field| {
            // 对每个字段进行映射
            let ident = &field.ident;  // 属性名
            let ty = &field.ty; // 属性的类型
            quote::quote!(
                #ident: Option<#ty>
            )
        })
        .collect();

然后将 fields 放入结构体中

rust 复制代码
    quote::quote! {
        struct #optional_struct_ident {
            #(#fields,)* 
        }
    }

可以通过 cargo-expand 检查一下生成的代码, 如下所示

rust 复制代码
struct OptionalPicea {
    id: Option<usize>,
    subs: Option<Option<Vec<Picea>>>,
    descriptions: Option<String>,
}

可以看到中间的 sub 变成了 Option<Option<Vec<Picea>>>, 我们希望已经 Option 的字段不会再 Option, 就要多做一点判断

rust 复制代码
// 判断每一个属性的类型开头是不是 Option , 如果是  Option 开头就不处理,直接返回
if let Type::Path(path) = ty {  // Option<xxxx> 是  Path 形式的 Type
    let path = &path.path;
    let is_option = path
        .segments
        .last()
        .map(|segment| segment.ident == "Option")
        .unwrap_or(false);

    if is_option {
        return quote::quote!(
            #ident: #ty
        );
    }
}

展开结果如下

rust 复制代码
struct OptionalPicea {
    id: Option<usize>,
    subs: Option<Vec<Picea>>,
    descriptions: Option<String>,
}

将代码整理一下, 整体的代码如下所示

rust 复制代码
use proc_macro::TokenStream;
use syn::{parse_macro_input, Data, DeriveInput, Ident, Type};

#[proc_macro_derive(Optional, attributes(optional))]
pub fn optional(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    let ident = input.ident;

    let Data::Struct(data_struct) = input.data else {
        // 不接受除了结构体之外的类型
        return syn::Error::new(ident.span(), "optional can only be applied to structs")
            .into_compile_error()
            .into();
    };

    let optional_struct_name = &format!("Optional{}", ident); // OptionalPicea
    let optional_struct_ident = Ident::new(optional_struct_name, ident.span());

    let fields: Vec<_> = data_struct
        .fields
        .iter()
        .map(|field| {
            // 对每个字段进行映射
            let ident = &field.ident;
            let ty = &field.ty;

            if is_ty_option(ty) {
                return quote::quote!(#ident: #ty);
            }

            quote::quote!(#ident: Option<#ty>)
        })
        .collect();

    quote::quote! {
        struct #optional_struct_ident {
            #(#fields,)*
        }
    }
    .into()
}

fn is_ty_option(ty: &Type) -> bool {
    let Type::Path(path) = ty else {
        return false;
    };
    let path = &path.path;
    path.segments
        .last()
        .map(|segment| segment.ident == "Option")
        .unwrap_or(false)
}

然后再看看 skip 字段, 我们希望标记了 #[optional(skip)] 的字段不进行 Option

在 attr 里面找到 optional 的字段

rust 复制代码
field.attrs.iter().find(|attr| attr.path().is_ident("optional"));

当为 optional(skip) 的时候, 才能跳过

rust 复制代码
attr.parse_args::<syn::Ident>()
                        .map(|ident| ident == "skip")
                        .unwrap_or(false)

这一块合并起来的逻辑为

rust 复制代码
let is_skip = field
    .attrs
    .iter()
    .find(|attr| attr.path().is_ident("optional"))
    .map(|attr| {
        attr.parse_args::<syn::Ident>()
            .map(|ident| ident == "skip")
            .unwrap_or(false)
    })
    .unwrap_or(false);

再看看展开的代码

rust 复制代码
struct OptionalPicea {
    id: Option<usize>,
    subs: Option<Vec<Picea>>,
    descriptions: String,
}

貌似是正确的, 但是把上面的代码改一改, 加入 rename 选项

rust 复制代码
#[derive(Optional)]
struct Picea {
    id: usize,
    subs: Option<Vec<Picea>>,
    #[optional(skip,rename = desc)]  // 将 rename 拼接在后面
    descriptions: String,
}

可以看到, skip 属性失效了, 这个时候, 这里我建议不要用 parse_args 进行解析了, 因为这个地方不做特殊的处理话,会连带 skip,rename = desc 一起进行解析

使用 parse_nested_meta 进行解析, 如下面的代码所示

rust 复制代码
let mut is_skip = false;

let attr = field
    .attrs
    .iter()
    .find(|attr| attr.path().is_ident("optional"));

if let Some(attr) = attr {
    let _ = attr.parse_nested_meta(|meta| {
        if meta.path.is_ident("skip") {
            is_skip = true; // 标记跳过
        } else if meta.path.is_ident("rename") {
            let renamed_ident = meta.value()?.parse::<syn::Ident>()?;
            ident = Some(renamed_ident);  // 更新  rename 的 属性
        }
        Ok(())
    });
}

得到结果如下

rust 复制代码
struct OptionalPicea {
    id: Option<usize>,
    subs: Option<Vec<Picea>>,
    desc: String,
}

最后看下整体的代码

rust 复制代码
use proc_macro::TokenStream;
use syn::{meta, parse_macro_input, Data, DeriveInput, Ident, Type};

#[proc_macro_derive(Optional, attributes(optional))]
pub fn optional(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    let ident = input.ident;

    let Data::Struct(data_struct) = input.data else {
        // 不接受除了结构体之外的类型
        return syn::Error::new(ident.span(), "optional can only be applied to structs")
            .into_compile_error()
            .into();
    };

    let optional_struct_name = &format!("Optional{}", ident); // OptionalPicea
    let optional_struct_ident = Ident::new(optional_struct_name, ident.span());

    let fields: Vec<_> = data_struct
        .fields
        .iter()
        .map(|field| {
            // 对每个字段进行映射
            let mut ident = field.ident.clone();
            let ty = &field.ty;

            let mut is_skip = false;

            let attr = field
                .attrs
                .iter()
                .find(|attr| attr.path().is_ident("optional"));

            if let Some(attr) = attr {
                let _ = attr.parse_nested_meta(|meta| {
                    if meta.path.is_ident("skip") {
                        is_skip = true;
                    } else if meta.path.is_ident("rename") {
                        let renamed_ident = meta.value()?.parse::<syn::Ident>()?;
                        ident = Some(renamed_ident);
                    }
                    Ok(())
                });
            }

            if is_skip || is_option(ty) {
                return quote::quote!(#ident: #ty);
            }

            quote::quote!(#ident: Option<#ty>)
        })
        .collect();

    quote::quote! {
        struct #optional_struct_ident {
            #(#fields,)*
        }
    }
    .into()
}

fn is_option(ty: &Type) -> bool {
    let Type::Path(path) = ty else {
        return false;
    };
    let path = &path.path;
    path.segments
        .last()
        .map(|segment| segment.ident == "Option")
        .unwrap_or(false)
}

总结

通过上面的示例, 可以掌握基本的派生宏的编写方式

你可以自己定义宏, 然后生成你想要的代码, 可以考虑下假设有生命周期和泛型的时候, 该如何编写

本文是宏魔法的第一篇, 后面会继续介绍 rust 其他的宏和它编写方式

相关推荐
gnip6 分钟前
项目开发流程之技术调用流程
前端·javascript
转转技术团队20 分钟前
多代理混战?用 PAC(Proxy Auto-Config) 优雅切换代理场景
前端·后端·面试
南囝coding21 分钟前
这几个 Vibe Coding 经验,真的建议学!
前端·后端
gnip35 分钟前
SSE技术介绍
前端·javascript
掘金安东尼43 分钟前
蔚来 600 亿研发成本,信还是不信。。
面试·程序员·github
yinke小琪1 小时前
JavaScript DOM节点操作(增删改)常用方法
前端·javascript
枣把儿1 小时前
Vercel 收购 NuxtLabs!Nuxt UI Pro 即将免费!
前端·vue.js·nuxt.js
望获linux1 小时前
【Linux基础知识系列】第四十三篇 - 基础正则表达式与 grep/sed
linux·运维·服务器·开发语言·前端·操作系统·嵌入式软件
爱编程的喵1 小时前
从XMLHttpRequest到Fetch:前端异步请求的演进之路
前端·javascript
喜欢吃豆1 小时前
深入企业内部的MCP知识(三):FastMCP工具转换(Tool Transformation)全解析:从适配到增强的工具进化指南
java·前端·人工智能·大模型·github·mcp