Rust 宏编程笔记(已完结)
本文为阅读《Rust圣经》的宏编程内容笔记。宏在Rust中的地位非常高,故用单独一篇文章记录笔记。查看《Rust圣经》原书请搜索“Rust圣经”或点击此链接:[Rust语言圣经(Rust Course)](https://course.rs/about-book.html)
宏的定义
Rust 的宏(Macro)是一种元编程工具,允许在编译时生成或转换代码。它们能减少重复、创建领域特定语言(DSL),并提供更强的表达能力。
宏的调用格式
1 | fn main() { |
宏与函数调用时最大的区别就是:它在调用时多了一个 ! ,而宏后面跟的括号可以是 () [] {} ,Rust 内置宏都有自己约定俗成的使用方式,例如:vec![...] 、assert_eq!(...)
宏的分类
在 Rust 中宏分为两大类:声明式宏(declarative macros) macro_rules! 和三种过程宏(procedural macros):
#[derive],在之前多次见到的派生宏,可以为目标结构体或枚举派生指定的代码,例如 Debug 特征- 类属性宏(Attribute-like macro),用于为目标添加自定义的属性
- 类函数宏(Function-like macro),看上去就像是函数调用
宏和函数的区别
元编程
元编程:一种”编写能够编写程序的程序”的编程范式。它让程序能够在编译时或运行时操作自身或其他程序。
为什么函数不属于元编程?函数的复用如何与元编程进行区分?
来看一个 Rust 伪代码例子:
1 | /* 函数版本 */ |
函数就是一个个菜谱(做鱼香肉丝()),而宏编程运用元编程,利用代码编写了一份份菜谱,也就是新的函数( 做宫保鸡丁() , 做麻婆豆腐() )
可变参数
宏的参数数量可变,函数的参数数量不可变
例如可以调用一个参数的 println!("hello") ,也可以调用两个参数的 println!("hello {}", name) 。
宏展开
由于宏会被展开成其它代码,且这个展开过程是发生在编译器对代码进行解释之前。因此,宏可以为指定的类型实现某个特征:先将宏展开成实现特征的代码后,再被编译。
而函数就做不到这一点,因为它直到运行时才能被调用,而特征需要在编译期被实现。
宏的缺点
相对函数来说,由于宏是基于代码再展开成代码,因此实现相比函数来说会更加复杂,再加上宏的语法更为复杂,最终导致定义宏的代码相当地难读,也难以理解和维护。(这也是单独开一篇文章的原因,带宏编程的代码真是难以读懂www)
声明式宏 macro_rules!
什么是声明式宏
声明式宏和 match 代码很相似
1 | match target { |
简单回顾模式和模式匹配:Rust的模式是用于匹配和解构数据的特殊语法,是Rust强大表达能力的重要组成部分。简单来说,模式匹配就是”看菜下碟”——根据数据的”长相”决定怎么处理它。(详见Rust 基础入门(二)(已完结) | EmotionalEDM)
宏也是将一个值跟对应的模式进行匹配,且该模式会与特定的代码相关联。但是与 match 不同的是,宏里的值是一段 Rust 源代码(字面量),模式用于跟这段源代码的结构相比较,一旦匹配,传入宏的那段源代码将被模式关联的代码所替换,最终实现宏展开。值得注意的是,所有的这些都是在编译期发生,并没有运行期的性能损耗。
使用声明式宏写一个简化版的 vec! 宏
vec! 宏的作用:让你用一行代码创建一个动态数组(向量),不用一个个 push
之前笔记使用过的一个例子:(见Rust 基础入门(二)(已完结) | EmotionalEDM)
1 | let v = vec!['a', 'b', 'c']; |
这里会发现一个问题,就是在创建动态数组的时候,我们不能确定每次创建的数组元素个数有几个,所以我们难以创建一个函数实现动态数组的创建,这个时候就需要用到宏编程。
简化版 vec! 宏定义如下:
1 |
|
注释版本:
1 |
|
模式匹配规则框架图:
1 | ( $( $x:expr ) , * ) => { |
$ 是 Rust 宏的元语法前缀(可以理解为 “宏专属标记”),凡是以 $ 开头的符号 / 语法,都不属于普通 Rust 代码,而是宏在 “匹配阶段” 或 “展开阶段” 处理的元变量 / 元操作。
简单说:普通 Rust 代码里的变量是 x,宏里用来捕获参数的 “临时变量” 必须标 $x;普通括号是 (),宏里的重复组必须标 $( ) —— $ 就是用来告诉编译器:“这是宏的语法,不是普通代码”。
$x 中的 $(捕获变量前缀)
1 | ( $( $x:expr ) , * ) => { |
核心作用:标记 x 是宏级别的捕获变量(而非普通 Rust 变量),专门用来存储宏在匹配阶段从用户输入中 “抓出来” 的参数值。
$( ) 中的 $(重复组标记前缀)
1 | ( $( $x:expr ) , * ) => { |
核心作用:标记 ( ) 是宏的重复组(而非普通括号),专门用来包裹 “需要循环匹配 / 循环展开的语法片段”。
模式解析
以调用 vec![1, 2, 3] 为例:
=> 之前,匹配模式
1 | ( $( $x:expr ),* ) |
当我们使用 vec![1, 2, 3] 来调用该宏时,$x 模式将被匹配三次,分别是 1、2、3。
$()中包含的是模式$x:expr,该模式中的expr表示会匹配任何 Rust 表达式,并给予该模式一个名称$x- 因此
$x模式可以跟整数 1 进行匹配,也可以跟字符串"hello"进行匹配:vec!["hello", "world"] $()之后的逗号,意味着 1 、 2 和 3 之间使用逗号进行分割*说明之前的模式可以出现零次也可以任意次,这里出现了三次
需要注意的是,此处简化的 vec! 实现中, 3 后面是不能继续接逗号的( vec![1, 2, 3] 合法,但 vec![1, 2, 3,] 不合法)。
=> 之后,展开代码
1 | { |
$() 中的 temp_vec.push() 将根据模式匹配的次数生成对应的代码,当调用 vec![1, 2, 3] 时,下面这段生成的代码将替代传入的源代码,也就是替代 vec![1, 2, 3] :
1 | { |
过程宏
什么是过程宏
过程宏是 Rust 中一种高级的 “代码生成工具”,本质是:运行在编译期的 Rust 函数,接收源码的 “语法片段(TokenStream)” 作为输入,经过自定义逻辑处理后,输出新的 Rust 代码(也是 TokenStream),最终被编译器整合到源码中。
当创建过程宏时,它的定义必须要放入一个独立的包中,且包的类型也是特殊的,这么做的原因相当复杂,我们只要知道这种限制在未来可能会有所改变即可。
假设我们要创建一个 derive 类型的过程宏:
1 | use proc_macro; |
用于定义过程宏的函数 some_name 使用 TokenStream 作为输入参数,并且返回的也是同一个类型。TokenStream 是在 proc_macro 包中定义的,顾名思义,它代表了一个 Token 序列。
自定义 derive 过程宏
项目准备
回忆特征:特征是 Rust 的核心概念之一,它定义了类型之间共享的行为接口。你可以把它理解成其他语言中的接口(Interface)或抽象类,但功能更强大。
假设我们有一个特征 HelloMacro,现在有两种方式让用户使用它:
- 为每个类型手动实现该特征,见Rust 基础入门(三)(更新中) | EmotionalEDM
- 使用过程宏来统一实现该特征,这样用户只需要对类型进行标记即可:
#[derive(HelloMacro)]
以上两种方式并没有孰优孰劣,主要在于不同的类型是否可以使用同样的默认特征实现,如果可以,那过程宏的方式可以帮我们减少很多代码实现:
1 | // 导入部分 |
我们设计 hello_macro 的作用为:println!("Hello, Macro! My name is XXXXXX!"); ,XXXXXX 是这个结构体的名称。
导入部分:这是两个不同的 crate。
hello_macro:定义了HelloMacro特征(trait)hello_macro_derive:包含了过程宏的实现
结构体定义:#[derive(HelloMacro)] 是属性宏,告诉编译器:”请为这个结构体自动生成 HelloMacro特征的实现”(这个 #[derive(HelloMacro)] 只会作用于其后的一个结构体)
接下来我们创建一个工程,内容如下:
1 | hello_macro/ |
lib.rs 如下:
1 | pub trait HelloMacro { |
main.rs 如下:
1 | use hello_macro::HelloMacro; |
lib.rs 和 main.rs 解决后就要开始定义过程宏了,在项目根目录运行代码:
1 | cargo new hello_macro_derive --lib |
获得以下项目结构:
1 | hello_macro |
该如何在项目的 src/main.rs 中引用 hello_macro_derive 包的内容?
将
hello_macro_derive发布到crates.io或GitHub中,就像我们引用的其它依赖一样使用相对路径引入的本地化方式,修改
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 | [lib] |
其中 syn 和 quote 依赖包都是定义过程宏所必需的,同时,还需要在 [lib] 中将过程宏的开关开启 : proc-macro = true。
其次,在 hello_macro_derive/src/lib.rs 中添加如下代码:
1 | // 1. 引入过程宏内置库(固定) |
对于绝大多数过程宏而言,这段代码往往只在 impl_hello_macro(&ast) 中的实现有所区别,对于其它部分基本都是一致的,如包的引入、宏函数的签名、语法树构建等。
下面来看看如何构建特征实现的代码,也是过程宏的核心目标(差异化逻辑):
1 | fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream { |
首先,将结构体的名称赋予给 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 | struct Sunfei; |
类属性宏 & 类函数宏(含实现要点)
- 类属性宏
- 标识:
#[proc_macro_attribute] - 参数:双TokenStream(attr=属性内容,item=标注项)
- 实现:① 开启
proc-macro=true,引入syn/quote;② 解析attr(如GET、”/“)和item(如index函数);③ 生成目标代码(如路由逻辑)。 - 核心签名:
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {}
- 标识:
- 类函数宏
- 标识:
#[proc_macro] - 参数:单TokenStream(输入内容,如SQL语句)
- 实现:① 同配置依赖;② 解析输入(如校验SQL语法);③ 生成执行代码。
- 核心签名:
pub fn sql(input: TokenStream) -> TokenStream {}
- 标识:
- 关键共性:均需解析TokenStream生成代码,适配macro_rules难以处理的复杂逻辑(如SQL校验)。






