本章内容:
- 隐藏信息以改善代码
- 使用类似函数的宏来修改和扩展代码
- 使用类似函数的宏来操作结构体和函数
- 通过让编译器或 IDE 帮助调试宏
- 使用灵活的类似函数的宏编写 DSL
- 决定哪种宏适合特定用例
第三种也是最后一种类型的过程宏是类似函数的宏,它与其他两种宏不同,不仅限于注解结构体、枚举等。在本章中,我们将通过两个示例来展示其强大功能。在第一个示例中,我们将保持熟悉的领域,专注于操作结构体。更大胆的实验可以留到本章后面,当我们再次探讨如何组合函数时。
5.1 隐藏信息
就像一个专业的滑板运动员或一个试图给雅典观众留下深刻印象的诡辩家一样,我们将做一个酷炫的 180 度翻转:这次我们不是告诉你暴露字段,而是我们将论证你需要一个宏来隐藏它们。这听起来可能有些奇怪,但其实并不难理解,因为编程中的每个问题的答案都是“这取决于情况”。是的,正如我们在前一章中提到的,具有公共字段的结构体非常适合在系统之间传输信息。但是在许多其他情况下,结构体和类更倾向于隐藏信息。这使得代码更容易理解和推理。例如,如果一个模块只暴露一个函数来完成一个任务,你可能永远不需要了解它实现该任务所需要的成百上千个方法、结构体和变量。避免考虑所有的实现细节会让你的生活更轻松。
隐藏字段的另一个相关原因是安全性。诚然,在 Rust 中这不是大问题,因为编译器非常擅长检测危险的(修改性)操作。但在像 Java、C# 或 JavaScript 这样的语言中,你应该隐藏字段和方法,这样没人能改变它们并搞乱东西。在多个线程可能访问同一个对象并对其进行修改的环境中,这变得更加重要,从而导致各种奇怪的问题——这也是函数式编程关注不可变性的原因之一。如果你的对象无法被他人操作,那么你的代码会更安全、更易理解。
即使在 Rust 中,这些做法也有其意义,至少从更容易推理代码的角度来看。事实上,语言为程序员提供了很好的工具来隐藏结构体、文件、模块等中的信息。显然,信息隐藏对 Rust 创建者来说非常重要。因此,我们的第一个示例将集中于编写一个宏,该宏添加方法以允许通过引用对字段进行不可变访问。当结构体包含应该被查询或使用但不应被修改的字段时,这非常有用。
5.1.1 隐藏信息宏的设置
Cargo.toml 和项目结构与之前相同,不同之处在于这次嵌套目录(宏)的名称是 private-macro。这意味着外部目录(应用程序)需要通过 private-macro = { path = "./private-macro" } 来导入此依赖项。lib.rs 和 main.rs 需要做更多的更改。main.rs 如下所示。
Listing 5.1 我们的应用代码
use private_macro::private; #1
private!( #2
struct Example {
string_value: String,
number_value: i32,
}
);
fn main() {
let e = Example {
string_value: "value".to_string(),
number_value: 2,
};
e.get_string_value(); #3
e.get_number_value();
}
#1 导入激活我们的宏。
#2 我们通过写宏的名字并加上感叹号来调用宏,并传递相关数据。
#3 一旦我们有了实例,我们可以使用生成的方法来获取字段的引用(例如 &String 和 &i32)。
与使用 derive 注解装饰结构体不同,我们通过写宏的名字并加上感叹号来调用类似函数的宏,并在括号中传递有用的数据。这与声明式宏的使用方式类似,这也意味着这两者之间的区别可能不太明显。为了让这两者更难以区分,Rust 还允许你选择使用圆括号、方括号或花括号来传递参数。由于我们要向结构体添加一个方法,因此我们只需将整个结构体作为参数传递。接下来,在 main 函数内部,我们预期能够构建 Example 结构体并调用其生成的方法。
接下来是 lib.rs:以下是我们代码的第一个版本。你能找出与 derive/attribute 宏的本质区别吗?
Listing 5.2 我们宏实现的第一个版本
use quote::quote;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro] #1
pub fn private(item: TokenStream) -> TokenStream {
let ast = parse_macro_input!(item as DeriveInput);
let name = ast.ident;
quote!(
struct #name {} #2
impl #name {} #3
).into()
}
#1 类似函数的宏需要 #[proc_macro]。宏的名称由装饰函数的名称决定。
#2 输出替换了输入,因此我们在输出中重新创建了结构体代码。
#3 我们将要生成的“getter”方法将被添加到这个 impl 中。
首先,我们使用 #[proc_macro] 代替 #[proc_macro_derive(Hello)] 或 #[proc_macro_attribute]。由于我们不能显式定义宏名称,因此函数名(即 private)就足够了。
接下来,不像属性宏——但像派生宏——我们只接收一个参数。这是因为附加的 TokenStream 包含了你正在创建的属性的信息,而类似函数的宏不会创建自定义属性。
最后,类似函数的宏与属性宏相似——并且与派生宏不同——因为它们的输出标记会替换输入(见图 5.1)。让输入替换输出是完全合理的。对于其他宏,我们接收到结构化的数据(结构体、枚举等),而类似函数的宏允许你传入任何内容,甚至是不符合 Rust 语法的内容。如果这些输入保留在源代码中,编译器会因为无效的语法大声抱怨,这显然不是你想要的结果。
这意味着我们必须重新创建我们传递给宏的结构体。在我们当前的版本中,我们获取结构体的名称并用它来创建一个空的结构体和空的实现。
5.1.2 重新创建结构体
目前,我们的应用程序无法编译,即使我们注释掉方法调用——因为字段已经消失了!这是一个典型的 déjà vu:我们又一次删除了已经存在的东西。糟糕,宏,糟糕!所以让我们通过修复结构体的重建来改进我们的代码。这非常容易修复:只需返回你在创建输出时收到的内容。有至少两种方法可以做到这一点:我们可以在输出中返回传入的 TokenStream,或者返回 AST。
在 Listing 5.3 的代码中,我们选择了第一种方法。由于传入的流被 parse_macro_input 移动了,我们克隆这个流,并使用 Into 特性将内置的 TokenStream 转换为实现 quote 宏所需的 ToTokens 特性的类型。一旦我们得到了转换后的克隆,剩下的工作就是将其传递给输出。
Listing 5.3 我们宏实现的第二个版本
// imports
#[proc_macro]
pub fn private(item: TokenStream) -> TokenStream {
let item_as_stream: quote::__private::TokenStream = item
.clone()
.into(); #1
let ast = parse_macro_input!(item as DeriveInput);
let name = ast.ident;
quote!(
#item_as_stream #2
impl #name {} #3
).into()
}
#1 克隆传入的(标准)TokenStream 并将其转换为一个实现 ToTokens 特性的私有 TokenStream。
#2 将这个克隆传递给输出。
使用 AST 路径会更简单,因为(a)DeriveInput 实现了 ToTokens,并且(b)我们可以使用引用来获取结构体的名称,从而避免部分移动。因此,后者的方法实际上更可取。或者,你也可以通过一开始就不传递结构体来解决删除问题。就目前而言,没有必要传递整个结构体——因此我们可以仅传递结构体的名称。但在这个特定示例中,这将不起作用,稍后我们将发现原因。
部分移动
部分移动是 Rust 中的一个棘手话题,就像——嗯——宏一样。实际上,这是 Rust 在所有权管理方面的保守做法,强迫你仔细思考你如何使用它。假设我们有一个结构体和一个偷取物品的函数:
#[derive(Debug)]
struct Car {
wheels: u8,
gps: String,
infotainment: String,
}
fn steal(item: String) {
println!("I am stealing {item}");
}
fn main() {
let car = Car {
wheels: 4,
gps: "Garmin".to_string(),
infotainment: "Android".to_string(),
};
println!("My car before the theft: {car:?}");
steal(car.gps);
// println!("My car after the theft: {car:?}"); // does not compile
}
这段代码可以运行。但如果我们添加最终的 println,Rust 会开始抱怨部分移动。发生了什么?steal 函数从 car 中偷走了 gps 字段。通过将该字符串作为参数传递,它被“移动”到函数中,从而使该函数成为字符串的唯一拥有者。所以 GPS 现在是 steal 的财产(并且会在函数中的 println 语句执行完后被丢弃)。
当我们到达最后一个注释掉的 print 时,编译器抱怨:“你让我打印这辆车和它的所有属性。但我怎么能打印 GPS,既然有人已经偷走并丢弃了它?”遗憾的是,编译器是对的:结构体的一部分所有权已经被转移——这意味着结构体不再可以使用,尽管我们仍然可以检索(并例如打印)尚未被移动的值,比如 infotainment。避免部分移动的简单方法是克隆。如果我们克隆 car 并从中偷走 GPS,我们的原始结构体仍然没问题,并且可以打印。克隆有性能上的影响,但有时它可能是唯一的解决方案。一个更好的解决方案是尽可能使用引用。至少,这会最小化你需要克隆的次数。其他时候,你可以使用一些巧妙的技巧,比如在 Option 上使用 take:
#[derive(Debug)]
struct Car {
wheels: u8,
gps: Option<String>,
infotainment: String,
}
fn main() {
let mut car = Car {
wheels: 4,
gps: Some("Garmin".to_string()),
infotainment: "Android".to_string(),
};
println!("My car before the theft: {car:?}");
steal(car.gps.take().unwrap());
// works, though the gps is now missing (None)
println!("My car after theft: {car:?}");
}
但对我们这些普通人来说,在可能的情况下使用引用,仅在需要时才克隆,是一个很好的开始!
5.1.3 生成辅助方法
现在我们可以专注于生成方法,Listing 5.4 中的代码展示了一个主要由熟悉的构建块组成的示例。由于这些方法返回字段的引用,并且它们的名称基于这些字段,我们需要提取字段信息以生成这些新方法。这也是为什么仅传递名称,如前所述,不可行的原因:我们的宏需要更详细的结构体信息。所以我们将遍历字段,并将每个字段映射到一个包含方法代码的标记流。在前一章的练习解法中(你解决了所有这些吧?),我们有一个不太优雅的返回类型(Map<Iter<'a, Field>, fn(&'a Field) -> quote::__private::TokenStream>)。因此,这次我们使用 collect,这样我们可以返回一个简洁的 Vec。
这是概述。现在,专注于 map 内部的代码。除了字段名称和类型外,我们还需要创建一个方法名称,该名称由字段名称前缀 get 组成(例如:get_string_value)。你可能会认为解决方案是简单地生成一个包含该值的 String。遗憾的是,将字符串传递给 quote 会产生错误,例如:“expected identifier, found 'get_string_value'”。
回想起来,原因显而易见。我们正在输出一个标记流,并要求 Rust 将其添加到我们的代码中。Rust 开始检查代码,包括我们生成的部分,并找到了 fn 关键字。这只能意味着接下来的标记是一个函数名称,它始终是标识符类型。但它却发现了一个字符串(get_string_value)。
出于相同的原因,我们需要将 field_name 作为标识符,因为我们将用它来获取引用(&self.field_name),如果我们尝试使用字符串来访问 self 的字段,Rust 会抱怨。
所以我们需要一个标识符。我们可以通过构造函数(new 方法)来创建一个,它需要两个参数:一个字符串引用——我们已经有了——以及一个 span。在前一章中,我们提到过 span 是一种将其与原始代码关联的方式,这在报告错误时非常有用。当你创建一个新的标识符时,Rust 想知道在出错时它应该引用原始代码中的什么内容。我们可以通过以下几种方式为我们的方法名称创建合适的 span:
一种简单且有效的选择是获取 field_name 的 span 并将其重用为我们的方法名称。重用 span 也很有用,当你希望将现有代码与生成的代码“链接”时。
call_site() 是 Span 的一个关联函数。这个 span “将被解析为直接写在宏调用位置的代码”,意味着它会在你调用宏的位置解析——即应用程序代码的位置。
mixed_site() 是另一个关联函数。不同之处在于它遵循与声明式宏相同的卫生规则。根据标识符的作用,span 会解析为宏调用位置(调用位置)或宏定义位置(定义位置)。拉取请求的作者(GitHub 链接)认为这是一个合理的默认设置,因为它提供了一些额外的安全性。不过,它并不完全符合我们的意图。我们实际上希望我们的生成方法能在应用程序中使用。
关于 call_site 和 mixed_site
好吧,也许 call_site() 和 mixed_site() 之间的区别还不清楚。以下是一些代码示例来帮助说明。如果我们有一个宏生成一个局部变量,并决定使用 mixed_site 为变量的标识符设置 span:
#[proc_macro]
pub fn local(_: TokenStream) -> TokenStream {
let greeting = Ident::new("greeting", Span::mixed_site());
quote!(
let #greeting = "Heya! It's me, Imoen!";
).into()
}
现在我们可以调用这个宏并尝试打印变量。注意,这个特定的宏是在函数内部调用的,而不是在外部调用的,因为局部变量只能在函数内存在:
fn main() {
local!();
println!("{}", greeting);
}
这会导致一个错误,提示 greeting 在这个作用域中找不到。发生了什么?正如你可能猜到的那样,这是由于卫生规则。正如所述,mixed_site 遵循与声明式宏相同的卫生规则。而在声明式宏中,局部变量不会暴露到外部世界。在文档中说到:“局部变量的 span 只存在于宏的定义位置,而不存在于调用位置”,在我们的例子中,调用位置是 main 函数。将 greeting 的 span 改为 call_site,应用程序就会编译并运行。现在,span 存在于调用位置,即我们尝试打印的 main。
我几乎能听到一些好奇的读者在想 quote 做了什么。毕竟,我们本可以使用它来为我们生成正确的 span:
#[proc_macro]
pub fn private(item: TokenStream) -> TokenStream {
quote!(
let greeting = "Heya! It's me, Imoen!";
).into()
}
这会自动工作,意味着 quote 可能使用了 call_site,它确实如此。从源代码来看:
pub fn push_ident(tokens: &mut TokenStream, s: &str) {
let span = Span::call_site();
push_ident_spanned(tokens, span, s);
}
回到正题:虽然三种选项都能工作,但在我们的示例中,我们选择了 call_site()。除了创建名称外,我们还需要提取字段标识符和字段类型。这三个变量随后用于生成方法(见图 5.2)。
Listing 5.4 生成方法的代码
// 其他导入
use syn::{DataStruct, FieldsNamed, Ident, Field};
use syn::__private::{Span, TokenStream2};
use syn::Data::Struct;
use syn::Fields::Named;
fn generated_methods(ast: &DeriveInput) -> Vec<TokenStream2> {
let named_fields = match ast.data {
Struct(
DataStruct {
fields: Named(
FieldsNamed {
ref named, ..
}), ..
}
) => named,
_ => unimplemented!(
"only works for structs with named fields"
),
}; #1
named_fields.iter()
.map(|f| {
let field_name = f.ident.as_ref().take().unwrap();
let type_name = &f.ty; #2
let method_name =
Ident::new( #3
&format!("get_{field_name}"),
Span::call_site(),
);
quote!(
fn #method_name(&self) -> &#type_name { #4
&self.#field_name
}
)
})
.collect() #5
}
#1 这是来自前一章的代码,我们需要提取字段以使其公开。
#2 我们需要字段的名称和类型来生成方法。
#3 我们使用 format 和 new 生成方法名,并将其作为标识符。
#4 我们使用 method_name、field_name 和 type_name 来生成方法。
#5 这是可选的:为了得到一个简单的 Vec 返回类型,我们使用 collect 来收集结果。
现在我们所需要做的就是调用函数并将结果传递给生成的 impl 块。请记住,我们有一个 TokenStream 的向量需要添加,因此我们需要使用正确的标记来告诉 quote:#(#name_of_variable)*。之前我们在其中使用了逗号,因为字段是通过逗号分隔的。但是方法声明并不是这样。随着宏的更新,代码应该能够编译通过。
Listing 5.5 在我们的宏中使用新方法
// imports
#[proc_macro]
pub fn private(item: TokenStream) -> TokenStream {
let item_as_stream: quote::__private::TokenStream = item
.clone()
.into();
let ast = parse_macro_input!(item as DeriveInput);
let name = &ast.ident;
let methods = generated_methods(&ast); #1
quote!(
#item_as_stream
impl #name {
#(#methods)* #2
}
).into()
}
#1 生成方法的 TokenStream。
#2 将它们全部添加到我们的实现块中。
仍然有一些细节需要处理:我们生成的方法不是公共的,字段仍然可以直接访问(这不安全!),我们还应该有一个用于创建具有私有字段的结构体的(新)方法。我们将这些留给练习部分。
5.2 通过编写普通代码进行调试
有时,你可能会从宏中获得令人困惑或陌生的错误信息,而不知道接下来该做什么。仔细重新阅读错误信息可能会有所帮助(是的,本节包含了一些“显而易见”的建议),但如果这样做仍然没有帮助,另一种选择是将你想要生成的代码写成普通代码。之所以这样做,是因为大部分你放在 quote 括号中的代码都会被 IDE 和编译器视为“真理”(至少在你尝试将其应用到程序中之前)——对于那些像我一样依赖 IDE 支持来避免愚蠢错误的人来说,这是一个严重的缺点。幸运的是,一旦你尝试在普通的 Rust 代码中创建相同的代码,工具链就会再次支持你。
例如,假设我为我们的“private”宏编写了以下代码:
quote!(
fn #method_name(&self) -> #type_name {
self.#field_name
}
)
这是我可能会收到的(简化版)错误:
error[E0507]: cannot move out of `self.string_value` which is behind
a shared reference
|
3 | / private!(
4 | | struct Example {
5 | | string_value: String,
6 | | number_value: i32,
7 | | }
8 | | );
| |_^ move occurs because `self.string_value` has type `String`,
which does not implement the `Copy` trait
如果这个错误信息没有“点醒”我,我可能会被卡住。我的生成代码中某些地方出了问题,但问题在哪里呢?与其盯着宏或错误信息发呆,我不如直接为一个虚拟结构体编写我想生成的方法,基于我传递给 quote 的代码段:
struct Test {
value: String
}
impl Test {
fn get_value(&self) -> String {
self.value
}
}
如果我这么做,我的 IDE 会开始提示我,self.value 是问题的根源。在添加一个 & 后,它会指出返回类型不匹配。一步一步地,错误逐渐消失,我们也能更好地继续进行宏的开发。
最后一条显而易见但仍然有价值的建议是,像我们在最近几章中所做的那样,逐步进行。如果在添加某个功能后立即出现问题,那么错误的来源就显而易见了。而且,已经有部分功能能够运行的满足感也是一个不错的奖励。
5.3 组合
我们看到如何使用函数式宏作为派生宏(或属性宏)的替代品。现在让我们来看一个例子,其中函数式宏是唯一合适的选择。在前一章中,我们讨论了组合以及如何为组合函数编写声明式宏。我们还讨论了声明式宏中链式表达式所能使用的符号限制(即,我们无法模仿 Haskell,通过使用 .. 来组合函数,尽管我们也解释了 tt 类型不受这些限制)。本章的第二个例子就是组合。但这次我们会使用 ..。
同样,我们不会重新设置所有内容,你之前已经见过了。相反,看看 main.rs 中的应用代码,在那里我们看到第二章“声明式宏”中的两个示例函数。在 main 函数内部,我们使用将要编写的 compose 宏将这些函数用点(.)连接起来。请注意,无法将这些信息传递给派生或属性宏,因为它们期望一个结构体、枚举或函数(即一些有效的 Rust 代码)。
Listing 5.6 我们的应用程序,包含一些熟悉的函数
use function_like_compose_macro::compose;
fn add_one(n: i32) -> i32 {
n + 1
}
fn stringify(n: i32) -> String {
n.to_string()
}
fn main() {
let composed = compose!( #1
add_one . add_one . stringify
);
println!("{:?}", composed); #E
}
#1 我们使用 compose,我们的函数式宏,将三个函数组合起来。
这个是如何工作的?转到 lib.rs。 (因为代码比平常多了一点——大约 60 行——我们首先单独看宏的入口点。)首先,我们将输入解析成一个自定义类型 ComposeInput 的结构体。DeriveInput 不适合处理我们当前的输入,因为我们肯定没有收到结构体或枚举作为输入。更普遍地说,这种组合在 Rust 中并不原生支持,所以没有理由假设一个预构建的解析器可以为我们处理它。
Listing 5.7 我们的 compose 宏入口点
#[proc_macro]
pub fn compose(item: TokenStream) -> TokenStream {
let ci: ComposeInput = parse_macro_input!(item); #1
quote!(
{
fn compose_two<FIRST, SECOND, THIRD, F, G>(first: F, second: G)
-> impl Fn(FIRST) -> THIRD
where
F: Fn(FIRST) -> SECOND,
G: Fn(SECOND) -> THIRD,
{
move |x| second(first(x))
} #2
#ci
}
).into()
}
#1 将输入解析为自定义结构体
#2 将来自第二章的 compose_two 函数添加到我们的输出中,后跟 ci 变量生成的输出。
接下来,我们生成输出。我们的应用程序代码没有包含来自声明式宏示例中的 compose_two 函数,所以我们生成它。自定义结构体负责输入和输出,所以我们只需将其传递给 compose_two 声明之后。额外的一对大括号是必要的。无论我们返回什么,都必须绑定到一个变量(如 let composed = …)。没有大括号时,我们返回的是两件事:一个函数加上对它的调用。加上大括号后,我们创建一个块作用域,并且只返回这个块。
生成 compose_two
将 compose_two 函数放入块作用域中还有一个额外的优点,那就是它将函数声明隐藏在代码的其他部分之外。记住,我们为每次调用宏都会生成这个输出。所以,如果我们调用 compose! 四次,我们会有四个 compose_two 函数,它们各自位于独立的、受限的作用域中,彼此互不干扰。
不过,总是生成相同的函数有点低效。我们难道不能只导出 compose_two 并在生成的代码中引用它吗?其实不行:一个 proc-macro 库只能导出过程宏。解决这个限制的一种方法是将 proc-macro 库放在另一个库中。那个外部库将我们的 proc-macro 作为依赖,并简单地导出 compose_two 并重新导出宏(例如,pub use function_like_compose_macro::compose;)。如果你的代码依赖于这个新库,你可以同时导入函数和宏,从而避免每次都生成相同的代码。
也许你现在在想单态化。单态化是 Rust 编译创建一个通用函数的副本,针对每个需要它的具体类型。所以,当你编写一个带有签名 foo<T>(t: T) -> T 的通用函数,并分别用 i32 和 u8 作为 T 的类型调用它时,Rust 会用两个版本替换这个通用函数:一个接受并返回 i32,另一个接受并返回 u8。这种转换通常会导致更快的代码,但也会增加二进制文件的大小,尤其是在使用许多不同类型调用通用函数时。
无论如何,单态化确实减少了我们重新导出技术的有效性,因为每种类型都会生成一个函数。即便如此,在当前的设置中,即使我们多次调用相同类型,compose_two 也会被生成。而且,至少在单态化的情况下,这不会发生。
一个小细节:这次我们没有为输出创建临时变量来调用 into()。相反,我们直接一次性做完了。而因为 IDE 并不喜欢大括号后跟方法调用(另外,我们本来会有两对大括号),所以我使用了圆括号。
现在剩下的就是看看正在执行所有繁重工作的自定义结构体。Parse 输入足够简单:我们期望得到用点分隔的函数名。这听起来像是 Punctuated!标识符将足以作为函数名(见图 5.3)。为了得到点,我们可以使用 Token 宏,它可以处理大约 100 种不同的符号。在幕后,Token!(.) 会生成 Dot,所以我们也可以导入这个类型。或者,如果愿意,我们也可以将这两者混合使用。
Listing 5.8 我们的自定义结构体
use proc_macro::TokenStream;
use proc_macro2::Ident;
use quote::{quote, ToTokens};
use syn::{parse_macro_input, Token};
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
struct ComposeInput {
expressions: Punctuated::<Ident, Token!(.)>,
}
impl Parse for ComposeInput {
fn parse(input: ParseStream) -> Result<Self, syn::Error> {
Ok(
ComposeInput { #1
expressions: Punctuated::<Ident, Token!(.)>::
parse_terminated(input).unwrap(),
}
)
}
}
impl ToTokens for ComposeInput {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
let mut total = None;
let mut as_idents: Vec<&Ident> = self.expressions #2
.iter()
.collect();
let last_ident = as_idents
.pop()
.unwrap();
as_idents.iter()
.rev()
.for_each(|i| {
if let Some(current_total) = &total { #3
total = Some(quote!(
compose_two(#i, #current_total)
));
} else {
total = Some(quote!(
compose_two(#i, #last_ident)
));
}
});
total.to_tokens(tokens);
}
}
#1 我们再次使用 Punctuated,因为我们的组合输入是一个重复的过程。
#2 我们需要一个可变版本的标识符,以便弹出最后一个元素。
#3 对于剩余的元素,如果我们还没有进行组合,则将当前元素与 last_ident 一起放入 compose_two 中,并将其放入 total。否则,我们将当前元素与 total 一起放入 compose_two 中。
ToTokens 的实现稍微复杂一些(当然,还有其他方式可以写这个)。因为我们想要从列表中检索最后一个标识符(函数),所以我们遍历表达式,创建一个可变变量,并弹出最后一个标识符。接下来,我们使用 for_each 反向遍历我们的函数。如果 total,即包含我们输出的变量为空,那么我们就到了第一个元素,应该将其与 last_ident 组合成 compose_two,并将整个 TokenStream 放入 total 中。这就是为什么我们弹出最后一个元素的原因:为了知道什么时候需要将反转后的向量中的前两个元素组合在一起。一旦越过这个第一次的组合,事情变得更简单:我们继续取当前元素,并将其与当前的 total 一起放入 compose_two 中。生成的 TokenStream 成为我们的新 total。最后,我们通过 to_tokens 将所有收集到的标记传递出去。和之前一样,我们需要使用 proc_macro2 的标识符和 TokenStream,因为 quote 使用的是 proc_macro2 变体,而不是内置版本。
我们现在能够在我们的组合宏中使用句点(.)了。再次感谢 syn,它使得解析我们的输入变得容易,即使这些输入并不是有效的 Rust 代码,而 quote 帮助我们优雅地组合输出。
5.4 你能做的,我可以做得更好
由于函数式宏是最强大的宏,你可能会决定将其用于所有的目的。但这样做并不明智。派生宏,作为三者中最有限的宏,或许是三者中最受欢迎的,这背后一定有它的意义:局限性也可以是优点。例如,派生宏的效果对于用户和创建者来说都是可预测的,因为它们只是添加功能,而不改变或删除任何东西。因此,如果你只想为结构体或枚举添加功能,并且问题过于复杂而无法使用声明式宏,那么你已经找到了适合该任务的正确工具。
派生宏的局限性和优点在于它们只能用于结构体、枚举和联合,而属性宏则适用于这些以及特征和函数。再次强调,这使得它们更加可预测。作为用户,我知道不能在函数上使用派生宏。作为创建者,"输入令牌空间"——如果你愿意这么称呼——比函数式宏更窄。也就是说,我可以合理地确定我将得到一个结构体或枚举,并且我知道它们是什么样子。
因此,如果你只想为结构体或枚举添加功能,使用派生宏。如果你需要修改它们,使用属性宏。而当你想要改变功能并需要宏应用到其他类别时,选择函数式宏(见图 5.5)。
5.5 来自现实世界的例子
在不深入细节的情况下,以下是一些创造性使用宏的 crate 示例。SQLx(github.com/launchbadge…)是一个“Rust SQL crate,提供编译时检查的查询”,它是与关系型数据库交互时的常见选择,当你不想添加对象关系映射的复杂性时,可以使用它。你可以通过调用 query 方法编写 SQL 查询,但为了获得广告中提到的编译时检查,你应该使用一个同名的宏:
let countries = sqlx::query!(
"SELECT country, COUNT(*) as count
FROM users
GROUP BY country
WHERE organization = ?",
organization
)
.fetch_all(&pool)
.await?;
Yew(yew.rs)是一个“用于创建可靠高效的 Web 应用程序的框架”。它的一个特点是 html! 宏,允许你编写在编译时检查的 HTML。例如,如果你忘记关闭嵌套的 div,你会收到一个错误,告诉你这个开始标签没有对应的结束标签:
use yew::prelude::*;
html! {
<div id="my_div">
<div id="nested"/>
</div>
};
稍微偏离一下,这里有一个 Yew 示例,其中库的作者决定通过调用 mixed_site() 创建一个 span。(它还包含了 format_ident 宏的示例,我们将在下一章中使用。)在这种情况下,作者选择这个方法是有道理的,因为他们不希望这个构建器与客户端应用中的任何内容冲突:
impl ToTokens for DerivePropsInput {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
// ...
let builder_name = format_ident!(
"{}Builder", props_name, span = Span::mixed_site()
);
let check_all_props_name = format_ident!(
"Check{}All", props_name, span = Span::mixed_site()
);
// ...
}
}
同时,Leptos(leptos.dev/)有一个 view 宏,允许你将 HTML 和 Rust 代码混合使用。以下代码示例创建了一个递增按钮。在这里,你也会收到关于标签不匹配或宏内 Rust 代码的问题的警告:
#[component]
fn App(cx: Scope) -> impl IntoView {
let (count, set_count) = create_signal(cx, 0);
view! { cx,
<button
on:click=move |_| {
set_count.update(|n| *n += 1);
}
>
"Click me: "
{count}
</button>
}
}
总结
- 函数式宏会替换其输入。
- 就像声明式宏一样,你通过编写宏的名称后跟感叹号来使用它们。
- 它们的输入位于后面的括号中。
- 它们不仅限于结构体等,而是可以接受任何你想传递的输入。
- 编写函数式宏与编写其他过程宏非常相似。但由于它们的输入更加多样,可能需要更多的解析工作。
- 处理输入的一种方法是创建一个自定义结构体来收集所有信息。
syn库有很多有用的工具,可以帮助你实现这一点。 - 当因为生成的代码中的编译错误而陷入困境时,尝试编写你想生成的代码,看看编译器或 IDE 是否给出有用的建议。
- 在决定需要哪种类型的宏时,你应该考虑需求:你将在哪里使用它?它是否需要改变现有代码?
- 尽可能选择最简单的选项。