Serde 元编程

第 7 章 syn:把 TokenStream 解析成 AST

作者 杨艺韬 · 12,333 字 · 发布于 · 更新于

7.1 从一堆 token 到一棵树

上一章讲了 TokenStream——它是过程宏的原始输入。但对写 derive 宏的人来说,"原始输入"远远不够好用。想象你要给一个 struct 生成 Serialize 实现:

#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
}

宏函数拿到的 TokenStream 大致长这样(简化):

Ident("struct") Ident("User") Group<Brace>{
    Ident("id") Punct(":") Ident("u64") Punct(",")
    Ident("name") Punct(":") Ident("String") Punct(",")
}

你想要的信息:

  • 类型名叫 User
  • 它是 struct(不是 enum 也不是 union)
  • 有两个字段:第一个叫 id、类型是 u64;第二个叫 name、类型是 String

从 TokenStream 直接拿这些信息要做什么?

  1. 检查第一个 Ident,如果是 struct 则记下这是 struct。
  2. 下一个 Ident 是类型名。
  3. 跳过 struct、类型名到下一个 Group,进入 Group 内部。
  4. 遍历 Group 内的 token,用状态机识别"Ident Punct(':') 类型 Punct(',')"这种模式……
  5. 当遇到泛型(struct User<T>)、where 子句、生命周期、引用(&'a T)、trait object(Box<dyn Trait>)、嵌套泛型(HashMap<String, Vec<T>>)——状态机爆炸。

Rust 的类型语法很丰富。想在 TokenStream 层手写解析器,是写一个小型 Rust 编译器前端。没人会这么做——太繁琐、太容易出 bug、太多边缘情况。

于是有了 syn。

syn 是 Rust 生态里把 TokenStream 解析成结构化 AST 的标准库。它由 dtolnay(Serde 的长期维护者)维护,代码量约 5 万行(其中 src/gen/ 目录下自动生成的 visit/fold/clone 等代码约 2.2 万行),覆盖了 Rust 完整的语法。你给它一段 token,它给你 struct/enum/fn/impl 等高层对象,字段齐全、类型精确。

本章的目标:让你能看懂 serde_derive 里每一次 syn 的调用。我们不会讲 syn 的全部(它有几百个类型),只聚焦 derive 宏最常用的那 20% 概念——DeriveInput、Fields、Type、Attribute、Generics——它们合起来够写绝大多数 derive 宏。

本专栏基于 syn 2.0.117(commit e027fef2)。syn 1.x 和 2.x API 有不兼容变化,读其他教程要注意版本。

7.2 syn 的三层 API

syn 提供三层抽象,由低到高:

第 1 层:解析函数。syn::parse、syn::parse2、syn::parse_str。把 TokenStream 或字符串解析成具体 AST 类型。

use syn::{parse, DeriveInput};
use proc_macro2::TokenStream;

let input: DeriveInput = syn::parse2(token_stream).unwrap();

第 2 层:AST 类型。DeriveInput、Item、Expr、Type、Pat……涵盖 Rust 所有语法构造。每个类型对应 Rust 参考手册里的一个语法节点。

第 3 层:Visitor / Fold。对 AST 做递归遍历或变换。Visit 是只读遍历,VisitMut 是可变遍历,Fold 是函数式变换(返回新树)。

serde_derive 主要用第 1 层和第 2 层——解析 DeriveInput,然后手动遍历它的 fields、variants、attributes。不太用 Visitor,因为 Serde 的代码生成是明确结构的,直接索引更清晰。

7.3 核心类型:DeriveInput

任何 derive 宏的入口都是这个类型(来自 syn/src/derive.rs:13):

// syn/src/derive.rs:13
pub struct DeriveInput {
    pub attrs: Vec<Attribute>,      // 类型上的所有属性
    pub vis: Visibility,             // 可见性(pub、pub(crate) 等)
    pub ident: Ident,                // 类型名
    pub generics: Generics,          // 泛型参数 + where 子句
    pub data: Data,                  // struct/enum/union 的具体内容
}

五个字段精准覆盖"一个类型定义"的所有信息。看它如何对应用户代码:

#[derive(Serialize)]                          ← 不进 attrs
#[serde(rename_all = "camelCase")]            ← attrs 之一
pub(crate) struct User<'a, T: Display>        ← vis + ident + generics
where T: Clone                                 ← generics 的 where 部分
{                                              ← data 开始(DataStruct)
    pub id: u64,
    name: &'a T,
}
  • attrs: [#[serde(rename_all = "camelCase")]]——注意 #[derive(...)] 属性本身会被编译器"吃掉"(它触发了这个宏),不会出现在宏收到的输入里,其他属性保留。
  • vis: Visibility::Restricted(pub(crate))
  • ident: Ident("User")
  • generics: Generics { params: [LifetimeParam('a), TypeParam(T: Display)], where_clause: Some(T: Clone) }
  • data: Data::Struct(DataStruct { fields: [Field(id, u64), Field(name, &'a T)] })

Data 的三个变体(来自 syn/src/derive.rs:31):

pub enum Data {
    Struct(DataStruct),
    Enum(DataEnum),
    Union(DataUnion),
}

derive 宏能作用于这三种——struct、enum、union。Serde 只支持前两种(Container::from_ast 遇到 syn::Data::Union 就 error_spanned_by 一句 "Serde does not support derive for unions" 后返回 None,见 serde_derive/src/internals/ast.rs:80——union 的字段虽有名字,但哪个字段当前有效只有运行时的程序逻辑知道,无法通用序列化)。

7.4 Fields:结构体字段的三种形态

DataStruct 里的 fields 字段是 Fields 类型:

pub enum Fields {
    Named(FieldsNamed),        // struct User { id: u64, name: String }
    Unnamed(FieldsUnnamed),    // struct Point(i32, i32);
    Unit,                       // struct Nothing;
}

对应 Rust 的三种 struct 形态——命名字段、元组结构、单元结构。Fields 的定义在 syn/src/data.rs:36;装着它的 DataStruct 在 syn/src/derive.rs:41。

Field 类型(syn/src/data.rs:185):

pub struct Field {
    pub attrs: Vec<Attribute>,   // 字段上的属性,如 #[serde(rename = "foo")]
    pub vis: Visibility,
    pub mutability: FieldMutability,
    pub ident: Option<Ident>,    // Named 时有,Unnamed 时是 None
    pub colon_token: Option<Token![:]>,
    pub ty: Type,                 // 字段类型
}

关键细节:ident 是 Option<Ident>。命名字段时是 Some,元组字段时是 None——tuple struct 的字段没名字,只能用 self.0、self.1 索引。Serde 在生成代码时必须处理这两种情况——第 12 章会看到 serde_derive 如何统一处理。

遍历字段的典型代码:

use syn::{Data, DeriveInput, Fields};

// 注意:用 {:?} 打印 syn 的 AST 类型,需要给 syn 开 extra-traits feature(默认不开)
fn process(input: DeriveInput) {
    if let Data::Struct(data) = input.data {
        match data.fields {
            Fields::Named(fields) => {
                for field in fields.named {
                    let name = field.ident.unwrap();  // Named 保证有 ident
                    let ty = field.ty;
                    println!("{}: {:?}", name, ty);
                }
            }
            Fields::Unnamed(fields) => {
                for (index, field) in fields.unnamed.iter().enumerate() {
                    println!("field_{}: {:?}", index, field.ty);
                }
            }
            Fields::Unit => {
                println!("unit struct, no fields");
            }
        }
    }
}

7.5 Type:Rust 类型的完整 AST

Type 是 syn 最复杂的枚举之一。它列出 Rust 所有可能的类型形式:

pub enum Type {
    Array(TypeArray),             // [T; N]
    BareFn(TypeBareFn),            // fn(i32) -> i32
    Group(TypeGroup),              // 分组(内部使用)
    ImplTrait(TypeImplTrait),      // impl Iterator
    Infer(TypeInfer),              // _
    Macro(TypeMacro),              // some_macro!()
    Never(TypeNever),              // !
    Paren(TypeParen),              // (T) 括起来的单个类型
    Path(TypePath),                // std::collections::HashMap<K, V>
    Ptr(TypePtr),                  // *const T
    Reference(TypeReference),      // &'a T
    Slice(TypeSlice),              // [T]
    TraitObject(TypeTraitObject),  // dyn Trait
    Tuple(TypeTuple),              // (A, B, C)
    Verbatim(TokenStream),          // 不认识的
}

上面 15 个变体就是 2.0.117 的全部(syn/src/ty.rs:25)——枚举带 #[non_exhaustive](syn/src/ty.rs:24),是给以后的 Rust 新语法留口子,不是说现在还有没列出来的。

对 Serde 来说,最常见的是 TypePath——绝大多数字段类型都是"路径形式"(u64、Vec<u8>、std::collections::HashMap、Option<&'a str> 等都是 TypePath)。

TypePath 的结构:

pub struct TypePath {
    pub qself: Option<QSelf>,
    pub path: Path,
}

pub struct Path {
    pub leading_colon: Option<Token![::]>,
    pub segments: Punctuated<PathSegment, Token![::]>,
}

pub struct PathSegment {
    pub ident: Ident,                // 这一段的名字
    pub arguments: PathArguments,    // 泛型参数或 fn 参数
}

看具体例子:

  • u64:一个 Path,segments = [PathSegment { ident: "u64", arguments: None }]。
  • Vec<u8>:segments = [PathSegment { ident: "Vec", arguments: AngleBracketed([GenericArgument::Type(u8)]) }]。
  • std::collections::HashMap<K, V>:segments 有三段,最后一段带 AngleBracketed([K, V])。
  • Option<&'a str>:segments = [PathSegment { ident: "Option", arguments: AngleBracketed([&'a str]) }]。

syn 给 Type 的处理不做任何"理解"——它不知道 u64 是内建整数、也不知道 Vec 是标准库类型。它只保证把 token 解析对了——具体含义由 derive 宏自己判断。

这对 Serde 有什么影响? Serde 对字段类型其实不关心——它生成 self.field.serialize(serializer),让 trait 系统处理具体类型。Serde 不需要知道类型是什么,只需要类型实现了 Serialize。这种"不做类型分析"的风格让 serde_derive 非常简洁。

7.6 Attribute:属性的 AST 表示

#[serde(rename = "foo")]、#[serde(default)] 这些都是属性(Attribute,syn/src/attr.rs:177):

pub struct Attribute {
    pub pound_token: Token![#],
    pub style: AttrStyle,          // Outer (#[]) 或 Inner (#![])
    pub bracket_token: token::Bracket,
    pub meta: Meta,
}

pub enum Meta {
    Path(Path),                    // #[serde] 或 #[cfg]
    List(MetaList),                // #[serde(rename = "x", default)]
    NameValue(MetaNameValue),      // #[serde = "foo"]  (少见)
}

pub struct MetaList {
    pub path: Path,                // "serde"
    pub delimiter: MacroDelimiter,
    pub tokens: TokenStream,       // 括号内的原始 tokens
}

(Meta 在 syn/src/attr.rs:477,MetaList 在 syn/src/attr.rs:491,MetaNameValue 在 syn/src/attr.rs:501——注意 2.0 的 MetaNameValue::value 是 Expr,不再是 1.0 的 Lit。)

重要细节:MetaList::tokens 是未解析的 TokenStream——里面 rename = "x", default 需要宏自己解析。syn 不为你做这一步,因为每个宏对属性语法的理解可能不同。

serde 如何解析自己的属性?serde_derive 在 serde_derive/src/internals/attr.rs 里写了自己的属性解析逻辑:对每个 #[serde(...)] 调下面要讲的 parse_nested_meta,在闭包里识别 rename、skip、default、flatten 等具体属性。第 11 章会深入这段代码。

但对简单属性解析,syn 2.x 有一个便利 API parse_nested_meta(syn/src/attr.rs:395,一行转给 syn/src/meta.rs:132 的 meta::parser;闭包收到的 ParseNestedMeta 只有 path 和 input 两个字段,syn/src/meta.rs:164):

for attr in input.attrs {
    if attr.path().is_ident("serde") {
        attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("rename") {
                let value = meta.value()?;  // 取 = 后面的值
                let lit: LitStr = value.parse()?;
                // 处理 rename = "..."
            }
            Ok(())
        })?;
    }
}

serde_derive 用的也正是 parse_nested_meta(serde_derive/src/internals/attr.rs:275 容器属性、:775 变体属性、:1074 字段属性),只是闭包里的分支多得多,出错时用 Ctxt 攒起来而不是直接返回(§7.12)。

7.7 Generics:泛型的完整表达

struct User<'a, T: Display + Clone> where T: 'static 这种类型定义,它的泛型信息存在 Generics:

pub struct Generics {
    pub lt_token: Option<Token![<]>,
    pub params: Punctuated<GenericParam, Token![,]>,
    pub gt_token: Option<Token![>]>,
    pub where_clause: Option<WhereClause>,
}

pub enum GenericParam {
    Lifetime(LifetimeParam),    // 'a
    Type(TypeParam),             // T: Display + Clone
    Const(ConstParam),           // const N: usize
}

对 Serde 来说,Generics 最关键的用法是生成 impl 块:

// 用户写:
struct User<'a, T: Display> { ... }

// 需要生成:
impl<'a, T: Display> Serialize for User<'a, T>
where
    T: Serialize,   // 继承原泛型参数,再为 T 补上 Serialize bound
{
    ...
}

Generics 提供一个便利方法 split_for_impl():

let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();

quote! {
    impl #impl_generics Serialize for MyType #ty_generics #where_clause {
        ...
    }
}
  • impl_generics: <'a, T: Display + Clone>——用于 impl 声明
  • ty_generics: <'a, T>——用于类型引用(不带 bound)
  • where_clause: where T: 'static——原始的 where 子句

(split_for_impl 在 syn/src/generics.rs:177,返回的正是 (ImplGenerics, TypeGenerics, Option<&WhereClause>);ImplGenerics 的 ToTokens(syn/src/generics.rs:1199-1249)会保留 bound、但丢掉参数默认值——syn/src/generics.rs:1227 那句注释 "Leave off the type parameter defaults" 管类型参数,紧挨着的 syn/src/generics.rs:1236 还有一句 "Leave off the const parameter defaults" 管 const 参数。原因很直白:struct Foo<T = u8> 的 = u8 只能出现在类型定义处,写进 impl<T = u8> 是语法错误。)

serde_derive 要做比这更复杂的事:它要给字段里用到的类型参数补上 Serialize 约束——做法是往 where 子句里追加 T: _serde::Serialize 这样的谓词,而不是改 TypeParam 的 bounds(serde_derive/src/bound.rs:98 的 with_bound)。这在第 10 章架构讲解时会详细看。

7.8 parse_macro_input! 与 parse2

过程宏入口的常见开头:

// serde_derive/src/lib.rs:113
#[proc_macro_derive(Serialize, attributes(serde))]
pub fn derive_serialize(input: TokenStream) -> TokenStream {
    let mut input = parse_macro_input!(input as DeriveInput);
    // ...
}

parse_macro_input! 是 syn 提供的便利宏(syn/src/parse_macro_input.rs)。它做两件事:

  1. 调用 syn::parse::<T>(input) 把 proc_macro::TokenStream 解析成指定类型(这里是 DeriveInput)。它并不是另一条解析路径:syn::parse 只是 parse::Parser::parse(T::parse, tokens)(syn/src/lib.rs:909),而 Parser::parse 的默认实现就是 self.parse2(proc_macro2::TokenStream::from(tokens))(syn/src/parse.rs:1255)——最终仍然走 parse2,区别只在入参类型。
  2. 如果解析失败,返回编译错误 TokenStream——不是 panic,而是优雅的 compile_error!(...) 输出。

如果自己动手做等价工作:

let input = match syn::parse::<DeriveInput>(input) {
    Ok(x) => x,
    Err(e) => return e.to_compile_error().into(),
};

parse_macro_input! 把这四行压缩成一行(宏体见 syn/src/parse_macro_input.rs:108)。两者功能等价。

三个解析函数的区别:

  • syn::parse<T>(input: proc_macro::TokenStream) -> Result<T>:只能在 proc-macro crate 用。
  • syn::parse2<T>(input: proc_macro2::TokenStream) -> Result<T>:任何 crate 可用,这是测试里常用的。
  • syn::parse_str<T>(s: &str) -> Result<T>:从字符串解析(先 lex 再 parse)。用于调试或 quick hack。

7.9 Parse trait:写自己的解析器

syn 的核心抽象是 Parse trait(syn/src/parse.rs:215):

pub trait Parse: Sized {
    fn parse(input: ParseStream) -> Result<Self>;
}

syn 的所有 AST 类型都实现了 Parse。你也可以给自己的类型实现 Parse,让它们能被 syn::parse::<MyType>(tokens) 解析。

看一个自定义宏输入的最小例子(my_macro!(ident = expr)):

use syn::{parse::{Parse, ParseStream}, Expr, Ident, Token};

struct MyInput {
    ident: Ident,
    eq_token: Token![=],
    expr: Expr,
}

impl Parse for MyInput {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        Ok(MyInput {
            ident: input.parse()?,         // 解析一个 Ident
            eq_token: input.parse()?,      // 解析 =
            expr: input.parse()?,           // 解析任意表达式
        })
    }
}

ParseStream 是一个消耗性游标——每次 .parse() 都向前推进。如果解析失败(遇到不匹配的 token),返回带 Span 的错误。

ParseStream 的常用方法:

  • .parse::<T>():解析一个 T 类型(syn/src/parse.rs:466)
  • .parse_terminated(parser, separator):解析 "T sep T sep T" 形式(syn/src/parse.rs:735)
  • .peek(Token![xxx]):向前看一个 token 但不消耗(syn/src/parse.rs:582;还有 .peek2() / .peek3(),syn/src/parse.rs:621 与 syn/src/parse.rs:631)
  • .lookahead1():带自动错误信息的向前看(syn/src/parse.rs:837,返回的 Lookahead1 在 syn/src/lookahead.rs:65,攒够候选后 .error() 按候选个数分档报错:1 个是 expected X、2 个是 expected X or Y、3 个以上才是 expected one of: ...,见 syn/src/lookahead.rs:115)
  • .fork():克隆当前位置,支持"尝试解析、失败回退"(syn/src/parse.rs:967)

顺带一提,ParseStream 本身不是结构体,只是 type ParseStream<'a> = &'a ParseBuffer<'a>(syn/src/parse.rs:225)——真正带状态的是 ParseBuffer(syn/src/parse.rs:247)。

这些工具让写自定义 parser 变得可控。大部分 derive 宏不需要自己写 Parse——DeriveInput::parse 已经足够。但如果写"复杂 DSL 宏"(像 html!,或 syn examples 目录里用过程宏重写的 lazy_static!),就需要深度使用 Parse trait。

7.10 serde_derive 里的 syn 使用模式

现在把前面的概念落回到 serde_derive。看它如何从 DeriveInput 提取信息。

第一步:parse DeriveInput。

// serde_derive/src/lib.rs:115
let mut input = parse_macro_input!(input as DeriveInput);

第二步:根据 Data 分派。

serde_derive 在 serde_derive/src/internals/ast.rs 里定义了自己的 AST(在 syn AST 之上的进一步加工):

// internals/ast.rs(简化)
pub struct Container<'a> {
    pub ident: syn::Ident,
    pub attrs: attr::Container,         // serde 专属属性
    pub data: Data<'a>,                 // serde 的 Data 版本
    pub generics: &'a syn::Generics,
    pub original: &'a syn::DeriveInput,
}

pub enum Data<'a> {
    Enum(Vec<Variant<'a>>),
    Struct(Style, Vec<Field<'a>>),
}

注意:serde 不直接用 syn::Data 和 syn::Field——它加工了一层 Container/Variant/Field。这一层加工做的事:

  1. 把 syn::Attribute 解析成 serde 专属的 attr::Field、attr::Variant、attr::Container(包含 rename、default、flatten 等所有 serde 语义)。
  2. 把 syn::Fields::Named/Unnamed/Unit 统一成 Style::Struct/Tuple/Unit/Newtype。
  3. 保留 original 引用指向 syn 原始数据,方便生成代码时取 span。

这一层加工是 serde_derive 的核心设计。它把"通用 Rust AST"转换成"serde 视角的 AST",后续所有代码生成都基于这层。第 10 章会详细展开。

第三步:基于 Container 生成代码。

// serde_derive/src/ser.rs:13 起(简化,不是源码原文)
pub fn expand_derive_serialize(input: &mut syn::DeriveInput) -> syn::Result<TokenStream> {
    let ctxt = Ctxt::new();
    // 第 4 个参数 `&private.ident()` 是带版本号的 `__private228` 标识符(第 9 章 9.14 节)
    let Some(cont) = Container::from_ast(&ctxt, input, Derive::Serialize, &private.ident()) else {
        return Err(ctxt.check().unwrap_err());
    };
    ctxt.check()?;

    // 根据 cont.data 是 Struct 还是 Enum 生成代码(serialize_body 内部再按 Style 分派)
    let ident = &cont.ident;
    let params = Parameters::new(&cont);
    let (impl_generics, ty_generics, where_clause) = params.generics.split_for_impl();
    let body = Stmts(serialize_body(&cont, &params));

    // 组装 impl 块
    let impl_block = quote! {
        #[automatically_derived]
        impl #impl_generics _serde::Serialize for #ident #ty_generics #where_clause {
            fn serialize<__S>(&self, __serializer: __S) -> _serde::#private::Result<__S::Ok, __S::Error>
            where
                __S: _serde::Serializer,
            {
                #body
            }
        }
    };

    Ok(dummy::wrap_in_const(cont.attrs.custom_serde_path(), impl_block))
}

这段代码(高度简化)包含 serde_derive 核心流程:解析 AST → 转成 serde AST → 分派到具体生成器 → 用 quote 组装 impl 块 → 返回 TokenStream。

7.11 Punctuated:有分隔符的序列

经常在 syn AST 里看到的一个类型:

pub struct Punctuated<T, P> { ... }

// 例子:
fields: Punctuated<Field, Token![,]>
params: Punctuated<GenericParam, Token![,]>

Punctuated<T, P> 表示"用 P 分隔的多个 T"——比如字段列表用逗号分隔。

为什么不直接用 Vec<T>? 因为要保留分隔符的 Span 信息——尾随逗号(, })、缺失分隔符等都要区分。Punctuated 是带分隔符版本的 Vec。

它的遍历非常像 Vec:

for field in fields.named.iter() {
    // field: &Field
}

fields.named.iter().map(|f| &f.ident).collect();

也实现了 IntoIterator、Extend 等,用起来和 Vec 几乎一样。

7.12 Error:带 span 的结构化错误

syn 的错误类型不是字符串,是带源位置的结构化对象(syn/src/error.rs:105):

pub struct Error { ... }

impl Error {
    pub fn new(span: Span, message: impl Display) -> Self;
    pub fn new_spanned<T: ToTokens, U: Display>(tokens: T, message: U) -> Self;
    pub fn to_compile_error(&self) -> TokenStream;
    pub fn into_compile_error(self) -> TokenStream;
}

(new 在 syn/src/error.rs:163、new_spanned 在 syn/src/error.rs:194、to_compile_error 在 syn/src/error.rs:230、into_compile_error 在 syn/src/error.rs:270。注意 new_spanned 的 bound 是 ToTokens 不是 Spanned——Spanned 是一个 sealed trait,反过来给所有 T: ToTokens 做了兜底实现,syn/src/spanned.rs:96 与 syn/src/spanned.rs:104。)

错误转 TokenStream 是关键——把 syn 错误变成 compile_error!("...") 调用,编译器会把这段代码里的 compile_error! 报告为编译错误,错误指向 span 位置。

serde_derive 的错误模式(来自 serde_derive/src/lib.rs:117):

ser::expand_derive_serialize(&mut input)
    .unwrap_or_else(syn::Error::into_compile_error)
    .into()

expand_derive_serialize 返回 syn::Result<TokenStream>。如果成功返回生成的代码;如果失败调用 into_compile_error 把错误转成 compile_error! 代码。用户看到的错误会指向他们代码里的具体问题位置——"这个字段的属性不对"、"这个变体的名字冲突"等。

多错误收集:serde_derive 有一个 Ctxt 类型专门做这件事——把所有解析中遇到的错误攒起来,最后一次性报告。这让用户一次看到所有问题,而不是"改一个看下一个"。

Ctxt 的真实实现(serde_derive/src/internals/ctxt.rs)只有 67 行,但每一行都在解决一个具体工程问题。贴出来(删去了文档注释,压缩了换行):

#[derive(Default)]
pub struct Ctxt {
    errors: RefCell<Option<Vec<syn::Error>>>,
}

impl Ctxt {
    pub fn new() -> Self {
        Ctxt { errors: RefCell::new(Some(Vec::new())) }
    }

    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 syn_error(&self, err: syn::Error) {
        self.errors.borrow_mut().as_mut().unwrap().push(err);
    }

    pub fn check(self) -> syn::Result<()> {
        let mut errors = self.errors.borrow_mut().take().unwrap().into_iter();
        let Some(mut combined) = errors.next() else { 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. impl Drop 是"忘记检查"检测器。注意 check(self) 吃 self、并用 .take() 把 Option<Vec<_>> 里的 Some(...) 置为 None。如果用户创建了 Ctxt 但没调 check() 就让它离开作用域,Drop 里 self.errors.borrow().is_some() 成立——直接 panic!("forgot to check for errors")。

作用是静态无法表达的一个契约:Rust 的类型系统不能表达"这个对象必须调用某个方法才能销毁"(Rust 没有 linear types),用 Drop 里的运行时 panic 补上这个约束。开发者第一次写一个新 derive 但忘了 ctxt.check()? 会当场崩掉,立刻注意到问题。这是 dtolnay 代码里常见的"linear type 模拟"手法。

2. RefCell<Option<Vec<_>>> 的 Option 看似冗余,其实是 consume-once 标记。如果只有 RefCell<Vec<syn::Error>>,check() 里把 Vec 取走之后 Vec 还在(只是空的),Drop 里没法区分"还没调 check"和"调过 check 但没错误"。用 Option 包一层——take() 后变 None、Drop 里 is_some() 精确判断——也绕开了另一条限制:Ctxt 实现了 Drop,check(self) 不能把字段整个 move 出来(E0509),只能借着 RefCell 原地 take()。

3. error_spanned_by 的反单态化注释——源码原文是 "Curb monomorphization from generating too many identical methods"。如果直接 syn::Error::new_spanned(obj, msg),每种 A: ToTokens 的调用点都会单态化出一份 new_spanned。提前 obj.into_token_stream() 转成统一的 TokenStream,new_spanned 的第一个类型参数就固定成了 TokenStream,只随消息类型 T 变化,不再乘上 A 的种类——编译出来的代码更小。这和第 3 章 tri! 宏省编译时间是同一种"每一行都为编译时间较真"的思维。

4. check() 里 combined.combine(rest) 把多个错误拼成一个。syn::Error::combine 是 syn 提供的"错误合并"API——它做的事就是 self.messages.extend(another.messages)(syn/src/error.rs:276),而 to_compile_error 是对 self.messages 逐条生成(syn/src/error.rs:230):所以最终产出的单个 syn::Error 会在 to_compile_error() 时产生多个 compile_error!(...) 调用,每个指向各自的 span。用户在 IDE 里会同时看到所有错误标记,而不是"修一个出一个"的打地鼠体验。

这种"累积错误再报告"的模式在复杂过程宏里很常见。它比"第一个错误就退出"的体验好得多。读懂了 Ctxt 的 67 行实现,你不只是学会一个模式——更看到了一个高手在每个细节处的工程决策密度:Drop 契约、类型状态模拟、反单态化、错误合并——都挤在这么小的一个类型里。

7.13 从本系列其他专栏看 syn 的应用

syn 不只在 Serde 里用。Rust 生态中几乎所有过程宏库都基于它:

  • Tokio:tokio-macros 里的 #[tokio::main] 用 syn 解析 async fn,然后重写成带 runtime 启动的同步函数。《Tokio 源码深度解析》专栏第 4 章讨论了这个宏如何和 runtime 集成——阅读时注意它如何处理属性参数 #[tokio::main(flavor = "current_thread")]。
  • Axum:axum-macros 里的 #[derive(FromRequest)]、#[axum::debug_handler] 都基于 syn 的 AST 操作。
  • sqlx:query! 宏用 syn 解析 Rust 侧的宏输入(QueryMacroInput 自己实现了 Parse,sqlx-macros-core/src/query/input.rs:35);SQL 侧不是自己 parse 的——它把 SQL 原样交给真实数据库 describe 一遍,拿回列与参数的类型(sqlx-macros-core/src/query/mod.rs:223 的 DB::describe_blocking),离线时读 cargo sqlx prepare 生成的缓存。sqlx 0.8.6 的依赖里根本没有 SQL parser。
  • clap:#[derive(Parser)] 用 syn 解析 struct,生成命令行解析代码。每个字段的属性(#[arg(short)] 等)都走 syn 的 attribute 处理流程。

如果你读过《Rust 编译器与运行时揭秘》专栏第 14 章,你会发现 syn 的 DeriveInput 和 rustc 内部 ast::ItemKind::Struct/Enum/Union 很像(注意是 ItemKind 不是 Item:rustc 1.96.0 源码 compiler/rustc_ast/src/ast.rs 第 3654 行的 Item 只有 attrs / id / span / vis / kind / tokens,泛型和名字都装在第 4039、4043、4047 行那三个 ItemKind 变体里)。不过 syn 是独立实现的解析器,并不暴露 rustc 的 AST,只是两者的设计思路相近。

7.14 源码实证:syn-2.0.117 的 derive.rs 260 行

打开 syn 仓库 e027fef(即声明的 2.0.117)的 src/derive.rs:10-36,DeriveInput + Data 的完整定义就这二十几行(下面这块去掉了文档注释和 #[cfg_attr(docsrs, ...)],不是逐字原文):

ast_struct! {
    pub struct DeriveInput {
        pub attrs: Vec<Attribute>,
        pub vis: Visibility,
        pub ident: Ident,
        pub generics: Generics,
        pub data: Data,
    }
}

ast_enum! {
    pub enum Data {
        Struct(DataStruct),
        Enum(DataEnum),
        Union(DataUnion),
    }
}

四个值得注意的设计:

  • DeriveInput 的五个字段对应 Rust 语法 #[attr] pub struct Name<T> { fields } 的五个语法元素,一一对应。
  • Data 是 tagged enum:Struct / Enum / Union 三种顶层类型都是合法的 derive 输入。
  • Token![struct](不在上面这段里,紧随其后的 DataStruct 用它存 struct_token,syn/src/derive.rs:42)是 syn 的 token 宏,在类型层面表示"struct 关键字"。
  • ast_struct! / ast_enum! 是 syn 内部的 DSL,但它们并不自动派生 Debug / Clone / PartialEq,也不生成 Parse:这两个宏基本只是把定义原样透传(syn/src/macros.rs:5 与 syn/src/macros.rs:41),派生实现另有出处,见下一节。

7.15 ast_struct! 宏做了什么、没做什么

syn 的 ast_struct! / ast_enum! / ast_enum_of_structs! 三个宏定义在 syn/src/macros.rs(182 行),每个 AST 类型都走其中之一。它们本身做的事比名字暗示的少:

  • ast_struct!:基本只是把定义原样透传;带 #full 标记的类型在没开 full feature 时会被替换成一个只含 PhantomData<Span> 的占位 struct,并给出一个 unreachable!() 的 ToTokens impl
  • ast_enum!:同样只是透传(check_keyword_matches! 做点语法检查)
  • ast_enum_of_structs!:多做两件事:为每个变体生成 From<TypeXxx> for Type impl(syn/src/macros.rs:86,其中 Verbatim 变体被显式跳过,syn/src/macros.rs:88),以及(feature="printing")按变体分派的 ToTokens impl(syn/src/macros.rs:99)

Clone / Debug / PartialEq / Eq / Hash / Visit / VisitMut / Fold 这些实现并不是宏生成的——它们由仓库里独立的 syn-internal-codegen 工具生成在 src/gen/(syn/codegen/Cargo.toml 第 2 行的 crate 名;每个生成文件的第一行都写着 "This file is @generated by syn-internal-codegen"。别和 syn/json/Cargo.toml 里那个对外发布的 syn-codegen 混了,后者只是 AST 元数据的 schema)(clone.rs、debug.rs、eq.rs、hash.rs、visit.rs、visit_mut.rs、fold.rs),各自 feature-gated;Parse 和大部分 ToTokens impl 则是各文件里手写的 parsing / printing 子模块。

syn 内部有 184 处 ast_struct! / ast_enum! / ast_enum_of_structs! 调用(151 + 19 + 14),src/gen/ 的 7 个 .rs 共 22299 行是机器生成的,占 src/ 全部 50057 行的四成有余。

7.16 attr.rs 841 行:属性的语法形态

syn/src/attr.rs 有 841 行,要应付的属性写法很多:

  • #[serde](裸)
  • #[serde(rename = "x")](key-value)
  • #[serde(rename_all = "camelCase")](更复杂 key-value)
  • #[serde(skip_serializing_if = "Option::is_none")](带 path)
  • #[doc = "..."](doc 属性)
  • #[cfg(feature = "foo")](cfg 属性)
  • #[serde(with = "module::path")](带字符串)
  • #[serde(bound(deserialize = "T: Clone"))](嵌套)

这 8 种常见形态解析规则各不相同,syn 用 Meta::Path / MetaList / MetaNameValue 三种结构把它们分开,让解析器按结构分治。核心 API 是 attr.parse_nested_meta(|meta| { ... }):用户在 closure 里逐项处理,比 syn 1.0 的 NestedMeta 枚举匹配友好得多。

7.17 syn 2.0 vs syn 1.0 的 breaking change

本章用 syn 2.0(当前版本 2.0.117),但生态里还有大量 syn 1.0 代码。两版最大的差异是 NestedMeta:

syn 1.0:

let nested = meta_list.nested;  // syn 1:Punctuated<NestedMeta, Token![,]>
for item in nested {
    match item {
        NestedMeta::Meta(Meta::NameValue(MetaNameValue { path, lit, .. })) => { ... }  // syn 1 的值字段叫 lit: Lit
        NestedMeta::Meta(Meta::Path(path)) => { ... }
        NestedMeta::Lit(Lit::Str(s)) => { ... }
        _ => { ... }  // Meta::List 与其他字面量
    }
}

syn 2.0:

meta_list.parse_nested_meta(|meta| {
    if meta.path.is_ident("key") {
        let value = meta.value()?.parse::<Lit>()?;
    }
    Ok(())
})?;

2.0 的写法更扁平:meta.path / meta.value() 一目了然,错误自动带位置信息。代价是用 syn 的 crate 几乎都要重写属性解析,是一次波及全生态的 breaking change。serde_derive 在 1.0.157(2023 年 3 月,commit 32f0d00f "Update to syn 2")完成了 syn 2.0 迁移。

7.18 Punctuated<T, P> 的内部表示

§7.11 讲过 Punctuated<T, P> 是"由 P 分隔的 T 列表",常见的有 Punctuated<Variant, Token![,]>(enum 变体)、Punctuated<Field, Token![,]>(struct 字段)、Punctuated<GenericParam, Token![,]>(泛型参数列表)。

它要保留"末尾有没有逗号"这个信息,所以内部是 inner: Vec<(T, P)> 加一个 last: Option<Box<T>>:尾随分隔符存在时 last 为 None,否则最后一个元素单独放在 last 里。

遍历有两种方式:

  • iter() 只遍历 T,最常用
  • pairs() 遍历 Pair<&T, &P>(带分隔符的 Pair::Punctuated(t, p),或末尾不带分隔符的 Pair::End(t),syn/src/punctuated.rs:959),保留格式信息

7.19 Ident 和 Type 的 ToTokens 行为

syn::Ident 实现了 ToTokens,能直接嵌入 quote! 输出:

let name = &input.ident;  // 假设是 MyStruct
quote! { impl #name { } }  // 输出:impl MyStruct { }

#name 是 quote 的插值语法,内部调用 name.to_tokens(&mut out)。syn::Type 同样实现了 ToTokens,#field_type 会直接输出 i32、Vec<String> 这样的类型表达。syn 的 AST 节点都知道"如何变回代码",derive 宏才能顺畅地读、改、写。

7.20 proc_macro2::TokenStream vs proc_macro::TokenStream

两者的区别第 6 章 §6.3 已经讲过:proc_macro::TokenStream 只在编译器展开宏时可用,proc_macro2::TokenStream 在任何代码里都能用。落到 derive 宏的入口,常见写法是:

#[proc_macro_derive(MyDerive)]
pub fn my_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
    let input = proc_macro2::TokenStream::from(input);
    let output = expand(input);  // 纯函数、用 proc_macro2
    proc_macro::TokenStream::from(output)
}

proc_macro → proc_macro2 → expand → proc_macro2 → proc_macro 这层夹心让 expand 可以在普通测试里调用,是成熟过程宏库常见的套路。

7.21 Spanned 与错误的源位置

好的 derive 宏报错时要指向出错的具体位置(哪一行、哪个字段)。syn 提供 Span 类型和 Spanned trait,每个 AST 节点都有 span() 方法:

if field_name == "id" {
    return Err(syn::Error::new(field.span(), "field named `id` is reserved"));
}

生成的错误消息:

error: field named `id` is reserved
 --> src/main.rs:5:5
  |
5 |     id: String,
  |     ^^

箭头指向的正是出问题的字段。多个这样的错误如何一次报完,见 §7.12 的 Ctxt。

7.22 derive 宏和属性宏的边界

第 5 章 §5.5 列过三种过程宏。放在 syn 的视角下再对比一次:

  • derive:#[derive(MyTrait)],只能附加到 struct/enum/union,不能修改原定义,只能生成额外代码
  • attribute:#[my_attr],能替换原定义,用于改写用户代码
  • function-like:my_macro!(...),像函数调用,输入输出完全自定义

三者的入口签名都是接 proc_macro::TokenStream、返回 proc_macro::TokenStream(attribute 宏收两个:属性参数和被修饰的项),但能做的事不同。serde_derive 只用 derive:不改用户 struct,只追加 impl 块。#[tokio::main] 是 attribute:把 async fn main 改写成 fn main + tokio runtime。选哪种,看你要"追加"还是"改写"。

7.23 Visit 和 VisitMut:AST 遍历的访问者模式

syn 提供 Visit / VisitMut trait,让你遍历整棵 AST,不必自己写 match:

use syn::{visit::Visit, ItemFn};

struct FnCounter(usize);

impl<'ast> Visit<'ast> for FnCounter {
    fn visit_item_fn(&mut self, i: &'ast ItemFn) {
        self.0 += 1;
        // 继续递归访问子节点
        syn::visit::visit_item_fn(self, i);
    }
}

let mut counter = FnCounter(0);
counter.visit_file(&parsed_file);
println!("Found {} functions", counter.0);

查找类任务用 Visit 计数或收集,改写类任务用 VisitMut 原地替换。serde_derive 不用 syn 的 VisitMut(它根本没开 visit-mut feature,§7.24),因为它不改用户代码;需要改写函数体的属性宏才是 VisitMut 的主要用户。

visit / visit_mut 的生成——syn 的 syn/src/gen/visit.rs(3941 行,全部机器生成)里,Visit trait 有 188 个 visit_* 方法(每个 AST 类型一个),每个方法的默认体只有一行:转调同名的自由函数 syn::visit::visit_xxx——这类自由函数有 187 个(唯一没有配对自由函数的是 visit_token_stream,因为 TokenStream 是叶子、没有子节点可递归)。所以 grep -c 'fn visit_' 会数出 375,那是 188 + 187 两类之和,不是"375 个 AST 类型"。

这个"trait 方法 + 同名自由函数"的双份结构是有意的:你重写 visit_item_fn 打断了默认递归,想继续往下走就得手动调一次自由函数 syn::visit::visit_item_fn(self, i)——上面例子里那一行正是干这个。

7.24 syn 的 feature flag 策略

syn = "2.0" 默认启用一组 feature,可以按需裁剪:

  • default = ["derive", "parsing", "printing", "clone-impls", "proc-macro"]
  • full:完整 Rust 语法支持(包括 expr、stmt、pat 等)
  • visit / visit-mut:visitor traits
  • extra-traits:Debug / Eq / Hash impls(编译慢、只在测试时开)

derive 宏的最小 feature——["derive", "parsing", "printing", "proc-macro"](少了 proc-macro 就没有 syn::parse 和 parse_macro_input!)。关闭 clone-impls / extra-traits 往往能减少编译负担,但具体收益取决于项目规模、增量编译状态和依赖图,应以 cargo build --timings 或 CI 数据验证。

要解析任意表达式、语句的函数式宏通常要开 full,代价是编译更慢。serde_derive 的 Cargo.toml 只开 ["clone-impls", "derive", "parsing", "printing", "proc-macro"](即默认集,不开 full / extra-traits / visit)。

7.25 syn 的性能考量

syn 被大量项目依赖,它自身的编译和解析开销会反映到下游的 cargo check 时间上。几处与此相关的设计:

  • feature 裁剪——full / visit / extra-traits 全部按需开启,derive 宏默认只编译 derive 子集,减少 syn 自身的编译量
  • Parse 路径不构造中间字符串——ParseStream 直接在 proc_macro2::TokenStream 的 token tree 上游标推进(syn/src/buffer.rs 的 TokenBuffer / Cursor),失败回退靠 fork() 而不是复制整段输入
  • ast_struct! #full 占位——没开 full feature 时,表达式/语句等类型退化成 PhantomData 占位,少编译大量代码
  • Punctuated 用普通 Vec<(T, P)>——没有 SmallVec 之类的花哨结构,Ident 与 Span 也直接复用 proc_macro2 的类型,不自己做 interning

syn 的解析时间通常不是 cargo check 的瓶颈——真正的大头是 syn 自身的编译(一次性)和 derive 生成代码的类型检查。反过来,derive 宏作者自己写的逻辑可能很慢:derive 里如果有 O(N²) 算法,字段一多就会明显拖慢编译。

7.26 syn 与 Rust 其他元编程能力的对比

Rust 的元编程能力可以分三层,syn 在第三层:

层 能做什么 限制
#[cfg] 条件编译 整块 include/exclude 不看代码结构
macro_rules! pattern 匹配、简单重复 不读类型信息
proc macro(#[proc_macro_derive] / #[proc_macro_attribute] / #[proc_macro]) 任意 AST 操作 编译慢、需要独立 crate

何时选哪个:

  • 简单重复(如 vec![1, 2, 3])→ macro_rules!
  • 读类型结构 + 生成代码(如 derive)→ proc macro + syn
  • feature-guarded 整块代码(如 no-std)→ cfg

7.27 实战:把一个"字段加前缀"的 derive 写出来

用本章的知识写一个 #[prefix_all = "my_"] 的 derive:

#[derive(PrefixedFields)]   // 不能再同时 derive(Deserialize):第 4 步会自己生成 impl,两份会冲突
#[prefix_all = "my_"]
struct Config {
    name: String,  // 读时用 my_name
    port: u16,     // 读时用 my_port
}

实现步骤:

  1. parse_macro_input!(input as DeriveInput)
  2. 找 #[prefix_all = "..."] 属性——用 input.attrs.iter().find(...) + attr.meta.require_name_value()(这是 Meta::NameValue 形态,parse_nested_meta 只收括号形式,会报 "expected parentheses";derive 入口还要写 #[proc_macro_derive(PrefixedFields, attributes(prefix_all))] 声明这个属性)
  3. 遍历 DataStruct::fields——为每个字段算出加前缀后的名字 my_{name}(derive 宏不能修改原 struct,所以不能往字段上追加 #[serde(rename)])
  4. 用 quote! 手写一个以前缀名做 key 的 impl Deserialize for Config

几十行代码就能跑通,前面各节的 API 已经够用。

7.28 macro_rules! 和 proc macro 的混合使用

有些场景把声明宏和过程宏组合起来效果最好:入口是 macro_rules!,负责把用户输入规整化;内部再转发给过程宏处理复杂语义。

反过来的例子是 serde_json::json!:它从头到尾都是声明宏,并不转发给过程宏(§7.33),直接把 JSON 风格的语法变成 Value 构造代码,好处是不用额外编译一个 proc-macro crate 和 syn。经验是:能用 macro_rules! 解决的就不用过程宏,过程宏的编译代价大、调试也难。

7.29 Token![xxx] 宏的背后

本章多处出现 Token![struct] / Token![,],这是 syn 提供的类型别名,每个关键字和符号都有对应类型:

  • Token![struct] → syn::token::Struct
  • Token![,] → syn::token::Comma
  • Token![=] → syn::token::Eq
  • Token![->] → syn::token::RArrow
  • Token![=>] → syn::token::FatArrow
  • Token![::] → syn::token::PathSep

约 100 种 token(关键字 + 标点 + 3 种定界符),每种都是一个 struct,syn 的 src/token.rs(1094 行)用 define_keywords! / define_punctuation! / define_delimiters! 三个宏把它们全部定义出来。

类型化的好处是 AST 的语法结构能被精确表达:比如 Signature { fn_token: Token![fn], ident: Ident, ... }(syn/src/item.rs:796)清楚写出"这里有 fn 关键字",而不是存一个 String。derive 宏里很少手写 Token![xxx],quote! 会处理这些,但读 syn 的 AST 定义时会频繁遇到。

7.30 syn::parse_str 与本地测试

syn 提供 parse_str,把字符串解析成任意 AST 类型,不需要 proc-macro 上下文:

let ty: syn::Type = syn::parse_str("Vec<Option<String>>")?;
let expr: syn::Expr = syn::parse_str("1 + 2 * 3")?;
let derive: syn::DeriveInput = syn::parse_str("struct Foo { x: i32 }")?;

本地调试时可以 let x = parse_str(...).unwrap(); println!("{:#?}", x);(打印要开 extra-traits)交互式地探索 syn AST。它也让单元测试不必搭 proc-macro harness,能在普通 #[test] 里测 derive 逻辑:

#[test]
fn test_my_derive() {
    let input = syn::parse_str::<DeriveInput>("struct Foo { x: i32 }").unwrap();
    let output = expand(input);  // 你的 derive 逻辑
    let output_str = output.to_string();
    assert!(output_str.contains("impl Deserialize for Foo"));
}

开发时的闭环是:改代码 → cargo test → 看输出字符串,不用跑完整编译。

7.31 prettyplease + syn:格式化宏输出

过程宏生成的代码打印出来默认挤在一行,很难读。可以用 prettyplease crate 美化(syn::File 要开 syn 的 full feature):

let tokens: TokenStream = quote! { ... };
let syntax_tree: syn::File = syn::parse2(tokens).unwrap();
let formatted = prettyplease::unparse(&syntax_tree);
println!("{}", formatted);

原本挤成一行的 impl Deserialize for MyStruct { ... } 会变成正常缩进的多行代码。另外,cargo expand(cargo-expand crate)也能看宏展开后的完整代码。

7.32 ItemFn / ItemStruct / ItemEnum 等顶层 Item 类型

除了 DeriveInput,syn 还有大量顶层 Item 类型,主要给属性宏用(这些类型都要开 full feature):

  • ItemFn —— fn foo() {}
  • ItemStruct —— struct S {}
  • ItemEnum —— enum E {}
  • ItemImpl —— impl Foo for Bar {}
  • ItemMod —— mod name {}
  • ItemUse —— use crate::foo;
  • ItemMacro —— macro_rules! foo { ... }
  • ItemTrait —— trait T {}

每种对应 Rust 语法的一个顶层构造。属性宏的入口是 fn(attr: TokenStream, item: TokenStream) -> TokenStream,item 往往 parse 成上面某一种;如果不确定属性会附加到什么上,就 parse 成囊括所有顶层类型的 Item enum,再 match。

7.33 Expr:表达式的 AST

syn 的 Expr 是个有 40 个变体的大 enum,对应 Rust 的各种表达式:

  • Expr::Lit —— 42 / "hi" / true
  • Expr::Binary —— a + b
  • Expr::Call —— foo(a, b)
  • Expr::MethodCall —— x.foo()
  • Expr::If —— if cond { ... }
  • Expr::Match —— match x { ... }
  • Expr::Closure —— |x| x + 1
  • Expr::Async —— async { ... }
  • Expr::Block —— { ... }

derive 宏很少用 Expr,因为 derive 只生成 impl,不改用户的表达式。

用 Expr 的是那些真要读用户表达式的宏——但要真用得上 Expr,前提是 syn 开了 full feature。Expr 的 40 个变体名一直都在,但其中 24 个变体装的那个 struct 在定义时带着 #full 标记(syn/src/expr.rs:274 的 pub struct ExprArray #full 是第一个,expr.rs 里共 24 处)——按 §7.15 讲过的 ast_struct! 规则,不开 full 时它们会退化成只含 PhantomData<Span> 的占位 struct,ToTokens 直接 unreachable!()。serde_derive 恰恰不开 full(§7.24)。

这里要澄清一个常见误解:serde_json 的 json!({...}) 不是这类宏。它是纯粹的 macro_rules!(serde_json/src/macros.rs:54,配套的 json_internal! 在 serde_json/src/macros.rs:70),靠 tt-munching 逐个吞 token,serde_json 的依赖里既没有 syn 也没有 proc-macro。这正是 §7.28 那条经验的现成例子——能用 macro_rules! 解决的就别上 proc macro。

7.34 parse_quote!:用 quote 语法构造 AST

syn::parse_quote! 把 quote 语法当作输入,直接得到 AST:

let bound: WhereClause = parse_quote! { where T: Clone + Send };
let ty: Type = parse_quote! { Vec<Option<String>> };

和 quote!、parse_str 的区别:

  • quote! 生成 TokenStream
  • parse_str 把字符串 parse 成 AST
  • parse_quote! 把 quote 模板直接 parse 成 AST,跳过中间的字符串化

它适合在 derive 宏里用代码构造 AST 片段,比如动态构造 where 子句:

let mut where_clause: WhereClause = parse_quote! { where };
for ty in &types {
    where_clause.predicates.push(parse_quote! { #ty: Deserialize });
}

没有 parse_quote!,就得手写 Punctuated 操作。

7.35 速查卡

需求 API
解析 derive 输入 parse_macro_input!(input as DeriveInput)
解析任意类型 syn::parse_str::<T>("...")
解析 quote 模板 parse_quote! { ... }
遍历 AST impl Visit<'ast> for MyVisitor
改写 AST impl VisitMut for MyVisitor
拆 generics input.generics.split_for_impl()
解析属性 attr.parse_nested_meta(|m| ...)
错误定位 syn::Error::new(span, "msg")
多错合并 err1.combine(err2)
格式化输出 prettyplease::unparse(&syn::parse2(tokens)?)

7.36 proc-macro-workshop

延伸阅读里提到的 proc-macro-workshop(GitHub dtolnay/proc-macro-workshop)值得单独说几句:

  • 5 道题:Builder / Debug / Seq / Sorted / Bitfield
  • README 建议先做 Builder;Bitfield 最难,建议至少吃透另外两题再碰
  • 每道题都配测试用例和预期的诊断输出

做完这几道题,读 serde_derive 源码、自己写复杂的属性宏都会顺手很多。README 没给估时,因人而异。

7.37 宏卫生性(macro hygiene)

Rust 的宏有"卫生性"(hygiene)的概念,源自 Scheme:宏里声明的局部变量、lifetime 不会和调用方的冲突。

macro_rules! bad {
    () => { let x = 42; println!("{}", x); }
}

let x = 10;
bad!();  // 不会打印 10、而是打印 42
// 但 x 还是 10(宏里的 let x 是另一个 x)

声明宏自动卫生:声明宏体内自己引入的局部变量 x 不会和调用方的 x 冲突。

过程宏的卫生性由 Span 控制,有 Span::mixed_site() / Span::call_site() / Span::def_site() 三种:

  • call_site:调用方的 span,会和调用方的 x 冲突
  • def_site:宏定义处的 span,完全隔离
  • mixed_site:声明宏的卫生规则,局部变量、label 和 $crate 按 def_site 解析,其余(类型、函数、字段等)按 call_site 解析(proc-macro2 Span::mixed_site 文档说局部变量、label、$crate 在定义处解析,"same hygiene behavior as macro_rules"),stable 可用

注意 Span::def_site() 至今仍是 nightly-only(proc_macro_def_site feature),stable 上只有 call_site 和 mixed_site。实践上,生成的局部变量用 mixed_site,行为和声明宏一致,最少惊喜。

7.38 syn 与编译时间

过程宏带来能力,也带来编译时间成本。定性地看,含 derive 宏的 crate 比纯手写代码的 crate 编译更慢,derive 用得越密(每个 struct 挂多个 derive)越明显;具体数字取决于机器、增量状态和依赖图,请用 cargo build --timings 自己量。

慢的原因:

  • 每个 derive 宏都要解析一次输入 TokenStream 再生成代码
  • 生成的代码还要再经 rustc 类型检查
  • 有些 derive 宏会开 syn 的 full feature,syn 自身就不小

缓解办法:

  • 用 sccache / mold linker
  • 关不必要的 derive
  • 拆成多个小 crate 并行编译
  • cargo check 代替 cargo build(跳 codegen)

7.39 调试 derive 宏的五条建议

  1. 先 println!("{:#?}", input) 打印 DeriveInput(要开 extra-traits),看清输入结构再动手。
  2. 用 cargo expand(cargo install cargo-expand)看宏展开后的真实代码。
  3. 把 quote! 输出转成字符串打印:let out = quote!{...}; println!("{}", out.to_string());,排查模板问题。
  4. 用 syn::parse_str 写单元测试,不用跑 proc macro 进程,能在 IDE 里断点。
  5. 错误一律用 Error::new(span, "msg").to_compile_error(),不用 panic!,让错误消息指向用户代码而不是宏内部。

7.40 darling 库:简化属性解析

写 derive 宏时,属性解析的样板代码很多,darling crate 能省掉其中大部分:

use darling::FromDeriveInput;

#[derive(FromDeriveInput)]
#[darling(attributes(my_attr))]
struct MyOpts {
    name: String,
    #[darling(default)]
    count: usize,
    #[darling(multiple)]
    aliases: Vec<String>,
}

let opts = MyOpts::from_derive_input(&input)?;
// opts.name / opts.count / opts.aliases 已填好

darling 自动从 DeriveInput::attrs 抓 #[my_attr(...)]、解析里面的 key=value、填进 struct。没有 darling,就要手写"遍历 attrs → 按 path 过滤 → parse_nested_meta → 逐个 match key/value",往往几十行;有了 darling,一个 struct 定义加一个 derive 就够。

serde_derive 没用 darling:它比 darling 早得多,而且自己的 Attr<T> / BoolAttr / VecAttr 抽象(第 9 章 9.12.1 节)已经够用;thiserror、clap_derive 等也都是手写属性解析,darling 更多见于社区第三方 derive crate。新项目写 derive,可以优先考虑 darling。

7.41 为什么 parse 能力放在外部 crate

很多语言的宏系统自带语法树 API。Rust 为什么把 parse 能力放在外部的 syn crate?下面三点是笔者的归纳,不是官方说明:

  1. 语言演进:Rust 语法在持续扩展(如 async fn、let-else、RPITIT)。如果 parse 能力内置,每次语法变更都要等新编译器版本;独立 crate 能更快跟上。
  2. 对外接口:rustc 内部的 AST 非常复杂,也不对外稳定;syn 是专门打磨的对外 API。
  3. 生态灵活:不同版本的 syn 能共存,不同 derive crate 可以用不同 syn 版本,不会锁定整个生态。

对比 Go:go/ast 在标准库里,语法也很少变化。两种做法各有取舍。

7.42 David Tolnay 的其他 crate

syn 的作者 David Tolnay 还维护着一批常用 crate:

  • serde / serde_derive:本专栏主角(Erick Tryzelaar 创建,dtolnay 长期维护)
  • syn:本章主角
  • quote:下章主角
  • anyhow:应用层错误处理
  • thiserror:库层错误处理
  • cxx:C++ 互操作
  • async-trait:async fn in trait 的早期解决方案

这些 crate 的共同风格是一个 crate 只做一件事、充分利用类型系统。

7.43 syn 的 5 条冷知识

冷知识 1——syn 的 Ident 就是 proc_macro2::Ident 的重导出——syn 自己不定义标识符类型,只在 src/ident.rs 里给它加 Parse / Peek 支持。

冷知识 2——syn 2.0.117 的 src/ 共 50057 行——其中 src/gen/ 下 7 个 .rs(visit / visit_mut / fold / clone / debug / eq / hash)共 22299 行是机器生成的——手写部分 27758 行。生成器是仓库内部的 syn-internal-codegen(§7.15),不是对外发布的那个 syn-codegen(后者只是 AST 元数据 schema)。

冷知识 3——Ident::new("foo bar", Span::call_site()) 会 panic——proc-macro2 文档:"Panics if the input string is neither a keyword nor a legal variable name"——关键字(比如 "match")是允许的、不会 panic;Ident::new_raw("match", ...) 是用来生成原始标识符 r#match 的。

冷知识 4——Lit 在 syn 2 里有 9 种 variant(Str / ByteStr / CStr / Byte / Char / Int / Float / Bool / Verbatim)——常常被误以为只有字符串和数字。

冷知识 5——几乎每个大 enum 都有一个 Verbatim 兜底变体——Type::Verbatim(syn/src/ty.rs:72)、Expr::Verbatim(syn/src/expr.rs:243)、Item::Verbatim(syn/src/item.rs:81)、Pat::Verbatim(syn/src/pat.rs:81)、TypeParamBound::Verbatim(syn/src/generics.rs:407)都装 TokenStream;Lit::Verbatim 是个例外,它装的是 proc_macro2::Literal 而不是 TokenStream(字面量本来就只有一个 token)。syn 解析不了的新语法会原样塞进去而不是报错——这是 syn 面对 Rust 语法演进的兜底。

7.44 跨 syn 版本的兼容

眼下生态里 syn 1.0 和 2.0 并存,一个 derive crate 要支持哪个?大多数 crate 的选择是在新的大版本里直接要求 syn 2.0,老用户留在上一个大版本。如果确实要两边兼容,只能靠 cargo feature 分别支持:

[dependencies]
syn = { version = "2.0", optional = true, features = ["derive"] }
syn1 = { package = "syn", version = "1.0", optional = true, features = ["derive"] }

[features]
default = ["syn2"]
syn2 = ["dep:syn"]
syn1 = ["dep:syn1"]

然后在代码里用 feature flag 分支,维护成本是解析代码写两份。

serde_derive 没有做双版本:1.0.157(2023 年 3 月)直接把依赖从 syn 1 切到 syn 2,之后的 1.0.x 全部只用 syn 2。这对下游用户是透明的,因为 syn 只是 serde_derive 的内部实现细节。

7.45 练习:读 serde_derive 的 Container::from_ast

  • 打开 serde_derive/src/internals/ast.rs
  • 找到 Container::from_ast(cx: &Ctxt, item: &'a syn::DeriveInput, derive: Derive, private: &Ident) -> Option<Container<'a>>(第 62 行)
  • 看 syn::DeriveInput 如何被加工成 serde 自己的 Container

你会看到:

  • attrs 被 attr::Container::from_ast 解析成 attr::Container(serde 自己的属性结构)
  • generics 原样借用(generics: &'a syn::Generics),bound 推导留给 bound.rs
  • fields 转成 Field { member, attrs: attr::Field, ty, original },syn::Fields 的三种形态归一成 Style::Struct / Tuple / Newtype / Unit
  • rename_all 规则从容器下放到变体、再下放到字段
  • union 直接 cx.error_spanned_by(item, ...) 后返回 None——每一步都用 Ctxt 累积错误(§7.12)

7.46 AST、HIR、MIR 在 Rust 编译管线里的位置

把 syn 的 AST 放到 rustc 内部的 IR 层次里对比:

层 谁产出 本质
TokenStream lexer 词法单元
AST(syn::...) syn / rustc_parse 语法树
HIR rustc 高级中间表示(去糖后)
MIR rustc 中级中间表示(控制流图、所有权检查)
LLVM IR rustc 低级中间表示(SSA)

syn 在最上层,只做"token → 语法树",不懂类型、不懂作用域。你 parse std::collections::HashMap,syn 只知道"这是 3 段 path",不知道 HashMap 是否真实存在、有哪些方法,那是 HIR / typeck 的事。

所以 derive 宏没有类型信息:你不能在 derive 里判断"字段 T 是否实现 Clone",只能生成 where 子句,交给 rustc 在后续阶段检查。宏在类型检查之前展开,它操作的是 token 和语法结构。

7.47 syn 和 rust-analyzer

rust-analyzer 是 Rust 的 LSP 实现,也要 parse Rust 代码,但它不用 syn:

  • rust-analyzer 用自己的 parser(仓库里的 parser + syntax crate,早期叫 ra_syntax)
  • 因为它需要容错解析:用户打字时代码常常不完整,而 syn 遇错就返回 Err,不适合 LSP

syn 的解析是严格的,要么完整成功、要么返回 Err,这对过程宏合适(用户代码本来就要能编译)。rust-analyzer 的 syntax crate 能容忍"花括号没闭合"这类未完成状态,给用户即时反馈。两者目标不同:

  • syn:离线解析、完整输入、生成代码(proc macro)
  • rust-analyzer syntax:实时解析、增量、不完整也能处理(IDE)

7.48 本章小结

syn 是过程宏的"AST 层"。它的核心价值是把 token 流翻译成结构化 AST——derive 宏的整个世界都建立在此之上。

必须掌握的 5 个类型:

  1. DeriveInput:derive 宏的输入,5 个字段覆盖一个类型定义。
  2. Data(Struct/Enum/Union) + Fields(Named/Unnamed/Unit):类型的"形状"。
  3. Type:字段类型,最常见是 TypePath。
  4. Attribute + Meta:属性的 AST,MetaList::tokens 需要自己解析。
  5. Generics:泛型参数、where 子句,split_for_impl 是生成 impl 时的标准工具。

必须掌握的 3 个工具:

  • parse_macro_input!:过程宏入口的标准解析器。
  • Parse trait + ParseStream:写自定义 parser 的基础。
  • syn::Error::to_compile_error:把错误变成编译错误 TokenStream。

serde_derive 对 syn 的使用模式:

  • 入口用 parse_macro_input! 得到 DeriveInput。
  • 立刻转成自己的 Container/Field/Variant AST(加工 serde 语义)。
  • 生成代码时从自己的 AST 读取信息,用 quote 组装。
  • 所有错误累积到 Ctxt,最后统一 compile_error!。

下一章我们学 quote——"反向生成 TokenStream"的工具。有了 syn 读入、quote 输出,你就有了写 derive 宏的两只手。第 9 章用这两只手从零写出第一个可工作的 derive 宏。

动手实验

  1. 解析一个小例子。写一段代码(不是过程宏,普通可执行):

    let input: DeriveInput = syn::parse_str("struct Foo { x: i32, y: String }").unwrap();
    println!("{:#?}", input);

    观察打印出来的完整结构,对照本章类型一个个看。(DeriveInput 的 Debug 实现要开 syn 的 extra-traits feature 才有。)

  2. 字段列表提取。写一个函数 fn field_names(input: &DeriveInput) -> Vec<String>,返回所有字段名。测试三种情况:命名字段、元组字段、enum。

  3. 尝试 parse_nested_meta。写一个简单的属性解析器,接受 #[my_attr(a = "x", b)],返回一个 HashMap<String, Option<String>>。

  4. 看 serde_derive 的真实代码。打开 serde_derive/src/internals/ast.rs 和 attr.rs,找到 Container::from_ast 函数。尝试理解它如何把 syn::DeriveInput 转换成 serde 自己的 Container。不需要读懂全部,能看懂 30% 就行。

延伸阅读

  • syn 文档:权威 API 参考,每个类型都有例子。
  • syn 的 examples 目录:多个完整的过程宏示例(lazy-static、trace-var、heapsize 等),是学 syn 最好的教材。
  • proc-macro-workshop:dtolnay 设计的过程宏练习题。做到 Builder 和 Debug 两道题后,你对 syn 的掌握就到了"可以看 serde_derive 源码不卡壳"的程度。
  • 《Rust 编译器与运行时揭秘》专栏第 14 章:看看 rustc 内部 AST(rustc_ast::ast)长什么样,对比 syn 的 AST——它们是"同一件事的两种表示"。
  • 《Tokio 源码深度解析》专栏第 4 章:其中对 #[tokio::main] 宏的讨论,是 syn 在属性宏里的典型应用。