Rust 使用宏与元编程
程序都是由数据和指令(代码)构成的。通常对于程序来讲,指令(代码)是不变的。然而,如果可以使用指令(代码)生成指令(代码),程序就可以获得更加高度的灵活性,而这就是 元编程。
元编程通常实现的方式有两种,根据代码生成的时机可以划分为「编译期」或者「运行期」。对于动态语言来讲,例如:Python、Lisp 或者 JavaScript,可以在运行期实现元编程。编译型的静态语言,例如:C/C++、Rust,是没有办法实现在运行时生成指令的,因此只有「编译期」的元编程。
最简单元编程是 C 语言的 #define
,它会在预处理器期间实现文本替换功能。在 C++ 中,可以通过「模板」实现更为高级的元编程。
对于 C 语言的
#define
,它并不是一个“卫生”的宏。预编译期间展开文本后,可能会破坏上下文的代码环境,从而产生一些意想不到的问题。“卫生”宏则指的是对于上下文没有潜在的破坏,没有副作用。
Rust 宏的使用场景
实际应用场景中,也会使用到宏,例如:
- 创建领域特定语言(Domain-Specific Language,DSL)来扩充语法
- 编写编译期序列化的功能(例如:
serde
包) - 将运行时的计算移到编译期,从而提高执行效率(现代编译器大多数情况下会优化计算,这个显得相对不是特别重要)
- 零成本记录日志
- 编写模板测试代码或者测试用例
需要注意,应该谨慎地使用宏,它们会使代码难以维护和理解。这是因为它们是在元级别工作,所以没有多少开发人员会习惯使用它们。使用太多的宏会使代码更难理解,从可维护性的角度来看,可读性的优先级始终应该排在前面。
此外,大量使用宏会导致产生大量重复的代码,这会影响 CPU 指令缓存,从而降低性能。
宏的类型
-
声明宏:宏的最简单形式。它们是使用
macro_rules!
宏创建的,其本身就是一个宏。可以提供与函数类似的功能,但是很容易通过名称末尾的!
来进行区分。 -
过程宏:宏的一种更高级形式,可以完全控制代码的操作和生成。缺点是实现起来很复杂,需要对编译器的内部机制,以及程序如何在编译器的内存中表示有一些了解。
使用 macro_rules! 创建宏
使用 macro_rules!
创建一个简单的宏,用于将输入的字符串读入缓冲区:
1 | use std::io; |
声明宏非常类似 match
表达式,也是一个模式匹配的过程。
($x:expr)
中 $x
是一个标记树的变量,右侧的部分是一个规则,expr
是标记树类型之一,表示只能接受表达式。
宏可以有多个匹配规则,可以添加一个空规则:
1 | use std::io; |
标记类型
类型 | 解释 | 示例 |
---|---|---|
expr |
匹配任意表达式 | 1 、x + 1 、if x == 4 { 1 } else { 2 } |
ident |
匹配标识符。不是关键字(比如:if 和 let )的 Unicode 字符串 |
x long_identifier SomeSortOfAStructType |
item |
匹配元素,模块级的内容可以被当作元素,包括函数、use 声明及类型定义 |
use std::io fn main() { println!("hello") } const X:usize = 8; |
meta |
元项 | #[foo] 的元项 foo #[foo(bar)] 的元项 foo(bar) |
pat |
模式,每个 match 表达式中左侧都是模式,由 pat 捕获 |
1 、"x" 、t 、Some(t) 、1...3 、 _ |
path |
限定名称,与标识符非常类似,只是允许在名称中使用双冒号 | foo 、foo::bar |
stmt |
语句,和表达式类似 | let x = 1 (expr 不会接收这个语句) |
tt |
标记树,它由一系列其他标记构成 | {bar; if x == 2 (3) ulse} 4 {;baz} (不一定具有语义的内容,只需要是一系列标记即可) |
ty |
Rust 类型 | u32 、u33 (宏展开阶段并不需要进行语义检查,但是进入语义分析阶段,会报错)、String |
vis |
可见性修饰符 | pub 、pub(crate) |
lifetime |
生命周期 | 'a 、'ctx 、'foo |
literal |
任何标记的文字 | 字符串文字(例如"foo" )或标识符(例如 bar ) |
重复
vec!
宏可以支持可变的参数,例如你可以使用 vec![1, 2, 3]
,可以查看一下标准库中 vec!
的定义:
1 | macro_rules! vec { |
先忽略 =>
后面的细节,重点放在匹配规则上:
1 | ($elem:expr; $n:expr) |
和正则表达式类似,重复可以有以下三种形式:
*
表示 0 次或者多次+
表示至少 1 次或者多次?
表示最多可以重复 1 次(0 或者 1)
第三个匹配规则中,vec!
宏只是将它转成 Box
类型,可以看到,右边并没有 expr
,只有 $x
。这就意味着可以宏里再调用宏。
示例:为 HashMap 初始化创建 DSL
我们可以使用宏生成一个语法糖,对于 HashMap,使用的时候通常是这样的:
1 | let mut contacts = HashMap::new(); |
如果希望实现类似下面的这样的语法,实现一个更加简洁、直观的插入操作:
1 | let mut contacts = map! { |
可以定义下面这样的宏:
1 | macro_rules! map { |
过程宏
对于复杂问题,如果需要完全控制代码的生成,你可以使用过程宏。按照调用方式,可以分为以下几类:
- 类函数过程宏:函数上使用
#[proc_macro]
属性 - 类属性过程宏:函数上使用
#[proc_macro_attribute]
属性 - 派生过程宏:使用
#[proc_macro_derive]
属性。- Rust 中最常见的宏之一,比如
serde
- Rust 中最常见的宏之一,比如
类属性宏
新建一个 lib 的项目 macro_demo,在 Cargo.toml
中指明要创建过程宏:
1 | [lib] |
过程宏传入是 TokenStream
,并返回一个 TokenStream
。为写一个过程宏,我们需要写一个解析器来解析 TokenStram
。Rust 中的 syn
是个解析 TokenStream
很不错的依赖,提供了现成的解析器。
添加 syn
和 quote
到 Cargo.toml
:
1 | [dependencies] |
在 lib.rs
中添加代码:
1 | extern crate proc_macro; |
可以新建一个测试文件进行测试:
1 | use macro_demo::*; |
派生宏
1 |
|
可以使用 DeriveInput
来解析输入到派生宏。
类函数宏
类函数宏类似于声明宏,但它比声明宏更加强大。类函数宏不是在运行时执行,而是在编译时执行。
1 |
|
小结
- 声明宏在 AST 层面上工作,意味着它无法任意扩展;
- 对于更加复杂的用例,可以使用过程宏来完全控制输入,并生成所需的代码;
- 宏应该是 Rust 其他抽象机制(例如:函数、trait、泛型)无法解决问题的时候,才考虑使用宏;