Anshul Goyal 2021-02-03
在本教程中,我们将涵盖关于 Rust 宏所需了解的一切内容,包括对 Rust 宏的介绍,并通过示例演示如何使用 Rust 宏。
什么是 Rust 宏?
Rust 对宏(macros)提供了出色的支持。宏使你能够编写生成其他代码的代码,这种技术被称为元编程(metaprogramming)。
宏提供的功能类似于函数,但没有运行时开销。不过,在编译时会有一些代价,因为宏是在编译期间展开的。
Rust 宏与 C 语言中的宏非常不同。Rust 宏作用于词法树(token tree),而 C 宏则是文本替换。
Rust 中宏的类型
Rust 有两种类型的宏:
- 声明式宏(Declarative macros):允许你编写类似 match 表达式的结构,对作为参数传入的 Rust 代码进行操作。它使用你提供的代码生成新的代码,以替换宏调用。
- 过程宏(Procedural macros):允许你对所给 Rust 代码的抽象语法树(AST)进行操作。过程宏本质上是一个从 TokenStream(或两个)到另一个 TokenStream 的函数,其输出将替换宏调用。
接下来,我们将深入探讨声明式宏和过程宏,并通过一些 Rust 宏的使用示例进行说明。
Rust 中的声明式宏
这些宏使用 macro_rules! 声明。声明式宏功能稍弱一些,但提供了一个易于使用的接口,用于创建宏以消除重复代码。一个常见的声明式宏是 println!。声明式宏提供了一个类似 match 的接口:当匹配成功时,宏会被匹配分支中的代码所替代。
创建声明式宏
// 使用 macro_rules! <宏名> {<宏体>}
macro_rules! add {
// 宏的匹配分支
($a:expr, $b:expr) => {
// 宏展开为此处的代码
{
// $a 和 $b 将使用传给宏的值/变量进行模板化
$a + $b
}
};
}
fn main() {
// 调用宏,$a=1 且 $b=2
add!(1, 2);
}
这段代码创建了一个用于相加两个数字的宏。macro_rules! 与宏名 add 及宏体一起使用。
该宏并不会真正执行加法运算,它只是将自身替换为执行加法的代码。宏的每个分支可以接受函数参数,并且可以为参数分配多种类型。如果 add 函数也可以接受单个参数,我们可以添加另一个分支:
macro_rules! add {
// 第一个分支匹配 add!(1,2)、add!(2,3) 等
($a:expr, $b:expr) => {
{
$a + $b
}
};
// 第二个分支匹配 add!(1)、add!(2) 等
($a:expr) => {
{
$a
}
};
}
fn main() {
// 调用宏
let x = 0;
add!(1, 2);
add!(x);
}
单个宏中可以有多个分支,根据不同的参数展开为不同的代码。每个分支可以接受多个参数,以 $ 开头,后跟一个标记类型(token type):
item— 一个项,如函数、结构体、模块等block— 一个代码块(即由花括号包围的一组语句和/或表达式)stmt— 一条语句pat— 一个模式expr— 一个表达式ty— 一个类型ident— 一个标识符path— 一个路径(例如foo、::std::mem::replace、transmute::<_, int>等)meta— 一个元项;即出现在#[...]和#![...]属性中的内容tt— 一个单独的 token 树vis— 一个可能为空的可见性限定符
在下面的例子中,我们使用 $typ 参数,其标记类型为 ty,表示像 u8、u16 这样的数据类型。此宏在相加前将数字转换为指定类型:
macro_rules! add_as {
// 使用 ty 标记类型来匹配传给宏的数据类型
($a:expr, $b:expr, $typ:ty) => {
$a as $typ + $b as $typ
};
}
fn main() {
println!("{}", add_as!(0, 2, u8));
}
Rust 宏还支持接受可变数量的参数。其操作符与正则表达式非常相似:* 表示零个或多个标记类型,+ 表示一个或多个(注:原文此处有误,+ 表示一个或多个,? 表示零个或一个)。
macro_rules! add_as {
(
// 重复块
$($a:expr)
// 分隔符
,
// 零个或多个
*
) => {
{
// 处理无参数的情况
0
// 要重复的代码块
$(+$a)*
}
};
}
fn main() {
println!("{}", add_as!(1, 2, 3, 4)); // => println!("{}", {0+1+2+3+4})
}
重复的标记类型被包裹在 $() 中,后跟一个分隔符以及 * 或 +,表示该标记重复的次数。分隔符用于区分各个标记。$() 块后跟 * 或 + 用于指示要重复的代码块。在上面的例子中,+$a 是重复的代码。
如果你仔细观察,会注意到代码中额外添加了一个零,以使语法有效。为了去掉这个零并使加法表达式与参数完全一致,我们需要创建一种称为 TT muncher(Token Tree Muncher) 的新宏。
macro_rules! add {
// 第一个分支:处理单个参数的情况,也是递归的终止条件
($a:expr) => {
$a
};
// 第二个分支:处理两个参数的情况,并在参数数量为奇数时终止递归
($a:expr, $b:expr) => {
{
$a + $b
}
};
// 将第一个数与其余参数的求和结果相加
($a:expr, $($b:tt)*) => {
{
$a + add!($($b)*)
}
};
}
fn main() {
println!("{}", add!(1, 2, 3, 4));
}
TT muncher 以递归方式逐个处理每个标记。一次处理一个标记更容易。该宏有三个分支:
- 第一个分支处理只传入一个参数的情况
- 第二个分支处理传入两个参数的情况
- 第三个分支再次调用
add!宏,传入剩余的参数
宏的参数不需要用逗号分隔。可以使用不同的标记类型配合多种标记。例如,可以使用方括号配合 ident 标记类型。Rust 编译器会匹配相应的分支,并从参数字符串中提取变量。
macro_rules! ok_or_return {
// 匹配 something(q,r,t,6,7,8) 等形式
// 编译器提取函数名和参数,并将值注入到相应变量中
($a:ident($($b:tt)*)) => {
{
match $a($($b)*) {
Ok(value) => value,
Err(err) => {
return Err(err);
}
}
}
};
}
fn some_work(i: i64, j: i64) -> Result<(i64, i64), String> {
if i + j > 2 {
Ok((i, j))
} else {
Err("error".to_owned())
}
}
fn main() -> Result<(), String> {
ok_or_return!(some_work(1, 4));
ok_or_return!(some_work(1, 0));
Ok(())
}
ok_or_return 宏会在操作返回 Err 时直接返回错误,若返回 Ok 则返回其值。它接受一个函数作为参数,并在 match 语句中执行该函数。对于传给函数的参数,它使用重复语法。
有时,需要将多个宏组合成一个宏。在这种情况下,会使用内部宏规则(internal macro rules)。这有助于操纵宏输入并编写清晰的 TT muncher。
要创建内部规则,只需将规则名以 @ 作为参数开头。这样,除非显式地将其作为参数指定,否则宏永远不会匹配到内部规则。
macro_rules! ok_or_return {
// 内部规则
(@error $a:ident, $($b:tt)*) => {
{
match $a($($b)*) {
Ok(value) => value,
Err(err) => {
return Err(err);
}
}
}
};
// 公共规则,可由用户调用
($a:ident($($b:tt)*)) => {
ok_or_return!(@error $a, $($b)*)
};
}
fn some_work(i: i64, j: i64) -> Result<(i64, i64), String> {
if i + j > 2 {
Ok((i, j))
} else {
Err("error".to_owned())
}
}
fn main() -> Result<(), String> {
// 除了圆括号,也可以使用花括号
ok_or_return!{some_work(1, 4)};
ok_or_return!(some_work(1, 0));
Ok(())
}
使用声明式宏进行高级解析
宏有时需要执行解析 Rust 语言本身的任务。
为了综合运用我们到目前为止所学的所有概念,让我们创建一个宏,通过添加 pub 关键字使结构体变为公有。
首先,我们需要解析 Rust 结构体,以获取结构体名称、字段及其类型。
解析结构体的名称和字段
结构体声明以可见性关键字(如 pub)开头,后跟 struct 关键字、结构体名称和结构体体。
![解析 Struct Name Field Diagram]
macro_rules! make_public {
(
// 使用 vis 类型表示可见性关键字,ident 表示结构体名称
$vis:vis struct $struct_name:ident { }
) => {
{
pub struct $struct_name { }
}
};
}
其中 $vis 将包含可见性信息,$struct_name 将包含结构体名称。要使结构体公有,我们只需添加 pub 关键字并忽略 $vis 变量。
![Make Struct Public with Keyword]
一个结构体可能包含多个字段,这些字段可以具有相同或不同的数据类型和可见性。使用 ty 标记类型表示数据类型,vis 表示可见性,ident 表示字段名。我们将对字段使用 * 重复语法(表示零个或多个字段)。
macro_rules! make_public {
(
$vis:vis struct $struct_name:ident {
$(
// vis 表示字段可见性,ident 表示字段名,ty 表示字段数据类型
$field_vis:vis $field_name:ident : $field_type:ty
),*
}
) => {
{
pub struct $struct_name {
$(
pub $field_name : $field_type,
)*
}
}
};
}
解析结构体的元数据
通常,结构体附带一些元数据或过程宏,例如 #[derive(Debug)]。这些元数据需要保持不变。使用 meta 类型来解析这些元数据。
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])*
pub $field_name : $field_type,
)*
}
}
};
}
现在,我们的 make_public 宏已经准备就绪。为了查看 make_public 的工作原理,让我们使用 Rust Playground 将宏展开为实际编译的代码。
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])*
pub $field_name : $field_type,
)*
}
};
}
fn main() {
make_public! {
#[derive(Debug)]
struct Name {
n: i64,
t: i64,
g: i64,
}
}
}
展开后的代码如下所示:
// 一些导入
fn main() {
pub struct Name {
pub n: i64,
pub t: i64,
pub g: i64,
}
}
声明式宏的局限性
声明式宏存在一些局限性。有些与 Rust 宏本身有关,有些则更具体地针对声明式宏:
- 缺乏对宏自动补全和展开的支持
- 调试声明式宏很困难
- 修改能力有限
- 生成的二进制文件更大
- 编译时间更长(这一点同时适用于声明式宏和过程宏)
Rust 中的过程宏
过程宏是宏的更高级版本。过程宏允许你扩展 Rust 的现有语法。它接受任意输入并返回有效的 Rust 代码。
过程宏是接受 TokenStream 作为输入并返回另一个 TokenStream 的函数。过程宏通过操纵输入的 TokenStream 来生成输出流。
过程宏有三种类型:
- 类属性宏(Attribute-like macros)
- 派生宏(Derive macros)
- 类函数宏(Function-like macros)
下面我们详细探讨每种过程宏类型。
类属性宏
类属性宏允许你创建一个自定义属性,该属性附加到某个项上并允许操纵该项。它还可以接受参数。
#[some_attribute_macro(some_argument)]
fn perform_task() {
// 一些代码
}
在上面的代码中,some_attribute_macros 是一个属性宏。它操纵了函数 perform_task。
要编写一个类属性宏,首先使用 cargo new macro-demo --lib 创建一个项目。项目准备好后,更新 Cargo.toml 以通知 Cargo 该项目将创建过程宏。
# Cargo.toml
[lib]
proc-macro = true
现在我们就可以开始探索过程宏了。
过程宏是接受 TokenStream 作为输入并返回另一个 TokenStream 的公共函数。要编写过程宏,我们需要编写自己的解析器来解析 TokenStream。Rust 社区有一个非常好的 crate——syn,可用于解析 TokenStream。
syn 提供了 Rust 语法的现成解析器,可用于解析 TokenStream。你也可以通过组合 syn 提供的底层解析器来解析自己的语法。
在 Cargo.toml 中添加 syn 和 quote:
# Cargo.toml
[dependencies]
syn = {version="1.0.57", features=["full","fold"]}
quote = "1.0.8"
现在,我们可以在 lib.rs 中使用编译器提供的 proc_macro crate 编写一个类属性宏。过程宏 crate 不能导出除过程宏以外的任何内容,且在 crate 内部无法使用该 crate 中定义的过程宏。
// lib.rs
extern crate proc_macro;
use proc_macro::{TokenStream};
use quote::{quote};
// 使用 proc_macro_attribute 声明一个类属性过程宏
#[proc_macro_attribute]
// _metadata 是提供给宏调用的参数,_input 是属性宏所附加的代码
pub fn my_custom_attribute(_metadata: TokenStream, _input: TokenStream) -> TokenStream {
// 返回一个简单的 TokenStream,代表一个结构体
TokenStream::from(quote! { struct H {} })
}
为了测试我们添加的宏,可以通过创建一个名为 tests 的文件夹并在其中添加 attribute_macro.rs 文件来创建集成测试。在这个文件中,我们可以使用我们的类属性宏进行测试。
// tests/attribute_macro.rs
use macro_demo::*;
// 宏将 struct S 转换为 struct H
#[my_custom_attribute]
struct S {}
#[test]
fn test_macro() {
// 由于宏的作用,我们在作用域内有了 struct H
let demo = H {};
}
使用 cargo test 命令运行上述测试。
现在我们已经理解了过程宏的基础知识,让我们使用 syn 进行一些高级的 TokenStream 操纵和解析。
为了学习如何使用 syn 进行解析和操纵,我们以 syn GitHub 仓库中的一个例子为例。这个例子创建了一个 Rust 宏,用于在变量值更改时跟踪变量。
首先,我们需要确定我们的宏将如何操纵它所附加的代码。
#[trace_vars(a)]
fn do_something() {
let a = 9;
a = 6;
a = 0;
}
trace_vars 宏接收它需要跟踪的变量名,并在每次输入变量(即 a)的值发生变化时注入一个打印语句。它会跟踪输入变量的值。
首先,解析属性宏所附加的代码。syn 为 Rust 函数语法提供了内置解析器。ItemFn 将解析函数,如果语法无效则抛出错误。
#[proc_macro_attribute]
pub fn trace_vars(_metadata: TokenStream, input: TokenStream) -> TokenStream {
// 将 Rust 函数解析为易于使用的结构体
let input_fn = parse_macro_input!(input as ItemFn);
TokenStream::from(quote! { fn dummy() {} })
}
现在我们有了已解析的输入,接下来处理元数据。对于元数据,没有内置解析器可用,因此我们必须使用 syn 的 parse 模块自己编写一个。
#[trace_vars(a,c,b)] // 我们需要解析一个由 "," 分隔的标记列表
// 代码
为了让 syn 正常工作,我们需要实现 syn 提供的 Parse trait。Punctuated 用于创建一个由 , 分隔的 Ident 向量。
struct Args {
vars: HashSet<Ident>
}
impl Parse for Args {
fn parse(input: ParseStream) -> Result<Self> {
// 解析 a,b,c, 或 a,b,c,其中 a、b 和 c 是 Ident
let vars = Punctuated::<Ident, Token![,]>::parse_terminated(input)?;
Ok(Args {
vars: vars.into_iter().collect(),
})
}
}
一旦我们实现了 Parse trait,就可以使用 parse_macro_input! 宏来解析元数据。
#[proc_macro_attribute]
pub fn trace_vars(metadata: TokenStream, input: TokenStream) -> TokenStream {
let input_fn = parse_macro_input!(input as ItemFn);
// 使用新创建的结构体 Args
let args = parse_macro_input!(metadata as Args);
TokenStream::from(quote! { fn dummy() {} })
}
现在我们将修改 input_fn,以便在变量值更改时添加 println! 语句。为此,我们需要过滤出包含赋值的语句,并在该语句后插入打印语句。
impl Args {
fn should_print_expr(&self, e: &Expr) -> bool {
match *e {
Expr::Path(ref e) => {
// 变量不应以 :: 开头
if e.path.leading_colon.is_some() {
false
// 应该是单个变量,如 `x=8`,而不是 `n::x=0`
} else if e.path.segments.len() != 1 {
false
} else {
// 获取第一部分
let first = e.path.segments.first().unwrap();
// 检查变量名是否在 Args.vars 哈希集中
self.vars.contains(&first.ident) && first.arguments.is_empty()
}
}
_ => false,
}
}
// 用于检查是否打印 let i=0 等语句
fn should_print_pat(&self, p: &Pat) -> bool {
match p {
// 检查变量名是否在集合中
Pat::Ident(ref p) => self.vars.contains(&p.ident),
_ => false,
}
}
// 操纵语法树以插入打印语句
fn assign_and_print(&mut self, left: Expr, op: &dyn ToTokens, right: Expr) -> Expr {
// 对赋值语句右侧进行递归调用
let right = fold::fold_expr(self, right);
// 返回操纵后的子树
parse_quote!({
#left #op #right;
println!(concat!(stringify!(#left), " = {:?}"), #left);
})
}
// 操纵 let 语句
fn let_and_print(&mut self, local: Local) -> Stmt {
let Local { pat, init, .. } = local;
let init = self.fold_expr(*init.unwrap().1);
// 获取被赋值变量的变量名
let ident = match pat {
Pat::Ident(ref p) => &p.ident,
_ => unreachable!(),
};
// 新的子树
parse_quote! {
let #pat = {
#[allow(unused_mut)]
let #pat = #init;
println!(concat!(stringify!(#ident), " = {:?}"), #ident);
#ident
};
}
}
}
在上面的例子中,quote 宏用于模板化和编写 Rust 代码。# 用于注入变量的值。
现在我们将对 input_fn 进行深度优先搜索(DFS),并插入打印语句。syn 提供了 Fold trait,可以对任何 Item 进行 DFS。我们只需要修改与我们想要操纵的标记类型相对应的 trait 方法。
impl Fold for Args {
fn fold_expr(&mut self, e: Expr) -> Expr {
match e {
// 处理赋值语句,如 a=5
Expr::Assign(e) => {
// 检查是否应打印
if self.should_print_expr(&e.left) {
self.assign_and_print(*e.left, &e.eq_token, *e.right)
} else {
// 使用默认方法继续默认遍历
Expr::Assign(fold::fold_expr_assign(self, e))
}
}
// 处理赋值与操作语句,如 a+=1
Expr::AssignOp(e) => {
// 检查是否应打印
if self.should_print_expr(&e.left) {
self.assign_and_print(*e.left, &e.op, *e.right)
} else {
// 继续默认行为
Expr::AssignOp(fold::fold_expr_assign_op(self, e))
}
}
// 对其余表达式继续默认行为
_ => fold::fold_expr(self, e),
}
}
// 处理 let 语句,如 let d=9
fn fold_stmt(&mut self, s: Stmt) -> Stmt {
match s {
Stmt::Local(s) => {
if s.init.is_some() && self.should_print_pat(&s.pat) {
self.let_and_print(s)
} else {
Stmt::Local(fold::fold_local(self, s))
}
}
_ => fold::fold_stmt(self, s),
}
}
}
Fold trait 用于对 Item 进行 DFS。它使你能够为各种标记类型使用不同的行为。
现在我们可以使用 fold_item_fn 在已解析的代码中注入打印语句。
#[proc_macro_attribute]
pub fn trace_var(args: TokenStream, input: TokenStream) -> TokenStream {
// 解析输入
let input = parse_macro_input!(input as ItemFn);
// 解析参数
let mut args = parse_macro_input!(args as Args);
// 创建输出
let output = args.fold_item_fn(input);
// 返回 TokenStream
TokenStream::from(quote!(#output))
}
此代码示例来自 syn 示例仓库,这是学习过程宏的绝佳资源。
自定义派生宏
Rust 中的自定义派生宏允许自动实现 trait。这些宏使你能够使用 #[derive(Trait)] 来实现 trait。
syn 对派生宏提供了出色的支持。
#[derive(Trait)]
struct MyStruct {}
要编写 Rust 中的自定义派生宏,我们可以使用 DeriveInput 来解析派生宏的输入。我们还将使用 proc_macro_derive 宏来定义自定义派生宏。
#[proc_macro_derive(Trait)]
pub fn derive_trait(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = input.ident;
let expanded = quote! {
impl Trait for #name {
fn print(&self) -> usize {
println!("{}","hello from #name")
}
}
};
proc_macro::TokenStream::from(expanded)
}
可以使用 syn 编写更高级的过程宏。请查看 syn 仓库中的这个示例。
类函数宏
类函数宏与声明式宏类似,它们通过宏调用操作符 ! 调用,看起来像函数调用。它们对括号内的代码进行操作。
以下是编写 Rust 类函数宏的方法:
#[proc_macro]
pub fn a_proc_macro(_input: TokenStream) -> TokenStream {
TokenStream::from(quote!(
fn anwser() -> i32 {
5
}
))
}
类函数宏不是在运行时执行,而是在编译时执行。它们可以在 Rust 代码的任何地方使用。类函数宏也接受一个 TokenStream 并返回一个 TokenStream。
使用过程宏的优势包括:
- 使用 span 提供更好的错误处理
- 对输出有更好的控制
- 社区构建的 crate
syn和quote - 比声明式宏更强大
结论
在本篇 Rust 宏教程中,我们涵盖了 Rust 宏的基础知识,定义了声明式宏和过程宏,并通过各种语法和社区构建的 crate 演示了如何编写这两种宏。我们还概述了使用每种 Rust 宏类型的优势。