Table of contents
Open Table of contents
宏展开在编译流程中的位置
源代码
→ Lexing(词法分析,产出 Token Stream)
→ Parsing + 宏展开(迭代展开,深度优先) ← 宏在这里执行
→ Name Resolution(名称解析)
→ HIR 构建(High-Level IR)
→ Type Checking(类型检查)
→ MIR 构建(Mid-Level IR)
→ Borrow Checking(借用检查)
→ Codegen(LLVM IR → 机器码)
关键点:宏展开发生在类型检查之前。宏看到的是 token 流,不知道类型信息。这是 Rust 宏和 C++ template metaprogramming 的根本区别 — C++ 模板在实例化时有完整的类型信息,Rust 宏没有。
宏展开是迭代的:一个宏展开后可能产生新的宏调用,编译器会反复展开直到没有宏调用为止(有递归深度限制,默认 128 层,可通过 #![recursion_limit = "256"] 调整)。
词法分析产出的 Token 不携带语法信息
Rust 的词法分析和传统编译器一样,不区分表达式、类型和变量。产出的 TokenTree 只有四种:
TokenTree:
├─ Ident "foo", "struct", "async"(标识符和关键字)
├─ Literal 42, "hello", 3.14
├─ Punct +, -, ::, ->, #
└─ Group (...), [...], {...}(递归包含子 token tree)
词法阶段完全不知道 foo 是变量名、类型名还是函数名,也不知道 1 + 2 是一个表达式。
那 $e:expr 是怎么回事? macro_rules! 的 fragment specifier 不是词法层的概念,而是宏匹配时调用 parser 的指令:
macro_rules! example {
($e:expr) => { ... };
}
example!(1 + 2 * 3);
// 1. 词法分析产出 [1, +, 2, *, 3] — 平坦的 token,无语法信息
// 2. 宏匹配遇到 $e:expr → 调用 parser 的表达式解析器,消费 token 解析出完整的表达式 AST
// 3. 解析结果作为不透明的 AST 节点绑定到 $e
一旦 token 被 parser 消费为 expr,就变成不可分割的 AST 块。下游宏无法再拆开匹配其内部 token:
macro_rules! inner {
(1 + $rest:expr) => { ... };
}
macro_rules! outer {
($e:expr) => { inner!($e) };
}
outer!(1 + 2);
// inner! 收到的 $e 是不透明的 expr AST,无法匹配字面 "1 +" — 编译错误
和传统编译器的唯一区别:Rust 词法阶段会做括号匹配,产出树结构(Token Tree),而不是传统的 flat token 序列。遇到 ( 就递归收集直到 )。这是为了让宏系统方便地处理分组,和”区分表达式/类型/变量”无关。
传统编译器: Lexer → flat [token] → Parser → AST
Rust: Lexer → [TokenTree] → Parser → AST
↑ 唯一区别:括号匹配形成树结构
一、声明式宏(macro_rules!)
基于模式匹配的 token 替换。编译器匹配输入 token,捕获片段,替换到输出模板中。
基本结构
macro_rules! 宏名 {
(匹配模式1) => { 展开模板1 };
(匹配模式2) => { 展开模板2 };
}
匹配从上到下,第一个命中的规则生效(类似 match)。
Fragment Specifiers(片段说明符)
用 $name:specifier 捕获特定类型的语法片段:
| 说明符 | 匹配内容 | 示例 |
|---|---|---|
expr | 表达式 | 1 + 2, foo(), if x { 1 } else { 2 } |
ident | 标识符或关键字 | foo, self, String |
ty | 类型 | i32, Vec<String>, &'a str |
path | 路径 | std::io::Error, ::foo::bar |
item | 条目 | fn foo() {}, struct Bar;, impl Trait for T {} |
stmt | 语句 | let x = 1, return 42 |
block | 块表达式 | { let x = 1; x + 2 } |
pat | 模式 | Some(x), 1..=5, _ |
pat_param | 模式(参数位置,不含 |) | ref x, mut y |
literal | 字面量 | 42, "hello", true |
lifetime | 生命周期 | 'a, 'static |
vis | 可见性 | pub, pub(crate), 空 |
meta | 属性内容 | derive(Debug), cfg(test) |
tt | 单个 Token Tree | 任何单个 token 或 ()/[]/{} 包裹的 token 组 |
tt 是最灵活的,能匹配任何东西,常用于”透传”场景。
重复
用 $(...)分隔符 重复符 语法处理可变数量的参数:
macro_rules! vec_of {
// $(...),* 表示零次或多次,逗号分隔
($($element:expr),* $(,)?) => {
{
let mut v = Vec::new();
$(v.push($element);)* // 对每个捕获的 element 展开一次
v
}
};
}
let v = vec_of![1, 2, 3];
// 展开为:
// let mut v = Vec::new();
// v.push(1);
// v.push(2);
// v.push(3);
// v
重复符号:
*— 零次或多次+— 一次或多次?— 零次或一次
实际例子:简化的 vec! 宏
macro_rules! my_vec {
() => { Vec::new() };
($($x:expr),+ $(,)?) => {
<[_]>::into_vec(Box::new([$($x),+]))
};
}
实际例子:简化的 println!
macro_rules! my_println {
() => { print!("\n") };
($fmt:expr) => { print!(concat!($fmt, "\n")) };
($fmt:expr, $($arg:tt)*) => { print!(concat!($fmt, "\n"), $($arg)*) };
}
$($arg:tt)* 是惯用模式 — 用 tt 通配捕获剩余所有 token,原样转发给内层宏。
宏的卫生性(Hygiene)
声明式宏具有部分卫生性:宏内部定义的变量不会和调用处的变量冲突。
macro_rules! check {
() => {
let x = 42; // 这个 x 属于宏定义的"语法上下文"
};
}
fn main() {
let x = 1;
check!();
println!("{}", x); // 输出 1,不是 42
// 宏内的 x 和外面的 x 是不同的
}
但宏可以访问调用处可见的函数和类型(混合卫生性):
macro_rules! call_it {
() => {
func(); // 使用调用处可见的 func
};
}
fn func() { println!("called"); }
fn main() {
call_it!(); // 正常调用 func()
}
宏导出
// 在定义 crate 中标记导出
#[macro_export]
macro_rules! my_macro { ... }
// 在其他 crate 中使用
use some_crate::my_macro;
#[macro_export] 将宏提升到 crate 根作用域。
FOLLOW Set 限制
为了避免歧义,Rust 对 fragment specifier 后面能跟什么 token 有严格限制:
// 合法:expr 后面必须跟 =>, ,, ;, 或闭括号
macro_rules! ok { ($e:expr, $f:expr) => {} }
// 非法:expr 后面直接跟 + 有歧义(1 + 2 是一个 expr 还是两个?)
// macro_rules! bad { ($e:expr + $f:expr) => {} } // 编译错误
二、过程宏(Procedural Macros)
过程宏是编译期执行的 Rust 函数,输入 TokenStream,输出 TokenStream。它是一段真正运行的代码,不是模式替换。
约束
过程宏必须定义在独立的 crate 中,该 crate 的 Cargo.toml 需要标记:
[lib]
proc-macro = true
这是因为过程宏在编译期执行,需要被编译为编译器插件(动态链接库),和普通代码不在同一个编译流程中。
三种过程宏
1. Function-like 宏
类似声明式宏的调用语法,但内部是任意 Rust 代码:
// my_macro_crate/src/lib.rs
use proc_macro::TokenStream;
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
// input 是 sql!(...) 括号内的所有 token
// 可以做任意处理:解析 SQL 语法、生成查询代码等
let sql_string = input.to_string();
// ... 处理 ...
output_tokens
}
// 使用
sql!(SELECT * FROM users WHERE id = 1);
2. Derive 宏
为 struct/enum 自动生成 trait 实现。只能添加新代码,不能修改原始定义:
// my_derive_crate/src/lib.rs
use proc_macro::TokenStream;
#[proc_macro_derive(MyTrait)]
pub fn derive_my_trait(input: TokenStream) -> TokenStream {
// input 是 struct/enum 的完整定义
// 返回值是新增的代码(追加到原始定义后面)
// ...
}
// 使用
#[derive(MyTrait)]
struct Foo {
x: i32,
y: String,
}
Derive 宏还可以声明 helper attribute:
#[proc_macro_derive(MyTrait, attributes(my_attr))]
pub fn derive_my_trait(input: TokenStream) -> TokenStream { ... }
// 使用时可以用 helper attribute 标记字段
#[derive(MyTrait)]
struct Foo {
#[my_attr(skip)]
x: i32,
y: String,
}
3. Attribute 宏
最强大的形式。可以完全替换被标记的条目(不仅仅是追加):
// my_attr_crate/src/lib.rs
use proc_macro::TokenStream;
#[proc_macro_attribute]
pub fn my_attribute(attr: TokenStream, item: TokenStream) -> TokenStream {
// attr: 属性参数,如 #[my_attribute(foo, bar)] 中的 foo, bar
// item: 被标记的完整条目(函数、struct 等)
// 返回值替换整个条目
// ...
}
// 使用
#[my_attribute(some_arg)]
fn my_function() { ... }
三种过程宏的能力对比
| Function-like | Derive | Attribute | |
|---|---|---|---|
| 调用语法 | macro!() | #[derive(Macro)] | #[macro] |
| 输入 | 括号内任意 token | struct/enum 定义 | 属性参数 + 被标记条目 |
| 能修改输入? | 不适用 | 不能(只追加) | 能(替换整个条目) |
| 能生成新条目? | 能 | 能 | 能 |
| 适用场景 | DSL、代码生成 | 自动 impl trait | 修改函数/类型行为 |
syn + quote:过程宏的标配工具链
直接操作 TokenStream 非常痛苦。社区标配是 syn(解析)+ quote(生成):
TokenStream → syn::parse() → AST 结构体 → 变换逻辑 → quote!() → TokenStream
输入 解析 结构化数据 你的代码 生成 输出
完整的 derive 宏示例:
// hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// 1. 用 syn 把 TokenStream 解析为结构化的 AST
let input = parse_macro_input!(input as DeriveInput);
// 2. 从 AST 中提取信息
let name = &input.ident; // struct/enum 的名字
// 3. 用 quote 生成新代码
let expanded = quote! {
impl HelloMacro for #name {
fn hello() {
println!("Hello from {}!", stringify!(#name));
}
}
};
// 4. 转回 TokenStream
TokenStream::from(expanded)
}
syn::DeriveInput 的结构:
pub struct DeriveInput {
pub attrs: Vec<Attribute>, // #[...] 属性
pub vis: Visibility, // pub 等可见性
pub ident: Ident, // 类型名
pub generics: Generics, // 泛型参数
pub data: Data, // struct 字段 / enum variants
}
pub enum Data {
Struct(DataStruct),
Enum(DataEnum),
Union(DataUnion),
}
#[derive(Debug)] 的内部实现
#[derive(Debug)] 是编译器内置的 derive 宏,但逻辑和自定义 derive 宏类似:
#[derive(Debug)]
struct Point {
x: f64,
y: f64,
}
// 编译器生成(大致等价):
impl std::fmt::Debug for Point {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Point")
.field("x", &self.x)
.field("y", &self.y)
.finish()
}
}
遍历 struct 的每个字段,为每个字段调用 .field(name, &self.field)。要求每个字段的类型也实现了 Debug,否则编译错误(这个检查在宏展开之后的类型检查阶段发生)。
#[tokio::main] 的内部实现
#[tokio::main] 是一个 attribute 宏,它把 async main 函数变换为普通 main 函数 + runtime 初始化:
// 你写的:
#[tokio::main]
async fn main() {
println!("hello");
}
// 宏展开后(大致等价):
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
println!("hello");
})
}
这就是为什么它必须是 attribute 宏而不是 derive 宏 — 它需要修改 main 函数的签名和函数体(去掉 async,包裹 block_on),derive 宏做不到这一点。
三、声明式宏 vs 过程宏
| 声明式宏 (macro_rules!) | 过程宏 | |
|---|---|---|
| 定义方式 | 模式匹配规则 | Rust 函数 |
| 运行时机 | 编译期 token 替换 | 编译期执行 Rust 代码 |
| 输入 | 按规则匹配的 token 片段 | TokenStream |
| 能力 | 模板替换 + 重复 | 任意计算(可访问文件系统、网络等) |
| crate 限制 | 可在任何 crate 中定义 | 必须在独立的 proc-macro crate |
| 卫生性 | 部分卫生 | 无卫生保证(手动处理) |
| 调试难度 | 中等 | 高(需要 cargo-expand 辅助) |
| 编译速度 | 快(简单替换) | 慢(需要编译并执行宏 crate) |
选择建议
- 简单的代码模板、重复模式 →
macro_rules! - 需要解析类型结构(字段遍历、生成 impl)→ derive 宏
- 需要修改函数/类型定义 → attribute 宏
- 需要自定义 DSL → function-like 过程宏
四、调试宏
cargo-expand
查看宏展开后的代码:
cargo install cargo-expand
cargo expand # 展开整个 crate
cargo expand main # 展开指定模块
trace_macros
在 nightly 中查看宏展开过程:
#![feature(trace_macros)]
trace_macros!(true);
my_macro!(some input);
trace_macros!(false);
log_syntax
在编译期打印 token:
#![feature(log_syntax)]
log_syntax!(hello world); // 编译时输出: hello world
五、常见的宏模式
TT Muncher(Token 逐步消费)
递归处理 token 列表,每次消费一部分:
macro_rules! count {
() => { 0usize };
($head:tt $($tail:tt)*) => { 1usize + count!($($tail)*) };
}
let n = count!(a b c d); // 展开为 1 + 1 + 1 + 1 + 0 = 4
Push-down Accumulation(累积模式)
用额外参数累积中间结果:
macro_rules! reverse {
// 入口
([$($output:tt)*] ) => { ($($output)*) };
// 递归:从输入头部取一个 token,推到 output 前面
([$($output:tt)*] $head:tt $($tail:tt)*) => {
reverse!([$head $($output)*] $($tail)*)
};
// 用户接口
($($input:tt)*) => { reverse!([] $($input)*) };
}
reverse!(1 2 3); // (3 2 1)
Internal Rules(内部规则)
用 @ 前缀区分内部和外部接口,防止用户误调内部规则:
macro_rules! my_macro {
// 公开接口
($($input:tt)*) => {
my_macro!(@internal [] $($input)*)
};
// 内部规则,用户不应直接调用
(@internal [$($acc:tt)*] ) => { ($($acc)*) };
(@internal [$($acc:tt)*] $head:tt $($tail:tt)*) => {
my_macro!(@internal [$($acc)* $head] $($tail)*)
};
}
Callback 模式
让宏接受另一个宏名作为参数,展开后调用它:
macro_rules! apply {
($callback:ident, $($args:tt)*) => {
$callback!($($args)*)
};
}
macro_rules! double {
($e:expr) => { $e * 2 };
}
let x = apply!(double, 21); // 42