Appearance
第 9 章 从零实现第一个 derive 宏:#[derive(Builder)]
9.1 为什么要自己写一个 derive 宏
前四章(第 5-8 章)讲了过程宏的全部工具——宏系统概念、TokenStream、syn、quote。但读完再多理论,不如动手写一次。本章我们从零实现一个完整可工作的 #[derive(Builder)] 宏。
Builder 模式是个经典设计——把一个"有很多字段(有的必需、有的可选)"的结构体的构造过程,拆成一个链式调用:
rust
// 没有 Builder 的做法
let cmd = Command {
executable: "cargo".to_string(),
args: vec!["build".to_string(), "--release".to_string()],
env: vec![("CARGO_HOME".to_string(), "/opt/cargo".to_string())],
current_dir: Some("/tmp".to_string()),
};
// 有 Builder 的做法
let cmd = Command::builder()
.executable("cargo".to_string())
.args(vec!["build".to_string(), "--release".to_string()])
.env(vec![("CARGO_HOME".to_string(), "/opt/cargo".to_string())])
.current_dir("/tmp".to_string())
.build()
.unwrap();Builder 版本的好处是可读性强、字段可选可省略、构造过程可配置。缺点是每个字段都要手写一个 setter 方法——如果 Command 有 15 个字段,就要写 15 个 setter,样板代码量恐怖。
这正是 derive 宏的典型用武之地。用户写:
rust
#[derive(Builder)]
pub struct Command {
executable: String,
args: Vec<String>,
env: Vec<(String, String)>,
current_dir: Option<String>,
}宏自动生成:
impl Command { pub fn builder() -> CommandBuilder { ... } }pub struct CommandBuilder { ... }(所有字段都Option<T>)impl CommandBuilder { pub fn executable(&mut self, ...) -> &mut Self { ... } ... }(每个字段一个 setter)impl CommandBuilder { pub fn build(&mut self) -> Result<Command, Error> { ... } }
这是一个 100+ 行样板代码 的生成任务。写出来,你就掌握了过程宏的生产级使用。
这道题是 dtolnay 的 proc-macro-workshop 第一题。workshop 是 Rust 社区公认的过程宏练习库,题目从简单 Builder 到复杂 Debug,覆盖过程宏的所有常见场景。做完 Builder,读 serde_derive 源码会顺畅很多。
9.2 项目结构
过程宏必须在独立 crate。我们建一个工作空间:
bash
mkdir builder-demo && cd builder-demo
cargo new --lib my-builder-derive # proc-macro crate
cargo new --bin test-app # 使用者my-builder-derive/Cargo.toml:
toml
[package]
name = "my-builder-derive"
version = "0.1.0"
edition = "2021"
[lib]
proc-macro = true # ← 关键,标记为 proc-macro crate
[dependencies]
proc-macro2 = "1.0"
quote = "1.0"
syn = { version = "2.0", features = ["full", "extra-traits"] }注意 syn 的 features:
full:启用所有 AST 类型(不只是 derive)extra-traits:给 AST 类型加Debug/PartialEq,便于调试
test-app/Cargo.toml:
toml
[dependencies]
my-builder-derive = { path = "../my-builder-derive" }test-app/src/main.rs(测试用例):
rust
use my_builder_derive::Builder;
#[derive(Builder)]
pub struct Command {
executable: String,
args: Vec<String>,
current_dir: Option<String>,
}
fn main() {
let cmd = Command::builder()
.executable("cargo".to_string())
.args(vec!["build".to_string()])
.current_dir("/tmp".to_string())
.build()
.unwrap();
println!("{}", cmd.executable);
}我们要让这个 main.rs 编译并运行成功。
9.3 Step 1:骨架——生成一个空的 builder
从最小可运行开始。目标:Command::builder() 函数存在、编译通过。
my-builder-derive/src/lib.rs:
rust
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Builder)]
pub fn derive_builder(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let output = quote! {
impl #name {
pub fn builder() {}
}
};
output.into()
}这份代码生成:
rust
impl Command {
pub fn builder() {}
}Command::builder() 存在了,但返回 (),没法链式调用。先让编译器接受这一步——test-app 目前的测试会失败(因为 builder() 返回空),但 crate 本身编译通过,证明我们的过程宏基础结构 OK。
测试它的最小方式:
rust
// test-app/src/main.rs 暂时改成
use my_builder_derive::Builder;
#[derive(Builder)]
pub struct Command {
executable: String,
}
fn main() {
Command::builder(); // 这一行能跑,就说明宏起作用了
}9.4 Step 2:生成 Builder 结构体
下一步:生成 CommandBuilder 结构体,所有字段都是 Option<T>。
rust
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use syn::{parse_macro_input, Data, DeriveInput, Fields, Type};
#[proc_macro_derive(Builder)]
pub fn derive_builder(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let builder_name = format_ident!("{}Builder", name);
// 提取字段列表
let fields = match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(fields) => &fields.named,
_ => panic!("only named fields supported"),
},
_ => panic!("only struct supported"),
};
// 为 builder 结构体的每个字段生成 "name: Option<Type>"
let builder_fields = fields.iter().map(|f| {
let name = &f.ident;
let ty = &f.ty;
quote! { #name: std::option::Option<#ty> }
});
// 为 builder() 方法初始化时,每个字段都是 None
let builder_init = fields.iter().map(|f| {
let name = &f.ident;
quote! { #name: std::option::Option::None }
});
let output = quote! {
pub struct #builder_name {
#( #builder_fields, )*
}
impl #name {
pub fn builder() -> #builder_name {
#builder_name {
#( #builder_init, )*
}
}
}
};
output.into()
}解读:
format_ident!("{}Builder", name)拼出CommandBuilder。- 用
match提取Data::Struct和Fields::Named,拿到字段列表。 fields.iter().map(...)对每个字段生成一段代码片段。这些片段是TokenStream,在外层 quote! 里用#( ... )*展开。- 生成的 Builder 结构体的每个字段都是
Option<原类型>——因为 builder 构建过程中字段可能还没设置。
现在 test-app 可以这样用:
rust
let b = Command::builder();
// b 是 CommandBuilder,每个字段都是 None但还不能链式调用——setter 还没有。
细节 1:为什么用 std::option::Option::None 而不是 None? 如果用户代码 use 了自己的叫 None 的东西(不太可能但合法),None 可能指向用户的东西。用全路径 std::option::Option::None 规避。这是过程宏的 hygiene 最佳实践——所有对标准库类型的引用都用全路径。Serde 里你会看到大量 _serde::Serializer::serialize_str(...) 而不是 serializer.serialize_str(...)——同一个目的。
细节 2:#( ... )* 里的变量是什么? builder_fields 和 builder_init 都是迭代器。quote 把它们展开成 N 段代码片段(每个字段一段),段之间用 , 分隔。
9.5 Step 3:生成 setter 方法
现在为每个字段生成一个 setter:
rust
impl CommandBuilder {
pub fn executable(&mut self, executable: String) -> &mut Self {
self.executable = Some(executable);
self
}
pub fn args(&mut self, args: Vec<String>) -> &mut Self {
self.args = Some(args);
self
}
// ...
}加到宏里:
rust
// ... 前面不变
// 为每个字段生成一个 setter
let setters = fields.iter().map(|f| {
let name = &f.ident;
let ty = &f.ty;
quote! {
pub fn #name(&mut self, #name: #ty) -> &mut Self {
self.#name = std::option::Option::Some(#name);
self
}
}
});
let output = quote! {
pub struct #builder_name {
#( #builder_fields, )*
}
impl #name {
pub fn builder() -> #builder_name {
#builder_name {
#( #builder_init, )*
}
}
}
impl #builder_name {
#( #setters )*
}
};测试:
rust
let cmd = Command::builder()
.executable("cargo".to_string())
.args(vec!["build".to_string()])
.current_dir("/tmp".to_string());
// cmd 现在是 &mut CommandBuilder,所有字段都被设置了可以链式调用了。但还不能 .build() 返回 Command。
9.6 Step 4:生成 build() 方法
rust
let build_fields = fields.iter().map(|f| {
let name = &f.ident;
let err_msg = format!("field `{}` is required", name.as_ref().unwrap());
quote! {
#name: self.#name.take().ok_or_else(|| #err_msg.to_string())?
}
});
let output = quote! {
// ... 前面的代码
impl #builder_name {
#( #setters )*
pub fn build(&mut self) -> std::result::Result<#name, std::string::String> {
std::result::Result::Ok(#name {
#( #build_fields, )*
})
}
}
};现在 build() 可以工作了:
rust
let cmd = Command::builder()
.executable("cargo".to_string())
.args(vec!["build".to_string()])
.current_dir("/tmp".to_string())
.build()
.unwrap();但我们的 Command 有个问题——current_dir 是 Option<String>,对应到 builder 里是 Option<Option<String>>,生成的 setter 签名是 fn current_dir(&mut self, current_dir: Option<String>) -> &mut Self——用户必须传 Some("/tmp".to_string()) 才能用。这不合理——Option 字段本身就代表"可选",用户应该能:
rust
.current_dir("/tmp".to_string()) // 直接传 String
// 或者不调用这个 setter(字段自动是 None)下一步:区分 Option<T> 字段。
9.7 Step 5:特殊处理 Option<T>
对 Option<T> 类型的字段,有两处要变:
- builder 结构体字段类型:不要变成
Option<Option<T>>,保持Option<T>。 - setter 签名:接受
T而不是Option<T>;内部包装成Some(T)。 - build() 时:不要要求 Option 字段必须设置;直接取值(已经是 Option)。
核心是识别 Option<T>。在 syn 层面,Option<T> 是一个 Type::Path,path 最后一段的 ident 是 Option,arguments 是 AngleBracketed([T])。
写一个辅助函数:
rust
fn extract_option_inner(ty: &Type) -> Option<&Type> {
use syn::{GenericArgument, PathArguments, PathSegment};
let path = if let Type::Path(p) = ty {
&p.path
} else {
return None;
};
// 简化:只匹配直接的 Option<T>,不处理 std::option::Option<T>
let last_segment = path.segments.last()?;
if last_segment.ident != "Option" {
return None;
}
let args = if let PathArguments::AngleBracketed(args) = &last_segment.arguments {
&args.args
} else {
return None;
};
if args.len() != 1 {
return None;
}
if let Some(GenericArgument::Type(inner)) = args.first() {
Some(inner)
} else {
None
}
}用这个函数改造宏:
rust
let builder_fields = fields.iter().map(|f| {
let name = &f.ident;
let ty = &f.ty;
// 如果已经是 Option<T>,保持;否则包装成 Option<T>
if extract_option_inner(ty).is_some() {
quote! { #name: #ty }
} else {
quote! { #name: std::option::Option<#ty> }
}
});
let setters = fields.iter().map(|f| {
let name = &f.ident;
let ty = &f.ty;
if let Some(inner) = extract_option_inner(ty) {
// Option<T> 字段:setter 接受 T
quote! {
pub fn #name(&mut self, #name: #inner) -> &mut Self {
self.#name = std::option::Option::Some(#name);
self
}
}
} else {
// 普通字段:setter 接受 T
quote! {
pub fn #name(&mut self, #name: #ty) -> &mut Self {
self.#name = std::option::Option::Some(#name);
self
}
}
}
});
let build_fields = fields.iter().map(|f| {
let name = &f.ident;
if extract_option_inner(&f.ty).is_some() {
// Option 字段:直接取,不报错
quote! { #name: self.#name.take() }
} else {
// 必需字段:没设置则报错
let err_msg = format!("field `{}` is required", name.as_ref().unwrap());
quote! {
#name: self.#name.take().ok_or_else(|| #err_msg.to_string())?
}
}
});现在 current_dir: Option<String> 字段:
- builder 里类型是
Option<String>(不是Option<Option<String>>) - setter 签名是
fn current_dir(&mut self, current_dir: String) -> &mut Self - build() 时
current_dir: self.current_dir.take()(如果用户没调 setter,就是 None,不报错)
这就是 proc-macro-workshop Builder 题的 test 04 部分——Option 字段的特殊处理。
9.7.1 extract_single_generic:一个隐蔽的 path 匹配陷阱
9.10 节的完整版代码把 extract_option_inner 合并进了 extract_single_generic(ty, "Option")。看似更通用,其实有一个陷阱:
rust
fn extract_single_generic<'a>(ty: &'a Type, wrapper: &str) -> Option<&'a Type> {
let Type::Path(p) = ty else { return None };
let seg = p.path.segments.last()?; // ← 只看最后一段
if seg.ident != wrapper { return None; }
// ...
}为什么 p.path.segments.last()?因为 Rust 允许用户写下列全部三种等价形式:
rust
a: Option<String> // last == "Option"
b: std::option::Option<String> // last == "Option",前面有 std::option::
c: core::option::Option<String> // last == "Option",前面有 core::option::只看 segments[0] 会错过 (b) 和 (c);看完整 path 又要处理三种变体。取 last 的默认假设是"用户不会在自己的 crate 里定义一个叫 Option 的类型"——这个假设 99.9% 成立,但也不是 100%。proc-macro-workshop Builder 题里用 field: option::Option<u32>(不用 use)测试时,这份简化实现还是能识别的,但万一用户写了个 mod my; struct Option<T>(T);,extract_single_generic 会把它误判成标准 Option——然后 build() 时生成的 self.#name.take() 会因为用户的 Option 没有 take 方法而编译失败。
这种"默认假设的边界"正是过程宏开发的典型痛点。serde_derive 处理这个问题的方式是干脆不识别 Option——Serialize/Deserialize 根本不关心字段是不是 Option,它走 trait 派发;Option 类型由 Option::serialize 的实现决定。不识别反而不会出错——这是一种"避开陷阱而不是修 bug"的高级设计选择。
9.8 Step 6:用属性实现"每次追加"setter
现实中还有一类场景:args: Vec<String> 字段,用户想一次追加一个:
rust
Command::builder()
.arg("build".to_string()) // 一次加一个
.arg("--release".to_string())
.arg("--verbose".to_string())
.build()而不是:
rust
.args(vec!["build".to_string(), "--release".to_string(), "--verbose".to_string()])用户用属性声明:
rust
#[derive(Builder)]
pub struct Command {
executable: String,
#[builder(each = "arg")] // ← 新属性
args: Vec<String>,
}这就进入了属性宏的领域——需要解析 #[builder(each = "arg")] 的参数。
Step 1:声明属性。
#[proc_macro_derive(Builder)] 默认不允许任何自定义属性——使用者会被编译器报错"unknown attribute"。要声明允许的属性:
rust
#[proc_macro_derive(Builder, attributes(builder))]加了 attributes(builder) 后,编译器接受 #[builder(...)] 属性,并把它留在 input.attrs 和各 field 的 attrs 里。
Step 2:解析属性。
rust
use syn::{Attribute, LitStr, Meta};
fn find_each_attr(attrs: &[Attribute]) -> syn::Result<Option<String>> {
for attr in attrs {
if !attr.path().is_ident("builder") {
continue;
}
// #[builder(each = "arg")]
let mut result = None;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("each") {
let value = meta.value()?; // 取 = 后的值
let lit: LitStr = value.parse()?; // 解析成字符串字面量
result = Some(lit.value());
} else {
return Err(meta.error("expected `builder(each = \"...\")`"));
}
Ok(())
})?;
return Ok(result);
}
Ok(None)
}Step 3:根据属性生成"追加式"setter。
如果字段有 #[builder(each = "arg")] 且类型是 Vec<T>,生成:
rust
pub fn arg(&mut self, arg: T) -> &mut Self {
self.args.get_or_insert_with(Vec::new).push(arg);
self
}整合到宏里:
rust
let setters = fields.iter().map(|f| -> syn::Result<TokenStream2> {
let name = f.ident.as_ref().unwrap();
let ty = &f.ty;
let each = find_each_attr(&f.attrs)?;
if let Some(each_name) = each {
// #[builder(each = "...")]:生成追加式 setter
let inner_ty = extract_vec_inner(ty)?; // 提取 Vec<T> 的 T
let each_ident = format_ident!("{}", each_name);
Ok(quote! {
pub fn #each_ident(&mut self, #each_ident: #inner_ty) -> &mut Self {
self.#name.get_or_insert_with(std::vec::Vec::new).push(#each_ident);
self
}
})
} else if let Some(inner) = extract_option_inner(ty) {
// Option<T> 字段
Ok(quote! {
pub fn #name(&mut self, #name: #inner) -> &mut Self {
self.#name = std::option::Option::Some(#name);
self
}
})
} else {
// 普通字段
Ok(quote! {
pub fn #name(&mut self, #name: #ty) -> &mut Self {
self.#name = std::option::Option::Some(#name);
self
}
})
}
});注意 setters 的类型变成 impl Iterator<Item = syn::Result<TokenStream2>>——因为 find_each_attr 可能失败。后续 collect 时要处理错误:
rust
let setters: syn::Result<Vec<TokenStream2>> = setters.collect();
let setters = setters?;到这里,我们的 Builder 宏已经是"生产可用"级别——支持必需字段、可选字段、追加式字段,错误信息清晰。
9.8.1 parse_nested_meta 的真身:ParseStream + .value() 语法糖
前面用 attr.parse_nested_meta(|meta| { ... }) 解析了 #[builder(each = "arg")]。这看起来很"声明式",好像是 syn 里专门为 key = "value" 语法设计的一套 mini-DSL。但打开 syn 源码它就是个朴素的 ParseStream 包装(syn-2.0.117/src/meta.rs:164):
rust
#[non_exhaustive]
pub struct ParseNestedMeta<'a> {
pub path: Path,
pub input: ParseStream<'a>,
}
impl<'a> ParseNestedMeta<'a> {
/// Used when parsing `key = "value"` syntax.
///
/// All it does is advance `meta.input` past the `=` sign in the input. You
/// could accomplish the same effect by writing
/// `meta.parse::<Token![=]>()?`, so at most it is a minor convenience to
/// use `meta.value()?`.
pub fn value(&self) -> Result<ParseStream<'a>> {
self.input.parse::<Token![=]>()?;
Ok(self.input)
}
// ...
}两条信息:
1、value() 只做了"吃掉一个 = 号"一件事。docstring 原话 "All it does is advance meta.input past the = sign"——意思是如果你不用 meta.value()?,写 meta.input.parse::<Token![=]>()? 效果完全一样。value() 是 API 糖,不是语法要求。理解这一点能帮你读懂更复杂的属性解析——比如 #[builder(each = "arg", default)](default 是个没有 = 的 flag 属性),你不需要对所有 path 都调 value(),只对"后面跟 ="的那些调。
2、path: Path 字段是已经解析好的 key。传进 parse_nested_meta 的闭包拿到的 meta 已经吃掉了 key(比如 each)、把 path 字段填上了,input 停在 = "arg" 的 = 之前。所以闭包里写 if meta.path.is_ident("each") 是匹配 key,meta.value()?.parse::<LitStr>()? 是读 = "..." 的字符串——两步的 ParseStream 状态变迁在 syn 源码里清清楚楚。
这也解释了 syn docstring(第 181-201 行)里那段注释是怎么工作的:
text
#[tea(kind = "EarlGrey")]
^ ^
| +- meta.value()?.parse::<LitStr>()?
+- meta.path.is_ident("kind") (这时 input 还没动过,指向 =)嵌套属性就是递归——parse_nested_meta 内部会为每个逗号分隔的项目调用一次闭包,每次 path 都是新的 key。你看到的"高层 DSL"只是标准 ParseStream 的结构化包装。
9.8.2 #[proc_macro_derive(Builder, attributes(builder))] 的 attributes 是如何"注册"的
前面讲过 attributes(builder) 不加会导致编译器报 "unknown attribute"。这个 "注册" 是通过什么机制工作的?它是 rustc 内建的 derive 属性协议的一部分,不是 syn 或 proc-macro2 能控制的。
具体来说:
- rustc 在 expand derive 时扫描
attrs参数里声明的所有 helper attribute 名字; - 把这些名字加入当前 derive 上下文的白名单——出现在
#[derive(Builder)]所修饰的结构体/字段上的这些属性被允许存在; - 其他 derive 宏(比如同一结构体上
#[derive(Serialize)])看不见这些属性——它们只对声明它们的 derive 宏可见。
一个容易踩的坑:两个 derive 宏不能声明同名的 helper attribute。比如你同时写 #[proc_macro_derive(Foo, attributes(tag))] 和 #[proc_macro_derive(Bar, attributes(tag))],同一个字段上写 #[tag = "x"] 会同时被两个宏看到——这是 Rust 属性解析的既定语义,不是 bug。serde 的所有属性用 serde 前缀(#[serde(rename = "...")]),就是为了避免这种碰撞;我们 Builder 用 builder 前缀也是同样的工程礼节。
这条规则在 §11 讲 serde 属性解析时还会再碰到——理解"属性名空间是全局扁平的"能避免很多跨 crate 的神秘行为。
9.9.1 错误处理进阶:Error::into_compile_error 的真实展开
9.9 节里第一次出现了 unwrap_or_else(Error::into_compile_error) 这个收尾式用法——用户写错属性时我们返回 syn::Error,这行代码把它转成一段会让编译器报错的 token stream。这段 token stream 长什么样? 打开 syn-2.0.117/src/error.rs 第 270-328 行就有完整答案:
rust
pub fn into_compile_error(self) -> TokenStream {
self.to_compile_error()
}
// ErrorMessage::to_compile_error 的核心逻辑
fn to_compile_error(&self, tokens: &mut TokenStream) {
// 发射 ::core::compile_error!("message")
tokens.append(TokenTree::Punct(Punct::new_spanned(':', Spacing::Joint, start)));
tokens.append(TokenTree::Punct(Punct::new_spanned(':', Spacing::Alone, start)));
tokens.append(TokenTree::Ident(Ident::new("core", start)));
tokens.append(TokenTree::Punct(Punct::new_spanned(':', Spacing::Joint, start)));
tokens.append(TokenTree::Punct(Punct::new_spanned(':', Spacing::Alone, start)));
tokens.append(TokenTree::Ident(Ident::new("compile_error", start)));
tokens.append(TokenTree::Punct(Punct::new_spanned('!', Spacing::Alone, start)));
tokens.append(TokenTree::Group({
let mut group = Group::new(
Delimiter::Brace,
TokenStream::from({
let mut string = Literal::string(&self.message);
string.set_span(end);
TokenTree::Literal(string)
}),
);
group.set_span(end);
group
}));
}翻译成用户可见的东西:Error::into_compile_error 其实就是手动构造了一段 ::core::compile_error!{ "你的错误信息" } 的 TokenStream。等这段 tokens 作为 derive 宏的返回值被喷回用户 crate,rustc 看到 ::core::compile_error!(...) 就会把其中的字符串作为编译错误抛出——这就是为什么错误会出现在"用户代码里属性那行"而不是"宏内部"。
这里三个工程细节值得记:
1、用 ::core:: 而不是 ::std::。compile_error! 是 core 的一部分,即使用户是 no_std 项目也能用。这是 syn 的刻意选择——不做假设用户的运行时。
2、span 被拆成 start 和 end。::/core/compile_error/! 用 start span(错误的起始位置),最终的字符串字面量用 end span(错误的"目标")——这让 rustc 的诊断器能画出"错误从这里开始、错误发生在这里"的下划线。读者可以 cargo expand 一下带错误的 Builder 用例对比:span 信息让错误消息能精确定位到 #[builder(xxx)] 而不是整个 #[derive(Builder)]。
3、ThreadBound<SpanRange> 让 Error 能跨线程。Error::span()(第 215 行)有段注释:"Spans are not thread-safe so this function returns Span::call_site() if called from a different thread than the one on which the Error was originally created."——过程宏内部基本不跨线程,但 syn 的类型系统为并行化过程宏(未来工作)留了接口。
和 panic 对比的价值:早期过程宏作者喜欢写 panic!("bad attr")——错误会出现,但显示成"internal compiler error: proc macro panicked",定位极差。into_compile_error 出现在 syn 2.0 之后,把"如何向用户报错"变成了标准流程。任何 syn 2.0 的 derive 宏入口都应该以 unwrap_or_else(Error::into_compile_error) 收尾——这是 Rust 过程宏生态过去5年积累的最重要实践之一。
9.9.2 serde_derive 的错误累积:Ctxt 与 Error::combine
我们这份 Builder 一碰到错误就 ? 抛出——意味着第一条错误就终止。serde_derive 不这么做,它用一个叫 Ctxt 的上下文对象把错误攒起来、一起报。
这对用户体验的差异:假设用户写了
rust
#[derive(Serialize)]
struct Foo {
#[serde(rename = 123)] // 类型错了:应该是 LitStr 不是 LitInt
a: String,
#[serde(invalid_flag)] // 未知属性
b: String,
}早停式错误处理只会告诉用户"a 字段的 rename 有问题";用户修完重新编译,再被告知 "b 字段的属性有问题"——两次编译两次报错。serde 的 Ctxt 会一次编译报出两个错误。打开 serde_derive-1.0.228/src/internals/ctxt.rs(只有 69 行,建议全文阅读):
rust
#[derive(Default)]
pub struct Ctxt {
// The contents will be set to `None` during checking. This is so that checking can be
// enforced.
errors: RefCell<Option<Vec<syn::Error>>>,
}
impl Ctxt {
pub fn error_spanned_by<A: ToTokens, T: Display>(&self, obj: A, msg: T) {
self.errors
.borrow_mut()
.as_mut()
.unwrap()
// Curb monomorphization from generating too many identical methods.
.push(syn::Error::new_spanned(obj.into_token_stream(), msg));
}
pub fn check(self) -> syn::Result<()> {
let mut errors = self.errors.borrow_mut().take().unwrap().into_iter();
let mut combined = match errors.next() {
Some(first) => first,
None => return Ok(()),
};
for rest in errors {
combined.combine(rest);
}
Err(combined)
}
}
impl Drop for Ctxt {
fn drop(&mut self) {
if !thread::panicking() && self.errors.borrow().is_some() {
panic!("forgot to check for errors");
}
}
}五条工程教学点:
1、RefCell<Option<Vec<syn::Error>>>——三层包装各有用。Vec 存多条错误;Option 用 None 表示"已经 check 过"(防止重复消费);RefCell 允许在大量不可变借用的解析过程里可变地 append 错误。
2、check() 里调用 combine() 而不是返回 Vec<Error>。syn 的 Error 天生支持多消息(见 §9.9.1 里提到的 self.messages: Vec<ErrorMessage> 结构),调 combine 后返回的是一个 Error 但携带多段 compile_error! emission——到最后 into_compile_error() 时会展开成多个 ::core::compile_error!(...) 调用,rustc 编译时全部输出。这就是"一次编译报全部错误"的机制。
3、Drop 带 panic 保护。如果开发者漏写 ctxt.check()? 就让 ctxt 超出作用域,Drop 里会 panic(除非当前已经在 panicking 状态下——避免二次 panic)。这是一种"静默忽略错误"的强防御——把"忘检查"变成一个即使在 CI 都能捕获的运行时错误。
4、.into_token_stream() 的反单态化优化。注释 "Curb monomorphization from generating too many identical methods." 非常重要——syn::Error::new_spanned<T: ToTokens>(tokens: T, ...) 是泛型方法,每调用一次用不同的 T 会生成一份机器码。serde 有几百处调用点、T 形形色色(字段、类型、表达式)——如果直接传 field、ty、expr,会产生几百份单态化版本,二进制体积涨一截。手动 .into_token_stream() 把 T 擦成统一的 TokenStream,所有调用最终走同一个 new_spanned(TokenStream, String) 路径——一份机器码搞定所有调用点。
5、这个模式可以移植到你的 Builder 宏。作为下一步练习,你可以重构第 9.10 节的完整版——把多处 return Err(...) 改成 ctxt.error_spanned_by(...)——这样用户结构体里有 3 个字段各自带错的 #[builder(...)] 时能一次看全。这是从"玩具宏"到"生产级宏"最重要的一跃。
9.9.3 进阶兜底:panic! 的最后防线
Error + into_compile_error + Ctxt 这一套覆盖了 可预期的错误(属性写错、类型不符、字段冲突)。但过程宏内部如果遇到真正的逻辑 bug(比如 serde 自己 parse 出来的 AST 有不应出现的组合),应该怎么办?
答案是:在不可能到达的代码路径上用 panic!("internal error: ...") 或 unreachable!()。rustc 在过程宏里 panic 会包成 "proc macro panicked" 错误——诊断不如 compile_error 清楚,但对开发者有至关重要的调试价值:它让 bug 立刻显形而不是生成一份错误的代码让下游再"诡异地 compile 失败"。serde_derive 在 receiver.rs/ast.rs 里有多处 unreachable!() ——都是"如果走到这里一定是 serde 自己有 bug"的断言。
对 derive 宏作者的指导:
- 用户能做错的事:用
Error::new_spanned+into_compile_error(或Ctxt) - 宏内部不可能发生的分支:用
unreachable!()或panic!
这条分界看起来琐碎,实际决定了你的宏在 "用户粗心" vs "宏自己写错了" 两种情形下分别展示给用户的是"友好的错误"还是"有用的诊断"——两者都重要,路径不同。
9.9 Step 7:错误处理——让错误指向正确的位置
过程宏的错误信息质量由两件事决定:消息清晰 + Span 准确。
前面我们用 panic!("only struct supported")——这种错误会让用户看到"thread 'rustc' panicked at...",非常不专业。正确做法是返回 syn::Error:
rust
#[proc_macro_derive(Builder, attributes(builder))]
pub fn derive_builder(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match expand(input) {
Ok(ts) => ts.into(),
Err(e) => e.to_compile_error().into(),
}
}
fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
let name = &input.ident;
let builder_name = format_ident!("{}Builder", name);
let fields = match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(fields) => &fields.named,
_ => return Err(syn::Error::new_spanned(
name,
"Builder only supports structs with named fields",
)),
},
_ => return Err(syn::Error::new_spanned(
name,
"Builder only supports structs, not enums or unions",
)),
};
// ... 其余逻辑
}syn::Error::new_spanned(token, msg) 把错误 span 附到特定 token 上——错误信息会指向用户代码里的那个 token。如果用户对一个 enum 写 derive(Builder),错误会精准指向那个 enum 的名字。
这是 serde_derive 的核心错误处理模式。看 serde_derive/src/lib.rs:117:
rust
ser::expand_derive_serialize(&mut input)
.unwrap_or_else(syn::Error::into_compile_error)
.into()完全同样的结构——一个 expand_* 函数返回 syn::Result<TokenStream>,外层把 Err 变成 compile_error。这是 Rust 过程宏错误处理的事实标准模式,你写的宏都应该这样组织。
9.9.4 serde_derive 的入口:90 行的 lib.rs
作为对照,我们的 Builder 从 #[proc_macro_derive(Builder, attributes(builder))] 进入 derive_builder,几十行解析完事;serde_derive 的入口也只有 90 行(serde_derive-1.0.228/src/lib.rs)——全部主逻辑在 mod ser、mod de 里。看 lib.rs 的 derive_serialize:
rust
#[proc_macro_derive(Serialize, attributes(serde))]
pub fn derive_serialize(input: TokenStream) -> TokenStream {
let mut input = parse_macro_input!(input as DeriveInput);
ser::expand_derive_serialize(&mut input)
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}与我们 Builder 宏结构完全一致:
#[proc_macro_derive(..., attributes(...))]注册属性parse_macro_input!解析输入为DeriveInput- 调一个返回
syn::Result<TokenStream>的expand_*函数 unwrap_or_else(syn::Error::into_compile_error)收尾
这个模板在过程宏生态里被重复了成千上万次——dtolnay 的 proc-macro-workshop、thiserror、anyhow 的派生、async-trait 的属性宏——几乎所有生产级过程宏都长这个样子。如果你将来要写一个新 derive,建议先把这四行骨架抄上,再往 expand_* 里填内容。
9.9.5 wrap_in_const 和 const _: () = { ... }:serde 的"Hygiene 隔离盒"
lib.rs 之外,serde 还有一个特别值得学的小文件——src/dummy.rs 只有 32 行:
rust
pub fn wrap_in_const(serde_path: Option<&syn::Path>, code: TokenStream) -> TokenStream {
let use_serde = match serde_path {
Some(path) => quote! {
use #path as _serde;
},
None => quote! {
#[allow(unused_extern_crates, clippy::useless_attribute)]
extern crate serde as _serde;
},
};
quote! {
#[doc(hidden)]
#[allow(
non_upper_case_globals,
unused_attributes,
unused_qualifications,
clippy::absolute_paths,
)]
const _: () = {
#use_serde
_serde::__require_serde_not_serde_core!();
#code
};
}
}每个 #[derive(Serialize)] 展开出来的 impl 块都被包进一个 const _: () = { ... };——这是 serde 最"有存在感"但最不被理解的设计之一。为什么要用 const 块包住?
1、创造 hygiene 隔离。use serde as _serde; 只在这个 const 块内可见。用户的 outer scope 里 serde 可能被重命名、可能根本没 import——但块里的 _serde::Serializer::serialize_str(...) 永远指向真正的 serde crate。一个无意义的 const _ 其实是 Rust 做 scoped namespace 的技巧。
2、允许用户自定义 serde 路径(#[serde(crate = "my_renamed_serde")])。通过 use #path as _serde,用户可以把 serde crate 重命名后仍然让 derive 宏找到它——这对 workspace 中使用自己 fork 版本的场景至关重要。
3、抑制 lint。#[allow(non_upper_case_globals, ...)] 在块上批量 allow,不需要给每个生成的 item 单独 allow。生成代码用的 naming 规则(比如私有 ident 以 __ 开头)本身不符合 Rust 社区风格,用 allow 一次性消掉所有警告。
4、_serde::__require_serde_not_serde_core!(); 是一条防御性宏——确保用户引用的是 serde crate 而不是 serde_core(serde 近期拆出来的最小实现)。如果版本不对,这条宏调用会在 hygiene 隔离盒内报错,不会污染上下文。
一个题外话:const _: () = { ... }; 这种"运行期什么也不做、纯用来当 scoped block"的技巧在 Rust 社区比较罕见——因为它 static assertion 以外的用法不直观。但在过程宏生态里它几乎是事实标准。如果你打算未来做生产级 derive(比如给你的 ORM 加一个 #[derive(Model)]),从 Day 1 就应该用这个模式;等到用户抱怨 "我把 serde 重命名了你的 Model 就挂了" 再改就太迟了。
9.10 完整代码整合
把前面所有步骤合起来,完整的 my-builder-derive/src/lib.rs(约 150 行):
rust
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use syn::{
parse_macro_input, punctuated::Punctuated, Attribute, Data, DeriveInput, Field, Fields,
GenericArgument, LitStr, PathArguments, Type,
};
#[proc_macro_derive(Builder, attributes(builder))]
pub fn derive_builder(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match expand(input) {
Ok(ts) => ts.into(),
Err(e) => e.to_compile_error().into(),
}
}
fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
let name = &input.ident;
let builder_name = format_ident!("{}Builder", name);
let fields = extract_named_fields(&input)?;
let builder_fields = fields.iter().map(|f| {
let name = &f.ident;
let ty = &f.ty;
if extract_option_inner(ty).is_some() {
quote! { #name: #ty }
} else {
quote! { #name: ::std::option::Option<#ty> }
}
});
let builder_init = fields.iter().map(|f| {
let name = &f.ident;
quote! { #name: ::std::option::Option::None }
});
let setters = fields
.iter()
.map(|f| make_setter(f))
.collect::<syn::Result<Vec<_>>>()?;
let build_fields = fields.iter().map(|f| {
let name = f.ident.as_ref().unwrap();
let ok_for_option = extract_option_inner(&f.ty).is_some();
let ok_for_each = find_each_attr(&f.attrs).ok().flatten().is_some();
if ok_for_option || ok_for_each {
quote! { #name: self.#name.take().unwrap_or_default() }
} else {
let err = format!("field `{}` is required", name);
quote! {
#name: self.#name.take()
.ok_or_else(|| ::std::boxed::Box::<dyn ::std::error::Error>::from(#err))?
}
}
});
Ok(quote! {
pub struct #builder_name {
#( #builder_fields, )*
}
impl #name {
pub fn builder() -> #builder_name {
#builder_name {
#( #builder_init, )*
}
}
}
impl #builder_name {
#( #setters )*
pub fn build(&mut self) -> ::std::result::Result<
#name,
::std::boxed::Box<dyn ::std::error::Error>,
> {
::std::result::Result::Ok(#name {
#( #build_fields, )*
})
}
}
})
}
fn extract_named_fields(input: &DeriveInput) -> syn::Result<&Punctuated<Field, syn::Token![,]>> {
match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(f) => Ok(&f.named),
_ => Err(syn::Error::new_spanned(
&input.ident,
"Builder only supports structs with named fields",
)),
},
_ => Err(syn::Error::new_spanned(
&input.ident,
"Builder only supports structs",
)),
}
}
fn make_setter(f: &Field) -> syn::Result<TokenStream2> {
let name = f.ident.as_ref().unwrap();
let ty = &f.ty;
if let Some(each) = find_each_attr(&f.attrs)? {
let inner = extract_vec_inner(ty).ok_or_else(|| {
syn::Error::new_spanned(ty, "`each` attribute requires Vec<T>")
})?;
let each_id = format_ident!("{}", each);
let collision = name == &each_id;
let regular_setter = if collision {
quote! {}
} else {
quote! {
pub fn #name(&mut self, #name: #ty) -> &mut Self {
self.#name = ::std::option::Option::Some(#name);
self
}
}
};
Ok(quote! {
pub fn #each_id(&mut self, #each_id: #inner) -> &mut Self {
self.#name
.get_or_insert_with(::std::vec::Vec::new)
.push(#each_id);
self
}
#regular_setter
})
} else if let Some(inner) = extract_option_inner(ty) {
Ok(quote! {
pub fn #name(&mut self, #name: #inner) -> &mut Self {
self.#name = ::std::option::Option::Some(#name);
self
}
})
} else {
Ok(quote! {
pub fn #name(&mut self, #name: #ty) -> &mut Self {
self.#name = ::std::option::Option::Some(#name);
self
}
})
}
}
fn extract_option_inner(ty: &Type) -> Option<&Type> {
extract_single_generic(ty, "Option")
}
fn extract_vec_inner(ty: &Type) -> Option<&Type> {
extract_single_generic(ty, "Vec")
}
fn extract_single_generic<'a>(ty: &'a Type, wrapper: &str) -> Option<&'a Type> {
let Type::Path(p) = ty else { return None };
let seg = p.path.segments.last()?;
if seg.ident != wrapper {
return None;
}
let PathArguments::AngleBracketed(args) = &seg.arguments else {
return None;
};
if args.args.len() != 1 {
return None;
}
let GenericArgument::Type(inner) = args.args.first()? else {
return None;
};
Some(inner)
}
fn find_each_attr(attrs: &[Attribute]) -> syn::Result<Option<String>> {
for attr in attrs {
if !attr.path().is_ident("builder") {
continue;
}
let mut result = None;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("each") {
let value = meta.value()?;
let lit: LitStr = value.parse()?;
result = Some(lit.value());
Ok(())
} else {
Err(meta.error("expected `each = \"...\"`"))
}
})?;
if result.is_some() {
return Ok(result);
}
}
Ok(None)
}150 行代码实现一个完整的 #[derive(Builder)]。
9.11 测试和验证
在 test-app 里全面测试:
rust
use my_builder_derive::Builder;
#[derive(Builder)]
pub struct Command {
executable: String,
#[builder(each = "arg")]
args: Vec<String>,
current_dir: Option<String>,
}
fn main() {
// 基础用法
let cmd = Command::builder()
.executable("cargo".to_string())
.arg("build".to_string())
.arg("--release".to_string())
.current_dir("/tmp".to_string())
.build()
.unwrap();
assert_eq!(cmd.args, vec!["build", "--release"]);
assert_eq!(cmd.current_dir, Some("/tmp".to_string()));
// Option 字段可以省略
let cmd2 = Command::builder()
.executable("git".to_string())
.build()
.unwrap();
assert_eq!(cmd2.current_dir, None);
assert_eq!(cmd2.args, Vec::<String>::new());
// 缺少必需字段会报错
let err = Command::builder().build().unwrap_err();
assert!(err.to_string().contains("executable"));
}可以用 cargo expand 看实际展开:
bash
cd test-app
cargo expand你会看到生成的完整 CommandBuilder 和所有 impl 块——大约 80 行代码,全部由 150 行宏代码生成。
9.12 回顾:我们学到了什么
写完这个宏,你掌握了过程宏的全套核心技能:
- 入口函数:
#[proc_macro_derive(..., attributes(...))]+parse_macro_input! - AST 解析:
Data::Struct/Fields::Named提取字段、extract_option_inner识别Option<T> - 属性解析:
parse_nested_meta解析#[builder(each = "...")] - 代码生成:quote! +
#( ... )*+format_ident! - 错误处理:
syn::Error::new_spanned+unwrap_or_else(Error::into_compile_error) - Hygiene 实践:全路径
::std::option::Option::Some避免命名冲突
9.12.0 Attr<T> / BoolAttr / VecAttr:serde 属性解析的三件套
如果说 Ctxt 是 serde_derive 的"错误收集模板",那 Attr<T> / BoolAttr / VecAttr 三个小类型是它的属性解析模板。源码在 internals/attr.rs 第 24-131 行,总共不到 100 行代码,却被 serde 内部几十种属性复用。
rust
pub(crate) struct Attr<'c, T> {
cx: &'c Ctxt, // 共享的错误上下文
name: Symbol, // 属性名(来自 symbol.rs 的常量)
tokens: TokenStream, // 出现位置(用于 span)
value: Option<T>, // 尚未设置 vs 已设置
}
impl<'c, T> Attr<'c, T> {
fn set<A: ToTokens>(&mut self, obj: A, value: T) {
let tokens = obj.into_token_stream();
if self.value.is_some() {
// 重复设置:不覆盖,而是报错
let msg = format!("duplicate serde attribute `{}`", self.name);
self.cx.error_spanned_by(tokens, msg);
} else {
self.tokens = tokens;
self.value = Some(value);
}
}
// ...
}
struct BoolAttr<'c>(Attr<'c, ()>);
// BoolAttr 就是 Attr<()>——value 只表示"有没有设置"
pub(crate) struct VecAttr<'c, T> {
cx: &'c Ctxt,
name: Symbol,
first_dup_tokens: TokenStream,
values: Vec<T>,
}
impl<'c, T> VecAttr<'c, T> {
fn insert<A: ToTokens>(&mut self, obj: A, value: T) {
if self.values.len() == 1 {
self.first_dup_tokens = obj.into_token_stream();
}
self.values.push(value);
}
fn at_most_one(mut self) -> Option<T> {
if self.values.len() > 1 {
let dup_token = self.first_dup_tokens;
let msg = format!("duplicate serde attribute `{}`", self.name);
self.cx.error_spanned_by(dup_token, msg);
None
} else {
self.values.pop()
}
}
}三条教学点:
1、Attr::set 的防重设计。用户如果写 #[serde(rename = "a", rename = "b")],不是后者覆盖前者,两者都不生效,外加一条 "duplicate serde attribute" 错误。Ctxt 负责收集这条错误。这是"用户意图不明确时绝不默默选一个"的工程态度——静默的二义性行为是维护噩梦的开端。
2、BoolAttr = Attr<()>。flag 类型的属性(#[serde(transparent)]、#[serde(deny_unknown_fields)])本质上也需要防重、需要带 span、需要 Ctxt——只是不需要带值。用 () 作为 T 复用 Attr 的全部基础设施——Rust 泛型的"零大小类型参数"用法。我们 Builder 宏里 Option / Vec 的识别也可以这样抽象,但目前规模还不值得。
3、VecAttr::at_most_one 的兼容性考量。有些属性(比如 #[serde(bound = "...")])在同一条属性里允许出现多次:#[serde(bound(serialize = "..."), bound(deserialize = "..."))]。这时 VecAttr 接受多次 insert;但如果用户不小心写了两次同类型的,at_most_one 会挑出重复并报错。第一个冗余的 token 被专门记在 first_dup_tokens 字段里——保证错误 span 是 "第二次出现的位置",而不是通用 call_site。这是 serde 在错误消息定位上的细腻度——它要告诉用户"你重复了,重复的那一次在这里",而不是"某处重复了"。
这三件套给我们 Builder 宏下一步的重构指引:当 #[builder(...)] 属性扩展到支持 3 个以上子属性时(each、default、skip),用 Attr/BoolAttr/VecAttr 的类似抽象能避免硬编码的重复 if/else 分支大爆炸。社区里 darling crate 把这套模式通用化了——后面 §13 章会对比 darling 与手写属性解析的取舍。
9.12.1 对照 serde_derive 的加工后 AST
我们的 Builder 宏直接用 syn::Field 作为字段的表示——从 input.data.fields.named 里迭代。serde_derive 不这么做,它在 internals/ast.rs 里定义了一套加工后的 AST:
rust
pub struct Container<'a> {
pub ident: syn::Ident,
pub attrs: attr::Container, // 已解析的结构体级 serde 属性
pub data: Data<'a>, // 加工后的 Struct/Enum 枚举
pub generics: &'a syn::Generics,
pub original: &'a syn::DeriveInput, // 原始 syn 节点,留着做 span
}
pub enum Data<'a> {
Enum(Vec<Variant<'a>>),
Struct(Style, Vec<Field<'a>>),
}
pub struct Field<'a> {
pub member: syn::Member, // 字段的 ident 或 index(tuple 字段用 index)
pub attrs: attr::Field, // 已解析的字段级 serde 属性
pub ty: &'a syn::Type, // 借用原 type
pub original: &'a syn::Field, // 留原始字段
}
#[derive(Copy, Clone)]
pub enum Style {
Struct, // Named fields
Tuple, // Many unnamed fields
Newtype, // One unnamed field
Unit, // No fields
}四个值得记的设计:
1、original 字段永远保留原始 syn 节点。每次发错误 cx.error_spanned_by(field.original, "...") 用的都是真实的 syn AST 节点——保证 span 指向用户写的那一行。如果我们只存加工后的信息(比如只留 ident 不留原 Field),后续报错时没法拿到原 span,错误会指向 Span::call_site()——用户根本定位不到。这是过程宏里一条铁律:加工 AST 是为了方便生成代码;原始 AST 是为了方便报错——两者都要留。
2、Style 枚举把四种字段形态统一。普通 struct、tuple struct、newtype(struct X(T);)、unit struct(struct X;)用一个 Style 区分——下游 ser.rs/de.rs 的分支就从"字段类型树"简化成"对 Style 做 match"。我们的 Builder 宏没做这个抽象,所以 panic!("only named fields supported")——遇到 tuple struct 就死了。生产级的 serde_derive 四种都支持,靠的就是这个 Style。
3、Union 被明确拒绝(第 79-82 行):
rust
syn::Data::Union(_) => {
cx.error_spanned_by(item, "Serde does not support derive for unions");
return None;
}Rust union 是 unsafe 的、没有字段身份的概念,序列化语义不清——serde 不做。注意用的是 cx.error_spanned_by——按 §9.9.2 的 Ctxt 模式,这不会立刻中断,而是把错误攒起来继续尝试其他字段,最后一起报。
4、Container::from_ast 里的 rename 规则下传(第 85-99 行):Container 级的 rename_all = "camelCase" 会通过 field.attrs.rename_by_rules(...) 下放到每个字段;Variant 级的 rename_all 又会覆盖 Container 级。这是一条三层嵌套的属性继承链——容器 → 变体 → 字段——每层都可以 override。我们的 Builder 宏字段属性不继承,不需要这套;但只要你的 derive 宏有"容器默认属性 + 字段独立覆盖"的需求,一定要把这条继承链显式建立起来,否则用户会遇到"我在结构体上写了 rename_all 但某个字段没跟随"的 bug。
把这段加工后 AST 与我们的 Builder 对照,就能清楚看出"玩具宏 → 生产宏"缺少什么:
| 维度 | 我们的 Builder | serde_derive |
|---|---|---|
| 字段表示 | 直接用 syn::Field | 自定义 Field { member, attrs, ty, original } |
| 容器形态 | 只支持 named struct | Struct(Style)/Enum/Union(reject) |
| 属性继承 | 无 | Container → Variant → Field 三层 |
| 原始节点留存 | 不留(panic 时丢 span) | 每个节点都带 original |
| 错误策略 | ? 早停 | Ctxt 累积 + Error::combine |
下一章进入 serde_derive 源码时,这张表就是"我该读哪些文件"的索引:第 10 章讲 internals/ast.rs(Container/Field 的建模),第 11 章讲 internals/attr.rs(属性解析的完整层级),第 12 章讲 ser.rs(用 Style 做代码生成分支)。
9.12.2 对照 serde_derive 的目录结构
serde_derive/src/
├── lib.rs ← 入口(90 行,对应我们的 derive_builder 函数)
├── internals/
│ ├── ast.rs ← 加工后的 AST(Container/Variant/Field/Style)
│ ├── attr.rs ← 属性解析(几十种 #[serde(...)] 属性)
│ ├── case.rs ← 命名风格转换(snake_case/camelCase/kebab-case 等)
│ ├── check.rs ← 属性组合合法性检查(如 rename 与 flatten 互斥)
│ ├── ctxt.rs ← 错误累积(69 行,§9.9.2 详述)
│ ├── name.rs ← 字段/变体的名字管理
│ ├── receiver.rs ← self / &self / &mut self 的处理
│ ├── respan.rs ← Span 改写,用于错误消息指向正确位置
│ └── symbol.rs ← 属性名常量(避免字符串拼写错误)
├── bound.rs ← 自动 where 子句推导(Serialize bound 的泛型处理)
├── de.rs ← Deserialize 生成
├── dummy.rs ← wrap_in_const(§9.9.5 详述)
├── fragment.rs ← 代码片段拼接工具
├── pretend.rs ← 假装使用的代码,为了精确 lint 提示
├── ser.rs ← Serialize 生成
└── this.rs ← Self 类型的生成辅助规模对比:我们的 Builder 宏 150 行、支持 3 种字段形态;serde_derive 超过 5000 行、支持 struct/enum/union × 4 种变体 tag × N 种属性 × 泛型 × 生命周期 × 借用反序列化……复杂度天差地别,但核心模式是一样的。第 10 章起,我们进入 serde_derive 源码,你会发现每一处代码都对应你刚才做过的事——只是放大了 30 倍。
9.12.2-bis Symbol:把"属性名字符串"变成可以 == 的类型
在 find_each_attr 里我们写了 if !attr.path().is_ident("builder") { continue; } 和 if meta.path.is_ident("each") { ... }——字符串"builder"/"each"写死在代码里。如果未来加一个属性 skip,你要在多处 grep 确认每次改的都是一样的拼写——硬编码字符串是过程宏里最容易产生 typo bug 的来源。
serde_derive 的解法是 internals/symbol.rs(71 行):
rust
#[derive(Copy, Clone)]
pub struct Symbol(&'static str);
pub const ALIAS: Symbol = Symbol("alias");
pub const BORROW: Symbol = Symbol("borrow");
pub const BOUND: Symbol = Symbol("bound");
// ... 共 40 个左右的 Symbol 常量
pub const SERDE: Symbol = Symbol("serde");
pub const SKIP: Symbol = Symbol("skip");
pub const RENAME: Symbol = Symbol("rename");
// ...
impl PartialEq<Symbol> for Ident {
fn eq(&self, word: &Symbol) -> bool {
self == word.0
}
}
impl PartialEq<Symbol> for Path {
fn eq(&self, word: &Symbol) -> bool {
self.is_ident(word.0)
}
}三条工程优美度:
1、Symbol 是个纯 &'static str 的 newtype——零开销(编译期常量),但类型系统保证只有 Symbol 能和 Ident/Path 做 == 比较。如果你打错字 symbol::REMAME(把 RENAME 打成 REMAME),编译器立刻报错 "no associated item named REMAME"——不是运行期 typo bug,而是编译期常量未定义。
2、impl PartialEq<Symbol> for Ident 的魔法。这让 if field.ident == RENAME 直接可用——读起来像自然英文、写起来像 static typed enum。注意这里必须同时 impl 为 Ident 和 &Ident(还有 Path 和 &Path)——因为 Rust 的 == 运算符对 "T vs U" 和 "T vs &U" 需要分别实现。serde 的源码 4 个 impl 正是覆盖这四种组合。
3、pub const 而不是 pub static。因为 Symbol 是 Copy 的简单 struct,const 让每个使用点零成本复制(不会有内存地址共享的问题)——这对被调用无数次的属性匹配代码是一个可见的优化。
把这个模式移植到你的 Builder 宏:定义一个 symbols.rs,写 pub const EACH: Symbol = Symbol("each"); pub const DEFAULT: Symbol = Symbol("default");——后续所有属性匹配都用常量,打错字编译期暴露。这是 10 分钟就能完成的重构,但能把未来所有因 typo 导致的"宏悄悄不工作"的 bug 彻底消除。
9.12.3 TagType:一个属性领域语言的枚举编码
serde 的 enum 序列化有四种风格,全部靠一个顶层 TagType 枚举区分(attr.rs:178):
rust
pub enum TagType {
/// Default:{"variant1": {"key1": "value1", ...}}
External,
/// #[serde(tag = "type")]:{"type": "variant1", "key1": ...}
Internal { tag: String },
/// #[serde(tag = "t", content = "c")]:{"t": "variant1", "c": {...}}
Adjacent { tag: String, content: String },
/// #[serde(untagged)]:{"key1": "value1", ...}(看不出是哪个 variant)
None,
}注释里的 JSON 样例直接体现了每种 tag 模式的实际形态——这是阅读 serde 属性设计文档之外的另一个参考源:源码枚举变体的 doc comment。源码写作者(dtolnay)把"这个配置对应的输出长什么样"塞进枚举定义,阅读源码的人连文档都不用翻。这是 Rust 生态里很重的一条风格:类型定义就是文档。
从这个 TagType 能反推出一条深层的 API 设计原则——用户能通过属性组合出的所有可能状态,最终要映射到一个有限、正交、可枚举的内部类型。用户写的 tag = "x" / tag = "x", content = "y" / untagged / 什么都不写——四种输入、四种 TagType 变体、ser.rs 和 de.rs 对 TagType 做 match 生成代码。属性是模糊、开放的语法;内部表示是精确、封闭的类型。过程宏工程的核心难度就在这个"解析+归一"的过程。
我们的 Builder 宏现在没有 TagType 这个级别的复杂性——只有"必需/可选/追加"三档,硬编码的 if/else 就够了。但如果未来要支持 #[builder(skip)]/#[builder(default = ...)]/#[builder(each = "...", into = true)] 等组合,同样应该把它归一到一个 enum FieldKind { Required, Optional(Option<DefaultExpr>), Each(String, InnerTy), ... }——否则 setters/build 阶段的分支会爆炸到无法维护。
9.12.3-bis bound.rs:自动推导 where 子句——A: Serialize 是怎么加上去的
我们的 Builder 宏不处理泛型——它只接受具体类型的 struct。但任何生产级 derive 都要处理这种声明:
rust
#[derive(Serialize)]
struct S<'b, A, B: 'b, C> {
a: A,
b: Option<&'b B>,
#[serde(skip_serializing)]
c: C,
}问题:生成的 impl Serialize for S<'b, A, B, C> 需要在哪些泛型参数上加 Serialize bound?
A必须Serialize(a: A要被序列化)B必须Serialize(b: Option<&'b B>间接要)C不需要(skip_serializing 了,根本不读 c 字段)'b不需要 bound(生命周期不参与 trait impl)
serde_derive 的 bound.rs 自动推导这个 bound 集合。核心算法在 with_bound()(第 91 行起)——它实现了一个 AST visitor,找出"在不跳过的字段里实际出现过的泛型参数",只给这些参数加 bound。源码注释(第 79-90 行)直接给了上面这个例子:
// Puts the given bound on any generic type parameters that are used in fields
// for which filter returns true.
//
// struct S<'b, A, B: 'b, C> {
// a: A,
// b: Option<&'b B>
// #[serde(skip_serializing)]
// c: C,
// }
// ...需要的 bound: `A: Serialize, B: Serialize`visitor 状态结构同样写得很清楚:
rust
struct FindTyParams<'ast> {
all_type_params: HashSet<syn::Ident>, // {A, B, C}
relevant_type_params: HashSet<syn::Ident>, // {A, B}
associated_type_usage: Vec<&'ast syn::TypePath>, // 关联类型
}with_bound 做的事:遍历所有不 skip 的字段类型,递归下到每个 Type::Path,看路径第一段是否出现在 all_type_params 里——如果是就加入 relevant_type_params。最后把 A: bound / B: bound 追加到 where 子句。
为什么"自动 bound"是 serde 能被普遍接受的关键之一? 因为 Rust 的默认规则是"derive 给每个类型参数自动加对应 trait bound"(即 #[derive(Clone)] struct S<T>(T) 自动加 T: Clone)——但这会导致 struct S<T> { _ph: PhantomData<T> } 这种"实际上不用 T 来做 Clone"的 struct 也被要求 T: Clone。serde 的 bound.rs 用 AST visitor 做得更精确——只给真正用到的参数加 bound——不会因为你结构体里有个 PhantomData<T> 就强制 T: Serialize。
这个算法也有逃生口:#[serde(bound = "...")] 允许用户完全接管 bound——适用于一些 visitor 看不穿的场景(比如字段类型是 Foo<T>::Assoc,关联类型算法保守来说应该加 bound,但有时实际不需要)。这是"自动化 + 手动 override"的标准模式——serde 的几乎所有特性都遵循这个模式,让默认行为正确、极端情况可自定义。
作为读者,你不需要在自己的 Builder 宏里立刻实现 bound.rs 这套——Builder 模式本来就没有"impl Trait for #name"生成需求。但如果你后续写 derive(比如自定义 PartialEq、Hash),这个思路就派上用场了。
9.12.4 pretend.rs:一段专门"假装在使用"的代码是做什么的
serde_derive 还有一个极其独特的小文件 src/pretend.rs——它的全部职责是生成一段假装使用所有字段和变体的无效代码。文件顶部的注释把动机说明白了(pretend.rs:6-22):
// Suppress dead_code warnings that would otherwise appear when using a remote
// derive. Other than this pretend code, a struct annotated with remote derive
// never has its fields referenced and an enum annotated with remote derive
// never has its variants constructed.翻译:serde 的 remote derive 功能让用户可以 #[derive(Serialize)] 一个别人 crate 里的类型(通过 #[serde(remote = "...")])。这种情况下,本地 crate 里只有一个代理类型,其字段/变体从未被实际读取或构造,rustc 就会报 warning: field is never used / variant is never constructed。
pretend.rs 的解法:在生成的代码里加一段永远不会执行(因为 None::<&T> match 肯定走 _ => {} 分支)、但让编译器看到所有字段被引用的 pattern matching:
rust
// 对 struct 字段:
match _serde::#private::None::<&#type_ident #ty_generics> {
_serde::#private::Some(#type_ident { #(#members: #placeholders),* }) => {}
_ => {}
}
// 对 packed struct(用 addr_of! 避免取不对齐字段的引用):
match None::<&T> {
Some(__v @ T { a: _, b: _ }) => {
let _ = addr_of!(__v.a);
let _ = addr_of!(__v.b);
}
_ => {}
}三点工程智慧:
1、运行时零开销。None::<&T> 是编译期常量,match 只会走 _ => {}——但借用检查器和 dead-code 分析把"Some 分支里引用了 field"看成"field 被使用"。rustc 看到被使用就不 warn。这是典型的"用编译器静态分析的短视来换一份干净的编译输出"。
2、对 packed struct 特殊处理。#[repr(packed)] 结构的字段不能取引用(因为可能未对齐),但可以用 addr_of! 取原始指针。pretend.rs 针对旧 rustc 和新 rustc 分出两种生成策略——新版用 addr_of!,旧版退而假设 Sized + !Drop 直接按值 match。为了让 dead_code warning 闭嘴,写了两条代码路径——这种"极端认真"正是 dtolnay 代码的标志。
3、Unit struct 不用 pretend(Data::Struct(Style::Unit, _) => quote!())——它没有字段可"假装使用",直接返回空 token stream。
作为读者的启示:过程宏生成的代码不只要"正确",还要"在用户 lint 配置下不产生噪声"。如果你的 derive 生成代码会触发 unused_variables/dead_code/clippy::xxx,用户 CI 会失败——哪怕你的代码逻辑完全正确。serde 通过 wrap_in_const 的 #[allow(...)](§9.9.5)+ pretend.rs 的"假装使用",把所有合理的警告都消掉——这是"生产级 derive 宏"和"玩具 derive 宏"的决定性差距之一。
9.13 和其他丛书的连接
Builder 模式不只 Rust 有。理解它的工程价值对所有语言都有意义:
- Java:Lombok 的
@Builder注解做的是完全一样的事——在编译期用注解处理器生成 Builder 类。Lombok 基于 Java 的 annotation processing API,是 Rust proc-macro 的精神前辈。 - TypeScript / JavaScript:通常手写 Builder 或用库(像 typeorm 里的 QueryBuilder)。动态语言因为能反射,很少用宏级生成。
- C++:需要靠模板特化和 concept 做类似的事,代码可读性差、SFINAE 复杂度高,同等功能通常需要显著更多样板(经验上数倍)。
丛书《Tokio 源码深度解析》第 17 章 observability里讨论过 RuntimeBuilder——Tokio runtime 的配置 builder。它是手写的,不是 derive 宏生成的,因为:
- 它需要在每个 setter 里做参数验证(检查数值范围)
- 有些字段互相排斥(enable_all vs enable_io,设置一个会影响另一个)
这是 Builder derive 宏的边界——对于有复杂业务逻辑的 builder,手写更合适;对于"单纯收集字段、最后组装"的场景,derive 完美。Serde 的 #[derive(Serialize)] 就是后一种——每个字段独立处理,没有互相约束,适合机械生成。
9.13.0 工程化细节:版本化的 __private identifier
回到 serde_derive/src/lib.rs:95-105 有一段容易漏掉但极其精妙的代码:
rust
#[allow(non_camel_case_types)]
struct private;
impl private {
fn ident(&self) -> Ident {
Ident::new(
concat!("__private", env!("CARGO_PKG_VERSION_PATCH")),
Span::call_site(),
)
}
}
impl ToTokens for private {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
tokens.append(self.ident());
}
}这段代码的用途是:在生成的代码里调用 _serde::#private::Some(...)、_serde::#private::None::<...>——其中 #private 是一个带 cargo 版本后缀的 identifier,比如 __private228(对应 serde_derive 1.0.228)。
为什么要这么做?考虑一个边角场景:你的工作空间里同时存在 serde 1.0.190 和 serde 1.0.228(通过不同依赖传递),而 derive 1.0.228 生成的代码里写 _serde::__private::Some——但如果某个版本的 __private 模块内部结构有变动,写死 __private 会在混用场景下出运行时链接错误。
版本后缀让每个 serde 小版本的生成代码和自己的 runtime 精确绑定——__private228 指向 serde 1.0.228 的内部实现,__private190 指向 1.0.190 的——不会串。这是极端场景下的防御代码,99% 用户永远不会碰到它生效的那 1% 场景,但正因为这 1% 的场景调试起来极其痛苦(版本偏差导致的错误通常没有可理解的错误消息),dtolnay 花了一个巧用 env!("CARGO_PKG_VERSION_PATCH") 的小技巧把它根治。
这种"为罕见场景写防御代码"的态度是 serde 能稳定支撑整个 Rust 生态十年的底气——你用了它,你几乎不会因为 serde 本身的 bug 浪费调试时间。理解到这一层,你才能明白为什么大家都说"serde 的源码是 Rust 过程宏教科书"。
顺带一提,serde 的 derive 还有一个经常被误解的设计:生成代码里所有 syn 里可能变化的 trait 函数都走全路径(_serde::ser::Serializer::serialize_str 而不是 serializer.serialize_str)。理由是如果用户在 scope 里有另一个同名 trait 被 use 了,方法解析可能优先选到用户的——而全路径调用总是精确的。这条实践已经被整个 Rust 宏社区采纳——你会在 tokio-macros、async-trait、thiserror 里看到同样的模式。
9.13.1 本章和全书体系的呼应
回到本书的整体脉络——本章的 Builder 宏是第 5-8 章(过程宏基础)的动手落地,也是第 10-14 章(serde_derive 源码拆解)的镜像。下面这张对照表把本章展示的"小规模"技能与后面章节要讲的"大规模"serde_derive 特性对应起来,方便读者形成脑内索引:
| 本章做的事 | 对应 serde_derive 的对象 | 后面讲到的章节 |
|---|---|---|
parse_macro_input!(input as DeriveInput) | 同一行,在 lib.rs 里 | 第 10 章(入口) |
直接用 syn::Field 迭代 | internals/ast.rs::Container/Field | 第 10 章(加工 AST) |
find_each_attr 硬编码字符串 | internals/attr.rs::Attr<T> + symbol.rs | 第 11 章(属性解析) |
早停式 ? 错误 | internals/ctxt.rs::Ctxt | 第 11 章(错误累积) |
Error::new_spanned + unwrap_or_else(into_compile_error) | 同一套,出现在 lib.rs 尾行 | 第 10 章 |
| 不处理泛型 | bound.rs::with_bound | 第 13 章(bound 推导) |
| 不处理多种字段形态 | ast.rs::Style + ser.rs::Serialize 分支 | 第 12 章(代码生成) |
| 不考虑 dead_code warning | pretend.rs + wrap_in_const | 第 13 章(工程化生成) |
硬编码 Option<T> 识别 | serde 不识别(让 trait 派发处理) | 第 14 章 |
读到这里你应该能回答一个很多初学者无法回答的问题——"为什么 serde_derive 有 5000 行而我的 Builder 150 行?" 答案不是"serde 写得啰嗦",而是"serde 把上面这张表的每一行都做到了生产级":属性种类多了 10 倍、形态覆盖完整、错误累积、bound 推导、代码 hygiene、工具友好性——每一维都投了显著的工程量。这些"多出来的 4850 行"不是冗余,是从可用到可信赖的距离。
如果你将来要做一个需要上 crates.io、被陌生人用的 derive 宏,这 4850 行里你大概会需要复制 3000 行以上的模式。把这一章当成"可运行的最小示范",把后续章节当成"抄作业的蓝本"——这就是本书安排这个顺序的理由。
9.14 本章小结
写一个 derive 宏就像玩拼图——所有工具(syn、quote、proc-macro2)在之前几章都介绍过,本章只是把它们组装起来。真正的学习发生在动手打代码的时候——你会撞到一堆不会出现在教程里的边缘情况(Option 识别、attr 解析失败、span 丢失),然后搜索、试错、修复。这个过程比读完五本教科书有用。
下一章正式进入 serde_derive 源码。你会发现它的组织结构和我们的 Builder 非常像——都是"parse 入口 → 加工 AST → 生成 impl 块 → 错误兜底"。只是 serde_derive 每一步都做得更细——AST 加工层专门有 Container 抽象(第 10 章)、属性解析层有几十个属性类型(第 11 章)、代码生成分成 struct/enum 专门函数(第 12-14 章)。
提前做完 proc-macro-workshop:workshop 有 7 道题,做完前 3 道(Builder、Debug、Seq),你就可以无痛阅读 serde_derive 全部源码。
动手实验
- 照着本章代码实现一遍。不要 copy——自己打一遍。每一行都想想"这里为什么要这样"。
- 添加新特性:让 Builder 支持
#[builder(default)]属性——如果字段有此属性,build() 时使用Default::default()而不是报错。 - 处理泛型:让 Builder 支持
struct Foo<T> { x: T }这种带泛型的结构——用generics.split_for_impl()。 - 做 proc-macro-workshop Debug 题:比 Builder 更难一些,需要处理生命周期 bound 和 trait bound 的自动推导。
延伸阅读
- proc-macro-workshop 完整题目:7 道题,每道都有详细测试用例。做完就是 proc-macro 高手。
- Rust 官方宏教程:Book 的宏章节,适合再次温习基础。
- cargo-expand 进阶使用:了解
--ugly、--tests等选项。 - 丛书卷一《Rust 编译器》第 14 章:从编译器内部理解我们这 150 行代码如何被 rustc 加载、运行、集成回编译产物。
- 《Serde 官方文档的 custom derive 指南》:https://serde.rs/custom-derive.html,为 Serde 设计的"第三方 derive" 写法,和本章的 Builder 路数一致。