程序都是由数据和指令(代码)构成的。通常对于程序来讲,指令(代码)是不变的。然而,如果可以使用指令(代码)生成指令(代码),程序就可以获得更加高度的灵活性,而这就是 元编程

元编程通常实现的方式有两种,根据代码生成的时机可以划分为「编译期」或者「运行期」。对于动态语言来讲,例如: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
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::io;

macro_rules! scanline {
($x:expr) => {
io::stdin().read_line(&mut $x).unwrap();
};
}

fn main() {
let mut input = String::new();
scanline!(input);
println!("I read: {:?}", input);
}

声明宏非常类似 match 表达式,也是一个模式匹配的过程。

($x:expr)$x 是一个标记树的变量,右侧的部分是一个规则,expr 是标记树类型之一,表示只能接受表达式。

宏可以有多个匹配规则,可以添加一个空规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::io;

macro_rules! scanline {
($x:expr) => {
io::stdin().read_line(&mut $x).unwrap();
};
() => {{
let mut s = String::new();
io::stdin().read_line(&mut s).unwrap();
s
}};
}

fn main() {
let mut input = String::new();
scanline!(input);
println!("I read: {:?}", input);

let a = scanline!();
println!("I read: {:?}", a);
}

标记类型

类型 解释 示例
expr 匹配任意表达式 1x + 1if x == 4 { 1 } else { 2 }
ident 匹配标识符。不是关键字(比如:iflet)的 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"tSome(t)1...3_
path 限定名称,与标识符非常类似,只是允许在名称中使用双冒号 foofoo::bar
stmt 语句,和表达式类似 let x = 1expr 不会接收这个语句)
tt 标记树,它由一系列其他标记构成 {bar; if x == 2 (3) ulse} 4 {;baz}(不一定具有语义的内容,只需要是一系列标记即可)
ty Rust 类型 u32u33(宏展开阶段并不需要进行语义检查,但是进入语义分析阶段,会报错)、String
vis 可见性修饰符 pubpub(crate)
lifetime 生命周期 'a'ctx'foo
literal 任何标记的文字 字符串文字(例如"foo")或标识符(例如 bar

重复

vec! 宏可以支持可变的参数,例如你可以使用 vec![1, 2, 3],可以查看一下标准库中 vec! 的定义:

1
2
3
4
5
6
7
8
9
10
11
macro_rules! vec {
() => (
$crate::__rust_force_expr!($crate::vec::Vec::new())
);
($elem:expr; $n:expr) => (
$crate::__rust_force_expr!($crate::vec::from_elem($elem, $n))
);
($($x:expr),+ $(,)?) => (
$crate::__rust_force_expr!(<[_]>::into_vec(box [$($x),+]))
);
}

先忽略 => 后面的细节,重点放在匹配规则上:

1
2
($elem:expr; $n:expr)
($($x:expr),+ $(,)?)

和正则表达式类似,重复可以有以下三种形式:

  • * 表示 0 次或者多次
  • + 表示至少 1 次或者多次
  • ? 表示最多可以重复 1 次(0 或者 1)

第三个匹配规则中,vec! 宏只是将它转成 Box 类型,可以看到,右边并没有 expr,只有 $x。这就意味着可以宏里再调用宏。

示例:为 HashMap 初始化创建 DSL

我们可以使用宏生成一个语法糖,对于 HashMap,使用的时候通常是这样的:

1
2
3
let mut contacts = HashMap::new();
contacts.insert("GuYu", "123456");
contacts.insert("George", "774321");

如果希望实现类似下面的这样的语法,实现一个更加简洁、直观的插入操作:

1
2
3
4
let mut contacts = map! {
"GuYu" => "123456",
"George" => "774321"
};

可以定义下面这样的宏:

1
2
3
4
5
6
7
8
9
10
11
macro_rules! map {
($($k:expr => $v:expr), *) => {
{
let mut map = ::std::collections::HashMap::new();
$(
map.insert($k, $v);
)*
map
}
};
}

过程宏

对于复杂问题,如果需要完全控制代码的生成,你可以使用过程宏。按照调用方式,可以分为以下几类:

  • 类函数过程宏:函数上使用 #[proc_macro] 属性
  • 类属性过程宏:函数上使用 #[proc_macro_attribute] 属性
  • 派生过程宏:使用 #[proc_macro_derive] 属性。
    • Rust 中最常见的宏之一,比如 serde

类属性宏

新建一个 lib 的项目 macro_demo,在 Cargo.toml 中指明要创建过程宏:

1
2
[lib]
proc-macro = true

过程宏传入是 TokenStream,并返回一个 TokenStream。为写一个过程宏,我们需要写一个解析器来解析 TokenStram。Rust 中的 syn 是个解析 TokenStream 很不错的依赖,提供了现成的解析器。

添加 synquoteCargo.toml

1
2
3
[dependencies]
syn = {version ="1.0.98", features = ["full", "fold"]}
quote = "1.0.20"

lib.rs 中添加代码:

1
2
3
4
5
6
7
8
9
extern crate proc_macro;

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_attribute]
pub fn my_custom_attribute(_metadata: TokenStream, _input: TokenStream) -> TokenStream {
TokenStream::from(quote! {struct H{}})
}

可以新建一个测试文件进行测试:

1
2
3
4
5
6
7
8
9
use macro_demo::*;

#[my_custom_attribute]
struct S {}

#[test]
fn test_macro() {
let _demo = H {};
}

派生宏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[proc_macro_derive(Trait)]
pub fn derive_trait(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);

let name = input.ident;

let expanded = quote! {
impl Trait for #name {
fn print(&self) -> usize {
println!("{}", "hello from #name")
}
}
};

TokenStream::from(expanded)
}

可以使用 DeriveInput 来解析输入到派生宏。

类函数宏

类函数宏类似于声明宏,但它比声明宏更加强大。类函数宏不是在运行时执行,而是在编译时执行。

1
2
3
4
5
6
7
8
#[proc_macro]
pub fn a_proc_macro(_input: TokenStream) -> TokenStream {
TokenStream::from(quote!(
fn answer() -> i32 {
5
}
))
}

小结

  • 声明宏在 AST 层面上工作,意味着它无法任意扩展;
  • 对于更加复杂的用例,可以使用过程宏来完全控制输入,并生成所需的代码;
  • 宏应该是 Rust 其他抽象机制(例如:函数、trait、泛型)无法解决问题的时候,才考虑使用宏;