Serde 元编程
第 8 章 quote:用模板反向生成 TokenStream
8.1 从"拼字符串"到"quasi-quoting"
第 6 章 §6.10 有一个简陋的例子——手写 hello_world!() 宏用字符串拼接:
let output = "fn hello_world() { println!(\"Hello!\"); }";
output.parse().unwrap()
这个做法能工作,但有三个致命问题:
问题 1:字符串拼接是盲盒。 想根据字段名动态生成代码:
let name = "User";
let code = format!("impl Serialize for {} {{ ... }}", name);
parse() 只做词法切分:括号不配对之类的错误会让宏执行时的 unwrap() 直接 panic;而 name 里要是混进了 User-X 这种非法标识符,照样能切成 token,要等到用户 crate 编译时才冒出一个指着宏调用处的语法错误——用户根本不知道是你拼字符串拼错了。
问题 2:没有 Span。 字符串 parse 出来的 token 一律是 Span::call_site()(rustc 1.96.0 源码 library/proc_macro/src/lib.rs 第 194 行的注释:"All tokens in the parsed stream get Span::call_site() spans"),用户字段原有的 span 全部丢失——编译器报错时只能指着宏调用处,指不到用户真实代码。第 6 章讲过 Span 对错误信息的重要性。
问题 3:可读性差。 想写复杂代码:
let code = format!(
"impl {}Serialize for {} {{\n fn serialize<__S>(&self, s: __S) -> Result<__S::Ok, __S::Error>\n where __S: Serializer {{\n {}\n }}\n}}",
"_serde::", struct_name, body
);
四行嵌套转义字符串写出一个 impl 块——读写都是折磨。
quasi-quoting(准引用)是这一切的解法。核心思想是——让你写的代码"看起来就像 Rust 代码,但是是 data"。看 quote 的典型用法:
use quote::{format_ident, quote};
let struct_name = format_ident!("User");
let body = quote! { state.serialize_field("id", &self.id)?; };
let tokens = quote! {
impl _serde::Serialize for #struct_name {
fn serialize<__S>(&self, s: __S) -> Result<__S::Ok, __S::Error>
where __S: _serde::Serializer,
{
#body
}
}
};
quote! { ... } 块里面写的 Rust 代码不会被执行。它是一个模板。#struct_name、#body 是插值点——运行时会被对应的变量值替换。最终 tokens 是一个 proc_macro2::TokenStream,包含展开后的完整代码。
三个好处一次解决:
- 结构安全——模板的 token 结构(括号配对等)在编译过程宏时就由宿主编译器检查,拼错会编译错,不是运行时 panic。但 quote 只按 token 树匹配,不检查生成的是不是合法 Rust,语法对不对要到用户 crate 里才见分晓。
- Span 自动管理——生成的 token 有合理的默认 span(
Span::call_site()),或者通过quote_spanned!精细控制。 - 可读性——模板部分看起来就是 Rust 代码,IDE 语法高亮、缩进、括号匹配全部可用。
quote! 宏是过程宏世界的"JSX"——让代码生成从"拼字符串"进化到"写模板"。本章要把 quote 的全部机制拆开——从 #var 插值到 #(...)* 展开、从 ToTokens trait 到 quote_spanned! 的 span 控制。
本专栏基于 quote 1.0.45(commit ba07807)。quote 1.0 的 API 非常稳定,本章内容对整个 1.0.x 系列都适用。
8.2 第一个 quote!例子
从最简单开始——生成一个空函数:
use quote::quote;
use proc_macro2::TokenStream;
let tokens: TokenStream = quote! {
fn hello() {}
};
tokens 现在是一个 TokenStream,里面装着 fn hello() {} 这 4 个 TokenTree(Ident(fn)、Ident(hello)、一个空的 Group(Parenthesis)、一个空的 Group(Brace)——括号在 TokenStream 里是 Group 而不是单独的 token)。
最关键的事实:quote! 里面的代码不会在 quote 的 crate 里执行——它只是数据,会作为输出 TokenStream 的一部分,最终插入到用户的 crate 里执行。
这和 React/JSX 高度相似。JSX 代码:
const element = <h1>Hello, world!</h1>;
这里的 <h1> 不会在你写 JSX 的地方执行——它构造一个 VDOM 对象。quote! 做的是同样的事——构造一个 TokenStream 对象。
8.3 插值:#var 语法
让模板变得动态的机制是插值。quote! 的插值语法是 #var:
let name = format_ident!("User");
let tokens = quote! {
struct #name {}
};
// 展开后:struct User {}
每个 #var 会被 var 的值替换。被替换的值必须实现 ToTokens trait(下一节详述)。
插值可以出现在任何位置:
let field_name = format_ident!("id");
let field_type = quote! { u64 };
let value = 42u32;
let tokens = quote! {
struct User {
#field_name: #field_type,
}
fn get() -> u32 { #value }
};
// 展开后:
// struct User { id: u64, }
// fn get() -> u32 { 42u32 }
#field_name(Ident)在字段位置、#field_type(TokenStream)在类型位置、#value(u32)在表达式位置——quote 不在乎插值位置的语法角色,只做 token 级替换。正确性由 ToTokens 实现和最终的类型检查保证。
注意区分: #var 是插值一次;要插值一个迭代器(多次),用 #(...)*——下一节细讲。
8.4 ToTokens trait:什么类型能被插值
插值的条件:值的类型必须实现 ToTokens。
pub trait ToTokens {
fn to_tokens(&self, tokens: &mut TokenStream);
}
非常简单——把自己追加到一个 TokenStream。
标准库常用类型都有实现:
- 数字(i32、u64、f64 等)→ 变成对应字面量
- 字符串
&str、String→ 变成字符串字面量 bool→ 变成true/falsechar→ 字符字面量
syn 和 proc-macro2 的类型都有实现:
Ident、Literal、Punct、Group→ 自身TokenStream、TokenTree→ 自身syn::Type、syn::Expr、syn::Path、syn::Field等 → 原样输出
这意味着:你从 syn 解析出的任何节点都能直接插值到 quote 里,不需要转换:
let input: DeriveInput = ...;
let name = &input.ident; // &syn::Ident
let generics = &input.generics; // &syn::Generics
quote! {
impl #generics Serialize for #name {}
// ^^^^^^^^^^ ^^^^^
// 仅演示插值;真写泛型类型的 impl 要用 generics.split_for_impl(),见 §8.27
}
这种"syn 读入、quote 输出"的无缝衔接是整个过程宏工具链设计的核心——两个库在 ToTokens trait 层紧密合作。
为自己的类型实现 ToTokens:
struct MyExpr { value: i32 }
impl ToTokens for MyExpr {
fn to_tokens(&self, tokens: &mut TokenStream) {
let v = self.value;
tokens.extend(quote! { #v + 1 });
}
}
// 现在可以插值 MyExpr
let e = MyExpr { value: 5 };
let out = quote! { let x = #e; };
// 展开:let x = 5i32 + 1;
这种"嵌套 quote"的能力让过程宏可以递归构造代码——外层 quote 遇到 MyExpr 时调用它的 to_tokens,MyExpr 内部又用 quote 生成更多 tokens。Serde 大量使用这种模式(如 serde_derive/src/fragment.rs 里 Expr、Stmts、Match 的 ToTokens 实现)。
8.5 重复:#(...)* 语法
插值单个值用 #var,插值一个集合用 #(...)*:
let names: Vec<Ident> = vec![
format_ident!("id"),
format_ident!("name"),
format_ident!("email"),
];
let types: Vec<Type> = vec![/* u64, String, String */];
let tokens = quote! {
struct User {
#(
pub #names: #types,
)*
}
};
// 展开:
// struct User {
// pub id: u64,
// pub name: String,
// pub email: String,
// }
#(...)* 只有两种形式(quote/src/lib.rs:152 的文档原文:"Repetition is done using #(...)* or #(...),* again similar to",下一行接 macro_rules!):
#(...)*——无分隔符,重复任意次(包括 0 次)#(...),*——星号前的那个字符被当作分隔符(,、;都行)
注意:quote 里没有 macro_rules! 那样的 + 重复。quote/src/lib.rs:885-952 的 quote_token_with_context! 只有以 * 收尾的重复规则,写 #(...)+ 匹配不上任何一条重复规则,#、圆括号组和 + 都会被当成普通 token 原样输出(括号里的 #var 只插值一次,若它是 Vec 这类没实现 ToTokens 的值,过程宏直接编译报错),而不是"至少重复一次"。
重复里的变量要么能当迭代器、要么能当常量:quote/src/runtime.rs:74-174 的 ext 模块给三类类型各实现了一个 quote_into_iter()——RepIteratorExt(任何 Iterator)、RepAsIteratorExt([T] / [T; N] / Vec<T> / BTreeSet<T> / RepInterp<T> 以及它们的引用)、RepToTokensExt(只实现了 ToTokens 的值,每轮重复原样再插一次)。但一个重复里至少要有一个真正的迭代器,否则编译报错 "repetition contains no interpolated value that is an iterator"(quote/src/runtime.rs:53-65 的 CheckHasIterator)。
重要规则:#(...)* 里面如果有多个不同迭代器变量,它们必须等长。quote 会并行遍历它们:
let keys = vec!["id", "name"];
let values = vec![1, 2];
let tokens = quote! {
#( #keys: #values, )*
};
// 展开:"id": 1i32, "name": 2i32,
如果 keys.len() != values.len(),quote 不会 panic——展开出的循环里每个迭代器各 next() 一次,任何一个返回 None 就 break(quote/src/lib.rs:742-749 的 quote_bind_next_or_break!),所以实际按最短的那个截断,多出来的元素被静默丢弃。这比 panic 更隐蔽,写代码时要自己保证等长。
嵌套重复也支持:
let groups: Vec<Vec<i32>> = vec![vec![1, 2], vec![3, 4]];
let tokens = quote! {
#(
fn group() -> Vec<i32> {
vec![#( #groups ),*]
}
)*
};
// 展开两个函数体,每个用不同的 Vec
内层的 #groups 是 Vec<i32>,在 #( ),* 里变成 1i32, 2i32 或 3i32, 4i32。外层 #( )* 对每个 group 生成一个 fn。嵌套之所以成立,是因为外层每轮把元素包成 RepInterp<Vec<i32>>,而 quote/src/runtime.rs:167-173 给 RepInterp<T> 也实现了 RepAsIteratorExt,内层再迭代一次。(照抄这段会得到两个同名的 fn group——真写的时候函数名也要跟着一起 #(...)* 展开。)
serde_derive 用重复的典型例子:
// 生成所有字段的 serialize_field 调用
quote! {
#( state.serialize_field(#field_names, &self.#field_idents)?; )*
}
// #field_names 是 Vec<&str>,#field_idents 是 Vec<syn::Ident>
// 展开成 N 行 serialize_field 调用
// (示意写法;serde_derive 实际先逐字段生成语句,再用 #(#serialize_fields)* 拼接,见 serde_derive/src/ser.rs:364)
8.6 format_ident!:动态生成标识符
经常需要根据基础名生成派生名——get_foo、set_foo、foo_iter。光有 #var 不够,因为插值是整体替换。这时用 format_ident!:
use quote::format_ident;
let field = format_ident!("data");
let getter = format_ident!("get_{}", field); // get_data
let setter = format_ident!("set_{}", field); // set_data
let tokens = quote! {
fn #getter(&self) -> &Data { &self.#field }
fn #setter(&mut self, v: Data) { self.#field = v; }
};
format_ident! 像 format! 但输出是 Ident 而不是 String。它会检查结果是合法标识符(否则 panic),所以是安全的。
生成带 span 的 ident:
let id = format_ident!("foo", span = some_field.span());
可以指定 span,让生成的 ident 指向特定源位置。
serde_derive 里格式化名字的典型场景:
// serde_derive/src/de.rs:658——为第 i 个字段生成标识符 __field{i}
pub(crate) fn field_i(i: usize) -> Ident {
Ident::new(&format!("__field{}", i), Span::call_site())
}
// 调用方:(0..fields.len()).map(field_i) 得到 Vec<Ident>,再在 quote! 里用 #(...)* 展开
(serde_derive 这里直接用 Ident::new + format!,效果和 format_ident!("__field{}", i) 一样。)
__field0、__field1、__field2……这是在生成的反序列化代码里存储临时字段值的变量。__ 前缀避免和用户代码冲突(第 8.8 节"hygiene"讨论)。
8.7 quote_spanned!:精细控制 Span
quote! 默认把所有生成的 token 的 span 设为 Span::call_site()——指向宏调用点。这在大多数情况下够用,但对于字段引用等位置,应该用原字段的 span。
quote_spanned! 让你指定 span:
let field_ident: &Ident = ...;
let field_type: &Type = ...;
let span = field_ident.span();
let tokens = quote_spanned! {span=>
state.serialize_field(stringify!(#field_ident), &self.#field_ident)?
};
用法:quote_spanned! {span=> 模板}(quote 文档特意要求 => 前不留空格)。模板里原生的 token 都用这个 span,插值进来的 token 仍保留各自的 span。
为什么重要:如果用户写:
#[derive(Serialize)]
struct Bad {
data: NotSerializable, // NotSerializable 没实现 Serialize
}
serde_derive 生成的代码里有一行 state.serialize_field("data", &self.data)。类型检查器发现 NotSerializable 没实现 Serialize,要报错——错误应该指向哪里?
如果那行生成代码的 span 全是 call_site():错误只指向 #[derive(Serialize)] 这一行,用户一脸懵——"我哪里错了?"
如果被调用的 serialize_field 带上字段的 span:主标注仍在 derive 那一行,但编译器会在 data 字段上加一条 "required by a bound introduced by this call" 的次标注,用户顺着它就找到了出问题的字段。
所以 serde_derive 在生成代码时,用 quote_spanned! 给每个字段对应的 serialize_field 路径带上字段自己的 span(serde_derive/src/ser.rs:1322-1334 的 StructTrait::serialize_field)。上面两种情形都用 rustc 1.96.0 + serde 1.0.228 实测过:把这里的 quote_spanned! 换成 quote!,那条指向 data 的次标注就消失了。这种细致的 span 管理是 Serde 错误信息质量的基础。
quote!vs quote_spanned!的选择:
- 模板框架(impl 块外壳、match arm 结构)→ quote!(用 call_site)
- 字段/变体/类型的具体引用 → quote_spanned!(用对应源位置 span)
8.8 宏卫生:__ 前缀与 hygiene
生成的代码可能和用户代码冲突。比如用户写:
#[derive(Serialize)]
struct Foo {
state: i32, // 用户叫 state 的字段
}
如果 serde_derive 生成:
// 错误的"生成代码"
impl Serialize for Foo {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut state = s.serialize_struct("Foo", 1)?; // 冲突?
state.serialize_field("state", &self.state)?;
state.end()
}
}
乍看是 state 撞了名,其实这一处不会出事:字段只能经 self.state 访问,局部变量 state 遮不住它(照这个样子写个 derive 实测能编译通过)。真正的风险在别处——生成代码里的名字按用户调用处的作用域解析:用户 crate 里要是恰好有一个 const state 或单元结构体 state,let state = ... 就不再是新绑定,而成了对那个常量的模式匹配(rustc 1.96.0 实测报 E0308,提示 "state is interpreted as a constant, not a new binding");用户类型带一个叫 S 的泛型参数(struct Foo<S>)时,生成的 fn serialize<S: Serializer> 又会与之撞名(E0403)。
过程宏默认没有卫生性可言(相比 Scheme 的 hygienic macro)。quote! 给模板 token 用的 Span::call_site(),按文档就是让标识符"像直接写在宏调用处一样"解析(proc-macro2/src/lib.rs:410-414);要 macro_rules! 那种局部变量隔离,得改用 Span::mixed_site()(proc-macro2/src/lib.rs:418-424,第 6 章讲过)。
serde_derive 的解决方案:给所有"内部使用"的标识符加 __ 前缀:
// 实际生成代码(简化)
impl _serde::Serialize for Foo {
fn serialize<__S>(&self, __serializer: __S) -> _serde::__private228::Result<__S::Ok, __S::Error>
where __S: _serde::Serializer,
{
let mut __serde_state = _serde::Serializer::serialize_struct(__serializer, "Foo", 1)?;
_serde::ser::SerializeStruct::serialize_field(&mut __serde_state, "state", &self.state)?;
_serde::ser::SerializeStruct::end(__serde_state)
}
}
注意 __serializer、__S、__serde_state、__private228(serde 内部模块名还带版本后缀,见第 9 章 9.14 节)——全部 __ 开头。__ 开头的名字在正常用户代码里几乎不会出现。这是 Serde 的手工 hygiene。
另一个细节:用 _serde:: 前缀而不是 serde::。这是 serde 生成代码里的一个小技巧——整个 impl 被包进一个 const _: () = { extern crate serde as _serde; ... }; 块(serde_derive/src/dummy.rs 的 wrap_in_const),_serde 这个名字只在块内可见,这样即使用户作用域里没有 serde、或者把它改了名(use my_serde as serde),生成代码也不受影响;#[serde(crate = "...")] 则会把这行换成 use #path as _serde;。第 9 章 9.9.2 节详述。
8.9 TokenStreamExt:累积生成
有时你想手动构造 TokenStream——比如在一个循环里累积生成代码,不能全部塞在一个 quote! 里。最直接的是标准库的 Extend:
use proc_macro2::TokenStream;
use quote::quote;
let mut out = TokenStream::new();
for field in fields.iter() {
let chunk = quote! {
s.serialize_field(stringify!(#field), &self.#field)?;
};
out.extend(chunk);
}
// out 现在是所有 serialize_field 调用的拼接
.extend() 来自标准库的 Extend trait(proc-macro2/src/lib.rs:300 起的一组 impl Extend<...> for TokenStream),不需要 quote——让你把多段生成片段拼起来。
quote 另外提供 TokenStreamExt:
.append(token):追加一个 TokenTree.append_all(iter):逐个追加实现了ToTokens的元素.append_separated(iter, sep):追加带分隔符的序列
看 serde_derive 的实际用法(serde_derive/src/lib.rs:79):
use quote::{ToTokens, TokenStreamExt as _};
as _ 表示"导入 trait 但不绑定到名字"——只需要方法可用就行。
8.10 一个完整例子:生成 Debug 实现
把前面所有内容串起来。写一个 #[derive(MyDebug)],生成 Debug 实现:
// my-debug-macro/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields};
#[proc_macro_derive(MyDebug)]
pub fn derive_my_debug(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
// 只支持命名字段 struct
let fields = match input.data {
Data::Struct(data) => match data.fields {
Fields::Named(fields) => fields.named,
_ => panic!("only named struct supported"),
},
_ => panic!("only struct supported"),
};
// 提取字段名
let field_names: Vec<_> = fields.iter().map(|f| f.ident.as_ref().unwrap()).collect();
// 生成 fmt 方法
let output = quote! {
impl std::fmt::Debug for #name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct(stringify!(#name))
#(
.field(stringify!(#field_names), &self.#field_names)
)*
.finish()
}
}
};
output.into()
}
解读:
parse_macro_input!(input as DeriveInput)— 解析类型定义。input.ident— 类型名。- 从
input.data提取Fields::Named(命名字段)。 - 提取字段名列表
field_names(类型是Vec<&Ident>)。 quote!模板:#name插值类型名。#( .field(stringify!(#field_names), &self.#field_names) )*对每个字段生成一次.field(...)调用。
用户用法:
#[derive(MyDebug)]
struct User {
id: u64,
name: String,
}
// 自动生成:
// impl Debug for User {
// fn fmt(&self, f: &mut Formatter) -> Result {
// f.debug_struct("User")
// .field("id", &self.id)
// .field("name", &self.name)
// .finish()
// }
// }
短短三十几行代码写出一个可工作的 derive 宏。 这就是 syn + quote 的威力。
第 9 章我们会写一个更完整的 derive 宏(#[derive(Builder)]),涵盖属性处理、错误报告、泛型支持。在此之前先完成对 quote 的深入理解。
8.11 quote! 的底层实现
quote! 宏本身是用什么实现的?——一个声明宏(macro_rules!)加大量辅助代码。
quote! 的核心逻辑(来自 quote/src/lib.rs,高度简化):
// 这是 quote! 的伪代码解释,实际代码更复杂
macro_rules! quote {
( ) => { proc_macro2::TokenStream::new() };
// 遇到 #var,调用 ToTokens
( # $var:ident $($rest:tt)* ) => {{
let mut ts = proc_macro2::TokenStream::new();
$var.to_tokens(&mut ts);
ts.extend(quote!($($rest)*));
ts
}};
// 遇到 #(...)*,循环调用
( #( $($inner:tt)* )* $($rest:tt)* ) => {{
// 解析 inner 里的变量,展开成 for 循环
...
}};
// 普通 token,直接生成
( $tok:tt $($rest:tt)* ) => {{
let mut ts = /* 构造 $tok 对应的 TokenTree */;
ts.extend(quote!($($rest)*));
ts
}};
}
实际实现更复杂——因为:
macro_rules!不能直接递归处理任意 token(比如嵌套括号),要特殊处理 Group。#(...)*内部可能有多个迭代变量,需要并行遍历。- 每个 token 要带合适的 span 和 spacing(Joint vs Alone)。
所以 quote 的真实实现 quote/src/lib.rs 有 1477 行(src/ 下七个文件合计 2708 行)。但你不需要读懂全部——记住它是"递归展开 + 调用 ToTokens"就够了。
性能提示:quote! 在编译期展开(因为它是 macro_rules!)。运行时只是一堆 ToTokens::to_tokens 调用。所以 quote! 的性能开销约等于手写 TokenStream::extend 的开销——几乎免费。
8.11.1 真实 quote! 里的四条 fast-path:不是递归到底、是分类优化
翻开 quote/src/lib.rs:494,macro_rules! quote 有 5 条分支,前 4 条都是 fast-path 特化,最后一条才走通用 quote_each_token! 展开:
// quote/src/lib.rs:494
macro_rules! quote {
() => {
$crate::__private::TokenStream::new() // 1. 空
};
($tt:tt) => {{ // 2. 单 token
let mut _s = $crate::__private::TokenStream::new();
$crate::quote_token!{$tt _s}
_s
}};
(# $var:ident) => {{ // 3. #var 一个变量
let mut _s = $crate::__private::TokenStream::new();
$crate::ToTokens::to_tokens(&$var, &mut _s);
_s
}};
($tt1:tt $tt2:tt) => {{ // 4. 两个 token
let mut _s = $crate::__private::TokenStream::new();
$crate::quote_token!{$tt1 _s}
$crate::quote_token!{$tt2 _s}
_s
}};
($($tt:tt)*) => {{ // 5. 通用(并非 tt-muncher,见 §8.29)
let mut _s = $crate::__private::TokenStream::new();
$crate::quote_each_token!{_s $($tt)*}
_s
}};
}
第 3 条 (# $var:ident) 尤其值得盯着看——它不走 quote_token! 机制、直接展开成一次 ToTokens::to_tokens(&$var, &mut _s)。
这个特化背后是对使用场景的精确观察:quote! { #x } 是过程宏里最高频的写法之一——把一个变量"塞进当前位置"。每次 derive 宏、attribute 宏处理字段、类型时都要用到。如果它走通用 quote_each_token! 路径,宏展开要先把 # 和 $var 分别识别、判断是 interpolation 而非字面 # 字符、然后再调 to_tokens——涉及几层 macro_rules! 分支匹配。特化后一步到位。注意两条路径最终展开出的代码是一样的(都只剩一行 to_tokens 调用,用 RUSTC_BOOTSTRAP=1 -Zunpretty=expanded 对照 quote! { #x } 和走通用路径的 quote! { #x ; } 可以看到),省下的是展开过程本身:少走 quote_each_token! → quote_tokens_with_context! → 逐 token 的 quote_token_with_context! 这几层匹配。quote/src/lib.rs:757-762 的注释说得直白:通用实现的常数因子比 tt-muncher 高,大量短输入的调用反而会慢一些——一两个 token 的特化规则(源码注释写的是 "for performance")正好补上这块短板。
在编译 serde_derive 这种被成千上万个下游 crate 依赖的 proc macro 时,这种"每个高频调用点少几层宏匹配"的积累——最终体现在用户的 cargo check 时间上。和前面第 3 章的 tri! 宏(serde 源码注释给出的数字:编译时间比 try! 少约 5.5%、比 ? 少约 9%,见 serde_core/src/crate_root.rs:122-125)、第 7 章 Ctxt::error_spanned_by 的"反单态化"注释是同一条工程神经:Serde 维护者、quote/syn 作者 dtolnay 反复优化的都是"每一个被展开上千次的点都值得看一眼"。
#[cfg(doc)] 的镜像版本(quote/src/lib.rs:481-489)也是个有趣的工程细节——给 docs.rs 展示的 macro_rules! 只有一条 ($($tt:tt)*) => { ... };,所有 fast-path 都隐藏掉:用户在文档里看到干净的抽象签名、同时真实编译走的是 5 条规则的优化版。这种"doc 与 impl 分离"的技巧靠在 macro_rules! 外再包一层元宏(源码里的 __quote!)实现,让两个版本共用同一份文档注释。
8.12 quote 的坑与规避
坑 1:整数字面量类型歧义。
let n = 42; // i32
let tokens = quote! { let x: u8 = #n; };
// 展开:let x: u8 = 42i32; ← 类型不匹配!
因为 quote 把 i32 的 42 插值成 42i32(带类型后缀),而你要 u8。解决方法:
- 用
proc_macro2::Literal::u8_unsuffixed(42) - 或者
quote! { let x: u8 = #n as u8; }强转
坑 2:Option 会静默展开成空。
let maybe: Option<Ident> = None;
quote! { struct #maybe {} }; // 能编译!展开成 `struct {}`——到用户 crate 里才报语法错误
Option<T: ToTokens> 实现了 ToTokens(quote/src/to_tokens.rs:114):Some(t) 输出 t,None 什么都不输出。这个特性用来做"条件插入"很方便(8.32 节技巧 1),但如果你本意是"这里必须有值",None 不会在宏里报错,而是生成一段残缺代码。想明确处理就 unwrap 或用 if/else:
// 调用侧用法示例,不是 quote 源码
let maybe: Option<Ident> = ...;
if let Some(name) = maybe {
quote! { struct #name {} }
} else {
quote! { struct Default {} }
}
坑 3:quote 里的错误信息有时不友好。 如果模板里某个变量类型不匹配(比如你以为是 Ident 但实际是 Option<Ident>),错误信息可能指向 quote! 调用点而不是具体变量。调试技巧:把复杂 quote! 拆成多个小 quote!,逐个测试。
8.13 与本系列其他专栏的关联
quote 的"代码模板化生成"思想在 Rust 生态外也有类似工具:
- JavaScript 的 JSX:
<Component prop={value}>{children}</Component>对应quote! { ... #value ... }。本质都是把"代码"当数据处理。 - Python 的
ast.unparse+ast.parse:在 AST 层面做同样的事,但运行时完成。 - Lisp 的
` ...和,:quote/quasi-quote 的祖先。`(a ,b c)对应 Rust 的quote! { a #b c }。quote 的文档把这种写法直接叫作 quasi-quoting——名字就来自 Lisp 传统。
《Tokio 源码深度解析》专栏第 4 章里,#[tokio::main] 属性宏的实现核心就是 quote——它接收一个 async fn main(),用 quote! 生成一个带 runtime 启动的同步 fn main。典型的"读入一个 AST、生成另一个 AST"模式。
《Rust 编译器与运行时揭秘》专栏第 14 章讨论过声明宏 macro_rules! 的展开机制。quote! 本身就是一个声明宏——你读懂《Rust 编译器与运行时揭秘》专栏那一章,就能理解 quote! 是如何在编译期被展开成"构造 TokenStream 的代码"的。
8.14 本章小结
quote 是过程宏的"代码生成层"。核心 API 只有三样:
quote! { 模板 }— quasi-quoting,插值用#var、重复用#(...)*。format_ident!("前缀{}", base)— 动态生成标识符。quote_spanned! {span=> 模板}— 精细控制 span。
ToTokens trait 是插值的基础——任何实现了 ToTokens 的类型都能被 #var 引用。syn 所有 AST 类型和标准库基础类型都实现了 ToTokens——这种生态级一致性是 syn+quote 黄金组合的前提。
serde_derive 的 quote 使用模式:
- 外层 quote! 写 impl 骨架(用 call_site span)
- 内层 quote_spanned! 处理字段引用(用字段自己的 span,让错误指向准确)
- 辅助变量用
__前缀避免用户代码冲突 _serde::路径前缀让 crate 重命名不会破坏宏
掌握到这里,你已经拥有读 serde_derive 源码的全部工具:
- 第 5 章:宏系统全景
- 第 6 章:TokenStream 的底层结构
- 第 7 章:syn 解析 AST
- 第 8 章:quote 生成代码
下一章 综合应用——从零写一个可工作的 #[derive(Builder)]。这是 dtolnay 的 proc-macro-workshop 第一道题,也是很多 Rust 开发者的"过程宏成人礼"。写完这一章,你就可以真正理解 serde_derive 为什么长那样、为什么要分那么多文件、每一段代码的动机。
动手实验
-
写一个最小 derive 宏:按第 8.10 节的
MyDebug自己实现一遍。注意不要 copy,自己打一遍——手指的记忆比眼睛的记忆牢固。 -
观察 quote 展开:用
println!("{}", tokens)(或eprintln!在过程宏里)打印 quote! 的输出。理解你写的模板最终变成了什么字符串。 -
测试 hygiene:给你的
MyDebug生成的代码加一个局部变量(比如let state = ...;)。先让用户结构体带一个叫state的字段,观察是否出事;再在用户 crate 里定义const state: u8 = 0;,观察会发生什么。然后改成__state看错误是否消失。 -
思考题:为什么 quote 不直接生成字符串再 parse,而是在内存里构造 TokenStream?(提示:性能 + span)
延伸阅读
- quote 文档:权威 API 参考。
- quote 的源码:
src/合计约 2700 行(宏定义所在的lib.rs约 1500 行),读一遍对"如何实现一个声明宏做代码生成"有完整理解。 - Rust Reference 的 Macros by Example:macro_rules! 的完整语法。quote! 自己就是一个 macro_rules,读懂这个你能读懂 quote 源码的大部分。
- proc-macro-workshop - Builder 题目:下一章要做的题目。现在可以先看一下题目描述。
- 《Tokio 源码深度解析》专栏第 4 章:看真实世界的过程宏设计如何用 quote 解决复杂问题。
8.15 源码实证:quote-1.0.45 的模块结构
打开 quote 仓库 ba07807(即本专栏声明的 1.0.45)的 src/——quote crate 总共 2708 行——分 7 个模块:
| 文件 | 行 | 职责 |
|---|---|---|
lib.rs |
1477 | quote! / quote_spanned! 宏定义(主场) |
runtime.rs |
506 | 展开代码调用的运行时函数(push_* 系列)+ 重复语法支持 |
to_tokens.rs |
281 | ToTokens trait + 标准类型 impl |
format.rs |
168 | format_ident! 宏 |
ext.rs |
138 | TokenStreamExt 扩展 trait |
ident_fragment.rs |
89 | IdentFragment trait |
spanned.rs |
49 | 内部 Spanned trait(非公开 API,供 syn::spanned::Spanned 使用) |
2708 行代码撑起整个 Rust 生态的代码生成——serde_derive / tokio-macros / axum-macros / clap_derive 全部依赖 quote。
8.16 ToTokens trait——quote 的"灵魂契约"
quote/src/to_tokens.rs:13-76 的 ToTokens trait——核心只有一个方法:
pub trait ToTokens {
fn to_tokens(&self, tokens: &mut TokenStream);
fn to_token_stream(&self) -> TokenStream { ... }
fn into_token_stream(self) -> TokenStream where Self: Sized { ... }
}
三个方法——
to_tokens——必须实现——把自己追加到 tokens 末尾to_token_stream——默认实现——创建新 TokenStream + 调 to_tokensinto_token_stream——默认实现——消费 self、拿到 TokenStream
为什么 to_tokens 不 consume self——因为 quote 可能#field #field 连续用两次——不能拿走所有权。
为什么不直接返回 TokenStream——因为"append 到现有 stream" 省掉了 "创建新 stream 再 merge" 的中间分配——quote 生成大量 token 时这是主路径。
8.17 九个基础 ToTokens impl
quote/src/to_tokens.rs:78-132 给了一串基础 impl——为标准库和 core 类型(前七个是泛型的转发 impl,后两个是字符串):
impl<T: ?Sized + ToTokens> ToTokens for &T { ... }
impl<T: ?Sized + ToTokens> ToTokens for &mut T { ... }
impl<'a, T: ?Sized + ToOwned + ToTokens> ToTokens for Cow<'a, T> { ... }
impl<T: ?Sized + ToTokens> ToTokens for Box<T> { ... }
impl<T: ?Sized + ToTokens> ToTokens for Rc<T> { ... }
impl<T: ?Sized + ToTokens> ToTokens for Arc<T> { ... }
impl<T: ToTokens> ToTokens for Option<T> { ... }
impl ToTokens for str { ... }
impl ToTokens for String { ... }
七个泛型 impl——让 &T / &mut T / Cow<T> / Box<T> / Rc<T> / Arc<T> / Option<T> 都能当 #var 插入 quote(quote/src/to_tokens.rs:134-281 是数字、char、bool、CStr/CString 和 proc-macro2 各类型的具体 impl)。
特别注意 Option<T>——
// quote/src/to_tokens.rs:114-120
impl<T: ToTokens> ToTokens for Option<T> {
fn to_tokens(&self, tokens: &mut TokenStream) {
if let Some(t) = self {
t.to_tokens(tokens);
}
}
}
None 什么都不追加、Some(t) 递归 to_tokens——这让#optional_field 能自然处理"有或无"——derive 宏里超常用。
8.18 format_ident! ——动态拼接标识符
quote/src/format.rs:111 的 format_ident! 宏——quote 的补充 API——动态构造 Ident:
let prefix = format_ident!("get_{}", field_name);
// field_name 是 Ident `age` 时,prefix 就是 `get_age`(若是字符串 "age",下面的 self.#field_name 会展开成 self."age")
quote! {
fn #prefix(&self) -> &#ty { &self.#field_name }
}
为什么需要单独的宏——quote! 的 #var 只能插入现成的 Ident——不能拼接——format_ident! 填补这个空白。
derive 宏里典型用途——
#[derive(Builder)]生成fn set_name / fn set_age / ...—— 每个 field 一个 setter#[derive(Getters)]生成fn name() / fn age() / ...- accessor / impl 块命名
8.19 runtime.rs 与重复语法的工程实现
runtime.rs 里除了展开代码要调用的一大串 push_* 函数,还有 quote! 重复语法 #(...)* 的运行时支持(HasIterator、ext 模块、RepInterp)——重复是 quote 最复杂的部分:
quote! {
#(
pub fn #methods(&self) {}
)*
}
运行时做什么——
- 遍历 iterator
methods - 对每个 item、代入模板——把
#methods替换为当前值 - 追加到 TokenStream
重复的实现分在两处——lib.rs 里的 quote_token_with_context! / quote_bind_into_iter! / quote_bind_next_or_break! 等宏负责展开出循环、插入分隔符,runtime.rs 负责让 Vec、切片、迭代器、普通值都能被统一当作"迭代器"——要支持的形态有:
#(...)*—— 无分隔#(...),*—— 逗号分隔#(...);*—— 分号分隔#(#a #b)*—— 多变量并行(要求长度一致)
并行迭代的 trick——如果两个 iterator 长度不一致,既不编译报错也不 panic,而是在最短的那个耗尽时 break(8.5 节)——等长要靠你自己保证。
这是 quote 的"隐藏高级功能"——绝大多数用户只用 #(...)* 和 #(...),*——但复杂 derive 场景需要这些。
8.20 TokenStreamExt trait——TokenStream 的扩展方法
ext.rs(138 行)的 TokenStreamExt——给 proc_macro2::TokenStream 加方法:
// quote/src/ext.rs:8-57(省去每个方法上方的文档注释与示例;后两个方法的 where 子句用 ... 略去)
pub trait TokenStreamExt: private::Sealed {
fn append<U>(&mut self, token: U)
where
U: Into<TokenTree>;
fn append_all<I>(&mut self, iter: I) where I: IntoIterator, I::Item: ToTokens;
fn append_separated<I, U>(&mut self, iter: I, op: U) where ...;
fn append_terminated<I, U>(&mut self, iter: I, term: U) where ...;
}
四个方法、分别对应:
append——追加单个 tokenappend_all——追加 iterator(无分隔)append_separated——带分隔符(如逗号分隔)append_terminated——每个都带 terminator(如a; b; c;)
其中 append 是 quote! 运行时 push_* 函数用的原语;另外三个 quote! 自己不用(重复由宏展开出的 while 循环完成),是留给手写 ToTokens 的——用户通常用宏、但也能手写——手写有时更灵活。
8.21 IdentFragment trait——"可拼接成 Ident"的抽象
quote/src/ident_fragment.rs(89 行,trait 在第 14 行)的 IdentFragment trait——让 format_ident! 接受多种类型作为"碎片":
pub trait IdentFragment {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result;
fn span(&self) -> Option<Span> { None }
}
哪些类型实现它——
Ident(直接,并保留它的 span)str/String/char/bool(按Display输出)u8/u16/u32/u64/u128/usize(无符号整数;有符号整数故意不实现,因为负号不是合法标识符字符)&T/&mut T/Cow<T>(转发)
源码注释(quote/src/ident_fragment.rs:74-75)说得明白:"Limited set of types which this is implemented for, as we want to avoid types which will often include non-identifier characters in their Display impl."——没有对所有 ToTokens 类型的 blanket impl。
用法——format_ident!("field_{}", idx) —— idx 是 usize、IdentFragment 把它转字符串再拼。
好处——format_ident! 一个宏、多种输入类型——不用写 N 个 overload。
8.22 quote 自己是声明宏
quote crate 产出的 TokenStream 供过程宏使用,但它自己是用 macro_rules! 实现的,并不是过程宏:
- 编译快:
macro_rules!不需要单独编译一个 proc-macro crate 再动态加载 - 不绕依赖链:如果 quote 本身是过程宏,它又需要自己的一套解析/生成工具,依赖链会绕回来
- 够用:
quote!要做的模式匹配,macro_rules!完全表达得了
8.23 Span 的默认值与 quote
本章 §8.7 提过 quote 默认用 Span::call_site()(quote/src/lib.rs:167-169 的 Hygiene 一节写明:模板里原生的 token 一律以 Span::call_site() 落 span,插值进来的 token 则保留各自 ToTokens 给的 span;实现侧见 quote/src/runtime.rs:311-314 的 push_ident 等一族 push_* 函数——push_ident 显式 let span = Span::call_site();,push_punct 一族靠 Punct::new 的默认 span,同样是 call_site)——quote_spanned! 让你覆盖:
// 默认:所有 token 用 call_site span
let default = quote! { let x = 1; };
// 指定:所有 token 用 custom span
let span = field.span();
let spanned = quote_spanned! {span=>
let x = 1;
};
用途——生成的代码里出错时、错误指向 span——derive 宏里常用 field.span() 让错误指向"哪个字段"。
对 derive 作者——生成 "用户可能触发错误" 的代码时、用 quote_spanned! + field.span()——用户体验飞跃。
8.24 一个典型的"quote hygiene bug"
一个常见的 derive crate 失误——生成代码里用了 let state = ...——如果用户 crate 里正好有一个叫 state 的常量——冲突(用户字段叫 state 反而没事,§8.8 解释过):
#[allow(non_upper_case_globals)]
const state: u8 = 0; // 用户 crate 里恰好有个同名常量(或单元结构体 `struct state;`)
#[derive(MyDerive)]
struct Foo {
name: String,
}
impl Foo {
fn my_method(&self) -> usize {
// MyDerive 生成的代码(span 是 call_site,按用户作用域解析)
let state = &self.name; // state 被解析成上面的常量,let 变成常量模式匹配:E0308
state.len()
}
}
避开——生成的局部变量总是用双下划线 prefix——let __state = &self.name;——用户几乎不会用 __xxx 起名。
serde_derive 的通用做法——所有内部变量 __field0 / __field1 / __name / ...——彻底避让用户 namespace。
8.25 #(...)* 的三种展开模式对比
quote 的重复语法常用的有三种"终止 + 分隔" 形态——
// 1. #(...)* —— 无分隔、多次追加
quote! { #( #items )* }
// 展开:item1 item2 item3
// 2. #(...),* —— 逗号分隔
quote! { #( #items ),* }
// 展开:item1, item2, item3
// 3. #(...);* —— 分号分隔
quote! { #( #items );* }
// 展开:item1; item2; item3
选用原则——
- 生成函数参数 →
,* - 生成语句序列 →
;*或* - 生成语句块(每条自带
;)→*
陷阱——#(#items)*,* 不会报错,但也不是你想要的:它按无分隔重复处理,后面的 ,* 被当成普通 token 原样输出(得到 a b , *)——要逗号分隔只能写 #(#items),*——逗号要么在重复内、要么在重复后作为分隔符。
8.26 #(...),* 和"空集合的 corner case"
细节问题——#(#items),* 生成 N 个元素、分 N-1 个逗号——但如果 items = [](空)——生成空串、没有 trailing comma:
let empty: Vec<Ident> = vec![];
quote! { fn foo( #( #empty ),* ) {} }
// 展开:fn foo() {} —— 不是 fn foo(,) {}
这行为正确——但新手容易写出 fn foo( #args )(忘了 #( ... ),*)——quote 把单个 #args 当成"插入一次 args 整体",要求 Vec 本身实现 ToTokens——而它没有——于是编译过程宏时就报 E0277(Vec<...>: ToTokens 不满足)。
记住——要逐个展开必用 #(...),*——#args 只对实现了 ToTokens 的单个值才对。
8.27 "quote 在 derive 里的典型 30 行骨架"
一个"拿来就能用"的 derive 宏骨架——几乎所有 derive 宏都这样开场:
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};
#[proc_macro_derive(MyDerive)]
pub fn my_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let fields_code = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(fields) => {
let field_names = fields.named.iter().map(|f| &f.ident);
quote! { #( println!("{}", self.#field_names); )* }
}
_ => quote! { } // tuple / unit 自己处理
},
_ => quote! { compile_error!("only structs supported"); }
};
let expanded = quote! {
impl #impl_generics #name #ty_generics #where_clause {
pub fn print_fields(&self) {
#fields_code
}
}
};
expanded.into()
}
这 30 来行——覆盖 derive 宏的大部分场景——替换 fields_code 里的逻辑就能衍生出#[derive(Debug)] / #[derive(Clone)] / #[derive(Builder)] 等任意变种。
背下这 30 来行——任何读者都能"写一个简单 derive"——本章 quote + 第 7 章 syn 的双剑合璧。
8.28 quote! 宏的"编译后的实际形态"
用 cargo expand 展开 quote 宏——你会看到quote! { #x + #y } 被展开成:
// 这是宏展开结果,不是源码原文
{
let mut _s = ::quote::__private::TokenStream::new();
::quote::ToTokens::to_tokens(&x, &mut _s);
::quote::__private::push_add(&mut _s);
::quote::ToTokens::to_tokens(&y, &mut _s);
_s
}
(::quote::__private 就是 quote/src/runtime.rs——quote/src/lib.rs:117-119 用 #[path = "runtime.rs"] pub mod __private; 把它挂上去;quote/src/runtime.rs:20 里 pub type TokenStream = proc_macro2::TokenStream; 只是别名,push_add 在 quote/src/runtime.rs:391。)
quote! 不是魔法——它只是 macro_rules! 生成的"一堆 push / to_tokens 调用"。
每个 #var → to_tokens(&var, &mut _s)——每个字面 token → __private::push_xxx——一一对应。
读懂这个展开 = 读懂 quote——剩下的只是 pattern matching 的细节。
cargo expand 是 quote 学习的"最佳工具"——比读 quote 源码更直观。
8.29 quote 和"宏展开递归深度"的关系
Rust 编译器有 #![recursion_limit = "128"] 默认上限——tt-muncher 风格的重宏展开会撞这个上限:
// 展开 1000 个字段的 derive 时可能报错:
// error: recursion limit reached while expanding `...`
// help: consider increasing the recursion limit by adding a `#![recursion_limit = "256"]` attribute to your crate (`...`)
原因——朴素写法是 tt-muncher:每吞一个 token 递归一层,模板长了就深。
quote 1.0 的现状——quote_each_token! 没有用 tt-muncher。它把整段模板复制成 7 份错位排列,交给 quote_tokens_with_context! 一次转置,为每个 token 生成一次带前后 3 个 token 上下文的 quote_token_with_context! 调用(quote/src/lib.rs:751-762 的注释原文:"With a tt muncher it's easy to hit Rust's built-in recursion_limit, which this implementation avoids because it isn't tail recursive",而且展开时间从二次方降到线性)。所以正常 derive 宏几乎不会因为 quote! 撞上限。真撞上了——通常是你自己的 macro_rules! 递归——在 crate 根加 #![recursion_limit = "256"] 之类——rustc 的默认值至今仍是 128——rustc 1.96.0 源码 compiler/rustc_interface/src/limits.rs 第 35 行:find_attr!(attrs, RecursionLimit { limit, .. } => limit.0).unwrap_or(128)。
8.30 proc_macro2::Literal 的"字面量类型"
quote 插值字面量时调用 Literal::i64_suffixed / Literal::u32_suffixed / Literal::string 等——每种生成不同的 token:
let v: i64 = 42;
quote! { let x = #v; }
// 展开:let x = 42i64; —— 自动带类型后缀
为什么要带后缀——防止"类型推断歧义"——42 可能被推断为 i32 或 i64、加 i64 后缀强制正确类型。
14 种数字 Literal 构造器——12 个整数类型 i8/i16/i32/i64/i128/isize/u8/u16/u32/u64/u128/usize 由 proc-macro2/src/lib.rs:1162-1190 的两张宏表批量生成,f32/f64 单独写在 proc-macro2/src/lib.rs:1205-1262——每种都有对应的 suffixed 和 unsuffixed 版本;另有 string / character / byte_character / byte_string / c_string 五种非数字构造器(proc-macro2/src/lib.rs:1265-1287)。bool 不是 Literal——true / false 在 TokenStream 里是 Ident,quote/src/to_tokens.rs:224-229 对 bool 的 ToTokens 也是 tokens.append(Ident::new(word, Span::call_site()))。
什么时候用 unsuffixed——当上下文已经明确类型、比如 #[repr(align(8))] 这类属性参数(属性里不允许带后缀的字面量)、元组字段下标 self.0(下标不能带后缀)——这时必须用 unsuffixed。
8.31 quote! vs 字符串拼接的性能对比
新手常犯错误——用 format!("fn {}() {{ ... }}", name).parse().unwrap() 代替 quote!——三个代价:
代价 1:多走一遍 lex + parse——字符串构造 → re-lex → re-parse——quote 对标识符、标点直接构造 token——只有模板里的字面量等少数 token 会走 __private::parse 单个解析(quote/src/lib.rs:1234-1239)。
代价 2:span 丢失——parse 出来的 token 一律是 Span::call_site()、用户字段原有的 span 全丢——错误消息只能指向"宏调用处"——体验差。
代价 3:字符串 bug——"{{" / "}} 转义很难记——写错字符串就编译失败。
所以生成代码时优先用 quote!,不要用 format! + parse()。
8.32 quote 的四个高阶技巧
读完本章基础——四个"老鸟才用"的技巧:
技巧 1:条件插入——用 Option<TokenStream> 实现按条件生成代码:
let maybe_clone: Option<TokenStream> = if needs_clone {
Some(quote! { #[derive(Clone)] })
} else { None };
quote! {
#maybe_clone
struct Foo { ... }
}
Option<TokenStream> 实现 ToTokens——None 什么也不追加、Some 展开。
技巧 2:嵌套 quote——在 quote 里用 quote:
let method_impls: Vec<TokenStream> = methods.iter().map(|m| {
let name = &m.ident;
quote! { fn #name(&self) { ... } }
}).collect();
quote! {
impl Foo { #( #method_impls )* }
}
先构造每个 method_impl、再集中追加——比一次性 quote 更灵活。
技巧 3:quote_spanned! 做 error-prone 代码定位:
use syn::spanned::Spanned;
let ty = &field.ty;
let span = ty.span();
let assert_sync = quote_spanned! {span=>
struct _AssertSync where #ty: Sync;
};
字段类型不满足 Sync 时、错误标在用户写的字段类型上——用户一眼看出"这个类型有问题"(这是 quote 文档里 quote_spanned! 的示例;rustc 1.96.0 实测:换成 quote! 的话错误只指向 derive 那一行)。
技巧 4:用 format_ident! 动态构造 Ident:
let as_ref_name = format_ident!("as_{}_ref", field_name);
quote! { fn #as_ref_name(&self) -> &#ty { &self.#field } }
生成 as_foo_ref、as_bar_ref 等动态方法名——derive 常见需求。
8.33 quote 做不了的事
quote 不能做的事——
- 不能 parse 代码 → 用 syn
- 不能查类型信息 → proc macro 没这能力、要靠 rustc type check
- 不能跨文件影响其他代码 → 只能在宏调用处输出新代码(derive 宏连被 derive 的 item 本身都改不了、只能追加)
"quote 只负责写"——读用 syn、语义分析用 rustc——三者分工明确。
8.34 "比 quote 更新"的 quote-use 和 venial
2022-2023 年前后有一些"补位 quote / syn"的 crate 出现——不是否定 quote、是填补特定空白:
quote-use——允许在模板开头写use语句、展开时把后文用到的名字替换成绝对路径(如use std::fs::read;之后的read变成::std::fs::read)——生成代码不再依赖用户作用域里的 importvenial——轻量级 syn 替代、编译更快——derive crate 如果只需要基础 AST、可以选 venialmanyhow——更好的错误处理 API——比syn::Error::new + to_compile_error简洁
这些都是"生态补位"——不是 quote 被淘汰——quote 1.0 自 2019 年发布以来一直稳定、仍是主流。
但新项目可以尝试 quote-use 等——编写体验稍好。
8.35 quote 和 macro_rules! 的双向互补
Rust 有两种宏——quote 是 proc macro 的辅助、macro_rules! 是 declarative macro——两者在现实项目里常常"混用":
典型场景——库作者同时提供两种宏:
// 1. 给用户一个 macro_rules! 简化调用
#[macro_export]
macro_rules! my_query {
($sql:expr) => { $crate::__private_derive::query!($sql) };
}
// 2. __private_derive::query 是 proc macro、用 quote 实现
#[proc_macro]
pub fn query(input: TokenStream) -> TokenStream {
let sql: LitStr = parse_macro_input!(input as LitStr);
let code = /* 用 quote! 生成 */;
code.into()
}
分工是:外层 macro_rules! 负责参数规整和对用户友好的调用形式,内层 proc macro 做需要解析和外部信息的重活。
sqlx 的 sqlx::query! 就是这个模式:外层是 macro_rules!(sqlx 0.8.6 源码 src/macros/mod.rs 第 314 行,只做参数规整,把 query!("...", a, b) 转写成 expand_query!(source = ..., args = [...])),内层 expand_query 才是 #[proc_macro](同版本 sqlx-macros/src/lib.rs 第 8-9 行)。注意内层 proc macro 做的不是 SQL 解析——它只用 syn 解析 Rust 侧的宏输入,SQL 本身原样交给真实数据库 describe(§7.13 已核过)。外层规整 DX、内层做需要 AST 与外部信息的重活——两级各司其职。(sqlx 是本专栏的对照树——已列进前言的 checkout 清单,路径与行号以 sqlx 0.8.6、commit bab1b02 为准。)
8.36 cargo expand 在 quote 调试中的地位
$ cargo install cargo-expand
$ cargo expand --bin my_app > expanded.rs
$ less expanded.rs
看到 quote! 展开后的完整代码,大多数"宏生成了奇怪的代码"的问题都能很快定位。
配合 rust-analyzer——可以在 IDE 里用 Expand macro recursively 命令看展开——更即时。