精通Rust系统教程-过程宏入门
本文介绍Rust过程宏定义、分类及应用示例。假设你已经熟悉Rust及基本概念、如数据类型、迭代和特性(traits)。
Rust宏简介
宏是Rust编程语言的重要组成部分,当你学习Rust语言时,很快就会遇到它们。Rust宏以最简单的方式让你在编译时执行一些代码。Rust宏几乎允许你做任何你想做的事情,以及你可以用它们做任何你想要的,该特性最常见的用例是编写生成其他代码的代码。总之,Rust宏非常强大,你必须要重视并掌握它。
宏是一种将编译器的功能扩展到标准支持之外的方法。无论您是希望基于现有代码生成代码,还是希望以某种形式转换现有代码,宏都是您的首选工具。
Rust官方描述如下:
术语宏指的是Rust中的一系列特性。从根本上说,宏是一种编写代码的方式,可以编写其他代码,这被称为元编程。
元编程有助于减少必须编写和维护的代码量,这也是函数的作用之一。然而,宏具有一些函数所没有的额外功能。
使用宏你还可以动态地添加需要在编译时添加的内容,这在使用函数时是不可能的,因为函数在运行时被调用。例如,在编码时给类型增加注解(我们定义的),让Rust在编译时自定实现相应的Traits ,就像Rust内置了Debug、Display能力一样。
宏的另一个优点是它们非常灵活,因为与函数不同,它们可以接受动态数量的参数或输入。宏确实有自己的语法来编写和使用宏,我们将在后面的部分中详细探讨。
下面是一些关于如何使用宏的例子,这些例子确实有助于传达它们有多么强大:
- SQLx项目使用宏在编译时验证所有SQL查询和语句(需要使用sqlx创建),方法是在运行的DB实例上实际执行它们(是的,在编译时)。
- typed_html在使用熟悉的JSX语法的同时,实现了一个具有编译时验证的完整HTML解析器。
Rust宏分类
在Rust中,有两种不同类型的宏:声明式宏和过程式宏。
- 声明式宏
声明性宏基于语法解析机制实现。虽然官方文档将它们定义为支持编写语法扩展,但我认为将它们视为编译器match关键字的高级版本更好理解。你能定义一个或多个模式来匹配,它们的主体应该返回你希望宏生成的输出Rust代码。声明式宏相对声明式宏要更易理解,后续我们再详细分享。本文专注过程宏的学习。
- 程序上的宏
过程宏最常见的用例是在编译时执行任何你想要的Rust代码。唯一的要求是它们应该接受Rust代码作为输入,并返回Rust代码作为输出。编写这些宏不涉及特殊的语法解析(除非你想这样做),因此,它们是相对比较容易理解和编写的。
过程宏进一步分为3类:派生宏、属性宏和函数宏。
派生宏
一般来说,派生宏应用于Rust中的数据类型。它们是一种扩展类型声明的方法,也可以自动为数据类型“派生”出新的功能。
你可以使用它们从类型生成“派生”类型,或者作为在目标数据类型上自动实现方法的一种方式。看一下下面的例子,这应该是有意义的。
打印非基本数据类型,如结构体、枚举甚至错误(它们只是结构体,但我们假设它们不是),用于调试是任何语言的一个非常常见的特性,不仅仅是Rust。在Rust中,只有基本数据类型具有在“调试”上下文中打印的能力,我们可以利用实现对非基本数据类型的打印功能。
如果你认为Rust中的所有东西都只是特征(traits ,甚至像add和equals这样的基本操作),这是有道理的。你希望能够调试打印自定义数据类型,但是Rust没有办法说“请将此特性应用于代码中的每一个数据类型,永远”。
这就是Debug派生宏的用武之地。在Rust中,有一种标准的方法可以调试打印用于其内部类型的每种类型的数据结构。Debug宏支持自动实现自定义类型的Debug特性,同时遵循与内部数据类型实现相同的规则和样式准则。
// 派生宏示例
/// 针对自定义数据类型
#[derive(Debug)]
pub struct User {
username: String,
first_name: String,
last_name: String,
}
调试派生宏将产生以下代码(为了说明,不完全准确):
impl core::fmt::Debug for User {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
f.debug_struct(
"User"
)
.field("username", &self.username)
.field("first_name", &self.first_name)
.field("last_name", &self.last_name)
.finish()
}
}
您可能已经知道,没有人愿意一次又一次地为所有自定义结构体和枚举编写这段代码。这个简单的宏让你了解了Rust中宏的强大功能,以及为什么它们是语言本身的重要组成部分。
请注意,原始类型声明是被保留在输出代码中的,这是派生宏与其他宏之间的主要区别之一。派生宏保留输入类型而无需修改,它们只向输出中添加额外的代码。另一方面,所有其他宏的行为方式并不相同,只有当宏本身的输出也包含目标时,它们才保留目标。
属性宏
除了数据类型之外,属性宏通常还应用于代码块,如函数、impl块、内联块等。它们通常用于以某种方式转换目标代码,或者用附加信息对其进行注释。
最常见的用例是修改函数以向其添加额外的功能或逻辑。例如,你可以很容易地编写一个属性宏:
- 记录所有输入和输出参数
- 记录函数的总运行时间
- 对函数被调用的次数进行计数
- 向任何结构体添加预先确定的附加字段
- …
我上面提到的所有东西,以及更多的东西,都结合在一起,形成了由trace
包提供的Rust中非常流行和有用的instrumentation
宏。当然我们在这里做了大量的简化,但作为例子说明已经足够好了。
如果你习惯于使用Clippy,那么它可能会多次将#[must_use]
属性添加到函数或方法中。这是用附加信息注释函数的宏示例。如果没有使用此函数调用的返回值,它告诉编译器警告用户。默认情况下,Result类型已经带有#[must_use]注释,这就是你为什么能看到警告:Unused Result<…> that must be used。属性宏也为Rust中的条件编译提供了支持。
除了Debug功能,我们看两个rust内置属性宏的示例:
#[allow(unused_variables)]
fn some_function() {
let x: i32 = 5;
// 假设变量y没有被使用,但由于有#[allow(unused_variables)]属性,编译器不会发出警告
let y: i32 = 10;
}
该示例在调试模式下可能需要打印更多的调试信息:
#[cfg(debug_assertions)]
fn debug_print() {
println!("This is a debug message.");
}
#[cfg(not(debug_assertions))]
fn debug_print() {
// 在非调试模式下,这个函数不做任何事
}
fn main() {
debug_print();
}
这里定义了一个debug_print
函数,通过cfg
属性宏根据是否为调试模式(debug_assertions
)来编译不同的函数体。在调试模式下,函数会打印出This is a debug message.
;在非调试模式下,函数体为空,不会执行任何操作。
函数宏
函数式宏是伪装成函数的宏。它们是限制最少的过程性宏类型,因为它们可以在任何地方使用,只要它们输出的代码在使用它们的上下文中是有效的。
这些宏不像其他两个宏那样“应用”于任何东西,而是像调用函数一样调用。函数可以带参数,只要你的宏知道如何解析它,就可以传递任何您想要的东西。这包括从没有参数到有效的Rust代码,再到只有宏才能理解的任何内容。
它们在某种意义上是声明性宏的过程版本。如果你需要执行Rust代码并能够解析自定义语法,那么函数式宏是首选工具。如果在不能使用其他宏的地方需要类似宏的功能,它们也很有用。
下面我们看几个内置函数宏示例。
- 断言类
/// assert!是用于在测试和调试中进行断言的宏。它接受一个布尔表达式作为参数,如果表达式为false,则程序会发生panic(崩溃)并打印出错误信息。此外,还有assert_eq!和assert_ne!等相关宏,分别用于断言两个值相等和不相等。
let x = 5;
assert!(x > 3);
let s1 = "hello";
let s2 = "hello";
assert_eq!(s1, s2);
let n1 = 10;
let n2 = 20;
assert_ne!(n1, n2);
- 格式化
/// format!宏与println!类似,但它不会将结果打印到控制台,而是返回一个格式化后的字符串。这在需要构建一个字符串用于其他用途(如存储、作为函数参数等)时非常有用。
let name = "Alice";
let age = 30;
let message = format!("{} is {} years old.", name, age);
println!("{}", message);
let pi: f64 = 3.1415926;
let pi_str = format!("Pi is approximately {:.2f}", pi);
println!("{}", pi_str);
- 无参函数宏
/// unimplemented!是一个不带参数的函数宏。它用于标记一段代码尚未实现,当执行到这个宏时,会引发一个panic(崩溃),提示该部分代码还没有完成。通常在开发过程中,用于表示函数、方法或者代码块的暂未完成的部分,让开发者清楚地知道还有哪些地方需要补充实现。
fn not_yet_implemented_function() {
unimplemented!();
}
/// todo!也是一个不带参数的函数宏,和unimplemented!类似,它用于表示待办事项,即这里应该有代码,但还没有编写。不过todo!宏提供了一些额外的灵活性,在某些 IDE(集成开发环境)或者工具支持下,可以更好地与任务管理等功能集成,帮助开发者跟踪需要完成的代码部分。
enum MyEnum {
Variant1,
Variant2,
}
fn handle_enum(e: MyEnum) {
match e {
MyEnum::Variant1 => println!("Handled Variant1"),
MyEnum::Variant2 => todo!("Need to handle Variant2"),
}
}
宏前置知识
了解宏基础知识后,要开始自定义宏,需要一些前置知识和开发工程,本节主要介绍如何新建宏项目,以及宏开发常用依赖包。
在编写自己的过程宏时,需要遵循一些特定的规则。这些规则适用于所有3种类型的过程性宏。它们是:
- 过程性宏项目,Cargo.toml中必须有proc-macro标记
- 标记为这样的项目不能导出过程宏以外的任何内容
- 宏本身都必须在lib中声明lib.rs文件
让我们开始用下面的代码开始我们的项目:
cargo new --bin my-app
cd my-app
cargo new --lib my-app-macros;
这里创建了根项目,以及其中的子项目,该子项目将实现我们自定义的宏。你需要对Cargo.toml文件做些改动。这两个项目的Toml文件。
首先是my-app-macros的Cargo.toml文件应该有以下内容(注意你在lib节中增加proc-macro属性):
# my-app/my-app-macros/Cargo.toml
[package]
name = "my-app-macros"
version = "0.1.0"
edition = "2021"
[lib]
name = "my_app_macros"
path = "src/lib.rs"
proc-macro = true
[dependencies]
接下来是my-app项目的Cargo.toml文件:
# my-app/Cargo.toml
workspace = { members = ["my-app-macros"] }
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
resolver = "2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
my-app-macros = { path = "./my-app-macros" }
你需要将依赖解析器版本设置为“2”,并将宏项目添加为my-app项目的依赖项。
resolver = "2"
表示使用 Cargo 的第二个版本的依赖解析器。这个设置会影响 Cargo 如何处理项目中的依赖关系,特别是当存在多个依赖项,并且这些依赖项之间可能有版本冲突或者重叠的依赖关系时。当项目有大量的依赖项,并且这些依赖项来自不同的来源,有着不同的版本更新频率和兼容性要求时,resolver = "2"
能够更好地应对。
常用依赖
从编译器的角度来看,宏是这样工作的:
- 它们接受一个标记流作为输入(或者宏本身可以定义标记流作为参数)
- 它们返回一个标记流作为输出
这就是编译器所知道的全部!你很快就会看到,编译器知道这一点就足够了。但这确实产生了一个问题。你需要能够以一种正确理解它们的方式来理解这个“标记流”,无论是作为Rust代码还是自定义语法,都能够修改它们,并输出它们。手动执行此操作并非易事。
然而,我们可以依靠社区提供的开源工作来减轻负担,因此需要学习一些依赖项来帮助解决这个问题:
-
syn - Rust语法解析器。这可以将输入标记流解析为Rust AST。AST是你在尝试编写自己的解释器或编译器时经常遇到的概念,但是对于使用宏来说,有基本的理解就够了。毕竟,从某种意义上说,宏只是为编译器编写扩展。如果你对AST有兴趣,深入学习也不妨。
-
Quote - Quote执行syn所做的反向操作包,它帮助我们将Rust源代码转换为可以从宏输出的标记流。
-
proc-macro2——标准库提供了proc-macro 包,但是它提供的类型不能存在于过程宏之外。Proc-macro2是标准库的包装器,它使所有内部类型都可以在宏上下文之外使用。例如,这允许syn和quote不仅用于过程性宏,而且在常规Rust代码中也可以使用。如果我们想要对宏或它们的展开进行单元测试,我们确实会广泛地使用它。
-
darling - 它有助于解析和处理宏参数,否则这是非常繁琐的任务,因为你必须从语法树中手动解析它。Darling为我们提供了类似服务器的功能,可以自动将输入参数树解析到参数结构中。它还帮助我们处理无效参数、必需参数等方面的错误。
可以通过下面命令增加依赖:
// my-app-macros
cargo add syn quote proc-macro2 darling
总结
本文介绍宏及其分类,包括声明式宏和过程式宏,其中后者又包括派生宏、属性宏以及函数式宏。为了自定义宏,我们介绍了开发相关的前置知识以及项目依赖配置等。学习完本文,应该可以卷起袖子开发自己的宏了,一起rust。