Python-鲁棒编程-全-

Python 鲁棒编程(全)

原文:annas-archive.org/md5/42e1aab1e8f4063de5f6437ba1b9efff

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

著名软件工程师和企业家马克·安德森(Marc Andreesen)曾经宣称“软件正在吞噬世界”。这是在 2011 年说的,而随着时间的推移,这一说法变得更加真实。软件系统不断变得复杂,并且可以在现代生活的各个方面找到它们的身影。站在这个贪婪野兽的中心是 Python 语言。程序员经常把 Python 作为最喜欢的语言,它无处不在:从网页应用到机器学习,再到开发工具,等等。

然而,并非所有闪闪发光的东西都是黄金。随着我们的软件系统变得越来越复杂,理解我们的心智模型如何映射到现实世界变得更加困难。如果不加控制,软件系统会变得臃肿且脆弱,赢得了可怕的“遗留代码”绰号。这些代码库通常伴随着诸如“不要触碰这些文件;我们不知道为什么,但你一旦触碰就会出问题”的警告,以及“哦,只有某某知道那段代码,而他们两年前就去了硅谷高薪工作”。软件开发是一个年轻的领域,但这类说法应该让开发人员和商业人士同样感到恐惧。

事实是,要编写持久的系统,你需要在做出选择时深思熟虑。正如 Titus Winters、Tom Manshreck 和 Hyrum Wright 所述,“软件工程是随时间整合的编程”。¹你的代码可能会持续很长时间——我曾经参与过那些在我上小学时就已经写好代码的项目。你的代码能持续多久?它会比你在当前工作岗位上的任期更长(或者你完成维护该项目的时间)吗?几年后当有人从中构建核心组件时,你希望你的代码如何被接纳?你希望你的继任者因为你的远见而感激你,还是因为你为这个世界带来的复杂性而诅咒你的名字?

Python 是一种很棒的语言,但有时在为未来构建时可能会变得棘手。其他编程语言的支持者曾经抨击 Python 为“不适合生产环境”或“仅适用于原型设计”,但事实是许多开发人员只是浅尝辄止,而没有学习编写健壮 Python 所需的所有工具和技巧。在本书中,你将学习如何做得更好。你将穿越多种方式使 Python 代码更加清晰和可维护。你未来的维护者将喜欢与你的代码一起工作,因为它从一开始就被设计成使事情变得简单。因此,去吧,阅读这本书,展望未来,构建持久的、令人敬畏的软件。

谁应该阅读这本书

这本书适用于任何希望以可持续和可维护的方式扩展其工作代码的 Python 开发人员。这并不是您的第一本 Python 教材;我预期您以前已经写过 Python。您应该对 Python 控制流感到舒适,并且以前已经使用过类。如果您正在寻找更入门的教材,我建议您先阅读 Learning Python,作者是 Mark Lutz(O’Reilly)。

尽管我将涵盖许多高级 Python 主题,但这本书的目标并不是教您如何使用 Python 的所有功能。相反,这些功能是更大对话的背景,讨论稳健性以及您的选择如何影响可维护性。有时我会讨论您几乎不应该或者根本不应该使用的策略。这是因为我想说明稳健性的第一原则;在代码中理解我们为什么以及如何做出决策的旅程比在最佳场景中使用什么工具更重要。在实践中,最佳场景是罕见的。使用本书中的原则从您的代码库中得出您自己的结论。

这本书不是一本参考书。你可以称之为讨论书。每一章都应该是您组织中的开发人员讨论如何最好地应用这些原则的起点。开始一个书籍俱乐部、讨论组或午餐学习,以促进沟通。我在每一章中提出了讨论主题,以启动对话。当您遇到这些主题时,我鼓励您停下来反思您当前的代码库。与您的同行交谈,并利用这些主题作为讨论您的代码状态、流程和工作流的跳板。如果您对 Python 语言的参考书感兴趣,我衷心推荐 Fluent Python,作者是 Luciano Ramalho(O’Reilly;第二版预计将于 2021 年底出版)。

系统可以通过多种方式保持稳健。它可以经过安全强化、可伸缩、容错或者不太可能引入新错误来实现。每一种稳健性的方面都值得一本完整的书籍;这本书专注于防止继承您代码的开发人员在您的系统中引入新故障。我将向您展示如何与未来的开发人员沟通,如何通过架构模式使他们的生活更轻松,并且如何在代码库中捕捉错误,以避免它们进入生产环境。这本书关注的是您的 Python 代码库的稳健性,而不是整个系统的稳健性。

我将涵盖丰富的信息,涵盖软件工程、计算机科学、测试、函数式编程和面向对象编程(OOP)等多个软件领域。我不希望您具有这些领域的背景。有些部分我会以初学者的水平来解释;这通常是为了分解我们如何思考语言核心基础的方式。总体而言,这是一本中级水平的文本。

理想的读者包括:

  • 目前在大型代码库工作的开发者们,希望找到更好的方法与他们的同事沟通

  • 主要的代码库维护者,寻找方法来帮助减轻未来维护者的负担

  • 自学成才的开发者们能够很好地编写 Python,但需要更好地理解我们为什么要做我们所做的事情

  • 需要提醒开发实践建议的软件工程毕业生

  • 寻找将他们的设计原理与健壮性的第一原则联系起来的高级开发者

本书侧重于随时间编写软件。如果您的大部分代码是原型、一次性或以其他方式可丢弃的,那么本书中的建议将导致比项目需要的更多的工作。同样,如果您的项目很小——比如 Python 代码少于一百行——那么使代码可维护确实增加了复杂性;毫无疑问。但是,我将指导您通过最小化这种复杂性。如果您的代码存在时间超过几周或增长到相当大的规模,则需要考虑代码库的可持续性。

关于本书

本书涵盖广泛的知识,分布在多个章节中。它分为四个部分:

第一部分,用类型为您的代码添加注释

我们将从 Python 的类型开始。类型对语言非常重要,但通常不会被深入研究。您选择的类型很重要,因为它们传达了非常具体的意图。我们将研究类型注释及其向开发人员传达的具体注释。我们还将讨论类型检查器及其如何帮助及早捕捉错误。

第二部分,定义您自己的类型

在讨论如何思考 Python 的类型后,我们将专注于如何创建自己的类型。我们将深入讲解枚举、数据类和类。我们将探索在设计类型时做出某些设计选择如何增加或减少代码的健壮性。

第三部分,可扩展的 Python

在学习如何更好地表达您的意图后,我们将专注于如何使开发人员轻松修改您的代码,有信心地在坚实的基础上构建。我们将涵盖可扩展性、依赖关系和允许您在最小影响下修改系统的架构模式。

第四部分,构建一个安全网

最后,我们将探讨如何建立一个安全网,这样你可以在他们摔倒时轻轻接住未来的合作者。他们的信心会增强,因为他们有一个强大而健壮的系统,可以毫不畏惧地适应他们的使用案例。最后,我们将介绍多种静态分析和测试工具,帮助你捕捉异常行为。

每章基本上是自包含的,涉及其他章节的引用会在适用时提及。你可以从头到尾阅读本书,也可以跳到你感兴趣的章节。每个部分中的章节相互关联,但书的各个部分之间的关系较少。

所有代码示例都是在 Python 3.9.0 上运行的,我会尽量指出你需要特定的 Python 版本或更高版本来运行示例(例如 Python 3.7 用于使用数据类)。

本书中,我将大部分工作都在命令行上进行。我在一个 Ubuntu 操作系统上运行了所有这些命令,但大多数工具在 Mac 或 Windows 系统上也同样适用。在某些情况下,我会展示某些工具如何与集成开发环境(IDE)如 Visual Studio Code(VS Code)互动。大多数 IDE 在幕后使用命令行选项;你学到的大部分命令行内容都可以直接转化为 IDE 选项。

本书将介绍许多不同的技术,可以提高你代码的健壮性。然而,在软件开发中并不存在万能药。在坚实的工程中,权衡是核心,我介绍的方法也不例外。在讨论这些主题时,我将公开透明地讨论它们的利弊。你对自己的系统了解更多,你最适合选择哪种工具来完成哪项工作。我所做的就是为你的工具箱添砖加瓦。

本书中使用的约定

本书中使用了以下印刷约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序清单,以及在段落中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

等宽字体粗体

显示用户应按字面输入的命令或其他文本。

等宽字体斜体

显示应由用户提供值或由上下文确定值的文本。

提示

这个元素表示一个提示或建议。

注意

这个元素表示一个一般注释。

警告

这个元素表示一个警告或注意。

使用代码示例

补充材料(代码示例、练习等)可以在https://github.com/pviafore/RobustPython上下载。

如果你有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。通常情况下,如果本书提供示例代码,您可以在程序和文档中使用它。除非您复制了代码的大部分内容,否则无需征得我们的许可。例如,编写使用本书中几个代码块的程序不需要许可。销售或分发 O’Reilly 书籍中的示例需要许可。引用本书并引用示例代码回答问题不需要许可。将本书中大量示例代码整合到您产品的文档中需要许可。

我们感激,但通常不要求署名。署名通常包括标题,作者,出版商和 ISBN。例如:“Robust Python by Patrick Viafore(O’Reilly)。版权所有 2021 年 Kudzera,LLC,978-1-098-10066-7。”

如果您认为您对示例代码的使用超出了合理使用范围或上述许可,欢迎通过邮件联系我们permissions@oreilly.com

O’Reilly 在线学习

注意

超过 40 年来,O’Reilly Media 提供技术和商业培训,知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍,文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问直播培训课程,深度学习路径,交互式编码环境以及来自 O’Reilly 和其他两百多家出版商的广泛的文本和视频集合。欲了解更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • O’Reilly Media,Inc.

  • 1005 Gravenstein Highway North

  • 加利福尼亚州塞巴斯托波尔 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书设立了一个网页,列出勘误表,示例和任何额外信息。您可以访问https://oreil.ly/robust-python

通过邮件bookquestions@oreilly.com发表评论或提出关于本书的技术问题。

关于我们的书籍和课程的新闻和信息,请访问http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

关注我们的 Twitter:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://youtube.com/oreillymedia

致谢

我要感谢我的不可思议的妻子,肯德尔。她是我的支持和听众,我感谢她为确保我有时间和空间来写作本书所做的一切。

没有一本书是孤立写成的,这本书也不例外。我站在软件行业的巨人们的肩膀上,并感谢那些在我之前的人。

我还想感谢所有参与审阅本书的人,确保我的信息传递一致,示例清晰。感谢 Bruce G.、David K.、David P. 和 Don P. 提供的早期反馈,并帮助我决定书籍的方向。感谢我的技术审阅者 Charles Givre、Drew Winstel、Jennifer Wilcox、Jordan Goldmeier、Nathan Stocks 和 Jess Males,他们的宝贵反馈对我帮助很大,特别是那些只在我的脑海中有意义而不在纸上显现的地方。最后,感谢所有阅读早期发布草稿并友善地通过电子邮件分享他们想法的人,特别是 Daniel C. 和 Francesco。

我要感谢所有帮助将我的最终草稿转变为值得投入生产的作品的人。感谢 Justin Billing 作为副本编辑深入探讨,并帮助我完善想法的表达。感谢 Shannon Turlington 进行校对;书籍因为你而更加精致。特别感谢 Ellen Troutman-Zaig 制作了一本让我感到惊叹的索引。

最后,没有 O'Reilly 的出色团队,我做不到这一切。感谢 Amanda Quinn 在提案过程中帮助我,并帮助我聚焦书籍内容。感谢 Kristen Brown 让我在制作阶段感觉异常轻松。感谢 Kate Dullea,她将我的 MS Paint 质量的草图转化为干净、清晰的插图。此外,我想特别感谢我的发展编辑 Sarah Grey。我期待我们每周的会议,她在帮助我打造一个面向广泛读者群体的书籍时表现出色,同时还让我能够深入技术细节。

¹ Titus Winters、Tom Manshreck 和 Hyrum Wright。Google 软件工程:编程经验教训。Sebastopol, CA: O’Reilly Media, Inc.,2020 年。

第一章:介绍健壮的 Python

这本书的重点是让你的 Python 代码更易管理。随着代码库的增长,你需要一套特定的技巧、诀窍和策略来构建可维护的代码。这本书将指导你减少 bug,让开发者更快乐。你将深入探讨自己编写代码的方式,并了解你的决策所带来的影响。在讨论代码编写方式时,我想起了 C.A.R. Hoare 的这些明智的话:

有两种构建软件设计的方法:一种是使它非常简单,以至于显然没有缺陷,另一种是使它非常复杂,以至于显然没有缺陷。第一种方法要困难得多。¹

这本书讨论的是第一种方式开发系统。是的,这会更加困难,但不要害怕。我将成为你在提升 Python 水平旅程中的向导,确保如 C.A.R. Hoare 所说,你的代码中显然没有缺陷。归根结底,这是一本关于编写健壮Python 的书籍。

在本章中,我们将讨论健壮性的含义以及你为何应关注它。我们将介绍你的沟通方式如何暗示某些利弊,并探讨最佳表示意图的方法。"Python 之禅"提到,在开发代码时,“应该有一种——最好只有一种——显而易见的方法来做到这一点。”你将学会如何评估你的代码是否以显而易见的方式编写,以及如何修复它。首先,我们需要解决基础问题。首先,健壮性究竟是什么?

健壮性

每本书至少需要一个字典定义,所以我会早早地把这个解决掉。Merriam-Webster 为健壮性提供了许多定义:

  1. 具有力量或健壮的表现

  2. 具有或显示活力、力量或坚定

  3. 强壮的或建造的

  4. 能够在广泛的条件下无故障运行

这些都是我们的目标的精彩描述。我们希望一个健康的系统,一个能够满足多年期望的系统。我们希望我们的软件表现出力量;显然,这段代码将经得起时间的考验。我们希望一个坚固构建的系统,一个建立在坚实基础上的系统。关键是,我们希望一个能够在没有故障的情况下运行的系统;随着变更的引入,系统不应变得脆弱。

人们通常把软件比作摩天大楼,一种伟大的结构,挺过所有变化,成为不朽的典范。不幸的是,事实更为复杂。软件系统不断演化。错误被修复,用户界面被调整,功能被添加、移除,然后重新添加。框架转变,组件过时,安全漏洞出现。软件在变化。开发软件更像是处理城市规划中的蔓延,而不是建造静态建筑。面对不断变化的代码库,如何使你的代码健壮?如何构建一个能抵御错误的坚固基础?

事实是,你必须接受变化。你的代码将被拆分,重新组合和重构。新的用例将改变大片代码——这是可以接受的。拥抱变化。明白你的代码可以轻易改变是不够的;最好的情况可能是它被删除并重写,因为它过时了。这并不会减少其价值;它仍然可以在聚光灯下有很长的寿命。你的任务是使系统的部分易于重写。一旦你开始接受你的代码的短暂性,你就开始意识到,为现在编写无 BUG 的代码是不够的;你需要让代码库的未来所有者能够有信心地修改你的代码。这就是本书的内容所在。

你将学会构建强大的系统。这种强度不是来自于铁块那种僵化的展示。它来自于灵活性。你的代码需要像一棵高大的柳树一样强壮,能在风中摇曳,灵活但不易折断。你的软件需要处理你从未梦到的情况。你的代码库需要能够适应新的环境,因为不总是你来维护它。未来的维护者需要知道他们在一个健康的代码库中工作。你的代码库需要传达其强度。你必须以一种方式编写 Python 代码,以减少失败,即使未来的维护者在拆解和重构它。

写健壮的代码意味着深思熟虑未来。你希望未来的维护者看着你的代码能轻松理解你的意图,而不是在深夜调试时咒骂你的名字。你必须传达你的思想、推理和警告。未来的开发者需要把你的代码弯成新的形状,并且希望这样做时不用担心每次修改都可能像摇摇欲坠的纸牌房屋一样崩溃。

简而言之,你不希望系统失败,尤其是在发生意外情况时。测试和质量保证在其中占据重要地位,但它们都不能完全保证质量。它们更适合揭示期望中的差距并提供安全网。相反,你必须让你的软件经得起时间的考验。为了做到这一点,你必须编写清晰且易于维护的代码。

清晰的代码按照顺序清晰而简洁地表达其意图。当你看着一行代码对自己说,“啊,这完全讲得通”,这就是清晰代码的指标。你越要通过调试器逐步执行,越要查看大量其他代码以弄清发生了什么,越要停下来盯着代码看,代码就越不清晰。清晰的代码不会因为巧妙的技巧而使其他开发人员无法阅读。就像 C.A.R. Hoare 之前说的那样,你不希望使你的代码如此晦涩,以至于仅凭视觉检查就难以理解。

可维护的代码是能够轻松维护的代码。维护从第一个提交开始,并持续到没有一个开发人员再看项目为止。开发人员将修复错误,添加功能,阅读代码,提取代码以供在其他库中使用等。可维护的代码使这些任务变得无摩擦。软件生命周期长达数年,甚至数十年。今天就专注于您的可维护性吧。

无论你是积极参与工作还是不参与,你都不希望成为系统失败的原因。你需要积极主动地让你的系统经得起时间的考验。你需要一个测试策略来作为你的安全网,但你也需要能够在第一时间避免掉入。因此,考虑到这一切,我提出了我对代码库稳健性的定义:

稳健的代码库在不断变化中仍然是弹性和无错误的。

为什么健壮性很重要?

让软件按照预期运行需要花费大量精力,但是很难确定何时才算完成。开发里程碑不容易预测。人为因素,如用户体验、可访问性和文档,只会增加复杂性。现在加入测试以确保您覆盖了已知和未知行为的片段,您将面临漫长的开发周期。

软件的目的是提供价值。每个利益相关者都有兴趣尽早交付这些价值。鉴于某些开发进度的不确定性,往往会增加满足期望的压力。我们都曾经历过不切实际的进度表或截止日期的反面。不幸的是,许多增强软件健壮性的工具只会在短期内增加我们的开发周期。

的确,在即时交付价值和编写健壮代码之间存在固有的紧张关系。如果你的软件“足够好”,为什么还要增加更多复杂性呢?为了回答这个问题,考虑那段软件将被迭代多少次。提供软件价值通常不是一个静态的过程;一个系统提供价值且永不修改的情况是罕见的。软件本质上是不断发展的。代码库需要准备好在长时间内频繁地提供价值。这就是健壮软件工程实践发挥作用的地方。如果你无法快速无损地交付功能,你需要重新评估技术,使你的代码更易维护。

如果你推迟或破坏你的系统,你会产生实时成本。仔细思考你的代码库。问问自己,如果你的代码一年后由于有人无法理解而出现问题会怎么样。你会损失多少价值?你的价值可能以金钱、时间,甚至生命来衡量。问问自己,如果价值不能按时交付会发生什么?会有什么后果?如果这些问题的答案让人感到害怕,好消息是,你正在做有价值的工作。但这也强调了消除未来错误的重要性。

多个开发人员同时在同一代码库上工作。许多软件项目将超过大多数开发人员的寿命。你需要找到一种方式与现在和未来的开发人员沟通,而不依赖于当面解释的好处。未来的开发人员将根据的决策进行构建。每一个错误的路径,每一个兔子洞,以及每一个 Yak-shaving²冒险都会减慢他们的速度,从而阻碍价值的实现。你需要为那些接替你工作的人们感到同情。你需要站在他们的角度思考。这本书是你思考协作者和维护者的入口。你需要考虑可持续的工程实践。你需要编写持久的代码。制造持久代码的第一步是通过你的代码进行沟通。你需要确保未来的开发人员理解你的意图。

你的意图是什么?

为什么你应该努力编写清晰和可维护的代码?为什么你应该如此关注健壮性?这些答案的核心在于沟通。你不是在交付静态系统。代码将继续变化。你还必须考虑维护者随时间变化。在编写代码时,你的目标是提供价值。你还要以一种能让其他开发人员同样快速提供价值的方式编写代码。为了做到这一点,你需要能够在未来维护者没有见面的情况下沟通推理和意图。

让我们看看一个在假设的传统系统中找到的代码块。我希望你估计一下你理解这段代码所需的时间。如果你对这里的所有概念不熟悉,或者觉得这段代码很复杂(这是有意的!),也没关系。

# Take a meal recipe and change the number of servings
# by adjusting each ingredient
# A recipe's first element is the number of servings, and the remainder
# of elements is (name, amount, unit), such as ("flour", 1.5, "cup")
def adjust_recipe(recipe, servings):
    new_recipe = [servings]
    old_servings = recipe[0]
    factor = servings / old_servings
    recipe.pop(0)
    while recipe:
        ingredient, amount, unit = recipe.pop(0)
        # please only use numbers that will be easily measurable
        new_recipe.append((ingredient, amount * factor, unit))
    return new_recipe

此函数接受一个食谱,并调整每个成分以处理新的份数。然而,这段代码引发了许多问题。

  • pop是干什么用的?

  • recipe[0]代表什么?为什么是旧的分量?

  • 为什么我需要为可轻松测量的数字添加注释?

这确实是有些值得商榷的 Python 代码。如果你觉得有必要重写它,我不会责怪你。像这样写起来看起来好多了:

def adjust_recipe(recipe, servings):
    old_servings = recipe.pop(0)
    factor = servings / old_servings
    new_recipe = {ingredient: (amount*factor, unit)
                  for ingredient, amount, unit in recipe}
    new_recipe["servings"] = servings
    return new_recipe

喜欢整洁代码的人可能更喜欢第二个版本(我肯定是这样)。没有原始循环。变量不变异。我返回一个字典而不是元组列表。根据情况,所有这些变化都可以看作是积极的。但我可能刚刚引入了三个微妙的错误。

  • 在原始代码片段中,我清理了原始食谱。现在我没有了。即使只有一个区域的调用代码依赖于这种行为,我也破坏了该调用代码的假设。

  • 通过返回一个字典,我已经删除了在列表中具有重复成分的能力。这可能会影响那些使用同一成分的多个部分(如主菜和酱料)的食谱。

  • 如果任何成分都被命名为“servings”,我刚刚引入了与命名冲突。

这些是否是错误取决于两个相互关联的事情:原始作者的意图和调用代码。作者打算解决一个问题,但我不确定他们为什么以这种方式编写代码。他们为什么弹出元素?为什么“servings”是列表内的元组?为什么使用列表?据推测,原始作者知道原因,并在本地向同事传达了。他们的同事根据这些假设编写了调用代码,但随着时间的推移,这种意图变得模糊了。在没有向未来传达信息的情况下,我只能有两种选择来维护这段代码:

  • 查看所有调用代码,并确认在实施之前,这种行为并不依赖于它。如果这是一个带有外部调用者的库的公共 API,祝你好运。我会花很多时间来做这件事情,这会让我很沮丧。

  • 进行更改并等待看看结果如何(客户投诉、测试失败等)。如果我幸运的话,不会发生什么不好的事情。如果不幸的话,我会花很多时间来修复用例,这会让我很沮丧。

在维护设置中,这两种选项都不太高效(尤其是如果我必须修改这段代码)。我不想浪费时间;我希望快速处理当前任务并继续下一个任务。如果考虑如何调用这段代码,情况会变得更糟。想想你如何与之前从未见过的代码互动。你可能看到其他调用代码的示例,将它们复制以适应你的用例,并且从未意识到你需要将一个名为“servings”的特定字符串作为列表的第一个元素传递。

这些是会让你感到困惑的决策。我们在更大的代码库中都见过它们。它们并非出于恶意编写,而是随着时间的推移和多个开发者的贡献而有机地形成。函数起初很简单,但随着用例的增长,那些代码往往会变形并且遮蔽原始意图。这明显表明可维护性正在受到影响。你需要在代码开发初期表达出你的意图。

那么,如果原始作者使用了更好的命名模式和更好的类型使用,那么代码会是什么样子呢?

def adjust_recipe(recipe, servings):
    """
 Take a meal recipe and change the number of servings
 :param recipe: a `Recipe` indicating what needs to be adusted
 :param servings: the number of servings
 :return Recipe: a recipe with serving size and ingredients adjusted
 for the new servings
 """
    # create a copy of the ingredients
    new_ingredients = list(recipe.get_ingredients())
    recipe.clear_ingredients()

    for ingredient in new_ingredients:
            ingredient.adjust_propoprtion(Fraction(servings, recipe.servings))
    return Recipe(servings, new_ingredients)

这看起来好多了,文档化更好,明确表达了原始意图。原始开发者直接将他们的想法编码到了代码中。从这段代码片段中,你知道以下内容是正确的:

  • 我正在使用 Recipe 类。这使我能够抽象出某些操作。可以假定,在类内部有一个不变量,允许存在重复的成分。(关于类和不变量,我会在第十章详细讲解。)这提供了一个通用词汇,使函数的行为更加明确。

  • 现在,份量已经成为 Recipe 类的显式部分,而不需要作为列表的第一个元素处理,这作为一种特殊情况。这大大简化了调用代码,并防止了意外冲突。

  • 很明显,我想要清除旧食谱上的成分。没有模棱两可的原因,我需要执行 .pop(0)

  • 成分是一个单独的类,并且处理 fractions 而不是显式的 float。这对所有参与者更清晰,我在处理分数单位时可以轻松执行 limit_denominator(),当人们想要限制计量单位时可以调用该方法(而不是依赖于注释)。

我已经用类型替换了变量,比如食谱类型和成分类型。我还定义了操作(clear_ingredientsadjust_proportion)来传达我的意图。通过做出这些改变,我已经让代码的行为对未来的读者非常清晰。他们不再需要来找我以理解这段代码。相反,他们理解我在做什么而不必与我交流。这就是异步通信的最佳体现。

异步通信

在 Python 书中写关于异步通信而不提及asyncawait感觉有些奇怪。但我不得不在一个更复杂的地方——现实世界中讨论异步通信。

异步沟通意味着生产信息和消费信息是相互独立的。在生产和消费之间存在时间间隔。这可能是几小时,就像处于不同时区的合作者的情况。或者可能是多年,未来的维护者试图深入了解代码的内部工作原理。你无法预测某人何时需要理解你的逻辑。甚至在他们消费信息时,你可能已经不再从事那个代码库(或那家公司)的工作。

与之形成对比的是同步沟通。同步沟通是实时交换想法。这种直接沟通是表达思想的最佳方式之一,但不幸的是,它缺乏可扩展性,你并不总是能在那儿回答问题。

为了评估每种沟通方法在试图理解意图时的适用性,让我们看看两个轴:亲近度和成本。

亲近度是沟通者在时间上需要多么接近才能使沟通富有成效。某些沟通方法在实时传递信息方面表现出色,而其他方法则在多年后传播信息方面表现出色。

成本是沟通所需的努力的度量。你必须权衡花费时间和金钱来沟通所提供的价值。然后,你的未来消费者必须权衡消费信息的成本与他们试图提供的价值。编写代码并没有提供任何其他沟通渠道是你的基准;你必须这样做来产生价值。为了评估额外沟通渠道的成本,我考虑以下因素:

可发现性

在正常工作流程之外查找这些信息有多容易?知识有多短暂?搜索信息容易吗?

维护成本

信息的准确性如何?更新频率如何?如果信息过时会出现什么问题?

生产成本

制作沟通所花费的时间和金钱有多少?

在图 1-1 中,我根据自己的经验绘制了一些常见沟通方法的成本和所需亲近度。

图 1-1. 绘制沟通方法的成本和亲近度

组成成本/亲近度图的四个象限。

低成本,高亲近度需求

这些虽然便宜易于生产和消费,但在时间上不具有可扩展性。直接沟通和即时通讯是这些方法的典型例子。把它们视为时间点上的信息快照;只有用户在积极倾听时,它们才有价值。不要依赖这些方法来传达给未来。

高成本,高亲近度需求

这些是昂贵的事件,并且通常只发生一次(如会议或会议)。这些事件在沟通时应提供大量价值,因为它们对未来的价值贡献不大。你参加过多少次觉得浪费时间的会议?你正在感受到直接的价值损失。每个参与者都需要支付多重成本(时间花费、主办空间、后勤等)。代码审查完成后很少被查看。

成本高,所需接近度低

这些是昂贵的,但随着时间的推移,可以通过提供的价值来回报成本,因为所需的接近度较低。电子邮件和敏捷看板包含大量信息,但他人无法发现。这些适合不需要经常更新的大概念。试图筛选出你寻找的信息之前必须经历噪音的噩梦。视频录像和设计文档适合了解某一时刻的快照,但更新成本高昂。不要依赖这些沟通方法来理解日常决策。

成本低,所需接近度低

这些内容易于创建,并且易于消费。代码注释、版本控制历史和项目 README 都属于此类,因为它们与我们编写的源代码相邻。用户可以在数年后查看这些通信内容。开发者在日常工作流程中遇到的任何内容都是可以发现的。这些沟通方法自然适合作为源代码之后某人首先查看的地方。然而,你的代码是你最好的文档工具之一,因为它是系统的生动记录和唯一真相来源。

讨论主题

这个图表在图 1-1 中基于一般化的使用案例创建。考虑你和你的组织使用的沟通路径。你会在图表上的哪个位置标出它们?消费准确信息有多容易?制造信息有多昂贵?这些问题的答案可能导致略有不同的图表,但真相的单一来源将在您交付的可执行软件中。

成本低,所需接近度低的沟通方法是向未来沟通的最佳工具。你应该努力将生产成本和沟通消费成本最小化。无论如何,你都必须编写软件来提供价值,因此最低成本的选项是将你的代码作为主要沟通工具。你的代码库成为清晰表达你的决策、观点和解决方法的最佳选择。

然而,为了使这个断言成立,代码也必须很容易消耗。你的意图必须清晰地在你的代码中表达出来。你的目标是尽量减少读者理解代码所需的时间。理想情况下,读者不需要阅读你的实现,只需阅读你的函数签名。通过使用良好的类型、注释和变量名,你的代码应该清晰地表明你的代码是做什么的。

Python 中的意图示例

现在我已经讲解了意图是什么以及它的重要性,让我们通过 Python 的视角看一些示例。你如何确保你正确地表达了你的意图?我将看两个不同的决策如何影响意图的示例:集合和迭代。

集合

当你选择一个集合时,你正在传达特定的信息。你必须为手头的任务选择正确的集合。否则,维护人员将从你的代码中推断出错误的意图。

考虑这段代码,它接受一系列烹饪书,并提供了作者和书籍数量之间的映射:

def create_author_count_mapping(cookbooks: list[Cookbook]):
    counter = {}
    for cookbook in cookbooks:
        if cookbook.author not in counter:
            counter[cookbook.author] = 0
        counter[cookbook.author] += 1
    return counter

我对集合的使用告诉了你什么?为什么我不传递一个字典或一个集合?为什么我不返回一个列表?根据我对集合的当前使用,你可以假设:

  • 我传入了一系列烹饪书。这个列表中可能有重复的烹饪书(我可能正在数一家商店里有多本副本的烹饪书架)。

  • 我正在返回一个字典。用户可以查找特定的作者,或者遍历整个字典。我不必担心返回集合中的重复作者。

如果我想传达的是这个函数不应该传入重复的内容怎么办?一个列表传达了错误的意图。相反,我应该选择一个集合来表明这段代码绝对不会处理重复项。

选择一个集合告诉读者你的具体意图。下面是一些常见集合类型及其传达的意图的列表:

列表

这是一个可迭代的集合。它是可变的:可以随时更改。你几乎不会指望从列表中间检索特定的元素(使用静态列表索引)。可能会有重复的元素。书架上的烹饪书可能会被存储在一个列表中。

字符串

一个不可变的字符集合。一本烹饪书的名称将是一个字符串。

生成器

一个可迭代的集合,永远不会被索引。每个元素访问都是惰性执行的,所以每次循环迭代可能需要时间和/或资源。它们非常适合计算昂贵或无限的集合。一个在线的菜谱数据库可能会被返回为生成器;当用户只查看搜索结果的前 10 个结果时,你不希望获取世界上所有的菜谱。

元组

一个不可变的集合。你不希望它改变,因此更有可能从元组的中间提取特定元素(通过索引或解包)。很少会迭代它。关于特定菜谱的信息可能以元组的形式表示,如(cookbook_name, author, pagecount)

集合

一个可迭代的集合,不包含重复项。您不能依赖元素的顺序。菜谱中的食材可能以集合的形式存储。

字典

从键到值的映射。字典中的键在整个字典中是唯一的。通常会迭代字典,或者使用动态键索引进入。菜谱索引是一个很好的键到值映射的例子(从主题到页码)。

不要为你的目的使用错误的集合。我太多次看到应该没有重复项的列表或者实际上并没有被用来映射键值对的字典。每当你的意图与代码不一致时,你就会创建维护负担。维护者必须停下来,弄清楚你实际上想表达的是什么,然后围绕他们错误的假设(还有你的错误假设)进行工作。

这些是基本集合,但还有更多表达意图的方法。以下是一些特殊的集合类型,它们在向未来传达意图方面更具表现力:

frozenset

一个不可变的集合。

OrderedDict

一个根据插入时间保留元素顺序的字典。截至到 CPython 3.6 和 Python 3.7,内置字典也会根据插入时间保留元素的顺序。

defaultdict

一个字典,如果键缺失则提供默认值。例如,我可以将我之前的例子重写如下:

from collections import defaultdict
def create_author_count_mapping(cookbooks: List[Cookbook]):
    counter = defaultdict(lambda: 0)
    for cookbook in cookbooks:
        counter[cookbook.author] += 1
    return counter

这为最终用户引入了一种新的行为 —— 如果他们查询字典中不存在的值,他们将收到一个 0。这在某些情况下可能是有益的,但如果不是的话,你可以只返回dict(counter)

Counter

一种特殊的字典类型,用于计算元素出现的次数。这极大地简化了我们上面的代码为以下形式:

from collections import Counter
def create_author_count_mapping(cookbooks: List[Cookbook]):
    return Counter(book.author for book in cookbooks)

花点时间反思一下最后一个例子。注意使用Counter如何使我们的代码更加简洁,而不会牺牲可读性。如果读者熟悉Counter,这个函数的含义(以及实现方式)就会立即显而易见。这是通过更好地选择集合类型向未来传达意图的一个很好的例子。我将在第五章进一步探讨集合。

还有许多其他类型可以探索,包括arraybytesrange。无论何时遇到一个新的集合类型,无论是内置的还是其他类型,都要问问自己它与其他集合有何不同,以及它向未来的读者传达了什么。

迭代

迭代是另一个例子,抽象你选择的决定了你传达的意图。

你多少次见过这样的代码?

text = "This is some generic text"
index = 0
while index < len(text):
    print(text[index])
    index += 1

这段简单的代码将每个字符打印在单独的一行上。对于这个问题的 Python 初步解决方案来说,这是完全可以接受的,但解决方案很快就会演变为更Pythonic(用惯用风格编写的代码,旨在强调简洁并且对大多数 Python 开发人员来说是可识别的):

for character in text:
    print(character)

请花一点时间思考为什么这个选项更可取。for循环是一个更合适的选择;它更清晰地传达了意图。就像集合类型一样,您选择的循环结构明确地传达了不同的概念。以下是一些常见的循环结构及其传达的概念:

for 循环

for 循环用于遍历集合或范围中的每个元素,并执行操作/副作用。

for cookbook in cookbooks:
    print(cookbook)

while 循环

while 循环用于在某个条件为真时进行迭代。

while is_cookbook_open(cookbook):
    narrate(cookbook)

推导式

推导式用于将一个集合转换为另一个集合(通常情况下,这不会产生副作用,特别是如果推导是惰性的)。

authors = [cookbook.author for cookbook in cookbooks]

递归

当集合的子结构与集合的结构相同时(例如,树的每个子节点也是一棵树),就会使用递归。

def list_ingredients(item):
    if isinstance(item, PreparedIngredient):
        list_ingredients(item)
    else:
        print(ingredient)

您希望代码库的每一行都能提供价值。此外,您希望每一行都能清晰地向未来的开发人员传达这个价值是什么。这驱使我们需要尽量减少任何样板代码、脚手架和多余的代码。在上面的示例中,我正在遍历每个元素并执行一个副作用(打印一个元素),这使得for循环成为一个理想的循环结构。我没有浪费代码。相比之下,while循环要求我们明确跟踪循环,直到某个特定条件发生为止。换句话说,我需要追踪一个特定的条件,并在每次迭代时改变一个变量。这会分散注意力,增加不必要的认知负担。

最小惊奇法则

从意图中分散注意力是不好的,但有一类通信更糟糕:当代码主动让您未来的协作者感到惊讶时。您希望遵循最小惊奇法则;当某人阅读代码库时,他们几乎不应该对行为或实现感到惊讶(当他们感到惊讶时,附近应该有一个很好的注释来解释为什么是这样)。这就是为什么传达意图至关重要。清晰、干净的代码降低了误传的可能性。

注意

最小惊奇法则,也称为最小惊讶法则,规定程序应该以最少让用户惊讶的方式响应用户。令人惊讶的行为会导致混淆。混淆会导致错误的假设。错误的假设会导致错误。这就是如何得到不可靠的软件。

请记住,您可以编写完全正确的代码,但将来仍会令某人感到惊讶。在我职业生涯早期,我曾经追踪过一个由于内存损坏而导致崩溃的恶心错误。通过调试器或添加太多打印语句会影响时序,使得错误不会显现出来(真正的“海森堡”现象)。⁴ 与此问题相关的代码行数多达数千行。

因此,我不得不手动进行二分,将代码一分为二,看看哪一半实际上有崩溃,通过删除另一半再次执行此操作。在抓狂两周之后,我最终决定检查一个听起来无害的函数 getEvent。结果发现,这个函数实际上是在用无效数据 设置 一个事件。毫无疑问,我感到非常惊讶。这个函数在执行它的功能时完全正确,但因为我忽视了代码的意图,至少有三天我都没有注意到这个错误。惊讶会浪费您协作者的时间。

这种惊讶大多来自复杂性。复杂性有两种类型:必要复杂性偶发复杂性。必要复杂性是您领域内在的复杂性。深度学习模型必然复杂;不是您能在几分钟内浏览其内部工作原理就能理解的东西。优化对象关系映射(ORM)必然复杂;必须考虑大量可能的用户输入。您无法消除必要复杂性,所以最好的选择是试图将其限制在一定范围内,以免蔓延到代码库中,最终变成偶发复杂性。

相反,偶发复杂性是代码中产生多余、浪费或混淆语句的复杂性。这种情况发生在系统随时间演变,开发者不断添加功能而不重新评估旧代码是否仍然有效时。我曾经参与过一个项目,其中添加一个单一的命令行选项(及其相关的程序设置)影响到了至少 10 个文件。为什么添加一个简单的值需要在整个代码库中进行更改呢?

如果您曾经经历以下情况,则说明您有偶发复杂性:

  • 看起来简单的事情(例如添加用户、更改 UI 控件等)实际上并不容易实现。

  • 难以使新开发者理解您的代码库入职困难。项目中的新开发者是评估当前代码可维护性的最佳指标 —— 无需等待多年。

  • 添加功能的估算总是很高,然而您仍然会超时。

尽可能消除意外复杂性,并将必要的复杂性隔离开来。这些将成为未来合作者的绊脚石。这些复杂性的来源会加剧沟通不畅,因为它们会在代码库中模糊和扩散意图。

讨论主题

在你的代码库中有哪些意外复杂性?如果你被丢到没有与其他开发者沟通的代码库中,理解简单的概念会有多大挑战?在本练习中,你能做些什么来简化识别出的复杂性(特别是它们经常变化的代码中)?

在本书的其余部分,我将探讨在 Python 中传达意图的不同技术。

结语

强大的代码很重要。清晰的代码很重要。你的代码需要在整个代码库的生命周期内都能够维护,为了确保这一点,你需要在你所传达的内容和方式上投入积极的前瞻性。你需要尽可能清晰地体现你的知识。不断朝前看可能会感到负担,但通过实践,这会变得自然,并且你开始在自己的代码库中享受成果。

每当你将现实世界的概念映射到代码时,你都在创建一个抽象,无论是通过使用集合还是决定保持函数分离。每个抽象都是一个选择,每个选择都传达着某种信息,无论是有意还是无意的。我鼓励你思考每一行代码你正在写的内容,并问自己,“未来的开发者会从中学到什么?”你有责任让未来的维护者能够以与你今天相同的速度交付价值。否则,你的代码库将变得臃肿,进度将滑坡,复杂性将增加。作为开发者,你的工作就是减少这种风险。

寻找潜在的热点,比如不正确的抽象(如集合或迭代),或意外的复杂性。这些是随着时间推移可能导致沟通中断的主要区域。如果这些热点位于经常变化的区域,现在就应该优先处理。

在下一章中,你将会将本章学到的内容应用到一个基本的 Python 概念中:类型。你选择的类型表达了你对未来开发者的意图,选择正确的类型将有助于提高可维护性。

¹ 查尔斯·安东尼·理查德·霍尔。"皇帝的旧衣服"。Commun. ACM 24, 2 (1981 年 2 月), 75–83. https://doi.org/10.1145/358549.358561

² "Yak-shaving" 描述了一种情况,即在解决原始问题之前,你经常不得不解决不相关的问题。你可以在 https://oreil.ly/4iZm7 了解这个术语的起源。

³ Geoffrey James。程序设计之道https://oreil.ly/NcKNK

⁴ 当被观察时显示不同行为的一个 bug。SIGSOFT ’83: ACM SIGSOFT/SIGPLAN 软件工程高级调试研讨会论文集

第一部分:使用类型为你的代码添加注释

欢迎来到第 I 部分,在这里我将专注于 Python 中的类型。类型模型化了你的程序行为。初学者程序员理解在 Python 中有不同的类型,比如floatstr。但是什么是类型?掌握类型如何使你的代码库更强大?类型是任何编程语言的基本支柱,但不幸的是,大多数入门文本只是简单地概述类型如何增强你的代码库(或者如果误用,这些类型如何增加复杂性)。

如果你以前见过,请告诉我:

>>>type(3.14)
<class 'float'>

>>>type("This is another boring example")
<class 'str'>

>>> type(["Even", "more", "boring", "examples"])
<class 'list'>

这可能源自几乎任何关于 Python 的初学者指南。你将了解到intstrfloatbool等数据类型,以及语言提供的各种其他内容。然后,突然间,你就会继续前进,因为让我们面对现实,Python 并不华丽。你想深入研究一些酷炫的东西,比如函数、循环和字典,我理解。但是很遗憾,许多教程从不再深入讨论类型并且没有为它们恰当地称赞。随着用户深入研究,他们可能会发现类型注解(我会在下一章介绍),或者开始编写类,但通常会错过关于何时适当使用类型的基础讨论。

那就是我要开始的地方。

第二章:介绍 Python 类型

要编写可维护的 Python 代码,你必须了解类型的本质,并有意识地使用它们。我将首先讨论类型实际上是什么,以及为什么这很重要。然后我将进一步探讨 Python 语言关于其类型系统的决策如何影响代码库的健壮性。

类型的含义是什么?

我希望你停下来回答一个问题:不提及数字、字符串、文本或布尔值,你如何解释什么是类型?

对每个人来说,这都不是一个简单的答案。尤其是在 Python 这样的语言中,你不必显式声明变量的类型,更难解释其好处。

我认为类型有一个非常简单的定义:一种通信方法。类型传递信息。它们提供了一个用户和计算机可以推理的表示。我将表示分解为两个不同的方面:

机械表示

类型向 Python 语言本身传达行为和约束。

语义表示

类型向其他开发者传达行为和约束。

让我们更深入地了解每种表示。

机械表示

从本质上讲,计算机都是关于二进制代码的。你的处理器不会讲 Python;它只看到电路中的电流存在或不存在。同样的情况也适用于你计算机内存中的内容。

假设你的内存看起来像下面这样:

0011001010001001000101001001000100100010000010101
0010101010101000000111111110010010100111110100100
0100100010010100101011101111011010101010101010101

010100000100000101010100

10100100100100010101000101001001010101001001001001
00011110101011010110100101011100000000000000000111

看起来像一堆胡言乱语。让我们放大其中的中间部分:

01010000 01000001 01010100

没有办法准确地告诉这个数字本身意味着什么。根据计算机架构,这可能表示数字 5259604 或 5521744。它也可以是字符串“PAT”。没有任何上下文,你不能确定。这就是为什么 Python 需要类型的原因。类型信息提供了 Python 需要了解的一切来理解所有的二进制数据。让我们看看它如何运作:

from ctypes import string_at
from sys import getsizeof
from binascii import hexlify

a = 0b01010000_01000001_01010100
print(a)
>>> 5259604

# prints out the memory of the variable
print(hexlify(string_at(id(a), getsizeof(a))))
>>> b'0100000000000000607c054995550000010000000000000054415000'

text = "PAT"
print(hexlify(string_at(id(text), getsizeof(text))))
>>>b'0100000000000000a00f0649955500000300000000000000375c9f1f02'
   b'acdbe4e5379218b77f0000000000000000000050415400'
注意

我在一台小端机器上运行 CPython 3.9.0,所以如果你看到不同的结果,不要担心,有些微妙的东西可能会改变你的答案。(这段代码不能保证在其他 Python 实现如 Jython 或 PyPy 上运行。)

这些十六进制字符串显示了包含 Python 对象的内存内容。你会在链表中找到指向下一个和上一个对象的指针(用于垃圾收集),一个引用计数,一个类型,以及实际的数据本身。你可以看到每个返回值的末尾字节,以查看数字或字符串(寻找字节0x5441500x504154)。其中重要的部分是内存中编码了一个类型。当 Python 查看一个变量时,它知道运行时每个东西的类型(就像当你使用type()函数时一样)。

很容易认为这是类型的唯一原因——计算机需要知道如何解释各种各样的内存块。了解 Python 如何使用类型是很重要的,因为它对编写健壮代码有一些影响,但更重要的是第二种表示法:语义表示。

语义表示

虽然类型的第一个定义对于底层编程非常有用,但第二个定义适用于每个开发人员。除了具有机械表示之外,类型还具有语义表示。语义表示是一种沟通工具;您选择的类型将信息跨越时间和空间传递给未来的开发人员。

类型告诉用户他们可以期望在与该实体交互时看到什么行为。在这个上下文中,“行为”是您与该类型关联的操作(加上任何前提条件或后置条件)。它们是用户在每次使用该类型时与之交互的边界、约束和自由。正确使用的类型具有低的理解障碍;它们变得自然而易用。相反,错误使用的类型是一种阻碍。

考虑一下低劣的int。花一分钟思考 Python 中整数具有什么行为。以下是我想到的一个快速(非详尽)列表:

  • 可以由整数、浮点数或字符串构建

  • 数学操作,如加法、减法、除法、乘法、指数和取反

  • 关系比较,如<、>、==和!=

  • 位操作(操作数字的各个位)如&、|、^、~和移位

  • 使用strrepr函数转换为字符串

  • 可以通过ceilfloorround方法进行四舍五入(即使它们返回整数本身,这些方法也是支持的)

一个int有很多行为。如果你在交互式 Python 控制台中键入help(int),你可以查看完整列表。

现在考虑一个datetime

>>>import datetime
>>>datetime.datetime.now()
datetime.datetime(2020, 9, 8, 22, 19, 28, 838667)

datetimeint并没有太大的区别。通常,它被表示为距离某个时间纪元(如 1970 年 1 月 1 日)的秒数或毫秒数。但是想想一个datetime具有的行为(我已经用斜体标出与整数不同的行为):

  • 可以由字符串或代表日/月/年等的整数集合构建。

  • 数学操作,如时间增量的加法和减法

  • 关系比较

  • 没有可用的位操作

  • 使用strrepr函数转换为字符串

  • 不能通过ceilfloorround方法进行四舍五入

datetime支持加法和减法,但不支持其他datetime类型。我们只能添加时间差(如添加一天或减去一年)。在标准库中,乘法和除法对datetime来说并没有意义。同样地,在标准库中,日期的四舍五入也不是一个支持的操作。然而,datetime提供了与整数类似语义的比较和字符串格式化操作。因此,尽管datetime本质上是一个整数,但它包含了一组受限制的操作。

注意

语义指的是操作的意义。虽然str(int)str(datetime.datetime.now())返回的字符串格式不同,但意义相同:我正在从一个值创建一个字符串。

datetime还支持它们自己的行为,以进一步将它们与整数区分开来。这些行为包括:

  • 基于时区更改值

  • 能够控制字符串的格式

  • 找出今天是星期几

如果你想要获取所有行为的完整列表,请在你的 REPL 中输入import datetime; help(datetime.datetime)

datetimeint更为具体。它传达了比普通数字更具体的使用情况。当你选择使用更具体的类型时,你在告诉未来的贡献者存在一些可能的操作和需注意的约束条件,这些在不太具体的类型中是不存在的。

让我们深入探讨这如何与健壮代码相关联。假设你继承了一个处理完全自动化厨房开关的代码库。你需要添加能够更改关闭时间的功能(例如,在假期延长厨房的营业时间)。

def close_kitchen_if_past_cutoff_time(point_in_time):
    if point_in_time >= closing_time():
        close_kitchen()
        log_time_closed(point_in_time)

你知道你需要操作的是point_in_time,但如何开始?你到底在处理什么类型?是strintdatetime还是一些自定义类?你可以在point_in_time上执行哪些操作?你没有编写这段代码,也没有它的历史记录。如果你想要调用这段代码,同样的问题也存在。你不知道什么是合法的传递给这个函数的内容。

如果你做出了错误的假设,不论朝什么方向,而这段代码又进入了生产环境,你将使代码变得不够健壮。也许这段代码并不经常执行。也许其他某个错误隐藏了这段代码的运行。也许这段代码周围的测试不多,这会在以后导致运行时错误。无论如何,代码中潜藏着一个错误,你降低了可维护性。

负责任的开发者尽力避免错误进入生产环境。他们会寻找测试、文档(当然,要带着一点谨慎——文档可能很快过时),或者调用代码。他们会查看closing_time()log_time_closed()来查看它们期望或提供的类型,并相应地计划。在这种情况下这是正确的路径,但我仍认为这是一条次优路径。虽然错误不会达到生产环境,但他们仍然花时间浏览代码,这阻碍了价值的快速交付。在这样一个小例子中,如果发生一次,你可能会原谅这不是个大问题。但要警惕千刀万剐:任何一次切割单独看不会太糟,但堆积成千上万在代码库中散落,将使你跋涉前行,试图交付代码。

根本原因在于参数的语义表达不清晰。当你编写代码时,尽力通过类型来表达你的意图。可以在需要时将其作为注释,但我建议使用类型注解(Python 3.5+支持)来解释代码的部分。

def close_kitchen_if_past_cutoff_time(point_in_time: datetime.datetime):
    if point_in_time >= closing_time():
        close_kitchen()
        log_time_closed(point_in_time)

我所需要做的就是在我的参数后面放一个:<type>。本书中的大多数代码示例将利用类型注解来清晰地表达代码期望的类型。

现在,当开发者们遇到这段代码时,他们会知道point_in_time的预期行为。他们不需要查看其他方法、测试或文档来了解如何操作这个变量。他们清楚地知道该做什么,并可以直接开始执行他们需要做的修改。你向未来的开发者传达了语义上的表示,而无需直接与他们交流。

此外,随着开发者对某种类型的使用越来越多,他们会变得熟悉它。当他们遇到它时,他们不需要查找文档或使用help()来使用该类型。你开始在代码库中创建一个众所周知的类型词汇表。这减少了维护的负担。当开发者修改现有代码时,他们希望专注于他们需要进行的更改,而不会陷入细节之中。

类型的语义表示极为重要,第一部分的其余部分将专门讨论如何利用类型为你所用。在继续之前,我需要介绍 Python 语言作为一种语言的一些基本结构元素,以及它们如何影响代码库的健壮性。

讨论主题

考虑一下代码库中使用的类型。选择几种类型,并问自己它们的语义表示是什么。列举它们的约束、用例和行为。你是否可以在更多地方使用这些类型?是否有地方你在误用类型?

类型系统

正如本章前面讨论的那样,类型系统旨在为用户提供一种模拟语言中的行为和约束的方法。编程语言对其特定类型系统在代码构建和运行时的工作方式有所期望。

强弱之分

类型系统按从弱到强的光谱进行分类。光谱较强一侧的语言倾向于限制对支持它们的类型的操作。换句话说,如果您违反了类型的语义表示,您将得到提示(有时会相当响亮),通过编译器错误或运行时错误。像 Haskell、TypeScript 和 Rust 这样的语言都被认为是强类型的。支持者主张强类型语言,因为在构建或运行代码时更容易发现错误。

相反,光谱较弱一侧的语言不会限制对支持它们的类型的操作。类型通常会被强制转换为不同的类型以理解操作。像 JavaScript、Perl 和较早版本的 C 这样的语言是弱类型的。支持者主张开发人员可以快速迭代代码而不必与语言作斗争。

Python 属于光谱的强一侧。在类型之间几乎没有发生隐式转换。当您执行非法操作时,这是显而易见的:

>>>[] + {}
TypeError: can only concatenate list (not "dict") to list

>>> {} + []
TypeError: unsupported operand type(s) for +: 'dict' and list

与弱类型语言相比,如 JavaScript:

>>> [] + {}
"[object Object]"

>>> {} + []
0

在健壮性方面,诸如 Python 之类的强类型语言确实帮助了我们。虽然错误仍然会在运行时而不是在开发时出现,但它们仍然会以明显的TypeError异常显示出来。这显著减少了调试问题所需的时间,再次使您能够更快地交付增量值。

弱类型语言固有不稳定吗?

弱类型语言的代码库绝对可以是健壮的;我绝不是在诋毁这些语言。考虑一下全球运行的大量生产级 JavaScript。但是,弱类型语言需要额外的注意才能健壮。很容易误解变量的类型并做出不正确的假设。开发人员非常依赖于 linters、测试和其他工具来提高可维护性。

动态与静态

还有另一个我需要讨论的类型光谱:静态与动态类型。这在处理类型的机械表示方面根本上是一种不同。

提供静态类型的语言在构建时将其类型信息嵌入变量中。开发人员可以显式向变量添加类型信息,或者一些工具(如编译器)可以为开发人员推断类型。变量在运行时不会改变其类型(因此称为“静态”)。静态类型的支持者吹嘘能够从一开始就编写安全的代码,并从强大的安全网中受益。

另一方面,动态类型将类型信息嵌入到值或变量本身。变量在运行时可以很容易地改变类型,因为该变量没有与之绑定的类型信息。动态类型的支持者主张开发的灵活性和速度;与编译器的战斗远远没有这么多。

Python 是一种动态类型语言。正如你在关于机械表示的讨论中看到的那样,变量的值内嵌了类型信息。Python 并不介意在运行时改变变量的类型:

>>> a = 5
>>> a = "string"
>>> a
"string"

>>> a = tuple()
>>> a
()

不幸的是,在许多情况下,运行时更改类型的能力对于编写健壮的代码是一种阻碍。你不能对变量的整个生命周期做出强烈的假设。随着假设的破灭,很容易在其上写出不稳定的假设,导致你的代码中存在一个滴答作响的逻辑炸弹。

动态类型语言本质上不具备健壮性吗?

就像弱类型语言一样,在动态类型语言中编写健壮的代码绝对是可能的。你只需要更努力一些。你将不得不做出更多有意识的决策,使你的代码库更易维护。另一方面,静态类型也不能保证健壮性;只能最低限度地使用类型并看到很少的好处。

更糟糕的是,我之前展示的类型注释对运行时的这种行为没有影响:

>>> a: int = 5
>>> a = "string"
>>> a
"string"

没有错误,没有警告,没有任何提示。但是希望并没有消失,你有很多策略可以使代码更加健壮(否则,这本书会非常简短)。我们将讨论最后一个有助于编写健壮代码的因素,然后开始深入研究如何改进我们的代码库。

鸭子类型

或许这是一个不成文的法则,每当有人提到鸭子类型时,总会有人回答:

如果它走起来像鸭子,嘎嘎叫起来像鸭子,那么它一定是鸭子。

我对这句话的问题是,我发现它完全没有帮助解释鸭子类型到底是什么。这句话很有吸引力,简洁明了,但关键是,只有那些已经理解鸭子类型的人才能理解。当我年轻的时候,我只是礼貌地点头,担心自己在这个简单的短语中错过了什么深刻的东西。直到后来我才真正理解了鸭子类型的威力。

鸭子类型 是使用编程语言中的对象和实体的能力,只要它们遵循某种接口。在 Python 中,这是一件很棒的事情,大多数人在不知不觉中都在使用它。让我们看一个简单的例子来说明我在说什么:

from typing import Iterable
def print_items(items: Iterable):
    for item in items:
        print(item)

print_items([1,2,3])
print_items({4, 5, 6})
print_items({"A": 1, "B": 2, "C": 3})

print_items的所有三次调用中,我们都遍历集合并打印每个项。想一想这是如何工作的。print_items根本不知道它将接收什么类型。它只是在运行时接收一个类型并对其进行操作。它不会检查每个参数并根据类型决定做不同的事情。事实上,情况要简单得多。print_items只是检查传递进来的东西是否可以迭代(通过调用__iter__方法)。如果属性__iter__存在,就调用它并循环遍历返回的迭代器。

我们可以通过一个简单的代码示例来验证这一点:

>>> print_items(5)

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in print_items
TypeError: 'int' object is not iterable

>>> '__iter__' in dir(int)
False
>>> '__iter__' in dir(list)
True

Duck typing(鸭子类型)使这一切成为可能。只要某个类型支持函数所使用的变量和方法,你就可以在该函数中自由地使用该类型。

这里有另一个例子:

>>>def double_value(value):
>>>    return value + value

>>>double_value(5)
10

>>>double_value("abc")
"abcabc"

在某个地方传递整数,在另一个地方传递字符串并不重要;两者都支持+运算符,因此都可以正常工作。任何支持+运算符的对象都可以传递进来。我们甚至可以用列表来做:

>>>double_value([1, 2, 3])
[1, 2, 3, 1, 2, 3]

那么这如何影响健壮性呢?事实证明,鸭子类型是一把双刃剑。它可以增加健壮性,因为它增加了组合性(我们将在第十七章中学到更多关于组合性的内容)。建立一个能够处理多种类型的坚实抽象库,可以减少复杂特殊情况的需求。然而,如果滥用鸭子类型,你开始打破开发者可以依赖的假设。在更新代码时,不仅仅是简单地进行更改;你必须查看所有调用代码,并确保传递给函数的类型也满足你的新更改。

总结所有这些,最好重述本节早期的成语如下:

如果它像鸭子一样走路、像鸭子一样嘎嘎叫,而你正在寻找走路和嘎嘎叫的东西,那么你可以把它当作是一只鸭子。

语言表达不太流畅,对吧?

讨论话题

在你的代码库中是否使用鸭子类型?是否有可以传递不匹配代码所寻找的类型的地方,但事情仍然正常工作?你认为这些对你的用例增加了还是减少了健壮性?

总结思路

类型是干净、可维护的代码的支柱,并且作为与其他开发人员交流的工具。如果你在类型上下功夫,就能传达很多信息,为未来的维护者减轻负担。第一部分 的其余内容将展示如何利用类型来增强代码库的健壮性。

请记住,Python 是动态和强类型的。强类型的特性对我们来说是一种福音;当我们使用不兼容的类型时,Python 会通知我们错误。但它的动态类型特性是我们需要克服的,以便编写更好的代码。这些语言选择决定了 Python 代码的编写方式,你在编写代码时应该牢记这些选择。

在接下来的章节中,我们将讨论类型注解,这是我们如何明确使用的类型。类型注解发挥着至关重要的作用:是我们向未来开发者传达行为的主要方式。它们帮助克服动态类型语言的局限性,并允许您在整个代码库中强化意图。

第三章:类型注解

Python 是一种动态类型语言;类型直到运行时才知道。这在试图编写健壮代码时是一个障碍。由于类型嵌入在值本身中,开发人员很难知道他们正在处理的是什么类型。当然,今天这个名称看起来像是一个str,但如果有人将其改为bytes会发生什么呢?对于动态类型语言,类型的假设是建立在不稳定的基础上的。然而,希望并未失去。在 Python 3.5 中,引入了一个全新的功能:类型注解。

类型注解将你编写健壮代码的能力提升到一个全新的水平。Python 的创造者 Guido van Rossum 表示得最好:

我已经学到了一个痛苦的教训,对于小程序来说,动态类型是很棒的。但对于大程序,你必须采取更严格的方法,如果语言实际上提供了这种纪律,而不是告诉你“好吧,你可以做任何你想做的事情”会更有帮助。¹

类型注解是更严谨的方法,是你在处理更大代码库时所需的额外关注。在本章中,你将学习如何使用类型注解,它们为什么如此重要,以及如何利用一种称为类型检查器的工具来强制执行你在整个代码库中的意图。

什么是类型注解?

在第二章,你首次瞥见了类型注解:

def close_kitchen_if_past_close(point_in_time: datetime.datetime): ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00002.gif)
    if point_in_time >= closing_time():
        close_kitchen()
        log_time_closed(point_in_time)

1

这里的类型注解是: datetime.datetime

类型注解是一种额外的语法,通知用户你的变量的预期类型。这些注解充当类型提示;它们为读者提供提示,但实际上 Python 语言在运行时并不使用它们。事实上,你完全可以忽略这些提示。考虑以下代码片段,以及开发人员写的评论。

# CustomDateTime offers all the same functionality with
# datetime.datetime. I'm using it here for its better
# logging facilities.
close_kitchen_if_past_close(CustomDateTime("now")) # no error
警告

在你违背类型提示的情况下,应该是一个罕见的案例。作者非常明确地打算了一个特定的用例。如果你不打算遵循类型注解,那么如果原始代码以不兼容你所使用的类型的方式发生更改,你将为自己设置问题。

Python 在这种情况下不会在运行时抛出任何错误。事实上,在 Python 执行时根本不使用这些类型注解。没有检查或成本用于使用这些类型注解。然而,这些类型注解仍然起到了至关重要的作用:通知你的读者预期的类型。代码的维护者将知道在更改你的实现时可以使用哪些类型。调用代码也会受益,因为开发人员将确切知道要传入的类型。通过实施类型注解,你减少了摩擦。

设想一下你未来的维护者的处境。遇到直观易用的代码会不会很棒?你不需要一行接着一行地挖掘函数来确定使用方法,也不会误用某种类型,然后需要处理异常和错误行为的后果。

考虑另一段代码,它接收员工的可用时间和餐厅的营业时间,然后为那一天安排可用的工人。你想使用这段代码,看到了以下内容:

def schedule_restaurant_open(open_time, workers_needed):

让我们忽略实现一会儿,因为我想专注于第一印象。你认为可以传入什么?停下来,闭上眼睛,问问自己在阅读之前,什么是合理的传入类型。open_timedatetime 类型,还是自纪元以来的秒数,或者是包含小时的字符串?workers_needed 是一个名字列表,一个 Worker 对象的列表,还是其他什么东西?如果你猜错了,或者不确定,你需要查看实现或调用代码,这我已经明确了需要时间,并且令人沮丧。

让我提供一个实现,你可以看看你离正解有多近。

import datetime
import random

def schedule_restaurant_open(open_time: datetime.datetime,
                             workers_needed: int):
    workers = find_workers_available_for_time(open_time)
    # Use random.sample to pick X available workers
    # where X is the number of workers needed.
    for worker in random.sample(workers, workers_needed):
        worker.schedule(open_time)

你可能猜到 open_timedatetime 类型,但你有没有考虑过 workers_needed 可能是一个 int?一旦你看到类型注解,你会更清楚地了解发生了什么。这减少了认知负担,降低了维护者的摩擦。

提示

这无疑是朝着正确方向迈出的一步,但不要停在这里。如果你看到这样的代码,考虑将变量重命名为 number_of_workers_needed,以明确整数的含义。在下一章,我还将探讨类型别名,它提供了一种表达自己方式的替代方法。

到目前为止,我展示的所有例子都集中在参数上,但你也可以注释 返回类型

考虑 schedule_restaurant_open 函数。在那段代码中间,我调用了 find_workers_available_for_time。它返回一个名为 workers 的变量。假设你想将代码更改为选择最长时间未工作的工人,而不是随机抽样?你有任何迹象表明 workers 的类型吗?

如果你只看函数签名,你会看到以下内容:

def find_workers_available_for_time(open_time: datetime.datetime):

这里没有任何东西能帮助我们更快地完成工作。你可以猜测,测试会告诉我们,对吧?也许是一个名字列表?不如让测试失败,还是去看看实现吧。

def find_workers_available_for_time(open_time: datetime.datetime):
    workers = worker_database.get_all_workers()
    available_workers = [worker for worker in workers
                           if is_available(worker)]
    if available_workers:
        return available_workers

    # fall back to workers who listed they are available
    # in an emergency
    emergency_workers = [worker for worker in get_emergency_workers()
                           if is_available(worker)]

    if emergency_workers:
        return emergency_workers

    # Schedule the owner to open, they will find someone else
    return [OWNER]

哦不,这里没有告诉你应该期望什么类型。这段代码中有三个不同的返回语句,而你希望它们都返回相同的类型。(当然,每个if语句都通过单元测试来确保它们一致,对吧?对吧?)你需要深入挖掘。你需要查看worker_database。你需要查看is_availableget_emergency_workers。你需要查看OWNER变量。这些都需要保持一致,否则你将需要在原始代码中处理特殊情况。

如果这些函数也不能确切告诉你需要的东西怎么办?如果你不得不通过多个函数调用深入了解?你需要穿过的每一层都是你大脑中需要保持的抽象层。每一点信息都会增加认知负荷。你承受的认知负荷越多,出错的可能性就越大。

所有这些都可以通过对返回类型进行注解来避免。返回类型通过在函数声明的末尾放置-> <type>来注释。假设你遇到了这个函数签名:

def find_workers_available_for_time(open_time: datetime.datetime) -> list[str]:

现在你知道应该把工作者确实看作是一个字符串列表。不需要深入数据库、函数调用或模块。

提示

在 Python 3.8 及更早版本中,内置的集合类型如listdictset不允许使用方括号语法,比如list[Cookbook]dict[str,int]。相反,你需要使用 typing 模块中的类型注解:

from typing import Dict,List
AuthorToCountMapping = Dict[str, int]
def count_authors(
                   cookbooks: List[Cookbook]
                 ) -> AuthorToCountMapping:
    # ...

需要时你也可以对变量进行注释:

workers: list[str] = find_workers_available_for_time(open_time)
numbers: list[int] = []
ratio: float = get_ratio(5,3)

虽然我会对所有的函数进行注释,但我通常不会打扰去对变量进行注释,除非我在代码中想传达的特定内容(比如一个与预期不同的类型)。我不想过分地在几乎所有地方都放置类型注解——Python 的简洁性是吸引许多开发者的原因。类型可能会使你的代码变得混乱,特别是当类型显而易见时。

number: int = 0
text: str = "useless"
values: list[float] = [1.2, 3.4, 6.0]
worker: Worker = Worker()

这些类型注解提供的价值并不比 Python 本身提供的更多。阅读这段代码的人知道"useless"是一个str。记住,类型注解用于类型提示;你为未来提供了改进沟通的注释。你不需要到处说明显而易见的事情。

类型注解的好处

就像你做出的每个决定一样,你需要权衡成本和收益。从一开始考虑类型有助于你的有意识的设计过程,但类型注解还提供其他的好处吗?我将向你展示类型注解如何通过工具发挥作用。

自动完成

我主要讨论了与其他开发人员的沟通,但你的 Python 环境也从类型注解中受益。由于 Python 是动态类型的,很难知道哪些操作是可用的。使用类型注解,许多 Python 感知代码编辑器将为你的变量自动完成操作。

在 图 3-1 中,你会看到一个屏幕截图,展示了一个流行的代码编辑器 VS Code 检测到一个 datetime 并提供自动补全我的变量。

VS Code 显示自动补全

图 3-1. VS Code 显示自动补全

类型检查器

在本书中,我一直在讲述类型如何传达意图,但一直忽略了一个关键细节:如果开发人员不愿意遵循这些类型注解,那么没有程序员必须遵守它们。如果你的代码与类型注解相矛盾,那很可能是一个错误,你仍然依赖人类来捕获错误。我希望能做得更好。我希望计算机能够帮我找到这些类型错误。

我在 第二章 谈论动态类型时展示了这个代码片段:

>>> a: int = 5
>>> a = "string"
>>> a
"string"

这里面就是挑战所在:当你不能信任开发人员会遵循它们的指导时,类型注解如何使你的代码库更加健壮?为了做到健壮,你希望你的代码经得住时间的考验。为此,你需要一种工具来检查所有的类型注解,并在有问题时做出标记。这种工具就叫做类型检查器。

类型检查器使得类型注解从一种沟通方式转变为一种安全网。它是一种静态分析的形式。静态分析工具 是在你的源代码上运行的工具,完全不会影响你的运行时。在 第二十章 中,你将更多地了解静态分析工具,但现在我只解释类型检查器。

首先,我需要安装一个类型检查器。我将使用 mypy,一个非常流行的类型检查工具。

pip install mypy

现在我将创建一个名为 invalid_type.py 的文件,其中有不正确的行为:

a: int = 5
a = "string"

如果我在命令行上运行 mypy 对那个文件进行检查,我会得到一个错误:

mypy invalid_type.py

chapter3/invalid_type.py:2: error: Incompatible types in assignment
                            (expression has type "str", variable has type
                             "int")
Found 1 error in 1 file (checked 1 source file)

正如此时此刻,我的类型注解已成为错误的第一道防线。每当你犯错背离了作者的意图,类型检查器就会发现并警告你。实际上,在大多数开发环境中,你可以实时获取这种分析,随着你的输入即时通知你错误。(尽管它不会读懂你的思维,但它可以在最早的时候捕获到错误,这非常棒。)

练习:找出 Bug

下面是一些在我的代码中由 mypy 捕捉到的错误示例。我希望你找出每个代码片段中的错误并计时,看你找到 Bug 或者放弃用了多长时间,然后检查下面列出的输出看看你找对了没有。

def read_file_and_reverse_it(filename: str) -> str:
    with open(filename) as f:
        # Convert bytes back into str
        return f.read().encode("utf-8")[::-1]

这是 mypy 输出显示的错误信息:

mypy chapter3/invalid_example1.py
chapter3/invalid_example1.py:3: error: Incompatible return value type
                                       (got "bytes", expected "str")
Found 1 error in 1 file (checked 1 source file)

糟糕,我返回的是 bytes,而不是 str。我调用了 encode 而不是 decode,弄混了我的返回类型。我甚至都无法告诉你我把 Python 2.7 代码迁移到 Python 3 时犯了多少次这个错误。幸好有类型检查器。

这里是另一个例子:

# takes a list and adds the doubled values
# to the end
# [1,2,3] => [1,2,3,2,4,6]
def add_doubled_values(my_list: list[int]):
    my_list.update([x*2 for x in my_list])

add_doubled_values([1,2,3])

mypy 的错误如下:

mypy chapter3/invalid_example2.py
chapter3/invalid_example2.py:6: error: "list[int]" has no attribute "update"
Found 1 error in 1 file (checked 1 source file)

另一个我犯的无辜错误是在列表上调用update而不是extend。当在不同集合类型之间移动时(在这种情况下是从setlist),这类错误很容易发生。

再举一个例子来总结一下:

# The restaurant is named differently
# in different parts of the world
def get_restaurant_name(city: str) -> str:
    if city in ITALY_CITIES:
            return "Trattoria Viafore"
    if city in GERMANY_CITIES:
            return "Pat's Kantine"
    if city in US_CITIES:
            return "Pat's Place"
    return None

if get_restaurant_name('Boston'):
    print("Location Found")

mypy 的错误如下:

chapter3/invalid_example3.py:14: error: Incompatible return value type
                                        (got "None", expected "str")
Found 1 error in 1 file (checked 1 source file)

这个例子有些微妙。当预期一个字符串值时,我却返回了None。如果所有的代码都只是条件性地检查餐厅名称来做决策,就像我之前做的那样,测试将会通过,一切看起来都没有问题。即使对于负面情况也是如此,因为在if语句中检查None是完全没问题的(它是假值)。这是 Python 动态类型的一个例子,回头来咬我们一口。

但是,几个月后,某些开发人员将尝试将此返回值作为字符串使用,一旦需要添加新城市,代码就会尝试操作None值,这将导致异常抛出。这并不是很健壮;这里存在一个潜在的代码错误隐患。但是通过类型检查器,您可以停止担心这一点,并及早捕捉到这些错误。

警告

有了类型检查器,你是否还需要测试?当然需要。类型检查器捕获一类特定的错误:不兼容类型的错误。还有许多其他类别的错误仍然需要测试。将类型检查器视为您在 bug 识别工具箱中的一种工具。

在所有这些示例中,类型检查器都找到了一个潜在的即将发生的 bug。无论这个 bug 是由测试、代码审查还是客户发现的,都无关紧要;类型检查器能更早地捕捉到它,从而节省时间和金钱。类型检查器开始为我们提供静态类型语言的好处,同时仍然允许 Python 运行时保持动态类型。这确实是两全其美的方式。

在本章开头,你会找到 Guido van Rossum 的一句话。在 Dropbox 工作期间,他发现大型代码库在没有安全网的情况下存在困难。他成为驱动类型提示进入语言的强烈支持者。如果你希望你的代码传达意图并捕获错误,请立即开始采用类型注释和类型检查。

讨论主题

您的代码库是否有过错误滑过,而这些错误可以被类型检查器捕获?这些错误给您造成了多大的损失?有多少次是代码审查或集成测试捕获了 bug?那些进入生产环境的 bug 呢?

何时使用类型注解

在你开始将类型添加到所有内容之前,我需要谈谈成本问题。添加类型很简单,但可能会做得过火。当用户尝试测试和调试代码时,他们可能会开始与类型检查器作斗争,因为他们觉得在编写所有类型注释时被拖累。对于刚开始使用类型提示的用户来说,这是一个采用的成本。我还提到过我并不会对所有变量进行类型注释,特别是类型显而易见时。我也通常不会为类中的每个小私有方法的参数进行类型注释。

什么时候应该使用类型检查器?

  • 对于你期望其他模块或用户调用的函数(例如公共 API、库入口点等)。

  • 当你想强调类型复杂(例如,将字符串映射到对象列表的字典)或不直观时。

  • 当 mypy 提示你需要类型时的区域(通常在将空集合赋值时更容易遵循工具而不是反对它)

类型检查器将推断任何可能的值的类型,因此即使你没有填写所有类型,你仍将获得收益。我将在第六章中介绍配置类型检查器。

结语

当类型提示引入时,Python 社区内部曾产生过疑虑。开发人员担心 Python 正在变成像 Java 或 C++这样的静态类型语言。他们担心到处添加类型会减慢开发速度,并破坏他们喜爱的动态类型语言的好处。

然而,类型提示仅仅是提示而已。它们是完全可选的。我不推荐它们用于小型脚本,或者任何不会存在很长时间的代码片段。但是,如果你的代码需要长期维护,类型提示就是无价的。它们作为一种沟通方法,使你的环境更智能,并且与类型检查器结合使用时能检测错误。它们保护了原始作者的意图。在注释类型时,你减轻了读者理解你的代码的负担。你减少了需要阅读函数实现来了解其操作的需要。代码很复杂,你应该尽量减少开发人员需要阅读的代码量。通过使用经过深思熟虑的类型,你减少了意外并增加了阅读理解力。

类型检查器也是一种信心的增强器。记住,为了使你的代码更健壮,它必须易于更改、重写和删除(如果需要)。类型检查器可以让开发人员在这样做时更加放心。如果某些内容依赖于已更改或删除的类型或字段,类型检查器将标记不兼容的代码。自动化工具使您和未来的合作者的工作更简单;减少错误进入生产环境,并更快地提供功能。

在下一章中,你将超越基本类型注解,学习如何构建所有新类型的词汇表。这些类型将帮助你约束代码库中的行为,限制事物可能出错的方式。我仅仅触及了类型注解可能非常有用的表面。

¹ Guido van Rossum,《语言创作者的对话》,PuPPy(Puget Sound Programming Python)2019 年度慈善活动。https://oreil.ly/1xf01

第四章:类型约束

许多开发人员学习了基本类型注解就结束了。但我们远未结束。有许多宝贵的高级类型注解。这些高级类型注解允许您约束类型,进一步限制它们可以表示的内容。您的目标是使非法状态无法表示。如果根本不可能在系统中创建错误,那么您的代码中就不会出现错误。您可以使用类型注解来实现这个目标,节省时间和金钱。在本章中,我将教您六种不同的技术:

Optional

用于在您的代码库中替换None引用。

Union

用于呈现一组类型的选择。

Literal

用于限制开发人员使用非常特定的值。

Annotated

用于提供类型的附加描述。

NewType

用于将类型限制在特定上下文中。

Final

用于防止变量被重新绑定到新值。

让我们从使用Optional类型处理None引用开始。

可选类型

空引用通常被称为“十亿美元的错误”,这个术语是由 C.A.R. Hoare 创造的:

我称之为我的十亿美元的错误。这是在 1965 年发明的空引用。当时,我正在设计一种用于对象导向语言中引用的第一个全面类型系统。我的目标是确保所有引用的使用都绝对安全,并由编译器自动执行检查。但我无法抵挡诱惑,简单地实现了一个空引用。这导致了无数的错误、漏洞和系统崩溃,这些问题可能在过去四十年中造成了数十亿美元的损失和损害。¹

尽管空引用起源于 Algol 语言,但它们已经渗透到无数其他语言中。C 和 C++经常因空指针解引用而受到嘲笑(导致段错误或其他导致程序崩溃的问题)。Java 因要求用户在整个代码中捕获NullPointerException而广为人知。可以毫不夸张地说,这些类型的错误具有以十亿计的代价。想想由于意外的空指针或引用而导致的开发人员时间、客户损失和系统故障。

那么,在 Python 中为什么这很重要呢?Hoare 的引用是关于 60 年代的面向对象编译语言;Python 现在一定会更好,对吧?我很遗憾地告诉您,这个十亿美元的错误也存在于 Python 中。它以不同的名字出现在我们面前:None。我将向您展示一种避免昂贵None错误的方法,但首先,让我们谈谈为什么None如此糟糕。

注意

尤其值得注意的是,霍尔承认,空引用是为了方便而产生的。这表明了选择更快捷的路径如何会在开发生命周期的后期导致各种痛苦。想想你今天的短期决策会如何不利于明天的维护。

让我们考虑一些运行自动热狗摊的代码。我希望我的系统能够拿起一个面包,把一根热狗放在面包里,然后通过自动发泡器挤出番茄酱和芥末,就像图 4-1 中描述的那样。会出什么问题呢?

自动热狗摊的工作流程

图 4-1. 自动热狗摊的工作流程
def create_hot_dog():
    bun = dispense_bun()
    frank = dispense_frank()
    hot_dog = bun.add_frank(frank)
    ketchup = dispense_ketchup()
    mustard = dispense_mustard()
    hot_dog.add_condiments(ketchup, mustard)
    dispense_hot_dog_to_customer(hot_dog)

相当直接,对吧?不幸的是,没有办法真正确定。想清楚正常路径,或者程序的控制流程当一切顺利时,是很容易的,但是当谈论到健壮的代码时,你需要考虑错误条件。如果这是一个没有人工干预的自动摊位,你能想到什么错误?

这是我能想到的错误的非全面列表:

  • 材料不足(面包、热狗或番茄酱/芥末)。

  • 订单在过程中取消。

  • 调料堵塞。

  • 电力中断。

  • 客户不想要番茄酱或芥末,并试图在过程中移动面包。

  • 竞争对手用番茄酱替换了番茄酱;混乱随之而来。

现在,你的系统是最先进的,会检测所有这些情况,但是当任何一步失败时,它会返回None。这对这段代码意味着什么?你开始看到以下错误:

Traceback (most recent call last):
 File "<stdin>", line 4, in <module>
AttributeError: 'NoneType' object has no attribute 'add_frank'

Traceback (most recent call last):
 File "<stdin>", line 7, in <module>
AttributeError: 'NoneType' object has no attribute 'add_condiments'

如果这些错误泡沫冒到你的客户那里会很灾难性;你以清爽的用户界面为傲,不希望丑陋的回溯污染你的界面。为了解决这个问题,你开始进行防御性编程,或者以一种能够预见每种可能错误情况并加以考虑的方式进行编码。防御性编程是一件好事,但它导致了这样的代码:

def create_hot_dog():
    bun = dispense_bun()
    if bun is None:
        print_error_code("Bun unavailable. Check for bun")
        return

    frank = dispense_frank()
    if frank is None:
        print_error_code("Frank was not properly dispensed")
        return

    hot_dog = bun.add_frank(frank)
    if hot_dog is None:
        print_error_code("Hot Dog unavailable. Check for Hot Dog")
        return

    ketchup = dispense_ketchup()
    mustard = dispense_mustard()
    if ketchup is None or mustard is None:
        print_error_code("Check for invalid catsup")
        return

    hot_dog.add_condiments(ketchup, mustard)
    dispense_hot_dog_to_customer(hot_dog)

这感觉,嗯,很烦人。因为在 Python 中任何值都可以是None,所以似乎你需要进行防御性编程,并在每次解除引用之前进行一个is None检查。这是多余的;大多数开发人员会跟踪调用堆栈,并确保不会将None值返回给调用者。这是错误的;你不能指望每个接触你的代码库的开发人员都能本能地知道在哪里检查None。此外,你编写代码时所做的原始假设(例如,此函数永远不会返回None)可能会在将来被打破,现在你的代码有了一个 bug。问题就在这里:依靠手动干预来捕捉错误情况是不可靠的。

这是如此棘手(也如此昂贵)的原因是,None 被视为一种特殊情况。它存在于正常类型层次结构之外。每个变量都可以被赋值为 None。为了应对这一点,你需要找到一种在你的类型层次结构中表示 None 的方法。你需要使用 Optional 类型。

Optional 类型为你提供两种选择:要么你有一个值,要么你没有。换句话说,将变量设置为一个值是可选的。

from typing import Optional
maybe_a_string: Optional[str] = "abcdef" # This has a value
maybe_a_string: Optional[str] = None     # This is the absence of a value

这段代码指示变量 maybe_a_string 可能包含一个字符串。无论 maybe_a_string 包含 "abcdef" 还是 None,该代码都能成功通过类型检查。

乍一看,不明显它能为你带来什么。你仍然需要使用 None 来表示值的缺失。不过,我有个好消息告诉你。我将 Optional 类型与三个好处关联起来。

首先,你可以更清晰地表达你的意图。如果开发者在类型签名中看到 Optional 类型,他们会把它看作一个大红旗,表明他们应该预期可能会出现 None

def dispense_bun() -> Optional[Bun]:
# ...

如果你注意到一个函数返回一个 Optional 值,请注意并检查 None 值。

其次,你能够进一步区分值的缺失和空值。考虑一个无害的列表。如果你进行一个函数调用并接收到一个空列表,会发生什么?是因为没有结果返回给你吗?还是发生了错误,你需要采取明确的行动?如果你收到一个原始列表,你不知道,除非你深入源代码。然而,如果你使用 Optional,你传达了三种可能性中的一种:

一个有元素的列表

可操作的有效数据

一个没有元素的列表

没有发生错误,但没有可用数据(假设没有数据不是错误条件)

None

发生了一个需要处理的错误

最后,类型检查器可以检测到 Optional 类型,并确保你没有让 None 值溜过去。

考虑:

def dispense_bun() -> Bun:
    return Bun('Wheat')

让我们给这段代码添加一些错误情况:

def dispense_bun() -> Bun:
    if not are_buns_available():
        return None
    return Bun('Wheat')

当使用类型检查器运行时,你将得到以下错误:

code_examples/chapter4/invalid/dispense_bun.py:12:
    error: Incompatible return value type (got "None", expected "Bun")

太好了!类型检查器将不允许你默认返回 None 值。通过将返回类型从 Bun 更改为 Optional[Bun],这段代码将成功地通过类型检查。这将为开发者提供提示,表明他们不应该在不在返回类型中编码信息的情况下返回 None,可以捕捉一个常见的错误,并使这段代码更加健壮。但是调用代码呢?

结果表明,调用代码也从中受益。考虑:

def create_hot_dog():
    bun = dispense_bun()
    frank = dispense_frank()
    hot_dog = bun.add_frank(frank)
    ketchup = dispense_ketchup()
    mustard = dispense_mustard()
    hot_dog.add_condiments(ketchup, mustard)
    dispense_hot_dog_to_customer(hot_dog)

如果 dispense_bun 返回一个 Optional,这段代码将无法通过类型检查。它会报以下错误:

code_examples/chapter4/invalid/hotdog_invalid.py:27:
    error: Item "None" of "Optional[Bun]" has no attribute "add_frank"
警告

根据你的类型检查器,你可能需要专门启用一个选项来捕捉这些错误。始终查看你的类型检查器的文档,了解有哪些选项可用。如果有一个你绝对想要捕捉的错误,你应该测试一下你的类型检查器确实捕捉到了这个错误。我强烈建议专门测试Optional的行为。对于我正在运行的mypy版本(0.800),我必须使用--strict-optional作为命令行标志来捕捉这个错误。

如果你想要消除类型检查器的警告,你需要显式检查None并处理None值,或者断言该值不能是None。以下代码可以成功进行类型检查:

def create_hot_dog():
    bun = dispense_bun()
    if bun is None:
        print_error_code("Bun could not be dispensed")
        return

    frank = dispense_frank()
    hot_dog = bun.add_frank(frank)
    ketchup = dispense_ketchup()
    mustard = dispense_mustard()
    hot_dog.add_condiments(ketchup, mustard)
    dispense_hot_dog_to_customer(hot_dog)

None值真的是一个十亿美元的错误。如果它们溜过去,程序可能会崩溃,用户会感到沮丧,钱会丢失。使用Optional类型告诉其他开发者注意None,并从你的工具的自动检查中受益。

讨论主题

你的代码库中经常处理None吗?你对每个可能的None值是否处理正确有多自信?查看错误和失败的测试,看看你被错误的None处理咬了多少次。讨论一下Optional类型将如何帮助你的代码库。

联合类型

Union类型是指多个不同的类型可以与同一个变量一起使用的类型。Union[int,str]表示一个变量可以使用int str。例如,考虑以下代码:

def dispense_snack() -> HotDog:
    if not are_ingredients_available():
        raise RuntimeError("Not all ingredients available")
    if order_interrupted():
        raise RuntimeError("Order interrupted")
    return create_hot_dog()

我现在希望我的热狗摊能进入利润丰厚的椒盐脆饼业务。而不是试图处理不应存在于热狗和椒盐脆饼之间的奇怪的类继承(我们将在第二部分中更多地介绍继承),你只需返回这两者的Union

from typing import Union
def dispense_snack(user_input: str) -> Union[HotDog, Pretzel]:
    if user_input == "Hot Dog":
        return dispense_hot_dog()
    elif user_input == "Pretzel":
        return dispense_pretzel()
    raise RuntimeError("Should never reach this code,"
                       "as an invalid input has been entered")
注意

Optional只是Union的一种特殊版本。Optional[int]Union[int, None]完全相同。

使用Union提供了与Optional几乎相同的好处。首先,你能得到同样的沟通优势。遇到Union的开发者知道他们必须能够处理调用代码中的多个类型。此外,类型检查器和Optional一样了解Union

你会发现Union在各种应用中都很有用:

  • 根据用户输入处理返回的不同类型(如上所述)

  • 处理错误返回类型,比如Optional,但提供更多信息,比如字符串或错误代码

  • 处理不同的用户输入(例如,如果用户能够提供一个列表或一个字符串)

  • 返回不同的类型,比如为了向后兼容性(根据请求的操作返回一个旧版本的对象或一个新版本的对象)

  • 以及其他任何你可能合理地有多个值表示的情况

假设你有一个调用dispense_snack函数的代码,但只期望返回一个HotDog(或None):

from typing import Union
def place_order() -> Optional[HotDog]:
    order = get_order()
    result = dispense_snack(order.name)
    if result is None
        print_error_code("An error occurred" + result)
        return None
    # Return our HotDog
    return result

一旦dispense_snack开始返回Pretzels,这段代码就无法通过类型检查。

code_examples/chapter4/invalid/union_hotdog.py:22:
    error: Incompatible return value type (got "Union[HotDog, Pretzel]",
                                           expected "Optional[HotDog]")

在这种情况下,类型检查器出错的事实是非常好的。如果您依赖的任何函数更改为返回新类型,则其返回签名必须更新为Union新类型,这将强制您更新代码以处理新类型。这意味着,当您的依赖项以与您的假设相矛盾的方式发生变化时,您的代码将被标记。通过您今天做出的决策,您可以在未来捕捉错误。这是健壮代码的标志;您使开发人员犯错的难度越来越大,从而降低了他们的错误率,减少了用户可能遇到的错误数量。

使用Union还有一个更根本的好处,但是要解释清楚,我需要教你一点类型理论,这是关于类型系统的一种数学分支。

产品和总和类型

Union很有用,因为它们有助于限制可表示的状态空间。 可表示的状态空间 是对象可以采用的所有可能组合的集合。

取这个dataclass

from dataclasses import dataclass
# If you aren't familiar with data classes, you'll learn more in chapter 10
# but for now, treat this as four fields grouped together and what types they are
@dataclass
class Snack:
    name: str
    condiments: set[str]
    error_code: int
    disposed_of: bool

Snack("Hotdog", {"Mustard", "Ketchup"}, 5, False)

我有一个名称,可以放在顶部的调味料,如果出了问题,还有一个错误代码,并且如果出了问题,有一个布尔值来跟踪我是否正确处理了该项。可以将多少种不同的值组合放入这个字典?可能是无限多个,对吧?name单独可以是任何有效值(“热狗”或“椒盐脆饼”)到无效值(“萨摩萨”、“泡菜”或“布丁”)甚至是荒谬的(“12345”、“”或“(╯°□°)╯︵ ┻━┻”)。 condiments也存在类似的问题。目前,无法计算可能的选项。

为了简单起见,我将人为地限制这种类型:

  • 名称可以是三个值之一:热狗、椒盐脆饼或素食汉堡。

  • 调味料可以是空的、芥末、番茄酱或两者兼有。

  • 有六个错误代码(0-5);0 表示成功。

  • disposed_of只能是TrueFalse

现在这组字段可以表示多少种不同的值?答案是 144,这是一个极其庞大的数字。我通过以下方式实现这一点:

名称有三种可能类型 × 调味品有四种可能类型 × 六个错误代码 × 两个布尔值用于记录条目是否已处理 = 3×4×6×2 = 144。

如果你接受这些值中的任何一个可以是None,则总数扩展到 420。虽然编码时应始终考虑None(请参阅本章前面关于Optional的内容),但对于这个思维实验,我将忽略None值。

这种操作被称为乘类型;可表示状态的数量由可能值的乘积决定。问题在于,并非所有这些状态都是有效的。如果将变量disposed_of设置为非零错误码,则应该将其设置为True。开发人员会做出这种假设,并相信非法状态永远不会出现。然而,一个无辜的错误可能导致整个系统崩溃。考虑以下代码:

def serve(snack):
    # if something went wrong, return early
    if snack.disposed_of:
        return
    # ...

在这种情况下,开发人员在检查非零错误码之前检查disposed_of。这是一个等待发生的逻辑炸弹。只要disposed_ofTrue 错误码为非零,这段代码就完全正常。如果一个有效的小吃错误地将disposed_of标志设置为True,这段代码将开始产生无效的结果。这很难找到,因为创建小吃的开发人员没有理由检查此代码。目前为止,除了手动检查每个用例之外,您没有办法捕获这种错误,对于大型代码库来说是不可行的。通过允许可表示非法状态,您打开了脆弱代码的大门。

要解决这个问题,我需要使这个非法状态不可表示。为了做到这一点,我将重新调整我的示例,并使用Union

from dataclasses import dataclass
from typing import Union
@dataclass
class Error:
    error_code: int
    disposed_of: bool

@dataclass
class Snack:
    name: str
    condiments: set[str]

snack: Union[Snack, Error] = Snack("Hotdog", {"Mustard", "Ketchup"})

snack = Error(5, True)

在这种情况下,snack可以是一个Snack(仅包含namecondiments)或一个Error(仅包含一个数字和一个布尔值)。通过使用Union,现在有多少个可表示的状态呢?

对于Snack,有 3 个名称和 4 个可能的列表值,总共有 12 个可表示的状态。对于ErrorCode,我可以移除 0 错误码(因为那只是成功的情况),这给了我 5 个错误码的值和 2 个布尔值的总计 10 个可表示的状态。由于Union是一个选择一个或另一个的结构,我可以在一种情况下有 12 个可表示的状态,或者在另一种情况下有 10 个,总共 22 个。这是一个和类型的示例,因为我是将可表示状态的数量相加而不是相乘。

这是 22 个总可表示的状态。将其与将所有字段合并到单个实体时的 144 个状态进行比较。我将我的可表示状态空间减少了几乎 85%。我使得不兼容的字段无法混合和匹配。这样做变得更难出错,并且需要测试的组合数量大大减少。任何时候您使用和类型,例如Union,都会大幅减少可能的可表示状态数量。

字面类型

在计算可表示状态的数量时,我在上一节中做了一些假设。我限制了可能的值的数量,但这有点作弊,不是吗?正如我之前所说的,可能的值几乎是无限的。幸运的是,有一种方法可以通过 Python 限制这些值:LiteralsLiteral类型允许您将变量限制为非常特定的值集。

I’ll change my earlier Snack class to employ Literal values:

from typing import Literal
@dataclass
class Error:
    error_code: Literal[1,2,3,4,5]
    disposed_of: bool

@dataclass
class Snack:
    name: Literal["Pretzel", "Hot Dog", "Veggie Burger"]
    condiments: set[Literal["Mustard", "Ketchup"]]

Now, if I try to instantiate these data classes with wrong values:

Error(0, False)
Snack("Invalid", set())
Snack("Pretzel", {"Mustard", "Relish"})

I receive the following typechecker errors:

code_examples/chapter4/invalid/literals.py:14: error: Argument 1 to "Error" has
    incompatible type "Literal[0]";
                      expected "Union[Literal[1], Literal[2], Literal[3],
                                      Literal[4], Literal[5]]"

code_examples/chapter4/invalid/literals.py:15: error: Argument 1 to "Snack" has
    incompatible type "Literal['Invalid']";
                       expected "Union[Literal['Pretzel'], Literal['Hotdog'],
                                       Literal['Veggie Burger']]"

code_examples/chapter4/invalid/literals.py:16: error: Argument 2 to <set> has
    incompatible type "Literal['Relish']";
                       expected "Union[Literal['Mustard'], Literal['Ketchup']]"

Literals were introduced in Python 3.8, and they are an invaluable way of restricting possible values of a variable. They are a little more lightweight than Python enumerations (which I’ll cover in Chapter 8).

Annotated Types

What if I wanted to get even deeper and specify more complex constraints? It would be tedious to write hundreds of literals, and some constraints aren’t able to be modeled by Literal types. There’s no way with a Literal to constrain a string to a certain size or to match a specific regular expression. This is where Annotated comes in. With Annotated, you can specify arbitrary metadata alongside your type annotation.

x: Annotated[int, ValueRange(3,5)]
y: Annotated[str, MatchesRegex('[0-9]{4}')]

Unfortunately, the above code will not run, as ValueRange and MatchesRegex are not built-in types; they are arbitrary expressions. You will need to write your own metadata as part of an Annotated variable. Secondly, there are no tools that will typecheck this for you. The best you can do until such a tool exists is write dummy annotations or use strings to describe your constraints. At this point, Annotated is best served as a communication method.

NewType

While waiting for tooling to support Annotated, there is another way to represent more complicated constraints: NewType. NewType allows you to, well, create a new type.

Suppose I want to separate my hot dog stand code to handle two separate cases: a hot dog in its unservable form (no plate, no napkins) and a hot dog that is ready to serve (plated, with napkins). In my code, there exist some functions that should only be operating on the hot dog in one case or the other. For example, an unservable hot dog should never be dispensed to the customer.

class HotDog:
    # ... snip hot dog class implementation ...

def dispense_to_customer(hot_dog: HotDog):
    # note, this should only accept ready-to-serve hot dogs.
    # ...

However, nothing prevents someone from passing in an unservable hot dog. If a developer makes a mistake and passes an unservable hot dog to this function, customers will be quite surprised to see just their order with no plate or napkins come out of the machine.

Rather than relying on developers to catch these errors whenever they happen, you need a way for your typechecker to catch this. To do that, you can use NewType:

from typing import NewType

class HotDog:
    ''' Used to represent an unservable hot dog'''
    # ... snip hot dog class implementation ...

ReadyToServeHotDog = NewType("ReadyToServeHotDog", HotDog)

def dispense_to_customer(hot_dog: ReadyToServeHotDog):
    # ...

NewType接受现有类型并创建一个全新的类型,该类型具有与现有类型相同的所有字段和方法。在本例中,我正在创建一个名为ReadyToServeHotDog的类型,它与HotDog不同;它们不可互换。这种美妙之处在于,该类型限制了隐式类型转换。你不能在需要ReadyToServeHotDog的任何地方使用HotDog(尽管你可以用ReadyToServeHotDog替换HotDog)。在前面的例子中,我正在限制dispense_to_customer仅接受ReadyToServeHotDog值作为参数。这可以防止开发者无意间使假设失效。如果开发者试图向该方法传递一个HotDog,类型检查器会警告他们:

code_examples/chapter4/invalid/newtype.py:10: error:
	Argument 1 to "dispense_to_customer"
	has incompatible type "HotDog";
	expected "ReadyToServeHotDog"

强调这种类型转换的单向性是很重要的。作为开发者,你可以控制旧类型何时成为新类型。

例如,我将创建一个函数,它接受一个无法服务的HotDog并使其准备好服务:

def prepare_for_serving(hot_dog: HotDog) -> ReadyToServeHotDog:
    assert not hot_dog.is_plated(), "Hot dog should not already be plated"
    hot_dog.put_on_plate()
    hot_dog.add_napkins()
    return ReadyToServeHotDog(hot_dog)

注意我如何明确地返回一个ReadyToServeHotDog而不是普通的HotDog。这充当了一个“被认可”的函数;这是我希望开发者创建ReadyToServeHotDog的唯一合法方式。任何试图使用接受ReadyToServeHotDog的方法的用户都需要首先使用prepare_for_serving来创建它。

重要的是要通知用户,创建你的新类型的唯一方法是通过一组“被认可”的函数。你不希望用户在任何情况下都创建你的新类型,因为那样会失去意义。

def make_snack():
    serve_to_customer(ReadyToServeHotDog(HotDog()))

不幸的是,Python 没有很好的方式来告诉用户这一点,除了通过注释。

from typing import NewType
# NOTE: Only create ReadyToServeHotDog using prepare_for_serving method.
ReadyToServeHotDog = NewType("ReadyToServeHotDog", HotDog)

依然,NewType适用于许多现实世界的场景。例如,以下是我遇到过的NewType可以解决的所有场景:

  • strSanitizedString分离,以捕捉诸如 SQL 注入漏洞之类的错误。通过将SanitizedString设为NewType,我确保只有经过适当处理的字符串才能进行操作,消除了 SQL 注入的可能性。

  • 分别跟踪User对象和LoggedInUser。通过将Users限制为NewType,我编写了仅适用于已登录用户的函数。

  • 追踪一个应该代表有效用户 ID 的整数。通过将用户 ID 限制为NewType,我可以确保某些函数只操作有效的 ID,而无需检查if语句。

在第十章中,你将看到如何使用类和不变量做类似的事情,以更强的保证避免非法状态。然而,NewType仍然是一个值得关注的有用模式,比起完整的类要轻便得多。

最终类型

最后(意在说笑),你可能想要限制类型不要改变其值。这就是Final的用武之地。Final在 Python 3.8 中引入,指示类型检查器变量不能绑定到另一个值。例如,我想开始特许经营我的热狗摊,但我不希望名称因错误而改变。

VENDOR_NAME: Final = "Viafore's Auto-Dog"

如果开发者不小心之后改变了名称,他们会看到一个错误。

def display_vendor_information():
    vendor_info = "Auto-Dog v1.0"
    # whoops, copy-paste error, this code should be vendor_info += VENDOR_NAME
    VENDOR_NAME += VENDOR_NAME
    print(vendor_info)
code_examples/chapter4/invalid/final.py:3: error:
	Cannot assign to final name "VENDOR_NAME"
Found 1 error in 1 file (checked 1 source file)

一般来说,Final 最好在变量的范围跨度较大时使用,比如一个模块。在这些情况下,让类型检查器捕获不可变性保证是一个福音,因为开发者很难跟踪变量在这些大范围内的所有用途。

警告

Final 在通过函数改变对象时不会出错。它只是防止变量被重新绑定(设置为新值)。

总结思考

在本章中,你学到了许多约束类型的不同方法。它们每一个都有特定的目的,从使用Optional处理None到使用Literal限制特定值,再到使用Final防止变量重新绑定。通过使用这些技术,你可以直接将假设和限制编码到你的代码库中,避免未来的读者猜测你的逻辑。类型检查器将使用这些高级类型注释为你提供更严格的代码保证,这将使维护者在处理你的代码库时更加自信。有了这种信心,他们会犯更少的错误,你的代码库也因此变得更加健壮。

在下一章中,你将继续从为单个值添加类型注释,学习如何正确为集合类型添加注释。集合类型在大多数 Python 中无处不在;你必须小心地表达你的意图。你需要精通所有可以表示集合的方式,包括必须自己创建的情况。

¹ C.A.R. Hoare. “空引用:十亿美元的错误。” 历史上的糟糕想法。2009 年在 Qcon London 上展示,无日期。

第五章:集合类型

在 Python 中,没有遇到集合类型就无法深入学习。集合类型存储数据的分组,比如用户列表或餐馆或地址之间的查找。而其他类型(例如,intfloatbool等)可能专注于单个值,集合可以存储任意数量的数据。在 Python 中,你会遇到常见的集合类型,如字典、列表和集合(哦,我的!)。甚至字符串也是一种集合类型;它包含一系列字符。然而,当阅读新代码时,理解集合类型可能会很困难。不同的集合类型有不同的行为。

回顾第一章,我讨论了一些集合之间的差异,讨论了可变性、可迭代性和索引需求。然而,选择正确的集合只是第一步。你必须理解你的集合的影响,并确保用户可以理解它。当标准集合类型无法满足需求时,你还需要知道如何创建自定义集合。但第一步是知道如何向未来传达你的集合选择。为此,我们将转向一位老朋友:类型注解。

注释集合

我已经涵盖了非集合类型的类型注解,现在你需要知道如何注释集合类型。幸运的是,这些注解与你已经学过的注解并没有太大的不同。

举例说明,假设我正在构建一个数字食谱应用程序。我希望将所有的食谱书籍数字化,以便可以按照菜系、配料或作者搜索它们。关于食谱集合的一个问题是,我可能会问每位作者有多少本书:

def create_author_count_mapping(cookbooks: list) -> dict:
    counter = defaultdict(lambda: 0)
    for book in cookbooks:
        counter[book.author] += 1
    return counter

此函数已经被注释;它接受一个食谱书籍列表,并返回一个字典。不幸的是,虽然这告诉我可以期待哪些集合,但它并没有告诉我如何使用这些集合。没有任何内容告诉我集合内部的元素是什么。例如,我怎么知道食谱书是什么类型?如果你在审查这段代码,你怎么知道使用 book.author 是合法的?即使你进行了调查以确保 book.author 是正确的,这段代码也不具备未来的可扩展性。如果底层类型发生变化,比如移除 author 字段,这段代码将会出问题。我需要一种方式来在类型检查器中捕获这一点。

我将通过使用方括号语法在我的类型中编码更多信息来做到这一点,以指示集合内部的类型信息:

AuthorToCountMapping = dict[str, int]
def create_author_count_mapping(
				cookbooks: list[Cookbook]
                               ) -> AuthorToCountMapping:
    counter = defaultdict(lambda: 0)
    for book in cookbooks:
        counter[book.author] += 1
    return counter
注意

我使用一个别名,AuthorToCountMapping,来表示一个dict[str, int]。我这样做是因为有时我会觉得很难记住strint应该表示什么。但是,我承认这会丢失一些信息(代码的读者将不得不找出AuthorToCountMapping是什么的别名)。理想情况下,你的代码编辑器可以显示出底层类型,而不需要你查找。

我可以指示集合中预期的确切类型。cookbooks 列表包含Cookbook对象,并且函数的返回值返回一个字符串(键)到整数(值)的字典映射。请注意,我正在使用一个类型别名来为我的返回值提供更多含义。从strint的映射并没有告诉用户类型的上下文。相反,我创建了一个名为AuthorToCountMapping的类型别名,以清楚地说明这个字典与问题域的关系。

为了有效地为集合进行类型提示,你需要考虑集合中包含的类型。为了做到这一点,你需要考虑同类集合和异类集合。

同类集合与异类集合

同类集合是指每个值都具有相同类型的集合。相比之下,异类集合中的值可能在其内部具有不同的类型。从可用性的角度来看,你的列表、集合和字典几乎总是应该是同类的。用户需要一种方法来推理你的集合,如果他们不能保证每个值都是相同类型的,那么他们就无法做到这一点。如果你将列表、集合或字典设为异类集合,那么你就在告诉用户他们需要注意处理特殊情况。假设我想从第一章中复活一个调整配方的示例,用于我的烹饪书应用:

def adjust_recipe(recipe, servings):
    """
 Take a meal recipe and change the number of servings
 :param recipe: A list, where the first element is the number of servings,
 and the remainder of elements follow the (name, amount, unit)
 format, such as ("flour", 1.5, "cup")
 :param servings: the number of servings
 :return list: a new list of ingredients, where the first element is the
 number of servings
 """
    new_recipe = [servings]
    old_servings = recipe[0]
    factor = servings / old_servings
    recipe.pop(0)
    while recipe:
            ingredient, amount, unit = recipe.pop(0)
            # please only use numbers that will be easily measurable
            new_recipe.append((ingredient, amount * factor, unit))
    return new_recipe

当时,我提到这段代码的部分很丑陋;一个令人困惑的因素是配方列表的第一个元素是一个特殊情况:一个表示份量的整数。这与其余列表元素形成对比,其为表示实际配料的元组,比如("面粉", 1.5, "杯")。这突显了异类集合的问题。对于您集合的每次使用,用户都需要记住处理特殊情况。这是建立在开发人员甚至知道特殊情况的假设的基础上的。目前没有办法表示特定元素需要以不同方式处理。因此,类型检查器不会在开发人员忘记时捕捉到。这会导致未来代码脆弱。

当谈到同质性时,重要的是讨论单一类型的含义。当我提到单一类型时,并不一定指的是 Python 中的具体类型;我指的是定义该类型的一组行为。单一类型表明消费者必须以完全相同的方式处理该类型的每个值。对于食谱列表,单一类型是Cookbook。对于字典示例,键的单一类型是字符串,值的单一类型是整数。对于异构集合,情况并非总是如此。如果您的集合必须具有不同的类型,并且它们之间没有关系,该怎么办?

请考虑一下我从第一章中的丑陋代码所传达的意思:

def adjust_recipe(recipe, servings):
    """
 Take a meal recipe and change the number of servings
 :param recipe: A list, where the first element is the number of servings,
 and the remainder of elements follow the (name, amount, unit)
 format, such as ("flour", 1.5, "cup")
 :param servings: the number of servings
 :return list: a new list of ingredients, where the first element is the
 number of servings
 """
    # ...

在文档字符串中包含了大量信息,但文档字符串并不能保证是正确的。它们也不能保护开发者免受意外破坏假设的影响。这段代码未能充分传达未来合作者的意图。未来的合作者无法推理你的代码。你最不希望给他们的是让他们不得不查看代码库,寻找调用和实现方式来使用你的集合。最终,你需要一种方法来协调列表中的第一个元素(一个整数)与列表中剩余元素(元组)之间的关系。为了解决这个问题,我将使用一个Union(以及一些类型别名来使代码更易读):

Ingredient = tuple[str, int, str] # (name, quantity, units)
Recipe = list[Union[int, Ingredient]] # the list can be servings or ingredients
def adjust_recipe(recipe: Recipe, servings):
    # ...

这将异构集合(项目可以是整数或成分)转换为开发者可以像处理同质集合一样推理的集合。开发者需要将每个值视为相同——它要么是整数,要么是Ingredient——然后再对其进行操作。虽然需要更多的代码来处理类型检查,但您可以放心,您的类型检查器将会捕获到未检查特例的用户。请记住,这并不完美;如果首次没有特例,并且serving可以以另一种方式传递给函数,那将更好。但对于您绝对必须处理特例的情况,请将它们表示为一种类型,以便类型检查器为您提供帮助。

小贴士

当异构集合足够复杂,涉及大量验证逻辑散布在代码库中时,考虑将其作为用户定义类型,例如数据类或类。有关创建用户定义类型的更多信息,请参阅第 II 部分。

但是,您可能会在Union中添加太多类型。您处理的类型特例越多,每次使用该类型时开发者需要编写的代码就越多,代码库也就变得越来越难以管理。

在光谱的另一端是Any类型。Any可以用于指示在此上下文中所有类型都是有效的。这听起来很吸引人,可以避开特殊情况,但这也意味着集合的消费者不知道如何处理集合中的值,从而打败了首次使用类型注解的目的。

警告

在静态类型语言中工作的开发人员无需花费太多精力确保集合是同构的;静态类型系统已经为他们完成了这项工作。Python 的挑战在于 Python 的动态类型特性。对于开发人员来说,创建一个异构集合而不受语言本身的任何警告要容易得多。

异构的集合类型仍然有很多用途;不要假设你应该为每种集合类型使用同构性,因为这样更容易理解。例如,元组经常是异构的。

假设一个包含名称和页数的元组表示一个Cookbook

Cookbook = tuple[str, int] # name, page count

我正在描述此元组的特定字段:名称和页数。这是一个异构集合的典型例子:

  • 每个字段(名称和页数)将始终按相同顺序出现。

  • 所有名称都是字符串;所有页数都是整数。

  • 很少迭代元组,因为我不会将两种类型视为相同。

  • 名称和页数是根本不同的类型,不应视为相等。

访问元组时,您通常会索引到您想要的特定字段:

food_lab: Cookbook = ("The Food Lab", 958)
odd_bits: Cookbook = ("Odd Bits", 248)

print(food_lab[0])
>>> "The Food Lab"

print(odd_bits[1])
>>> 248

然而,在许多代码库中,这样的元组很快就会变得繁琐。开发人员厌倦了每次想要名称时都写cookbook[0]。更好的做法是找到一种方法来为这些字段命名。第一选择可能是一个字典:

food_lab = {
    "name": "The Food Lab",
    "page_count": 958
}

现在,他们可以将字段称为food_lab['name']food_lab['page_count']。问题是,字典通常用于表示从键到值的同构映射。但是,当字典用于表示异构数据时,您会遇到与上述写有效类型注释时相似的问题。如果我想尝试使用类型系统来表示这个字典,我最终会得到以下结果:

def print_cookbook(cookbook: dict[str, Union[str,int]])
    # ...

这种方法存在以下问题:

  • 大字典可能有许多不同类型的值。编写一个Union非常麻烦。

  • 对于用户来说,处理每个字典访问的每种情况都很烦琐。(因为我表明字典是同构的,我向开发人员传达了他们需要将每个值视为相同类型的信息,这意味着对每个值访问进行类型检查。知道name总是strpage_count总是int,但这种类型的消费者不会知道。)

  • 开发人员没有任何指示字典中有哪些键。他们必须从字典创建时间到当前访问的所有代码中查找已添加的字段。

  • 当字典增长时,开发者往往倾向于将值的类型用Any表示。在这种情况下,使用Any会使类型检查器失去作用。

注意

Any可以用于有效的类型注解;它仅表示你对类型没有任何假设。例如,如果你想要复制一个列表,类型签名将是def copy(coll: list[Any]) -> list[Any]。当然,你也可以这样做def copy(coll: list) -> list,它的含义是一样的。

所有这些问题都源于同质数据集合中的异构数据。你要么把负担转嫁给调用者,要么完全放弃类型注解。在某些情况下,你希望调用者在每次值访问时明确检查每个类型,但在其他情况下,这种方法会显得过于复杂和乏味。那么,在处理异构类型时,特别是在像 API 交互或用户可配置数据这样自然使用字典的情况下,你该如何解释你的理由呢?对于这些情况,你应该使用TypedDict

TypedDict

TypedDict,引入于 Python 3.8,用于必须在字典中存储异构数据的场景。这些通常是你无法避免异构数据的情况。JSON API、YAML、TOML、XML 和 CSV 都有易于使用的 Python 模块,可以将这些数据格式转换为字典,并且自然是异构的。这意味着返回的数据具有前面章节列出的所有问题。你的类型检查器帮不上忙,用户也不知道可用的键和值是什么。

提示

如果你完全控制字典,即你在自己的代码中创建它并在自己的代码中处理它,你应该考虑使用dataclass(见第九章)或class(见第十章)。

例如,假设我想扩展我的数字食谱应用程序,以提供列出的食谱的营养信息。我决定使用Spoonacular API,并编写一些代码来获取营养信息:

nutrition_information = get_nutrition_from_spoonacular(recipe_name)
# print grams of fat in recipe
print(nutrition_information["fat"]["value"])

如果你正在审查代码,你如何知道这段代码是正确的?如果你还想打印卡路里,你如何访问这些数据?你对这个字典内部字段有什么保证?要回答这些问题,你有两个选择:

  • 查阅 API 文档(如果有的话),确认是否使用了正确的字段。在这种情况下,你希望文档实际上是完整和正确的。

  • 运行代码并打印返回的字典。在这种情况下,你希望测试响应与生产响应基本相同。

问题在于,你要求每个读者、审阅者和维护者必须执行这两个步骤之一才能理解代码。如果他们没有这样做,你将得不到良好的代码审查反馈,开发者将冒着使用响应不正确的风险。这会导致错误的假设和脆弱的代码。TypedDict允许你直接将你对 API 的了解编码到你的类型系统中。

from typing import TypedDict
class Range(TypedDict):
    min: float
    max: float

class NutritionInformation(TypedDict):
    value: int
    unit: str
    confidenceRange95Percent: Range
    standardDeviation: float

class RecipeNutritionInformation(TypedDict):
    recipes_used: int
    calories: NutritionInformation
    fat: NutritionInformation
    protein: NutritionInformation
    carbs: NutritionInformation

nutrition_information:RecipeNutritionInformation = \
	get_nutrition_from_spoonacular(recipe_name)

现在非常明显,你可以依赖哪些数据类型。如果 API 有所更改,开发者可以更新所有TypedDict类,并让类型检查器捕捉到任何不一致之处。你的类型检查器现在完全理解你的字典,代码的读者可以在不进行任何外部搜索的情况下推理出响应。

更好的是,这些TypedDict集合可以随意复杂化,以满足你的需求。你会看到我嵌套了TypedDict实例以提高复用性,但你也可以嵌入自己的自定义类型、UnionOptional,以反映 API 可能返回的情况。虽然我大多数时候在谈论 API,但请记住,这些好处适用于任何异质字典,比如读取 JSON 或 YAML 时。

注意

TypedDict仅用于类型检查器的利益。完全没有运行时验证;运行时类型只是一个字典。

到目前为止,我教你如何处理内置的集合类型:列表/集合/字典用于同质集合,元组/TypedDict用于异质集合。如果这些类型不能满足你的所有需求呢?如果你想创建易于使用的新集合呢?为此,你需要一套新的工具。

创建新集合

当你要编写一个新的集合时,你应该问自己:我是想编写一个无法用其他集合类型表示的新集合,还是想修改一个现有集合以提供一些新的行为?根据答案的不同,你可能需要采用不同的技术来实现你的目标。

如果你编写了一个无法用其他集合类型表示的集合类型,你在某个时候必然会遇到泛型

泛型

泛型类型表示你不关心使用什么类型。然而,它有助于阻止用户在不合适的地方混合类型。

考虑这个无害的反转列表函数:

def reverse(coll: list) -> list:
    return coll[::-1]

我如何表明返回的列表应该包含与传入列表相同类型的类型?为了实现这一点,我使用了一个泛型,在 Python 中使用TypeVar来实现:

from typing import TypeVar
T = TypeVar('T')
def reverse(coll: list[T]) -> list[T]:
    return coll[::-1]

这意味着对于类型Treverse接受一个类型为T的元素列表,并返回一个类型为T的元素列表。我不能混合类型:如果这些列表没有使用相同的TypeVar,那么整数列表永远无法变成字符串列表。

我可以使用这种模式来定义整个类。假设我想将一个烹饪书推荐服务集成到烹饪集合应用程序中。我想要根据客户的评分推荐烹饪书或食谱。为此,我想将每个这些评分信息存储在一个中。图是一种包含一系列实体(称为节点)并跟踪(这些节点之间的关系)的数据结构。但是,我不想为烹饪图和食谱图编写单独的代码。因此,我定义了一个可以用于通用类型的Graph类:

from collections import defaultdict
from typing import Generic, TypeVar

Node = TypeVar("Node")
Edge = TypeVar("Edge")

# directed graph
class Graph(Generic[Node, Edge]):
    def __init__(self):
        self.edges: dict[Node, list[Edge]] = defaultdict(list)

    def add_relation(self, node: Node, to: Edge):
        self.edges[node].append(to)

    def get_relations(self, node: Node) -> list[Edge]:
        return self.edges[node]

有了这段代码,我可以定义各种类型的图并且仍然可以成功进行类型检查:

cookbooks: Graph[Cookbook, Cookbook] = Graph()
recipes: Graph[Recipe, Recipe] = Graph()

cookbook_recipes: Graph[Cookbook, Recipe] = Graph()

recipes.add_relation(Recipe('Pasta Bolognese'),
                     Recipe('Pasta with Sausage and Basil'))

cookbook_recipes.add_relation(Cookbook('The Food Lab'),
                              Recipe('Pasta Bolognese'))

而这段代码无法进行类型检查:

cookbooks.add_relation(Recipe('Cheeseburger'), Recipe('Hamburger'))
code_examples/chapter5/invalid/graph.py:25:
    error: Argument 1 to "add_relation" of "Graph" has
           incompatible type "Recipe"; expected "Cookbook"

使用泛型可以帮助您编写在其整个生命周期中一致使用类型的集合。这减少了代码库中的重复量,从而减少了错误的机会并减轻了认知负担。

修改现有类型

泛型非常适合创建自己的集合类型,但是如果您只想调整现有集合类型(例如列表或字典)的某些行为怎么办?完全重新编写集合的所有语义将是乏味且容易出错的。幸运的是,存在可以轻松完成这项工作的方法。让我们回到我们的烹饪应用程序。我之前写过代码来获取营养信息,但现在我想将所有这些营养信息存储在一个字典中。

但是,我遇到了一个问题:同一种成分在不同地方具有非常不同的名称。以沙拉中常见的深色叶绿素为例。美国厨师可能称之为“火箭”,而欧洲厨师可能称之为“火箭菜”。这甚至还不包括除英语以外的其他语言中的名称。为了应对这个问题,我想创建一个类似字典的对象,可以自动处理这些别名:

>>> nutrition = NutritionalInformation()
>>> nutrition["arugula"] = get_nutrition_information("arugula")
>>> print(nutrition["rocket"]) # arugula is the same as rocket
{
    "name": "arugula",
    "calories_per_serving": 5,
    # ... snip ...
}

那么我如何让NutritionalInformation的行为像字典一样呢?

许多开发人员的第一反应是对字典进行子类化。如果您对子类化不是很擅长也不用担心;我将在第十二章中更加深入地讨论这个问题。目前,只需将子类化视为一种表达“我希望我的子类的行为与父类完全相同”的方式即可。但是,您将会发现,子类化字典可能并不总是您想要的。考虑以下代码:

class NutritionalInformation(dict): ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00002.gif)
    def __getitem__(self, key): ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00005.gif)
        try:
            return super().__getitem__(key) ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00006.gif)
        except KeyError:
            pass
        for alias in get_aliases(key):
            try: ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00007.gif)
                return super().__getitem__(alias)
            except KeyError:
                pass
        raise KeyError(f"Could not find {key} or any of its aliases") ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00008.gif)

1

(dict)语法表示我们正在从字典进行子类化。

2

__getitem__是在字典中使用括号检查键时调用的方法:(nutrition["rocket"])调用 __getitem__(nutrition, "rocket")

3

如果找到键,则使用父字典的键检查。

4

对于每个别名,请检查它是否在字典中。

5

如果找不到键或其任何别名,则抛出KeyError异常。

我们正在重写__getitem__函数,这样就可以了!

如果我尝试在上面的片段中访问nutrition["rocket"],我将获得与nutrition["arugula"]相同的营养信息。太棒了!于是你将其部署到生产环境,并算是一天结束了。

但是(总会有个但是),随着时间的推移,一位开发人员向你抱怨说有时字典不起作用。你花了些时间进行调试,但是它对你来说总是有效的。你寻找竞态条件、线程问题、API 问题或任何其他非确定性因素,但却完全找不到潜在的错误。最终,你找到了一些时间可以和另一位开发人员坐下来看看他们在做什么。

现在他们的终端上显示以下行:

# arugula is the same as rocket
>>> nutrition = NutritionalInformation()
>>> nutrition["arugula"] = get_nutrition_information("arugula")
>>> print(nutrition.get("rocket", "No Ingredient Found"))
"No Ingredient Found"

字典上的get函数尝试获取键,如果找不到,则返回第二个参数(在本例中是“No Ingredient Found”)。这里出现了问题:当从字典派生并重写方法时,你无法保证其他方法在字典中调用这些方法。内置集合类型的设计考虑了性能;许多方法使用内联代码以提高速度。这意味着重写一个方法(如__getitem__)将不会被大多数字典方法使用。这显然违反了最小惊讶法则,我们在第一章中讨论过这一点。

注意

如果仅添加方法,则可以从内置集合类继承,但是因为将来的修改可能会犯同样的错误,我仍然更喜欢使用其他一种方法来构建自定义集合。

因此,覆盖dict是不行的。我将使用collections模块中的类型。在这种情况下,有一个便利的类型叫做collections.UserDictUserDict正好符合我需要的用例:我可以从UserDict派生,重写关键方法,并获得我期望的行为。

from collections import UserDict
class NutritionalInformation(UserDict):
    def __getitem__(self, key):
        try:
            return self.data[key]
        except KeyError:
            pass
        for alias in get_aliases(key):
            try:
                return self.data[alias]
            except KeyError:
                pass
        raise KeyError(f"Could not find {key} or any of its aliases")

这正好符合你的使用场景。你应该从UserDict而不是dict派生,然后使用self.data来访问底层字典。

你再次运行你同事的代码:

# arugula is the same as rocket
>>> print(nutrition.get("rocket", "No Ingredient Found"))
{
    "name": "arugula",
    "calories_per_serving": 5,
    # ... snip ...
}

你可以获取芝麻菜的营养信息。

在这种情况下,UserDict并不是你可以重写的唯一集合类型。collections模块中还有UserStringUserList。每当你想要调整字典、字符串或列表时,这些集合就派上用场了。

警告

从这些类继承确实会增加性能成本。内置集合做了一些假设以实现性能优化。对于UserDictUserStringUserList,方法无法内联,因为你可能会重写它们。如果你需要在性能关键代码中使用这些结构,请确保进行基准测试和测量,找出潜在问题。

您会注意到,我上面谈到了字典、列表和字符串,但是遗漏了一个重要的内置类型:集合。在 collections 模块中不存在 UserSet。我将不得不从 collections.abc 中选择一个不同的抽象。更具体地说,我需要抽象基类,这些基类位于 collections.abc 中。

ABC 就这么简单

collections.abc 模块中的抽象基类(ABC)提供了另一组可以重写以创建自定义集合的类。ABC 是打算作为子类化的类,并要求子类实现非常具体的函数。对于 collections.abc,这些 ABC 都围绕着自定义集合展开。为了创建自定义集合,您必须覆盖特定的函数,具体取决于您想要模拟的类型。一旦实现了这些必需的函数,ABC 就会自动填充其他函数。您可以在 collections.abc模块文档中找到要实现的全部必需函数列表。

User* 类不同,collections.abc 类中没有内置存储,如 self.data。您必须提供自己的存储。

让我们来看一个 collections.abc.Set,因为在 collections 中没有 UserSet。我想创建一个自定义集合,自动处理成分的别名(如 rocket 和 arugula)。为了创建此自定义集合,我需要按照 collections.abc.Set 的要求实现三种方法:

__contains__

用于成员检查:"arugula" in ingredients

__iter__

用于迭代:for ingredient in ingredients

__len__

用于检查长度:len(ingredients)

一旦定义了这三种方法,像关系操作、相等操作和集合操作(并集、交集、差集、不相交)等方法就能正常工作。这就是 collections.abc 的美妙之处。一旦定义了少数几个方法,其他方法就会自动补充。它在这里得以实现:

import collections
class AliasedIngredients(collections.abc.Set):
    def __init__(self, ingredients: set[str]):
        self.ingredients = ingredients

    def __contains__(self, value: str):
        return value in self.ingredients or any(alias in self.ingredients
                                                for alias in get_aliases(value))

    def __iter__(self):
        return iter(self.ingredients)

    def __len__(self):
        return len(self.ingredients)

>>> ingredients = AliasedIngredients({'arugula', 'eggplant', 'pepper'})
>>> for ingredient in ingredients:
>>>    print(ingredient)
'arugula'
'eggplant'
'pepper'

>>> print(len(ingredients))
3

>>> print('arugula' in ingredients)
True

>>> print('rocket' in ingredients)
True

>>> list(ingredients | AliasedIngredients({'garlic'}))
['pepper', 'arugula', 'eggplant', 'garlic']

collections.abc 的另一个酷炫之处在于,使用它进行类型注释可以帮助你编写更通用的代码。回到第二章中的这段代码:

def print_items(items):
    for item in items:
        print(item)

print_items([1,2,3])
print_items({4, 5, 6})
print_items({"A": 1, "B": 2, "C": 3})

我谈到了鸭子类型如何成为可靠代码的福音和诅咒。能够编写一个可以接受多种不同类型的单一函数是很棒的,但是通过类型注释来传达意图变得具有挑战性。幸运的是,我可以使用 collections.abc 类来提供类型提示:

def print_items(items: collections.abc.Iterable):
    for item in items:
        print(item)

在这种情况下,我指出项目仅通过 Iterable ABC 可迭代。只要参数支持 __iter__ 方法(大多数集合都支持),此代码将进行类型检查。

自 Python 3.9 起,有 25 种不同的 ABC 可供使用。在 Python 文档 中查看它们的全部内容。

总结

在 Python 中,少不了与集合打交道。列表、字典和集合很常见,因此向未来提供关于你正在使用的集合类型的提示是至关重要的。考虑你的集合是同质的还是异质的,以及这对未来的读者有何启发。对于使用异质集合的情况,提供足够的信息让其他开发者能够推理,比如TypedDict。一旦学会了让其他开发者理解你的集合的技巧,你的代码库将变得更加易读。

创建新集合时,务必仔细考虑各种选择:

  • 如果你只是在扩展类型,比如添加新方法,可以直接从集合(如列表或字典)派生子类。然而,要注意粗糙的边缘,因为如果用户覆盖了内置方法,Python 会有一些出人意料的行为。

  • 如果你想要更改列表、字典或字符串中的一小部分,请分别使用collections.UserListcollections.UserDictcollections.UserString。记得引用self.data来访问相应类型的存储。

  • 如果需要编写接口与其他集合类型相似的更复杂的类,请使用collections.abc。你需要为类内部的数据提供自己的存储,并实现所有必需的方法,但一旦完成,你可以根据心情自定义该集合。

讨论主题

查看代码库中对集合和泛型的使用情况,并评估向未来开发者传达了多少信息。你的代码库中有多少自定义的集合类型?新开发者只需查看类型签名和名称就能了解集合类型的多少信息?你是否可以更通用地定义一些集合?其他类型是否可以使用泛型?

现在,类型注解没有一个类型检查器的帮助就无法充分发挥其潜力。在接下来的章节中,我将专注于类型检查器本身。你将学会如何有效地配置类型检查器、生成报告并评估不同的检查器。你了解的工具越多,就能越有效地使用它。对于你的类型检查器来说,这一点尤为重要。

第六章:自定义您的类型检查器

类型检查器是构建健壮代码库的最佳资源之一。Jukka Lehtosalo,mypy 的主要开发者,对类型检查器给出了一个精炼的定义:“本质上,[类型检查器] 提供了验证过的文档。”¹ 类型注解提供了关于代码库的文档,允许其他开发者理解您的意图。类型检查器使用这些注解来验证文档是否与实际行为一致。

因此,类型检查器是无价的。孔子曾说过:“工欲善其事,必先利其器。”² 本章旨在帮助您磨练您的类型检查器。优秀的编码技术可以带您走得更远,但是您周围的工具才能将您带入下一个水平。不要止步于学习您的编辑器、编译器或操作系统,也要学会您的类型检查器。我将向您展示一些更有用的选项,以充分发挥您的工具的潜力。

配置您的类型检查器

我将专注于目前最受欢迎的类型检查器之一:mypy。当您在 IDE 中运行类型检查器(如 PyCharm)时,通常在幕后运行 mypy(尽管许多 IDE 允许您更改默认的类型检查器)。每当配置 mypy(或您的默认类型检查器)时,您的 IDE 也会使用该配置。

mypy 提供了许多配置选项来控制类型检查器的严格性或报告的错误数量。您使类型检查器越严格,就需要编写更多类型注解,这提供了更好的文档,并减少了错误。但是,如果类型检查器过于严格,您将发现开发代码的最低要求过高,导致进行更改的成本很高。mypy 配置选项控制这些严格性水平。我将介绍一些可供您选择的不同选项,您可以决定您和您的代码库所在的最低要求。

首先,如果您尚未安装 mypy,则需要安装它。最简单的方法是通过命令行上的 pip

pip install mypy

安装了 mypy 后,您可以以三种方式之一控制配置:

命令行

当从终端实例化 mypy 时,您可以传递各种选项来配置行为。这对于在代码库中探索新的检查非常有用。

内联配置

您可以在文件顶部指定配置值,以指示您可能想设置的任何选项。例如:

    # mypy: disallow-any-generics

将此行放在文件顶部将告诉 mypy 明确地失败,如果它发现带有 Any 泛型类型注释。

配置文件

您可以设置一个配置文件,以便每次运行 mypy 时都使用相同的选项。在团队需要共享相同选项时,这非常有用。该文件通常与代码一起存储在版本控制中。

配置 mypy

运行 mypy 时,它会在当前目录中查找名为mypy.ini的配置文件。此文件将定义您为项目设置的选项。一些选项是全局的,适用于每个文件,而其他选项是每个模块的。一个示例的mypy.ini文件可能如下所示:

# Global options:

[mypy]
python_version = 3.9
warn_return_any = True

# Per-module options:

[mypy-mycode.foo.*]
disallow_untyped_defs = True

[mypy-mycode.bar]
warn_return_any = False

[mypy-somelibrary]
ignore_missing_imports = True
提示

可以使用--config-file命令行选项来指定不同位置的配置文件。此外,如果找不到本地配置文件,mypy 将在特定的主目录中查找配置文件,以便在多个项目中使用相同的设置。要获取更多信息,请查看mypy 文档

顺便提一下,我不会详细讨论配置文件。我将讨论的大多数选项都可以在配置文件和命令行中使用,为了简单起见,我将向您展示如何在 mypy 调用中运行命令。

在接下来的页面中,我将涵盖多种类型检查器配置;您不需要应用每一个配置项,即可看到类型检查器的价值。大多数类型检查器在开箱即用时提供了巨大的价值。不过,随时考虑以下选项以提高类型检查器发现错误的可能性。

捕捉动态行为

如前所述,Python 的动态类型特性将使得长期维护的代码库变得困难。变量可以随时重新绑定到具有不同类型的值。当发生这种情况时,变量本质上是Any类型。Any类型表明您不应做任何关于变量类型的假设。这使得推理变得棘手:您的类型检查器在防止错误方面没有太多用处,也无法向未来的开发人员传达任何特殊信息。

Mypy 带有一组标志,您可以打开它们来标记Any类型的实例。

例如,您可以打开--disallow-any-expr选项来标记具有Any类型的任何表达式。在打开此选项时,以下代码将无法通过类型检查:

from typing import Any
x: Any = 1
y = x + 1
test.py:4: error: Expression has type "Any"
Found 1 error in 1 file (checked 1 source file)

另一个我喜欢的选项是在类型声明中禁用Any(例如在集合中)是--disallow-any-generics。这可以捕捉到使用泛型(例如集合类型)的Any的使用。在打开此选项时,以下代码将无法通过类型检查:

x: list = [1,2,3,4]

您需要明确使用list[int]以使此代码正常工作。

您可以查看所有禁用Any的方法在mypy 动态类型文档中。

但是要小心过于广泛地禁用AnyAny的一个有效用例是,您不希望错误地标记类型。Any应保留给绝对不关心某个变量类型并且由调用者验证类型的情况。一个主要的例子是异构键值存储(也许是通用缓存)。

需要类型

如果没有类型注释,表达式就是未类型化的。在这些情况下,如果mypy无法推断类型,它将把该表达式的结果视为Any类型。然而,用于禁止Any的先前检查不会捕捉到函数未带类型注释的情况。有一组独立的标志用于检查未带类型注释的函数。

此代码将不会在类型检查器中报错,除非设置了--disallow-untyped-defs选项:

def plus_four(x):
    return x + 4

如果设置了该选项,你将收到以下错误:

test.py:4: error: Function is missing a type annotation

如果这对你来说太严格了,你可以考虑查看--disallow-incomplete-defs,它只会标记函数,如果它们只有部分变量/返回值被注释(但不是全部),或者--disallow-untyped-calls,它只会标记从带注释函数到未带注释函数的调用。你可以在mypy 文档中找到关于未类型化函数的所有不同选项。

处理None/Optional

在第四章中,你学会了在使用None值时如何轻松犯下“十亿美元的错误”。如果你没有打开其他选项,请确保你的类型检查器已经打开了--strict-optional以捕捉这些代价高昂的错误。你绝对希望检查你对None的使用是否隐藏了潜在的错误。

使用--strict-optional时,你必须显式执行is None检查;否则,你的代码将在类型检查时失败。

如果设置了--strict-optional(默认值因mypy版本而异,请务必仔细检查),此代码应该失败:

from typing import Optional
x: Optional[int] = None
print(x + 5)
test.py:3: error: Unsupported operand types for + ("None" and "int")
test.py:3: note: Left operand is of type "Optional[int]"

值得注意的是,mypy也将None值隐式地视为Optional。我建议关闭这个选项,这样在你的代码中就更明确。例如:

def foo(x: int = None) -> None:
    print(x)

参数x会被隐式转换为Optional[int],因为None是其有效值。如果你对x执行任何整数操作,类型检查器会标记它。然而,最好更明确地表达一个值可以是None(以便未来的读者消除歧义)。

你可以设置--no-implicit-optional以获取错误,强制你指定Optional。如果你使用此选项来对上述代码进行类型检查,你将看到:

test.py:2: error: Incompatible default for argument "x"
          (default has type "None", argument has type "int")

mypy报告

如果类型检查失败而且周围没有人看到,它会打印错误消息吗?你如何知道mypy确实在检查你的文件,并且它确实会捕捉到错误?使用mypy的内置报告技术来更好地可视化结果。

首先,你可以通过将--html-report传递给mypy来获取一个关于mypy能够检查多少行代码的 HTML 报告。这将生成一个 HTML 文件,其中提供了类似于图 6-1 中所示的表格。

运行对源代码进行的 HTML 报告

图 6-1. 运行mypymypy源代码进行的 HTML 报告
提示

如果你需要一个纯文本文件,你可以使用--linecount-report替代。

Mypy 还允许您跟踪显式的 Any 表达式,以便逐行了解您的进展情况。当使用 --any-exprs-report 命令行选项时,mypy 将创建一个文本文件,列出每个模块中使用 Any 的统计信息。这对于查看代码库中类型注解的显式程度非常有用。这里是在 mypy 代码库本身上运行 --any-exprs-report 选项时的前几行内容:

                  Name   Anys    Exprs   Coverage
--------------------------------------------------
         mypy.__main__      0       29    100.00%
              mypy.api      0       57    100.00%
        mypy.applytype      0      169    100.00%
           mypy.argmap      0      394    100.00%
           mypy.binder      0      817    100.00%
       mypy.bogus_type      0       10    100.00%
            mypy.build     97     6257     98.45%
          mypy.checker     10    12914     99.92%
        mypy.checkexpr     18    10646     99.83%
      mypy.checkmember      6     2274     99.74%
   mypy.checkstrformat     53     2271     97.67%
    mypy.config_parser     16      737     97.83%

如果您需要更多的机器可读格式,可以使用 --junit-xml 选项创建一个符合 JUnit 格式的 XML 文件。大多数持续集成系统可以解析这种格式,使其成为构建系统自动报告生成的理想选择。要了解所有不同的报告选项,请查阅 mypy 的报告生成文档

加速 mypy

mypy 的一个常见抱怨是其对大型代码库进行类型检查所花费的时间。默认情况下,mypy 增量地检查文件。也就是说,它使用一个缓存(通常是一个 .mypy_cache 文件夹,但位置也是可配置的)来仅检查自上次类型检查以来发生了什么变化。这确实加快了类型检查的速度,但随着代码库的扩大,无论如何,您的类型检查器都会花费更长的时间运行。这对于开发周期中快速反馈是有害的。工具提供有用反馈给开发者所需时间越长,开发者运行工具的频率就会越低,从而达不到目的。尽可能快地运行类型检查器符合每个人的利益,这样开发者可以接近实时地获取类型错误信息。

为了进一步加快 mypy 的速度,您可能需要考虑使用远程缓存。远程缓存提供了一种将您的 mypy 类型检查缓存到整个团队都能访问的地方的方式。这样,您可以基于特定的提交 ID 缓存版本控制中的结果,并共享类型检查器信息。建立这个系统超出了本书的范围,但是在 mypy 的远程缓存文档中会提供一个坚实的起点。

您还应该考虑将 mypy 设置为守护进程模式。守护进程模式是指 mypy 作为一个独立的进程运行,并将先前的 mypy 状态保留在内存中,而不是文件系统(或网络链接)上。您可以通过运行 dmypy run -- mypy-flags <mypy-files> 来启动 mypy 守护进程。一旦守护进程运行起来,您可以再次运行完全相同的命令来检查文件。

例如,我在 mypy 源代码上运行了 mypy。我的初始运行花费了 23 秒。系统上的后续类型检查花费了 16 到 18 秒之间。这在技术上更快,但我不认为它很快。然而,当我使用 mypy 守护进程时,我的后续运行时间缩短到不到半秒。有了这样的速度,我可以更频繁地运行我的类型检查器以获得更快的反馈。了解更多有关 dmypy 的信息,请查阅mypy 守护进程模式文档

替代型检查工具

Mypy 是高度可配置的,它丰富的选项将让您决定您寻找的精确行为,但它不会始终满足您的所有需求。它并非唯一的类型检查器。我想介绍另外两个类型检查器:Pyre(由 Facebook 编写)和 Pyright(由 Microsoft 编写)。

Pyre

您可以使用pip安装 Pyre:

pip install pyre-check

Pyre运行方式与 mypy 的守护程序模式非常相似。一个独立的进程将运行,您可以向其请求类型检查结果。要对您的代码进行类型检查,您需要在项目目录中设置 Pyre(通过运行pyre init),然后运行pyre来启动守护程序。从这里,您收到的信息与 mypy 非常相似。然而,有两个功能使 Pyre 与其他类型检查器不同:代码库查询和 Python 静态分析器(Pysa)框架。

代码库查询

一旦pyre守护程序运行起来,你可以进行许多很酷的查询来检查你的代码库。我将使用 mypy 代码库作为以下所有查询的示例代码库。

例如,我可以了解代码库中任何类的属性:

pyre query "attributes(mypy.errors.CompileError)" ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00002.gif)

{
   "response": {
       "attributes": 
           {
               "name": "__init__", ![2
               "annotation": "BoundMethod[
				typing.Callable(
                                   mypy.errors.CompileError.__init__)
                              [[Named(self, mypy.errors.CompileError),
                                Named(messages, typing.list[str]),
                                Named(use_stdout, bool, default),
                                Named(module_with_blocker,
                                typing.Optional[str], default)], None],
                                mypy.errors.CompileError]",
               "kind": "regular",
               "final": false
           },
           {
               "name": "messages", ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00006.gif)
               "annotation": "typing.list[str]",
               "kind": "regular",
               "final": false
           },
           {
               "name": "module_with_blocker", ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00007.gif)
               "annotation": "typing.Optional[str]",
               "kind": "regular",
               "final": false
           },
           {
               "name": "use_stdout", ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00008.gif)
               "annotation": "bool",
               "kind": "regular",
               "final": false
           }
       ]
   }
}

1

Pyre 查询属性

2

构造函数的描述

3

一组消息的字符串列表

4

一个描述具有阻塞器的模块的可选字符串

5

指示将打印到屏幕的标志

看看我可以找出关于类属性的所有这些信息!我可以看到它们的类型注释,以了解工具如何看待这些属性。这在探索类时非常方便。

另一个很酷的查询是任何函数的callees

pyre query "callees(mypy.errors.remove_path_prefix)"

{
   "response": {
       "callees": 
           {
               "kind": "function", ![1               "target": "len"           },           {               "kind": "method", ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00005.gif)
               "is_optional_class_attribute": false,
               "direct_target": "str.__getitem__",
               "class_name": "str",
               "dispatch": "dynamic"
           },
           {
               "kind": "method", ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00006.gif)
               "is_optional_class_attribute": false,
               "direct_target": "str.startswith",
               "class_name": "str",
               "dispatch": "dynamic"
           },
           {
               "kind": "method", ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00007.gif)
               "is_optional_class_attribute": false,
               "direct_target": "slice.__init__",
               "class_name": "slice",
               "dispatch": "static"
           }
       ]
   }
}

1

调用长度函数

2

在字符串上调用 string.getitem函数(例如str[0]

3

在字符串上调用startswith函数

4

初始化列表切片(例如str[3:8]

类型检查器需要存储所有这些信息以完成其工作。能够查询这些信息是一个巨大的优势。我可以写一整本关于你可以如何利用这些信息的书,但现在,先查看Pyre 查询文档。你将了解到可以执行的不同查询,比如观察类层次结构、调用图等。这些查询可以帮助你更多地了解你的代码库,或者构建新工具以更好地理解你的代码库(并捕获类型检查器无法捕获的其他类型错误,比如时间依赖性,我将在第三部分中讨论)。

Python 静态分析器(Pysa)

Pysa(发音类似比萨斜塔)是内置在 Pyre 中的静态代码分析器。Pysa 专注于一种称为taint analysis的安全静态分析类型。污点分析是追踪潜在污染数据(如用户提供的输入)的过程。这些污点数据在整个数据生命周期中被追踪;Pyre 确保任何污点数据都不能以不安全的方式传播到系统中。

让我带你走过捕获简单安全缺陷的过程(修改自Pyre 文档)。考虑一个用户在文件系统中创建新食谱的情况:

import os

def create_recipe():
   recipe = input("Enter in recipe")
   create_recipe_on_disk(recipe)

def create_recipe_on_disk(recipe):
   command = "touch ~/food_data/{}.json".format(recipe)
   return os.system(command)

这看起来非常无害。用户可以输入carrots以创建文件~/food_data/carrots.json。但是如果用户输入carrots; ls ~;呢?如果输入这个命令,它会打印出整个家目录(命令变为touch ~/food_data/carrots; ls ~;.json)。根据输入,恶意用户可以在您的服务器上输入任意命令(这称为远程代码执行[RCE]),这是一个巨大的安全风险。

Pysa 提供工具来检查这一点。我可以指定从input()输入的任何内容都是潜在的污点数据(称为污点源),并且传递给os.system的任何内容都不应该是污点数据(称为污点汇)。有了这些信息,我需要构建一个污点模型,即一组用于检测潜在安全漏洞的规则。首先,我必须指定一个taint.config文件:

{
  sources: 
    {
      name: "UserControlled", ![1
      comment: "use to annotate user input"
    }
  ],

  sinks: 
    {
      name: "RemoteCodeExecution", ![2
      comment: "use to annotate execution of code"
    }
  ],

  features: [],

  rules: 
    {
      name: "Possible shell injection", ![3
      code: 5001,
      sources: [ "UserControlled" ],
      sinks: [ "RemoteCodeExecution" ],
      message_format: "Data from [{$sources}] source(s) may reach " +
                      "[{$sinks}] sink(s)"
    }
  ]
}

1

指定用户控制输入的注释。

2

为 RCE 缺陷指定注释。

3

指定一个规则,如果来自UserControlled来源的任何污点数据最终进入RemoteCodeExecution汇,就会产生错误。

接下来,我必须指定一个污点模型以注释这些来源为污点:

# stubs/taint/general.pysa

 # model for raw_input
def input(__prompt = ...) -> TaintSource[UserControlled]: ...

# model for os.system
def os.system(command: TaintSink[RemoteCodeExecution]): ...

这些存根通过类型注解告诉 Pysa 您系统中的污点来源和污点汇。

最后,您需要修改.pyre_configuration以告知 Pyre 检测污点信息的位置。

"source_directories": ["."],
"taint_models_path": ["stubs/taint"]

现在,当我运行 pyre analyze 时,Pysa 发现了一个错误。

[
    {
        "line": 9,
        "column": 26,
        "stop_line": 9,
        "stop_column": 32,
        "path": "insecure.py",
        "code": 5001,
        "name": "Possible shell injection",
        "description":
            "Possible shell injection [5001]: " +
            "Data from [UserControlled] source(s) may reach " +
            "[RemoteCodeExecution] sink(s)",
        "long_description":
            "Possible shell injection [5001]: " +
            "Data from [UserControlled] source(s) may reach " +
            "[RemoteCodeExecution] sink(s)",
        "concise_description":
            "Possible shell injection [5001]: " +
            "Data from [UserControlled] source(s) may reach " + "
            "[RemoteCodeExecution] sink(s)",
        "inference": null,
        "define": "insecure.create_recipe"
    }
]

为了解决这个问题,我要么需要使这些数据流变得不可能,要么通过 sanitizer 函数运行受污染的数据。Sanitizer 函数接收不受信任的数据并检查/修改它,使其可以信任。Pysa 允许你使用 @sanitize 装饰函数以指定你的净化器。³

这确实是一个简单的例子,但 Pysa 允许你对代码库进行注释,以捕获更复杂的问题(例如 SQL 注入和 cookie 管理不当)。要了解 Pysa 能做什么(包括内置的常见安全漏洞检查),请查阅 完整文档

Pyright

Pyright 是由 Microsoft 设计的一种类型检查器。我发现它是我遇到的类型检查器中最可配置的。如果你想比当前的类型检查器有更多的控制权,探索 Pyright 配置文档。然而,Pyright 还有一个额外的很棒的特性:VS Code 集成。

VS Code(也由 Microsoft 构建)是开发者中极其流行的代码编辑器。Microsoft 充分利用了这两个工具的所有权,创建了一个名为 Pylance 的 VS Code 扩展。你可以从你的 VS Code 扩展浏览器安装 Pylance。Pylance 是建立在 Pyright 之上的,使用类型注解提供更好的代码编辑体验。之前我提到过自动补全是 IDE 中类型注解的一个好处,但 Pylance 将其提升到了一个新水平。Pylance 提供以下功能:

  • 基于你的类型自动插入导入语句

  • 基于签名的完整类型注释的工具提示

  • 代码库浏览,例如找到引用或浏览调用图

  • 实时诊断检查

正是这一特性让我选择了 Pylance/Pyright。Pylance 有一个设置,允许你在整个工作区中持续运行诊断。这意味着每次你编辑文件时,pyright 将会在整个工作区(而且运行速度很快)查找你可能破坏的其他区域。你无需手动运行任何命令;它会自动发生。作为一个喜欢经常重构的人,我发现这个工具在及早发现问题方面非常宝贵。记住,你希望尽可能地实时找到错误。

我再次查看了 mypy 源代码库,并启用了 Pylance 并处于工作区诊断模式。我想将第 19 行的一个类型从 sequence 改为 tuple,并看看 Pylance 如何处理这个变化。我正在改变的代码片段显示在 图 6-2 中。

编辑前 VS Code 中的问题

图 6-2. 编辑前的 VS Code 中的问题

请注意底部列出的我的“问题”。当前视图显示的是另一个文件中的问题,该文件导入并使用我正在编辑的当前函数。一旦我将paths参数从sequence更改为tuple,就可以看到“问题”在图 6-3 中如何更改。

在保存文件的半秒钟内,我的“问题”窗格中就出现了新的错误,告诉我我刚刚在调用代码中破坏了假设。我不必手动运行类型检查器,也不必等待持续集成(CI)过程指责我;我的错误就出现在我的编辑器中。如果这不能让我更早地发现错误,我不知道还有什么能。

编辑后的 VS Code 中的问题

图 6-3. 编辑后的 VS Code 中的问题

结语

Python 类型检查器为您提供了丰富的选项,您需要熟悉高级配置,以充分利用您的工具。您可以控制严重性选项和报告,甚至使用不同的类型检查器来获得收益。在评估工具和选项时,问问自己您希望您的类型检查器有多严格。随着您可以捕获的错误范围的增加,您将需要增加使您的代码库符合规范所需的时间和精力。然而,您的代码越具有信息量,它在其生命周期中就越健壮。

在下一章中,我将讨论如何评估与类型检查相关的收益和成本之间的权衡。您将学习如何确定重要的类型检查区域,并使用策略来减轻您的痛苦。

¹ Jukka Lehtosalo。“我们对检查 4 百万行 Python 代码的旅程。” Dropbox.Tech(博客)。Dropbox,2019 年 9 月 5 日。https://oreil.ly/4BK3k

² 孔子和阿瑟·韦利。 论语。纽约,纽约州:随机之家,1938 年。

³ 您可以在https://oreil.ly/AghGg了解更多关于消毒剂的信息。

第七章:实际采用类型检查

许多开发人员梦想有一天终于可以在一个全新的绿地项目中工作。绿地项目是全新的项目,你可以在代码的架构、设计和模块化上有一个空白的板子。然而,大多数项目很快就变成了褐地或遗留代码。这些项目已经存在一段时间了;很多架构和设计已经固化。进行重大变更将影响真实用户。术语“褐地”通常被视为贬义,特别是当你感觉自己在浑浊的一团泥中跋涉时。

然而,并非所有的褐地项目都是一种惩罚。《与遗留代码高效工作》(Pearson)的作者 Michael Feathers 这样说:

在一个维护良好的系统中,可能需要一段时间来弄清楚如何进行更改,但一旦做到了,更改通常很容易,并且你对系统感到更加舒适。在一个遗留系统中,弄清楚该做什么可能需要很长时间,而且变更也很困难。¹

Feathers 将遗留代码定义为“没有测试的代码”。我更倾向于另一种定义:遗留代码就是那些你无法再与编写它的开发人员讨论代码的代码。在没有那种交流的情况下,你依赖代码库本身来描述其行为。如果代码库清楚地传达了其意图,那么这是一个维护良好的系统,易于操作。可能需要一点时间来理解它,但一旦理解了,你就能够添加功能并发展系统。然而,如果那个代码库难以理解,你将面临一场艰苦的战斗。那些代码变得难以维护。这就是为什么健壮性至关重要。编写健壮的代码通过使代码更易于维护,从绿地向褐地的过渡更加顺畅。

我在本书的第一部分展示的大多数类型注解策略在项目刚开始时更容易采用。在成熟项目中采用这些实践更具挑战性。这并非不可能,但成本可能更高。这是工程的核心:在权衡中做出明智的决策。

权衡

每个决策都涉及权衡。许多开发人员关注算法中经典的时间与空间权衡。但还有许多其他权衡,通常涉及无形的特质。我已经在本书的第一部分广泛介绍了类型检查器的好处:

  • 类型检查器增加了沟通,并减少了出错的机会。

  • 类型检查器为进行变更提供了安全网,并增强了代码库的健壮性。

  • 类型检查器能让你更快地提供功能。

但是,成本是什么?采用类型注解并非免费,而且随着代码库的增大而变得更糟。这些成本包括:

  • 需要获得认同。根据文化背景,说服一个组织采用类型检查可能需要一些时间。

  • 一旦获得认同,就会有采用的初始成本。开发人员不会一夜之间开始为他们的代码添加类型注解,他们需要时间才能理解它。他们需要学习和实验,然后才能接受。

  • 采用工具需要时间和精力。您需要以某种方式进行集中检查,并且开发人员需要熟悉在工作流程中运行这些工具。

  • 在您的代码库中写入类型注解需要时间。

  • 当类型注解被检查时,开发人员将不得不习惯于在与类型检查器的战斗中减速。在思考类型时会有额外的认知负担。

开发人员的时间是昂贵的,而且很容易集中精力在那些开发人员可能在做的其他事情上。采用类型注解并非免费。更糟糕的是,对于足够庞大的代码库来说,这些成本很容易超过类型检查所带来的初始好处。问题本质上是一个鸡和蛋的困境。在代码库中写入足够的类型之前,您不会看到类型注解的好处。然而,在早期没有这些好处的情况下获得认同编写类型是困难的。您可以将您的价值建模为:

  • 价值 =(总收益)-(总成本)

您的收益和成本将遵循一条曲线;它们不是线性函数。我已经概述了图 7-1 中曲线的基本形状。

随时间变化的成本和收益曲线

图 7-1. 随时间变化的成本和收益曲线

我故意没有列出范围,因为规模会根据您的代码库大小而变化,但形状保持不变。您的成本将一开始很高,但随着采用增加,会变得更容易。您的收益一开始很低,但随着注释代码库,您将看到更多价值。直到这两条曲线相交,您才会看到投资回报。为了最大化价值,您需要尽早达到这个交点。

更早地实现收支平衡

要最大化类型注解的收益,您需要尽早获得价值或尽早减少成本。这两条曲线的交点是一个收支平衡点;这是您的付出努力与您所获得价值相抵消的地方。您希望尽快可持续地达到这一点,以便您的类型注解产生积极影响。以下是一些实现这一目标的策略。

找到您的痛点

创造价值的最佳方式之一是减少您当前正在经历的痛苦。问问自己:我目前的过程中哪些地方花费了我的时间?我在哪里损失了金钱?看看您的测试失败和客户反馈。这些错误案例造成了实际成本;您应该进行根本原因分析。如果发现通过类型注释可以修复常见的根本原因,那么您有充分的理由采用类型注释。以下是您需要密切关注的特定错误类别:

  • 任何关于None的错误

  • 无效的属性访问,例如试图在错误的类型上访问函数的变量

  • 关于类型转换的错误,例如整数与字符串、字节与字符串或列表与元组

此外,与必须在代码库中工作的人交谈。排查那些经常引起困惑的区域。如果开发人员今天在代码库的某些部分遇到问题,未来的开发人员可能也会遇到困难。

别忘了与那些对您的代码库有投资但可能不直接在其中工作的人交谈,例如技术支持、产品管理和质量保证。他们通常对代码库中的痛点有独特的看法,这在查看代码时可能不明显。尝试将这些成本转化为具体的术语,比如时间或金钱。这在评估类型注释将带来益处的地方将是非常宝贵的。

有策略地定位代码

您可能希望专注于尽早获得价值。类型注释不会在大型代码库中一夜之间出现。相反,您将需要确定具体且战略性的代码区域以便进行类型注释。类型注释的美妙之处在于它们是完全可选的。通过仅对这些区域进行类型检查,您可以非常快速地看到好处,而无需巨大的前期投入。以下是您可能采用的一些策略,以选择性地对代码进行类型注释。

仅对新代码进行类型注释

考虑保持当前的未注释代码不变,并根据以下两个规则注释代码:

  • 注释您编写的任何新代码。

  • 注释您更改的任何旧代码。

随着时间的推移,您将在所有代码中构建出类型注释,除了长时间没有更改的代码。长时间没有更改的代码相对稳定,可能不经常被阅读。为其进行类型注释不太可能为您带来太多好处。

从下往上类型注释

你的代码库可能依赖于常见的代码区域。这些是你的核心库和实用程序,它们作为一切构建的基础。对代码库中的这些部分进行类型注释使你的收益更多地体现在广度上而不是深度上。因为许多其他部分都位于这个基础之上,它们都将受益于类型检查。新代码很常常也会依赖于这些实用程序,因此你的新代码将具有额外的保护层。

对你的赚钱代码进行类型注释

在一些代码库中,核心业务逻辑与支持业务逻辑的所有其他代码之间存在明显的分离。你的业务逻辑是系统中最负责提供价值的部分。它可能是旅行社的核心预订系统,餐厅的订单系统,或者媒体服务的推荐系统。所有其他代码(例如日志记录、消息传递、数据库驱动程序和用户界面)存在的目的是支持你的业务逻辑。通过对业务逻辑进行类型注解,你正在保护代码库的核心部分。这些代码通常存在较长的生命周期,使其成为长期价值的简单获得。

对代码变动频繁的部分进行类型注解

你的代码库中有些部分的变化频率远高于其他部分。每次代码变动时,都存在着错误假设引入 bug 的风险。健壮代码的整个重点在于减少引入错误的机会,那么有什么比经常变化的代码更好的地方来保护呢?查找在版本控制中有许多不同提交的代码,或者分析哪些文件在一段时间内改动的代码行数最多。还要看一下哪些文件有最多的提交者;这是一个很好的指示,表明这是一个你可以加强类型注解以进行沟通的区域。

对复杂代码进行类型注解

如果你遇到了一些复杂的代码,理解它将需要一些时间。在理解该代码之后,你可以为下一个阅读代码的开发人员减少复杂性。重构代码、改进命名和添加注释都是提高理解能力的绝佳方法,但也要考虑添加更多类型注解。类型注解将帮助开发人员理解使用的类型、如何调用函数以及如何处理返回值。类型注解为复杂代码提供了额外的文档。

讨论话题

这些策略中哪些对你的代码库最有益?为什么这种策略对你最有效?实施该策略的成本是多少?

倚赖你的工具

有些事情计算机做得很好,有些事情人类做得很好。这一部分是关于前者的。在尝试采用类型注解时,自动化工具可以提供一些极好的帮助。首先,让我们来谈谈目前最常见的类型检查器:mypy。

我在第六章中对 mypy 的配置进行了相当详细的介绍,但还有一些我想深入探讨的选项,这些选项将帮助您采用类型检查。你将遇到的最大问题之一是,在较大的代码库上第一次运行 mypy 时,它将报告的错误数量之多。在这种情况下,你可能犯的最大错误就是保持数百(或数千)个错误,并希望开发人员随着时间的推移逐渐消除这些错误。

这些错误不会迅速修复。如果这些错误总是打开的话,你就看不到类型检查器的好处,因为几乎不可能检测到新错误。任何新问题都会在其他问题的噪音中丢失。

使用 mypy,你可以通过配置告诉类型检查器忽略某些类别的错误或模块。这里是一个样本 mypy 文件,如果返回Any类型则全局警告,并在每个模块基础上设置配置选项:

# Global options:

[mypy]
python_version = 3.9
warn_return_any = True

# Per-module options:

[mypy-mycode.foo.*]
disallow_untyped_defs = True

[mypy-mycode.bar]
warn_return_any = False

[mypy-somelibrary]
ignore_missing_imports = True

使用这种格式,你可以挑选并选择你的类型检查器追踪的错误。你可以屏蔽所有现有的错误,同时专注于修复新错误。在定义要忽略的错误时,要尽可能具体;你不想掩盖代码的其他部分出现的新错误。

更具体地说,mypy 会忽略任何带有# type: ignore注释的行。

# typechecks just fine
a: int = "not an int" # type: ignore
警告

# type: ignore不应成为偷懒的借口!在编写新代码时,不要忽略类型错误——在编写时就修复它们。

你采用类型注释的首要目标是确保你的类型检查器完全运行无误。如果有错误,你需要用注释修复它们(推荐),或者接受不是所有错误都能很快修复的事实,并忽略它们。

随着时间的推移,确保被忽略的代码部分的数量减少。你可以跟踪包含# type: ignore行的数量,或者你正在使用的配置文件部分的数量;无论如何,努力尽可能少地忽略这些部分(在合理的范围内,当然——这里有递减收益法则)。

我还建议在你的 mypy 配置中打开warn_unused_ignores标志,这样当不再需要一个忽略指令时会发出警告。

现在,这一切都不能帮助你更接近实际注释代码的目标;它只是给你一个起点。为了帮助用工具注释你的代码库,你需要一些能够自动插入注释的东西。

MonkeyType

MonkeyType是一个工具,将自动为你的 Python 代码添加注释。这是一种在不花费太多精力的情况下对大量代码进行类型检查的好方法。

首先用pip安装 MonkeyType:

pip install monkeytype

假设你的代码库控制着一个自动厨师,带有机械臂,能够每次都烹饪出完美的食物。你想要为这位厨师编程,使用我家最喜欢的食谱之一,意大利香肠通心粉:

# Pasta with Sausage Automated Maker ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00002.gif)
italian_sausage = Ingredient('Italian Sausage', 4, 'links')
olive_oil = Ingredient('Olive Oil', 1, 'tablespoon')
plum_tomato = Ingredient('Plum Tomato', 6, '')
garlic = Ingredient('Garlic', 4, 'cloves')
black_pepper = Ingredient('Black Pepper', 2, 'teaspoons')
basil = Ingredient('Basil Leaves', 1, 'cup')
pasta = Ingredient('Rigatoni', 1, 'pound')
salt = Ingredient('Salt', 1, 'tablespoon')
water = Ingredient('Water', 6, 'quarts')
cheese = Ingredient('Pecorino Romano', 2, "ounces")
pasta_with_sausage = Recipe(6, [italian_sausage,
                                olive_oil,
                                plum_tomato,
                                garlic,
                                black_pepper,
                                pasta,
                                salt,
                                water,
                                cheese,
                                basil])

def make_pasta_with_sausage(servings): ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00005.gif)
    sauté_pan = Receptacle('Sauté Pan')
    pasta_pot = Receptacle('Stock Pot')
    adjusted_recipe = adjust_recipe(pasta_with_sausage, servings)

    print("Prepping ingredients") ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00006.gif)

    adjusted_tomatoes = adjusted_recipe.get_ingredient('Plum Tomato')
    adjusted_garlic = adjusted_recipe.get_ingredient('Garlic')
    adjusted_cheese = adjusted_recipe.get_ingredient('Pecorino Romano')
    adjusted_basil = adjusted_recipe.get_ingredient('Basil Leaves')

    garlic_and_tomatoes = recipe_maker.dice(adjusted_tomatoes,
                                            adjusted_garlic)
    grated_cheese = recipe_maker.grate(adjusted_cheese)
    sliced_basil = recipe_maker.chiffonade(adjusted_basil)

    print("Cooking Pasta") ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00007.gif)
    pasta_pot.add(adjusted_recipe.get_ingredient('Water'))
    pasta_pot.add(adjusted_recipe.get_ingredient('Salt'))
    recipe_maker.put_receptacle_on_stovetop(pasta_pot, heat_level=10)

    pasta_pot.add(adjusted_recipe.get_ingredient('Rigatoni'))
    recipe_maker.set_stir_mode(pasta_pot, ('every minute'))

    print("Cooking Sausage")
    sauté_pan.add(adjusted_recipe.get_ingredient('Olive Oil'))
    heat_level = recipe_maker.HeatLevel.MEDIUM
    recipe_maker.put_receptacle_on_stovetop(sauté_pan, heat_level)
    sauté_pan.add(adjusted_recipe.get_ingredient('Italian Sausage'))
    recipe_maker.brown_on_all_sides('Italian Sausage')
    cooked_sausage = sauté_pan.remove_ingredients(to_ignore=['Olive Oil'])

    sliced_sausage = recipe_maker.slice(cooked_sausage, thickness_in_inches=.25)

    print("Making Sauce")
    sauté_pan.add(garlic_and_tomatoes)
    recipe_maker.set_stir_mode(sauté_pan, ('every minute'))
    while recipe_maker.is_not_cooked('Rigatoni'):
        time.sleep(30)
    cooked_pasta = pasta_pot.remove_ingredients(to_ignore=['Water', 'Salt'])

    sauté_pan.add(sliced_sausage)
    while recipe_maker.is_not_cooked('Italian Sausage'):
        time.sleep(30)

    print("Mixing ingredients together")
    sauté_pan.add(sliced_basil)
    sauté_pan.add(cooked_pasta)
    recipe_maker.set_stir_mode(sauté_pan, "once")

    print("Serving") ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00008.gif)
    dishes = recipe_maker.divide(sauté_pan, servings)

    recipe_maker.garnish(dishes, grated_cheese)
    return dishes

1

所有配料的定义

2

做香肠通心粉的功能

3

准备指南

4

烹饪说明

5

使用说明

我省略了很多辅助函数以节省空间,但这让您了解我试图实现的内容。您可以在附带本书的GitHub 仓库中查看完整示例。

在整个示例中,我没有任何类型注释。我不想手动编写所有的类型注释,因此我将使用 MonkeyType。为了帮助,我可以生成存根文件来创建类型注释。存根文件只包含函数签名。

为了生成存根文件,您必须运行您的代码。这是一个重要的细节;MonkeyType 只会注释您先运行的代码。您可以像这样运行特定脚本:

monkeytype run code_examples/chapter7/main.py

这将生成一个SQLite数据库,其中存储了程序执行过程中的所有函数调用。您应该尽可能运行系统的多个部分以填充此数据库。单元测试,集成测试和测试程序都有助于填充数据库。

提示

因为 MonkeyType 通过使用sys.setprofile来仪器化您的代码工作,其他仪器化,如代码覆盖率和分析,将无法同时工作。任何使用仪器化的工具都需要单独运行。

当您通过代码的多条路径后,可以生成存根文件:

monkeytype stub code_examples.chapter7.pasta_with_sausage

这将输出此特定模块的存根文件:

def adjust_recipe(
    recipe: Recipe,
    servings: int
) -> Recipe: ...

class Receptacle:
    def __init__(self, name: str) -> None: ...
    def add(self, ingredient: Ingredient) -> None: ...

class Recipe:
    def clear_ingredients(self) -> None: ...
    def get_ingredient(self, name: str) -> Ingredient: ...

它不会注释所有内容,但肯定会为您的代码库提供足够的起步。一旦您对建议感到满意,可以使用monkeytype apply <module-name>应用它们。生成这些注释后,搜索代码库中任何使用Union的地方。Union告诉您在代码执行过程中,该函数作为参数传递了多种类型。这是一种代码异味,或者即使不完全错误(尚未),看起来有些奇怪的东西。在这种情况下,使用Union可能表示代码难以维护;您的代码接收到不同类型可能并不具备处理它们的能力。如果参数传递错误类型,这很可能表明某处的假设已被无效化。

举例说明,我的recipe_maker存根包含一个函数签名中的Union

def put_receptacle_on_stovetop(
    receptacle: Receptacle,
    heat_level: Union[HeatLevel, int]
) -> None: ...

参数heat_level在某些情况下采用了HeatLevel,在其他情况下采用了整数。回顾我的配方,我看到以下代码行:

recipe_maker.put_receptacle_on_stovetop(pasta_pot, heat_level=10)
# ...
heat_level = recipe_maker.HeatLevel.MEDIUM
recipe_maker.put_receptacle_on_stovetop(sauté_pan, heat_level)

这是否是错误取决于函数的实现。在我的情况下,我希望保持一致,因此我会将整数用法更改为Enum用法。对于您的代码库,您需要确定什么是可接受的,什么是不可接受的。

Pytype

MonkeyType 的一个问题是它只会注释运行时看到的代码。如果您的代码有一些成本高昂或无法运行的分支,MonkeyType 将无法为您提供太多帮助。幸运的是,有一个工具填补了这个空白:Pytype,由 Google 编写。Pytype 通过静态分析添加类型注解,这意味着它不需要运行您的代码来确定类型。

要运行 Pytype,请使用pip进行安装:

pip install pytype

然后,在您的代码文件夹上运行 Pytype(例如,code_examples/chapter7):

pytype code_examples/chapter7

这将在.pytype文件夹中生成一组.pyi文件。这些文件与 MonkeyType 创建的存根文件非常相似。它们包含了带有注释的函数签名和变量,您可以将其复制到您的源文件中。

Pytype 还提供其他有趣的好处。Pytype 不仅仅是一种类型注解工具;它还是一个完整的检查器和类型检查工具。它与其他类型检查器(如 mypy、Pyright 和 Pyre)具有不同的类型检查哲学。

Pytype 将使用推断来进行类型检查,这意味着即使在缺少类型注解的情况下,它也将对您的代码进行类型检查。这是在不必在整个代码库中编写类型的情况下获得类型检查器好处的一个很好的方法。

Pytype 对类型在其生命周期中改变也更加宽容。这对于完全接受 Python 动态类型特性的人来说是一个福音。只要代码在运行时能够工作,Pytype 就很高兴。例如:

    # Run in Python 3.6
    def get_pasta_dish_ingredients(ingredients: list[Ingredient]
                                   ) -> list[str]:
    names = ingredients
    # make sure there is water to boil the pasta in
    if does_not_contain_water(ingredients)
        names.append("water")
    return [str(i) for i in names]

在这种情况下,名称将首先作为一个Ingredients列表。如果水不在配料中,我会将字符串“water”添加到列表中。此时,列表是异构的;它既包含配料又包含字符串。如果您将名称注释为list[Ingredient],mypy 将在这种情况下报错。在这里,我通常也会提出异构集合的一个红旗;在没有良好类型注解的情况下,这些集合很难推理。然而,下一行使 mypy 和我的反对都变得无关紧要。当返回时,一切都被转换为字符串,这符合预期的返回类型注解。Pytype 足够智能,能够检测到这一点,并认为此代码没有问题。

Pytype 的宽容度和类型检查方法使其非常适合用于现有代码库。您无需添加任何类型注解即可看到价值。这意味着您可以在非常少的工作量下获得类型检查器的所有好处。高价值,低成本?当然。

但是,在这种情况下,Pytype 是一把双刃剑。确保您不要将 Pytype 用作支架;您仍应编写类型注解。通过 Pytype,认为您根本不需要类型注解变得非常容易。然而,出于两个原因,您仍应编写它们。首先,类型注解提供了文档化的好处,这有助于提高代码的可读性。其次,如果存在类型注解,Pytype 将能够做出更加智能的决策。

总结思路

类型注解非常有用,但它们的成本不可否认。代码库越大,实际采用类型注解的成本就越高。每个代码库都是不同的;您需要评估特定情景下类型注解的价值和成本。如果类型注解的成本过高,请考虑以下三种策略来克服这一障碍:

找到痛点

如果您可以通过类型注解消除错误、破损测试或不清晰代码等整类痛点,那么您将节省时间和金钱。您的目标是解决最痛的领域,通过减轻痛苦使开发人员随着时间的推移更轻松地交付价值(这是可维护代码的明确标志)。

策略性地定位代码目标

明智地选择位置。在大型代码库中,注解每个有意义的代码部分几乎是不可能的。相反,重点放在能够获得巨大收益的较小部分上。

依赖您的工具

使用 mypy 帮助您选择性地忽略文件(并确保随着时间的推移忽略的代码行数越来越少)。使用 MonkeyType 和 Pytype 等类型注解工具,快速在整个代码中生成类型。不要忽视 Pytype 作为类型检查器,因为它可以在最小设置的情况下发现代码中潜藏的错误。

这是书的第一部分的总结。它专注于类型注解和类型检查。请随意混合和匹配我讨论的策略和工具。您不需要对所有内容都进行类型注解,因为如果严格应用,类型注解可能会限制表达能力。但是,您应该努力澄清代码并使其更难出现错误。随着时间的推移,您将找到平衡点,但您需要开始思考在 Python 中如何表达类型以及如何向其他开发人员解释它们的意图。记住,目标是可维护的代码库。人们需要能够从代码本身理解尽可能多的您意图。

在第二部分,我将重点介绍如何创建自己的类型。您已经通过构建自己的集合类型稍微了解了一些内容,但您可以做得更多。您将学习枚举、数据类和类,并了解为什么应该选择一个而不是另一个。您将学习如何设计 API 并对类型进行子类化和建模。您将继续建立提高代码库可读性的词汇表。

¹ 迈克尔·C·费瑟斯。《与遗留代码有效工作》。Upper Saddle River, NJ: Pearson, 2013 年。

第二部分:定义你自己的类型

欢迎来到第二部分,在这里你将学习关于用户定义类型的一切。用户定义类型是你作为开发者创建的类型。在本书的第一部分中,我主要关注 Python 提供的类型。然而,这些类型是为一般用例构建的。它们并不告诉你任何关于你所操作领域的具体信息。相反,用户定义类型作为一种桥梁,让你在代码库中表达领域概念。

你需要构建能够代表你领域的类型。Python 提供了几种不同的方式来定义你自己的数据类型,但你应该谨慎选择。在本书的这一部分,我们将介绍三种不同的用户定义类型:

枚举(Enums

枚举提供开发者一组受限制的值。

数据类(Data classes)

数据类表示不同概念之间的关系。

类(Classes)

类(Classes)代表不同概念之间的关系,具有需要保持的不变性。

你将学习如何以自然的方式使用这些类型,以及它们如何相互关联。在第二部分的结尾,我们将演示如何以更自然的方式对领域数据进行建模。在设计类型时所做的选择至关重要。通过学习用户定义类型背后的原则,你将更有效地与未来的开发者沟通。

第八章:用户定义类型:枚举

在本章中,我将专注于用户定义类型是什么,并涵盖最简单的用户定义数据类型:枚举。我将讨论如何创建一个能够防止常见编程错误的枚举。然后我会介绍一些高级特性,允许你更清晰地表达你的想法,比如创建别名、使枚举唯一,或提供自动生成的值。

用户定义类型

用户定义类型是你作为开发者创建的一种类型。你定义与类型关联的数据和行为。这些类型中的每一个都应该与一个独特的概念相关联。这将帮助其他开发人员在你的代码库中建立心智模型。

例如,如果我正在编写餐厅销售点系统,我期望在你的代码库中找到关于餐厅领域的概念。像餐厅、菜单项和税收计算这样的概念应该自然地在代码中表示出来。如果我使用列表、字典和元组,我会迫使读者不断重新解释变量的含义以符合其更自然的映射。

考虑一个简单的计算带税总额的函数。你宁愿使用哪个函数?

def calculate_total_with_tax(restaurant: tuple[str, str, str, int],
                             subtotal: float) -> float:
    return subtotal * (1 + tax_lookup[restaurant[2]])

def calculate_total_with_tax(restaurant: Restaurant,
                             subtotal: decimal.Decimal) -> decimal.Decimal:
    return subtotal * (1 + tax_lookup[restaurant.zip_code])

通过使用自定义类型Restaurant,你为读者提供了关于代码行为的重要知识。尽管它可能很简单,但构建这些领域概念是非常强大的。埃里克·埃文斯,《领域驱动设计》的作者写道:“软件的核心是解决用户的领域相关问题。”¹ 如果软件的核心是解决领域相关问题,那么领域特定的抽象就是血管。它们是支持系统,是流经你的代码库的网络,所有这些都与作为你的代码存在原因的中心生命赋予者紧密联系在一起。通过建立出色的领域相关类型,你建立了一个更健康的系统。

最可读的代码库是那些可以推理的代码库,而且最容易推理的是你日常遇到的概念。对于代码库的新手来说,如果他们熟悉核心业务概念,他们将已经占据了先机。你在本书的第一部分中专注于通过注释表达意图;接下来的部分将专注于通过建立共享词汇和使该词汇对每个在代码库中工作的开发者可用来传达意图。

你将学习如何将领域概念映射到类型的第一种方式是通过 Python 的枚举类型:Enum

枚举

在某些情况下,你希望开发者从列表中选择一个值。交通灯的颜色、网络服务的定价计划和 HTTP 方法都是这种关系的绝佳例子。为了在 Python 中表达这种关系,你应该使用枚举。枚举是一种构造,让你定义值的列表,开发者可以选择他们想要的具体值。Python 在 Python 3.4 中首次支持了枚举。

为了说明列举的特殊之处,假设你正在开发一个应用程序,通过提供送货上门网络来使法国烹饪更加可访问,从长棍面包到甜甜圈。它提供了一个菜单,让饥饿的用户可以选择,然后通过邮件收到所有的配料和烹饪说明。

在这个应用程序中最受欢迎的服务之一是定制化。用户可以选择他们想要的肉类、配菜和调料来准备食物。法国烹饪最重要的部分之一是母酱汁。这五种广为人知的酱汁是无数其他酱汁的基础,我希望能够通过程序向其中添加新的成分,创造出所谓的女儿酱汁。这样,用户在点餐时就可以了解法国酱汁的分类。

假设我用 Python 元组表示母酱汁:

# Note: use UPPER_CASE variable names to denote constant/immutable values
MOTHER_SAUCES = ("Béchamel", "Velouté", "Espagnole", "Tomato", "Hollandaise")

这个元组向其他开发者传达了什么信息?

  • 这个集合是不可变的。

  • 他们可以遍历这个集合来获取所有的酱汁。

  • 他们可以通过静态索引检索特定的元素。

不可变性和检索属性对我的应用程序很重要。我不希望在运行时添加或删除任何母酱汁(这样做将是烹饪的亵渎)。使用元组清楚地告诉未来的开发者,他们不应该更改这些值。检索让我可以选择只有一个酱汁,尽管有点笨拙。每次我需要引用一个元素时,我可以通过静态索引来做到:

MOTHER_SAUCES[2]

不幸的是,这并没有传达出意图。每次开发者看到这个,他们必须记住2代表着"Espagnole"。不断将数字与酱汁对应会浪费时间。这是脆弱的,必然会导致错误。如果有人对酱汁进行字母排序,索引将会改变,破坏代码。通过静态索引访问这个元组也不会增强代码的健壮性。

为了应对这个问题,我将为每一个添加别名:

BÉCHAMEL = "Béchamel"
VELOUTÉ = "Velouté"
ESPAGNOLE = "Espagnole"
TOMATO = "Tomato"
HOLLANDAISE = "Hollandaise"
MOTHER_SAUCES = (BÉCHAMEL, VELOUTÉ, ESPAGNOLE, TOMATO, HOLLANDAISE)

这是更多的代码,但仍然不能使索引到这个元组变得更容易。此外,调用代码仍然存在一个潜在的问题。

考虑一个创建女儿酱汁的函数:

def create_daughter_sauce(mother_sauce: str,
                          extra_ingredients: list[str]):
    # ...

我希望你停顿一下,考虑这个函数告诉未来开发者什么。我特意省略了实现部分,因为我想讨论的是第一印象;函数签名是开发者看到的第一件事。仅仅基于函数签名,这个函数是否适当地传达了允许什么?

未来的开发者可能会遇到类似这样的代码:

create_daughter_sauce(MOTHER_SAUCES[0], ["Onions"]) # not super helpful
create_daughter_sauce(BÉCHAMEL, ["Onions"]) # Better

或者:

create_daughter_sauce("Hollandaise", ["Horseradish"])
create_daughter_sauce("Veloute", ["Mustard"])

# Definitely wrong
create_daughter_sauce("Alabama White BBQ Sauce", [])

这就是问题的关键所在。在正常情况下,开发人员可以使用预定义的变量。但是,如果有人意外地使用了错误的酱料(毕竟,create_daughter_sauce期望一个字符串,它可以是任何东西),很快就会出现意外的行为。请记住,我说的是几个月甚至几年后的开发人员。他们被要求向代码库添加一个功能,尽管他们对其不熟悉。通过选择字符串类型,我只是在邀请以后提供错误的值。

警告

即使是诚实的错误也会产生后果。你有没有注意到我在Velouté的“e”上漏掉了重音符号?在生产环境中调试这个问题会很有趣。

相反,你希望找到一种方式来传达你希望在特定位置使用非常具体和受限制的一组值。既然你现在在“枚举”章节中,而我还没有展示它们,我相信你可以猜到解决方案是什么。

枚举

下面是 Python 枚举Enum的示例:

from enum import Enum
class MotherSauce(Enum):
    BÉCHAMEL = "Béchamel"
    VELOUTÉ = "Velouté"
    ESPAGNOLE = "Espagnole"
    TOMATO = "Tomato"
    HOLLANDAISE = "Hollandaise"

要访问特定的实例,你只需:

MotherSauce.BÉCHAMEL
MotherSauce.HOLLANDAISE

这与字符串别名几乎相同,但有一些额外的好处。

你不会意外创建MotherSauce并获得意外值:

>>>MotherSauce("Hollandaise") # OKAY

>>>MotherSauce("Alabama White BBQ Sauce")
ValueError: 'Alabama White BBQ Sauce' is not a valid MotherSauce

这肯定会限制错误(无论是无效的酱料还是无辜的拼写错误)。

如果你想打印出枚举的所有值,你可以简单地迭代枚举(无需创建单独的列表)。

>>>for option_number, sauce in enumerate(MotherSauce, start=1):
>>>    print(f"Option {option_number}: {sauce.value}")

Option 1: Béchamel
Option 2: Velouté
Option 3: Espagnole
Option 4: Tomato
Option 5: Hollandaise

最后但至关重要的是,你可以在使用这个Enum的函数中传达你的意图:

def create_daughter_sauce(mother_sauce: MotherSauce,
                          extra_ingredients: list[str]):
    # ...

这告诉所有查看此函数的开发人员,他们应该传入一个MotherSauce枚举,而不仅仅是任意的字符串。这样一来,要引入拼写错误或不正确的值就变得更加困难。(用户仍然可以传递错误的值,如果他们真的想这样做,但这将直接违反预期的行为,这更容易捕捉——我在第一部分中讲解了如何捕捉这些错误。)

讨论主题

你的代码库中哪些数据集会受益于Enum?你有没有开发人员在尽管类型正确但传入了错误的值的代码区域?讨论一下枚举如何改进你的代码库。

不适用时机

枚举类型非常适合用于向用户传达静态选择集。如果选项在运行时确定,就不应使用枚举,因为这样会失去它们在传达意图和工具方面的许多优势(如果每次运行都可以更改,代码阅读者很难知道可能的值是什么)。如果你发现自己处于这种情况下,我建议使用字典,它提供了两个值之间的自然映射,可以在运行时更改。但如果你需要限制用户可以选择的值,你将需要执行成员资格检查。

高级用法

一旦掌握了枚举的基础知识,您可以做很多事情来进一步完善您的使用。记住,您选择的类型越具体,传达的信息就越具体。

自动值

对于某些枚举,您可能希望明确指定您不关心枚举所关联的值。这告诉用户他们不应依赖这些值。为此,您可以使用auto()函数。

from enum import auto, Enum
class MotherSauce(Enum):
    BÉCHAMEL = auto()
    VELOUTÉ = auto()
    ESPAGNOLE = auto()
    TOMATO = auto()
    HOLLANDAISE = auto()

>>>list(MotherSauce)
[<MotherSauce.BÉCHAMEL: 1>, <MotherSauce.VELOUTÉ: 2>, <MotherSauce.ESPAGNOLE: 3>,
 <MotherSauce.TOMATO: 4>, <MotherSauce.HOLLANDAISE: 5>]

默认情况下,auto()将选择单调递增的值(1、2、3、4、5...)。如果您想控制设置的值,您应该实现一个_generate_next_value_()函数:

from enum import auto, Enum
class MotherSauce(Enum):
    def _generate_next_value_(name, start, count, last_values):
        return name.capitalize()
    BÉCHAMEL = auto()
    VELOUTÉ = auto()
    ESPAGNOLE = auto()
    TOMATO = auto()
    HOLLANDAISE = auto()

>>>list(MotherSauce)
[<MotherSauce.BÉCHAMEL: 'Béchamel'>, <MotherSauce.VELOUTÉ: 'Velouté'>,
 <MotherSauce.ESPAGNOLE: 'Espagnole'>, <MotherSauce.TOMATO: 'Tomato'>,
 <MotherSauce.HOLLANDAISE: 'Hollandaise'>]

很少会看到_generate_next_value_像这样定义,直接在具有值的枚举内部。如果auto用于指示值无关紧要,那么_generate_next_value_指示您希望auto的非常具体的值。这感觉矛盾。这就是为什么通常在基本Enum类中使用_generate_next_value_,这些枚举意味着要被子类型化,并且不包括任何值。接下来您将看到的Flag类就是一个很好的基类示例。

标志

现在您已经用Enum表示了主酱,您决定准备开始用这些酱料来供应餐点。但在您开始之前,您希望意识到顾客的过敏反应,因此您决定为每道菜设置过敏原信息。有了您对auto()的新知识,设置Allergen枚举就像小菜一碟:

from enum import auto, Enum
from typing import Set
class Allergen(Enum):
    FISH = auto()
    SHELLFISH = auto()
    TREE_NUTS = auto()
    PEANUTS = auto()
    GLUTEN = auto()
    SOY = auto()
    DAIRY = auto()

对于食谱,您可能会像这样跟踪一组过敏原:

allergens: Set[Allergen] = {Allergen.FISH, Allergen.SOY}

这告诉读者过敏原的集合将是唯一的,并且可能有零个、一个或多个过敏原。这正是您想要的。但如果我希望系统中的所有过敏原信息都像这样被跟踪呢?我不希望依赖每个开发人员记住使用集合(仅使用列表或字典的一种用法可能会引发错误行为)。我希望有某种方式通用地表示一组唯一的枚举值的集合。

enum模块为您提供了一个便利的基类可供使用——Flag

from enum import Flag
class Allergen(Flag):
    FISH = auto()
    SHELLFISH = auto()
    TREE_NUTS = auto()
    PEANUTS = auto()
    GLUTEN = auto()
    SOY = auto()
    DAIRY = auto()

这允许您执行位操作来组合过敏原或检查特定过敏原是否存在。

>>>allergens = Allergen.FISH | Allergen.SHELLFISH
>>>allergens
<Allergen.SHELLFISH|FISH: 3>

>>>if allergens & Allergen.FISH:
>>>    print("This recipe contains fish.")
This recipe contains fish.

当您希望表示一组值(比如通过多选下拉或位掩码设置的值)时,这非常有用。但也有一些限制。这些值必须支持位操作(|、&等)。字符串将是不支持的类型的示例,而整数则支持。此外,在进行位操作时,这些值不能重叠。例如,您不能使用 1 到 4(包括)的值用于您的Enum,因为 4 会对 1、2 和 4 的值进行“位与”操作,这可能不是您想要的。auto()会为您处理这些问题,因为Flag_generate_next_value_自动使用 2 的幂。

class Allergen(Flag):
    FISH = auto()
    SHELLFISH = auto()
    TREE_NUTS = auto()
    PEANUTS = auto()
    GLUTEN = auto()
    SOY = auto()
    DAIRY = auto()
    SEAFOOD = Allergen.FISH | Allergen.SHELLFISH
    ALL_NUTS = Allergen.TREE_NUTS | Allergen.PEANUTS

使用标志可以在非常具体的情况下表达你的意图,但如果你希望更好地控制你的值,或者在列举不支持按位操作的值时,请使用非标志的Enum

最后要注意的是,你可以自由地为内置的多个枚举选择创建自己的别名,就像我上面为SEAFOODALL_NUTS所做的那样。

整数转换

还有两个特殊情况的枚举称为IntEnumIntFlag。它们分别映射到EnumFlag,但允许降级到原始整数进行比较。实际上,我不推荐使用这些特性,并且了解其原因是非常重要的。首先,让我们看看它们想解决的问题。

在法国烹饪中,某些成分的测量对成功至关重要,因此您需要确保这一点得到了覆盖。您创建了米制和英制液体测量(毕竟您希望国际化工作)作为枚举,但却发现您无法将您的枚举值简单地与整数进行比较。

这段代码不起作用:

class ImperialLiquidMeasure(Enum):
    CUP = 8
    PINT = 16
    QUART = 32
    GALLON = 128

>>>ImperialLiquidMeasure.CUP == 8
False

但是,如果你从IntEnum派生子类,它就可以正常工作:

class ImperialLiquidMeasure(IntEnum):
    CUP = 8
    PINT = 16
    QUART = 32
    GALLON = 128

>>>ImperialLiquidMeasure.CUP == 8
True

IntFlag的表现类似。当在系统之间或可能是硬件之间进行互操作时,您会更频繁地看到这一点。如果没有使用IntEnum,你可能需要做类似以下的事情:

>>>ImperialLiquidMeasure.CUP.value == 8
True

使用IntEnum的便利性往往不超过它作为一种较弱类型所带来的弊端。任何对整数的隐式转换都隐藏了类的真正意图。由于隐式整数转换的发生,您可能会在不想要的情况下遇到复制/粘贴错误(我们都曾经犯过这种错误,对吧?)。

考虑:

class Kitchenware(IntEnum):
    # Note to future programmers: these numbers are customer-defined
    # and apt to change
    PLATE = 7
    CUP = 8
    UTENSILS = 9

假设有人错误地执行了以下操作:

def pour_liquid(volume: ImperialLiquidMeasure):
    if volume == Kitchenware.CUP:
        pour_into_smaller_vessel()
    else:
        pour_into_larger_vessel()

如果这段代码进入生产环境,一切都会很顺利,没有异常抛出,所有测试都通过了。然而,一旦Kitchenware枚举发生变化(也许将一个BOWL添加到值8,并将CUP移动到10),这段代码现在会做完全相反于预期的事情。Kitchenware.CUP不再与ImperialLiquidMeasure.CUP相同(它们没有理由关联);然后你将开始向更大的容器倾倒,这可能会造成溢出(液体的溢出,而不是整数的溢出)。

这是一个关于不够健壮的代码如何导致微妙错误的教科书案例,这些错误直到代码库的后期才会成为问题。这可能是一个快速修复,但这个 bug 却带来了很大的实际成本。测试失败(或更糟的是,客户抱怨将错误的液体量倒入容器),有人必须爬行源代码,找到 bug,修复它,然后在思考这为何之前如此工作后,长时间的咖啡休息。所有这些都是因为有人决定懒惰地使用IntEnum,这样他们就不必一遍又一遍地输入.value。所以请为你未来的维护者着想:除非出于遗留目的,否则不要使用IntEnum

独特的

枚举的一个很棒的特性是能够给值取别名。让我们回到MotherSauce枚举。也许在法国键盘开发的代码库需要适应美国键盘,其中键盘布局不利于在元音字母上添加重音符号。删除重音以使法语原生拼写变得类似英语是许多开发人员不愿意接受的(他们坚持使用原始拼写)。为了避免国际事件,我将为一些酱汁添加别名。

from enum import Enum
class MotherSauce(Enum):
    BÉCHAMEL = "Béchamel"
    BECHAMEL = "Béchamel"
    VELOUTÉ = "Velouté"
    VELOUTE = "Velouté"
    ESPAGNOLE = "Espagnole"
    TOMATO = "Tomato"
    HOLLANDAISE = "Hollandaise"

有关此事,所有键盘所有者都欢欣鼓舞。枚举绝对允许这种行为;它们可以具有重复值,只要键不重复即可。

然而,有些情况下,您可能希望对值强制唯一性。也许您依赖枚举始终包含一组固定数量的值,或者可能会影响向客户显示的某些字符串表示。无论情况如何,如果要在您的Enum中保持唯一性,只需添加一个@unique装饰器。

from enum import Enum, unique
@unique
class MotherSauce(Enum):
    BÉCHAMEL = "Béchamel"
    VELOUTÉ = "Velouté"
    ESPAGNOLE = "Espagnole"
    TOMATO = "Tomato"
    HOLLANDAISE = "Hollandaise"

在大多数我遇到的用例中,创建别名比保持唯一性更有可能,因此我默认首先使枚举非唯一,并仅在需要时添加唯一装饰器。

总结思考

枚举很简单,通常被忽视作为一种强大的通信方法。每当您想要从静态值集合中表示单个值时,枚举应该是您首选的用户定义类型。定义和使用它们都很容易。它们提供了丰富的操作,包括迭代,在位操作中(在Flag枚举的情况下),以及对唯一性的控制。

记住这些关键限制:

  • 枚举不适用于在运行时动态更改的键值映射。使用字典来实现此功能。

  • Flag枚举仅适用于支持与非重叠值进行位操作的值。

  • 避免除非绝对必要与系统互操作性,否则使用IntEnumIntFlag

接下来,我将探索另一种用户定义类型:一个dataclass。虽然枚举在只需一个变量就能指定一组值的关系方面非常出色,但数据类定义了多个变量之间的关系。

¹ Eric Evans. 领域驱动设计:软件核心复杂性的应对. Upper Saddle River, NJ: Addison-Wesley Professional, 2003.

第九章:用户定义类型:数据类

数据类是用户定义的类型,允许您将相关数据组合在一起。许多类型,如整数、字符串和枚举,都是标量;它们表示一个且仅一个值。其他类型,如列表、集合和字典,表示同类集合。但是,您仍然需要能够将多个数据字段组合成单个数据类型。字典和元组可以做到这一点,但它们存在一些问题。可读性较差,因为在运行时很难知道字典或元组包含什么内容。这使得在阅读和审查代码时很难理解,这对代码的健壮性是一个重大打击。

当您的数据难以理解时,读者会做出错误的假设,并且很难发现错误。数据类更易于阅读和理解,并且类型检查器知道如何自然地处理它们。

数据类实例

数据类表示一个混合集合的变量,全部被卷入一个复合类型中。复合类型由多个值组成,应始终表示某种关系或逻辑分组。例如,Fraction是复合类型的一个很好的例子。它包含两个标量值:一个分子和一个分母

from fraction import Fraction
Fraction(numerator=3, denominator=5)

Fraction表示分子分母之间的关系。分子分母是彼此独立的;更改其中一个不会影响另一个。但是,通过将它们组合成单一类型,它们被组合在一起以创建一个逻辑概念。

数据类允许您轻松创建这些概念。要使用dataclass表示分数,您需要执行以下操作:

from dataclasses import dataclass
@dataclass
class MyFraction:
    numerator: int = 0
    denominator: int = 1

简单吧?在类定义前的@dataclass被称为装饰器。您将在第十七章中详细了解装饰器,但现在,您只需知道在类前加上@dataclass会将其转换为dataclass。装饰类后,您需要列出所有要表示为关系的字段。必须提供默认值或类型,以便 Python 将其识别为该dataclass的成员。在上述情况下,我展示了两者都有。

通过建立这样的关系,您可以为代码库中的共享词汇增添内容。与开发人员总是需要单独实现每个字段不同,您提供了一个可重复使用的分组。数据类强制您显式地为字段分配类型,因此在维护时减少类型混淆的机会。

数据类和其他用户定义的类型可以嵌套在dataclass中。假设我正在创建一个自动化的汤制造机,并且需要将我的汤成分分组在一起。使用dataclass,它看起来像这样:

import datetime
from dataclasses import dataclass
from enum import auto, Enum

class ImperialMeasure(Enum): ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00002.gif)
    TEASPOON = auto()
    TABLESPOON = auto()
    CUP = auto()

class Broth(Enum): ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00005.gif)
    VEGETABLE = auto()
    CHICKEN = auto()
    BEEF = auto()
    FISH = auto()

@dataclass(frozen=True) ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00006.gif)
# Ingredients added into the broth
class Ingredient:
    name: str
    amount: float = 1
    units: ImperialMeasure = ImperialMeasure.CUP

@dataclass
class Recipe: ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00007.gif)
    aromatics: set[Ingredient]
    broth: Broth
    vegetables: set[Ingredient]
    meats: set[Ingredient]
    starches: set[Ingredient]
    garnishes: set[Ingredient]
    time_to_cook: datetime.timedelta

1

用于跟踪不同液体测量尺寸的枚举

2

一个用于跟踪汤中使用的高汤的枚举

3

表示放入汤中的单个配料的dataclass。请注意,参数frozen=Truedataclass的特殊属性,表示这个dataclass是不可变的(稍后详细讨论)。这并不意味着这些配料来自超市的冷冻部分。

4

表示汤食谱的dataclass

我们能够将多个用户定义的类型(ImperialMeasureBrothIngredient)组合到一个复合类型Recipe中。从这个Recipe中,您可以推断出多个概念:

  • 汤食谱是一组分组的信息。具体来说,它可以由其配料(分为特定类别)、使用的高汤以及烹饪所需的时间来定义。

  • 每种配料都有一个名称和您需要的数量。

  • 您可以使用枚举来了解汤高汤和测量单位。这些本身不构成关系,但确实向读者传达了意图。

  • 每个配料分组都是一个集合,而不是元组。这意味着用户可以在构建后更改这些配料,但仍然可以防止重复。

要创建dataclass,我执行以下操作:

pepper = Ingredient("Pepper", 1, ImperialMeasure.TABLESPOON)
garlic = Ingredient("Garlic", 2, ImperialMeasure.TEASPOON)
carrots = Ingredient("Carrots", .25, ImperialMeasure.CUP)
celery = Ingredient("Celery", .25, ImperialMeasure.CUP)
onions = Ingredient("Onions", .25, ImperialMeasure.CUP)
parsley = Ingredient("Parsley", 2, ImperialMeasure.TABLESPOON)
noodles = Ingredient("Noodles", 1.5, ImperialMeasure.CUP)
chicken = Ingredient("Chicken", 1.5, ImperialMeasure.CUP)

chicken_noodle_soup = Recipe(
    aromatics={pepper, garlic},
    broth=Broth.CHICKEN,
    vegetables={celery, onions, carrots},
    meats={chicken},
    starches={noodles},
    garnishes={parsley},
    time_to_cook=datetime.timedelta(minutes=60))

您还可以获取和设置单个字段:

chicken_noodle_soup.broth
>>> Broth.CHICKEN
chicken_noodle_soup.garnishes.add(pepper)

Figure 9-1 显示了这个dataclass的构造方式。

构造 dataclass

图 9-1。dataclass的构造

通过类型的使用,我清楚地定义了什么是食谱。用户不能漏掉任何字段。创建复合类型是通过代码库表达关系的最佳方式之一。

到目前为止,我只描述了dataclass中的字段,但您也可以通过方法添加行为。假设我想通过替换蔬菜高汤和去除任何肉类来使任何汤变成素食。我还想列出所有的配料,以便您可以确保没有肉类产品混入。

我可以像这样直接向dataclass添加方法:

@dataclass
class Recipe:
    aromatics: set[Ingredient]
    broth: Broth
    vegetables: set[Ingredient]
    meats: set[Ingredient]
    starches: set[Ingredient]
    garnishes: set[ingredient]
    time_to_cook: datetime.timedelta

    def make_vegetarian(self):
        self.meats.clear()
        self.broth = Broth.VEGETABLE

    def get_ingredient_names(self):
        ingredients = (self.aromatics |
                       self.vegetables |
                       self.meats |
                       self.starches |
                       self.garnishes)

        return ({i.name for i in ingredients} |
                {self.broth.name.capitalize() + " broth"})

这比原始字典或元组有了重大改进。我可以直接嵌入功能到我的dataclass中,提高了可重用性。如果用户想要获取所有的配料名称或使食谱变为素食,他们不必每次都记得自己去做。调用函数简单明了。这里是直接在dataclass上调用函数的示例。

from copy import deepcopy
# make a deep copy so that changing one soup
# does not change the original
noodle_soup = deepcopy(chicken_noodle_soup)
noodle_soup.make_vegetarian()
noodle_soup.get_ingredient_names()
>>> {'Garlic', 'Pepper', 'Carrots', 'Celery', 'Onions',
     'Noodles', 'Parsley', 'Vegetable Broth'}

用法

dataclass具有一些内置函数,使其非常易于使用。您已经看到构造数据类是一件轻而易举的事情,但您还能做什么呢?

字符串转换

有两个特殊方法,__str____repr__,用于将对象转换为其非正式和正式的字符串表示形式。¹ 注意它们周围的双下划线;它们被称为魔术方法。我将在第十一章中更详细地介绍魔术方法,但现在,您可以将它们视为在对象上调用str()repr()时调用的函数。数据类默认定义这些函数:

# Both repr() and str() will return the output below
str(chicken_noodle_soup)
>>> Recipe(
    aromatics={
        Ingredient(name='Pepper', amount=1, units=<ImperialMeasure.TABLESPOON: 2>),
        Ingredient(name='Garlic', amount=2, units=<ImperialMeasure.TEASPOON: 1>)},
    broth=<Broth.CHICKEN: 2>,
    vegetables={
        Ingredient(name='Celery', amount=0.25, units=<ImperialMeasure.CUP: 3>),
        Ingredient(name='Onions', amount=0.25, units=<ImperialMeasure.CUP: 3>),
        Ingredient(name='Carrots', amount=0.25, units=<ImperialMeasure.CUP: 3>)},
    meats={
        Ingredient(name='Chicken', amount=1.5, units=<ImperialMeasure.CUP: 3>)},
    starches={
        Ingredient(name='Noodles', amount=1.5, units=<ImperialMeasure.CUP: 3>)},
    garnishes={
        Ingredient(name='Parsley', amount=2,
                   units=<ImperialMeasure.TABLESPOON: 2>)},
    time_to_cook=datetime.timedelta(seconds=3600)
)

有点冗长,但这意味着您不会得到像<__main__.Recipe object at 0x7fef44240730>这样更丑陋的东西,这是其他用户定义类型的默认字符串转换。

相等性

如果要能够在两个数据类之间进行相等性测试(==、!=),可以在定义dataclass时指定eq=True

from copy import deepcopy

@dataclass(eq=True)
class Recipe:
    # ...

chicken_noodle_soup == noodle_soup
>>> False

noodle_soup == deepcopy(noodle_soup)
>>> True

默认情况下,相等性检查将比较dataclass的两个实例的每个字段。在执行相等性检查时,Python 机械地调用名为__eq__的函数。如果希望为相等性检查提供不同的默认功能,可以编写自己的__eq__函数。

关系比较

假设我想在我的汤应用程序中显示营养信息给健康意识的用户。我希望能够按照各种轴排序汤,例如卡路里或碳水化合物的数量。

nutritionals = [NutritionInformation(calories=100, fat=1, carbohydrates=3),
                NutritionInformation(calories=50, fat=6, carbohydrates=4),
                NutritionInformation(calories=125, fat=12, carbohydrates=3)]

默认情况下,数据类不支持关系比较(<><=>=),因此无法对信息进行排序:

>>> sorted(nutritionals)
TypeError: '<' not supported between instances of
           'NutritionInformation' and 'NutritionInformation'

如果要能够定义关系比较(<><=>=),您需要在dataclass定义中设置eq=Trueorder=True。生成的比较函数将依次比较每个字段,按照定义时的顺序进行比较。

@dataclass(eq=True, order=True)
class NutritionInformation:
    calories: int
    fat: int
    carbohydrates: int
nutritionals = [NutritionInformation(calories=100, fat=1, carbohydrates=3),
                NutritionInformation(calories=50, fat=6, carbohydrates=4),
                NutritionInformation(calories=125, fat=12, carbohydrates=3)]

>>> sorted(nutritionals)
    [NutritionInformation(calories=50, fat=6, carbohydrates=4),
     NutritionInformation(calories=100, fat=1, carbohydrates=3),
     NutritionInformation(calories=125, fat=12, carbohydrates=3)]

如果您想控制如何定义比较,可以在dataclass中编写自己的__le____lt____gt____ge__函数,分别映射到小于或等于、小于、大于和大于或等于。例如,如果您希望NutritionInformation默认按照脂肪、碳水化合物和卡路里的顺序排序:

@dataclass(eq=True)
class NutritionInformation:
    calories: int
    fat: int
    carbohydrates: int

    def __lt__(self, rhs) -> bool:
        return ((self.fat, self.carbohydrates, self.calories) <
                (rhs.fat, rhs.carbohydrates, rhs.calories))

    def __le__(self, rhs) -> bool:
        return self < rhs or self == rhs

    def __gt__(self, rhs) -> bool:
        return not self <= rhs

    def __ge__(self, rhs) -> bool:
        return not self < rhs

nutritionals = [NutritionInformation(calories=100, fat=1, carbohydrates=3),
                NutritionInformation(calories=50, fat=6, carbohydrates=4),
                NutritionInformation(calories=125, fat=12, carbohydrates=3)]

>>> sorted(nutritionals)
    [NutritionInformation(calories=100, fat=1, carbohydrates=3),
     NutritionInformation(calories=50, fat=6, carbohydrates=4),
     NutritionInformation(calories=125, fat=12, carbohydrates=3)]
警告

如果重写比较函数,请不要指定order=True,因为这将引发ValueError

不可变性

有时,您需要表明dataclass不应该可以更改。在这种情况下,您可以指定dataclass必须是frozen或无法更改的。每当改变dataclass的状态时,您都可能引入整个类别的可能错误:

  • 您代码的调用者可能不知道字段已更改;他们可能错误地假设字段是静态的。

  • 将单个字段设置为不正确的值可能与其他字段的设置方式不兼容。

  • 如果有多个线程修改字段,您将面临数据竞争的风险,这意味着无法保证修改的顺序。

如果您的dataclassfrozen,则不会发生这些错误情况。要冻结一个dataclass,只需向dataclass装饰器添加frozen=True

@dataclass(frozen=True)
class Recipe:
    aromatics: Set[Ingredient]
    broth: Broth
    vegetables: Set[Ingredient]
    meats: Set[Ingredient]
    starches: Set[Ingredient]
    garnishes: Set[Ingredient]
    time_to_cook: datetime.timedelta

如果您希望将您的dataclass用作集合中的元素或作为字典中的键,则它必须是可哈希的。这意味着它必须定义一个__hash__函数,将您的对象转换为一个数字。² 当您冻结一个dataclass时,它会自动变为可哈希的,只要您不明确禁用相等检查并且所有字段都是可哈希的。

关于这种不可变性有两个需要注意的地方。首先,当我说不可变性时,我指的是dataclass中的字段,而不是包含dataclass本身的变量。例如:

# assume that Recipe is immutable because
# frozen was set to true in the decorator
soup = Recipe(
    aromatics={pepper, garlic},
    broth=Broth.CHICKEN,
    vegetables={celery, onions, carrots},
    meats={chicken},
    starches={noodles},
    garnishes={parsley},
    time_to_cook=datetime.timedelta(minutes=60))

# this is an error
soup.broth =  Broth.VEGETABLE

# this is not an error
soup = Recipe(
    aromatics=set(),
    broth=Broth.CHICKEN,
    vegetables=set(),
    meats=set(),
    starches=set(),
    garnishes=set(),
    time_to_cook=datetime.timedelta(seconds=3600))
)

如果您希望类型检查器在变量重新绑定时报错,可以将变量注释为Final(有关Final的更多详细信息,请参见第四章)。

其次,frozen dataclass仅防止其成员被设置。如果成员是可变的,则仍然可以调用这些成员的方法来修改它们的值。frozen dataclass不会将不可变性扩展到其属性。

例如,这段代码是完全没有问题的:

soup.aromatics.add(Ingredient("Garlic"))

即使它正在修改frozen dataclassaromatics字段,也不会引发错误。在使用frozen dataclass时,使成员不可变(例如整数、字符串或其他frozen dataclass)以避免这种陷阱。

与其他类型的比较

数据类相对较新(在 Python 3.7 中引入);许多传统代码将不包含数据类。在评估数据类的采用时,您需要了解数据类在与其他结构相比的优势所在。

数据类与字典的比较

正如在第五章中讨论的,字典非常适合将键映射到值,但当它们是同质化的(所有键都是相同类型,所有值都是相同类型)时才是最适合的。当用于异构数据时,字典对人类的推理更加困难。此外,类型检查器对字典的了解不足以检查错误。

然而,数据类非常适合基本异构数据。代码的读者知道类型中存在的确切字段,并且类型检查器可以检查正确的使用。如果您有异构数据,请在考虑使用字典之前使用数据类。

数据类与 TypedDict 的比较

此外,在第五章中还讨论了TypedDict类型。这是另一种存储异构数据的方式,对读者和类型检查器都有意义。乍一看,TypedDict和数据类解决了非常相似的问题,因此很难决定哪一个更合适。我的经验法则是将dataclass视为默认选项,因为它可以在其上定义函数,并且你可以控制不可变性、可比性、相等性和其他操作。但是,如果你已经使用字典(例如用于处理 JSON),你应该考虑使用TypedDict,前提是你不需要dataclass的任何好处。

数据类与 namedtuple

namedtuple是集合模块中类似元组的集合类型。与元组不同的是,它允许你为元组中的字段命名,如下所示:

>>> from collections import namedtuple
>>> NutritionInformation = namedtuple('NutritionInformation',
                                      ['calories', 'fat', 'carbohydrates'])
>>> nutrition = NutritionInformation(calories=100, fat=5, carbohydrates=10)
>>> print(nutrition.calories)

100

namedtuple在使元组更易读方面有很大帮助,但是使用dataclass同样如此。我几乎总是选择dataclass而不是namedtupledataclassnamedtuple一样提供了命名字段以及其他好处,比如:

  • 明确为你的参数进行类型注释

  • 控制不可变性、可比性和相等性

  • 在类型中更容易定义函数

通常情况下,我只在明确需要与 Python 3.6 或更早版本兼容时才使用namedtuple

讨论主题

你在代码库中使用什么类型来表示异构数据?如果你使用字典,开发者了解字典中所有键值对有多容易?如果你使用元组,开发者了解各个字段的含义有多容易?

总结思路

当 Python 3.7 发布时,数据类是一个重要的变革,因为它允许开发者定义完全类型化但仍然轻量级的异构类型。在编写代码时,我发现自己越来越多地使用数据类。每当你遇到异构的、由开发者控制的字典或namedtuple时,数据类更加合适。你可以在dataclass文档中找到大量额外的信息。

然而,尽管数据类很棒,它们不应普遍使用。数据类本质上代表一种概念关系,但只有在数据类内的成员彼此独立时才合适。如果任何成员根据其他成员应受限制,数据类将使得你的代码难以理解。任何开发者都可能在数据类的生命周期内更改字段,可能导致非法状态。在这些情况下,你需要选择更重的东西。在接下来的章节中,我将教你如何使用类来实现这一点。

¹ 非正式的字符串表示对于打印对象很有用。官方的字符串表示重现了关于对象的所有信息,以便可以重建它。

² 哈希是一个复杂的主题,超出了本书的范围。你可以在Python 文档中了解更多关于hash函数的信息。

第十章:用户定义类型:类

类将是本书中我将介绍的最终用户定义类型。许多开发者很早就学习了类,这既是一个利好又是一个诅咒。类被用在许多框架和代码库中,因此精通类设计是值得的。然而,当开发者过早学习类时,他们会错过何时以及更重要的是何时不应该使用它们的微妙之处。

回想一下你使用类的情况。你能把那些数据表示为一个dataclass吗?或者用一组自由函数?我见过太多到处都使用类的代码库,当然他们真的不应该这样做,因此可维护性因此受到影响。

然而,我也遇到过完全不使用类的代码库。这也会影响可维护性;很容易打破假设,并且在整个代码中有不一致的数据。在 Python 中,你应该追求一种平衡。类在你的代码库中有其位置,但是认识到它们的优点和缺点是很重要的。是时候深入挖掘,抛开你的成见,学习类如何帮助你编写更加健壮的代码了。

类的解剖

类被设计为另一种将相关数据组合在一起的方式。它们在面向对象范式中有几十年的历史,并且乍看之下与你对数据类学到的东西并没有太大的不同。事实上,你可以像写dataclass一样写一个类:

class Person:
    name: str = ""
    years_experience: int = 0
    address: str = ""

pat = Person()
pat.name = "Pat"
print(f"Hello {pat.name}")

看着上面的代码,你可能可以用一个dictdataclass以不同的方式编写它:

pat = {
    "name": "",
    "years_experience": 0,
    "address": ""
}

@dataclass
class Person():
    name: str = ""
    years_experience: int = 0
    address: str = ""

在第九章中,你了解了数据类相对于原始字典的优势,而类提供了许多相同的好处。但你可能会(理所当然地)想知道,为什么你会再次使用类而不是数据类呢?

实际上,考虑到数据类的灵活性和便利性,类可能会感觉不如其优。你得不到像frozenordered这样的高级特性。你得不到内置的字符串方法。为什么呢,你甚至不能像使用数据类那样优雅地实例化一个Person

尝试像这样做:

pat = Person("Pat", 13, "123 Fake St.")

当尝试使用类时,你会立即遇到错误:

TypeError: Person() takes no arguments

乍看起来,这真的很令人沮丧。然而,这种设计决策是有意的。你需要明确地定义类如何被构建,这通过一个称为构造函数的特殊方法完成。与数据类相比,这可能看起来像是一个缺点,但它允许你对类中的字段有更精细的控制。接下来的几节将描述如何利用这种控制带来好处。首先,让我们看看类的构造函数实际上为你提供了什么。

构造函数

构造函数描述了如何初始化你的类。你使用一个__init__方法来定义构造函数:

class Person:
    def __init__(self,
                  name: str,
                  years_experience: int,
                  address: str):
        self.name = name
        self.years_experience = years_experience
        self.address = address

pat = Person("Pat", 13, "123 Fake St.")

注意,我稍微调整了类。与我在dataclass中定义变量的方式不同,我在构造函数中定义了所有变量。构造函数是在实例化类时调用的特殊方法。它接受定义用户数据类型所需的参数,以及一个称为self的特殊参数。这个参数的具体名称是任意的,但你会看到大多数代码都使用self作为约定。每次实例化一个类时,self参数都指向该特定实例;一个实例的属性不会与另一个实例的属性冲突,即使它们是相同的类。

那么,为什么您会编写一个类呢?字典或数据类更简单,涉及的仪式更少。对于之前列出的Person对象这样的东西,我并不反对。然而,类可以传达字典或数据类无法轻松传达的一个关键点:不变量

不变量

不变量是实体的一个在其生命周期内保持不变的属性。不变量是关于您的代码的真实概念。代码的读者和编写者将推理您的代码,并依赖于这种推理来保持一切顺利。不变量是理解您的代码库的基础。以下是一些不变量的示例:

  • 每个员工都有一个唯一的 ID;没有两个员工 ID 是重复的。

  • 游戏中的敌人只有在其健康点数高于零时才能采取行动。

  • 圆形可能只有正半径。

  • 每个披萨上都会有酱料上面的奶酪。

不变量传达对象的不可变属性。它们可以反映数学属性、业务规则、协调保证或任何您希望保持真实的东西。不变量不必反映现实世界;它们只需要在您的系统中为真即可。例如,芝加哥风格的深盘披萨爱好者可能不同意最后一个与披萨有关的条款,但如果您的系统只处理奶酪-酱披萨,将其编码为不变量就可以了。不变量只涉及特定的实体。您可以决定不变量的范围,无论它是否适用于整个系统,或者它是否仅适用于特定的程序、模块或类。本章将重点介绍类及其在保持不变量方面的作用。

那么,类如何帮助传达不变量呢?让我们从构造函数开始。您可以添加保护和断言来检查不变量是否满足,并且从那时起,该类的用户应该能够依赖于该不变量在类的生命周期内始终为真。让我们看看如何做到这一点。

考虑一个想象中的自动披萨制造机,每次都能制作完美的披萨。它会拿面团,擀成圆形,涂抹酱料和配料,然后烤披萨。我将列出一些我希望在系统中保留的不变量(这些不变量并不适用于世界上所有披萨,仅适用于我想要创建的披萨)。

我希望以下内容在披萨的整个生命周期内都成立:

  • 酱料永远不会被放在配料(在这种情况下,奶酪是一种配料)的上面。

  • 配料可以放在奶酪的上面或下面。

  • 披萨最多只会有一种酱料。

  • 面团半径只能是整数。

  • 面团的半径可能只能在 6 到 12 英寸之间,包括端点(15 到 30 厘米之间)。

这些规定可能是出于业务原因,也可能是出于健康原因,还可能只是机器的限制,但每一个都意味着在其生命周期内都应该成立。我会在披萨构建过程中检查这些不变性。

from pizza.sauces import is_sauce
class PizzaSpecification:
    def __init__(self,
                  dough_radius_in_inches: int,
                  toppings: list[str]):
        assert 6 <= dough_radius_in_inches <= 12, \
            'Dough must be between 6 and 12 inches'
        sauces = [t for t in toppings if is_sauce(t)]
        assert len(sauces) < 2, \
            'Can only have at most one sauce'

        self.dough_radius_in_inches = dough_radius_in_inches
        sauce = sauces[:1]
        self.toppings = sauce + \
            [t for t in toppings if not is_sauce(t)]

让我们来详细讨论一下这个不变性检查:

  • dough_radius_in_inches 是一个整数。这并不会阻止调用者将浮点数/字符串/其他内容传递给构造函数,但如果与类型检查器一起使用(就像你在第一部分中使用的那些),你可以检测到调用者传递错误类型的情况。如果你没有使用类型检查器,你将不得不使用isinstance()检查(或类似的东西)。

  • 此代码断言面团半径在 6 到 12 英寸(包括端点)之间。如果不是这样,将抛出AssertionError(阻止类的构造)。

  • 这段代码断言最多只有一种酱料,如果不成立,则抛出AssertionError

  • 这段代码确保酱料位于我们配料列表的开头(可以推断这将用于告诉披萨制作者应该以什么顺序放置配料)。

  • 注意,我并没有明确采取任何措施来保持配料可以放在奶酪的上面或下面。这是因为实现的默认行为满足了不变性。然而,你仍然可以通过文档向调用者传达这个不变性。

避免破坏不变性

如果违反不变性,千万不要构造这个类。如果调用者以违反不变性的方式构造对象,你有两个选择。

抛出异常

这阻止了对象的构造。这是我在确保面团半径合适并且最多只有一种酱料时所做的。

对数据进行调整

使数据符合不变性。当我没有按照正确的顺序获取配料时,我本可以抛出异常,但我选择重新排列它们以满足不变性。

不变性有什么好处?

编写类和确定不变性是一项很大的工作。但我希望你在每次将一些数据组合在一起时都能有意识地思考不变性。问问自己:

  • 如果有任何数据以任何我无法通过类型系统捕捉到的形式受限(比如配料的顺序)?

  • 一些字段是否相互依赖(例如,改变一个字段可能需要改变另一个字段)?

  • 我是否有什么数据保证?

如果你对以下任何问题回答“是”,你有想要保留的不变量,并且应该编写一个类。当你选择编写一个类并定义一组不变量时,你做了几件事:

  1. 你遵循了不重复自己(DRY)的原则。¹而不是在对象构建之前在你的代码中散落检查,你把这些检查放在一个地方。

  2. 你让编写者更费力,以减轻读者/维护者/调用者的工作。你的代码很可能会比你工作的时间更长。通过提供不变量(并且很好地沟通它们—参见下一节),你减轻了那些继你之后者的负担。

  3. 你能更有效地推理代码。有一个原因,为什么像Ada这样的语言和形式证明这样的概念被用于关键任务环境中。它们给开发人员提供了一定程度的信任;其他程序员可以在一定程度上信任你的代码。

所有这些都导致了更少的错误。你不会冒人们构建对象不正确或遗漏必需检查的风险。你正在为人们思考提供更简单的 API,并减少人们错误使用你的对象的风险。你还将更接近最小惊讶法则。你绝不希望在使用你的代码时让人感到惊讶(你听过多少次这样的说法:“等等,就是类的工作方式?”)。通过定义不变量并坚持它们,减少了某人感到惊讶的机会。

一个字典根本无法做到这一点。

考虑一个由字典表示的比萨规范:

{
    "dough_radius_in_inches": 7
    "toppings": ["tomato sauce", "mozzarella", "pepperoni"]
}

没有简单的方法可以强制用户正确构建这个字典。你必须依赖调用者在每次调用中都做正确的事情(随着代码库的增长,这将变得更加困难)。也没有办法阻止用户自由修改字典并破坏不变量。

注:

你可以在检查不变量后定义构造字典的方法,并且只通过同时检查不变量的函数改变字典。或者,你当然可以在数据类上编写构造函数和检查不变量的方法。但如果你为此付出了所有的努力,为什么不写一个类呢?要注意你的选择对未来的维护者意味着什么。你必须在字典、数据类和类之间做出明智的选择。每种抽象都传达了一种非常具体的含义,如果你选择错误,你将会使维护者困惑不解。

还有另一个好处,我还没有谈论过,它与“S”中的 SOLID 原则相关(请参见下一个侧栏):单一职责原则。单一职责原则指出每个对象“应该有且仅有一个改变的理由。”² 这听起来很简单,但在实践中确切地知道什么是 一个 改变的理由可能会很困难。我建议你定义一组相关的不变量(例如你的面团和配料)并为每组相关的不变量编写一个类。如果你发现自己在编写与这些不变量无直接关系的属性或方法,那么你的类的 内聚性 较低,这意味着它承担了太多的责任。

讨论话题

考虑你代码库中最重要的部分。关于该系统有哪些不变量是真实的?这些不变量被多么有效地强制执行,以至于开发者无法打破它们?

传达不变量

现在,除非你能有效地传达这些好处,否则无法实现这些好处。没有人可以推理出他们不知道的不变量。那么,你该怎么做呢?嗯,对于任何沟通,你都应考虑你的观众。你有两种类型的人,有两种不同的用例:

类的消费者

这些人试图解决自己的问题,并寻找帮助他们的工具。他们可能正在调试问题或查找代码库中能帮助他们的类。

类的未来维护者

人们将会扩展你的类,重要的是他们不要破坏所有调用者都依赖的不变量。

当设计你的类时,你需要同时考虑这两者。

使用你的类

首先,你的类的消费者通常会查看你的源代码,以了解其工作原理及是否符合他们的需求。在构造函数中放置断言语句(或引发其他异常)是告诉用户关于你的类可做和不可做的好方法。通常开发者会首先查看构造函数(毕竟,如果他们不能实例化你的类,那么如何使用它呢?)。对于在代码中无法表示的不变量(是的,这些存在),你需要在用户用于 API 参考的任何文档中记录它们。文档距离代码越近,用户在查看代码时找到它的可能性就越大。

头脑中的知识不具备可扩展性或可发现性。Wiki 和/或文档门户是一个不错的步骤,但通常更适合大规模的想法,不容易过时。代码库中的 README 是一个更好的步骤,但真正最佳的地方是与类本身的注释或文档字符串。

class PizzaSpecification:
    """
 This class represents a Pizza Specification for use in
 Automated Pizza Machines.

 The pizza specification is defined by the size of the dough and
 the toppings. Dough should be a whole number between 6 and 12
 inches (inclusive). If anything else is passed in, an AssertionError
 is thrown. The machinery cannot handle less than 6 inches and the
 business case is too costly for more than 12 inches.

 Toppings may have at most one sauce, but you may pass in toppings
 in any order. If there is more than one sauce, an AssertionError is
 thrown. This is done based on our research telling us that
 consumers find two-sauced pizzas do not taste good.

 This class will make sure that sauce is always the first topping,
 regardless of order passed in.

 Toppings are allowed to go above and below cheese
 (the order of non-sauce toppings matters).

 """
    def __init__(...)
        # ... implementation goes here

在我的职业生涯中,我对注释有些争议。起初,我会注释一切,可能是因为我的大学教授要求如此。几年后,情况完全反转,我主张“代码应该自说明”,意味着代码应该能够自给自足。毕竟,注释可能会过时,俗话说得好,“错误的注释比没有注释还糟糕”。现在情况已经改变,我学到了代码绝对应该自我记录做什么(这只是最小惊讶法则的另一种体现),但注释有助于理解代码的人性化特征。大多数人简化为为什么代码行为如此,但有时这样说还是有些含糊。在上面的片段中,我通过记录我的不变量(包括代码中未显式的不变量),并用业务原因加以支持来进行注释。这样,消费者可以确定类的用途和不用途,以及该类是否适合他们的预期用例。

关于维护者怎么样?

您将不得不与另一组人打交道,即您代码的未来维护者。这是一个棘手的问题。您有一个帮助定义约束的注释,但这不会阻止无意中更改不变量。更改不变量是一件微妙的事情。人们会依赖这些不变量,即使它们不反映在函数签名或类型系统中。如果有人更改了一个不变量,每个类的使用者都可能受到影响(有时这是不可避免的,但要意识到成本)。

为了帮助捕捉这一点,我将依靠一个老朋友作为安全网——单元测试。单元测试是一小段代码,将自动测试您自己的类和函数。(有关单元测试的更多讨论,请查看第二十一章。)您绝对应该围绕您的期望和不变量编写单元测试,但我希望您考虑另一个方面:帮助未来的测试编写者知道何时破坏了不变量。我喜欢借助上下文管理器来做到这一点——这是 Python 中的一种结构,在退出with块时强制运行代码(如果您对上下文管理器不熟悉,您可以在第十一章中了解更多):

import contextlib
from pizza_specification import PizzaSpecification

@contextlib.contetxtmanager
def create_pizza_specification(dough_radius_in_inches: int,
                               toppings: list[str]):
    pizza_spec = PizzaSpecification(dough_radius_in_inches, toppings)
    yield pizza_spec
    assert 6 <= pizza_spec.dough_radius_in_inches <= 12
    sauces = [t for t in pizza_spec.toppings if is_sauce(t)]
    assert len(sauces) < 2
    if sauces:
        assert pizza_spec.toppings[0] == sauces[0]

    # check that we assert order of all non sauces
    # keep in mind, no invariant is specified that we can't add
    # toppings at a later date, so we only check against what was
    # passed in
    non_sauces = [t for t in pizza_spec.toppings if t not in sauces]
    expected_non_sauces = [t for t in toppings if t not in sauces]
    for expected, actual in zip(expected_non_sauces, non_sauces):
        assert expected == actual

def test_pizza_operations():
    with create_pizza_specification(8, ["Tomato Sauce", "Peppers"]) \
        as pizza_spec:

        # do something with pizza_spec

使用这种上下文管理器的美妙之处在于,可以将每个不变量作为测试的后置条件进行检查。这感觉像是重复和直接违反了 DRY 原则,但在这种情况下,这是合理的。单元测试是一种双入账簿记,您希望它们在一方错误更改时发现错误。

封装和维护不变量

我有一个小秘密告诉你。我在上一节中并不完全诚实。我知道,我知道,我真是羞愧,我相信眼尖的读者早已发现了我的欺骗。

想象一下:

pizza_spec = PizzaSpecification(dough_radius_in_inches=8,
                                toppings=['Olive Oil',
                                          'Garlic',
                                          'Sliced Roma Tomatoes',
                                          'Mozzarella'])

没有任何东西可以阻止未来的开发者在事后改变一些不变量。

pizza_spec.dough_radius_in_inches = 100  # BAD!
pizza_spec.toppings.append('Tomato Sauce')  # Second sauce, oh no!

如果任何开发者可以立即使它们失效,那谈论不变量的意义是什么呢?嗯,事实证明我有另一个概念要讨论:封装

封装是什么鬼?

封装。简单来说,它是一个实体隐藏属性及其操作的能力。从实际角度来看,这意味着你决定哪些属性对调用者可见,并限制他们如何访问和/或更改数据。这是通过应用程序编程接口(API)来实现的。

当大多数人想到 API 时,他们会想到 REST 或 SDK(软件开发工具包)。但每个类都有自己的 API。这是你与类交互的基石。每个函数调用,每个属性访问,每个初始化都是对象 API 的一部分。

到目前为止,我在PizzaSpecification中已经涵盖了 API 的两个部分:初始化(构造函数)和属性访问。我对构造函数没有更多的话要说;它在验证不变量方面已经做了它的工作。现在,我将讨论如何在你拓展 API 的其余部分时保持这些不变量。

保护数据访问

这把我们带回到本节开头的问题:我们如何防止我们 API 的用户(我们的类)破坏不变量?通过表明这些数据应该是私有的。

许多编程语言中有三种类型的访问控制:

公共

任何其他代码可以访问 API 的这部分。

受保护的

只有子类(我们将在第十二章中详细了解)应该访问 API 的这部分。

私有

只有这个类(以及该类的任何其他实例)应该访问 API 的这部分。

公共和受保护的属性构成了你的公共 API,在人们大量依赖你的类之前应该是相对稳定的。然而,一般约定人们应该让你的私有 API 保持原样。这应该让你自由隐藏你认为需要不可访问的内容。这就是你可以保持你的不变量的方法。

在 Python 中,通过在属性前加下划线(_)向其他开发者表明该属性应受保护。私有属性和方法应该用双下划线前缀(__)。 (请注意,这与被两个下划线包围的函数不同——那些表示特殊的魔术方法,我将在 第十一章 中介绍。)在 Python 中,你没有编译器能够在访问控制被打破时进行捕捉。没有什么能阻止开发者进入并操纵你的受保护和私有成员。强制执行这一点成为组织挑战的一部分,这是像 Python 这样动态类型语言的本质。设置 linting,执行代码风格,进行彻底的代码审查;你应该把 API 视为类的核心原则,不允许它轻易被破坏。

使属性受保护/私有有几个好处。受保护和私有属性不会出现在类的 help() 中。这将减少无意中使用这些属性的机会。此外,私有属性不容易被访问。

考虑带有私有成员的 PizzaSpecification

from pizza.sauces import is_sauce
class PizzaSpecification:
    def __init__(self,
                 dough_radius_in_inches: int,
                 toppings: list[str]):
        assert 6 <= dough_radius_in_inches <= 12, \
        'Dough must be between 6 and 12 inches'
        sauces = [t for t in toppings if is_sauce(t)]
        assert len(sauces) < 2, \
            'Can have at most one sauce'

        self.__dough_radius_in_inches = dough_radius_in_inches ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00002.gif)
        sauce = sauces[:1]
        self.__toppings = sauce + \
            [t for t in toppings if not is_sauce(t)] ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00005.gif)

pizza_spec = PizzaSpecification(dough_radius_in_inches=8,
                                toppings=['Olive Oil',
                                          'Garlic',
                                          'Sliced Roma Tomatoes',
                                          'Mozzarella'])

pizza_spec.__toppings.append('Tomato Sauce') # OOPS
>>> AttributeError: type object 'pizza_spec' has no attribute '__toppings'

1

现在 Dough radius 以英寸为单位是一个私有成员。

2

现在 Toppings 是一个私有成员。

Python 在你用双下划线前缀属性时会进行名称混淆。也就是说,Python 会在你眼皮底下改变属性名,使得用户滥用你的 API 时显得非常明显。我可以通过使用对象的 __dict__ 属性找出名称混淆的具体含义:

pizza_spec.__dict__
>>> { '_PizzaSpecification__toppings': ['Olive Oil',
                                        'Garlic',
                                        'Sliced Roma Tomatoes',
                                        'Mozzarella'],
      '_PizzaSpecification__dough_radius_in_inches': 8
}

pizza_spec._PizzaSpecification__dough_radius_in_inches = 100
print(pizza_spec._PizzaSpecification__dough_radius_in_inches)
>>> 100

如果你看到像这样的属性访问,应该引起警觉:开发者正在操纵类内部,这可能会破坏不变式。幸运的是,当进行代码基础的检查时,很容易就能抓住这一点(你将在 第二十章 中学到更多关于 linter 的知识)。与你的合作者形成协议,不要触碰任何私有内容;否则,你会陷入一个难以维护的混乱中。

操作

现在我有了一个类,它的不变式不容易被(轻易地)破坏。我有了一个可以构造的类,但我无法更改或读取其中任何数据。这是因为我目前只涉及了封装的一部分:数据的隐藏。我仍然需要介绍如何将操作与数据捆绑在一起。进入方法。

我相信你已经很好地掌握了在类外部存在的函数(也称为自由函数)。我将专注于存在于类内部的函数,也称为方法。

假设对于我的披萨规格,我希望在排队制作披萨时能够添加配料。毕竟,我的披萨非常成功(这是我的想象,请让我拥有这一点),通常有很长的披萨制作队列。但是一家刚刚下订单的家庭意识到他们忘记了儿子最喜欢的配料,为了防止因融化的奶酪引起的小孩崩溃,他们需要在提交订单后修改订单。我将定义一个新函数,为他们方便地添加配料。

from typing import List
from pizza.exceptions import PizzaException
from pizza.sauces import is_sauce
class PizzaSpecification:
    def __init__(self,
                 dough_radius_in_inches: int,
                 toppings: list[str]):
        assert 6 <= dough_radius_in_inches <= 12, \
            'Dough must be between 6 and 12 inches'

        self.__dough_radius_in_inches = dough_radius_in_inches
        self.__toppings: list[str] = []
        for topping in toppings:
            self.add_topping(topping) ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00002.gif)

    def add_topping(self, topping: str): ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00005.gif)
        '''
        Add a topping to the pizza
        All rules for pizza construction (one sauce, no sauce above
        cheese, etc.) still apply.
        '''
        if (is_sauce(topping) and
               any(t for t in self.__toppings if is_sauce(t))):
               raise PizzaException('Pizza may only have one sauce')

        if is_sauce(topping):
            self.__toppings.insert(0, topping)
        else:
            self.__toppings.append(topping)

1

使用新的add_topping方法。

2

新的add_topping方法。

编写一个仅将配料追加到列表的方法很容易。但那不对。我有一个不变量要维持,我现在不会退缩。代码确保我们不会添加第二种酱料,并且如果配料是酱料,则确保首先涂抹。请记住,不变量需要在对象的整个生命周期内保持为真,这远远超出了初始构建。您添加的每种方法都应继续保持该不变量。

方法通常分为两类:访问器和变异器。有些人简化为“获取器”和“设置器”,但我觉得这有点狭隘。“获取器”和“设置器”通常描述的是仅返回简单值或设置成员变量的方法。许多方法要复杂得多:设置多个字段,执行复杂计算,或操作数据结构。

访问器用于检索信息。如果您有关于如何表示数据的不变量,这些就是您关心的方法。例如,披萨规格可能包括将其内部数据转换为机器操作的方法(擀面团,涂酱,加配料,烘烤)。根据不变量的性质,您希望确保不会产生无效的机器操作。

变异器是改变对象状态的东西。如果您有变异器,需要特别小心,在更改状态时保持所有不变量。向现有披萨添加新配料就是一种变异器。

这也是衡量一个函数是否应该在类内部的好方法。如果你有一些不关心不变性的函数,或者更糟糕的是,不关心类成员的函数,你可能是在写自由函数。这种类最好存在于模块范围而不是类内部。也许在一个已经臃肿的类中再添加一个函数看起来很有吸引力(通常是最简单的方法),但是如果你追求可维护性,将无关的函数放在一个类中会导致噩梦般的结果。(你设置了各种有趣的依赖链;如果你曾经问过自己为什么一个文件依赖于另一个文件,这往往就是原因。)也可能发生的情况是你的类根本没有不变性,你应该考虑只是链式调用自由函数。

而不变性是关键。开发者很少谈论这一点,但一旦你开始以不变性的术语思考,你将会看到类的可维护性显著提升。记住,你使用不变性来让用户推理你的对象并减少认知负荷。如果你需要为了读者的理解而付出额外的时间,编写代码时多花点时间也是值得的。

结语

我在类上花了相当多的时间,特别是与其他用户定义数据类型如枚举和数据类相比。然而,这是有意为之的。类通常很早就开始教授,但很少回顾。我发现大多数开发者倾向于过度使用类,而不考虑它们的实际用途。

当你决定如何创建用户定义类型时,我为你提供以下指南:

字典

字典是用来将键映射到值的。如果你使用字典却很少迭代它们或者动态地查询键,那么你没有像关联映射一样使用它们,可能需要使用不同的类型。有一个例外情况是在运行时从数据源中检索数据(例如获取 JSON、解析 YAML、检索数据库数据等),这时候可以使用 TypedDict(见第五章)。然而,如果你在其他地方不需要将它们用作字典,你应该在解析数据后将其转换为用户定义的类。

枚举类型

枚举类型非常适合表示离散标量值的并集。你并不一定关心枚举值是什么;你只需要单独的标识符来区分代码中的不同情况。

数据类

数据类非常适合用于大部分独立的数据集合。你可能对个别字段的设置有一些限制,但大多数情况下,用户可以自由获取和设置单个属性。

类是关于不变性的。如果你想保持一个不变性,创建一个类,确保在构建时前提条件成立,并且不允许任何方法或用户访问破坏该不变性。

图 10-1 是一个方便的流程图,描述了这些经验法则。

选择合适的抽象

图 10-1。选择合适的抽象

然而,知道选择哪种类型只是战斗的一半。一旦你选择了正确的类型,你需要使其对消费者来说无缝交互。在下一章中,你将学习如何通过关注类型的 API 使您的用户定义类型更加自然易用。

¹ 安德鲁·亨特和大卫·托马斯。《实用程序员:从新手到大师》。雷丁,MA:Addison-Wesley,2000 年。

² 罗伯特·C·马丁。《单一职责原则》。干净代码博客(博客),2014 年 5 月 8 日。https://oreil.ly/ZOMxb

第十一章:定义你的接口

你已经学会如何创建自己的用户定义类型,但创建它们只是战斗的一半。现在开发者必须实际使用你的类型。为此,他们使用你类型的 API。这是开发者与之交互以使用你的代码的类型和相关函数集合,以及任何外部函数。

一旦你的类型展示给用户,它们将以你从未想到的方式被使用(和滥用)。一旦开发者依赖你的类型,改变它们的行为将变得困难。这导致了我所说的代码接口悖论的产生:

你只有一次机会来正确设计你的接口,但是你不会知道它是否正确,直到它被使用。

一旦开发者使用你创建的类型,他们就会依赖这些类型所包含的行为。如果你尝试进行不向后兼容的更改,你可能会破坏所有调用代码。更改接口的风险与依赖它的外部代码量成正比。

如果你控制所有依赖你类型的代码,这个悖论就不适用;你可以改变它。但是一旦这种类型投入生产,并且人们开始使用它,你会发现难以改变它。在一个庞大的代码库中,稳健性和可维护性很重要,协调变更和需要广泛支持的成本很高。如果你的类型被组织外的实体使用,比如开源库或平台 SDK,这几乎是不可能的。这很快导致难以处理的代码,而难以处理的代码会减慢开发者的速度。

更糟糕的是,你不会真正知道一个接口是否自然易用,直到足够多的人依赖它,这就产生了这个悖论。如果你不知道接口将如何被使用,你又如何设计它呢?当然,你知道会如何使用接口,这是一个很好的起点,但在创建接口时你有一种内在的偏见。对你来说自然的东西并不一定对其他人来说也自然。你的目标是让用户以最小的努力做正确的事情(并避免错误的事情)。理想情况下,用户使用你的接口不需要额外的操作。

我没有一个对你来说完美无缺的方法;没有一种绝对可靠的方法来一次满足所有人的接口需求。相反,我会讨论一些原则,你可以应用它们来增加成功的机会。对于需要对现有 API 进行更改的情况,你将学习到减轻策略。你的 API 是其他开发者的第一印象;要珍惜它。

讨论话题

你的代码库中有哪些难以使用的接口?寻找使用你的类型时人们经常犯的常见错误。还要找出很少被调用的接口,特别是如果你觉得它们有用的话。为什么用户不调用这些有用的函数?讨论当开发者遇到这些难以使用的接口时会出现什么成本。

自然的接口设计

你的目标,尽管看起来很困难,是使你的接口看起来自然易用。换句话说,你希望减少调用者代码的摩擦力。当代码难以使用时,会发生以下情况:

重复的功能

一些开发者发现你的类型难以使用,会编写他们自己的类型,重复功能。在大规模竞争(比如竞争的开源项目)中,不同的想法相互竞争可能是健康的,但在你的代码库中存在这种分歧并不健康。开发者面对大量类型,不确定应该使用哪一个。他们的注意力分散,思维混乱,会犯错,导致错误,这样会造成成本。另外,如果你想要向其中一个类型添加任何内容,你需要在所有功能分歧的地方添加,否则会产生错误,这样也会造成成本。

破碎的心智模型

开发者会建立起他们所使用的代码的心智模型。如果某些类型难以推理,那么心智模型就会破碎。开发者会误用你的类型,导致微妙的错误。也许他们没有按照你要求的顺序调用方法。也许他们忽略了应该调用的方法。也许他们只是误解了代码在做什么,并向其传递了错误的信息。任何这些都会给你的代码库引入脆弱性。

减少测试

难以使用的代码很难测试。无论是复杂的接口,还是庞大的依赖链,或是复杂的交互;如果无法轻松测试代码,就会少写测试。写的测试越少,当事情变化时捕获的错误就越少。每次做一个看似无关的改变时,测试以微妙的方式断开都是非常令人沮丧的。

使用困难的代码会使你的代码库变得不健康。在设计接口时,你必须格外小心。试着遵循 Scott Meyers 的这个经验法则:

使接口易于正确使用,难以错误使用。(参见第一章中提到的最小惊奇法则的微妙陈述¹)

你希望开发者能够轻松使用你的类型,就好像一切都按照预期行为一样(这是最小惊奇法则的一个微妙的陈述)。此外,你还希望阻止用户错误地使用你的类型。你的工作是考虑在你的接口中应该支持和禁止的所有行为。为此,你需要深入了解你的合作者的思维。

以用户为中心思考

想象成用户思考是棘手的,因为你已经被赋予了知识的诅咒。这不是什么奥秘的咒语或神秘的咒文引起的,而是你在代码库中度过的时间的副产品。当你构建想法时,你变得如此熟悉它们,以至于可能会蒙蔽你如何看待新用户对你的代码的感知。处理认知偏见的第一步是承认它们的存在。从那时起,你可以在试图理解用户思维空间时考虑这些偏见。以下是一些有用的策略,你可以采用。

测试驱动开发

测试驱动开发(TDD),由肯特·贝克在 21 世纪初制定,是一种流行的测试代码框架。² TDD 围绕着一个简单的循环:

  • 添加一个失败的测试。

  • 只需写足够的代码来通过那个测试。

  • 重构。

有关 TDD 的整本书,我就不详细介绍了。³ 然而,TDD 的目的是了解如何使用一个类型,这是非常棒的。

许多开发者认为测试驱动开发(先写测试)与测试开发(后写测试)有类似的好处。无论哪种情况,你都有测试过的代码,对吧?当简化到这个程度时,TDD 似乎不值得付出这么多的努力。

然而,这是一个不幸的过度简化。混淆的根源在于把 TDD 看作是一种测试方法论,而实际上,它是一种设计方法论。测试很重要,但它们只是方法论的副产品。真正的价值在于测试如何帮助设计你的接口。

通过 TDD,你可以在编写实现之前看到调用代码的样子。因为你先写测试,所以你有机会停下来问自己,你与你的类型如何交互感觉是否无摩擦。如果你发现自己在做令人困惑的函数调用、构建长链依赖或者必须按固定顺序编写测试,那么这些都是应该警惕你正在构建的类型过于复杂的红旗。在这些情况下,重新评估或重构你的接口。你能在甚至写代码之前就简化它多么棒?

作为额外的好处,你的测试也可以作为文档的一种形式。其他开发者希望知道如何使用你的代码,尤其是那些没有在顶层文档中描述的部分。一套完整的单元测试提供了关于如何使用你的类型的工作文档;你希望它们给人留下良好的第一印象。正如你的代码是系统行为的唯一真实来源,你的测试是与你的代码交互的唯一真实来源。

基于 README 的开发

类似于 TDD,README 驱动开发(RDD),由Tom Preston-Werner 创造,是另一种旨在在编写代码之前捕捉难以使用的代码的设计方法论。RDD 的目标是将您的顶层思想和与代码的最重要交互精炼成一个单一文档,该文档位于您的项目中:README 文件。这是制定代码不同部分如何交互的好方法,并可能为用户提供更高级别的模式。

RDD 拥有以下一些好处:

  • 无需像瀑布模型中那样一次性创建每个层级的文档。

  • README 通常是开发者看到的第一件事情;RDD 给了你一个机会来打造尽可能好的第一印象。

  • 根据团队讨论更改文档要比更改已写好的代码容易。

  • 您不需要使用 README 来解释糟糕的代码决策;相反,代码需要变形以支持理想的用例。

记住,只有在未来的开发者能够真正维护软件时,你才能成功构建可维护的软件。给予他们尽可能多的成功机会,并为他们在文档开始时创造一种体验。

可用性测试

最终,您正在尝试考虑用户如何思考。有一个完全致力于这项任务的学科:用户体验(UX)。UX 是另一个有无数书籍可供选择的领域,所以我将专注于一个让我在简化代码方面取得显著成效的策略:可用性测试。

可用性测试是主动询问用户对您的产品的看法的过程。听起来很简单,对吧?为了考虑用户会如何行动,只需询问他们。您可以做的最简单的事情就是与潜在用户(在这种情况下,其他开发者)交谈,但很容易忽视。

通过走廊测试非常容易开始进行可用性测试。当您设计界面时,只需抓住第一个经过走廊的人,并请他们就设计给予反馈意见。这是学习痛点的一个很好的低成本方法。不过,不要太字面理解这个建议。请随时扩展到除了走廊上看到的人之外,并请队友、同事或测试人员评估您的界面。

然而,对于将由更广泛的受众使用的界面(例如流行的开源库的界面),您可能需要更正式一些。在这些情况下,可用性测试涉及将您的潜在用户放在您正在编写的界面前。您给他们一组任务来完成,然后观察。您的角色不是教导他们或引导他们完成练习,而是看到他们在哪里遇到困难,以及在哪些地方表现出色。从他们的困难中学习;他们展示了明显难以使用的区域。

提示

可用性测试对于团队中的初级成员来说是一个很好的任务。他们的知识诅咒不像高级成员那样强烈,他们更有可能用一双新的眼睛评估设计。

自然交互

唐纳德·诺曼(Donald Norman)将映射描述为“控件及其运动与现实世界结果之间的关系”。如果这种映射“利用物理类比和文化标准,[会导致]立即理解。”⁴ 这正是你在设计界面时所追求的。你希望那种即时理解消除混乱。

在这种情况下,“控制及其运动”是构成你界面的功能和类型。在这里,“现实世界的结果”代表代码的行为。为了使这感觉自然,操作必须符合用户的心理模型。这就是唐纳德·诺曼在谈到“物理类比和文化标准”时的意思。你必须以一种他们理解的方式与代码的读者联系,利用他们的经验和知识。将你的领域和其他常见知识映射到你的代码中是做到这一点的最佳方式。

在设计界面时,你需要考虑用户交互的整个生命周期,并问自己是否整体上与不熟悉你代码的用户的理解相匹配。模拟你的界面,使得对于熟悉该领域但不熟悉代码的人易于理解。当你这样做时,你的界面变得直观,这减少了开发人员犯错误的可能性。

自然界面的实际应用

对于本章,你将设计一个自动化杂货取货服务的部分界面。用户使用智能手机扫描其食谱,应用程序将自动确定所需的成分。用户确认订单后,应用程序查询本地杂货店的成分可用性并安排送货。图 11-1 提供了这个工作流程的表示。

我将专注于特定界面,以构建给定一组食谱的订单。

自动化杂货送货应用的工作流程

图 11-1. 自动化杂货送货应用的工作流程

为了表示一个食谱,我将修改来自第九章的Recipe dataclass的部分:

from dataclasses import dataclass
from enum import auto, Enum

from grocery.measure import ImperialMeasure

@dataclass(frozen=True)
class Ingredient:
    name: str
    brand: str
    amount: float = 1
    units: ImperialMeasure = ImperialMeasure.CUP

@dataclass
class Recipe:
    name: str
    ingredients: list[Ingredient]
    servings: int

代码库还具有用于获取本地杂货店库存的功能和类型:

import decimal
from dataclasses import dataclass
from typing import Iterable

from grocery.geospatial import Coordinates
from grocery.measure import ImperialMeasure

@dataclass(frozen=True)
class Store:
    coordinates: Coordinates
    name: str

@dataclass(frozen=True)
class Item:
    name: str
    brand: str
    measure: ImperialMeasure
    price_in_cents: decimal.Decimal
    amount: float

Inventory = dict[Store, List[Item]]
def get_grocery_inventory() -> Inventory:
    # reach out to APIs and populate the dictionary
    # ... snip ...

def reserve_items(store: Store, items: Iterable[Item]) -> bool:
    # ... snip ...

def unreserve_items(store: Store, items: Iterable[Item]) -> bool:
    # ... snip ...

def order_items(store: Store, item: items: Iterable[Item]) -> bool:
    # ... snip ...

代码库中的其他开发人员已经设置好代码,从智能手机扫描中找出食谱,但现在他们需要生成从每个杂货店订购的配料清单。这就是你的任务。以下是他们目前的情况:

recipes: List[Recipe] = get_recipes_from_scans()

# We need to do something here to get the order
order = ????
# the user can make changes if needed
display_order(order) # TODO once we know what an order is
wait_for_user_order_confirmation()
if order.is_confirmed():
    grocery_inventory = get_grocery_inventory()
    # HELP, what do we do with ingredients now that we have grocery inventory
    grocery_list =  ????
    # HELP we need to do some reservation of ingredients so others
    # don't take them
    wait_for_user_grocery_confirmation(grocery_list)
    # HELP - actually order the ingredients ????
    deliver_ingredients(grocery_list)

你的目标是填写标记为HELP????的空白。我希望你在开始编码之前刻意设计你的接口。你会如何向非技术产品经理或市场代理人描述代码的目的?在查看以下代码之前花几分钟:你希望用户如何与你的接口交互?

这是我想出的解决方案(解决这个问题的方法有很多;如果你有完全不同的东西,也没关系):

  1. 对于收到的每个食谱,获取所有原料并将它们聚合在一起。这就成为一个Order

  2. 一个Order是一组原料,用户可以根据需要添加/删除原料。但是,一旦确认,Order就不应该再被修改。

  3. 一旦订单确认,获取所有原料并找出哪些商店有这些物品可用。这是一个Grocery List

  4. Grocery List包含一系列商店和要从每个商店提取的物品。每个项目都在商店预订,直到应用程序下订单。物品可能来自不同的商店;应用程序会尝试找到与之匹配的最便宜的物品。

  5. 一旦用户确认GroceryList,就下订单。杂货商品将被取消预订并设置为送货。

  6. 订单送到用户家里。

注意

你是否觉得很神奇,你可以在不必知道get_recipe_from_scansget_grocery_inventory的具体实现方式的情况下想出一个实现?这就是使用类型描述领域概念的美妙之处:如果这些是由元组或字典表示的(或者没有类型注释,这让我感到恐惧),你将不得不在代码库中查找数据,找出你正在处理的数据是什么。

描述接口的内容中没有代码概念;所有内容都以熟悉的方式描述给杂货行业的工作人员。设计接口时,你希望尽可能自然地映射到领域中。

让我们从订单处理开始创建一个类:

from typing import Iterable, Optional
from copy import deepcopy
class Order:
    ''' An Order class that represents a list of ingredients '''
    def __init__(self, recipes: Iterable[Recipe]):
        self.__ingredients: set[Ingredient] = set()
        for recipe in recipes:
            for ingredient in recipe.ingredients:
                self.add_ingredient(ingredient)

    def get_ingredients(self) -> list[Ingredient]:
        ''' Return a alphabetically sorted list of ingredients '''
        # return a copy so that users won't inadvertently mess with
        # our internal data
        return sorted(deepcopy(self.__ingredients),
                        key=lambda ing: ing.name)

       def _get_matching_ingredient(self,
                                 ingredient: Ingredient) -> Optional[Ingredient]:
        try:
            return next(ing for ing in self.__ingredients if
                        ((ing.name, ing.brand) ==
                         (ingredient.name, ingredient.brand)))
        except StopIteration:
            return None

    def add_ingredient(self, ingredient: Ingredient):
        ''' adds the ingredient if it's not already added,
 or increases the amount if it has
 '''
        target_ingredient = self._get_matching_ingredient(ingredient)
        if target_ingredient is None:
            # ingredient for the first time - add it
            self.__ingredients.add(ingredient)
        else:
            # add ingredient to existing set
            ????

开始并不算太糟。如果我看一下上面描述的第一步,它与代码非常相似。我正在从每个食谱中获取原料,并将它们汇总到一起成为一个集合。我对如何表示向我已经跟踪的集合添加原料感到有些困扰,但我保证一会儿会回来解决这个问题。

目前,我想确保我正确地表示了Order的不变量。如果订单已确认,则用户不应该能够修改其中任何内容。我将更改Order类以执行以下操作:

# create a new exception type so that users can explicitly catch this error
class OrderAlreadyFinalizedError(RuntimeError):
    # inheriting from RuntimeError to allow users to provide a message
    # when raising this exception
    pass

class Order:
    ''' An Order class that represents a list of ingredients
 Once confirmed, it cannot be modified
 '''
    def __init__(self, recipes: Iterable[Recipe]):
        self.__confirmed = False
        # ... snip ...

    # ... snip ...

    def add_ingredient(self, ingredient: Ingredient):
        self.__disallow_modification_if_confirmed()
        # ... snip ...

    def __disallow_modification_if_confirmed():
        if self.__confirmed:
            raise OrderAlreadyFinalizedError('Order is confirmed -'
                                             ' changing it is not allowed')

    def confirm(self):
        self.__confirmed = True

    def unconfirm(self):
        self.__confirmed = False

    def is_confirmed(self):
        return self.__confirmed

现在我已经用代码表示了列表中的前两个项目,并且代码与描述非常相似。通过使用类型来表示Order,我已经为调用代码创建了一个接口。你可以用order = Order(recipes)构造一个订单,然后使用该订单添加原料,更改现有原料的数量,并处理确认逻辑。

唯一遗漏的是当添加一个我已经跟踪的成分时(例如额外添加 3 杯面粉),我需要????。我的第一反应是将数量加在一起,但如果计量单位不同就行不通,比如将 1 杯橄榄油加到 1 汤匙中。既不是 2 汤匙也不是 2 杯是正确答案。

我可以在代码中进行类型转换,但这并不自然。我真正想做的是像这样做一些事情:already_tracked_ingredient += new_ingredient。但这样做会导致异常:

TypeError: unsupported operand type(s) for +=: 'Ingredient' and 'Ingredient'

不过,这是可以实现的;我只需使用一点 Python 魔术就可以搞定。

魔术方法

魔术方法允许您在 Python 中调用内置操作时定义自定义行为。魔术方法由两个下划线前缀和后缀。因此,它们有时被称为dunder方法(或双下划线方法)。您已经在早期章节中看到了它们:

  • 在第十章中,我使用__init__方法来构建一个类。每次构造类时都会调用__init__

  • 在第九章中,我使用了__lt____gt__等方法来定义当两个对象用<或>进行比较时的行为。

  • 在第五章中,我介绍了__getitem__,用于拦截用括号进行索引的调用,例如recipes['Stromboli']

我可以使用魔术方法__add__来控制加法的行为:

@dataclass(frozen=True)
class Ingredient:
    name: str
    brand: str
    amount: float = 1
    units: ImperialMeasure = ImperialMeasure.CUP

    def __add__(self, rhs: Ingredient):
        # make sure we are adding the same ingredient
        assert (self.name, self.brand) == (rhs.name, rhs.brand)
        # build up conversion chart (lhs, rhs): multiplication factor
        conversion: dict[tuple[ImperialMeasure, ImperialMeasure], float] = {
            (ImperialMeasure.CUP, ImperialMeasure.CUP): 1,
            (ImperialMeasure.CUP, ImperialMeasure.TABLESPOON): 16,
            (ImperialMeasure.CUP, ImperialMeasure.TEASPOON): 48,
            (ImperialMeasure.TABLESPOON, ImperialMeasure.CUP): 1/16,
            (ImperialMeasure.TABLESPOON, ImperialMeasure.TABLESPOON): 1,
            (ImperialMeasure.TABLESPOON, ImperialMeasure.TEASPOON): 3,
            (ImperialMeasure.TEASPOON, ImperialMeasure.CUP): 1/48,
            (ImperialMeasure.TEASPOON, ImperialMeasure.TABLESPOON): 1/3,
            (ImperialMeasure.TEASPOON, ImperialMeasure.TEASPOON): 1
        }

        return Ingredient(rhs.name,
                          rhs.brand,
                          rhs.amount + self.amount * conversion[(rhs.units,
                                                                 self.units)],
                          rhs.units)

现在有了定义的__add__方法,我可以使用+运算符将成分相加。add_ingredient方法可以如下所示:

def add_ingredient(self, ingredient: Ingredient):
    '''Adds the ingredient if it's not already added,
 or increases the amount if it has '''

    target_ingredient = self._get_matching_ingredient(ingredient)
    if target_ingredient is None:
        # ingredient for the first time - add it
        self.__ingredients.add(ingredient)
    else:
        # add ingredient to existing set
        target_ingredient += ingredient

现在我可以自然地表达添加成分的想法了。这还不止于此。我还可以定义减法,或乘法/除法(用于扩展服务数量),或比较。当这些自然操作可用时,用户更容易理解您的代码库。Python 中几乎每个操作都有一个支持它的魔术方法。有很多方法,我甚至无法一一列举。不过,一些常见方法列在表 11-1 中。

表 11-1. Python 中常见的魔术方法

魔术方法 用途
__add__, __sub__, __mul__, __div__ 算术操作(加法、减法、乘法、除法)
__bool__ 隐式转换为布尔值用于if <expression>检查
__and__, __or__ 逻辑操作(andor
__getattr__, __setattr__, __delattr__ 属性访问(如obj.namedel obj.name
__le__, __lt__, __eq__, __ne__, __gt__, __ge__ 比较(<=<==!=>>=
__str__, __repr__ 转换为字符串(str())或可复制(repr())形式

如果你想了解更多,请查阅有关数据模型的 Python 文档。

讨论主题

你的代码库中有哪些类型可以从更自然的映射中受益?讨论魔术方法何时有意义,何时没有。

上下文管理器

您的代码现在可以处理订单,但是现在是填写杂货清单处理的另一半的时候了。我希望你停下来阅读,考虑填写杂货清单处理代码中的空白。从上一节中学到的内容,并创建一个自然映射到问题描述的接口。

这里是处理杂货清单的提醒:

  1. Grocery List包含一系列商店和从每个商店提取的项目。每个项目在商店中保留,直到应用程序下订单。商品可能来自不同的商店;应用程序尝试找到符合条件的最便宜的商品。

  2. 一旦用户确认了GroceryList,就下单了。杂货商品取消预订并设置为送货。

从调用代码的角度来看,这是我拥有的:

order = Order(recipes)
# the user can make changes if needed
display_order(order)
wait_for_user_order_confirmation()
if order.is_confirmed():
    grocery_inventory = get_grocery_inventory()
    grocery_list =  GroceryList(order, grocery_inventory)
    grocery_list.reserve_items_from_stores()
    wait_for_user_grocery_confirmation(grocery_list)
    if grocery_list.is_confirmed():
        grocery_list.order_and_unreserve_items()
        deliver_ingredients(grocery_list)
    else:
        grocery_list.unreserve_items()

鉴于这个杂货清单接口,这肯定很容易使用(如果我这么说的话)。清楚代码正在做什么,如果使接口直观成为完整故事的一部分,我会很成功。但我忘了 Scott Meyers 的引用的另一半。我忘了让代码难以使用不正确

再看看。如果用户不确认他们的订单会发生什么?如果在等待时抛出了某些异常怎么办?如果发生这种情况,我永远不会取消预订的商品,使其永久保留。当然,我可以希望调用代码总是尝试捕获异常,但这很容易忘记做。事实上,它可能非常容易使用不正确,你同意吗?

提示

您不能只关注快乐路径,即一切按计划进行的代码执行。您的接口还必须处理可能出现问题的所有可能方式。

当您完成操作后自动调用某种功能是 Python 中的常见情况。文件打开/关闭,会话身份验证/注销,数据库命令批处理/提交;这些都是您希望始终确保调用第二个操作的例子,无论以前的代码做了什么。如果不这样做,您经常会泄漏资源或以其他方式占用系统。

很可能,您实际上已经了解如何处理此问题:使用with块。

with open(filename, "r") as handle:
    print(handle.read())
# at this point, the with block has ended, closing the file handle

这是你在 Python 旅程的早期学习中作为最佳实践学到的东西。一旦with块完成(当代码返回到with语句的原始缩进级别时),Python 关闭打开的文件。这是确保操作发生的便捷方式,即使没有明确的用户交互。这是使您的杂货清单接口难以不正确使用的关键—无论代码走哪条路径,都可以使杂货清单自动取消预订商品的方式。

要做到这一点,您需要使用上下文管理器,这是 Python 的一种构造,允许您利用with块。使用上下文管理器,我可以使我们的杂货清单代码更加容错:

from contextlib import contextmanager

@contextmanager
def create_grocery_list(order: Order, inventory: Inventory):
    grocery_list = _GroceryList(order, inventory)
    try:
        yield grocery_list
    finally:
        if grocery_list.has_reserved_items():
            grocery_list.unreserve_items()

任何用@contextmanager装饰的函数都可以与with块一起使用。我构造了一个_GroceryList(注意它是私有的,因此没有人应该以create_grocery_list之外的方式创建杂货清单),然后yield它。yield 一个值会中断这个函数,将 yield 的值返回给调用代码。然后用户可以这样使用它:

# ... snip ...
if order.is_confirmed():
    grocery_inventory = get_grocery_inventory()
    with create_grocery_list(order, grocery_inventory) as grocery_list:
        grocery_list.reserve_items_from_stores()
        wait_for_user_grocery_confirmation(grocery_list)
        grocery_list.order_and_unreserve_items()
        deliver_ingredients(grocery_list)

在上面的示例中,yielded 的值变成了grocery_list。当with块退出时,执行会返回到上下文管理器,就在 yield 语句之后。无论是否抛出异常,或者with块是否正常结束;因为我将我们的 yield 包装在了一个try...finally块中,杂货清单总是会清除任何保留的项目。

这是如何有效地强制用户在自己之后进行清理的方法。通过使用上下文管理器,您消除了可能发生的整个错误类别——遗漏错误。遗漏错误非常容易发生;您只需什么都不做。相反,上下文管理器让用户在什么都不做时也能做正确的事情。当用户即使在不知情的情况下也能做正确的事情时,这表明代码库是稳健的。

警告

如果程序被强制关闭,例如操作系统的强制终止或断电,上下文管理器将无法完成。上下文管理器只是一个工具,用来防止开发人员忘记在自己之后进行清理;确保您的系统仍然可以处理开发人员无法控制的情况。

总结思考

您可以创建世界上所有类型,但如果其他开发人员在没有错误的情况下无法使用它们,您的代码库将受到影响。就像房子需要坚实的基础才能站立一样,您创建的类型和围绕它们的词汇需要为您的代码库提供坚实的支持。当您的代码有自然的接口时,未来的开发人员将能够轻松地获取这些类型并构建新功能。为那些未来的开发人员怀有同情心,并精心设计您的类型。

您需要仔细考虑您的类型代表的领域概念,以及用户如何与这些类型交互。通过建立自然映射,将真实世界的操作与您的代码库联系起来。您构建的接口应该感觉直观;记住,它们应该易于正确使用并难以错误使用。利用您掌握的所有技巧和窍门,从正确的命名到魔术方法到上下文管理器。

在下一章中,我将讲解当你创建子类型时,类型之间如何关联。子类型是专门化类型接口的一种方式;它们允许在不修改原始类型的情况下进行扩展。对现有代码的任何修改都可能引起回归,因此能够创建新类型而无需更改旧类型,可以显著减少异常行为。

¹ 凯夫林·亨尼(Kevlin Henney)和斯科特·迈耶斯(Scott Meyers)。《97 件每个程序员都应该知道的事:专家集体智慧》。Sebastopol: O’Reilly Media, 2010。

² Kent Beck。《测试驱动开发:实战与模式》。Upper Saddle River, NJ: Addison-Wesley Professional, 2002。

³ 如果你想要更多信息,我推荐哈里·珀西瓦尔(Harry Percival)的《Python 测试驱动开发》(O’Reilly, 2017)。

⁴ 这段来自唐纳德·诺曼(Donald Norman)的《设计心理学》(Basic Books)。这本经典著作对于想要进入用户体验(UX)思维的人至关重要。

第十二章:子类型

Part II 的大部分内容集中在创建自己的类型和定义接口上。这些类型不是孤立存在的;类型通常彼此相关联。到目前为止,您已经看到了 组合,其中类型使用其他类型作为成员。在本章中,您将了解 子类型,即基于其他类型创建类型。

当正确应用时,子类型使得扩展代码库变得非常容易。您可以引入新的行为,而无需担心破坏代码库的其他部分。但是,在创建子类型关系时必须非常小心;如果处理不当,可能会以意想不到的方式降低代码库的健壮性。

我将从最常见的子类型关系之一开始:继承。继承被视为面向对象编程(OOP)的传统支柱之一。¹ 如果不正确应用,继承可能会很棘手。然后,我将进一步介绍 Python 编程语言中存在的其他形式的子类型。您还将了解到基本的 SOLID 设计原则之一,即里斯科夫替换原则。本章将帮助您理解何时以及何地适合使用子类型,以及何时不适合。

继承

当大多数开发者谈论子类型时,他们立即想到继承。继承 是一种从另一个类型创建新类型的方式,将所有行为复制到新类型中。这种新类型称为子类派生类衍生类。相反,被继承的类型称为父类基类超类。在这种类型的讨论中,我们说这种关系是一种 is-a 关系。派生类的任何对象也是基类的实例。

为了说明这一点,你将设计一个帮助餐厅业主组织运营的应用程序(跟踪财务、定制菜单等)。对于这种情况,餐厅具有以下行为:

  • 一个餐厅具有以下属性:名称、位置、员工及其排班表、库存、菜单和当前财务状况。所有这些属性都是可变的;甚至餐厅的名称或位置也可以更改。当餐厅更改位置时,其位置属性将反映其最终目的地。

  • 一个所有者可以拥有多个餐厅。

  • 员工可以从一个餐厅调动到另一个餐厅,但不能同时在两个餐厅工作。

  • 当点菜时,使用的成分将从库存中移除。当库存中的特定项目用尽时,需要该成分的任何菜品将不再通过菜单提供。

  • 每当销售一个菜单项目时,餐厅的资金增加。每当购买新的库存时,餐厅的资金减少。每个员工在餐厅工作的每个小时,餐厅的资金都会根据员工的工资和/或时薪减少。

餐馆业主将使用此应用程序查看他们所有的餐馆,管理库存,并实时跟踪利润。

由于餐馆有特定的不变量,我将使用一个类来代表一个餐馆:

from restaurant import geo
from restaurant import operations as ops
class Restaurant:
    def __init__(self,
                 name: str,
                 location: geo.Coordinates,
                 employees: list[ops.Employee],
                 inventory: list[ops.Ingredient],
                 menu: ops.Menu,
                 finances: ops.Finances):
        # ... snip ...
        # note that location refers to where the restaurant is located when
        # serving food

    def transfer_employees(self,
                           employees: list[ops.Employee],
                           restaurant: 'Restaurant'):
        # ... snip ...

    def order_dish(self, dish: ops.Dish):
        # ... snip ..

    def add_inventory(self, ingredients: list[ops.Ingredient],
                      cost_in_cents: int):
        # ... snip ...

    def register_hours_employee_worked(self,
                                       employee: Employee,
                                       minutes_worked: int):
        # ... snip ...

    def get_restaurant_data(self) -> ops.RestaurantData:
        # ... snip ...

    def change_menu(self, menu: ops.Menu):
        self.__menu = menu

    def move_location(self, new_location: geo.Coordinates):
        # ... snip ...

除了上述描述的“标准”餐馆外,还有几家“专业化”的餐馆:食品车和流动小吃摊。

食品车是移动的:它们到不同的地点行驶,并根据场合更改菜单。流动小吃摊是短暂的;它们出现在有限的时间内,并提供有限的菜单(通常是某种事件,如节日或集市)。尽管它们在运作方式上略有不同,但食品车和流动小吃摊仍然是餐馆。这就是我所说的“is-a”关系的含义——食品车“是一个”餐馆,流动小吃摊“是一个”餐馆。因为这是一个“is-a”关系,继承是一个适当的构造来使用。

在定义派生类时,通过指定基类来表示继承:

class FoodTruck(Restaurant):
    #... snip ...

class PopUpStall(Restaurant):
    # ... snip ...

图 12-1 展示了这种关系通常如何绘制。

餐馆的继承树

图 12-1. 餐馆的继承树

通过以这种方式定义继承,确保派生类将继承基类的所有方法和属性,而无需重新定义它们。

这意味着,如果您实例化其中一个派生类,比如 FoodTruck,您将能够使用与与 Restaurant 交互时相同的所有方法。

food_truck = FoodTruck("Pat's Food Truck", location, employees,
                       inventory, menu, finances)
food_truck.order_dish(Dish('Pasta with Sausage'))
food_truck.move_location(geo.find_coordinates('Huntsville, Alabama'))

这个方法的优点在于,派生类可以传递给期望基类的函数,类型检查器不会抱怨一点:

def display_restaurant_data(restaurant: Restaurant):
    data = restaurant.get_restaurant_data()
    # ... snip drawing code here ...

restaurants: list[Restaurant] = [food_truck]
for restaurant in restaurants:
    display_restaurant_data(restaurant)

默认情况下,派生类的行为与基类完全相同。如果希望派生类执行不同的操作,可以重写方法或在派生类中重新定义方法。

假设我希望我的食品车在位置更改时自动驶向下一个位置。但对于这种用例,当请求餐馆数据时,我只想要最终的位置,而不是食品车在路上的位置。开发人员可以调用一个单独的方法来显示当前位置(用于单独的食品车地图)。我将在 FoodTruck 的构造函数中设置 GPS 定位器,并重写 move_location 来启动自动驾驶:

from restaurant.logging import log_error
class FoodTruck(Restaurant):
    def __init__(self,
                 name: str,
                 location: geo.Coordinates,
                 employees: list[ops.Employee],
                 inventory: list[ops.Ingredient],
                 menu: ops: Menu,
                 finances: ops.Finances):
        super().__init__(name, location, employees,inventory, menu, finances)
        self.__gps = initialize_gps()

    def move_location(self, new_location: geo.Coordinates):
        # schedule a task to drive us to our new location
        schedule_auto_driving_task(new_location)
        super().move_location(new_location)

    def get_current_location(self) -> geo.Coordinates:
        return self.__gps.get_coordinates()

我正在使用一个特殊的函数,super(),来访问基类。当我调用 super().__init__() 时,实际上是在调用 Restaurant 的构造函数。当我调用 super().move_location 时,我在调用 Restaurantmove_location,而不是 FoodTruckmove_location。这样,代码可以完全像基类一样运行。

请花一点时间思考通过子类扩展代码的影响。您可以向现有代码中插入新的行为,而无需修改该现有代码。如果避免修改现有代码,则大大减少引入新错误的机会;如果不更改消费者依赖的代码,您也不会无意中打破他们的假设。良好设计的继承结构可以极大提高可维护性。不幸的是,反之亦然;设计不良的继承会导致可维护性下降。在使用继承时,您始终需要考虑代码替代的简易性。

可替代性

正如前面所述,继承关系涉及建模是一个关系。描述具有是一个关系的事物可能听起来很简单,但实际上可能出现很多意外情况。要正确建模是一个关系,您需要理解可替代性。

可替代性表明,当您从基类派生时,您应该能够在每个使用基类的实例中使用该派生类。

如果我创建一个可以显示相关餐厅数据的函数:

def display_restaurant(restaurant: Restaurant):
    # ... snip ...

我应该能够传递一个Restaurant、一个FoodTruck或一个PopUpStall,而这个函数不应该感知到任何不同。这再次听起来很简单;有什么难点吗?

的确有一个难点。为了向您展示,我想暂时摆脱食品概念,回到任何一年级学生都应该能回答的基本问题:一个正方形是矩形吗?

从您上学的早期,您可能知道答案是“是的,正方形是矩形”。矩形是一个有四条边的多边形,两条边的交点是 90 度角。正方形也是如此,但额外要求每条边的长度必须完全相同。

如果我用继承来建模这个问题,可能会这样做:

class Rectangle:
    def __init__(self, height: int, width: int):
        self._height = height
        self._width = width

    def set_width(self, new_width):
        self._width = new_width

    def set_height(self, new_height):
        self._height = new_height

    def get_width(self) -> int:
        return self._width

    def get_height(self) -> int:
        return self._height

class Square(Rectangle):
    def __init__(self, length: int):
        super().__init__(length, length)

    def set_side_length(self, new_length):
        super().set_width(new_length)
        super().set_height(new_length)

    def set_width(self, new_width):
        self.set_side_length(new_width)

    def set_height(self, new_height):
        self.set_side_length(new_height)

所以是的,从几何学的角度来看,正方形确实是矩形。但是,将这种假设映射到是一个关系时是有缺陷的。花几分钟时间看看我的假设在哪里崩溃。

还看不出来?这里有个提示:如果我问你正方形是否可以在所有用例中替代矩形,你能构造一个正方形无法替代矩形的用例吗?

假设应用程序的用户在餐厅地图上选择正方形和矩形以评估市场规模。用户可以在地图上绘制一个形状,然后根据需要扩展它。处理此功能之一如下:

def double_width(rectangle: Rectangle):
    old_height = rectangle.get_height()
    rectangle.set_width(rectangle.get_width() * 2)
    # check that the height is unchanged
    assert rectangle.get_height() == old_height

使用这段代码,如果我将一个Square作为参数传递会发生什么?突然间,之前通过的断言将开始失败,因为当长度改变时,正方形的高度也会改变。这是灾难性的;继承的整个意图是在不破坏现有代码的情况下扩展功能。在这种情况下,通过传递一个Square(因为它也是一个Rectangle,类型检查器不会抱怨),我引入了一个等待发生的错误。

这种错误也会影响派生类。上述错误源于在Square中重写set_width,以便同时更改高度。如果没有重写set_width,而是调用了Rectangleset_width function,会发生什么?如果是这样的话,并且你把一个Square传递给函数,断言将不会失败。相反,会发生一些不那么明显但更为有害的事情:函数成功执行。你不再收到带有堆栈跟踪导致错误的AssertionError。现在,你创建了一个不再是正方形的正方形;宽度已经改变,但高度没有变。你犯了一个基本错误,并且破坏了该类的不变量。

使得这种错误如此阴险的是继承的目标是为了解耦或者说移除现有代码和新代码之间的依赖关系。基类的实现者和使用者在运行时看不到不同的派生类。也许派生类的定义存在于完全不同的代码库中,由不同的组织拥有。在这种错误情况下,你会使得每次派生类发生变化时,都需要查看基类的每个调用和使用,并评估你的更改是否会破坏代码。

为了解决这个问题,你有几个可选的方法。首先,你可以根本不让SquareRectangle继承,避免整个问题。其次,你可以限制Rectangle的方法,使得Square不会与其矛盾(比如使字段不可变)。最后,你可以完全取消类的层次结构,并在Rectangle中提供一个is_square方法。

这些错误可能会以微妙的方式破坏你的代码库。考虑这样一个使用案例:我想要给我的餐馆加盟; 加盟商可以创建自己的菜单,但必须始终具有一组共同的菜肴。

这里是一个潜在的实现方式:

class RestrictedMenuRestaurant(Restaurant):

    def __init__(self,
                 name: str,
                 location: geo.Coordinates,
                 employees: list[ops.Employee],
                 inventory: list[ops.Ingredient],
                 menu: ops.Menu,
                 finances: ops.Finances,
                 restricted_items: list[ops.Ingredient]):
        super().__init__(name,location,employees,inventory,menu,finances)
        self.__restricted_items = restricted_items

    def change_menu(self, menu: ops.Menu):
        if any(not menu.contains(ingredient)
               for ingredient in self.__restricted_items):
            # new menus MUST contain restricted ingredients
            return super().change_menu(menu)

在这种情况下,如果新菜单中缺少任何限制项,函数会提前返回。一个看似合理的单独行为放在继承层次结构中完全会出现问题。请设身处地地换位思考,想象一下另一位开发人员,他想要实现应用程序中更改菜单的 UI。他看到了一个Restaurant类,并根据这个接口编写代码。当一个RestrictedMenuRestaurant不可避免地被用作Restaurant的替代品时,UI 将尝试更改菜单,但没有任何迹象表明更新实际上没有发生。唯一能更早发现这个错误的方法是开发人员搜索代码库,寻找破坏不变量的派生类。如果这本书有一个主题,那就是每当开发人员不得不搜索代码库以理解其中一部分代码时,这都是代码脆弱性的明显迹象。

如果我写的代码抛出异常而不是简单返回会怎么样呢?不幸的是,这也解决不了任何问题。现在,当用户更改Restaurant的菜单时,他们可能会收到一个异常。如果他们查看Restaurant类的代码,就看不到他们需要考虑异常的迹象。他们也不应该偏执地每次调用都包装在try...except块中,担心某个地方的派生类可能会抛出异常。

在这两种情况下,当一个类继承自一个基类但其行为并不完全与基类一致时,就会引入微妙的错误。这些错误需要特定的条件组合才会发生:代码必须在基类上执行方法,必须依赖于基类的特定行为,并且一个破坏该行为的派生类被用作基类的替代。棘手的是,这些条件中的任何一个都可能在原始代码编写后的很长时间内引入。这就是为什么可替换性如此重要。实际上,可替换性的重要性体现在一个非常重要的原则中:里斯科夫替换原则。

里斯科夫替换原则(Liskov Substitution Principle),以芭芭拉·里斯科夫命名,陈述如下:²

子类型要求:让Φ(X)是对类型T的对象X可证明的一个属性。那么对于类型S的对象Y(其中ST的一个子类型),Φ(Y)也应该为真。

不要让正式的符号表达吓到你。LSP(Liskov Substitution Principle)其实非常简单:为了存在一个子类型,它必须遵循与超类型相同的所有属性(行为)。这一切都归结为可替换性。每当你考虑超类型的属性及其对子类型意味着什么时,你都应该牢记 LSP。在使用继承设计时,请考虑以下几点:

不变量

第十章 主要关注不变性(关于类型的真理,不得违反)。当您从其他类型进行子类型化时,子类型必须保持所有不变性。当我从Rectangle中的Square进行子类型化时,我忽略了高度和宽度可以独立设置的不变性。

前置条件

前置条件是在与类型属性交互之前必须为真的任何条件(如调用函数)。如果超类型定义了发生的前置条件,则子类型不得更为严格。这就是当我从Restaurant中的RestrictedMenuRestaurant进行子类型化时发生的情况。我添加了一个额外的前置条件,即在更改菜单时某些成分是强制性的。通过抛出异常,我使先前的良好数据现在失败了。

后置条件

后置条件是与类型属性交互后必须为真的任何条件。如果超类型定义了后置条件,则子类型不得削弱这些后置条件。如果任何保证未满足,则后置条件被削弱。当我从Restaurant中的RestrictedMenuRestaurant中派生出来并且返回早期而不是更改菜单时,我违反了后置条件。基类保证了一个后置条件,即菜单将被更新,而不管菜单内容如何。当我像我做的那样派生子类时,我再也不能保证这个后置条件。

如果您在重写函数中的任何时候违反不变性、前置条件或后置条件,您将会引发错误。以下是我在评估继承关系时在派生类的重写函数中寻找的一些警告信号:

条件检查参数

确定前置条件是否更为严格的一个好方法是查看函数开始处是否有任何检查传入参数的if语句。如果有,那么它们很可能与基类的检查不同,通常意味着派生类正在进一步限制参数。

早期返回语句

如果子类型的函数在中间返回(在函数块的中部),这表示后续部分不会执行。检查后续部分是否有任何后置条件保证;您不希望通过早期返回而省略它们。

抛出异常

子类型应该只抛出与超类型完全匹配或派生异常类型的异常。如果有任何不同的异常,调用者将不会预期它们,更不用说写代码来捕获它们。如果在基类根本不指示可能发生异常的情况下抛出异常,则情况更糟。我见过的最严重的违反是抛出NotImplementedError异常(或类似异常)。

没有调用super()

根据可替换性的定义,子类型必须提供与超类型相同的行为。如果您的子类型在重写的函数中没有调用super(),那么您的子类型与代码中该行为没有定义关系。即使您将超类型的代码复制粘贴到您的子类型中,也不能保证这些代码保持同步;开发者可能对超类型的函数进行无害的更改,甚至没有意识到需要同时更改的子类型。

在建模具有继承性的类型时,您需要格外小心。任何错误都可能引入微妙的错误,可能会产生灾难性的影响。在设计继承时,务必格外谨慎。

讨论主题

您是否在代码库中遇到过任何红旗?在从其他类继承时,是否导致意外行为?讨论为何这些打破了假设,并在这些情况下可能发生的错误。

设计考虑

每当编写打算派生的类时,请采取预防措施。您的目标是尽可能地使其他开发者编写派生类变得容易。以下是编写基类的几条指导原则(稍后我将讨论派生类的指导原则)。

不要更改不变量

通常情况下,改变不变量本身就是一个不好的想法。无数的代码片段可能依赖于您的类型,改变不变量将打破对您代码的假设。不幸的是,如果基类更改了不变量,派生类也可能会受到影响。如果必须更改基类,请尽量仅添加新功能,而不是修改现有功能。

当将不变量与受保护字段绑定时需要特别小心。

受保护字段本质上是供派生类交互的。如果将不变量与这些字段绑定,您基本上在限制应调用哪些操作。这会造成其他开发者可能意识不到的紧张局势。最好将不变量保留在私有数据中,并强制派生类通过公共或受保护的方法与该私有数据进行交互。

记录您的不变量

这是帮助其他开发者的最重要的事情之一。虽然有些不变量可以在代码中表示(如第十章所示),但有些不变量无法通过计算机进行数学证明,比如关于异常是否抛出的保证。在设计基类时,您必须记录这些不变量,并且要确保派生类可以轻松发现它们,比如在文档字符串中。

最终,派生类有责任遵守基类的不变量。如果您正在编写一个派生类,请遵循以下准则:

熟悉基类的不变量

没有了解不变性,就不能正确地编写派生类。你的工作是了解所有基类的不变性以保持它们。查看代码、文档和与类相关的所有内容,以了解你应该做什么和不应该做什么。

扩展基类中的功能

如果需要编写与当前不变性不一致的代码,你可能需要将该功能放在基类中。举个例子,不支持可重写方法的情况。你可以在基类中创建一个布尔标志,指示基类中是否支持该功能,而不是抛出NotImplementedError。如果这样做,注意本章前面所有修改基类的准则。

每个重写的方法应该包含super()

如果在重写的方法中不调用super(),那么你不能保证你的子类行为与基类完全一致,特别是如果将来基类发生任何变化。如果你要重写一个方法,请确保调用super()。唯一可以不这样做的时候是基类方法是空的(比如一个抽象基类),并且你确信它会在代码库的余生中保持空白。

组合

了解何时不使用继承也很重要。我见过的最大错误之一是仅仅为了代码复用而使用继承。不要误会,继承是重用代码的好方法,但继承的主要目的是建模一个子类型可以替代超类型的关系。如果你在代码中从不与子类型交互,那么你并没有建模一个 is-a 关系。

在这种情况下,你希望使用组合,也被称为 has-a 关系。组合 是将成员变量放在类型内部的情况。我主要使用组合来组合类型在一起。例如,之前提到的餐馆:

class Restaurant:
    def __init__(self,
                 name: str,
                 location: geo.Coordinates,
                 employees: list[ops.Employee],
                 inventory: list[ops.Ingredient],
                 menu: ops: Menu,
                 finances: ops.Finances):
        self.name = name
        self.location = location
        self.employees = employees
        # ... etc etc snip snip ...

讨论主题

在你的代码库中,你是否过度使用了继承?你是否仅仅因为重用而使用它?讨论如何转换为使用组合而不是继承。

构造函数中设置的每个成员字段都是组合的一个示例。一个Restaurant可以替代一个Menuis-a 关系)是没有意义的,但一个餐馆包含一个菜单(has-a 关系)是有意义的,以及其他一些东西。每当你需要重用代码但不会替代类型时,你应该更倾向于使用组合而不是继承。

作为一种重用机制,组合优于继承,因为它是一种更弱的 耦合 形式,耦合是实体之间的依赖关系的另一个术语。其他一切相等时,你希望有更弱的耦合形式,因为这样更容易重新组织类和重构功能。如果类之间的耦合度高,其中一个的变化会直接影响另一个的行为。

注意

Mixins 是一个例外,不推荐组合优于继承,因为它们是专门设计为被继承以提供对类型接口的补充。

在继承中,派生类受制于基类的更改。开发人员必须注意不仅公共接口的变化,还有不变量和受保护成员的变化。相比之下,当另一个类拥有你的类的实例时,该类仅受到一部分变化的影响:那些影响其依赖的公共方法和不变量的变化。通过限制变化的影响,你减少了破坏性假设的机会,降低了代码的脆弱性。要编写健壮的代码,请谨慎使用继承。

总结思考

子类型化关系在编程中是一个非常强大的概念。你可以利用它们扩展现有功能而不修改它。然而,继承往往被滥用或者使用不当。只有当子类型能够直接替代其超类型时才应使用子类型。如果情况不是这样,请考虑使用组合。

引入超类型或子类型时需要特别小心。开发人员可能不容易知道与单个超类型相关联的所有子类型;有些子类型甚至可能存在于其他代码库中。超类型和子类型非常紧密地耦合在一起,因此在进行任何更改时都要谨慎。通过适当的勤勉性,你可以在不引入一大堆问题的情况下享受子类型化带来的所有好处。

在下一章中,我将专注于子类型化的一个特定应用,即协议。这些协议是类型检查器和鸭子类型之间的缺失连接。协议以重要的方式弥合了这一差距:它们帮助你的类型检查器捕捉到在超类型/子类型关系中引入的一些错误。任何时候你通过类型检查器捕获更多的错误,尤其是这些错误,都有助于代码库的稳健性。

¹ 面向对象编程是一种编程范式,它围绕封装的数据及其行为组织代码。如果你想了解面向对象编程,我建议阅读 Head First Object-Oriented Analysis and Design,作者是布雷特·麦克劳林、加里·波利斯和戴夫·韦斯特(O'Reilly)。

² Barbara H. Liskov 和 Jeannette M. Wing。《子类型化的行为概念》。ACM Trans. Program. Lang. Syst. 16, 6(1994 年 11 月),1811–41. https://doi.org/10.1145/197320.197383

第十三章:协议

我有一个坦白要做。在 Python 类型系统中,我一直在绕开一个东西,乍一看,它是矛盾的。这涉及到 Python 运行时类型系统与静态类型提示之间哲学上的一个关键区别。

回到第二章,我描述了 Python 如何支持鸭子类型。记住,这意味着只要对象支持特定的一组行为,你就可以在上下文中使用它们。你不需要任何父类或预定义的继承结构来使用鸭子类型。

然而,类型检查器在没有任何帮助的情况下无法处理鸭子类型。类型检查器知道如何处理静态分析时已知的类型,但它如何处理运行时做出的鸭子类型决策呢?

为了解决这个问题,我打算介绍协议,这是 Python 3.8 中引入的一个特性。协议解决了上述列出的矛盾;它们在类型检查期间为鸭子类型的变量做注释。我将讨论为什么你需要协议,如何定义你自己的协议以及如何在高级场景中使用它们。但在开始之前,你需要理解 Python 的鸭子类型和静态类型检查器之间的差异。

类型系统之间的张力

在本章中,你将建立一个自动化午餐店的数字菜单系统。这家餐厅有各种可以“分割”的条目,意味着你可以点半份。三明治、卷饼和汤可以分割,但饮料和汉堡等条目不能分割。为了去重,我希望有一个方法可以完成所有的分割。这里有一些条目作为例子:

class BLTSandwich:
    def __init__(self):
        self.cost = 6.95
        self.name = 'BLT'
        # This class handles a fully constructed BLT sandwich
        # ...

    def split_in_half(self) -> tuple['BLTSandwich', 'BLTSandwich']:
        # Instructions for how to split a sandwich in half
        # Cut along diagonal, wrap separately, etc.
        # Return two sandwiches in return

class Chili:
    def __init__(self):
        self.cost = 4.95
        self.name = 'Chili'
        # This class handles a fully loaded chili
        # ...

    def split_in_half(self) -> tuple['Chili', 'Chili']:
        # Instructions for how to split chili in half
        # Ladle into new container, add toppings
        # Return two cups of chili in return
        # ...

class BaconCheeseburger:
    def __init__(self):
        self.cost = 11.95
        self.name = 'Bacon Cheeseburger'
        # This class handles a delicious Bacon Cheeseburger
        # ...

    # NOTE! no split_in_half method

现在,分割方法可能看起来像这样:

import math
def split_dish(dish: ???) -> ????:
    dishes = dish.split_in_half()
    assert len(dishes) == 2
    for half_dish in dishes:
        half_dish.cost = math.ceil(half_dish.cost) / 2
        half_dish.name = "½ " + half_dish.name
    return dishes

参数order应该被定义为什么类型?记住,类型是一组行为,不一定是具体的 Python 类型。我可能没有为这组行为取名字,但我确实希望能够保持它们。在这个例子中,类型必须具有以下行为:

  • 类型必须有一个名为split_in_half的函数。它必须返回包含两个对象的可迭代集合。

  • split_in_half返回的每个对象必须有一个名为cost的属性。这个cost必须能够被取天花板函数并且能够整除以 2。这个cost必须是可变的。

  • split_in_half返回的每个对象必须有一个名为name的属性。这个name必须允许在其前面加上文本"½ "。这个name必须是可变的。

一个Chili或者BLTSandwich对象将完全作为子类型工作,但BaconCheeseburger则不会。BaconCheeseburger没有代码正在寻找的结构。如果你试图传入BaconCheeseburger,你将收到一个AttributeEr⁠ror,指明BaconCheeseburger没有名为split_in_half()的方法。换句话说,BaconCheeseburger的结构与预期类型不匹配。实际上,这就是鸭子类型赢得其另一个名称的地方:结构子类型,或者基于结构的子类型。

相比之下,在本书的这一部分中你一直在探索的大部分类型提示被称为名义子类型。这意味着具有不同名称的类型是彼此分离的。你看到问题了吗?这两种子类型相互对立。一种是基于类型名称,另一种是基于结构。为了在类型检查期间捕捉错误,你需要想出一个命名类型:

def split_dish(dish: ???) -> ???:

所以,再问一次,参数应该被标记为什么类型?我列出了一些选项如下。

留空类型或使用任何类型

def split_dish(dish: Any)

我无法认可这一点,尤其是在一本关于健壮性的书中。这不向未来的开发者传达任何意图,而且类型检查器无法检测常见的错误。继续。

使用联合类型

def split_dish(dish: Union[BLTSandwich, Chili])

啊,这比留空好一些。一个订单可以是BLTSandwichChili。对于这个有限的示例,它确实有效。但是,你可能会觉得这有点不对劲。我需要找出如何调和结构子类型和名义子类型,而我所做的只是在类型签名中硬编码了几个类。

更糟糕的是,这种方法很脆弱。每当有人需要添加一个可分割的类时,他们必须记得更新这个函数。你只能希望这个函数在类定义附近,以便未来的维护者可能会碰巧发现它。

这里还有另一个潜在的危险。如果这个自动午餐制造机是一个库,用于由不同供应商的自动售货亭使用?它们可能会引入这个午餐制造库,制作他们自己的类,并在这些类上调用split_dish。在库代码中定义了split_dish,消费者几乎没有合理的方法可以让他们的代码通过类型检查。

使用继承

有些有经验的面向对象语言(如 C++或 Java)的用户可能会喊道,这里适合使用接口类。让这两个类都继承自某个基类,该基类定义了你想要的方法将是很简单的。

class Splittable:
    def __init__(self, cost, name):
        self.cost = cost
        self.name = name

    def split_in_half(self) -> tuple['Splittable', 'Splittable']:
        raise NotImplementedError("Must implement split in half")

class BLTSandwich(Splittable):
    # ...

class Chili(Splittable):
    # ...

这种类型层次结构在图 13-1 中有所体现。

可分割类型的类型层次结构

图 13-1. 可分割类型的类型层次结构

而且这确实有效:

def split_dish(dish: Splittable):

实际上,你甚至可以注释返回类型:

def split_dish(dish: Splittable) ->
    tuple[Splittable, Splittable]:

但如果存在更复杂的类层次结构呢?如果您的类层次结构看起来像图 13-2 那样复杂?

一个更复杂的类型层次结构

图 13-2. 一个更复杂的类型层次结构

现在,您面临一个棘手的决定。您应该将 Splittable 类放在类型层次结构的哪里?您不能把它放在树的父级;并非每种餐品都应该可以分割。您可以将 Splittable 类制作成一个 SplittableLunch 类,并将其插入到 Lunch 和任何可分割的类之间,如图 13-3 所示。

ropy 1303

图 13-3. 一个更复杂的类型层次结构,其中插入了 Splittable

随着代码库的增长,这种方法将会失效。首先,如果您希望在其他地方使用 Splittable(比如晚餐、账单或其他任何东西),您将不得不复制该代码;没有人想要一个从 SplittableLunch 继承的账单系统。此外,Splittable 可能不是您想要引入的唯一父类。您可能还有其他属性,比如能够共享主菜、提供外带服务、指定允许替代等等。随着每个选项的引入,您需要编写的类的数量将急剧增加。

使用 Mixins

现在,有些语言通过 mixins 来解决这个问题,我在第十一章中介绍过。Mixins 将负担转移到类层次结构底部的每个类,而不会污染上面的任何类。如果我希望我的 BLTSandwich 可以 ShareablePickUppableSubstitutableSplittable,那么我除了修改 BLTSandwich 之外,无需修改任何其他内容。

class BLTSandwich(Shareable,
                  PickUppable,
                  Substitutable,
                  Splittable):
    # ...

只有需要该功能的类才需要更改。您减少了在大型代码库中协调的需求。但这并不完美;用户仍然需要在其类中添加多重继承来解决此问题,如果能最小化类型检查所需的更改,将会更好。当您导入父类时,它还引入了物理依赖性,这可能不是理想的。

事实上,以上选项都不合适。您只是为了类型检查而修改现有类,这在我看来并不像 Python 风格。许多开发人员喜欢 Python 是因为它不需要如此啰嗦。幸运的是,协议 提供了更好的解决方案。

协议

协议提供了一种在类型检查和运行时类型系统之间缩小差距的方式。它们允许您在类型检查期间提供结构子类型化。事实上,您可能已经熟悉某种协议而不自知:迭代器协议。

迭代器协议是一组定义好的对象可能实现的行为。如果一个对象实现了这些行为,您可以对该对象进行循环遍历。考虑:

from random import shuffle
from typing import Iterator, MutableSequence
class ShuffleIterator:
    def __init__(self, sequence: MutableSequence):
        self.sequence = list(sequence)
        shuffle(self.sequence)

    def __iter__(self):
        return self

    def __next__(self):
        if not self.sequence:
            raise StopIteration
        return self.sequence.pop(0)

my_list = [1, 2, 3, 4]
iterator: Iterator = ShuffleIterator(my_list)

for num in iterator:
    print(num)

注意我并不需要子类化 Iterator 就可以使类型工作。这是因为 ShuffleIterator 具有迭代器工作所需的两个方法:用于迭代器循环的 __iter__ 方法,以及用于获取序列中下一个项的 __next__ 方法。

这正是我想要在 Splittable 示例中实现的模式。我希望能够根据代码结构使类型工作。为此,你可以定义自己的协议。

定义协议

定义协议非常简单。如果你希望某物可分割,你可以根据协议定义 Splittable

from typing import Protocol
class Splittable(Protocol):
    cost: int
    name: str

    def split_in_half(self) -> tuple['Splittable', 'Splittable']:
        """ No implementation needed """
        ...

这看起来与本章早期子类化的示例非常接近,但你稍微不同地使用了它。

要使 BLTSandwich 可分割,你无需在类中指示任何不同之处。不需要进行子类化:

class BLTSandwich:
    def __init__(self):
        self.cost = 6.95
        self.name = 'BLT'
        # This class handles a fully constructed BLT sandwich
        # ...

    def split_in_half(self) -> ('BLTSandwich', 'BLTSandwich'):
        # Instructions for how to split a sandwich in half
        # Cut along diagonal, wrap separately, etc.
        # Return two sandwiches in return

对于 BLTSandwich 没有显式的父类。如果你想要明确,仍然可以从 Splittable 进行子类化,但这不是必需的。

现在 split_dish 函数可以期望使用任何支持新 Splittable 协议的内容:

def split_dish(order: Splittable) -> tuple[Splittable, Splittable]:

讨论话题

在你的代码库中可以在哪些地方使用协议?讨论你在哪些地方大量使用鸭子类型或编写通用代码。讨论如果不使用协议,这些代码区域如何容易被误用。

类型检查器将检测到 BLTSandwich 仅通过其定义的字段和方法即为 Splittable。这极大简化了类层次结构。即使添加更多协议,也无需复杂的树形结构。你可以简单地为每组所需行为定义不同的协议,包括 ShareableSubstitutablePickUppable。依赖于这些行为的函数可以依赖于这些协议,而不是任何基类。只要它们实现了所需的功能,原始类就不需要以任何形式更改。

高级用法

我已经讨论了迄今为止协议的主要用例,但还有一些我想向你展示的内容。你可能不会经常使用这些特性,但它们填补了协议的一个重要空白。

复合协议

在上一节中,我谈到了一个类如何满足多个协议的情况。例如,单个午餐项目可能是 SplittableShareableSubstitutablePickUppable。虽然你可以很容易地混合这些协议,但如果你发现超过一半的午餐条目都属于这一类别,怎么办?你可以将这些午餐条目指定为 StandardLunchEntry,允许你将所有四个协议视为单一类型进行引用。

第一次尝试可能只是编写一个类型别名以涵盖你的基础:

StandardLunchEntry = Union[Splittable, Shareable,
                           Substitutable, PickUppable]

但是,这将匹配满足至少一个协议的任何内容,而不是所有四个协议。要匹配所有四个协议,你需要使用复合协议:

class StandardLunchEntry(Splittable, Shareable, Substitutable,
                         PickUppable, Protocol):
    pass

# Remember, you don't need to explicitly subclass from the protocol
# I do so here for clarity's sake
class BLTSandwich(StandardLunchEntry):
    # ... snip ...

然后,你可以在任何应该支持所有四个协议的地方使用StandardLunchEntry。这允许你将协议组合在一起,而无需在整个代码库中反复复制相同的组合。

注意

StandardLunchEntry也从Protocol子类化。这是必需的;如果省略,即使它从其他协议子类化,StandardLunchEntry也不会成为协议。更普遍地说:从协议子类化的类并不会自动成为协议。

运行时可检查的协议

在整个协议讨论过程中,我一直停留在静态类型检查的领域。但有时候,你确实需要在运行时检查类型。不过,现成的协议并不支持任何形式的isinstance()issubclass()检查。不过添加起来很容易:

from typing import runtime_checkable, Protocol

@runtime_checkable
class Splittable(Protocol):
    cost: int
    name: str

    def split_in_half(self) -> tuple['Splittable', 'Splittable']:
        ...

class BLTSandwich():
    # ... snip ..

assert isinstance(BLTSandwich(), Splittable)

只要在那里添加runtime_checkable装饰器,你就可以使用isinstance()检查对象是否满足协议。当你这样做时,isinstance()实质上是在每个预期变量和函数的协议上调用__hasattr__方法。

注意

如果您的协议是一个没有任何协议变量的非数据协议,issubclass()才能正常工作。这涉及处理在构造函数中设置变量时的边缘情况。

当您使用协议的联合时,通常会将协议标记为runtime_checkable。函数可能期望一个协议或不同的协议,而这些函数可能需要一种在函数体内部在运行时区分两者的方式。

模块满足协议

虽然我到目前为止只谈论了满足协议的对象,但有一个更窄的用例值得一提。原来,模块也可以满足协议。毕竟,模块仍然是一个对象。

假设我想在餐厅周围定义一个协议,每个餐厅都在单独的文件中定义。以下是其中一个文件:

name = "Chameleon Café"
address = "123 Fake St."

standard_lunch_entries = [BLTSandwich, TurkeyAvocadoWrap, Chili]
other_entries = [BaconCheeseburger, FrenchOnionSoup]

def render_menu() -> Menu:
    # Code to render a menu

然后,我需要一些代码来定义Restaurant协议,并能够加载餐厅:

from typing import Protocol
from lunch import LunchEntry, Menu, StandardLunchEntry

class Restaurant(Protocol):
    name: str
    address: str
    standard_lunch_entries: list[StandardLunchEntry]
    other_entries: List[LunchEntry]

    def render_menu(self) -> Menu:
        """ No implementation needed """
        ...

def load_restaurant(restaurant: Restaurant):
    # code to load restaurant
    # ...

现在,我可以将导入的模块传递给我的load_restaurant函数:

import restaurant
from load_restaurant import load_restaurant

# Loads our restaurant model
load_restaurant(restaurant)

main.py中,对load_restaurant的调用将通过类型检查。餐厅模块满足我定义的Restaurant协议。协议甚至足够智能,以在传递模块时忽略render_menu中的self参数。使用协议来定义模块不是每天都会发生的 Python 事情,但如果你有 Python 配置文件或需要强制执行合同的插件架构,你会看到它出现。

警告

并非每种类型检查器都可能支持将模块用作协议;请仔细检查您喜欢的类型检查器的错误和文档是否支持。

总结思路

协议在 Python 3.8 中刚刚引入,因此它们仍然相对较新。但是,它们填补了 Python 静态类型检查中的一个巨大空白。请记住,虽然运行时是结构子类型化的,但大部分静态类型检查是名义子类型化的。协议填补了这一空白,并允许您在类型检查期间进行结构子类型化。当您编写库代码并希望提供稳定的 API 给用户使用而不依赖于特定类型时,您最常会使用它们。使用协议减少了代码的物理依赖,有助于提高可维护性,但仍然可以及早捕捉错误。

在下一章中,您将学习另一种增强类型的方法:建模类型。建模类型允许您创建一组丰富的约束条件,在类型检查和运行时进行检查,并可以消除一整类错误,而无需为每个字段手动编写验证。更好的是,通过对类型进行建模,您为代码库中允许和不允许的内容提供了内置文档。在接下来的章节中,您将看到如何使用流行的库 pydantic 实现所有这些功能。

第十四章:使用 pydantic 进行运行时检查

健壮代码的核心主题是使错误检测变得更加容易。错误是开发复杂系统不可避免的一部分;你无法避免它们。通过编写自己的类型,您创建了一个词汇表,使引入不一致性变得更加困难。使用类型注解为您提供了一个安全网,让您在开发过程中捕获错误。这两者都是“将错误左移”的示例;而不是在测试期间(或更糟的是,在生产中)找到错误,您可以更早地找到它们,理想情况下是在开发代码时。

然而,并非每个错误都能通过代码检查和静态分析轻松发现。有一整类错误只能在运行时检测到。每当与程序外部提供的数据交互时(例如数据库、配置文件、网络请求),您都会面临输入无效数据的风险。您的代码在检索和解析数据方面可能非常可靠,但是您无法阻止用户传递无效数据。

您的第一个倾向可能是编写大量验证逻辑if语句和检查以确保所有传入的数据都是正确的。问题在于验证逻辑通常是复杂的、扩展的,一眼看去很难理解。验证越全面,情况越糟。如果您的目标是查找错误,阅读所有代码(和测试)将是您最好的选择。在这种情况下,您需要最小化查看的代码量。这就是难题所在:您阅读的代码越多,您理解的越多,但阅读的越多,认知负担就越大,降低您找到错误的机会。

在本章中,您将学习如何使用 pydantic 库解决此问题。pydantic 允许您定义建模类,减少需要编写的验证逻辑量,而不会牺牲可读性。pydantic 可以轻松解析用户提供的数据,并为输出数据结构提供保证。我将介绍一些基本的使用示例,然后以一些高级 pydantic 用法结束本章。

动态配置

在本章中,我将构建描述餐馆的类型。我将首先提供一种通过配置文件指定餐馆的方法。以下是每家餐馆可配置字段(及其约束)的列表:

  • 餐馆名称

    • 基于传统原因,名称必须少于 32 个字符,并且只能包含字母、数字、引号和空格(抱歉,没有 Unicode)。
  • 业主全名

  • 地址

  • 员工列表

    • 必须至少有一位厨师和一位服务员。

    • 每位员工都有姓名和职位(厨师、服务员、主持人、副厨师或送货司机)。

    • 每个员工都有邮寄地址用于支票或直接存款详细信息。

  • 菜品列表

    • 每道菜品都有名称、价格和描述。名称限制在 16 个字符以内,描述限制在 80 个字符以内。可选地,每道菜还带有图片(以文件名的形式)。

    • 每道菜品必须有唯一的名称。

    • 菜单上至少要有三道菜。

  • 座位数

  • 提供外卖订单(布尔值)

  • 提供送货服务(布尔值)

这些信息存储在一个YAML 文件,看起来像这样:

name: Viafore's
owner: Pat Viafore
address: 123 Fake St. Fakington, FA 01234
employees:
  - name: Pat Viafore
    position: Chef
    payment_details:
      bank_details:
        routing_number: "123456789"
        account_number: "123456789012"
  - name: Made-up McGee
    position: Server
    payment_details:
      bank_details:
        routing_number: "123456789"
        account_number: "123456789012"
  - name: Fabricated Frank
    position: Sous Chef
    payment_details:
      bank_details:
        routing_number: "123456789"
        account_number: "123456789012"
  - name: Illusory Ilsa
    position: Host
    payment_details:
      bank_details:
        routing_number: "123456789"
        account_number: "123456789012"
dishes:
  - name: Pasta and Sausage
    price_in_cents: 1295
    description: Rigatoni and sausage with a tomato-garlic-basil sauce
  - name: Pasta Bolognese
    price_in_cents: 1495
    description: Spaghetti with a rich tomato and beef Sauce
  - name: Caprese Salad
    price_in_cents: 795
    description: Tomato, buffalo mozzarella, and basil
    picture: caprese.png
number_of_seats: 12
to_go: true
delivery: false

可以通过可安装的 Python 库yaml轻松读取此文件,返回一个字典:

with open('code_examples/chapter14/restaurant.yaml') as yaml_file:
    restaurant = yaml.safe_load(yaml_file)

print(restaurant)
>>> {
    "name": "Viafore's",
    "owner": "Pat Viafore",
    "address": "123 Fake St. Fakington, FA 01234",
    "employees": [{
        "name": "Pat Viafore",
        "position": "Chef",
        "payment_details": {
            "bank_details": {
                "routing_number": '123456789',
                "account_number": '123456789012'
            }
        }
    },
    {
        "name": "Made-up McGee",
        "position": "Server",
        "payment_details": {
            "bank_details": {
                "routing_number": '123456789',
                "account_number": '123456789012'
            }
        }
    },
    {
        "name": "Fabricated Frank",
        "position": "Sous Chef",
        "payment_details": {
            "bank_details": {
                "routing_number": '123456789',
                "account_number": '123456789012'
            }
        }
    },
    {
        "name": "Illusory Ilsa",
        "position": "Host",
        "payment_details": {
            "bank_details": {
                "routing_number": '123456789',
                "account_number": '123456789012'
            }
        }
    }],
    "dishes": [{
        "name": "Pasta and Sausage",
        "price_in_cents": 1295,
        "description": "Rigatoni and sausage with a tomato-garlic-basil sauce"
    },
    {
        "name": "Pasta Bolognese",
        "price_in_cents": 1495,
        "description": "Spaghetti with a rich tomato and beef Sauce"
    },
    {
        "name": "Caprese Salad",
        "price_in_cents": 795,
        "description": "Tomato, buffalo mozzarella, and basil",
        "picture": "caprese.png"
    }],
    'number_of_seats': 12,
    "to_go": True,
    "delivery": False
}

我希望您可以戴上测试帽子。我刚刚给出的要求显然并不详尽;您会如何完善它们?请花几分钟时间列出您认为的所有不同约束条件,只需使用给定的字典假设 YAML 文件解析并返回一个字典,您能想到多少个无效的测试用例?

注意

您可能会注意到上面示例中的路由号和账号都是字符串。这是有意为之。尽管是数字串,我不希望它成为数值类型。数值运算(比如加法或乘法)毫无意义,我也不希望账号 000000001234 被截断为 1234。

这里有一些思考枚举测试用例时的想法:

  • Python 是一种动态语言。您确定每个东西都是正确的类型吗?

  • 字典不需要任何必填字段——您确定每个字段都存在吗?

  • 所有问题陈述中的约束条件都被测试过了吗?

  • 额外的约束条件如何(正确的路由号、账号和地址)?

  • 在不应存在的地方使用负数怎么办?

我花了大约五分钟时间,想出了 67 种不同的带有无效数据的测试用例。我的一些测试用例包括(完整列表包含在此书的 GitHub 存储库中):

  • 名称长度为零字符。

  • 名称不是字符串。

  • 没有厨师。

  • 员工没有银行详细信息或地址。

  • 员工的路由号被截断(0000123 变成 123)。

  • 座位数为负数。

诚然,这不是一个非常复杂的类。您能想象一个更复杂的类需要多少测试用例吗?即使有 67 个测试用例,您能想象打开一个类型的构造函数并检查 67 个不同的条件吗?在我工作过的大多数代码库中,验证逻辑远不如此详尽。然而,这是用户可配置的数据,我希望在运行时尽早捕获错误。您应该优先在数据注入时捕获错误,而不是在首次使用时。毕竟,这些值的首次使用可能发生在您与解析逻辑分离的另一个系统中。

讨论主题

思考一下在你的系统中以数据类型表示的某些用户数据。这些数据有多复杂?有多少种方法可以构造出错误的数据?讨论创建这些数据错误的影响以及你对代码能否捕获所有错误的信心。

在本章中,我将向你展示如何创建一个易于阅读并模拟所有列出约束条件的类型。由于我非常关注类型注解,如果在类型检查时可以捕获缺少字段或错误类型,那将是很好的。首先的想法是使用 TypedDict(更多关于 TypedDict 的信息请参见第五章):

from typing import Literal, TypedDict, Union
class AccountAndRoutingNumber(TypedDict):
    account_number: str
    routing_number: str

class BankDetails(TypedDict):
    bank_details: AccountAndRoutingNumber

AddressOrBankDetails = Union[str, BankDetails]

Position = Literal['Chef', 'Sous Chef', 'Host',
                   'Server', 'Delivery Driver']

class Dish(TypedDict):
    name: str
    price_in_cents: int
    description: str

class DishWithOptionalPicture(Dish, TypedDict, total=False):
    picture: str

class Employee(TypedDict):
    name: str
    position: Position
    payment_information: AddressOrBankDetails

class Restaurant(TypedDict):
    name: str
    owner: str
    address: str
    employees: list[Employee]
    dishes: list[Dish]
    number_of_seats: int
    to_go: bool
    delivery: bool

这对于提高可读性是一个巨大的进步;你可以准确地知道构建你的类型所需的类型。你可以编写以下函数:

def load_restaurant(filename: str) -> Restaurant:
    with open(filename) as yaml_file:
        return yaml.safe_load(yaml_file)

下游消费者将自动从我刚刚建立的类型中受益。然而,这种方法存在一些问题:

  • 我无法控制 TypedDict 的构建,因此无法在类型构建的过程中验证任何字段。我必须强制消费者进行验证。

  • TypedDict 不能在其上有额外的方法。

  • TypedDict 隐式地不进行验证。如果你从 YAML 中创建了错误的字典,类型检查器不会抱怨。

最后一点很重要。事实上,我可以将以下内容作为我的 YAML 文件的全部内容,代码仍将进行类型检查:

invalid_name: "This is the wrong file format"

类型检查不会在运行时捕获错误。你需要更强大的工具。这时候就需要 pydantic 出马了。

pydantic

pydantic 是一个提供运行时类型检查且不牺牲可读性的库。你可以像这样使用 pydantic 来建模你的类:

from pydantic.dataclasses import dataclass
from typing import Literal, Optional, TypedDict, Union

@dataclass
class AccountAndRoutingNumber:
    account_number: str
    routing_number: str

@dataclass
class BankDetails:
    bank_details: AccountAndRoutingNumber

AddressOrBankDetails = Union[str, BankDetails]

Position = Literal['Chef', 'Sous Chef', 'Host',
                   'Server', 'Delivery Driver']

@dataclass
class Dish:
    name: str
    price_in_cents: int
    description: str
    picture: Optional[str] = None

@dataclass
class Employee:
    name: str
    position: Position
    payment_information: AddressOrBankDetails

@dataclass
class Restaurant:
    name: str
    owner: str
    address: str
    employees: list[Employee]
    dishes: list[Dish]
    number_of_seats: int
    to_go: bool
    delivery: bool

你使用 pydantic.dataclasses.dataclass 装饰每个类,而不是继承自 TypedDict。一旦你这样做了,pydantic 将在类型构建时进行验证。

要构建 pydantic 类型,我将按以下方式更改我的加载函数:

def load_restaurant(filename: str) -> Restaurant:
    with open(filename) as yaml_file:
        data = yaml.safe_load(yaml_file)
        return Restaurant(**data)

如果将来的开发者违反了任何约束条件,pydantic 将抛出异常。以下是一些示例异常:

如果缺少字段,比如缺少描述:

pydantic.error_wrappers.ValidationError: 1 validation error for Restaurant
dishes -> 2
  __init__() missing 1 required positional argument:
    'description' (type=type_error)

当提供无效类型时,例如将数字 3 作为员工职位:

pydantic.error_wrappers.ValidationError: 1 validation error for Restaurant
employees -> 0 -> position
  unexpected value; permitted: 'Chef', 'Sous Chef', 'Host',
                               'Server', 'Delivery Driver'
                               (type=value_error.const; given=3;
                                permitted=('Chef', 'Sous Chef', 'Host',
                                           'Server', 'Delivery Driver'))
警告

Pydantic 可以与 mypy 配合使用,但你可能需要在 mypy.ini 中启用 pydantic 插件以利用所有功能。你的 mypy.ini 需要包含以下内容:

[mypy]
plugins = pydantic.mypy

欲了解更多信息,请查阅 pydantic 文档

通过使用 pydantic 建模类型,我可以在不编写自己的验证逻辑的情况下捕获整类错误。上述的 pydantic 数据类能够捕获我之前提出的 67 个测试用例中的 38 个。但我可以做得更好。这段代码仍然缺少对其他 29 个测试用例的功能,但我可以使用 pydantic 的内置验证器在类型构建时捕获更多错误。

验证器

Pydantic 提供了大量内置的验证器。验证器是检查特定约束条件的自定义类型。例如,如果我想确保字符串的大小或所有整数都是正数,我可以使用 pydantic 的约束类型:

from typing import Optional

from pydantic.dataclasses import dataclass
from pydantic import constr, PositiveInt

@dataclass
class AccountAndRoutingNumber:
    account_number: constr(min_length=9,max_length=9) ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00002.gif)
    routing_number: constr(min_length=8,max_length=12)

@dataclass
class Address:
    address: constr(min_length=1)

# ... snip ...

@dataclass
class Dish:
    name: constr(min_length=1, max_length=16)
    price_in_cents: PositiveInt
    description: constr(min_length=1, max_length=80)
    picture: Optional[str] = None

@dataclass
class Restaurant:
    name: constr(regex=r'^[a-zA-Z0-9 ]*$', ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00005.gif)
                  min_length=1, max_length=16)
    owner: constr(min_length=1)
    address: constr(min_length=1)
    employees: List[Employee]
    dishes: List[Dish]
    number_of_seats: PositiveInt
    to_go: bool
    delivery: bool

1

我正在限制一个字符串的长度。

2

我正在限制一个字符串以匹配一个正则表达式(在本例中,只包括字母数字字符和空格)。

如果我传入一个无效类型(例如带有特殊字符的餐馆名称或负座位数),我将收到以下错误:

pydantic.error_wrappers.ValidationError: 2 validation errors for Restaurant
name
  string does not match regex "^[a-zA-Z0-9 ]$" (type=value_error.str.regex;
                                                pattern=^[a-zA-Z0-9 ]$)
number_of_seats
  ensure this value is greater than 0
    (type=value_error.number.not_gt; limit_value=0)

我甚至可以约束列表以强制执行进一步的限制。

from pydantic import conlist,constr
@dataclass
class Restaurant:
    name: constr(regex=r'^[a-zA-Z0-9 ]*$',
                   min_length=1, max_length=16)
    owner: constr(min_length=1)
    address: constr(min_length=1)
    employees: conlist(Employee, min_items=2) ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00002.gif)
    dishes: conlist(Dish, min_items=3) ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/rbs-py/img/00005.gif)
    number_of_seats: PositiveInt
    to_go: bool
    delivery: bool

1

此列表仅限于Employee类型,必须至少有两名员工。

2

此列表仅限于Dish类型,必须至少有三种菜肴。

如果我传入不符合这些约束的内容(例如忘记一个菜品):

pydantic.error_wrappers.ValidationError: 1 validation error for Restaurant
dishes
  ensure this value has at least 3 items
    (type=value_error.list.min_items; limit_value=3)

在受限类型下,我额外捕获了我之前设想的 17 个测试用例,总数达到了 67 个测试用例中的 55 个。相当不错,不是吗?

为了捕捉剩余的错误集,我可以使用自定义验证器来嵌入那些最后的验证逻辑:

from pydantic import validator
@dataclass
class Restaurant:
    name: constr(regex=r'^[a-zA-Z0-9 ]*$',
                   min_length=1, max_length=16)
    owner: constr(min_length=1)
    address: constr(min_length=1)
    employees: conlist(Employee, min_items=2)
    dishes: conlist(Dish, min_items=3)
    number_of_seats: PositiveInt
    to_go: bool
    delivery: bool

    @validator('employees')
    def check_chef_and_server(cls, employees):
        if (any(e for e in employees if e.position == 'Chef') and
            any(e for e in employees if e.position == 'Server')):
                return employees
        raise ValueError('Must have at least one chef and one server')

如果接着没有提供至少一位厨师和服务员:

pydantic.error_wrappers.ValidationError: 1 validation error for Restaurant
employees
  Must have at least one chef and one server (type=value_error)

我将让你为其他错误情况编写自定义验证器(例如有效地址、有效路由号码或存在于文件系统上的有效图像)。

验证与解析的区别

诚然,pydantic 并非严格的验证库,而是一个parsing库。这两者的区别细微,但需要明确。在我所有的示例中,我一直在使用 pydantic 来检查参数和类型,但它并不是一个严格的验证器。Pydantic 宣传自己是一个parsing库,这意味着它提供了一个保证,即从数据模型中得到的内容是什么,而不是输入的内容。也就是说,当你定义 pydantic 模型时,pydantic 将尽其所能将数据强制转换为你定义的类型。

如果你有一个模型:

from pydantic import dataclass
@dataclass
class Model:
    value: int

将字符串或浮点数传递给此模型没有问题;pydantic 将尽最大努力将该值强制转换为整数(或者如果该值不可强制转换,则抛出异常)。此代码不会抛出异常:

Model(value="123") # value is set to the integer 123
Model(value=5.5) # this truncates the value to 5

Pydantic 正在解析这些值,而不是验证它们。你不能保证将整数传递给模型,但你始终可以保证另一端输出一个int(或者抛出异常)。

如果你希望限制这种行为,可以使用 pydantic 的严格字段:

from pydantic.dataclasses import dataclass
from pydantic import StrictInt
@dataclass
class Model:
    value: StrictInt

现在,当从另一种类型构建时,

x = Model(value="0023").value

你会得到一个错误:

pydantic.error_wrappers.ValidationError: 1 validation error for Model
value
  value is not a valid integer (type=type_error.integer)

因此,虽然 pydantic 自称为一个解析库,但在你的数据模型中可以强制执行更严格的行为。

总结思考

在整本书中,我一直强调类型检查器的重要性,但这并不意味着在运行时捕获错误毫无意义。虽然类型检查器可以捕获大部分错误并减少运行时检查,但它们无法捕获所有问题。你仍然需要验证逻辑来填补这些空白。

对于这类检查,pydantic 库是你工具箱中的一个好工具。通过将验证逻辑直接嵌入到你的类型定义中(而不是编写大量乏味的if语句),你提升了健壮性。首先,大大提高了可读性;阅读你的类型定义的开发人员将清楚地知道施加在其上的约束。其次,它为你提供了那个急需的具备运行时检查的保护层。

我发现 pydantic 还有助于填补数据类和类之间的中间地带。每个约束技术上都在维护关于该类的不变量。我通常建议不要为你的数据类设置不变量,因为你无法保护它;你不能控制构造和属性访问是公开的。然而,pydantic 在你调用构造函数或设置字段时仍然保护不变量。但是,如果你有相互依赖的字段(例如需要同时设置两个字段或根据另一个字段的值设置一个字段),那么就坚持使用类。

第二部分到此为止。你已经学会了如何使用Enums、数据类和类来创建自己的类型。每种类型都适用于特定的用例,因此在编写类型时要注意你的意图。你学会了类型如何通过子类型化来建模is-a关系。你还了解了为什么你的 API 对每个类都如此重要;这是其他开发人员第一次了解你在做什么的机会。在本章结束时,你学到了除了静态类型检查之外,进行运行时验证的必要性。

在接下来的部分中,我将退后一步,从一个更广泛的视角来看待健壮性。本书前两部分的几乎所有指导都集中在类型注解和类型检查器上。可读性和错误检查是健壮性的重要好处,但它们并不是全部。其他维护者需要能够对你的代码库进行重大更改,引入新功能,而不仅仅是与你的类型交互进行小的更改。第三部分将专注于可扩展性。

第三部分:可扩展的 Python

健壮的代码是可维护的代码。为了可维护性,代码必须易于阅读、易于检查错误,以及易于修改。本书的第 I 部分和第 II 部分侧重于可读性和错误检测,但并不一定涉及如何扩展或修改现有代码。类型注解和类型检查器在与个体类型交互时为维护者提供信心,但是对于代码库中的较大变更,比如引入新的工作流程或替换关键组件,又该如何?

第 III 部分 探讨了更大的变更,并向您展示如何使未来的开发者能够实施这些变更。您将了解到可扩展性和可组合性,这两个核心原则能够提高代码的健壮性。您将学习如何管理依赖关系,以确保简单的变更不会导致错误和 Bug 的连锁反应。然后,您将应用这些概念到架构模型中,如基于插件的系统、反应式编程和任务导向型程序。

第十五章:可扩展性

本章重点介绍可扩展性。可扩展性是本书的重点内容之一;理解这一关键概念非常重要。一旦你了解了可扩展性对稳健性的影响,你就会开始看到在代码库中应用它的机会。可扩展的系统允许其他开发人员自信地增强你的代码库,从而减少错误的机会。让我们来看看是如何做到这一点的。

什么是可扩展性?

可扩展性是系统的属性,允许在不修改系统现有部分的情况下添加新功能。软件不是静态的;它会发生变化。在你的代码库的生命周期中,开发人员会改变你的软件。软件中的部分就表示这一点。这些变化可能是相当大的。想想你需要在扩展规模时交换体系结构的关键部分,或者添加新的工作流程的情况。这些变化会触及代码库的多个部分;简单的类型检查无法在这个级别捕获所有错误。毕竟,你可能正在完全重新设计你的类型。可扩展软件的目标是以一种使未来开发人员能够轻松扩展的方式设计,特别是在经常更改的代码区域。

为了阐明这个想法,让我们考虑一个希望实现某种通知系统以帮助供应商应对需求的连锁餐厅。餐厅可能有特色菜,或者缺少某种成分,或者表明某种成分已经变质。在每种情况下,餐厅都希望供应商能够自动收到通知,提示需要重新补货。供应商已经提供了一个用于实际通知的 Python 库。

实现看起来像下面这样:

def declare_special(dish: Dish, start_date: datetime.datetime,
                    end_time: datetime.datetime):
    # ... snip setup in local system ...
    # ... snip send notification to the supplier ...

def order_dish(dish: Dish):
    # ... snip automated preparation
    out_of_stock_ingredients = {ingred for ingred in dish
                                if out_of_stock(ingred)}
    if out_of_stock_ingredients:
        # ... snip taking dishes off menu ...
        # ... snip send notification to the supplier ...

# called every 24 hours
def check_for_expired_ingredients():
    expired_ingredients = {ing for ing in ingredient in get_items_in_stock()}:
    if expired_ingredients:
        # ... snip taking dishes off menu ...
        # ... snip send notifications to the supplier ...

这段代码一开始看起来很简单。每当发生重要事件时,适当的通知就可以发送给供应商(想象一下将某个字典作为 JSON 请求的一部分发送)。

快进几个月,一个新的工作项出现了。你在餐厅的老板对通知系统非常满意,他们想要扩展它。他们希望通知发送到他们的电子邮件地址。听起来很简单,对吧?你让declare_special函数也接受电子邮件地址:

def declare_special(notification: NotificationType,
                    start_date: datetime.datetime,
                    end_time: datetime.datetime,
                    email: Email):
    # ... snip ...

不过,这有着深远的影响。调用declare_special的函数也需要知道传递下去的电子邮件是什么。幸运的是,类型检查器会捕获任何遗漏。但如果出现其他用例会怎样呢?你看了一下你的待办事项清单,下面的任务都在那里:

  • 通知销售团队有关特价和缺货商品。

  • 通知餐厅的顾客有关新特价。

  • 支持为不同的供应商使用不同的 API。

  • 支持文本消息通知,以便你的老板也可以收到通知。

  • 创建一个新的通知类型:新菜单项。市场营销人员和老板想了解这一点,但供应商不想知道。

当开发人员实现这些功能时,declare_special变得越来越庞大。它处理的案例越来越多,逻辑变得越来越复杂,出错的可能性也增加。更糟糕的是,对 API 的任何更改(比如添加一个用于发送短信的电子邮件地址或电话号码列表)都会对所有调用方产生影响。在某个时候,像将新的电子邮件地址添加到营销人员列表中这样的简单操作会触及代码库中的多个文件。这在口语中被称为“散弹手术”:¹,一个变化会在一系列文件中传播开来,影响各种文件。此外,开发人员正在修改现有的代码,增加出错的机会。更糟糕的是,我们只涉及了declare_special,但order_dishcheck_for_expired_ingredients也需要它们自己的定制逻辑。处理到处重复的通知代码将会非常乏味。问问自己,如果一个新用户想要文本通知,你会喜欢不得不在整个代码库中寻找每一个通知片段吗?

这一切都源于代码不太可扩展。您开始要求开发人员了解多个文件的所有细节才能进行更改。维护者实现其功能将需要更多的工作。回想一下第一章中对意外复杂性和必要复杂性的讨论。必要复杂性是与您的问题领域内在相关的;意外复杂性是您引入的复杂性。在这种情况下,通知、接收者和过滤器的组合是必要的;它是系统的必需功能。

然而,您实现系统的方式决定了您承担多少意外复杂性。我描述的方式充满了意外复杂性。添加任何一个简单的东西都是一项相当大的工程。要求开发人员在代码库中搜索需要更改的所有位置只会招来麻烦。简单的更改应该很容易实现。否则,每次扩展系统都会变成一项琐事。

重新设计

让我们再次看一下declare_special函数:

def declare_special(notification: NotificationType,
                    start_date: datetime.datetime,
                    end_time: datetime.datetime,
                    email: Email):
    # ... snip ...

所有问题都始于向函数添加电子邮件作为参数。这导致了涟漪效应,影响了代码库的其他部分。这不是未来开发人员的错;他们通常受到时间的限制,试图将他们的功能塞进他们不熟悉的代码库的某个部分。他们通常会遵循已为他们铺平道路的模式。如果您能奠定基础,引导他们朝着正确的方向发展,您就会提高代码的可维护性。如果您让可维护性滋生,您会开始看到如下方法:

def declare_special(notification: NotificationType,
                    start_date: datetime.datetime,
                    end_time: datetime.datetime,
                    emails: list[Email],
                    texts: list[PhoneNumber],
                    send_to_customer: bool):
    # ... snip ...

函数将会不断增长,直到它变成一个纠缠不清的依赖混乱。如果我需要将客户添加到邮件列表,为什么我还需要查看如何声明特价?

需要重新设计通知系统,以便于进行更改。首先,我将查看使用情况,并考虑未来开发人员需要简化的内容。(如果您需要有关设计界面的额外建议,请参阅第二部分,特别是第十一章。)在这个特定的使用情况中,我希望未来的开发人员能够轻松添加三个事项:

  • 新通知类型

  • 新通知方法(例如电子邮件、短信或 API)

  • 新用户需要通知

通知代码散落在代码库中,因此我希望在开发人员进行这些更改时,他们无需进行任何散弹手术。记住,我希望简单的事情变得简单。

现在,考虑我必要的复杂性。在这种情况下,将有多种通知方法、多种通知类型和多个需要收到通知的用户。这些是三种不同的复杂性;我希望限制它们之间的交互。declare_special的部分问题在于它必须考虑的关注点的组合是令人生畏的。将这种复杂性乘以每个需要稍有不同通知需求的函数,你将面临一个维护的真正噩梦。

首先要做的是尽量解耦意图。我将首先为每种通知类型创建类:

@dataclass
class NewSpecial:
    dish: Dish
    start_date: datetime.datetime
    end_date: datetime.datetime

@dataclass
class IngredientsOutOfStock:
    ingredients: Set[Ingredient]

@dataclass
class IngredientsExpired:
    ingredients: Set[Ingredient]

@dataclass
class NewMenuItem:
    dish: Dish

Notification = Union[NewSpecial, IngredientsOutOfStock,
                     IngredientsExpired, NewMenuItem]

如果我考虑declare_special如何与代码库进行交互,我真的只希望它知道这个NotificationType。声明特殊不应该需要知道谁注册了该特殊以及他们将如何收到通知。理想情况下,declare_special(以及任何需要发送通知的其他函数)应该看起来像这样:

def declare_special(dish: Dish, start_date: datetime.datetime,
                    end_time: datetime.datetime):
    # ... snip setup in local system ...
    send_notification(NewSpecial(dish, start_date, end_date))

send_notification可以像这样声明:

def send_notification(notification: Notification):
    # ... snip ...

这意味着如果代码库的任何部分想要发送通知,它只需调用这个函数。你只需要传入一个通知类型。添加新的通知类型很简单;你添加一个新的类,将该类添加到Union中,并调用send_notification以使用新的通知类型。

接下来,您需要轻松地添加新的通知方法。同样,我将添加新的类型来代表每种通知方法:

@dataclass
class Text:
    phone_number: str

@dataclass
class Email:
    email_address: str

@dataclass
class SupplierAPI:
    pass

NotificationMethod = Union[Text, Email, SupplierAPI]

在代码库的某个地方,我需要根据每种方法发送不同的通知类型。我可以创建一些辅助函数来处理这个功能:

def notify(notification_method: NotificationMethod, notification: Notification):
    if isinstance(notification_method, Text):
        send_text(notification_method, notification)
    elif isinstance(notification_method, Email):
        send_email(notification_method, notification)
    elif isinstance(notification_method, SupplierAPI):
        send_to_supplier(notification)
    else:
        raise ValueError("Unsupported Notification Method")

def send_text(text: Text, notification: Notification):
    if isinstance(notification, NewSpecial):
        # ... snip send text ...
        pass
    elif isinstance(notification, IngredientsOutOfStock):
        # ... snip send text ...
        pass
    elif isinstance(notification, IngredientsExpired):
        # ... snip send text ...
        pass
    elif isinstance(notification, NewMenuItem):
        # .. snip send text ...
        pass
    raise NotImplementedError("Unsupported Notification Method")

def send_email(email: Email, notification: Notification):
    # .. similar to send_text ...

def send_to_supplier(notification: Notification):
    # .. similar to send_text

现在,添加新的通知方法也很简单。我添加一个新类型,将其添加到联合中,在notify中添加一个if语句,并编写一个相应的方法来处理所有不同的通知类型。

在每个send_***方法中处理所有通知类型可能看起来很笨重,但这是必要的复杂性;由于不同的消息、不同的信息和不同的格式,每种方法/类型组合都有不同的功能。如果代码量太大,你可以创建一个动态查找字典(这样添加新的键值对就是需要的所有内容,用于添加通知方法),但在这些情况下,你将牺牲早期错误检测以换取更好的可读性。

现在我有了添加新的通知方法或类型的简单方法。我只需要将它们全部联系起来,以便轻松添加新用户。为此,我将编写一个函数来获取需要通知的用户列表:

users_to_notify: Dict[type, List[NotificationMethod]] = {
    NewSpecial: [SupplierAPI(), Email("boss@company.org"),
                 Email("marketing@company.org"), Text("555-2345")],
    IngredientsOutOfStock: [SupplierAPI(), Email("boss@company.org")],
    IngredientsExpired: [SupplierAPI(), Email("boss@company.org")],
    NewMenuItem: [Email("boss@company.org"), Email("marketing@company.org")]
}

在实践中,这些数据可能来自配置文件或其他声明性来源,但为了书中的简洁性,它就足够了。要添加新用户,我只需在这个字典中添加一个新条目。为用户添加新的通知方法或通知类型同样简单。处理需要通知的用户的代码要容易得多。

为了将所有这些概念整合到一起,我将实现send_notification

def send_notification(notification: Notification):
    try:
        users = users_to_notify[type(notification)]
    except KeyError:
        raise ValueError("Unsupported Notification Method")
    for notification_method in users:
        notify(notification_method, notification)

就是这样!所有这些通知代码可以放在一个文件中,代码库的其余部分只需要知道一个函数——send_notification——与通知系统进行交互。一旦不再需要与代码库的任何其他部分进行交互,这将使得测试变得更加容易。此外,这段代码是可扩展的;开发者可以轻松地添加新的通知类型、方法或用户,而无需搜索整个代码库以查找所有复杂的调用。你希望在最小化对现有代码修改的同时,轻松地向你的代码库添加新功能。这就是开闭原则。

开闭原则

开闭原则(OCP)指出,代码应该对扩展开放,对修改关闭²。这是可扩展性的核心。我们在前一节的重设计中试图遵循这一原则。与要求新功能触及代码库的多个部分不同,它要求添加新类型或函数。即使现有函数发生了变化,我也只是添加了一个新的条件检查,而不是修改现有的检查。

看起来我一直在追求代码重用,但开闭原则(OCP)更进一步。是的,我已经去重了通知代码,但更重要的是,我让开发者更容易管理复杂性。问问自己,你更喜欢哪个:通过检查调用堆栈来实现一个功能,但不确定是否找到了需要更改的每个位置,还是修改简单且不需要大幅修改的一个文件。我知道我会选择哪个。

你已经在本书中接触过 OCP。鸭子类型(在第二章)、子类型(在第十二章)、以及协议(在第十三章)都是可以帮助实现 OCP 的机制。所有这些机制的共同点是它们允许您以一种通用的方式编程。你不再需要直接处理每个特殊情况,而是为其他开发人员提供扩展点,让他们能够在不修改你的代码的情况下注入自己的功能。

OCP 是可扩展性的核心。保持代码的可扩展性将提高鲁棒性。开发人员可以自信地实现功能;只需在一个地方进行更改,而其他代码库已经准备好支持该更改。较少的认知开销和较少的代码更改将导致较少的错误。

检测 OCP 违规

如何判断你是否应该编写更易扩展的代码,遵循 OCP?以下是一些指标,当你考虑你的代码库时应该引起注意:

看起来容易的事情难以做到吗?

你的代码库中应该有一些概念上容易的事情。实现这个概念所需的工作量应该与领域复杂性相匹配。我曾经在一个代码库中工作,为了添加一个用户可配置选项需要修改 13 个不同的文件。对于一个有数百个可配置选项的产品来说,这应该是一个容易的任务。可以说,事实并非如此。

你是否遇到了类似特性的阻力?

如果特性请求者经常对特性的时间表提出异议,特别是如果,在他们的话中,它“几乎与以前的特性X相同”,请问是否存在复杂性的脱节。可能复杂性固有于领域中,在这种情况下,你应该确保特性请求者与你在同一页面上。但如果复杂性是偶然的,那么你的代码可能需要重新设计,以使其更容易操作。

你是否一直有着高估算?

一些团队使用估算来预测他们在给定时间范围内要完成的工作量。如果特性一直有很高的估算,请问估算的来源是什么。是复杂性驱动了高估算吗?那种复杂性是必要的吗?还是风险和未知的恐惧?如果是后者,请问为什么你的代码库感觉有风险?有些团队将特性分割成独立的估算,通过分割工作。如果你一直这样做,请问重组代码库是否可以减轻分割。

提交包含大的变更集吗?

查找你的版本控制系统中具有大量文件的提交。这很可能表明“散弹手术”正在发生,尤其是如果相同的文件在多个提交中反复出现。请记住,这只是一个指导原则;大提交并不总是表示问题,但如果频繁发生,值得进一步检查。

讨论话题

在你的代码库中遇到了哪些 OCP(开闭原则)的违规情况?你如何重构代码以避免这些问题?

缺点

可扩展性并非解决所有编码问题的灵丹妙药。事实上,如果过度灵活,你的代码库实际上可能会降级。如果你过度使用 OCP 并试图使所有东西可配置和可扩展,你很快会发现自己陷入困境。问题在于,虽然使代码可扩展可以减少在进行更改时的意外复杂性,但它可能会增加其他方面的意外复杂性。

首先,可读性下降。你正在创建一个全新的抽象层,将你的业务逻辑与代码库的其他部分分离开来。任何想要理解整体图片的人都必须跳过一些额外的步骤。这将影响新开发人员快速上手,同时也会妨碍调试工作。你可以通过良好的文档和解释代码结构来缓解这种情况。

其次,你引入了一个可能之前不存在的耦合。之前,代码库的各个部分是相互独立的。现在,它们共享一个公共子系统;该子系统的任何变化都会影响所有的使用者。我将在第十六章中深入讨论这一点。通过一套强大的测试来减轻这种影响。

适度使用 OCP,并在应用这些原则时谨慎行事。如果使用过度,你的代码库将被过度抽象化和混乱的依赖关系所困扰。使用过少,则开发人员将需要更长时间来进行更改,并引入更多的错误。在你合理确定某些区域需要再次修改时,定义扩展点将大大改善未来维护者在处理你的代码库时的体验。

总结思考

可扩展性是代码库维护中最重要的方面之一。它允许你的协作者在不修改现有代码的情况下添加功能。任何时候,避免修改现有代码,就是避免引入回归的时机。现在添加可扩展的代码可以预防未来的错误。记住 OCP 原则:保持代码对扩展开放,但对修改关闭。合理应用这一原则,你将看到你的代码库变得更易维护。

可扩展性是接下来几章将贯穿始终的重要主题。在下一章中,我将专注于依赖关系以及代码库中的关系如何限制其可扩展性。您将了解不同类型的依赖关系以及如何管理它们。您将学习如何可视化和理解您的依赖关系,以及为什么您的代码库中的某些部分可能具有更多的依赖关系。一旦开始管理您的依赖关系,您将发现扩展和修改代码变得更加容易。

¹ 马丁·福勒。重构:改善现有代码的设计。第二版。上沙德尔河,NJ:Addison-Wesley Professional,2018。

² OCP 首次在 Bertrand Meyer(Pearson)的面向对象软件构建中描述。

第十六章:依赖关系

编写没有任何依赖关系的程序是困难的。函数依赖于其他函数,模块依赖于其他模块,程序依赖于其他程序。架构是分形的;无论你看的是哪个层次,你的代码都可以表示为某种盒子和箭头图,就像 图 16-1 中所示。无论是函数、类、模块、程序还是系统,你都可以画一个类似的图来表示代码中的依赖关系。

盒子和箭头图

图 16-1. 盒子和箭头图

然而,如果你不积极管理你的依赖关系,很快就会陷入所谓的“意大利面条式代码”,使你的盒子和箭头图看起来像是 图 16-2。

依赖关系的混乱纠结

图 16-2. 依赖关系的混乱纠结

在本章中,你将学习有关依赖关系以及如何控制它们的全部内容。你将学习不同类型的依赖关系,所有这些都应该用不同的技术来管理。你将学习如何绘制依赖关系图,以及如何解读是否拥有一个健康的系统。你将学习如何真正简化你的代码架构,这将帮助你管理复杂性并增加代码库的健壮性。

关系

依赖关系本质上是关系。当一段代码需要另一段代码以某种特定的方式运行时,我们称之为 依赖关系。你通常使用依赖关系来以某种方式重用代码。函数调用其他函数以重用行为。模块导入其他模块以重用该模块中定义的类型和函数。在大多数代码库中,从头开始写所有东西是没有意义的。重用代码库的其他部分,甚至是来自其他组织的代码,可能极大地有利。

当你重用代码时,你节省了时间。你不需要浪费精力编写代码;你可以直接调用或导入你需要的功能。此外,你依赖的任何代码很可能已经在其他地方使用过。这意味着已经进行了某种层次的测试,这应该减少 bug 的数量。如果代码是可以读取的,那就更好了。正如 Linus 法则(即 Linux 创建者 Linus Torvalds 的法则)所述:¹

“足够多的眼睛,所有的 bug 都会变得浅显易懂。”

换句话说,由于有很多人在查看代码,发现 bug 的可能性更高。这又是支持可读性导致可维护性的另一点。如果你的代码可读性好,其他开发者将更容易找到并修复其中的错误,帮助你的健壮性增强。

不过,这里有一个问题。说到依赖关系,没有免费的午餐。你创建的每一个依赖关系都会增加耦合度,或者说将两个实体绑定在一起。如果依赖关系以不兼容的方式发生变化,你的代码也需要相应变化。如果这种情况经常发生,你的健壮性将会受到影响;你将不断努力维持稳定性,因为你的依赖关系在变化。

依赖关系中还存在着一个人为因素。你依赖的每一行代码都是由活生生的人(甚至可能是一群人)维护的。这些维护者有他们自己的时间表、自己的截止日期以及他们对所开发代码的愿景。很可能这些都不会与你的时间表、截止日期和愿景相一致。代码被重复使用的次数越多,越不可能满足每个消费者的所有需求。当你的依赖与你的实现分歧时,你可以选择忍受困难,选择替代依赖(可能是你控制的一个),或者分叉它(并自行维护)。你的选择取决于具体情况,但无论哪种情况,健壮性都会受到影响。

任何在 2016 年工作的 JavaScript 开发者都能告诉你,“left-pad 事件”是如何使依赖关系出现问题的。由于政策分歧,一个开发者从包仓库中移除了一个名为 left-pad 的库,结果第二天,成千上万个项目突然崩溃,无法构建。许多大型项目(包括非常流行的 React 库)并不直接依赖于 left-pad,但通过它们自己的依赖关系间接地依赖于它。没错,依赖关系也有它们自己的依赖关系,当你依赖其他代码时,你也会得到它们。这个故事的寓意是:不要忘记人为因素及其相关工作流的成本。准备好你的任何依赖关系以最糟糕的方式发生变化,包括被移除。依赖关系是一种负担。必要的,但仍然是负担。

从安全的角度来看,依赖关系还会扩展攻击面。每一个依赖项(以及它们自己的依赖项)都有可能 compromise 你的系统。有一些专门的网站致力于跟踪安全漏洞,例如https://cve.mitre.org。通过关键字搜索“Python”,你可以看到今天存在多少漏洞,而且自然地,这些网站甚至无法计算尚未知晓的漏洞。如果你的组织维护的依赖存在安全问题,除非有专注于安全的个体不断审视你的所有代码,否则未知的漏洞可能随时存在于你的代码库中。

仔细平衡你对依赖关系的使用。你的代码天然地会有依赖关系,这是一件好事。关键在于如何聪明地管理它们。粗心大意会导致代码混乱不堪。要学会如何处理依赖关系,首先需要知道如何识别不同类型的依赖关系。

依赖关系的类型

我将依赖关系分为三类:物理、逻辑和时间性。每种都以不同的方式影响代码的健壮性。你必须能够识别它们,并知道它们何时出现问题。正确使用依赖关系可以使你的代码保持可扩展性而不致使其变得笨重。

物理依赖

当大多数开发者思考依赖关系时,他们想到的是物理依赖关系。物理依赖 是直接在代码中观察到的关系。函数调用函数,类型由其他类型组成,模块导入模块,类继承自其他类……这些都是物理依赖的例子。它们是静态的,意味着在运行时不会改变。

物理依赖关系是最容易理解的;即使是工具也可以查看代码库并映射出物理依赖关系(你将在几页后看到这一点)。它们在第一眼看起来就很容易阅读和理解,这对于代码的健壮性是一个胜利。当未来的维护者阅读或调试代码时,依赖链的解决方式变得非常明显;他们可以跟随导入或函数调用的路径到达链的末端。

图 16-3 着眼于一个名为PizzaMat的完全自动化披萨咖啡馆。特许经营者可以购买整个 PizzaMat 作为一个完整的模块,并在任何地方部署以获得即时(和美味的)披萨。PizzaMat 有几个不同的系统:制作披萨系统、控制付款和订购的系统,以及处理桌面管理(座位、加料和订单送达)的系统。

一个自动化披萨咖啡馆

图 16-3. 一个自动化披萨咖啡馆

这三个系统中的每一个都与其他系统互动(这就是箭头所代表的)。顾客与付款/订购系统互动以订购他们的披萨。一旦完成,披萨制作者检查是否有新订单并开始制作披萨,桌面管理系统开始安排顾客的座位。一旦桌面管理服务得知披萨已完成,它会为桌子准备披萨并为顾客服务。如果出于任何原因顾客对披萨不满意,桌面管理系统会退还披萨,付款系统会发出退款。

每个依赖关系都是一个关系,只有这些系统共同工作,我们才有一个正常运作的披萨店。物理依赖关系对于理解大型系统至关重要;它们允许你将问题分解为较小的实体,并定义每个实体之间的交互。我可以拆分任何一个系统为模块,或者拆分任何一个模块为函数。我想要专注于这些关系如何影响可维护性。

假设这三个系统由三个不同的实体维护。您和您的团队负责披萨制作系统。您公司的另一个团队(位于不同建筑物内)负责桌子管理系统,而独立承包商则负责支付系统。您已参与推出新的披萨制作项目——斯特龙博利,为此进行了数周的工作,仔细协调变更。每个系统都需要调整以处理新的菜单项目。在无数个深夜(当然都是披萨驱动的),您已经准备好为客户进行重大更新。然而,一旦更新推出,错误报告就开始涌现。一系列不幸的事件引入了一个 bug,导致全球的披萨店崩溃。随着越来越多的系统上线,问题变得更加严重。管理层决定您需要尽快修复它。

花点时间想想您希望今晚过得如何。您想要在努力尝试联系所有其他团队并试图在三个系统中试图快速修复的情况下度过吗?您恰好知道承包商已经关闭了今晚的通知,而另一个团队在今天下班后对他们的发布庆祝活动有点过于投入。或者,您想看看代码,并意识到只需稍微修改几行代码就可以轻松从所有三个系统中移除斯特龙博利,而无需其他团队的参与?

依赖关系是一种单向关系。您受制于您的依赖关系。如果它们在您需要时不按您的要求执行,您几乎没有什么办法。请记住,依赖的另一端是真正的活生生的人,他们并不一定会在您要求时立即行动。您如何构建依赖关系将直接影响您如何维护系统。

在我们的斯特龙博利示例中,依赖关系是一个循环;任何一个变更都有可能影响其他两个系统。您需要考虑依赖关系的每一个方向,以及变更如何在系统中传播。对于 PizzaMat 来说,披萨制作设备的支持是我们的唯一真理;没有必要为不存在的披萨产品设置计费和桌子管理。然而,在上述示例中,所有三个系统都有它们自己的菜单项目副本。根据依赖关系的方向,披萨制造商可以移除斯特龙博利代码,但斯特龙博利仍然会出现在支付系统中。如何使其更具扩展性以避免这些依赖问题?

警告

大型架构变更的棘手之处在于正确答案始终取决于特定问题的背景。如果要构建一个自动披萨制造机,可能会根据各种不同的因素和约束条件绘制依赖树。重要的是要关注你为何以你所画的依赖关系而不是确保它们与其他系统总是相同。

要开始,你可以构建你的系统,让所有菜单定义都在披萨制造系统中;毕竟,只有系统知道它能做什么和不能做什么。然后,定价系统可以向披萨制造者查询实际可用的项目。这样,如果需要紧急移除比萨卷,你可以在披萨制造系统中执行;定价系统不控制什么是可用和不可用的。通过倒置或反转依赖的方向,你可以将控制权还给披萨制造系统。如果我倒置这个依赖关系,依赖图看起来像图 16-4。

更合理的依赖关系

图 16-4. 更合理的依赖关系

现在披萨制造者决定可以点什么和不能点什么。这在限制所需更改方面可以大有裨益。如果披萨制造者需要停止支持某种原料在菜品中的使用,支付系统将自动跟随变化。这不仅在紧急情况下可以救你一命,而且在未来给你的业务更多灵活性。你已经增加了根据披萨制造者能够自动制作的内容,选择在支付系统中可选显示不同的菜品的功能,而无需与外部支付团队协调。

讨论主题

思考一下,如果把一个功能加入,使支付系统在披萨制造者缺少原料时不显示某些选项。考虑 16-3 图和 16-4 图中的系统。

作为一个额外的讨论主题,讨论表管理系统与支付系统之间的循环。如何打破这种循环?每种依赖方向的利弊是什么?

逻辑依赖

逻辑依赖 是指两个实体之间有关系,但在代码中没有直接的链接。这种依赖是抽象的;它包含了一层间接。这是一种只在运行时存在的依赖。在我们的披萨制造者示例中,我们有三个子系统相互作用。我们在图 16-3 中用箭头表示依赖关系。如果这些箭头是导入或函数调用,那么它们就是物理依赖。然而,可以在运行时连接这些子系统,而无需函数调用或导入。

假设子系统位于不同的计算机上,并通过 HTTP 进行通信。如果披萨制造商要使用requests库通过 HTTP 通知桌面管理服务何时制作披萨,它会看起来像这样:

def on_pizza_made(order: int, pizza: Pizza):

    requests.post("table-management/pizza-made", {
        "id": order,
        "pizza": pizza.to_json()
    })

物理依赖不再是从披萨制造商到我们的桌面管理系统,而是从披萨制造商到requests库。对于披萨制造商来说,它只需要一个 HTTP 端点,可以将数据作为 JSON 格式的 ID 和披萨数据发布到名为“/pizza-done”的端点,来自名为“table-management”的某个 Web 服务器。

现在,在现实中,你的披萨制造商仍然需要桌面管理服务才能工作。这就是逻辑依赖的作用。尽管没有直接依赖,但披萨制造商与桌面管理系统之间仍然存在关系。这种关系不会消失,它会从物理上转变为逻辑上的。

引入逻辑依赖的关键好处在于可替换性。当没有任何物理依赖于某个组件时,替换该组件就容易得多。以通过 HTTP 请求on_pizza_done的例子为例。你完全可以替换桌面管理服务,只要它遵循与原始服务相同的契约。如果这听起来很熟悉,那是因为这与你在第十二章学到的完全相同。子类型化,无论是通过鸭子类型、继承还是其他方式,都引入了逻辑依赖。调用代码在物理上依赖于基类,但使用哪个子类的逻辑依赖直到运行时才确定。

提高可替换性可以提升可维护性。记住,可维护的代码易于修改。如果你可以用最小的影响替换大量功能,那么你未来的维护者在做决策时将拥有极大的灵活性。如果某个特定的函数、类或子系统不符合你的需求,你可以直接替换它。易于删除的代码本质上易于修改。

但与任何事物一样,逻辑依赖是有代价的。每个逻辑依赖都间接引用某种关系。因为没有物理链接,工具很难识别逻辑依赖。你将无法创建一个漂亮的框和箭头图表来显示逻辑依赖。此外,开发者阅读你的代码时,逻辑依赖不会立即显现。通常情况下,代码阅读者会看到与某个抽象层的物理依赖,而忽视或直到运行时才解决逻辑依赖。

引入逻辑依赖的权衡之处在于增加了可维护性,通过增加可替换性和减少耦合性,但也因为使代码难以阅读和理解而降低了可维护性。抽象层次过多会像抽象层次过少一样容易造成混乱。并没有硬性规定适当的抽象层次数量;在特定场景下,你需要根据自己的最佳判断,确定是需要灵活性还是可读性。

一些逻辑依赖会创建一些无法通过工具检测到的关系,比如依赖于集合的特定顺序或依赖于类中特定字段的存在。发现这些依赖时,往往会让开发人员感到惊讶,因为在仔细检查之前很少有迹象表明它们存在。

我曾经在一个代码库中处理过存储网络接口的问题。有两段代码依赖于这些接口:一个用于性能统计,另一个用于与其他系统建立通信路径。问题在于它们对这些接口排序有不同的假设。这个系统多年来都运行良好,直到新增了新的网络接口。由于通信路径的工作方式,新的接口需要放在列表的前面。但是性能统计只能在这些接口放在列表后面时才能工作。由于隐藏的逻辑依赖,这两部分代码紧密耦合在一起(我从未想过增加通信路径会破坏性能统计)。

事后看来,修复很简单。我创建了一个函数,将通信路径期望的顺序映射到重新排序的列表中。性能统计系统随后依赖于这个新函数。然而,这并没有修复之前的 bug(也没有挽回我为解决性能统计问题而花费的时间)。每当你对代码中不显而易见的东西创建依赖时,找到一种方法使它显而易见。留下一串面包屑,最好是通过一个单独的代码路径(比如上面的中间函数)或类型。如果做不到这一点,留下一个注释。如果网络接口列表中有一个注释表明对特定顺序的依赖,我就不会为这段代码遭遇如此大的困扰了。

时间依赖

最后一种依赖类型是时间依赖性。这实际上是一种逻辑依赖的一种类型,但你处理它的方式略有不同。时间依赖性是一种由时间联系的依赖关系。每当有具体的操作顺序时,比如“面团必须在酱和奶酪之前铺开”或“订单必须在开始制作披萨之前付款”,你就有了时间依赖性。大多数时间依赖性都很直接;它们是你业务领域的自然部分。(反正没有面团,你怎么放披萨酱和奶酪呢?)这些不是会给你带来问题的时间依赖性。相反,问题出在那些不总是那么明显的时间依赖性上。

时间依赖性在你必须按特定顺序执行某些操作的情况下最容易出问题,但你却没有指示你需要这样做。想象一下,如果你的自动披萨制造机可以配置为两种模式:单披萨(用于高质量披萨)或大批量生产(用于便宜快捷披萨)。每当披萨制造机从单披萨切换到大批量生产时,就需要进行显式重新配置。如果这种重新配置没有进行,机器的安全系统会启动,并拒绝制作披萨,直到手动操作员覆盖发生。

当这个选项首次被引入时,开发人员在确保在任何对mass_produce的调用之前(比如:

pizza_maker.mass_produce(number_of_pizzas=50, type=PizzaType.CHEESE)

必须有一个检查:

if not pizza_maker.is_configured(ProductionType.MASS_PRODUCE):
    pizza_maker.configure_for_mass_production()
    pizza.maker.wait_for_reconfiguration()

开发人员在代码审查中认真寻找这段代码,并确保始终进行适当的检查。然而,随着时间的推移,开发人员在项目中的循环进出,团队对必要检查的知识逐渐减少。想象一下,一个新的自动披萨制造机型号面市,不需要重新配置(调用configure_for_mass_production不会对系统进行任何更改)。只熟悉这种新型号的开发人员可能永远不会考虑在这些情况下调用configure_for_mass_production

现在,假设你是未来几年中的一名开发人员。比如说,你正在为披萨制造机编写新功能,而mass_produce函数正好适合你所需的用例。你怎么知道你需要为大批量生产进行显式检查,特别是对于旧型号呢?单元测试对你没有帮助,因为新功能的单元测试尚不存在。难道你真的想要等到集成测试失败(或者客户投诉)才发现你错过了这个检查吗?

这里有一些减少遗漏此类检查的策略:

依赖于你的类型系统

通过将特定类型的操作限制为特定类型,你可以防止混淆。想象一下,如果mass_produce只能从MassProductionPizzaMaker对象中调用。你可以编写函数调用,以确保在重新配置之后仅创建MassProductionPizzaMaker。你正在使用类型系统来防止出现错误(NewType在第四章中描述了类似的功能)。

嵌入先决条件更深

披萨制造商在使用前必须进行配置是一个先决条件。考虑通过将检查移动到mass_produce内部来将此作为mass_produce函数的先决条件。思考如何处理错误条件(例如抛出异常)。你将能够防止违反时间依赖性,但在运行时引入了不同的错误。你的具体用例将决定你认为哪种错误较小:违反时间依赖性还是处理新的错误情况。

留下线索

这不一定是捕获违反时间依赖性的策略。相反,如果所有其他努力都失败,它更像是提醒开发人员有关时间依赖性的最后努力。尝试在同一个文件中组织时间依赖性(理想情况下在彼此几行之内)。留下注释和文档,以便通知未来的开发人员这种联系。带有任何运气的话,那些未来的开发人员将看到这些线索,并知道这里存在时间依赖性。

在任何线性程序中,大多数行都对其前面的行有时间依赖性。这是正常的,你不需要针对每一种情况都采取减轻措施。相反,寻找可能仅在某些情况下应用的时间依赖关系(例如旧型号上的机器重新配置),或者如果忽略将会产生灾难性后果的时间依赖关系(例如在将用户输入字符串传递给数据库之前不对其进行净化)。权衡违反时间依赖性的成本与检测和减轻它的努力。这将取决于你的用例,但是当你减轻时间依赖性时,它可以在以后节省你大量的头痛。

可视化你的依赖关系

查找这些依赖关系并理解在哪里寻找潜在问题点可能是具有挑战性的。有时候,你需要更直观的表示方式。幸运的是,存在工具可以帮助你在视觉上理解你的依赖关系。

注意

对于以下许多示例,我将使用 GraphViz 库来显示图片。要安装它,请按照GraphViz 网站上的说明操作。

可视化包

很可能,你的代码使用了由 pip 安装的其他包。了解你依赖的所有包、它们的依赖关系及其依赖关系,依此类推,可能会有所帮助。

为此,我将安装两个包,pipdeptree和 GraphViz。pipdeptree是一个实用工具,可以告诉你各个包是如何相互交互的,而 GraphViz 则负责实际的可视化部分。在这个示例中,我将使用 mypy 代码库。我已经下载了 mypy 源代码,创建了一个虚拟环境,并从源代码安装了 mypy。²

从这个虚拟环境中,我已经安装了pipdeptree和 GraphViz:

pip install pipdeptree graphviz

现在我运行以下命令:

pipdeptree --graph-output png --exclude pipdeptree,graphviz > deps.png

你可以在图 16-5 中看到结果。

可视化包

图 16-5. 可视化包

我将忽略 wheel、setuptools 和 pip 包,专注于 mypy。在这种情况下,我看到安装的确切版本是 mypy,以及直接依赖项(在本例中为 typed_ast 1.4.2、typing-extensions 3.7.4.3 和 mypy-extensions 0.4.3)。pipdeptree还指定了存在的版本约束条件(例如只允许 mypy-extensions 版本大于或等于 0.4.3,但小于 0.5.0)。借助这些工具,你可以方便地获得打包依赖项的图形化表示。对于依赖项众多的项目尤为有用,特别是如果你积极维护许多包的话。

可视化导入

可视化包是一个相当高层次的视图,所以深入了解会更有帮助。你如何找出模块级别的导入内容?另一个名为pydeps的工具非常适合这个任务。

要安装它,你可以:

pip install pydeps

安装后,你可以运行:

pydeps --show-deps <source code location> -T png -o deps.png

我对 mypy 运行了这个命令,并得到了一个非常复杂和密集的图。在打印出来会浪费纸张,所以我决定放大特定部分在图 16-6 中。

导入可视化

图 16-6. 可视化导入

即使在依赖图的这个小部分中,箭头混乱不堪。然而,你可以看到代码库的许多不同区域依赖于mypy.options,以及fastparseerrors模块。由于这些图的规模,我建议一次深入挖掘你的代码库中的较小子系统。

可视化函数调用

如果你想获取比导入图更多的信息,你可以看到哪些函数彼此调用。这被称为调用图。首先,我将查看一个静态调用图生成器。这些生成器查看你的源代码并确定哪些函数调用哪些函数;不执行任何代码。在这个例子中,我将使用库 pyan3,可以通过以下命令安装:

pip install pyan3

要运行 pyan3,你需要在命令行上执行以下操作:

pyan3 <Python files> --grouped --annotated --html > deps.html

当我在 mypy 内部的dmypy文件夹上运行此操作(我选择了一个子文件夹来限制所绘制信息的数量),我收到一个交互式 HTML 页面,可以让我探索这些依赖关系。 图 16-7 显示了工具中的一个片段。

静态可视化函数调用

图 16-7. 静态可视化函数调用

请注意,这仅跟踪物理依赖关系,因为逻辑依赖关系直到运行时才知晓。如果您想在运行时看到调用图,请与dynamic调用图生成器一起执行您的代码。出于这个目的,我喜欢使用内置的 Python 分析器。Profiler会在程序执行期间审计您所做的所有函数调用,并记录性能数据。此外,整个函数调用历史记录也将保存在概要文件中。让我们试一试。

我首先会构建概要文件(出于尺寸考虑,我正在对 mypy 中的一个测试文件进行性能分析):

python -m cProfile -o deps.profile mypy/test/testutil.py

然后我将概要文件转换为 GraphViz 能理解的文件:一个 dot 文件。

pip install gprof2dot
gprof2dot --format=pstats deps.profile -o deps.dot

最后,我将使用 GraphViz 将.dot文件转换为.png文件。

dot deps.dot -Tpng > deps.png

再次提醒,这会产生大量的框和箭头,因此 图 16-8 只是一个小截图,说明了调用图的一部分。

动态可视化函数调用

图 16-8. 动态可视化函数调用

您可以了解函数被调用的次数,以及在函数中花费了多少执行时间。这不仅是找出性能瓶颈的好方法,还有助于理解您的调用图。

解读您的依赖图

好了,你画了这么多漂亮的图表;你可以用它们做什么?当您以这种方式看到您的依赖关系时,您会很好地了解到您的可维护性热点在哪里。请记住,每个依赖关系都是代码变更的原因。每当代码库中的任何内容发生变化时,它可能通过物理和逻辑依赖关系向上传播,可能会影响大片代码。

在这种情况下,您需要考虑您正在更改的内容与依赖它们的内容之间的关系。考虑依赖于您的代码量,以及您自己依赖的代码量。如果有很多依赖进入,但没有依赖出去,这被称为高fan-in。相反,如果进入的依赖不多,但您依赖的实体数量很大,这被称为高fan-out。 图 16-9 阐明了扇入和扇出之间的差异。

扇入与扇出的差异

图 16-9. 扇入与扇出的差异

你希望系统中具有高入度的实体成为依赖图的叶子节点,或者说在底部。你的代码库的大部分部分依赖于这些实体;你的每一个依赖关系都会影响到你的整个代码库。你还希望这些实体保持稳定,这意味着它们变化的频率应该较低。每次引入变化,都有可能由于大量的入度而影响到你的整个代码库。

另一方面,扩散实体应该位于依赖图的顶部。这里可能是你的大部分业务逻辑所在;随着业务的发展,它们将会变化。你的代码库中的这些部分可以承受更高的变更率;由于其相对较少的上游依赖关系,当行为变化时,它们的代码不太容易经常性地出现故障。

警告

改变扩散实体不会影响你代码库中的大部分假设,但我不能说它是否会破坏客户的假设。你希望外部行为保持向后兼容的程度是一个用户体验的问题,不在本书的讨论范围之内。

结语

依赖的存在并不决定你的代码有多健壮。关键在于你如何利用和管理这些依赖关系。依赖关系对于系统中合理的重用至关重要。你可以将代码分解为更小的块,并适当重新组织你的代码库。通过给你的依赖关系正确的方向性,你实际上可以增强代码的健壮性。通过增加可替换性和可扩展性,你可以使你的代码更易于维护。

但是,就像工程中的任何事物一样,总是有成本的。依赖关系是一种耦合;将代码库的不同部分链接在一起并进行更改可能会产生比你预期更广泛的影响。有不同类型的依赖关系,它们必须以不同的方式处理。物理依赖关系通过工具化是容易可视化的,但也在它们所施加的结构上是刚性的。逻辑依赖关系为你的代码库提供了扩展性,但它们的本质在运行时是隐藏的。时间依赖关系是以线性方式执行 Python 的必要部分,但当这些依赖关系变得不直观时,它们会带来大量未来的痛苦。

所有这些教训都假设你有可以依赖的代码片段。在下一章中,你将探索可组合的代码,或者将代码分解为更小的部分以便重用。你将学习如何组合对象、循环模式和函数,以将你的代码重新组织成新的用例。当你以可组合的代码来思考时,你将轻松地构建出新功能。你未来的维护者会感谢你。

¹ Eric S. Raymond. 大教堂与集市. Sebastopol, CA: O’Reilly Media, 2001.

² 创建虚拟环境是将您的依赖项与系统的 Python 安装隔离开来的好方法。

第十七章:可组合性

作为开发者,你面临的最大挑战之一是预测未来开发者将如何改变你的系统。业务会发展,今天的断言会成为未来的遗留系统。你如何支持这样的系统?你如何减少未来开发者在适应你的系统时所面临的阻力?你需要开发你的代码,使其能够在各种情况下运行。

在本章中,你将学习如何通过可组合性的思维方式来开发代码。当你以可组合性为重点编写代码时,你会创建小型、独立和可重复使用的代码。我会向你展示一个不具备可组合性的架构,以及它如何阻碍开发。然后你将学习如何以可组合性为考量来修复它。你将学会如何组合对象、函数和算法,使得你的代码库更具可扩展性。但首先,让我们看看可组合性如何提高可维护性。

可组合性

可组合性 侧重于构建具有最小相互依赖和少量业务逻辑嵌入的小组件。其目标是未来的开发者可以使用这些组件中的任何一个来构建他们自己的解决方案。通过使它们变小,你使它们更易于阅读和理解。通过减少依赖,你让未来的开发者不必担心拉取新代码所涉及的所有成本(例如你在第十六章中学到的成本)。通过保持组件基本免于业务逻辑,你允许你的代码解决新问题,即使这些新问题看起来与今天遇到的问题毫不相似。随着可组合组件数量的增加,开发者可以混合匹配你的代码,轻松创建全新的应用程序。专注于可组合性,使得代码更易于重用和扩展。

考虑厨房里的低调香料架。如果你要完全依靠香料混合物,比如南瓜派香料(肉桂、肉豆蔻、姜和丁香)或者五香粉(肉桂、茴香、八角、花椒和丁香),你会创造出什么样的餐点呢?你最终会主要制作那些以这些香料混合物为中心的食谱,比如南瓜派或五香鸡。虽然这些混合物使得制作专门的餐点非常容易,但是如果你需要制作只用单一成分的东西,比如肉桂丁香糖浆,你可以尝试用南瓜派香料或五香粉代替,并希望额外的成分不会产生冲突,或者你可以单独购买肉桂和丁香。

各种香料类似于小型、可组合的软件组件。你不知道未来可能要做什么菜,也不知道未来会有什么业务需求。专注于离散组件,你可以让合作者根据需要灵活使用,而不必尝试进行次优的替代或拉动其他组件。如果需要特定的组件混合(比如南瓜派香料),你可以自由地从这些组件构建应用。软件不像香料混合那样会过期;你可以既拥有蛋糕(或南瓜派),又能吃掉它。从小型、离散、可组合的软件构建专业应用程序,你会发现可以在下周或明年以全新的方式重复使用这些组件。

你在第二部分学习构建自己的类型时实际上已经见过可组合性。我建立了一系列小型的离散类型,可以在多个场景中重复使用。每种类型都为代码库中的概念词汇贡献了一部分。开发者可以使用这些类型来表示领域概念,同时也可以基于它们来定义新的概念。看一下来自第九章的一道汤的定义:

class ImperialMeasure(Enum):
    TEASPOON = auto()
    TABLESPOON = auto()
    CUP = auto()

class Broth(Enum):
    VEGETABLE = auto()
    CHICKEN = auto()
    BEEF = auto()
    FISH = auto()

@dataclass(frozen=True)
# Ingredients added into the broth
class Ingredient:
    name: str
    amount: float = 1
    units: ImperialMeasure = ImperialMeasure.CUP

@dataclass
class Recipe:
    aromatics: set[Ingredient]
    broth: Broth
    vegetables: set[Ingredient]
    meats: set[Ingredient]
    starches: set[Ingredient]
    garnishes: set[Ingredient]
    time_to_cook: datetime.timedelta

我能够用IngredientBrothImperialMeasure对象创建一个Recipe。所有这些概念本可以嵌入到Recipe本身,但这会增加重复使用的难度(如果有人想使用ImperialMeasure,依赖Recipe会令人困惑)。通过保持每种类型的分离,我允许未来的维护者构建新的类型,比如与汤无关的概念,而无需寻找解开依赖的方法。

这是类型组合的一个例子,我创建了可以以新方式混合和匹配的离散类型。在本章中,我将关注 Python 中的其他常见组合类型,如组合功能、函数和算法。例如,在像图 17-1 中的三明治店的简单菜单。

一个虚构的菜单,包含各种卷饼、三明治、配菜和饮料。菜单上宣传了一个选两种搭配的组合

图 17-1. 一个虚构的菜单

这个菜单是可组合性的另一个例子。顾客从菜单的第一部分选两个项目,再加一份配菜和一杯饮料。他们组合菜单的不同部分,以获得他们想要的完美午餐。如果这个菜单不可组合,你将不得不列出每个选项,以表示所有可能的组合(共有 1,120 种选择,这个菜单足以让大多数餐厅感到羞愧)。对于任何餐厅来说,这是不可行的;最好把菜单分解成可以拼接在一起的部分。

我希望你以同样的方式思考你的代码。代码不仅仅因为存在就变得可组合;你必须积极地以可组合性为设计目标。你希望看看你创建的类、函数和数据类型,问问自己如何编写它们,以便未来的开发人员可以重用它们。

考虑一个自动化厨房,创意地命名为 AutoKitchen,作为 Pat's Café的支柱。这是一个完全自动化的系统,能够制作菜单上的任何菜品。我希望能够轻松地向这个系统添加新的菜品;Pat's Café自豪地宣称拥有不断变化的菜单,开发人员厌倦了每次都要花费大量时间修改系统的大块内容。AutoKitchen 的设计如图 17-2 所示。

自动厨房设计

图 17-2. AutoKitchen 设计

这个设计相当简单明了。AutoKitchen 依赖于各种准备机制,称为准备者。每个准备者依赖于厨房元素,将成分转化为菜品组件(比如把碎牛肉变成煮熟的汉堡)。厨房元素,比如烤箱或烧烤架,被命令来烹饪各种成分;它们不知道具体使用的成分或生成的菜品组件。图 17-3 展示了一个特定准备者可能的样子。

这个设计是可扩展的,这是一件好事。添加新的三明治类型很简单,因为我不需要修改任何现有的三明治代码。然而,这并不太可组合。如果我想把盘子组件拿出来,为新的菜品重用它们(比如为 BLT 卷饼煮培根,或为芝士汉堡煮汤),我必须带着整个BLT 制造机肉饼融化机。如果我这么做了,我还得带上一个面包机和一个数据库。这正是我想避免的。

三明治准备者

图 17-3. 三明治准备者

现在,我想介绍一种新的汤:土豆、韭菜和培根。汤准备者已经知道如何处理从其他汤中得到的韭菜和土豆;现在我希望汤准备者知道如何制作培根。在修改汤准备者时,我有几个选项:引入对BLT 制造机的依赖,编写自己的培根处理代码,或找到一种方法单独重用培根处理部分,而不依赖于BLT 制造机

第一种选择存在问题:如果我依赖于BLT 制造机,我需要依赖于它所有的物理依赖,比如面包机汤准备者可能不想要所有这些包袱。第二种选择也不太好,因为现在我的代码库中存在培根处理的重复(一旦有两个,不要惊讶如果最终出现第三个)。唯一好的选择是找到一种方法将培根制作从BLT 制造机中分离出来。

然而,代码并不会仅仅因为你希望它可重复使用而变得可重复使用(虽然这样会很好)。你必须有意识地设计你的代码以实现可重复使用。你需要将其设计得小巧、独立,并且大部分独立于业务逻辑,以使其具有可组合性。而要做到这一点,你需要将策略与机制分开。

策略与机制

策略是你的业务逻辑,或者直接负责解决业务需求的代码。机制是提供实现策略的方法的代码片段。在前面的例子中,系统的策略是具体的菜谱。相反,如何制作这些菜谱的方式就是机制。

当你专注于使代码具有可组合性时,需要将策略与机制分开。机制通常是你想要重复使用的部分;如果它们与策略紧密耦合,就无法达到这个目的。这就是为什么一个依赖于BLT MakerSoup Preparer没有意义的原因。这样会导致一个策略依赖于一个完全独立且无关的策略。

当你连接两个无关的策略时,你开始创建一个难以稍后打破的依赖关系。随着你连接更多的策略,你创建了一团乱麻的代码。你会得到一个纠缠不清的依赖关系,并且解脱任何一个依赖关系都变得困难。这就是为什么你需要意识到你的代码库中哪些部分是策略,哪些是机制的原因。

Python 中一个很好的策略与机制的例子是logging模块。策略定义了需要记录的内容及其记录位置;而机制允许你设置日志级别、过滤日志消息和格式化日志。

在实际操作中,任何模块都可以调用日志方法:

logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.DEBUG)
logger.warning("Family did not match any restaurants: Lookup code A1503")

logging模块并不关心记录的内容或日志消息的格式。logging模块只提供了日志记录的方法。任何使用的应用程序需要定义策略,或者需要记录的内容,来确定需要记录什么。将策略与机制分离使得logging模块可以重复使用。你可以轻松地扩展代码库的功能,而不需要带上大量的负担。这就是你在代码库中应该追求的机制模型。

在前面的咖啡馆例子中,我可以改变代码架构以分离出机制。我的目标是设计一个系统,使得制作任何菜品组件都是独立的,并且可以将这些组件组合在一起以创建菜谱。这将使我能够在系统间重复使用代码,并在创建新菜谱时具有灵活性。图 17-4 展示了一个更具可组合性的架构(注意出于空间考虑,我已省略了一些系统)。

可组合架构

图 17-4. 可组合架构

通过将特定的准备器拆分到它们自己的系统中,我既实现了可扩展性又实现了可组合性。不仅易于扩展新的菜肴,比如一个新的三明治,而且还可以轻松定义新的连接,比如让汤准备器重复使用培根准备代码。

当像这样拆分您的机制时,您会发现编写策略变得更加简单。没有任何机制与策略绑定,您可以开始以声明式的方式编写,或者简单地声明要做什么。看看以下土豆、韭葱和培根汤的定义:

import bacon_preparer
import veg_cheese_preparer

def make_potato_leek_and_bacon_soup():
    bacon = bacon_preparer.make_bacon(slices=2)
    potatoes = veg_cheese_preparer.cube_potatoes(grams=300)
    leeks = veg_cheese_preparer.slice(ingredient=Vegetable.LEEKS, grams=250)

    chopped_bacon = chop(bacon)

    # the following methods are provided by soup preparer
    add_chicken_stock()
    add(potatoes)
    add(leeks)
    cook_for(minutes=30)
    blend()
    garnish(chopped_bacon)
    garnish(Garnish.BLACK_PEPPER)

通过仅关注代码中的配方是什么,我不必被如何制作培根或切丁土豆等外部细节困扰。我将培根准备器蔬菜/奶酪准备器汤准备器组合在一起来定义新的配方。如果明天出现新的汤(或任何其他菜肴),定义它将同样简单,就像一系列线性指令一样。策略将比您的机制更经常更改;使其易于添加、修改或删除以满足您的业务需求。

讨论主题

您的代码库中哪些部分易于重用?哪些部分难以重用?您是否想重用代码的策略还是机制?讨论使您的代码更具组合性和可重用性的策略。

如果预见将来需要重用,请尝试使您的机制可组合。这将加速未来的开发,因为开发人员将能够真正重用您的代码而几乎没有任何条件。您正在增加灵活性和可重用性,这将使代码更易于维护。

然而,可组合性是有代价的。通过在更多文件中分散功能,您会降低可读性,并引入更多的移动部件,这意味着变更可能会产生负面影响的机会增加。寻找引入组合性的机会,但要注意使您的代码过于灵活,需要开发人员浏览整个代码库才能找出如何编写简单工作流的情况。

在较小的规模上进行组合

AutoKitchen 示例向您展示了如何组合不同的模块和子系统,但您也可以在较小的范围内应用组合原则。您可以编写可组合的函数和算法,使您能够轻松构建新的代码。

组合函数

本书的很大一部分关注面向对象的原则(如 SOLID 和基于类的设计),但学习其他软件范式同样重要。一个越来越受欢迎的范式是函数式编程(FP)。在 OOP 中,一等公民是对象,而 FP 则专注于纯函数。纯函数是一个其输出完全由输入决定的函数。给定一个纯函数和一组输入参数,无论全局状态或环境如何改变,它始终返回相同的输出。

使函数式编程如此吸引人的原因是纯函数比带有副作用的函数更容易组合。副作用是函数在其返回值之外执行的任何操作,例如记录消息、进行网络调用或变异变量。通过从函数中删除副作用,使它们更容易重用。没有隐藏的依赖关系或令人惊讶的结果;整个函数依赖于输入数据,并且唯一的可观察效果是返回的数据。

然而,当您尝试重用代码时,您必须将所有该代码的物理依赖项拉入(并在运行时提供逻辑依赖项,如果需要的话)。使用纯函数时,您在函数调用图之外没有任何物理依赖项。您不需要拉入具有复杂设置或全局变量的额外对象。FP 鼓励开发人员编写短小、单一目的的函数,这些函数本质上是可组合的。

开发人员习惯于将函数视为任何其他变量。他们创建高阶函数,这些函数接受其他函数作为参数,或者作为返回值返回其他函数。最简单的例子是接受一个函数并调用两次:

from typing import Callable
def do_twice(func: Callable, *args, **kwargs):
    func(*args, **kwargs)
    func(*args, **kwargs)

这并不是一个非常激动人心的例子,但它为组合函数的一些非常有趣的方式打开了大门。事实上,有一个专门用于高阶函数的 Python 模块:functools。大部分functools,以及您编写的任何函数组合,将以装饰器的形式存在。

装饰器

装饰器是接受另一个函数并包装它或指定必须在函数执行之前执行的行为的函数。它为您提供了一种组合函数的方式,而不需要函数体彼此了解。

装饰器是 Python 中包装函数的主要方法之一。我可以将do_twice函数重写为更通用的repeat函数,如下所示:

def repeat(func: Callable, times: int = 1) -> Callable:
    ''' this is a function that calls the wrapped function
 a specified number of times
 '''
    def _wrapper(*args, **kwargs):
        for _ in range(times):
            func(*args, **kwargs)
    return _wrapper

@repeat(times=3)
def say_hello():
    print("Hello")

say_hello()
>>> "Hello"
"Hello"
"Hello"

再次,我将策略(重复说 hello)与机制(实际重复函数调用)分开。这是我可以在其他代码库中使用的机制,没有任何后果。我可以将此装饰器应用于代码库中的各种函数,例如一次为双层芝士汉堡制作两个汉堡饼或者为宴会活动批量生产特定订单。

当然,装饰器可以做的远不止简单重复函数调用。我最喜欢的一个装饰器之一来自backoffbackoff帮助您定义重试逻辑,或者在代码的不确定部分重试时采取的操作。考虑早期的AutoKitchen需要将数据保存在数据库中。它将保存接受的订单、当前库存水平以及制作每道菜所花费的时间。

在其最简单的形式下,代码将如下所示:

# setting properties of self.*_db objects will
# update data in the database
def on_dish_ordered(dish: Dish):
    dish_db[dish].count += 1

def save_inventory_counts(inventory):
    for ingredient in inventory:
        inventory_db[ingredient.name] = ingredient.count

def log_time_per_dish(dish: Dish, number_of_seconds: int):
    dish_db[dish].time_spent.append(number_of_seconds)

每当你与数据库(或任何其他 I/O 请求)打交道时,都要做好处理错误的准备。数据库可能宕机,网络可能中断,可能与你输入的数据发生冲突,或者可能出现任何其他错误。不能总是指望这段代码无错误地执行。业务不希望代码在第一次出错时就放弃;这些操作应该在放弃之前重试一定次数或一定时间段。

我可以使用backoff.on_exception指定这些函数在抛出异常时应进行重试:

import backoff
import requests
from autokitchen.database import OperationException
# setting properties of self.*_db objects will
# update data in the database
@backoff.on_exception(backoff.expo,
                      OperationException,
                      max_tries=5)
def on_dish_ordered(dish: Dish):
    self.dish_db[dish].count += 1

@backoff.on_exception(backoff.expo,
                      OperationException,
                      max_tries=5)
@backoff.on_exception(backoff.expo,
                      requests.exceptions.HTTPError,
                      max_time=60)
def save_inventory_counts(inventory):
    for ingredient in inventory:
        self.inventory_db[ingredient.name] = ingredient.count

@backoff.on_exception(backoff.expo,
                      OperationException,
                      max_time=60)
def log_time_per_dish(dish: Dish, number_of_seconds: int):
    self.dish_db[dish].time_spent.append(number_of_seconds)

通过使用装饰器,我能够修改行为而不会干扰函数体。每个函数现在在特定异常被抛出时将呈指数级退避(每次重试间隔时间更长)。每个函数还有自己的条件,用于决定在完全放弃之前重试多长时间或多少次。我在代码中定义了策略,但将实际的如何操作(即机制)抽象到了backoff库中。

特别注意save_inventory_counts函数:

@backoff.on_exception(backoff.expo,
                      OperationException,
                      max_tries=5)
@backoff.on_exception(backoff.expo,
                      requests.exceptions.HTTPError,
                      max_time=60)
def save_inventory_counts(inventory):
    # ...

我在这里定义了两个装饰器。在这种情况下,我将在OperationException出现时最多重试五次,在requests.exceptions.HTTPError出现时最多重试 60 秒。这就是组合性的体现;我可以混合和匹配完全不同的backoff装饰器来任意定义策略。

将机制直接写入函数与编写装饰器相比如何:

def save_inventory_counts(inventory):
    retry = True
    retry_counter = 0
    time_to_sleep = 1
    while retry:
        try:
            for ingredient in inventory:
                self.inventory_db[ingredient.name] = ingredient.count
        except OperationException:
            retry_counter += 1
            if retry_counter == 5:
                retry = False
        except requests.exception.HTTPError:
            time.sleep(time_to_sleep)
            time_to_sleep *= 2
            if time_to_sleep > 60:
                retry = False

处理重试机制所需的代码量最终会掩盖函数的实际意图。一眼看上去很难确定这个函数在做什么。此外,你需要在每个需要处理非确定性操作的函数中编写类似的重试逻辑。更容易的做法是组合装饰器来定义你的业务需求,避免在整个代码中重复繁琐的操作。

backoff 并非唯一有用的装饰器。还有一系列可组合的装饰器可以简化你的代码,例如用于保存函数结果的 functools.lru_cache,用于命令行应用的 click 中的 click.command,或用于限制函数执行时间的 timeout_decorator 中的 timeout_decorator.timeout。也不要害怕编写自己的装饰器。找到代码中结构相似的地方,寻找将机制抽象出来的方法。

组合算法

函数并不是你能进行小规模组合的唯一方式;你还可以组合算法。算法是解决问题所需的一系列定义步骤的描述,如对集合进行排序或比较文本片段。要使算法可组合,你需要再次将策略与机制分离。

考虑最后一节咖啡馆餐点的餐点推荐。假设算法如下:

Recommendation Algorithm #1

Look at all daily specials
Sort based on number of matching surplus ingredients
Select the meals with the highest number of surplus ingredients
Sort by proximity to last meal ordered
    (proximity is defined by number of ingredients that match)
Take only results that are above 75% proximity
Return up to top 3 results

如果我用 for 循环来写这一切,可能看起来会像这样:

def recommend_meal(last_meal: Meal,
                   specials: list[Meal],
                   surplus: list[Ingredient]) -> list[Meal]:
    highest_proximity = 0
    for special in specials:
        if (proximity := get_proximity(special, surplus)) > highest_proximity:
            highest_proximity = proximity

    grouped_by_surplus_matching = []
    for special in specials:
        if get_proximity(special, surplus) == highest_proximity:
            grouped_by_surplus_matching.append(special)

    filtered_meals = []
    for meal in grouped_by_surplus_matching:
        if get_proximity(meal, last_meal) > .75:
            filtered_meals.append(meal)

    sorted_meals = sorted(filtered_meals,
                          key=lambda meal: get_proximity(meal, last_meal),
                          reverse=True)

    return sorted_meals[:3]

这并不是最漂亮的代码。如果我没有事先在文本中列出步骤,理解代码并确保没有错误会花费更长时间。现在,假设一个开发者来找你,告诉你说,不够多的客户选择了推荐,并且他们想尝试不同的算法。新的算法如下:

Recommendation Algorithm #2

Look at all meals available
Sort based on proximity to last meal
Select the meals with the highest proximity
Sort the meals by number of surplus ingredients
Take only results that are a special or have more than 3 surplus ingredients
Return up to top 5 results

问题在于,这位开发者希望对这些算法进行 A/B 测试(以及他们提出的任何其他算法)。通过 A/B 测试,他们希望 75% 的客户来自第一个算法的推荐,而 25% 的客户来自第二个算法的推荐。这样,他们可以测量新算法与旧算法的表现。这意味着你的代码库必须支持这两种算法(并且灵活支持将来的新算法)。你不希望看到你的代码库里布满丑陋的推荐算法方法。

你需要将可组合性原则应用到算法本身。复制粘贴 for 循环代码片段并进行微调并不是一个可行的答案。为了解决这个问题,你需要再次区分策略和机制。这将帮助你分解问题并改进代码库。

这次你的策略是算法的具体细节:你正在排序什么,如何进行筛选,以及最终选择什么。机制是描述我们如何塑造数据的迭代模式。事实上,在我上面的代码中,我已经使用了一个迭代机制:排序。与其手动排序(并迫使读者理解我在做什么),我使用了 sorted 方法。我指明了我想要排序的内容和排序的关键。但我真的不关心(也不期望读者关心)实际的排序算法。

如果我要比较这两种算法,我可以将机制分解如下(我将使用 <尖括号> 标记策略):

Look at <a list of meals>
Sort based on <initial sorting criteria>
Select the meals with the <grouping criteria>
Sort the meals by <secondary sorting criteria>
Take top results that match <selection criteria>
Return up to top <number> results
注意

itertools 模块 是一个基于迭代的可组合算法的绝佳源头。它展示了当你创建抽象机制时可以做些什么。

有了这些想法,并借助 itertools 模块的帮助,我将再次尝试编写推荐算法:

import itertools
def recommend_meal(policy: RecommendationPolicy) -> list[Meal]:
    meals = policy.meals
    sorted_meals = sorted(meals, key=policy.initial_sorting_criteria,
                          reverse=True)
    grouped_meals = itertools.groupby(sorted_meals, key=policy.grouping_criteria)
    _, top_grouped = next(grouped_meals)
    secondary_sorted = sorted(top_grouped, key=policy.secondary_sorting_criteria,
                              reverse=True)
    candidates = itertools.takewhile(policy.selection_criteria, secondary_sorted)
    return list(candidates)[:policy.desired_number_of_recommendations]

然后,要将此算法用于实际操作,我要执行以下步骤:

# I've used named functions to increase readability in the following example
# instead of lambda functions
recommend_meal(RecommendationPolicy(
    meals=get_specials(),
    initial_sorting_criteria=get_proximity_to_surplus_ingredients,
    grouping_criteria=get_proximity_to_surplus_ingredients,
    secondary_sorting_criteria=get_proximity_to_last_meal,
    selection_criteria=proximity_greater_than_75_percent,
    desired_number_of_recommendations=3)
)

想象一下,能够在此动态调整算法是多么美好。我创建了一个不同的 RecommendationPolicy 并将其传递给 recommend_meal。通过将算法的策略与机制分离,我提供了许多好处。我使代码更易于阅读,更易于扩展,并且更加灵活。

结语

可组合的代码是可重用的代码。当您构建小型的、独立的工作单元时,您会发现它们很容易引入到新的上下文或程序中。要使您的代码可组合化,重点是分离策略和机制。无论您是在处理子系统、算法,甚至是函数,您会发现您的机制因为更大范围的重复使用而受益,策略也更容易修改。当您识别出可组合的代码时,您系统的健壮性将大大提高。

在接下来的章节中,您将学习如何在架构层面应用可扩展性和可组合性,使用基于事件的架构。基于事件的架构帮助您将代码解耦为信息的发布者和消费者。它们为您提供了一种在保留可扩展性的同时最小化依赖关系的方法。

第十八章:事件驱动架构

可扩展性在你的代码库的每个层次都非常重要。在代码层面,你利用可扩展性来使你的函数和类灵活。在抽象层面,你在代码库的架构中使用相同的原则。架构是塑造软件设计方式的高级指导方针和约束集。它是影响所有开发人员的愿景,包括过去、现在和未来。本章以及接下来的章节将展示两个示例,说明架构示例如何提高可维护性。你在本书的这部分中学到的一切都适用:良好的架构促进可扩展性,良好地管理依赖关系,并促进可组合性。

在本章中,你将学习有关事件驱动架构的知识。事件驱动架构围绕着事件或系统中的通知。它是解耦代码库不同部分的绝佳方式,同时还可以为新功能或性能扩展系统。事件驱动架构使你可以轻松引入新的变化,并带来最小的影响。首先,我想谈谈事件驱动架构所提供的灵活性。然后,我将介绍事件驱动架构的两种不同变体:简单事件和流式事件。虽然它们相似,但你会在稍微不同的场景中使用它们。

工作原理

当你专注于事件驱动架构时,你实际上是围绕着对刺激的反应。你一直在处理对刺激的反应,无论是从烤箱中取出烩菜还是在手机通知后从前门取货。在事件驱动架构中,你的代码被构建成了这种模型。你的刺激是某种事件的生产者。对这些事件的消费者就是对那个刺激的反应。事件只是从生产者传递到消费者的信息传输。Table 18-1 展示了一些常见的生产者-消费者对。

Table 18-1。日常事件及其消费者

生产者 消费者
厨房计时器响起 厨师从烤箱取出一份烩菜
烹饪员在菜做好时敲铃 服务员接过并上菜
闹钟响起 睡眠者醒来
机场最后一次登机通知 匆忙的家庭着急赶上他们的连接航班

事实上,你在编程时实际上一直在处理生产者和消费者。任何返回值的函数都是生产者,任何使用该返回值的代码片段都是消费者。观察:

def complete_order(order: Order):
    package_order(order)
    notify_customer_that_order_is_done(order)
    notify_restaurant_that_order_is_done(order)

在这种情况下,complete_order以完成订单的形式产生信息。根据函数名称,客户和餐馆正在消耗订单完成的事实。生产者通知消费者存在直接的链接。事件驱动架构的目标是断开这种物理依赖关系。目标是解耦生产者和消费者。生产者不知道消费者,消费者也不知道生产者。这就是推动事件驱动架构灵活性的因素。

通过这种解耦,向系统添加新的功能变得非常容易。如果需要新的消费者,可以添加它们而不需要触及生产者。如果需要不同的生产者,也可以添加它们而不需要触及消费者。这种双向的可扩展性允许您在隔离的多个代码部分中实现重大变更。

发生在幕后的事情非常巧妙。生产者和消费者之间不存在任何依赖关系,它们都依赖于传输机制,如图 18-1 所示。传输机制只是两段代码之间传递数据的方式。

图 18-01

图 18-1. 生产者-消费者关系

缺点

因为生产者和消费者依赖于传输机制,它们必须就消息格式达成一致。在大多数事件驱动架构中,生产者和消费者都会就常见标识符和消息格式达成一致。这确实在两者之间创建了逻辑依赖关系,但并非物理依赖关系。如果任何一方以不兼容的方式更改标识符或消息格式,则方案将崩溃。而且像大多数逻辑依赖关系一样,很难通过检查将这些依赖关系连接在一起。请参阅第十六章了解如何缓解这些问题。

由于代码的分离,当出现问题时,您的类型检查器将无法提供太多帮助。如果一个消费者开始依赖错误的事件类型,类型检查器将不会标记它。在更改生产者或消费者的类型时要格外小心,因为您将不得不更新所有其他生产者-消费者以匹配。

事件驱动架构可能会增加调试的难度。当您在调试器中逐步执行代码时,您将到达生成事件的代码,但是当您进入传输机制时,通常会进入第三方代码。在最坏的情况下,实际传输事件的代码可能在不同的进程中运行,甚至在不同的机器上运行。您可能需要多个活动调试器(每个进程或系统一个)来正确调试事件驱动架构。

最后,当使用事件驱动架构时,错误处理变得稍微复杂一些。大多数生产者与它们的消费者解耦;当消费者抛出异常或返回错误时,往往不容易从生产者端处理。

作为一个思维实验,考虑一下如果一个生产者产生了一个事件,而五个消费者消费了它会发生什么。如果被通知的第三个消费者抛出异常,应该发生什么?其他消费者应该得到异常吗,还是应该停止执行?生产者应该知道任何错误条件吗,还是错误应该被吞噬?如果生产者接收到异常,如果不同的消费者产生不同的异常会发生什么?对于所有这些问题没有一个正确的答案;请咨询您用于事件驱动架构的工具,以更好地了解在这些情况下会发生什么。

尽管存在这些缺点,事件驱动架构在需要为系统提供急需的灵活性的情况下是值得的。未来的维护者可以在最小的影响下替换您的生产者或消费者。他们可以引入新的生产者和消费者以创建新功能。他们可以快速集成外部系统,为新的合作伙伴关系打开大门。而且最重要的是,他们正在处理小型、模块化的系统,这些系统易于独立测试和理解。

简单事件

事件导向架构的最简单情况是处理简单事件,比如在某些条件变化时采取行动或通知您。您的信息生产者发送事件,您的消费者接收并对事件采取行动。有两种典型的实现方式:使用或不使用消息代理。

使用消息代理

消息代理是一种特定的代码片段,用作数据传输。生产者会将称为消息的数据发布到消息代理上的特定主题。主题只是一个唯一的标识符,比如一个字符串。它可以是简单的,比如“orders”,或者复杂的,比如“sandwich order is finished”。它只是一个命名空间,用于区分一个消息通道与另一个。消费者使用相同的标识符订阅一个主题。消息代理然后将消息发送给所有订阅该主题的消费者。这种类型的系统也被称为发布/订阅,简称 pub/sub。图 18-2 展示了一个假设的 pub/sub 架构。

ropy 1802

图 18-2. 一个假设的基于消息代理的架构

在本章中,我将设计为餐厅的自动无人机送餐服务通知系统。当顾客订单烹饪完成时,无人机系统立即启动,接收订单并将餐点送到正确的地址。此系统中有五个通知,我已将它们拆分成生产者-消费者在 Table 18-2 中。

表 18-2. 自动无人机送餐系统中的生产者和消费者

生产者 消费者
餐点已经烹饪完成 无人机已经通知进行取货
餐点已经烹饪完成 顾客已经收到餐点烹饪完成的通知
无人机正在途中 顾客已经收到关于预计到达时间的通知
无人机已经交付餐点 顾客已经收到交付通知
无人机已经交付餐点 餐厅已经收到交付通知

我不希望这些系统直接相互了解,因为处理顾客、无人机和餐厅的代码应保持独立(它们由不同的团队维护,我希望保持物理依赖低)。

首先,我将定义系统中存在的主题:餐点已经烹饪完成、无人机正在途中以及订单已经交付。

为了这个示例,我将使用 Python 库PyPubSub,这是用于单进程应用程序的发布-订阅 API。要使用它,您需要设置订阅主题的代码和发布到主题的其他代码。首先,您需要安装pypubsub

pip install pypubsub

然后,要订阅该主题,您需要指定主题和要调用的函数:

from pubsub import pub

def notify_customer_that_meal_is_done(order: Order):
    # ... snip ...

pub.subscribe(notify_customer_that_meal_is_done, "meal-done")

然后,要发布到这个主题,您需要执行以下操作:

from pubsub import pub

def complete_order(order: Order):
    packge_order(order)
    pub.publish("meal-done", order)
警告

订阅者在与发布者相同的线程中运行,这意味着任何阻塞 I/O,如等待读取套接字,将会阻塞发布者。这将影响所有其他订阅者,应避免这种情况发生。

这两段代码彼此之间没有任何关联;它们的全部依赖仅限于 PyPubSub 库以及在主题/消息数据上达成一致。这使得添加新的订阅者变得非常容易:

from pubsub import pub

def schedule_pick_up_for_meal(order: Order):
    '''Schedule a drone pick-up'''
    # ... snip ...

pub.subscribe(schedule_pick_up_for_meal, "meal-done")

您不能更容易扩展。通过定义存在于系统内的主题,您可以轻松创建新的生产者或消费者。随着系统的增长需求,您通过与现有消息系统的交互来扩展它。

PyPubSub 还提供了一些选项来帮助调试。您可以通过添加自己的功能来添加审计操作,例如创建新主题或发送消息。您可以添加错误处理程序来处理任何订阅者抛出的异常。您还可以设置订阅所有主题的订阅者。如果您想了解更多关于这些功能或 PyPubSub 中任何其他功能的信息,请查阅PyPubSub 文档

注意

PyPubSub 用于单进程应用程序;你无法发布到运行在其他进程或系统中的代码。其他应用程序可以提供此功能,例如KafkaRedis,或RabbitMQ。查阅每个工具的文档以了解如何在 Python 中使用它们。

观察者模式

如果你不想使用消息代理,你可以选择实现观察者模式。¹ 在观察者模式中,你的生产者包含一个观察者列表:这些在此场景中是消费者。观察者模式不需要单独的库来充当消息代理。

为了避免直接连接生产者和消费者,你需要将观察者的知识保持通用化。换句话说,将观察者的任何具体知识抽象出来。我将通过仅使用函数(类型注释为Callable)来做到这一点。以下是我将如何重写先前示例以使用观察者模式的方法:

def complete_order(order: Order, observers: list[Callable[Order]]):
    package_order(order)
    for observer_func in observers:
        observer(order)

在这种情况下,生产者只知道调用以通知的函数列表。要添加新的观察者,你只需将它们添加到作为参数传递的列表中。此外,由于这只是函数调用,你的类型检查器将能够检测到当生产者或其观察者以不兼容的方式发生变化时,这是消息代理范式的巨大优势。这也更容易调试,因为你不需要在调试器中步进第三方消息代理代码。

上面的观察者模式确实有一些缺点。首先,你对出现的错误更加敏感。如果观察者抛出异常,生产者需要能够直接处理(或者使用一个辅助函数或类来处理包装在try…except中的通知)。其次,生产者到观察者的连接比消息代理范式更直接。在消息代理范式中,发布者和订阅者可以连接起来,而不管它们在代码库中的位置如何。

相反,观察者模式要求通知的调用者(在前面的情况下是complete_order)知道观察者。如果调用者不直接知道观察者,那么它的调用者需要传递观察者。这可能一直延续到调用栈深处,直到有一段代码直接了解观察者为止。如果发现自己通过多个函数传递观察者以到达调用栈深处的生产者,请考虑使用消息代理代替。

如果您想更深入地了解简单事件的事件驱动架构,我推荐 Harry Percival 和 Bob Gregory(O’Reilly)的书籍Architecture Patterns with Python,其第二部分完全是关于事件驱动架构的。

讨论主题

事件驱动架构如何提升代码库内的解耦性?观察者模式或消息代理哪一个更适合您的需求?

流式事件

在前面的部分中,简单事件被表示为满足某一条件时发生的离散事件。消息代理和观察者模式是处理简单事件的好方法。然而,一些系统处理永不停止的事件序列。事件以连续的数据流的形式流入系统。想象一下上一节中描述的无人机系统。考虑每个无人机传输的所有数据。可能包括位置数据、电池电量、当前速度、风数据、天气数据和当前负载重量。这些数据将定期传入,并且您需要一种处理方式。

在这类用例中,您不希望构建所有发布/订阅或观察者的样板代码;您需要一种与您的用例匹配的架构。您需要一个以事件为中心并为处理每个事件定义工作流的编程模型。这就是响应式编程的作用。

响应式编程是围绕事件流的一种架构风格。您将数据源定义为这些流的生产者,然后将多个观察者链接在一起。每个观察者在数据变化时得到通知,并定义一系列操作来处理数据流。响应式编程风格由ReactiveX推广。在本节中,我将使用 ReactiveX 的 Python 实现:RxPY。

我将使用pip安装 RxPy:

pip install rx

接下来,我需要定义一个数据流。在 RxPY 术语中,这被称为可观察对象。例如,我将使用一个硬编码的单个可观察对象进行示例,但实际上,您将从真实数据生成多个可观察对象。

import rx
# Each one of these is simulating an independent real-world event streaming in
observable = rx.of(
    LocationData(x=3, y=12, z=40),
    BatteryLevel(percent=95),
    BatteryLevel(percent=94),
    WindData(speed=15, direction=Direction.NORTH),
    # ... snip 100s of events
    BatteryLevel(percent=72),
    CurrentWeight(grams=300)
)

此可观察对象是从不同类型事件的事件列表中生成的,用于无人机数据。

下一步需要定义每个事件的处理方法。一旦有可观察对象,观察者可以订阅它,类似于发布/订阅机制:

def handle_drone_data(value):
    # ... snip handle drone data ...

observable.subscribe(handle_drone_data)

这看起来与普通的发布/订阅习语并没有太大不同。

管道运算符真正的魔力就在这里。RxPY 允许您将操作管道化或链接在一起,形成一个过滤器、转换和计算的管道。例如,我可以使用rx.pipe编写一个操作符管道来获取飞行器的平均重量:

import rx.operators

get_average_weight = observable.pipe(
    rx.operators.filter(lambda data: isinstance(data, CurrentWeight)),
    rx.operators.map(lambda cw: cw.grams),
    rx.operators.average()
)

# save_average_weight does something with the final data
# (e.g. save to database, print to screen, etc.)
get_average_weight.subscribe(save_average_weight)

类似地,我可以编写一个管道链,跟踪无人机离开餐厅后的最大高度:

get_max_altitude = observable.pipe(
    rx.operators.skip_while(is_close_to_restaurant),
    rx.operators.filter(lambda data: isinstance(data, LocationData)),
    rx.operators.map(lambda loc: loc.z),
    rx.operators.max()
)

# save max altitude does something with the final data
# (e.g. save to database, print to screen, etc)
get_max_altitude.subscribe(save_max_altitude)
注意

Lambda 函数 只是一个没有名称的内联函数。它通常用于只使用一次的函数,你不希望将函数的定义放得离它的使用太远。

这是我们老朋友可组合性(如第十七章中所见)在帮助我们。我可以随心所欲地组合不同的操作符,以产生符合我的用例的数据流。RxPY 支持超过一百个内置操作符,以及定义自己操作符的框架。你甚至可以将一个管道的结果组合成其他程序部分可以观察的新事件流。这种可组合性,加上事件订阅的解耦特性,使你在编写代码时拥有极大的灵活性。此外,响应式编程鼓励不可变性,大大降低了出错的可能性。你可以连接新的管道,组合操作符,异步处理数据等等,这些都是响应式框架如 RxPY 能够做到的。

在独立环境中调试也变得容易了。虽然你不能轻易地通过调试器逐步进行 RxPY 的调试(你会陷入与操作和可观察对象相关的大量复杂代码中),但你可以步进到你传递给操作符的函数中。测试也非常简单。由于所有的函数都应该是不可变的,你可以单独测试它们中的任何一个。最终你会得到很多小而专用的函数,这些函数易于理解。

这种模型在围绕数据流的系统中表现出色,比如数据管道和抽取、转换、加载(ETL)系统。在以对 I/O 事件的反应为主的应用程序中,如服务器应用程序和 GUI 应用程序中,它也非常有用。如果响应式编程符合你的领域模型,我鼓励你阅读RxPY 文档。如果你想要更结构化的学习,我推荐视频课程Reactive Python for Data Science或书籍Hands-On Reactive Programming with Python: Event-Driven Development Unraveled with RxPY,作者是 Romain Picard(O’Reilly)。

总结思考

事件驱动架构非常强大。事件驱动架构允许你将信息的生产者和消费者分开。通过解耦这两者,你为系统引入了灵活性。你可以替换功能、在隔离环境中测试你的代码,或者通过引入新的生产者或消费者来扩展新功能。

设计事件驱动系统有许多方式。您可以选择在系统中继续使用简单事件和观察者模式来处理轻量级事件。随着规模扩大,您可能需要引入消息代理,例如使用 PyPubSub。甚至在跨进程或系统进行扩展时,您可能需要使用另一个库作为消息代理。最后,当您接近事件流时,您可以考虑使用响应式编程框架,如 RxPY。

在接下来的章节中,我将介绍一种不同类型的架构范例:插件架构。插件架构提供了与事件驱动架构类似的灵活性、可组合性和可扩展性,但方式完全不同。而事件驱动架构专注于事件,插件架构则专注于可插拔的实现单元。您将看到,插件架构如何为您提供丰富的选项,以构建一个易于维护的健壮代码库。

¹ 观察者模式首次被描述在《设计模式:可复用面向对象软件的基础》一书中,作者是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(Addison-Wesley Professional)。这本书通常被称为“四人组(GoF)”书籍。

第十九章:可插拔的 Python

建立稳健代码库最大的挑战在于预测未来。你永远不可能完全猜透未来的开发者会做什么。最好的策略不是完全精准地预见,而是创建灵活性,使未来的合作者可以用最少的工作接入你的系统。在本章中,我将专注于创建可插拔代码。可插拔的代码允许你定义稍后提供的行为。你可以定义一个带有扩展点的框架,或者是系统中其他开发者将用来扩展功能的部分。

想象一台放在厨房台面上的搅拌机。你可以选择各种附件与你的搅拌机一起使用:揉面包的钩子、打蛋和奶油的打蛋器,以及通用搅拌的扁平搅拌器。每个附件都有特定的用途。很棒的是,你可以根据情况拆卸和安装钩子或刀片。你不需要为每个用例购买全新的搅拌机;你在需要时插入你需要的东西即可。

这就是可插拔 Python 的目标。当需要新功能时,你无需重建整个应用程序。你构建扩展或附件,它们可以轻松地连接到坚实的基础上。你选择你特定用例所需的功能,然后将其插入你的系统中。

在本书的大部分内容中,我一直在用各种自动食品制造器做例子。在本章中,我将执行各种合并并设计一个可以结合它们所有的系统。我想要构建一个可以接受我讲过的任何食谱并烹饪它们的系统。我称之为“终极厨房助手”(如果你认为这是个糟糕的名字,现在你知道为什么我不从事市场营销工作了)。

终极厨房助手包含了你在厨房工作所需的所有指示和装备。它知道如何切片、切块、炸、煎、烘烤、烧烤和混合任何食材。它附带了一些预制的食谱,但真正的魔力在于顾客可以购买现成的模块来扩展其功能(比如“意大利面条制作模块”,满足意大利菜的需求)。

问题在于,我不希望代码变得难以维护。有很多不同的菜需要做,我希望系统具有某种灵活性,而不是因为大量物理依赖而使系统变成意大利面条代码(尽管你的系统自己在厨房制作意大利面条非常鼓励!)。就像给搅拌机插上新附件一样,我希望开发者能够连接不同的附件来解决他们的用例。我甚至希望其他组织为终极厨房助手构建模块。我希望这个代码库具有可扩展性和可组合性。

我将用这个例子来说明三种不同的插入不同 Python 结构的方法。首先,我将专注于如何使用模板方法模式插入算法的特定部分。然后,我将讲解如何使用策略模式插入整个类。最后,我将向您介绍一个非常有用的库,stevedore,以在更大的架构规模上进行插件。所有这些技术都将帮助您为未来的开发人员提供所需的可扩展性。

模板方法模式

模板方法模式 是一种填充算法空白的设计模式。¹ 思想是你定义一个算法为一系列步骤,但强制调用者重写其中的一些步骤,如 图 19-1 所示。

ropy 1901

图 19-1. 模板方法模式

终极厨房助手首先介绍的是一个披萨制作模块。虽然传统的酱和奶酪披萨很棒,但我希望终极厨房助手更加灵活。我希望它能处理各种类似披萨的实体,从黎巴嫩马努什到韩国烤牛肉披萨。为了制作这些类似披萨的菜肴中的任何一种,我希望机制执行一系列类似的步骤,但让开发人员调整某些操作,以制作他们自己风格的披萨。 图 19-2 描述了这样一个披萨制作算法。

ropy 1902

图 19-2. 披萨制作算法

每个披萨将使用相同的基本步骤,但我希望能够调整某些步骤(准备配料、添加预烘烤配料和添加后烘烤配料)。我在应用模板方法模式时的目标是使这些步骤可插拔。

在最简单的情况下,我可以将函数传递给模板方法:

@dataclass
class PizzaCreationFunctions:
    prepare_ingredients: Callable
    add_pre_bake_toppings: Callable
    add_post_bake_toppings: Callable

def create_pizza(pizza_creation_functions: PizzaCreationFunctions):
    pizza_creation_functions.prepare_ingredients()
    roll_out_pizza_base()
    pizza_creation_functions.add_pre_bake_toppings()
    bake_pizza()
    pizza_creation_functions.add_post_bake_toppings()

现在,如果您想要制作披萨,您只需传入自己的函数:

pizza_creation_functions = PizzaCreationFunctions(
    prepare_ingredients=mix_zaatar,
    add_pre_bake_toppings=add_meat_and_halloumi,
    add_post_bake_toppings=drizzle_olive_oil
)

create_pizza(pizza_creation_functions)

这对任何披萨来说都非常方便,现在或将来。随着新的披萨制作能力上线,开发人员需要将他们的新函数传递到模板方法中。这些开发人员可以插入披萨制作算法的特定部分,以满足他们的需求。他们根本不需要了解他们的用例;他们可以自由地扩展系统,而不会被改变旧代码所困扰。假设他们想要创建烤牛肉披萨。我只需传入一个新的 PizzaCreationFunctions,而不是改变 create_pizza

pizza_creation_functions = PizzaCreationFunctions(
    prepare_ingredients=cook_bulgogi,
    add_pre_bake_toppings=add_bulgogi_toppings,
    add_post_bake_toppings=garnish_with_scallions_and_sesame
)

create_pizza(pizza_creation_functions)

策略模式

模板方法模式非常适合交换算法中的某些部分,但如果您想要替换整个算法呢?对于这种情况,存在一个非常类似的设计模式:策略模式。

策略模式用于将整个算法插入到上下文中。² 对于最终的厨房助手,考虑专门从事 Tex-Mex 的模块(一种将美国西南部和墨西哥北部菜肴混合的美国地区菜肴)。可以从一组共同的食材制作出各种各样的菜肴;你可以通过不同方式混搭这些不同的配料。

例如,你会在大多数 Tex-Mex 菜单上找到以下配料:玉米或小麦面粉的玉米饼,豆类,碎牛肉,鸡肉,生菜,番茄,鳄梨酱,莎莎酱和奶酪。从这些配料中,你可以制作出塔科斯、弗劳塔斯、奇米昌加斯、恩奇拉达、塔科沙拉、玉米片、戈迪塔……种类繁多。我不希望系统限制所有不同的 Tex-Mex 菜肴;我希望不同的开发团队提供如何制作这些菜肴的信息。

要使用策略模式做到这一点,我需要定义最终的厨房助手所做的事情以及策略所做的事情。在这种情况下,最终的厨房助手应提供与配料交互的机制,但未来的开发人员可以自由添加新的 Tex-Mex 调配方案,如TexMexStrategy

与任何设计为可扩展的代码一样,我需要确保我最终的厨房助手和 Tex-Mex 模块之间的交互符合前置条件和后置条件,即传递给 Tex-Mex 模块的内容以及输出的内容。

假设最终的厨房助手有编号的箱子用于放置食材。Tex-Mex 模块需要知道常见的 Tex-Mex 食材放在哪些箱子里,以便可以利用最终的厨房助手进行准备和烹饪。

@dataclass
class TexMexIngredients:
    corn_tortilla_bin: int
    flour_tortilla_bin: int
    salsa_bin: int
    ground_beef_bin: int
    # ... snip ..
    shredded_cheese_bin: int

def prepare_tex_mex_dish(tex_mex_recipe_maker: Callable[TexMexIngredients]);
    tex_mex_ingredients = get_available_ingredients("Tex-Mex")
    dish = tex_mex_recipe_maker(tex_mex_ingredients)
    serve(dish)

函数prepare_tex_mex_dish收集配料,然后委托给实际的tex_mex_recipe_maker来创建要服务的菜肴。tex_mex_recipe_maker就是策略。这与模板方法模式非常相似,但通常只传递单个函数而不是一组函数。

未来的开发人员只需编写一个根据配料实际进行准备的函数。他们可以编写:

import tex_mex_module as tmm
def make_soft_taco(ingredients: TexMexIngredients) -> tmm.Dish:
    tortilla = tmm.get_ingredient_from_bin(ingredients.flour_tortilla_bin)
    beef = tmm.get_ingredient_from_bin(ingredients.ground_beef_bin)
    dish = tmm.get_plate()
    dish.lay_on_dish(tortilla)
    tmm.season(beef, tmm.CHILE_POWDER_BLEND)
    # ... snip

prepare_tex_mex_dish(make_soft_taco)

如果他们决定未来某个时候提供对不同菜肴的支持,他们只需编写一个新的函数:

def make_chimichanga(ingredients: TexMexIngredients):
    # ... snip

开发人员可以随时随地继续定义函数。就像模板方法模式一样,他们可以在对原始代码影响最小的情况下插入新功能。

注意

与模板方法一样,我展示的实现与《四人组设计模式》中最初描述的有些不同。原始实现涉及包装单个方法的类和子类。在 Python 中,仅传递单个函数要简单得多。

插件架构

策略模式和模板方法模式非常适合插入小功能块:在这里是一个类或一个函数。然而,同样的模式也适用于你的架构。能够注入类、模块或子系统同样重要。一个名为stevedore的 Python 库是管理插件的一个非常有用的工具。

插件是可以在运行时动态加载的代码片段。代码可以扫描已安装的插件,选择合适的插件,并将责任委派给该插件。这是另一个可扩展性的例子;开发人员可以专注于特定的插件而不用触及核心代码库。

插件架构不仅具有可扩展性的优点:

  • 您可以独立部署插件,而不影响核心,这使得您在推出更新时拥有更多的粒度。

  • 第三方可以编写插件,而无需修改您的代码库。

  • 插件可以在与核心代码库隔离的环境中开发,减少创建紧密耦合代码的可能性。

为了演示插件的工作原理,假设我想支持终极厨房助手的生态系统,用户可以单独购买和安装模块(例如上一节中的 Tex-Mex 模块)。每个模块为终极厨房助手提供一组食谱、特殊设备和食材存储。真正的好处在于,每个模块都可以与终极厨房助手核心分开开发;每个模块都是一个插件。

设计插件时的第一步是确定核心与各种插件之间的契约。问问自己核心平台提供了哪些服务,您期望插件提供什么。在终极厨房助手的情况下,Figure 19-3 展示了我将在接下来的示例中使用的契约。

ropy 1903

图 19-3. 核心与插件之间的契约

我想将这个契约放入代码中,以便清楚地表达我对插件的期望:

from abc import abstractmethod
from typing import runtime_checkable, Protocol

from ultimate_kitchen_assistant import Amount, Dish, Ingredient, Recipe

@runtime_checkable
class UltimateKitchenAssistantModule(Protocol):

    ingredients: list[Ingredient]

    @abstractmethod
    def get_recipes() -> list[Recipe]:
        raise NotImplementedError

    @abstractmethod
    def prepare_dish(inventory: dict[Ingredient, Amount],
                     recipe: Recipe) -> Dish:
        raise NotImplementedError

这就是插件的定义。要创建符合我的期望的插件,我只需创建一个从我的基类继承的类。

class PastaModule(UltimateKitchenAssistantModule):
    def __init__(self):
        self.ingredients = ["Linguine",
                             # ... snip ...
                            "Spaghetti" ]

    def get_recipes(self) -> list[Recipe]:
        # ... snip returning all possible recipes ...

    def prepare_dish(self, inventory: dict[Ingredient, Amount],
                     recipe: Recipe) -> Dish:
        # interact with Ultimate Kitchen Assistant to make recipe
        # ... snip ...

一旦您创建了插件,您需要使用 stevedore 将其注册。stevedore 将插件与一个命名空间或将插件分组在一起的标识符进行匹配。它通过使用 Python 的入口点在运行时发现组件来实现这一点。³

您可以通过setuptoolssetup.py注册插件。许多 Python 包使用setup.py来定义打包规则,其中之一就是入口点。在ultimate_kitchen_assistantsetup.py中,我将我的插件注册如下:

from setuptools import setup

setup(
    name='ultimate_kitchen_assistant',
    version='1.0',
    #.... snip ....

    entry_points={
        'ultimate_kitchen_assistant.recipe_maker': [
            'pasta_maker = ultimate_kitchen_assistant.pasta_maker:PastaModule',
            'tex_mex = ultimate_kitchen_assistant.tex_mex:TexMexModule'
        ],
    },
)
注意

如果你在链接插件时遇到问题,请查看 entry-point-inspector 获取调试帮助。

我正在将我的 PastaMaker 类(在 ultimate_kitchen_assistant.pasta_maker 包中)绑定到命名空间为 ultimate_kitchen_assistant.recipe_maker 的插件上。我还创建了另一个名为 TexMexModule 的假设性插件。

一旦插件被注册为入口点,你可以在运行时使用 stevedore 动态加载它们。例如,如果我想从所有插件中收集所有的菜谱,我可以编写以下代码:

import itertools
from stevedore import extension
from ultimate_kitchen_assisstant import Recipe

def get_all_recipes() -> list[Recipe]:
    mgr = extension.ExtensionManager(
            namespace='ultimate_kitchen_assistant.recipe_maker',
            invoke_on_load=True,
        )

    def get_recipes(extension):
        return extension.obj.get_recipes()

    return list(itertools.chain(mgr.map(get_recipes)))

我使用 stevedore.extension.ExtensionManager 查找和加载命名空间为 ultimate_kitchen_assistant.recipe_maker 的所有插件。然后,我可以对找到的每个插件映射(或应用)一个函数以获取它们的菜谱。最后,我使用 itertools 将它们全部连接在一起。无论我设置了多少个插件,都可以用这段代码加载它们。

假设用户想要从意大利面机制造一些东西,比如“意式香肠意面”。所有调用代码需要做的就是请求一个名为 pasta_maker 的插件。我可以通过 stevedore.driver.DriverManager 加载特定的插件。

from stevedore import driver

def make_dish(recipe: Recipe, module_name: str) -> Dish:
    mgr = driver.DriverManager(
        namespace='ultimate_kitchen_assistant.recipe_maker',
        name=module_name,
        invoke_on_load=True,
    )

    return mgr.driver.prepare_dish(get_inventory(), recipe)

讨论主题

你的系统哪些部分可以使用插件架构?这如何使你的代码库受益?

stevedore 提供了一种很好的方式来解耦代码;将代码分离成插件使其保持灵活和可扩展。记住,可扩展程序的目标是限制对核心系统所需的修改次数。开发者可以独立创建插件,测试它们,并将其无缝集成到你的核心中。

我最喜欢的 stevedore 的部分是它实际上可以跨包工作。你可以在完全独立的 Python 包中编写插件,而不是核心包。只要插件使用相同的命名空间,stevedore 就可以把所有东西组合起来。stevedore 还有许多其他值得一探的功能,比如事件通知、通过多种方法启用插件以及自动生成插件文档。如果插件架构符合你的需求,我强烈建议多了解 stevedore。

警告

你实际上可以注册任何类作为插件,无论它是否可替换基类。因为代码被 stevedore 分离到一个抽象层中,你的类型检查器将无法检测到这一点。在使用插件之前,考虑在运行时检查接口以捕捉任何不匹配。

总结思考

创建可插拔的 Python 程序时,你赋予了合作者隔离新功能的能力,同时仍然可以轻松地将其集成到现有的代码库中。开发者可以使用模板方法模式(Template Method Pattern)插入现有算法,使用策略模式(Strategy Pattern)插入整个类或算法,或者使用 stevedore 插入整个子系统。当你想将插件跨离散的 Python 包中分布时,stevedore 尤为有用。

这结束了关于第 III 部分的内容,重点是可扩展性。编写可扩展的代码遵循开闭原则,使得您可以轻松添加代码而无需修改现有代码。事件驱动架构和插件架构是设计可扩展性的绝佳例子。所有这些架构模式都要求您了解依赖关系:物理、逻辑和时间依赖。当您找到减少物理依赖的方法时,您会发现您的代码变得可组合,并可以随意重新组合成新的组合形式。

本书的前三部分着重于可以使您的代码更易于维护和阅读,并减少错误发生的几率。然而,错误仍然可能会出现;它们是软件开发中不可避免的一部分。为了应对这一点,您需要使错误在进入生产环境之前易于检测。您将学会如何使用诸如 linter 和测试工具来实现这一点,详见第 IV 部分,构建安全网络

¹ Erich Gamma, Richard Helm, Ralph E. Johnson, and John Vlissides. 设计模式:可复用面向对象软件的元素. 波士顿, MA: Addison-Wesley Professional, 1994.

² Erich Gamma, Richard Helm, Ralph E. Johnson, and John Vlissides. 设计模式:可复用面向对象软件的元素. 波士顿, MA: Addison-Wesley Professional, 1994.

³ 入口点在与 Python 包装互动方面可能会很复杂,但这超出了本书的范围。您可以在https://oreil.ly/bMyJS了解更多信息。

第四部分:构建安全网

欢迎来到本书的第四部分,这部分内容讲述了围绕代码库构建安全网的重要性。想象一下一个在高空中危险地平衡的走钢丝演员。无论表演者练习了多少次他们的表演,都总是会有一套安全措施以防万一。走钢丝的演员可以充满信心地表演,相信如果他们失足,一定会有东西来阻止他们坠落。你希望为你的合作者提供同样的信心和信任,让他们在你的代码库中工作。

即使你的代码完全没有错误,它能保持多久呢?每一次变更都带来风险。每一个新的开发人员进入代码库都需要时间才能完全理解其中的复杂性。客户会改变主意,要求完全与六个月前相反的东西。这都是软件开发生命周期的自然部分。

你的开发安全网是静态分析和测试的结合。关于测试以及如何编写好测试的话题已经有很多文章写过。在接下来的章节中,我将重点介绍为什么编写测试,如何决定编写哪些测试,以及如何使这些测试更有价值。我将超越简单的单元测试和集成测试,谈论更高级的测试技术,如验收测试、基于属性的测试和变异测试。

第二十章:静态分析

在进行测试之前,我首先想谈一下静态分析。静态分析 是一组工具,检查您的代码库,寻找潜在的错误或不一致之处。它是发现常见错误的重要工具。实际上,您已经在使用一个静态分析工具:mypy。Mypy(和其他类型检查器)检查您的代码库并找到类型错误。其他静态分析工具检查其他类型的错误。在本章中,我将介绍常见的用于代码检查、复杂度检查和安全扫描的静态分析工具。

代码检查

我将首先向您介绍的静态分析工具类别称为 代码检查器。代码检查器在您的代码库中搜索常见的编程错误和风格违规。它们的名称源自最初的代码检查器:一个名为 lint 的程序,用于检查 C 语言程序的常见错误。它会搜索“模糊”逻辑并尝试消除这种模糊(因此称为 linting)。在 Python 中,您最常遇到的代码检查器是 Pylint。Pylint 用于检查大量常见错误:

  • 某些违反 PEP 8 Python 风格指南的风格违规

  • 不可达的死代码(例如在返回语句之后的代码)

  • 违反访问限制条件(例如类的私有或受保护成员)

  • 未使用的变量和函数

  • 类内的内聚性不足(在方法中没有使用 self,公共方法过多)

  • 缺少文档,如文档字符串形式

  • 常见的编程错误

这些错误类别中的许多是我们先前讨论过的内容,例如访问私有成员或函数需要成为自由函数而不是成员函数(如第十章讨论的)。像 Pylint 这样的代码检查工具将为您补充本书中学到的所有技术;如果您违反了我一直提倡的一些原则,代码检查工具将为您捕捉这些违规行为。

Pylint 在查找代码中一些常见错误方面也非常有用。考虑一个开发者添加将所有作者的食谱书籍添加到现有列表的代码:

def add_authors_cookbooks(author_name: str, cookbooks: list[str] = []) -> bool:

    author = find_author(author_name)
    if author is None:
        assert False, "Author does not exist"
    else:
        for cookbook in author.get_cookbooks():
            cookbooks.append(cookbook)
        return True

这看起来无害,但是这段代码中有两个问题。请花几分钟看看您能否找到它们。

现在让我们看看 Pylint 能做什么。首先,我需要安装它:

pip install pylint

然后,我将对上述示例运行 Pylint:

pylint code_examples/chapter20/lint_example.py
************* Module lint_example

code_examples/chapter20/lint_example.py:11:0: W0102:
    Dangerous default value [] as argument (dangerous-default-value)
code_examples/chapter20/lint_example.py:11:0: R1710:
    Either all return statements in a function should return an expression,
    or none of them should. (inconsistent-return-statements)

Pylint 在我的代码中标识出了两个问题(实际上找到了更多,比如缺少文档字符串,但为了本讨论的目的我已经省略了它们)。首先,存在一个危险的可变默认参数形式为[]。关于这种行为已经写了很多文章,但这对于错误,特别是对于新手来说,是一个常见的陷阱。

另一个错误更加微妙:不是所有分支都返回相同的类型。“等等!”你会说。“没关系,因为我断言,这会引发一个错误,而不是通过if语句(返回None)。然而,虽然assert语句很棒,但它们可以被关闭。当你给 Python 传递-O标志时,它会禁用所有assert语句。因此,当打开-O标志时,这个函数返回None。值得一提的是,mypy 并不会捕获这个错误,但是 Pylint 可以。更好的是,Pylint 在不到一秒钟的时间内找到了这些错误。

无论你是否犯下这些错误,或者你是否总是在代码审查中找到它们。在任何代码库中都有无数开发人员在工作,错误可能发生在任何地方。通过强制执行像 Pylint 这样的代码检查工具,你可以消除非常常见的可检测错误。有关内置检查器的完整列表,请参阅Pylint 文档

编写你自己的 Pylint 插件

当你编写自己的插件时,真正的 Pylint 魔法开始发挥作用(有关插件架构的更多信息,请参阅第十九章)。Pylint 插件允许你编写自己的自定义检查器或规则。虽然内置检查器查找常见的 Python 错误,但你的自定义检查器可以查找你问题领域中的错误。

看一看远在第四章的代码片段:

ReadyToServeHotDog = NewType("ReadyToServeHotDog", HotDog)

def prepare_for_serving() -> ReadyToServeHotDog:
    # snip preparation
    return ReadyToServeHotDog(hotdog)

在第四章中,我提到过,为了使NewType生效,你需要确保只能通过blessed方法来构造它,或者强制执行与该类型相关的约束。当时,我的建议是使用注释来给代码读者一些提示。然而,使用 Pylint,你可以编写一个自定义检查器来查找违反这一期望的情况。

这是插件的完整内容。之后我会为你详细解释:

from typing import Optional

import astroid

from pylint.checkers import BaseChecker
from pylint.interfaces import IAstroidChecker
from pylint.lint.pylinter import PyLinter

class ServableHotDogChecker(BaseChecker):
    __implements__ = IAstroidChecker

    name = 'unverified-ready-to-serve-hotdog'
    priority = -1
    msgs = {
      'W0001': (
        'ReadyToServeHotDog created outside of hotdog.prepare_for_serving.',
        'unverified-ready-to-serve-hotdog',
        'Only create a ReadyToServeHotDog through hotdog.prepare_for_serving.'
      ),
    }

    def __init__(self, linter: Optional[PyLinter] = None):
        super(ServableHotDogChecker, self).__init__(linter)
        self._is_in_prepare_for_serving = False

    def visit_functiondef(self, node: astroid.scoped_nodes.FunctionDef):
        if (node.name == "prepare_for_serving" and
            node.parent.name =="hotdog" and
            isinstance(node.parent, astroid.scoped_nodes.Module)):

            self._is_in_prepare_for_serving = True

    def leave_functiondef(self, node: astroid.scoped_nodes.FunctionDef):
        if (node.name == "prepare_for_serving" and
            node.parent.name =="hotdog" and
            isinstance(node.parent, astroid.scoped_nodes.Module)):

            self._is_in_prepare_for_serving = False

    def visit_call(self, node: astroid.node_classes.Call):
        if node.func.name != 'ReadyToServeHotDog':
            return

        if self._is_in_prepare_for_serving:
            return

        self.add_message(
            'unverified-ready-to-serve-hotdog', node=node,
        )

def register(linter: PyLinter):
    linter.register_checker(ServableHotDogChecker(linter))

这个代码检查器验证了当有人创建一个ReadyToServeHotDog时,它只能在一个名为prepare_for_serving的函数中完成,并且该函数必须位于名为hotdog的模块中。现在假设我创建了任何其他创建准备供应热狗的函数,如下所示:

def create_hot_dog() -> ReadyToServeHotDog:
    hot_dog = HotDog()
    return ReadyToServeHotDog(hot_dog)

我可以运行我的自定义 Pylint 检查器:

 PYTHONPATH=code_examples/chapter20 pylint --load-plugins \
        hotdog_checker code_examples/chapter20/hotdog.py

Pylint 确认现在服务“不可供应”的热狗是一个错误:

************* Module hotdog
code_examples/chapter20/hotdog.py:13:12: W0001:
    ReadyToServeHotDog created outside of prepare_for_serving.
        (unverified-ready-to-serve-hotdog)

这太棒了。现在我可以编写自动化工具,用来检查那些像我的 mypy 类型检查器无法甚至开始查找的错误。不要让你的想象力束缚你。使用 Pylint 可以捕捉任何你能想到的东西:业务逻辑约束违规、时间依赖性或者自定义样式指南。现在,让我们看看这个代码检查器是如何工作的,这样你就能够构建你自己的。

插件分解

写插件的第一件事是定义一个从pylint.checkers.BaseChecker继承的类:

import astroid

from pylint.checkers import BaseChecker
from pylint.interfaces import IAstroidChecker

class ReadyToServeHotDogChecker(BaseChecker):
    __implements__ = IAstroidChecker

您还会注意到一些对astroid的引用。astroid库用于将 Python 文件解析为抽象语法树(AST),这为与 Python 源代码交互提供了一种便捷的结构化方式。很快您将看到这在哪些方面非常有用。

接下来,我定义插件的元数据。这提供了插件名称、显示给用户的消息以及一个标识符(unverified-ready-to-serve-hotdog),以便稍后引用。

    name = 'unverified-ready-to-serve-hotdog'
    priority = -1
    msgs = {
     'W0001': ( # this is an arbitrary number I've assigned as an identifier
        'ReadyToServeHotDog created outside of hotdog.prepare_for_serving.',
        'unverified-ready-to-serve-hotdog',
        'Only create a ReadyToServeHotDog through hotdog.prepare_for_serving.'
     ),
    }

接下来,我想跟踪我所在的函数,以便判断我是否在使用prepare_for_serving。这就是astroid库发挥作用的地方。如前所述,astroid库帮助 Pylint 检查器以 AST 的形式思考;您无需担心字符串解析。如果您想了解有关 AST 和 Python 解析的更多信息,可以查看astroid文档,但现在,您只需知道,如果在检查器中定义了特定函数,它们将在astroid解析代码时被调用。每个调用的函数都会传递一个node,代表代码的特定部分,例如表达式或类定义。

    def __init__(self, linter: Optional[PyLinter] = None):
        super(ReadyToServeHotDogChecker, self).__init__(linter)
        self._is_in_prepare_for_serving = False

    def visit_functiondef(self, node: astroid.scoped_nodes.FunctionDef):
        if (node.name == "prepare_for_serving" and
            node.parent.name =="hotdog" and
            isinstance(node.parent, astroid.scoped_nodes.Module)):
                self._is_in_prepare_for_serving = True

    def leave_functiondef(self, node: astroid.scoped_nodes.FunctionDef):
        if (node.name == "prepare_for_serving" and
            node.parent.name =="hotdog" and
            isinstance(node.parent, astroid.scoped_nodes.Module)):

            self._is_in_prepare_for_serving = False

在这种情况下,我定义了一个构造函数来保存一个成员变量,以跟踪我是否在正确的函数中。我还定义了两个函数,visit_functiondefleave_functiondefvisit_functiondef将在astroid解析函数定义时调用,而leave_functiondef在解析器停止解析函数定义时调用。因此,当解析器遇到函数时,我会检查该函数是否命名为prepare_for_serving,它位于名为hotdog的模块中。

现在我有一个成员变量来跟踪我是否在正确的函数中,我可以编写另一个astroid钩子,以便在每次调用函数时调用它(比如ReadyToServeHotDog(hot_dog))。

    def visit_call(self, node: astroid.node_classes.Call):
        if node.func.name != 'ReadyToServeHotDog':
            return

        if self._is_in_prepare_for_serving:
            return

        self.add_message(
            'unverified-ready-to-serve-hotdog', node=node,
        )

如果函数调用不是ReadyToServeHotDog,或者执行在prepare_serving中,这个检查器则不会发现问题并早早返回。如果函数调用是ReadyToServeHotDog,而执行不在prepare_serving中,检查器将失败并添加一条消息来指示unverified-ready-to-serve-hotdog检查失败。通过添加消息,Pylint 将把此信息传递给用户并标记为检查失败。

最后,我需要注册这个 linter:

    def register(linter: PyLinter):
        linter.register_checker(ReadyToServeHotDogChecker(linter))

就是这样了!使用大约 45 行 Python 代码,我定义了一个 Pylint 插件。这是一个简单的检查器,但是您可以无限想象您能做的事情。无论是内置的还是用户创建的 Pylint 检查器,对于查找错误都是无价的。

讨论主题

您在代码库中可以创建哪些检查器?您可以使用这些检查器捕捉哪些错误情况?

其他静态分析工具

类型检查器和代码检查器通常是人们在听到“静态分析”时首先想到的工具,但还有许多其他工具可以帮助你编写健壮的代码。每个工具都像是瑞士奶酪的一块。¹ 每块瑞士奶酪都有不同宽度或大小的孔洞,但当多块奶酪堆叠在一起时,几乎不可能有一个区域所有孔洞对齐,从而可以透过这个堆看到。

同样,你用来构建安全网络的每个工具都会忽略某些错误。类型检查器无法捕捉常见的编程错误,代码检查器无法检查安全违规,安全检查器无法捕捉复杂代码,等等。但是当这些工具堆叠在一起时,合法错误通过的可能性大大降低(对于那些通过的,那就是你需要测试的原因)。正如布鲁斯·麦克莱南所说,“设置一系列防御措施,这样如果一个错误没有被一个工具捕捉到,很可能会被另一个捕捉到。”²

复杂性检查器

本书大部分内容都集中在可读性和可维护性代码上。我谈到了复杂代码如何影响功能开发的速度。一个工具可以指示代码库中哪些部分具有高复杂性将会很好。不幸的是,复杂性是主观的,减少复杂性并不总是会减少错误。但我可以将复杂性度量视为启发式。启发式是提供答案但不保证是最优答案的东西。在这种情况下,问题是,“我代码中哪里可能有最多的 bug?”大多数情况下,会在复杂性高的代码中发现,但请记住这并非保证。

带麦卡比的圈复杂度

最流行的复杂性启发式之一被称为圈复杂度,最早由托马斯·麦卡比描述。³ 要测量代码的圈复杂度,你必须将代码视为控制流图,或者一个绘制出代码可以执行的不同路径的图形。图 20-1 展示了几个不同的例子。

ropy 2001

图 20-1. 圈复杂度示例

图 20-1 的 A 部分展示了语句的线性流动,复杂度为一。如同 B 部分所示,没有 elif 语句的 if 语句有两条路径(if 或 else/跟随),因此复杂度为两。类似地,像 C 部分中的 while 循环,有两个不同的路径:循环继续或退出。随着代码变得更复杂,圈复杂度数字会变得更高。

您可以使用 Python 中的静态分析工具来测量圈复杂度,其名为mccabe

我将用pip安装它:

pip install mccabe

为了测试它,我将在mccabe代码库本身上运行它,并标记任何圈复杂度大于或等于五的函数:

python -m mccabe --min 5 mccabe.py
192:4: 'PathGraphingAstVisitor._subgraph_parse' 5
273:0: 'get_code_complexity' 5
298:0: '_read' 5
315:0: 'main' 7

让我们来看看PathGraphingAstVisitor._subgraph_parse

def _subgraph_parse(self, node, pathnode, extra_blocks):
       """parse the body and any `else` block of `if` and `for` statements"""
       loose_ends = []
       self.tail = pathnode
       self.dispatch_list(node.body)
       loose_ends.append(self.tail)
       for extra in extra_blocks:
           self.tail = pathnode
           self.dispatch_list(extra.body)
           loose_ends.append(self.tail)
       if node.orelse:
           self.tail = pathnode
           self.dispatch_list(node.orelse)
           loose_ends.append(self.tail)
       else:
           loose_ends.append(pathnode)
       if pathnode:
           bottom = PathNode("", look='point')
           for le in loose_ends:
               self.graph.connect(le, bottom)
           self.tail = bottom

这个函数中发生了几件事情:各种条件分支、循环,甚至在if语句中嵌套了一个循环。每条路径都是独立的,需要进行测试。随着圈复杂度的增加,代码变得越来越难阅读和理解。圈复杂度没有一个魔法数字;您需要检查您的代码库并寻找一个合适的限制。

空白启发式

还有一种复杂度启发式方法我非常喜欢,比圈复杂度稍微简单一些来理解:空白检查。其思想如下:计算一个 Python 文件中有多少级缩进。高水平的缩进表示嵌套循环和分支,这可能表明代码复杂度高。

不幸的是,在撰写本文时还没有流行的工具来处理空白启发式。然而,编写这个检查器自己是很容易的:

def get_amount_of_preceding_whitespace(line: str) -> int:
    # replace tabs with 4 spaces (and start tab/spaces flame-war)
    tab_normalized_text = line.replace("\t", "    ")
    return len(tab_normalized_text) - len(tab_normalized_text.lstrip())

def get_average_whitespace(filename: str):
    with open(filename) as file_to_check:
        whitespace_count = [get_amount_of_preceding_whitespace(line)
                            for line in file_to_check
                            if line != ""]
        average = sum(whitespace_count) / len(whitespace_count) / 4
        print(f"Avg indentation level for {filename}: {average}")
注意

另一种可能的空白度量是每个函数的缩进“面积”,其中您总结所有缩进而不是对其进行平均。我将这留给读者自行实现。

和圈复杂度一样,空白复杂度也没有一个魔法数字可以检查。我鼓励你在你的代码库中试验,并确定适当的缩进量。

安全分析

安全性很难做到正确,并且几乎没有人因为防范漏洞而受到赞扬。相反,似乎是漏洞本身主导了新闻。每个月我都会听说另一起泄露或数据泄露。这些故障对公司来说无比昂贵,无论是因为监管罚款还是失去客户基础。

每个开发人员都需要高度关注他们代码库的安全性。您不希望听说您的代码库是新闻中最新大规模数据泄露的根本原因。幸运的是,有些静态分析工具可以防止常见的安全漏洞。

泄露的秘密

如果你想要被吓到,可以在你喜欢的代码托管工具中搜索文本AWS_SECRET_KEY,比如GitHub。你会惊讶地发现有多少人提交了像 AWS 访问密钥这样的秘密值。⁴

一旦秘密信息进入版本控制系统,尤其是公开托管的系统,要消除其痕迹非常困难。组织被迫撤销任何泄露的凭据,但他们必须比搜索密钥的大量黑客更快。为了防止这种情况发生,请使用专门查找泄漏秘密的静态分析工具,例如dodgy。如果您选择不使用预构建工具,请至少在代码库中执行文本搜索,以确保没有人泄露常见凭据。

安全漏洞检查

检查泄露凭据只是一件事,但更严重的安全漏洞怎么办?如何找到像 SQL 注入、任意代码执行或错误配置的网络设置等问题?当这些漏洞被利用时,会对您的安全配置造成重大损害。但就像本章中的其他问题一样,有一个静态分析工具可以处理这些问题:Bandit。

Bandit 检查常见的安全问题。您可以在Bandit 文档中找到完整的列表,但这里是 Bandit 寻找的缺陷类型的预览:

  • Flask 调试模式可能导致远程代码执行

  • 发出不进行证书验证的 HTTPS 请求

  • 潜在存在 SQL 注入风险的原始 SQL 语句

  • 弱密码密钥生成

  • 标记不受信任的数据影响代码路径,例如不安全的 YAML 加载

Bandit 检查了许多不同的潜在安全漏洞。我强烈建议对您的代码库运行它:

pip install bandit
bandit -r path/to/your/code

Bandit 还具有强大的插件系统,因此您可以使用自己的安全检查来增强缺陷检测。

警告

虽然以安全为导向的静态分析工具非常有用,但不要将它们作为唯一的防线。通过继续实施额外的安全实践(如进行审计、运行渗透测试和保护您的网络),来补充这些工具。

总结思考

尽早捕获错误可以节省时间和金钱。您的目标是在开发代码时发现错误。静态分析工具在这方面是您的好帮手。它们是在代码库中快速发现问题的廉价方式。有各种静态分析器可供选择:代码检查器、安全检查器和复杂性检查器。每种工具都有其自身的目的,并提供了一层防护。对于这些工具未能捕捉的错误,您可以通过使用插件系统来扩展静态分析工具。

虽然静态分析工具是您的第一道防线,但它们不是唯一的防线。本书的其余部分将专注于测试。下一章将专注于您的测试策略。我将详细介绍如何组织您的测试,以及围绕编写测试的最佳实践。您将学习如何编写测试金字塔,如何在测试中提出正确的问题,以及如何编写有效的开发者测试。

¹ J. Reason. “人为错误:模型与管理。” BMJ 320, 第 7237 期(2000 年):768–70. https://doi.org/10.1136/bmj.320.7237.768.

² Bruce MacLennan. “编程语言设计原理。” web.eecs.utk.edu,1998 年 9 月 10 日. https://oreil.ly/hrjdR.

³ T.J. McCabe. “一个复杂性度量。” IEEE 软件工程期刊 SE-2,第 4 期(1976 年 12 月):308–20. https://doi.org/10.1109/tse.1976.233837.

⁴ 这有现实世界的影响。在互联网上快速搜索会找到大量详细介绍这个问题的文章,比如 https://oreil.ly/gimse.

第二十一章:测试策略

测试是你可以在代码库周围建立的最重要的安全网之一。改变后,看到所有测试通过是非常令人欣慰的。然而,评估测试的最佳时间使用是具有挑战性的。测试过多会成为负担;你会花更多时间维护测试而非交付功能。测试过少会让潜在的灾难进入生产环境。

在本章中,我将请你专注于你的测试策略。我将分解不同类型的测试以及如何选择要编写的测试。我将关注 Python 在测试构建方面的最佳实践,然后我会结束一些特定于 Python 的常见测试策略。

定义你的测试策略

在你编写测试之前,你应该决定你的 测试策略 将是什么。测试策略是在测试软件以减少风险方面花费时间和精力的计划。这种策略将影响你编写什么类型的测试,如何编写它们以及你花费多少时间编写(和维护)它们。每个人的测试策略都会有所不同,但它们都会有类似的形式:关于你的系统及其如何计划回答这些问题的问题列表。例如,如果我正在编写一个卡路里计数应用程序,这将是我的测试策略的一部分:

Does my system function as expected?
Tests to write (automated - run daily):
    Acceptance tests: Adding calories to the daily count
    Acceptance tests: Resetting calories on daily boundaries
    Acceptance tests: Aggregating calories over a time period
    Unit tests: Corner Cases
    Unit tests: Happy Path

Will this application be usable by a large user base?
Tests to write (automated - run weekly):
    Interoperability tests: Phones (Apple, Android, etc.)
    Interoperability tests: Tablets
    Interoperability tests: Smart Fridge

Is it hard to use maliciously?
Tests to write: (ongoing audit by security engineer)
    Security tests: Device Interactions
    Security tests: Network Interactions
    Security tests: Backend Vulnerability Scanning (automated)

... etc. ...
提示

不要将你的测试策略视为一次创建并永不修改的静态文档。在开发软件时,继续提问并讨论是否需要根据学到的知识进化你的策略。

这种测试策略将决定你在编写测试时的关注点。当你开始填写它时,你首先需要了解什么是测试以及为什么要编写它们。

什么是测试?

你应该理解为什么和为什么编写软件。回答这些问题将为编写测试确定目标。测试是验证代码执行 what 的一种方式,你编写测试是为了不会负面影响 why。软件产生价值。这就是全部。每个软件都有一定的附加值。Web 应用为广大人群提供重要服务。数据科学管道可能创建预测模型,帮助我们更好地理解世界中的模式。即使是恶意软件也有价值;执行攻击的人使用软件来实现目标(即使对受影响者有负面价值)。

这就是软件提供的内容,但是为什么要写软件呢?大多数人会说“钱”,我不想否认这一点,但也有其他原因。有时候软件是为了赚钱而写的,有时候是为了自我实现,有时候是为了广告(比如为开源项目做贡献以增强简历)。测试为这些系统提供了验证。它们远不止于捕捉错误或者让你在发布产品时有信心。

如果我为学习目的编写一些代码,那么我的为什么纯粹是为了自我实现,价值来自我学到了多少。如果我做错了事情,那也是一个学习机会;如果所有测试只是项目结束时的手工抽查,我也可以应付。然而,市场上为其他开发者提供工具的公司可能有完全不同的策略。这些公司的开发人员可能选择编写测试,以确保他们没有退化任何功能,从而避免公司失去客户(这会转化为利润损失)。每个项目都需要不同层次的测试。

所以,测试是什么?它是用来捕捉错误的东西吗?它是让你有信心发布产品的东西吗?是的,但真正的答案还要深入一些。测试回答了关于你的系统的问题。我希望你思考一下你写的软件。它的目的是什么?关于你构建的东西,你希望永远知道什么?对你重要的东西构成了你的测试策略。

当你问自己问题时,你真正在问自己的是哪些测试对你有价值:

  • 我的应用程序能处理预测的负载吗?

  • 我的代码是否满足客户的需求?

  • 我的应用程序安全吗?

  • 当客户向我的系统输入不良数据时会发生什么?

每一个问题都指向你可能需要编写的不同类型的测试。查看表 21-1,列出了常见问题和相应的测试类型。

表 21-1. 测试类型及其所回答的问题

测试类型 测试回答的问题
单元 单元(函数和类)是否如开发人员期望的那样工作?
集成 系统的各个部分是否正确地拼接在一起?
验收 系统是否符合最终用户的期望?
负载 系统在重压下是否保持操作能力?
安全性 系统是否能抵御特定的攻击和利用?
可用性 系统是否直观易用?

注意,表 21-1 没有提到确保软件没有 bug。正如 Edsger Djikstra 所说,“程序测试可以用来显示 bug 的存在,但永远不能证明其不存在!”¹ 测试回答了关于你的软件质量的问题。

质量 是一个模糊的、难以定义的术语,经常被人提及。这是一个难以把握的东西,但我更喜欢 Gerald Weinberg 的这句话:“质量是对某个人的价值。”² 我喜欢这句话多么开放式;你需要考虑到任何可能从你的系统中获得价值的人。不仅仅是你的直接客户,还有你客户的客户,你的运维团队,你的销售团队,你的同事等等。

一旦确定了谁从你的系统中获得价值,你需要在出现问题时衡量影响。对于每个未运行的测试,你失去了了解你是否正在交付价值的机会。如果未能交付该价值会有什么影响?对于核心业务需求,影响是相当大的。对于不在最终用户关键路径之外的功能,影响可能较小。了解你的影响,并将其与测试成本进行权衡。如果影响的成本高于测试的成本,写测试。如果低于测试成本,跳过编写测试,花时间做更有影响力的事情。

测试金字塔

几乎在任何测试书籍中,你都会碰到类似于 图 21-1 的图:一个“测试金字塔”。³

ropy 2101

图 21-1. 测试金字塔

这个想法是你想要编写大量小型、孤立的单元测试。理论上这些测试成本较低,应该占据你测试的大部分,因此它们位于底部。你有较少的集成测试,这些成本较高,甚至更少的 UI 测试,这些成本非常高。从诞生之时起,开发者们就在多种方式上辩论测试金字塔,包括画线的位置、单元测试的有效性,甚至三角形的形状(我甚至见过倒置的三角形)。

事实上,标签是什么或者你如何分隔你的测试并不重要。你想要的是你的三角形看起来像 图 21-2,它侧重于价值与成本的比率。

ropy 2102

图 21-2. 着眼于价值与成本的测试金字塔

写大量价值与成本比高的测试。无论是单元测试还是验收测试都无所谓。找到方法经常运行它们。让测试快速运行,这样开发者在提交之间多次运行它们,验证事情仍然正常工作。把你的不那么有价值、较慢或者成本较高的测试保留用于每次提交时的测试(或至少定期测试)。

你拥有的测试越多,你就会有越少的未知数。你拥有的未知数越少,你的代码库就会更加健壮。每次你进行更改时,你都有一个更大的安全网来检查任何回归。但是,如果测试变得过于昂贵,远远超过任何影响的成本,该怎么办?如果你觉得这些测试仍然值得,你需要找到一种方式来降低它们的成本。

测试成本包括三个方面:编写的初始成本、运行的成本以及维护的成本。测试至少需要运行一段时间,这将耗费资金。然而,减少这些成本通常成为优化练习,您可以寻找并行化测试或在开发人员机器上更频繁地运行测试的方式。您仍然需要减少编写的初始成本和维护测试的持续成本。幸运的是,迄今为止您所阅读的每一本书都直接适用于减少这些成本。您的测试代码与您代码库的其余部分一样重要,您需要确保它同样强大。选择正确的工具,正确组织您的测试用例,并确保您的测试清晰易读且易于维护。

讨论话题

评估系统中测试的成本。编写时间、运行时间或维护时间哪个占主导地位?您可以采取什么措施来降低这些成本?

降低测试成本

当您对比测试成本与价值时,您正在收集将帮助您优先考虑测试策略的信息。有些测试可能不值得运行,而有些则会成为您希望首先编写以最大化价值的测试。然而,有时候您可能会遇到这样的情况:有一个非常重要的测试,您希望编写,但编写和/或维护成本非常高。在这种情况下,找到一种方法来降低该测试的成本。编写和组织测试的方式对于使测试更便宜、更易于理解至关重要。

AAA 测试

与生产代码一样,专注于测试代码的可读性和可维护性。尽可能清晰地传达您的意图。如果测试读者能清楚地看到您试图测试的内容,他们会感谢您。在编写测试时,每个测试遵循相同的基本模式会有所帮助。

在测试中您将发现的最常见的模式之一是 3A 或 AAA 测试模式⁴。AAA 代表Arrange-Act-Assert。您将每个测试分为三个独立的代码块:一个用于设置预置条件(arrange),一个用于执行正在测试的操作(act),然后一个用于检查任何后置条件(assert)。您可能也会听说第四个 A,用于annihilate或清理代码。我将详细介绍每个步骤,讨论如何使您的测试更易于阅读和维护。

安排

安排步骤主要是设置系统处于准备测试的状态。这些被称为测试的前置条件。您设置任何依赖项或测试数据,以确保测试能够正确运行。

考虑以下测试:

def test_calorie_calculation():

    # arrange (set up everything the test needs to run)
    add_ingredient_to_database("Ground Beef", calories_per_pound=1500)
    add_ingredient_to_database("Bacon", calories_per_pound=2400)
    add_ingredient_to_database("Cheese", calories_per_pound=1800)
    # ... snip 13 more ingredients

    set_ingredients("Bacon Cheeseburger w/ Fries",
                    ingredients=["Ground Beef", "Bacon" ... ])

    # act (the thing getting tested)
    calories = get_calories("Bacon Cheeseburger w/ Fries")

    # assert (verify some property about the program)
    assert calories == 1200

    #annihilate (cleanup any resources that were allocated)
    cleanup_database()

首先,我向数据库添加食材,并将食材列表与名为“培根奶酪汉堡配薯条”的菜肴关联。然后,我查找汉堡的卡路里数量,检查它与已知值是否一致,并清理数据库。

看看在我实际进入测试本身之前有多少代码(get_calories 调用)。庞大的arrange块是一个警告信号。你将会有很多看起来非常相似的测试,你希望读者能够一目了然地知道它们之间的区别。

警告

大型的arrange块可能表示依赖关系的复杂设置。代码的任何使用者都可能需要以类似的方式设置这些依赖关系。退一步思考,问自己是否有更简单的方法来处理依赖关系,比如使用第 III 部分中描述的策略。

在前面的示例中,如果我必须在两个单独的测试中添加 15 种成分,但设置一个成分稍有不同以模拟替换,那么眼测这些测试的区别将会很困难。给这些测试起一个能够指示它们不同之处的详细名称是一个不错的方法,但这只能走得这么远。要在保持测试信息丰富与便于一目了然之间找到平衡点。

一致的前提条件与变化的前提条件

查看你的测试并问问自己哪些前提条件在一组测试中是相同的。通过函数提取这些条件并在每个测试中重复使用该函数。看看比较以下两个测试变得多么容易:

def test_calorie_calculation_bacon_cheeseburger():
    add_base_ingredients_to_database()
    add_ingredient_to_database("Bacon", calories_per_pound=2400)

    st /etup_bacon_cheeseburger(bacon="Bacon")
    calories = get_calories("Bacon Cheeseburger w/ Fries")

    assert calories == 1200

    cleanup_database()

def test_calorie_calculation_bacon_cheeseburger_with_substitution():
    add_base_ingredients_to_database()
    add_ingredient_to_database("Turkey Bacon", calories_per_pound=1700)

    setup_bacon_cheeseburger(bacon="Turkey Bacon")
    calories = get_calories("Bacon Cheeseburger w/ Fries")

    assert calories == 1100

    cleanup_database()

通过创建辅助函数(在本例中为 add_base_ingredients_to_databasesetup_bacon_cheeseburger),你可以将所有不重要的测试样板代码减少,使开发人员能够专注于测试之间的差异。

使用测试框架特性来处理样板代码

大多数测试框架都提供了一种在测试前自动运行代码的方法。在内置的 unittest 模块中,你可以编写一个 setUp 函数在每个测试前运行。在 pytest 中,你可以通过 fixture 实现类似的功能。

pytest 中,fixture 是指定测试初始化和清理代码的一种方式。Fixture 提供了许多有用的功能,如定义对其他 fixture 的依赖(让 pytest 控制初始化顺序)和控制初始化,以便每个模块只初始化一次 fixture。在前面的示例中,我们可以为 test_database 使用一个 fixture:

import pytest

@pytest.fixture
def db_creation():
    # ... snip  set up local sqlite database
    return database

@pytest.fixture
def test_database(db_creation):
    # ... snip adding all ingredients and meals
    return database

def test_calorie_calculation_bacon_cheeseburger(test_database):
    test_database.add_ingredient("Bacon", calories_per_pound=2400)
    setup_bacon_cheeseburger(bacon="Bacon")

    calories = get_calories("Bacon Cheeseburger w/ Fries")

    assert calories == 1200

    test_database.cleanup()()

注意现在测试中有一个 test_database 参数。这就是 fixture 的工作原理;函数 test_database(以及 db_creation)会在测试之前调用。随着测试数量的增加,fixture 变得越来越有用。它们是可组合的,允许你将它们组合在一起,减少代码重复。通常情况下,我不会将它们用来抽象单个文件中的代码,但一旦初始化需要在多个文件中使用时,fixture 就是最佳选择。

模拟

Python 提供了鸭子类型(首次在第二章提到)作为其类型系统的一部分,这意味着只要它们遵循相同的契约,你可以很容易地将类型替换为另一个类型(如在第十二章中讨论的那样)。这意味着你可以完全不同地处理复杂的依赖关系:使用一个简单的模拟对象代替。mocked对象是看起来与生产对象完全相同(方法和字段),但提供了简化的数据。

提示

单元测试中经常使用模拟对象(Mocks),但随着测试变得不那么细粒度,它们的使用会减少。这是因为你尝试在更高层次测试系统的同时,你正在模拟的服务往往是测试的一部分。

例如,如果前面示例中的数据库设置非常复杂,有多个表和模式,可能不值得为每个测试设置,特别是如果测试共享一个数据库;你希望保持测试互相隔离。(稍后我将详细讨论这一点。)处理数据库的类可能如下所示:

class DatabaseHandler:

    def __init__(self):
        # ... snip complex setup

    def add_ingredient(self, ingredient):
        # ... snip complex queries

    def get_calories_for_ingredient(self, ingredient):
        # ... snip complex queries

而不是直接使用这个类,创建一个看起来像数据库处理程序的模拟类:

class MockDatabaseHandler
    def __init__(self):
        self.data = {
            "Ground Beef": 1500,
            "Bacon": 2400,
            # ... snip ...
        }

    def add_ingredient(self, ingredient):
        name, calories = ingredient
        self.data[name] = calories

    def get_calories_for_ingredient(self, ingredient):
        return self.data[ingredient]

对于模拟对象,我只是使用一个简单的字典来存储我的数据。如何模拟你的数据将因情况而异,但如果你能找到一种方法用模拟对象替换真实对象,你可以显著降低设置的复杂性。

警告

有些人使用monkeypatching,即在运行时替换方法以注入模拟对象。适度使用这种方法是可以接受的,但如果你发现你的测试中充斥着 monkeypatching,这是一种反模式。这意味着你的不同模块之间有过于严格的物理依赖,应该考虑找到方法使你的系统更加模块化。(请参阅第三部分了解更多关于使代码可扩展性的想法。)

消灭

技术上,annihilate 阶段是你在测试中做的最后一件事,但我却在第二次讨论它。为什么呢?因为它与你的arrange步骤密切相关。无论你在arrange中设置了什么,如果它可能影响其他测试,都需要拆除。

你希望你的测试互相隔离;这样会使它们更易于维护。对于测试自动化写作者来说,最大的噩梦之一就是测试失败取决于它们运行的顺序(尤其是如果你有成千上万个测试)。这是测试彼此之间存在微妙依赖的明确迹象。在离开之前清理你的测试,并减少测试相互交互的可能性。以下是一些处理测试清理的策略。

不要使用共享资源

如果可以做到的话,测试之间不要共享任何东西。这并不总是可行的,但这应该是你的目标。如果没有测试共享任何资源,那么你就不需要清理任何东西。共享资源可以是 Python 中的(全局变量,类变量)或环境中的(数据库,文件访问,套接字池)。

使用上下文管理器

使用上下文管理器(在第十一章讨论)确保资源始终被清理。在我的上一个示例中,眼尖的读者可能已经注意到了一个错误:

def test_calorie_calculation_bacon_cheeseburger():
    add_base_ingredients_to_database()
    add_ingredient_to_database("Bacon", calories_per_pound=2400)
    setup_bacon_cheeseburger(bacon="Bacon")

    calories = get_calories("Bacon Cheeseburger w/ Fries")

    assert calories == 1200

    cleanup_database()

如果断言失败,则会引发异常,cleanup_database永远不会执行。更好的方法是通过上下文管理器强制使用:

def test_calorie_calculation_bacon_cheeseburger():
    with construct_test_database() as db:
        db.add_ingredient("Bacon", calories_per_pound=2400)
        setup_bacon_cheeseburger(bacon="Bacon")

        calories = get_calories("Bacon Cheeseburger w/ Fries")

        assert calories == 1200

将清理代码放在上下文管理器中,这样你的测试编写者永远不必主动考虑它;它已经为他们完成了。

使用夹具

如果你正在使用pytest夹具,你可以像使用上下文管理器一样使用它们。你可以从夹具yield值,允许你在测试完成后返回到夹具的执行。观察:

import pytest

@pytest.fixture
def db_creation():
    # ... snip  set up local sqlite database
    return database

@pytest.fixture
def test_database(db_creation):
    # ... snip adding all ingredients and meals
    try:
        yield database
    finally:
        database.cleanup()

def test_calorie_calculation_bacon_cheeseburger(test_database):
    test_database.add_ingredient("Bacon", calories_per_pound=2400)
    setup_bacon_cheeseburger(bacon="Bacon")

    calories = get_calories("Bacon Cheeseburger w/ Fries")

    assert calories == 1200

注意现在test_database夹具如何产生数据库。当使用此函数的任何测试完成(无论是通过还是失败),数据库清理函数都将始终执行。

行动

act阶段是测试中最重要的部分。它体现了你要测试的实际操作。在前面的例子中,act阶段是获取特定菜品的卡路里。你不希望act阶段比一两行代码长。少即是多;通过保持此阶段小,可以减少读者理解测试的核心内容所需的时间。

有时,你希望在多个测试之间重复使用相同的act阶段。如果你发现自己想要编写相同的测试,但输入数据和断言略有不同,请考虑对测试参数化。测试参数化是一种在不同参数上运行相同测试的方法。这允许你编写table-driven测试,或以表格形式组织测试数据的方法。

这是使用参数化的get_calories测试:

@pytest.mark.parametrize(
    "extra_ingredients,dish_name,expected_calories",
    [
        (["Bacon", 2400], "Bacon Cheeseburger", 900),
        ([],  "Cobb Salad", 1000),
        ([],  "Buffalo Wings", 800),
        ([],  "Garlicky Brussels Sprouts", 200),
        ([],  "Mashed Potatoes", 400)
    ]
)
def test_calorie_calculation_bacon_cheeseburger(extra_ingredients,
                                                dish_name,
                                                expected_calories,
                                                test_database):
    for ingredient in extra_ingredients:
        test_database.add_ingredient(ingredient)

    # assume this function can set up any dish
    # alternatively, dish ingredients could be passed in as a test parameter
    setup_dish_ingredients(dish_name)

    calories = get_calories(dish_name)

    assert calories == expected_calories

将参数定义为元组列表,每个测试用例一个。每个参数都作为参数传递给测试用例。pytest会自动对每组参数运行此测试。

参数化测试的好处是将许多测试用例压缩到一个函数中。测试的读者只需按照参数化表中列出的顺序查看表格,就可以理解预期的输入和输出是什么(科布沙拉应该有 1,000 卡路里,土豆泥应该有 400 卡路里,等等)。

警告

参数化是将测试数据与实际测试分离的好方法(类似于分离策略和机制,如第十七章所讨论的)。但是要小心。如果你让你的测试过于通用,那么确定它们在测试什么就会更加困难。如果可以的话,避免使用三四个以上的参数。

断言

在清理之前要做的最后一步是断言关于系统的某个属性为真。最好,在你的测试末尾应该有一个逻辑断言。如果你发现自己在一个测试中塞入了太多断言,要么是你的测试中有太多动作,要么是太多测试匹配到了一个。当一个测试有太多责任时,维护者很难调试软件。如果他们进行了一个导致测试失败的更改,你希望他们能快速找出问题所在。理想情况下,他们可以根据测试名称找出问题,但至少他们应该能打开测试,看上去大约 20 或 30 秒,就能意识到出了什么问题。如果有多个断言,就有多个原因会导致测试失败,维护者需要花时间来梳理它们。

这并不意味着你只能有一个assert语句;只要它们都涉及测试相同的属性,有几个assert语句也是可以的。同样要使你的断言详细,这样开发人员在出错时能得到有用的信息。在 Python 中,你可以提供一个文本消息,随着AssertionError一起传递,以帮助调试。

def test_calorie_calculation_bacon_cheeseburger(test_database):
    test_database.add_ingredient("Bacon", calories_per_pound=2400)
    setup_bacon_cheeseburger(bacon="Bacon")

    calories = get_calories("Bacon Cheeseburger w/ Fries")

    assert calories == 1200, "Incorrect calories for Bacon Cheeseburger w/ Fries"

pytest重新编写断言语句,这也提供了额外的调试消息级别。如果上述测试失败,返回给测试编写者的消息将是:

E       AssertionError: Incorrect calories for Bacon Cheeseburger w/ Fries
E       assert 1100 == 1200

对于更复杂的断言,构建一个断言库可以非常轻松地定义新的测试。这就像在你的代码库中建立词汇表一样;你希望测试代码中有多样的概念可以共享。为此,我推荐使用Hamcrest 匹配器。⁵

Hamcrest 匹配器是一种编写断言,以类似自然语言的方式阅读的方法。PyHamcrest库提供了常见的匹配器,帮助你编写你的断言。看看它如何使用自定义断言匹配器来使测试更加清晰:

from hamcrest import assert_that, matches_regexp, is_, empty, equal_to
def test_all_menu_items_are_alphanumeric():
    menu = create_menu()
    for item in menu:
        assert_that(item, matches_regexp(r'[a-zA-Z0-9 ]'))

def test_getting_calories():
    dish = "Bacon Cheeseburger w/ Fries"
    calories = get_calories(dish)
    assert_that(calories, is_(equal_to(1200)))

def test_no_restaurant_found_in_non_matching_areas():
    city = "Huntsville, AL"
    restaurants = find_owned_restaurants_in(city)
    assert_that(restaurants, is_(empty()))

PyHamcrest的真正强大之处在于你可以定义自己的匹配器。⁶

from hamcrest.core.base_matcher import BaseMatcher
from hamcrest.core.helpers.hasmethod import hasmethod

def is_vegan(ingredient: str) -> bool:
    return ingredient not in ["Beef Burger"]

class IsVegan(BaseMatcher):

    def _matches(self, dish):
        if not hasmethod(dish, "ingredients"):
            return False
        return all(is_vegan(ingredient) for ingredient in dish.ingredients())

    def describe_to(self, description):
        description.append_text("Expected dish to be vegan")

    def describe_mismatch(self, dish, description):
        message = f"the following ingredients are not vegan: "
        message += ", ".join(ing for ing in dish.ingredients()
                             if not is_vegan(ing))
        description.append_text(message)

def vegan():
    return IsVegan()

from hamcrest import assert_that, is_
def test_vegan_substitution():
    dish = create_dish("Hamburger and Fries")
    dish.make_vegan()
    assert_that(dish, is_(vegan()))

如果测试失败,你会得到以下错误:

    def test_vegan_substitution():
        dish = create_dish("Hamburger and Fries")
        dish.make_vegan()
>       assert_that(dish, is_(vegan()))
E       AssertionError:
E       Expected: Expected dish to be vegan
E            but: the following ingredients are not vegan: Beef Burger

讨论主题

在你的测试中,你可以在哪里使用自定义匹配器?讨论在你的测试中共享的测试词汇会是什么,以及自定义匹配器如何提高可读性。

总结思考

就像走钢丝的安全网一样,测试在你工作时给予你安全感和信心。这不仅仅是找到错误。测试验证你构建的东西是否按照你的期望执行。它们给未来的合作者提供了更多的自由去做更多风险的改变;他们知道如果他们失败,测试会捕捉到他们。你会发现回归变得更加少见,你的代码库变得更容易工作。

然而,测试并非免费。编写、运行和维护测试都是有成本的。你需要谨慎地安排你的时间和精力。使用构建测试的知名模式来最小化成本:遵循 AAA 模式,保持每个阶段简短,并确保你的测试清晰易读。你的测试和你的代码库一样重要。要同样尊重它们,并使其健壮。

在下一章中,我将专注于验收测试。验收测试有不同于单元测试或集成测试的目的,你使用的一些模式将不同。

你将学习到验收测试如何引发对话,以及如何确保你的代码库为客户正确执行任务。它们是交付价值的代码库中的宝贵工具。

¹ Edsger W. Dijkstra。“关于结构化编程的注释。”荷兰,埃因霍温科技大学,数学系,1970 年。https://oreil.ly/NAhWf

² Gerald M. Weinberg。《优质软件管理》。第 1 卷:《系统思维》。纽约,纽约:Dorset House Publishing,1992 年。

³ 这被称为测试金字塔,在迈克·科恩(Mike Cohn)的《成功实现敏捷》(Addison-Wesley Professional)中首次提出。Cohn 最初使用“Service”级别测试代替集成测试,但我看到更多的迭代使用“integration”测试作为中间层。

⁴ AAA 模式最早由 Bill Wake 在 2001 年命名。查看这篇博文了解更多信息。

⁵ Hamcrest 是“matchers”的字母重排。

⁶ 查看PyHamcrest 文档了解更多信息,如额外的匹配器或与测试框架的集成。

第二十二章:验收测试

作为开发者,很容易专注于直接涉及你代码库的测试:单元测试、集成测试、UI 测试等等。这些测试验证代码是否按照你的意图执行,是保持代码库无回归问题的宝贵工具。然而,它们完全是用来构建客户期望的错误工具。

开发人员在编写这些测试时对代码了如指掌,这意味着测试结果偏向于开发者的期望。尽管如此,不能保证这些测试覆盖的行为确实符合客户的需求。

考虑以下单元测试:

def test_chili_has_correct_ingredients():
    assert make_chili().ingredients() == [
        "Ground Beef",
        "Chile Blend",
        "Onion",
        ...
        "Tomatoes",
        "Pinto Beans"
    ]

这个测试可能是无懈可击的;它能通过并捕捉代码中的任何回归。然而,当呈现给客户时,你可能会遇到:“不,我想要德州风味的辣椒!你知道的,没有番茄或豆子?” 即使世界上所有的单元测试也无法阻止你构建错误的东西。

这就是验收测试的用武之地。验收测试检查你是否正在构建正确的产品。虽然单元测试和集成测试是一种验证形式,验收测试则是确认。它们验证你是否正在构建用户期望的东西。

在本章中,你将了解 Python 中的验收测试。我将向你展示使用 Gherkin 语言定义需求的behave框架,以全新的方式进行行为驱动开发。¹ 你将学习 BDD 作为澄清对话的工具。验收测试是构建安全网的重要组成部分;它将保护你免受构建错误东西的风险。

行为驱动开发

客户期望与软件行为之间的不匹配问题由来已久。这个问题源于将自然语言转换为编程语言。自然语言充满了歧义、不一致和细微差别。编程语言则是严格的。计算机会严格按照你告诉它的去执行(即使这不是你的本意)。更糟糕的是,这就像是一个电话游戏²,需求经过几个人(客户、销售、经理、测试人员)传递,最后才编写测试。

就像软件生命周期中的所有事情一样,这种错误案例越晚发现修复代价越高。理想情况下,你希望在制定用户需求时就发现这些问题。这就是行为驱动开发发挥作用的时候。

Gherkin 语言

行为驱动开发,最初由丹尼尔·特霍斯特-诺斯首创,是一种侧重于定义系统行为的实践。BDD 着重于澄清沟通;你与最终用户一起迭代需求,定义他们想要的行为。

在您编写任何代码之前,确保您已就要构建的正确内容达成一致。定义的行为集将推动您编写的代码。您与最终用户(或其代理人,如业务分析师或产品经理)合作,将您的需求定义为一种规范。这些规范遵循一种正式的语言,以在其定义中引入更多的严格性。指定需求的最常见语言之一是 Gherkin。

Gherkin 是一种遵循Given-When-Then(GWT)格式的规范。每个需求都组织如下:

Feature: Name of test suite

  Scenario: A test case
    Given some precondition
    When I take some action
    Then I expect this result

例如,如果我想捕捉一个检查菜肴素食替代的需求,我会这样写:

Feature: Vegan-friendly menu

  Scenario: Can substitute for vegan alternative
    Given an order containing a Cheeseburger with Fries
    When I ask for vegan substitutions
    Then I receive the meal with no animal products

另一个要求可能是某些菜品不能做成素食:

  Scenario: Cannot substitute vegan alternatives for certain meals
    Given an order containing Meatloaf
    When I ask for vegan substitutions
    Then an error shows up stating the meal is not vegan substitutable
注意

如果 GWT 格式感觉熟悉,那是因为它与您在第二十一章中学到的 AAA 测试组织完全相同。

通过与最终用户合作,以此方式编写您的需求,您将从以下几个关键原则中获益:

使用简单的语言编写

没有必要深入任何编程语言或正式逻辑。所有内容都以一种对业务人员和开发人员都能理解的形式编写。这使得非常容易抓住最终用户实际想要的东西。

建立共享词汇

随着需求数量的增加,您会发现多个需求中开始有相同的条款(如上所示,使用When I ask for vegan substitutions)。这会建立起您的领域语言,并使所有相关方更容易理解需求。

需求是可测试的

这可能是这种需求格式的最大好处。因为您正在以 GWT 方式编写需求,所以您在本章中使用的辣椒示例作为 Gherkin 测试的指定方式:

  Scenario: Texas-Style Chili
    Given a Chili-Making Machine
    When a Chili is dispensed
    Then that dish does not contain beans
    And that dish does not contain tomatoes

清楚地表明了需要编写哪些测试作为验收测试。如果 Gherkin 测试存在任何歧义,您可以与最终用户合作,找出一个具体的测试应该是什么样子。这也可以帮助解决传统上模糊的需求,例如,“辣椒制作机应该快速。”相反,通过专注于具体的测试,您最终得到像这样的测试:

Scenario: Chili order takes less than two minutes
Given a Chili-Making Machine
When a Chili is ordered
Then the Chili is dispensed to the customer within two minutes
警告

这些需求规格并非消除需求中错误的灵丹妙药,而是一种缓解策略。如果在编写代码之前让技术和业务人员审查它们,您将更有可能发现歧义或意图不匹配。

一旦您开始用 Gherkin 定义您的测试,您可以做一些令人惊讶的事情:您可以使您的规格可执行

可执行规格

可执行规范直接将一组需求转换为代码。这意味着您的需求不仅是可测试的,而且也是测试。当需求变化时,您的测试也会同时变化。这是可追溯性的最终形式,或者连接您的需求到具体测试或代码的能力。

讨论话题

您的组织如何跟踪需求?如何将这些需求追溯到测试用例?如何处理需求变更?讨论如果您的需求和测试是相同的东西,您的流程会如何变化。

Python 模块behave允许您用具体的测试支持您的 Gherkin 需求。它通过将函数与需求中的特定条款关联起来来实现这一点。

提示

默认情况下,behave期望您的 Gherkin 文件在名为features的文件夹中,并且您的 Python 函数(称为步骤)在名为features/steps的文件夹中。

让我们来看看我在本章前面展示的第一个 Gherkin 需求:

Feature: Vegan-friendly menu

  Scenario: Can substitute for vegan alternative
    Given an order containing a Cheeseburger with Fries
    When I ask for vegan substitutions
    Then I receive the meal with no animal products

使用behave,我可以编写与每个 GWT 语句相对应的 Python 代码:

from behave import given, when, then

@given("an order containing a Cheeseburger with Fries")
def setup_order(ctx):
    ctx.dish = CheeseburgerWithFries()

@when("I ask for vegan substitutions")
def substitute_vegan(ctx):
    ctx.dish.substitute_vegan_ingredients()

@then("I receive the meal with no animal products")
def check_all_vegan(ctx):
    assert all(is_vegan(ing) for ing in ctx.dish.ingredients())

每个步骤表示为与 Gherkin 需求条款匹配的装饰器。装饰的函数是作为规范的一部分执行的。在上面的示例中,Gherkin 需求将由以下代码表示(您无需编写此代码;Gherkin 为您完成):

from behave.runner import Context
context = Context()
setup_order(context)
substitute_vegan(context)
check_all_vegan(context)

要运行此操作,请先安装behave

pip install behave

然后,在包含您的需求和步骤的文件夹上运行behave

behave code_examples/chapter22/features

您将看到以下输出:

Feature: Vegan-friendly menu

  Scenario: Can substitute for vegan alternatives
    Given an order containing a Cheeseburger with Fries
    When I ask for vegan substitutions
    Then I receive the meal with no animal products

1 feature passed, 0 failed, 0 skipped
1 scenario passed, 0 failed, 0 skipped
3 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.000s

当此代码在终端或 IDE 中运行时,所有步骤显示为绿色。如果任何步骤失败,该步骤将变为红色,并显示失败的详细信息。

现在,您可以直接将您的需求与验收测试联系起来。如果最终用户改变主意,他们可以编写新的测试。如果 GWT 条款已经存在于新测试中,那是一个胜利;新测试可以在没有开发人员帮助的情况下编写。如果条款尚不存在,那也是一个胜利,因为当测试立即失败时,它会引发一场对话。您的最终用户和业务人员不需要 Python 知识即可理解您正在测试的内容。

使用 Gherkin 规范来推动关于需要构建的软件的对话。behave允许您直接将验收测试与这些需求联系起来,它们作为聚焦对话的一种方式。使用 BDD 防止您直接开始编写错误的内容。正如流行的说法所说:“几周的编码将节省您几小时的计划。”³

额外的 behave 特性

前面的示例有些基本,但幸运的是,behave提供了一些额外的功能,使测试编写更加简便。

参数化步骤

你可能已经注意到,我有两个非常相似的Given步骤:

Given an order containing a Cheeseburger with Fries

Given an order containing Meatloaf

在 Python 中编写两个类似的函数将是愚蠢的。behave 允许您参数化步骤,以减少编写多个步骤的需要。

@given("an order containing {dish_name}")
def setup_order(ctx, dish_name):
    if dish_name == "a Cheeseburger with Fries":
        ctx.dish = CheeseburgerWithFries()
    elif dish_name == "Meatloaf":
        ctx.dish = Meatloaf()

或者,如果需要的话,您可以在函数上堆叠从句:

@given("an order containing a Cheeseburger with Fries")
@given("a typical drive-thru order")
def setup_order(context):
    ctx.dish = CheeseBurgerWithFries()

参数化和重用步骤将帮助您构建直观易用的词汇表,从而减少编写 Gherkin 测试的成本。

表驱动需求

在 第二十一章 中,我提到您可以参数化测试,以便在表中定义所有的前置条件和断言。behave 提供了非常相似的功能:

Feature: Vegan-friendly menu

Scenario Outline: Vegan Substitutions
  Given an order containing <dish_name>,
  When I ask for vegan substitutions
  Then <result>

 Examples: Vegan Substitutable
   | dish_name                  | result |
   | a Cheeseburger with Fries  | I receive the meal with no animal products  |
   | Cobb Salad                 | I receive the meal with no animal products  |
   | French Fries               | I receive the meal with no animal products  |
   | Lemonade                   | I receive the meal with no animal products  |

 Examples: Not Vegan Substitutable
   | dish_name     | result |
   | Meatloaf      | a non-vegan-substitutable error shows up |
   | Meatballs     | a non-vegan-substitutable error shows up |
   | Fried Shrimp  | a non-vegan-substitutable error shows up |

behave 将自动为每个表项运行一个测试。这是在非常相似的数据上运行相同测试的绝佳方式。

步骤匹配

有时,基本的装饰器不足以捕获您尝试表达的内容。您可以告诉 behave 在装饰器中使用正则表达式解析。这对于使 Gherkin 规范编写起来更加自然(特别是在处理复杂数据格式或奇怪的语法问题时)非常有用。这里有一个示例,允许您在菜名前面加上可选的“a”或“an”(以简化菜名)。

from behave import use_context_matcher

use_step_matcher("re")

@given("an order containing [a |an ]?(?P<dish_name>.*)")
def setup_order(ctx, dish_name):
    ctx.dish = create_dish(dish_name)

定制测试生命周期

有时候您需要在测试运行之前或之后运行代码。比如,在所有规范设置之前需要设置数据库,或者告诉服务在测试运行之间清除其缓存。就像内置的 unittest 模块中的 setUptearDown 一样,behave 提供了让您在步骤、特性或整个测试运行之前或之后挂接函数的功能。使用这个功能可以整合通用的设置代码。为了充分利用这个功能,您可以在名为 environment.py 的文件中定义具体命名的函数。

def before_all(ctx):
    ctx.database = setup_database()

def before_feature(ctx, feature):
    ctx.database.empty_tables()

def after_all(ctx):
    ctx.database.cleanup()

查看 behave documentation 以获取有关控制环境的更多信息。如果您更喜欢 pytest 的 fixture,请查看 behavefixtures,它们具有非常相似的思想。

小贴士

before_featurebefore_scenario 这样的函数会将相应的特性或场景传递给它们。您可以根据这些特性和场景的名称来执行特定的动作,以处理测试的特定部分。

使用标签选择性运行测试

behave 还提供了标记某些测试的能力,这些标记可以是任何您想要的:@wip 用于正在进行的工作,@slow 用于运行缓慢的测试,@smoke 用于选择性运行的少数测试等。

要在 behave 中标记测试,只需装饰您的 Gherkin 场景:

Feature: Vegan-friendly Menu

  @smoke
  @wip
  Scenario: Can substitute for vegan alternatives
    Given an order containing a Cheeseburger with Fries
    When I ask for vegan substitutions
    Then I receive the meal with no animal products

要仅运行带有特定标签的测试,可以在 behave 调用时传递 --tags 标志:

behave code_examples/chapter22 --tags=smoke
小贴士

如果您想要排除运行某些测试,可以在标签前加一个连字符,就像在这个例子中,我排除了带有 wip 标签的测试:

behave code_examples/chapter22 --tags=-wip

报告生成

如果你不涉及最终用户或其代理,使用behave和 BDD 进行验收测试将毫无意义。找到让他们易于理解和使用 Gherkin 需求的方法。

你可以通过调用behave --steps-catalog获取所有步骤定义的列表。

当然,你还需要一种方法来展示测试结果,让最终用户了解什么在运行,什么不在运行。behave允许你以多种不同的方式格式化输出(你也可以定义自己的格式)。开箱即用,还可以从JUnit创建报告,JUnit 是为 Java 语言设计的单元测试框架。JUnit 将其测试结果写成 XML 文件,并构建了许多工具来接收和可视化测试结果。

要生成 JUnit 测试报告,你可以在behave调用中传递--junit。然后,你可以使用junit2html工具为所有测试用例生成报告:

pip install junit2html
behave code_examples/chapter22/features/ --junit
# xml files are in the reports folder
junit2html <filename>

示例输出显示在图 22-1 中。

ropy 2201

图 22-1. 使用junit2html的示例behave报告

有很多 JUnit 报告生成器,所以找一个你喜欢的并使用它生成你的测试结果的 HTML 报告。

结语

如果所有的测试都通过了,但未提供最终用户想要的内容,则浪费了时间和精力。构建正确的东西是昂贵的;你希望第一次就做对。使用 BDD 来推动关于系统需求的关键对话。一旦有了需求,使用behave和 Gherkin 语言编写验收测试。这些验收测试成为确保你提供最终用户所需内容的安全网。

在下一章中,你将继续学习如何修补你的安全网中的漏洞。你将了解使用名为Hypothesis的 Python 工具进行基于属性的测试。它可以为你生成测试用例,包括你可能从未想过的测试。你可以更放心地知道,你的测试覆盖范围比以往任何时候都要广泛。

¹ Gherkin 语言由 Aslak Hellesøy 创建。他的妻子建议他的 BDD 测试工具命名为黄瓜(显然没有具体原因),他希望将规范语言与测试工具本身区分开来。由于黄瓜是一种小型的腌制黄瓜,他延续了这个主题,于是 Gherkin 规范语言诞生了。

² 电话是一个游戏,每个人坐在一个圈子里,一个人对另一个人耳语一条消息。消息继续在圈子里传递,直到回到原点。每个人都会因为消息被扭曲而发笑。

³ 虽然这句话的作者是匿名的,但我最先看到它是在Programming Wisdom Twitter 账号上。

第二十三章:属性化测试

在您的代码库中不可能测试所有东西。您能做的最好的事情就是在如何针对特定用例上变得聪明。您寻找边界情况,代码路径,以及代码的其他有趣属性。您的主要希望是您没有在安全网中留下任何大漏洞。然而,您可以做得比希望更好。您可以使用属性化测试填补这些空白。

在本章中,您将学习如何使用名为Hypothesis的 Python 库进行基于属性的测试。您将使用Hypothesis来为您生成测试用例,通常是以您意想不到的方式。您将学习如何跟踪失败的测试用例,以新的方式制定输入数据,甚至让Hypothesis创建算法的组合来测试您的软件。Hypothesis将保护您的代码库免受一系列新错误的影响。

使用 Hypothesis 进行属性化测试

属性化测试是一种生成式测试形式,工具会为您生成测试用例。与基于特定输入/输出组合编写测试用例不同,您定义系统的属性。在这个上下文中,属性是指系统中成立的不变量(在第十章中讨论)的另一个名称。

考虑一个菜单推荐系统,根据顾客提供的约束条件选择菜肴,例如总热量、价格和菜系。对于这个特定示例,我希望顾客能够订购一顿全餐,热量低于特定的热量目标。以下是我为此功能定义的不变量:

  • 客户将收到三道菜:前菜、沙拉和主菜。

  • 当将所有菜肴的热量加在一起时,总和会小于它们的预期目标。

如果我要将此作为pytest测试来专注于测试这些属性,它会看起来像以下内容:

def test_meal_recommendation_under_specific_calories():
    calories = 900
    meals = get_recommended_meal(Recommendation.BY_CALORIES, calories)
    assert len(meals) == 3
    assert is_appetizer(meals[0])
    assert is_salad(meals[1])
    assert is_main_dish(meals[2])
    assert sum(meal.calories for meal in meals) < calories

将此与测试特定结果进行对比:

def test_meal_recommendation_under_specific_calories():
    calories = 900
    meals = get_recommended_meal(Recommendation.BY_CALORIES, calories)
    assert meals == [Meal("Spring Roll", 120),
                     Meal("Green Papaya Salad", 230),
                     Meal("Larb Chicken", 500)]

第二种方法是测试非常具体的一组餐点;这种测试更具体,但也更脆弱。当生产代码发生变化时,比如引入新菜单项或更改推荐算法时,它更容易出现问题。理想的测试是只有在出现真正的错误时才会失败。请记住,测试并非免费。您希望减少维护成本,缩短调整测试所需的时间是一个很好的方法。

在这两种情况下,我正在使用特定输入进行测试:900 卡路里。为了建立更全面的安全网,扩展您的输入领域以测试更多案例是一个好主意。在传统的测试案例中,您通过执行边界值分析来选择编写哪些测试。边界值分析是指分析待测试的代码,寻找不同输入如何影响控制流程或代码中的不同执行路径。

例如,假设 get_recommended_meal 在卡路里限制低于 650 时引发错误。在这种情况下的边界值是 650;这将输入域分割成两个等价类或具有相同属性的值集。一个等价类是所有低于 650 卡路里的数字,另一个等价类是 650 及以上的值。通过边界值分析,应该有三个测试:一个测试低于 650 卡路里的卡路里,一个测试刚好在 650 卡路里的边界处,以及一个测试一个高于 650 卡路里的值。实际上,这验证了开发人员没有搞错关系运算符(例如写成 <= 而不是 <)或者出现了差一错误。

然而,边界值分析仅在你能够轻松分割输入域时才有用。如果确定在哪里应该分割域很困难,那么挑选边界值将不容易。这就是 Hypothesis 的生成性质发挥作用的地方;Hypothesis 为测试用例生成输入。它将为你找到边界值。

你可以通过 pip 安装 Hypothesis

pip install hypothesis

我将修改我的原始属性测试,让 Hypothesis 负责生成输入数据。

from hypothesis import given
from hypothesis.strategies import integers

@given(integers())
def test_meal_recommendation_under_specific_calories(calories):
    meals = get_recommended_meal(Recommendation.BY_CALORIES, calories)
    assert len(meals) == 3
    assert is_appetizer(meals[0])
    assert is_salad(meals[1])
    assert is_main_dish(meals[2])
    assert sum(meal.calories for meal in meals) < calories

只需一个简单的装饰器,我就可以告诉 Hypothesis 为我选择输入。在这种情况下,我要求 Hypothesis 生成不同的 integers 值。Hypothesis 将运行此测试多次,尝试找到违反预期属性的值。如果我用 pytest 运行这个测试,我会看到以下输出:

Falsifying example: test_meal_recommendation_under_specific_calories(
    calories=0,
)
============= short test summary info ======================
FAILED code_examples/chapter23/test_basic_hypothesis.py::
    test_meal_recommendation_under_specific_calories - assert 850 < 0

Hypothesis 在我的生产代码中早期发现了一个错误:代码不能处理零卡路里限制。现在,对于这种情况,我想指定我只应该测试某个特定数量的卡路里或以上:

@given(integers(min_value=900))
def test_meal_recommendation_under_specific_calories(calories)
    # ... snip ...

现在,当我用 pytest 命令运行时,我想展示一些关于 Hypothesis 的更多信息。我会运行:

py.test code_examples/chapter23 --hypothesis-show-statistics

这将产生以下输出:

code_examples/chapter23/test_basic_hypothesis.py::
    test_meal_recommendation_under_specific_calories:

  - during generate phase (0.19 seconds):
    - Typical runtimes: 0-1 ms, ~ 48% in data generation
    - 100 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because settings.max_examples=100

Hypothesis 为我检查了 100 个不同的值,而我不需要提供任何具体的输入。更重要的是,每次运行这个测试时,Hypothesis 都会检查新值。与一次又一次地限制自己于相同的测试用例不同,你可以在你测试的内容上获得更广泛的覆盖面。考虑到所有不同的开发人员和持续集成管道系统执行测试,你会意识到你可以多快地捕获边缘情况。

提示

你还可以通过使用 hypothesis.assume 来在你的领域上指定约束条件。你可以在你的测试中写入假设,比如 assume(calories > 850),告诉 Hypothesis 跳过违反这些假设的任何测试用例。

如果我引入一个错误(例如因某种原因在 5,000 到 5,200 卡路里之间出错),Hypothesis 将在四次测试运行内捕获错误(你的测试运行次数可能会有所不同):

_________ test_meal_recommendation_under_specific_calories _________

    @given(integers(min_value=900))
>   def test_meal_recommendation_under_specific_calories(calories):

code_examples/chapter23/test_basic_hypothesis.py:33:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

calories = 5001

    @given(integers(min_value=900))
    def test_meal_recommendation_under_specific_calories(calories):
        meals = get_recommended_meal(Recommendation.BY_CALORIES, calories)
>       assert len(meals) == 3
E       TypeError: object of type 'NoneType' has no len()

code_examples/chapter23/test_basic_hypothesis.py:35: TypeError
------------------------ Hypothesis --------------------------------
Falsifying example: test_meal_recommendation_under_specific_calories(
    calories=5001,
)
=========== Hypothesis Statistics ========================
code_examples/chapter23/test_basic_hypothesis.py::
   test_meal_recommendation_under_specific_calories:

  - during reuse phase (0.00 seconds):
    - Typical runtimes: ~ 1ms, ~ 43% in data generation
    - 1 passing examples, 0 failing examples, 0 invalid examples

  - during generate phase (0.08 seconds):
    - Typical runtimes: 0-2 ms, ~ 51% in data generation
    - 26 passing examples, 1 failing examples, 0 invalid examples
    - Found 1 failing example in this phase

  - during shrink phase (0.07 seconds):
    - Typical runtimes: 0-2 ms, ~ 37% in data generation
    - 22 passing examples, 12 failing examples, 1 invalid examples
    - Tried 35 shrinks of which 11 were successful

  - Stopped because nothing left to do

当您发现错误时,Hypothesis会记录失败的错误,以便将来可以专门检查该值。您还可以通过hypothesis.example装饰器确保Hypothesis始终测试特定情况:

@given(integers(min_value=900))
@example(5001)
def test_meal_recommendation_under_specific_calories(calories)
    # ... snip ...

魔法假设

Hypothesis非常擅长生成会发现错误的测试用例。这似乎像是魔法,但实际上相当聪明。在前面的例子中,您可能已经注意到Hypothesis在值 5001 上出现了错误。如果您运行相同的代码并为大于 5000 的值引入一个错误,您将发现测试仍在 5001 处出现错误。如果Hypothesis正在测试不同的值,我们难道不应该看到稍微不同的结果吗?

Hypothesis发现失败时,它会为您做一些非常好的事情:它缩小了测试用例。缩小是指Hypothesis尝试找到仍然导致测试失败的最小输入。对于integers()Hypothesis会尝试依次更小的数字(或处理负数时更大的数字),直到输入值达到零。Hypothesis试图聚焦(无意冒犯)于仍然导致测试失败的最小值。

要了解有关Hypothesis如何生成和缩小值的更多信息,值得阅读原始QuickCheck 论文。QuickCheck 是最早的基于属性的工具之一,尽管它涉及 Haskell 编程语言,但信息量很大。大多数基于属性的测试工具(如Hypothesis)都是基于 QuickCheck 提出的思想的后继者。

与传统测试的对比

基于属性的测试可以极大地简化编写测试的过程。有一整类问题是你不需要担心的:

更容易测试非确定性

非确定性是大多数传统测试的祸根。随机行为、创建临时目录或从数据库中检索不同的记录可能会使编写测试变得非常困难。您必须在测试中创建一组特定的输出值,为此,您需要是确定性的;否则,您的测试将一直失败。通常,您会尝试通过强制特定行为来控制非确定性,例如强制创建相同的文件夹或对随机数生成器进行种子化。

使用基于属性的测试,非确定性是其中的一部分。Hypothesis将为每个测试运行提供不同的输入。您不必再担心测试特定值了;定义属性并接受非确定性。您的代码库会因此变得更好。

更少的脆弱性

在测试特定输入/输出组合时,您受到一大堆硬编码假设的影响。您假设列表始终按照相同的顺序排列,字典不会添加任何键-值对,并且您的依赖项永远不会改变其行为。这些看似不相关的变化中的任何一个都可能破坏您的一个测试。

当测试由于与被测试功能无关的原因而失败时,这是令人沮丧的。测试因易出错而声名狼藉,要么被忽视(掩盖真正的失败),要么开发者不得不面对不断需要修复测试的烦恼。使用基于属性的测试增强您的测试的韧性。

更好地找到错误的机会

基于属性的测试不仅仅是为了减少测试创建和维护成本。它将增加您发现错误的机会。即使今天您编写的测试覆盖了代码的每条路径,仍然有可能会遗漏某些情况。如果您的函数以不向后兼容的方式更改(例如,现在对您先前认为是正常的值出现错误),那么您的运气取决于是否有一个特定值的测试用例。基于属性的测试,通过生成新的测试用例,将在多次运行中更有可能发现该错误。

讨论主题

检查您当前的测试用例,并选择阅读起来复杂的测试。搜索需要大量输入和输出以充分测试功能的测试。讨论基于属性的测试如何取代这些测试并简化您的测试套件。

充分利用Hypothesis

到目前为止,我只是初步了解了Hypothesis。一旦你真正深入进行基于属性的测试,你会为自己打开大量的机会。Hypothesis提供了一些非常酷的功能,可以显著改进您的测试体验。

Hypothesis 策略

在上一节中,我向您介绍了integers()策略。Hypothesis策略定义了如何生成测试用例以及在测试用例失败时如何收缩数据。Hypothesis内置了大量的策略。类似于将integers()传递给您的测试用例,您可以传递floats()text()times()来生成浮点数、字符串或datetime.time对象的值。

Hypothesis还提供了一些可以组合其他策略的策略,例如构建策略的列表、元组或字典(这是组合性的一个很好的例子,如第十七章所述)。例如,假设我想创建一个策略,将菜名(文本)映射到卡路里(在 100 到 2000 之间的数字):

from hypothesis import given
from hypothesis.strategies import dictionary, integers, text

@given(dictionaries(text(), integers(min_value=100, max_value=2000)))
def test_calorie_count(ingredient_to_calorie_mapping : dict[str, int]):
    # ... snip ...

对于更复杂的数据,你可以使用Hypothesis来定义自己的策略。您可以使用mapfilter策略,这些策略的概念类似于内置的mapfilter函数。

您还可以使用hypothesis.composite策略装饰器来定义自己的策略。我想创建一个策略,为我创建三道菜的套餐,包括前菜、主菜和甜点。每道菜包含名称和卡路里计数:

from hypothesis import given
from hypothesis.strategies import composite, integers

ThreeCourseMeal = tuple[Dish, Dish, Dish]

@composite
def three_course_meals(draw) -> ThreeCourseMeal:
    appetizer_calories = integers(min_value=100, max_value=900)
    main_dish_calories = integers(min_value=550, max_value=1800)
    dessert_calories = integers(min_value=500, max_value=1000)

    return (Dish("Appetizer", draw(appetizer_calories)),
            Dish("Main Dish", draw(main_dish_calories)),
            Dish("Dessert", draw(dessert_calories)))

@given(three_course_meals)
def test_three_course_meal_substitutions(three_course_meal: ThreeCourseMeal):
    # ... do something with three_course_meal

此示例通过定义一个名为three_course_meals的新复合策略来工作。我创建了三种整数策略;每种类型的菜品都有自己的策略及其自己的最小/最大值。然后,我创建了一个新的菜品,它具有名称和从策略中绘制的值。draw是一个传递给您的复合策略的函数,您可以使用它来选择策略中的值。

一旦您定义了自己的策略,就可以在多个测试中重复使用它们,从而轻松为系统生成新数据。要了解更多关于Hypothesis策略的信息,建议您阅读Hypothesis文档

生成算法

在先前的示例中,我专注于生成输入数据以创建您的测试。然而,Hypothesis可以进一步生成操作的组合。Hypothesis称之为有状态测试

考虑我们的餐饮推荐系统。我展示了如何按卡路里进行过滤,但现在我还想按价格、课程数、接近用户等进行过滤。以下是我想要对系统断言的一些属性:

  • 餐饮推荐系统始终返回三种餐饮选择;可能不是所有推荐的选项都符合用户的所有标准。

  • 所有三种餐饮选择都是唯一的。

  • 餐饮选择是基于最近应用的过滤器排序的。在出现平局的情况下,使用次新的过滤器。

  • 新的过滤器会替换相同类型的旧过滤器。例如,如果您将价格过滤器设置为<$20,然后将其更改为<$15,则只应用<$15 过滤器。设置像卡路里过滤器这样的内容,例如<1800 卡路里,并不会影响价格过滤器。

而不是编写大量的测试用例,我将使用hypothesis.stateful.RuleBasedStateMachine来表示我的测试。这将允许我使用Hypothesis测试整个算法,同时检查不变量。有点复杂,所以我会先展示整段代码,然后逐部分解释。

from functools import reduce
from hypothesis.strategies import integers
from hypothesis.stateful import Bundle, RuleBasedStateMachine, invariant, rule

class RecommendationChecker(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.recommender = MealRecommendationEngine()
        self.filters = []

    @rule(price_limit=integers(min_value=6, max_value=200))
    def filter_by_price(self, price_limit):
        self.recommender.apply_price_filter(price_limit)
        self.filters = [f for f in self.filters if f[0] != "price"]
        self.filters.append(("price", lambda m: m.price))

    @rule(calorie_limit=integers(min_value=500, max_value=2000))
    def filter_by_calories(self, calorie_limit):
        self.recommender.apply_calorie_filter(calorie_limit)
        self.filters = [f for f in self.filters if f[0] != "calorie"]
        self.filters.append(("calorie", lambda m: m.calories))

    @rule(distance_limit=integers(max_value=100))
    def filter_by_distance(self, distance_limit):
        self.recommender.apply_distance_filter(distance_limit)
        self.filters = [f for f in self.filters if f[0] != "distance"]
        self.filters.append(("distance", lambda m: m.distance))

    @invariant()
    def recommender_provides_three_unique_meals(self):
        assert len(self.recommender.get_meals()) == 3
        assert len(set(self.recommender.get_meals())) == 3

    @invariant()
    def meals_are_appropriately_ordered(self):
        meals = self.recommender.get_meals()
        ordered_meals = reduce(lambda meals, f: sorted(meals, key=f[1]),
                               self.filters,
                               meals)
        assert ordered_meals == meals

TestRecommender = RecommendationChecker.TestCase

这是相当多的代码,但它真的很酷,因为它是如何运作的。让我们逐步分解它。

首先,我将创建一个hypothesis.stateful.RuleBasedStateMachine的子类:

from functools import reduce
from hypothesis.strategies import integers
from hypothesis.stateful import Bundle, RuleBasedStateMachine, invariant, rule

class RecommendationChecker(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.recommender = MealRecommendationEngine()
        self.filters = []

此类将负责定义我想要以组合形式测试的离散步骤。在构造函数中,我将self.recommender设置为MealRecommendationEngine,这是我在此场景中正在测试的内容。我还将跟踪作为此类的一部分应用的过滤器列表。接下来,我将设置hypothesis.stateful.rule函数:

    @rule(price_limit=integers(min_value=6, max_value=200))
    def filter_by_price(self, price_limit):
        self.recommender.apply_price_filter(price_limit)
        self.filters = [f for f in self.filters if f[0] != "price"]
        self.filters.append(("price", lambda m: m.price))

    @rule(calorie_limit=integers(min_value=500, max_value=2000))
    def filter_by_calories(self, calorie_limit):
        self.recommender.apply_calorie_filter(calorie_limit)
        self.filters = [f for f in self.filters if f[0] != "calorie"]
        self.filters.append(("calorie", lambda m: m.calories))

    @rule(distance_limit=integers(max_value=100))
    def filter_by_distance(self, distance_limit):
        self.recommender.apply_distance_filter(distance_limit)
        self.filters = [f for f in self.filters if f[0] != "distance"]
        self.filters.append(("distance", lambda m: m.distance))

每个规则都充当您想要测试的算法的步骤。Hypothesis将使用这些规则生成测试,而不是生成测试数据。在这种情况下,每个规则都将一个过滤器应用于推荐引擎。我还将这些过滤器保存在本地,以便稍后检查结果。

然后,我使用hypothesis.stateful.invariant装饰器来定义应在每次规则更改后检查的断言。

    @invariant()
    def recommender_provides_three_unique_meals(self):
        assert len(self.recommender.get_meals()) == 3
        # make sure all of the meals are unique - sets de-dupe elements
        # so we should have three unique elements
        assert len(set(self.recommender.get_meals())) == 3

    @invariant()
    def meals_are_appropriately_ordered(self):
        meals = self.recommender.get_meals()
        ordered_meals = reduce(lambda meals, f: sorted(meals, key=f[1]),
                               self.filters,
                               meals)
        assert ordered_meals == meals

我写了两个不变量:一个声明推荐器始终返回三个唯一的餐点,另一个声明这些餐点根据所选择的过滤器按正确的顺序排列。

最后,我将RecommendationChecker中的TestCase保存到一个以Test为前缀的变量中。这样做是为了让pytest能够发现这个有状态的Hypothesis测试。

TestRecommender = RecommendationChecker.TestCase

一旦所有东西都组装好了,Hypothesis将开始生成具有不同规则组合的测试用例。例如,通过一个Hypothesis测试运行(故意引入错误),Hypothesis生成了以下测试。

state = RecommendationChecker()
state.filter_by_distance(distance_limit=0)
state.filter_by_distance(distance_limit=0)
state.filter_by_distance(distance_limit=0)
state.filter_by_calories(calorie_limit=500)
state.filter_by_distance(distance_limit=0)
state.teardown()

当我引入不同的错误时,Hypothesis会展示给我一个不同的测试用例来捕获错误。

state = RecommendationChecker()
state.filter_by_price(price_limit=6)
state.filter_by_price(price_limit=6)
state.filter_by_price(price_limit=6)
state.filter_by_price(price_limit=6)
state.filter_by_distance(distance_limit=0)
state.filter_by_price(price_limit=16)
state.teardown()

这对于测试复杂算法或具有非常特定不变量的对象非常方便。Hypothesis会混合匹配不同的步骤,不断寻找能产生错误的步骤顺序。

讨论主题

你的代码库中的哪些区域包含难以测试、高度相关的函数?写几个有状态的Hypothesis测试作为概念验证,并讨论这些测试如何增强你的测试套件的信心。

总结思考

基于属性的测试并不是为了取代传统测试;它是为了补充传统测试。当你的代码具有明确定义的输入和输出时,使用硬编码的前提条件和预期断言进行测试就足够了。然而,随着你的代码变得越来越复杂,你的测试也变得越来越复杂,你会发现自己花费的时间比想象中多,解析和理解测试。

在 Python 中,使用Hypothesis很容易实现基于属性的测试。它通过在代码库的整个生命周期内生成新的测试来修补你的安全网。你可以使用hypothesis.strategies来精确控制测试数据的生成方式。你甚至可以通过将不同的步骤组合进行hypothesis.stateful测试来测试算法。Hypothesis将让你专注于代码的属性和不变量,并更自然地表达你的测试。

在下一章中,我将用变异测试来结束本书。变异测试是填补安全网漏洞的另一种方法。与找到测试代码的新方法不同,变异代码专注于衡量你的测试的有效性。它是你更强大测试工具中的另一个工具。

第二十四章:突变测试

当你编织静态分析和测试的安全网时,你如何知道你是否尽可能多地进行了测试?测试绝对所有内容是不可能的;你需要在编写测试时聪明地选择。设想每个测试都是安全网中的一根单独的绳索:你拥有的测试越多,你的网越宽。然而,这并不意味着你的安全网就一定构建得很好。一张由破旧、脆弱绳索编织的安全网比没有安全网更糟糕;它会产生安全的错觉,并提供虚假的信心。

目标是加强你的安全网,使其不易破损。你需要一种方式确保当代码中存在 bug 时,你的测试确实能失败。在本章中,你将学习如何通过突变测试来做到这一点。你将学习如何使用一个名为 mutmut 的 Python 工具进行突变测试。你将使用突变测试来检查你的测试与代码之间的关系。最后,你将了解代码覆盖工具,如何最佳地使用这些工具,以及如何将 mutmut 与你的覆盖报告集成。学习如何进行突变测试将为你提供一种衡量你的测试有效性的方法。

什么是突变测试?

突变测试 是有意在源代码中引入 bug 的操作。¹ 你每次以这种方式进行的更改称为 突变体。然后你运行你的测试套件。如果测试失败,那是好消息;你的测试成功消除了突变体。但是,如果你的测试通过了,这意味着你的测试不够强大,无法捕获合法的失败;突变体存活了下来。突变测试是一种 元测试 形式,因为你在测试你的测试有多好。毕竟,你的测试代码应该是代码库中的一等公民;它也需要一定程度的测试。

考虑一个简单的卡路里追踪应用程序。用户可以输入一系列餐食,并在超出他们每日卡路里预算时收到通知。核心功能由以下函数实现:

def check_meals_for_calorie_overage(meals: list[Meal], target: int):
    for meal in meals:
        target -= meal.calories
        if target < 0:
            display_warning(meal, WarningType.OVER_CALORIE_LIMIT)
            continue
        display_checkmark(meal)

这里是一组针对此功能的测试,全部通过了:

def test_no_warnings_if_under_calories():
    meals = [Meal("Fish 'n' Chips", 1000)]
    check_meals_for_calorie_overage(meals, 1200)
    assert_no_warnings_displayed_on_meal("Fish 'n' Chips")
    assert_checkmark_on_meal("Fish 'n' Chips")

def test_no_exception_thrown_if_no_meals():
    check_meals_for_calorie_overage([], 1200)
    # no explicit assert, just checking for no exceptions

def test_meal_is_marked_as_over_calories():
    meals = [Meal("Fish 'n' Chips", 1000)]
    check_meals_for_calorie_overage(meals, 900)
    assert_meal_is_over_calories("Fish 'n' Chips")

def test_meal_going_over_calories_does_not_conflict_with_previous_meals():
    meals = [Meal("Fish 'n' Chips", 1000), Meal("Banana Split", 400)]
    check_meals_for_calorie_overage(meals, 1200)
    assert_no_warnings_displayed_on_meal("Fish 'n' Chips")
    assert_checkmark_on_meal("Fish 'n' Chips")
    assert_meal_is_over_calories("Banana Split")

这是一个思维练习,我希望你能审视这些测试(暂且不论这是关于突变测试的一章),并问问自己如果在生产环境中发现这些测试,你的观点会是什么。你对它们的正确性有多大信心?你确信我没有漏掉任何东西吗?你相信这些测试能在代码变更时捕获错误吗?

本书的核心主题是软件将会不断变化。你需要让你未来的合作者能够轻松地维护你的代码库,尽管这些变化。你需要编写不仅可以捕获你所写内容中的错误,还能捕获其他开发者在修改你的代码时产生的错误的测试。

无论未来的开发人员是重构方法以使用常用库,更改单个行还是向代码添加更多功能,您希望您的测试都能捕捉到他们引入的任何错误。要进入变异测试的思维方式,您需要考虑可能对代码进行的所有更改,并检查您的测试是否能捕捉到任何错误的变化。表 24-1 逐行分解上述代码,并显示如果缺少该行,则测试的结果。

表 24-1。删除每一行的影响

代码行 删除后的影响
for meal in meals: 测试失败:语法错误,代码不执行循环
target -= meal.calories 测试失败:从未显示任何警告
if target < 0 测试失败:所有餐点显示警告
display_warning(meal, WarningType.OVER_CALO⁠RIE_LIMIT) 测试失败:未显示任何警告
continue 测试通过
display_checkmark(meal) 测试失败:餐点上没有显示勾号

查看表 24-1 中continue语句所在的行。如果删除该行,则所有测试都通过。这意味着发生了三种情况之一:该行不需要;该行是需要的,但不重要到需要进行测试;或者我们的测试套件中存在覆盖不足。

前两种情况很容易处理。如果不需要该行,请删除它。如果该行不重要到需要进行测试(这在诸如调试日志语句或版本字符串等情况下很常见),则可以忽略对此行的变异测试。但是,如果第三种情况属实,则意味着测试覆盖率不足。你发现了安全网中的一个漏洞。

如果从算法中删除continue,则在超过卡路里限制的任何餐点上将显示一个勾号和一个警告。这不是理想的行为;这是一个信号,表明我应该有一个测试来覆盖这种情况。如果我只是添加一个断言,即带有警告的餐点也没有勾号,那么我们的测试套件就会捕捉到这个变异。

删除行只是变异的一个示例。我可以对上述代码应用许多其他的变异。事实上,如果我将continue改为break,测试仍然通过。浏览我可以想到的每个变异是件令人厌烦的事情,所以我希望有一个自动化工具来为我完成这个过程。进入mutmut

使用 mutmut 进行变异测试

mutmut是一个 Python 工具,用于为您进行变异测试。它附带了一组预编程的变异,可以应用于您的代码库,例如:

  • 查找整数字面量并将其加 1 以捕捉偏移一个错误

  • 通过在字符串字面量中插入文本来更改字符串字面量

  • 交换breakcontinue

  • 交换TrueFalse

  • 否定表达式,例如将x is None转换为x is not None

  • 更改运算符(特别是从///

这绝不是一个全面的列表;mutmut有很多巧妙的方式来变异你的代码。它通过进行离散变异,运行你的测试套件,然后显示哪些变异在测试过程中存活下来。

要开始使用,您需要安装mutmut

pip install mutmut

然后,你运行mutmut对所有测试进行测试(警告,这可能需要一些时间)。你可以使用以下命令在我上面的代码片段上运行mutmut

mutmut run --paths-to-mutate code_examples/chapter24
提示

对于长时间运行的测试和大型代码库,你可能需要将mutmut运行分开,因为它确实需要一些时间。然而,mutmut足够智能,可以将其进度保存到名为.mutmut-cache的文件夹中,因此如果中途退出,未来的运行将从相同的点继续执行。

mutmut在运行时会显示一些统计信息,包括存活的变异数量、被消除的变异数量以及哪些测试耗时过长(例如意外引入无限循环)。

执行完成后,你可以使用mutmut results查看结果。在我的代码片段中,mutmut识别出三个存活的变异。它将变异列为数字 ID,你可以使用mutmut show <id>命令显示具体的变异。

这是我代码片段中存活的三个变异:

mutmut show 32
--- code_examples/chapter24/calorie_tracker.py
+++ code_examples/chapter24/calorie_tracker.py
@@ -26,7 +26,7 @@
 def check_meals_for_calorie_overage(meals: list[Meal], target: int):
     for meal in meals:
         target -= meal.calories
-        if target < 0:
+        if target <= 0:
             display_warning(meal, WarningType.OVER_CALORIE_LIMIT)
             continue
         display_checkmark(meal)

mutmut show 33
--- code_examples/chapter24/calorie_tracker.py
+++ code_examples/chapter24/calorie_tracker.py
@@ -26,7 +26,7 @@
 def check_meals_for_calorie_overage(meals: list[Meal], target: int):
     for meal in meals:
         target -= meal.calories
-        if target < 0:
+        if target < 1:
             display_warning(meal, WarningType.OVER_CALORIE_LIMIT)
             continue
         display_checkmark(meal)

mutmut show 34
--- code_examples/chapter24/calorie_tracker.py
+++ code_examples/chapter24/calorie_tracker.py
@@ -28,6 +28,6 @@
         target -= meal.calories
         if target < 0:
             display_warning(meal, WarningType.OVER_CALORIE_LIMIT)
-            continue
+            break
         display_checkmark(meal)

在每个示例中,mutmut差异符号显示结果,这是一种表示文件从一个变更集到另一个变更集变化的方法。在这种情况下,任何以减号“-”开头的行表示由mutmut更改的行;以加号“+”开头的行是mutmut进行的更改;这些就是你的变异。

这些情况中的每一个都是我测试中的潜在漏洞。通过将<=更改为<,我发现我没有覆盖当餐点的卡路里恰好等于目标时的情况。通过将0更改为1,我发现我在输入域的边界上没有覆盖(参见第二十三章讨论的边界值分析)。通过将continue更改为break,我提前终止循环,可能会错过标记后续餐点为 OK 的机会。

修复变异

一旦确定了变异,开始修复它们。最好的方法之一是将变异应用到你磁盘上的文件中。在我之前的示例中,我的变异有 32、33 和 34。我可以这样将它们应用到我的代码库中:

mutmut apply 32
mutmut apply 33
mutmut apply 34
警告

只对通过版本控制备份的文件执行此操作。这使得完成后还原变异变得容易,恢复原始代码。

一旦 一旦变异已应用到磁盘上,你的目标是编写一个失败的测试。例如,我可以编写以下代码:

def test_failing_mutmut():
    clear_warnings()
    meals = [Meal("Fish 'n' Chips", 1000),
             Meal("Late-Night Cookies", 300),
             Meal("Banana Split", 400)
             Meal("Tub of Cookie Dough", 1000)]

    check_meals_for_calorie_overage(meals, 1300)

    assert_no_warnings_displayed_on_meal("Fish 'n' Chips")
    assert_checkmark_on_meal("Fish 'n' Chips")
    assert_no_warnings_displayed_on_meal("Late-Night Cookies")
    assert_checkmark_on_meal("Late-Night Cookies")
    assert_meal_is_over_calories("Banana Split")
    assert_meal_is_over_calories("Tub of Cookie Dough")

即使你只应用了一个变异,你也应该看到这个测试失败。一旦你确信已经捕获了所有变异,就还原变异,并确保测试现在通过。重新运行mutmut,应该显示你已消除变异。

变异测试报告

mutmut 还提供了一种将其结果导出为 JUnit 报告格式的方法。在本书中已经看到其他工具导出为 JUnit 报告(例如第二十二章),mutmut 也不例外:

mutmut junitxml > /tmp/test.xml

正如第二十二章中提到的那样,我可以使用 junit2html 为变异测试生成一个漂亮的 HTML 报告,如图 24-1 所示。

ropy 2401

Figure 24-1. 用 junit2html 生成的 mutmut 报告示例

采用突变测试

突变测试在今天的软件开发社区中并不普遍。我认为原因有三:

  • 人们对它及其带来的好处并不了解。

  • 一个代码库的测试还不够成熟,以至于无法进行有用的突变测试。

  • 成本与价值比例太高。

本书正在积极努力改进第一个观点,但第二和第三观点确实有其道理。

如果您的代码库没有成熟的测试集,那么引入突变测试将毫无意义。这将导致信号与噪声比过高。与其试图找到所有的突变体,不如通过改进测试套件来获得更多价值。考虑在代码库中那些已经具有成熟测试套件的较小部分上运行突变测试。

突变测试确实成本高;为了使突变测试值得进行,必须最大化收益。由于多次运行测试套件,突变测试非常缓慢。将突变测试引入现有代码库也很痛苦。从一开始就在全新代码上进行远比较轻松。

但是,由于您正在阅读一本关于提高潜在复杂代码库健壮性的书,很可能您正在处理现有的代码库。如果您想引入突变测试,还是有希望的。与提高健壮性的任何方法一样,关键是选择性地在需要进行突变测试的地方进行。

寻找有大量 bug 的代码区域。查阅 bug 报告并找出表明某个代码区域有问题的趋势。还要考虑找出代码变动频繁的区域,因为这些区域最有可能引入当前测试尚未完全覆盖的变更。² 找到突变测试将多倍回报成本的代码区域。您可以使用 mutmut 选择性地在这些区域上运行突变测试。

此外,mutmut 还提供了一种只对具有行覆盖率的代码库进行突变测试的选项。如果一行代码至少被任何测试执行过一次,则该行代码具有测试套件覆盖率。还存在其他覆盖类型,如 API 覆盖率和分支覆盖率,但mutmut专注于行覆盖率。mutmut只会为您实际上已经有测试的代码生成突变体。

要生成覆盖率,请首先安装 coverage

pip install coverage

然后使用coverage命令运行你的测试套件。例如,我运行:

coverage run -m pytest code_examples/chapter24

接下来,你只需在你的mutmut运行中传递--use-coverage标志:

mutmut run --paths-to-mutate code_examples/chapter24 --use-coverage

有了这个,mutmut将忽略任何未经测试的代码,大大减少了噪音量。

覆盖率的谬论(及其他度量标准)

每当有一种衡量代码的方法出现时,都会急于将该衡量作为一个指标或目标,作为商业价值的代理预测器。然而,在软件开发历史上,出现了许多不明智的度量标准,其中没有比使用编写的代码行数作为项目进展指标更臭名昭著的了。这种想法认为,如果你能直接测量任何一个人编写的代码量,你就能直接衡量该人的生产力。不幸的是,这导致开发人员操纵系统,并试图故意编写冗长的代码。这种指标反而适得其反,因为系统变得复杂且臃肿,开发由于维护性差而放缓。

作为一个行业,我们已经超越了衡量代码行数(希望如此)。然而,一个指标消失之处,另外两个指标便会顶替其位置。我见过其他受到责难的指标出现,如修复的错误数量或编写的测试数量。表面上,这些都是应该做的好事情,但问题在于,当它们作为与商业价值相关联的指标被审视时。在每个指标中都有操纵数据的方法。你是否因为修复的错误数量而受到评判?那么,首先只需写更多的错误!

不幸的是,代码覆盖率在近年来也陷入了同样的陷阱。你会听到诸如“这段代码应该 100%覆盖每一行”或“我们应该争取 90%的分支覆盖率”的目标。这在孤立情况下值得赞扬,但未能预测商业价值。它忽略了首先为什么你要设定这些目标的原因

代码覆盖率预示着健壮性的缺失,而非许多人所认为的质量。覆盖率低的代码可能会或可能不会做你需要的每件事;你无法可靠地知道。这表明你在修改代码时会遇到挑战,因为你的系统中没有任何安全网围绕该部分建立。你绝对应该寻找覆盖率非常低的区域,并改善其周围的测试情况。

相反,这导致许多人认为高覆盖率预示着健壮性,但实际上并非如此。你可以测试每一行和每一个分支,但仍然可能维护性糟糕。测试可能会变得脆弱甚至彻底无用。

我曾在一个开始采用单元测试的代码库中工作过。我遇到了一个类似以下内容的文件:

def test_foo_can_do_something():
    foo = Thingamajiggy()
    foo.doSomething()
    assert foo is not None

def test_foo_parameterized_still_does_the_right_thing():
    foo = Thingamajiggy(y=12)
    foo.doSomethingElse(15)
    assert foo is not None

大约有 30 个这样的测试,所有的测试都有良好的名称,并遵循 AAA 模式(如在第二十一章中所见)。但它们实际上是完全无用的:它们只是确保没有抛出异常。最糟糕的是,这些测试实际上具有 100%的行覆盖率和接近 80%的分支覆盖率。这些测试检查没有抛出异常并不是件坏事;坏的是,它们实际上没有测试实际的函数,尽管表面上看起来不是这样。

变异测试是防止关于代码覆盖的错误假设的最佳防御。当你衡量你的测试的有效性时,编写无用、无意义的测试变得更加困难,同时还要消除突变体。变异测试将覆盖率测量提升为更真实的健壮性预测器。覆盖率指标仍然不能完美地代表业务价值,但变异测试确实使它们作为健壮性指标更有价值。

警告

随着变异测试变得越来越流行,我完全预料到,“消除突变体的数量”将成为取代“100%代码覆盖率”的新流行度量标准。虽然你确实希望更少的突变体存活,但要注意任何脱离上下文的单一指标目标;这个数字可以像其他所有指标一样被操控。你仍然需要一个完整的测试策略来确保代码库的健壮性。

总结思考

变异测试可能不会是你首选的工具。然而,它是你测试策略的完美补充;它找出你安全网中的漏洞并引起你的注意。通过像mutmut这样的自动化工具,你可以利用现有的测试套件轻松进行变异测试。变异测试帮助你提高测试套件的健壮性,进而帮助你编写更加健壮的代码。

这是本书的第四部分的结尾。你从学习静态分析开始,它以低成本提供早期反馈。然后你了解了测试策略以及如何问自己你希望你的测试回答什么样的问题。从那里,你学习了三种具体的测试类型:验收测试、基于属性的测试和变异测试。所有这些都是增强你现有测试策略的方式,为你的代码库建立更密集、更强大的安全网。有了强大的安全网,你将为未来的开发人员提供信心和灵活性,让他们按需发展你的系统。

这也标志着整本书的结束。这是一个漫长的旅程,你在这条路上学到了各种技巧、工具和方法。你深入研究了 Python 的类型系统,学会了如何编写自己的类型以及如何编写可扩展的 Python 代码。本书的每一部分都为你提供了构建块,将帮助你的代码库经受住时间的考验。

虽然这是书的结尾,但这并非关于 Python 鲁棒性终结的故事。我们这个相对年轻的行业仍在不断演变和转型,随着软件不断“吞噬世界”,复杂系统的健康性和可维护性变得至关重要。我预计我们对软件理解的方式将持续变化,并出现新的工具和技术来构建更好的系统。

永远不要停止学习。Python 将继续发展,添加功能并提供新工具。每一个功能都有可能改变你编写代码的方式。我无法预测 Python 或其生态系统的未来。随着 Python 引入新功能,问问自己这个功能表达了什么意图。如果他们看到了这个新功能,代码读者会假设什么?如果没有使用这个功能,他们会假设什么?了解开发者如何与你的代码库交互,并与他们产生共鸣,以创建开发愉悦的系统。

此外,将本书中的每一个观点都经过批判性思考。问问自己:提供了什么价值,以及实施它需要什么代价?我不希望读者把本书的建议完全当作箴言,并用它作为强迫代码库遵循“书中说要用”的标准的工具(任何在 90 年代或 00 年代工作过的开发者可能还记得“设计模式热”,你走 10 步都会碰到一个AbstractInterfaceFactorySingleton)。本书中的每一个概念都应被视为工具箱中的一种工具;我希望你已经学到了足够的背景知识,能够在使用它们时做出正确的决策。

最重要的是,记住你是一个在复杂系统上工作的人类,而其他人也将与你一起或在你之后继续工作。每个人都有他们自己的动机、目标和梦想。每个人都会面对自己的挑战和困难。错误会发生。我们永远无法完全消除所有错误。相反,我希望你看待这些错误,并通过从中学习推动我们的领域向前发展。我希望你能帮助未来建立在你工作基础上。尽管软件开发中存在各种变化、歧义、截止日期和范围扩展的困难,以及所有问题,我希望你能站在你的工作背后并说:“我为构建这个系统感到自豪。这是一个好系统。”

感谢你抽出时间阅读本书。现在,继续前进,编写经得起时间考验的精彩代码吧。

¹ 突变测试最初由理查德·A·德米洛(Richard A. DeMillo)、理查德·J·利普顿(Richard J. Lipton)和弗雷德·G·塞沃德(Fred G. Sayward)在“测试数据选择提示:为实践程序员提供帮助”(IEEE Computer,11(4): 34–41,1978 年 4 月)中于 1971 年首次提出。首个实现于 1980 年由蒂姆·A·布德(Tim A. Budd)完成,详见“程序测试数据的突变分析”,耶鲁大学博士论文,1980 年。

² 通过统计具有最高提交次数的文件,您可以找到代码更新频繁的代码。我在快速谷歌搜索后找到了以下 Git 一行命令:git rev-list --objects --all | awk '$2' | sort -k2 | uniq -cf1 | sort -rn | head。这是由 sehe这个 Stack Overflow 问题中提供的。

posted @ 2024-06-17 19:07  绝不原创的飞龙  阅读(40)  评论(0编辑  收藏  举报