【Rust自学】19.4. 宏(macro)
必须要说,这篇文章的内容你一定不可能一次性完全消化,除非你是身经百战的程序员。当然也没多大的必要完全掌握,实战中真的很少遇到要必须写宏才能解决的问题。宏的概念非常复杂,不可能是一篇文章讲得完的,这里只做了一个广却不精的介绍。如果真要系统性了解必须得依靠一个专栏的文章量。如果你真想深入了解,不妨看 The Little Book of Rust Macros。
喜欢的话别忘了点赞、收藏加关注哦(加关注即可阅读全文),对接下来的教程有兴趣的可以关注专栏。谢谢喵!(=・ω・=)
19.4.1. 什么是宏
宏(macro)在Rust里指的是一组相关特性的集合称谓:
- 使用
macro_rules!
构建的声明宏(declarative macro) - 3种过程宏:
- 自定义派生宏,用于
struct
或enum
,可以为其指定随derive
属性添加的代码 - 类似属性的宏,在任何条目上添加自定义属性
- 类似函数的宏,看起来像函数调用,对其指定为参数的token进行操作
- 自定义派生宏,用于
19.4.2. 函数与宏的差别
- 从本质上来讲,宏是用来编写可以生成其它代码的代码,也就是所谓的元编程(metaprogramming)。
- 函数在定义签名时,必须声明参数的个数和类型;宏可以处理可变的参数。
- 编译器会在解释代码前展开宏
- 宏的定义比函数复杂很多,难以阅读、理解和维护
- 在某个文件调用宏时,必须提前定义宏或将宏引入当前作用域;函数可以在任何位置定义并在任何位置使用。
19.4.3. macro_rules!
声明宏
声明宏有时候叫做宏模版,有时候叫做macro rules宏,有时候就叫做宏。
它是Rust里最常见的宏的形式,有点类似于match
表达式的模式匹配,在定义声明宏时我们会用到macro_rules
。
看个例子:
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
这是vec!
(用于创建Vector
)这个宏的简化定义版本,我们一行一行地看:
#[macro_export]
这个标注意味着这个宏会在它所处的包被引入作用域后才可以使用,缺少了这个标注的宏就不能被引入作用域macro_rules!
是声明宏的关键字,这个宏的名称叫vec
,后边的{}
内的东西就是宏的定义体。- 定义体里的东西有点类似于
match
的模式匹配,有点像match
的分支,而这里实际上只有一个分支。虽然我们一直说定义体里的东西有点类似于match
的模式匹配,但它和match
有本质区别:match
匹配的是模式,而它匹配的是Rust的代码结构。 ( $( $x:expr ),* )
是它的模式,后面是代码。由于这里只有一个模式,所以任何其它的模式都会导致编译时的错误。某些比较复杂的宏就可能包含多个分支。
首先,我们使用一组括号来包含整个模式。我们使用美元符号 ($
) 在宏系统中声明一个变量,该变量将包含与模式匹配的Rust代码。美元符号清楚地表明这是一个宏变量,而不是常规的Rust变量。接下来是一组括号,它们捕获与括号内的模式匹配的值,以便在替换代码中使用。$()
中是$x:expr
,它匹配任何 Rust 表达式,并为表达式指定名称$x
。*
意味着这个模式能够匹配0个或是多个*
之前的东西。
假入我们写let v: Vec<u32> = vec![1, 2, 3]
,那么$x
就会分别匹配到1、2和3上。
现在让我们看看与该手臂相关的代码主体中的模式:$()*
中的temp_vec.push()
是为每个匹配$()
部分生成的 在模式中出现零次或多次,具体取决于模式的次数 匹配。$x
被替换为每个匹配的表达式。当我们用vec![1, 2, 3];
调用这个宏时,生成的替换该宏调用的代码如下:
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
要了解有关如何编写宏的更多信息,可以看由Daniel Keep撰写并由Lukas Wirth继续编写的“The Little Book of Rust Macros” 。
大多数程序员只是用宏而不会去编写宏,所以这部分就不深入研究了。
19.4.4. 基于属性来生成代码的过程宏
宏的第二种形式是过程宏,它的作用更像是一个函数(或者叫某种形式的过程)。过程宏接受一些代码作为输入,对该代码进行操作,并生成一些代码作为输出,而不是像声明性宏那样匹配模式并用其他代码替换代码。
一共有三种过程宏:
- 自定义派生宏
- 类属性宏
- 类函数宏
创建过程宏时,定义必须单独放在其自己的包中,并且使用特殊的包类型。这是出于复杂的技术原因。Rust也在致力于消除这个要求,但起码目前还没做到。
看个例子:
use proc_macro;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
some_attribute
是一个用来指定过程宏类型的占位符- 下面定义了过程宏的函数,接收一个
TokenStream
的值作为参数,产生一个TokenStream
的值作为输出。
TokenStream
是在pro_macro
包中定义的,它表示一段标记序列,而这也是过程宏的核心所在:需要被宏处理的源代码就组成了输入的TokenStream
,而宏生成的代码则组成了输出的TokenStream
。
函数附带的属性决定了我们究竟创建的是哪一种过程宏,同一个包装可以拥有多种不同类型的过程宏。
自定义派生(derive)宏
我们通过一个例子来看:
创建一个名为hello_macro
的包,定义一个拥有关联函数hello_macro
的HelloMacro
trait。我们要提供一个能自动实现trait的过程,使得用户在类型上标注#[derive(HelloMacro)]
就能得到hello_macro
的默认实现
首先我们需要创建一个新的工作空间(workspace),其它的项目都在工作空间之下。创建并打开Cargo.toml
:
touch Cargo.toml
在里面这么写:
[workspace]
members = [
"hello_macro",
"hello_macro_derive",
"pancakes",
]
首先创建库crate,输入指令(注意指令应该执行在工作空间的路径下):
cargo new hellow_macro --lib
在hello_macro
的lib.rs
里写:
pub trait HelloMacro {
fn hello_macro();
}
这么写我们就得到了一个hello_macro
trait和hello_macro
方法(但是没有具体实现)。
然后我们就可以在main.rs
实现这个trait并为方法写上具体实现:
use hello_macro::HelloMacro;
struct Pancakes;
impl HelloMacro for Pancakes {
fn hello_macro() {
println!("Hello, Macro! My name is Pancakes!");
}
}
fn main() {
Pancakes::hello_macro();
}
这么写没问题,但是有缺点:用户希望很多类型都能使用到hello_macro
功能,所以他们就必须为每一个希望使用到hello_macro
功能的函数编写出类似的代码,这就非常的繁琐。
所以我们就会想使用过程宏来生成相关的代码。而且在这里面打印的话需要把类型名打印进去,它是可变的。比如说类型是Pancakes
就打印"Hello, Macro! My name is Pancakes!“,如果是Apple
就打印"Hello, Macro! My name is Apple!”。由于Rust没有反射,所以只能使用宏。
过程宏需要自己的库,所以在工作空间的目录下要再创建一个库crate,输入指令:
cargo new hellow_macro_derive --lib
hellow_macro_derive
就是过程宏所在的crate。hellow_macro
的宏写在hellow_macro_derive
里是命名的惯例。
在这个crate的Cargo.toml
里添加(不要覆盖原本的内容!!!)这部分内容:
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
会用到syn
和quote
这两个包,所以把它们添加为依赖。
然后看一下这个crate的lib.rs
怎么写:
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_hello_macro(&ast)
}
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}
- 通过
pro_macro
提供的编译器接口从而在代码中读取和执行Rust代码。由于它被内置在Rust里,所以不需要把它添加为依赖项。 syn
包是用来把Rust代码从字符转化为可供我们进一步操作的数据结构quote
包将syn
产生的数据结构重新转化为Rust代码
这三个包使得解析Rust代码变得相当轻松。得知道,要编写一个完整的Rust代码解析器可不是一件简单的事。
简单地讲一下这里的逻辑:
- 函数
hello_macro_derive
负责解析TokenStream
impl_hello_macro
负责转换语法树(ast)
hello_macro_derive
的代码在每一个过程宏的创建中都是大差不差的,不同的就是里面的impl_hello_macro
。它实现的效果是用户在某个类型标注#[derive(HelloMacro)]
的时候,下边的hello_macro_derive
函数就会被自动地调用。
能够实现自动调用的原因是我们在定义宏时使用了#[proc_macro_derive(HelloMacro)]
,而且属性我们指明了是HelloMacro
trait。
这个函数首先会把输入的TokenStream
转化为一个可供我们解释和操作的数据结构,通过syn::parse
函数把TokenStream
作为输入,输出DeriveInput
结构体,表示解析后的Rust代码。以上文的Pancakes
类型为例,产生的输出应该是这样的:
DeriveInput {
// ...
ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}
其ident
(标识符,意思是名称)为Pancakes
。其余的不细讲,详见DeriveInput官方文档。
impl_hello_macro
是最后生成Rust代码的地方,返回TokenStream
类型的数据。
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}
我们使用ast.ident
获得一个Ident
结构实例,其中包含带注释的类型的名称(标识符)。以Pancakes
类型为例,当我们对清单中的代码运行impl_hello_macro
函数时, 我们得到的ident
将具有值为"Pancakes"
的ident
字段。因此,name
变量将包含一个Ident
结构体实例,打印时该实例将是字符串"Pancakes"
。
quote!
宏让我们定义要返回的Rust代码。由于quote!
的执行结果不能被编译器所理解,因此我们需要将其转换为TokenStream
。我们通过调用into
方法来完成此操作,该方法使用此中间表示并返回所需的TokenStream
类型的值。
quote!
宏还提供了一些模板机制:我们可以输入#name
,然后quote!
将其替换为变量中的值 name
。您甚至可以像常规宏的工作方式一样进行一些重复。详见quote官方文档。
stringify!
宏内置于Rust中。它会接收Rust表达式,例如1 + 2
,但并不会计算结果,1 + 2
会被直接转化为字符串"1 + 2"
并输出。这与format!
或者 println!
有点区别——它们计算表达式,然后将结果转换为String
。 #name
输入有可能是一个按字面值打印的表达式,所以我们使用stringify!
。使用stringify!
还通过在编译时将#name
转换为字符串文字来保存分配。
写完这些以后来编译这两个包(使用cargo build 包名
即可,注意路径哦,否则会找不到crate),然后创建一个二进制crate(一样在工作空间的目录下):
cargo new pancakes
在pancakes
这个crate的Cargo.toml
里添加(不要覆盖其他内容!!!)这些:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
添加上hello_macro
和hello_macro_derive
这两个依赖项。
在pancakes
这个crate的main.rs
里这么写:
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}
这么写就大功告成了,运行一下看看:
Hello, Macro! My name is Pancakes!
类似属性的宏
类似属性的宏又叫做属性宏。类属性宏与自定义派生宏类似,但它们不是为derive
属性生成代码,而是允许你创建新属性。它们也更灵活: derive
仅适用于结构和枚举;属性也可以应用于其他项目,例如函数。
下面是使用类似属性的宏的示例:
有一个名为route
属性(表示路由),该属性在使用 Web 应用程序框架时注释函数。
#[route(GET, "/")]
fn index() {
这个代码只是一部分,并不完整。这部分代码表示如果路径是/
,方法是Get
的话就会执行index
这个函数。而route
这个属性就是由过程宏定义的,这个宏定义的函数签名就是:
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
有两个TokenStream
作为它的参数,attr
参数对应(GET, "/")
,item
对应函数体,也就是index
函数。
除此之外,属性宏和派生宏的工作方式几乎一样,都需要建立一个pro_macro
的包并提供生成相应代码的函数。
类似函数的宏
类似函数的宏又叫做函数宏。它的定义看起来像函数调用的宏。类似于macro_rules!
宏,但比函数更灵活;例如,它们可以接受未知数量的参数。
函数宏可以接收TokenStream
作为参数,并且它与另外两种过程宏一样,在定义中使用Rust代码来操作TokenStream
。
看例子:
let sql = sql!(SELECT * FROM posts WHERE id=1);
这个代码只是一部分,并不完整。我们想要定义一个能解析sql
语句的宏,具体来说就是解析SELECT * FROM posts WHERE id=1
,这个宏的定义就可以是:
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
它的签名和派生宏也是比较类似的,接收一个TokenStream
,返回一个相应功能的TokenStream
。