程序都是由数据和指令(代码)构成的。通常对于程序来讲,指令(代码)是不变的。然而,如果可以使用指令(代码)生成指令(代码),程序就可以获得更加高度的灵活性,而这就是 元编程。
元编程通常实现的方式有两种,根据代码生成的时机可以划分为「编译期」或者「运行期」。对于动态语言来讲,例如:Python、Lisp 或者 JavaScript,可以在运行期实现元编程。编译型的静态语言,例如:C/C++、Rust,是没有办法实现在运行时生成指令的,因此只有「编译期」的元编程。
最简单元编程是 C 语言的 #define
,它会在预处理器期间实现文本替换功能。在 C++ 中,可以通过「模板」实现更为高级的元编程。
对于 C 语言的
#define
,它并不是一个“卫生”的宏。预编译期间展开文本后,可能会破坏上下文的代码环境,从而产生一些意想不到的问题。“卫生”宏则指的是对于上下文没有潜在的破坏,没有副作用。
Rust 宏的使用场景
实际应用场景中,也会使用到宏,例如:
- 领域特定语言(DSL):如
regex!
宏编译期验证正则表达式 - 零成本抽象:
lazy_static!
实现安全的惰性初始化 - 编译期计算:
const_format!
在编译时生成格式化的字符串 - 元数据处理:
serde
的序列化/反序列化实现 - 测试工具增强:
tokio::test
宏简化异步测试
需要注意,应该谨慎地使用宏,它们会使代码难以维护和理解。这是因为它们是在元级别工作,所以没有多少开发人员会习惯使用它们。使用太多的宏会使代码更难理解,从可维护性的角度来看,可读性的优先级始终应该排在前面。
此外,大量使用宏会导致产生大量重复的代码,这会影响 CPU 指令缓存,从而降低性能。
宏的类型
-
声明宏:宏的最简单形式。它们是使用
macro_rules!
宏创建的,其本身就是一个宏。可以提供与函数类似的功能,但是很容易通过名称末尾的!
来进行区分。 -
过程宏:宏的一种更高级形式,可以完全控制代码的操作和生成。缺点是实现起来很复杂,需要对编译器的内部机制,以及程序如何在编译器的内存中表示有一些了解。
使用 macro_rules! 创建宏
使用 macro_rules!
创建一个简单的宏,用于将输入的字符串读入缓冲区:
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
是标记树类型之一,表示只能接受表达式。
宏可以有多个匹配规则,可以添加一个空规则:
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 | 匹配任意表达式 | 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!
的定义:
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),+]))
);
}
先忽略 =>
后面的细节,重点放在匹配规则上:
($elem:expr; $n:expr)
($($x:expr),+ $(,)?)
和正则表达式类似,重复可以有以下三种形式:
*
表示 0 次或者多次+
表示至少 1 次或者多次?
表示最多可以重复 1 次(0 或者 1)
第三个匹配规则中,vec!
宏只是将它转成 Box
类型,可以看到,右边并没有 expr
,只有 $x
。这就意味着可以宏里再调用宏。
示例:为 HashMap 初始化创建 DSL
我们可以使用宏生成一个语法糖,对于 HashMap,使用的时候通常是这样的:
let mut contacts = HashMap::new();
contacts.insert("GuYu", "123456");
contacts.insert("George", "774321");
如果希望实现类似下面的这样的语法,实现一个更加简洁、直观的插入操作:
let mut contacts = map! {
"GuYu" => "123456",
"George" => "774321"
};
可以定义下面这样的宏:
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
- Rust 中最常见的宏之一,比如
类属性宏
新建一个 lib 的项目 macro_demo,在 Cargo.toml
中指明要创建过程宏:
[lib]
proc-macro = true
过程宏传入是 TokenStream
,并返回一个 TokenStream
。为写一个过程宏,我们需要写一个解析器来解析 TokenStram
。Rust 中的 syn
是个解析 TokenStream
很不错的依赖,提供了现成的解析器。
添加 syn
和 quote
到 Cargo.toml
:
[dependencies]
syn = {version ="1.0.98", features = ["full", "fold"]}
quote = "1.0.20"
在 lib.rs
中添加代码:
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{}})
}
可以新建一个测试文件进行测试:
use macro_demo::*;
#[my_custom_attribute]
struct S {}
#[test]
fn test_macro() {
let _demo = H {};
}
派生宏
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput}; // 添加必要的导入
#[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
来解析输入到派生宏。
类函数宏
类函数宏类似于声明宏,但它比声明宏更加强大。类函数宏不是在运行时执行,而是在编译时执行。
#[proc_macro]
pub fn a_proc_macro(_input: TokenStream) -> TokenStream {
TokenStream::from(quote!(
fn answer() -> i32 {
5
}
))
}
小结
- 声明宏在 AST 层面上工作,意味着它无法任意扩展;
- 对于更加复杂的用例,可以使用过程宏来完全控制输入,并生成所需的代码;
- 宏应该是 Rust 其他抽象机制(例如:函数、trait、泛型)无法解决问题的时候,才考虑使用宏;