跳转到正文
zeno's blog
返回

Rust 基础:宏系统

专题: Rust 基础

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-likeDeriveAttribute
调用语法macro!()#[derive(Macro)]#[macro]
输入括号内任意 tokenstruct/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)

选择建议


四、调试宏

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

分享这篇文章:

上一篇
Rust 基础:? 操作符
下一篇
系统设计基础(三):前台、中台、后台的兴衰与演进