随便讲讲 Rust 的 “宏”
最近在看《通过例子学 Rust》,真切感受到了 Rust 有多么难学设计有多么精巧。作为一门安全性和效率优先的编程语言,Rust 的语法个人感觉是半 C 半 Python,但结构上明显比二者都复杂。
在写第一个语句
1 |
|
的时候,我很好奇为什么 println
后面要加一个感叹号 !
。查阅后得知,这是一种 Rust 宏。
什么是宏?
在 Rust 中,宏(macros) 是一种元编程的工具,允许你在编译时对代码进行模式匹配、转换和生成。宏允许你编写一些特定的代码模板,在编译时根据这些模板生成具体的代码。这使得你可以编写更加灵活、通用和高效的代码。
其实说得简单点,这个宏和 C/C++ 中的宏定义是一个性质的,起到片段替换的一个作用。
Rust 中的宏有两种宏:声明式宏、过程宏。
声明式宏(declarative macros)
声明式宏也称为 macro_rules!
宏,它允许你定义模式和替换规则,用于在代码中执行简单的文本替换。声明式宏类似于 C 语言中的宏替换,但更加强大和类型安全。声明式宏可以使用模式匹配、重复、递归等功能来定义宏,并且可以在宏中执行一些基本的代码转换。
声明式宏,顾名思义,是可以由用户自己声明的,声明时也可以调用其他已经声明好的宏。
比如我声明一个 say_hello
宏,让它去固定输出 Hello, World!
:
1 |
|
然后我需要使用的时候,就这样子调用:
1 |
|
执行结果就是这样的:
1 |
|
对比:Rust 声明式宏 vs C 语言宏定义
模式匹配和重复
Rust 的声明式宏允许使用模式匹配和重复来定义更复杂的模板。你可以使用
match
、if
、for
等语法来对输入的token
流进行匹配和处理。1
2
3
4
5
6
7
8
9macro_rules! print_range {
($start:expr, $end:expr) => {
for i in $start..$end {
println!("{}", i);
}
};
}
print_range!(1, 5);我可以直接在声明宏里面使用一个 for 循环,编译器预处理时候也知道这是个 for 循环。其实宏做到这样子,我个人感觉已经很接近函数了。
C 语言的宏定义也支持简单的模式匹配和重复,但功能相对受限,只能进行简单的文本替换和展开。
1
2
3
4
5
6#define PRINT_RANGE(start, end) \
for (int i = start; i < end; i++) { \
printf("%d\n", i); \
}
PRINT_RANGE(1, 5);预处理的时候,编译器并不知道这个宏定义里面的语句是一个 for 循环。它只知道预处理的时候等价替换就是的了。
这样看来 Rust 的声明宏确实要更加灵活,我感觉已经可以成为半个函数了。
类型安全和错误检查:
Rust 的声明式宏在编译时可以进行类型检查和错误检查,可以避免一些常见的错误。
1
2
3
4
5
6
7
8
9
10macro_rules! divide {
($numerator:expr, 0) => {
panic!("division by zero");
};
($numerator:expr, $denominator:expr) => {
$numerator / $denominator
};
}
let result = divide!(10, 0);C 语言的宏定义没有类型检查和错误检查,需要自己去写,像这样一个除数为 0 的简单情况,如果不做处理,可能会导致一些潜在的错误。
1
2
3
4#define DIVIDE(numerator, denominator) \
(denominator == 0 ? (fprintf(stderr, "division by zero\n"), exit(EXIT_FAILURE), 0) : (numerator / denominator))
int result = DIVIDE(10, 0);其实这个问题还是比较像第一条的,对于一些小的代码片段,也许我们不会太关注这个问题,但是一旦项目比较大,类型检查和错误检查就比较重要了。
代码生成和抽象:
Rust 的声明式宏可以生成更复杂和灵活的代码,允许对输入进行更多的操作和转换。
1
2
3
4
5
6
7macro_rules! vec_of_strings {
($($elem:expr),*) => {
vec![$($elem.to_string()),*]
};
}
let v = vec_of_strings!["hello", "world"];C 语言的宏定义主要用于文本替换,功能相对较弱,难以实现复杂的代码生成和抽象。
1
2
3
4#define VEC_OF_STRINGS(elem1, elem2) \
{elem1, elem2}
const char* v[] = VEC_OF_STRINGS("hello", "world");
其实这几条说到底也就是, Rust 的声明宏不像 C 的宏替换那样就是个简单的文本替换,它的功能更加丰富。
过程宏(procedural macros)
过程宏通常以函数的形式定义,并接收一个或多个输入 token 流,然后根据这些输入生成新的代码。过程宏可以在代码的语法树级别进行操作,因此可以实现更高级的代码转换和分析。
过程宏分为三种类型:
属性宏 (Attribute Macros)
属性宏允许你在代码上方使用类似于注解的语法来应用宏,比如
1 |
|
就是一个常见的属性宏,用于自动为结构体或枚举类型实现 Debug
trait。
函数宏(Function-like Macros)
函数宏类似于声明式宏,但完全以函数的形式定义,可以更灵活地处理输入 token 流并生成代码。
自定义派生宏 (Custom Derive Macros)
自定义派生宏允许编写用于自动实现 trait 或其他行为的宏,使得你可以为类型自动生成通用的实现代码。
刚刚提到的 print!()
就是一种过程宏。其实我也很好奇,几乎所有语言中,打印标准输出是被设计为一个函数的,而 Rust 为什么要采用宏这个设计方式。这个下文再聊。
为什么标准输出被定义为一个宏?
我们先来看一下 Rust 源码中对 print!()
宏的定义:
1 |
|
至于为什么 print 是个宏,Stack Overflow 上也有人提出了这个问题。我的理解是:
避免移动语义问题
在 Rust 中,过程宏可以自动引用其参数,即使参数在调用宏的代码中已经被使用过,也不会产生移动语义的问题。这是因为过程宏在编译时操作代码的语法树,而不是在运行时处理值的拷贝和移动。
对于普通的函数或方法,参数的所有权传递是显式的,一旦参数被使用,它就会被移动并在后续代码中不可再用。
这实际上是一个 Rust 所有权的问题。在 Rust 中,当一个值被传递给一个函数或者移动到另一个变量时,它的所有权就会转移到接收它的函数或者变量上。这种所有权转移被称为移动(move)。一旦值的所有权被移动,原始变量将不能再继续使用它。
也就是说,每个变量对于函数来说,只能用一次。当你调用一个函数并将一个拥有所有权的值传递给它时,函数可能会消费这个值并且不能再继续使用它。这是为了确保内存安全和避免悬垂指针的问题而设计的。所有权的问题,以后我单写一篇文章讲。
我们以一个简单的日志记录为例,我们先用过程宏去实现:
1
2
3
4
5
6
7
8
9
10
11
12macro_rules! log {
($level:expr, $($arg:tt)*) => {
println!("{}: {}", $level, format_args!($($arg)*));
};
}
fn main() {
let message = "An error occurred".to_string();
log!("ERROR", "Error message: {}", message);
// 这里仍然可以继续使用 message
println!("Continuing to use message: {}", message);
}我们再用函数等价替换一下,看看会怎么样:
1
2
3
4
5
6
7
8
9
10fn log(level: &str, message: String) {
println!("{}: {}", level, message);
}
fn main() {
let message = "An error occurred".to_string();
log("ERROR", message.clone());
// 尝试在此处继续使用 message
println!("Continuing to use message: {}", message);
}因为
log
函数会获取message
的所有权,所以在函数调用之后,message
将被移动,导致后续的使用产生编译错误。而使用过程宏的情况下,由于参数的传递是基于宏的文本替换,并不会导致所有权的转移,因此后续对message
的使用不会产生问题。很显然,几乎所有的场景下,标准输出都是需要重复输出一些变量内容的,那么你也只能使用过程宏,而不能用一次性的函数。
接受任意数量的参数
过程宏可以接受任意数量的参数,这使得它们更加灵活和通用。你可以编写接受任意数量参数的宏来处理不同的场景。
而普通函数或方法的参数数量是固定的,若用函数实现,要么需要为每种情况都编写不同的函数或方法,要么想尽办法把各种情况转换成统一参数数量和格式的形式。
编译前就进行格式验证
过程宏可以在编译时验证格式字符串和参数的匹配,不匹配就不编译通过。C 语言中的
printf
函数在运行时才检查,运行的时候万一不匹配或者出现意料之外的结果,就很可能导致运行时出现 bug。
当然这些只是 Rust 宏的一个很基础的部分,还有很多东西需要学习。很多东西也只是我自己的一个理解,如果这篇文章有什么错误或者不足,欢迎友好指出。