如何编写自定义 derive 宏?

解读

国内 Rust 面试中,“能写 derive 宏” 是区分“写过业务”与“写过框架”的硬指标。
面试官不会只让你背诵 #[derive(Debug)] 的原理,而是会追问:

  1. 如何从零新建一个 derive 宏 crate
  2. 如何解析复杂结构体与枚举
  3. 如何生成高效且无副作用的 TokenStream
  4. 如何在编译期给出中文错误提示
  5. 如何与 Cargo 工作空间、CI、单元测试无缝集成

答不到“工程级落地”细节,很容易被判定为“只看过书”。

知识点

  1. proc-macro crate 类型:必须 proc-macro = true,且不能与主库混在一个 crate
  2. 三件套 crate
    • proc_macro:编译器只在此 crate 内提供 TokenStream
    • syn:把 TokenStream 解析成可递归访问的 AST 结构体DeriveInputFieldsVariant 等)。
    • quote!:把 Rust 语法模板反序列化回 TokenStream,支持 # 插值。
  3. 命名约定:宏入口函数名即 derive 名,例如 #[derive(MyEncode)] 对应 fn my_encode
  4. 属性透传:自定义属性需用 #[proc_macro_derive(MyEncode, attributes(my_attr))] 声明,否则编译器直接拒绝。
  5. ** hygiene 与 Span**:
    • 使用 quote_spanned! 把错误定位到用户写的字段,而不是宏内部。
    • 通过 compile_error! 抛出中文且带行号的友好提示,提升国内团队排错效率。
  6. 单元测试
    • trybuild编译失败断言,确保错误信息精准。
    • tests/ 下放置 ui/*.rs 用例,CI 直接 cargo test --test ui
  7. 性能陷阱
    • 避免在宏里 clone() 大型 TokenStream
    • syn::parse_macro_input! 一次性消费,减少二次遍历

答案

下面给出一个国内工程级示例:为每个结构体/枚举生成 MyEncode trait,支持 #[my_encode(skip)] 跳过字段,并能在编译期给出中文错误提示。

步骤 1:新建独立 crate

cargo new --lib my-encode-derive
cd my-encode-derive

Cargo.toml

[lib]
proc-macro = true

[dependencies]
syn = { version = "2.0", features = ["full", "extra-traits"] }
quote = "1.0"
proc-macro2 = "1.0"

步骤 2:入口函数

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields, Attribute, Meta};

#[proc_macro_derive(MyEncode, attributes(my_encode))]
pub fn my_encode(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;

    let expanded = match &input.data {
        Data::Struct(data) => impl_for_struct(&name, &data.fields),
        Data::Enum(data) => impl_for_enum(&name, &data.variants),
        _ => {
            return syn::Error::new_spanned(
                input,
                "MyEncode 目前仅支持 struct 与 enum"
            ).to_compile_error().into();
        }
    };
    TokenStream::from(expanded)
}

步骤 3:字段过滤与错误提示

fn has_skip(attrs: &[Attribute]) -> bool {
    attrs.iter().any(|a| {
        a.path().is_ident("my_encode") &&
        matches!(a.parse_args::<Meta>(), Ok(Meta::Path(p)) if p.is_ident("skip"))
    })
}

fn impl_for_struct(name: &syn::Ident, fields: &Fields) -> proc_macro2::TokenStream {
    let encoders = fields.iter().enumerate().filter_map(|(i, f)| {
        if has_skip(&f.attrs) { return None; }
        let member = match &f.ident {
            Some(i) => quote!(#i),
            None => {
                let idx = syn::Index::from(i);
                quote!(#idx)
            }
        };
        Some(quote! {
            self.#member.my_encode(buf)?;
        })
    });
    quote! {
        impl MyEncode for #name {
            fn my_encode(&self, buf: &mut Vec<u8>) -> Result<(), MyError> {
                #(#encoders)*
                Ok(())
            }
        }
    }
}

步骤 4:枚举支持

fn impl_for_enum(name: &syn::Ident, variants: &syn::punctuated::Punctuated<syn::Variant, _>) -> proc_macro2::TokenStream {
    let arms = variants.iter().enumerate().map(|(disc, v)| {
        if has_skip(&v.attrs) {
            return syn::Error::new_spanned(
                &v.ident,
                "枚举变体不允许使用 #[my_encode(skip)]"
            ).to_compile_error();
        }
        let vname = &v.ident;
        let pat = match &v.fields {
            Fields::Unit => quote!(#name::#vname),
            Fields::Named(_) => quote!(#name::#vname { .. }),
            Fields::Unnamed(_) => quote!(#name::#vname(..)),
        };
        quote! {
            #pat => {
                buf.push(#disc as u8);
            }
        }
    });
    quote! {
        impl MyEncode for #name {
            fn my_encode(&self, buf: &mut Vec<u8>) -> Result<(), MyError> {
                match self {
                    #(#arms)*
                }
                Ok(())
            }
        }
    }
}

步骤 5:使用端

[dependencies]
my-encode-derive = { path = "../my-encode-derive" }
use my_encode_derive::MyEncode;

#[derive(MyEncode)]
struct User {
    id: u64,
    #[my_encode(skip)]
    password: String,
}

#[derive(MyEncode)]
enum Msg {
    Login(User),
    Logout,
}

步骤 6:CI 集成

- name: 运行 UI 测试
  run: cargo test --test ui -- --nocapture

tests/ui/enum_skip_fail.rstrybuild 断言编译失败并检查中文提示。

拓展思考

  1. 过程宏的调试黑魔法
    在宏里直接 eprintln!("{:#?}", input)被编译器吞掉,正确姿势是 cargo expand 配合 RUSTFLAGS="-Z unpretty=expanded",或者写 “dbg! 宏” 把 TokenStream 写入 /tmp 文件再 cargo check
  2. 与 const generic 联动
    当结构体带 const N: usize 时,syn 解析后拿到 ConstParam,可用 quote!(#const_param) 原样插回,实现编译期长度检查
  3. 跨 crate 复用
    MyEncode trait 定义在独立 -trait crate,让 -derive crate 仅依赖它,避免循环依赖;国内大厂 mono-repo 常用此模式。
  4. IDE 友好
    #[proc_macro_derive] 上再加 #[doc(hidden)] pub fn my_encode_raw() -> TokenStream 暴露原始接口,rust-analyzer 可跳转,方便同事二次封装。
  5. 合规与安全
    生成代码若涉及 unsafe,务必在文档里加 “SAFETY: 生成的 unsafe 块已验证所有前置条件”,否则国内安全审计会直接打回。