先回答一个你从第 1 篇起就在用、却可能没想过的问题:println! 为什么不是函数?因为它接收任意个数的参数——println!("{}", x)、println!("{} {}", x, y)——而 Rust 函数没有变长参数。vec! 同理。你每天都在用的这些感叹号,全都是宏:写代码的代码——在编译期接收你的代码片段,吐出更多代码,然后编译器假装那些代码本来就是你写的。本篇拆这门元编程手艺:声明宏的模式匹配本质、卫生性这个最容易被忽略的保命设计、过程宏三兄弟各自的领土,以及本系列唯一一个“先学什么时候不用它”的判据。
展开发生在类型检查之前
理解宏的第一个事实是它在编译流水线里的位置:展开早于类型检查。宏处理的是语法——token 的序列——那时类型还不存在。
这一个事实推导出两个日常体验。好的一面:借用检查器永远看不到宏,只看到展开后的普通代码——宏不会绕过任何安全检查,展开结果就是全部真相(cargo expand 可以随时让你亲眼核对)。痛的一面:宏报错的指向是展开后的代码,而那不是你写的代码——“error in this macro expansion”是每个 Rustacean 都读过的句子,也是宏的定价单上第一行。
声明宏:macro_rules! 是模式匹配器
macro_rules! 的本质是一台模式匹配加模板替换的机器:左边是“调用长什么样”的模式,右边是“展开成什么”的模板。亲手写一个迷你版 vec!:
macro_rules! my_vec {
() => { Vec::new() };
($($x:expr),+ $(,)?) => {{
let mut v = Vec::new();
$( v.push($x); )+
v
}};
}
let a: Vec<i32> = my_vec![]; // 命中第一条规则
let b = my_vec![1, 2, 3]; // 命中第二条
let c = my_vec![1, 2, 3,]; // $(,)? 吃掉了尾巴逗号
拆解语法:$x:expr 捕获一个表达式并命名 x(其他捕获类型:ident、ty、stmt、block 等);$( ... ),+ 是“逗号分隔、至少一个”的重复;$(,)? 是“可选的尾巴逗号”——别小看它,没有它你的宏在多行格式化后会拒绝编译,这是所有手写宏的标准配件;模板里的 $( ... )+ 按捕获的次数重复展开。规则从上到下匹配,命中即止——和 match 一模一样,只不过匹配的是语法。
卫生性:宏内的变量被隔离
C 的宏是文本替换,一个不小心就把调用者的变量吃掉。Rust 的声明宏是卫生的(hygienic):宏展开里引入的局部变量会被隐式改名,永远撞不上、也吃不掉调用者的同名变量。亲手验证:
macro_rules! inc_twice {
($x:expr) => {{
let mut tmp = $x;
tmp += 1;
tmp += 1;
tmp
}};
}
let tmp = 10;
let r = inc_twice!(5);
println!("tmp = {tmp}, r = {r}"); // tmp = 10, r = 7——调用者的 tmp 毫发无损
宏里的 tmp 被悄悄改成了一个猜不到的名字,调用者的 tmp 岁月静好。这个设计让宏可以安全地造中间变量——C 程序员看到这里会想哭。
卫生性的例外是路径:宏体内引用自己 crate 里的项时,写 $crate::helper() 而不是 crate::helper()——后者会在调用者的 crate 里解析,而 $crate 永远指向定义宏的那个 crate。凡是写库里的宏,这是和尾巴逗号并列的第二配件。
过程宏三兄弟:TokenStream 进,TokenStream 出
声明宏是模式匹配;过程宏是真函数——接收 TokenStream、返回 TokenStream,编译成编译器插件在编译期执行。它能看到代码的完整语法结构(配合 syn 解析、quote 生成),所以能干声明宏干不了的事:按类型的结构生成代码。三兄弟各管一块:
- derive:原封不动保留你的类型,在它下面追加生成的
impl。serde 的全部魔法、你每天写的#[derive(Debug)],都是它。 - attribute:改写它标注的那个项——包装、替换、插桩。第 10 篇的
#[tokio::main]真身就是它:把你的async fn main改写成“建 runtime、block_on”的普通fn main。 - function-like:长得像声明宏的调用,但展开逻辑是任意 Rust 代码。
sqlx::query!在编译期连数据库校验 SQL——声明宏想都不敢想。
纸面不如亲手。一个真的能用的迷你 derive,三十行:
// hello-derive/src/lib.rs —— 一个 proc-macro crate
use proc_macro::TokenStream;
use quote::quote;
use syn::{DeriveInput, parse_macro_input};
#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = ast.ident;
quote! {
impl #name {
fn hello() -> String {
format!("hello from {}!", stringify!(#name))
}
}
}
.into()
}
// 使用方
use hello_derive::Hello;
#[derive(Hello)]
struct Report;
fn main() {
println!("{}", Report::hello()); // hello from Report!
}
结构一目了然:syn 把 token 解析成语法树(DeriveInput),取出类型名,quote! 把名字缝回生成的 impl 模板。serde 的核心循环与此同构,只是大了一万倍——看懂了这三十行,就看懂了过程宏的全部原理。
什么时候不用宏:判据阶梯
宏是本系列唯一一个“先学什么时候不用它”的特性,因为它的代价真实而隐蔽:错误信息隔一层展开、IDE 补全变瞎、编译时间变长、读者要在脑子里先跑一遍展开器。判据是一架阶梯,能用低的就不用高的:
- 普通函数能解决,就用函数(绝大多数情况)。
- 需要对多种类型工作,上泛型 + trait bound(第 7 篇)——类型检查完整、错误信息一流。
- 需要“按类型分派行为”,上 trait(第 5 篇)。
- 只有以下三种情况,才轮到宏:变长输入(
println!的形态)、语法上函数表达不了的扩展(惰性求值、领域语言)、按类型结构批量生成 impl(derive 的领土)。
一句话版本:宏是在语法层编程,类型系统是你的安全气囊——能在类型层解决的,永远不要降维到语法层。
练习,以及第 13 篇
- 给
my_vec!加第三条规则:my_vec![x; n]展开为vec![$x; $n]的等价物(提示:vec!本身可以直接用——标准库就是你的参照系)。然后用cargo expand亲眼看你三条规则的展开结果。 - 写一个
hashmap!宏:hashmap!{ "a" => 1, "b" => 2 }返回填满的HashMap。这是声明宏练习册的保留曲目,做完它,$()*重复语法就长在你手上了。 - 把迷你 derive 扩展一步:让
hello()打印类型的字段数量(提示:DeriveInput里的data字段)。你会第一次摸到“按类型结构生成代码”的手感——serde 每天做的就是这件事。
第 13 篇是本系列的倒数第二站:性能工程。测量先行的纪律(cargo bench 与 profiler 的分工)、分配作为看不见的税、迭代器“零成本”承诺的验证方法(亲眼读汇编)、Vec 扩容策略与预分配,以及那句被说滥的话的正确版本——“过早优化是万恶之源”的后半句,才是工程师的部分。