课程概览 · 第 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
2
3
4
5
6
7
8
9
10
11
12
fn require_prefix(value: &str, prefix: &str) -> Result<(), String> {
if value.starts_with(prefix) {
Ok(())
} else {
Err(format!("{value:?} does not start with {prefix:?}"))
}
}

fn main() {
assert!(require_prefix("task-1", "task-").is_ok());
assert!(require_prefix("job-1", "task-").is_err());
}

断言宏适合接收表达式并保留调用点文本;“保存后审计”则是业务动作,应优先成为函数或 trait 方法。不要把 return、break 或错误传播藏进不透明的业务宏。

匹配器、片段与转写器

macro_rules 的左侧匹配输入 token,右侧转写为输出 token,捕获变量以 $name:fragment 声明。片段约束是宏 API 的一部分,不是字符串替换。只允许名字时用 ident,允许表达式时用 expr,嵌套括号会按 Rust 语法理解。

语义反例(仅标准库;独立内容放 src/main.rs;可正常运行,但同一表达式被展开两次)。本例断言重复求值,不能把它分类为编译失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
macro_rules! squared {
($value:expr) => {
($value) * ($value)
};
}

fn next(counter: &mut u32) -> u32 {
*counter += 1;
*counter
}

fn main() {
assert_eq!(squared!(3 + 1), 16);
let mut counter = 0;
assert_eq!(squared!(next(&mut counter)), 2);
assert_eq!(counter, 2);
}

以下是本章用到的片段与阅读相关宏时常见的补充,不是所有合法片段的完整清单。

片段匹配内容例子
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
macro_rules! squared_once {
($value:expr) => {{
let value = $value;
value * value
}};
}

fn next(counter: &mut u32) -> u32 {
*counter += 1;
*counter
}

fn main() {
let mut counter = 0;
let value = 100;
assert_eq!(squared_once!(next(&mut counter)), 1);
assert_eq!(counter, 1);
assert_eq!(value, 100);
}

这只解决重复求值,不自动支持所有类型。例如 String 不能相乘,展开后仍会报类型错误。宏若要接受更广的类型,必须定义适当操作,而不是依靠宏绕过类型系统。

重复与尾逗号

$(...)* 重复零次或多次,$(...)+ 至少一次,$(...)? 零次或一次;分隔符写在重复组后、重复操作符前。输入重复和输出重复必须一致地使用捕获变量。

独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。count_values 会按序求值每个传入表达式一次,借用其值用于计数,不是只数 token 而完全不执行实参。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
macro_rules! count_values {
($($value:expr),* $(,)?) => {
0usize $(+ { let _ = &$value; 1usize })*
};
}

fn main() {
assert_eq!(count_values!(), 0);
assert_eq!(count_values!(10, 20, 30), 3);
assert_eq!(count_values!(10, 20,), 2);
let mut calls = 0;
assert_eq!(count_values!({ calls += 1; calls },), 1);
assert_eq!(calls, 1);
}

真实 vec! 还支持 vec![value; count],有自己的元素求值与分配规则;不要把教学宏的行为直接推广为标准宏的实现。

规则顺序、卫生性与 $crate

多规则按顺序选择首个匹配项,不会因为展开后类型检查失败再回退到下一项。更具体的规则通常放前面。声明宏具有混合位置卫生性,局部变量等名字不会像文本拼接那样轻易捕获调用者同名绑定;其他项目路径仍需要明确设计。

导出宏用 $crate 引用定义 crate 内的项目,依赖改名后也能定位定义方。#[macro_export] 将宏导出到 crate 根,不会让它访问到的类型和方法自动对外公开;外部展开仍要满足可见性。

独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。这里把定义与使用放在同一 crate,展示完整 TaskId 契约;对外导出时仍需公开 TaskId::new。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#[derive(Debug, PartialEq)]
pub struct TaskId(u64);

impl TaskId {
pub fn new(value: u64) -> Option<Self> {
(value != 0).then_some(Self(value))
}
}

#[macro_export]
macro_rules! make_task_id {
($value:expr) => {
$crate::TaskId::new($value)
};
}

macro_rules! label {
(urgent $id:expr) => { format!("urgent-{}", $id) };
($id:expr) => { format!("task-{}", $id) };
}

fn main() {
assert_eq!(make_task_id!(7), Some(TaskId(7)));
assert_eq!(make_task_id!(0), None);
assert_eq!(label!(urgent 7), "urgent-7");
assert_eq!(label!(7), "task-7");
}

未导出的 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
2
3
4
5
6
7
macro_rules! evaluate {
($value:expr) => { $value };
}

fn main() {
assert_eq!(evaluate!(const { 1 + 2 }), 3);
}

故意失败:编译错误(仅标准库;独立内容放 src/main.rs;Edition 2024;expr_2021 不匹配顶层 const,预期宏规则匹配失败)。

1
2
3
4
5
6
7
macro_rules! old_evaluate {
($value:expr_2021) => { $value };
}

fn main() {
let _ = old_evaluate!(const { 1 + 2 });
}

具体迁移规则见官方 2024 片段说明。

三类过程宏

过程宏定义在单独的 proc-macro crate,以 TokenStream 为输入输出。它们能够解析语法、生成 impl 或专用代码,但增加依赖、构建时间和诊断链;并非运行时反射。标准库派生是内置派生,Serde 等第三方派生则由过程宏实现。

派生宏:完整 Serde 项目

Serde 按 CreateTask 字段生成 Serialize/Deserialize,实现运行时的数据转换。字段名称、重命名、默认值与跳过策略会影响数据协议,私有字段也不会因为私有性自动排除序列化。

下面只建立本章的 serde_example,三个声明都在正文中给出,不依赖其他章的隐藏 manifest。

多文件示例(第三方项目创建命令;执行目录为独立项目父目录)。

1
cargo +stable new --bin --edition 2024 serde_example

多文件示例(serde_example/Cargo.toml 完整内容;依赖 serde 与 serde_json;执行目录 serde_example/)。

1
2
3
4
5
6
7
8
[package]
name = "serde_example"
version = "0.1.0"
edition = "2024"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

多文件示例(serde_example/src/main.rs 完整内容;依赖上述 manifest;执行目录 serde_example/)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
struct CreateTask {
title: String,
labels: Vec<String>,
}

fn main() -> Result<(), serde_json::Error> {
let task = CreateTask {
title: "修复登录".into(),
labels: vec!["urgent".into(), "backend".into()],
};
let json = serde_json::to_string(&task)?;
let restored: CreateTask = serde_json::from_str(&json)?;
assert_eq!(restored.title, task.title);
assert_eq!(restored.labels, task.labels);
assert_eq!(restored, task);
assert!(serde_json::from_str::<CreateTask>("not json").is_err());
println!("{json}");
Ok(())
}

多文件示例(执行命令;目录 serde_example/;依赖上述 Cargo.toml 与 src/main.rs)。

1
cargo +stable run

属性宏:改写项目

属性宏接收属性参数与整个项目,返回替代项目。tokio::main 通常生成运行时创建与 block_on 逻辑,不是 Rust 语言内置的 async main;应查看线程模型、feature 与签名约束。

代码片段(Tokio 属性语法;放使用 Tokio 的 src/main.rs;需 tokio = { version = "1", features = ["macros", "rt-multi-thread"] };本章不建立第二个第三方主项目,完整运行时例见第 18 章)。

1
2
3
4
#[tokio::main]
async fn main() {
println!("runtime is ready");
}

函数式过程宏:接收 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
2
3
4
let query = sqlx::query!(
"select id, title from tasks where id = $1",
task_id,
);

在线校验与离线元数据也是构建合同。不要因宏很短就隐藏数据库前置,或把构建期检查说成保证查询运行时永不失败。

深入阅读:诊断与失败定位

先最小化调用,读匹配与展开位置,再用覆盖输入形状和副作用的测试验证宏合同。已安装 cargo-expand 时可查看近似展开代码;它是额外工具,不是本章项目隐含依赖,也不能替代类型检查与运行证据。

现象本章对应原因修复方向
const 调用没有匹配规则old_evaluate 使用 expr_2021明确要保留旧范围还是采用 expr
squared 实参执行两次转写器重复插入表达式使用 squared_once 的局部绑定
Serde/Tokio 名称或派生不可用缺依赖或所需 feature检查所在项目 manifest
squared_once 对 String 报错展开后的乘法没有合法实现调整操作合同,不绕过类型系统
错误位置落在生成代码宏展开引入不合法代码查定义、实参与展开信息

文件加载、模块隐私与外部测试问题见第 14 章,cfg 与 cfg! 问题见第 15 章;这些不是宏匹配失败。宏公开输入、求值次数与生成 API 都属于兼容性承诺,应随示例和测试一起维护。

自测

  1. 为什么普通前缀校验优先函数,而断言适合宏?
  2. squared 与 squared_once 在 next 实参上的计数各是多少?
  3. count_values 的尾逗号、空输入与副作用合同是什么?
  4. expr 的匹配由定义还是调用 edition 决定?$crate 是否绕过私有性?
  5. Serde、Tokio 入口与 SQLx query! 分别属于哪类过程宏,依赖前置是什么?

参考答案

  1. 前者是有明确参数的运行逻辑;断言还需要调用点表达式语法与文本。
  2. 分别两次、一次;宏是否单次求值由转写器决定。
  3. 接受可选尾逗号,空输入为 0,每个实参按序求值一次。
  4. 由定义 edition 决定;$crate 只定位定义方,不改变可见性。
  5. 分别是派生、属性、函数式过程宏;需相应 crate/feature,SQLx 还需匹配 schema 的连接或离线元数据。