Rust 宏是一种强大的元编程工具,允许开发者在编译时生成或修改源代码,从而增强代码的可复用性、简洁性和抽象能力。Rust依赖宏编程,实现了零成本抽象:抽象在和替换在编译阶段,不在运行时引入任何开销。Rust 提供了两种主要的宏类型:macro_rules! 宏(也称为“声明式宏”或“模式宏”)和过程宏。本文主要是介绍Rust声明式宏的定义和使用,以及一些宏编程基本原理,帮助大家实现Rust宏编程入门。
获取关于宏
熟悉C语言的童鞋对宏不会陌生,比如定义一个取最小值的宏,类似这样:
#define MIN(a, b) ((a) < (b) ? (a) : (b))
应用该宏,取两个字符串最小长度,类似这样:
int minLenght(char* str1, char* str2)
{
return MIN(strlen(str1), strlen(str2));
}
在C语言中,宏是在预编译阶段进行替换(展开)。可以简单的认为(不严谨),C语言中的宏是“符号”的替换过程:
(1)MIN(..) => ( (..)< (..) ? (..) : (..) )
(2)a => strlen(str1) , b => strlen(str2)
(3) MIN(strlen(str1), strlen(str2)) => ( (strlen(str1)) < (strlen(str2)) ? (strlen(str1)) : (strlen(str2)) )
上述代码经过预编译后,代码为:
int minLenght(char* str1, char* str2)
{
return ( (strlen(str1))<(strlen(str2) )? (strlen(str1)) : (strlen(str2)) );
}
Rust中,也支持宏。
Rust中的宏概念和C中的宏类似,都是面向编译器制定的一种源代码扩展、替换的方法和规则。
虽然我们之前没有系统的学习宏,但是已经多次用过它,例如在很多例子中,使用println!打印输出,而这个println!就是最常用的一个宏;可以看到它和函数最大的区别是:它在调用时多了一个 !
,除此之外还有 vec!
、assert_eq!
都是相当常用的,可以说宏在Rust中无处不在。
在 Rust 中宏分为两大类:声明式宏( declarative macros ) macro_rules!
和过程宏( procedural macros )。
进一步,过程宏又分为派生宏,类属性宏,类函数宏:
#[derive]
,在阅读代码过程中经常见到,可以为目标结构体或枚举派生指定的代码,例如Debug
特征- 类属性宏(Attribute-like macro),用于为目标添加自定义的属性
- 类函数宏(Function-like macro),看上去就像是函数调用。
Rust宏相比于C的宏,在类型安全性、模式匹配、卫生性(见下面注释)、定义与使用上都有大幅提升;自然其复杂程度也相比C提升不少。但也不必担心,接下来我们将逐个看看它们的庐山真面目。
注:宏的卫生性(Hygiene)是指在宏展开过程中,确保宏生成的代码与宏调用所在的上下文之间具有清晰的隔离,以防止宏引入的标识符(如变量名、函数名、类型名等)与上下文中的已有标识符发生意外的冲突或绑定。卫生性是现代宏系统的一个重要特性,旨在解决传统宏系统中常见的命名空间污染和未预期的绑定问题。
声明式宏 —— macro_rules!
在 Rust 中使用最广的就是声明式宏,一些场景中说:“Rust的宏”,通常指的就是声明式宏 。
声明式宏和C代码中的宏最接近:声明一个宏样式,按模式匹配的方式进行代码扩展或替换。与C中的宏替换不同的是,Rust宏展开过程中,应用了模式匹配(和rust的match模式相似):
match target {
模式1 => 表达式1,
模式2 => {
语句1;
语句2;
表达式2
},
_ => 表达式3
}
声明式宏,允许我们像使用match一样,通过对宏声明内的表达式按多个模式逐个进行匹配,一旦匹配到某个模式,则按该模式后面所定义的表达式进行宏展开(这个过程和C的宏展开类似,属于源代码级“符号”替换);一旦所有模式都不匹配,则宏展开失败。
简化版的vec!宏
在学习数组章节,一定会接触到用一个vec!宏来便捷地定义并初始化一个数组。其“便捷”在于:
1) 无需事先声明数组内元素的类型;
2) 无需事先指定长度,可以按自己的需求随意添加初始化元素个数。
列如:
let v = vec![1, 2, 3];
在Rust强类型限制、严格的初始化流程检测要求下,上面的代码看山去有些不可思议。
这正是macor_rules!的魅力所在。
我们来实现一下。
cargo new --libe imarco
在lib.rs中添加以下代码:
#[macro_export]
macro_rules! ivec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
pub fn get_default_i32vec() -> Vec<i32> {
ivec![1,2,3]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_declare_int() {
let array = ivec![1,2,3,4];
assert_eq!(array.len(), 4);
assert_eq!(array[0], 1);
assert_eq!(array[1], 2);
assert_eq!(array[2], 3);
assert_eq!(array[3], 4);
}
}
完成后,我们运行一下测试: cargo test test_declare_int
测试运行成功,我们第一个宏定义顺利通过!
对宏内容逐行解释:
见截图,对ivec!宏进行了逐行说明。其中关键点
1) 第3行描述了一种模式匹配:宏参数中包含多个表达式,表达式按“,”分割;表达式重复0次或多次(详见后续 模式解析 章节);
2) 6、7、8行描述了重复模式的替换方式:每一个重复的表达式,都应用一次temp_vec.push([该表达式])的展开。
总结而言,ivec!的功能是,对ivec![ item1, item2, ...] 声明的数组,执行以下过程:
1) 定义一个代码块,在该代码块内定义一个rust 数组 temp_vec;
2) 对宏参数进行匹配,按 ","
进行表达式分割;对每一个有效表达式执行一次 temp_vec.push([表达式内容])的代码扩展;
3) 代码块返回temp_vec。
以get_default_i32vec 函数为例,该函数展开后,应该是这样:
fn get_default_i32vec()->Vec<i32> {
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
}
我们在imarco工程下,用cargo expand --lib 将该工程代码进行宏展开:
其结果与我们分析的一致!
多模式匹配
针对ivec!宏,补充一组测试用例:
#[test]
fn test_empty_input() {
let array = ivec![];
assert_eq!(array.len(), 0);
}
该测试意图测试ivec!宏参数为空时,期望返回一个长度为0的数组。
实际执行时,该测试会失败:
原因是按照宏展开,该代码为:
#[test]
fn test_empty_input() {
let array = {
let mut temp_vec = Vec::new(); // new 一个数组,但目前尚不清楚类型,需要在使用前进行一次数据绑定(设置)以明确数据类型
temp_vec
}
assert_eq!(array.len(), 0); // 此处已经开始使用数组,但尚未明确类型,因而编译报错
}
对ivec!宏增加一个使用限制为:当不传递参数时,默认初始化一个i32数据类型的数组。
修改ivec!增加一个match分支,匹配空参数,执行初始化一个i32 数组并返回。代码如下:
#[macro_export]
macro_rules! ivec {
() => {
{
let temp_vec:Vec<i32> = Vec::new();
temp_vec
}
};
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
再运行测试则可以顺利通过!
注意:我们定义一个空分支的处理方式只是为了演示多模式匹配,而不是vec!宏真实的需求(实际上宏参数为空时,就应该返回一个未绑定数据类型的空数组,让后续使用时进行数据绑定)。
上述代码中,我们对宏增加了一个match分支,以对宏参数为空时进行处理;在实际实现中,可能会有更多场景需要细化考虑,因此可能有更多的分支。以下是标准库中vec!宏实现
该宏实现了三中模式匹配:
1) vec![]:创建一个空的数组(未绑定数据类型)。
2) vec![$elem; $n]:创建一个包含 $n 个相同元素 $elem 的数组。
3) vec![$x, $y, $z, ...]:创建一个包含给定元素的数组。
标准库中的vec! 实现要比ivec!实现复杂,原因是它还处理空间预分配等事宜,并且对多个元素的初始化采用了指针数组的分配方式,提高效率。
该宏的进一步理解,就留给大家作为课后作业啦!
模式解析
回过头来,我们再来捋一捋 ivec!关键模式匹配 ( $( $x:expr ),* )
的含义。
- 首先,
(...)=>
表示一组模式匹配分支,其写法是固定的。 - 一个匹配分支内,
$()
描述一个特定的模式(pattern);与该pattern相匹配的宏参数,会被捕获,然后用于代码扩展或替换;在这里$($x:expr)
会匹配任何Rust有效表达式,并捕获该内容记录到$x
中。其中,$x
称之为元变量,expr
是该元变量的类型(expr
为表达式,block
为块,ty
为数据类型等等,后续会单独说明)。 - 紧跟
$( $x:expr )
之后的 "," 是模式重复分割标识,表示重复的pattern之间用“,”
进行分割。 - 在模式重复标识后面,是重复次数的正则规则;
*
代表可能重复0次或多次,+
代表可能重复1次或多次,?
代表可能重复0次或1次(实际上是不会重复的,因此?
前面不能出现重复分割标识)。
当我们使用 ivec![1, 2, 3]
来调用该宏时,$x
模式将被匹配三次,分别是 1
、2
、3
。为了帮助大家巩固,我们再来手动展开一次:
$()
中包含的是模式$x:expr
,该模式中的expr
表示会匹配任何 Rust 表达式,并给予该模式一个名称$x
- 因此
$x
模式可以跟整数1
进行匹配,也可以跟字符串 "hello" 进行匹配:ivec!["hello", "world"]
$()
之后的逗号,意味着1
和2
之间可以使用逗号进行分割,也意味着3
既可以没有逗号,也可以有逗号:vec![1, 2, 3,]
*
说明之前的模式可以出现零次也可以任意次,这里出现了三次。
接下来,我们再来看看与模式相关联、在 =>
之后的代码:
{
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
$(...)*
描述了每次模式匹配命中后的行为:在源码中添加代码
temp_vec.push($x)
,其中$x 替换为本次模式匹配后捕获到的入参内容。
ivec![1,2,3];
展开为
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
};
至此,我们定义了一个宏,它可以接受任意类型和数量的参数,并且理解了其语法的含义。
元变量类型
block
block
类型符只匹配 block 表达式。
块 (block) 由 {
开始,接着是一些语句,最后是可选的表达式(返回值表达式),然后以 }
结束。 块的类型要么是最后的值表达式类型,要么是 ()
类型。
ident
ident
分类符用于匹配任何形式的标识符 (identifier) 或者关键字。
示例:
macro_rules! define_fn {
($func_name:ident, $block:block) => {
fn $func_name(s: &str) -> String {
let mut out = String::from(s);
let inner = $block;
out.push_str(inner.into());
out
}
};
}
define_fn!(comand_appender, { "command-appender" });
#[test]
fn test_dynamic_fn_command_appender() {
let x =comand_appender("hello");
assert_eq!(x, "hellocommand-appender");
}
define_fn! 宏可以动态定义函数。示例中,定义了一个comand_appender函数,函数名和部分函数实现是由宏实现的;其中,func_name就是ident 类型,而block是 block类型。用cargo expand 展开该函数为:
item
item
分类符只匹配 Rust 的 item 的 定义 (definitions) , 不会匹配指向 item 的标识符 (identifiers)。
例子:
macro_rules! items {
($($item:item)*) => ();
}
items! {
struct Foo;
enum Bar {
Baz
}
impl Foo {}
}
item是在编译时完全确定的,通常在程序执行期间保持固定,并且可以驻留在只读存储器中。具体指:
modules
extern crate
declarationsuse
declarationsfunction definitions
type definitions
struct definitions
enumeration definitions
union definitions
constant items
static items
trait definitions
implementations
extern
blocks
ty
ty
类型用于匹配任何形式的类型表达式 (type expression)。类型表达式是在 Rust 中指代类型的语法。
示例:
macro_rules! types {
($($type:ty)*) => ();
}
types! {
foo::bar
bool
[u8]
impl IntoIterator<Item = u32>
}
path
path
类型符用于匹配类型中的路径 (TypePath)。这包括函数式的 trait 形式。
示例:
macro_rules! paths {
($($path:path)*) => ();
}
paths! {
ASimplePath
::A::B::C::D
G::<eneri>::C
FnMut(u32) -> ()
}
lifetime
lifetime
类型符用于匹配生命周期注解或者标签 (lifetime or label)。 它与 ident
很像,但是 lifetime
会匹配到前缀 "'" 。
示例:
macro_rules! lifetimes {
($($lifetime:lifetime)*) => ();
}
lifetimes! {
'static
'shiv
'_
}
others
除了上面几种较为常见的元类型,还有一些类型如pat,meta,literal,stmt,tt等,感兴趣的可以阅读 《Rust 宏小册》获取更多信息。
实战
在开发中遇到一个需求:
有一个命令调度器被实现为结构体 engine,engine中有一个Command 的指针数组,记录着所有注册的指令(每个指令需要实现Command 的接口)。在初始化engine时,需要将builtin的所有指令注册到engine中(约150条)。
其中, 指令注册的关键代码为(示例注册Ls指令,Ls为结构体,实现了Command):
engine.add_decl(Box::new(Ls));
我们设计一个宏,register_commands!,支持多个参数,其中第一个参数固定为engine的实例,之后都是各种指令的机构体类型,当我们调用该宏时,就可以实现向engine实例中注册所罗列的指令:
register_commands! (engine, Ls, Cd, Echo, Evl, Ps, Find, Grep...);
分析:
(1) 核心模式匹配中,一个需要匹配两类参数:一个事engine实例,一个是指令类型;其中engine示例必须有一条,而指令类型可能有多个;
(2) 边界问题:如果指令类型为0个,则什么事情也不做,也符合预期。
因此,我们给出如下宏实现:
macro_rules! register_commands {
($target:expr, $($cmd_type:expr),*) => {
{
$(
$target.add_decl(Box::new($cmd_type));
)*
}
};
}
$target为表达式,直接匹配,后面跟一个 ","
。
$cmd_type 为表达式,可以重复,重复分隔符为","
; 可以重复0次或多次。
对于重复的匹配,每次匹配都执行一次 $target.add_decl(Box::new($cmd_type));
的代码替换和扩展。
实际使用:
小结
要深入理解Rust宏,还需要掌握一些Rust编译的基础知识:包括标记(token)解析,语法解析,AST抽象语法树,标记树(token tree)等等。而抽象语法树和标记树在后续过程宏中将会接触到。本文尽量避开这些比较底层的知识,给大家简述了一下rust的 声明宏,希望能够对Rust的宏编程(元编程)有个初步认识;之后大家再逐步深入掌握吧......
本篇就此“杀割”,下一篇我们将继续探讨Rust的过程宏。
下回分解了老铁....