Rust最令人敬畏和强大的特性之一是它使用和创建宏的能力。不幸的是,用于创建宏的语法可能相当令人生畏,并且对于新开发人员来说,这些示例可能会令人不知所措。我向你保证Rust宏非常容易理解,本文将为你介绍如何创建自己的宏。
什么是Rust宏?
rust
println!("hello {}", name)
如果你已经尝试过Rust,你应该已经使用过一个宏println!这个宏允许您打印一行文本,并能够在文本字符串中插入变量。
宏只是允许你发明自己的语法并编写代码生成更多的代码。这被称为元编程,支持语法糖,使你的代码更短,更容易使用你的库,甚至可以在rust中创建自己的DSL(领域特定语言)。
宏的工作方式是匹配宏规则中定义的特定模式,将匹配的部分捕获为变量,然后展开以生成更多代码。如果你听不懂也没关系。让我们开始吧!
如何创建宏
我们可以使用macro_rules!宏。Macroception !
这就是如何创建一个空白嘿!宏;很明显,它现在没有任何作用。
rust
macro_rules! hello {
() => {}
}
()=>{}
部分似乎很有趣,不是吗?
这是一个宏规则的条目,我们可以在一个宏中有许多规则来匹配它。这与模式匹配非常相似,在模式匹配中,我们也可以有许多用逗号分隔的情况。
但是,它到底是什么意思呢?
() | => | {} |
---|---|---|
匹配器 | 编写器 | |
匹配模式 | 扩展代码 |
括号部分是匹配器,它将允许我们匹配模式并捕获其中一部分作为变量。这就是我们如何发明自己的自定义语法和dsl的方法。
花括号部分是转录器,在这里我们可以使用从匹配器捕获的变量。Rust编译器会将宏的代码及其变量扩展为实际的Rust代码。
匹配模式
我们如何匹配一个模式呢?让我们来看看。
($name:expr)
- $name 将在后面编写器引用
- 指示符:用于匹配类型,如:expression(表达式类型)
Rust将尝试匹配在匹配器对中定义的模式,匹配器对可以是()、{}或[]。宏规则中的字符将与输入进行匹配,以确定是否匹配。在规则的模式匹配成功之后,我们可以捕获模式的一部分,并将其捕获为一个变量,以便在编写器中使用。
美元符号之后的部分是变量名称,它将在编写器中作为变量使用,这就是我们的代码所在的位置。在本例中,我们当前将其捕获为$name。
冒号后面的部分称为指示符,是我们可以选择匹配的类型。例如,我们目前使用的是表达式指示符,由冒号后面的expr表示。这告诉Rust匹配一个表达式,并将其捕获为$name。
我们可以使用许多指示符,而不仅仅是表达式。下面是Rust中可用的指示符的快速列表:
- 标识符(Identifiers)
说明:标识符是用来表示变量、函数、结构体等的名称。在声明性宏中,标识符参数用于匹配和捕获代码中的名称,然后可以在宏展开时对这些名称进行操作。
rust
macro_rules! print_variable_name {
($var:ident) => {
println!("The variable name is: {}", stringify!($var));
};
}
let x = 5;
print_variable_name!(x);
- 字面量(Literals)
说明:字面量包括整数字面量、浮点数字面量、字符字面量和字符串字面量等。在宏中,字面量参数用于匹配特定类型的常量值。
rust
-- 数值字面量
macro_rules! double_number {
($num:literal) => {
$num * 2
};
}
let result = double_number!(5);
assert_eq!(result, 10);
-- 字符串字面量
macro_rules! print_greeting {
($name:literal) => {
println!("Hello, {}!", $name);
};
}
print_greeting!("Alice");
- 路径(Paths)
说明:路径用于指定一个类型、函数或者模块的位置。在宏中,路径参数可以用来匹配和操作代码中的类型或函数引用。
rust
macro_rules! call_function {
($func_path:path) => {
$func_path();
};
}
fn my_function() {
println!("This function is called through a macro.");
}
call_function!(my_function);
$func_path:path
是路径参数。这里宏call_function
接受函数路径作为参数,然后调用该函数。
- 表达式(Expressions)
说明:表达式是可以计算出值的代码片段。在宏中,表达式参数用于匹配和操作代码中的各种表达式,如算术表达式、函数调用表达式等。
rust
macro_rules! square_expression {
($expr:expr) => {
($expr) * ($expr)
};
}
let x = 3;
let result = square_expression!(x + 1);
assert_eq!(result, 16);
宏square_expression
接受一个表达式作为参数,然后将这个表达式自身相乘,计算出表达式值的平方。
- 类型(Types)
说明:类型参数用于匹配和操作代码中的数据类型。可以在宏中根据不同的类型进行不同的代码展开。
rust
macro_rules! print_type_name {
($ty:ty) => {
println!("The type is: {}", stringify!($ty));
};
}
print_type_name!(u32);
$ty:ty
是类型参数。宏print_type_name
接受类型作为参数,然后打印出这个类型的名称(通过stringify!
宏转换为字符串)
- 块(Blocks)
说明:块是由花括号包围的一系列语句。在宏中,块参数用于匹配和操作代码中的语句块,这样可以在宏展开时对整个语句块进行处理。
rust
macro_rules! execute_block {
($block:block) => {
$block
};
}
let result = execute_block!({
let x = 5;
x * 2
});
assert_eq!(result, 10);
宏execute_block
接受语句块作为参数,然后执行这个语句块并返回结果。
token
(标记)参数
token
参数是一种比较通用的参数类型,它可以匹配几乎任何语法结构单元。这包括但不限于关键字、操作符、标点符号等。使用token
参数可以让宏更灵活地处理各种复杂的语法模式。
rust
macro_rules! handle_tokens {
($first_token:token $second_token:token) => {
println!("The first token is: {:?}, the second token is: {:?}", $first_token, $second_token);
};
}
handle_tokens!(let +);
$first_token:token
和$second_token:token
用于匹配任意两个连续的语法标记。这个宏会打印出这两个标记的内容。
meta
(元数据)参数
meta
参数用于匹配和处理属性(attribute)相关的语法结构。在 Rust 中,属性用于为各种语言元素(如函数、结构体、枚举等)添加额外的元数据,如条件编译信息、派生(derive)的 trait 等。
rust
macro_rules! print_meta {
($meta:meta) => {
println!("The meta data is: {:?}", $meta);
};
}
#[derive(Debug)]
struct MyStruct;
print_meta!(#[derive(Debug)]);
$meta:meta
用于匹配属性语法结构。这个宏可以打印出属性的内容,就像上面打印#[derive(Debug)]
这个属性一样。
第一个宏示例
太酷了! 现在我们知道了创建简单宏的足够内容。让我们创造属于我们自己的hello!宏。
rust
macro_rules! hello{
($name:expr) => {
println!("hello {}!", $name);
};
}
fn main(){
hello!("Rust");
}
就是这样,我们刚刚创建了第一个宏!这个程序会打印出 hello Rust!作为调用宏的结果。我们匹配了输入表达式,并将其捕获为$name
变量,然后在编写器中使用捕获的$name,它将由Rust编译器展开。这看起来很简单,不是吗?
第二个宏示例
我们所熟悉和喜爱的许多宏可以一次接受大量输入。vec!宏就是典型例子;我们可以通过调用vec!宏像这样:vec![1,2,3,4,5]。vec!宏如何实现的呢,我们来慢慢分解说明。
( $( $x:expr),* )
这些*号 会在$(...) 重复多次, 逗号是分隔符;
我们只需将想要重复的模式放在$(...)部分中。然后,插入分隔符,在本例中是逗号(,)符号。这将是一个字符,将模式分开,让我们有重复。
最后在末尾添加星号(*)符号,它将重复匹配$()中的模式,直到匹配完成为止。困惑吗?让我们再看一个例子吧!
在这种情况下,hello!宏,我们捕捉所有的输入表达式作为$name,我们将继续匹配它,直到没有匹配。我们可以用任意多的参数调用这个宏。
仍然困惑吗?这完全没问题!让我们实现一个令人敬畏的现实世界的宏,看看它是如何工作的。
- 实现Rust HashMap
rust
use std::collections::HashMap;
macro_rules! mapx {
( $( $key:expr=>$value:expr ),* ) => {
{
let mut hmap = HashMap::new();
$(hmap.insert($key, $value);)*
hmap
}
};
}
fn main() {
let addr_maps = mapx!(
"a1"=>"beijing01",
"a2"=>"beijing02",
"a3"=>"beijing03"
);
println!("{:?}", addr_maps);
}
// 输出结果:
// {"a1": "beijing01", "a2": "beijing02", "a3": "beijing03"}
- 定义了宏
mapx!
,它接受一系列以=>
分隔的键值对表达式作为参数。 - 在宏展开时,首先创建一个新的空
HashMap
,然后通过循环遍历传入的键值对,将每个键值对插入到HashMap
中,最后返回初始化好的HashMap
。
这样就可以方便地一次性初始化多个 HashMap
的键值对,类似于 vec!
宏用于初始化向量的便捷方式。
这里需要注意的是,这段展开代码使用 {} 括起来, 虽然外面已经有 {};,下面我们分析下:
rust
{
let mut hmap = HashMap::new();
$(hmap.insert($key, $value);)*
hmap
}
- 作用域和变量遮蔽问题
- 在宏展开的环境中,使用
{}
创建一个新的内部块作用域是很重要的。虽然外层可能有其他的块作用域(比如main
函数本身就是一个块作用域),但这里的内部块主要是为了确保变量的正确生命周期和作用范围。 - 如果没有这个内部块,在宏展开时可能会出现变量遮蔽(shadowing)等问题。例如,如果在宏调用的上下文中已经存在一个名为
hmap
的变量,那么没有内部块的话,宏内部对hmap
的操作可能会意外地影响到外部的hmap
变量。
- 在宏展开的环境中,使用
- 返回值的明确性
- 这个内部块最后返回
hmap
,明确了宏的返回值是这个新创建并初始化好的HashMap
。从语法角度看,在 Rust 的表达式导向的语法中,一个块({}
)可以作为一个表达式,它的值是最后一个表达式的值(在这里就是hmap
的值)。如果没有这个块,宏展开后的代码在语法上可能不符合期望的返回值要求,导致编译错误或者意外的行为。
- 这个内部块最后返回
最后总结
本文介绍了Rust宏,主要了解宏的宏的工作方式,如何匹配宏规则中定义的特定模式,将匹配的部分捕获为变量,然后展开以生成更多代码。我们通过几个简单示例,你应该大概清楚了创建宏的过程。当然要掌握Rust宏,还有很长的路要走,如何编写声明宏、过程宏等,未来我们继续,一起rust。