原文:Writing complex macros in Rust: Reverse Polish Notation,作者 Ingvar Stepanyan,Cloudflare Blog。
Rust 的宏系统功能强大,但也以"难以掌握"著称。很多人读完官方文档、照着示例写了几个简单的宏之后,一旦遇到需要处理复杂 token 序列的场景,就完全不知道从何下手了。
这篇文章以 Cloudflare 工程师的一篇技术博客为蓝本,通过实现一个编译期的逆波兰表达式求值宏,把 Rust 声明宏(macro_rules!)的核心技巧完整地走一遍。
什么是逆波兰表达式
逆波兰表达式(Reverse Polish Notation,RPN),也叫后缀表达式,是一种不需要括号就能表达运算优先级的记法。它依赖一个栈来工作:
- 遇到操作数,压栈
- 遇到运算符,从栈中取出两个操作数,计算结果后再压回栈
举个例子,RPN 表达式:
2 3 + 4 *
执行步骤如下:
2入栈 → 栈:[2]3入栈 → 栈:[3, 2]- 遇到
+,取出3和2,计算2 + 3 = 5,压回 → 栈:[5] 4入栈 → 栈:[4, 5]- 遇到
*,取出4和5,计算5 * 4 = 20,压回 → 栈:[20] - 表达式结束,栈顶即为结果
20
对应的中缀表达式是 (2 + 3) * 4。
我们的目标是写一个宏,让下面的代码能在编译期完成求值:
println!("{}", rpn!(2 3 + 4 *)); // 20
第一步:用 token 序列模拟栈
Rust 宏没有"变量"这个概念,无法在运行时维护一个真正的栈。但宏可以在递归调用时携带一段 token 序列,用来充当编译期的栈。
我们用方括号包裹的、逗号分隔的 expr 序列来表示栈:
[ $($stack:expr),* ]
每次递归调用时,我们把这个"栈"更新后传给下一次调用,以此来模拟栈的 push/pop 操作。
第二步:处理操作数
先写处理单个数字(操作数)的分支。把数字压入栈,然后继续处理剩余 token:
macro_rules! rpn {
([ $($stack:expr),* ] $num:tt $($rest:tt)*) => {
rpn!([ $num $(, $stack)* ] $($rest)*)
};
}
这里有两个关键点:
为什么用 tt 而不是 expr 或 literal?
因为 expr 会贪婪地匹配,可能把 2 + 3 整体吃掉,而我们只需要匹配一个 token。tt(token tree)恰好只匹配一个 token 树。
递归是宏处理序列的唯一方式。
宏不能用循环,也不能修改变量。通过递归,每次把处理好的新栈状态传入下一次调用,直到消耗完所有 token,这是声明宏处理列表的标准模式。
第三步:处理运算符
运算符分支需要从栈中弹出两个操作数,组合成中缀表达式后压回:
macro_rules! rpn {
([ $b:expr, $a:expr $(, $stack:expr)* ] + $($rest:tt)*) => {
rpn!([ $a + $b $(, $stack)* ] $($rest)*)
};
// - * / 类似...
}
注意栈中元素的顺序:先入栈的 $a 在后,后入栈的 $b 在前(因为栈顶在左侧)。运算时是 $a op $b,而不是 $b op $a,减法和除法的情况下这一点尤为重要。
由于四个运算符的处理逻辑完全相同,重复写四次显然不够优雅。
第四步:用 @op 内部 helper 消除重复
Rust 宏不能调用外部 helper,但可以在同一个宏里定义"内部分支",用一个特殊的标记 token(如 @op)作为标识符,与正常输入区分开:
macro_rules! rpn {
// 内部 helper:执行实际运算
(@op [ $b:expr, $a:expr $(, $stack:expr)* ] $op:tt $($rest:tt)*) => {
rpn!([ $a $op $b $(, $stack)* ] $($rest)*)
};
// 四个运算符统一转发给 @op
($stack:tt + $($rest:tt)*) => { rpn!(@op $stack + $($rest)*) };
($stack:tt - $($rest:tt)*) => { rpn!(@op $stack - $($rest)*) };
($stack:tt * $($rest:tt)*) => { rpn!(@op $stack * $($rest)*) };
($stack:tt / $($rest:tt)*) => { rpn!(@op $stack / $($rest)*) };
// 操作数:压栈
([ $($stack:expr),* ] $num:tt $($rest:tt)*) => {
rpn!([ $num $(, $stack)* ] $($rest)*)
};
}
这里还有一个技巧:在运算符分支里,整个栈 $stack 被作为 tt 整体传递(因为它是一个被方括号包裹的 token 树),不需要展开里面的内容。只有在 @op 分支里,才真正拆解栈的内部结构。
第五步:处理终止条件和入口
当所有 token 处理完毕,栈中应该剩下唯一的结果:
([ $result:expr ]) => {
$result
};
还需要一个入口分支,让调用者不必手动传入空栈 []:
($($tokens:tt)*) => {
rpn!([] $($tokens)*)
};
注意分支顺序很重要。这个兜底分支必须放在最后,否则它会匹配一切,导致其他分支永远无法触发。
完整宏定义如下:
macro_rules! rpn {
(@op [ $b:expr, $a:expr $(, $stack:expr)* ] $op:tt $($rest:tt)*) => {
rpn!([ $a $op $b $(, $stack)* ] $($rest)*)
};
($stack:tt + $($rest:tt)*) => { rpn!(@op $stack + $($rest)*) };
($stack:tt - $($rest:tt)*) => { rpn!(@op $stack - $($rest)*) };
($stack:tt * $($rest:tt)*) => { rpn!(@op $stack * $($rest)*) };
($stack:tt / $($rest:tt)*) => { rpn!(@op $stack / $($rest)*) };
([ $($stack:expr),* ] $num:tt $($rest:tt)*) => {
rpn!([ $num $(, $stack)* ] $($rest)*)
};
([ $result:expr ]) => { $result };
($($tokens:tt)*) => { rpn!([] $($tokens)*) };
}
测试:
println!("{}", rpn!(2 3 + 4 *)); // 20
println!("{}", rpn!(15 7 1 1 + - / 3 * 2 1 1 + + -)); // 5
两行都能正确输出,且完全在编译期求值。
第六步:让错误信息更有用
一个生产可用的宏,还需要处理非法输入时给出清晰的错误提示,而不是让编译器抛出莫名其妙的类型错误。
情况一:操作数过多(缺少运算符)
输入 rpn!(2 3 7 + 4 *) 时,栈最终有两个值而不是一个。此时会触发兜底分支,产生难以理解的类型错误。
解决方案:在终止分支和兜底分支之间,插入一个匹配"栈里有多个值"的错误分支:
([ $($stack:expr),* ]) => {
compile_error!(concat!(
"表达式求值失败,可能缺少运算符。当前栈状态:",
stringify!([ $($stack),* ])
))
};
情况二:操作数不足(缺少操作数)
输入 rpn!(2 3 + *) 时,栈只有一个值却遇到了运算符,@op 分支无法匹配两个操作数,导致 @ 字符被当成普通 token 压栈,产生奇怪的错误。
解决方案:给 @op 也加一个兜底错误分支:
(@op $stack:tt $op:tt $($rest:tt)*) => {
compile_error!(concat!(
"运算符 `",
stringify!($op),
"` 无法应用于当前栈:",
stringify!($stack)
))
};
加入这两个分支后,错误信息会清晰地告诉用户问题所在:
error: 运算符 `*` 无法应用于当前栈:[ 2 + 3 ]
调试技巧:trace_macros!
宏的递归展开过程很难在脑子里完整跟踪。Rust nightly 提供了 trace_macros! 宏,可以打印出每一步的展开过程:
#![feature(trace_macros)]
fn main() {
trace_macros!(true);
let e = rpn!(2 3 + 4 *);
trace_macros!(false);
println!("{}", e);
}
编译时会输出类似这样的展开链:
expanding `rpn! { 2 3 + 4 * }`
to `rpn ! ( [ ] 2 3 + 4 * )`
expanding `rpn! { [ ] 2 3 + 4 * }`
to `rpn ! ( [ 2 ] 3 + 4 * )`
...
写复杂宏时,这是定位问题最直接的工具。
总结:Rust 声明宏的三个核心技巧
通过这个例子,可以总结出编写复杂 macro_rules! 宏的三个核心模式:
1. 用 token 序列模拟状态
宏没有变量,但可以把状态编码在一段 token 序列里,随着递归调用一路传下去。数据结构、栈、累加器,都可以用这种方式实现。
2. 用 @标记 划分内部 helper
在同一个宏里,用特殊前缀(如 @op、@parse)标记"内部分支",实现逻辑分层和代码复用,避免大量重复的分支。
3. 分支顺序决定匹配优先级
macro_rules! 按分支定义顺序逐一尝试匹配,更具体的分支要放在更通用的分支之前。兜底的 $($tokens:tt)* 必须永远在最后。
这三个技巧组合在一起,足以应对绝大多数需要在编译期处理复杂 token 序列的场景。