宏与元编程
这一课你将能做到
- 说清宏与函数的区别,熟练使用标准库高频宏(vec!、format!、assert!、dbg!)
- 用 macro_rules! 编写声明宏,掌握匹配臂与重复语法
- 理解 derive 宏机制,能读懂 serde/thiserror 的用法并知道它们对标 Java 的什么
为什么要学
Rust 代码里到处是感叹号:println!、vec!、assert_eq!。它们是宏——编译期展开为代码的代码。Java 世界里这类需求靠注解 + 反射(运行时付出性能代价)或 Lombok/MapStruct(编译期字节码魔法)实现;Rust 把「编译期生成代码」做成了一等公民,derive 宏就是它的 Lombok,而且功能强得多。
工程上的实际意义:serde 一个 #[derive(Serialize)] 生成的序列化代码是手写级别的性能(无反射),判题接口的 JSON 编解码零成本——这就是第 11 课 axum 开发的地基。
先建立图像
Java 注解体系 Rust 宏体系
@interface + 运行时反射 ↔ 过程宏(编译期生成代码,无反射)
Lombok @Getter/@Builder ↔ #[derive(Debug, Clone, ...)]
MapStruct @Mapper ↔ derive 宏 / 函数式过程宏
varargs / String.format ↔ macro_rules! 声明宏(可变参数、编译期检查)
两类宏的分工:
- 声明宏(macro_rules!):模式匹配替换,写「语法糖工具」,如自定义初始化语法
- 过程宏(proc-macro):拿到 TokenStream 直接操作,分 derive / 属性 / 函数式三种,生态重器(serde、thiserror、tokio::main)全是它
跟着做
步骤 1:先认全高频宏
cargo new macro-demo
cd macro-demo
#[derive(Debug)]
struct Case { id: u32, time_ms: u32 }
fn main() {
let cases = vec![ // vec!:字面量建 Vec
Case { id: 1, time_ms: 120 },
Case { id: 2, time_ms: 990 },
];
println!("{cases:?}"); // println!:格式化输出,{:?} 走 Debug
println!("{} 最慢 {} ms", cases.len(), cases.iter().map(|c| c.time_ms).max().unwrap());
let summary = format!("共 {} 个用例", cases.len()); // format!:返回 String
println!("{summary}");
assert_eq!(cases.len(), 2); // assert!/assert_eq!:测试断言
dbg!(&cases[0]); // dbg!:打印文件行号 + 表达式 + 值,调试利器
let max = cases.iter().map(|c| c.time_ms).max().unwrap_or(0);
println!("最长 {max} ms");
}
dbg! 对标 IDE 里的「打印调试但带定位」:输出文件名、行号、表达式原文和值,比 println! 调试效率高一个档位。
步骤 2:第一个声明宏
macro_rules! hash_map {
// 匹配臂:零或多组「表达式 => 表达式」,允许尾逗号
( $( $k:expr => $v:expr ),* $(,)? ) => {{
let mut m = std::collections::HashMap::new();
$( m.insert($k, $v); )* // 对每组匹配展开一次 insert
m
}};
}
fn main() {
let limits = hash_map! {
"cpp" => 1000u32,
"java" => 2000,
"python" => 5000,
};
println!("{limits:?}");
println!("python 时限 {}", limits["python"]);
}
读法:$( ... ),* 是「重复片段 + 分隔符」,$(,)? 是可选的尾逗号。展开发生在编译期、按 token 精确匹配——类型错误会在展开后的代码里被正常检查,没有任何运行时魔法。
步骤 3:带多种捕获的声明宏
macro_rules! judge_log {
// 两条匹配臂:宏按传入形式自动选路,编译期分派
($id:expr, pass, $ms:expr) => {
println!("[submit-{}] => AC,{} ms", $id, $ms);
};
($id:expr, fail, $reason:expr) => {
println!("[submit-{}] => 失败:{}", $id, $reason);
};
}
fn main() {
judge_log!(1, pass, 128);
judge_log!(2, fail, "WA on case 3");
}
捕获种类:expr(表达式)、ident(标识符)、literal(字面量)、tt(单棵语法树)。宏不是文本替换,是 token 树替换——因此没有 C 宏的副作用灾难,且天然卫生(宏内变量不会泄漏污染调用方作用域)。
步骤 4:derive 宏——一行生成实现
use std::fmt;
#[derive(Debug, Clone, PartialEq, Default)]
struct Submission {
id: u64,
language: String,
time_used_ms: u32,
}
// Debug 手写版长这样——derive 帮你省掉的全是这种模板代码
impl fmt::Debug for Submission2 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Submission2")
.field("id", &self.id)
.field("language", &self.language)
.finish()
}
}
struct Submission2 { id: u64, language: String }
fn main() {
let a = Submission::default();
let b = a.clone();
println!("{a:?} 相等 {}", a == b);
println!("{:?}", Submission2 { id: 1, language: "cpp".into() });
}
#[derive(Debug, Clone, PartialEq, Default)] 一行生成四个 impl。derive 宏在编译期读取结构体字段信息,逐字段生成代码——这正是第 11 课 serde 序列化、第 6 课 thiserror 错误的底层机制。
步骤 5:生态级 derive 实战预演
[dependencies]
serde = { version = "1", features = ["derive"] }
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct SubmitReq {
#[serde(rename = "problemId")] // 属性宏式配置:字段重命名
problem_id: u64,
language: String,
#[serde(default)] // 缺字段时用 Default
priority: u8,
}
fn main() {
let json = r#"{"problemId": 1001, "language": "rust"}"#;
let req: SubmitReq = serde_json::from_str(json).unwrap();
println!("{req:?}");
let out = serde_json::to_string_pretty(&req).unwrap();
println!("{out}");
}
#[derive(Serialize, Deserialize)] 让 serde 在编译期生成该结构体专用的 JSON 编解码器——零反射、零运行时注册。对标 Java Jackson 的注解 + 反射,性能差一个数量级。
讲清楚要点
- 宏的判定标志是感叹号:
println! 是宏,format! 也是;宏接受不定数量的 token,函数做不到的事它做。
- 声明宏的骨架:
macro_rules! 名字 { (模式) => { 展开 }; },多臂匹配从上到下尝试;捕获用 $name:kind,重复用 $(...),*。
- 宏卫生:宏展开里的标识符不会意外捕获调用方的同名变量(除非显式传入),比 C 宏安全一个时代。
- 过程宏三分类:derive(挂在类型上生成 impl)、属性宏(
#[tokio::main] 改写整个函数)、函数式宏(sqlx::query! 编译期校验 SQL)。
- 编译期 vs 运行期的分界线:Rust 宏在编译期展开成普通代码再走类型检查;Java 注解反射在运行期读取——这是 serde 快于 Jackson 的根本原因。
你可能会懵的地方
-
宏和泛型怎么选?
泛型约束「同一段逻辑适配多种类型」;宏适配「不同代码形状/语法」(如 vec! 接受任意数量元素)。能用泛型先用泛型,类型系统还帮你检查;宏是语法层工具,类型检查发生在展开之后。
-
为什么宏必须先定义后使用?
macro_rules! 按 crate 内文本顺序生效(use 之前要 macro 处于作用域)。同 crate 内把宏定义放到 lib.rs 顶部或用 #[macro_use] 模块声明;过程宏则没有顺序限制。
-
derive 生成的代码出错了怎么调?
报错位置在展开后的代码,cargo expand(cargo install cargo-expand)能看到完整展开结果。90% 的情况是字段类型不支持对应 trait(如 Vec 嵌套缺 derive)。
-
宏能写业务逻辑吗?
能但不该。宏的可读性和 IDE 支持弱于函数/泛型,只用于「语法糖、样板消除、DSL」三类场景;业务逻辑用函数和 trait。
-
thiserror/anyhow 和手写 impl Display 什么关系?
thiserror 是 derive 宏,替你生成第 6 课手写的 Display + From 三件套;anyhow 是运行时类型擦除的错误袋。手写一次理解原理,生产用宏省样板。
错法对照
| 错法 | 问题 | 正解 |
|---|
调用宏漏写感叹号 vec[1,2] | 被当索引/数组语法报错 | vec![1, 2] |
| 宏定义放在使用之后 | macro 未定义先使用 | 定义提到文件顶部,或按 crate 加载顺序组织 |
重复分隔符写错 $(...)* , | 展开的 token 流不对 | 分隔符在 * 之前:$(...),* |
| derive 的 trait 没开 feature | serde 报 derive 不可用 | serde = { version = "1", features = ["derive"] } |
| 宏内随手引用外部变量 | 卫生规则导致找不到 | 变量通过宏参数 $x:expr 显式传入 |
卡点急救
- error: expected expression, found
=>:宏匹配臂语法错了。检查 $( $k:expr => $v:expr ),* 的捕获种类与分隔符位置;从最简单的单臂宏开始加复杂度。
- 编译错误指向宏展开内部:
cargo expand 看展开产物定位真凶;确认每个捕获的 kind(expr/ident/tt)与传参形式一致。
- derive 报 "cannot find trait":依赖 feature 未开启(serde derive、tokio macros),核对 Cargo.toml;自定义 derive 需要单独的 proc-macro crate。
面试常问
-
声明宏和过程宏的区别?
声明宏基于模式匹配做 token 替换,语法简单、适合小型语法糖;过程宏拿到完整 TokenStream 用 Rust 代码操作,能生成任意代码,derive/属性/函数式三种。serde、tokio::main 都是过程宏。
-
derive 宏和 Java 注解处理器的对比?
两者都编译期生效,但 derive 直接输出 Rust 代码参与后续类型检查,无需字节码插桩;Java 反射注解走运行时元数据查询。Lombok 更接近 derive,但通过修改 AST/字节码实现,调试体验差于 Rust 的 cargo expand。
-
为什么 serde 比 Jackson 快?
编译期生成结构体专用的序列化代码:字段偏移在编译期确定,无反射查找、无 Map 装箱、可内联;Jackson 运行时反射 + 缓存。这是「元编程前移到编译期」的典型性能红利。
自测三问
$( $k:expr => $v:expr ),* $(,)? 每一段各是什么含义?
- 宏的「卫生性」指什么?它避免了 C 宏的哪类灾难?
- serde 的
#[serde(rename = "...")] 属于哪一类宏机制?
小结一句话
宏是 Rust 的编译期代码生成器:声明宏做语法糖,derive 宏做样板消除——Java 靠反射和 Lombok 绕的弯路,Rust 在编译期一次走直。
延伸