Serde 元编程

第 8 章 quote:用模板反向生成 TokenStream

作者 杨艺韬 · 10,511 字 · 发布于 · 更新于

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/false
  • char → 字符字面量

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()
}

解读:

  1. parse_macro_input!(input as DeriveInput) — 解析类型定义。
  2. input.ident — 类型名。
  3. 从 input.data 提取 Fields::Named(命名字段)。
  4. 提取字段名列表 field_names(类型是 Vec<&Ident>)。
  5. 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
    }};
}

实际实现更复杂——因为:

  1. macro_rules! 不能直接递归处理任意 token(比如嵌套括号),要特殊处理 Group。
  2. #(...)* 内部可能有多个迭代变量,需要并行遍历。
  3. 每个 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 只有三样:

  1. quote! { 模板 } — quasi-quoting,插值用 #var、重复用 #(...)*。
  2. format_ident!("前缀{}", base) — 动态生成标识符。
  3. 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 为什么长那样、为什么要分那么多文件、每一段代码的动机。

动手实验

  1. 写一个最小 derive 宏:按第 8.10 节的 MyDebug 自己实现一遍。注意不要 copy,自己打一遍——手指的记忆比眼睛的记忆牢固。

  2. 观察 quote 展开:用 println!("{}", tokens)(或 eprintln! 在过程宏里)打印 quote! 的输出。理解你写的模板最终变成了什么字符串。

  3. 测试 hygiene:给你的 MyDebug 生成的代码加一个局部变量(比如 let state = ...;)。先让用户结构体带一个叫 state 的字段,观察是否出事;再在用户 crate 里定义 const state: u8 = 0;,观察会发生什么。然后改成 __state 看错误是否消失。

  4. 思考题:为什么 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_tokens
  • into_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——追加单个 token
  • append_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)——生成代码不再依赖用户作用域里的 import
  • venial——轻量级 syn 替代、编译更快——derive crate 如果只需要基础 AST、可以选 venial
  • manyhow——更好的错误处理 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 命令看展开——更即时。