如何编写自定义 derive 宏?
解读
国内 Rust 面试中,“能写 derive 宏” 是区分“写过业务”与“写过框架”的硬指标。
面试官不会只让你背诵 #[derive(Debug)] 的原理,而是会追问:
- 如何从零新建一个 derive 宏 crate?
- 如何解析复杂结构体与枚举?
- 如何生成高效且无副作用的 TokenStream?
- 如何在编译期给出中文错误提示?
- 如何与 Cargo 工作空间、CI、单元测试无缝集成?
答不到“工程级落地”细节,很容易被判定为“只看过书”。
知识点
- proc-macro crate 类型:必须
proc-macro = true,且不能与主库混在一个 crate。 - 三件套 crate:
proc_macro:编译器只在此 crate 内提供TokenStream。syn:把TokenStream解析成可递归访问的 AST 结构体(DeriveInput、Fields、Variant等)。quote!:把 Rust 语法模板反序列化回 TokenStream,支持#插值。
- 命名约定:宏入口函数名即 derive 名,例如
#[derive(MyEncode)]对应fn my_encode。 - 属性透传:自定义属性需用
#[proc_macro_derive(MyEncode, attributes(my_attr))]声明,否则编译器直接拒绝。 - ** hygiene 与 Span**:
- 使用
quote_spanned!把错误定位到用户写的字段,而不是宏内部。 - 通过
compile_error!抛出中文且带行号的友好提示,提升国内团队排错效率。
- 使用
- 单元测试:
- 用
trybuild做编译失败断言,确保错误信息精准。 - 在
tests/下放置ui/*.rs用例,CI 直接cargo test --test ui。
- 用
- 性能陷阱:
- 避免在宏里
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.rs 用 trybuild 断言编译失败并检查中文提示。
拓展思考
- 过程宏的调试黑魔法:
在宏里直接eprintln!("{:#?}", input)会被编译器吞掉,正确姿势是cargo expand配合RUSTFLAGS="-Z unpretty=expanded",或者写 “dbg! 宏” 把 TokenStream 写入/tmp文件再cargo check。 - 与 const generic 联动:
当结构体带const N: usize时,syn解析后拿到ConstParam,可用quote!(#const_param)原样插回,实现编译期长度检查。 - 跨 crate 复用:
把MyEncodetrait 定义在独立-traitcrate,让-derivecrate 仅依赖它,避免循环依赖;国内大厂 mono-repo 常用此模式。 - IDE 友好:
在#[proc_macro_derive]上再加#[doc(hidden)] pub fn my_encode_raw() -> TokenStream暴露原始接口,rust-analyzer 可跳转,方便同事二次封装。 - 合规与安全:
生成代码若涉及unsafe,务必在文档里加 “SAFETY: 生成的 unsafe 块已验证所有前置条件”,否则国内安全审计会直接打回。