原文: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 *

执行步骤如下:

  1. 2 入栈 → 栈:[2]
  2. 3 入栈 → 栈:[3, 2]
  3. 遇到 +,取出 32,计算 2 + 3 = 5,压回 → 栈:[5]
  4. 4 入栈 → 栈:[4, 5]
  5. 遇到 *,取出 45,计算 5 * 4 = 20,压回 → 栈:[20]
  6. 表达式结束,栈顶即为结果 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 而不是 exprliteral

因为 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 序列的场景。


标签: none

添加新评论