Rust 宏:带示例的教程

更新于 2026-01-15

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::replacetransmute::<_, int> 等)
  • meta — 一个元项;即出现在 #[...]#![...] 属性中的内容
  • tt — 一个单独的 token 树
  • vis — 一个可能为空的可见性限定符

在下面的例子中,我们使用 $typ 参数,其标记类型为 ty,表示像 u8u16 这样的数据类型。此宏在相加前将数字转换为指定类型:

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 中添加 synquote

# 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() {} })
}

现在我们有了已解析的输入,接下来处理元数据。对于元数据,没有内置解析器可用,因此我们必须使用 synparse 模块自己编写一个。

#[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 synquote
  • 比声明式宏更强大

结论

在本篇 Rust 宏教程中,我们涵盖了 Rust 宏的基础知识,定义了声明式宏和过程宏,并通过各种语法和社区构建的 crate 演示了如何编写这两种宏。我们还概述了使用每种 Rust 宏类型的优势。