Rust支持两种宏,一种是声明宏,一种是过程宏,前者相较于后者还是比较简单的。本文主要是讲解Rust元编程里的声明宏,通过声明宏可以减少一些样板代码,它是一个用代码生成代码的技术。

<!--more-->

声明宏的主要原理是通过匹配传入的代码然后替换成指定的代码,因为替换是发生在编译器,所以rust的宏编程没有任何运行时的开销,可以放心的用,不用担心性能 :)。

快速入门

声明宏不像过程宏那样需要在单独的包(package/crate)中定义,只需要使用macro_rules!就可以简单的定义一个声明宏,一个简单的示例如下。

// https://youerning.top/post/rust-declarative-macros-tutorial/
macro_rules! add {
    ($a:expr, $b:expr) => {
        $a + $b
    };
}

fn main() {
    let sum = add!(1,2);
    println!("sum: {sum}");
}

输出如下:

sum: 3

上面这个结果应该不会让人意外,你会发现声明宏定义的那一段代码和普通的match代码非常相似,不同的在于变量前面多了个前缀$, 而且需要通过冒号:注明变量的类型,这里的变量类型是expr,这是表达式的意思。

声明宏语法

一个声明宏大致可以分为三个部分

  1. 声明宏的名称定义,比如例子中的add
  2. 模式匹配部分, 比如例子中的($a:expr, $b:expr)
  3. 声明宏返回的部分, 也就是花括号被包裹的部分, 比如例子中的$a + $b

本文的开头说过,过程宏的原理就是通过匹配传入的代码然后替换成指定的代码, 所以上面的例子在编译(展开)之后应该会变成下面的代码。

fn main() {
    let sum = 1 + 2;
    println!("sum: {sum}");
}

如果我们传递三个参数呢? 比如add!(1,2,3),那么它会在编译的时候报以下错误。

error: no rules expected the token `,`
 --> src\main.rs:8:23
  |
1 | macro_rules! add {
  | ---------------- when calling this macro
...
8 |     let sum = add!(1,2,3);
  |                       ^ no rules expected this token in macro call
  |
note: while trying to match meta-variable `$b:expr`
 --> src\main.rs:2:15
  |
2 |     ($a:expr, $b:expr)=>{
  |               ^^^^^^^

error: could not compile `declarative-macros` (bin "declarative-macros") due to previous error

其实这很好理解,我们的模式只能匹配两个变量$a$b, 但是add!(1,2,3)却传入了三个变量,所以匹配不了,那么就会报错,因为这是不合法的语法。

那么,怎么匹配三个变量,或者是一个变量呢? 有两个办法,一是一一对应,二是使用重复的匹配方法。为了简单起见,我们先使用比较笨的方法,代码如下。

macro_rules! add {
    // 声明宏的第一条匹配规则
    ($a: expr) => {
        $a
    };
    // 声明宏的第二条匹配规则
    ($a:expr, $b:expr)=>{
        $a + $b
    };
    // 声明宏的第三条匹配规则
    ($a:expr, $b:expr, $c: expr)=>{
        $a + $b
    };
}

fn main() {
    let sum = add!(1);
    println!("sum1: {sum}");
    let sum = add!(1,2);
    println!("sum2: {sum}");
    let sum = add!(1,2,3);
    println!("sum3: {sum}");
}

上面的代码和快速入门的例子没有太大的区别,主要的区别是之前的例子只有一个匹配规则,而新的例子有三条匹配规则,当rust编译代码的时候,会将调用声明宏的输入参数从上至下依次匹配每条规则,当匹配到就会停止匹配,然后返回对应的代码,这和rust的match模式匹配没有太大的区别,唯一的区别可能是, 声明宏使用;分隔不同的匹配模式,而match的不同匹配模式使用,分隔。

上面的代码输出如下:

sum1: 1
sum2: 3
sum3: 3

这样的结果并不让人意外,唯一让人沮丧的是,每种情况都写一个对应的表达式的话,得累死去。

元变量

现在让我们继续看看rust的声明宏支持哪些类型。

  • item: 条目,比如函数、结构体、模组等。
  • block: 区块(即由花括号包起的一些语句加上/或是一项表达式)。
  • stmt: 语句
  • pat: 模式
  • expr: 表达式
  • ty: 类型
  • ident: 标识符
  • path: 路径 (例如 foo, ::std::mem::replace, transmute::<_, int>, …)
  • meta: 元条目,即被包含在 #[...]#![...]属性内的东西。
  • tt: 标记树

大多数情况,一般只会使用exprtt, 使用expr是因为rust中几乎可以被称为基于表达式的编程语言,因为它的表达式概念非常大,即使是ifwhile这样的语句也可以作为一个表达式返回值,而tt是一个万金油,它可以简单的被认为是其他类型都不匹配的情况下的兜底类型。

下面看一个tt类型的例子。

macro_rules! add {
    ($a: tt) => {
        {
            println!("{}", stringify!($a));
            1
        }
    };
}

fn main() {
    let sum = add!(1);
    println!("sum: {sum}");
    let sum = add!(,);
    println!("sum: {sum}");
    let sum = add!({});
    println!("sum: {sum}");
    let sum = add!(youerning);
    println!("sum: {sum}");
}

代码输出如下:

1
sum: 1
,
sum: 1
{}
sum: 1
youerning
sum: 1

代码展开后长这样:

值得注意的是: 下面的代码是手动的展开,与真实的编译代码还是有点区别的!!!
fn main() {
    let sum = {
        println!("{}", "1")
        1
    };
    println!("sum: {sum}");
    
    let sum = {
        println!("{}", ",")
        1
    };
    println!("sum: {sum}");
    let sum = {
        println!("{}", "{}")
        1
    };
    println!("sum: {sum}");
}

总的来说, tt这个类型可以接受合法或者不合法的各种标识符。

stringify!是啥? 说实话我也不太懂,我的理解是,你可以将任何东西扔给它,它会返回一个字符串字面量给你。

宏展开(expand)

如果我真的能够手动展开自己的代码,那就肯定会了,也就不用开文章学习了不是,所以如果吃不准宏展开之后的结果或者故障排查的时候可以使用cargo expand命令查看展开后的代码。

可以通过以下命令安装。

cargo install cargo-expand

安装之后在项目的根目录执行cargo expand即可,上面的例子展开之后如下。

#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;
fn main() {
    let sum = {
        {
            ::std::io::_print(format_args!("{0}\n", "1"));
        };
        1
    };
    {
        ::std::io::_print(format_args!("sum: {0}\n", sum));
    };
    let sum = {
        {
            ::std::io::_print(format_args!("{0}\n", ","));
        };
        1
    };
    {
        ::std::io::_print(format_args!("sum: {0}\n", sum));
    };
    let sum = {
        {
            ::std::io::_print(format_args!("{0}\n", "{}"));
        };
        1
    };
    {
        ::std::io::_print(format_args!("sum: {0}\n", sum));
    };
    let sum = {
        {
            ::std::io::_print(format_args!("{0}\n", "youerning"));
        };
        1
    };
    {
        ::std::io::_print(format_args!("sum: {0}\n", sum));
    };
}

如果看不太懂可以结合我手动展开的代码一起看。

标记树撕咬机(TT muncher)

通过标记树撕咬机(TT muncher)我们可以实现递归的声明宏,不过在此之前让我们先解决不定参数的问题,之前解决的方案是根据要传的参数编写声明宏的匹配代码,这样实在是太不优雅了,让我们看看怎么一次性搞定。

macro_rules! add {
    ($($a: expr),*) => {
        0$(+$a)*
    };
}

fn main() {
    let sum = add!();
    println!("sum1: {sum}");
    let sum = add!(1);
    println!("sum1: {sum}");
    let sum = add!(1,2);
    println!("sum2: {sum}");
    let sum = add!(1,2,3);
    println!("sum3: {sum}");
}

输出如下:

sum1: 0
sum1: 1
sum2: 3
sum3: 6

重复

声明宏里面有一些难点,其中一个就是重复的匹配模式, 也就是这个例子中的$($a: expr),*, 为啥要这样写? 因为这是rust的语法, 就像定义一个新变量必须使用let表达式一样,这个不需要太纠结。

下面来看看这种模式的语法定义,重复的一般形式是$ ( ... ) sep rep

  • $ 是字面标记。
  • ( ... ) 代表了将要被重复匹配的模式,由小括号包围。
  • sep是一个可选的分隔标记。常用例子包括,;
  • rep是重复控制标记。当前有两种选择,分别是* (代表接受0或多次重复)以及+ (代表1或多次重复)。目前没有办法指定“0或1”或者任何其它更加具体的重复计数或区间。
大家可以将($($a: expr),*)改成($($a: expr);*)然后就会发现编译不过了,因为分隔符需要是;

也就是说, $($a: expr),*匹配到了(), (1), (1,2),(1,2,3),为啥能匹配到()?, 因为*能匹配0个或多个,所以零参数的()也能匹配上,如果你将这个例子中的*换成+,就会发现add!()会报错,因为+要求至少一个参数。

下面以参数(1,2,3)的例子再深入一下宏展开时的操作,当传入(1,2,3)时,因为跟$($a: expr),*能够匹配上, 所以(1,2,3)里的冒号,$($a: expr),*的冒号,给匹配上,而$a代表1 2 3中的每个元素, 那么怎么在返回的代码中标识重复的参数呢?rust的语法是, 我们需要使用$()*$a包裹起来,外面的包装代码对应参数匹配时的重复次数, 你可以简单的将$()*认为是必要的语法。

下面看一个简单的例子

macro_rules! print {
    ($($a: expr),*) => {
        println!("{} {}", $($a),*)
    };
}

fn main() {
    print!(1,2);
}

$($a),*会原封不动的将参数放在它对应的位置,因为println!指定了两个位置参数,所以使用自定义的print只能传递两个参数。

最后看看上面那个add!宏的例子, add!(1,2,3)展开之后应该变成下面这样。

0+1+2+3

之所以这样,是因为我们在返回的代码模式中$($a)*$a前面加了一个+, 而这个加号+因为被$()*包裹,所以会跟着$a重复一样的次数,也就变成了+1+2+3

为啥前面要加个0?因为不加0的话, 就不是合法的表达式了。

递归示例1

虽然add!这个宏可以使用一个模式匹配就能完成,但是我们可以使用更加复杂的方式实现,也就是标记树撕咬机(TT muncher)

macro_rules! add {
    ($a: expr) => {
        $a
    };
    ($a: expr, $b: expr) => {
        $a + $b
    };
    ($a: expr, $($other: tt)*) => {
        $a + add!($($other)*)
    };
}

fn main() {
    let sum = add!(1,2,3,4,5);
    println!("sum: {sum}");
}

使用标记树撕咬机(TT muncher)的代码和之前的代码结果没有什么区别,但是展开的过程中会有些不同,因为后者使用了递归,它的递归调用类似于add!(1, add!(2, add!(3, add!(3, add!(3, add!(5))))));

这段代码的前两个匹配模式不用过多介绍,关键在于最后一个($a: expr, $($other: tt)*), $a 和 ,会吃掉一个参数和一个逗号,, 而$($other: tt)*会匹配到后面所有的参数2,3,4,5

注意这些参数包含逗号,, 还有就是我们在使用$($other: tt)*这种重复模式的时候没有指定分隔符, 所以tt既匹配了参数2 3 4 5也匹配了分割这些数字的逗号,, 所以在展开的代码$a + add!($($other)*)会变成1 + add!(2,3,4,5), 然后就是不断的递归了,直到遇到第一个匹配模式。

递归示例2

你可能在上一个例子不能感受到标记树撕咬机(TT muncher)的威力,所以我们继续看下一个例子。

我们可以通过标记树撕咬机(TT muncher)的递归调用来生成对嵌套对象的递归调用,这样就不需要不断的判断Option的值是Some还是None了。

use serde_json::{json, Value};


macro_rules! serde_get {
    ($value: ident, $first: expr) => {
        {
            match ($value).get($first) {
                Some(val) => Some(val),
                None => {
                    None
                }
            }
        }
    };

    ($value: ident, $first: expr, $($others:expr),+) => {
        {
            match ($value).get($first) {
                Some(val) => {
                    serde_get!(val, $($others),+)
                },
                None => {
                    None
                }
            }
        }
    };

    ($value: ident, $first: expr, $($others:tt)* ) => { 
        {
            match ($ident).get($first) {
                Some(val) => {
                    serde_get!(val, $($others)+),
                }
                None => None
            }
        }
    };
    
}


fn main() {
    let object = json!({
        "key11": {"key12": "key13"},
        "key21": {"key22": {"key23": "key24"}}
    });

    if let Some(val) = serde_get!(object, "xx") {
        println!(r#"object["a"]["b"]["c"]={val:?}"#);
    } else {
        println!(r#"object["a"]["b"]["c"]不存在"#);
    }

    if let Some(val) = serde_get!(object, "key1", "key12") {
        println!(r#"object["key11"]["key12"] = {val:}"#);
    }

    if let Some(val) = serde_get!(object, "key21", "key22", "key23") {
        println!(r#"object["key21"]["key21"]["key23"] = {val:}"#);
    }
}

这个例子写完,我才发现serde_json可以直接使用["key21"]["key21"]["key23"]这样的语法直接判断!!!, 不过serde_json的返回结果都是null, 如果键值对不存在的话。

总结

我感觉rust的宏编程还是很有意思的,不过这东西的确得真正有需求的时候才会真的理解,我之前也不是太懂,看了视频和文章也不是太懂,只是知道它能干啥,但是没有一个真正要解决的问题,所以一直不能很好的掌握,直到在使用serde_json时遇到嵌套的数据结构需要写重复的判断代码时,我才在应用的时候掌握了声明宏(虽然最后发现它的实用价值可能不是那么大),至于过程宏,可能等我遇到需要过程宏的时候才会很好的掌握吧,到时候在写对应的文章吧。

参考链接


又耳笔记
1 声望2 粉丝