- 发布于
Rust的宏(一)
- Authors

- Name
- Charly
Rust的宏(一)
前言
作为Rust初学者,看到宏的奇奇怪怪语法,让人心生敬畏,瞬间Rust的门槛又提升了一级。学习这块的时候,我也翻阅不少文章,大部分主要是类比了一下其他语言的宏,然后就开始了rust宏的讲解,切入的比较直接,为了方便大家理解,我梳理了一下宏的来龙去脉。
为什么有宏
简单一点,其实宏的主要目的就是为了封装,把一些重复的逻辑封装到宏里,当然这一点函数也可以轻松做到。不过宏没有像执行函数时候的开销,Rust会在编译阶段把宏展开。想深一点在Rust语言里其实宏和函数做类比也不太合适,函数操作的对象是通过Rust语言对业务逻辑的实现。而宏操作的对象是Rust语言被编译处理后的词条(token), 和函数相比操作的对象不是一个维度,所以宏也被称为元编程。
Rust的编译
Rust强大之处也是基于LLVM编译框架,不禁要问,为什么基于LLVM编译框架就强大了呢,因为有Clang (Apple研发的用来编译C、C++ 和Object-C) 成功先例,包括后来Apple 推出的Swift语言也是基于LLVM编译器框架。LLVM架构如下:

框架主要分为前端、中间层(LLVM IR)、后端。当有新的语言接入框架我们只需要新建前端编译器部分,例如rustc,clang。后端生成各个平台的二进制编码部分是通用的。 如果想理解宏何时被rustc 处理的 我们还需要整体看一下rustc 如何生成LLVM IR的,整理后流程如下图 
- 分析rust文本代码中编译器可以识别的词条(token)。
Rust宏就是在这个阶段进行处理,在词条流转为AST之前对他进行“二次加工”。
- 将词条流转为抽象语法书(AST).
- 语法分析阶段MIR
主要把语法糖进行转化以及类型推断与检测。
- 语法分析阶段MIR
主要用来检查所有权,生命周期等。
宏的分类
Rust 中有两种类型的宏:
- 声明宏 (Delarative macros) 通过写类似匹配表达式方式来匹配rust代码中的关键字, 然后生成最终代码,来替换引用宏的位置。
- 过程宏 (Procedural macros) 过程宏的函数签名 TokenStream => TokenStream, 一般我们可以借助第三方包解析TokenStream 生成抽象语法树,在此基础上,添加我们的逻辑,然后再借助第三方包把逻辑转为TokenStream.
值得注意的是两类宏的展开都是在rustc编译器AST阶段之前。
声明宏
书写声明宏的时候,以 macro_rules! 作为标识,然后可以在语句块里写若干个"匹配函数",匹配到的函数,来替换代码中宏的位置。下面是一个简单的宏:
macro_rules! add {
($a: expr, $b: expr)=> {
{
$a+$b
}
}
}
fn main() {
let a = add!(1, 2);
println!("the a is {}", a);
}
每个“匹配函数”可以有多个参数 $作为标识,后面跟着分词(token)类型(上面代码中的expr)。token类型可以有如下几种:
- item 条目, 例如函数、结构体、模块等
- block 代码块,
- stmt 语句(statement)
- pat 匹配模式(pattern)
- expr 表达式
- ty 类型(type) 例如 u8 u16
- ident 标识符 例如结构体、函数的名字
- path 路径 例如::std::mem::replace
- meta 元条目 例如 #[...] #![...]
- tt 词条树
- vis 显示限定符 例如 pub
下面来给add! 进行改造使其可以接收任意多个参数,为了做区分命名为add_more, 代码如下:
macro_rules! add_more {
// 匹配函数1
($a: expr) => {
$a
};
// 匹配函数2
($a: expr, $b:expr) => {
{
$a + $b
}
};
// 匹配函数3
($a: expr, $($b:tt)*) => {
{
$a + add_more!($($b)*)
}
}
}
fn main() {
println!("the sum is {}", add_more!(1,2,3,4,5,6,7,8,9)); // the sum is 45
}
上面代码通过使用递归调用来处理不确定数量参数,匹配函数1和匹配函数2用来处理参数数量为一个和两个的情况, 当参数数量大于等于三个的时候会进入匹配函数3,匹配函数3会触发递归调用,递归的结束条件就是进入匹配函数2。
下面通过实现一个宏来给 struct 添加pub 前缀使struct变为共有, 看一下其他token类型的匹配。
#[macro_export]
macro_rules! make_public {
(
$(#[$meta:meta])*
$vis: vis struct $struct_name:ident {
$(
$(#[$field_meta:meta])*
$field_vis:vis $field_name:ident : $field_type:ty
),*$(,)+ // 匹配结构体最后一个逗号
}
) => {
{
$(#[$meta])*
pub struct $struct_name {
$(
$(#[$field_meta:meta])*
pub $field_name : $field_type,
)*
}
}
};
}
fn main () {
make_public!(
#[derive(Debug)]
struct Name {
n: i64,
t: i64,
g: i64, // 最后一个field需要逗号
}
)
}
$() 表示重复匹配当前类型的分词(token), 一般后面还会有分隔符和*或+ (*表示匹配0次或多次,+表示匹配多次或一次), 值得注意的是$(), 这里逗号分隔符是分词之间的分割,像struct 结构体内最后一句field语句也需要逗号,所以实例中在$(...),*语句后又添加了逗号匹配语句$(,)+。
过程宏
Rust的声明宏相比C语言的宏相比虽然很强大了,但是还是有一些局限性。声明宏只是针对匹配到的分词做“移花接木”,过程宏打破了这种局限,前面也提到过程宏的函数签名TokenStream => TokenStream, 在实现过程宏的时候一般需要借助syn 和 quote 两个库,syn 把接受到的TokenStream 转为 AST (抽象语法树),quote 库负责把Rust代码转为TokenStream。写过程宏的过程简单说就是 分析AST,添加业务逻辑,转回TokenStream。 为了说明声明宏和过程宏的区别,从Rust月刊上找到一个非常好的图(如侵权的话,联系必删)。 
注意: 声明宏和过程宏的处理都是在rustc编译的TokenStream阶段,在rustc编译的AST阶段之前进行展开,syn库生成的AST,主要是为了帮我们分析TokenStream 这里不要和rustc编译的AST阶段弄混。
过程宏按功能分为三种:
- 属性宏 (Attribute-like macros)
- 派生宏 (Derive macros)
- 类函数宏 (Function-like macros)
练习过程宏建议使用
dtolnay大神分享 proc-macro-workshop 库,下面属性宏的使用的是sorted题目,派生宏使用的是CustomDebug题目。proc-macro-workshopclone 下来后可以直接在已有的架构上写过程宏。为了讲述一下过程宏的配置,在属性宏部分还是讲解了过程宏的创建过程。
属性宏
在开始写属性宏之前我们先建一个 lib crate 使用如下命令
cargo new --lib sort-attr
需要在Cargo.toml 文件里声明一下过程宏
[lib]
proc_macro = true
添加syn 和 quote 两个依赖crate
syn = { version = "1.0.105", features = ["full", "extra-traits"] }
quote = "1"
下面我们以proc-macro-workshop sorted题目来实战一下属性宏,题目大概如下:
实现一个属性宏(Attribute macro)保证Enum里的属性是按照字母顺序排序的,如果发现不是这样的在编译阶段抛出错误,并且提示具体触发错误的属性位置 效果如下:
#[sorted]
#[derive(Debug)]
pub enum Error {
BlockSignal(signal::Error),
CreateCrasClient(libcras::Error),
CreateEventFd(sys_util::Error),
CreateSignalFd(sys_util::SignalFdError),
CreateSocket(io::Error),
DetectImageType(qcow::Error),
DeviceJail(io_jail::Error),
NetDeviceNew(virtio::NetError),
SpawnVcpu(io::Error),
}
- 先做一下准备工作在
lib.rs里 声明一下属性宏, 结构如下:
#[proc_macro_attribute]
pub fn sorted(_args: TokenStream, input: TokenStream) -> TokenStream {
TokenStream::default()
}
proc_macro_attribute是属性宏的标识,另外属性宏函数除了接受被修饰元素的TokenStream, 还接受自身参数的TokenStrem, 可以根据参数做业务逻辑处理。比如Rocket 处理路由#[get("/")] fn index() -> &'static str { "Hello, world!" }宏属性参数当前sorted题目并没有涉及到。
- 想实现题目要求,其实只需要两步处理,第一步取出
Enum中的variants, 第二步,对比每个variant发现名称字符串没有按照顺序排序抛出错误,代码实现如下:
use proc_macro::{TokenStream };
use syn::{DeriveInput, parse_macro_input, Data, DataEnum, Ident };
#[proc_macro_attribute]
pub fn sorted(_args: TokenStream, input: TokenStream) -> TokenStream {
let clone_input = input.clone();
// 第一步
let derive_input = parse_macro_input!(input as DeriveInput);
let fields = match &derive_input.data {
Data::Enum( DataEnum { variants, .. }) => variants,
_ => panic!("expected enum or match expression"),
};
let idents = fields.iter().map(|field| &field.ident).collect::<Vec<_>>();
// 第二步
let mut prev_ident: Option<&Ident> = None;
for ident in idents.into_iter() {
if let Some(prev) = prev_ident {
if ident < prev {
panic!("{} should sort before {}", ident, prev);
}
}
prev_ident = Some(ident)
}
// 没有panic 返回最初克隆tokenstream
clone_input
}
对于没有接触过过程宏编程的同学可能会对第一步有点陌生,第一步其实主要是syn库的应用,针对返回的AST 做模式匹配取出据, 想要做深入的了解可以 参考一下syn官方文档。 更直观一点也可以打印derive_input查看一下数据结构(打印依赖 syn 的 extra-traits feature)。
上述代码github地址
大而全的文章读起来并不是很友好,所以关于派生宏 (Derive macros) 的内容准备在新起一个文档。
参考文档
https://zhuanlan.zhihu.com/p/140462815
https://rustmagazine.github.io/rust_magazine_2021/chapter_1/rustc_part1.html