宏:从声明宏到过程宏的边界
课程概览 · 第 16 章
宏接收 token 并生成 Rust 代码,适合函数签名不能表达的语法重复。展开之后仍是普通 Rust,仍须通过类型、借用与可见性检查。本章把求值次数、匹配范围与依赖合同放到调用点旁边,让宏保持薄而透明。
前置知识与学习目标
需要理解函数、trait、块表达式与第 15 章的属性。学完本章应能:
- 在函数、trait 与宏之间按需求选工具;
- 读懂匹配、重复、规则顺序与单次求值;
- 解释卫生性、$crate 与 Edition 2024 的 expr 匹配边界;
- 区分三类过程宏,并建立明确依赖的 Serde 运行项目。
先选择函数或 trait
| 重复的性质 | 优先工具 |
|---|---|
| 同一段运行期逻辑 | 函数 |
| 算法相同、类型不同 | 泛型或 trait |
| 使用调用点局部环境 | 闭包 |
| 捕获表达式文本、接收语法或生成声明 | 声明宏 |
| 按类型字段批量生成实现、解析 DSL | 过程宏 |
普通前缀校验不需要生成语法。函数的参数、错误类型和控制流清晰;本例的字符串错误只供本地展示,需要调用方分支时应采用第 13 章的命名错误类型。
独立示例(仅标准库;完整内容放 src/main.rs,Edition 2024;执行 cargo +stable run)。
1 | |
断言宏适合接收表达式并保留调用点文本;“保存后审计”则是业务动作,应优先成为函数或 trait 方法。不要把 return、break 或错误传播藏进不透明的业务宏。
匹配器、片段与转写器
macro_rules 的左侧匹配输入 token,右侧转写为输出 token,捕获变量以 $name:fragment 声明。片段约束是宏 API 的一部分,不是字符串替换。只允许名字时用 ident,允许表达式时用 expr,嵌套括号会按 Rust 语法理解。
语义反例(仅标准库;独立内容放 src/main.rs;可正常运行,但同一表达式被展开两次)。本例断言重复求值,不能把它分类为编译失败。
1 | |
以下是本章用到的片段与阅读相关宏时常见的补充,不是所有合法片段的完整清单。
| 片段 | 匹配内容 | 例子 |
|---|---|---|
| expr | 表达式 | a + b、call() |
| expr_2021 | 保留旧版 expr 的匹配范围 | 不接受顶层 const 块与 _ |
| ty | 类型 | Vec<u8> |
| ident | 标识符 | name |
| path | 路径 | std::fmt::Debug |
| pat | 模式 | Some(value) |
| item | 一个项目 | fn run() {} |
| block | 块 | { work(); } |
| literal | 字面量 | 42、"ok" |
单次求值
函数参数自然只求值一次,宏的转写器则可能多次插入实参。先绑定到宏内部局部变量,才能为有副作用的实参提供单次求值合同。双层花括号把展开结果组织为一个块表达式,临时绑定不会泄漏到外层。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run;不沿用前一个块)。
1 | |
这只解决重复求值,不自动支持所有类型。例如 String 不能相乘,展开后仍会报类型错误。宏若要接受更广的类型,必须定义适当操作,而不是依靠宏绕过类型系统。
重复与尾逗号
$(...)* 重复零次或多次,$(...)+ 至少一次,$(...)? 零次或一次;分隔符写在重复组后、重复操作符前。输入重复和输出重复必须一致地使用捕获变量。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。count_values 会按序求值每个传入表达式一次,借用其值用于计数,不是只数 token 而完全不执行实参。
1 | |
真实 vec! 还支持 vec![value; count],有自己的元素求值与分配规则;不要把教学宏的行为直接推广为标准宏的实现。
规则顺序、卫生性与 $crate
多规则按顺序选择首个匹配项,不会因为展开后类型检查失败再回退到下一项。更具体的规则通常放前面。声明宏具有混合位置卫生性,局部变量等名字不会像文本拼接那样轻易捕获调用者同名绑定;其他项目路径仍需要明确设计。
导出宏用 $crate 引用定义 crate 内的项目,依赖改名后也能定位定义方。#[macro_export] 将宏导出到 crate 根,不会让它访问到的类型和方法自动对外公开;外部展开仍要满足可见性。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。这里把定义与使用放在同一 crate,展示完整 TaskId 契约;对外导出时仍需公开 TaskId::new。
1 | |
未导出的 macro_rules 宏有文本作用域,通常需先定义再调用;路径作用域、显式 use 与 macro_export 则处理导出与导入。旧代码可能使用 macro_use,阅读时应查明来源,而不是扩大隐式导入。宏定义位置与模块加载问题的区别见第 14 章。
Edition 2024 的 expr 范围
定义宏所在的 edition决定 expr 片段的匹配范围,不是调用者的 edition。Edition 2024 的 expr 接受顶层 const 块与下划线表达式;expr_2021 保留旧范围。能匹配 token 不代表展开后 _ 在任意位置都有合法类型或值。加入更宽的匹配也可能影响多规则的首匹配顺序,迁移时应检查原来的 const 专用规则。
独立示例(仅标准库;替换 src/main.rs;必须按 Edition 2024 定义并编译;执行 cargo +stable run)。
1 | |
故意失败:编译错误(仅标准库;独立内容放 src/main.rs;Edition 2024;expr_2021 不匹配顶层 const,预期宏规则匹配失败)。
1 | |
具体迁移规则见官方 2024 片段说明。
三类过程宏
过程宏定义在单独的 proc-macro crate,以 TokenStream 为输入输出。它们能够解析语法、生成 impl 或专用代码,但增加依赖、构建时间和诊断链;并非运行时反射。标准库派生是内置派生,Serde 等第三方派生则由过程宏实现。
派生宏:完整 Serde 项目
Serde 按 CreateTask 字段生成 Serialize/Deserialize,实现运行时的数据转换。字段名称、重命名、默认值与跳过策略会影响数据协议,私有字段也不会因为私有性自动排除序列化。
下面只建立本章的 serde_example,三个声明都在正文中给出,不依赖其他章的隐藏 manifest。
多文件示例(第三方项目创建命令;执行目录为独立项目父目录)。
1 | |
多文件示例(serde_example/Cargo.toml 完整内容;依赖 serde 与 serde_json;执行目录 serde_example/)。
1 | |
多文件示例(serde_example/src/main.rs 完整内容;依赖上述 manifest;执行目录 serde_example/)。
1 | |
多文件示例(执行命令;目录 serde_example/;依赖上述 Cargo.toml 与 src/main.rs)。
1 | |
属性宏:改写项目
属性宏接收属性参数与整个项目,返回替代项目。tokio::main 通常生成运行时创建与 block_on 逻辑,不是 Rust 语言内置的 async main;应查看线程模型、feature 与签名约束。
代码片段(Tokio 属性语法;放使用 Tokio 的 src/main.rs;需 tokio = { version = "1", features = ["macros", "rt-multi-thread"] };本章不建立第二个第三方主项目,完整运行时例见第 18 章)。
1 | |
函数式过程宏:接收 DSL
函数式过程宏以 name!(tokens) 调用,可解析 SQL 等专用语言。SQLx query! 可根据数据库 schema 检查列类型与参数,但只有构建环境提供匹配 schema 的在线连接或已生成离线元数据时,才能兑现这个检查。
代码片段(SQLx/PostgreSQL 查询语法;放应用 src/main.rs 的 async 查询函数体,沿用该函数中 task_id: i64)。依赖示意为 sqlx = { version = "0.8", features = ["macros", "postgres", "runtime-tokio"] };还需真实 tasks 表含 id/title、匹配的 DATABASE_URL 或离线元数据。本块不执行查询、不建立数据库工程,也不构成可独立运行例。
1 | |
在线校验与离线元数据也是构建合同。不要因宏很短就隐藏数据库前置,或把构建期检查说成保证查询运行时永不失败。
深入阅读:诊断与失败定位
先最小化调用,读匹配与展开位置,再用覆盖输入形状和副作用的测试验证宏合同。已安装 cargo-expand 时可查看近似展开代码;它是额外工具,不是本章项目隐含依赖,也不能替代类型检查与运行证据。
| 现象 | 本章对应原因 | 修复方向 |
|---|---|---|
| const 调用没有匹配规则 | old_evaluate 使用 expr_2021 | 明确要保留旧范围还是采用 expr |
| squared 实参执行两次 | 转写器重复插入表达式 | 使用 squared_once 的局部绑定 |
| Serde/Tokio 名称或派生不可用 | 缺依赖或所需 feature | 检查所在项目 manifest |
| squared_once 对 String 报错 | 展开后的乘法没有合法实现 | 调整操作合同,不绕过类型系统 |
| 错误位置落在生成代码 | 宏展开引入不合法代码 | 查定义、实参与展开信息 |
文件加载、模块隐私与外部测试问题见第 14 章,cfg 与 cfg! 问题见第 15 章;这些不是宏匹配失败。宏公开输入、求值次数与生成 API 都属于兼容性承诺,应随示例和测试一起维护。
自测
- 为什么普通前缀校验优先函数,而断言适合宏?
- squared 与 squared_once 在 next 实参上的计数各是多少?
- count_values 的尾逗号、空输入与副作用合同是什么?
- expr 的匹配由定义还是调用 edition 决定?$crate 是否绕过私有性?
- Serde、Tokio 入口与 SQLx query! 分别属于哪类过程宏,依赖前置是什么?
参考答案
- 前者是有明确参数的运行逻辑;断言还需要调用点表达式语法与文本。
- 分别两次、一次;宏是否单次求值由转写器决定。
- 接受可选尾逗号,空输入为 0,每个实参按序求值一次。
- 由定义 edition 决定;$crate 只定位定义方,不改变可见性。
- 分别是派生、属性、函数式过程宏;需相应 crate/feature,SQLx 还需匹配 schema 的连接或离线元数据。






