当前位置: 首页 > 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.cnnetsun.cn/news/2087179.html

相关文章:

  • 一间给Markdown文档准备的工作室
  • 杭州玖叁鹿实操:宁波GEO优化排名提升60%
  • 2026墙体广告厂家综合实力亲测排行
  • 解锁7-Zip隐藏能力:5个让文件管理效率翻倍的实用技巧
  • pbtk v 3.5.0安装与使用--生信工具084
  • 三步解锁B站视频下载:跨平台智能助手的完整指南
  • 斯坦福AI Index Report 2026核心解读:开发者必须知道的5个信号
  • 以太网端口的ESD防护器件选型
  • 【收藏级】2026年AI普及时代,Java程序员必看:转型大模型不落后,小白也能入门
  • ESP-IDF离线安装包+Python虚拟环境:打造Windows上最稳定的ESP32开发环境(避坑网络问题)
  • 从ResNet到ResNeXt:PyTorch实战中如何优雅地升级你的网络(避坑指南)
  • 权威见证:Ledger携手京东开启官方授权新篇章,正品保障触手可及
  • 如何在30分钟内搭建本地AI写作助手:KoboldAI完全部署指南
  • 构建企业级数据可视化大屏:DataV架构深度解析与实战应用
  • 01-语言模型+维特比
  • 不只是安装:用VSCode在Windows 10上配置ROS开发环境,实现高效调试
  • Vue Infinite Loading深度解析:高性能无限滚动架构优化策略
  • 为什么92%的嵌入式团队卡在LLM插件安装环节?揭秘GCC 11.4符号重定义陷阱与__attribute__((section))绕过方案
  • 抖音无水印下载工具:3分钟快速掌握批量下载技巧
  • Vivado FFT IP核配置避坑指南:从数据格式到AXI时序的实战经验分享
  • Qwen3-TTS语音设计世界开源大模型部署:MIT协议下企业可用方案
  • BilibiliDown终极指南:5个步骤轻松下载B站任何视频
  • 如何快速创建Unity透明窗口:终极桌面悬浮效果指南
  • CentOS 6老系统维护指南:当阿里云镜像源失效后,如何手动切换到vault.centos.org源
  • Jmeter压力测试前,如何用Java代码快速准备1000个有效登录Token?
  • Flux2-Klein-9B-True-V2新手指南:Negative Prompt避坑与高质量提示词写法
  • 【嵌入式进阶】告别“屎山”代码!资深老鸟都在用的5个C语言神级技巧
  • 从零配置一个UVC摄像头:手把手解析VC和VS接口描述符(附Wireshark抓包分析)
  • PHP V6 单商户常见问题——数据库模式修改 sql-mode
  • 部署模型总爆显存?保姆级教程:用PyTorch Profiler和简单公式预估你的最大Batch Size