Rust 学习笔记:宏
术语宏指的是 Rust 中的一系列特性,主要分为两大类:
- 声明性宏:通过 macro_rules! 定义的宏
- 过程宏,细分为三种不同的形式:
- 自定义的 #[derive] 宏:用于结构体或枚举上,自动为类型生成一些 derive 属性的代码
- 类属性宏:定义可用于任何项的自定义属性
- 类函数宏:看起来像函数调用,但实际操作的是指定为其参数的代码片段(token)
我们将依次讨论它们,但首先,让我们看看为什么在已经有函数的情况下还需要宏。
宏与函数的区别
从根本上说,宏是一种编写代码的代码,这被称为元编程。所有这些宏都会展开,生成比你手动编写的更多的代码。
元编程有助于减少必须编写和维护的代码量,这也是函数的作用之一。然而,宏具有一些函数所不具备的额外功能。
函数签名必须声明函数拥有的参数的数量和类型。但是,宏可以接受可变数量的形参。
此外,在编译器解释代码的含义之前,宏就被展开。因此,宏可以在给定类型上实现 trait。而函数不能,因为函数在运行时才被调用,trait 需要在编译时实现。
宏定义比函数定义更复杂,因为你正在编写“编写 Rust 代码”的 Rust 代码。由于这种间接性,宏定义通常比函数定义更难阅读、理解和维护。
宏和函数之间的另一个重要区别是,在调用宏之前,必须定义宏或将它们引入作用域,而不像函数那样可以在任何地方定义和调用。
使用声明性宏进行通用元编程
声明性宏的核心是允许你编写类似于 Rust 匹配表达式的东西。
匹配表达式是一种控制结构,它接受一个表达式,将表达式的结果值与模式进行比较,然后运行与匹配模式相关的代码。
宏还将值与与特定代码相关联的模式进行比较:在这种情况下,值是传递给宏的字面 Rust 源代码,将模式与源代码的结构进行比较,与每个模式相关联的代码在匹配时将替换传递给宏的代码。这一切都发生在编译期间。
要定义宏,可以使用 macro_rules! 构造。
以一个稍微简化的 vec! 宏的定义为例。注意,标准库中 vec! 宏的实际定义包括预先分配正确内存量的代码。为了使示例更简单,这里没有包括这些代码。
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
#[macro_export] 注释表明,当定义宏的 crate 进入作用域时,应该使这个宏可用。如果没有这个注释,就不能将宏引入作用域。
然后用 macro_rules! 开始宏定义。我们定义的宏的名称不带感叹号。该名称(在本例中为 vec)后跟花括号,表示宏定义的主体。
vec! 的主体类似于匹配表达式的结构。在这里,我们有一个带有模式 ( $( $x:expr ),* ) 的分支,后面跟着 => 和与该模式相关的代码块。如果模式匹配,将发出相关的代码块。鉴于这是该宏中唯一的模式,因此只有一种有效的匹配方式,任何其他模式都将导致错误。更复杂的宏将有多个分支。
宏定义中的有效模式语法与之前介绍的模式语法不同,因为宏模式匹配的是 Rust 代码结构而不是值。
首先,我们使用一组括号来包含整个模式。我们使用美元符号($)在宏系统中声明一个变量,该变量将包含与模式匹配的 Rust 代码。$ 清楚地表明,这是一个宏变量,而不是普通的 Rust 变量。接下来是一组圆括号,它捕获与圆括号内的模式匹配的值,以便在替换代码中使用。在 $() 中是 $x:expr,它匹配任何 Rust 表达式并给表达式命名为 $x。
$() 后面的逗号表示在与 $() 中的代码匹配的每个代码实例之间必须出现一个字面逗号分隔符。* 指定模式匹配 0 个或多个 * 前面的内容。
现在让我们来看看与该分支相关的代码体中的模式:$()* 中的 temp_vec.push() 是为模式中匹配 $() 0 次或更多次的每个部分生成的,具体取决于模式匹配的次数。$x 替换为匹配的每个表达式。
当我们用 vec![1, 2, 3];
,则 $x 模式与三个表达式 1、2 和 3 匹配三次,生成的替换宏调用的代码如下:
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
我们已经定义了一个宏,它可以接受任意数量的任意类型的实参,并且可以生成代码来创建包含指定元素的 vector。
要了解有关如何编写宏的更多信息,请参阅:The Little Book of Rust Macros。
过程性宏:从属性生成代码
过程性宏的行为更像函数(并且是一种过程),它接受一些代码作为输入,对这些代码进行操作,并生成一些代码作为输出,而不是像声明性宏那样根据模式进行匹配,并用其他代码替换代码。自定义派生、类属性和类函数这三种过程性宏都以类似的方式工作。
要定义过程性宏,我们必须将它们写在一个单独的 crate 中,而且这个 crate 还必须有特殊的 crate 类型。
在下面代码中,我们展示了如何定义过程性宏,其中 some_attribute 是使用特定宏变体的占位符。
use proc_macro;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
定义过程性宏的函数接受 TokenStream 作为输入,并产生 TokenStream 作为输出。TokenStream 类型由 Rust 中包含的 proc_macro crate 定义,它表示一系列 token。这是宏的核心:宏操作的源代码构成了输入 TokenStream,而宏产生的代码是输出 TokenStream。该函数还附加了一个属性,用于指定要创建的过程性宏的类型。在同一个 crate 中可以有多种过程性宏。
让我们来看看不同类型的过程性宏。我们将从一个自定义的派生宏开始,然后解释使其他形式不同的小差异。
如何编写自定义派生宏
让我们创建一个名为 hello_macro 的库 crate,它定义了一个名为 HelloMacro 的 trait 和一个名为 hello_macro 的关联函数。
我们将提供一个过程性宏,以便用户可以用 #[derive(HelloMacro)] 注释他们的类型,以获得 hello_macro 函数的默认实现,而不是让用户为他们的每个类型实现 HelloMacro trait。默认实现将打印 Hello, Macro! My name is TypeName!,其中 TypeName 是定义了该 trait 的类型的名称。换句话说,我们将编写一个 crate,使其他程序员能够使用我们的 crate 编写类似下面的代码。
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}
第一步是创建一个新的库 crate,如下所示:
cargo new hello_macro --lib
接下来,我们将定义 HelloMacro trait 及其相关函数:
pub trait HelloMacro {
fn hello_macro();
}
我们有一个 trait 和它的 hello_macro 函数。此时,我们的 crate 用户可以实现 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();
}
然而,crate 用户需要为他们想要使用 hello_macro 的每种类型编写实现块,我们希望他们不必做这些工作。
此外,我们还不能提供 hello_macro 函数的默认实现,它将打印 trait 实现的类型的名称。Rust 没有反射功能,所以它不能在运行时查找类型的名称。我们需要一个宏来在编译时生成代码。
下一步是定义过程性宏。过程性宏需要放在它们自己的 crate 中。构造 crate 和宏 crate 的约定如下:对于名为 foo 的 crate,自定义派生过程宏 crate 称为 foo_derived。让我们在 hello_macro 项目中创建一个名为 hello_macro_derived 的新的库 crate:
cargo new hello_macro_derive --lib
这两个 crate 是紧密相关的,因此我们在 hello_macro crate 的目录中创建过程性宏 crate。如果我们改变 hello_macro 中的 trait 定义,我们也必须改变 hello_macro_derived 中的过程性宏的实现。这两个 crate 需要分别发布,使用这两个 crate 的程序员需要将它们作为依赖项添加,并将它们都纳入作用域。
我们当然可以让 hello_macro crate 使用 hello_macro_derived 作为依赖项,然后重新导出过程宏代码。然而,我们构建项目的方式使得程序员即使不需要派生功能也可以使用 hello_macro 函数。
我们需要将 hello_macro_derived crate 声明为一个过程性宏 crate。我们还需要 syn 和 quote crate 的功能,因此我们需要将它们作为依赖项添加。因此,将以下内容添加到 hello_macro_derived crate 的 Cargo.toml 文件中:
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
要开始定义过程性宏,请将下列代码放入 hello_macro_derived crate 的 src/lib.rs 中。注意,在为 impl_hello_macro 函数添加一个定义之前,这段代码无法编译。
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)
}
注意,我们将代码分成了 hello_macro_derived 函数和 impl_hello_macro 函数,前者负责解析 TokenStream,后者负责转换语法树,这使得编写过程宏更加方便。
外部函数(在本例中为 hello_macro_derived)中的代码对于你看到或创建的几乎每个过程性宏 crate 都是相同的。在内部函数体中指定的代码(本例中为 impl_hello_macro)将根据过程性宏的目的而有所不同。
我们引入了三个新的 crate:proc_macro、syn 和 quote。
proc_macro crate 是 Rust 自带的,所以我们不需要将它添加到 Cargo.toml 中的依赖项中。该 crate 是编译器的 API,它允许我们从自己的代码中读取和操作 Rust 代码。
syn crate 将 Rust 代码从字符串解析为可以对其执行操作的数据结构。
quote crate 将 syn 数据结构转换回 Rust 代码。
这些 crate 使得解析我们想要处理的任何类型的 Rust 代码变得更加简单。为 Rust 代码编写一个完整的解析器并不是一件简单的任务。
当库的用户在类型上指定 #[derive(HelloMacro)] 时,将调用 hello_macro_derived 函数。这是可能的,因为我们在这里用 proc_macro_derived 注释了 hello_macro_derived 函数,并指定了名称 HelloMacro,它与我们的 trait 名称相匹配;这是大多数过程性宏遵循的约定。
hello_macro_derived 函数首先将来自 TokenStream 的输入转换为一个数据结构,然后我们可以对其进行解释和执行操作。这就是 syn crate 发挥作用的地方。syn::parse 函数接受一个 TokenStream 并返回一个表示解析过的 Rust 代码的 DeriveInput 结构体。下列代码显示了我们通过解析 struct Pancakes;
得到的 DeriveInput 结构的相关部分。
DeriveInput {
// --snip--
ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}
这个结构体的字段表明,我们解析的 Rust 代码是一个单元结构体,其标识符是 Pancakes。在这个结构体上有更多的字段来描述各种 Rust 代码。
注意,派生宏的输出也是一个 TokenStream。返回的 TokenStream 被添加到我们的 crate 用户编写的代码中,所以当他们编译他们的 crate 时,他们将获得我们在修改后的 TokenStream 中提供的额外功能。
你可能已经发现,如果对 syn::parse 函数的调用失败,我们调用 unwrap 是为了使 hello_macro_derived 函数出现 panic。我们的过程性宏有必要对错误进行恐慌,因为 proc_macro_derived 函数必须返回 TokenStream 而不是 Result,以符合过程性宏 API 的要求。我们通过使用 unwrap 简化了这个例子,但在实际开发中,应该使用 panic! 或者 expect 提供更具体的错误消息,说明出了什么问题。
现在我们已经有了将带注释的 Rust 代码从 TokenStream 转换为 DeriveInput 实例的代码,让我们编写在带注释的类型上实现 HelloMacro trait 的代码,如下所示:
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let generated = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
generated.into()
}
我们使用 ast.ident 获得一个包含带注释类型的标识符的 Ident 结构体实例。当我们运行 impl_hello_macro 函数时,我们获得的 ident 将具有值为 “Pancakes” 的 ident 字段。因此,上面代码中的 name 变量是 Ident 结构体实例的引用,该实例在打印时将是字符串 “Pancakes”,即结构体的名称。
quote! 宏允许我们定义想要返回的 Rust 代码。编译器期望得到与 quote! 宏执行的直接结果不同的结果,因此我们需要将其转换为 TokenStream。我们通过调用 into 方法来实现这一点,该方法使用此中间表示并返回所需 TokenStream 类型的值。
quote! 宏还提供了一些非常酷的模板机制:我们可以输入 #name, quote! 将用变量名中的值替换它。你甚至可以执行一些类似于常规宏工作方式的重复操作。
我们希望过程宏为用户注释的类型生成 HelloMacro trait 的实现,我们可以通过使用 #name 来获得。trait 实现有一个 hello_macro 函数,它的主体包含了我们想要提供的功能:打印 Hello, Macro! My name is ,然后是注释类型的名字。
这里使用的 stringify! 宏是内置在 Rust 中的。它接受一个 Rust 表达式,并在编译时将该表达式转换为字符串字面值。这与 format! 或 println! 这两种宏不同,它们对表达式求值,然后将结果转换为字符串。输入的 #name 有可能是要按字面意思打印的表达式,因此我们使用 stringify!。使用 stringify! 还可以通过在编译时将 #name 转换为字符串字面值来节省分配。
hello_macro 和 hello_macro_derived crate 的构建完成了。
让我们使用它们,看看过程性宏是如何工作的。
使用 cargo new pancakes 在项目目录中创建一个新的二进制 crate。我们需要将 hello_macro 和 hello_macro_derived 作为依赖项添加到 pancakes crate 的 Cargo.toml 中,如下所示:
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
main.rs 中的代码为:
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)]
struct Pancakes;
fn main() {
Pancakes::hello_macro();
}
过程性宏中 HelloMacro trait 的实现被包括在内,而 pancakes crate 不需要实现它,因为 #[derive(HelloMacro)] 添加了 HelloMacro trait 的实现。
接下来,让我们探索其他类型的过程性宏与自定义派生宏的区别。
类属性宏
类属性宏类似于自定义派生宏,但不是为派生属性生成代码,而是允许你创建新属性。它们也更加灵活:派生只适用于结构体和枚举;属性也可以应用于其他项目,比如函数。
下面是一个使用类属性宏的示例。假设你有一个名为 route 的属性,它在使用 Web 应用程序框架时注释了函数:
#[route(GET, "/")]
fn index() {
这个 #[route] 属性将被框架定义为一个过程性宏。宏定义函数的签名是这样的:
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
这里,我们有两个 TokenStream 类型的参数。第一个用于属性(attr)的内容:GET, “/” 部分。第二个是属性所附加的项(item)的主体:在本例中是 fn index() {} 和函数主体的其余部分。
除此之外,类属性宏的工作方式与自定义派生宏相同:使用 proc-macro crate 类型创建一个 crate,并实现一个生成所需代码的函数。
类函数宏
类函数宏定义了看起来像函数调用的宏。类似于 macro_rules! 宏,它们比函数更灵活。例如,它们可以接受未知数量的参数。
类函数宏接受一个 TokenStream 参数,它们的定义使用 Rust 代码像其他两种类型的过程性宏一样操作 TokenStream 参数。
类函数宏的一个例子是 sql! 宏,可以像这样调用:
let sql = sql!(SELECT * FROM posts WHERE id=1);
这个宏将解析其中的 SQL 语句,并检查其语法是否正确,这比 macro_rules! 宏所能做的处理要复杂得多。
sql! 宏应该这样定义:
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
这个定义类似于定制的派生宏的签名:我们接收括号内的 token,并返回我们想要生成的代码。