rust声明宏快速入门教程
Rust支持两种宏,一种是声明宏,一种是过程宏,前者相较于后者还是比较简单的。本文主要是讲解Rust元编程里的声明宏,通过声明宏可以减少一些样板代码,它是一个用代码生成代码的技术。
声明宏的主要原理是通过匹配传入的代码然后替换成指定的代码,因为替换是发生在编译器,所以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
,这是表达式的意思。
声明宏语法
一个声明宏大致可以分为三个部分
- 声明宏的名称定义,比如例子中的
add
- 模式匹配部分, 比如例子中的
($a:expr, $b:expr)
- 声明宏返回的部分, 也就是花括号被包裹的部分, 比如例子中的
$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
: 标记树
大多数情况,一般只会使用expr
和tt
, 使用expr
是因为rust中几乎可以被称为基于表达式的编程语言,因为它的表达式概念非常大,即使是if
和while
这样的语句也可以作为一个表达式返回值,而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
时遇到嵌套的数据结构需要写重复的判断代码时,我才在应用的时候掌握了声明宏(虽然最后发现它的实用价值可能不是那么大),至于过程宏,可能等我遇到需要过程宏的时候才会很好的掌握吧,到时候在写对应的文章吧。