一文弄懂rust声明宏
(福利推荐:你还在原价购买阿里云服务器?现在阿里云0.8折限时抢购活动来啦!4核8G企业云服务器仅2998元/3年,立即抢购>>>:9i0i.cn/aliyun)
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时遇到嵌套的数据结构需要写重复的判断代码时,我才在应用的时候掌握了声明宏(虽然最后发现它的实用价值可能不是那么大),至于过程宏,可能等我遇到需要过程宏的时候才会很好的掌握吧,到时候在写对应的文章吧。
参考链接
https://earthly.dev/blog/rust-macros/
https://doc.rust-lang.org/reference/macros-by-example.html#metavariables
https://www.bookstack.cn/read/DaseinPhaos-tlborm-chinese/mbe-macro-rules.md
https://veykril.github.io/tlborm/
https://github.com/dtolnay/cargo-expandhttps://youerning.top/post/rust/rust-declarative-macros-tutorial/
到此这篇关于一文弄懂rust声明宏的文章就介绍到这了,更多相关rust声明宏内容请搜索程序员之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持程序员之家!
相关文章
rust解决嵌套——Option类型的map和and_then方法的使用
这篇文章主要介绍了rust解决嵌套——Option类型的map和and_then方法,本文结合实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下2023-02-02
最新评论