Skip to content

第 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::StructFields::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_fieldsbuilder_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_dirOption<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> 类型的字段,有两处要变:

  1. builder 结构体字段类型:不要变成 Option<Option<T>>,保持 Option<T>
  2. setter 签名:接受 T 而不是 Option<T>;内部包装成 Some(T)
  3. 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 被拆成 startend::/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 形形色色(字段、类型、表达式)——如果直接传 fieldtyexpr,会产生几百份单态化版本,二进制体积涨一截。手动 .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 sermod 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 回顾:我们学到了什么

写完这个宏,你掌握了过程宏的全套核心技能:

  1. 入口函数#[proc_macro_derive(..., attributes(...))] + parse_macro_input!
  2. AST 解析Data::Struct/Fields::Named 提取字段、extract_option_inner 识别 Option<T>
  3. 属性解析parse_nested_meta 解析 #[builder(each = "...")]
  4. 代码生成:quote! + #( ... )* + format_ident!
  5. 错误处理syn::Error::new_spanned + unwrap_or_else(Error::into_compile_error)
  6. 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 个以上子属性时(eachdefaultskip),用 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 对照,就能清楚看出"玩具宏 → 生产宏"缺少什么:

维度我们的 Builderserde_derive
字段表示直接用 syn::Field自定义 Field { member, attrs, ty, original }
容器形态只支持 named structStruct(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 必须 Serializea: A 要被序列化)
  • B 必须 Serializeb: 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 不用 pretendData::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 warningpretend.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 全部源码。

动手实验

  1. 照着本章代码实现一遍。不要 copy——自己打一遍。每一行都想想"这里为什么要这样"。
  2. 添加新特性:让 Builder 支持 #[builder(default)] 属性——如果字段有此属性,build() 时使用 Default::default() 而不是报错。
  3. 处理泛型:让 Builder 支持 struct Foo<T> { x: T } 这种带泛型的结构——用 generics.split_for_impl()
  4. 做 proc-macro-workshop Debug 题:比 Builder 更难一些,需要处理生命周期 bound 和 trait bound 的自动推导。

延伸阅读

基于 VitePress 构建