楼层: 首页/ 软件技术/ Rust 语言基础/ 宏系统:macro_rules! 与过程宏的完整实践
22

宏系统:macro_rules! 与过程宏的完整实践

Macros · Declarative & Procedural

第 16 章你已经知道"vec! 是宏不是函数"。但真到要自己写一个宏的时候,绝大多数人卡在三个点上:片段分类符记不住、重复语法 $()* 看不懂、过程宏一写就报一堆看不懂的错。这一章把宏当成一门"小语言"来教:先讲清三套宏各自解决什么问题,再手搓一个能用的 hashmap!,最后完整走一遍"从零写一个自定义 derive"。学完你应该能看懂 serde 的报错、能自己写 cargo expand 去查问题。

四种宏,各管一段:先把地图铺开

Rust 的"宏"其实是一族机制,它们的实现方式、能做的事、出错时的体验完全不同。选错种类是最常见的返工原因。

种类长相本质典型用途与限制
声明宏
macro_rules!
name!(...) 基于词法 token 的模式匹配,在 AST 之前展开 减少重复模板代码:vec!、hashmap!、lazy_static 风格初始化。不能做新的语法、拿不到类型信息(它只看 token 长什么样)。
derive 过程宏 #[derive(Foo)] 函数式:把结构体/枚举的定义喂给你的函数,你吐回新代码 自动生成 impl:Serialize、Clone、Error、ToSchema。只能"附加"代码,不能修改原定义。
属性宏 #[foo(bar)] 函数式:把属性参数 + 被标注的 item 原文一起给你,你把 item 整个替换掉 改函数体/改签名:#[tokio::main]、#[instrument]、#[utoipa::path]、#[tauri::command]。必须原样把 item 吐回去,否则那个函数就消失了。
函数式过程宏 foo!(...) 函数式:输入一段 token,输出一段 token 做 DSL:sqlx::query!、html!、quote!。看起来像 macro_rules!,但内部能做任意 Rust 计算。

论为什么要分成"声明"和"过程"两套

① 声明宏是"就地文本替换",必须稳定且快。它在 rustc 早期阶段展开,只依赖 token 序列,因此编译快、错误定位直接指向调用点。代价是它看不见类型,也无法读取 #[attr] 的内容做判断。

② 过程宏是"用 Rust 写编译器插件"。它拿到的是通过 syn 解析出的真实语法树,可以遍历字段、看类型、看属性参数,于是 serde 能针对每个字段生成不同的序列化代码。代价是必须放在独立的 proc-macro crate、编译更慢、错误信息需要你手动构造。

③ 判断标准一句话:需要"根据字段名/类型/属性做不同处理"就用过程宏;只是"少写重复的样板"就用 macro_rules!。能用声明宏解决的,绝对不要上过程宏——前者编译快十倍,维护成本低一个数量级。

macro_rules! 的三件事:分类符、重复、卫生性

把 macro_rules! 想象成一个"词法模式匹配器":左边是模式,右边是展开结果,多条规则从上往下试,第一条匹配成功就停止。

片段分类符(fragment specifier)全家福

分类符匹配什么实战说明
$x:expr一个表达式最常用。注意后面只能跟 =>、,、;,跟着别的 token 编译器会报 expected one of ...。
$x:ident一个标识符用来传变量名、函数名、字段名。配合 stringify!($x) 能拿到字符串名字。
$x:ty一个类型泛型参数、返回类型常用。解析歧义多,能不用就不用。
$x:path一条路径std::collections::HashMap 这种。比 :ty 更明确,优先级更高。
$x:pat一个模式match 分支里用。注意它不接受 | 或,需要或模式请用 :pat_param(旧名)或加括号。
$x:stmt一条语句用得少,因为匹配边界模糊,容易和 block 打架。
$x:block一个 { ... }传一段代码块,比 :expr 更明确。
$x:item一项(函数/结构体/impl)用来动态生成函数、结构体。
$x:tt一个 token 树万能兜底:既能匹配单个 token,也能匹配一整个 {...}。写 DSL 时首选。
$x:literal一个字面量只匹配 123、"abc" 这种,不匹配变量。比 :expr 严格。
$x:meta一条属性内容配合 #[derive($x)] 这类场景。
$x:lifetime一个生命周期'a 这种。$x:vis(可见性)也常一起用。

最小可用:三条规则 + 尾巴逗号容忍

// 规则从上往下匹配,所以"具体"的必须写在"通用"的前面 macro_rules! log_level { // 1) 空:不传参数 () => { println!("[]") }; // 2) 一个表达式 ($msg:expr) => { println!("{}", $msg) }; // 3) 等级 + 消息 ($level:ident, $msg:expr) => { println!("[{}] {}", stringify!($level), $msg) }; } fn main() { log_level!(); log_level!("服务启动"); log_level!(warn, format!("磁盘剩余 {}%", 3)); }

现在写出真正实用的那个:hashmap!。标准库只有 vec!,没有 hashmap!,而写测试数据时它极其顺手。

hashmap!:演示重复语法 $( ... )sep*

macro_rules! hashmap { // 分支 1:空。必须先写,否则下面的 + 匹配不到"零个元素" () => { std::collections::HashMap::new() }; // 分支 2:一个或多个 k => v,中间逗号分隔,末尾允许一个多余逗号 // 语法读法:$( ... )+ 表示"把括号里这段重复 1 次以上,以 , 分隔" $( $key:expr => $val:expr ),+ $(,)? => {{ let mut map = std::collections::HashMap::new(); // 重复体内可以同时引用 $key / $val,两者会被"同步展开" $( map.insert($key, $val); )+ map }}; } fn main() { let m = hashmap! { "a" => 1, "b" => 2, // 末尾逗号不写也行,$(,)? 就是用来吃掉它的 }; println!("{:?}", m); }

重复语法的完整形态只有一句话要记:$( 内容 )分隔符 重复符。分隔符可以省略,重复符可以是 *(零或多次)、+(一次或多次)、?(零或一次)。嵌套重复也合法,但内层重复必须至少引用一个内层引入的元变量,否则编译器会告诉你"重复体内的元变量未定义"。

嵌套重复:生成二维结构

macro_rules! matrix { // 外层重复是"行",内层重复是"列";内层用到了 $x,所以合法 ( $( [ $( $x:expr ),* ] ),* $(,)? ) => {{ let rows: Vec<Vec<i32>> = vec![ $( vec![ $( $x as i32 ),* ] ),* ]; rows }}; } fn main() { let m = matrix![ [1, 2, 3], [4, 5, 6], ]; assert_eq!(m[1][2], 6); }

卫生性(hygiene):宏里的 x 和外面的 x 是两个人的 x

macro_rules! double { ($v:expr) => {{ // 这里声明的 temp 是"宏内部颜色"的标识符 let temp = $v; temp + temp }}; } fn main() { let temp = 100; // 宏不会污染外面的 temp,外面的 temp 也不影响宏内部 let r = double!(temp); assert_eq!(r, 200); assert_eq!(temp, 100); } // 但"传进来的标识符"是调用方的颜色,所以下面这个宏能正常工作 macro_rules! make_getter { ($field:ident) => { pub fn $field(&self) -> &str { &self.$field } }; } struct User { name: String, email: String } impl User { make_getter!(name); make_getter!(email); }
坑:宏里的 $x 被求值两次(甚至多次)

这是声明宏最经典、也最难发现的坑。看这个手写的 max!:

macro_rules! max { ($a:expr, $b:expr) => { if $a > $b { $a } else { $b } } }

如果调用 max!(expensive(), 0),expensive() 会被调用两次(先比较、再返回),在有副作用时结果直接错了,在无副作用时白算一遍。正确处理是"先用 let 绑一次,再复用变量":

macro_rules! max { ($a:expr, $b:expr) => {{ // 关键:每个表达式只求值一次,后面都用绑定后的变量 let a = $a; let b = $b; if a > b { a } else { b } }}; } // 标准库的 std::cmp::max 就是这么写的,可以对照源码看

过程宏实战:从零写一个自定义 derive

过程宏有一个绕不开的硬约束:必须放在一个独立的 crate 里,且这个 crate 的 Cargo.toml 要声明 proc-macro = true。这个 crate 里只能导出过程宏,不能导出普通的函数和类型,也不能被同一 crate 内的代码 use。

第一步:建一个 proc-macro crate(放在 workspace 的 crates/ 下)

# cargo new --lib crates/macros 之后,改 Cargo.toml [package] name = "my-macros" version = "0.1.0" edition = "2021" [lib] # 这一行是开关,没有它就不能写 #[proc_macro_derive] proc-macro = true [dependencies] # syn:把 TokenStream 解析成可遍历的语法树;features 按需开,full 才支持解析函数体 syn = { version = "2", features = ["full"] } # quote:把 Rust 代码写成模板并插入变量,反向生成 TokenStream quote = "1" # proc-macro2:让 syn/quote 能在非 proc-macro 环境下测试(单元测试必需) proc-macro2 = "1"

第二步:写 derive 宏本体 —— 给结构体生成一个 describe() 方法

// crates/macros/src/lib.rs use proc_macro::TokenStream; use quote::quote; use syn::{parse_macro_input, DeriveInput, Data, Fields}; // #[proc_macro_derive(名字)] 里的名字是"使用方写的名字" // 括号里的 attributes(...) 声明本宏会消费哪些属性参数,不声明的话嵌套属性会被 rustc 拒绝 #[proc_macro_derive(Describe, attributes(describe))] pub fn derive_describe(input: TokenStream) -> TokenStream { // parse_macro_input! 解析失败会直接把编译错误返回给用户,别手动 unwrap let ast = parse_macro_input!(input as DeriveInput); let name = &ast.ident; // 只支持结构体,其他形态用 syn::Error 报一个"能指到行"的错误 let fields = match &ast.data { Data::Struct(s) => match &s.fields { Fields::Named(f) => f.named.iter().map(|x| x.ident.clone().unwrap()).collect::<Vec<_>>(), _ => Vec::new(), }, _ => { return syn::Error::new_spanned(&name, "Describe 只能用在结构体上") .to_compile_error() .into(); } }; // 字段名列表:把每个 ident 变成字符串字面量 let field_names: Vec<String> = fields.iter().map(|i| i.to_string()).collect(); // quote! 里 #var 表示"插入这个变量",#( ... )* 表示重复 let expanded = quote! { impl #name { pub fn describe() -> &'static str { // stringify! 在编译期把类型名变成字符串,零运行时开销 stringify!(#name) } pub fn field_names() -> Vec<&'static str> { // #( ... ),* 会把下面的字面量列表展开成 &["a", "b", "c"] vec![ #( #field_names ),* ] } } }; TokenStream::from(expanded) }

第三步:在业务 crate 里用它

# crates/api/Cargo.toml [dependencies] my-macros = { workspace = true } // crates/api/src/model.rs use my_macros::Describe; // 注意 crate 名里的 - 在代码里要写成 _ #[derive(Describe)] pub struct Order { pub id: u64, pub owner: String, pub total: f64, } // 现在可以直接调用 fn demo() { println!("{}", Order::describe()); // "Order" println!("{:?}", Order::field_names()); // ["id", "owner", "total"] }

属性宏与函数式过程宏的骨架(都是同一个套路)

// 属性宏:输入 (属性参数, 被标注的 item),输出替换后的 item #[proc_macro_attribute] pub fn timed(args: TokenStream, item: TokenStream) -> TokenStream { // args 是 #[timed(x)] 括号里的内容,没用上就忽略 let _ = args; let func = parse_macro_input!(item as syn::ItemFn); let sig = &func.sig; let block = &func.block; quote! { #sig { let __t = std::time::Instant::now(); // 把原函数体原样搬过来,用闭包立即执行以拿到返回值 let __r = (|| #block)(); tracing::info!(elapsed_ms = __t.elapsed().as_millis() as u64, "timed"); __r } } .into() } // 函数式过程宏:长得像 macro_rules!,但内部能跑任意 Rust 逻辑 #[proc_macro] pub fn env_or(input: TokenStream) -> TokenStream { // 用法:env_or!("PORT", "8080") —— 自己实现一个 Parser 描述期望的语法 struct Args { key: syn::LitStr, default: syn::LitStr } impl syn::parse::Parse for Args { fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { let key: syn::LitStr = input.parse()?; input.parse::<syn::Token![,]>()?; // 吃掉逗号 let default: syn::LitStr = input.parse()?; Ok(Args { key, default }) } } let Args { key, default } = parse_macro_input!(input as Args); quote! { std::env::var(#key).unwrap_or_else(|_| #default.to_string()) } .into() }

论syn / quote / proc-macro2 三件套到底谁负责什么

① proc-macro2 是"可移植的 TokenStream"。标准库的 proc_macro::TokenStream 只能在过程宏进程里用,没法在普通单元测试里构造。所以 syn 和 quote 全都在 proc-macro2::TokenStream 上工作,只有最外层进出时做一次 .into() 转换。这也是为什么你能给自己的宏写单元测试——直接喂一个 quote!{ struct A; } 进去断言输出即可。

② syn 负责"输入解析"。它把 token 流变成 DeriveInput / ItemFn / Expr 这些结构化类型,你才能遍历字段、读属性。解析失败时它会给出带 Span 的错误,Span 能映射回用户源码的行列,这是过程宏错误信息可读性的唯一来源。

③ quote 负责"输出生成"。它提供一个近似 Rust 语法的模板:#var 插入变量、#( ... )* 重复、#var.foo() 做插值调用。它不会做语法检查,拼错的部分会原样进入输出,最后在用户代码里报错——这是过程宏调试困难的根本原因,所以必须配合下面讲的 cargo expand。

调试宏:cargo expand 是你的显微镜

宏最难的不是"写不出来",而是"报错指向宏调用点,你根本不知道展开成了什么"。Rust 生态给了一个标准答案。

常用调试手段(按使用频率排序)

# 1) 安装并展开:最有用的一招,看清宏最终生成了什么代码 cargo install cargo-expand cargo expand --lib model # 展开指定模块 cargo expand -p order-api path::to::Order # 展开到某个类型 cargo expand --test integration_tests # 2) 不加 nightly 开关的替代:看 HIR/MIR,用来查"为什么这句没优化" cargo rustc -- -Zunpretty=hir-tree # 需要 nightly # 3) 让编译器打印宏展开栈,报错来自哪个宏一目了然 RUSTFLAGS="-Zmacro-backtrace" cargo build # nightly # 4) 过程宏内部调试:直接 eprintln!,输出会出现在 cargo build 的 stderr 里 # eprintln!("input ast = {:#?}", ast);
// 给自己的过程宏写单元测试:proc-macro2 让这件事成为可能 #[cfg(test)] mod tests { use quote::quote; // 把宏的内部逻辑抽成普通函数,参数是 proc_macro2::TokenStream // 这样就能在普通 #[test] 里直接调用、断言输出字符串 #[test] fn expands_field_names() { let ast = syn::parse2::<syn::DeriveInput>(quote! { struct Order { id: u64, owner: String } }).unwrap(); let out = expand_describe(&ast).to_string(); assert!(out.contains("field_names")); assert!(out.contains("owner")); } }
坑:过程宏报错信息难读,问题多半出在你没做这两件事

① 用了 .unwrap() 而不是 parse_macro_input!。unwrap() 抛出的是 panic,用户看到的是 proc macro panicked 加一堆内部栈;parse_macro_input! 会把 syn 的带位置错误原样变成编译错误,指向用户代码的准确位置。

② 自己拼错误信息时没有 Span。要用 syn::Error::new_spanned(tokens, "消息") 而不是 Error::new(Span::call_site(), ...),前者能让 IDE 和编译器把波浪线画在出问题的那个字段上。如果要做"多错误一起报",引入 proc-macro-error2 或 syn::Error::combine。

另外一条常被忽略的:quote! 生成的结构体/枚举必须用 #name 而不是硬编码名字,否则泛型参数(struct Foo<T>)会因为缺少 impl<T> 而报 "cannot find type T"。泛型结构体请用 ast.generics.split_for_impl() 拿 impl_generics / ty_generics / where_clause 三段拼。

泛型结构体的正确处理(这是 derive 宏最常见的翻车点)

use syn::DeriveInput; use quote::quote; fn expand(ast: &DeriveInput) -> proc_macro2::TokenStream { let name = &ast.ident; // 拆成三段:impl 的泛型声明 / 类型处的泛型实参 / where 子句 let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl(); quote! { // 少了任何一段,struct Foo<T> 都会编不过 impl #impl_generics #name #ty_generics #where_clause { pub fn type_name() -> &'static str { stringify!(#name) } } } }
记
本章小结

① 选种类的顺序:能用 macro_rules! 就别上过程宏;需要"按字段名/属性生成不同代码"才用 derive,需要"改写被标注的 item"才用属性宏。

② 片段分类符记住三个高频的::expr(表达式,后面只能接 => , ;)、:ident(标识符)、:tt(万能兜底)。

③ 重复语法就一句:$( 内容 )分隔符 重复符,重复符是 */+/?。

④ 卫生性保护宏内部的局部变量不污染调用方,但传进来的 $ident 是调用方的颜色,所以 make_getter!(name) 才能生成 fn name()。

⑤ 宏里的 $x 会被求值多次,一律先 let a = $a; 绑一次再复用。

⑥ 过程宏必须独立 crate,内部逻辑抽成 expand(&DeriveInput) 这类纯函数,就能用 proc-macro2 写单元测试。

⑦ cargo expand 是宏的显微镜,写宏不装它等于闭着眼睛开车。

小练习 · 五道宏自测题(点开看答案)

1.(排错题)写 macro_rules! m { ($x:expr == 0) => {...} } 报错,为什么?

查看答案

:expr 后面只能跟 =>、,、;。你写了 == 属于"后面跟了其他 token",编译器会报 expected one of `=>`, `,`, `;`。修法:把 == 0 也放进 ($x:expr) 里由调用方写,或者用 $x:tt 逐个匹配 token。

2.(概念题)下面的宏有卫生性问题吗?macro_rules! m { () => { let x = 1; x + 1 } },调用方也有个 x。

查看答案

没有。宏内部 let x 带的是"宏定义处的语法上下文",和调用方的 x 是两个不同的标识符,不会冲突。这正是 hygienic macro 的价值:调用方不用关心宏内部用了什么变量名。但注意:结构体字段名、类型名、泛型参数不走卫生性,所以宏里生成 let T = ... 之类的标识符仍可能与调用方冲突。

3.(实操题)为什么 hashmap! 里必须先写 () => {...} 这条空分支?

查看答案

因为 $( ... ),+ 要求至少一个元素。不写空分支的话,hashmap!{} 匹配不到任何规则,报 "no rules expected this token"。要么补 () => 分支,要么改用 * 并处理零元素时 HashMap::new() 的类型推断问题(零元素时无法推断 K/V 类型,所以显式分支更安全)。

4.(架构题)为什么过程宏必须单独一个 crate?为什么它不能被自己 crate 里的代码使用?

查看答案

过程宏是"编译期运行的插件"。rustc 编译某个 crate 时,需要先把它的过程宏编译成可执行代码并加载进编译器进程执行。如果宏和业务代码在同一个 crate,就变成"编译 A 需要先运行 A"的循环依赖。所以 Cargo 强制拆开,并且 proc-macro crate 只能导出过程宏(不能导出普通函数/类型),也因此无法在自身内被 use。

5.(实战题)你的 derive 宏在 struct Response<T> 上报 cannot find type `T` in this scope,怎么改?

查看答案

泛型要拆三段。用 ast.generics.split_for_impl() 得到 (impl_generics, ty_generics, where_clause),然后写 impl #impl_generics #name #ty_generics #where_clause { ... }。只写 impl #name 会丢掉 <T> 声明,导致类型参数在 impl 作用域里不存在。