本文为阅读《Rust圣经》的宏编程内容笔记。宏在Rust中的地位非常高,故用单独一篇文章记录笔记。查看《Rust圣经》原书请搜索“Rust圣经”或点击此链接:[Rust语言圣经(Rust Course)](https://course.rs/about-book.html)


宏的定义

Rust 的宏(Macro)是一种元编程工具,允许在编译时生成或转换代码。它们能减少重复、创建领域特定语言(DSL),并提供更强的表达能力。

宏的调用格式

1
2
3
4
5
fn main() {
println!("aaaa");
println!["aaaa"];
println!{"aaaa"}
}

宏与函数调用时最大的区别就是:它在调用时多了一个 ! ,而宏后面跟的括号可以是 () [] {} ,Rust 内置宏都有自己约定俗成的使用方式,例如:vec![...]assert_eq!(...)

宏的分类

在 Rust 中宏分为两大类:声明式宏(declarative macros) macro_rules! 和三种过程宏(procedural macros):

  • #[derive] ,在之前多次见到的派生宏,可以为目标结构体或枚举派生指定的代码,例如 Debug 特征
  • 类属性宏(Attribute-like macro),用于为目标添加自定义的属性
  • 类函数宏(Function-like macro),看上去就像是函数调用

宏和函数的区别

元编程

元编程:一种”编写能够编写程序的程序”的编程范式。它让程序能够在编译时或运行时操作自身或其他程序。

为什么函数不属于元编程?函数的复用如何与元编程进行区分?

来看一个 Rust 伪代码例子:

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
28
29
/* 函数版本 */

// 普通函数:做鱼香肉丝
fn 做鱼香肉丝(肉: &str, 配菜: &str) -> String {
format!("用{}和{}做鱼香肉丝", 肉, 配菜)
}

// 调用函数
let 菜品 = 做鱼香肉丝("猪肉", "木耳");
println!("{}", 菜品); // 输出:用猪肉和木耳做鱼香肉丝

/* 宏版本 */

// 宏:创建新菜谱
macro_rules! 创建新菜谱 {
($菜名:ident, $主料:expr, $做法:expr) => {
fn $菜名() -> String {
format!("用{},采用{}做法", $主料, $做法)
}
};
}

// 使用宏创建新函数
创建新菜谱!(做宫保鸡丁, "鸡丁和花生", "大火爆炒");
创建新菜谱!(做麻婆豆腐, "豆腐和肉末", "麻辣烧制");

// 现在有了新的函数
println!("{}", 做宫保鸡丁()); // 用鸡丁和花生,采用大火爆炒做法
println!("{}", 做麻婆豆腐()); // 用豆腐和肉末,采用麻辣烧制做法

函数就是一个个菜谱(做鱼香肉丝()),而宏编程运用元编程,利用代码编写了一份份菜谱,也就是新的函数( 做宫保鸡丁()做麻婆豆腐()

可变参数

宏的参数数量可变,函数的参数数量不可变

例如可以调用一个参数的 println!("hello") ,也可以调用两个参数的 println!("hello {}", name)

宏展开

由于宏会被展开成其它代码,且这个展开过程是发生在编译器对代码进行解释之前。因此,宏可以为指定的类型实现某个特征:先将宏展开成实现特征的代码后,再被编译。

而函数就做不到这一点,因为它直到运行时才能被调用,而特征需要在编译期被实现。

宏的缺点

相对函数来说,由于宏是基于代码再展开成代码,因此实现相比函数来说会更加复杂,再加上宏的语法更为复杂,最终导致定义宏的代码相当地难读,也难以理解和维护。(这也是单独开一篇文章的原因,带宏编程的代码真是难以读懂www)

声明式宏 macro_rules!

什么是声明式宏

声明式宏和 match 代码很相似

1
2
3
4
5
6
7
8
9
match target {
模式1 => 表达式1,
模式2 => {
语句1;
语句2;
表达式2
},
_ => 表达式3
}

简单回顾模式和模式匹配:Rust的模式是用于匹配和解构数据的特殊语法,是Rust强大表达能力的重要组成部分。简单来说,模式匹配就是”看菜下碟”——根据数据的”长相”决定怎么处理它。(详见Rust 基础入门(二)(已完结) | EmotionalEDM

宏也是将一个值跟对应的模式进行匹配,且该模式会与特定的代码相关联。但是与 match 不同的是,宏里的值是一段 Rust 源代码(字面量),模式用于跟这段源代码的结构相比较,一旦匹配,传入宏的那段源代码将被模式关联的代码所替换,最终实现宏展开。值得注意的是,所有的这些都是在编译期发生,并没有运行期的性能损耗。

使用声明式宏写一个简化版的 vec! 宏

vec! 宏的作用:让你用一行代码创建一个动态数组(向量),不用一个个 push

之前笔记使用过的一个例子:(见Rust 基础入门(二)(已完结) | EmotionalEDM

1
2
3
4
5
let v = vec!['a', 'b', 'c'];

for (index, value) in v.iter().enumerate() {
println!("{} is at index {}", value, index);
}

这里会发现一个问题,就是在创建动态数组的时候,我们不能确定每次创建的数组元素个数有几个,所以我们难以创建一个函数实现动态数组的创建,这个时候就需要用到宏编程。

简化版 vec! 宏定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}

注释版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[macro_export]
// #[macro_export] 注释将宏进行了导出,这样其它的包就可以将该宏引入到当前作用域中,才能使用。
macro_rules! vec { // 注意:宏的名称是 vec ,而不是 vec! ,感叹号只有在调用时才需要
( $( $x:expr ),* ) => { // 见下模式匹配规则框架图
{
let mut temp_vec = Vec::new(); // 声明一个可变(后面会修改)的临时 Vec 容器
$( // 宏的重复执行标记:$(...)* 表示包裹的代码会根据参数数量循环执行
temp_vec.push($x); // 向 temp_vec 中添加宏捕获到的参数值 $x
// 每次循环对应一个参数
)*
temp_vec // 将填充完成的 temp_vec 作为块表达式的返回值
}
};
}

模式匹配规则框架图:

1
2
3
4
5
6
7
8
9
10
11
( $( $x:expr ) , * ) => {
┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬
│ │ │ │ │ │ │ │ └── 宏展开代码块的左花括号:开启宏展开的独立作用域
│ │ │ │ │ │ │ └── 分支箭头分隔符(=>):表示“匹配左侧模式则展开为右侧代码”
│ │ │ │ │ │ └── 重复操作符(*):表示其左侧的「重复组」可匹配 0 次/1 次/多次(核心)
│ │ │ │ │ └── 字面量逗号:匹配宏调用时参数之间的分隔逗号(如 vec!(1,2) 里的 ,)
│ │ │ │ └── 重复组右闭合标记:`$()` 重复组的右括号,闭合需要重复匹配的内容范围
│ │ │ └── 类型约束(:expr):限定 $x 只能匹配 Rust 中 “能求值的表达式”,保证宏接收的参数合法
│ │ └── 宏的捕获变量($x):用来临时存储每次匹配到的参数值
│ └── 重复组左开启标记:`$()` 重复组的左括号,包裹需要重复匹配的语法片段
└── 模式左圆括号:匹配宏调用时的外层圆括号(如 vec!(1,2) 里的 ( ))

$ 是 Rust 宏的元语法前缀(可以理解为 “宏专属标记”),凡是以 $ 开头的符号 / 语法,都不属于普通 Rust 代码,而是宏在 “匹配阶段” 或 “展开阶段” 处理的元变量 / 元操作。

简单说:普通 Rust 代码里的变量是 x,宏里用来捕获参数的 “临时变量” 必须标 $x;普通括号是 (),宏里的重复组必须标 $( ) —— $ 就是用来告诉编译器:“这是宏的语法,不是普通代码”。

$x 中的 $(捕获变量前缀)

1
2
3
4
( $( $x:expr ) , * ) => { 


└── $:宏捕获变量的专属前缀

核心作用:标记 x 是宏级别的捕获变量(而非普通 Rust 变量),专门用来存储宏在匹配阶段从用户输入中 “抓出来” 的参数值。

$( ) 中的 $(重复组标记前缀)

1
2
3
4
5
( $( $x:expr ) , * ) => { 
┬ ┬
│ │
│ └── 重复组右闭合(无$,因为$只标记开启)
└── $:重复组的专属前缀

核心作用:标记 ( ) 是宏的重复组(而非普通括号),专门用来包裹 “需要循环匹配 / 循环展开的语法片段”。

模式解析

以调用 vec![1, 2, 3] 为例:

=> 之前,匹配模式

1
( $( $x:expr ),* )

当我们使用 vec![1, 2, 3] 来调用该宏时,$x 模式将被匹配三次,分别是 1、2、3。

  1. $() 中包含的是模式 $x:expr ,该模式中的 expr 表示会匹配任何 Rust 表达式,并给予该模式一个名称 $x
  2. 因此 $x 模式可以跟整数 1 进行匹配,也可以跟字符串 "hello" 进行匹配: vec!["hello", "world"]
  3. $() 之后的逗号,意味着 1 、 2 和 3 之间使用逗号进行分割
  4. * 说明之前的模式可以出现零次也可以任意次,这里出现了三次

需要注意的是,此处简化的 vec! 实现中, 3 后面是不能继续接逗号的( vec![1, 2, 3] 合法,但 vec![1, 2, 3,] 不合法)。

=> 之后,展开代码

1
2
3
4
5
6
7
8
9
{
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};

$() 中的 temp_vec.push() 将根据模式匹配的次数生成对应的代码,当调用 vec![1, 2, 3] 时,下面这段生成的代码将替代传入的源代码,也就是替代 vec![1, 2, 3] :

1
2
3
4
5
6
7
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}

过程宏

什么是过程宏

过程宏是 Rust 中一种高级的 “代码生成工具”,本质是:运行在编译期的 Rust 函数,接收源码的 “语法片段(TokenStream)” 作为输入,经过自定义逻辑处理后,输出新的 Rust 代码(也是 TokenStream),最终被编译器整合到源码中。

当创建过程宏时,它的定义必须要放入一个独立的包中,且包的类型也是特殊的,这么做的原因相当复杂,我们只要知道这种限制在未来可能会有所改变即可。

假设我们要创建一个 derive 类型的过程宏:

1
2
3
4
5
use proc_macro;

#[proc_macro_derive(HelloMacro)]
pub fn some_name(input: TokenStream) -> TokenStream {
}

用于定义过程宏的函数 some_name 使用 TokenStream 作为输入参数,并且返回的也是同一个类型。TokenStream 是在 proc_macro 包中定义的,顾名思义,它代表了一个 Token 序列。

自定义 derive 过程宏

项目准备

回忆特征:特征是 Rust 的核心概念之一,它定义了类型之间共享的行为接口。你可以把它理解成其他语言中的接口(Interface)或抽象类,但功能更强大。

假设我们有一个特征 HelloMacro,现在有两种方式让用户使用它:

以上两种方式并没有孰优孰劣,主要在于不同的类型是否可以使用同样的默认特征实现,如果可以,那过程宏的方式可以帮我们减少很多代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 导入部分
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

// 结构体定义
#[derive(HelloMacro)]
struct Sunfei;

#[derive(HelloMacro)]
struct Sunface;

// 主函数
fn main() {
Sunfei::hello_macro();
Sunface::hello_macro();
}

我们设计 hello_macro 的作用为:println!("Hello, Macro! My name is XXXXXX!"); ,XXXXXX 是这个结构体的名称。

导入部分:这是两个不同的 crate。

  • hello_macro:定义了 HelloMacro特征(trait)
  • hello_macro_derive:包含了过程宏的实现

结构体定义:#[derive(HelloMacro)] 是属性宏,告诉编译器:”请为这个结构体自动生成 HelloMacro特征的实现”(这个 #[derive(HelloMacro)] 只会作用于其后的一个结构体)

接下来我们创建一个工程,内容如下:

1
2
3
4
5
hello_macro/
├── Cargo.toml
└── src
├── lib.rs
└── main.rs

lib.rs 如下:

1
2
3
pub trait HelloMacro {
fn hello_macro();
}

main.rs 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Sunfei;

#[derive(HelloMacro)]
struct Sunface;

fn main() {
Sunfei::hello_macro();
Sunface::hello_macro();
}

lib.rs 和 main.rs 解决后就要开始定义过程宏了,在项目根目录运行代码:

1
cargo new hello_macro_derive --lib

获得以下项目结构:

1
2
3
4
5
6
7
8
9
10
hello_macro
├── Cargo.toml
├── src
│ ├── main.rs
│ └── lib.rs
└── hello_macro_derive
├── Cargo.toml
└── src
└── lib.rs

该如何在项目的 src/main.rs 中引用 hello_macro_derive 包的内容?

  1. hello_macro_derive 发布到 crates.ioGitHub 中,就像我们引用的其它依赖一样

  2. 使用相对路径引入的本地化方式,修改 hello_macro/Cargo.toml 文件添加以下内容:

    1
    2
    3
    4
    [dependencies]
    hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
    # 也可以使用下面的相对路径
    # hello_macro_derive = { path = "./hello_macro_derive" }

定义过程宏

首先,在 hello_macro_derive/Cargo.toml 文件中添加以下内容:

1
2
3
4
5
6
[lib]
proc-macro = true

[dependencies]
syn = "1.0"
quote = "1.0"

其中 synquote 依赖包都是定义过程宏所必需的,同时,还需要在 [lib] 中将过程宏的开关开启 : proc-macro = true

其次,在 hello_macro_derive/src/lib.rs 中添加如下代码:

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
// 1. 引入过程宏内置库(固定)
extern crate proc_macro;

// 2. 导入核心类型(固定,仅需按需补充syn的子类型)
use proc_macro::TokenStream;
use quote::quote;
use syn;
use syn::DeriveInput; // 解析后的AST核心类型,固定

// 3. 宏函数签名(仅需修改括号内的特征名,其余固定)
#[proc_macro_derive(特征名)] // 比如HelloMacro/MyDefault
pub fn 宏函数名(input: TokenStream) -> TokenStream {
// 4. 解析输入为AST(核心逻辑固定,错误处理可微调)
let ast: DeriveInput = syn::parse(input).unwrap();

// 5. 调用差异化的AST处理函数(函数名可改,但调用逻辑固定)
处理AST的函数(&ast) // “impl_hello_macro(&ast)”
}

// 6. 最终生成代码的返回方式(固定)
fn 处理AST的函数(ast: &syn::DeriveInput) -> TokenStream { // “impl_hello_macro(&ast)”
// 差异化逻辑写在这里...

// 固定:用quote!包裹代码,.into()转为TokenStream返回
let gen = quote! { /* 生成的代码 */ };
gen.into()
}

对于绝大多数过程宏而言,这段代码往往只在 impl_hello_macro(&ast) 中的实现有所区别,对于其它部分基本都是一致的,如包的引入、宏函数的签名、语法树构建等。

下面来看看如何构建特征实现的代码,也是过程宏的核心目标(差异化逻辑):

1
2
3
4
5
6
7
8
9
10
11
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}

首先,将结构体的名称赋予给 name,也就是 name 中会包含一个字段,它的值是字符串 “Sunfei”。

其次,使用 quote! 可以定义我们想要返回的 Rust 代码。由于编译器需要的内容和 quote! 直接返回的不一样,因此还需要使用 .into 方法其转换为 TokenStream

特征的 hell_macro() 函数只有一个功能,就是使用 println! 打印一行欢迎语句。

其中 stringify! 是 Rust 提供的内置宏,可以将一个表达式(例如 1 + 2)在编译期转换成一个字符串字面值("1 + 2"),该字面量会直接打包进编译出的二进制文件中,具有 'static 生命周期。而 format! 宏会对表达式进行求值,最终结果是一个 String 类型。在这里使用 stringify! 有两个好处:

  • #name 可能是一个表达式,我们需要它的字面值形式
  • 可以减少一次 String 带来的内存分配

使用下列语句可以先用 expand 展开宏,观察是否有错误或符合预期

1
$ cargo expand --bin hello_macro

结果为:

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
28
29
30
struct Sunfei;
impl HelloMacro for Sunfei {
fn hello_macro() {
{
::std::io::_print(
::core::fmt::Arguments::new_v1(
&["Hello, Macro! My name is ", "!\n"],
&[::core::fmt::ArgumentV1::new_display(&"Sunfei")],
),
);
};
}
}
struct Sunface;
impl HelloMacro for Sunface {
fn hello_macro() {
{
::std::io::_print(
::core::fmt::Arguments::new_v1(
&["Hello, Macro! My name is ", "!\n"],
&[::core::fmt::ArgumentV1::new_display(&"Sunface")],
),
);
};
}
}
fn main() {
Sunfei::hello_macro();
Sunface::hello_macro();
}

类属性宏 & 类函数宏(含实现要点)

  1. 类属性宏
    • 标识:#[proc_macro_attribute]
    • 参数:双TokenStream(attr=属性内容,item=标注项)
    • 实现:① 开启proc-macro=true,引入syn/quote;② 解析attr(如GET、”/“)和item(如index函数);③ 生成目标代码(如路由逻辑)。
    • 核心签名:pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {}
  2. 类函数宏
    • 标识:#[proc_macro]
    • 参数:单TokenStream(输入内容,如SQL语句)
    • 实现:① 同配置依赖;② 解析输入(如校验SQL语法);③ 生成执行代码。
    • 核心签名:pub fn sql(input: TokenStream) -> TokenStream {}
  3. 关键共性:均需解析TokenStream生成代码,适配macro_rules难以处理的复杂逻辑(如SQL校验)。