likes
comments
collection
share

编写强大的 Rust 宏——元编程本章内容包括: 什么是元编程 Rust 中的元编程 何时使用宏 本书将教会你什么 宏是

作者站长头像
站长
· 阅读数 29

本章内容包括:

  • 什么是元编程
  • Rust 中的元编程
  • 何时使用宏
  • 本书将教会你什么

宏是 Rust 提供的一些最重要且最强大的工具。由于它们拥有 Rust 正常工具(如函数)所不具备的能力,它们可以作为“在其他光源熄灭时的指引”。仅此一点就足以使宏成为本书的核心主题。然而,宏还有另一个有趣的特点:它们可以成为通往其他能力的途径。当你想编写宏时,你需要掌握测试和调试的知识。你必须知道如何设置库,因为你无法在不创建库的情况下编写过程宏。此外,了解 Rust 内部结构、编译、类型、代码组织、模式匹配和解析等知识也很有用。因此,讲解宏使我能够涉及其他编程话题。我们将学习 Rust 宏,并利用它们来探索其他主题。

但我们还是提前了些。让我们退一步,从头开始。

1.1 Rust 开发者的一天

你是一名 Rust 开发者,正在开始一个新的应用程序,该程序将接受包含用户数据的 JSON 请求,比如名字和姓氏,并输出有用的信息,比如用户的全名。你首先添加了一个函数,该函数基于名字和姓氏的组合生成全名,使用 format!。为了将 JSON 转换为结构体,你在 Request 上添加了 #[derive(Deserialize)] 注解。你总是编写测试,因此添加了一个测试函数,并用 #[test] 属性进行了标注。为了确保一切符合你的预期,你使用了 assert_eq!。当出现问题时,你要么使用调试器,要么添加一些日志信息,使用 dbg!

清单 1.1 你刚刚编写的程序

use serde::Deserialize;

#[derive(Deserialize)]           #1
struct Request {
  given_name: String,
  last_name: String,
}

fn full_name(given: &str, last: &str) -> String {
  format!("{} {}", given, last)       #2
}

fn main() {
  let r = Request {
     given_name: "Sam".to_string(),
     last_name: "Hall".to_string()
  };
  dbg!(full_name(&r.given_name, &r.last_name));    
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn test_deserialize() {
    let actual: Request =
        serde_json::from_str("{ "given_name": "Test", "last_name": "McTest" }")
            .expect("deserialize to work");

    assert_eq!(actual.given_name, "Test".to_string());    
    assert_eq!(actual.last_name, "McTest".to_string());   
  }
}

#1 一个过程宏 #2 这段代码包含了几个声明式宏。

突然间,你意识到。即使是在编写最简单的 Rust 代码时,你也无法停止使用宏。你被 Rust 的元编程成果包围着。

1.2 什么是元编程?

简而言之,元编程是指你编写的代码将使用其他代码作为数据输入。这意味着你可以操控现有的代码,生成额外的代码,或为应用程序添加新功能。为了实现元编程,Rust 提供了宏,这是元编程的一种特定形式。Rust 的宏在编译时运行,展开成“正常”的代码(如结构体、函数等)。当这一过程完成后,你的代码就准备好进入下一步,比如被 lint 检查、类型检查(使用 cargo check)、编译成可链接的库,或由 rustc 转换成可运行的二进制文件(见图 1.1)。

编写强大的 Rust 宏——元编程本章内容包括: 什么是元编程 Rust 中的元编程 何时使用宏 本书将教会你什么 宏是

图 1.1 我包含了无数的代码!我们的简单示例隐藏了许多由宏生成的额外代码行。

Rust 不是唯一提供元编程能力的语言。C 和 Clojure 也有强大的宏,其中 C 还提供了模板。Java 具有反射功能来操作类,这使得 Spring 框架能够开发出一些最令人印象深刻的功能,包括使用注解进行依赖注入。JavaScript 有 eval 函数,该函数接受一个字符串作为数据,并在运行时将其作为指令进行评估。在 Python 中,你不仅可以使用 eval,还可以使用更好的选项,如 metaclasses 和非常流行的装饰器,这些装饰器可以操作类和函数。

1.3 Rust 中的元编程

在职业生涯的某个阶段,大多数程序员都会接触到某种形式的元编程——通常是为了做一些使用常规编程工具难以完成的任务。但除非你编写大量的 Common Lisp 或 Clojure 代码(这些语言中的宏非常流行),否则这种经验通常会比较有限。那么我们为什么要关心 Rust 中的元编程呢?因为 Rust 与众不同。这可能是你听到过很多次的说法,但请耐心听我讲解!

与许多其他语言相比,第一个不同之处是,类似于 Clojure,很难想象没有宏的 Rust 代码。宏在标准库(例如 ubiquitous dbg!println!)和自定义 crate 中得到了广泛使用。在撰写本文时(2024 年中期),在 crates.io 上下载量排名前十的包中,有三个是用于创建过程宏的(synquoteproc-macro2)。另一个是 serde,它的过程宏简化了序列化工作。或者搜索关键词“derive”,这通常意味着包中包含了宏。你会得到超过 10,661 个结果,约占所有包的 7%!简而言之,在 Rust 中,宏不仅仅是语法糖,而是核心功能。

为什么这么多人编写宏呢?因为在 Rust 中,它们提供了一种非常强大的元编程形式,而且相对容易和安全使用。部分安全性来源于它作为编译语言的特点。相比之下,Clojure 是一门困难的语言(在我看来),虽然宏易于使用,但缺乏编译时检查的帮助。JavaScript 和 Python 也有类似的情况。对于 JavaScript,安全性/安全是 Mozilla 文档中“永远不要使用直接 eval()”建议的一个重要原因(mng.bz/4JMv)。

同时,Rust 的所有宏都是在编译时评估的,并且必须经过语言用来验证代码的彻底检查。这意味着你生成的代码和正常代码一样安全,你仍然可以享受到编译器准确告诉你错误原因的好处!特别是对于声明式宏,宏的卫生性是安全性的一部分,避免了与代码中其他部分使用的名称发生冲突。这意味着你比 C 语言宏获得了更多的安全性,因为 C 语言宏是不卫生的,允许宏无意中引用或捕获来自其他代码的符号。C 语言宏的安全性也较低,因为它们在类型信息不可用时被展开。虽然模板更安全,但返回的错误可能会比较难以理解。

在编译时完成所有操作的另一个优势是,对最终二进制文件的性能影响在大多数情况下是微不足道的。你只是在添加一点代码,没必要为此失眠。(与此同时,编译时间显然会受到影响,但无论是否使用宏,这些时间都令人烦恼地长。)相比之下,Java 中,前述的 Spring 框架在启动时进行大量的反射操作以进行依赖注入。这意味着性能会受到影响,并且元编程变得——我听起来像破碎的唱片——不那么安全,因为你只有在运行时才能知道一切是否正常,可能只有在进入生产环境时才会发现问题。

最后,对我而言,元编程有时可能过于“神秘”,比如应用程序中的一个 Spring Bean 会在完全不同的部分改变行为。尽管 Rust 的宏可能看起来很神秘,但与 Spring 的“运行时的鬼魅行动”相比,它们的“神秘性”要少得多。因为 Rust 中的宏是 (a) 更加局部化的,并且 (b) 在编译时运行,这使得检查和验证更容易。

1.3.1 宏的丰富多彩

为了让局部化的论点更加具体,让我介绍一下本书的主要角色之一——过程宏。过程宏将你的代码作为一个令牌流处理,并返回另一个令牌流,这将与代码的其他部分一起由编译器处理。这种低级别的操作与更为人知的声明式宏的方法形成对比(声明式宏将在下一章介绍)。声明式宏允许你使用更高的“声明性”抽象级别来生成代码。这使得声明式宏成为一个安全且容易上手的选项——即使它们缺乏过程宏的原始力量。

令牌流、宏展开:正如你可能猜到的,我们将在接下来的章节中更深入地讨论这些内容。

过程宏有三种类型(见图 1.2)。第一种是 derive 宏。你可以通过在结构体、枚举或联合体上添加 #[derive] 属性来使用它们。完成后,该结构体/枚举/联合体的代码将作为输入传递给宏。这个输入不会被修改。相反,宏会生成新的代码作为输出。这些宏用于通过添加函数或实现 traits 来扩展类型的功能。因此,每当你看到 #[derive] 装饰一个结构体时,你知道它正在为该特定结构体添加某种额外的功能。没有功能被添加到应用程序的随机部分,结构体本身也没有以任何方式被修改。尽管(或许正因为)这些限制,这些宏可能是最广泛使用的过程宏。

第二种类型是属性宏,可以放置在结构体、枚举、联合体、trait 定义和函数上。它们的名称来源于它们定义了一个新的自定义属性(一个著名的例子是 #[tokio::main]),而 derive 宏则需要使用 #[derive]。属性宏更强大,因此也更危险,因为它们会转换它们装饰的项目:它们生成的输出会替代输入。虽然 derive 宏只是添加功能,而属性宏则可能改变类型的定义。但至少注解会告诉你它正在转换哪个结构体,并且不会改变其他代码和其他文件。

编写强大的 Rust 宏——元编程本章内容包括: 什么是元编程 Rust 中的元编程 何时使用宏 本书将教会你什么 宏是

第三种类型是函数式宏。它们通过 ! 操作符调用,并处理你传递的任何输入。这个输入会被替换成你生成的输出,与属性宏类似。但与其他宏不同,函数式宏不限于注解结构体或函数。你可以在代码中的几乎任何地方调用它。这可以产生一些强大的效果。但——你可能已经知道我要说什么——这种魔法的输入是你决定传递的内容。Rust 再次似乎找到了一种方式,让一个已知的编程概念变得更安全。

1.3.2 适当的使用场景

“好的,既然宏如此棒且安全,我应该在所有事情上都使用它们。” 哇,慢点,稻草人!显然,你应该在开始任何应用程序时避免使用自定义宏。Zero to Production in Rust(www.zero2prod.com/index.html)构建了一个可部署的新闻通讯应用程序,而没有写过任何宏(尽管作者使用了许多语言和库提供的宏)。结构体、枚举和函数更容易理解和使用,简单明了。虽然宏对运行时性能的影响不大,但它们仍然会增加编译时间和二进制文件的大小。而前者已经是Rust 开发者报告的最大痛点之一!对于本书中的小型宏,这种编译时成本可以忽略不计。但对于许多“生产级”宏,权衡会变得现实,但希望值得。

那么,什么时候以及为什么使用宏呢?在较大的应用程序中,它们可能会因为减少样板代码而显得诱人。但这可能会使代码对不熟悉项目的人来说更难理解,因为与函数相比,宏的签名无法提供任何关于正在发生什么的见解。此外,读者往往有更多经验来理解普通的 Rust 代码,因此即使他们需要“深入”函数定义,也会花更少的时间来理解其要点。而且泛型函数是避免重复的好工具,因此它们提供了一个有效的替代方案。类似地,泛型实现块(或称为通用实现)也非常强大。只需查看列表 1.2 中的代码示例,这是一种“扩展 trait”模式的例子,将自定义 trait 与通用实现结合起来。我们为所有实现了 Copy 的类型实现我们的 trait。数字、字符、布尔值等,突然有了一个新函数。我们可能应该对使用通用实现以及宏保持警惕。

列表 1.2 泛型的力量与危险

trait Hello {
    fn hello(&self);
}

impl<T:Copy> Hello for T {
    fn hello(&self) {
        println!("Hello world");
    }
}

fn main() {
    2.hello();
    true.hello();
    'c'.hello();
}

所以,第一个要点:避免样板代码以及重复是使用宏的一个很好的理由,但前提是它不会使代码难以理解。如果为了使用宏,开发者经常查看实现,这就不好了。考虑标准库中提供的宏:DebugCloneDefault 等。它们都为一个定义明确的重复任务(例如,Clone 只做一件事:使你的对象可克隆)完成了繁重的工作。作为额外的好处,开发者在阅读你的代码时会立即理解你的意图,当他们看到 #[derive(Clone)] 属性时。他们可能不会关心实际的实现细节。这是完美的,因为它避免了深入代码的额外心理负担。这种避免重复的方法比某些语言提供的自动代码生成要好得多。是的,代码生成可能通过为你的应用程序添加有用的样板代码来帮助编写代码。但它增加了噪音,使代码更难阅读。而编写代码通常不是编程中的难点。使后来的人能够理解你的代码才是。

我在阅读 sqlx 提供的 Decode trait 时,直觉地想到:“这个 trait 可能有一个 derive 宏,看起来是一个完美的用例。” 果然,确实有一个 derive 宏可用。

因此,寻找那些从整体视角很容易描述的重复任务(“克隆这个,复制那个,打印它”),并且其输出是可预测的(调试会打印结构体的每个属性)。这些通常是具有普遍吸引力的任务,对许多应用程序有用且易于理解。例如,确保某物可以与相同类型的其他物品进行比较(PartialEq)是大多数开发者都会遇到的常见任务。函数也可以帮助抵御重复,但它们不能操作结构体或添加辅助方法。(通用实现可以,但它们限于通过 traits 工作。)在标准库之外,你可以找到许多其他示例,这些示例有助于避免重复和样板代码,同时易于描述并产生可预测的结果。Serde 允许轻松序列化/反序列化结构体。Tokio 管理创建异步 main 函数的样板代码。

另一个转向宏的理由,与前述类别密切相关,是易用性。你希望去掉开发人员在编写应用程序时不需要了解的任务的无聊技术细节。你可以认为 SerdeTokio 属于这一类别,因为它们隐藏了序列化和异步行为的细节。你很少需要深入了解这些宏的底层实现;大多数情况下,它们“只会工作”。如何实现——再次为读者和编写者带来的好处。此外,Clap 隐藏了解析命令行参数的细节和样板代码,Rocket 使用宏来隐藏 REST 应用程序的复杂性。

最后一个用例是模拟 Rust 中不可用的功能。我们将在下一章中看到一个例子,说明声明式宏如何将可变参数添加到语言中。在核心语言之外,Tokio 再次值得一提,因为它允许你有异步的 main 函数。但在这一类别中还有很多其他例子。静态断言也可以在不运行代码的情况下对你的代码做出保证,例如检查结构体是否实现了给定的 traits。SQLx 让你编写 SQL 字符串,检查它们在编译时是否有效,并将结果转换为结构体。YewLeptos 允许你在 Rust 中编写类型检查的 HTML。Shuttle 基于注解为你设置云基础设施。显然,很多这些都提供了编译时的验证。毕竟,这是宏运行的时候。但它也是检查和验证的最有趣的时机,比起进行更昂贵、更耗时的操作,如单元测试、集成测试、端到端测试,甚至生产环境测试(见图 1.3)。这些在现代应用程序构建中都有其位置。然而,当一个简单的 cargo check 可以在运行任何测试之前指出错误时,你节省了很多时间和精力。此外,所有编译时的操作对用户来说都是性能上的胜利。

编写强大的 Rust 宏——元编程本章内容包括: 什么是元编程 Rust 中的元编程 何时使用宏 本书将教会你什么 宏是

除了验证之外,这一类别的宏还增加了领域特定语言(DSL)的能力,使你能够以比原生 Rust 更简单、更优雅的方式编写代码。使用宏来创建 DSL 也对那些希望以更接近业务专家语言的方式表达思想的应用开发者来说很有趣。当做得好时,这种类型的宏也属于易用性类别。

定义 什么是领域特定语言(DSL)?我们程序员最熟悉的编程语言是通用语言,适用于几乎任何领域。你可以使用 JavaScript 编写代码,无论你在什么行业工作。但是,DSL 是针对特定领域编写的。想象一下 SQL,它专门用于与数据库交互。这意味着创建者可以专注于使业务概念更容易表达。如果你在为银行编写语言,你可能会让开发者(甚至最终用户)非常容易地编写代码来在账户之间转账。DSL 还可以让你进行优化。如果你在为 DNA 处理编写语言,你可以假设只需要四个字母(A、C、G、T)来表示你的数据,这可能允许更好的压缩(而且由于 A 总是与 T 配对,G 与 C 配对,也许你只需要两个选项)。DSL 有两种类型:一种是从头开始创建的,另一种是使用像 Rust 这样的通用语言作为基础创建的。在本书中,我们对后者感兴趣。

总之,当你面临一个具有可预测输出的任务时,宏是一个很好的选择,这些任务的细节对(大多数)开发者来说并不重要,并且需要频繁执行。此外,宏是扩展语言和编写优雅或复杂 DSL 的最佳或唯一选择。在其他情况下,你可能希望转向函数、结构体和枚举。例如,避免在两个或三个地方重复过滤和映射传入数据需要的是函数,而不是新的宏。

如果你确实找到一个好的过程宏或声明式宏的使用案例,请在开始编码之前做一个(谷歌)搜索。可能有人已经先你一步了。

1.3.3 不适合的场景:什么时候不使用宏

谈到宏的不适用场景时,有两个类别浮现在脑海中。第一个已经提到过:那些你可以通过函数轻松完成的事情。通常,从函数开始,只有当事情变得过于复杂或需要过多样板代码时,才转向宏,这往往是一个好主意。不要试图过度工程化。另一个我有些怀疑的类别是业务逻辑。你的业务代码是特定于你的用例和应用程序的。因此,几乎所有公开可用的宏在一开始就被排除。你可能会为公司内部编写自定义宏。但在微服务世界中,跨服务和团队共享业务代码通常是个坏主意。你在微服务中的“用户”、“飞机”、“篮子”或“工厂”概念将与下一个微服务中的不同。这是一条铺满善意的道路,往往导致混淆和错误,或者是对已经定制的宏的进一步定制。不过,这个类别也有例外。首先,在较大的代码库中,宏可能有助于避免一些罕见的业务样板代码。其次,我们已经注意到 DSL 如何改善应用工程师的生活质量——特别是在复杂领域。而且,宏是编写 DSL 的绝佳工具。

在我们进入下一部分之前,还有一个最后但较小的点需要记住:集成开发环境(IDE)对宏的支持总是比对“正常”编程的支持要少。这几乎是不可避免的缺点。更强大的工具带来了更多的选项。这使得你的计算机更难猜测你可以和不能合法做什么。想象一下一个编程语言,其中唯一有效的语句是 2 + 2 = 4。IDE 在指出错误(“你输入了 b - @? !,是否意味着 2 + 2 = 4?”)和提供代码补全时会非常有用。现在想象一个允许一切的语言。struvt Example {} 是否有拼写错误?也许有,也许没有。谁知道?这也是为什么当你使用动态语言时,IDE 更难提供帮助的原因;即,类型对机器也有帮助!类型系统限制了你的选项,这可能限制了语言的能力。但它可以提供更多的安全性、性能和易用性作为回报。

在过程宏的情况下,额外的复杂性在于 IDE 必须以 Rust 相同的方式展开代码。只有这样,它才能告诉你宏添加的字段或方法是否实际存在。IntelliJ、RustRover 和(在较小程度上)Visual Studio Code 会这样做,正如我们将在后面的章节中简要讨论的那样,但即便如此,当展开失败时,它们的建议仍然可能遇到问题,此时它们应该向用户报告发生了什么错误。但这说起来容易做起来难。例如,当发生错误时,它们应该指向哪里呢?

1.4 本书的方法

本书的方法可以总结为以实例驱动和循序渐进的方式。大多数章节将以一个应用程序为中心主题,探讨宏主题以及 Rust 中其他相关主题。从一个简单的“Hello, World”开始,我们将逐步增加知识层次:如何解析、如何测试、如何处理错误。我们还会指出你可能遇到的常见错误,并提供一些调试提示。最后,章节将简要指出流行的库(包括本章提到的那些)如何使用所讲解的技术或实现特定的功能。这将让你了解如何应用所学知识。最后,尽管下一章将对声明式宏进行比较全面的概述,其余部分将主要集中在过程宏上,主要因为后者使用起来更具挑战性,而前者已经有很多有用的内容。

练习

回想一下你最近参与的一个应用程序。你能想到哪些地方是重复和样板代码不可避免的?你是否觉得缺少一种工具来使应用程序更易于使用?是否有任何事情在语言的约束下无法完成?希望在本书结束时,你会将宏视为解决这些问题的一个可能工具。

总结

  • 元编程允许你编写生成更多代码的代码。
  • 许多语言提供某种方式进行元编程,但这些工具通常难以使用,并且没有很好地集成到语言中,这可能导致难以理解或有缺陷的代码。
  • Rust 的宏功能强大,避免了许多这些缺点,专注于安全性,并且对运行时性能没有实际影响。
  • Rust 的宏被“展开”成由编译器检查的代码。
  • Rust 有高级声明式宏和三种过程宏(derive 宏、属性宏和类似函数的宏),它们将代码处理为一个标记流。
  • 元编程不应该是解决问题的首选,但它可以帮助你避免样板代码和重复,使你的应用程序更易于使用,或者做一些用“正常” Rust 难以做到的事情。
  • 本书将通过以实例驱动的方法探讨宏,同时讨论其他高级主题。
转载自:https://juejin.cn/post/7405776432726294564
评论
请登录