Skip to content

9.2 声明式宏 (Declarative Macros)

在 Rust 中,宏是一种强大的元编程(metaprogramming)工具,它允许我们编写能够“写代码”的代码。这在减少重复、创建领域特定语言(DSL)等方面非常有用。

自定义宏主要有两种实现方式:

  • 声明式宏 (Declarative Macros):通过 macro_rules! 规则来定义,也被称为“示例宏 (macros by example)”。它们通过模式匹配来转换代码。
  • 过程宏 (Procedural Macros):作为一种特殊的 Rust 函数,直接在编译过程中操作代码的抽象语法树(AST),功能更强大,也更复杂。

本章将重点介绍声明式宏,因为它们更常见,也更容易上手。

macro_rules! 本身是标准库提供的一个宏,它让我们可以用一种声明式、类似 match 表达式的语法来创建自己的宏。它非常适合处理重复的代码模式,我们熟知的 vec!println! 就是用它实现的。

9.2.1 创建一个自定义宏:hashmap!

学习宏最好的方式就是动手实践。让我们来创建一个实用的 hashmap! 宏,目标是实现下面这样便捷的 HashMap 初始化语法:

rust
// 我们想要达成的效果
let counts = hashmap!{
    'A' => 0,
    'C' => 0,
    'G' => 0,
    'T' => 0
};

第 1 步:定义宏的基本结构

首先,我们使用 macro_rules! 来定义一个名为 hashmap 的宏。

rust
macro_rules! hashmap {
    // 匹配规则将写在这里
    // 格式为:(匹配模式) => { 替换代码 }
}

宏的规则都定义在花括号内,每一条规则都由一个 匹配模式(matcher) 和一个 转换模板(transcriber) 组成,中间用 => 分隔。当调用宏时,编译器会逐一尝试匹配这些规则。

第 2 步:匹配单个键值对

我们的目标是匹配 key => value 这样的形式。在宏里,我们可以用 $ 符号来捕获一部分代码,并为其指定需要匹配的语法类型,这被称为 片段指示符(fragment specifier)

keyvalue 都是表达式(expression),所以我们使用 expr 指示符。

rust
macro_rules! hashmap {
    // 匹配一个表达式,后跟 =>,再跟一个表达式
    ( $key:expr => $val:expr ) => {
        // 暂时先生成一个空的代码块
        { }
    };
}

现在,我们的宏已经可以正确匹配 hashmap!('A' => 1) 这样的调用了。

  • $key:expr:这部分会捕获一个表达式,并将其绑定到变量 $key 上。
  • =>:这是一个字面量标记,必须和宏调用中的 => 完全一致。
  • $val:expr:这部分会捕获另一个表达式,并绑定到 $val 上。

第 3 步:生成代码

接下来,我们需要在 => 右侧的模板中生成真正的代码。我们希望它能创建一个 HashMap,插入捕获到的键值对,并返回这个 HashMap

rust
macro_rules! hashmap {
    ( $key:expr => $val:expr ) => {
        // 使用一个代码块来创建局部变量并返回值
        {
            let mut map = ::std::collections::HashMap::new();
            map.insert($key, $val);
            map
        }
    };
}

注意这里我们使用了 ::std::collections::HashMap 这种绝对路径。这是一个好习惯,可以确保宏在任何模块环境下都能正确找到 HashMap 类型,而不会与用户定义的同名类型冲突。这个特性被称为 宏卫生(Macro Hygiene) 的一部分。

第 4 步:处理多个键值对(重复模式)

现在我们的宏只能处理单个键值对,但我们的目标是支持多个。为此,我们需要使用宏的重复匹配语法 $(...)*$(...)+

  • $( ... )*:匹配 ... 内的模式零次或多次。
  • $( ... )+:匹配 ... 内的模式一次或多次。

我们可以指定一个分隔符,让模式之间用它隔开。在这里,我们希望键值对之间用逗号 , 分隔。

rust
macro_rules! hashmap {
    // $(...),* 表示括号内的模式可以重复零次或多次,并以逗号分隔
    ( $( $key:expr => $val:expr ),* ) => {
        {
            let mut map = ::std::collections::HashMap::new();
            // 同样使用 $(...)* 来重复生成代码
            $(
                map.insert($key, $val);
            )*
            map
        }
    };
}

看,我们在匹配模式和代码生成模板中都使用了 $(...)*。当宏被调用时:

  1. $( $key:expr => $val:expr ),* 会匹配所有以逗号分隔的 key => value 对。
  2. $( map.insert($key, $val); )* 会为每一对匹配到的 $key$val 生成一条 map.insert(...) 语句。

最终版本与测试

现在,我们完整的宏已经诞生了!

rust
use std::collections::HashMap;

macro_rules! hashmap {
    ( $( $key:expr => $val:expr ),* ) => {
        {
            let mut map = HashMap::new();
            $(
                map.insert($key, $val);
            )*
            map
        }
    };
}

fn main() {
    let counts = hashmap!['A' => 0, 'C' => 0, 'G' => 0, 'T' => 0];
    println!("{:?}", counts); // 输出: {'A': 0, 'C': 0, 'T': 0, 'G': 0}
}

为了验证宏展开后的样子,你可以使用 rustc 的内部命令(需要 nightly 工具链):

sh
rustc +nightly -Z unstable-options --pretty=expanded your_file.rs

你会看到 main 函数中的代码被展开为:

rust
fn main() {
    let counts = {
        let mut map = HashMap::new();
        map.insert('A', 0);
        map.insert('C', 0);
        map.insert('G', 0);
        map.insert('T', 0);
        map
    };
    println!("{:?}", counts);
}

这正是我们期望的结果!

9.2.2 深入讲解

macro_rules! 宏的核心在于其模式匹配和代码生成能力。它通过一系列的规则来定义如何将输入的 token 序列转换为输出的 token 序列。

模式(Patterns)

  • 字面量和标识符:可以直接在模式中匹配特定的关键字、运算符或标识符。例如,macro_rules! my_macro { (hello world) => { ... } } 会匹配精确的 hello world 序列。

  • 元变量(Metavariables):这是模式中最强大的部分,用于捕获输入代码的片段。每个元变量都以 $ 开头,后跟一个名称,然后是一个冒号和片段指示符(fragment specifier)。

    • $name:fragment_specifier:捕获一个与 fragment_specifier 类型匹配的代码片段,并将其绑定到 $name 变量。

    片段指示符的进一步解释:

    • expr:匹配任何有效的 Rust 表达式。这是最常用的片段指示符之一,因为它允许宏处理各种计算和值。

      rust
      macro_rules! debug_print {
          ($e:expr) => {
              println!("Debug: {} = {:?}", stringify!($e), $e);
          };
      }
      debug_print!(1 + 2); // 匹配表达式 `1 + 2`
      debug_print!(vec![1, 2, 3]); // 匹配表达式 `vec![1, 2, 3]`
    • ident:匹配一个标识符,例如变量名、函数名、类型名等。这对于生成新的标识符或引用现有标识符非常有用。

      rust
      macro_rules! create_fn {
          ($fn_name:ident) => {
              fn $fn_name() {
                  println!("Hello from {}", stringify!($fn_name));
              }
          };
      }
      create_fn!(my_function); // 生成函数 `my_function`
    • ty:匹配一个类型。这在需要宏根据不同类型生成代码时非常有用。

      rust
      macro_rules! print_type {
          ($t:ty) => {
              println!("Type is: {}", stringify!($t));
          };
      }
      print_type!(i32); // 匹配类型 `i32`
      print_type!(String); // 匹配类型 `String`
    • pat:匹配一个模式,例如 let 绑定中的模式、match 表达式中的分支模式等。

      rust
      macro_rules! match_option {
          ($opt:expr, $some_pat:pat => $some_expr:expr, $none_pat:pat => $none_expr:expr) => {
              match $opt {
                  $some_pat => $some_expr,
                  $none_pat => $none_expr,
              }
          };
      }
      match_option!(Some(5), Some(x) => println!("Got: {}", x), None => println!("None"));
    • stmt:匹配一个语句。这允许宏生成或包装多个语句。

      rust
      macro_rules! do_and_log {
          ($s:stmt) => {
              $s
              println!("Statement executed.");
          };
      }
      do_and_log!(let x = 10;); // 执行 `let x = 10;` 并打印日志
    • block:匹配一个代码块,即用花括号 {} 包裹的代码。

      rust
      macro_rules! run_in_block {
          ($b:block) => {
              println!("Running block...");
              $b
              println!("Block finished.");
          };
      }
      run_in_block!({ let y = 20; println!("Inside block: {}", y); });
    • path:匹配一个路径,例如 std::collections::HashMapcrate::my_module::MyStruct

      rust
      macro_rules! use_path {
          ($p:path) => {
              println!("Using path: {}", stringify!($p));
          };
      }
      use_path!(std::io::Result); // 匹配路径 `std::io::Result`
    • meta:匹配一个元项,通常用于属性(attributes)。

      rust
      macro_rules! print_meta {
          (#[$m:meta]) => {
              println!("Meta: {}", stringify!($m));
          };
      }
      print_meta!(#[derive(Debug)]); // 匹配属性 `#[derive(Debug)]`
    • tt:匹配一个 token 树。这是最通用的片段指示符,可以匹配任何用括号 ()、方括号 [] 或花括号 {} 包裹的 token 序列,或者单个非括号 token。它通常用于当宏需要处理任意的、未分类的 token 序列时。

      rust
      macro_rules! pass_through {
          ($tokens:tt) => {
              $tokens
          };
      }
      pass_through!((1 + 2) * 3); // 匹配 `(1 + 2) * 3`
      pass_through!([1, 2, 3]);   // 匹配 `[1, 2, 3]`
  • 重复运算符*+? 用于匹配重复的模式。它们必须与 $(...) 语法结合使用。

    • $($var:fragment_specifier),*:匹配零个或多个由逗号分隔的 $var。逗号是分隔符,可以替换为其他 token,如 ;
    • $($var:fragment_specifier),+:匹配一个或多个由逗号分隔的 $var
    • $($var:fragment_specifier)?:匹配零个或一个 $var

    重复运算符与分隔符和定界符: 在重复模式中,你可以指定一个分隔符(separator)和一个定界符(delimiter)。

    • 分隔符:出现在重复项之间,例如 vec![1, 2, 3] 中的逗号 ,
    • 定界符:包裹整个重复序列,例如 vec![...] 中的方括号 []
    rust
    macro_rules! create_struct {
        ($name:ident { $($field_name:ident: $field_type:ty),* $(,)? }) => {
            struct $name {
                $($field_name: $field_type),*
            }
        };
    }
    
    create_struct!(MyStruct { a: i32, b: String });
    create_struct!(AnotherStruct { x: f64 });
    create_struct!(EmptyStruct {});

    在这个 create_struct 宏中,$($field_name:ident: $field_type:ty),* 表示匹配零个或多个 标识符: 类型 对,它们之间用逗号 , 分隔。$(,)? 允许在最后一个字段后可选地添加逗号,这是一种常见的 Rust 语法习惯。

更多片段指示符

macro_rules! 中,指示符为捕获的参数指定了期望的语法类型。这好比是给宏的参数加上了“类型注解”,它能帮助编译器正确解析宏的输入,避免语法歧义,并使宏的定义更加清晰和健壮。

指示符描述示例匹配
block一个代码块(Block),由 {} 包围的一系列语句和/或一个可选的尾部表达式。{ let x = 5; x + 1 }
expr一个表达式(Expression),任何可以计算出值的代码片段。2 + 3, vec![1], x.foo()
ident一个标识符(Identifier),如变量名、函数名、模块名等。my_variable, foo, HashMap
item一个 Rust (Item),例如函数定义、结构体、枚举、模块等。fn foo() {}, struct Bar;, mod my_mod {}
lifetime一个生命周期(Lifetime),如 'a'static'a, 'static
meta一个元信息项(Meta Item),通常是属性 #[...]#![...] 内部的内容。#[derive(Debug)] (匹配 derive(Debug))
pat一个模式(Pattern),用于 letmatchif let 等语句中进行解构和匹配。Some(x), (a, b), MyStruct {..}
path一个路径(Path),用于定位一个项,可以带泛型参数。std::collections::HashMap, Vec<T>
stmt一条语句(Statement),通常以分号结尾,或是一个成为语句的表达式。let x = 5;, return;, println!("hi")
tt一棵标记树(Token Tree)。这是最灵活的指示符,可以匹配几乎任何单个代码单元,包括括号包围的任意内容。foo, (1 + 2), [a, b], { key: value }
ty一个类型(Type)。i32, String, &str, Vec<T>
vis一个可见性限定符(Visibility Qualifier),可为空。用于生成带 pub 或其他可见性的项。pub, pub(crate), pub(in super)

更多可用的指示符请查看:官网

模板(Transcriptions)

模板是 macro_rules! 宏定义中用于生成代码的部分。当模式匹配成功后,宏会将模式中捕获的元变量替换到模板中,并生成最终的代码。

  • 捕获的元变量:在模板中,你可以直接使用模式中捕获的元变量(例如 $arg1$fn_name)。这些元变量会被它们所匹配的实际代码片段所替换。

  • stringify!:这是一个内置的宏,它将任何 Rust token 序列转换为一个字符串字面量。这在需要将代码片段的名称或结构作为字符串处理时非常有用,例如在调试输出中。

    rust
    macro_rules! log_expr {
        ($e:expr) => {
            println!("Expression \"{}\" evaluates to: {:?}", stringify!($e), $e);
        };
    }
    log_expr!(2 * (3 + 4)); // 输出:Expression "2 * (3 + 4)" evaluates to: 14
  • concat!:用于在编译时连接字符串字面量、字节字符串字面量和字符字面量。它在生成文件名、路径或需要拼接固定字符串的场景中很有用。

    rust
    macro_rules! create_file_name {
        ($prefix:expr, $suffix:expr) => {
            concat!($prefix, "_", $suffix, ".txt")
        };
    }
    let filename = create_file_name!("report", "2023"); // filename = "report_2023.txt"
  • file!line!column!:这些宏在编译时展开为当前文件路径、行号和列号的字符串字面量或整数。它们常用于调试和日志记录。

    rust
    macro_rules! log_location {
        () => {
            println!("Logged from {}:{}:{}", file!(), line!(), column!());
        };
    }
    log_location!(); // 打印当前宏调用所在的文件、行和列

宏的匹配优先级

当一个 macro_rules! 宏有多个规则时,编译器会按照规则定义的顺序尝试匹配。一旦找到第一个匹配的规则,就会使用该规则进行宏展开,而不会继续尝试后续的规则。这意味着规则的顺序很重要,更具体的规则应该放在更通用的规则之前,以避免“贪婪”匹配导致的问题。

rust
macro_rules! example_macro {
    ($x:expr, $y:expr) => {
        println!("两个表达式: {}, {}", $x, $y);
    };
    ($x:expr) => {
        println!("一个表达式: {}", $x);
    };
}

example_macro!(1, 2); // 匹配第一个规则
example_macro!(1);   // 匹配第二个规则

如果将规则的顺序颠倒,example_macro!(1, 2) 将会尝试匹配 ($x:expr) 规则,但由于输入是两个表达式,匹配会失败。因此,更具体的模式(匹配更多参数或更复杂的结构)应该放在前面。

9.2.3 宏的卫生性(Macro Hygiene)

宏的卫生性是指宏展开后生成的代码不会意外地捕获或引入与宏调用上下文中的变量名冲突的变量。Rust 的 macro_rules! 宏在很大程度上是卫生的,这意味着它们会自动处理变量名冲突的问题。

例如,如果一个宏内部定义了一个变量 x,而调用宏的代码也定义了一个变量 x,宏展开后这两个 x 不会相互冲突。编译器会为宏内部生成的变量自动重命名,以确保唯一性。

rust
macro_rules! define_x {
    () => {
        let x = 10;
        println!("Macro x: {}", x);
    };
}

fn main() {
    let x = 5;
    define_x!(); // 宏内部的 x 不会覆盖外部的 x
    println!("Main x: {}", x);
}
// 输出:
// Macro x: 10
// Main x: 5

这种卫生性极大地简化了宏的编写,因为开发者无需担心宏内部变量名与外部变量名冲突的问题。然而,需要注意的是,宏的卫生性主要针对标识符(ident),对于字面量(如字符串字面量)或类型路径,卫生性可能不适用。

9.2.4 探索标准库中的内置宏

标准库提供了许多开箱即用的宏,例如 println!,它们极大地提升了 Rust 的表达力和便利性。了解它们不仅能让我们写出更简洁的代码,还能帮助我们理解宏的实际应用场景。

以下是一些常用的标准库宏:

  • vec!:用于创建 Vec<T> 的便捷宏,例如 vec![1, 2, 3]
  • println!:将格式化的文本打印到标准输出,并换行。
  • dbg!:一个强大的调试工具,是对eprintln的封装。它会接收一个表达式,打印出文件名、行号、表达式本身及其结果,然后返回表达式的值。非常适合快速检查代码中的变量状态。
  • compile_error!:在编译时产生一个自定义的错误信息并终止编译。这在编写宏并需要向用户报告错误时特别有用。
  • concat!:在编译时将多个字符串字面量连接成一个静态字符串 &'static str
  • env!:在编译时读取一个环境变量的值。如果环境变量未设置,编译将会失败。它的安全版本是 option_env!,它返回一个 Option<&'static str>
  • eprint!eprintln!:与 print! 类似,但会将消息输出到标准错误流(stderr)。
  • include_bytes!:在编译时将一个文件的内容作为字节数组 &'static [u8; N] 直接嵌入到二进制文件中。
  • stringify!:接收任意 Rust 代码片段,并将其转换为字符串字面量。当我们编写自己的过程宏时,将会用到它。例如 stringify!(let a = 1;) 会得到 "let a = 1;"

想要了解标准库中所有可用的宏,请访问 官方文档中的宏列表

Released under the MIT License