当前位置: 首页 > news >正文

用逆波兰表达式,彻底搞懂 Rust 宏的递归写法

原文: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!(23+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!(23+4*));// 20println!("{}",rpn!(15711+-/3*211++-));// 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)]fnmain(){trace_macros!(true);lete=rpn!(23+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 序列的场景。


http://www.jsqmd.com/news/698761/

相关文章:

  • 长沙福麟家居设计:望城靠谱的座垫塌陷修复公司 - LYL仔仔
  • 新年新气象:用像素皇城生成独一无二马年春联,简单操作惊艳效果
  • 为什么换了降AI工具AI率还是不通过?4个失败原因深度解析
  • 从碰撞检测到智能避让:深入解析NX二次开发中UF_MODL_trace_a_ray的5个高级应用场景(附C#实战代码)
  • 2026年适老化服务公司口碑优选指南,含适老化施工、老年能力评估、智慧防跌倒设备及家庭卫浴改造推荐 - 海棠依旧大
  • Flutter主题与样式管理:打造一致的视觉体验
  • 别再只看跑分了!3dMax渲染、模拟、建模三大场景,AMD线程撕裂者与Intel酷睿i9实战选购指南
  • csdn-report-openclaw
  • 神经网络的量子力学特征
  • 3种Docker镜像如何选?MDCX容器化部署的终极选择指南
  • Diablo Edit2:暗黑破坏神2角色存档编辑器的完整指南
  • 日常用什么防晒能避免毛孔粗大变老?Leeyo防晒霜隔绝光衰紧致嫩肤不垮脸 - 全网最美
  • 如何快速上手DJI Cloud API Demo:无人机云服务集成的终极指南
  • 如何构建本地AI写作助手:KoboldAI的完整实践指南
  • 2026年口碑爆棚的400电话办理商TOP榜 - GrowthUME
  • DDR模式寄存器配置:从MRS命令到性能调优的实战指南
  • 基于MCP协议构建亚马逊数据查询AI技能:从原理到实践
  • 不开端口,不配 DNS,用树莓派在家搭一个公网可访问的 Web 服务
  • 从交通灯故障检测到智能家居:组合逻辑电路在FPGA上的两个趣味实践项目
  • 2026年天津新能源汽车推荐去哪里?一站式汽车文化广场深度评测指南 - 优质企业观察收录
  • 你的微信聊天记录还在吗?这个开源工具帮你永久保存珍贵对话
  • 算法训练营第十二天| 169.多数元素
  • 如何用Fay数字人框架3步打造你的智能虚拟助手:从零到一的实践指南
  • 广州值得信赖的靠谱除甲醛机构 TOP5 推荐 - GrowthUME
  • 智能基线校正终极指南:如何用airPLS算法解决光谱分析中的基线漂移问题
  • 慧科讯业:2026年北京车展前瞻报告
  • 2026年天津新能源汽车推荐去哪里买?101汽车文化广场一站式体验深度指南 - 优质企业观察收录
  • 开源音乐格式转换工具实战:5步解锁网易云音乐加密文件
  • 3分钟掌握机构级金融数据:Finnhub Python客户端的终极指南
  • jcifs-ng终极指南:5分钟掌握Java SMB客户端开发