- 引言 — 宏是「编写代码的代码」
- 两种宏概览
- 声明式宏 — macro_rules!
- 过程式宏 — 把代码当作数据处理
- serde 的 #[derive(Serialize)] 做了什么
- 声明式 vs 过程式 — 何时用哪个
- 什么时候不该用宏
- 结语
- 参考资料
引言 — 宏是「编写代码的代码」
只要稍微用一段时间 Rust,你每天都会遇到这些东西:用于输出的 println!、构建向量的 vec!,以及最重要的、贴在结构体上方的 #[derive(Debug)]。它们不是函数,而是宏。名字后面的感叹号,或者 #[derive(...)] 这种语法,就是信号。
宏与函数的根本区别在于,它处理的不是值,而是代码本身。宏在编译期执行,把你写下的少量代码展开成更多代码。这被称为元编程。而 Rust 有两种性格截然不同的宏,本文的目标就是把这两种分开来理解。
如果想边读边动手运行语法,可以在阅读本文的同时,去本站的 Rust 学习实验室 里跑一跑这些例子。
两种宏概览
Rust 的宏大体分为两类。
- 声明式宏(declarative macro) — 用
macro_rules!定义。它对输入代码做模式匹配,再用匹配到的片段拼出预先定好的代码。就像正则表达式处理字符串的模式一样,声明式宏处理的是代码片段的模式。 - 过程式宏(procedural macro) — 把输入代码接收为一个 token 流(TokenStream),用任意的 Rust 代码去分析它,再生成一个新的 token 流。它又分为三支:由
#[derive(...)]调用的 derive 宏,像#[route(...)]这样的 attribute 宏,以及像sql!(...)那样以函数形式调用的 function-like 宏。
一句话概括:声明式宏是「看模式、拼固定代码」的工具,过程式宏则是「把代码当程序来读、再用程序写代码」的工具。先从简单的一种看起。
声明式宏 — macro_rules!
模仿一下最常见的宏之一 vec!,就能对声明式宏有个直观感受。下面是一个简化版本,靠罗列元素来构建向量。
macro_rules! my_vec {
// 接收以逗号分隔的任意数量的表达式
( $( $x:expr ),* ) => {
{
let mut temp = Vec::new();
$(
temp.push($x);
)*
temp
}
};
}
fn main() {
let v = my_vec![1, 2, 3];
println!("{:?}", v); // [1, 2, 3]
}
这段短短的代码里,已经包含了声明式宏的全部核心。箭头左边是要匹配的模式,右边是要展开出的代码。
拆开模式里用到的符号来看是这样的:以美元符号开头的宏变量(上例中的 x)用来存放匹配到的代码片段。它后面跟着的 expr 是一个片段指定符(fragment specifier),意思是「这里应该是一个表达式」。除了表达式之外,还有 ident(标识符)、ty(类型)、stmt(语句)、block(代码块)、tt(token 树)等好几种。而包裹住模式的重复标记表示「这部分重复零次或多次」。重复内部的逗号是分隔符,星号表示「零次或多次」,把星号换成加号就变成「一次或多次」。
展开的一侧也用同样的重复标记。匹配时捕获了多个值的那个宏变量,在展开时会再次重复,为每一个值各自拼出一行 push 调用。也就是说,my_vec![1, 2, 3] 会展开成 push(1); push(2); push(3);。
也可以列出多条规则。宏会从上到下选取第一条匹配的规则。
macro_rules! greet {
// 没有参数时
() => {
println!("你好!");
};
// 接收一个名字时
($name:expr) => {
println!("你好,{}!", $name);
};
}
fn main() {
greet!(); // 你好!
greet!("Rust"); // 你好,Rust!
}
卫生性 — 为什么宏不会污染变量
用过 C 语言文本替换式宏的人,都体会过宏悄悄弄坏外部变量的噩梦。Rust 的声明式宏是卫生的(hygienic)。在宏内部创建的变量,不会和宏外部同名的变量冲突。
macro_rules! make_temp {
() => {
let temp = 42;
};
}
fn main() {
let temp = 1;
make_temp!(); // 宏内部的 temp 是一个独立的、不相干的变量
println!("{}", temp); // 1(没有被污染)
}
即便宏内部用的名字也是 temp,这个名字也属于宏自己的上下文,不会和外部的 temp 混在一起。正是这份卫生性,让声明式宏可以放心使用,不必担心命名冲突。这一点,和单纯的文本替换有着决定性的差别。
过程式宏 — 把代码当作数据处理
如果说声明式宏是「模式匹配」,过程式宏就是货真价实的「代码转换程序」。过程式宏把输入代码接收为一个 token 流。token 流是把源代码切细之后得到的 token 序列。举例来说,let x = 1; 大致就是 let、x、=、1、; 这样一串 token。
过程式宏函数的签名大致是这个样子:接收一个 token 流,返回一个 token 流。
use proc_macro::TokenStream;
#[proc_macro_derive(MyTrait)]
pub fn my_derive(input: TokenStream) -> TokenStream {
// input: 挂着这个宏的结构体/枚举所对应的 token
// 返回值: 要生成的新代码所对应的 token
// ...
}
问题在于,手工解析并拼装 token 流是件极其繁琐的事。所以这个生态圈实际上依赖两个 crate。
- syn — 把 token 流解析成便于操作的语法树(syntax tree)。可以方便地取出结构体名、字段列表、类型之类的信息。
- quote — 反过来,把你想生成的代码几乎原样写出来,它就会把这段代码转换成 token 流。在
quote!宏内写下目标代码,并在变量所在的位置插入值。
用上这两个 crate,过程式宏的典型流程就变成了三步。
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(HelloName)]
pub fn hello_name(input: TokenStream) -> TokenStream {
// 1) 用 syn 解析输入
let ast = parse_macro_input!(input as DeriveInput);
let name = ast.ident; // 结构体(或枚举)的名字
// 2) 用 quote 拼装要生成的代码
let expanded = quote! {
impl #name {
fn hello() {
println!("你好,我是 {}!", stringify!(#name));
}
}
};
// 3) 作为 token 流返回
expanded.into()
}
这里 quote! 内部的 #name,就是把我们解析得到的结构体名插入到那个位置的语法。使用方如果这样挂上这个宏:
#[derive(HelloName)]
struct Robot;
fn main() {
Robot::hello(); // 你好,我是 Robot!
}
编译器就会自动为 Robot 这个名字生成一个 hello 方法——即便我们从未手写过那个 impl 代码块。顺带一提,过程式宏必须定义在独立的 crate 里,并且要在 Cargo.toml 中标注 proc-macro = true。
serde 的 #[derive(Serialize)] 做了什么
这个原理最出名、也最强大的实际例子就是 serde。在 Rust 里把数据序列化成 JSON 之类的格式时,我们通常这样写。
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct User {
name: String,
age: u32,
active: bool,
}
fn main() {
let user = User {
name: "阿荣".to_string(),
age: 30,
active: true,
};
// 用 serde_json 序列化
let json = serde_json::to_string(&user).unwrap();
println!("{}", json);
// {"name":"阿荣","age":30,"active":true}
}
这里 #[derive(Serialize)] 所做的事情,正是上一节看到的那种过程式 derive 宏。在编译期,serde 的 derive 宏把 User 结构体的定义接收为 token 流,遍历每一个字段(name、age、active),生成一份完整的、知道该如何序列化每个字段的 Serialize trait 实现。
关键在于,如果没有这段代码,你原本要为每个字段手写、既枯燥又容易出错的序列化代码,现在由宏读取字段定义、精确地拼出来。只要新增一个字段,下次编译时宏就会重新生成把这个字段也纳入处理的代码。没有需要你自己维护的代码。这正是过程式宏带来的力量,也是「把代码当数据读、再写出代码」这种元编程的实际价值所在。
serde 在这之上还掺入了一点attribute 宏的味道。贴在字段上方的提示,可以调整序列化的行为。
use serde::Serialize;
#[derive(Serialize)]
struct Config {
// 在 JSON 里用不同的字段名
#[serde(rename = "maxRetries")]
max_retries: u32,
// 没有值时就直接从输出里去掉
#[serde(skip_serializing_if = "Option::is_none")]
nickname: Option<String>,
}
这类 #[serde(...)] attribute,是 derive 宏在生成代码时会参考的指令。宏遍历字段时会读取这些提示,并据此改变生成出来的代码。
声明式 vs 过程式 — 何时用哪个
把两种宏的性格整理成表格,选择就一目了然了。
| 项目 | 声明式宏 | 过程式宏 |
|---|---|---|
| 定义方式 | macro_rules! | 专用 crate + proc_macro |
| 工作原理 | 模式匹配后替换代码 | 把 token 流当程序来转换 |
| 表达力 | 局限于固定的模式内 | 任意的 Rust 逻辑 |
| 编写难度 | 低 | 高(需要 syn/quote) |
| 代表示例 | vec!、println! | #[derive(Serialize)] |
实用的指导原则是这样:如果只是简单的代码重复或者便利语法,声明式宏就够用了。像是重复性的初始化、简短的辅助语法这类场景,macro_rules! 既轻量又安全。反过来,如果真的需要分析输入的结构——比如读取结构体或枚举的定义来自动生成 trait 实现——那答案就是过程式宏。serde、clap 的 derive,以及其他大量库都选择了这条路。
什么时候不该用宏
宏很强大,但不是免费的。滥用它,代码库反而会变得更难理解。看到下面这些信号时,不妨停下来想一想。
- 函数够用的时候就用函数。真正需要宏的理由通常只有两个:要接收数量可变的参数(比如
println!),或者需要生成代码本身。如果只是要处理值,普通的函数或泛型几乎总是更好的选择——类型检查、调试、写文档都更容易。 - 记住调试的代价。因为宏生成出来的代码是看不见的,报错信息可能会指向陌生的位置,或者难以理解。虽然可以用
cargo expand之类的工具查看展开后的代码,但这本身就是额外的负担。 - 留意编译时间。尤其是过程式宏,会引入 syn 这类较重的依赖,并且在编译期执行,因此会拉长构建时间。要留意一下,是不是在为一点小小的便利,付出了过大的编译成本。
- 优先考虑可读性。如果一个宏正在变成团队成员难以读懂的「魔法」,就该问问这份魔法是否真的值得。很多时候,几行明确的代码,胜过一个聪明的宏。
总结来说,宏是留给「用其他方式表达不出来的东西」的工具。可变参数、编译期代码生成、新语法——真有这类需求时,它会发光;而为了图一时方便而滥用,它就会变成负担。
结语
Rust 的宏是一种在编译期生成代码的元编程工具,分成性格不同的两支。声明式宏是用 macro_rules! 匹配模式、拼出代码的轻量、卫生的工具;过程式宏则是把输入接收为 token 流,再用 syn 和 quote 去分析、生成代码的强大工具。serde 的 #[derive(Serialize)] 就是后者的代表性成果,能从字段定义自动生成那些手写起来枯燥无比的代码。
理解这两种工具之间的边界,你就能判断什么时候 macro_rules! 就够用,什么时候需要过程式宏,以及什么时候干脆用一个函数才是正确答案。宏在被节制使用时最为强大。这份克制,能让你的代码始终是工具,而不是魔法。
参考资料
- The Rust Programming Language: Macros — https://doc.rust-lang.org/book/ch19-06-macros.html
- The Little Book of Rust Macros — https://veykril.github.io/tlborm/
- Rust Reference: Macros — https://doc.rust-lang.org/reference/macros.html
- syn crate — https://docs.rs/syn/
- quote crate — https://docs.rs/quote/
- serde 官方网站 — https://serde.rs/
- cargo-expand(查看宏展开结果的工具) — https://github.com/dtolnay/cargo-expand
현재 단락 (1/143)
只要稍微用一段时间 Rust,你每天都会遇到这些东西:用于输出的 `println!`、构建向量的 `vec!`,以及最重要的、贴在结构体上方的 `#[derive(Debug)]`。它们不是函数,而...