Serde 元编程
第 7 章 syn:把 TokenStream 解析成 AST
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 直接拿这些信息要做什么?
- 检查第一个 Ident,如果是
struct则记下这是 struct。 - 下一个 Ident 是类型名。
- 跳过
struct、类型名到下一个 Group,进入 Group 内部。 - 遍历 Group 内的 token,用状态机识别"Ident Punct(':') 类型 Punct(',')"这种模式……
- 当遇到泛型(
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)。它做两件事:
- 调用
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,区别只在入参类型。 - 如果解析失败,返回编译错误 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。这一层加工做的事:
- 把
syn::Attribute解析成 serde 专属的attr::Field、attr::Variant、attr::Container(包含 rename、default、flatten 等所有 serde 语义)。 - 把
syn::Fields::Named/Unnamed/Unit统一成Style::Struct/Tuple/Unit/Newtype。 - 保留
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, ¶ms));
// 组装 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是 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标记的类型在没开fullfeature 时会被替换成一个只含PhantomData<Span>的占位 struct,并给出一个unreachable!()的ToTokensimplast_enum!:同样只是透传(check_keyword_matches!做点语法检查)ast_enum_of_structs!:多做两件事:为每个变体生成From<TypeXxx> for Typeimpl(syn/src/macros.rs:86,其中Verbatim变体被显式跳过,syn/src/macros.rs:88),以及(feature="printing")按变体分派的ToTokensimpl(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 traitsextra-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占位——没开fullfeature 时,表达式/语句等类型退化成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
}
实现步骤:
parse_macro_input!(input as DeriveInput)- 找
#[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))]声明这个属性) - 遍历
DataStruct::fields——为每个字段算出加前缀后的名字my_{name}(derive 宏不能修改原 struct,所以不能往字段上追加#[serde(rename)]) - 用
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::StructToken![,]→syn::token::CommaToken![=]→syn::token::EqToken![->]→syn::token::RArrowToken![=>]→syn::token::FatArrowToken![::]→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"/trueExpr::Binary——a + bExpr::Call——foo(a, b)Expr::MethodCall——x.foo()Expr::If——if cond { ... }Expr::Match——match x { ... }Expr::Closure——|x| x + 1Expr::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!生成 TokenStreamparse_str把字符串 parse 成 ASTparse_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-macro2Span::mixed_site文档说局部变量、label、$crate在定义处解析,"same hygiene behavior asmacro_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 的
fullfeature,syn 自身就不小
缓解办法:
- 用
sccache/moldlinker - 关不必要的 derive
- 拆成多个小 crate 并行编译
cargo check代替cargo build(跳 codegen)
7.39 调试 derive 宏的五条建议
- 先
println!("{:#?}", input)打印 DeriveInput(要开extra-traits),看清输入结构再动手。 - 用
cargo expand(cargo install cargo-expand)看宏展开后的真实代码。 - 把
quote!输出转成字符串打印:let out = quote!{...}; println!("{}", out.to_string());,排查模板问题。 - 用
syn::parse_str写单元测试,不用跑 proc macro 进程,能在 IDE 里断点。 - 错误一律用
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?下面三点是笔者的归纳,不是官方说明:
- 语言演进:Rust 语法在持续扩展(如
async fn、let-else、RPITIT)。如果 parse 能力内置,每次语法变更都要等新编译器版本;独立 crate 能更快跟上。 - 对外接口:rustc 内部的 AST 非常复杂,也不对外稳定;syn 是专门打磨的对外 API。
- 生态灵活:不同版本的 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+syntaxcrate,早期叫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 个类型:
DeriveInput:derive 宏的输入,5 个字段覆盖一个类型定义。Data(Struct/Enum/Union) +Fields(Named/Unnamed/Unit):类型的"形状"。Type:字段类型,最常见是TypePath。Attribute+Meta:属性的 AST,MetaList::tokens需要自己解析。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/VariantAST(加工 serde 语义)。 - 生成代码时从自己的 AST 读取信息,用 quote 组装。
- 所有错误累积到
Ctxt,最后统一compile_error!。
下一章我们学 quote——"反向生成 TokenStream"的工具。有了 syn 读入、quote 输出,你就有了写 derive 宏的两只手。第 9 章用这两只手从零写出第一个可工作的 derive 宏。
动手实验
-
解析一个小例子。写一段代码(不是过程宏,普通可执行):
let input: DeriveInput = syn::parse_str("struct Foo { x: i32, y: String }").unwrap(); println!("{:#?}", input);观察打印出来的完整结构,对照本章类型一个个看。(
DeriveInput的Debug实现要开 syn 的extra-traitsfeature 才有。) -
字段列表提取。写一个函数
fn field_names(input: &DeriveInput) -> Vec<String>,返回所有字段名。测试三种情况:命名字段、元组字段、enum。 -
尝试 parse_nested_meta。写一个简单的属性解析器,接受
#[my_attr(a = "x", b)],返回一个HashMap<String, Option<String>>。 -
看 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 在属性宏里的典型应用。