编写强大的 Rust 宏——声明式宏本章内容包括: 编写声明式宏 避免样板代码和重复实现,创建新类型,编写简单的领域特定
本章内容包括:
- 编写声明式宏
- 避免样板代码和重复实现,创建新类型,编写简单的领域特定语言,并进行函数组合
- 理解
lazy_static
crate
我们将从声明式宏开始本书的内容。声明式宏的语法会让你立刻想起模式匹配,它结合了匹配器和转录器。匹配器包含你想要匹配的内容;转录器则包含当找到匹配项时你将生成的代码。这就是这么简单。
注意:本章的重点是对声明式宏及其用法的广泛概述。这与本书其余部分的内容形成对比,后续内容将重点关注特定主题和有限数量的示例。原因在于声明式宏并不是本书的主要焦点,我期望读者对它们的了解比对过程宏的了解更多。因此,我们可以更快速地完成本章的内容。
2.1 创建向量
但等一下,这是一本以示例为主的书!这意味着我们应该首先介绍一个示例。vec!
在多个初学者的声明式宏解释中被使用。我们将通过一个简化的实现来展示上述的匹配器和转录器如何协作生成适当的代码输出,以适应任何给定的情况。
清单 2.1 我们的第一个声明式宏 my_vec
macro_rules! my_vec {
() => [
Vec::new()
];
(make an empty vec) => (
Vec::new()
);
{$x:expr} => {
{
let mut v = Vec::new();
v.push($x);
v
}
};
[$($x:expr),+] => (
{
let mut v = Vec::new();
$(
v.push($x);
)+
v
}
)
}
fn main() {
let empty: Vec<i32> = my_vec![];
println!("{:?}", empty);
let also_empty: Vec<i32> = my_vec!(make an empty vec);
println!("{:?}", also_empty);
let three_numbers = my_vec!(1, 2, 3);
println!("{:?}", three_numbers);
}
说明:
- #1 我们声明了一个名为
my_vec
的新宏。 - #2
()
是我们的第一个匹配器。由于它是空的,它将匹配没有任何参数的宏调用。 - #3 方括号中的所有内容是第一个转录器。这是我们在宏调用空值时生成的内容。注意末尾的分号。
- #4
(make an empty vec)
是我们的第二个匹配器。当输入文字匹配“make an empty vec”时,它会进行匹配。 - #5 这是我们的第二个转录器,这次使用了圆括号。我们生成的输出与之前的转录器相同。
- #6 接下来的两个匹配器-转录器对。第一个接受一个表达式(expr)并将其绑定到
x
。第二个接受多个以逗号分隔的表达式。这些也会绑定到x
。 - #7 这两个打印
[]
。 - #8 这个打印
[1, 2, 3]
。
2.1.1 语法基础
你可以用 macro_rules!
开始声明一个声明式宏,后面跟上你想用的宏名,类似于用 fn
声明函数时的语法。在花括号内,你放置所需的匹配器和转录器。匹配器和转录器通过箭头进行分隔:(匹配器) => (转录器)。在这种情况下,我们有四对匹配器和转录器。我们的第一对由一个空匹配器(由空括号表示)和一个内容被方括号包裹的转录器组成。虽然方括号不是强制要求的,但对于匹配器和转录器,你可以选择括号:()、{} 和 [] 都是有效的。然而,你需要选择其中一种,因为完全去掉括号(例如,() => Vec::new())会导致 Rust 变得困惑。它会开始抱怨双冒号:没有规则期望 ::
这个标记。如果去掉这些,它会更有帮助地指出“宏的右侧必须有分隔符”——即,使用括号!
注意:细心的读者会注意到示例中的每对语法不同。这仅仅是为了展示你在括号选择上的选项。你的代码会更干净,如果你选择其中一种。你应该选择哪一种?使用大括号可能会让你的代码在转录器中包含代码块时变得不太清晰(参见清单 2.1 中的第二对)。而方括号似乎是较少使用的选择,所以圆括号可能是一个好的默认选择。
另一个重要的语法元素是匹配器和转录器对之间用分号分隔。如果你忘记这么做,Rust 会报错:
5 | {$x:expr} => {
| ^ no rules expected this token in macro call
这是在说如果你结束一个匹配器-转录器对时没有分号,那么不应有任何规则。因此,只要你还有更多匹配器-转录器对,就要继续添加分号。当你到达最后一对时,分号是可选的。
2.1.2 声明和导出声明式宏
需要考虑的一个限制是声明式宏只能在声明之后使用。如果我把宏放在 main
函数下面,Rust 会报错:
error: cannot find macro `my_vec` in this scope
--> src/main.rs:5:25
|
5 | let three_numbers = my_vec!(1, 2, 3);
| ^^^^^^
|
= help: have you added the `#[macro_use]` on the module/import?
一旦你开始导出宏,这将不再是问题,因为在模块或导入的顶部添加 #[macro_use]
(例如 #[macro_use] mod some_module;
)会将宏添加到“宏使用预置模块”中。在编程中,预置模块是指在语言中全局可用的功能集合。例如,Clone
(#[derive(Clone)]
)不需要导入,因为它在 Rust 的预置模块中。当你添加 #[macro_use]
时,同样的规则适用于从所选导入中获取的宏:在任何地方都可用,无需额外的导入。因此,之前错误信息中的提示将解决错误,尽管这种做法有点“杀鸡用牛刀”。此外,这是一种“旧方法”,现在已经不是推荐的方法了。但我们会在后面讨论。
当你需要调用宏时,你使用宏名后跟一个感叹号和括号中的参数。类似于宏本身,在调用时,你可以使用任何你喜欢的括号,只要是正常的圆括号、花括号或方括号。你可能经常看到 vec![]
,但 vec!()
和 vec!{}
也是有效的,尽管花括号似乎不太适用于简短的调用。在本书中,你会看到我在多行 quote!
调用中使用花括号。
2.1.3 第一个匹配器解释
现在我们已经介绍了基本语法,再次来看我们的第一个匹配器:
() => [
Vec::new()
];
由于我们的匹配器是空的,它将匹配任何空的宏调用。因此,当我们在 main
函数中调用 let empty: Vec<i32> = my_vec!();
时,这就是我们最终匹配到的匹配器,因为(a)Rust 从上到下遍历匹配器,(b)我们没有在括号中传递任何内容。我们说转录器的内容位于(在这种情况下是方括号)括号之间,这意味着 Vec::new()
是 Rust 在匹配时生成的代码。因此,在这种情况下,我们告诉 Rust 我们要调用向量结构体的 new
方法。这段代码将被添加到我们调用宏的地方。
这就回到了 main
中的第一次调用。Rust 看到 my_vec!()
并想到:“一个感叹号!这一定是一个宏调用。”由于文件中没有导入,这要么是来自标准库的宏,要么是自定义宏。结果证明这是一个自定义宏,因为 Rust 在同一文件中找到了它。找到宏后,Rust 从第一个匹配器开始,这恰好是正确的匹配器。现在,它可以用转录器的内容 Vec::new()
替换 my_vec!()
。所以当你对代码进行任何操作(检查、lint、运行等)时,let empty: Vec<i32> = my_vec!();
已经被替换成 let empty: Vec<i32> = Vec::new();
。这是一个细小但重要的细节:由于只有 my_vec!()
被替换,因此语句末尾的分号保持不变。因此,我们不需要在转录器中添加一个分号。
2.1.4 非空匹配器
让我们来看看第二个匹配器,它如下所示:
(make an empty vec) => (
Vec::new()
);
在这个例子中,匹配器包含了字面值。这意味着,为了匹配宏的这个特定“分支”,你需要在调用宏时在括号中放入那个确切的字面值,这正是我们在 main
函数中的第二个例子中所做的:let also_empty: Vec<i32> = my_vec!(make an empty vec);
。我们的转录器没有改变,因此输出仍然是 Vec::new()
,代码变成了 let also_empty: Vec<i32> = Vec::new();
。在这种情况下,字面值没有添加任何有趣的内容。但我们稍后会看到一些更有用的示例。
接下来的匹配器对我们来说更有趣:
{$x:expr} => {
{
let mut v = Vec::new();
v.push($x);
v
}
};
这次我们告诉 Rust,我们想要匹配任何单一的 Rust 表达式 (expr
) 并将其绑定到一个名为 x
的值上。前置的美元符号 $
是重要的,因为它表示这是一个宏变量。如果没有它,Rust 会认为这只是另一个字面值,在这种情况下会有一个匹配(即 my_vec![x:expr]
)。除了表达式,这是常见的匹配目标,你还可以匹配标识符、字面值、类型等。
元变量
在 Rust 术语中,expr
被称为元变量或片段说明符(fragment specifier)。这些元变量中最强大的一个是 tt
(TokenTree),它可以接受几乎任何你传递给它的内容。这是一个强大的选项。但它的全面性也可能是一个缺点。对于更简单的类型,Rust 可以捕捉错误,比如当你传递一个字面值时,而宏只匹配一个标识符。此外,使用 tt
时,你的匹配器变得不那么精细,因为它会说“给我任何你有的东西!”出于同样的原因,tt
可能过于急切。许多东西都会匹配一个令牌树!这有点类似于正则表达式。\d+
只捕获一个或多个数字,而 .*
会捕获任何内容。但限制也是一种优势,使得 \d
更可预测且易于管理。在元变量的情况下,建议从更具体的类型开始,只有在证明需要时才使用像 tt
这样的东西。如果你确实需要它,考虑并仔细测试。
以下是所有片段说明符的列表。别担心;我们只会使用其中的有限子集来完成本章的目标:
block
—一个块表达式;即大括号中的语句。expr
—一个表达式;Rust 中各种各样的东西。ident
—一个标识符或关键字。例如,函数声明的开头(fn hello
)有一个关键字后跟一个标识符,我们可以使用ident
捕获它们。item
—像结构体、枚举、导入(“use 声明”)等。lifetime
—一个 Rust 生命周期('a
)。literal
—一个字面值,如数字或字符。meta
—属性的内容,例如Clone
或rename = "true"
。你可以在后续章节中对属性内容有一个更好的了解。pat
—一个模式。例如1 | 2 | 3
。pat_param
—类似于pat
,但可以有|
作为分隔符。因此规则($first:pat_param | $second:ident)
会工作,但($first:pat | $second:ident)
会告诉你在pat
后面不允许使用|
。这也意味着你需要做额外的工作来解析1 | 2 | 3
,因为它将其视为三个独立的令牌而不是一个。path
—一个路径;例如::A::B::C
,或Self::method
。stmt
—一个语句;例如,一个赋值语句(let foo = "bar"
)。tt
—一个令牌树;见之前的解释。ty
—一个类型,例如String
。vis
—一个可见性修饰符;例如pub
。
在转录器内部,我们创建了一个新的向量,添加了输入表达式,然后返回整个向量,其中现在包含了表达式作为唯一元素。这是基本的 Rust 代码,只有两件值得提及的事。第一件是我们必须在转录器内也使用美元符号。记住,通过 $
我们将 x
识别为一个宏变量。因此,我们告诉 Rust 将这个绑定到输入的变量推送到向量中。如果没有美元符号,Rust 会告诉你在这个作用域中找不到 x
的值,因为没有 x
,只有 $x
。
第二件需要注意的是额外的一对大括号。如果没有这些,大括号,Rust 会给你一个错误,说明期望表达式,发现了 let
语句。一旦你尝试将宏调用替换为其输出,这个原因就会变得清晰。考虑这个例子,它应该匹配我们当前的规则:let a_number_vec = my_vec!(1);
。我们知道 my_vec!(1)
将被替换为转录器的内容。所以由于 let a_number_vec =
将保持不变,我们需要一个可以赋值给 let
的东西——比如一个表达式。相反,我们得到了两个语句和一个表达式!Rust 怎么能将这些给 let
呢?一如既往,错误听起来很神秘,但完全有意义。解决办法就是将我们的输出转化为一个单一的表达式。大括号正是为了这个目的。以下是宏运行后的代码:
let a_number_vec = {
let mut v = Vec::new();
v.push(1);
v
}
是的,编写宏确实需要一些思考和(大量的)调试。但既然你选择了 Rust,你知道思考肯定是必不可少的。
我们现在差不多完成了基础部分!最后一个匹配器-转录器对是:
$[($x:expr),+] => (
{
let mut v = Vec::new();
$(
v.push($x);
)+
v
}
)
这基本上与之前的匹配器一样,只不过多了一些美元符号和加号。在我们的匹配器中,我们可以看到 $x:expr
现在被包裹在 $( ),+
中。这告诉 Rust 接受“一个或多个表达式,用逗号分隔。”作为程序员,你可能不会惊讶地听到,除了 +
,你还可以使用 *
表示零个或多个出现,以及 ?
表示零个或一个。像宏一样,正则表达式无处不在。+
只会捕获一个或多个匹配项,而 *
会捕获零个或多个匹配项。但限制也是一种优势,使得 \d
更加可预测和易于管理。对于元变量,建议从更具体的类型开始,只有在确实需要时才使用像 tt
这样的东西。如果你确实需要它,考虑并仔细测试。
以下是所有的片段说明符。别担心,我们只会使用其中的有限子集来完成本章的目标:
block
—一个块表达式;即大括号中的语句。expr
—一个表达式;Rust 中各种各样的东西。ident
—一个标识符或关键字。例如,函数声明的开头(fn hello
)有一个关键字后跟一个标识符,我们可以使用ident
捕获它们。item
—像结构体、枚举、导入(“use 声明”)等。lifetime
—一个 Rust 生命周期('a
)。literal
—一个字面值,如数字或字符。meta
—属性的内容,例如Clone
或rename = "true"
。你可以在后续章节中对属性内容有一个更好的了解。pat
—一个模式。例如1 | 2 | 3
。pat_param
—类似于pat
,但可以有|
作为分隔符。因此规则($first:pat_param | $second:ident)
会工作,但($first:pat | $second:ident)
会告诉你在pat
后面不允许使用|
。这也意味着你需要做额外的工作来解析1 | 2 | 3
,因为它将其视为三个独立的令牌而不是一个。path
—一个路径;例如::A::B::C
,或Self::method
。stmt
—一个语句;例如,一个赋值语句(let foo = "bar"
)。tt
—一个令牌树;见之前的解释。ty
—一个类型,例如String
。vis
—一个可见性修饰符;例如pub
。
在转录器内部,唯一改变的就是我们的 push
语句周围有一个类似的美元符号-括号-加号组合,不过这次没有逗号。这里,这也表示重复。“对于匹配器中的每个表达式,重复这些括号中的内容。”也就是说,为你找到的每个表达式写一个 push
语句。这意味着 my_vec![1,2,3]
将生成三个 push
语句。
注意:到现在为止,第三个匹配器-转录器对实际上已经被这个对覆盖了。但这个额外的对使得逐步解释变得更容易。
有很多不编译的替代方案。例如,也许你希望 Rust 足够聪明,能够自动识别你要将每个表达式推送到向量中。所以你移除了 $()+
只留下 $(v.push($x))+
——结果是编译器提示变量 x
在这个深度仍在重复。通过“重复”,编译器告诉你 x
包含多个表达式,而这在你的代码中似乎只假设了一个表达式要推送到 Vec
中。
如果你喜欢玩弄这些代码,就像我的一个审稿人一样,你最终会发现你可以在转录器中使用任何你想要的重复操作符,无论是在匹配器中使用什么。你可以用 ?
和 *
进行 push
,一切都会按预期工作,至少目前如此,因为这是 Rust 中的一个开放错误(参见 github.com/rust-lang/r… 以了解更多背景)。如果你想确保你的代码在未来的语言版本中不会因为这个问题而崩溃,你可以在你的文件中添加 #![deny(meta_variable_misuse)]
lint,但这可能会触发误报。
在我们结束这一部分之前,还有最后一点:当你尝试在宏内部做非法操作时会发生什么?如果你尝试混合整数和字符串作为输入,而 Vec
无法接受这些类型的输入呢?你的集成开发环境可能不会意识到任何问题。毕竟,传入的都是有效的表达式!但 Rust 不会被愚弄,因为它从宏的“规则”中生成“正常”代码。而这些代码必须遵守 Rust 的编译规则。这意味着,如果你尝试混合类型,你将会得到一个错误,提示期望 x
,发现 y
(具体的名字取决于你首先传入了什么)。
现在你已经了解了基础内容,我们可以继续探索更有趣的内容。
2.2 使用场景
在这一节中,我们将展示声明式宏如何增强应用程序的功能。在某些情况下,它们的用途非常直接:例如,它们帮助你避免编写样板代码,我们将在新的类型示例中看到这一点。但其他示例则展示了我们如何通过宏完成在其他任何方式下都很难或不可能完成的任务,比如创建领域特定语言(DSL)和流畅的函数组合,或向函数添加额外的功能。让我们开始吧。
2.2.1 可变参数和默认参数
首先,当我们遇到函数的限制时怎么办?例如,与 Java 或 C# 不同,Rust 函数不允许可变参数。一种可能的原因是,可变参数使编译器的工作变得更复杂。或者可能是这不是一个足够重要的特性。显然,关于将它们添加到语言中的讨论非常古老且极具争议(默认参数也是如此!)。无论如何,如果你确实需要可变参数,宏总是可以解决这个问题。事实上,我们的向量宏正是执行这个精巧技巧的。传入任意数量的参数,Rust 会生成代码来处理你的需求。
如果你来自许多允许重载或默认参数的编程语言,宏也能为你提供支持。例如,我有一个问候函数,我希望它默认问候为“Hello”,同时也允许更具创意的自定义问候。我可以创建两个函数,名字稍有不同,以覆盖这些情况。但令人烦恼的是,名字不同而功能相同。我们可以写一个问候宏来解决这个问题。
列表 2.2 在 greeting.rs 中问候他人,带有默认值
pub fn base_greeting_fn(name: &str, greeting: &str) -> String {
format!("{}, {}!", greeting, name)
}
macro_rules! greeting {
($name:literal) => {
base_greeting_fn($name, "Hello")
};
($name:literal, $greeting:literal) => {
base_greeting_fn($name, $greeting)
}
}
本章首次,我们的实现不在主函数所在的同一个文件中,而是放在了一个名为 greeting.rs
的单独文件中。为了在定义宏的文件之外使用宏,我们必须在 main
中添加 #[macro_use]
标记到模块声明上。
列表 2.3 在 main.rs 中使用我们的问候宏的示例
use crate::greeting::base_greeting_fn; #1
#[macro_use]
mod greeting; #2
fn main() {
let greet = greeting!("Sam", "Heya");
println!("{}", greet); #3
let greet_with_default = greeting!("Sam");
println!("{}", greet_with_default); #4
}
#1 导入 base_greeting_fn
#2 导入包含我们宏的模块。通过 #[macro_use]
注解,我们告诉 Rust 我们想要导入定义在那个文件中的宏。 #3 打印 "Heya, Sam!" #4 打印 "Hello, Sam!"
在更复杂的设置中,当 mod.rs
导入并重新导出模块时,你需要在“根”文件(即你的 main.rs
文件)和任何进行重新导出的 mod.rs
文件中都添加这个注解。但不用担心:Rust 会持续提示你是否在模块/导入中添加了 #[macro_use]
,直到你修复所有问题。这有时可能会很繁琐,但这种专注于保持事物私密的做法,除非明确公开,会迫使你考虑信息隐藏。但如我们之前提到的,这是一种旧的宏暴露方式。相反,你应该优先使用“使用声明”来重新导出宏——例如,pub(crate) use greeting
。这是你在较新的 Rust 代码中会遇到的做法。
注意,我们不得不将 base_greeting_fn
函数设为公共(并导入到我们的 main.rs
中)。仔细考虑,原因再次显而易见:我们的声明式宏是在主函数中展开的。在前一节中,我们已经了解到,我们可以将转录器的内容放在脑中,并用这些内容替换宏调用。在这种情况下,greeting!("Sam", "Heya")
被替换为 base_greeting_fn
。如果 base_greeting_fn
不是公共的,你就会尝试调用一个未知的函数。这种行为可能不是你所期望的(因为你可能希望宏是你所有节日问候的入口点),但这是 Rust 中宏和可见性的工作方式的逻辑结果。
2.2.2 多种代码扩展方式
我们暂时中断一下这个讨论,来进一步探讨代码扩展的问题。这个术语“扩展”是“用转录内容替换”的更正式的说法,因为尽管在脑中进行替换是很棒的,有时你还想看到真正发生了什么。为此,Rust 提供了一个很好的特性——追踪宏(trace macros),这本身就是一个声明式宏——“乌龟一直到脚下”。在撰写时的 Rust 版本 1.77.2 中,这一特性仍然是不稳定的,这意味着你需要将其作为一个功能激活,并使用 nightly 版本来运行代码。你可以使用 rustup default nightly
将 nightly 设为默认版本。或者——如果你希望保持稳定版本——你可以指示 Cargo 使用 nightly 版本运行特定的命令,使用 cargo +nightly your-command
。
以下代码展示了如何激活和停用追踪宏功能。
#![feature(trace_macros)] #1
use crate::greeting::base_greeting_fn;
#[macro_use]
mod greeting;
fn main() {
trace_macros!(true); #2
let _greet = greeting!("Sam", "Heya");
let _greet_with_default = greeting!("Sam");
trace_macros!(false); #3
}
#1 添加不稳定的追踪宏功能 #2 激活追踪宏 #3 停用追踪宏
这段代码是我们之前的代码,移除了 println!
语句,并添加了 trace_macros!
调用。使用 true
时,追踪宏被激活;使用 false
时,追踪宏被停用。在这种情况下,停用并非严格必要,因为我们已经到了程序的末尾。运行这段代码将打印如下内容:
--> ch2-trace-macros/src/main.rs:9:18
|
9 | let _greet = greeting!("Sam", "Heya");
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: expanding `greeting! { "Sam", "Heya" }`
= note: to `greeting("Sam", "Heya")`
--> ch2-trace-macros/src/main.rs:10:31
|
10 | let _greet_with_default = greeting!("Sam");
| ^^^^^^^^^^^^^^^^
|
= note: expanding `greeting! { "Sam" }`
= note: to `greeting("Sam", "Hello")`
日志显示了我们的默认值的工作情况!greeting!("Sam");
显然被转换为 greeting("Sam", "Hello")
。多么巧妙的设计。追踪宏特性现在为我们完成了所有的替换工作,这可以节省大量的思维精力。
另一个偶尔有用的工具是 log_syntax!
宏(在 1.77.2 中也不稳定),它允许你在编译时进行日志记录。如果你之前从未编写过宏,可能未曾考虑过这可能为何重要。作为一个小演示,我们可以向我们的 greeting
宏添加一个第三个选项。这个选项使用 log_syntax
来告诉用户接收到的参数是什么,调用 println!
来告知用户返回了默认问候,并调用 greeting
函数。所有这些都被包裹在一个额外的括号对中。这是因为我们的宏必须返回一个表达式以绑定到 let
。通过添加第二对括号,我们通过封装两个语句和表达式来创建一个表达式。
macro_rules! greeting {
($name:literal) => {
base_greeting_fn($name,"Hello")
};
($name:literal,$greeting:literal) => {
base_greeting_fn($name,$greeting)
};
(test $name:literal) => {{ #1
log_syntax!("The name passed to test is ", $name); #2
println!("Returning default greeting");
base_greeting_fn($name,"Hello")
}}
}
#1 双重括号是因为我们想用 { }
包围生成的代码,从而创建一个单一的表达式作为输出 #2 我们使用 log_syntax!
来记录我们的输入。
在我们的主文件中,除了添加另一个特性和一个调用到我们第三个匹配器的宏调用外,没有太大变化:
#![feature(trace_macros)]
#![feature(log_syntax)] #1
use crate::greeting::greeting;
#[macro_use]
mod greeting;
fn main() {
trace_macros!(true);
let _greet = greeting!("Sam", "Heya");
let _greet_with_default = greeting!("Sam");
let _greet_with_default_test = greeting!(test "Sam"); #2
trace_macros!(false);
}
#1 由于日志语法不稳定,我们需要用特性激活它。 #2 我们调用了 greeting
宏的新分支。
现在,如果你使用 cargo +nightly check
运行这段代码,你将看到 log_syntax!
输出(The name passed to test is Sam),因为它在编译时执行。只有当我们使用 cargo +nightly run
时,我们才会看到 log_syntax
输出和我们在宏内部添加的 println!
语句。这个差异对于调试发生在编译时的事情很重要。使用前者,你可以通过打印语句(客观上是最好的调试方法)来调试声明式宏。结合这些工具,你可以追踪从宏到扩展的 Rust 代码的路径。这不及真正调试器的功能,但总比没有要好。遗憾的是,你需要使用 nightly Rust。稍后,我们将看到一些与稳定 Rust 版本兼容的工具。
2.2.3 新类型(Newtypes)
声明式宏的另一个帮助方式是通过避免模板代码和重复来减少冗余。为探讨这个主题,我们将介绍新类型。新类型是来自函数式编程世界的一个概念。实质上,它们是一个现有值的“包装器”,迫使你的类型系统帮助你避免错误。假设我有一个计算薪资增加的函数。这个函数的问题在于,它不仅需要四个参数,而且第一个和第二个参数与第三个和第四个参数具有相同的类型。这意味着很容易犯愚蠢的错误。
fn calculate_raise(first_name: String,
_last_name: String,
_age: i32,
current_pay: i32) -> i32 {
if first_name == "Sam" { #1
current_pay + 1000
} else {
current_pay
}
}
fn main() {
let first_raise = calculate_raise(
"Smith".to_string(), #2
"Sam".to_string(),
20,
1000
);
println!("{}", first_raise); #3
let second_raise = calculate_raise(
"Sam".to_string(),
"Smith".to_string(),
1000, #4
20
);
println!("{}", second_raise); #5
}
#1 只有名字为 "Sam" 的人会获得加薪。 #2 哎呀,我把我的名字和姓氏搞混了。我没有加薪! #3 打印 "1000" #4 这次至少名字的顺序正确,我会得到加薪。但是尽管我已经一千岁了(!),我的加薪只有 $20。确实是个错误。 #5 打印 "1020"
通过为参数创建唯一的包装器(FirstName、LastName、Age、CurrentPay),类型系统可以防止我们犯这种错误。此外,我们的代码变得更具可读性,因为我们让一切更明确。这使得这种模式在清晰代码和领域驱动设计(见 2.2.4 节)倡导者中非常受欢迎。隐藏参数的“真实”值并赋予它们更有意义的类型,使得新类型非常适合于使公共 API 更易于理解和演变。
注 Rust 也有类型别名,可以为选定的类型创建一个替代名称(别名)。如果我们希望为 FirstName 创建别名,我们可以写 type FirstName = String;
。这可以使你的代码对其他开发人员更具可读性,他们现在可以看到你需要一个特定类型的字符串,即名字。类型别名通常用于你想要使其更易读和使用的更复杂的类型。Crates 通常有一个在所有地方使用的自定义错误。例如,syn crate 有 syn::Error
。因此,提供一个已经填充了默认 crate 错误的类型别名,例如 type Result<T> = std::result::Result<T, syn::Error>
,也是很方便的。这样,包中的代码可以使用 Result<T>
作为返回类型。但类型别名并不会使你的类型系统更智能:我仍然可以传递错误的类型作为别名。这就是新类型的优势。它们使你的代码更具可读性,并通过增加类型安全性减少错误。
struct FirstName(String); #1
struct LastName(String); #2
struct Age(i32); #3
struct CurrentPay(i32); #4
fn calculate_raise(first_name: FirstName,
_last_name: LastName,
_age: Age,
current_pay: CurrentPay) -> i32 {
if first_name.0 == "Sam" {
current_pay.0 + 1000
} else {
current_pay.0
}
}
fn main() {
let first_raise = calculate_raise(
FirstName("Smith".to_string()), #5
LastName("Sam".to_string()),
Age(20),
CurrentPay(1000)
);
println!("{}", first_raise);
let second_raise = calculate_raise(
FirstName("Sam".to_string()),
LastName("Smith".to_string()),
Age(1000),
CurrentPay(20)
);
println!("{}", second_raise);
}
#1 FirstName 是一个包含 String 的新类型。 #2 LastName 是另一个包含 String 的新类型。 #3 Age 是一个包含 i32 的新类型。 #4 CurrentPay 是另一个包含 i32 的新类型。 #5 你看到我用 FirstName 而不是 String。
现在 Rust 将检测到不一致的问题,使用编译器错误代替潜在的运行时错误。新类型使代码更具可读性,并避免了类型系统中潜在的错误。希望我已经有效地展示了 Rust 中新类型的好处。如果不需要使用你的新类型(即你不需要它们的类型安全性),使用类型别名可能已经足够了。但如果你需要更多的类型安全性,新类型是很好的选择。
我们现在编写的样板代码比之前少了;生成 get_value
方法的所有逻辑都集中在一个地方;我们使用的新类型越多,从宏中获得的收益也就越大。在更大的项目中,我们可以为附加的便利方法编写额外的宏,甚至可能有另一个宏调用所有其他宏。但这些留给读者作为练习。
注 在你开始自己编写太多代码之前,值得一提的是 derive_more
crate(docs.rs/derive_more…)。它可以自动为像这样的包装器实现一些基本的trait。不过,它使用的是过程宏,而非声明式宏。
在结束这一节之前,我应该提到 Rust 中使用新类型的一个特定原因:孤儿规则。如果你想在 Rust 中实现一个给定的 trait,那个 trait、类型(例如结构体)或两者都应该是本地的。这可以避免各种问题。如果我决定为 String
重新实现 Clone
,Rust 应该优先选择哪个实现?在一个应用程序中,也许优先选择本地实现是有意义的。但是如果有人在库中编写新的实现呢?Rust 应该优先选择标准库中的 Clone
实现,还是库 A、B …… X 中的实现?
另一方面,这些规则有时会阻止你做一些酷(或者说有用)的事情。所以解决办法是将非本地类型(String
)包装在一个新类型(MyString
)中。这个新类型是本地的,因此我们可以自由地为 MyString
添加 Clone
的实现。各种内置和自定义宏将使处理这些样板代码变得更容易。哦,还有,使用声明式宏进一步减轻负担呢?就像我们刚才做的那样?此外,新类型是一种零成本抽象:没有运行时开销,因为编译器应该会将其优化掉。
最后,由于我们刚刚看到宏可以自我调用,因此重要的是要补充说明,声明式宏的递归行为并不总是等同于函数或方法的递归行为。以以下代码为例:
macro_rules! count {
($val: expr) => {
if $val == 1 {
1
} else {
count!($val - 1)
}
}
}
如果这是一种函数,你会期望任何调用(例如,count!(1)
、count!(5)
……)都返回 1。遗憾的是,你会在编译时遇到递归限制错误。添加追踪宏,结果输出会揭示问题。以下是前几行输出:
= note: expanding `count! { 5 }`
= note: to `if 5 == 1 { 1 } else { count! (5 - 1) }`
= note: expanding `count! { 5 - 1 }`
= note: to `if 5 - 1 == 1 { 1 } else { count! (5 - 1 - 1) }`
= note: expanding `count! { 5 - 1 - 1 }`
= note: to `if 5 - 1 - 1 == 1 { 1 } else { count! (5 - 1 - 1 - 1) }`
$val - 1
没有被求值,因此我们永远无法达到终止条件。这并不是说具有未知数量参数的递归是不可能的。请参阅 2.2.5 节,了解如何使其工作。
2.2.4 领域特定语言 (DSLs)
声明式宏同样适合用于创建领域特定语言(DSLs)。正如在引言中提到的,DSLs 将开发者从领域专家那里学到的领域知识封装进应用程序中。这种捕捉领域知识的理念与领域驱动设计中的“普遍语言”概念有关,该概念认为专家和开发者使用相同的词汇和概念可以更容易地进行沟通,从而生成更好的代码。
DSL 的目标是创建一种适合领域的专用语言,并隐藏不相关的复杂性。这些复杂性可能包括额外的验证和处理某些细微差别。一些人认为,DSL 可以使代码库对非程序员也变得易于理解,例如,测试框架 Cucumber(github.com/cucumber-rs…)的一个想法就是用领域专家可以理解的语言编写测试。另一个想法是,专家甚至可以使用这些工具添加自己的测试!在Rust 生态系统中,小型 DSL 比比皆是,它们通常通过使用宏来创建。标准库中的两个简单示例是 println!
和 format!
,它们使用花括号提供特殊的语法来确定如何打印指定的变量。
作为示例,我们将编写一个处理账户间转账的 DSL。首先,我们创建一个包含账户余额的 Account
结构体。它还具有用于往账户中添加和移除资金的方法。我们只希望处理正数(因此使用 u32
),但处理账户负数超出本节范围。Account
还派生了 Debug
。这是一个普遍适用的好主意,虽然我们这里的动机只是为了打印一些结果。
use std::ops::{Add, Sub}; #1
#[derive(Debug)]
struct Account {
money: u32,
}
impl Account {
fn add(&mut self, money: u32) {
self.money = self.money.add(money)
}
fn subtract(&mut self, money: u32) {
self.money = self.money.sub(money)
}
}
#1 `Add` 和 `Sub` traits 允许我们在 `Account` 实现中对 `u32` 使用 `add` 和 `sub` 方法。
现在看看列表 2.13 中的 exchange
宏,它为用户提供了一个小型 DSL。如你所见,这个宏允许我们使用自然语言来描述动作,并且当宏不理解某个命令时,它会在编译时报告错误。此外,当在两个账户之间转账(第三对)时,我们隐藏了交易的复杂性。
macro_rules! exchange {
(Give $amount:literal to $name:ident) => {
$name.add($amount)
};
(Take $amount:literal from $name:ident) => {
$name.subtract($amount)
};
(Give $amount:literal from $giver:ident to $receiver:ident) => {
$giver.subtract($amount); #1
$receiver.add($amount)
};
}
fn main() {
let mut the_poor = Account {
money: 0,
};
let mut the_rich = Account {
money: 200,
};
exchange!(Give 20 to the_poor); #2
exchange!(Take 10 from the_rich);
exchange!(Give 30 from the_rich to the_poor);
println!("Poor: {the_poor:?}, rich: {the_rich:?}"); #3
}
#1 我们必须用分号结束 `$giver.subtract($amount)`,因为转录器有两个语句而不是一个,否则编译器会抱怨期待一个分号。
#2 使用自然语言指定交易
#3 这会打印 "Poor: Account { money: 50 }, rich: Account { money: 160 }"。
我们可以继续扩展这个 DSL。例如,可以添加货币类型并自动转换它们,或者对透支进行特殊规则。不过,需要良好的测试来确保宏场景不会相互“冲突”(即你以为会匹配到模式 X,但实际上却匹配到 Y)。作为一个简单的示例,以下宏用于给穷人送钱,并批评那些不给钱的人。我们调用我们的宏,却因为没有给钱而被夸奖。这是因为第一个分支接受任何字面量,包括零。由于宏按顺序检查模式并总是匹配第一个更通用的分支,第二个分支从未被匹配到。
macro_rules! give_money_to_the_poor {
(Give $example:literal) => {
println!("How generous");
};
(Give 0) => {
println!("Cheapskate");
};
}
fn main() {
give_money_to_the_poor!(Give 0); #1
}
#1 打印 "How generous"!这不是我们预期的结果。
解决方案(在这种情况下)很简单。只需交换这些情况的顺序即可!基本规则是:从最具体到最不具体编写宏规则/匹配器。这条规则同样适用于使用 match
的模式匹配,尽管编译器不会强制执行顺序,只会发出警告。(尝试将 catchall _
放在其他匹配项之前。)更令人担忧的是,忽略这条规则仍会导致有效的宏实现,Rust 甚至不会发出警告。因此,静态检查不足以找出这些错误,你应该测试所有“分支”。
2.2.5 组合是容易的
除了避免样板代码外,宏还帮助我们完成那些在普通 Rust 中优雅、困难甚至不可能完成的事情。(你可以将 DSL 视为这种更一般类别的特定示例。)以组合为例,组合是函数式编程的一个常见特性,也是面向对象编程中的一种设计模式。组合允许你将简单的函数组合成更大的函数,从较小的构建块创建更大的功能。当你想保持函数简单、易于理解和测试时,这是一种非常有趣的构建应用程序的方式。它在避免对象交互的应用程序构建范式中作为一种组合组件的方式也很有用。为了更具体一点:假设我们有三个函数,用于递增一个数字、将数字转换为字符串以及在字符串前加前缀。这三个函数如下所示:
fn add_one(n: i32) -> i32 {
n + 1
}
fn stringify(n: i32) -> String {
n.to_string()
}
fn prefix_with(prefix: &str) -> impl Fn(String) -> String + '_ {
move |x| format!("{}{}", prefix, x)
} #1
#1 `String + _` 在返回类型中是必要的,因为我们传递了一个 `&str`,而这个生命周期必须被显式指定。
下面的伪代码展示了我们如何像在其他语言中一样组合这些函数。我们将这三个函数传递给 compose
,并返回一个期望一个输入的新函数。这个输入与我们传入的第一个函数的参数类型相同。在我们的例子中,这个函数是 add_one
,它期望一个 i32
。在内部,我们的组合函数将这个参数传递给 add_one
,它将输出一个递增的数字。这个递增的数字被传递给下一个函数 stringify
,它将数字转换为字符串。最后,这个字符串传递给 prefix_with
。由于这个最后的函数需要两个参数,一个前缀和一个输入字符串,我们已经传入了一个前缀。在函数式语言中,这个函数已经被部分应用:
rust
复制代码
fn main() {
let composed = compose(
add_one,
stringify,
prefix_with("Result: ")
);
println!("{}", composed(5)); #1
}
#1 这应该打印 "Result: 6"。
而且你并不限于几个函数!你可以继续添加更多,只要它们接受与前一个函数输出匹配的单个参数,并返回一个下一个函数接受的值。
注 在一些实现中,compose
调用函数的顺序可能会被反转(即,从最右边的参数到最左边)。在这种情况下,你首先提供最后一个函数,然后以第一个函数结尾。
但是我们如何编写这个 compose
呢?我们将从两个输入函数的简单情况开始。实现这个功能不需要宏,只需大量的泛型。compose_two
(参见列表 2.16 的代码)需要两个函数作为参数,这两个函数都接受一个参数并返回一个结果。第二个函数必须接受第一个函数的输出作为其唯一输入:
fn compose_two<FIRST, SECOND, THIRD, F, G>(f: F, g: G)
-> impl Fn(FIRST) -> THIRD #1
where F: Fn(FIRST) -> SECOND, #2
G: Fn(SECOND) -> THIRD #3
{
move |x| g(f(x)) #4
}
fn main() {
let two_composed_function = compose_two(
compose_two(add_one, stringify),
prefix_with("Result: ")
);
} #5
#1 `compose_two` 是一个接受两个泛型参数(即 `f` 和 `g`)的函数,并返回一个函数(或实现函数的东西),它接受一个泛型参数并返回另一个泛型参数。
#2 在这个 `where` 子句部分,我们确定 `f` 是一个接受泛型参数 `FIRST` 并返回泛型结果 `SECOND` 的函数。`FIRST` 成为 `compose_two` 的输入。
#3 在 `where` 子句中,我们还决定 `g` 接受 `SECOND` 并返回 `THIRD`。`THIRD` 是 `compose_two` 的输出。
#4 给定一个参数,将其传递给我们收到的第一个函数,并将该结果传递给第二个函数,产生最终结果 `THIRD`。
#5 重复调用 `compose_two` 来组合我们之前的三个函数。
这是第一步。我们如何让它适用于多个函数呢?一种方法是为最常见的参数数量编写实现:compose_three
、compose_four
等,直到 compose_ten
。这应该覆盖 90% 的所有情况。如果需要更多,我们也可以开始嵌套现有的实现(例如,将 compose_ten
嵌套在另一个 compose_ten
中)。这是一个不错的解决方法,并且在纯 Rust 工具中很难做得更好。假设我们决定编写一个接受函数向量作为参数的 compose
。我们需要一种方法来告诉编译器每个函数在向量中都接受前一个函数的输出作为输入,否则代码将无法编译。这在 Rust 中很难表达。
但是,在声明式宏中表达这个思想是微不足道的(见列表 2.17)。我们逐步讲解一下。如果我们的 compose
宏收到一个表达式,即一个函数,它就返回这个表达式——这是一个简单的基本情况。如果宏被调用时有两个或更多参数(注意 $tail
后的 +
,这是针对尾部有多个表达式的情况),我们需要递归魔法。我们做的是调用 compose_two
并传入两个所需的参数。第一个是我们收到的第一个函数 head
。第二个是调用我们的 compose
宏的结果,这次是对剩余参数的调用。如果这个第二次 compose
调用收到一个表达式,我们就会进入第一个匹配器,我们只需要用简单的 compose_two
调用来组合两个函数。在另一种情况下,我们会再次进入第二个分支,并返回一个函数,该函数是再次用 compose_two
得到的结果。实际上,这就是我们之前建议的通过嵌套来完成的,除了现在宏在后台处理所有事情(见图 2.2)。
macro_rules! compose {
($last:expr) => { $last };
($head:expr,$($tail:expr),+) => {
compose_two($head, compose!($($tail),+))
}
}
fn main() {
let composed = compose!(
add_one,
stringify,
prefix_with("Result: ")
);
println!("{}", composed(5)); #1
}
#1 打印 "Result: 6"
传递给宏的参数不仅可以使用逗号进行分隔。类似于括号,你还有其他选项。在 expr
的情况下,你也可以使用分号或箭头。然而,如果你是 Haskell 爱好者并希望使用句点进行组合,那就不行了。你会得到一个错误,提示 $head:expr
后跟 .
,而这对于 expr
片段是不允许的。不过,其他宏输入类型,如 tt
,有更多的选项。
列表 2.18 使用箭头分隔宏输入的替代方法
macro_rules! compose_alt {
($last:expr) => { $last };
($head:expr => $($tail:expr)=>+) => {
compose_two($head, compose_alt!($($tail)=>+))
} #1
}
fn main() {
let composed = compose_alt!(
add_one => stringify => prefix_with("Result: ")
); #2
println!("{}", composed(5));
}
#1 在第二个匹配器中,我们用箭头替换了逗号。
#2 现在我们在调用中也使用了箭头。
2.2.6 另一方面,柯里化...
假设你现在已经对 Rust 的函数式风格感到非常着迷,但你发现 compose
的“一输入”要求让你很烦恼,因为实际上,你经常有需要同时传递多个参数的函数,而组合函数不能处理这种情况。幸运的是,你发现了柯里化(currying)。柯里化指的是将一个需要多个参数的函数转化为一个“递归的”函数,这个函数一次只接受一个参数。例如,柯里化将:
Fn(i32, i32) -> i32
转化为:
Fn(i32) -> Fn(i32) -> i32
这使得函数的部分应用,即只提供函数所需参数的一部分,变得更加容易,从而使得组合变得更简单。你希望在 Rust 中实现这一点。经过你对组合的积极体验后,你认为声明式宏是实现的途径。
然而,你很快会发现使用声明式宏进行柯里化要困难得多。一个问题是函数签名的“可见性”缺失:它需要什么参数,返回什么?回到我们之前的方法,我们可以从最简单的情况开始,一个 curry2
函数,用于将一个具有两个参数的函数转化为柯里化版本。现在我们只需递归调用这个函数。但我们应该(或者说宏应该)调用多少次呢?没有函数签名,我们一无所知,而使用组合时,我们知道需要多少次调用,因为函数作为参数传递给了宏。
虽然组合很简单,但柯里化却很困难。尽管显式地传递细节会有帮助,但这正是我们试图避免的繁琐工作。由于闭包对其参数数量是明确的,因此处理起来更容易。因此,有一个用于柯里化闭包的 crate(mng.bz/v8Pm)。看一下该 crate 中最简单的规则:
macro_rules! curry {
(|$first_arg:ident $(, $arg:ident )*| $function_body:expr) => {
move |$first_arg| $(move |$arg|)* {
$function_body
}
};
// ...
}
这个匹配器期望在管道符(| |)内有一个或多个标识符,后跟一个表达式。以调用 curry!(|a, b| a + b);
为例。a
和 b
是我们的两个标识符,a + b
是我们的表达式。转录者只需为这些标识符添加一个 move
,并将其传递给函数。在我们的示例中,这变成 move |a| move |b| a + b;
。突然间,我们有了两个闭包,每个闭包接受一个参数。但再次强调,这样做是因为我们需要了解的所有信息都作为参数传递了。普通函数在其签名中包含这些信息,这使得找到好的解决方案更困难(虽然可能不是不可能)。相反,正如博客文章《Auto-currying Rust Functions》(peppe.rs/posts/auto-…)所示,过程宏提供了合适的工具。尽管结果比本章的平均声明式宏更复杂,代码行数更多,但实际解决方案仍然相当简短:不到100 行。
2.2.7 卫生性也是需要考虑的因素
你应该知道,并不是所有生成的内容都会按原样添加到你的代码中,因为声明式宏有一个标识符的卫生性概念。简单来说,你宏中的标识符总是不同于宏外部的标识符,即使它们的名称相同,这意味着一些事情是不可能的。例如,我不能让 generate_x!()
输出 let x = 0
,然后在我的普通代码中递增这个命名的变量。如果我尝试这样做,Rust 会抱怨在这个作用域中找不到值 x
。这是因为我在宏中初始化的 x
不是我在应用程序中尝试递增的 x
。
我这样描述卫生性,听起来像是不好的东西。但它作为防止污染的保护措施是有用的。意图的差异是一个需要考虑的因素。如果我通过输入获得一个标识符并编写实现块,我想要影响宏外部的代码,否则这个实现有什么意义呢?但宏内部创建的标识符可以服务于其他目标。也许我想进行计算或将东西推入一个向量中。这些操作与我在宏外部做的事情是独立的。由于开发者通常选择易于理解的变量名,因此宏的用户可能在自己的代码中使用相同的变量名。所以,当你看到编译器关于“未解析”和“未知”标识符的错误,且这些错误与宏代码有关时,请记住这种行为。如果你想对一个标识符产生影响,只需将其作为参数传递给宏即可。
2.3 现实世界中的应用
曾几何时,Rust 并没有一个很好的内建方式来创建懒初始化的静态值。但懒静态值有几个有用的优点。首先,也许你静态变量中的值需要大量计算。如果最后发现这个值从未被使用,你就不需要支付它的初始化成本。另一个优势是初始化发生在运行时,这比编译时提供了更多的选项。(是的,这与本书中常常倾向于编译时的偏好相矛盾。正如在软件中常见的那样,这取决于情况。编译时工作让事情更安全、更快速。但在运行时,你可以做一些在编译时无法做到的事情。)看到这一缺陷,lazy_static
(docs.rs/lazy_static… once_cell
。因此,lazy_static
已不再是推荐的 crate。但这并不意味着它的代码就不再有趣了!
列表 2.19 来自文档的懒静态示例
lazy_static! {
static ref EXAMPLE: u8 = 42;
}
在这个 crate 的核心是 lazy_static
宏。一个 #[macro_export]
确保我们可以在这个文件外使用它。匹配器可以有可选的元数据(即元信息)(请注意下一个列表中的星号),而 static ref
作为输入是必需的。static
还有一个标识符、一个类型和一个初始化表达式,以及一些可选的附加信息(以 TokenTrees
的形式)。大多数传入的变量会被简单地传递给一个内部宏 __lazy_static_internal
。但为了避免解析歧义,()
被添加为(默认)可见性修饰符的指示器。(在宏的其他匹配器中,(pub)
被传递以表示公共可见性。)
列表 2.20 lazy_static
宏的入口点,稍微简化
#[macro_export]
macro_rules! lazy_static {
($(#[$attr:meta])* static ref $N:ident : $T:ty = $e:expr; $($t:tt)*) => {
__lazy_static_internal!($(#[$attr])* () static ref $N : $T = $e; $($t)*);
};
// other arms...
}
大部分实现隐藏在内部宏中,如列表 2.21 所示。你可以将 @MAKE TY
和 @TAIL
视为迷你(nano?)DSL。它用于确保宏中的其他匹配器-转录器对被调用,这种模式出现在《The Little Book of Rust Macros》(veykril.github.io/tlborm/)中。两个额外的分支中的第一个(@MAKE]() TY
)如其名称所示,负责创建类型,它只是一个带有空内部字段的结构体,并且附有原始的元数据(以免丢失)。第二个分支(@TAIL
)则创建一个 Deref
和初始化。这是魔法发生的地方。如果你在代码中的某处需要懒初始化的静态值,你将会在开始使用它时进行解引用。就在那时,你提供的初始化表达式将被执行。你可以看到该表达式( $e
)被传递给 __static_ref_initialize
函数。在这一切之下, Once
,来自 spin
库(docs.rs/spin/latest…),被用来确保这个初始化只发生一次。这是在lazy_static_create
宏中完成的,这个宏在生成的deref
内部被调用。
列表 2.21 lazy_static
内部实现,简化版
macro_rules! __lazy_static_internal { #1
($(#[$attr:meta])* ($($vis:tt)*) static ref $N:ident : $T:ty = $e:expr; $($t:tt)*) => { #2
__lazy_static_internal!(@MAKE TY, $(#[$attr])*,
($($vis)*), $N); #3
__lazy_static_internal!(@TAIL, $N : $T = $e); #4
};
(@TAIL, $N:ident : $T:ty = $e:expr) => {
impl $crate::__Deref for $N { #5
type Target = $T;
fn deref(&self) -> &$T {
fn __static_ref_initialize() -> $T { $e }
fn __stability() -> &'static $T {
__lazy_static_create!(LAZY, $T);
LAZY.get(__static_ref_initialize)
}
__stability()
}
}
impl $crate::LazyStatic for $N { #6
fn initialize(lazy: &Self) {
let _ = &**lazy;
}
}
};
(@MAKE TY, $(#[$attr:meta])*, ($($vis:tt)*), $N:ident) => {
$(#[$attr])*
$($vis)* struct $N {__private_field: ()}
$($vis)* static $N: $N = $N {__private_field: ()};
};
}
macro_rules! __lazy_static_create {
($NAME:ident, $T:ty) => {
static $NAME: $crate::lazy::Lazy<$T> = $crate::lazy::Lazy::INIT;
};
}
#1 这是一个名为 lazy_static_internal
的声明式宏的声明。 #2 第一个匹配器期望可选的属性、可选的可见性、一个字面量“static ref”值、一个标识符、类型和表达式。最后,再次可选地,我们将其他所有内容捕获在 TokenTree
中。 #3 我们调用自己的宏,而 @MAKE TY
字面量确保我们最终进入最后一个分支。 #4 类似地,这里 @TAIL
确保我们进入下一个分支。 #5 这包含了 lazy_static
的很多魔法,使用 Deref
初始化我们的静态变量。 #6 这里使用 $crate
确保与其他地方定义的 LazyStatic
不发生冲突(即,我们只想要在我们的 crate 中定义的 LazyStatic
)。
现在你已经了解了 lazy_static
crate 工作原理的 80%(需要引用)!
转载自:https://juejin.cn/post/7406148257784414271