阶段三 · 元编程与底层

声明宏 macro_rules!

一句话总结

声明宏(macro_rules!)是"代码的查找替换引擎":它在编译早期按写好的模式匹配输入的代码片段,再展开成另一段代码.
它能做到函数做不到的事--接受任意数量参数,操作类型本身,生成重复结构.

前置回顾

前面的迭代器和闭包两篇,涉及了大量标准库宏的使用:vec!,println!,format!.本篇揭开宏的面纱:这些以 ! 结尾的调用到底是怎么工作的,以及如何自己写一个.

为什么需要宏:函数的天花板

vec!,println!,format! 末尾的感叹号就是宏的标志.先想清楚一个问题:为什么 vec! 是宏,而不是函数?

let v = vec![1, 2, 3];        // 可以
let v = vec![1, 2, 3, 4, 5]; // 也可以,参数个数任意

普通函数的参数个数是固定的,无法写一个能接收"任意多个参数"的 vec 函数.宏却可以,因为它工作在语法层面--它看到的是代码本身,能根据写了几个元素,生成对应数量的 push 调用.

能力 函数
可变参数个数 不支持 支持
操作类型作为参数 不支持 支持
生成 impl,结构体等定义 不支持 支持
编译期展开,无运行时开销 -
易读,易调试 较差

函数是运行时调用的工人,拿到具体的值再干活.
宏是编译时的代码生成器,更像一套"文本模板 + 填空规则",在程序还没跑起来之前,先把模板展开成真正的代码.

宏在编译的哪个阶段展开

理解宏,关键要知道它发生在什么时候.Rust 编译大致经过:源码 → 词法分析(切成 token)→ 宏展开 → 语法树 → 类型检查 → 代码生成.

源代码 ──► 词法分析 ──► [宏展开]──► 构建语法树 ──► 类型检查 ──► 生成机器码
vec![1,2] 展开成真正的代码 此后编译器看到的
let mut v = Vec::new(); 已经是展开后的普通代码
v.push(1); v.push(2);

宏展开在类型检查之前

宏处理的是 token(语法片段),而非具体的值,且发生在类型检查之前.

这解释了两个现象:其一,宏能接受还没"类型化"的东西,比如一个类型名,一个表达式片段;其二,宏展开后的代码若有类型错误,编译器报错位置会指向展开结果,有时不太直观--这正是宏比函数难调试的根源.

基本语法:像 match 一样匹配

声明宏的结构和 match 表达式高度相似,左边是匹配器(matcher),右边是展开模板(transcriber).

macro_rules! say_hello {
() => { // 匹配器:这里匹配"空输入"
println!("Hello!"); // 模板:匹配成功就展开成这段代码
};
}

say_hello!(); // 展开为 println!("Hello!");

带参数的版本,需要用元变量(metavariable) 捕获输入片段,语法是 $名字:类型:

macro_rules! square {
($x:expr) => { // 捕获一个表达式,绑定到 $x
$x * $x
};
}

let n = square!(3 + 1); // 展开为 (3 + 1) * (3 + 1) = 16

片段说明符:捕获什么样的代码

元变量后面的"类型"称为片段说明符(fragment specifier),它规定能捕获哪一类语法片段.常用的有:

说明符 匹配的内容 例子
expr 表达式 1 + 2,foo()
ident 标识符(变量/函数名) x,my_fn
ty 类型 i32,Vec<u8>
literal 字面量 42,"hi"
stmt 语句 let x = 1
tt 单个 token 树(最灵活) 几乎任何东西

expr 会被当作整体,自动加括号

当用 $x:expr 捕获到 3 + 1 后,在模板里展开时它被视为一个完整表达式整体,相当于自动套了括号.所以 square!(3 + 1) 展开成 (3+1)*(3+1) 而非 3+1*3+1.

这是宏卫生性设计的一部分,避免了 C 语言宏里经典的运算符优先级陷阱.

重复:处理任意数量参数

宏真正强大之处,是用重复(repetition) 语法处理可变数量的输入.这正是 vec! 的实现核心.语法是 $(...)分隔符重复标记:

macro_rules! my_vec {
( $( $x:expr ),* ) => { // $(...),* 表示"逗号分隔,重复零或多次"
{
let mut v = Vec::new();
$( v.push($x); )* // 对每个捕获到的 $x,各展开一行 push
v
}
};
}

let v = my_vec![10, 20, 30];
// 展开为:
// { let mut v = Vec::new(); v.push(10); v.push(20); v.push(30); v }

重复标记有三种:

  • *:重复零次或多次
  • +:重复一次或多次
  • ?:出现零次或一次

重复语法像一个"复印 + 套用"的流水线:$( ... )* 是模板,编译器对传入的每一项,都按模板复印一份并把对应的值填进去.传三个值,就复印三份 push.

宏卫生性:不会污染变量

C 语言的宏臭名昭著,因为宏内部定义的变量可能意外覆盖调用处的同名变量.Rust 宏通过卫生性(hygiene) 机制根除了这个问题.

macro_rules! make_temp {
() => {
let tmp = 999; // 宏内部的 tmp
};
}

let tmp = 1;
make_temp!(); // 宏内的 tmp 和外面的 tmp 是两个独立变量
println!("{tmp}"); // 1,外部 tmp 毫发无损

卫生性 = 宏内外的标识符互不干扰

Rust 编译器为宏展开引入的标识符打上特殊的"语法上下文"标记,使宏内部的 tmp 与调用处的 tmp 即便同名,也被视为不同的变量.用别人的宏时,不必担心它偷偷改坏局部变量.这是 Rust 宏相比 C 宏的一大安全优势.

如何调试宏

宏出错时报错信息常指向展开后的代码,不直观.两个实用工具:

# 查看宏展开后的完整代码(需安装 cargo-expand)
$ cargo install cargo-expand
$ cargo expand

# 或在 nightly 工具链下使用编译器内置标志
$ cargo +nightly rustc -- -Zunpretty=expanded

声明宏的能力边界

macro_rules! 虽强,但它只能做"基于模式的语法替换",无法执行任意逻辑(如读取文件,解析复杂语法,根据字段名生成代码).

当需求超出模式匹配--比如实现 #[derive(Debug)] 这种要遍历结构体字段的功能--就得升级到过程宏,这正是下一篇的主题.

快速回顾

  • 宏突破函数的限制:可变参数,操作类型,生成定义,编译期展开无运行时开销
  • 展开时机:在词法分析之后,类型检查之前,处理的是 token 而非值
  • 结构像 match:左侧匹配器(含元变量 + 片段说明符),右侧展开模板
  • 重复语法 $(...)分隔符* 处理任意数量参数,是 vec! 的核心
  • 卫生性保证宏内外同名标识符互不干扰,杜绝 C 宏式的变量污染

动手练习

  1. max! 宏:实现一个 max! 宏,接受两个表达式返回较大者;再扩展成支持任意多个参数(提示:用重复 + 递归调用宏自身).
  2. hashmap! 宏:用 $key:expr => $val:expr 配合重复语法,写一个 hashmap! 宏,让 hashmap!{"a" => 1, "b" => 2} 构造出一个 HashMap.
  3. cargo expand:安装 cargo-expand,对写好的 hashmap! 调用执行 cargo expand,查看真实展开结果.