Skip to content

필사 모드: Rust 宏:声明式宏 vs 过程式宏(derive)

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 宏是「编写代码的代码」

只要稍微用一段时间 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; 大致就是 letx=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 流,遍历每一个字段(nameageactive),生成一份完整的、知道该如何序列化每个字段的 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! 就够用,什么时候需要过程式宏,以及什么时候干脆用一个函数才是正确答案。宏在被节制使用时最为强大。这份克制,能让你的代码始终是工具,而不是魔法。

参考资料

현재 단락 (1/143)

只要稍微用一段时间 Rust,你每天都会遇到这些东西:用于输出的 `println!`、构建向量的 `vec!`,以及最重要的、贴在结构体上方的 `#[derive(Debug)]`。它们不是函数,而...

작성 글자: 0원문 글자: 5,741작성 단락: 0/143