Python-代码整洁之道(全)

Python 代码整洁之道(全)

原文:zh.annas-archive.org/md5/164695888A8A98C80BA0F014DEE631C7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这是一本关于软件工程原则应用于 Python 的书。

关于软件工程有很多书籍,也有很多关于 Python 的信息资源。然而,这两个集合的交集需要行动,这就是这本书试图弥补的差距。

在一本书中涵盖软件工程的所有可能主题是不现实的,因为这个领域是如此广阔,以至于有整本书专门讨论某些主题。这本书侧重于软件工程的主要实践或原则,这些实践或原则将帮助我们编写更易于维护的代码,并且同时利用 Python 的特性来编写它。

智者之言:解决软件问题没有单一的解决方案。通常涉及权衡。每种解决方案都有优势和劣势,必须遵循一些标准来在它们之间进行选择,接受成本并获得收益。通常没有单一的最佳解决方案,但有原则可循,只要我们遵循这些原则,我们就会走上更安全的道路。这本书的目的就是激励读者遵循原则并做出最佳选择,因为即使面临困难,如果我们遵循良好的实践,我们也会好得多。

说到良好的实践,虽然一些解释遵循已建立和经过验证的原则,其他部分则是主观的。但这并不意味着只能以这种特定的方式来完成。作者并不声称自己是任何一种清洁代码的权威,因为这样的头衔根本不存在。鼓励读者进行批判性思考:采用最适合您项目的方法,并且可以自由地提出异议。鼓励不同的观点,只要它们能产生启发性的辩论。

我写这本书的目的是分享 Python 的乐趣,以及我从经验中学到的习惯用法,希望读者会发现它们对提高他们的语言专业知识有用。

这本书通过代码示例来解释主题。这些示例假定在撰写本文时使用的是 Python 的最新版本,即 Python 3.7,尽管未来版本也应该兼容。代码中没有任何将其绑定到特定平台的特殊性,因此使用 Python 解释器,可以在任何操作系统上测试代码示例。

在大多数示例中,为了尽可能保持代码简单,实现及其测试都是使用纯 Python 编写的,只使用标准库。在一些章节中,需要额外的库,并且为了运行这些案例的示例,已经提供了相应的requirements.txt文件的说明。

在本书中,我们将发现 Python 提供的所有功能,使我们的代码更好,更易读,更易于维护。我们不仅通过探索语言的特性来做到这一点,还通过分析软件工程实践如何应用于 Python 来实现。读者会注意到 Python 中一些参考实现的不同之处,其他原则或模式略有变化,有些甚至可能根本不适用。理解每种情况都代表着更深入地了解 Python。

这本书适合谁

这本书适合所有对软件设计感兴趣或想了解更多关于 Python 的软件工程从业者。假定读者已经熟悉面向对象软件设计原则,并具有一定的编写代码经验。

就 Python 而言,本书适合所有级别的读者。它适合学习 Python,因为它以递增的复杂性组织内容。最初的章节将涵盖 Python 的基础知识,这是学习主要习语、函数和语言中可用的实用程序的好方法。这个想法不仅仅是用 Python 解决一些问题,而是以一种惯用的方式来解决问题。

有经验的程序员也会从本书的主题中受益,因为一些部分涵盖了 Python 中的高级主题,如装饰器、描述符和异步编程的介绍。它将帮助读者更多地了解 Python,因为其中一些案例是从语言本身的内部分析的。

值得强调的是本节第一句中的“从业者”一词。这是一本务实的书。示例仅限于案例研究所需的内容,但也意在类似于真实软件项目的背景。这不是一本学术书,因此所做的定义、备注和建议应以谨慎的态度对待。读者应该以批判和务实的态度来审视这些建议,而不是教条地接受。毕竟,实用性胜过纯粹。

本书涵盖的内容

第一章《介绍、代码格式和工具》是介绍你需要设置 Python 开发环境的主要工具。我们涵盖了 Python 开发人员建议了解的基础知识,以及一些维护项目中可读代码的指南,如静态分析工具、文档、类型检查和代码格式化。

第二章《Pythonic Code》探讨了 Python 中的第一个习语,我们将在接下来的章节中继续使用。我们涵盖了 Python 的特定特性,以及它们应该如何使用,并开始建立围绕 Pythonic 代码通常更高质量的代码的知识。

第三章《良好代码的一般特征》回顾了专注于编写可维护代码的软件工程的一般原则。我们探讨了这个想法,并应用了语言中的工具。

第四章《SOLID 原则》涵盖了面向对象软件设计的一组设计原则。这个首字母缩略词是软件工程的术语或行话的一部分,我们看到它们每一个如何应用到 Python 中。由于语言的性质,可以说并非所有原则都完全适用。

第五章《使用装饰器改进我们的代码》探讨了 Python 最伟大的特性之一。在理解如何创建装饰器(用于函数和类)之后,我们将它们应用于重用代码、分离责任和创建更细粒度的函数。

第六章《使用描述符更充分地利用我们的对象》探讨了 Python 中的描述符,它将面向对象设计提升到一个新的水平。虽然这是一个更与框架和工具相关的特性,但我们可以看到如何通过描述符来提高我们代码的可读性,并重用代码。

第七章《使用生成器》表明生成器可能是 Python 最好的特性。迭代是 Python 的核心组件,这可能让我们认为它导致了一种新的编程范式。通过使用生成器和迭代器,我们可以思考我们编写程序的方式。通过生成器学到的经验,我们进一步学习了 Python 中的协程和异步编程的基础知识。

第八章,单元测试和重构,讨论了在任何声称可维护的代码库中进行单元测试的重要性。该章节审查了单元测试的重要性,我们探讨了主要的框架(unittestpytest)。

第九章,常见设计模式,审查了如何在 Python 中实现最常见的设计模式,而不是从解决问题的角度,而是通过研究它们如何通过更好和更易维护的解决方案来解决问题。该章节提到了 Python 的一些特点,使得一些设计模式变得不可见,并采取了实用的方法来实现其中一些设计模式。

第十章,干净的架构,着重于干净的代码是良好架构的基础这一观念。我们在第一章提到的所有细节,以及沿途重新审视的一切,在系统部署时将在整个设计中发挥关键作用。

充分利用本书

读者应熟悉 Python 的语法,并安装有效的 Python 解释器,可从www.python.org/downloads/下载。

建议按照本书中的示例并在本地测试代码。为此,强烈建议使用 Python 3.7 创建虚拟环境,并使用该解释器运行代码。有关创建虚拟环境的说明,请访问docs.python.org/3/tutorial/venv.html

下载示例代码文件

您可以从www.packtpub.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,文件将直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择 SUPPORT 选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的指示操作。

文件下载完成后,请确保使用最新版本的解压缩软件解压缩文件夹:

  • Windows 系统使用 WinRAR/7-Zip

  • Mac 系统使用 Zipeg/iZip/UnRarX

  • Linux 系统使用 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Clean-Code-in-Python。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还提供来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上查看!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:"然后,只需运行pylint命令就足以在代码中进行检查。"

代码块设置如下:

class Point:
    def __init__(self, lat, long):
        self.lat = lat
        self.long = long

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

setup(
    name="apptool",
    description="Description of the intention of the package",
    long_description=long_description,

任何命令行输入或输出都以以下方式书写:

>>> locate.__annotations__
 {'latitude': float, 'longitue': float, 'return': __main__.Point}

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"从管理面板中选择系统信息。"

警告或重要提示会以这种方式出现。技巧和窍门会以这种方式出现。

第一章:介绍、代码格式化和工具

在本章中,我们将探讨与干净代码相关的第一个概念,从它是什么和意味着什么开始。本章的主要观点是要理解干净代码不仅仅是软件项目中的一件好事或奢侈品。这是必需的。没有质量的代码,项目将面临由于积累的技术债务而失败的危险。

沿着同样的思路,但更详细地讨论的是格式化和文档化代码的概念。这也可能听起来像是一个多余的要求或任务,但我们将发现它在保持代码库的可维护性和可操作性方面起着基本作用。

我们将分析采用良好的编码准则对该项目的重要性。意识到保持代码与参考一致是一项持续的任务,我们将看到如何从自动化工具中获得帮助,以简化我们的工作。因此,我们迅速讨论如何配置主要工具,以便它们作为构建的一部分自动运行在项目上。

阅读本章后,您将了解干净代码是什么,为什么它很重要,为什么格式化和文档化代码是关键任务,以及如何自动化这个过程。从中,您应该获得快速组织新项目结构的思维方式,以追求良好的代码质量。

阅读本章后,您将学到以下内容:

  • 干净代码在软件构建中的真正意义远远重要于格式化

  • 即使如此,拥有标准的格式化是软件项目中必须具备的关键组成部分,以确保其可维护性

  • 如何通过使用 Python 提供的功能使代码自我记录

  • 如何配置工具以帮助以一致的方式安排代码布局,以便团队成员可以专注于问题的本质。

干净代码的含义

没有干净代码的唯一或严格定义。此外,可能没有正式衡量干净代码的方法,因此您无法在存储库上运行工具,告诉您代码的好坏、可维护性或不可维护性。当然,您可以运行检查器、linter、静态分析器等工具。这些工具非常有帮助。它们是必需的,但不够。干净的代码不是机器或脚本可以告诉的东西(到目前为止),而是我们作为专业人士可以决定的东西。

几十年来,我们一直在使用编程语言这个术语,我们认为它们是用来向机器传达我们的想法,以便它可以运行我们的程序。我们错了。这不是真相,而是真相的一部分。编程语言背后的真正语言是将我们的想法传达给其他开发人员。

这就是干净代码的真正本质所在。它取决于其他工程师能够阅读和维护代码。因此,我们作为专业人士是唯一能够判断这一点的人。想想看;作为开发人员,我们花在阅读代码上的时间要比实际编写代码的时间多得多。每当我们想要进行更改或添加新功能时,我们首先必须阅读我们需要修改或扩展的代码周围的所有内容。语言(Python)是我们用来相互交流的工具。

因此,与其给您一个干净代码的定义(或我的定义),我邀请您阅读全书,了解有关惯用 Python 的所有内容,看到好代码和坏代码之间的区别,识别好代码和良好架构的特征,然后提出您自己的定义。阅读本书后,您将能够自行判断和分析代码,并对干净代码有更清晰的理解。您将知道它是什么和意味着什么,而不受任何给定的定义的影响。

拥有干净代码的重要性

清洁代码重要的原因有很多。其中大部分都围绕着可维护性、减少技术债务、有效地与敏捷开发合作以及管理成功的项目。

我想探讨的第一个想法是关于敏捷开发和持续交付。如果我们希望我们的项目能够以稳定和可预测的速度不断成功地交付功能,那么拥有一个良好且可维护的代码库是必不可少的。

想象一下,你正在驾驶一辆汽车沿着一条通往你想在某个时间点到达的目的地的道路行驶。你必须估计到达时间,这样你就可以告诉等待你的人。如果汽车运行良好,道路平坦完美,那么我不明白为什么你会大大地错过你的估计。现在,如果道路破损,你不得不下车移动路上的石头,或者避开裂缝,每隔几公里就停下来检查引擎,等等,那么你很可能不会确定你何时到达(或者是否会到达)。我认为这个类比很清楚;道路就是代码。如果你想以稳定、持续和可预测的速度前进,代码就需要是可维护和可读的。如果不是,每当产品管理要求新功能时,你都必须停下来重构和修复技术债务。

技术债务是指由于妥协和错误决策而导致软件中的问题的概念。在某种程度上,可以从现在到过去来思考技术债务。如果我们目前面临的问题是先前编写的糟糕代码的结果,那么怎么办?从现在到未来——如果我们决定现在走捷径,而不是投入时间进行适当的解决方案,那么我们将为自己创造什么问题?

“债务”这个词是一个很好的选择。这是一种债务,因为将来更改代码将比现在更改代码更困难。这种产生的成本就是债务的利息。技术债务意味着明天,代码将比今天更难、更昂贵(甚至可能测量);后天更昂贵,以此类推。

每当团队无法按时交付某些东西并不得不停下来修复和重构代码时,就是在支付技术债务的代价。

技术债务最糟糕的地方在于它代表了一个长期和潜在的问题。这不是引起高度警报的事情。相反,它是一个潜在的问题,分散在项目的所有部分,某一天,在某个特定的时间,它将醒来并成为一个停工的原因。

代码格式在清洁代码中的作用

清洁的代码是关于根据一些标准(例如 PEP-8,或项目指南定义的自定义标准)格式化和构造代码吗?简短的答案是否定的。

清洁的代码是另一回事,远远超出了编码标准、格式化、linting 工具和其他有关代码布局的检查。清洁的代码是关于实现高质量的软件,构建一个健壮、可维护的系统,避免技术债务。一段代码或整个软件组件可以符合 PEP-8(或任何其他指南),但仍然不能满足这些要求。

然而,不关注代码结构也有一些危险。因此,我们将首先分析糟糕的代码结构问题,如何解决这些问题,然后我们将看到如何配置和使用 Python 项目工具,以便自动检查和纠正问题。

总之,我们可以说,清洁的代码与 PEP-8 或编码风格之类的东西无关。它远远超出了那些,对代码的可维护性和软件质量意味着更有意义的东西。然而,正如我们将看到的,正确格式化代码对于高效工作是重要的。

- 遵循项目的编码风格指南

编码指南是项目应该具备的最低标准,以便被认为是按照质量标准开发的项目。在本节中,我们将探讨其中的原因,以便在接下来的章节中,我们可以开始探讨通过工具自动强制执行这一点的方法。

当我试图在代码布局中找到好的特点时,我脑海中首先想到的是一致性。我希望代码能够一致地结构化,以便更容易阅读和理解。如果代码不正确或结构不一致,并且团队中的每个人都按照自己的方式做事,那么我们最终会得到需要额外努力和专注才能正确遵循的代码。它将容易出错,误导人,并且错误或微妙之处可能很容易被忽略。

我们要避免这种情况。我们想要的正是相反的情况——我们能够在一瞥之间尽快阅读和理解的代码。

如果开发团队的所有成员都同意以标准化的方式结构化代码,那么最终的代码看起来会更加熟悉。因此,你将很快识别出模式(稍后会详细介绍),并且有了这些模式,理解事物和发现错误将变得更加容易。例如,当出现问题时,你会注意到你习惯看到的模式中有些地方不对劲,这会引起你的注意。你会仔细看一看,很可能会发现错误!

正如经典著作《代码大全》中所述,对此进行了有趣的分析,即在题为《国际象棋中的感知》(1973)的论文中进行了一项实验,以确定不同人如何理解或记忆不同的国际象棋局面。实验涉及各个水平的玩家(初学者、中级和国际象棋大师),以及棋盘上的不同国际象棋局面。他们发现,当局面是随机的时,初学者和国际象棋大师的表现一样好;这只是一个任何人都可以在相当同等水平上完成的记忆练习。当局面遵循可能在真实比赛中出现的逻辑顺序时(再次强调,一致性,遵循模式),国际象棋大师的表现要远远好于其他人。

现在想象一下,将这种情况应用到软件中。作为 Python 软件工程师专家,我们就像前面例子中的国际象棋大师。当代码结构随意,没有遵循任何逻辑或标准时,我们很难像初学者开发人员那样发现错误。另一方面,如果我们习惯以结构化的方式阅读代码,并且已经学会通过遵循模式快速理解代码的思想,那么我们就处于相当大的优势。

特别是对于 Python,你应该遵循的编码风格是 PEP-8。你可以扩展它或采用它的一些部分来适应你正在工作的项目的特殊情况(例如,行的长度,关于字符串的注释等)。然而,我建议无论你是只使用 PEP-8 还是扩展它,你都应该坚持使用它,而不是试图从头开始制定另一个不同的标准。

原因是这个文档已经考虑了 Python 语法的许多特殊情况(这些特殊情况通常不适用于其他语言),而且它是由实际为 Python 语法做出贡献的核心 Python 开发人员创建的。因此,很难想象 PEP-8 的准确性可以被否定,更不用说改进了。

特别是,PEP-8 在处理代码时具有一些特点,可以带来其他良好的改进,例如:

  • 可搜索性:这是在代码中搜索标记的能力;也就是说,在特定文件中(以及这些文件的哪个部分)搜索我们正在寻找的特定字符串。这个标准引入的其中一项内容是区分变量赋值的方式和传递给函数的关键字参数的方式。

为了更好地理解这一点,让我们举个例子。假设我们正在调试,我们需要找到将值传递给名为location的参数的地方。我们可以运行以下grep命令,结果将告诉我们我们正在寻找的文件和行:

$ grep -nr "location=" . 
./core.py:13: location=current_location,

现在,我们想知道这个变量是在哪里被赋予这个值,下面的命令也会给我们提供我们正在寻找的信息:

$ grep -nr "location =" .
./core.py:10: current_location = get_location()

PEP-8 规定,当通过关键字将参数传递给函数时,我们不使用空格,但在赋值变量时使用。因此,我们可以调整我们的搜索标准(在第一次搜索时不使用=周围的空格,在第二次搜索时使用一个空格),并且在我们的搜索中更加高效。这是遵循约定的优势之一。

  • 一致性:如果代码看起来像一个统一的格式,那么阅读它将更容易。这对于入职非常重要,如果你想欢迎新的开发人员加入你的项目,或者雇佣新的(可能经验不足)程序员加入你的团队,并且他们需要熟悉代码(甚至可能由多个存储库组成)。如果他们打开的所有文件中的代码布局、文档、命名约定等都是相同的,那么他们的生活将变得更加轻松。

  • 代码质量:通过以结构化的方式查看代码,你将更加熟练地一览无余地理解它(再次,就像国际象棋中的感知),并且更容易地发现错误和错误。除此之外,检查代码质量的工具也会提示潜在的错误。对代码进行静态分析可能有助于减少每行代码的错误比率。

文档字符串和注释

这一部分是关于在 Python 中从代码内部对代码进行文档化。良好的代码是自解释的,但也要有良好的文档。解释它应该做什么(而不是如何)是一个好主意。

一个重要的区别;对代码进行文档化并不等同于在代码上添加注释。注释是不好的,应该避免。通过文档化,我们指的是解释数据类型,提供它们的示例,并对变量进行注释。

这在 Python 中很重要,因为它是动态类型的,可能很容易在函数和方法之间的变量或对象的值上迷失。因此,陈述这些信息将使未来的代码读者更容易理解。

还有另一个特别与注释相关的原因。它们还可以帮助运行一些自动检查,比如通过 Mypy 这样的工具进行类型提示。最终,我们会发现,添加注释是值得的。

文档字符串

简单来说,我们可以说文档字符串基本上是嵌入在源代码中的文档。文档字符串基本上是一个字面字符串,放在代码的某个地方,目的是对该逻辑部分进行文档化。

注意对文档一词的强调。这种微妙之处很重要,因为它的意图是代表解释,而不是证明。文档字符串不是注释;它们是文档。

在代码中添加注释是一个不好的做法,原因有多个。首先,注释代表了我们未能用代码表达我们的想法。如果我们实际上必须解释为什么或如何做某事,那么该代码可能还不够好。首先,它没有足够的自解释性。其次,它可能会误导。在阅读复杂部分时,最糟糕的情况是阅读一个注释,说明它应该如何工作,然后发现代码实际上做了不同的事情。人们往往会忘记在更改代码时更新注释,因此刚刚更改的行旁边的注释将过时,导致危险的错误方向。

有时,在极少数情况下,我们无法避免添加注释。也许第三方库上有一个错误,我们必须规避。在这些情况下,放置一个小但描述性的注释可能是可以接受的。

然而,文档字符串的情况不同。再次强调,它们不代表注释,而是代码中特定组件(模块、类、方法或函数)的文档。它们的使用不仅被接受,而且被鼓励。尽可能添加文档字符串是一个好习惯。

它们在代码中是一个好东西的原因(或者甚至可能是必需的,这取决于项目的标准)是因为 Python 是动态类型的。这意味着,例如,函数可以将任何东西作为其参数的值。Python 不会强制执行或检查任何这样的事情。因此,想象一下,在代码中找到一个您知道必须修改的函数。您甚至很幸运,该函数有一个描述性的名称,它的参数也是如此。它可能仍然不太清楚应该传递什么类型。即使是这种情况,它们应该如何使用?

这就是一个好的文档字符串可能有所帮助的地方。记录函数的预期输入和输出是一个好习惯,将帮助该函数的读者理解它应该如何工作。

考虑一下标准库中的这个很好的例子:

In [1]: dict.update??
Docstring:
D.update([E, ]**F) -> None. Update D from dict/iterable E and F.
If E is present and has a .keys() method, then does: for k in E: D[k] = E[k]
If E is present and lacks a .keys() method, then does: for k, v in E: D[k] = v
In either case, this is followed by: for k in F: D[k] = F[k]
Type: method_descriptor

在这里,字典上update方法的文档字符串为我们提供了有用的信息,并告诉我们可以以不同的方式使用它:

  1. 我们可以传递一个具有.keys()方法的对象(例如,另一个字典),它将使用传递的对象的键更新原始字典:
>>> d = {}
>>> d.update({1: "one", 2: "two"})
>>> d
{1: 'one', 2: 'two'}
  1. 我们可以传递一个键和值的对的可迭代对象,并将它们解包到update中:
>>> d.update([(3, "three"), (4, "four")])
>>> d
{1: 'one', 2: 'two', 3: 'three', 4: 'four'}

在任何情况下,字典将使用传递给它的其余关键字参数进行更新。

这对于必须学习和理解新函数如何工作以及如何利用它的人来说是至关重要的信息。

请注意,在第一个示例中,我们通过在其上使用双问号(dict.update??)获得了函数的文档字符串。这是 IPython 交互式解释器的一个特性。调用它时,它将打印您期望的对象的文档字符串。现在,想象一下,以同样的方式,我们从标准库的这个函数中获得帮助;如果您在编写的函数上放置文档字符串,以便其他人可以以同样的方式理解它们的工作原理,那么您可以使您的读者(代码的用户)的生活变得更加轻松多少?

文档字符串不是与代码分离或孤立的东西。它成为代码的一部分,您可以访问它。当对象有定义的文档字符串时,这通过其__doc__属性成为其一部分:

>>> def my_function():
 ... """Run some computation"""
 ... return None
 ...
 >>> my_function.__doc__
 'Run some computation'

这意味着甚至可以在运行时访问它,甚至可以从源代码生成或编译文档。实际上,有工具可以做到这一点。如果运行 Sphinx,它将为项目的文档创建基本的框架。特别是使用autodoc扩展(sphinx.ext.autodoc),该工具将从代码中获取文档字符串,并将其放置在记录函数的页面中。

一旦你有了构建文档的工具,就将其公开,使其成为项目本身的一部分。对于开源项目,你可以使用 read the docs,它将根据分支或版本(可配置)自动生成文档。对于公司或项目,你可以使用相同的工具或在本地配置这些服务,但无论做出何种决定,重要的是文档应该准备好并对团队的所有成员可用。

不幸的是,文档字符串也有一个缺点,就是和所有文档一样,它需要手动和持续的维护。随着代码的变化,文档也需要更新。另一个问题是,为了使文档字符串真正有用,它们必须详细,这就需要多行。

维护适当的文档是我们无法逃避的软件工程挑战。这也是有道理的。如果你仔细想想,文档需要手动编写的原因是因为它是打算让其他人阅读的。如果它是自动化的,可能就没有太大的用处。为了使文档有价值,团队中的每个人都必须同意它是需要手动干预的东西,因此需要付出努力。关键是要理解软件不仅仅是代码。随之而来的文档也是交付内容的一部分。因此,当有人对一个函数进行更改时,同样重要的是更新文档的相应部分,无论是维基、用户手册、README 文件还是多个文档字符串。

注解

PEP-3107 引入了注解的概念。它们的基本想法是向代码的读者提示函数参数的预期值。使用“提示”这个词并非偶然;注解使类型提示成为可能,我们将在本章后面讨论这个问题,首先介绍注解。

注解允许您指定已定义的某些变量的预期类型。实际上,这不仅仅是关于类型,还有任何可以帮助您更好地了解该变量实际代表的元数据。

考虑以下示例:

class Point:
    def __init__(self, lat, long):
        self.lat = lat
        self.long = long

def locate(latitude: float, longitude: float) -> Point:
    """Find an object in the map by its coordinates"""

在这里,我们使用float来指示latitudelongitude的预期类型。这仅仅是为了让函数的读者了解这些预期类型。Python 不会检查这些类型,也不会强制执行它们。

我们还可以指定函数返回值的预期类型。在这种情况下,Point是一个用户定义的类,这意味着无论返回什么都将是Point的一个实例。

然而,类型或内置类型并不是我们可以用作注解的唯一类型。基本上,任何在当前 Python 解释器范围内有效的东西都可以放在那里。例如,解释变量意图的字符串,可用作回调或验证函数的可调用对象等。

随着注解的引入,还包括了一个新的特殊属性,即__annotations__。这将使我们能够访问一个字典,将注解的名称(作为字典中的键)与它们的对应值进行映射,这些值是我们为它们定义的。在我们的示例中,这将如下所示:

>>> locate.__annotations__
 {'latitude': float, 'longitue': float, 'return': __main__.Point}

如果我们认为有必要,我们可以使用这些来生成文档,运行验证,或者在我们的代码中强制检查。

说到通过注解检查代码,这就是 PEP-484 发挥作用的时候。这个 PEP 指定了类型提示的基础,即通过注解检查我们函数的类型。再次明确一下,引用 PEP-484 本身:

Python 将保持为一种动态类型的语言,作者们也不希望通过约定来强制类型提示成为必须。

类型提示的想法是提供额外的工具(与解释器无关),以检查和评估代码中类型的正确使用,并在检测到任何不兼容性时提示用户。运行这些检查的工具 Mypy 将在后面的章节中详细解释,我们将讨论如何为项目使用和配置这些工具。现在,您可以将其视为一种检查代码中使用的类型语义的 linter。这有时有助于在运行测试和检查时尽早发现错误。因此,将 Mypy 配置到项目中并将其与其他静态分析工具放在同一级别是一个好主意。

然而,类型提示意味着不仅仅是代码中类型检查的工具。从 Python 3.5 开始,引入了新的 typing 模块,这显著改进了我们在 Python 代码中定义类型和注释的方式。

其基本思想是,现在语义扩展到更有意义的概念,使我们(人类)更容易理解代码的含义,或者在给定点处期望的内容。例如,您可以有一个函数,它的一个参数是列表或元组,并且您可以将这两种类型之一作为注释,甚至是一个解释字符串。但是使用这个模块,可以告诉 Python 它期望一个可迭代对象或序列。甚至可以识别类型或其中的值;例如,它需要一个整数序列。

在编写本书时,关于注释方面进行了一项额外的改进,那就是从 Python 3.6 开始,可以直接注释变量,而不仅仅是函数参数和返回类型。这是在 PEP-526 中引入的,其想法是可以声明一些变量的类型,而不一定给它们赋值,如下面的清单所示:

class Point:
    lat: float
    long: float

>>> Point.__annotations__
{'lat': <class 'float'>, 'long': <class 'float'>} 

注释是否取代了 docstrings?

这是一个合理的问题,因为在 Python 的旧版本中,在引入注释之前很久,函数或属性的参数类型的文档方式是通过在它们上面放置 docstrings 来完成的。甚至有一些关于如何构造 docstrings 的格式的约定,以包括函数的基本信息,包括每个参数的类型和含义,结果的类型和含义,以及函数可能引发的异常。

大部分内容已经通过注释以更紧凑的方式进行了处理,因此人们可能会想知道是否真的值得使用 docstrings。答案是肯定的,因为它们互补。

确实,以前包含在 docstring 中的一部分信息现在可以移动到注释中。但这只应该为 docstring 提供更好的文档空间。特别是对于动态和嵌套数据类型,提供预期数据的示例总是一个好主意,这样我们就可以更好地了解我们正在处理的内容。

考虑以下示例。假设我们有一个函数,它期望一个字典来验证一些数据:

def data_from_response(response: dict) -> dict:
    if response["status"] != 200:
        raise ValueError
    return {"data": response["payload"]}

在这里,我们可以看到一个接受字典并返回另一个字典的函数。可能会在键"status"下的值不是预期值时引发异常。但是,我们对此了解不多。例如,response对象的正确实例是什么样的?result的实例会是什么样的?为了回答这两个问题,最好是记录预期由参数传入并由此函数返回的数据的示例。

让我们看看是否可以通过 docstring 更好地解释这一点:

def data_from_response(response: dict) -> dict:
    """If the response is OK, return its payload.

    - response: A dict like::

    {
        "status": 200, # <int>
        "timestamp": "....", # ISO format string of the current
        date time
        "payload": { ... } # dict with the returned data
    }

    - Returns a dictionary like::

    {"data": { .. } }

    - Raises:
    - ValueError if the HTTP status is != 200
    """
    if response["status"] != 200:
        raise ValueError
    return {"data": response["payload"]}

现在,我们对这个函数预期接收和返回的内容有了更好的了解。文档不仅作为理解和了解传递内容的宝贵输入,还是单元测试的宝贵来源。我们可以从中获取数据作为输入,并知道测试时应该使用的正确和不正确的值。实际上,测试也可以作为我们代码的可执行文档,但这将在更详细地解释。

好处是现在我们知道键的可能值以及它们的类型,我们对数据的外观有了更具体的解释。成本是,正如我们之前提到的,它占用了很多行,并且需要冗长和详细才能有效。

配置工具以强制执行基本质量门

在这一部分,我们将探讨如何配置一些基本工具,并自动运行代码检查,目的是利用重复性验证检查的一部分。

这是一个重要的观点:记住,代码是为了我们人类理解的,所以只有我们才能确定什么是好的或坏的代码。我们应该在代码审查上投入时间,思考什么是好的代码,以及它的可读性和可理解性。当查看同事编写的代码时,你应该问这样的问题:

  • 这段代码对其他程序员来说易于理解和遵循吗?

  • 它是否以问题域的术语来表达?

  • 一个新加入团队的人能够理解并有效地使用它吗?

正如我们之前所看到的,代码格式化、一致的布局和适当的缩进是必需的,但不足以成为代码库中具有的特征。此外,作为具有高质量意识的工程师,我们会认为这是理所当然的,因此我们会读写代码,远远超出其布局的基本概念。因此,我们不愿意浪费时间审查这些种类的项目,因此我们可以通过查看代码中的实际模式来更有效地投入时间,以理解其真正含义并提供有价值的结果。

所有这些检查都应该是自动化的。它们应该是测试或清单的一部分,反过来又应该是持续集成构建的一部分。如果这些检查未通过,构建将失败。这是确保代码结构始终连续的唯一方法。它也作为团队的客观参数参考。与其让一些工程师或团队领导在代码审查中总是说相同的关于 PEP-8 的评论,构建将自动失败,使其成为客观的事实。

Mypy 的类型提示

Mypy (mypy-lang.org/) 是 Python 中可选的静态类型检查的主要工具。其想法是,一旦安装,它将分析项目中的所有文件,检查类型的使用是否一致。这是有用的,因为大多数时候,它会及早检测到实际的错误,但有时它可能会产生误报。

你可以使用 pip 安装它,并建议将其包含在项目的设置文件中作为依赖项:

$ pip install mypy

一旦它安装在虚拟环境中,你只需运行上述命令,它将报告类型检查的所有发现。尽量遵循它的报告,因为大多数时候,它提供的见解有助于避免可能会滑入生产中的错误。然而,该工具并不完美,所以如果你认为它报告了一个误报,你可以用以下标记忽略该行作为注释:

type_to_ignore = "something" # type: ignore

使用 Pylint 检查代码

在 Python 中,有许多用于检查代码结构的工具(基本上是符合 PEP-8 的),例如 pycodestyle(以前称为 PEP-8)、Flake8 等。它们都是可配置的,并且像运行它们提供的命令一样容易使用。在所有这些工具中,我发现 Pylint 是最完整(也是最严格)的。它也是可配置的。

再次强调,您只需在虚拟环境中使用pip安装它:

$ pip install pylint

然后,只需运行pylint命令就足以检查代码。

可以通过名为pylintrc的配置文件来配置 Pylint。

在这个文件中,您可以决定要启用或禁用的规则,并对其他规则进行参数化(例如,更改列的最大长度)。

自动检查设置

在 Unix 开发环境中,最常见的工作方式是通过 makefile。Makefile是强大的工具,让我们可以配置在项目中运行的命令,主要用于编译、运行等。除此之外,我们还可以在项目的根目录中使用一个 makefile,其中配置了一些命令来自动运行代码的格式和约定检查。

一个好的方法是为测试设置目标,每个特定的测试,然后再设置一个将所有测试一起运行的目标。例如:

typehint:
mypy src/ tests/

test:
pytest tests/

lint:
pylint src/ tests/

checklist: lint typehint test

.PHONY: typehint test lint checklist

在这里,我们应该运行的命令(无论是在我们的开发机器上还是在持续集成环境构建中)是以下命令:

make checklist

这将按以下步骤运行所有内容:

  1. 它将首先检查是否符合编码指南(例如 PEP-8)

  2. 然后它将检查代码中类型的使用

  3. 最后,它将运行测试

如果这些步骤中的任何一个失败,都要将整个过程视为失败。

除了在构建中自动配置这些检查之外,如果团队采用了一种约定和自动化的代码结构方法,那也是一个好主意。诸如 Black(github.com/ambv/black)之类的工具会自动格式化代码。有许多工具可以自动编辑代码,但 Black 的有趣之处在于它以独特的方式进行。它是一种主观和确定性的工具,因此代码最终总是以相同的方式排列。

例如,Black 字符串总是双引号,参数的顺序总是遵循相同的结构。这可能听起来很严格,但这是确保代码差异最小的唯一方法。如果代码始终遵守相同的结构,代码中的更改将只显示在实际进行更改的拉取请求中,而不会有额外的美观修改。它比 PEP-8 更严格,但也更方便,因为通过工具直接格式化代码,我们不必真正担心这个问题,可以专注于手头问题的关键。

在撰写本书时,唯一可以配置的是行的长度。其他一切都由项目的标准来纠正。

以下代码符合 PEP-8 规范,但不符合black的约定:

def my_function(name):
 """
 >>> my_function('black')
 'received Black'
 """
 return 'received {0}'.format(name.title())

现在,我们可以运行以下命令来格式化文件:

black -l 79 *.py

现在,我们可以看到工具写了什么:

def my_function(name):
 """
 >>> my_function('black')
 'received Black'
 """
 return "received {0}".format(name.title())

在更复杂的代码中,会有更多的变化(尾随逗号等),但这个想法可以清楚地看到。再次强调,这是一种主观看法,但对于我们来说,拥有一个处理细节的工具也是一个好主意。这也是 Golang 社区很久以前就学会的东西,以至于有一个标准的工具库got fmt,可以根据语言的约定自动格式化代码。Python 现在也有这样的东西,这是件好事。

这些工具(Black、Pylint、Mypy 等)可以与您选择的编辑器或 IDE 集成,使事情变得更加容易。将编辑器配置为在保存文件时或通过快捷键进行这些修改是一个不错的投资。

总结

现在我们对清晰的代码有了第一个概念,以及一个可行的解释,这将成为本书其余部分的参考点。

更重要的是,我们明白了清晰的代码比代码的结构和布局更重要得多。我们必须关注代码中的思想是如何表示的,以确定它们是否正确。清晰的代码是关于代码的可读性、可维护性,将技术债务降到最低,并有效地将我们的想法传达到代码中,以便他人能够理解我们最初打算写的东西。

然而,我们讨论了遵循编码风格或指南的重要性,有多种原因。我们一致认为这是一个必要的条件,但并不充分,因为它是每个扎实项目都应该遵守的最低要求,很明显,这是我们最好留给工具的事情。因此,自动化所有这些检查变得至关重要,在这方面,我们必须记住如何配置诸如 Mypy、Pylint 等工具。

接下来的章节将更加专注于特定于 Python 的代码,以及如何用 Python 的习惯用法表达我们的想法。我们将探讨 Python 中使代码更简洁高效的习惯用法。在这个分析中,我们将看到,总的来说,Python 有不同的想法或不同的方法来完成任务,与其他语言相比。

第二章:Pythonic 代码

在本章中,我们将探索在 Python 中表达观念的方式,以及它自己的特点。如果您熟悉编程中一些任务的标准完成方式(例如获取列表的最后一个元素,迭代,搜索等),或者如果您来自更传统的编程语言(如 C、C++和 Java),那么您将发现,总的来说,Python 为大多数常见任务提供了自己的机制。

在编程中,成语是为了执行特定任务而编写代码的一种特定方式。它是一种常见的重复出现并且每次都遵循相同结构的东西。有些人甚至可能争论并称它们为一种模式,但要小心,因为它们不是设计模式(我们稍后将探讨)。主要区别在于设计模式是高级别的想法,独立于语言(在某种程度上),但它们不能立即转化为代码。另一方面,成语实际上是编码的。这是我们想要执行特定任务时应该编写的方式。

由于成语是代码,因此它们是与语言相关的。每种语言都有自己的习语,这意味着在该特定语言中完成任务的方式(例如,在 C、C++等语言中如何打开和写入文件)。当代码遵循这些习语时,它被称为成语化,而在 Python 中通常被称为Pythonic

有多个原因要遵循这些建议并首先编写 Pythonic 代码(我们将看到并分析),以成语化的方式编写代码通常性能更好。它也更紧凑,更容易理解。这些都是我们希望在代码中始终具备的特征,以使其有效运行。其次,正如在上一章中介绍的,整个开发团队能够习惯相同的代码模式和结构非常重要,因为这将帮助他们专注于问题的真正本质,并帮助他们避免犯错。

本章的目标如下:

  • 了解索引和切片,并正确实现可以进行索引的对象

  • 实现序列和其他可迭代对象

  • 学习上下文管理器的良好使用案例

  • 通过魔术方法实现更成语化的代码

  • 避免导致不良副作用的 Python 常见错误

索引和切片

与其他语言一样,在 Python 中,一些数据结构或类型支持通过索引访问其元素。它与大多数编程语言共有的另一点是,第一个元素位于索引号零。然而,与那些语言不同的是,当我们想以与通常不同的顺序访问元素时,Python 提供了额外的功能。

例如,在 C 语言中,如何访问数组的最后一个元素?这是我第一次尝试 Python 时做的事情。以与 C 语言相同的方式思考,我会得到数组长度减一的位置的元素。这可能有效,但我们也可以使用负索引号,它将从最后开始计数,如下面的命令所示:

>>> my_numbers = (4, 5, 3, 9)
>>> my_numbers[-1]
9
>>> my_numbers[-3]
5

除了获取单个元素外,我们还可以使用slice获取多个元素,如下面的命令所示:

>>> my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
>>> my_numbers[2:5]
(2, 3, 5)

在这种情况下,方括号中的语法意味着我们获取元组中的所有元素,从第一个数字的索引开始(包括该索引),直到第二个数字的索引(不包括该索引)。在 Python 中,切片的工作方式是通过排除所选区间的末尾来实现的。

您可以排除间隔的任一端点,开始或停止,这种情况下,它将分别从序列的开头或结尾起作用,如下面的命令所示:

>>> my_numbers[:3]
(1, 1, 2)
>>> my_numbers[3:]
(3, 5, 8, 13, 21)
>>> my_numbers[::]
(1, 1, 2, 3, 5, 8, 13, 21)
>>> my_numbers[1:7:2]
(1, 3, 8)

在第一个示例中,它将获取到索引位置号为3的所有内容。在第二个示例中,它将获取从位置3(包括)开始到末尾的所有数字。在倒数第二个示例中,两端都被排除,实际上是创建了原始元组的副本。

最后一个例子包括第三个参数,即步长。这表示在迭代间隔时要跳过多少个元素。在这种情况下,它意味着获取位置为一和七之间的元素,每两个跳一次。

在所有这些情况下,当我们将间隔传递给一个序列时,实际上发生的是我们传递了slice。注意,slice是 Python 中的一个内置对象,你可以自己构建并直接传递:

>>> interval = slice(1, 7, 2)
>>> my_numbers[interval]
(1, 3, 8)

>>> interval = slice(None, 3)
>>> my_numbers[interval] == my_numbers[:3]
True

注意,当元素中的一个缺失(开始、停止或步长),它被认为是无。

你应该始终优先使用这种内置的切片语法,而不是手动尝试在for循环中迭代元组、字符串或列表,手动排除元素。

创建你自己的序列

我们刚刚讨论的功能得益于一个叫做__getitem__的魔术方法。当像myobject[key]这样的东西被调用时,传递键(方括号内的值)作为参数调用这个方法。特别是,序列是一个实现了__getitem____len__的对象,因此它可以被迭代。列表、元组和字符串是标准库中序列对象的例子。

在这一部分,我们更关心通过关键字从对象中获取特定元素,而不是构建序列或可迭代对象,这是第七章中探讨的主题,使用生成器

如果你要在你的领域的自定义类中实现__getitem__,你将不得不考虑一些问题,以便遵循 Pythonic 的方法。

如果你的类是标准库对象的包装器,你可能会尽可能地将行为委托给底层对象。这意味着如果你的类实际上是列表的包装器,调用列表上的所有相同方法,以确保它保持兼容。在下面的列表中,我们可以看到一个对象如何包装一个列表的例子,对于我们感兴趣的方法,我们只是委托给list对象上对应的版本:

class Items:
    def __init__(self, *values):
        self._values = list(values)

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

    def __getitem__(self, item):
        return self._values.__getitem__(item)

这个例子使用了封装。另一种方法是通过继承,这种情况下,我们将不得不扩展collections.UserList基类,考虑到本章的最后部分提到的注意事项和警告。

然而,如果你正在实现自己的序列,而不是一个包装器或不依赖于任何内置对象,那么请记住以下几点:

  • 当通过范围进行索引时,结果应该是与类的相同类型的实例

  • slice提供的范围内,遵守 Python 使用的语义,排除末尾的元素

第一点是一个微妙的错误。想想看——当你得到一个列表的slice时,结果是一个列表;当你在元组中请求一个范围时,结果是一个元组;当你请求一个子字符串时,结果是一个字符串。在每种情况下,结果与原始对象的类型相同是有道理的。如果你正在创建一个表示日期间隔的对象,并且你在该间隔上请求一个范围,返回一个列表或元组等都是错误的。相反,它应该返回一个设置了新间隔的相同类的新实例。最好的例子是在标准库中的range函数。在 Python 2 中,range函数用于构建一个列表。现在,如果你用一个间隔调用range,它将构造一个可迭代的对象,知道如何产生所选范围内的值。当你为 range 指定一个间隔时,你得到一个新的 range(这是有道理的),而不是一个列表:

>>> range(1, 100)[25:50]
range(26, 51)

第二条规则也是关于一致性 - 代码的用户会发现如果与 Python 本身保持一致,那么使用起来更加熟悉和容易。作为 Python 开发人员,我们已经习惯了切片的工作方式,range函数的工作方式等。对自定义类做出异常会造成混乱,这意味着更难记住,可能导致错误。

上下文管理器

上下文管理器是 Python 提供的一个非常有用的特性。它们之所以如此有用的原因是它们正确地响应了一种模式。这种模式实际上是我们想要运行一些代码的每种情况,并且具有前置条件和后置条件,这意味着我们想在某个主要操作之前和之后运行一些东西。

大多数情况下,我们在资源管理周围看到上下文管理器。例如,在打开文件时,我们希望在处理后确保它们被关闭(这样我们就不会泄漏文件描述符),或者如果我们打开到服务(甚至是套接字)的连接,我们也希望相应地关闭它,或者在删除临时文件时等等。

在所有这些情况下,通常需要记住释放分配的所有资源,这只是考虑最佳情况,但是异常和错误处理呢?考虑到处理程序的所有可能组合和执行路径会使调试变得更加困难,解决这个问题的最常见方法是将清理代码放在finally块中,以确保不会遗漏它。例如,一个非常简单的情况看起来像下面这样:

fd = open(filename)
try:
    process_file(fd)
finally:
    fd.close()

尽管如此,有一种更加优雅和 Pythonic 的方法来实现相同的功能:

with open(filename) as fd:
    process_file(fd)

with语句(PEP-343)进入上下文管理器。在这种情况下,open函数实现了上下文管理器协议,这意味着文件将在块完成时自动关闭,即使发生异常也是如此。

上下文管理器由两个魔术方法组成:__enter____exit__。在上下文管理器的第一行,with语句将调用第一个方法__enter__,并且无论这个方法返回什么都将被分配给as后面标记的变量。这是可选的 - 我们不真的需要在__enter__方法上返回任何特定的东西,即使我们这样做了,如果不需要,也没有严格的理由将其分配给一个变量。

在执行这行之后,代码进入一个新的上下文,可以运行任何其他 Python 代码。在该块上的最后一条语句完成后,上下文将被退出,这意味着 Python 将调用我们首先调用的原始上下文管理器对象的__exit__方法。

如果在上下文管理器块内部发生异常或错误,__exit__方法仍然会被调用,这使得安全地管理清理条件变得方便。实际上,如果我们想以自定义方式处理,这个方法会接收在块上触发的异常。

尽管上下文管理器在处理资源时经常出现(比如我们提到的文件、连接等示例),但这并不是它们唯一的应用。我们可以实现自己的上下文管理器来处理我们需要的特定逻辑。

上下文管理器是分离关注点和隔离应该保持独立的代码部分的好方法,因为如果我们混合它们,那么逻辑将变得更难以维护。

举个例子,考虑这样一种情况:我们想要用一个脚本对数据库进行备份。问题在于备份是离线的,这意味着只有在数据库不运行时才能进行备份,为此我们必须停止它。备份完成后,我们希望确保无论备份过程本身如何进行,我们都要重新启动该进程。现在,第一种方法是创建一个巨大的单片函数,试图在同一个地方做所有事情,停止服务,执行备份任务,处理异常和所有可能的边缘情况,然后尝试重新启动服务。你可以想象这样一个函数,因此我将省略细节,而直接提出一种可能的解决这个问题的方式,即使用上下文管理器:

def stop_database():
    run("systemctl stop postgresql.service")

def start_database():
    run("systemctl start postgresql.service")

class DBHandler:
    def __enter__(self):
        stop_database()
        return self

    def __exit__(self, exc_type, ex_value, ex_traceback):
        start_database()

def db_backup():
    run("pg_dump database")

def main():
    with DBHandler():
        db_backup()

在这个例子中,我们不需要上下文管理器在块内的结果,这就是为什么我们可以认为,至少对于这种特殊情况,__enter__的返回值是无关紧要的。在设计上下文管理器时,这是需要考虑的事情——一旦块开始,我们需要什么?作为一个一般规则,总是在__enter__上返回一些东西应该是一个好的做法(尽管不是强制性的)。

在这个块中,我们只运行备份任务,独立于维护任务,就像我们之前看到的那样。我们还提到,即使备份任务出现错误,__exit__仍然会被调用。

注意__exit__方法的签名。它接收了在块上引发的异常的值。如果块上没有异常,它们都是None

__exit__的返回值是需要考虑的。通常,我们希望保持该方法不变,不返回任何特定的内容。如果该方法返回True,这意味着潜在引发的异常不会传播到调用者那里,而是在此处停止。有时,这是期望的效果,甚至可能取决于引发的异常类型,但一般来说,吞没异常并不是一个好主意。记住:错误不应该悄悄地传递。

请记住不要在__exit__上意外返回True。如果你这样做了,请确保这确实是你想要的,并且有一个很好的理由。

实现上下文管理器

一般来说,我们可以像前面的例子一样实现上下文管理器。我们只需要一个实现__enter____exit__魔术方法的类,然后该对象就能支持上下文管理器协议。虽然这是实现上下文管理器最常见的方式,但并不是唯一的方式。

在本节中,我们将看到不仅实现上下文管理器的不同(有时更紧凑)的方法,还将看到如何通过使用标准库,特别是contextlib模块,充分利用它们。

contextlib模块包含了许多辅助函数和对象,可以实现上下文管理器,或者使用一些已经提供的可以帮助我们编写更紧凑代码的上下文管理器。

让我们从看contextmanager装饰器开始。

contextlib.contextmanager装饰器应用于一个函数时,它将该函数中的代码转换为上下文管理器。所涉及的函数必须是一种特殊类型的函数,称为生成器函数,它将语句分开成分别位于__enter____exit__魔术方法中的内容。

如果你现在对装饰器和生成器不熟悉,这并不是问题,因为我们将要看的例子是独立的,而且这个方法或习惯可以被应用和理解。这些主题在第七章中有详细讨论,使用生成器

前面例子的等价代码可以用contextmanager装饰器重写如下:

import contextlib

@contextlib.contextmanager
def db_handler():
    stop_database()
    yield
    start_database()

with db_handler():
    db_backup()

在这里,我们定义了生成器函数,并将@contextlib.contextmanager装饰器应用到它上面。该函数包含一个yield语句,这使它成为一个生成器函数。在这种情况下,生成器的细节并不相关。我们只需要知道,当应用这个装饰器时,yield语句之前的所有内容将被视为__enter__方法的一部分运行。然后,yield 的值将成为上下文管理器评估的结果(__enter__将返回的内容),如果我们选择像as x:这样分配它的话,将被分配给变量——在这种情况下,没有任何 yield(这意味着 yield 的值将是隐式的 none),但如果我们想要的话,我们可以 yield 一个语句,这将成为我们可能想要在上下文管理器块中使用的东西。

在那一点上,生成器函数被挂起,进入上下文管理器,在那里,我们再次运行数据库的备份代码。完成后,执行恢复,因此我们可以认为yield语句之后的每一行都将成为__exit__逻辑的一部分。

像这样编写上下文管理器的优势在于,更容易重构现有函数,重用代码,总的来说,当我们需要一个不属于任何特定对象的上下文管理器时,这是一个好主意。添加额外的魔术方法会使我们领域中的另一个对象更加耦合,责任更多,并支持一些它可能不应该支持的东西。当我们只需要一个上下文管理器函数,而不需要保留许多状态,并且完全独立于我们的其他类时,这可能是一个不错的选择。

然而,我们可以以更多的方式实现上下文管理器,再一次,答案在标准库的contextlib包中。

我们还可以使用contextlib.ContextDecorator这个辅助工具。这是一个混合基类,提供了将装饰器应用到函数的逻辑,使其在上下文管理器中运行,而上下文管理器本身的逻辑必须通过实现前面提到的魔术方法来提供。

为了使用它,我们必须扩展这个类,并在所需的方法上实现逻辑:

class dbhandler_decorator(contextlib.ContextDecorator):
    def __enter__(self):
        stop_database()

    def __exit__(self, ext_type, ex_value, ex_traceback):
        start_database()

@dbhandler_decorator()
def offline_backup():
    run("pg_dump database")

你注意到和之前的例子有什么不同了吗?这里没有with语句。我们只需要调用这个函数,offline_backup()就会自动在上下文管理器中运行。这是基类提供的逻辑,用作装饰器包装原始函数,使其在上下文管理器中运行。

这种方法的唯一缺点是,对象完全独立运作(这是一个好特点)——装饰器对装饰的函数一无所知,反之亦然。然而,这意味着你无法获得一个你想要在上下文管理器中使用的对象(例如,分配with offline_backup() as bp:),所以如果你真的需要使用__exit__方法返回的对象,之前的方法将成为首选。

作为一个装饰器,这也带来了一个优势,即逻辑只定义一次,我们可以通过简单地将装饰器应用到其他需要相同不变逻辑的函数上,来重复使用它。

让我们探索contextlib的最后一个特性,看看我们可以从上下文管理器中期望什么,并了解我们可以用它们做什么样的事情。

请注意,contextlib.suppress是一个util包,它进入一个上下文管理器,如果其中一个提供的异常被触发,它不会失败。这类似于在try/except块上运行相同的代码并传递异常或记录它,但不同之处在于调用suppress方法更明确地表明那些作为我们逻辑一部分受控的异常。

例如,考虑以下代码:

import contextlib

with contextlib.suppress(DataConversionException):
      parse_data(input_json_or_dict)

在这里,异常的存在意味着输入数据已经是预期格式,因此不需要转换,因此可以安全地忽略它。

属性、属性和对象的不同类型的方法

在 Python 中,对象的所有属性和函数都是公开的,这与其他语言不同,其他语言中属性可以是公共的、私有的或受保护的。也就是说,没有必要阻止调用对象调用对象具有的任何属性。这是与其他编程语言的另一个不同之处,其他编程语言可以将一些属性标记为私有或受保护。

没有严格的强制,但有一些约定。以下划线开头的属性意味着它是对象的私有属性,我们期望没有外部代理调用它(但同样,没有阻止这种情况)。

在深入了解属性的细节之前,值得提到 Python 中下划线的一些特点,理解约定和属性的范围。

Python 中的下划线

Python 中有一些约定和实现细节,使用下划线是一个有趣的话题,值得分析。

正如我们之前提到的,默认情况下,对象的所有属性都是公开的。考虑以下示例来说明这一点:

>>> class Connector:
...     def __init__(self, source):
...         self.source = source
...         self._timeout = 60
... 
>>> conn = Connector("postgresql://localhost")
>>> conn.source
'postgresql://localhost'
>>> conn._timeout
60
>>> conn.__dict__
{'source': 'postgresql://localhost', '_timeout': 60}

在这里,创建了一个Connector对象与source,并且它开始有两个属性——前面提到的sourcetimeout。前者是公开的,后者是私有的。然而,正如我们从以下行中看到的,当我们创建这样一个对象时,我们实际上可以访问它们两个。

这段代码的解释是,_timeout应该只在connector内部访问,而不是从调用者访问。这意味着你应该以一种安全的方式组织代码,以便在所有需要的时间安全地重构超时,依赖于它不是从对象外部调用(只在内部调用),因此保持与以前相同的接口。遵守这些规则使代码更容易维护,更健壮,因为我们在重构代码时不必担心连锁反应,如果我们保持对象的接口不变。同样的原则也适用于方法。

对象应该只公开对外部调用对象相关的属性和方法,即其接口。一切不严格属于对象接口的东西都应该以单下划线为前缀。

这是清晰地界定对象接口的 Python 方式。然而,有一个常见的误解,即一些属性和方法实际上可以被私有化。这又是一个误解。让我们想象一下,现在timeout属性定义为双下划线:

>>> class Connector:
...     def __init__(self, source):
...         self.source = source
...         self.__timeout = 60
...
...      def connect(self):
...         print("connecting with {0}s".format(self.__timeout))
...         # ...
... 
>>> conn = Connector("postgresql://localhost")
>>> conn.connect()
connecting with 60s
>>> conn.__timeout
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
AttributeError: 'Connector' object has no attribute '__timeout'

一些开发人员使用这种方法来隐藏一些属性,像在这个例子中一样,认为timeout现在是私有的,没有其他对象可以修改它。现在,看一下尝试访问__timeout时引发的异常。它是AttributeError,表示它不存在。它没有说像“这是私有的”或“这不能被访问”等等。它说它不存在。这应该给我们一个线索,实际上发生了不同的事情,这种行为只是一个副作用,而不是我们想要的真正效果。

实际上发生的是,使用双下划线,Python 为属性创建了一个不同的名称(这称为名称混淆)。它创建的属性的名称如下:"_<class-name>__<attribute-name>"。在这种情况下,将创建一个名为'_Connector__timeout'的属性,可以通过以下方式访问(和修改)这样的属性:

>>> vars(conn)
{'source': 'postgresql://localhost', '_Connector__timeout': 60}
>>> conn._Connector__timeout
60
>>> conn._Connector__timeout = 30
>>> conn.connect()
connecting with 30s

注意我们之前提到的副作用——属性只存在不同的名称,因此在我们第一次尝试访问它时引发了AttributeError

Python 中双下划线的概念完全不同。它被创建为一种方式,用于覆盖将被多次扩展的类的不同方法,而不会出现与方法名称的冲突的风险。即使这是一个牵强的用例,也不能证明使用这种机制的必要性。

双下划线是一种非 Pythonic 的方法。如果需要将属性定义为私有的,请使用单下划线,并遵守 Pythonic 的约定,即它是一个私有属性。

不要使用双下划线。

属性

当对象只需要保存值时,我们可以使用常规属性。有时,我们可能希望根据对象的状态和其他属性的值进行一些计算。大多数情况下,属性是一个不错的选择。

当我们需要定义对象中某些属性的访问控制时,属性就应该被使用,这是 Python 在另一个方面有自己的做事方式的地方。在其他编程语言(如 Java)中,你会创建访问方法(getter 和 setter),但惯用的 Python 会使用属性。

想象一下,我们有一个用户可以注册的应用程序,我们希望保护用户的某些信息不被错误地修改,比如他们的电子邮件,如下面的代码所示:

import re

EMAIL_FORMAT = re.compile(r"[^@]+@[^@]+\.[^@]+")

def is_valid_email(potentially_valid_email: str):
    return re.match(EMAIL_FORMAT, potentially_valid_email) is not None

class User:
    def __init__(self, username):
        self.username = username
        self._email = None

    @property
    def email(self):
        return self._email

    @email.setter
    def email(self, new_email):
        if not is_valid_email(new_email):
            raise ValueError(f"Can't set {new_email} as it's not a 
            valid email")
        self._email = new_email

通过将电子邮件放在属性下,我们可以免费获得一些优势。在这个例子中,第一个@property方法将返回私有属性email保存的值。如前所述,前导下划线确定了这个属性是私有的,因此不应该从这个类的外部访问。

然后,第二个方法使用了@email.setter,使用了前一个方法已经定义的属性。当调用者代码中运行<user>.email = <new_email>时,将调用这个方法,<new_email>将成为这个方法的参数。在这里,我们明确定义了一个验证,如果试图设置的值不是实际的电子邮件地址,将失败。如果是,它将使用新值更新属性,如下所示:

>>> u1 = User("jsmith")
>>> u1.email = "jsmith@"
Traceback (most recent call last):
...
ValueError: Can't set jsmith@ as it's not a valid email
>>> u1.email = "jsmith@g.co"
>>> u1.email
'jsmith@g.co'

这种方法比使用以get_set_为前缀的自定义方法要紧凑得多。因为它只是email,所以期望是清晰的。

不要为对象的所有属性编写自定义的get_*set_*方法。大多数情况下,将它们作为常规属性留下就足够了。如果需要修改检索或修改属性时的逻辑,那么使用属性。

您可能会发现属性是实现命令和查询分离(CC08)的一种好方法。命令和查询分离表明对象的方法应该要么回答问题,要么执行操作,但不能两者兼而有之。如果对象的方法既在做某事,又同时返回一个回答该操作进行得如何的状态,那么它做了超过一件事,显然违反了函数应该只做一件事的原则。

根据方法的名称,这可能会导致更多的混淆,使读者更难理解代码的实际意图。例如,如果一个方法被称为set_email,我们使用它作为if self.set_email("a@j.com"): ...,那么这段代码在做什么?它是将电子邮件设置为a@j.com吗?它是在检查电子邮件是否已经设置为该值吗?两者(设置然后检查状态是否正确)?

通过属性,我们可以避免这种混淆。@property装饰器是回答问题的查询,@<property_name>.setter是执行命令的命令。

从这个例子中得出的另一个好建议是——不要在一个方法上做超过一件事。如果你想分配一些东西然后检查值,把它分解成两个或更多个句子。

方法应该只做一件事。如果你必须运行一个动作,然后检查状态,那么应该在不同的语句中调用不同的方法。

可迭代对象

在 Python 中,我们有默认可迭代的对象。例如,列表、元组、集合和字典不仅可以以我们想要的结构保存数据,还可以在for循环中重复获取这些值。

然而,内置的可迭代对象并不是我们在for循环中唯一可以拥有的类型。我们还可以创建自己的可迭代对象,并定义迭代的逻辑。

为了实现这一点,我们再次依赖于魔术方法。

迭代是通过 Python 自己的协议(即迭代协议)工作的。当你尝试以for e in myobject:...的形式迭代一个对象时,Python 在非常高的层次上检查以下两件事,按顺序:

  • 如果对象包含迭代器方法之一——__next____iter__

  • 如果对象是一个序列,并且具有__len____getitem__

因此,作为后备机制,序列可以被迭代,因此有两种方法可以自定义我们的对象以在for循环中工作。

创建可迭代对象

当我们尝试迭代一个对象时,Python 将在其上调用iter()函数。这个函数首先检查的是该对象是否存在__iter__方法,如果存在,将执行该方法。

以下代码创建了一个对象,允许在一系列日期上进行迭代,每次循环产生一天:

from datetime import timedelta

class DateRangeIterable:
    """An iterable that contains its own iterator object."""

    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._present_day = start_date

    def __iter__(self):
        return self

    def __next__(self):
        if self._present_day >= self.end_date:
            raise StopIteration
        today = self._present_day
        self._present_day += timedelta(days=1)
        return today

该对象旨在使用一对日期创建,并在迭代时,将产生指定日期间隔内的每一天,如下代码所示:

>>> for day in DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5)):
...     print(day)
... 
2018-01-01
2018-01-02
2018-01-03
2018-01-04
>>> 

在这里,for循环开始对我们的对象进行新的迭代。此时,Python 将在其上调用iter()函数,然后iter()函数将调用__iter__魔术方法。在这个方法中,它被定义为返回 self,表示对象本身是可迭代的,因此在每一步循环中都将在该对象上调用next()函数,该函数委托给__next__方法。在这个方法中,我们决定如何产生元素并一次返回一个。当没有其他东西可以产生时,我们必须通过引发StopIteration异常向 Python 发出信号。

这意味着实际上发生的情况类似于 Python 每次在我们的对象上调用next(),直到出现StopIteration异常,然后它知道必须停止for循环:

>>> r = DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> next(r)
datetime.date(2018, 1, 1)
>>> next(r)
datetime.date(2018, 1, 2)
>>> next(r)
datetime.date(2018, 1, 3)
>>> next(r)
datetime.date(2018, 1, 4)
>>> next(r)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File ... __next__
 raise StopIteration
StopIteration
>>> 

这个例子是有效的,但存在一个小问题——一旦耗尽,可迭代对象将继续为空,因此引发StopIteration。这意味着如果我们在两个或更多连续的for循环中使用它,只有第一个会起作用,而第二个会为空:

>>> r1 = DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> ", ".join(map(str, r1))
'2018-01-01, 2018-01-02, 2018-01-03, 2018-01-04'
>>> max(r1)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
ValueError: max() arg is an empty sequence
>>> 

这是因为迭代协议的工作方式——一个可迭代对象构造一个迭代器,这个迭代器被迭代。在我们的例子中,__iter__只是返回了self,但我们可以让它每次调用时创建一个新的迭代器。修复这个问题的一种方法是创建DateRangeIterable的新实例,这不是一个可怕的问题,但我们可以让__iter__使用生成器(它是迭代器对象),每次创建一个:

class DateRangeContainerIterable:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date

    def __iter__(self):
        current_day = self.start_date
        while current_day < self.end_date:
            yield current_day
            current_day += timedelta(days=1)

这一次,它起作用了:

>>> r1 = DateRangeContainerIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> ", ".join(map(str, r1))
'2018-01-01, 2018-01-02, 2018-01-03, 2018-01-04'
>>> max(r1)
datetime.date(2018, 1, 4)
>>> 

不同之处在于每个for循环都会再次调用__iter__,并且每个for循环都会再次创建生成器。

这被称为容器可迭代对象。

一般来说,处理生成器时最好使用容器可迭代对象。

有关生成器的详细信息将在第七章中详细解释,使用生成器

创建序列

也许我们的对象没有定义__iter__()方法,但我们仍然希望能够对其进行迭代。如果对象上没有定义__iter__iter()函数将查找__getitem__的存在,如果找不到,将引发TypeError

序列是一个实现__len____getitem__的对象,并期望能够按顺序一次获取它包含的元素,从零开始作为第一个索引。这意味着你应该在逻辑上小心,以便正确实现__getitem__以期望这种类型的索引,否则迭代将无法工作。

前一节的示例有一个优点,它使用的内存更少。这意味着它一次只保存一个日期,并且知道如何逐个生成日期。然而,它的缺点是,如果我们想要获取第 n 个元素,我们除了迭代 n 次直到达到它之外别无选择。这是计算机科学中内存和 CPU 使用之间的典型权衡。

使用可迭代的实现会占用更少的内存,但获取一个元素最多需要O(n)的时间,而实现一个序列会占用更多的内存(因为我们必须一次性保存所有东西),但支持常数时间的索引,O(1)

这就是新实现可能看起来的样子:

class DateRangeSequence:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._range = self._create_range()

    def _create_range(self):
        days = []
        current_day = self.start_date
        while current_day < self.end_date:
            days.append(current_day)
            current_day += timedelta(days=1)
        return days

    def __getitem__(self, day_no):
        return self._range[day_no]

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

这是对象的行为:

>>> s1 = DateRangeSequence(date(2018, 1, 1), date(2018, 1, 5))
>>> for day in s1:
...     print(day)
... 
2018-01-01
2018-01-02
2018-01-03
2018-01-04
>>> s1[0]
datetime.date(2018, 1, 1)
>>> s1[3]
datetime.date(2018, 1, 4)
>>> s1[-1]
datetime.date(2018, 1, 4)

在前面的代码中,我们可以看到负索引也是有效的。这是因为DateRangeSequence对象将所有操作委托给其包装对象(一个list),这是保持兼容性和一致行为的最佳方式。

在决定使用哪种可能的实现时,要评估内存和 CPU 使用之间的权衡。一般来说,迭代是可取的(甚至是生成器),但要记住每种情况的要求。

容器对象

容器是实现__contains__方法的对象(通常返回一个布尔值)。在 Python 中的in关键字的存在下会调用这个方法。

类似下面这样的:

element in container

在 Python 中使用时变成这样:

container.__contains__(element)

当这种方法被正确实现时,你可以想象代码会变得更可读(并且更 Pythonic!)。

假设我们必须在一个具有二维坐标的游戏地图上标记一些点。我们可能期望找到以下函数:

def mark_coordinate(grid, coord):
    if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height:
        grid[coord] = MARKED

现在,检查第一个if语句条件的部分似乎很复杂;它没有显示代码的意图,不够表达,最糟糕的是它要求代码重复(在代码的每个部分在继续之前都需要重复那个if语句)。

如果地图本身(在代码中称为grid)能够回答这个问题怎么办?更好的是,如果地图能够将这个动作委托给一个更小(因此更内聚)的对象呢?因此,我们可以问地图是否包含一个坐标,地图本身可以有关于其限制的信息,并询问这个对象以下内容:

class Boundaries:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def __contains__(self, coord):
        x, y = coord
        return 0 <= x < self.width and 0 <= y < self.height

class Grid:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.limits = Boundaries(width, height)

    def __contains__(self, coord):
        return coord in self.limits

这段代码本身就是一个更好的实现。首先,它进行了简单的组合,并使用委托来解决问题。两个对象都非常内聚,具有尽可能少的逻辑;方法很短,逻辑清晰明了——coord in self.limits基本上就是对要解决的问题的声明,表达了代码的意图。

从外部来看,我们也能看到好处。几乎就像 Python 在为我们解决问题:

def mark_coordinate(grid, coord):
    if coord in grid:
        grid[coord] = MARKED

对象的动态属性

可以通过__getattr__魔术方法来控制从对象中获取属性的方式。当我们调用类似<myobject>.<myattribute>的东西时,Python 会在对象的字典中查找<myattribute>,并调用__getattribute__。如果没有找到(即对象没有我们要找的属性),那么会调用额外的方法__getattr__,并将属性的名称(myattribute)作为参数传递。通过接收这个值,我们可以控制返回给我们对象的方式。我们甚至可以创建新的属性等等。

在下面的清单中,演示了__getattr__方法:

class DynamicAttributes:

    def __init__(self, attribute):
        self.attribute = attribute

    def __getattr__(self, attr):
        if attr.startswith("fallback_"):
            name = attr.replace("fallback_", "")
            return f"[fallback resolved] {name}"
        raise AttributeError(
            f"{self.__class__.__name__} has no attribute {attr}"
        )

这是对该类对象的一些调用:

>>> dyn = DynamicAttributes("value")
>>> dyn.attribute
'value'

>>> dyn.fallback_test
'[fallback resolved] test'

>>> dyn.__dict__["fallback_new"] = "new value"
>>> dyn.fallback_new
'new value'

>>> getattr(dyn, "something", "default")
'default'

第一个调用很简单——我们只是请求对象具有的属性,并将其值作为结果。第二个是这个方法发挥作用的地方,因为对象没有任何叫做fallback_test的东西,所以__getattr__将以该值运行。在该方法内部,我们放置了返回一个字符串的代码,我们得到的是该转换的结果。

第三个例子很有趣,因为这里创建了一个名为fallback_new的新属性(实际上,这个调用与运行dyn.fallback_new = "new value"是一样的),所以当我们请求该属性时,注意到我们放在__getattr__中的逻辑不适用,因为那段代码根本没有被调用。

现在,最后一个例子是最有趣的。这里有一个微妙的细节,这会产生很大的差异。再看一下__getattr__方法中的代码。注意当值不可检索时它引发的异常AttributeError。这不仅是为了一致性(以及异常中的消息),而且也是内置的getattr()函数所要求的。如果这个异常是其他任何异常,它都会引发,而默认值将不会被返回。

在实现__getattr__这样动态的方法时要小心,并谨慎使用。在实现__getattr__时,要引发AttributeError

可调用对象

定义可以作为函数的对象是可能的(而且通常很方便)。其中最常见的应用之一是创建更好的装饰器,但不仅限于此。

当我们尝试执行我们的对象,就好像它是一个常规函数一样时,魔术方法__call__将被调用。传递给它的每个参数都将传递给__call__方法。

通过这种方式实现函数的主要优势是,对象具有状态,因此我们可以在调用之间保存和维护信息。

当我们有一个对象时,类似这样的语句object(*args, **kwargs)在 Python 中被翻译为object.__call__(*args, **kwargs)

当我们想要创建可作为带参数函数的可调用对象时,这种方法非常有用,或者在某些情况下是具有记忆功能的函数。

以下清单使用此方法构建一个对象,当使用参数调用时,返回它已经使用相同值调用的次数:

from collections import defaultdict

class CallCount:

    def __init__(self):
        self._counts = defaultdict(int)

    def __call__(self, argument):
        self._counts[argument] += 1
        return self._counts[argument]

这个类的一些示例操作如下:

>>> cc = CallCount()
>>> cc(1)
1
>>> cc(2)
1
>>> cc(1)
2
>>> cc(1)
3
>>> cc("something")
1

在本书的后面,我们将发现这种方法在创建装饰器时非常方便。

魔术方法总结

我们可以总结前面描述的概念,形成一个类似下面所示的速查表。对于 Python 中的每个操作,都会呈现涉及的魔术方法,以及它所代表的概念:

语句 魔术方法 Python 概念
obj[key]``obj[i:j]``obj[i:j:k] __getitem__(key) 可以进行下标操作的对象
with obj: ... __enter__ / __exit__ 上下文管理器
for i in obj: ... __iter__ / __next__``__len__ / __getitem__ 可迭代对象序列
obj.<attribute> __getattr__ 动态属性检索
obj(*args, **kwargs) __call__(*args, **kwargs) 可调用对象

Python 中的注意事项

除了理解语言的主要特性之外,能够编写惯用代码也意味着要意识到一些习语的潜在问题,以及如何避免它们。在本节中,我们将探讨一些常见问题,如果让你措手不及,可能会导致长时间的调试会话。

本节讨论的大部分观点都是要完全避免的,我敢说几乎没有可能的情况能够证明反模式(或者在这种情况下是习语)的存在是合理的。因此,如果你在你正在工作的代码库中发现了这种情况,可以随意按照建议进行重构。如果你在进行代码审查时发现了这些特征,这清楚地表明需要做出一些改变。

可变默认参数

简单来说,不要将可变对象用作函数的默认参数。如果您将可变对象用作默认参数,您将得到意料之外的结果。

考虑以下错误的函数定义:

def wrong_user_display(user_metadata: dict = {"name": "John", "age": 30}):
    name = user_metadata.pop("name")
    age = user_metadata.pop("age")

    return f"{name} ({age})"

实际上,这有两个问题。除了默认的可变参数外,函数体正在改变一个可变对象,因此产生了副作用。但主要问题是user_medatada的默认参数。

实际上,这只会在第一次不带参数调用时起作用。第二次,我们在不明确传递任何内容给user_metadata的情况下调用它。它将失败并显示KeyError,如下所示:

>>> wrong_user_display()
'John (30)'
>>> wrong_user_display({"name": "Jane", "age": 25})
'Jane (25)'
>>> wrong_user_display()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File ... in wrong_user_display
 name = user_metadata.pop("name")
KeyError: 'name' 

解释很简单 - 在函数定义中将带有默认数据的字典分配给user_metadata,实际上是创建了一个字典,并且变量user_metadata指向它。函数体修改了这个对象,在程序运行时它会一直存在于内存中。当我们给它传递一个值时,这将取代我们刚刚创建的默认参数。当我们不想要这个对象时再次调用它,并且自上次运行以来它已经被修改;下一次运行它时,将不包含键,因为它们在上一次调用时被移除了。

修复也很简单 - 我们需要使用None作为默认的标记值,并在函数体中分配默认值。因为每个函数都有自己的作用域和生命周期,user_metadata将在每次出现None时被分配给字典:

def user_display(user_metadata: dict = None):
    user_metadata = user_metadata or {"name": "John", "age": 30}

    name = user_metadata.pop("name")
    age = user_metadata.pop("age")

    return f"{name} ({age})"

扩展内置类型

正确的扩展内置类型(如列表、字符串和字典)的方法是使用collections模块。

如果您直接扩展 dict 等类,您将得到可能不是您期望的结果。这是因为在 CPython 中,类的方法不会相互调用(应该调用),因此如果您覆盖其中一个方法,这不会被其他方法反映出来,导致意外的结果。例如,您可能想要覆盖__getitem__,然后当您使用for循环迭代对象时,您会注意到您在该方法中放置的逻辑没有被应用。

这一切都可以通过使用collections.UserDict来解决,它提供了对实际字典的透明接口,并且更加健壮。

假设我们想要一个最初由数字创建的列表将值转换为字符串,并添加前缀。第一种方法可能看起来解决了问题,但是是错误的:

class BadList(list):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "even"
        else:
            prefix = "odd"
        return f"[{prefix}] {value}"

乍一看,它看起来像我们想要的对象行为。但是,如果我们尝试迭代它(毕竟,它是一个列表),我们会发现我们得不到我们想要的东西:

>>> bl = BadList((0, 1, 2, 3, 4, 5))
>>> bl[0]
'[even] 0'
>>> bl[1]
'[odd] 1'
>>> "".join(bl)
Traceback (most recent call last):
...
TypeError: sequence item 0: expected str instance, int found

join函数将尝试迭代(在列表上运行for循环),但期望的是字符串类型的值。这应该可以工作,因为这正是我们对列表所做的更改,但显然在迭代列表时,我们修改的__getitem__版本没有被调用。

这实际上是 CPython 的一个实现细节(一种 C 优化),在其他平台(如 PyPy)中不会发生(请参阅本章末尾的 PyPy 和 CPython 之间的差异)。

尽管如此,我们应该编写可移植且兼容所有实现的代码,因此我们将通过不是从list而是从UserList扩展来修复它:

from collections import UserList

class GoodList(UserList):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "even"
        else:
            prefix = "odd"
        return f"[{prefix}] {value}"

现在事情看起来好多了:

>>> gl = GoodList((0, 1, 2))
>>> gl[0]
'[even] 0'
>>> gl[1]
'[odd] 1'
>>> "; ".join(gl)
'[even] 0; [odd] 1; [even] 2'

不要直接从 dict 扩展,而是使用collections.UserDict。对于列表,使用collections.UserList,对于字符串,使用collections.UserString

总结

在本章中,我们已经探讨了 Python 的主要特性,目标是理解其最独特的特性,这些特性使 Python 成为与其他语言相比独特的语言。在这条道路上,我们探索了 Python 的不同方法、协议和它们的内部机制。

与上一章相反,这一章更加关注 Python。本书主题的一个关键要点是,清晰的代码不仅仅是遵循格式规则(当然,这对于良好的代码库是必不可少的)。这是一个必要条件,但不是充分条件。在接下来的几章中,我们将看到更多与代码相关的想法和原则,旨在实现更好的软件解决方案设计和实现。

通过本章的概念和想法,我们探索了 Python 的核心:其协议和魔术方法。现在应该清楚了,编写 Pythonic、惯用的代码的最佳方式不仅仅是遵循格式约定,还要充分利用 Python 提供的所有功能。这意味着有时您应该使用特定的魔术方法,实现上下文管理器等。

在下一章中,我们将把这些概念付诸实践,将软件工程的一般概念与它们在 Python 中的书写方式联系起来。

参考资料

读者将在以下参考资料中找到更多关于本章涵盖的一些主题的信息。 Python 中索引如何工作的决定是基于(EWD831),该文分析了数学和编程语言中范围的几种替代方案:

第三章:良好代码的一般特征

这是一本关于 Python 软件构建的书。优秀的软件建立在良好的设计基础上。说到干净的代码之类的话,人们可能会认为我们将探讨只与软件的实现细节相关的良好实践,而不是其设计。然而,这种假设是错误的,因为代码并不是与设计不同的东西——代码就是设计。

代码可能是设计最详细的表现。在前两章中,我们讨论了为什么以一致的方式构建代码很重要,并且我们已经看到了编写更紧凑和成语化代码的习惯用法。现在是时候理解干净的代码就是这样,而且更多——最终目标是尽可能使代码健壮,并以最小化缺陷的方式编写代码,或者使它们完全显而易见,如果它们发生了。

本章和下一章侧重于更高层次的抽象设计原则。这些想法不仅与特定的 Python 相关,而且是软件工程的一般原则。

特别是对于本章,我们将回顾构建良好软件设计的不同原则。高质量的软件应该围绕这些想法构建,并且它们将作为设计工具。这并不意味着所有这些原则都应该始终应用;事实上,其中一些代表不同的观点(这就是按合同设计DbC)方法与防御性编程相对立的情况)。其中一些取决于上下文,不一定适用于所有情况。

高质量的代码是一个具有多个维度的概念。我们可以将其类比为我们思考软件架构的质量属性。例如,我们希望我们的软件安全并且具有良好的性能、可靠性和可维护性,仅举几例。

本章的目标如下:

  • 理解健壮软件背后的概念

  • 学习如何在应用程序的工作流程中处理错误数据

  • 设计可维护的软件,可以轻松扩展和适应新的需求

  • 设计可重用的软件

  • 编写有效的代码,保持开发团队的生产力高

按合同设计

我们正在处理的软件的某些部分并不是直接由用户调用的,而是由代码的其他部分调用的。当我们将应用程序的责任划分为不同的组件或层时,我们必须考虑它们之间的交互。

我们将不得不将一些功能封装在每个组件后面,并向将使用该功能的客户端公开一个接口,即应用程序编程接口API)。我们为该组件编写的函数、类或方法在特定考虑下有一种特定的工作方式,如果不满足这些条件,我们的代码将崩溃。相反,调用该代码的客户端期望特定的响应,我们的函数未能提供这一点将代表一个缺陷。

也就是说,例如,如果我们有一个函数,预期与一系列整数类型的参数一起工作,并且另一个函数调用我们传递字符串,很明显它不应该按预期工作,但实际上,函数根本不应该运行,因为它被错误调用了(客户端犯了一个错误)。这个错误不应该悄无声息地通过。

当设计 API 时,预期的输入、输出和副作用应该有文档记录。但文档无法强制软件在运行时的行为。这些规则,代码的每个部分期望为了正常工作而满足的条件,以及调用者对它们的期望,应该成为设计的一部分,这就是合同的概念所在。

DbC 背后的想法是,与其在代码中隐含地放置每个参与方的期望,不如让双方就一个合同达成一致,如果违反了合同,就会引发异常,清楚地说明为什么不能继续。

在我们的上下文中,合同是一种强制执行软件组件通信过程中必须遵守的规则的构造。合同主要包括前置条件和后置条件,但在某些情况下,还描述了不变量和副作用:

  • 前置条件:我们可以说这些是代码在运行之前进行的所有检查。它将检查函数可以继续进行之前必须满足的所有条件。一般来说,它是通过验证传递的参数中提供的数据集来实现的,但如果我们认为它们的副作用被这样的验证的重要性所掩盖,那么没有什么可以阻止我们运行各种验证(例如,验证数据库中的一个集合,一个文件,之前调用的另一个方法等)。请注意,这对调用者施加了约束。

  • 后置条件:与前置条件相反,在函数调用返回后进行验证。后置条件验证用于验证调用者对该组件的期望。

  • 不变量:可选地,在函数的文档字符串中记录不变量是一个好主意,这些不变量在函数运行时保持不变,作为函数逻辑正确性的表达。

  • 副作用:可选地,我们可以在文档字符串中提及代码的任何副作用。

尽管在概念上,所有这些项目都构成了软件组件的合同的一部分,这也是应该放入这样一块文档的内容,但只有前两个(前置条件和后置条件)需要在低级别(代码)上执行。

我们之所以要通过合同设计,是因为如果出现错误,它们必须很容易被发现(通过注意是前置条件还是后置条件失败,我们将更容易地找到罪魁祸首),以便可以快速纠正。更重要的是,我们希望代码的关键部分避免在错误的假设下执行。这应该有助于清楚地标记责任的界限和错误的发生,而不是说——应用程序的这一部分出现了问题...但是调用者提供了错误的参数,那么我们应该在哪里应用修复?

这个想法是,前置条件约束了客户端(如果他们想要运行代码的某个部分,他们就有义务满足这些条件),而后置条件约束了相关组件对客户端可以验证和执行的某些保证。

通过这种方式,我们可以快速确定责任。如果前置条件失败,我们知道这是客户端的缺陷。另一方面,如果后置条件检查失败,我们知道问题出现在例程或类(供应商)本身。

特别是关于前置条件,重要的是要强调它们可以在运行时进行检查,如果发生,被调用的代码就不应该运行(因为它的条件不成立,而且这样做可能会使情况变得更糟)。

前置条件

前置条件是函数或方法期望接收的所有保证,以便正确工作。在一般的编程术语中,这通常意味着提供正确形式的数据,例如初始化的对象,非空值等。对于 Python 来说,特别是由于其动态类型,这也意味着有时我们需要检查所提供的数据的确切类型。这与类型检查并不完全相同,mypy会执行此操作,而是验证所需的确切值。

通过使用静态分析工具,例如mypy,可以尽早地检测到这些检查中的一部分,但这些检查还不够。函数应该对其要处理的信息进行适当的验证。

现在,这就引出了一个问题,即根据我们是让客户在调用函数之前验证所有数据,还是允许函数在运行自己的逻辑之前验证接收到的所有数据的地方放置验证逻辑。前者对应于宽容的方法(因为函数本身仍然允许任何数据,可能是格式不正确的数据),而后者对应于严格的方法。

为了分析的目的,我们更喜欢在 DbC 方面采取严格的方法,因为这通常是在健壮性方面最安全的选择,也是行业中最常见的做法。

无论我们决定采取哪种方法,我们都应该始终牢记非冗余原则,即函数的每个前置条件的执行应该由合同的两个部分中的一个来完成,而不是两者都完成。这意味着我们将验证逻辑放在客户端上,或者我们将其留给函数本身,但在任何情况下都不应该重复(这也与 DRY 原则有关,我们将在本章后面讨论)。

后置条件

后置条件是合同的一部分,负责在方法或函数返回后强制执行状态。

假设函数或方法已经以正确的属性被调用(也就是说,满足了其前置条件),那么后置条件将保证某些属性得到保持。

这个想法是使用后置条件来检查和验证客户可能需要的一切。如果方法执行正确,并且后置条件验证通过,那么调用该代码的任何客户端应该能够无问题地使用返回的对象,因为合同已经履行。

Python 的合同

在撰写本书时,名为 PEP-316 的 Python 合同编程被推迟。这并不意味着我们不能在 Python 中实现它,因为正如在本章开头介绍的那样,这是一个通用的设计原则。

可能最好的执行方法是通过向我们的方法、函数和类添加控制机制,如果它们失败会引发RuntimeError异常或ValueError。很难为正确的异常类型制定一般规则,因为这在很大程度上取决于特定的应用。前面提到的这些异常是最常见的异常类型,但如果它们不能准确地解决问题,创建自定义异常将是最佳选择。

我们还希望尽可能地保持代码的隔离。也就是说,前置条件的代码在一个部分,后置条件的代码在另一个部分,函数的核心分开。我们可以通过创建更小的函数来实现这种分离,但在某些情况下,实现装饰器可能是一个有趣的选择。

按合同设计-结论

这种设计原则的主要价值在于有效地确定问题所在。通过定义合同,当运行时出现问题时,清楚地知道代码的哪一部分出了问题,以及是什么破坏了合同。

遵循这一原则的结果是代码将更加健壮。每个组件都在强制执行自己的约束并保持一些不变量,只要这些不变量得到保持,程序就可以被证明是正确的。

它还有助于更好地澄清程序的结构。与其试图运行临时验证,或者试图克服所有可能的失败场景,合同明确指定了每个函数或方法期望正常工作的内容,以及对它们的期望。

当然,遵循这些原则也会增加额外的工作,因为我们不仅仅是在编写主要应用程序的核心逻辑,还有合同。此外,我们可能还希望考虑为这些合同添加单元测试。然而,这种方法所获得的质量在长远来看是值得的;因此,对应用程序的关键组件实施这一原则是一个好主意。

然而,为了使这种方法有效,我们应该仔细考虑我们愿意验证什么,这必须是一个有意义的值。例如,定义仅检查传递给函数的参数的正确数据类型的合同并没有太多意义。许多程序员会认为这就像试图使 Python 成为一种静态类型的语言。不管怎样,工具如 Mypy,结合注释的使用,会更好地实现这一目的,而且付出的努力更少。考虑到这一点,设计合同以便它们确实有价值,例如检查传递和返回的对象的属性,它们必须满足的条件等等。

防御性编程

防御性编程与 DbC 有些不同的方法;它不是陈述必须在合同中保持的所有条件,如果不满足将引发异常并使程序失败,而是更多地使代码的所有部分(对象、函数或方法)能够保护自己免受无效输入的影响。

防御性编程是一种具有多个方面的技术,如果与其他设计原则结合使用,尤其有用(这意味着它遵循与 DbC 不同的哲学,并不意味着要么是这种情况,要么是那种情况——它可能意味着它们可以互补)。

防御性编程的主要思想是如何处理我们可能预期发生的情况的错误,以及如何处理不应该发生的错误(当发生不可能的情况时)。前者将属于错误处理程序,而后者将是断言的情况,这两个主题我们将在接下来的部分中探讨。

错误处理

在我们的程序中,我们会采用错误处理程序来处理我们预期会导致错误的情况。这通常是数据输入的情况。

错误处理的理念是优雅地响应这些预期的错误,试图要么继续我们的程序执行,要么决定失败,如果错误被证明是无法克服的。

我们可以通过不同的方法处理程序中的错误,但并非所有方法都总是适用。以下是其中一些方法:

  • 值替换

  • 错误日志记录

  • 异常处理

值替换

在某些情况下,当出现错误并且软件有可能产生不正确的值或完全失败时,我们可能能够用另一个更安全的值替换结果。我们称这种值替换,因为我们实际上是用一个被认为是非破坏性的值(可以是默认值、众所周知的常量、哨兵值,或者简单地是根本不影响结果的东西,比如在结果意图应用于求和的情况下返回零)来替换实际错误的结果。

然而,并非总是可能进行值替换。这种策略必须在替换值实际上是安全选项的情况下谨慎选择。做出这个决定是在健壮性和正确性之间进行权衡。当软件程序在错误场景中甚至不会失败时,它是健壮的。但这也不正确。

对于某些类型的软件来说,这可能是不可接受的。如果应用程序很关键,或者正在处理的数据太敏感,这不是一个选择,因为我们不能提供给用户(或应用程序的其他部分)错误的结果。在这些情况下,我们选择正确性,而不是在产生错误结果时让程序崩溃。

这个决定的一个稍微不同、更安全的版本是对未提供的数据使用默认值。这可能是代码的一部分可以使用默认行为的情况,例如,未设置的环境变量的默认值,配置文件中缺少的条目,或者函数的参数。我们可以在 Python 的 API 的不同方法中找到支持这一点的例子,例如,字典有一个get方法,它的(可选的)第二个参数允许您指定一个默认值:

>>> configuration = {"dbport": 5432}
>>> configuration.get("dbhost", "localhost")
'localhost'
>>> configuration.get("dbport")
5432

环境变量具有类似的 API:

>>> import os
>>> os.getenv("DBHOST")
'localhost'
>>> os.getenv("DPORT", 5432)
5432

在前面的两个例子中,如果未提供第二个参数,将返回None,因为这是这些函数定义的默认值。我们也可以为我们自己函数的参数定义默认值:

>>> def connect_database(host="localhost", port=5432):
...     logger.info("connecting to database server at %s:%i", host, port)

一般来说,用默认值替换缺失的参数是可以接受的,但用合法的接近值替换错误数据更加危险,可能会掩盖一些错误。在决定采用这种方法时,要考虑这个标准。

异常处理

在存在不正确或缺失的输入数据的情况下,有时可以通过一些在前一节中提到的示例来纠正情况。然而,在其他情况下,最好是阻止程序继续以错误的数据运行,而不是让它在错误的假设下进行计算。在这些情况下,失败并通知调用者出现问题是一个很好的方法,这就是我们在 DbC 中看到的违反的前提条件的情况。

然而,错误的输入数据并不是函数出错的唯一可能方式。毕竟,函数不仅仅是传递数据;它们还具有副作用并连接到外部组件。

可能是函数调用中的错误是由于这些外部组件中的一个问题,而不是我们函数本身。如果是这种情况,我们的函数应该适当地进行通信。这将使调试变得更容易。函数应该清楚、明确地通知应用程序的其他部分有关无法忽略的错误,以便可以相应地进行处理。

实现这一点的机制是异常。重要的是要强调异常应该用于清楚地宣布异常情况,而不是根据业务逻辑改变程序的流程。

如果代码尝试使用异常来处理预期的场景或业务逻辑,程序的流程将变得更难阅读。这将导致一种情况,即异常被用作一种go-to语句,这可能会跨越调用堆栈的多个级别(直到调用者函数),违反了将逻辑封装到其正确抽象级别的封装。如果这些except块混合了业务逻辑和代码试图防御的真正异常情况,情况可能会变得更糟;在这种情况下,将更难区分我们必须维护的核心逻辑和需要处理的错误。

不要将异常用作业务逻辑的go-to机制。只有在代码出现实际问题需要调用者知道时才引发异常。

这个最后的概念是一个重要的概念;异常通常是关于通知调用者有什么问题。这意味着异常应该谨慎使用,因为它们削弱了封装性。一个函数有越多的异常,调用函数就必须预期越多,因此了解它正在调用的函数。如果一个函数引发了太多的异常,这意味着它不是那么无上下文的,因为每次我们想要调用它,我们都必须牢记它可能产生的所有副作用。

这可以作为一种启发式方法,用来判断一个函数是否不够内聚,责任太多。如果它引发了太多的异常,这可能意味着它必须被拆分成多个更小的函数。

以下是与 Python 中异常相关的一些建议。

在正确的抽象级别处理异常

异常也是主要函数的一部分,只做一件事。函数处理(或引发)的异常必须与其封装的逻辑一致。

在这个例子中,我们可以看到我们所说的混合不同抽象级别是什么意思。想象一个对象,它作为我们应用程序中一些数据的传输器。它连接到一个外部组件,在那里数据将在解码后被发送。在下面的清单中,我们将专注于deliver_event方法:

class DataTransport:
    """An example of an object handling exceptions of different levels."""

    retry_threshold: int = 5
    retry_n_times: int = 3

    def __init__(self, connector):
        self._connector = connector
        self.connection = None

    def deliver_event(self, event):
        try:
            self.connect()
            data = event.decode()
            self.send(data)
        except ConnectionError as e:
            logger.info("connection error detected: %s", e)
            raise
        except ValueError as e:
            logger.error("%r contains incorrect data: %s", event, e)
            raise

    def connect(self):
        for _ in range(self.retry_n_times):
            try:
                self.connection = self._connector.connect()
            except ConnectionError as e:
                logger.info(
                    "%s: attempting new connection in %is",
                    e,
                    self.retry_threshold,
                )
                time.sleep(self.retry_threshold)
            else:
                return self.connection
        raise ConnectionError(
            f"Couldn't connect after {self.retry_n_times} times"
        )

    def send(self, data):
        return self.connection.send(data)

对于我们的分析,让我们放大并关注deliver_event()方法如何处理异常。

ValueErrorConnectionError有什么关系?不多。通过查看这两种非常不同的错误,我们可以了解责任应该如何划分。ConnectionError应该在connect方法内处理。这将允许行为的清晰分离。例如,如果这个方法需要支持重试,那将是一种方法。相反,ValueError属于事件的decode方法。通过这种新的实现,这个方法不需要捕获任何异常——它以前担心的异常要么由内部方法处理,要么故意留待抛出。

我们应该将这些片段分开成不同的方法或函数。对于连接管理,一个小函数就足够了。这个函数将负责尝试建立连接,捕获异常(如果发生),并相应地记录它们:

def connect_with_retry(connector, retry_n_times, retry_threshold=5):
    """Tries to establish the connection of <connector> retrying
    <retry_n_times>.

    If it can connect, returns the connection object.
    If it's not possible after the retries, raises ConnectionError

    :param connector: An object with a `.connect()` method.
    :param retry_n_times int: The number of times to try to call
                                ``connector.connect()``.
    :param retry_threshold int: The time lapse between retry calls.

    """
    for _ in range(retry_n_times):
        try:
            return connector.connect()
        except ConnectionError as e:
            logger.info(
                "%s: attempting new connection in %is", e, retry_threshold
            )
            time.sleep(retry_threshold)
    exc = ConnectionError(f"Couldn't connect after {retry_n_times} times")
    logger.exception(exc)
    raise exc

然后,我们将在我们的方法中调用这个函数。至于事件中的ValueError异常,我们可以用一个新对象分开它,并进行组合,但对于这个有限的情况来说,这将是过度的,所以只需将逻辑移到一个单独的方法中就足够了。有了这两个考虑,方法的新版本看起来更加简洁和易于阅读:

class DataTransport:
    """An example of an object that separates the exception handling by
    abstraction levels.
    """

    retry_threshold: int = 5
    retry_n_times: int = 3

    def __init__(self, connector):    
        self._connector = connector
        self.connection = None

    def deliver_event(self, event):
        self.connection = connect_with_retry(
            self._connector, self.retry_n_times, self.retry_threshold
        )
        self.send(event)

    def send(self, event):
        try:
            return self.connection.send(event.decode())
        except ValueError as e:
            logger.error("%r contains incorrect data: %s", event, e)
            raise

不要暴露 tracebacks

这是一个安全考虑。在处理异常时,如果错误太重要,甚至让程序失败,如果这是特定情况的决定,并且正确性优先于健壮性。

当有一个表示问题的异常时,重要的是要尽可能详细地记录(包括 traceback 信息、消息和我们能收集到的所有信息),以便能够有效地纠正问题。同时,我们希望为自己包含尽可能多的细节——我们绝对不希望任何这些对用户可见。

在 Python 中,异常的回溯包含非常丰富和有用的调试信息。不幸的是,这些信息对于想要尝试损害应用程序的攻击者或恶意用户也非常有用,更不用说泄漏将代表重要的信息披露,危及您的组织的知识产权(代码的部分将被公开)。

如果选择让异常传播,请确保不要披露任何敏感信息。此外,如果必须通知用户有问题,请选择通用消息(例如出现了一些问题,或找不到页面)。这是 Web 应用程序中常用的技术,当发生 HTTP 错误时显示通用的信息性消息。

避免空的 except 块

这甚至被称为最邪恶的 Python 反模式(REAL 01)。虽然预期并保护我们的程序免受一些错误的影响是好的,但过于防御可能会导致更严重的问题。特别是,过于防御的唯一问题是有一个空的except块,它会悄悄地通过而不做任何事情。

Python 非常灵活,可以让我们编写可能有错误但不会引发错误的代码,就像这样:

try:
    process_data()
except:
    pass

这样做的问题是它永远不会失败。即使应该失败也是如此。如果您还记得 Python 之禅,错误永远不应该悄悄地传递,这也是不符合 Python 风格的。

如果有真正的异常,这段代码将不会失败,这可能是我们最初想要的。但是如果有缺陷呢?我们需要知道我们的逻辑中是否存在错误,以便进行更正。编写这样的代码块将掩盖问题,使维护变得更加困难。

有两种选择:

  • 捕获更具体的异常(不要太宽泛,例如Exception)。实际上,一些代码检查工具和集成开发环境在代码处理过于宽泛的异常时会在某些情况下发出警告。

  • except块上进行一些实际的错误处理。

最好的做法是同时应用这两个项目。

处理更具体的异常(例如AttributeErrorKeyError)将使程序更易于维护,因为读者将知道可以预期什么,并且可以了解原因。这也将使其他异常可以自由地被引发,如果发生这种情况,这可能意味着一个错误,只是这一次可以被发现。

处理异常本身可能意味着多种情况。在其最简单的形式中,可能只是记录异常(确保使用logger.exceptionlogger.error提供发生的完整上下文)。其他替代方案可能是返回默认值(替换,只是在检测到错误后,而不是在引起错误之前),或引发不同的异常。

如果您选择引发不同的异常,以包括导致问题的原始异常,这将引出我们下一个要点。

包括原始异常

作为我们的错误处理逻辑的一部分,我们可能决定引发不同的异常,甚至可能更改其消息。如果是这种情况,建议包括导致该异常的原始异常。

在 Python 3(PEP-3134)中,我们现在可以使用raise <e> from <original_exception>语法。使用此结构时,原始的回溯将嵌入到新的异常中,并且原始异常将设置在生成的异常的__cause__属性中。

例如,如果我们希望在项目内部使用自定义异常包装默认异常,我们仍然可以这样做,同时包含有关根异常的信息:

class InternalDataError(Exception):
    """An exception with the data of our domain problem."""

def process(data_dictionary, record_id):
    try:
        return data_dictionary[record_id]
    except KeyError as e:
        raise InternalDataError("Record not present") from e

在更改异常类型时,始终使用raise <e> from <o>语法。

在 Python 中使用断言

断言用于永远不应该发生的情况,因此assert语句中的表达式必须表示一个不可能的条件。如果发生这种情况,这意味着软件中存在缺陷。

与错误处理方法相比,这里没有(或不应该有)继续程序的可能性。如果发生这样的错误,程序必须停止。停止程序是有道理的,因为正如前面所述,我们面临的是一个缺陷,因此无法通过发布修复此缺陷的新软件版本来继续前进。

使用断言的想法是防止程序在出现无效场景时造成进一步的损害。有时,最好是停止并让程序崩溃,而不是让它在错误的假设下继续处理。

因此,断言不应与业务逻辑混合,也不应作为软件的控制流机制使用。以下示例是一个坏主意:

try:
    assert condition.holds(), "Condition is not satisfied"
except AssertionError:
    alternative_procedure()

不要捕获AssertionError异常。

确保程序在断言失败时终止。

在断言语句中包含描述性错误消息,并记录错误,以确保您可以正确调试和纠正问题。

前面的代码之所以是一个坏主意的另一个重要原因是,除了捕获AssertionError之外,断言中的语句是一个函数调用。函数调用可能会产生副作用,并且它们并不总是可重复的(我们不知道再次调用condition.holds()是否会产生相同的结果)。此外,如果我们在那一行停止调试器,我们可能无法方便地看到导致错误的结果,而且即使我们再次调用该函数,我们也不知道那是否是有问题的值。

更好的替代方案需要更少的代码行,并提供更多有用的信息:

result = condition.holds()
assert result > 0, "Error with {0}".format(result)

关注点的分离

这是一个应用在多个层次的设计原则。它不仅仅是关于低级设计(代码),而且在更高的抽象层次上也是相关的,因此当我们谈论架构时,它将在后面出现。

不同的责任应该放在应用程序的不同组件、层或模块中。程序的每个部分应该只负责一部分功能(我们称之为其关注点),并且对其余部分一无所知。

软件中关注点的分离旨在通过最小化连锁效应来增强可维护性。连锁效应意味着软件中变化的传播从一个起点开始。这可能是错误或异常触发一系列其他异常的情况,导致失败,从而导致应用程序的远程部分出现缺陷。也可能是我们不得不改变散布在代码库多个部分的大量代码,因为函数定义的简单更改的结果。

显然,我们不希望发生这些情况。软件必须易于更改。如果我们必须修改或重构代码的某个部分,这对应用程序的其余部分的影响应该最小化,实现这一点的方法是通过适当的封装。

同样,我们希望任何潜在错误都能被包含,以免造成重大损害。

这个概念与 DbC 原则相关,因为每个关注点都可以通过合同来执行。当合同被违反,并且由于这种违反而引发异常时,我们知道程序的哪一部分出现了故障,以及未能满足的责任是什么。

尽管有这种相似性,关注点的分离更进一步。我们通常认为函数、方法或类之间存在合同,虽然这也适用于需要分离的责任,但关注点分离的想法也适用于 Python 模块、包,基本上适用于任何软件组件。

内聚和耦合

这些是良好软件设计的重要概念。

一方面,内聚意味着对象应该有一个小而明确定义的目的,并且它们应该尽可能少地做事情。它遵循与 Unix 命令相似的哲学,即只做一件事,并且做得很好。我们的对象越内聚,它们就越有用和可重用,使我们的设计变得更好。

另一方面,耦合指的是两个或多个对象彼此依赖的概念。这种依赖性带来了一些不希望的后果:

  • 没有代码重用:如果一个函数过于依赖特定对象,或者需要太多参数,它就与这个对象耦合,这意味着在不同的上下文中使用该函数将非常困难(为了这样做,我们必须找到一个适合的参数,符合非常严格的接口)

  • 连锁反应:两个部分中的一个发生变化肯定会影响另一个,因为它们太接近了

  • 抽象水平低:当两个函数如此紧密相关时,很难将它们视为解决不同抽象水平问题的不同关注点

经验法则:定义良好的软件将实现高内聚和低耦合。

生活中的缩写

在这一部分,我们将回顾一些原则,这些原则产生了一些良好的设计思想。重点是通过易于记忆的缩写快速与良好的软件实践联系起来,作为一种记忆规则。如果你记住这些词,你将更容易将它们与良好的实践联系起来,并且更快地找到你正在查看的特定代码行背后的正确思想。

这些绝不是正式或学术定义,而更像是从多年的软件行业工作中产生的经验性想法。其中一些出现在书籍中,因为它们是由重要的作者创造的(请参考参考文献以了解更多细节),其他可能源于博客文章、论文或会议演讲。

DRY/OAOO

不要重复自己(DRY)和一次且仅一次(OAOO)的想法是密切相关的,因此它们被放在一起。它们是不言自明的,你应该尽量避免重复。

代码中的事物、知识,必须只定义一次,并且在一个地方。当你需要对代码进行更改时,应该只有一个正确的位置进行修改。否则这是一个设计不良的系统的迹象。

代码重复是直接影响可维护性的问题。代码重复是非常不希望的,因为它有许多负面后果:

  • 这容易出错:当某个逻辑在代码中多次重复出现,并且需要更改时,这意味着我们依赖于有效地纠正所有具有这种逻辑的实例,而不会忘记其中任何一个,因为在这种情况下将会出现错误。

  • 这很昂贵:与前一点相关,对多个位置进行更改需要更多的时间(开发和测试工作),而不是只定义一次。这将拖慢团队的速度。

  • 不可靠:也与第一点相关,当一个上下文需要在多个位置进行更改时,你依赖于编写代码的人记住所有需要进行修改的实例。没有一个真正的信息来源。

重复通常是由于忽视(或遗忘)代码代表知识而引起的。通过赋予代码的某些部分意义,我们正在识别和标记这些知识。

让我们通过一个例子来看看这意味着什么。想象一下,在一个学习中心,学生根据以下标准排名:通过考试得 11 分,未通过考试扣 5 分,每在该机构学习一年扣 2 分。以下不是实际代码,而只是对这在真实代码库中可能如何分散的一种表示:

def process_students_list(students):
    # do some processing...

    students_ranking = sorted(
        students, key=lambda s: s.passed * 11 - s.failed * 5 - s.years * 2
    )
    # more processing
    for student in students_ranking:
        print(
            "Name: {0}, Score: {1}".format(
                student.name,
                (student.passed * 11 - student.failed * 5 - student.years * 2),
            )
        )

注意排序函数中的 lambda 表示领域问题的一些有效知识,但它并没有反映出来(它没有名称,没有适当和合适的位置,没有赋予代码任何含义)。代码中的这种缺乏含义导致了我们在列出排名时发现的重复。

我们应该在我们的代码中反映我们对领域问题的了解,这样我们的代码就不太可能遭受重复,并且更容易理解。

def score_for_student(student):
    return student.passed * 11 - student.failed * 5 - student.years * 2

def process_students_list(students):
    # do some processing...

    students_ranking = sorted(students, key=score_for_student)
    # more processing
    for student in students_ranking:
        print(
            "Name: {0}, Score: {1}".format(
                student.name, score_for_student(student)
            )
        )

公平的免责声明:这只是对代码重复的一个特征进行分析。实际上,代码重复有更多的情况、类型和分类,整个章节都可以专门讨论这个主题,但在这里我们专注于一个特定的方面,以使首字母缩略词背后的思想清晰明了。

在这个例子中,我们采取了可能是消除重复最简单的方法:创建一个函数。根据情况,最好的解决方案可能是不同的。在某些情况下,可能需要创建一个全新的对象(也许整个抽象缺失)。在其他情况下,我们可以使用上下文管理器消除重复。迭代器或生成器(在第七章中描述,使用生成器)也可以帮助避免代码中的重复,装饰器(在第五章中解释,使用装饰器改进我们的代码)也会有所帮助。

不幸的是,没有一般规则或模式可以告诉你 Python 的哪些特性最适合解决代码重复的问题,但希望在看到本书中的例子以及 Python 元素的使用后,读者将能够发展自己的直觉。

YAGNI

YAGNIYou Ain't Gonna Need It的缩写)是一个想法,当你写解决方案时,如果你不想过度设计,你可能经常要记住这个想法。

我们希望能够轻松修改我们的程序,所以我们希望使它们具有未来的可扩展性。与此一致,许多开发人员认为他们必须预测所有未来的需求,并创建非常复杂的解决方案,因此创建了难以阅读、维护和理解的抽象。后来,事实证明那些预期的需求没有出现,或者以不同的方式出现(惊喜!),原来应该精确处理的原始代码也无法工作。问题是,现在更难重构和扩展我们的程序。发生的是,原始解决方案没有正确处理原始需求,当前的需求也没有,只是因为它是错误的抽象。

拥有可维护的软件并不是关于预测未来的需求(不要进行未来学!)。它是关于编写只以一种可能(和容易)后续更改的方式来满足当前需求的软件。换句话说,在设计时,确保你的决定不会束缚你,你将能够继续构建,但不要构建比必要的更多。

KIS

KISKeep It Simple的缩写)与前面的观点非常相关。当你设计软件组件时,避免过度设计;问问自己你的解决方案是否是最小的,适合问题的解决方案。

实现最小功能,正确解决问题,不要使解决方案变得比必要的更复杂。记住:设计越简单,维护性就越强。

这个设计原则是一个我们在所有抽象层面都要记住的想法,无论是高层设计还是处理特定行代码。

在高层次上,考虑我们正在创建的组件。我们真的需要它们吗?这个模块现在实际上需要完全可扩展吗?强调最后一部分——也许我们想要使该组件可扩展,但现在不是正确的时间,或者现在还不适合这样做,因为我们仍然没有足够的信息来创建适当的抽象,试图在这一点上提出通用接口只会导致更糟糕的问题。

在代码方面,保持简单通常意味着使用最适合问题的最小数据结构。您很可能会在标准库中找到它。

有时,我们可能会过于复杂化代码,创建比必要更多的函数或方法。以下类从一组提供的关键字参数创建一个命名空间,但它的代码接口相当复杂:

class ComplicatedNamespace:
    """An convoluted example of initializing an object with some properties."""

    ACCEPTED_VALUES = ("id_", "user", "location")

    @classmethod
    def init_with_data(cls, **data):
        instance = cls()
        for key, value in data.items():
            if key in cls.ACCEPTED_VALUES:
                setattr(instance, key, value)
        return instance

添加额外的类方法来初始化对象似乎并不是真正必要的。然后,迭代和其中的setattr调用使事情变得更加奇怪,向用户呈现的接口也不是很清晰:

>>> cn = ComplicatedNamespace.init_with_data(
...     id_=42, user="root", location="127.0.0.1", extra="excluded"
... )
>>> cn.id_, cn.user, cn.location
(42, 'root', '127.0.0.1')

>>> hasattr(cn, "extra")
False

用户必须知道存在这种方法,这并不方便。保持简单,就像初始化 Python 中的任何其他对象一样(毕竟,有一个方法可以做到)使用__init__方法会更好:

class Namespace:
    """Create an object from keyword arguments."""

    ACCEPTED_VALUES = ("id_", "user", "location")

    def __init__(self, **data):
        accepted_data = {
            k: v for k, v in data.items() if k in self.ACCEPTED_VALUES
        }
        self.__dict__.update(accepted_data)

记住 Python 的禅意:简单胜于复杂。

EAFP/LBYL

EAFP(代表宁愿请求宽恕,而不是事先征得许可),而LBYL(代表先看后跳)。

EAFP 的理念是,我们编写代码以便直接执行操作,然后在以后处理后果,以防它不起作用。通常,这意味着尝试运行一些代码,期望它能够工作,但如果它不起作用,则捕获异常,然后在 except 块中处理纠正代码。

这是LBYL的相反。正如其名称所示,在先看后跳的方法中,我们首先检查我们将要使用的内容。例如,我们可能希望在尝试操作文件之前检查文件是否可用:

if os.path.exists(filename):
    with open(filename) as f:
        ...

这可能对其他编程语言有好处,但这不是编写代码的 Pythonic 方式。Python 是建立在 EAFP 等思想上的,并鼓励您遵循它们(记住,显式胜于隐式)。这段代码将被重写如下:

try:
    with open(filename) as f:
        ...
except FileNotFoundError as e:
    logger.error(e)

更喜欢 EAFP 而不是 LBYL。

组合和继承

在面向对象的软件设计中,通常会讨论如何使用该范式的主要思想(多态性、继承和封装)来解决一些问题。

这些想法中最常用的可能是继承——开发人员通常首先创建一个类层次结构,其中包含他们将需要的类,并决定每个类应该实现哪些方法。

虽然继承是一个强大的概念,但它也有其危险。主要的危险是,每次我们扩展一个基类,我们都会创建一个与父类紧密耦合的新类。正如我们已经讨论过的,耦合是我们在设计软件时要尽量减少的事情之一。

开发人员将继承与代码重用联系在一起的主要用途之一。虽然我们应该始终支持代码重用,但强制设计使用继承来重用代码并不是一个好主意,只是因为我们可以免费从父类获取方法。重用代码的正确方法是拥有高度内聚的对象,这些对象可以很容易地组合,并且可以在多个上下文中工作。

当继承是一个好决定时

在创建派生类时,我们必须小心,因为这是一把双刃剑——一方面,它的优势是我们可以免费获得父类方法的所有代码,但另一方面,我们将所有这些代码都放入一个新的定义中,这意味着我们可能在新定义中放入了太多的功能。

在创建一个新的子类时,我们必须考虑它是否实际上会使用它刚刚继承的所有方法,这是一个判断类是否正确定义的启发法。如果相反,我们发现我们不需要大多数方法,并且必须重写或替换它们,这是一个设计错误,可能由多种原因引起:

  • 超类定义模糊,并包含太多责任,而不是一个明确定义的接口

  • 子类不是它试图扩展的超类的适当专门化

使用继承的一个很好的情况是,当你有一个类,它定义了某些组件及其行为,这些行为由这个类的接口(公共方法和属性)定义,然后你需要专门化这个类,以创建做同样事情但添加了其他东西,或者改变了一些特定部分行为的对象。

在 Python 标准库中,你可以找到继承的良好用例。例如,在http.server包中(docs.python.org/3/library/http.server.html#http.server.BaseHTTPRequestHandler),我们可以找到一个名为BaseHTTPRequestHandler的基类,以及像SimpleHTTPRequestHandler这样的子类,它们通过添加或更改其基本接口来扩展这个基类。

谈到接口定义,这是继承的另一个很好的用途。当我们想要强制一些对象的接口时,我们可以创建一个抽象基类,它本身不实现行为,而是只定义接口——每个扩展这个类的类都必须实现这些接口才能成为一个合适的子类型。

最后,继承的另一个很好的用例是异常。我们可以看到 Python 中的标准异常派生自Exception。这就是允许你有一个通用的子句,比如except Exception:,它将捕获每一个可能的错误。重要的一点是概念上的,它们是从Exception派生的类,因为它们是更具体的异常。这也适用于知名的库,比如requests,其中HTTPErrorRequestException,而RequestException又是IOError

继承的反模式

如果前面的部分必须总结成一个单词,那就是专门化。继承的正确用法是专门化对象,并从基本对象开始创建更详细的抽象。

父类(或基类)是新派生类的公共定义的一部分。这是因为继承的方法将成为这个新类的接口的一部分。因此,当我们阅读一个类的公共方法时,它们必须与父类定义的一致。

例如,如果我们看到一个从BaseHTTPRequestHandler派生的类实现了一个名为handle()的方法,这是有意义的,因为它是覆盖父类的一个方法。如果它有任何其他方法,其名称与与 HTTP 请求相关的操作有关,那么我们也可以认为它被正确放置(但如果我们在该类中找到名为process_purchase()的内容,我们就不会这样认为)。

前面的例子可能看起来很明显,但这种情况经常发生,特别是当开发人员试图使用继承来重用代码时。在下一个例子中,我们将看到一个典型的情况,代表了 Python 中的一个常见反模式——需要表示一个领域问题,并为该问题设计一个合适的数据结构,但是不是创建一个使用这样的数据结构的对象,而是对象本身成为数据结构。

让我们通过一个例子更具体地看看这些问题。假设我们有一个管理保险的系统,其中一个模块负责将策略应用于不同的客户。我们需要在内存中保存一组正在处理的客户,以便在进一步处理或持久化之前应用这些更改。我们需要的基本操作是存储一个新客户及其记录作为卫星数据,对策略进行更改,或编辑一些数据,仅举几例。我们还需要支持批量操作,也就是说,当策略本身发生变化(这个模块当前正在处理的策略),我们必须将这些更改应用到当前交易中的所有客户。

从我们需要的数据结构的角度来思考,我们意识到以恒定时间访问特定客户的记录是一个很好的特性。因此,像policy_transaction[customer_id]这样的接口看起来很不错。从这里,我们可能会认为可订阅的对象是一个好主意,进一步地,我们可能会陷入认为我们需要的对象是一个字典:

class TransactionalPolicy(collections.UserDict):
    """Example of an incorrect use of inheritance."""

    def change_in_policy(self, customer_id, **new_policy_data):
        self[customer_id].update(**new_policy_data)

有了这段代码,我们可以通过其标识符获取有关客户的策略的信息:

>>> policy = TransactionalPolicy({
...     "client001": { 
...         "fee": 1000.0, 
...         "expiration_date": datetime(2020, 1, 3), 
...     } 
... }) 
>>> policy["client001"]
{'fee': 1000.0, 'expiration_date': datetime.datetime(2020, 1, 3, 0, 0)}
>>> policy.change_in_policy("client001", expiration_date=datetime(2020, 1, 4))
>>> policy["client001"]
{'fee': 1000.0, 'expiration_date': datetime.datetime(2020, 1, 4, 0, 0)}

当然,我们在第一次实现中实现了我们想要的接口,但是代价是什么?现在,这个类有了很多额外的行为,执行了不必要的方法:

>>> dir(policy)
[ # all magic and special method have been omitted for brevity...
 'change_in_policy', 'clear', 'copy', 'data', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values']

这个设计至少存在两个主要问题。一方面,层次结构是错误的。从基类创建一个新类在概念上意味着它是类的更具体的版本(因此得名)。TransactionalPolicy怎么会是一个字典呢?这有意义吗?请记住,这是对象的公共接口的一部分,所以用户会看到这个类,它们的层次结构,并且会注意到这样一个奇怪的特化,以及它的公共方法。

这带我们来到第二个问题——耦合。交易策略的接口现在包括了字典的所有方法。交易策略真的需要pop()items()这样的方法吗?然而,它们在那里。它们也是公共的,因此任何使用此接口的用户都有权调用它们,无论它们可能带来什么不希望的副作用。更进一步——通过扩展字典,我们并没有真正获得太多好处。它实际上需要更新的唯一方法是当前策略变化对所有受影响的客户进行更新(change_in_policy()),而这个方法不在基类中,因此我们无论如何都必须自己定义它。

这是将实现对象与领域对象混合的问题。字典是一个实现对象,一个数据结构,适用于某些操作,并且像所有数据结构一样具有权衡。交易策略应该代表领域问题中的某个实体,这是我们试图解决的问题的一部分。

这样的层次结构是不正确的,仅仅因为我们从基类获得了一些魔术方法(通过扩展字典使对象可订阅)并不足以创建这样的扩展。实现类应该仅在创建其他更具体的实现类时才进行扩展。换句话说,如果要创建另一个(更具体或稍微修改的)字典,才应该扩展字典。相同的规则适用于领域问题的类。

这里的正确解决方案是使用组合。TransactionalPolicy不是一个字典——它使用一个字典。它应该在一个私有属性中存储一个字典,并通过代理从该字典实现__getitem__(),然后只实现它所需的其余公共方法:

class TransactionalPolicy:
    """Example refactored to use composition."""

    def __init__(self, policy_data, **extra_data):
        self._data = {**policy_data, **extra_data}

    def change_in_policy(self, customer_id, **new_policy_data):
        self._data[customer_id].update(**new_policy_data)

    def __getitem__(self, customer_id):
        return self._data[customer_id]

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

这种方式不仅在概念上是正确的,而且更具扩展性。如果底层数据结构(目前是字典)在将来发生变化,只要保持接口不变,对象的调用者就不会受到影响。这减少了耦合,最小化了涟漪效应,允许更好的重构(单元测试不应该被改变),并使代码更易于维护。

Python 中的多重继承

Python 支持多重继承。继承,当使用不当时,会导致设计问题,你也可以期望多重继承在实现不正确时也会产生更大的问题。

因此,多重继承是一把双刃剑。在某些情况下,它也可能非常有益。只要清楚,多重继承并没有什么问题——它唯一的问题是,当它没有正确实现时,它会放大问题。

当正确使用时,多重继承是一个完全有效的解决方案,并且这为新模式(例如我们在第九章*,常见设计模式中讨论的适配器模式)和混入打开了新的可能性。

多重继承最强大的应用之一可能是它使混入的创建成为可能。在探索混入之前,我们需要了解多重继承的工作原理,以及如何在复杂的继承结构中解析方法。

方法解析顺序(MRO)

有些人不喜欢多重继承,因为它在其他编程语言中有一些限制,例如所谓的菱形问题。当一个类从两个或更多类继承,并且所有这些类也从其他基类继承时,底层类将有多种方法来解析来自顶层类的方法。问题是,哪个实现会被使用?

考虑以下具有多重继承结构的图表。顶层类具有一个类属性并实现__str__方法。想象任何具体类,例如ConcreteModuleA12——它从BaseModule1BaseModule2扩展,并且它们中的每一个都将从BaseModule中获取__str__的实现。这两种方法中的哪一个将成为ConcreteModuleA12的方法?

有了类属性的值,这将变得明显:

class BaseModule:
    module_name = "top"

    def __init__(self, module_name):
        self.name = module_name

    def __str__(self):
        return f"{self.module_name}:{self.name}"

class BaseModule1(BaseModule):
    module_name = "module-1"

class BaseModule2(BaseModule):
    module_name = "module-2"

class BaseModule3(BaseModule):
    module_name = "module-3"

class ConcreteModuleA12(BaseModule1, BaseModule2):
    """Extend 1 & 2"""

class ConcreteModuleB23(BaseModule2, BaseModule3):
    """Extend 2 & 3"""

现在,让我们测试一下调用了哪个方法:

>>> str(ConcreteModuleA12("test"))
'module-1:test'

没有冲突。Python 通过使用称为C3 线性化或 MRO 的算法来解决这个问题,该算法定义了方法将被调用的确定性方式。

实际上,我们可以明确地询问类的解析顺序:

>>> [cls.__name__ for cls in ConcreteModuleA12.mro()]
['ConcreteModuleA12', 'BaseModule1', 'BaseModule2', 'BaseModule', 'object']

了解在继承结构中方法将如何解析可以在设计类时对我们有利,因为我们可以利用混入。

混入

混入是一个封装了一些常见行为的基类,其目标是重用代码。通常,混入类本身并不实用,仅扩展这个类肯定不会起作用,因为大多数情况下它依赖于其他类中定义的方法和属性。想法是通过多重继承将混入类与其他类一起使用,以便混入中使用的方法或属性可用。

想象我们有一个简单的解析器,它接受一个字符串,并通过破折号(-)分隔的值提供迭代:

class BaseTokenizer:

    def __init__(self, str_token):
        self.str_token = str_token

    def __iter__(self):
        yield from self.str_token.split("-")

这非常直观:

>>> tk = BaseTokenizer("28a2320b-fd3f-4627-9792-a2b38e3c46b0")
>>> list(tk)
['28a2320b', 'fd3f', '4627', '9792', 'a2b38e3c46b0']

但现在我们希望将值发送为大写,而不改变基类。对于这个简单的例子,我们可以创建一个新类,但想象一下,许多类已经从BaseTokenizer扩展了,我们不想替换所有这些类。我们可以将一个新类混合到处理这种转换的层次结构中:

class UpperIterableMixin:
    def __iter__(self):
        return map(str.upper, super().__iter__())

class Tokenizer(UpperIterableMixin, BaseTokenizer):
    pass

新的Tokenizer类非常简单。它不需要任何代码,因为它利用了 mixin。这种混合类型充当一种装饰器。根据我们刚才看到的,Tokenizer将从 mixin 中获取__iter__,而这个 mixin 又通过调用super()委托给了下一行中的类(即BaseTokenizer),但它将其值转换为大写,从而产生了期望的效果。

函数和方法中的参数

在 Python 中,函数可以以多种不同的方式定义接收参数,并且这些参数也可以由调用者以多种方式提供。

在软件工程中也有一套行业标准的接口定义实践,这与函数中参数的定义密切相关。

在本节中,我们将首先探讨 Python 函数中参数的机制,然后回顾与这些概念相关的软件工程的一般原则,最后将这两个概念联系起来。

Python 中函数参数的工作原理

首先,我们将探讨 Python 中函数参数传递的特殊性,然后我们将回顾与这些概念相关的良好软件工程实践的一般理论。

通过首先了解 Python 处理参数的可能性,我们将能够更容易地吸收一般规则,而且我们的想法是在这样做之后,我们可以很容易地得出关于处理参数时的良好模式或习惯用法的结论。然后,我们可以确定在哪些情况下 Pythonic 方法是正确的,在哪些情况下我们可能滥用了语言的特性。

参数如何被复制到函数中

Python 中的第一条规则是所有参数都是按值传递的。总是。这意味着当将值传递给函数时,它们被分配给函数签名定义中的变量,以便以后在函数中使用。您会注意到函数更改参数可能取决于参数的类型 - 如果我们传递mutable对象,并且函数的主体修改了它,那么当函数返回时,它们的值已经被改变了。

在接下来的内容中我们可以看到区别:

>>> def function(argument):
...     argument += " in function"
...     print(argument)
... 
>>> immutable = "hello"
>>> function(immutable)
hello in function
>>> mutable = list("hello")
>>> immutable
'hello'
>>> function(mutable)
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
>>> mutable
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
>>> 

这可能看起来像是一个不一致,但实际上并不是。当我们传递第一个参数,一个字符串,这个参数被分配给函数中的参数。由于字符串对象是不可变的,类似"argument += <expression>"这样的语句实际上会创建一个新对象"argument + <expression>",并将其分配回参数。在那一点上,argument只是函数范围内的一个局部变量,与调用者原始的变量无关。

另一方面,当我们传递list,这是一个mutable对象时,那个语句的意义就不同了(实际上等同于在那个list上调用.extend())。这个操作符通过在一个保存对原始list对象的引用的变量上就地修改list,从而修改它。

在处理这些类型的参数时,我们必须小心,因为它可能导致意想不到的副作用。除非你绝对确定以这种方式操纵可变参数是正确的,我们建议避免这样做,并选择没有这些问题的替代方案。

不要mutate函数参数。一般来说,尽量避免函数中的副作用。

Python 中的参数可以按位置传递,就像许多其他编程语言一样,但也可以按关键字传递。这意味着我们可以明确告诉函数我们想要哪些参数的值。唯一的注意事项是在通过关键字传递参数之后,其后的参数也必须以这种方式传递,否则将引发SyntaxError

可变数量的参数

Python 以及其他语言都有内置函数和结构,可以接受可变数量的参数。例如,考虑字符串插值函数(无论是使用%运算符还是字符串的format方法),它们遵循与 C 语言中的printf函数类似的结构,首先是字符串格式的第一个位置参数,然后是任意数量的参数,这些参数将放置在格式化字符串的标记上。

除了利用 Python 中可用的这些函数之外,我们还可以创建自己的函数,其工作方式类似。在本节中,我们将介绍具有可变数量参数的函数的基本原则,以及一些建议,以便在下一节中,我们可以探索如何在处理函数可能具有太多参数的常见问题、问题和约束时,如何利用这些功能。

对于可变数量的位置参数,使用星号(*)作为变量名的前缀,用于打包这些参数。这通过 Python 的打包机制实现。

假设有一个函数需要三个位置参数。在代码的某个部分,我们恰好有一个列表中我们想要传递给函数的参数,顺序与函数期望的顺序相同。我们可以使用打包机制,一次性将它们全部传递给一个指令,而不是一个一个地按位置传递(即list[0]到第一个元素,list[1]到第二个元素,依此类推),这样做真的不符合 Python 的风格。

>>> def f(first, second, third):
...     print(first)
...     print(second)
...     print(third)
... 
>>> l = [1, 2, 3]
>>> f(*l)
1
2
3

打包机制的好处在于它也可以反过来使用。如果我们想要按照它们各自的位置从列表中提取变量的值,我们可以这样赋值:

>>> a, b, c = [1, 2, 3]
>>> a
1
>>> b
2
>>> c
3

部分解包也是可能的。假设我们只对序列的第一个值感兴趣(可以是列表、元组或其他内容),并且在某个点之后,我们只想保留其余的部分在一起。我们可以分配我们需要的变量,然后将其余部分放在一个打包的列表下。解包的顺序没有限制。如果没有东西放在其中一个解包的子部分中,结果将是一个空列表。鼓励读者在 Python 终端上尝试以下清单中呈现的示例,并且还要探索解包也适用于生成器:

>>> def show(e, rest):
...     print("Element: {0} - Rest: {1}".format(e, rest))
... 
>>> first, *rest = [1, 2, 3, 4, 5]
>>> show(first, rest)
Element: 1 - Rest: [2, 3, 4, 5]
>>> *rest, last = range(6)
>>> show(last, rest)
Element: 5 - Rest: [0, 1, 2, 3, 4]
>>> first, *middle, last = range(6)
>>> first
0
>>> middle
[1, 2, 3, 4]
>>> last
5
>>> first, last, *empty = (1, 2)
>>> first
1
>>> last
2
>>> empty
[]

解包变量的最佳用途之一可以在迭代中找到。当我们必须迭代一系列元素,并且每个元素依次是一个序列时,同时进行解包是一个很好的主意。为了看到这种情况的示例,我们假装有一个函数接收一个数据库行的列表,并且负责从这些数据中创建用户。第一个实现从行中每列的位置获取值来构造用户,这一点根本不符合惯例。第二个实现在迭代时使用了解包:

USERS = [(i, f"first_name_{i}", "last_name_{i}") for i in range(1_000)]

class User:
    def __init__(self, user_id, first_name, last_name):
        self.user_id = user_id
        self.first_name = first_name
        self.last_name = last_name

def bad_users_from_rows(dbrows) -> list:
    """A bad case (non-pythonic) of creating ``User``s from DB rows."""
    return [User(row[0], row[1], row[2]) for row in dbrows]

def users_from_rows(dbrows) -> list:
    """Create ``User``s from DB rows."""
    return [
        User(user_id, first_name, last_name)
        for (user_id, first_name, last_name) in dbrows
    ]

注意第二个版本要容易阅读得多。在函数的第一个版本(bad_users_from_rows)中,我们的数据以row[0]row[1]row[2]的形式表示,这并没有告诉我们它们是什么。另一方面,像user_idfirst_namelast_name这样的变量就不言自明了。

我们可以利用这种功能来设计我们自己的函数时获得优势。

这种情况的一个例子可以在标准库中找到,就在max函数中,它的定义如下:

max(...)
    max(iterable, *[, default=obj, key=func]) -> value
    max(arg1, arg2, *args, *[, key=func]) -> value

    With a single iterable argument, return its biggest item. The
    default keyword-only argument specifies an object to return if
    the provided iterable is empty.
    With two or more arguments, return the largest argument.

还有一种类似的表示法,使用两个星号(**)用于关键字参数。如果我们有一个字典,并且将其带有双星号传递给函数,它将使用键作为参数的名称,并将该键的值作为该函数中该参数的值。

例如,看看这个:

function(**{"key": "value"})

这与以下内容相同:

function(key="value")

相反,如果我们定义一个以两个星号符号开头的参数的函数,将会发生相反的情况——通过关键字提供的参数将被打包成一个字典:

>>> def function(**kwargs):
...     print(kwargs)
... 
>>> function(key="value")
{'key': 'value'}

函数中的参数数量

在这一部分,我们同意函数或方法接受太多参数是糟糕设计的迹象(代码异味)。然后,我们提出了处理这个问题的方法。

第一个选择是软件设计的一个更一般的原则——具体化(为我们传递的所有参数创建一个新对象,这可能是我们缺少的抽象)。将多个参数压缩成一个新对象并不是 Python 特有的解决方案,而是我们可以在任何编程语言中应用的东西。

另一个选择是使用我们在前一节中看到的 Python 特定特性,利用可变位置和关键字参数创建具有动态签名的函数。虽然这可能是一种 Pythonic 的做法,但我们必须小心不要滥用这个特性,因为我们可能会创建一些非常动态的东西,很难维护。在这种情况下,我们应该看一下函数的主体。无论签名如何,以及参数是否正确,如果函数对参数的值做出了太多不同的反应,那么这就是它必须被分解成多个更小的函数的迹象(记住,函数应该只做一件事!)。

函数参数和耦合

函数签名的参数越多,这个函数与调用函数的耦合性就越高。

假设我们有两个函数,f1f2,后者接受五个参数。f2接受的参数越多,任何试图调用该函数并收集所有信息并正确传递的人都会变得更加困难。

现在,f1似乎有了所有这些信息,因为它可以正确地调用它。由此,我们可以得出两个结论:首先,f2可能是一个泄漏的抽象,这意味着由于f1知道f2需要的一切,它几乎可以弄清楚它在内部做什么,并且能够自己做到。总而言之,f2并没有那么抽象。其次,看起来f2只对f1有用,很难想象在不同的上下文中使用这个函数,这使得它更难以重用。

当函数具有更一般的接口并且能够使用更高级的抽象时,它们变得更具重用性。

这适用于所有类型的函数和对象方法,包括类的__init__方法。这样的方法的存在通常(但并非总是)意味着应该传递一个新的更高级的抽象,或者缺少一个对象。

如果一个函数需要太多参数才能正常工作,那就应该考虑它是代码异味。

事实上,这是一个设计问题,静态分析工具(如pylint)(在第一章中讨论,介绍、代码格式和工具)会默认发出警告。当发生这种情况时,不要压制警告,而是进行重构。

压缩函数签名,接受太多参数

假设我们发现一个函数需要太多参数。我们知道我们不能让代码库保持这样,重构是必不可少的。但是,有哪些选择呢?

根据情况,以下规则可能适用。这绝不是详尽无遗的,但它确实提供了解决一些经常发生的情况的想法。

有时,如果我们发现大部分参数属于一个公共对象,改变参数可能是一种简单的方法。例如,考虑这样一个函数调用:

track_request(request.headers, request.ip_addr, request.request_id)

现在,这个函数可能会或可能不会接受额外的参数,但有一点非常明显:所有的参数都依赖于request,那么为什么不直接传递request对象呢?这是一个简单的改变,但它显著改进了代码。正确的函数调用应该是track_request(request)——更不用说,从语义上讲,这也更有意义。

虽然鼓励传递参数,但在所有传递可变对象给函数的情况下,我们必须非常小心副作用。我们调用的函数不应对我们传递的对象进行任何修改,因为那样会改变对象,产生不希望的副作用。除非这实际上是期望的效果(在这种情况下,必须明确说明),否则不鼓励这种行为。即使我们实际上想要改变我们正在处理的对象上的某些东西,更好的选择是复制它并返回(新的)修改后的版本。

使用不可变对象,并尽量避免副作用。

这将我们带到一个类似的话题:参数分组。在前面的例子中,参数已经被分组了,但这个组(在这种情况下是请求对象)并没有被使用。但其他情况并不像那个例子那么明显,我们可能希望将所有数据在参数中分组到一个充当容器的单个对象中。不用说,这种分组必须是有意义的。这里的想法是实体化:创建我们设计中缺失的抽象。

如果之前的策略不起作用,作为最后的手段,我们可以改变函数的签名以接受可变数量的参数。如果参数数量太多,使用*args**kwargs会使事情变得更难以理解,因此我们必须确保接口被正确记录和正确使用,但在某些情况下这是值得做的。

函数定义为*args**kwargs确实非常灵活和适应性强,但缺点是它失去了它的签名,以及部分意义,几乎所有的可读性。我们已经看到了变量(包括函数参数)的命名如何使代码更容易阅读的例子。如果一个函数将接受任意数量的参数(位置或关键字),我们可能会发现,当我们想在将来查看该函数时,我们可能不会完全知道它应该如何处理它的参数,除非它有一个非常好的文档字符串。

关于软件设计的最终建议

良好的软件设计涉及遵循软件工程的良好实践和充分利用语言的大部分特性的结合。充分利用 Python 所提供的一切是非常有价值的,但也存在滥用的风险,试图将复杂的特性适应简单的设计。

除了这个一般原则,最好还要添加一些最终建议。

软件中的正交性

这个词非常一般,可能有多种含义或解释。在数学中,正交意味着两个元素是独立的。如果两个向量正交,它们的数量积为零。这也意味着它们根本没有关系:其中一个的变化不会对另一个产生任何影响。这就是我们应该思考我们的软件的方式。

更改模块、类或函数不应对正在修改的组件的外部世界产生影响。这当然是非常理想的,但并非总是可能的。但即使在不可能的情况下,良好的设计也会尽量减少影响。我们已经看到了关注点分离、内聚性和组件隔离等概念。

在软件的运行时结构方面,正交性可以被解释为使改变(或副作用)局部化的事实。这意味着,例如,调用对象的方法不应该改变其他(无关的)对象的内部状态。我们已经(并将继续这样做)在本书中强调了在我们的代码中最小化副作用的重要性。

在混合类的示例中,我们创建了一个返回可迭代对象的tokenizer对象。__iter__方法返回一个新的生成器增加了这三个类(基类、混合类和具体类)都是正交的可能性。如果这返回了一个具体的东西(比如一个列表),这将在一定程度上创建了对其余类的依赖,因为当我们将列表更改为其他东西时,我们可能需要更新代码的其他部分,这表明这些类并不像它们应该的那样独立。

让我们给你一个快速的例子。Python 允许通过参数传递函数,因为它们只是常规对象。我们可以利用这个特性来实现一些正交性。我们有一个计算价格的函数,包括税金和折扣,但之后我们想要格式化获得的最终价格:

def calculate_price(base_price: float, tax: float, discount: float) -> 
    return (base_price * (1 + tax)) * (1 - discount)

def show_price(price: float) -> str:
    return "$ {0:,.2f}".format(price)

def str_final_price(
    base_price: float, tax: float, discount: float, fmt_function=str
) -> str:
    return fmt_function(calculate_price(base_price, tax, discount))

请注意,顶层函数正在组合两个正交函数。需要注意的一件事是我们如何计算价格,这也是另一个函数将被表示的方式。改变一个不会改变另一个。如果我们没有特别传递任何内容,它将使用字符串转换作为默认表示函数,如果我们选择传递自定义函数,结果字符串将改变。但是,对show_price的更改不会影响calculate_price。我们可以对任一函数进行更改,知道另一个函数将保持原样:

>>> str_final_price(10, 0.2, 0.5)
'6.0'

>>> str_final_price(1000, 0.2, 0)
'1200.0'

>>> str_final_price(1000, 0.2, 0.1, fmt_function=show_price)
'$ 1,080.00'

正交性与质量有关。如果代码的两部分是正交的,这意味着一个可以更改而不影响另一个。这意味着更改的部分具有单元测试,这些单元测试也与应用程序的其余部分的单元测试正交。在这种假设下,如果这些测试通过,我们可以假设(在一定程度上)应用程序是正确的,而不需要进行完整的回归测试。

更广泛地说,正交性可以从功能的角度来考虑。应用程序的两个功能可以是完全独立的,以至于它们可以在不必担心一个可能破坏另一个(或代码的其余部分)的情况下进行测试和发布。想象一下,项目需要一个新的身份验证机制(比如oauth2,只是举例说明),与此同时另一个团队也在开发一个新的报告。除非系统中有根本性的问题,这两个功能都不应该相互影响。无论哪一个先合并,另一个都不应该受到任何影响。

组织代码

代码的组织方式也影响团队的性能和可维护性。

特别是,拥有大量定义(类、函数、常量等)的大文件是一种不好的做法,应该予以反对。这并不意味着要极端地将一个定义放在一个文件中,但一个良好的代码库将通过相似性来结构和安排组件。

幸运的是,在 Python 中,大文件转换为小文件通常并不困难。即使代码的多个其他部分依赖于该文件中的定义,也可以将其拆分为一个包,并保持完全兼容。这个想法是创建一个新的目录,并在其中放置一个__init__.py文件(这将使它成为一个 Python 包)。除了这个文件,我们还将有多个文件,其中包含每个文件所需的所有特定定义(按某种标准分组的较少函数和类)。然后,__init__.py文件将从所有其他文件中导入先前具有的定义(这就是保证其兼容性的内容)。此外,这些定义可以在模块的__all__变量中提及,以便导出它们。

这样做有很多好处。除了每个文件更容易浏览,事物更容易找到之外,我们还可以认为它将更有效,因为有以下原因:

  • 当模块被导入时,它包含较少的对象需要解析和加载到内存中

  • 模块本身可能会导入较少的模块,因为它需要较少的依赖,就像以前一样

项目还有一个约定也是有帮助的。例如,我们可以创建一个特定于项目中要使用的常量值的文件,而不是在所有文件中放置constants,然后从那里导入它:

from mypoject.constants import CONNECTION_TIMEOUT

像这样集中信息使得代码更容易重用,并有助于避免无意中的重复。

有关分离模块和创建 Python 包的更多细节将在第十章 清晰架构中讨论,当我们在软件架构的背景下探讨这个问题时。

总结

在本章中,我们探讨了几个实现清晰设计的原则。理解代码是设计的一部分是实现高质量软件的关键。这一章和下一章都专注于这一点。

有了这些想法,我们现在可以构建更健壮的代码。例如,通过应用契约设计,我们可以创建保证在其约束下工作的组件。更重要的是,如果出现错误,这不会突然发生,而是我们将清楚地知道是谁犯了错,以及代码的哪一部分违反了契约。这种分隔清晰有助于有效的调试。

在类似的情况下,如果每个组件都能够防御恶意意图或不正确的输入,那么它们就可以变得更加健壮。虽然这个想法与契约设计有所不同,但它可能会很好地补充它。防御性编程是一个好主意,特别是对于应用程序的关键部分。

对于契约设计和防御性编程,正确处理断言是很重要的。牢记它们在 Python 中应该如何使用,并且不要将断言用作程序的控制流逻辑的一部分。也不要捕获这个异常。

说到异常,重要的是要知道何时以及如何使用它们,这里最重要的概念是避免将异常用作控制流(类似于 go-to)的构造。

我们探讨了面向对象设计中一个经常出现的话题:在继承和组合之间做出决定。这里的主要教训不是要使用其中一个,而是要使用更好的选项;我们还应该避免一些常见的反模式,在 Python 中我们经常会看到这些(特别是考虑到它的高度动态性)。

最后,我们讨论了函数中参数的数量,以及关于清晰设计的启发式方法,始终牢记 Python 的特殊性。

这些概念是奠定下一章内容基础的基本设计理念。我们需要首先理解这些想法,以便能够继续进行更高级的主题,比如 SOLID 原则。

参考资料

这里是一些你可以参考的信息清单:

第四章:SOLID 原则

在本章中,我们将继续探讨应用于 Python 的清晰设计概念。特别是,我们将回顾所谓的SOLID原则,以及如何以 Pythonic 的方式实现它们。这些原则包括一系列实践,以实现更高质量的软件。如果我们中有人不知道 SOLID 代表什么,这里是解释:

  • S:单一责任原则

  • O:开闭原则

  • L:里氏替换原则

  • I:接口隔离原则

  • D:依赖反转原则

本章的目标如下:

  • 熟悉软件设计的 SOLID 原则

  • 设计遵循单一责任原则的软件组件

  • 通过开闭原则实现更易维护的代码

  • 通过遵守里氏替换原则,在面向对象设计中实现适当的类层次结构

  • 通过接口隔离和依赖反转进行设计

单一责任原则

单一责任原则SRP)规定软件组件(通常是一个类)必须只有一个责任。类只有一个责任意味着它只负责做一件具体的事情,因此我们可以得出结论,它只有一个变化的原因。

只有在领域问题上有一件事情改变时,类才需要更新。如果我们不得不因为不同的原因对一个类进行修改,那意味着抽象不正确,类承担了太多责任。

正如在第二章中介绍的Pythonic Code,这个设计原则帮助我们构建更具凝聚力的抽象;对象只做一件事情,而且做得很好,遵循 Unix 哲学。我们要避免的情况是拥有承担多个责任的对象(通常称为god-objects),因为它们知道得太多,或者比它们应该知道的更多。这些对象组合了不同(大多数是不相关的)行为,因此使它们更难以维护。

再次强调,类越小越好。

SRP 与软件设计中的内聚概念密切相关,我们在第三章中已经探讨过这一点,当时我们讨论了软件中的关注点分离。我们努力实现的目标是,类被设计成大部分时间内它们的属性和方法被使用。当这种情况发生时,我们知道它们是相关的概念,因此将它们分组到同一个抽象下是有意义的。

在某种程度上,这个想法与关系数据库设计中的规范化概念有些相似。当我们发现对象的接口的属性或方法有分区时,它们可能被移动到其他地方——这表明它们是两个或更多不同的抽象混合在一起。

还有另一种看待这个原则的方式。如果在查看一个类时,我们发现方法是相互独立的,彼此之间没有关联,那么它们就是需要分解成更小的类的不同责任。

一个类承担太多责任

在这个例子中,我们将创建一个应用程序,负责从源头(可以是日志文件、数据库或其他许多来源)读取有关事件的信息,并识别与每个特定日志对应的动作。

不符合 SRP 的设计如下所示:

不考虑实现,该类的代码可能如下所示:

# srp_1.py
class SystemMonitor:
    def load_activity(self):
        """Get the events from a source, to be processed."""

    def identify_events(self):
        """Parse the source raw data into events (domain objects)."""

    def stream_events(self):
        """Send the parsed events to an external agent."""

这个类的问题在于它定义了一个接口,其中包含一组与彼此正交的动作对应的方法:每个动作都可以独立于其他动作完成。

这种设计缺陷使得类变得僵化、不灵活、容易出错,因为很难维护。在这个例子中,每个方法代表了类的一个责任。每个责任都意味着类可能需要被修改的原因。在这种情况下,每个方法代表了类将被修改的各种原因之一。

考虑加载器方法,它从特定来源检索信息。无论这是如何完成的(我们可以在这里抽象实现细节),很明显它将有自己的一系列步骤,例如连接到数据源,加载数据,将其解析为预期格式等。如果其中任何一项发生变化(例如,我们想要更改用于保存数据的数据结构),SystemMonitor类将需要更改。问问自己这是否有意义。系统监视器对象是否必须因为我们改变了数据的表示而改变?不。

相同的推理也适用于其他两种方法。如果我们改变了指纹事件的方式,或者我们如何将它们传递到另一个数据源,我们最终会对同一个类进行修改。

现在应该很清楚,这个类相当脆弱,而且不太容易维护。有很多不同的原因会影响这个类的变化。相反,我们希望外部因素对我们的代码的影响尽可能小。解决方案是再次创建更小、更具凝聚力的抽象。

分配责任

为了使解决方案更易于维护,我们将每个方法分离到不同的类中。这样,每个类都将有一个单一的责任:

通过使用一个对象来实现相同的行为,该对象将与这些新类的实例进行交互,使用这些对象作为协作者,但是这个想法仍然是每个类封装了一组独立于其他类的特定方法。现在的想法是,对这些类的任何更改都不会影响其他类,它们都有一个明确而具体的含义。如果我们需要改变如何从数据源加载事件,警报系统甚至不知道这些变化,因此我们不需要修改系统监视器上的任何内容(只要合同仍然得到保留),数据目标也没有被修改。

现在变化是局部的,影响是最小的,每个类更容易维护。

新的类定义了接口,不仅更易于维护,而且更可重用。想象一下,现在在应用程序的另一个部分,我们还需要从日志中读取活动,但是为了不同的目的。有了这个设计,我们可以简单地使用ActivityReader类型的对象(实际上应该是一个接口,但是在本节的目的上,这个细节并不重要,将在下一个原则中解释)。这是有意义的,而在以前的设计中是没有意义的,因为尝试重用我们定义的唯一类也会带有不需要的额外方法(比如identify_events()stream_events())。

一个重要的澄清是,这个原则并不意味着每个类必须只有一个方法。任何新类都可能有额外的方法,只要它们对应于该类负责处理的相同逻辑。

开闭原则

开闭原则(OCP)规定一个模块应该是开放的和封闭的(但是针对不同的方面)。

例如,在设计一个类时,我们应该仔细地封装逻辑,使其具有良好的维护性,这意味着我们希望它对扩展是开放的,但对修改是封闭的。

简单来说,这意味着当领域问题出现新情况时,我们当然希望我们的代码是可扩展的,能够适应新的要求或领域问题的变化。这意味着当领域问题出现新情况时,我们只想向我们的模型添加新的东西,而不是更改任何已经关闭修改的现有内容。

如果由于某种原因,当需要添加新内容时,我们发现自己修改了代码,那么这个逻辑可能设计得很糟糕。理想情况下,当需求发生变化时,我们只需扩展模块以满足新需求,而无需修改代码。

这个原则适用于多个软件抽象。它可以是一个类,甚至是一个模块。在接下来的两个小节中,我们将分别看到每个示例。

不遵循开闭原则的可维护性问题示例

让我们从一个系统的示例开始,该系统设计方式不符合开闭原则,以便看到这种设计的可维护性问题以及这种设计的不灵活性。

我们的想法是,系统的一部分负责在另一个正在被监视的系统中发生事件时识别这些事件。在每个点上,我们希望这个组件根据先前收集的数据的值(为简单起见,我们将假设它被打包到一个字典中,并且先前是通过日志、查询等其他方式检索的)正确地识别事件类型。我们有一个类,根据这些数据,将检索事件,这是另一种具有自己层次结构的类型。

解决这个问题的第一次尝试可能看起来像这样:

# openclosed_1.py
class Event:
    def __init__(self, raw_data):
        self.raw_data = raw_data

class UnknownEvent(Event):
    """A type of event that cannot be identified from its data."""

class LoginEvent(Event):
    """A event representing a user that has just entered the system."""

class LogoutEvent(Event):
    """An event representing a user that has just left the system."""

class SystemMonitor:
    """Identify events that occurred in the system."""

    def __init__(self, event_data):
        self.event_data = event_data

    def identify_event(self):
        if (
            self.event_data["before"]["session"] == 0
            and self.event_data["after"]["session"] == 1
        ):
            return LoginEvent(self.event_data)
        elif (
            self.event_data["before"]["session"] == 1
            and self.event_data["after"]["session"] == 0
        ):
            return LogoutEvent(self.event_data)

        return UnknownEvent(self.event_data)

以下是前述代码的预期行为:

>>> l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}})
>>> l1.identify_event().__class__.__name__
'LoginEvent'

>>> l2 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}})
>>> l2.identify_event().__class__.__name__
'LogoutEvent'

>>> l3 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}})
>>> l3.identify_event().__class__.__name__
'UnknownEvent'

我们可以清楚地注意到事件类型的层次结构,以及一些构造它们的业务逻辑。例如,当会话之前没有标志,但现在有了,我们将该记录标识为登录事件。相反,当相反情况发生时,这意味着它是一个注销事件。如果无法识别事件,则返回类型未知的事件。这是为了通过遵循空对象模式(而不是返回None,它检索具有一些默认逻辑的相应类型的对象)来保持多态性。空对象模式在第九章中有描述,常见设计模式

这种设计存在一些问题。第一个问题是确定事件类型的逻辑集中在一个庞大的方法中。随着我们想要支持的事件数量增加,这个方法也会增长,最终可能会变成一个非常长的方法,这是不好的,因为正如我们已经讨论过的,它不会只做一件事情并且做得很好。

在同一行上,我们可以看到这种方法不适合修改。每当我们想要向系统添加新类型的事件时,我们都必须更改这种方法中的某些内容(更不用说elif语句的链将是一场噩梦!)。

我们希望能够添加新类型的事件,而无需更改这种方法(关闭修改)。我们还希望能够支持新类型的事件(扩展开放),这样当添加新事件时,我们只需添加代码,而不是更改已经存在的代码。

重构事件系统以实现可扩展性

前面示例的问题在于SystemMonitor类直接与它将要检索的具体类进行交互。

为了实现符合开闭原则的设计,我们必须朝着抽象设计。

一个可能的替代方案是将这个类视为与事件协作,然后将每种特定类型的事件的逻辑委托给其相应的类:

然后,我们必须为每种类型的事件添加一个新的(多态的)方法,其单一责任是确定它是否与传递的数据相对应,我们还必须改变逻辑以遍历所有事件,找到正确的事件。

新代码应该如下所示:

# openclosed_2.py
class Event:
    def __init__(self, raw_data):
        self.raw_data = raw_data

    @staticmethod
    def meets_condition(event_data: dict):
        return False

class UnknownEvent(Event):
    """A type of event that cannot be identified from its data"""

class LoginEvent(Event):
    @staticmethod
    def meets_condition(event_data: dict):
        return (
            event_data["before"]["session"] == 0
            and event_data["after"]["session"] == 1
        )

class LogoutEvent(Event):
    @staticmethod
    def meets_condition(event_data: dict):
        return (
            event_data["before"]["session"] == 1
            and event_data["after"]["session"] == 0
        )

class SystemMonitor:
    """Identify events that occurred in the system."""

    def __init__(self, event_data):
        self.event_data = event_data

    def identify_event(self):
        for event_cls in Event.__subclasses__():
            try:
                if event_cls.meets_condition(self.event_data):
                    return event_cls(self.event_data)
            except KeyError:
                continue
        return UnknownEvent(self.event_data)

请注意,现在交互是针对抽象的(在这种情况下,它将是通用基类Event,甚至可能是一个抽象基类或接口,但对于这个例子来说,拥有一个具体的基类就足够了)。该方法不再使用特定类型的事件,而只是使用遵循通用接口的通用事件 - 它们在meets_condition方法方面都是多态的。

请注意,事件是通过__subclasses__()方法发现的。支持新类型的事件现在只是创建一个新的事件类,该类必须继承自Event并根据其特定的业务逻辑实现自己的meets_condition()方法。

扩展事件系统

现在,让我们证明这个设计实际上是我们想要的那样具有可扩展性。想象一下,出现了一个新的需求,我们还必须支持与用户在监视系统上执行的交易相对应的事件。

设计的类图必须包括这样一种新的事件类型,如下所示:

只需添加到这个新类的代码,逻辑就能按预期工作:

# openclosed_3.py
class Event:
    def __init__(self, raw_data):
        self.raw_data = raw_data

    @staticmethod
    def meets_condition(event_data: dict):
        return False

class UnknownEvent(Event):
    """A type of event that cannot be identified from its data"""

class LoginEvent(Event):
    @staticmethod
    def meets_condition(event_data: dict):
        return (
            event_data["before"]["session"] == 0
            and event_data["after"]["session"] == 1
        )

class LogoutEvent(Event):
    @staticmethod
    def meets_condition(event_data: dict):
        return (
            event_data["before"]["session"] == 1
            and event_data["after"]["session"] == 0
        )

class TransactionEvent(Event):
    """Represents a transaction that has just occurred on the system."""

    @staticmethod
    def meets_condition(event_data: dict):
        return event_data["after"].get("transaction") is not None

class SystemMonitor:
    """Identify events that occurred in the system."""

    def __init__(self, event_data):
        self.event_data = event_data

    def identify_event(self):
        for event_cls in Event.__subclasses__():
            try:
                if event_cls.meets_condition(self.event_data):
                    return event_cls(self.event_data)
            except KeyError:
                continue
        return UnknownEvent(self.event_data)

我们可以验证以前的情况仍然像以前一样工作,并且新事件也被正确识别:

>>> l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}})
>>> l1.identify_event().__class__.__name__
'LoginEvent'

>>> l2 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}})
>>> l2.identify_event().__class__.__name__
'LogoutEvent'

>>> l3 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}})
>>> l3.identify_event().__class__.__name__
'UnknownEvent'

>>> l4 = SystemMonitor({"after": {"transaction": "Tx001"}})
>>> l4.identify_event().__class__.__name__
'TransactionEvent'

请注意,当我们添加新的事件类型时,SystemMonitor.identify_event()方法根本没有改变。因此,我们说这个方法对于新类型的事件是封闭的。

相反,Event类允许我们在需要时添加新类型的事件。然后我们说,事件对于新类型是开放的。

这就是这个原则的真正本质 - 当领域问题出现新的东西时,我们只想添加新的代码,而不是修改现有的代码。

关于 OCP 的最终想法

正如你可能已经注意到的,这个原则与多态的有效使用密切相关。我们希望设计符合客户端可以使用的多态合同的抽象,以及足够通用以便扩展模型是可能的,只要多态关系得到保留。

这个原则解决了软件工程中的一个重要问题:可维护性。不遵循 OCP 的危险是连锁效应和软件中的问题,其中单个更改触发整个代码库的更改,或者有风险破坏代码的其他部分。

一个重要的最终说明是,为了实现这种设计,我们需要能够对我们想要保护的抽象(在这个例子中是新类型的事件)进行适当的封闭。这在所有程序中并不总是可能的,因为一些抽象可能会发生冲突(例如,我们可能有一个适当的抽象,它提供了对一个需求的封闭,但对其他类型的需求却不起作用)。在这些情况下,我们需要有选择地应用一种策略,为需要最具可扩展性的需求提供最佳的封闭。

Liskov 的替换原则

Liskov 的替换原则LSP)规定了对象类型必须具有的一系列属性,以保持其设计的可靠性。

LSP 背后的主要思想是,对于任何类,客户端应该能够无法区分地使用其任何子类型,甚至在运行时也不会影响预期的行为。这意味着客户端完全与类层次结构的变化隔离和不知情。

更正式地说,这是 Liskov 替换原则的原始定义(LISKOV 01):如果ST的子类型,那么类型为T的对象可以被类型为S的对象替换,而不会破坏程序。

这可以通过一个通用的图表来理解,比如下面的图表。想象一下,有一个客户类需要(包括)另一种类型的对象。一般来说,我们希望这个客户与某种类型的对象进行交互,换句话说,它将通过一个接口来工作。

现在,这种类型可能只是一个通用的接口定义,一个抽象类或一个接口,而不是具有行为本身的类。可能有几个子类扩展了这种类型(在图表中用名称子类型描述,最多N)。这个原则背后的想法是,如果层次结构被正确实现,客户类必须能够使用任何子类的实例而不会注意到。这些对象应该是可互换的,如下所示:

这与我们已经讨论过的其他设计原则相关,比如按接口设计。一个好的类必须定义一个清晰简洁的接口,只要子类遵守该接口,程序就会保持正确。

作为这一原则的结果,它也与按合同设计的思想相关。给定类型和客户之间有一个合同。通过遵循 LSP 的规则,设计将确保子类遵守由父类定义的合同。

使用工具检测 LSP 问题

有一些与 LSP 相关的情景是如此明显错误,以至于我们学会配置的工具(主要是 Mypy 和 Pylint)可以轻松识别。

使用 Mypy 检测方法签名中的不正确数据类型

通过在整个代码中使用类型注释(如之前在第一章中推荐的,介绍、代码格式和工具),并配置 Mypy,我们可以快速检测到一些基本错误,并免费检查 LSP 的基本合规性。

如果Event类的一个子类以不兼容的方式覆盖了一个方法,Mypy 会通过检查注释来注意到这一点:

class Event:
    ...
    def meets_condition(self, event_data: dict) -> bool:
        return False

class LoginEvent(Event):
    def meets_condition(self, event_data: list) -> bool:
        return bool(event_data)

当我们在这个文件上运行 Mypy 时,将会得到一个错误消息,内容如下:

error: Argument 1 of "meets_condition" incompatible with supertype "Event"

LSP 的违反是明显的——因为派生类使用了与基类定义的类型不同的event_data参数类型,我们不能指望它们能够同样工作。请记住,根据这个原则,这个层次结构的任何调用者都必须能够透明地使用EventLoginEvent,而不会注意到任何差异。这两种类型的对象可以互换,不应该使应用程序失败。如果不能做到这一点,将会破坏层次结构上的多态性。

如果返回类型被更改为布尔值之外的其他值,同样的错误也会发生。其理由是这段代码的客户端期望使用布尔值。如果派生类中的一个更改了这个返回类型,它将违反合同,再次,我们不能指望程序会继续正常工作。

关于类型不同但共享公共接口的快速说明:尽管这只是一个简单的例子来演示错误,但事实上字典和列表都有一些共同之处;它们都是可迭代的。这意味着在某些情况下,可能会有一个方法期望接收一个字典,另一个方法期望接收一个列表,只要两者都通过可迭代接口处理参数,这可能是有效的。在这种情况下,问题不在于逻辑本身(LSP 可能仍然适用),而在于签名类型的定义,它们既不应该是list也不应该是dict,而是两者的并集。无论如何,都必须修改一些东西,无论是方法的代码、整个设计,还是类型注释,但在任何情况下,我们都不应该消除警告并忽略 Mypy 给出的错误。

不要通过# type: ignore或类似的方式忽略这样的错误。重构或更改代码以解决真正的问题。工具之所以报告实际的设计缺陷是有充分理由的。

使用 Pylint 检测不兼容的签名

LSP 的另一个严重违规是,与其在层次结构中变化参数的类型,方法的签名完全不同。这可能看起来像一个大错误,但要检测它并不总是那么容易记住;Python 是解释性语言,所以没有编译器能够及早检测到这种类型的错误,因此它们直到运行时才会被捕获。幸运的是,我们有静态代码分析器,如 Mypy 和 Pylint,可以及早捕获这类错误。

虽然 Mypy 也会捕捉到这种类型的错误,但同时运行 Pylint 以获得更多的见解也不是坏事。

在存在一个违反层次结构定义的类的情况下(例如,通过更改方法的签名,添加额外参数等),如下所示:

# lsp_1.py
class LogoutEvent(Event):
    def meets_condition(self, event_data: dict, override: bool) -> bool:
        if override:
            return True
        ...

Pylint 将检测到它,并打印出一个信息性的错误:

Parameters differ from overridden 'meets_condition' method (arguments-differ)

再次,就像在先前的情况下一样,不要压制这些错误。注意工具给出的警告和错误,并相应地调整代码。

更微妙的 LSP 违规案例

然而,在其他情况下,LSP 被破坏的方式并不那么清晰或明显,工具无法自动识别,我们必须依靠仔细的代码检查进行代码审查。

修改合同的情况特别难以自动检测。鉴于 LSP 的整个理念是子类可以像其父类一样被客户使用,这也意味着合同在层次结构上必须得到正确保留。

请记住第三章中提到的良好代码的一般特征,即在按合同设计时,客户和供应商之间的合同设定了一些规则——客户必须提供方法的前置条件,供应商可能会验证,然后以后置条件的形式返回一些结果给客户进行检查。

父类与其客户定义了一个合同。这个类的子类必须尊重这样的合同。这意味着,例如:

  • 子类永远不能使前置条件比父类中定义的更严格

  • 子类永远不能使后置条件比父类中定义的更弱

考虑前一节中定义的事件层次结构的例子,但现在通过一个变化来说明 LSP 和 DbC 之间的关系。

这一次,我们假设了一个方法的前提条件,根据数据检查标准,提供的参数必须是一个包含"before""after"两个键的字典,并且它们的值也是嵌套字典。这使我们能够进一步封装,因为现在客户端不需要捕获KeyError异常,而只需调用前提条件方法(假设如果系统在错误的假设下运行是可以失败的)。顺便说一句,很好的是我们可以从客户端中删除这个,因为现在,SystemMonitor不需要知道协作者类的方法可能引发哪些类型的异常(请记住,异常会削弱封装,因为它们要求调用者对其所调用的对象有额外的了解)。

这种设计可以通过代码中的以下更改来表示:

# lsp_2.py

class Event:
    def __init__(self, raw_data):
        self.raw_data = raw_data

    @staticmethod
    def meets_condition(event_data: dict):
        return False

    @staticmethod
    def meets_condition_pre(event_data: dict):
        """Precondition of the contract of this interface.

        Validate that the ``event_data`` parameter is properly formed.
        """
        assert isinstance(event_data, dict), f"{event_data!r} is not a dict"
        for moment in ("before", "after"):
            assert moment in event_data, f"{moment} not in {event_data}"
            assert isinstance(event_data[moment], dict)

现在尝试检测正确事件类型的代码只检查前提条件一次,然后继续找到正确类型的事件:

# lsp_2.py
class SystemMonitor:
    """Identify events that occurred in the system."""

    def __init__(self, event_data):
        self.event_data = event_data

    def identify_event(self):
        Event.meets_condition_pre(self.event_data)
        event_cls = next(
            (
                event_cls
                for event_cls in Event.__subclasses__()
                if event_cls.meets_condition(self.event_data)
            ),
            UnknownEvent,
        )
        return event_cls(self.event_data)

合同只规定顶层键"before""after"是必须的,它们的值也应该是字典。在子类中试图要求更严格的参数将会失败。

交易事件的类最初设计是正确的。看看代码如何不对内部名为"transaction"的键施加限制;它只在那里使用它的值,但这不是强制性的:

# lsp_2.py
class TransactionEvent(Event):
    """Represents a transaction that has just occurred on the system."""

    @staticmethod
    def meets_condition(event_data: dict):
        return event_data["after"].get("transaction") is not None

然而,原始的两个方法是不正确的,因为它们要求存在一个名为"session"的键,这不是原始合同的一部分。这违反了合同,现在客户端无法像使用其他类一样使用这些类,因为它会引发KeyError

在修复这个问题之后(更改了.get()方法的方括号),LSP 的顺序已经恢复,多态性占优势:

>>> l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}})
>>> l1.identify_event().__class__.__name__
'LoginEvent'

>>> l2 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}})
>>> l2.identify_event().__class__.__name__
'LogoutEvent'

>>> l3 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}})
>>> l3.identify_event().__class__.__name__
'UnknownEvent'

>>> l4 = SystemMonitor({"before": {}, "after": {"transaction": "Tx001"}})
>>> l4.identify_event().__class__.__name__
'TransactionEvent'

期望自动化工具(无论它们有多好和有用)能够检测到这种情况是不合理的。在设计类时,我们必须小心,不要意外地改变方法的输入或输出,以使其与客户端最初期望的不兼容。

LSP 的备注

LSP 对于良好的面向对象软件设计是至关重要的,因为它强调了其核心特性之一——多态性。它是关于创建正确的层次结构,使得从基类派生的类在其接口的方法方面对父类具有多态性。

有趣的是注意到这个原则如何与前一个原则相关联——如果我们尝试用一个不兼容的新类扩展一个类,它将失败,与客户端的合同将被打破,因此这样的扩展将不可能(或者,为了使其可能,我们将不得不打破原则的另一端,并修改应该对修改封闭的客户端代码,这是完全不可取和不可接受的)。

仔细思考 LSP 建议的新类的方式有助于我们正确地扩展层次结构。然后我们可以说 LSP 有助于 OCP。

接口隔离

接口隔离原则(ISP)提供了一些关于我们已经反复讨论过的想法的指导:接口应该是小的。

在面向对象的术语中,接口由对象公开的方法集表示。这就是说,对象能够接收或解释的所有消息构成了它的接口,这是其他客户端可以请求的内容。接口将类的公开行为的定义与其实现分离开来。

在 Python 中,接口是根据类的方法隐式定义的。这是因为 Python 遵循所谓的鸭子类型原则。

传统上,鸭子类型的理念是任何对象实际上是由它所拥有的方法和它能够做什么来表示的。这意味着,无论类的类型、名称、文档字符串、类属性或实例属性如何,最终定义对象本质的是它所拥有的方法。类上定义的方法(它知道如何做)决定了对象实际上是什么。它被称为鸭子类型,是因为“如果它走起来像鸭子,叫起来像鸭子,那它一定是鸭子”。

很长一段时间以来,鸭子类型是 Python 中定义接口的唯一方式。后来,Python 3(PEP-3119)引入了抽象基类的概念,作为一种以不同方式定义接口的方法。抽象基类的基本思想是它定义了一种基本行为或接口,一些派生类负责实现。这在我们想要确保某些关键方法实际上被覆盖时非常有用,它还可以作为覆盖或扩展诸如isinstance()之类方法功能的机制。

该模块还包含一种将某些类型注册为层次结构的一部分的方法,称为虚拟子类。其想法是通过添加一个新的标准——走起来像鸭子,叫起来像鸭子,或者...它说它是鸭子,将鸭子类型的概念扩展得更远一些。

Python 解释接口的这些概念对于理解这个原则和下一个原则非常重要。

抽象地说,这意味着 ISP 规定,当我们定义一个提供多个方法的接口时,最好将其分解为多个接口,每个接口包含较少的方法(最好只有一个),具有非常具体和准确的范围。通过将接口分离为尽可能小的单元,以促进代码的可重用性,想要实现这些接口之一的每个类很可能会具有高度的内聚性,因为它具有相当明确的行为和一组责任。

提供太多方法的接口

现在,我们希望能够从不同格式的多个数据源中解析事件(例如 XML 和 JSON)。遵循良好的实践,我们决定将接口作为我们的依赖目标,而不是具体的类,设计如下:

为了在 Python 中将其创建为接口,我们将使用抽象基类,并将方法(from_xml()from_json())定义为抽象的,以强制派生类实现它们。从这个抽象基类派生并实现这些方法的事件将能够处理它们对应的类型。

但是,如果特定的类不需要 XML 方法,只能从 JSON 构建,它仍然会携带接口的from_xml()方法,因为它不需要它,它将不得不通过。这不太灵活,因为它会创建耦合,并迫使接口的客户端使用它们不需要的方法。

接口越小,越好。

最好将其分成两个不同的接口,每个接口对应一个方法:

通过这种设计,从XMLEventParser派生并实现from_xml()方法的对象将知道如何从 XML 构建,从 JSON 文件构建也是一样,但更重要的是,我们保持了两个独立函数的正交性,并保留了系统的灵活性,而不会失去可以通过组合新的较小对象实现的任何功能。

与 SRP 有些相似,但主要区别在于这里我们谈论的是接口,因此它是行为的抽象定义。没有理由改变,因为在接口实际实现之前什么都没有。然而,不遵守这个原则将创建一个与正交功能耦合的接口,这个派生类也将无法遵守 SRP(它将有多个改变的原因)。

接口应该有多小?

前一节提出的观点是有效的,但也需要警告——如果被误解或被过分解读,要避免走上危险的道路。

基类(抽象或非抽象)为所有其他类定义了一个接口来扩展它。这应该尽可能小的事实必须以内聚性的方式理解——它应该只做一件事。这并不意味着它一定必须有一个方法。在前面的例子中,巧合的是两种方法完全不同,因此将它们分开成不同的类是有意义的。

但也可能有多个方法合理地属于同一个类。想象一下,您想提供一个混合类,它在上下文管理器中抽象出某些逻辑,以便所有从该混合类派生的类都可以免费获得该上下文管理器逻辑。正如我们已经知道的那样,上下文管理器包括两种方法:__enter____exit__。它们必须一起使用,否则结果将根本不是有效的上下文管理器!

如果不将两种方法放在同一个类中,将导致一个破损的组件,不仅毫无用处,而且具有误导性的危险。希望这个夸张的例子能够对前一节中的例子起到平衡作用,读者可以更准确地了解设计接口。

依赖反转

这是一个非常强大的想法,当我们在第九章中探索一些设计模式时,它将再次出现,以及第十章中的清晰架构

依赖反转原则(DIP)提出了一个有趣的设计原则,通过它我们可以保护我们的代码,使其不依赖于脆弱、易变或超出我们控制范围的东西。反转依赖的想法是,我们的代码不应该适应细节或具体实现,而是相反的:我们希望通过一种 API 强制任何实现或细节适应我们的代码。

抽象必须以不依赖于细节的方式组织,而是相反的方式——细节(具体实现)应该依赖于抽象。

想象一下,我们设计中的两个对象需要合作,ABAB的实例一起工作,但事实证明,我们的模块并不直接控制B(它可能是外部库,或者是由另一个团队维护的模块等)。如果我们的代码严重依赖于B,当这种情况发生变化时,代码将会崩溃。为了防止这种情况,我们必须反转依赖:使B必须适应A。这是通过提供一个接口并强制我们的代码不依赖于B的具体实现,而是依赖于我们定义的接口来完成的。然后,B有责任遵守该接口。

与前几节探讨的概念一致,抽象也以接口的形式出现(或者在 Python 中是抽象基类)。

一般来说,我们可以期望具体实现的变化频率要比抽象组件高得多。正因为如此,我们将抽象(接口)放在我们期望系统发生变化、被修改或扩展的灵活性点上,而不必更改抽象本身。

刚性依赖的情况

我们事件监控系统的最后一部分是将识别的事件传递给数据收集器进行进一步分析。这样一个想法的天真实现将包括一个与数据目标交互的事件流类,例如Syslog

然而,这种设计并不是很好,因为我们有一个高级类(EventStreamer)依赖于一个低级类(Syslog是一个实现细节)。如果我们想要以不同的方式发送数据到SyslogEventStreamer将不得不进行修改。如果我们想要在运行时更改数据目标为另一个目标或添加新目标,我们也会遇到麻烦,因为我们将不断修改stream()方法以适应这些要求。

倒置依赖关系

解决这些问题的方法是使EventStreamer使用接口而不是具体类。这样,实现这个接口取决于包含实现细节的低级类:

现在有一个表示通用数据目标的接口,数据将被发送到该接口。请注意,依赖关系已经被倒置,因为EventStreamer不依赖于特定数据目标的具体实现,它不必随着此数据目标的更改而更改,而是由每个特定的数据目标来正确实现接口并根据需要进行调整。

换句话说,第一个实现的原始EventStreamer只能与类型为Syslog的对象一起工作,这并不太灵活。然后我们意识到它可以与任何能够响应.send()消息的对象一起工作,并确定这个方法是它需要遵守的接口。现在,在这个版本中,Syslog实际上是扩展了名为DataTargetClient的抽象基类,该类定义了send()方法。从现在开始,每种新类型的数据目标(例如电子邮件)都要扩展这个抽象基类并实现send()方法。

我们甚至可以在运行时修改此属性以适用于实现send()方法的任何其他对象,它仍然可以工作。这就是为什么它经常被称为依赖注入的原因:因为依赖关系可以动态提供。

热心的读者可能会想知道为什么这是必要的。Python 足够灵活(有时太灵活了),并且允许我们向EventStreamer提供任何特定数据目标对象,而无需该对象遵守任何接口,因为它是动态类型的。问题是:当我们可以简单地向其传递具有send()方法的对象时,为什么我们需要定义抽象基类(接口)呢?

公平地说,这是真的;实际上并不需要这样做,程序仍然可以正常工作。毕竟,多态性并不意味着(或要求)继承才能工作。然而,定义抽象基类是一种良好的实践,带来了一些优势,第一个优势是鸭子类型。除了鸭子类型,我们还可以提到模型变得更易读的事实——请记住,继承遵循是一个规则,因此通过声明抽象基类并从中扩展,我们在说,例如,SyslogDataTargetClient,这是您的代码用户可以阅读和理解的内容(再次强调,这是鸭子类型)。

总的来说,定义抽象基类并不是强制性的,但为了实现更清晰的设计是值得的。这本书的目的之一就是帮助程序员避免犯易犯的错误,因为 Python 太灵活了,我们可以逃避这些错误。

摘要

SOLID 原则是良好的面向对象软件设计的关键指导原则。

构建软件是一项非常艰巨的任务——代码的逻辑是复杂的,它在运行时的行为很难(有时甚至是不可能)预测,要求不断变化,环境也在不断变化,还有很多事情可能会出错。

此外,有多种构建软件的方式,不同的技术、范式和许多不同的设计可以共同解决特定问题。然而,并非所有这些方法随着时间的推移都会被证明是正确的,要求也会变化或演变。然而,到那时,要对不正确的设计做出改变已经为时已晚,因为它是僵化的、不灵活的,因此很难将其重构为正确的解决方案。

这意味着,如果我们设计错误,将会在未来付出很大的代价。那么我们如何才能实现最终会有回报的良好设计呢?答案是我们并不确定。我们正在处理未来,而未来是不确定的——我们无法确定我们的设计是否正确,我们的软件是否在未来几年内灵活和适应。正是因为这个原因,我们必须坚持原则。

这就是 SOLID 原则发挥作用的地方。它们并不是魔法规则(毕竟,在软件工程中没有银弹),但它们提供了良好的指导方针,这些指导方针在过去的项目中已被证明有效,并且会使我们的软件更有可能成功。

在本章中,我们探讨了 SOLID 原则,目的是理解清晰的设计。在接下来的章节中,我们将继续探讨语言的细节,并在某些情况下看到这些工具和特性如何与这些原则一起使用。

参考资料

以下是您可以参考的信息列表:

第五章:使用装饰器改进我们的代码

在本章中,我们将探讨装饰器,并看到它们在许多情况下如何有用,我们想要改进我们的设计。我们将首先探讨装饰器是什么,它们是如何工作的,以及它们是如何实现的。

有了这些知识,我们将重新审视我们在以前章节中学到的关于软件设计的一般良好实践,并看看装饰器如何帮助我们遵守每个原则。

本章的目标如下:

  • 了解 Python 中装饰器的工作原理

  • 学习如何实现适用于函数和类的装饰器

  • 有效实现装饰器,避免常见的实现错误

  • 分析如何通过装饰器避免代码重复(DRY 原则)

  • 研究装饰器如何有助于关注点分离

  • 分析良好装饰器的示例

  • 审查常见情况、习语或模式,以确定装饰器是正确的选择

Python 中的装饰器是什么?

装饰器在 Python 中很久以前就被引入了(PEP-318),作为一种简化函数和方法定义的机制,当它们在原始定义之后需要被修改时。

最初的动机之一是因为诸如classmethodstaticmethod之类的函数被用来转换方法的原始定义,但它们需要额外的一行,修改函数的原始定义。

更一般地说,每当我们必须对函数应用转换时,我们必须使用modifier函数调用它,然后将其重新分配给与函数最初定义的相同名称。

例如,如果我们有一个名为original的函数,然后我们有一个在其上更改original行为的函数,称为modifier,我们必须编写类似以下的内容:

def original(...):
    ...
original = modifier(original)

注意我们如何更改函数并将其重新分配给相同的名称。这很令人困惑,容易出错(想象有人忘记重新分配函数,或者确实重新分配了函数,但不是在函数定义后的下一行,而是在更远的地方),而且很麻烦。因此,语言中添加了一些语法支持。

前面的示例可以这样重写:

@modifier
def original(...):
   ...

这意味着装饰器只是调用装饰器后面的内容作为装饰器本身的第一个参数的语法糖,结果将是装饰器返回的内容。

根据 Python 术语和我们的示例,modifier是我们称之为装饰器,original是被装饰的函数,通常也称为“wrapped”对象。

虽然最初的功能是为方法和函数设计的,但实际的语法允许对任何类型的对象进行装饰,因此我们将探讨应用于函数、方法、生成器和类的装饰器。

最后一点是,虽然装饰器的名称是正确的(毕竟,装饰器实际上是在对wrapped函数进行更改、扩展或处理),但它不应与装饰器设计模式混淆。

装饰函数

函数可能是 Python 对象的最简单表示形式,可以对函数使用装饰器来应用各种逻辑——我们可以验证参数、检查前提条件、完全改变行为、修改其签名、缓存结果(创建原始函数的记忆版本),等等。

例如,我们将创建一个实现“重试”机制的基本装饰器,控制特定领域级别的异常并重试一定次数:

# decorator_function_1.py
class ControlledException(Exception):
    """A generic exception on the program's domain."""

def retry(operation):
    @wraps(operation)
    def wrapped(*args, **kwargs):
        last_raised = None
        RETRIES_LIMIT = 3
        for _ in range(RETRIES_LIMIT):
            try:
                return operation(*args, **kwargs)
            except ControlledException as e:
                logger.info("retrying %s", operation.__qualname__)
                last_raised = e
        raise last_raised

    return wrapped

现在可以忽略@wraps的使用,因为它将在名为有效装饰器-避免常见错误的部分中进行介绍。在 for 循环中使用_,意味着这个数字被赋值给一个我们目前不感兴趣的变量,因为它在 for 循环内没有被使用(在 Python 中命名_的值被忽略是一个常见的习惯)。

retry装饰器不接受任何参数,因此可以轻松地应用到任何函数,如下所示:

@retry
def run_operation(task):
    """Run a particular task, simulating some failures on its execution."""
    return task.run()

正如在开头解释的那样,在run_operation的顶部定义@retry只是 Python 提供的语法糖,实际上执行run_operation = retry(run_operation)

在这个有限的例子中,我们可以看到装饰器如何被用来创建一个通用的retry操作,根据一定的条件(在这种情况下,表示为可能与超时相关的异常),允许调用被装饰的代码多次。

装饰类

类也可以使用相同的语法装饰(PEP-3129)作用于函数。唯一的区别是,在编写这个装饰器的代码时,我们必须考虑到我们接收到的是一个类,而不是一个函数。

一些从业者可能会认为装饰一个类是相当复杂的,这种情况可能会危及可读性,因为我们会在类中声明一些属性和方法,但在幕后,装饰器可能会应用会使一个完全不同的类。

这个评估是正确的,但只有在这种技术被滥用的情况下。客观地说,这与装饰函数没有什么不同;毕竟,类只是 Python 生态系统中的另一种对象类型,就像函数一样。我们将在标题为装饰器和关注点分离的部分中审查这个问题的利弊,但现在我们将探讨特别适用于类的装饰器的好处:

  • 所有重用代码和 DRY 原则的好处。类装饰器的一个有效案例是强制多个类符合某个接口或标准(通过在将应用于这些多个类的装饰器中只进行一次检查)。

  • 我们可以创建更小或更简单的类,稍后可以通过装饰器进行增强。

  • 我们需要应用到某个类的转换逻辑,如果我们使用装饰器,将会更容易维护,而不是使用更复杂(通常是被不鼓励的)方法,比如元类。

在所有可能的装饰器应用中,我们将探讨一个简单的例子,以给出它们可以有用的事情的一些想法。请记住,这不是类装饰器的唯一应用类型,但我们展示的代码也可能有许多其他多种解决方案,都有各自的利弊,但我们选择了装饰器,目的是说明它们的用处。

回顾我们的监控平台的事件系统,现在我们需要为每个事件转换数据并将其发送到外部系统。然而,每种类型的事件在选择如何发送其数据时可能有其自己的特殊之处。

特别是,登录的event可能包含诸如我们想要隐藏的凭据之类的敏感信息。其他字段,比如timestamp,也可能需要一些转换,因为我们想以特定格式显示它们。满足这些要求的第一次尝试可能就像有一个映射到每个特定event的类,并且知道如何对其进行序列化:

class LoginEventSerializer:
    def __init__(self, event):
        self.event = event

    def serialize(self) -> dict:
        return {
            "username": self.event.username,
            "password": "**redacted**",
            "ip": self.event.ip,
            "timestamp": self.event.timestamp.strftime("%Y-%m-%d 
             %H:%M"),
        }

class LoginEvent:
    SERIALIZER = LoginEventSerializer

    def __init__(self, username, password, ip, timestamp):
        self.username = username
        self.password = password
        self.ip = ip
        self.timestamp = timestamp

    def serialize(self) -> dict:
        return self.SERIALIZER(self).serialize()

在这里,我们声明了一个类,它将直接与登录事件进行映射,包含了它的逻辑——隐藏password字段,并按要求格式化timestamp

虽然这种方法有效,看起来可能是一个不错的选择,但随着时间的推移,当我们想要扩展我们的系统时,我们会发现一些问题:

  • 类太多:随着事件数量的增加,序列化类的数量也会按同等数量级增长,因为它们是一对一映射的。

  • 解决方案不够灵活:如果我们需要重用组件的部分(例如,我们需要隐藏另一种类型的event中也有的password),我们将不得不将其提取到一个函数中,但也要从多个类中重复调用它,这意味着我们实际上并没有重用太多代码。

  • 样板代码serialize()方法必须存在于所有event类中,调用相同的代码。尽管我们可以将其提取到另一个类中(创建一个 mixin),但这似乎不是继承的好用法。

另一种解决方案是能够动态构造一个对象,给定一组过滤器(转换函数)和一个event实例,能够通过将这些过滤器应用于其字段来对其进行序列化。然后,我们只需要定义转换每种字段类型的函数,序列化器通过组合许多这些函数来创建。

有了这个对象后,我们可以装饰类以添加serialize()方法,它将只调用这些Serialization对象本身:


def hide_field(field) -> str:
    return "**redacted**"

def format_time(field_timestamp: datetime) -> str:
    return field_timestamp.strftime("%Y-%m-%d %H:%M")

def show_original(event_field):
    return event_field

class EventSerializer:
    def __init__(self, serialization_fields: dict) -> None:
        self.serialization_fields = serialization_fields

    def serialize(self, event) -> dict:
        return {
            field: transformation(getattr(event, field))
            for field, transformation in 
            self.serialization_fields.items()
        }

class Serialization:

    def __init__(self, **transformations):
        self.serializer = EventSerializer(transformations)

    def __call__(self, event_class):
        def serialize_method(event_instance):
            return self.serializer.serialize(event_instance)
        event_class.serialize = serialize_method
        return event_class

@Serialization(
    username=show_original,
    password=hide_field,
    ip=show_original,
    timestamp=format_time,
)
class LoginEvent:

    def __init__(self, username, password, ip, timestamp):
        self.username = username
        self.password = password
        self.ip = ip
        self.timestamp = timestamp

请注意,装饰器使用户更容易知道每个字段将如何处理,而无需查看另一个类的代码。只需阅读传递给类装饰器的参数,我们就知道username和 IP 地址将保持不变,password将被隐藏,timestamp将被格式化。

现在,类的代码不需要定义serialize()方法,也不需要扩展实现它的 mixin,因为装饰器将添加它。实际上,这可能是唯一证明创建类装饰器的部分,因为否则,Serialization对象可以是LoginEvent的类属性,但它正在通过向其添加新方法来更改类的事实使其成为不可能。

此外,我们可以有另一个类装饰器,只需定义类的属性,就可以实现init方法的逻辑,但这超出了本示例的范围。这就是诸如attrs(ATTRS 01)这样的库所做的事情,标准库中的(PEP-557)也提出了类似的功能。

通过使用 Python 3.7+中的(PEP-557)中的这个类装饰器,可以以更紧凑的方式重写先前的示例,而不需要init的样板代码,如下所示:

from dataclasses import dataclass
from datetime import datetime

@Serialization(
    username=show_original,
    password=hide_field,
    ip=show_original,
    timestamp=format_time,
)
@dataclass
class LoginEvent:
    username: str
    password: str
    ip: str
    timestamp: datetime

其他类型的装饰器

现在我们知道了装饰器的@语法实际上意味着什么,我们可以得出结论,不仅可以装饰函数、方法或类;实际上,任何可以定义的东西,例如生成器、协程,甚至已经被装饰的对象,都可以被装饰,这意味着装饰器可以被堆叠。

先前的示例展示了装饰器如何链接。我们首先定义了类,然后对其应用了@dataclass,将其转换为数据类,充当这些属性的容器。之后,@Serialization将对该类应用逻辑,从而产生一个新的类,其中添加了新的serialize()方法。

装饰器的另一个很好的用途是用于应该用作协程的生成器。我们将在第七章中探讨生成器和协程的细节,但主要思想是,在向新创建的生成器发送任何数据之前,必须通过调用next()将其推进到下一个yield语句。这是每个用户都必须记住的手动过程,因此容易出错。我们可以轻松地创建一个装饰器,它以生成器作为参数,调用next(),然后返回生成器。

将参数传递给装饰器

到目前为止,我们已经将装饰器视为 Python 中的强大工具。但是,如果我们可以向它们传递参数,使其逻辑更加抽象,它们可能会更加强大。

实现装饰器的几种方法,可以接受参数,但我们将介绍最常见的方法。第一种方法是将装饰器创建为嵌套函数,增加一个新的间接层,使装饰器中的所有内容深入一层。第二种方法是使用类作为装饰器。

一般来说,第二种方法更有利于可读性,因为以对象的方式思考比使用三个或更多个嵌套函数与闭包更容易。然而,为了完整起见,我们将探讨两种方法,读者可以决定对于手头的问题哪种方法更好。

带有嵌套函数的装饰器

大致来说,装饰器的一般思想是创建一个返回函数的函数(通常称为高阶函数)。在装饰器主体中定义的内部函数将是实际被调用的函数。

现在,如果我们希望向其传递参数,那么我们需要另一个间接层。第一个将接受参数,并在该函数内部,我们将定义一个新函数,这将是装饰器,然后将定义另一个新函数,即作为装饰过程的结果返回的函数。这意味着我们将至少有三个级别的嵌套函数。

如果到目前为止这还不清楚,不要担心。在查看即将出现的示例之后,一切都会变得清晰起来。

我们看到的第一个装饰器的示例是在一些函数上实现retry功能。这是一个好主意,但是有一个问题;我们的实现不允许我们指定重试次数,而是在装饰器内部是一个固定的数字。

现在,我们希望能够指示每个实例将具有多少次重试,也许我们甚至可以为此参数添加一个默认值。为了做到这一点,我们需要另一个级别的嵌套函数——首先是参数,然后是装饰器本身。

这是因为我们现在将有以下形式的东西:

 @retry(arg1, arg2,... )

并且必须返回一个装饰器,因为@语法将该计算的结果应用于要装饰的对象。从语义上讲,它将转换为以下内容:

  <original_function> = retry(arg1, arg2, ....)(<original_function>)

除了所需的重试次数,我们还可以指示我们希望控制的异常类型。支持新要求的代码的新版本可能如下所示:

RETRIES_LIMIT = 3

def with_retry(retries_limit=RETRIES_LIMIT, allowed_exceptions=None):
    allowed_exceptions = allowed_exceptions or (ControlledException,)

    def retry(operation):

        @wraps(operation)
        def wrapped(*args, **kwargs):
            last_raised = None
            for _ in range(retries_limit):
                try:
                    return operation(*args, **kwargs)
                except allowed_exceptions as e:
                    logger.info("retrying %s due to %s", operation, e)
                    last_raised = e
            raise last_raised

        return wrapped

    return retry

以下是如何将此装饰器应用于函数的一些示例,显示它接受的不同选项:

# decorator_parametrized_1.py
@with_retry()
def run_operation(task):
    return task.run()

@with_retry(retries_limit=5)
def run_with_custom_retries_limit(task):
    return task.run()

@with_retry(allowed_exceptions=(AttributeError,))
def run_with_custom_exceptions(task):
    return task.run()

@with_retry(
    retries_limit=4, allowed_exceptions=(ZeroDivisionError, AttributeError)
)
def run_with_custom_parameters(task):
    return task.run()

装饰器对象

前面的示例需要三个级别的嵌套函数。第一个将是一个接收我们想要使用的装饰器的参数的函数。在这个函数内部,其余的函数都是使用这些参数以及装饰器的逻辑的闭包。

更干净的实现方法是使用类来定义装饰器。在这种情况下,我们可以在__init__方法中传递参数,然后在名为__call__的魔术方法上实现装饰器的逻辑。

装饰器的代码看起来像以下示例中的样子:

class WithRetry:

    def __init__(self, retries_limit=RETRIES_LIMIT, allowed_exceptions=None):
        self.retries_limit = retries_limit
        self.allowed_exceptions = allowed_exceptions or (ControlledException,)

    def __call__(self, operation):

        @wraps(operation)
        def wrapped(*args, **kwargs):
            last_raised = None

            for _ in range(self.retries_limit):
                try:
                    return operation(*args, **kwargs)
                except self.allowed_exceptions as e:
                    logger.info("retrying %s due to %s", operation, e)
                    last_raised = e
            raise last_raised

        return wrapped

这个装饰器可以应用得和之前的一个差不多,像这样:

@WithRetry(retries_limit=5)
def run_with_custom_retries_limit(task):
    return task.run()

重要的是要注意 Python 语法在这里的作用。首先,我们创建对象,因此在应用@操作之前,对象已经被创建,并且其参数传递给它。这将创建一个新对象,并使用init方法中定义的这些参数进行初始化。之后,调用@操作,因此这个对象将包装名为run_with_custom_reries_limit的函数,这意味着它将被传递给call魔术方法。

在这个call魔术方法中,我们像往常一样定义了装饰器的逻辑-我们包装原始函数,返回一个具有我们想要的逻辑的新函数。

装饰器的好处

在本节中,我们将看一些常见的模式,这些模式充分利用了装饰器。这些都是装饰器是一个不错选择的常见情况。

从装饰器可以使用的无数应用中,我们将列举一些最常见或相关的:

  • 转换参数:更改函数的签名以公开更好的 API,同时封装有关如何处理和转换参数的细节

  • 跟踪代码:记录函数的执行及其参数

  • 验证参数

  • 实现重试操作

  • 通过将一些(重复的)逻辑移入装饰器来简化类

让我们在下一节详细讨论前两个应用。

转换参数

我们之前提到过,装饰器可以用于验证参数(甚至在 DbC 的概念下强制执行一些前置条件或后置条件),因此您可能已经得到这样的想法,即在处理或操作参数时,使用装饰器是很常见的。

特别是,在某些情况下,我们发现自己反复创建类似的对象,或者应用类似的转换,我们希望将其抽象化。大多数情况下,我们可以通过简单地使用装饰器来实现这一点。

跟踪代码

在本节讨论“跟踪”时,我们将指的是处理我们希望监视的函数的执行的更一般的内容。这可能涉及到我们希望的一些情况:

  • 实际上跟踪函数的执行(例如,通过记录它执行的行)

  • 监视函数的一些指标(如 CPU 使用率或内存占用)

  • 测量函数的运行时间

  • 记录函数调用的时间和传递给它的参数

在下一节中,我们将探讨一个简单的例子,即记录函数的执行情况,包括其名称和运行所花费的时间的装饰器。

有效的装饰器-避免常见错误

虽然装饰器是 Python 的一个很棒的特性,但如果使用不当,它们也不免有问题。在本节中,我们将看到一些常见的问题,以避免创建有效的装饰器。

保留有关原始包装对象的数据

将装饰器应用于函数时最常见的问题之一是,原始函数的某些属性或属性未得到保留,导致不希望的、难以跟踪的副作用。

为了说明这一点,我们展示了一个负责记录函数即将运行时的装饰器:

# decorator_wraps_1.py

def trace_decorator(function):
    def wrapped(*args, **kwargs):
        logger.info("running %s", function.__qualname__)
        return function(*args, **kwargs)

    return wrapped

现在,让我们想象一下,我们有一个应用了这个装饰器的函数。我们可能最初会认为该函数的任何部分都没有修改其原始定义:

@trace_decorator
def process_account(account_id):
    """Process an account by Id."""
    logger.info("processing account %s", account_id)
    ...

但也许有一些变化。

装饰器不应该改变原始函数的任何内容,但事实证明,由于它包含一个缺陷,它实际上修改了其名称和docstring等属性。

让我们尝试为这个函数获取help

>>> help(process_account)
Help on function wrapped in module decorator_wraps_1:

wrapped(*args, **kwargs) 

让我们检查它是如何被调用的:

>>> process_account.__qualname__
'trace_decorator.<locals>.wrapped'

我们可以看到,由于装饰器实际上是将原始函数更改为一个新函数(称为wrapped),我们实际上看到的是这个函数的属性,而不是原始函数的属性。

如果我们将这样一个装饰器应用于多个函数,它们都有不同的名称,它们最终都将被称为wrapped,这是一个主要问题(例如,如果我们想要记录或跟踪函数,这将使调试变得更加困难)。

另一个问题是,如果我们在这些函数上放置了带有测试的文档字符串,它们将被装饰器的文档字符串覆盖。结果,我们希望的带有测试的文档字符串在我们使用doctest模块调用我们的代码时将不会运行(正如我们在第一章中所看到的,介绍、代码格式和工具)。

修复很简单。我们只需在内部函数(wrapped)中应用wraps装饰器,告诉它实际上是在包装function

# decorator_wraps_2.py
def trace_decorator(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("running %s", function.__qualname__)
        return function(*args, **kwargs)

    return wrapped

现在,如果我们检查属性,我们将得到我们最初期望的结果。像这样检查函数的help

>>> Help on function process_account in module decorator_wraps_2:

process_account(account_id)
    Process an account by Id. 

并验证其合格的名称是否正确,如下所示:

>>> process_account.__qualname__
'process_account'

最重要的是,我们恢复了可能存在于文档字符串中的单元测试!通过使用wraps装饰器,我们还可以在__wrapped__属性下访问原始的未修改的函数。虽然不应该在生产中使用,但在一些单元测试中,当我们想要检查函数的未修改版本时,它可能会派上用场。

通常,对于简单的装饰器,我们使用functools.wraps的方式通常遵循以下一般公式或结构:

def decorator(original_function):
    @wraps(original_function)
    def decorated_function(*args, **kwargs):
        # modifications done by the decorator ...
        return original_function(*args, **kwargs)

    return decorated_function

在创建装饰器时,通常对包装的函数应用functools.wraps,如前面的公式所示。

处理装饰器中的副作用

在本节中,我们将了解在装饰器的主体中避免副作用是明智的。有些情况下可能是可以接受的,但最重要的是,如果有疑问,最好不要这样做,原因将在后面解释。

尽管如此,有时这些副作用是必需的(甚至是期望的)在导入时运行,反之亦然。

我们将看到两者的示例,以及每种情况的适用情况。如果有疑问,最好谨慎行事,并将所有副作用延迟到最后,就在wrapped函数将被调用之后。

接下来,我们将看到在wrapped函数之外放置额外逻辑不是一个好主意的情况。

装饰器中副作用的处理不正确

让我们想象一个创建目的是在函数开始运行时记录日志,然后记录其运行时间的装饰器的情况:

def traced_function_wrong(function):
    logger.info("started execution of %s", function)
    start_time = time.time()

    @functools.wraps(function)
    def wrapped(*args, **kwargs):
        result = function(*args, **kwargs)
        logger.info(
            "function %s took %.2fs",
            function,
            time.time() - start_time
        )
        return result
    return wrapped

现在,我们将装饰器应用到一个常规函数上,认为它会正常工作:

@traced_function_wrong
def process_with_delay(callback, delay=0):
    time.sleep(delay)
    return callback()

这个装饰器有一个微妙但关键的错误。

首先,让我们导入函数,多次调用它,看看会发生什么:

>>> from decorator_side_effects_1 import process_with_delay
INFO:started execution of <function process_with_delay at 0x...>

通过导入函数,我们会注意到有些地方不对劲。日志行不应该出现在那里,因为函数没有被调用。

现在,如果我们运行函数,看看运行需要多长时间?实际上,我们期望多次调用相同的函数会得到类似的结果:

>>> main()
...
INFO:function <function process_with_delay at 0x> took 8.67s

>>> main()
...
INFO:function <function process_with_delay at 0x> took 13.39s

>>> main()
...
INFO:function <function process_with_delay at 0x> took 17.01s

每次运行相同的函数,都会花费更长的时间!此时,您可能已经注意到(现在显而易见的)错误。

除了装饰的函数之外,装饰器需要做的一切都应该放在最内部的函数定义中,否则在导入时会出现问题。

process_with_delay = traced_function_wrong(process_with_delay)

这将在模块导入时运行。因此,函数中设置的时间将是模块导入时的时间。连续调用将计算从运行时间到原始开始时间的时间差。它还将在错误的时刻记录,而不是在实际调用函数时。

幸运的是,修复也很简单——我们只需将代码移到wrapped函数内部以延迟其执行:

def traced_function(function):
    @functools.wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("started execution of %s", function.__qualname__)
        start_time = time.time()
        result = function(*args, **kwargs)
        logger.info(
            "function %s took %.2fs",
            function.__qualname__,
            time.time() - start_time
        )
        return result
    return wrapped

记住装饰器的语法。@traced_function_wrong实际上意味着以下内容:

如果装饰器的操作不同,结果可能会更加灾难性。例如,如果它要求您记录事件并将其发送到外部服务,除非在导入此模块之前正确运行了配置,否则肯定会失败,而这是我们无法保证的。即使我们可以,这也是不好的做法。如果装饰器具有其他任何形式的副作用,例如从文件中读取、解析配置等,也是一样。

需要具有副作用的装饰器

有时,装饰器上的副作用是必要的,我们不应该延迟它们的执行直到最后可能的时间,因为这是它们工作所需的机制的一部分。

当我们不想延迟装饰器的副作用时,一个常见的情况是,我们需要将对象注册到一个将在模块中可用的公共注册表中。

例如,回到我们之前的event系统示例,现在我们只想在模块中使一些事件可用,而不是所有事件。在事件的层次结构中,我们可能希望有一些中间类,它们不是我们想要在系统上处理的实际事件,而是它们的一些派生类。

我们可以通过装饰器显式注册每个类,而不是根据它是否要被处理来标记每个类。

在这种情况下,我们有一个与用户活动相关的所有事件的类。然而,这只是我们实际想要的事件类型的中间表,即UserLoginEventUserLogoutEvent

EVENTS_REGISTRY = {}

def register_event(event_cls):
    """Place the class for the event into the registry to make it 
    accessible in
    the module.
    """
    EVENTS_REGISTRY[event_cls.__name__] = event_cls
    return event_cls

class Event:
    """A base event object"""

class UserEvent:
    TYPE = "user"

@register_event
class UserLoginEvent(UserEvent):
    """Represents the event of a user when it has just accessed the system."""

@register_event
class UserLogoutEvent(UserEvent):
    """Event triggered right after a user abandoned the system."""

当我们查看前面的代码时,似乎EVENTS_REGISTRY是空的,但在从这个模块导入一些内容之后,它将被填充为所有在register_event装饰器下的类。

>>> from decorator_side_effects_2 import EVENTS_REGISTRY
>>> EVENTS_REGISTRY
{'UserLoginEvent': decorator_side_effects_2.UserLoginEvent,
 'UserLogoutEvent': decorator_side_effects_2.UserLogoutEvent}

这可能看起来很难阅读,甚至具有误导性,因为EVENTS_REGISTRY将在运行时具有其最终值,就在模块导入后,我们无法仅通过查看代码来轻松预测其值。

虽然在某些情况下这种模式是合理的。事实上,许多 Web 框架或知名库使用这种模式来工作和公开对象或使它们可用。

在这种情况下,装饰器并没有改变wrapped对象,也没有以任何方式改变它的工作方式。然而,这里需要注意的是,如果我们进行一些修改并定义一个修改wrapped对象的内部函数,我们可能仍然希望在外部注册生成的对象的代码。

注意使用outside这个词。它不一定意味着之前,它只是不属于同一个闭包;但它在外部范围,因此不会延迟到运行时。

创建始终有效的装饰器

装饰器可能适用于几种不同的情况。也可能出现这样的情况,我们需要对落入这些不同多种情况的对象使用相同的装饰器,例如,如果我们想重用我们的装饰器并将其应用于函数、类、方法或静态方法。

如果我们创建装饰器,只考虑支持我们想要装饰的第一种对象类型,我们可能会注意到相同的装饰器在不同类型的对象上效果不同。典型的例子是,我们创建一个用于函数的装饰器,然后想将其应用于类的方法,结果发现它不起作用。如果我们为方法设计了装饰器,然后希望它也适用于静态方法或类方法,可能会发生类似的情况。

在设计装饰器时,我们通常考虑重用代码,因此我们也希望将该装饰器用于函数和方法。

使用*args**kwargs签名定义我们的装饰器将使它们在所有情况下都起作用,因为这是我们可以拥有的最通用的签名。然而,有时我们可能不想使用这个,而是根据原始函数的签名定义装饰器包装函数,主要是因为两个原因:

  • 它将更易读,因为它类似于原始函数。

  • 它实际上需要对参数进行一些处理,因此接收*args**kwargs将不方便。

考虑我们的代码库中有许多函数需要从参数创建特定对象的情况。例如,我们传递一个字符串,并重复使用它初始化一个驱动程序对象。然后我们认为可以通过使用一个装饰器来消除这种重复。

在下一个例子中,我们假设DBDriver是一个知道如何连接和在数据库上运行操作的对象,但它需要一个连接字符串。我们在我们的代码中有的方法,都设计为接收包含数据库信息的字符串,并且总是需要创建一个DBDriver实例。装饰器的想法是它将自动进行这种转换——函数将继续接收一个字符串,但装饰器将创建一个DBDriver并将其传递给函数,因此在内部我们可以假设我们直接接收到了我们需要的对象。

在下一个清单中展示了在函数中使用这个的例子:

import logging
from functools import wraps

logger = logging.getLogger(__name__)

class DBDriver:
    def __init__(self, dbstring):
        self.dbstring = dbstring

    def execute(self, query):
        return f"query {query} at {self.dbstring}"

def inject_db_driver(function):
    """This decorator converts the parameter by creating a ``DBDriver``
    instance from the database dsn string.
    """
    @wraps(function)
    def wrapped(dbstring):
        return function(DBDriver(dbstring))
    return wrapped

@inject_db_driver
def run_query(driver):
    return driver.execute("test_function")

很容易验证,如果我们将一个字符串传递给函数,我们会得到一个DBDriver实例完成的结果,所以装饰器的工作是符合预期的:

>>> run_query("test_OK")
'query test_function at test_OK'

但现在,我们想在类方法中重用这个相同的装饰器,我们发现了同样的问题:

class DataHandler:
    @inject_db_driver
    def run_query(self, driver):
        return driver.execute(self.__class__.__name__)

我们尝试使用这个装饰器,只是意识到它不起作用:

>>> DataHandler().run_query("test_fails")
Traceback (most recent call last):
 ...
TypeError: wrapped() takes 1 positional argument but 2 were given

问题是什么?

类中的方法是用额外的参数self定义的。

方法只是一种特殊类型的函数,它接收self(它们所定义的对象)作为第一个参数。

因此,在这种情况下,装饰器(设计为仅适用于名为dbstring的参数)将解释self是所说的参数,并调用该方法传递字符串作为 self 的位置,以及在第二个参数的位置上什么都不传,即我们正在传递的字符串。

为了解决这个问题,我们需要创建一个装饰器,它可以同时适用于方法和函数,我们通过将其定义为一个装饰器对象来实现这一点,该对象还实现了协议描述符。

描述符在第七章中有详细解释,使用生成器,所以,现在,我们可以将其视为一个可以使装饰器工作的配方。

解决方案是将装饰器实现为一个类对象,并使该对象成为一个描述符,通过实现__get__方法。

from functools import wraps
from types import MethodType

class inject_db_driver:
    """Convert a string to a DBDriver instance and pass this to the 
       wrapped function."""

    def __init__(self, function):
        self.function = function
        wraps(self.function)(self)

    def __call__(self, dbstring):
        return self.function(DBDriver(dbstring))

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.__class__(MethodType(self.function, instance))

描述符的详细信息将在第六章中解释,使用描述符更充分地利用我们的对象,但是对于这个例子的目的,我们现在可以说它实际上是将它装饰的可调用对象重新绑定到一个方法,这意味着它将函数绑定到对象,然后使用这个新的可调用对象重新创建装饰器。

对于函数,它仍然有效,因为它根本不会调用__get__方法。

装饰器与 DRY 原则

我们已经看到装饰器如何允许我们将某些逻辑抽象成一个单独的组件。这样做的主要优势是我们可以多次应用装饰器到不同的对象中,以便重用代码。这遵循了不要重复自己DRY)原则,因为我们只定义了某些知识一次。

在前面的部分中实现的“重试”机制是一个很好的例子,它是一个可以多次应用以重用代码的装饰器。我们不是让每个特定的函数包含其“重试”逻辑,而是创建一个装饰器并多次应用它。一旦我们确保装饰器可以同样适用于方法和函数,这就是有意义的。

定义了事件如何表示的类装饰器也符合 DRY 原则,因为它定义了一个特定的位置来序列化事件的逻辑,而无需在不同的类中重复代码。由于我们希望重用这个装饰器并将其应用于许多类,它的开发(和复杂性)是值得的。

当尝试使用装饰器来重用代码时,这最后一点很重要——我们必须绝对确定我们实际上将节省代码。

任何装饰器(特别是如果设计不慎)都会给代码增加另一层间接性,因此会增加更多的复杂性。代码的读者可能希望跟踪装饰器的路径以充分理解函数的逻辑(尽管这些考虑在下一节中有所解决),因此请记住这种复杂性必须得到回报。如果不会有太多的重用,那么不要选择装饰器,而选择一个更简单的选项(也许只是一个单独的函数或另一个小类就足够了)。

但我们如何知道太多的重用是什么?有没有规则来确定何时将现有代码重构为装饰器?在 Python 中,没有特定于装饰器的规则,但我们可以应用软件工程中的一个经验法则(GLASS 01),该法则规定在考虑创建可重用组件之前,应该至少尝试三次使用组件。从同一参考资料(GLASS 01)中还得出了一个观点,即创建可重用组件比创建简单组件困难三倍。

底线是,通过装饰器重用代码是可以接受的,但只有在考虑以下几点时才可以:

  • 不要从头开始创建装饰器。等到模式出现并且装饰器的抽象变得清晰时再进行重构。

  • 考虑到装饰器必须被应用多次(至少三次)才能实施。

  • 将装饰器中的代码保持在最小限度。

装饰器和关注点分离

前面列表中的最后一点非常重要,值得单独一节来讨论。我们已经探讨了重用代码的想法,并注意到重用代码的一个关键元素是具有内聚性的组件。这意味着它们应该具有最小的责任水平——只做一件事,只做一件事,并且做得很好。我们的组件越小,就越可重用,也越能在不同的上下文中应用,而不会带有额外的行为,这将导致耦合和依赖,使软件变得僵化。

为了向您展示这意味着什么,让我们回顾一下我们在先前示例中使用的装饰器之一。我们创建了一个装饰器,用类似以下代码的方式跟踪了某些函数的执行:

def traced_function(function):
    @functools.wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("started execution of %s", function.__qualname__)
        start_time = time.time()
        result = function(*args, **kwargs)
        logger.info(
            "function %s took %.2fs",
            function.__qualname__,
            time.time() - start_time
        )
        return result
    return wrapped

现在,这个装饰器虽然有效,但存在一个问题——它做了不止一件事。它记录了特定函数的调用,并记录了运行所花费的时间。每次使用这个装饰器,我们都要承担这两个责任,即使我们只想要其中一个。

这应该被分解成更小的装饰器,每个装饰器都有更具体和有限的责任:

def log_execution(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        logger.info("started execution of %s", function.__qualname__)
        return function(*kwargs, **kwargs)
    return wrapped

def measure_time(function):
 @wraps(function)
 def wrapped(*args, **kwargs):
 start_time = time.time()
 result = function(*args, **kwargs)

 logger.info("function %s took %.2f", function.__qualname__,
 time.time() - start_time)
 return result
 return wrapped

请注意,我们之前所拥有的相同功能可以通过简单地将它们结合起来来实现:

@measure_time
@log_execution
def operation():
    ....

注意装饰器的应用顺序也很重要。

不要在一个装饰器中放置多个责任。单一责任原则也适用于装饰器。

分析好的装饰器

作为本章的结束语,让我们回顾一些好的装饰器的示例以及它们在 Python 本身以及流行库中的用法。这个想法是获得如何创建好的装饰器的指导方针。

在跳入示例之前,让我们首先确定好的装饰器应该具有的特征:

  • 封装,或关注点分离:一个好的装饰器应该有效地将它所做的事情和它所装饰的事物之间的不同责任分开。它不能是一个有漏洞的抽象,这意味着装饰器的客户端应该只以黑盒模式调用它,而不知道它实际上是如何实现其逻辑的。

  • 正交性:装饰器所做的事情应该是独立的,并且尽可能与它所装饰的对象解耦。

  • 可重用性:希望装饰器可以应用于多种类型,而不仅仅出现在一个函数的一个实例上,因为这意味着它本来可以只是一个函数。它必须足够通用。

装饰器的一个很好的例子可以在 Celery 项目中找到,其中通过将应用程序的task装饰器应用到一个函数来定义task

@app.task
def mytask():
   ....

这是一个好的装饰器的原因之一是因为它在封装方面非常出色。库的用户只需要定义函数体,装饰器就会自动将其转换为一个任务。"@app.task"装饰器肯定包含了大量的逻辑和代码,但这些对"mytask()"的主体来说都不相关。这是完全的封装和关注点分离——没有人需要查看装饰器在做什么,因此它是一个不泄漏任何细节的正确抽象。

装饰器的另一个常见用法是在 Web 框架(例如 Pyramid,Flask 和 Sanic 等)中,通过装饰器将视图的处理程序注册到 URL:

@route("/", method=["GET"])
def view_handler(request):
 ...

这些类型的装饰器与之前的考虑相同;它们也提供了完全的封装,因为 Web 框架的用户很少(如果有的话)需要知道"@route"装饰器在做什么。在这种情况下,我们知道装饰器正在做更多的事情,比如将这些函数注册到 URL 的映射器上,并且它还改变了原始函数的签名,以便为我们提供一个更好的接口,接收一个已经设置好所有信息的请求对象。

前面的两个例子足以让我们注意到关于装饰器的这种用法的另一点。它们符合 API。这些库或框架通过装饰器向用户公开其功能,结果表明装饰器是定义清晰的编程接口的绝佳方式。

这可能是我们应该考虑装饰器的最佳方式。就像在告诉我们事件属性将如何被处理的类装饰器的示例中一样,一个好的装饰器应该提供一个清晰的接口,以便代码的用户知道可以从装饰器中期望什么,而不需要知道它是如何工作的,或者它的任何细节。

总结

装饰器是 Python 中强大的工具,可以应用于许多事物,如类、方法、函数、生成器等。我们已经演示了如何以不同的方式创建装饰器,以及不同的目的,并在这个过程中得出了一些结论。

在为函数创建装饰器时,尝试使其签名与被装饰的原始函数匹配。与使用通用的*args**kwargs不同,使签名与原始函数匹配将使其更容易阅读和维护,并且它将更接近原始函数,因此对于代码的读者来说更加熟悉。

装饰器是重用代码和遵循 DRY 原则的非常有用的工具。然而,它们的有用性是有代价的,如果不明智地使用,复杂性可能会带来更多的害处。因此,我们强调装饰器应该在实际上会被多次应用(三次或更多次)时使用。与 DRY 原则一样,我们发现关注点分离的想法,目标是尽可能保持装饰器的小巧。

另一个很好的装饰器用法是创建更清晰的接口,例如,通过将类的一部分逻辑提取到装饰器中来简化类的定义。在这个意义上,装饰器还通过提供关于特定组件将要做什么的信息来帮助可读性,而不需要知道如何做(封装)。

在下一章中,我们将看看 Python 的另一个高级特性——描述符。特别是,我们将看到如何借助描述符创建更好的装饰器,并解决本章遇到的一些问题。

参考资料

以下是您可以参考的信息列表:

第六章:通过描述符更充分地利用我们的对象

本章介绍了一个在 Python 开发中更为高级的新概念,因为它涉及到描述符。此外,描述符并不是其他语言的程序员熟悉的东西,因此没有简单的类比或类似之处。

描述符是 Python 的另一个独特特性,它将面向对象编程提升到另一个水平,其潜力允许用户构建更强大和可重用的抽象。大多数情况下,描述符的全部潜力都体现在库或框架中。

在本章中,我们将实现与描述符相关的以下目标:

  • 了解描述符是什么,它们是如何工作的,以及如何有效地实现它们

  • 分析两种类型的描述符(数据和非数据描述符),从它们的概念差异和实现细节方面进行分析

  • 通过描述符有效地重用代码

  • 分析描述符的良好使用示例,以及如何利用它们来构建我们自己的 API 库

描述符的初步了解

首先,我们将探索描述符背后的主要思想,以了解它们的机制和内部工作。一旦这一点清楚,就会更容易吸收不同类型的描述符是如何工作的,我们将在下一节中探讨。

一旦我们对描述符背后的思想有了初步了解,我们将看一个示例,其中它们的使用为我们提供了更清晰和更符合 Python 风格的实现。

描述符背后的机制

描述符的工作方式并不是很复杂,但它们的问题在于有很多需要考虑的注意事项,因此这里的实现细节至关重要。

为了实现描述符,我们至少需要两个类。对于这个通用示例,我们将称client类为将要利用我们想要在descriptor中实现的功能的类(这个类通常只是一个领域模型类,是我们解决方案中创建的常规抽象),我们将称descriptor类为实现描述符逻辑的类。

因此,描述符只是一个实现描述符协议的类的实例对象。这意味着这个类必须包含至少一个以下魔术方法(作为 Python 3.6+的描述符协议的一部分)的接口:

  • __get__

  • __set__

  • __delete__

  • __set_name__

为了这个最初的高层次介绍,将使用以下命名约定:

名称 含义
ClientClass 将利用要由描述符实现的功能的领域级抽象。这个类被称为描述符的客户端。这个类包含一个类属性(按照惯例命名为descriptor),它是DescriptorClass的一个实例。
DescriptorClass 实现descriptor本身的类。这个类应该实现一些前面提到的涉及描述符协议的魔术方法。
client ClientClass的一个实例。client = ClientClass()
descriptor DescriptorClass的一个实例。descriptor = DescriptorClass()。这个对象是一个放置在ClientClass中的类属性。

这种关系在下图中得到了说明:

要牢记的一个非常重要的观察是,为了使这个协议工作,descriptor对象必须被定义为一个类属性。将这个对象创建为一个实例属性是行不通的,因此它必须在类的主体中,而不是在init方法中。

始终将descriptor对象放置为一个类属性!

稍微批评一下,读者还可以注意到,部分实现描述符协议是可能的——并非所有方法总是必须被定义;相反,我们可以只实现我们需要的方法,我们很快将看到。

现在,我们已经有了结构——我们知道设置了哪些元素以及它们如何交互。我们需要一个用于descriptor的类,另一个将使用descriptor逻辑的类,这个类将作为类属性具有descriptor对象(DescriptorClass的实例),以及在调用名为descriptor的属性时将遵循描述符协议的ClientClass的实例。但现在呢?所有这些在运行时如何组合在一起?

通常,当我们有一个常规类并访问其属性时,我们会按预期获得对象,甚至它们的属性,如下例所示:

>>> class Attribute:
...     value = 42
... 
>>> class Client:
...     attribute = Attribute()
... 
>>> Client().attribute
<__main__.Attribute object at 0x7ff37ea90940>
>>> Client().attribute.value
42

但是,在描述符的情况下,情况有所不同。当一个对象被定义为类属性(并且这是一个descriptor)时,当一个client请求此属性时,我们不是得到对象本身(正如我们从前面的例子中所期望的那样),而是得到了调用__get__魔术方法的结果。

让我们从一些仅记录有关上下文的信息并返回相同的client对象的简单代码开始:

class DescriptorClass:
    def __get__(self, instance, owner):
        if instance is None:
            return self
        logger.info("Call: %s.__get__(%r, %r)", 
        self.__class__.__name__,instance, owner)
        return instance

class ClientClass:
    descriptor = DescriptorClass()

当运行此代码并请求ClientClass实例的descriptor属性时,我们将发现实际上并没有得到DescriptorClass的实例,而是得到了其__get__()方法返回的内容:

>>> client = ClientClass()
>>> client.descriptor
INFO:Call: DescriptorClass.__get__(<ClientClass object at 0x...>, <class 'ClientClass'>)
<ClientClass object at 0x...>
>>> client.descriptor is client
INFO:Call: DescriptorClass.__get__(ClientClass object at 0x...>, <class 'ClientClass'>)
True

请注意,放置在__get__方法下面的日志行被调用,而不是只返回我们创建的对象。在这种情况下,我们让该方法返回client本身,从而使最后一条语句的比较成立。在我们更详细地探讨每个方法时,将更详细地解释此方法的参数。

从这个简单但有示例性的例子开始,我们可以开始创建更复杂的抽象和更好的装饰器,因为这里的重要说明是我们有了一个新的(强大的)工具来使用。请注意,这如何以完全不同的方式改变了程序的控制流。有了这个工具,我们可以在__get__方法背后抽象出各种逻辑,并使descriptor在客户端甚至察觉不到的情况下运行各种转换。这将封装提升到一个新的水平。

探索描述符协议的每个方法

到目前为止,我们已经看到了很多描述符在实际中的例子,并且了解了它们的工作原理。这些例子让我们初步了解了描述符的强大之处,但您可能想知道一些我们未能解释的实现细节和习惯用法。

由于描述符只是对象,这些方法将self作为第一个参数。对于所有这些方法,这只是指descriptor对象本身。

在本节中,我们将详细探讨描述符协议的每个方法,解释每个参数的意义,以及它们的预期用法。

get(self, instance, owner)

第一个参数instance指的是调用descriptor的对象。在我们的第一个例子中,这意味着client对象。

owner参数是指对象的类,根据我们的例子(来自描述符背后的机制部分的上一个类图),将是ClientClass

从前面的段落中我们得出结论,__get__签名中名为instance的参数是描述符正在操作的对象,而ownerinstance的类。热心的读者可能会想知道为什么签名会这样定义,毕竟类可以直接从instance中获取(owner = instance.__class__)。这里有一个特殊情况——当从类(ClientClass)而不是从实例(client)中调用descriptor时,instance的值是None,但在这种情况下我们可能仍然想要进行一些处理。

通过以下简单的代码,我们可以演示当描述符从类或实例中被调用时的区别。在这种情况下,__get__方法对每种情况都做了两件不同的事情。

# descriptors_methods_1.py

class DescriptorClass:
    def __get__(self, instance, owner):
        if instance is None:
            return f"{self.__class__.__name__}.{owner.__name__}"
        return f"value for {instance}"

class ClientClass:

    descriptor = DescriptorClass()

当我们直接从ClientClass中调用它时,它会做一件事,即用类的名称组成一个命名空间:

>>> ClientClass.descriptor
'DescriptorClass.ClientClass'

然后,如果我们从创建的对象中调用它,它将返回另一条消息:

>>> ClientClass().descriptor
'value for <descriptors_methods_1.ClientClass object at 0x...>'

一般来说,除非我们真的需要使用owner参数做一些事情,最常见的习惯是当instanceNone时,只返回描述符本身。

set(self, instance, value)

当我们尝试给descriptor赋值时,就会调用这个方法。它会被以下语句激活,其中descriptor是一个实现了__set__()的对象。在这种情况下,instance参数将是client,而value将是字符串"value"

client.descriptor = "value"

如果client.descriptor没有实现__set__(),那么"value"将完全覆盖descriptor

在给描述符属性赋值时要小心。确保它实现了__set__方法,并且我们没有引起不希望的副作用。

默认情况下,这个方法最常见的用途就是在对象中存储数据。然而,到目前为止我们已经看到了描述符的强大之处,我们可以利用它们,例如,如果我们要创建可以多次应用的通用验证对象(再次强调,如果我们不进行抽象,可能会在属性的 setter 方法中重复多次)。

以下清单说明了我们如何利用这个方法来为属性创建通用的validation对象,可以使用函数动态创建用于在分配给对象之前验证值的对象:

class Validation:

    def __init__(self, validation_function, error_msg: str):
        self.validation_function = validation_function
        self.error_msg = error_msg

    def __call__(self, value):
        if not self.validation_function(value):
            raise ValueError(f"{value!r} {self.error_msg}")

class Field:

    def __init__(self, *validations):
        self._name = None
        self.validations = validations

    def __set_name__(self, owner, name):
        self._name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self._name]

    def validate(self, value):
        for validation in self.validations:
            validation(value)

    def __set__(self, instance, value):
        self.validate(value)
        instance.__dict__[self._name] = value

class ClientClass:
    descriptor = Field(
        Validation(lambda x: isinstance(x, (int, float)), "is not a 
        number"),
        Validation(lambda x: x >= 0, "is not >= 0"),
    )

我们可以在以下清单中看到这个对象的作用:

>>> client = ClientClass()
>>> client.descriptor = 42
>>> client.descriptor
42
>>> client.descriptor = -42
Traceback (most recent call last):
 ...
ValueError: -42 is not >= 0
>>> client.descriptor = "invalid value"
...
ValueError: 'invalid value' is not a number

这个想法是,我们通常会将属性放在属性中的东西抽象成一个descriptor,并且可以多次重用它。在这种情况下,__set__()方法将会做@property.setter本来会做的事情。

delete(self, instance)

在以下语句中调用这个方法时,self将是descriptor属性,instance将是这个例子中的client对象:

>>> del client.descriptor

在下面的例子中,我们使用这个方法来创建一个descriptor,目的是防止用户在没有必要的管理权限的情况下删除对象的属性。请注意,在这种情况下,descriptor具有用于预测使用它的对象的值的逻辑,而不是不同相关对象的逻辑:

# descriptors_methods_3.py

class ProtectedAttribute:
    def __init__(self, requires_role=None) -> None: 
        self.permission_required = requires_role
        self._name = None

    def __set_name__(self, owner, name):
        self._name = name

    def __set__(self, user, value):
        if value is None:
 raise ValueError(f"{self._name} can't be set to None")
        user.__dict__[self._name] = value

    def __delete__(self, user):
        if self.permission_required in user.permissions:
            user.__dict__[self._name] = None
        else:
            raise ValueError(
                f"User {user!s} doesn't have {self.permission_required} "
                "permission"
            )

class User:
    """Only users with "admin" privileges can remove their email address."""

    email = ProtectedAttribute(requires_role="admin")

    def __init__(self, username: str, email: str, permission_list: list = None) -> None:
        self.username = username
        self.email = email
        self.permissions = permission_list or []

    def __str__(self):
        return self.username

在看到这个对象如何工作的例子之前,重要的是要注意这个描述符的一些标准。注意User类要求usernameemail作为强制参数。根据其**__init__**方法,如果没有email属性,它就不能成为用户。如果我们要删除该属性,并从对象中完全提取它,我们将创建一个不一致的对象,其中包含一些无效的中间状态,这些状态与User类定义的接口不符。像这样的细节非常重要,以避免问题。其他对象期望与这个User一起工作,并且也期望它有一个email属性。

因此,决定“删除”电子邮件只会将其简单地设置为None,这是代码清单中加粗部分的一部分。出于同样的原因,我们必须禁止有人尝试将None值设置为它,因为那样会绕过我们放置在**__delete__**方法中的机制。

在这里,我们可以看到它的作用,假设只有具有"admin"权限的用户才能删除他们的电子邮件地址:

>>> admin = User("root", "root@d.com", ["admin"])
>>> user = User("user", "user1@d.com", ["email", "helpdesk"]) 
>>> admin.email
'root@d.com'
>>> del admin.email
>>> admin.email is None
True
>>> user.email
'user1@d.com'
>>> user.email = None
...
ValueError: email can't be set to None
>>> del user.email
...
ValueError: User user doesn't have admin permission

在这个简单的descriptor中,我们可以看到只有包含"admin"权限的用户才能删除用户的电子邮件。至于其他情况,当我们尝试在该属性上调用del时,我们将得到一个ValueError异常。

一般来说,描述符的这种方法并不像前两种方法那样常用,但是出于完整性的考虑,还是值得展示一下。

set_name(self, owner, name)

当我们在将要使用它的类中创建descriptor对象时,通常需要descriptor知道它将要处理的属性的名称。

这个属性名称是我们在__get____set__方法中从__dict__中读取和写入的名称。

在 Python 3.6 之前,描述符无法自动获取这个名称,因此最常见的方法是在初始化对象时显式传递它。这样做没问题,但有一个问题,就是每次我们想要为新属性使用描述符时,都需要重复名称。

如果没有这个方法,典型的descriptor将如下所示:

class DescriptorWithName:
    def __init__(self, name):
        self.name = name

    def __get__(self, instance, value):
        if instance is None:
            return self
        logger.info("getting %r attribute from %r", self.name, instance)
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        instance.__dict__[self.name] = value

class ClientClass:
    descriptor = DescriptorWithName("descriptor")

我们可以看到descriptor如何使用这个值:

>>> client = ClientClass()
>>> client.descriptor = "value"
>>> client.descriptor
INFO:getting 'descriptor' attribute from <ClientClass object at 0x...>
'value'

现在,如果我们想要避免两次写入属性名称(一次是在类内部分配的变量,一次是作为描述符的第一个参数的名称),我们必须求助于一些技巧,比如使用类装饰器,或者(更糟糕的是)使用元类。

在 Python 3.6 中,添加了新的方法__set_name__,它接收正在创建该描述符的类和正在赋予该描述符的名称。最常见的习惯用法是使用这种方法来存储所需的名称。

为了兼容性,通常最好在__init__方法中保留默认值,但仍然利用__set_name__

有了这个方法,我们可以将前面的描述符重写如下:

class DescriptorWithName:
    def __init__(self, name=None):
        self.name = name

    def __set_name__(self, owner, name):
        self.name = name
    ...

描述符的类型

根据我们刚刚探讨的方法,我们可以在描述符的工作方式方面进行重要的区分。了解这种区别在有效地使用描述符方面起着重要作用,并且还有助于避免运行时的注意事项或常见错误。

如果描述符实现了__set____delete__方法,则称为数据描述符。否则,仅实现__get__的描述符是非数据描述符。请注意,__set_name__对这一分类没有影响。

在尝试解析对象的属性时,数据描述符将始终优先于对象的字典,而非数据描述符则不会。这意味着在非数据描述符中,如果对象的字典中有与描述符相同名称的键,将始终调用该键,并且描述符本身永远不会运行。相反,在数据描述符中,即使字典中有与描述符相同名称的键,也永远不会使用该键,因为描述符本身总是会被调用。

接下来的两个部分将通过示例更详细地解释这一点,以便更深入地了解每种类型的描述符可以期望得到什么。

非数据描述符

我们将从仅实现__get__方法的descriptor开始,看看它是如何使用的:

class NonDataDescriptor:
    def __get__(self, instance, owner):
        if instance is None:
            return self
        return 42

class ClientClass:
    descriptor = NonDataDescriptor()

像往常一样,如果我们请求descriptor,我们将得到其__get__方法的结果:

>>> client = ClientClass()
>>> client.descriptor
42

但是,如果我们将descriptor属性更改为其他值,我们将失去对该值的访问,并获得分配给它的值:

>>> client.descriptor = 43
>>> client.descriptor
43

现在,如果我们删除descriptor,然后再次请求它,让我们看看我们得到什么:

>>> del client.descriptor
>>> client.descriptor
42

让我们回顾一下刚刚发生的事情。当我们首次创建client对象时,descriptor属性位于类中,而不是实例中,因此如果我们要求client对象的字典,它将是空的:

>>> vars(client)
{}

然后,当我们请求.descriptor属性时,在client.__dict__中找不到任何名为"descriptor"的键,因此它转到类中,在那里找到它……但只是作为描述符,因此返回__get__方法的结果。

但是,我们将.descriptor属性的值更改为其他值,这样做的效果是将其设置到instance的字典中,这意味着这次它不会是空的:

>>> client.descriptor = 99
>>> vars(client)
{'descriptor': 99}

因此,当我们在这里请求.descriptor属性时,它将在对象中查找它(这次会找到,因为__dict__属性中有一个名为descriptor的键,正如vars结果所显示的),并返回它,而无需在类中查找。因此,从未调用描述符协议,下次我们请求此属性时,它将返回我们已经覆盖的值(99)。

之后,我们通过调用del删除此属性,这样做的效果是从对象的字典中删除键`"descriptor",使我们回到第一个场景,它将默认到描述符协议将被激活的类中:

>>> del client.descriptor
>>> vars(client)
{}
>>> client.descriptor
42

这意味着如果我们将descriptor的属性设置为其他值,我们可能会意外地破坏它。为什么?因为descriptor不处理删除操作(有些不需要)。

这被称为非数据描述符,因为它没有实现__set__魔术方法,正如我们将在下一个示例中看到的那样。

数据描述符

现在,让我们看看使用数据描述符的区别。为此,我们将创建另一个实现__set__方法的简单descriptor

class DataDescriptor:

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return 42

    def __set__(self, instance, value):
        logger.debug("setting %s.descriptor to %s", instance, value)
        instance.__dict__["descriptor"] = value

class ClientClass:
    descriptor = DataDescriptor()

让我们看看descriptor的值返回的是什么:

>>> client = ClientClass()
>>> client.descriptor
42

现在,让我们尝试将此值更改为其他值,看看它返回的是什么:

>>> client.descriptor = 99
>>> client.descriptor
42

descriptor返回的值没有改变。但是当我们为其分配不同的值时,它必须设置为对象的字典(就像以前一样):

>>> vars(client)
{'descriptor': 99}

>>> client.__dict__["descriptor"]
99

因此,__set__()方法被调用,确实将值设置到了对象的字典中,但是这次,当我们请求此属性时,不再使用字典的__dict__属性,而是使用descriptor(因为它是覆盖的descriptor)。

还有一件事——删除属性将不再起作用:

>>> del client.descriptor
Traceback (most recent call last):
 ...
AttributeError: __delete__

原因如下——现在,descriptor总是生效,调用del删除对象的属性时,不会尝试从对象的字典(__dict__)中删除属性,而是尝试调用descriptor__delete__()方法(在这个例子中没有实现,因此会出现属性错误)。

这是数据和非数据描述符之间的区别。如果描述符实现了__set__(),那么它将始终优先,无论对象的字典中存在什么属性。如果这个方法没有被实现,那么首先会查找字典,然后再运行描述符。

你可能已经注意到set方法中的这行代码是一个有趣的观察:

instance.__dict__["descriptor"] = value

关于这行代码有很多问题,但让我们分解成几部分。

首先,为什么只改变"descriptor"属性的名称?这只是一个简化的例子,但是,当使用描述符时,它在这一点上并不知道它被分配的参数的名称,所以我们只是使用了例子中的一个,知道它将是"descriptor"

在一个真实的例子中,你可以做两件事中的一件——要么接收名称作为参数并在init方法中内部存储它,这样这个方法将只使用内部属性,或者更好的是使用__set_name__方法。

为什么直接访问实例的__dict__属性?另一个很好的问题,至少有两种解释。首先,你可能会想为什么不直接这样做:

setattr(instance, "descriptor", value)

记住,当我们尝试给一个descriptor属性赋值时,会调用这个方法(__set__)。所以,使用setattr()会再次调用这个descriptor,然后再次调用,依此类推。这将导致无限递归。

不要在__set__方法内部直接使用setattr()或赋值表达式来操作描述符,因为这将触发无限递归。

那么,为什么描述符不能记录所有对象的属性值?

client类已经引用了描述符。如果我们从描述符到client对象创建一个引用,我们就会创建循环依赖关系,这些对象将永远不会被垃圾回收。因为它们相互指向,它们的引用计数永远不会降到移除的阈值以下。

这里的一个可能的替代方案是使用弱引用,使用weakref模块,并且如果我们想要这样做,创建一个弱引用键字典。这个实现在本章后面有解释,但对于本书中的实现,我们更倾向于使用这种习惯用法,因为在编写描述符时它是相当常见和被接受的。

描述符的运作

现在我们已经看到了描述符是什么,它们是如何工作的,以及它们背后的主要思想是什么,我们可以看到它们在实际中的运作。在这一部分,我们将探讨一些可以通过描述符优雅地解决的情况。

在这里,我们将看一些使用描述符的例子,并且我们也将涵盖它们的实现考虑因素(创建它们的不同方式,以及它们的优缺点),最后我们将讨论描述符最适合的场景是什么。

描述符的应用

我们将从一个简单的可以工作的例子开始,但这将导致一些代码重复。不太清楚这个问题将如何解决。之后,我们将想出一种将重复逻辑抽象成描述符的方法,这将解决重复问题,我们将注意到我们的客户类上的代码将大大减少。

首次尝试不使用描述符

我们现在要解决的问题是,我们有一个普通的类,有一些属性,但我们希望跟踪特定属性随时间变化的所有不同值,例如,以列表的形式。我们脑海中首先想到的解决方案是使用属性,每当在属性的 setter 方法中更改值时,我们将其添加到一个内部列表中,以便保持所需的痕迹。

假设我们的类代表应用程序中的一个旅行者,他有一个当前城市,我们希望在程序运行期间跟踪用户访问过的所有城市。以下代码是一个可能的实现,满足这些要求:

class Traveller:

    def __init__(self, name, current_city):
        self.name = name
        self._current_city = current_city
        self._cities_visited = [current_city]

    @property
    def current_city(self):
        return self._current_city

    @current_city.setter
    def current_city(self, new_city):
        if new_city != self._current_city:
            self._cities_visited.append(new_city)
        self._current_city = new_city

    @property
    def cities_visited(self):
        return self._cities_visited

我们可以轻松地检查这段代码是否符合我们的要求:

>>> alice = Traveller("Alice", "Barcelona")
>>> alice.current_city = "Paris"
>>> alice.current_city = "Brussels"
>>> alice.current_city = "Amsterdam"

>>> alice.cities_visited
['Barcelona', 'Paris', 'Brussels', 'Amsterdam']

到目前为止,这就是我们需要的一切,不需要实现其他内容。对于这个问题来说,属性已经足够了。如果我们需要在应用程序的多个地方使用完全相同的逻辑会发生什么?这意味着这实际上是一个更通用问题的实例——在另一个属性中跟踪所有值。如果我们想对其他属性执行相同的操作,比如跟踪爱丽丝购买的所有票或她去过的所有国家,会发生什么?我们将不得不在所有这些地方重复逻辑。

此外,如果我们需要在不同的类中具有相同的行为,会发生什么?我们将不得不重复代码或提出一个通用解决方案(也许是装饰器、属性构建器或描述符)。由于属性构建器是描述符的一个特殊(更加复杂)的情况,它超出了本书的范围,因此建议使用描述符作为更清晰的处理方式。

惯用的实现

现在,我们将看看如何通过使用一个通用的描述符来解决上一节的问题。再次强调,这个例子实际上并不需要,因为要求并没有指定这种通用行为(我们甚至没有遵循之前创建抽象的相似模式的三个实例的规则),但它展示了描述符的作用目标。

除非有实际证据表明我们要解决的重复问题,且复杂性已经证明是值得的,否则不要实现描述符。

现在,我们将创建一个通用的描述符,给定一个属性名称来保存另一个属性的痕迹,将会把属性的不同值存储在一个列表中。

正如我们之前提到的,代码超出了问题的需求,但其意图只是展示描述符如何帮助我们解决问题。由于描述符的通用性,读者会注意到它的逻辑(方法的名称和属性)与手头的领域问题(旅行者对象)无关。这是因为描述符的理念是能够在任何类型的类中使用它,可能是在不同的项目中,产生相同的结果。

为了解决这个问题,代码的一些部分被注释,并且对每个部分的相应解释(它的作用以及它如何与原始问题相关)在下面的代码中描述。

class HistoryTracedAttribute:
    def __init__(self, trace_attribute_name) -> None:
        self.trace_attribute_name = trace_attribute_name  # [1]
        self._name = None

    def __set_name__(self, owner, name):
        self._name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self._name]

    def __set__(self, instance, value):
        self._track_change_in_value_for_instance(instance, value)
        instance.__dict__[self._name] = value

    def _track_change_in_value_for_instance(self, instance, value):
        self._set_default(instance)   # [2]
        if self._needs_to_track_change(instance, value):
            instance.__dict__[self.trace_attribute_name].append(value)

    def _needs_to_track_change(self, instance, value) -> bool:
        try:
            current_value = instance.__dict__[self._name]
        except KeyError:   # [3]
            return True
        return value != current_value  # [4]

    def _set_default(self, instance):
        instance.__dict__.setdefault(self.trace_attribute_name, [])  # [6]

class Traveller:

    current_city = HistoryTracedAttribute("cities_visited")  # [1]

    def __init__(self, name, current_city):
        self.name = name
        self.current_city = current_city  # [5]

对代码的一些注解和评论如下(列表中的数字对应前面清单中的注解编号):

  1. 属性的名称是分配给descriptor的变量之一,在这种情况下是current_city。我们将变量的名称传递给descriptor,它将存储descriptor的变量的痕迹。在这个例子中,我们告诉我们的对象跟踪current_city在名为cities_visited的属性中的所有值。

  2. 第一次调用descriptor时,在init中,用于跟踪值的属性将不存在,这种情况下,我们将其初始化为空列表,以便稍后向其添加值。

  3. init方法中,属性current_city的名称也不存在,所以我们也想跟踪这个变化。这相当于在前面的例子中用第一个值初始化列表。

  4. 只有在新值与当前设置的值不同时才跟踪更改。

  5. init方法中,descriptor已经存在,这个赋值指令会触发第 2 步(创建空列表以开始跟踪其值)和第 3 步(将值附加到此列表,并将其设置为对象中的键以便以后检索)的操作。

  6. 字典中的setdefault方法用于避免KeyError。在这种情况下,对于尚不可用的属性,将返回一个空列表(参见docs.python.org/3.6/library/stdtypes.html#dict.setdefault以供参考)。

descriptor中的代码确实相当复杂。另一方面,client类中的代码要简单得多。当然,只有在我们要多次使用这个descriptor时才能实现这种平衡,这是我们已经讨论过的问题。

在这一点上可能不太清楚的是,描述符确实完全独立于client类。它没有任何关于业务逻辑的暗示。这使得它完全适用于任何其他类;即使它执行完全不同的操作,描述符也会产生相同的效果。

这才是描述符真正的 Python 特性。它们更适合于定义库、框架或内部 API,而不太适合业务逻辑。

实现描述符的不同形式

在考虑实现描述符的方法之前,我们必须首先了解描述符特有的一个常见问题。首先,我们将讨论全局共享状态的问题,然后我们将继续看看在考虑这一点的情况下可以实现描述符的不同方式。

全局共享状态的问题

正如我们已经提到的,描述符需要被设置为类属性才能工作。这在大多数情况下不会成为问题,但它确实需要考虑一些警告。

类属性的问题在于它们在该类的所有实例之间共享。描述符也不例外,因此,如果我们试图在descriptor对象中保留数据,请记住所有这些对象都将访问相同的值。

让我们看看当我们不正确地定义一个将数据本身保存在descriptor中而不是在每个对象中存储时会发生什么:

class SharedDataDescriptor:
    def __init__(self, initial_value):
        self.value = initial_value

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.value

    def __set__(self, instance, value):
        self.value = value

class ClientClass:
    descriptor = SharedDataDescriptor("first value")

在这个例子中,descriptor对象存储数据本身。这带来的不便之处在于,当我们修改一个instance的值时,同一类的所有其他实例也会被修改为相同的值。下面的代码清单将这个理论付诸实践:

>>> client1 = ClientClass()
>>> client1.descriptor
'first value'

>>> client2 = ClientClass()
>>> client2.descriptor
'first value'

>>> client2.descriptor = "value for client 2"
>>> client2.descriptor
'value for client 2'

>>> client1.descriptor
'value for client 2'

注意我们如何改变一个对象,突然之间所有这些对象都来自同一个类,我们可以看到这个值是如何反映的。这是因为ClientClass.descriptor是唯一的;它对于所有这些对象都是相同的对象。

在某些情况下,这可能是我们实际想要的(例如,如果我们要创建一种共享状态的 Borg 模式实现,我们希望在一个类的所有对象之间共享状态),但一般情况下并非如此,我们需要区分对象。这种模式在《常见设计模式》中有更详细的讨论。

为了实现这一点,描述符需要知道每个instance的值并相应地返回它。这就是我们一直在使用每个instance的字典(__dict__)并从中设置和检索值的原因。

这是最常见的方法。我们已经讨论过为什么不能在这些方法上使用getattr()setattr(),因此修改__dict__属性是最后的选择,而在这种情况下是可以接受的。

访问对象的字典

我们在本书中实现描述符的方式是让descriptor对象将值存储在对象的字典__dict__中,并从那里检索参数。

始终从实例的__dict__属性中存储和返回数据。

使用弱引用

另一种选择(如果我们不想使用__dict__)是让descriptor对象自己跟踪每个实例的值,在内部映射中返回这些值。

不过,有一个警告。这个映射不能是任何字典。由于client类有一个对描述符的引用,现在描述符将保持对使用它的对象的引用,这将创建循环依赖关系,结果这些对象永远不会被垃圾回收,因为它们互相指向。

为了解决这个问题,字典必须是一个弱键字典,如weakref(WEAKREF 01)模块中定义的那样。

在这种情况下,descriptor的代码可能如下所示:

from weakref import WeakKeyDictionary

class DescriptorClass:
    def __init__(self, initial_value):
        self.value = initial_value
        self.mapping = WeakKeyDictionary()

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return self.mapping.get(instance, self.value)

    def __set__(self, instance, value):
        self.mapping[instance] = value

这解决了问题,但也带来了一些考虑:

  • 对象不再持有它们的属性,而是由描述符代替。这在概念上可能有争议,并且从概念上来看可能并不完全准确。如果我们忘记了这个细节,我们可能会通过检查它的字典来询问对象,试图找到根本不存在的东西(例如调用vars(client)将不会返回完整的数据)。

  • 它对对象提出了需要是可散列的要求。如果它们不是,它们就不能成为映射的一部分。这对一些应用来说可能是一个要求过于苛刻的要求。

出于这些原因,我们更喜欢本书中到目前为止所展示的使用每个实例的字典的实现。然而,为了完整起见,我们也展示了这种替代方法。

关于描述符的更多考虑

在这里,我们将讨论关于描述符的一般考虑,包括在何时使用它们是一个好主意,以及我们最初可能认为通过另一种方法解决的问题如何通过描述符得到改进。然后我们将分析原始实现与使用描述符后的实现之间的利弊。

重用代码

描述符是一种通用工具和强大的抽象,我们可以使用它们来避免代码重复。决定何时使用描述符的最佳方法是识别我们将使用属性的情况(无论是用于get逻辑、set逻辑还是两者),但重复其结构多次。

属性只是描述符的一个特例(@property装饰器是实现完整描述符协议的描述符,用于定义它们的getsetdelete操作),这意味着我们可以将描述符用于更复杂的任务。

我们在重用代码方面看到的另一个强大类型是装饰器,如第五章中所解释的那样,使用装饰器改进我们的代码。描述符可以帮助我们创建更好的装饰器,确保它们能够正确地为类方法工作。

在装饰器方面,我们可以说始终在它们上实现__get__()方法是安全的,并且也将其作为描述符。在尝试决定是否值得创建装饰器时,考虑我们在第五章中提到的三个问题规则,使用装饰器改进我们的代码,但请注意,对描述符没有额外的考虑。

至于通用描述符,除了适用于装饰器的前述三个实例规则之外(通常适用于任何可重用组件),还应该记住,当我们想要定义一个内部 API 时,应该使用描述符,这是一些客户端将使用的代码。这更多地是面向设计库和框架的特性,而不是一次性解决方案。

除非有非常好的理由,或者代码看起来明显更好,否则我们应该避免在描述符中放入业务逻辑。相反,描述符的代码将包含更多的实现代码,而不是业务代码。这更类似于定义另一部分业务逻辑将用作工具的新数据结构或对象。

一般来说,描述符将包含实现逻辑,而不是业务逻辑。

避免类装饰器

如果我们回想一下我们在第五章中使用的类装饰器,使用装饰器改进我们的代码,来确定如何序列化事件对象,我们最终得到了一个实现(对于 Python 3.7+)依赖于两个类装饰器的实现:

@Serialization(
    username=show_original,
    password=hide_field,
    ip=show_original,
    timestamp=format_time,
)
@dataclass
class LoginEvent:
    username: str
    password: str
    ip: str
    timestamp: datetime

第一个从注释中获取属性来声明变量,而第二个定义了如何处理每个文件。让我们看看是否可以将这两个装饰器改为描述符。

这个想法是创建一个描述符,它将对每个属性的值应用转换,根据我们的要求返回修改后的版本(例如,隐藏敏感信息,并正确格式化日期):

from functools import partial
from typing import Callable

class BaseFieldTransformation:

    def __init__(self, transformation: Callable[[], str]) -> None:
        self._name = None
        self.transformation = transformation

    def __get__(self, instance, owner):
        if instance is None:
            return self
        raw_value = instance.__dict__[self._name]
        return self.transformation(raw_value)

    def __set_name__(self, owner, name):
        self._name = name

    def __set__(self, instance, value):
        instance.__dict__[self._name] = value

ShowOriginal = partial(BaseFieldTransformation, transformation=lambda x: x)
HideField = partial(
    BaseFieldTransformation, transformation=lambda x: "**redacted**"
)
FormatTime = partial(
    BaseFieldTransformation,
    transformation=lambda ft: ft.strftime("%Y-%m-%d %H:%M"),
)

这个“描述符”很有趣。它是用一个接受一个参数并返回一个值的函数创建的。这个函数将是我们想要应用于字段的转换。从定义了通用工作方式的基本定义开始,其余的“描述符”类被定义,只需更改每个类需要的特定函数即可。

该示例使用functools.partialdocs.python.org/3.6/library/functools.html#functools.partial)来模拟子类的方式,通过对该类的转换函数进行部分应用,留下一个可以直接实例化的新可调用函数。

为了保持示例简单,我们将实现__init__()serialize()方法,尽管它们也可以被抽象化。在这些考虑下,事件的类现在将被定义如下:

class LoginEvent:
    username = ShowOriginal()
    password = HideField()
    ip = ShowOriginal()
    timestamp = FormatTime()

    def __init__(self, username, password, ip, timestamp):
        self.username = username
        self.password = password
        self.ip = ip
        self.timestamp = timestamp

    def serialize(self):
        return {
            "username": self.username,
            "password": self.password,
            "ip": self.ip,
            "timestamp": self.timestamp,
        }

我们可以看到对象在运行时的行为:

>>> le = LoginEvent("john", "secret password", "1.1.1.1", datetime.utcnow())
>>> vars(le)
{'username': 'john', 'password': 'secret password', 'ip': '1.1.1.1', 'timestamp': ...}
>>> le.serialize()
{'username': 'john', 'password': '**redacted**', 'ip': '1.1.1.1', 'timestamp': '...'}
>>> le.password
'**redacted**'

与以前使用装饰器的实现相比,这里有一些不同之处。这个例子添加了serialize()方法,并在呈现其结果的字典之前隐藏了字段,但是如果我们在内存中的任何时候向事件实例询问这些属性,它仍然会给我们原始值,而不会对其进行任何转换(我们可以选择在设置值时应用转换,并直接在__get__()中返回它)。

根据应用程序的敏感性,这可能是可以接受的,也可能是不可以接受的,但在这种情况下,当我们要求对象提供其public属性时,描述符将在呈现结果之前应用转换。仍然可以通过访问对象的字典(通过访问__dict__)来访问原始值,但是当我们请求值时,默认情况下会返回转换后的值。

在这个例子中,所有描述符都遵循一个共同的逻辑,这个逻辑是在基类中定义的。描述符应该将值存储在对象中,然后请求它,应用它定义的转换。我们可以创建一个类的层次结构,每个类定义自己的转换函数,以使模板方法设计模式起作用。在这种情况下,由于派生类中的更改相对较小(只有一个函数),我们选择将派生类创建为基类的部分应用。创建任何新的转换字段应该像定义一个新的类那样简单,这个类将是基类,部分应用了我们需要的函数。这甚至可以临时完成,因此可能不需要为其设置名称。

不管这种实现方式,重点是,由于描述符是对象,我们可以创建模型,并将面向对象编程的所有规则应用于它们。设计模式也适用于描述符。我们可以定义我们的层次结构,设置自定义行为等等。这个例子遵循了我们在第四章中介绍的 OCP,SOLID 原则,因为添加新的转换方法类型只需要创建一个新的类,从基类派生出它所需的函数,而无需修改基类本身(公平地说,以前使用装饰器的实现也符合 OCP,但没有涉及每种转换机制的类)。

让我们举一个例子,我们创建一个基类,实现__init__()serialize()方法,这样我们就可以通过继承它来简单地定义LoginEvent类,如下所示:

class LoginEvent(BaseEvent):
    username = ShowOriginal()
    password = HideField()
    ip = ShowOriginal()
    timestamp = FormatTime()

一旦我们实现了这段代码,类看起来更清晰。它只定义了它需要的属性,通过查看每个属性的类,可以快速分析其逻辑。基类将仅抽象出共同的方法,每个事件的类看起来更简单、更紧凑。

每个事件的类不仅看起来简单,而且描述符本身也非常紧凑,比类装饰器简单得多。原始的类装饰器实现很好,但描述符使其变得更好。

描述符的分析

到目前为止,我们已经看到了描述符是如何工作的,并探索了一些有趣的情况,其中它们通过简化逻辑和利用更紧凑的类来促进清晰的设计。

到目前为止,我们知道通过使用描述符,我们可以实现更清晰的代码,抽象掉重复的逻辑和实现细节。但是我们如何知道我们的描述符实现是干净和正确的呢?什么是一个好的描述符?我们是否正确地使用了这个工具,还是过度设计了它?

在本节中,我们将分析描述符以回答这些问题。

Python 如何在内部使用描述符

关于什么是好的描述符的问题,一个简单的答案是,一个好的描述符几乎就像任何其他良好的 Python 对象一样。它与 Python 本身一致。遵循这个前提的想法是,分析 Python 如何使用描述符将给我们一个很好的实现想法,这样我们就知道从我们编写的描述符中可以期望什么。

我们将看到 Python 本身使用描述符来解决其内部逻辑的最常见情况,并且我们还将发现优雅的描述符,它们一直就在眼前。

函数和方法

可能最引人共鸣的描述符对象案例可能是函数。函数实现了__get__方法,因此当在类内定义时,它们可以作为方法工作。

方法只是多了一个额外参数的函数。按照惯例,方法的第一个参数命名为"self",它代表正在定义方法的类的实例。然后,方法对"self"的任何操作都与任何其他接收对象并对其进行修改的函数相同。

换句话说,当我们定义类似这样的东西时:

class MyClass:
    def method(self, ...):
        self.x = 1

实际上,这与我们定义以下内容是一样的:

class MyClass: pass

def method(myclass_instance, ...):
    myclass_instance.x = 1

 method(MyClass())

因此,它只是另一个函数,修改对象,只是它是在类内部定义的,并且被认为是绑定到对象上。

当我们以这种形式调用某些东西时:

instance = MyClass()
instance.method(...)

实际上,Python 正在做类似于这样的事情:

instance = MyClass()
MyClass.method(instance, ...)

请注意,这只是 Python 在内部处理的一种语法转换。这种工作方式是通过描述符实现的。

由于函数在调用方法之前实现了描述符协议(请参见以下清单),因此首先调用__get__()方法,然后在运行内部可调用对象的代码之前进行一些转换:

>>> def function(): pass
...
>>> function.__get__
<method-wrapper '__get__' of function object at 0x...>

instance.method(...)语句中,在处理括号内可调用对象的所有参数之前,会先评估"instance.method"部分。

由于method是作为类属性定义的对象,并且具有__get__方法,因此会被调用。它的作用是将function转换为方法,这意味着将可调用对象绑定到它将要使用的对象的实例上。

让我们通过一个例子来看看这个,以便我们可以对 Python 内部可能正在做的事情有一个概念。

我们将在类内部定义一个可调用对象,它将充当我们想要定义的函数或方法,以便在外部调用。Method类的一个实例应该是在不同类内部使用的函数或方法。这个函数将只打印它的三个参数——它接收到的instance(它将是在定义它的类中的self参数),以及另外两个参数。请注意,在__call__()方法中,self参数不代表MyClass的实例,而是Method的一个实例。名为instance的参数应该是MyClass类型的对象:

class Method:
    def __init__(self, name):
        self.name = name

    def __call__(self, instance, arg1, arg2):
        print(f"{self.name}: {instance} called with {arg1} and {arg2}")

class MyClass:
    method = Method("Internal call")

在考虑这些因素并创建对象之后,根据前面的定义,以下两个调用应该是等效的:

instance = MyClass()
Method("External call")(instance, "first", "second")
instance.method("first", "second")

然而,只有第一个按预期工作,因为第二个会出错:

Traceback (most recent call last):
File "file", line , in <module>
    instance.method("first", "second")
TypeError: __call__() missing 1 required positional argument: 'arg2'

我们看到了与第五章中装饰器面临的相同错误,使用装饰器改进我们的代码。参数向左移动了一个位置,instance取代了selfarg1将成为instance,而arg2没有提供任何内容。

为了解决这个问题,我们需要将Method作为描述符。

这样,当我们首先调用instance.method时,我们将调用它的__get__(),然后将这个可调用对象绑定到对象上(绕过对象作为第一个参数),然后继续:

from types import MethodType

class Method:
    def __init__(self, name):
        self.name = name

    def __call__(self, instance, arg1, arg2):
        print(f"{self.name}: {instance} called with {arg1} and {arg2}")

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return MethodType(self, instance)

现在,这两个调用都按预期工作:

External call: <MyClass object at 0x...> called with fist and second
Internal call: <MyClass object at 0x...> called with first and second

我们所做的是通过使用types模块中的MethodTypefunction(实际上是我们定义的可调用对象)转换为方法。这个类的第一个参数应该是一个可调用对象(在这种情况下是self,因为它实现了__call__),第二个参数是要将这个函数绑定到的对象。

类似的东西是 Python 中函数对象使用的,这样它们在类内定义时可以作为方法工作。

由于这是一个非常优雅的解决方案,值得探索一下,以便在定义自己的对象时将其作为 Pythonic 方法。例如,如果我们要定义自己的可调用对象,也将其作为描述符是一个好主意,这样我们也可以在类中将其用作类属性。

方法的内置装饰器

正如你可能从官方文档(PYDESCR-02)中了解到的,所有的@property@classmethod@staticmethod装饰器都是描述符。

我们已经多次提到,当从类直接调用时,惯用法使描述符返回自身。由于属性实际上是描述符,这就是为什么当我们从类中获取它时,我们得到的不是计算属性的结果,而是整个property object

>>> class MyClass:
... @property
... def prop(self): pass
...
>>> MyClass.prop
<property object at 0x...>

对于类方法,在描述符中的__get__函数将确保类是传递给被装饰的函数的第一个参数,无论是直接从类调用还是从实例调用。对于静态方法,它将确保除了函数定义的参数之外不绑定任何参数,即撤消__get__()在使self成为该函数的第一个参数的函数上所做的绑定。

让我们举个例子;我们创建一个@classproperty装饰器,它的工作方式与常规的@property装饰器相同,但是用于类。有了这样一个装饰器,以下代码应该能够工作:

class TableEvent:
    schema = "public"
    table = "user"

    @classproperty
    def topic(cls):
        prefix = read_prefix_from_config()
        return f"{prefix}{cls.schema}.{cls.table}"

>>> TableEvent.topic
'public.user'

>>> TableEvent().topic
'public.user'

Slots

当一个类定义了__slots__属性时,它可以包含类所期望的所有属性,但不能再多了。

试图动态地向定义了__slots__的类添加额外的属性将导致AttributeError。通过定义这个属性,类变得静态,因此它将没有__dict__属性,你无法动态地添加更多的对象。

那么,如果不是从对象的字典中检索它的属性,它的属性是如何检索的呢?通过使用描述符。在 slot 中定义的每个名称都将有自己的描述符,它将存储值以便以后检索:

class Coordinate2D:
    __slots__ = ("lat", "long")

    def __init__(self, lat, long):
        self.lat = lat
        self.long = long

    def __repr__(self):
        return f"{self.__class__.__name__}({self.lat}, {self.long})"

虽然这是一个有趣的特性,但必须谨慎使用,因为它会剥夺 Python 的动态特性。一般来说,这应该只用于我们知道是静态的对象,并且如果我们绝对确定在代码的其他部分动态地添加任何属性到它们。

作为其优势,使用 slots 定义的对象使用的内存更少,因为它们只需要一个固定的字段集来保存值,而不是整个字典。

在装饰器中实现描述符

我们现在了解了 Python 如何在函数中使用描述符,使它们在类内部定义时作为方法工作。我们还看到了一些例子,其中我们可以通过使用接口的__get__()方法使装饰器遵守描述符协议,从而使装饰器适应被调用的对象。这解决了我们的装饰器的问题,就像 Python 解决了对象中函数作为方法的问题一样。

调整装饰器的一般方法是在其中实现__get__()方法,并使用types.MethodType将可调用对象(装饰器本身)转换为绑定到接收到的对象(__get__接收的instance参数)的方法。

为了使其工作,我们将不得不将装饰器实现为一个对象,因为如果我们使用一个函数,它已经有一个__get__()方法,除非我们对其进行调整,否则它将执行不同的操作,这将无法工作。更干净的方法是为装饰器定义一个类。

在定义一个我们想要应用于类方法的装饰器时,使用装饰器类,并在其中实现__get__()方法。

总结

描述符是 Python 中更高级的功能,它们推动了边界,更接近元编程。它们最有趣的一个方面是它们清晰地表明 Python 中的类只是普通对象,因此它们具有属性,我们可以与它们交互。描述符在这个意义上是类可以拥有的最有趣的属性类型,因为它的协议提供了更高级的面向对象的可能性。

我们已经看到了描述符的机制,它们的方法,以及所有这些是如何结合在一起的,从而使面向对象的软件设计更加有趣。通过理解描述符,我们能够创建强大的抽象,产生清晰而紧凑的类。我们已经看到了如何修复我们想要应用于函数和方法的装饰器,我们对 Python 内部的工作原理有了更多的了解,以及描述符在语言实现中起着核心和关键的作用。

这个关于描述符在 Python 内部如何使用的研究应该作为一个参考,以便在我们自己的代码中识别描述符的良好用法,从而实现成熟的解决方案。

尽管描述符代表了我们的优势的强大选项,但我们必须记住何时适当地使用它们而不是过度设计。在这方面,我们建议应该将描述符的功能保留给真正通用的情况,比如内部开发 API、库或框架的设计。沿着这些线路的另一个重要考虑因素是,一般来说,我们不应该在描述符中放置业务逻辑,而是放置实现技术功能的逻辑,供其他包含业务逻辑的组件使用。

谈到高级功能,下一章还涵盖了一个有趣且深入的主题:生成器。乍一看,生成器相当简单(大多数读者可能已经熟悉它们),但它们与描述符的共同之处在于,它们也可以是复杂的,产生更高级和优雅的设计,并使 Python 成为一种独特的工作语言。

参考资料

以下是一些可以供您参考的信息:

第七章:使用生成器

生成器是 Python 作为一种特殊语言的另一个特性。在本章中,我们将探讨它们的基本原理,它们为什么被引入到语言中以及它们解决的问题。我们还将介绍如何通过使用生成器来惯用地解决问题,以及如何使我们的生成器(或任何可迭代对象)符合 Python 的风格。

我们将了解为什么迭代(以迭代器模式的形式)在语言中得到了自动支持。从那里,我们将再次探索生成器如何成为 Python 的一个基本特性,以支持其他功能,如协程和异步编程。

本章的目标如下:

  • 创建提高程序性能的生成器

  • 研究迭代器(特别是迭代器模式)如何深度嵌入 Python

  • 解决涉及迭代的问题

  • 了解生成器作为协程和异步编程的基础是如何工作的

  • 探索协程的语法支持——yield fromawaitasync def

技术要求

本章中的示例将适用于任何平台上的 Python 3.6 的任何版本。

本章中使用的代码可以在github.com/PacktPublishing/Clean-Code-in-Python找到

说明可在README文件中找到。

创建生成器

生成器在很久以前就被引入 Python 中(PEP-255),其目的是在 Python 中引入迭代的同时提高程序的性能(通过使用更少的内存)。

生成器的想法是创建一个可迭代的对象,当被迭代时,它将逐个产生它包含的元素。生成器的主要用途是节省内存——而不是在内存中拥有一个非常大的元素列表,一次性保存所有元素,我们有一个知道如何逐个产生每个特定元素的对象,只要它们被需要。

这个特性使得惰性计算或内存中的重量级对象成为可能,类似于其他函数式编程语言(例如 Haskell)提供的方式。甚至可以处理无限序列,因为生成器的惰性特性允许这样的选项。

首先看一下生成器

让我们从一个例子开始。现在手头的问题是,我们想处理一个大量的记录列表,并对它们进行一些指标和度量。给定一个包含有关购买信息的大型数据集,我们希望处理它以获得最低销售额、最高销售额和销售额的平均价格。

为了简化这个例子,我们将假设一个只有两个字段的 CSV,格式如下:

<purchase_date>, <price>
...

我们将创建一个接收所有购买的对象,并且这将为我们提供必要的指标。我们可以通过简单地使用min()max()内置函数来获得其中一些值,但这将需要多次迭代所有的购买,因此我们使用我们的自定义对象,它将在单次迭代中获取这些值。

将为我们获取数字的代码看起来相当简单。它只是一个具有一种方法的对象,该方法将一次性处理所有价格,并且在每一步中,将更新我们感兴趣的每个特定指标的值。首先,我们将在以下清单中显示第一个实现,然后在本章的后面(一旦我们更多地了解迭代),我们将重新访问这个实现,并获得一个更好(更紧凑)的版本。现在,我们暂时采用以下方式:

class PurchasesStats:

    def __init__(self, purchases):
        self.purchases = iter(purchases)
        self.min_price: float = None
        self.max_price: float = None
        self._total_purchases_price: float = 0.0
        self._total_purchases = 0
        self._initialize()

    def _initialize(self):
        try:
            first_value = next(self.purchases)
        except StopIteration:
            raise ValueError("no values provided")

        self.min_price = self.max_price = first_value
        self._update_avg(first_value)

    def process(self):
        for purchase_value in self.purchases:
            self._update_min(purchase_value)
            self._update_max(purchase_value)
            self._update_avg(purchase_value)
        return self

    def _update_min(self, new_value: float):
        if new_value < self.min_price:
            self.min_price = new_value

    def _update_max(self, new_value: float):
        if new_value > self.max_price:
            self.max_price = new_value

    @property
    def avg_price(self):
        return self._total_purchases_price / self._total_purchases

    def _update_avg(self, new_value: float):
        self._total_purchases_price += new_value
        self._total_purchases += 1

    def __str__(self):
        return (
            f"{self.__class__.__name__}({self.min_price}, "
            f"{self.max_price}, {self.avg_price})"
        )

这个对象将接收purchases的所有总数并处理所需的值。现在,我们需要一个函数,将这些数字加载到这个对象可以处理的东西中。以下是第一个版本:

def _load_purchases(filename):
    purchases = []
    with open(filename) as f:
        for line in f:
            *_, price_raw = line.partition(",")
            purchases.append(float(price_raw))

    return purchases

这段代码可以工作;它将文件中的所有数字加载到一个列表中,当传递给我们的自定义对象时,将产生我们想要的数字。但它有一个性能问题。如果你用一个相当大的数据集运行它,它将需要一段时间才能完成,如果数据集足够大以至于无法放入主内存中,甚至可能会失败。

如果我们看一下消耗这些数据的代码,它是逐个处理purchases的,所以我们可能会想知道为什么我们的生产者一次性将所有内容都放入内存。它创建了一个列表,将文件的所有内容都放入其中,但我们知道我们可以做得更好。

解决方案是创建一个生成器。我们不再将文件的整个内容加载到列表中,而是逐个产生结果。现在的代码看起来是这样的:

def load_purchases(filename):
    with open(filename) as f:
        for line in f:
            *_, price_raw = line.partition(",")
            yield float(price_raw)

如果你这次测量这个过程,你会注意到内存的使用显著减少了。我们还可以看到代码看起来更简单——不需要定义列表(因此也不需要向其添加元素),return语句也消失了。

在这种情况下,load_purchases函数是一个生成器函数,或者简单地说是一个生成器。

在 Python 中,任何函数中存在yield关键字都会使其成为一个生成器,因此,当调用它时,除了创建一个生成器实例之外,什么都不会发生:

>>> load_purchases("file")
<generator object load_purchases at 0x...>

生成器对象是可迭代的(我们稍后会更详细地讨论可迭代对象),这意味着它可以与for循环一起工作。请注意,我们在消费者代码上没有改变任何东西——我们的统计处理器保持不变,在新实现后for循环也没有修改。

使用可迭代对象使我们能够创建这些强大的抽象,这些抽象对for循环是多态的。只要我们保持可迭代接口,我们就可以透明地迭代该对象。

生成器表达式

生成器节省了大量内存,而且由于它们是迭代器,它们是其他需要更多内存空间的可迭代对象或容器的方便替代品,比如列表、元组或集合。

就像这些数据结构一样,它们也可以通过推导来定义,只是它被称为生成器表达式(关于它们是否应该被称为生成器推导有一个持续的争论。在本书中,我们将只用它们的规范名称来提及它们,但请随意使用你更喜欢的名称)。

同样,我们可以定义一个列表推导。如果我们用括号替换方括号,我们就得到了一个生成器,它是表达式的结果。生成器表达式也可以直接传递给那些与可迭代对象一起工作的函数,比如sum()max()

>>> [x**2 for x in range(10)]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

>>> (x**2 for x in range(10))
<generator object <genexpr> at 0x...>

>>> sum(x**2 for x in range(10))
285

总是传递一个生成器表达式,而不是列表推导,给那些期望可迭代对象的函数,比如min()max()sum()。这样更有效率和符合 Python 的风格。

迭代习语

在本节中,我们将首先探讨一些在 Python 中处理迭代时非常有用的习语。这些代码示例将帮助我们更好地了解我们可以用生成器做什么类型的事情(特别是在我们已经看过生成器表达式之后),以及如何解决与它们相关的典型问题。

一旦我们看过一些习语,我们将继续更深入地探讨 Python 中的迭代,分析使迭代成为可能的方法,以及可迭代对象的工作原理。

迭代的习语

我们已经熟悉了内置的enumerate()函数,它给定一个可迭代对象,将返回另一个可迭代对象,其中元素是一个元组,其第一个元素是第二个元素的枚举(对应于原始可迭代对象中的元素):

>>> list(enumerate("abcdef"))
[(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f')]

我们希望创建一个类似的对象,但以更低级的方式;一个可以简单地创建一个无限序列的对象。我们想要一个对象,可以从一个起始数字开始产生一个数字序列,没有任何限制。

一个简单的对象就可以解决问题。每次调用这个对象,我们都会得到序列的下一个数字无穷

class NumberSequence:

    def __init__(self, start=0):
        self.current = start

    def next(self):
        current = self.current
        self.current += 1
        return current

基于这个接口,我们必须通过显式调用它的next()方法来使用这个对象:

>>> seq = NumberSequence()
>>> seq.next()
0
>>> seq.next()
1

>>> seq2 = NumberSequence(10)
>>> seq2.next()
10
>>> seq2.next()
11

但是,使用这段代码,我们无法像我们想要的那样重建enumerate()函数,因为它的接口不支持在常规的 Python for循环中进行迭代,这也意味着我们无法将其作为参数传递给期望迭代的函数。请注意以下代码的失败:

>>> list(zip(NumberSequence(), "abcdef"))
Traceback (most recent call last):
 File "...", line 1, in <module>
TypeError: zip argument #1 must support iteration

问题在于NumberSequence不支持迭代。为了解决这个问题,我们必须通过实现魔术方法__iter__()使对象成为可迭代的。我们还改变了之前的next()方法,使用了魔术方法__next__,这使得对象成为了迭代器:

class SequenceOfNumbers:

    def __init__(self, start=0):
        self.current = start

    def __next__(self):
        current = self.current
        self.current += 1
        return current

    def __iter__(self):
        return self

这有一个优点——不仅可以迭代元素,而且我们甚至不再需要.next()方法,因为有了__next__(),我们可以使用next()内置函数:

>>> list(zip(SequenceOfNumbers(), "abcdef"))
[(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f')]
>>> seq = SequenceOfNumbers(100)
>>> next(seq)
100
>>> next(seq)
101

next()函数

next()内置函数将使可迭代对象前进到它的下一个元素并返回它:

>>> word = iter("hello")
>>> next(word)
'h'
>>> next(word)
'e'  # ...

如果迭代器没有更多的元素产生,就会引发StopIteration异常:

>>> ...
>>> next(word)
'o'
>>> next(word)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration
>>>

这个异常表示迭代已经结束,没有更多的元素可以消耗了。

如果我们希望处理这种情况,除了捕获StopIteration异常,我们可以在第二个参数中为这个函数提供一个默认值。如果提供了这个值,它将成为StopIteration抛出时的返回值:

>>> next(word, "default value")
'default value'

使用生成器

通过简单地使用生成器,可以显著简化上述代码。生成器对象是迭代器。这样,我们可以定义一个函数,根据需要yield值,而不是创建一个类:

def sequence(start=0):
    while True:
        yield start
        start += 1

记住,根据我们的第一个定义,函数体中的yield关键字使其成为一个生成器。因为它是一个生成器,所以像这样创建一个无限循环是完全可以的,因为当调用这个生成器函数时,它将运行到下一个yield语句被执行之前的所有代码。它将产生它的值并在那里暂停:

>>> seq = sequence(10)
>>> next(seq)
10
>>> next(seq)
11

>>> list(zip(sequence(), "abcdef"))
[(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd'), (4, 'e'), (5, 'f')]

Itertools

使用可迭代对象的好处在于,代码与 Python 本身更好地融合在一起,因为迭代是语言的一个关键组成部分。除此之外,我们还可以充分利用itertools模块(ITER-01)。实际上,我们刚刚创建的sequence()生成器与itertools.count()非常相似。但是,我们还可以做更多的事情。

迭代器、生成器和 itertools 最好的一点是它们是可组合的对象,可以链接在一起。

例如,回到我们的第一个例子,处理purchases以获得一些指标,如果我们想做同样的事情,但只针对某个阈值以上的值怎么办?解决这个问题的天真方法是在迭代时放置条件:

# ...
    def process(self):
        for purchase in self.purchases:
            if purchase > 1000.0:
                ...

这不仅不符合 Python 的风格,而且也很死板(死板是一个表明糟糕代码的特征)。它不能很好地处理变化。如果数字现在改变了怎么办?我们通过参数传递吗?如果我们需要多个怎么办?如果条件不同(比如小于),我们传递一个 lambda 吗?

这些问题不应该由这个对象来回答,它的唯一责任是计算一组以数字表示的购买流的明确定义的指标。当然,答案是否定的。将这样的改变是一个巨大的错误(再次强调,清晰的代码是灵活的,我们不希望通过将这个对象与外部因素耦合来使其变得死板)。这些要求必须在其他地方解决。

最好让这个对象独立于它的客户端。这个类的责任越少,对更多客户端来说就越有用,从而增加它被重用的机会。

我们不会改变这段代码,而是保持原样,并假设新数据根据该类的每个客户的要求进行了过滤。

例如,如果我们只想处理前 10 个购买金额超过 1,000 的购买,我们将执行以下操作:

>>> from itertools import islice
>>> purchases = islice(filter(lambda p: p > 1000.0, purchases), 10)
>>> stats = PurchasesStats(purchases).process()  # ...

这种过滤方式不会对内存造成惩罚,因为它们都是生成器,评估总是延迟的。这使我们有能力像一次性过滤整个集合然后将其传递给对象一样思考,但实际上并没有将所有内容都适应到内存中。

通过迭代器简化代码

现在,我们将简要讨论一些可以通过迭代器和偶尔的itertools模块帮助改进的情况。在讨论每种情况及其提出的优化后,我们将用一个推论来结束每个观点。

重复迭代

现在我们已经更多地了解了迭代器,并介绍了itertools模块,我们可以向您展示本章的第一个示例(用于计算有关某些购买的统计信息)如何被大大简化:

def process_purchases(purchases):
    min_, max_, avg = itertools.tee(purchases, 3)
    return min(min_), max(max_), median(avg)

在这个例子中,itertools.tee将原始可迭代对象分成三个新的可迭代对象。我们将使用每个对象进行不同类型的迭代,而无需重复三个不同的循环。

读者可以简单地验证,如果我们将可迭代对象作为purchases参数传递,这个对象只被遍历一次(感谢itertools.tee函数[参见参考资料]),这是我们的主要要求。还可以验证这个版本如何等价于我们的原始实现。在这种情况下,不需要手动引发ValueError,因为将空序列传递给min()函数将产生相同的效果。

如果您正在考虑在同一个对象上多次运行循环,请停下来思考一下itertools.tee是否有所帮助。

嵌套循环

在某些情况下,我们需要在多个维度上进行迭代,寻找一个值,嵌套循环是第一个想法。当找到值时,我们需要停止迭代,但break关键字并不完全起作用,因为我们需要从两个(或更多)for循环中逃离,而不仅仅是一个。

这个问题的解决方案是什么?一个信号逃脱的标志?不是。引发异常?不,这与标志相同,但更糟,因为我们知道异常不应该用于控制流逻辑。将代码移到一个更小的函数并返回它?接近,但不完全。

答案是,尽可能将迭代简化为单个for循环。

这是我们想要避免的代码类型:

def search_nested_bad(array, desired_value):
    coords = None
    for i, row in enumerate(array):
        for j, cell in enumerate(row):
            if cell == desired_value:
                coords = (i, j)
                break

        if coords is not None:
            break

    if coords is None:
        raise ValueError(f"{desired_value} not found")

    logger.info("value %r found at [%i, %i]", desired_value, *coords)
    return coords

以下是一个简化版本,它不依赖于标志来表示终止,并且具有更简单、更紧凑的迭代结构:

def _iterate_array2d(array2d):
    for i, row in enumerate(array2d):
        for j, cell in enumerate(row):
            yield (i, j), cell

def search_nested(array, desired_value):
    try:
        coord = next(
            coord
            for (coord, cell) in _iterate_array2d(array)
            if cell == desired_value
        )
    except StopIteration:
        raise ValueError("{desired_value} not found")

    logger.info("value %r found at [%i, %i]", desired_value, *coord)
    return coord

值得一提的是,创建的辅助生成器如何作为所需迭代的抽象。在这种情况下,我们只需要在两个维度上进行迭代,但如果我们需要更多,不同的对象可以处理这一点,而客户端无需知道。这就是迭代器设计模式的本质,在 Python 中是透明的,因为它自动支持迭代器对象,这是下一节讨论的主题。

尽量简化迭代,使用尽可能多的抽象,尽可能将循环展平。

Python 中的迭代器模式

在这里,我们将从生成器中稍微偏离,更深入地了解 Python 中的迭代。生成器是可迭代对象的特殊情况,但 Python 中的迭代超越了生成器,能够创建良好的可迭代对象将使我们有机会创建更高效、更紧凑和更可读的代码。

在前面的代码清单中,我们一直在看一些可迭代对象的示例,这些对象也是迭代器,因为它们实现了__iter__()__next__()魔术方法。虽然这在一般情况下是可以的,但并不严格要求它们总是必须实现这两个方法,这里我们将展示可迭代对象(实现__iter__)和迭代器(实现__next__)之间的细微差别。

我们还探讨了与迭代相关的其他主题,如序列和容器对象。

迭代的接口

可迭代对象是支持迭代的对象,从非常高的层次来看,这意味着我们可以在其上运行for .. in ...循环,并且不会出现任何问题。然而,可迭代并不意味着与迭代器相同。

一般来说,可迭代只是我们可以迭代的东西,并且它使用迭代器来实现。这意味着在__iter__魔术方法中,我们希望返回一个迭代器,即一个实现了__next__()方法的对象。

迭代器是一个只知道如何产生一系列值的对象,每次被已探索的内置next()函数调用时,它都会一次产生一个值。当迭代器没有被调用时,它只是被冻结,静静地坐着,直到再次为下一个值调用它。在这个意义上,生成器是迭代器。

Python 概念 魔术方法 注意事项
可迭代对象 __iter__ 它们与迭代器一起工作,构建迭代逻辑。这些对象可以在for ... in ...:循环中迭代
迭代器 __next__ 定义逐个产生值的逻辑。StopIteration异常表示迭代结束。可以通过内置的next()函数逐个获取值。

在下面的代码中,我们将看到一个迭代器对象的示例,它不是可迭代的——它只支持一次调用其值。在这里,名称sequence只是指一系列连续的数字,并不是 Python 中的序列概念,我们稍后会探讨:

class SequenceIterator:
    def __init__(self, start=0, step=1):
        self.current = start
        self.step = step

    def __next__(self):
        value = self.current
        self.current += self.step
        return value

请注意,我们可以逐个获取序列的值,但我们无法迭代这个对象(这是幸运的,否则将导致无限循环):

>>> si = SequenceIterator(1, 2)
>>> next(si)
1
>>> next(si)
3
>>> next(si)
5
>>> for _ in SequenceIterator(): pass
... 
Traceback (most recent call last):
 ...
TypeError: 'SequenceIterator' object is not iterable

错误消息很清楚,因为对象没有实现__iter__()

仅仅为了说明的目的,我们可以将迭代分离到另一个对象中(同样,只需使对象分别实现__iter____next__即可,但这样做可以帮助澄清我们在这个解释中试图阐明的不同点)。

序列对象作为可迭代对象

正如我们刚刚看到的,如果一个对象实现了__iter__()魔术方法,这意味着它可以在for循环中使用。虽然这是一个很好的特性,但我们可以实现的迭代形式并不仅限于此。当我们编写for循环时,Python 会尝试查看我们使用的对象是否实现了__iter__,如果实现了,它将使用它来构建迭代,但如果没有,还有备用选项。

如果对象恰好是一个序列(意味着它实现了__getitem__()__len__()魔术方法),它也可以被迭代。如果是这种情况,解释器将按顺序提供值,直到引发IndexError异常,这与前面提到的StopIteration类似,也表示迭代的结束。

为了说明这种行为,我们运行以下实验,展示了一个实现map()在一系列数字上的序列对象:

# generators_iteration_2.py

class MappedRange:
    """Apply a transformation to a range of numbers."""

    def __init__(self, transformation, start, end):
        self._transformation = transformation
        self._wrapped = range(start, end)

    def __getitem__(self, index):
        value = self._wrapped.__getitem__(index)
        result = self._transformation(value)
        logger.info("Index %d: %s", index, result)
        return result

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

请记住,这个示例只是为了说明这样一个对象可以用常规的for循环进行迭代。在__getitem__方法中放置了一个日志行,以探索在迭代对象时传递了哪些值,正如我们从以下测试中所看到的:

>>> mr = MappedRange(abs, -10, 5)
>>> mr[0]
Index 0: 10
10
>>> mr[-1]
Index -1: 4
4
>>> list(mr)
Index 0: 10
Index 1: 9
Index 2: 8
Index 3: 7
Index 4: 6
Index 5: 5
Index 6: 4
Index 7: 3
Index 8: 2
Index 9: 1
Index 10: 0
Index 11: 1
Index 12: 2
Index 13: 3
Index 14: 4
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4]

需要注意的是,重要的是要强调,虽然了解这一点很有用,但它也是对象不实现__iter__时的后备机制,因此大多数时候我们会希望通过考虑创建适当的序列来使用这些方法,而不仅仅是我们想要迭代的对象。

在设计用于迭代的对象时,更倾向于一个适当的可迭代对象(带有__iter__),而不是一个偶然也可以被迭代的序列。

协程

正如我们已经知道的,生成器对象是可迭代的。它们实现了__iter__()__next__()。这是由 Python 自动提供的,因此当我们创建一个生成器对象函数时,我们会得到一个可以通过next()函数进行迭代或推进的对象。

除了这个基本功能,它们还有更多的方法,以便它们可以作为协程(PEP-342)工作。在这里,我们将探讨生成器如何演变成协程,以支持异步编程的基础,然后在下一节中更详细地探讨 Python 的新特性和涵盖异步编程的语法。用于支持协程的(PEP-342)中添加的基本方法如下:

  • .close()

  • .throw(ex_type[, ex_value[, ex_traceback]])

  • .send(value)

生成器接口的方法

在本节中,我们将探讨上述每个方法的作用,工作原理以及预期的使用方式。通过理解如何使用这些方法,我们将能够使用简单的协程。

稍后,我们将探讨协程的更高级用法,以及如何委托给子生成器(协程)以重构代码,以及如何编排不同的协程。

close()

调用这个方法时,生成器将接收GeneratorExit异常。如果没有处理,那么生成器将在不产生更多值的情况下结束,并且它的迭代将停止。

这个异常可以用来处理完成状态。一般来说,如果我们的协程进行某种资源管理,我们希望捕获这个异常并使用该控制块来释放协程持有的所有资源。一般来说,这类似于使用上下文管理器或将代码放在异常控制的finally块中,但专门处理这个异常使得它更加明确。

在下面的例子中,我们有一个协程,它利用一个持有数据库连接的数据库处理程序对象,并在其上运行查询,通过固定长度的页面流式传输数据(而不是一次性读取所有可用的数据):

def stream_db_records(db_handler):
    try:
        while True:
            yield db_handler.read_n_records(10)
    except GeneratorExit:
        db_handler.close()

在每次调用生成器时,它将返回从数据库处理程序获取的10行,但当我们决定明确完成迭代并调用close()时,我们还希望关闭与数据库的连接:

>>> streamer = stream_db_records(DBHandler("testdb"))
>>> next(streamer)
[(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3'), ...]
>>> next(streamer)
[(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3'), ...]
>>> streamer.close()
INFO:...:closing connection to database 'testdb'

使用close()方法关闭生成器以在需要时执行收尾任务。

throw(ex_type[, ex_value[, ex_traceback]])

这个方法将在生成器当前暂停的地方throw异常。如果生成器处理了发送的异常,那么特定的except子句中的代码将被调用,否则,异常将传播到调用者。

在这里,我们稍微修改了之前的例子,以展示当我们使用这个方法处理协程处理的异常和未处理的异常时的区别:

class CustomException(Exception):
    pass

def stream_data(db_handler):
    while True:
        try:
            yield db_handler.read_n_records(10)
        except CustomException as e:
            logger.info("controlled error %r, continuing", e)
        except Exception as e:
            logger.info("unhandled error %r, stopping", e)
            db_handler.close()
            break

现在,接收CustomException已经成为控制流的一部分,如果出现这种情况,生成器将记录一条信息性消息(当然,我们可以根据每种情况的业务逻辑进行调整),然后继续执行下一个yield语句,这是协程从数据库读取并返回数据的地方。

这个特定的例子处理了所有异常,但如果最后一个块(except Exception:)不在那里,结果将是生成器在生成器暂停的地方被引发(再次是yield*),然后从那里传播到调用者:

>>> streamer = stream_data(DBHandler("testdb"))
>>> next(streamer)
[(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3'), (4, 'row 4'), ...]
>>> next(streamer)
[(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3'), (4, 'row 4'), ...]
>>> streamer.throw(CustomException)
WARNING:controlled error CustomException(), continuing
[(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3'), (4, 'row 4'), ...]
>>> streamer.throw(RuntimeError)
ERROR:unhandled error RuntimeError(), stopping
INFO:closing connection to database 'testdb'
Traceback (most recent call last):
 ...
StopIteration

当我们收到来自领域的异常时,生成器继续。然而,当它收到另一个意外的异常时,捕获了默认块,我们关闭了与数据库的连接并完成了迭代,这导致生成器停止。正如我们从引发的StopIteration中看到的,这个生成器不能进一步迭代。

send(value)

在前面的例子中,我们创建了一个简单的生成器,从数据库中读取行,当我们希望完成它的迭代时,这个生成器释放了与数据库相关的资源。这是使用生成器提供的方法之一(close)的一个很好的例子,但我们还可以做更多的事情。

这样的生成器很明显是从数据库中读取了固定数量的行。

我们希望参数化那个数字(10),以便我们可以在不同的调用中更改它。不幸的是,next()函数不为我们提供这样的选项。但幸运的是,我们有send()

def stream_db_records(db_handler):
    retrieved_data = None
    previous_page_size = 10
    try:
        while True:
            page_size = yield retrieved_data
            if page_size is None:
                page_size = previous_page_size

            previous_page_size = page_size

            retrieved_data = db_handler.read_n_records(page_size)
    except GeneratorExit:
        db_handler.close()

我们现在的想法是,我们现在已经使协程能够通过send()方法从调用者那里接收值。这个方法实际上是区分生成器和协程的方法,因为当它被使用时,意味着yield关键字将出现在语句的右侧,并且它的返回值将被分配给其他东西。

在协程中,我们通常发现yield关键字以以下形式使用:

receive = yield produced

在这种情况下,yield将做两件事。它将produced发送回调用者,调用者将在下一轮迭代(例如在调用next()之后)中接收到它,并在那里暂停。稍后,调用者将想要通过使用send()方法向协程发送一个值。这个值将成为yield语句的结果,在这种情况下赋给名为receive的变量。

只有当协程在yield语句处暂停等待某些东西产生时,向协程发送值才有效。为了实现这一点,协程必须被推进到这种状态。唯一的方法是通过调用next()来做到这一点。这意味着在向协程发送任何东西之前,必须通过next()方法至少推进一次。如果不这样做,将导致异常:

>>> c = coro()
>>> c.send(1)
Traceback (most recent call last):
 ...
TypeError: can't send non-None value to a just-started generator

在向协程发送任何值之前,请记住通过调用next()来推进协程。

回到我们的例子。我们正在改变元素被生成或流式传输的方式,使其能够接收它期望从数据库中读取的记录的长度。

第一次调用next()时,生成器将前进到包含yield的行;它将向调用者提供一个值(如变量中设置的None),并在那里暂停。从这里,我们有两个选择。如果我们选择通过调用next()来推进生成器,将使用10的默认值,并且它将像往常一样继续进行。这是因为next()在技术上与send(None)相同,但这是在我们之前设置的值的if语句中处理的。

另一方面,如果我们决定通过send(<value>)提供一个显式值,这个值将成为yield语句的结果,这将被赋给包含要使用的页面长度的变量,而这个变量将被用来从数据库中读取。

后续的调用将具有这种逻辑,但重要的是现在我们可以在迭代中间动态改变要读取的数据的长度。

现在我们了解了之前的代码是如何工作的,大多数 Pythonistas 都希望有一个简化版本(毕竟,Python 也是关于简洁和干净紧凑的代码):

def stream_db_records(db_handler):
    retrieved_data = None
    page_size = 10
    try:
        while True:
            page_size = (yield retrieved_data) or page_size
            retrieved_data = db_handler.read_n_records(page_size)
    except GeneratorExit:
        db_handler.close()

这个版本不仅更紧凑,而且更好地说明了这个想法。yield周围的括号使它更清晰,表明它是一个语句(把它想象成一个函数调用),我们正在使用它的结果与先前的值进行比较。

这符合我们的预期,但我们总是要记住在向其发送任何数据之前先推进协程。如果我们忘记调用第一个next(),我们将得到一个TypeError。这个调用可以被忽略,因为它不会返回我们将使用的任何东西。

如果我们能够直接使用协程,在创建后不必记住每次使用它时都调用next()第一次,那将是很好的。一些作者(PYCOOK)设计了一个有趣的装饰器来实现这一点。这个装饰器的想法是推进协程,所以下面的定义可以自动工作:

@prepare_coroutine
def stream_db_records(db_handler):
    retrieved_data = None
    page_size = 10
    try:
        while True:
            page_size = (yield retrieved_data) or page_size
            retrieved_data = db_handler.read_n_records(page_size)
    except GeneratorExit:
        db_handler.close()

>>> streamer = stream_db_records(DBHandler("testdb"))
>>> len(streamer.send(5))
5

让我们举个例子,我们创建了prepare_coroutine()装饰器。

更高级的协程

到目前为止,我们对协程有了更好的理解,并且能够创建简单的协程来处理小任务。我们可以说这些协程实际上只是更高级的生成器(这是正确的,协程只是花哨的生成器),但是,如果我们真的想要开始支持更复杂的场景,通常我们必须采用一种处理许多协程并发的设计,并且需要更多的功能。

处理许多协程时,我们发现了新的问题。随着应用程序的控制流变得更加复杂,我们希望能够在堆栈上传递值和异常,能够捕获我们可能在任何级别调用的子协程的值,并最终安排多个协程朝着共同的目标运行。

为了简化事情,生成器必须再次扩展。这就是 PEP-380 所解决的问题——通过改变生成器的语义,使其能够返回值,并引入新的yield from构造。

在协程中返回值

正如本章开头介绍的那样,迭代是一种机制,它在可迭代对象上多次调用next(),直到引发StopIteration异常。

到目前为止,我们一直在探索生成器的迭代性质——我们一次产生一个值,并且通常只关心for循环的每一步产生的每个值。这是一种非常逻辑的生成器思维方式,但是协程有一个不同的想法;尽管它们在技术上是生成器,但它们并不是以迭代为目的而构思的,而是以挂起代码的执行直到稍后恢复为目标。

这是一个有趣的挑战;当我们设计一个协程时,我们通常更关心挂起状态而不是迭代(迭代协程将是一个奇怪的情况)。挑战在于很容易混合它们两者。这是因为技术实现细节;Python 中对协程的支持是建立在生成器之上的。

如果我们想要使用协程来处理一些信息并挂起其执行,那么把它们看作轻量级线程(或者在其他平台上称为绿色线程)是有意义的。在这种情况下,如果它们能够返回值,就像调用任何其他常规函数一样,那将是有意义的。

但让我们记住生成器不是常规函数,因此在生成器中,构造value = generator()除了创建一个generator对象之外什么也不会做。那么使生成器返回一个值的语义是什么?它将必须在迭代完成后。

当生成器返回一个值时,迭代立即停止(不能再迭代)。为了保持语义,StopIteration异常仍然被引发,并且要返回的值存储在exception对象中。捕获它是调用者的责任。

在下面的例子中,我们创建了一个简单的generator,产生两个值,然后返回第三个值。请注意,我们必须捕获异常以获取这个value,以及它如何精确地存储在异常的属性value下:

>>> def generator():
...     yield 1
...     yield 2
...     return 3
... 
>>> value = generator()
>>> next(value)
1
>>> next(value)
2
>>> try:
...     next(value)
... except StopIteration as e:
...     print(">>>>>> returned value ", e.value)
... 
>>>>>> returned value  3

委托到更小的协程 - yield from 语法

以前的特性很有趣,因为它为协程(生成器)打开了许多新的可能性,现在它们可以返回值。但是,这个特性本身如果没有适当的语法支持,就不会那么有用,因为以这种方式捕获返回值有点麻烦。

这是yield from语法的主要特性之一。除了其他事情(我们将详细审查),它可以收集子生成器返回的值。记住我们说过在生成器中返回数据很好,但不幸的是,编写语句value = generator()是行不通的吗?好吧,将其编写为value = yield from generator()就可以了。

yield from的最简单用法

在其最基本的形式中,新的yield from语法可以用于将嵌套的for循环中的生成器链接成一个单一的生成器,最终将得到一个连续流中所有值的单个字符串。

典型的例子是创建一个类似于standard库中的itertools.chain()的函数。这是一个非常好的函数,因为它允许您传递任意数量的iterables,并将它们一起返回一个流。

天真的实现可能看起来像这样:

def chain(*iterables):
    for it in iterables:
        for value in it:
            yield value

它接收可变数量的iterables,遍历所有这些 iterables,由于每个值都是iterable,它支持for... in..结构,因此我们有另一个for循环来获取每个特定 iterable 中的每个值,这是由调用函数产生的。这在多种情况下可能会有所帮助,例如将生成器链接在一起或尝试迭代通常不可能一次比较的东西(例如列表与元组等)。

然而,yield from语法允许我们更进一步,避免嵌套循环,因为它能够直接从子生成器产生值。在这种情况下,我们可以简化代码如下:

def chain(*iterables):
    for it in iterables:
        yield from it

请注意,对于这两种实现,生成器的行为完全相同:

>>> list(chain("hello", ["world"], ("tuple", " of ", "values.")))
['h', 'e', 'l', 'l', 'o', 'world', 'tuple', ' of ', 'values.']

这意味着我们可以在任何其他可迭代对象上使用yield from,它将起到作用,就好像顶层生成器(使用yield from的那个)自己生成这些值一样。

这适用于任何可迭代对象,甚至生成器表达式也不例外。现在我们熟悉了它的语法,让我们看看如何编写一个简单的生成器函数,它将产生一个数字的所有幂(例如,如果提供all_powers(2, 3),它将产生2⁰, 2¹,... 2³):

def all_powers(n, pow):
    yield from (n ** i for i in range(pow + 1))

虽然这样简化了语法,节省了一个for语句的行数并不是一个很大的优势,这并不能证明向语言中添加这样的更改是合理的。

实际上,这实际上只是一个副作用,而yield from结构的真正存在意义是我们将在接下来的两个部分中探讨的。

捕获子生成器返回的值

在下面的例子中,我们有一个生成器调用另外两个嵌套的生成器,按顺序产生值。每一个嵌套的生成器都返回一个值,我们将看到顶层生成器如何能够有效地捕获返回值,因为它通过yield from调用内部生成器:

def sequence(name, start, end):
    logger.info("%s started at %i", name, start)
    yield from range(start, end)
    logger.info("%s finished at %i", name, end)
    return end

def main():
    step1 = yield from sequence("first", 0, 5)
    step2 = yield from sequence("second", step1, 10)
    return step1 + step2

这是在主函数中迭代时代码的可能执行方式:

>>> g = main()
>>> next(g)
INFO:generators_yieldfrom_2:first started at 0
0
>>> next(g)
1
>>> next(g)
2
>>> next(g)
3
>>> next(g)
4
>>> next(g)
INFO:generators_yieldfrom_2:first finished at 5
INFO:generators_yieldfrom_2:second started at 5
5
>>> next(g)
6
>>> next(g)
7
>>> next(g)
8
>>> next(g)
9
>>> next(g)
INFO:generators_yieldfrom_2:second finished at 10
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration: 15

main 的第一行委托给内部生成器,并产生值,直接从中提取。这并不是什么新鲜事,因为我们已经见过了。请注意,sequence()生成器函数返回结束值,在第一行赋给名为step1的变量,这个值在下一个生成器实例的开始正确使用。

最后,这个其他生成器也返回第二个结束值(10),而主生成器则返回它们的和(5+10=15),这是我们在迭代停止后看到的值。

我们可以使用yield from在协程完成处理后捕获最后一个值。

向子生成器发送和接收数据

现在,我们将看到yield from语法的另一个很好的特性,这可能是它赋予它完整力量的原因。正如我们在探索生成器作为协程时已经介绍的,我们知道我们可以向它们发送值和抛出异常,在这种情况下,协程要么接收值进行内部处理,要么必须相应地处理异常。

如果现在我们有一个委托给其他协程的协程(就像在前面的例子中),我们也希望保留这个逻辑。手动这样做将会相当复杂(如果我们没有通过yield from自动处理的话,可以看一下 PEP-380 中描述的代码)。

为了说明这一点,让我们保持相同的顶层生成器(main)与之前的例子相同,但让我们修改内部生成器,使它们能够接收值并处理异常。这段代码可能不是惯用的,只是为了展示这个机制是如何工作的。

def sequence(name, start, end):
    value = start
    logger.info("%s started at %i", name, value)
    while value < end:
        try:
            received = yield value
            logger.info("%s received %r", name, received)
            value += 1
        except CustomException as e:
            logger.info("%s is handling %s", name, e)
            received = yield "OK"
    return end

现在,我们将调用main协程,不仅通过迭代它,还通过向它传递值和抛出异常,以查看它们在sequence内部是如何处理的:

>>> g = main()
>>> next(g)
INFO: first started at 0
0
>>> next(g)
INFO: first received None
1
>>> g.send("value for 1")
INFO: first received 'value for 1'
2
>>> g.throw(CustomException("controlled error"))
INFO: first is handling controlled error
'OK'
... # advance more times
INFO:second started at 5
5
>>> g.throw(CustomException("exception at second generator"))
INFO: second is handling exception at second generator
'OK'

这个例子向我们展示了很多不同的东西。请注意,我们从未向sequence发送值,而只是向main发送,即使如此,接收这些值的代码是嵌套生成器。即使我们从未明确向sequence发送任何东西,它也会接收数据,因为它是通过yield from传递的。

main协程在内部调用另外两个协程,产生它们的值,并且在任何一个特定时间点被挂起。当它停在第一个时,我们可以看到日志告诉我们,正是这个协程实例接收了我们发送的值。当我们向它抛出异常时也是一样的。当第一个协程完成时,它返回了在名为step1的变量中分配的值,并作为第二个协程的输入,第二个协程也会做同样的事情(它将相应地处理send()throw()调用)。

对于每个协程产生的值也是如此。当我们处于任何给定步骤时,调用send()返回的值对应于子协程(main当前挂起的那个)产生的值。当我们抛出一个正在处理的异常时,sequence协程产生值OK,这个值传播到被调用的(main),然后最终到达 main 的调用者。

异步编程

通过我们迄今为止看到的构造,我们能够在 Python 中创建异步程序。这意味着我们可以创建具有许多协程的程序,安排它们按特定顺序工作,并在每个协程上调用yield from后挂起时在它们之间切换。

我们可以从中获得的主要优势是以非阻塞的方式并行化 I/O 操作的可能性。我们需要的是一个低级生成器(通常由第三方库实现),它知道如何在协程被挂起时处理实际的 I/O。协程的目的是实现挂起,以便我们的程序可以在此期间处理另一个任务。应用程序重新获取控制的方式是通过yield from语句,它将挂起并向调用者产生一个值(就像我们之前看到的例子中使用这个语法来改变程序的控制流)。

这大致是多年来 Python 中异步编程的工作方式,直到决定需要更好的语法支持。

协程和生成器在技术上是相同的,这导致了一些混淆。从语法上(和技术上)来看,它们是相同的,但从语义上来看,它们是不同的。当我们想要实现高效的迭代时,我们创建生成器。我们通常创建协程的目的是运行非阻塞 I/O 操作。

尽管这种差异是明显的,Python 的动态特性仍然允许开发人员混合这些不同类型的对象,在程序的非常后期出现运行时错误。记住,在最简单和最基本的yield from语法中,我们使用这个结构来迭代(我们创建了一种在字符串、列表等上应用的chain函数)。这些对象都不是协程,但它仍然有效。然后,我们看到我们可以有多个协程,使用yield from发送值(或异常),并获得一些结果。这显然是两种非常不同的用例,但是,如果我们写出类似以下语句的内容:

result = yield from iterable_or_awaitable()

不清楚iterable_or_awaitable返回什么。它可以是一个简单的可迭代对象,比如字符串,这可能仍然是语法上正确的。或者,它可能是一个实际的协程。这个错误的代价将在以后付出。

因此,Python 中的输入系统必须得到扩展。在 Python 3.5 之前,协程只是应用了@coroutine装饰器的生成器,并且它们需要使用yield from语法进行调用。现在,有一种特定类型的对象,即协程。

这个改变也带来了语法的改变。引入了awaitasync def语法。前者旨在替代yield from,它只能与awaitable对象一起使用(方便地,协程恰好是这种对象)。尝试使用不符合awaitable接口的东西来调用await将引发异常。async def是定义协程的新方法,取代了前面提到的装饰器,实际上创建了一个对象,当调用时,将返回一个协程的实例。

不去深入讨论 Python 中异步编程的所有细节和可能性,我们可以说,尽管有新的语法和新的类型,但这并没有从本质上做任何不同于我们在本章中介绍的概念。

在 Python 中异步编程的思想是有一个事件循环(通常是asyncio,因为它是标准库中包含的一个,但还有许多其他可以正常工作的),它管理一系列协程。这些协程属于事件循环,事件循环将根据其调度机制来调用它们。当这些协程中的每一个运行时,它将调用我们的代码(根据我们在编写的协程中定义的逻辑),当我们想要将控制返回给事件循环时,我们调用await <coroutine>,这将异步处理一个任务。事件循环将恢复,另一个协程将代替正在运行的操作。

实际上,还有更多的细节和边缘情况超出了本书的范围。然而,值得一提的是,这些概念与本章介绍的思想相关,并且这个领域是生成器证明是语言的核心概念的另一个地方,因为有许多东西是在它们的基础上构建的。

总结

生成器在 Python 中随处可见。自它们在 Python 中诞生以来,它们被证明是一个很好的补充,使程序更加高效,迭代更加简单。

随着时间的推移,以及需要向 Python 添加更复杂的任务,生成器再次帮助支持协程。

而在 Python 中,协程是生成器,我们仍然不必忘记它们在语义上是不同的。生成器是为了迭代而创建的,而协程的目标是异步编程(在任何给定时间暂停和恢复程序的执行部分)。这种区别变得如此重要,以至于它使 Python 的语法(和类型系统)发生了演变。

迭代和异步编程构成了 Python 编程的最后一根主要支柱。现在,是时候看看所有这些概念如何结合在一起,并将我们在过去几章中探讨的所有这些概念付诸实践了。

接下来的章节将描述 Python 项目的其他基本方面,如测试、设计模式和架构。

参考资料

以下是您可以参考的信息列表:

第八章:单元测试和重构

本章探讨的思想是本书全局背景中的基本支柱,因为它们对我们的最终目标至关重要:编写更好、更易维护的软件。

单元测试(以及任何形式的自动测试)对于软件的可维护性至关重要,因此是任何优质项目中不可或缺的东西。正因为如此,本章专门致力于自动化测试作为一个关键策略,以安全地修改代码,并在逐步改进的版本中进行迭代。

在本章之后,我们将对以下内容有更深入的了解:

  • 为什么自动化测试对于采用敏捷软件开发方法论的项目至关重要

  • 单元测试作为代码质量的一种启发方式

  • 可用于开发自动化测试和设置质量门限的框架和工具

  • 利用单元测试更好地理解领域问题并记录代码

  • 与单元测试相关的概念,比如测试驱动开发

设计原则和单元测试

在本节中,我们首先从概念角度来看一下单元测试。我们将重新审视我们在之前讨论过的一些软件工程原则,以了解这与清晰代码的关系。

之后,我们将更详细地讨论如何将这些概念付诸实践(在代码层面),以及我们可以利用哪些框架和工具。

首先,我们快速定义一下单元测试的内容。单元测试是负责验证代码的其他部分的代码。通常,任何人都会倾向于说单元测试验证应用程序的“核心”,但这样的定义将单元测试视为次要的,这并不是本书中对单元测试的思考方式。单元测试是核心,是软件的关键组成部分,应该像业务逻辑一样受到同等的考虑。

单元测试是一段代码,它导入代码的部分业务逻辑,并运行其逻辑,断言几种情景,以保证特定条件。单元测试必须具有一些特征,比如:

  • 隔离:单元测试应该完全独立于任何其他外部代理,并且它们必须只关注业务逻辑。因此,它们不连接到数据库,不执行 HTTP 请求等。隔离还意味着测试在彼此之间是独立的:它们必须能够以任何顺序运行,而不依赖于任何先前的状态。

  • 性能:单元测试必须运行快速。它们旨在多次重复运行。

  • 自我验证:单元测试的执行决定了其结果。不需要额外的步骤来解释单元测试(更不用说手动了)。

更具体地说,在 Python 中,这意味着我们将有新的*.py文件,我们将在其中放置我们的单元测试,并且它们将被某个工具调用。这些文件将有import语句,以从我们的业务逻辑中获取我们需要的内容(我们打算测试的内容),并在这个文件中编写测试本身。之后,一个工具将收集我们的单元测试并运行它们,给出一个结果。

这最后一部分实际上就是自我验证的含义。当工具调用我们的文件时,将启动一个 Python 进程,并在其上运行我们的测试。如果测试失败,进程将以错误代码退出(在 Unix 环境中,这可以是任何不等于0的数字)。标准是工具运行测试,并为每个成功的测试打印一个点(.),如果测试失败,则打印一个F(测试条件未满足),如果出现异常,则打印一个E

关于其他形式的自动化测试的说明

单元测试旨在验证非常小的单元,例如函数或方法。我们希望通过单元测试达到非常详细的粒度,尽可能测试更多的代码。为了测试一个类,我们不想使用单元测试,而是使用测试套件,这是一组单元测试。每一个单元测试将测试更具体的内容,比如该类的一个方法。

这并不是唯一的单元测试形式,也不能捕捉到每一个可能的错误。还有验收测试和集成测试,都超出了本书的范围。

在集成测试中,我们希望一次测试多个组件。在这种情况下,我们希望验证它们是否集体按预期工作。在这种情况下,有副作用是可以接受的(甚至是可取的),并且可以忽略隔离,这意味着我们希望发出 HTTP 请求,连接到数据库等。

验收测试是一种自动化的测试形式,试图从用户的角度验证系统,通常执行用例。

这两种测试方式失去了单元测试的另一个优点:速度。正如你可以想象的,它们将需要更多时间来运行,因此它们将运行得更少。

在一个良好的开发环境中,程序员将拥有整个测试套件,并且在进行代码更改、迭代、重构等过程中,会一直运行单元测试。一旦更改准备就绪,并且拉取请求已经打开,持续集成服务将对该分支运行构建,其中将运行单元测试,以及可能存在的集成或验收测试。不用说,在合并之前,构建的状态应该是成功的(绿色),但重要的是测试类型之间的差异:我们希望一直运行单元测试,并且较少频繁地运行那些需要更长时间的测试。因此,我们希望有很多小的单元测试,以及一些自动化测试,策略性地设计来尽可能覆盖单元测试无法达到的地方(例如数据库)。

最后,智者之言。请记住,本书鼓励实用主义。除了本书中给出的定义和关于单元测试的观点之外,读者必须牢记,根据您的标准和背景,最佳解决方案应该占主导地位。没有人比您更了解您的系统。这意味着,如果由于某种原因,您必须编写一个需要启动 Docker 容器来针对数据库进行测试的单元测试,那就去做吧。正如我们在整本书中反复提醒的那样,实用性胜过纯粹性。

单元测试和敏捷软件开发

在现代软件开发中,我们希望不断地以尽可能快的速度交付价值。这些目标背后的理念是,我们获得反馈的越早,影响就越小,改变就越容易。这些并不是新的想法;其中一些类似于几十年前的制造原则,而其他一些(比如尽快从利益相关者那里获得反馈并对其进行迭代的想法)可以在《大教堂与集市》等文章中找到。

因此,我们希望能够有效地应对变化,为此,我们编写的软件将不得不改变。就像我们在前几章中提到的,我们希望我们的软件是适应性强、灵活和可扩展的。

单单代码(无论它写得多么好和设计得多么好)不能保证它足够灵活以便进行更改。假设我们按照 SOLID 原则设计了一款软件,并且在某个部分实际上有一组符合开闭原则的组件,这意味着我们可以很容易地扩展它们而不会影响太多现有的代码。进一步假设代码是以有利于重构的方式编写的,因此我们可以根据需要进行更改。当我们进行这些更改时,有什么可以证明我们没有引入任何错误?我们怎么知道现有的功能被保留了?你会对向用户发布这个新版本感到有信心吗?他们会相信新版本的工作方式与预期一样吗?

对所有这些问题的答案是,除非我们有正式的证明,否则我们无法确定。而单元测试就是这样,它是程序按照规范工作的正式证明。

因此,单元(或自动)测试作为一个安全网,给了我们在代码上工作的信心。有了这些工具,我们可以高效地工作在我们的代码上,因此这最终决定了团队在软件产品上的速度(或能力)。测试越好,我们就越有可能快速交付价值,而不会因为不时出现的错误而停滞不前。

单元测试和软件设计

当涉及主代码和单元测试之间的关系时,这是另一面的问题。除了在前一节中探讨的实用原因之外,它归结为良好的软件是可测试的软件。可测试性(决定软件易于测试程度的质量属性)不仅仅是一种美好的东西,而是对清晰代码的驱动。

单元测试不仅仅是主代码库的补充,而是对代码编写方式有直接影响和真正影响的东西。从最初意识到我们想要为代码的某些部分添加单元测试时,我们必须对其进行更改(从而得到更好的版本),到其最终表达(在本章末尾附近探讨)时,整个代码(设计)是由它将如何通过测试驱动设计进行测试而驱动的。

从一个简单的例子开始,我们将向您展示一个小的用例,其中测试(以及测试我们的代码的需要)导致我们编写代码的方式得到改进。

在以下示例中,我们将模拟一个需要向外部系统发送关于每个特定任务获得的结果的指标的过程(和往常一样,只要我们专注于代码,细节就不重要)。我们有一个代表领域问题上某个任务的Process对象,并且它使用一个metrics客户端(一个外部依赖,因此我们无法控制)来将实际的指标发送到外部实体(这可能是发送数据到syslogstatsd,例如):

class MetricsClient:
    """3rd-party metrics client"""

    def send(self, metric_name, metric_value):
        if not isinstance(metric_name, str):
            raise TypeError("expected type str for metric_name")

        if not isinstance(metric_value, str):
            raise TypeError("expected type str for metric_value")

        logger.info("sending %s = %s", metric_name, metric_value)

class Process:

    def __init__(self):
        self.client = MetricsClient() # A 3rd-party metrics client

    def process_iterations(self, n_iterations):
        for i in range(n_iterations):
            result = self.run_process()
            self.client.send("iteration.{}".format(i), result)

在第三方客户端的模拟版本中,我们规定提供的参数必须是字符串类型。因此,如果run_process方法的result不是字符串,我们可能期望它会失败,而事实上确实如此:

Traceback (most recent call last):
...
    raise TypeError("expected type str for metric_value")
TypeError: expected type str for metric_value

记住,这种验证不在我们的控制之内,我们无法改变代码,因此在继续之前,我们必须为方法提供正确类型的参数。但由于这是我们发现的一个错误,我们首先想要编写一个单元测试,以确保它不会再次发生。我们这样做实际上是为了证明我们修复了问题,并且保护免受这个错误的影响,无论代码被重构多少次。

通过模拟Process对象的client,我们可以测试代码,但这样做会运行比需要的更多的代码(注意我们想要测试的部分嵌套在代码中)。此外,方法相对较小是件好事,因为如果不是这样,测试将不得不运行更多不需要的部分,我们可能也需要模拟。这是另一个良好设计的例子(小而紧密的函数或方法),与可测试性相关。

最后,我们决定不费太多力气,只测试我们需要的部分,所以我们不直接在main方法上与client交互,而是委托给一个wrapper方法,新的类看起来是这样的:

class WrappedClient:

    def __init__(self):
        self.client = MetricsClient()

    def send(self, metric_name, metric_value):
        return self.client.send(str(metric_name), str(metric_value))

class Process:
    def __init__(self):
        self.client = WrappedClient()

    ... # rest of the code remains unchanged

在这种情况下,我们选择为指标创建我们自己的版本的client,也就是说,一个围绕我们以前使用的第三方库的包装器。为此,我们放置了一个类(具有相同的接口),将根据需要转换类型。

这种使用组合的方式类似于适配器设计模式(我们将在下一章中探讨设计模式,所以现在只是一个信息性的消息),而且由于这是我们领域中的一个新对象,它可以有其相应的单元测试。拥有这个对象将使测试变得更简单,但更重要的是,现在我们看到,我们意识到这可能是代码应该一开始就应该编写的方式。尝试为我们的代码编写单元测试使我们意识到我们完全错过了一个重要的抽象!

既然我们已经将方法分离出来,让我们为其编写实际的单元测试。在本例中使用的unittest模块的详细信息将在我们探讨测试工具和库的章节中更详细地探讨,但现在阅读代码将给我们一个关于如何测试的第一印象,并且会使之前的概念变得不那么抽象:

import unittest
from unittest.mock import Mock

class TestWrappedClient(unittest.TestCase):
    def test_send_converts_types(self):
        wrapped_client = WrappedClient()
        wrapped_client.client = Mock()
        wrapped_client.send("value", 1)

        wrapped_client.client.send.assert_called_with("value", "1")

Mockunittest.mock模块中可用的一种类型,它是一个非常方便的对象,可以询问各种事情。例如,在这种情况下,我们将其用于替代第三方库(模拟成系统边界,如下一节所述),以检查它是否按预期调用(再次强调,我们不测试库本身,只测试它是否被正确调用)。注意我们运行了一个类似于我们的Process对象的调用,但我们期望参数被转换为字符串。

定义要测试的边界

测试需要付出努力。如果我们在决定要测试什么时不小心,我们将永远无法结束测试,因此浪费了大量的精力而没有取得多少成果。

我们应该将测试范围限定在我们的代码边界内。如果不这样做,我们将不得不测试依赖项(外部/第三方库或模块)或我们的代码,然后测试它们各自的依赖项,依此类推,永无止境。我们不负责测试依赖关系,因此我们可以假设这些项目有自己的测试。只需测试对外部依赖的正确调用是否使用了正确的参数(这甚至可能是对补丁的可接受使用),但我们不应该投入更多的精力。

这是另一个良好软件设计的实例。如果我们在设计时小心谨慎,并清晰地定义了系统的边界(也就是说,我们设计时朝向接口,而不是会改变的具体实现,从而颠倒了对外部组件的依赖关系以减少时间耦合),那么在编写单元测试时,模拟这些接口将会更容易得多。

在良好的单元测试中,我们希望在系统的边界上打补丁,并专注于要执行的核心功能。我们不测试外部库(例如通过pip安装的第三方工具),而是检查它们是否被正确调用。当我们在本章后面探讨mock对象时,我们将回顾执行这些类型的断言的技术和工具。

测试框架和工具

有很多工具可以用于编写单元测试,它们都有各自的优缺点并且服务于不同的目的。但在所有工具中,有两种最有可能覆盖几乎所有场景,因此我们将本节限制在这两种工具上。

除了测试框架和测试运行库之外,通常还可以找到配置代码覆盖率的项目,它们将其用作质量指标。由于覆盖率(作为指标使用时)是误导性的,因此在了解如何创建单元测试之后,我们将讨论为什么不应轻视它。

单元测试的框架和库

在本节中,我们将讨论两个编写和运行单元测试的框架。第一个是unittest,它在 Python 的标准库中可用,而第二个pytest必须通过pip外部安装。

当涉及到为我们的代码覆盖测试场景时,unittest可能就足够了,因为它有很多辅助功能。然而,对于我们有多个依赖项、连接到外部系统并且可能需要打补丁对象以及定义固定参数化测试用例的更复杂的系统,pytest看起来更完整。

我们将使用一个小程序作为示例,以展示如何使用这两种选项进行测试,最终将帮助我们更好地了解它们之间的比较。

演示测试工具的示例是一个支持合并请求中的代码审查的版本控制工具的简化版本。我们将从以下标准开始:

  • 如果至少有一个人不同意更改,合并请求将被拒绝

  • 如果没有人反对,并且至少有其他两个开发人员认为合并请求是好的,它就会被批准

  • 在其他情况下,它的状态是pending

代码可能如下所示:

from enum import Enum

class MergeRequestStatus(Enum):
    APPROVED = "approved"
    REJECTED = "rejected"
    PENDING = "pending"

class MergeRequest:
    def __init__(self):
        self._context = {
            "upvotes": set(),
            "downvotes": set(),
        }

    @property
    def status(self):
        if self._context["downvotes"]:
            return MergeRequestStatus.REJECTED
        elif len(self._context["upvotes"]) >= 2:
            return MergeRequestStatus.APPROVED
        return MergeRequestStatus.PENDING

    def upvote(self, by_user):
        self._context["downvotes"].discard(by_user)
        self._context["upvotes"].add(by_user)

    def downvote(self, by_user):
        self._context["upvotes"].discard(by_user)
        self._context["downvotes"].add(by_user)

unittest

unittest模块是一个很好的选择,可以开始编写单元测试,因为它提供了丰富的 API 来编写各种测试条件,并且由于它在标准库中可用,因此它非常灵活和方便。

unittest模块基于 JUnit(来自 Java)的概念,而 JUnit 又基于来自 Smalltalk 的单元测试的原始思想,因此它是面向对象的。因此,测试是通过对象编写的,其中检查由方法验证,并且通常通过类将测试分组到场景中。

要开始编写单元测试,我们必须创建一个从unittest.TestCase继承的测试类,并定义我们想要在其方法中强调的条件。这些方法应该以test_*开头,并且可以在内部使用从unittest.TestCase继承的任何方法来检查必须成立的条件。

我们可能想要验证我们的情况的一些条件的示例包括:

class TestMergeRequestStatus(unittest.TestCase):

    def test_simple_rejected(self):
        merge_request = MergeRequest()
        merge_request.downvote("maintainer")
        self.assertEqual(merge_request.status, MergeRequestStatus.REJECTED)

    def test_just_created_is_pending(self):
        self.assertEqual(MergeRequest().status, MergeRequestStatus.PENDING)

    def test_pending_awaiting_review(self):
        merge_request = MergeRequest()
        merge_request.upvote("core-dev")
        self.assertEqual(merge_request.status, MergeRequestStatus.PENDING)

    def test_approved(self):
        merge_request = MergeRequest()
        merge_request.upvote("dev1")
        merge_request.upvote("dev2")

        self.assertEqual(merge_request.status, MergeRequestStatus.APPROVED)

单元测试的 API 提供了许多有用的比较方法,其中最常见的是assertEquals(<actual>, <expected>[, message]),它可以用来比较操作的结果与我们期望的值,可选地使用在错误情况下显示的消息。

另一个有用的测试方法允许我们检查是否引发了某个异常。当发生异常情况时,我们在代码中引发异常,以防止在错误的假设下进行持续处理,并且通知调用者调用的方式有问题。这是应该进行测试的逻辑的一部分,这就是这个方法的作用。

假设我们现在正在进一步扩展我们的逻辑,以允许用户关闭他们的合并请求,一旦发生这种情况,我们就不希望再进行更多的投票(在合并请求已经关闭后评估合并请求是没有意义的)。为了防止这种情况发生,我们扩展我们的代码,并在不幸的事件发生时引发异常,当有人试图对已关闭的合并请求进行投票时。

在添加了两个新状态(OPENCLOSED)和一个新的close()方法之后,我们修改了之前的投票方法,以处理此检查:

class MergeRequest:
    def __init__(self):
        self._context = {
            "upvotes": set(),
            "downvotes": set(),
        }
        self._status = MergeRequestStatus.OPEN

    def close(self):
        self._status = MergeRequestStatus.CLOSED

    ...
    def _cannot_vote_if_closed(self):
        if self._status == MergeRequestStatus.CLOSED:
            raise MergeRequestException("can't vote on a closed merge 
            request")

    def upvote(self, by_user):
        self._cannot_vote_if_closed()

        self._context["downvotes"].discard(by_user)
        self._context["upvotes"].add(by_user)

    def downvote(self, by_user):
        self._cannot_vote_if_closed()

        self._context["upvotes"].discard(by_user)
        self._context["downvotes"].add(by_user)

现在,我们想要检查这个验证是否有效。为此,我们将使用asssertRaisesassertRaisesRegex方法:

    def test_cannot_upvote_on_closed_merge_request(self):
        self.merge_request.close()
        self.assertRaises(
            MergeRequestException, self.merge_request.upvote, "dev1"
        )

    def test_cannot_downvote_on_closed_merge_request(self):
        self.merge_request.close()
        self.assertRaisesRegex(
            MergeRequestException,
            "can't vote on a closed merge request",
            self.merge_request.downvote,
            "dev1",
        )

前者期望在调用第二个参数中的可调用对象时引发提供的异常,使用函数的其余部分的参数(*args**kwargs),如果不是这种情况,它将失败,并表示预期引发的异常未被引发。后者也是如此,但它还检查引发的异常是否包含与提供的正则表达式匹配的消息。即使引发了异常,但消息不同(不匹配正则表达式),测试也会失败。

尝试检查错误消息,因为异常不仅会更准确地进行额外检查,确保实际上触发了我们想要的异常,还会检查是否另一个相同类型的异常偶然发生。

参数化测试

现在,我们想要测试合并请求的阈值接受如何工作,只需提供context的数据样本,而不需要整个MergeRequest对象。我们想要测试status属性的部分,即在检查它是否关闭之后的部分,但是独立地。

实现这一目标的最佳方法是将该组件分离为另一个类,使用组合,然后继续使用自己的测试套件测试这个新的抽象:

class AcceptanceThreshold:
    def __init__(self, merge_request_context: dict) -> None:
        self._context = merge_request_context

    def status(self):
        if self._context["downvotes"]:
            return MergeRequestStatus.REJECTED
        elif len(self._context["upvotes"]) >= 2:
            return MergeRequestStatus.APPROVED
        return MergeRequestStatus.PENDING

class MergeRequest:
    ...
    @property
    def status(self):
        if self._status == MergeRequestStatus.CLOSED:
            return self._status

        return AcceptanceThreshold(self._context).status()

有了这些变化,我们可以再次运行测试并验证它们是否通过,这意味着这次小的重构没有破坏当前功能(单元测试确保回归)。有了这一点,我们可以继续实现编写特定于新类的测试的目标:

class TestAcceptanceThreshold(unittest.TestCase):
    def setUp(self):
        self.fixture_data = (
            (
                {"downvotes": set(), "upvotes": set()},
                MergeRequestStatus.PENDING
            ),
            (
                {"downvotes": set(), "upvotes": {"dev1"}},
                MergeRequestStatus.PENDING,
            ),
            (
                {"downvotes": "dev1", "upvotes": set()},
                MergeRequestStatus.REJECTED
            ),
            (
                {"downvotes": set(), "upvotes": {"dev1", "dev2"}},
                MergeRequestStatus.APPROVED
            ),
        )

    def test_status_resolution(self):
        for context, expected in self.fixture_data:
            with self.subTest(context=context):
                status = AcceptanceThreshold(context).status()
                self.assertEqual(status, expected)

setUp()方法中,我们定义了要在整个测试中使用的数据装置。在这种情况下,实际上并不需要,因为我们可以直接放在方法中,但是如果我们希望在执行任何测试之前运行一些代码,这就是写入的地方,因为这个方法在每次运行测试之前都会被调用一次。

通过编写代码的新版本,被测试代码下的参数更清晰更紧凑,并且在每种情况下都会报告结果。

为了模拟我们正在运行所有参数,测试会遍历所有数据,并对每个实例执行代码。这里一个有趣的辅助方法是使用subTest,在这种情况下,我们使用它来标记被调用的测试条件。如果其中一个迭代失败,unittest会报告相应的变量值,这些变量被传递给subTest(在这种情况下,它被命名为context,但任何一系列关键字参数都可以起到同样的作用)。例如,一个错误可能看起来像这样:

FAIL: (context={'downvotes': set(), 'upvotes': {'dev1', 'dev2'}})
----------------------------------------------------------------------
Traceback (most recent call last):
  File "" test_status_resolution
    self.assertEqual(status, expected)
AssertionError: <MergeRequestStatus.APPROVED: 'approved'> != <MergeRequestStatus.REJECTED: 'rejected'>

如果选择参数化测试,请尽量提供每个参数实例的上下文信息,以便更容易进行调试。

pytest

Pytest 是一个很棒的测试框架,可以通过pip install pytest进行安装。与unittest相比的一个区别是,虽然仍然可以将测试场景分类为类,并创建我们测试的面向对象模型,但这并不是强制性的,也可以通过使用assert语句来写更少的样板代码进行单元测试。

默认情况下,使用assert语句进行比较就足以让pytest识别单元测试并相应地报告其结果。还可以使用包中的特定函数进行更高级的用法,但这需要使用特定的函数。

一个很好的特性是命令pytests将运行它能够发现的所有测试,即使它们是用unittest编写的。这种兼容性使得逐渐从unittest过渡到pytest变得更容易。

使用 pytest 进行基本测试用例

我们在上一节中测试的条件可以用pytest中的简单函数重写。

一些简单断言的示例如下:

def test_simple_rejected():
    merge_request = MergeRequest()
    merge_request.downvote("maintainer")
    assert merge_request.status == MergeRequestStatus.REJECTED

def test_just_created_is_pending():
    assert MergeRequest().status == MergeRequestStatus.PENDING

def test_pending_awaiting_review():
    merge_request = MergeRequest()
    merge_request.upvote("core-dev")
    assert merge_request.status == MergeRequestStatus.PENDING

布尔相等比较不需要更多的简单断言语句,而其他类型的检查,比如异常的检查需要我们使用一些函数:

def test_invalid_types():
    merge_request = MergeRequest()
    pytest.raises(TypeError, merge_request.upvote, {"invalid-object"})

def test_cannot_vote_on_closed_merge_request():
    merge_request = MergeRequest()
    merge_request.close()
    pytest.raises(MergeRequestException, merge_request.upvote, "dev1")
    with pytest.raises(
        MergeRequestException,
        match="can't vote on a closed merge request",
    ):
        merge_request.downvote("dev1")

在这种情况下,pytest.raises相当于unittest.TestCase.assertRaises,它也接受作为方法和上下文管理器调用。如果我们想检查异常的消息,而不是使用不同的方法(如assertRaisesRegex),则必须使用相同的函数,但作为上下文管理器,并提供match参数与我们想要识别的表达式。

pytest还会将原始异常包装成一个自定义异常,可以通过检查其属性(例如.value)来预期,以便在需要检查更多条件时使用,但这个函数的使用覆盖了绝大多数情况。

参数化测试

使用pytest运行参数化测试更好,不仅因为它提供了更清晰的 API,而且因为每个测试与其参数的组合都会生成一个新的测试用例。

为了使用这个,我们必须在我们的测试上使用pytest.mark.parametrize装饰器。装饰器的第一个参数是一个字符串,指示要传递给test函数的参数的名称,第二个参数必须是可迭代的,包含这些参数的相应值。

注意测试函数的主体如何被简化为一行(在移除内部for循环和其嵌套的上下文管理器后),并且每个测试用例的数据都正确地与函数的主体隔离开来,这样更容易扩展和维护:

@pytest.mark.parametrize("context,expected_status", (
    (
        {"downvotes": set(), "upvotes": set()},
        MergeRequestStatus.PENDING
    ),
    (
        {"downvotes": set(), "upvotes": {"dev1"}},
        MergeRequestStatus.PENDING,
    ),
    (
        {"downvotes": "dev1", "upvotes": set()},
        MergeRequestStatus.REJECTED
    ),
    (
        {"downvotes": set(), "upvotes": {"dev1", "dev2"}},
        MergeRequestStatus.APPROVED
    ),
))
def test_acceptance_threshold_status_resolution(context, expected_status):
    assert AcceptanceThreshold(context).status() == expected_status

使用@pytest.mark.parametrize来消除重复,尽可能使测试主体保持内聚,并明确指定代码必须支持的参数(测试输入或场景)。

Fixture

pytest的一个很棒的功能是它如何促进创建可重用的功能,这样我们可以有效地测试数据或对象,而不需要重复。

例如,我们可能想要创建一个处于特定状态的MergeRequest对象,并在多个测试中使用该对象。我们通过创建一个函数并应用@pytest.fixture装饰器来将我们的对象定义为 fixture。想要使用该 fixture 的测试将必须具有与定义的函数相同名称的参数,pytest将确保提供它:

@pytest.fixture
def rejected_mr():
    merge_request = MergeRequest()

    merge_request.downvote("dev1")
    merge_request.upvote("dev2")
    merge_request.upvote("dev3")
    merge_request.downvote("dev4")

    return merge_request

def test_simple_rejected(rejected_mr):
    assert rejected_mr.status == MergeRequestStatus.REJECTED

def test_rejected_with_approvals(rejected_mr):
    rejected_mr.upvote("dev2")
    rejected_mr.upvote("dev3")
    assert rejected_mr.status == MergeRequestStatus.REJECTED

def test_rejected_to_pending(rejected_mr):
    rejected_mr.upvote("dev1")
    assert rejected_mr.status == MergeRequestStatus.PENDING

def test_rejected_to_approved(rejected_mr):
    rejected_mr.upvote("dev1")
    rejected_mr.upvote("dev2")
    assert rejected_mr.status == MergeRequestStatus.APPROVED

记住,测试也会影响主要代码,因此干净代码的原则也适用于它们。在这种情况下,我们在之前章节中探讨过的不要重复自己DRY)原则再次出现,我们可以借助pytest的 fixture 来实现它。

除了创建多个对象或公开将在整个测试套件中使用的数据之外,还可以使用它们来设置一些条件,例如全局修补一些不希望被调用的函数,或者当我们希望使用修补对象时。

代码覆盖率

测试运行器支持覆盖插件(通过pip安装)将提供有关测试运行时执行了代码的哪些行的有用信息。这些信息对我们非常有帮助,以便我们知道代码的哪些部分需要被测试覆盖,并确定需要进行的改进(无论是在生产代码中还是在测试中)。其中最广泛使用的库之一是coveragepypi.org/project/coverage/)。

虽然它们非常有帮助(我们强烈建议您使用它们并配置您的项目在运行测试时在 CI 中运行覆盖),但它们也可能会产生误导;特别是在 Python 中,如果我们不仔细阅读覆盖报告,就会产生错误的印象。

设置其余覆盖

pytest的情况下,我们必须安装pytest-cov软件包(在撰写本书时,本书使用的是版本2.5.1)。安装后,当运行测试时,我们必须告诉pytest运行器也将运行pytest-cov,以及应该覆盖哪个软件包(以及其他参数和配置)。

该软件包支持多种配置,如不同类型的输出格式,并且很容易将其与任何 CI 工具集成,但在所有这些功能中,一个强烈推荐的选项是设置标志,告诉我们哪些行尚未被测试覆盖,因为这将帮助我们诊断我们的代码,并允许我们开始编写更多的测试。

为了向您展示这是什么样子,使用以下命令:

pytest \
    --cov-report term-missing \
    --cov=coverage_1 \
    test_coverage_1.py

这将产生类似以下的输出:

test_coverage_1.py ................ [100%]

----------- coverage: platform linux, python 3.6.5-final-0 -----------
Name         Stmts Miss Cover Missing
---------------------------------------------
coverage_1.py 38      1  97%    53

在这里,它告诉我们有一行没有单元测试,因此我们可以查看并了解如何为其编写单元测试。这是一个常见的情况,我们意识到为了覆盖这些缺失的行,我们需要通过创建更小的方法来重构代码。结果,我们的代码看起来会好得多,就像我们在本章开头看到的例子一样。

问题在于相反的情况——我们能相信高覆盖率吗?这是否意味着我们的代码是正确的?不幸的是,拥有良好的测试覆盖率是必要的,但不足以保证代码的清洁。对代码的某些部分没有测试显然是不好的。拥有测试实际上是非常好的(我们可以说对于已经存在的测试),并且实际上断言了它们是代码质量的保证。然而,我们不能说这就是所有需要的;尽管覆盖率很高,但仍需要更多的测试。

这些是测试覆盖率的注意事项,我们将在下一节中提到。

测试覆盖的注意事项

Python 是解释性的,而覆盖工具利用这一点来识别在测试运行时被解释(运行)的行。然后它会在最后报告这一点。一行被解释并不意味着它被正确测试了,这就是为什么我们应该仔细阅读最终的覆盖报告并信任它所说的内容。

这实际上对于任何语言都是正确的。执行了一行代码并不意味着它已经经历了所有可能的组合。所有分支在提供的数据下成功运行只意味着代码支持了该组合,但这并不能告诉我们任何其他可能导致程序崩溃的参数组合。

使用覆盖作为发现代码中盲点的工具,而不是作为度量标准或目标。

模拟对象

有些情况下,我们的代码不是在测试环境中唯一存在的东西。毕竟,我们设计和构建的系统必须做一些真实的事情,这通常意味着连接到外部服务(数据库、存储服务、外部 API、云服务等)。因为它们需要具有这些副作用,它们是不可避免的。尽管我们抽象我们的代码,朝着接口编程,并且隔离代码以最小化副作用,但它们会出现在我们的测试中,我们需要一种有效的方式来处理它们。

模拟对象是防止不良副作用的最佳策略之一。我们的代码可能需要执行 HTTP 请求或发送通知电子邮件,但我们肯定不希望这些事件发生在我们的单元测试中。此外,单元测试应该运行得很快,因为我们希望经常运行它们(实际上是一直),这意味着我们不能承受延迟。因此,真正的单元测试不使用任何实际服务——它们不连接到任何数据库,不发出 HTTP 请求,基本上除了执行生产代码的逻辑之外什么都不做。

我们需要执行这些操作的测试,但它们不是单元测试。集成测试应该以更广泛的视角测试功能,几乎模仿用户的行为。但它们不快。因为它们连接到外部系统和服务,所以运行时间更长,成本更高。通常,我们希望有大量的单元测试能够快速运行,以便一直运行它们,而集成测试则较少运行(例如,在任何新的合并请求上)。

虽然模拟对象很有用,但滥用它们的使用范围介于代码异味和反模式之间是我们在深入讨论之前想要提到的第一个警告。

关于修补和模拟的公平警告

我们之前说过,单元测试帮助我们编写更好的代码,因为我们想要开始测试代码的部分时,通常必须编写可测试的代码,这通常意味着它们也是内聚的、细粒度的和小的。这些都是软件组件中具有的良好特性。

另一个有趣的收获是,测试将帮助我们注意到代码中存在代码异味的地方。我们的代码存在代码异味的主要警告之一是,我们发现自己试图 monkey-patch(或模拟)许多不同的东西,只是为了覆盖一个简单的测试用例。

unittest模块提供了一个在unittest.mock.patch中修补对象的工具。修补意味着原始代码(由导入时指定其位置的字符串给出)将被其他东西替换,而不是其原始代码,默认情况下是模拟对象。这会在运行时替换代码,并且有一个缺点,即我们失去了原始代码的联系,使我们的测试变得更加肤浅。它还带来了性能考虑,因为在运行时修改对象会带来开销,并且如果我们重构代码并移动事物,这可能会导致更新。

在我们的测试中使用 monkey-patching 或模拟可能是可以接受的,而且本身并不代表一个问题。另一方面,滥用 monkey-patching 确实是一个标志,表明我们的代码需要改进。

使用模拟对象

在单元测试术语中,有几种对象属于名为测试替身的类别。测试替身是一种对象,它将以不同种类的原因在我们的测试套件中代替真实对象(也许我们不需要实际的生产代码,而只需要一个虚拟对象,或者我们不能使用它,因为它需要访问服务或者它具有我们不希望在单元测试中出现的副作用等)。

有不同类型的测试替身,例如虚拟对象、存根、间谍或模拟。模拟是最一般的对象类型,由于它们非常灵活和多功能,因此适用于所有情况,而无需详细了解其他情况。正因为如此,标准库还包括了这种类型的对象,并且在大多数 Python 程序中都很常见。这就是我们将在这里使用的:unittest.mock.Mock

模拟是一种根据规范创建的对象类型(通常类似于生产类的对象)和一些配置的响应(也就是说,我们可以告诉模拟在某些调用时应该返回什么,并且它的行为应该是什么)。然后,“模拟”对象将记录其内部状态的一部分,例如它是如何被调用的(使用了什么参数,多少次等),我们可以使用该信息在以后的阶段验证我们应用程序的行为。

在 Python 的情况下,标准库中提供的Mock对象提供了一个很好的 API,可以进行各种行为断言,例如检查模拟调用了多少次,使用了什么参数等。

模拟的类型

标准库在unittest.mock模块中提供了MockMagicMock对象。前者是一个可以配置为返回任何值并将跟踪对其进行的调用的测试替身。后者也是如此,但它还支持魔术方法。这意味着,如果我们编写了使用魔术方法的成语代码(并且我们正在测试的代码的某些部分将依赖于它),那么我们可能必须使用MagicMock实例而不仅仅是Mock

当我们的代码需要调用魔术方法时,尝试使用Mock将导致错误。请参阅以下代码,以了解此示例:

class GitBranch:
    def __init__(self, commits: List[Dict]):
        self._commits = {c["id"]: c for c in commits}

    def __getitem__(self, commit_id):
        return self._commits[commit_id]

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

def author_by_id(commit_id, branch):
    return branch[commit_id]["author"]

我们想测试这个函数;但是,另一个测试需要调用author_by_id函数。由于某种原因,因为我们没有测试该函数,提供给该函数(并返回)的任何值都将是好的:

def test_find_commit():
    branch = GitBranch([{"id": "123", "author": "dev1"}])
    assert author_by_id("123", branch) == "dev1"

def test_find_any():
    author = author_by_id("123", Mock()) is not None
    # ... rest of the tests..

正如预期的那样,这不起作用:

def author_by_id(commit_id, branch):
    > return branch[commit_id]["author"]
    E TypeError: 'Mock' object is not subscriptable

使用MagicMock将起作用。我们甚至可以配置此类型模拟的魔术方法,以返回我们需要的内容,以便控制我们测试的执行:

def test_find_any():
    mbranch = MagicMock()
    mbranch.__getitem__.return_value = {"author": "test"}
    assert author_by_id("123", mbranch) == "test"

测试替身的用例

为了看到模拟的可能用途,我们需要向我们的应用程序添加一个新组件,该组件将负责通知“构建”“状态”的合并请求。当“构建”完成时,将使用合并请求的 ID 和“构建”的“状态”调用此对象,并通过向特定的固定端点发送 HTTPPOST请求来使用此信息更新合并请求的“状态”:

# mock_2.py

from datetime import datetime

import requests
from constants import STATUS_ENDPOINT

class BuildStatus:
    """The CI status of a pull request."""

    @staticmethod
    def build_date() -> str:
        return datetime.utcnow().isoformat()

    @classmethod
    def notify(cls, merge_request_id, status):
        build_status = {
            "id": merge_request_id,
            "status": status,
            "built_at": cls.build_date(),
        }
        response = requests.post(STATUS_ENDPOINT, json=build_status)
        response.raise_for_status()
        return response

这个类有很多副作用,但其中一个是一个重要的难以克服的外部依赖。如果我们试图在不修改任何内容的情况下对其进行测试,那么它将在尝试执行 HTTP 连接时立即失败并出现连接错误。

作为测试目标,我们只想确保信息被正确组成,并且库请求是使用适当的参数进行调用的。由于这是一个外部依赖项,我们不测试请求;只需检查它是否被正确调用就足够了。

当尝试比较发送到库的数据时,我们将面临另一个问题,即该类正在计算当前时间戳,这在单元测试中是不可能预测的。直接修补datetime是不可能的,因为该模块是用 C 编写的。有一些外部库可以做到这一点(例如freezegun),但它们会带来性能损耗,并且对于这个例子来说会过度。因此,我们选择将我们想要的功能封装在一个静态方法中,以便我们可以修补它。

现在我们已经确定了代码中需要替换的要点,让我们编写单元测试:

# test_mock_2.py

from unittest import mock

from constants import STATUS_ENDPOINT
from mock_2 import BuildStatus

@mock.patch("mock_2.requests")
def test_build_notification_sent(mock_requests):
    build_date = "2018-01-01T00:00:01"
    with mock.patch("mock_2.BuildStatus.build_date", 
    return_value=build_date):
        BuildStatus.notify(123, "OK")

    expected_payload = {"id": 123, "status": "OK", "built_at": 
    build_date}
    mock_requests.post.assert_called_with(
        STATUS_ENDPOINT, json=expected_payload
    )

首先,我们使用mock.patch作为装饰器来替换requests模块。这个函数的结果将创建一个mock对象,将作为参数传递给测试(在这个例子中命名为mock_requests)。然后,我们再次使用这个函数,但这次作为上下文管理器,来改变计算“构建”日期的类的方法的返回值,用我们控制的值替换它,我们将在断言中使用。

一旦我们把所有这些都放在那里,我们就可以用一些参数调用类方法,然后我们可以使用mock对象来检查它是如何被调用的。在这种情况下,我们使用这个方法来查看requests.post是否确实以我们想要的参数被调用。

这是模拟的一个很好的特性——它们不仅限制了所有外部组件的范围(在这种情况下,以防止实际发送一些通知或发出 HTTP 请求),而且还提供了一个有用的 API 来验证调用及其参数。

在这种情况下,我们能够通过设置相应的“模拟”对象来测试代码,但事实上,与主要功能的总代码行数相比,我们不得不进行相当多的补丁。关于被测试的纯生产代码与我们必须模拟的代码部分之间的比例没有明确的规则,但是通过运用常识,我们可以看到,如果我们不得不在相同的部分进行相当多的补丁,那么某些东西并没有被清晰地抽象出来,看起来像是代码异味。

在下一节中,我们将探讨如何重构代码来解决这个问题。

重构

重构是软件维护中的一个关键活动,但如果没有单元测试,就不能做到(至少是正确的)。我们时不时需要支持一个新功能或以意想不到的方式使用我们的软件。我们需要意识到,满足这些要求的唯一方法是首先重构我们的代码,使其更通用。只有这样,我们才能继续前进。

通常,在重构我们的代码时,我们希望改进其结构,使其更好,有时更通用,更可读,或更灵活。挑战在于在实现这些目标的同时保持与修改之前完全相同的功能。这意味着,在我们重构的组件的客户眼中,可能根本没有发生任何事情。

必须支持与之前相同的功能,但使用不同版本的代码这一约束意味着我们需要对修改过的代码运行回归测试。运行回归测试的唯一经济有效的方法是自动化。自动化测试的最经济有效的版本是单元测试。

改进我们的代码

在前面的例子中,我们能够将代码的副作用与我们无法在单元测试中控制的部分分离出来,通过对依赖于这些部分的代码进行补丁,使其可测试。这是一个很好的方法,因为毕竟,mock.patch函数对于这些任务来说非常方便,可以替换我们告诉它的对象,给我们一个Mock对象。

这样做的缺点是,我们必须提供我们将要模拟的对象的路径,包括模块,作为一个字符串。这有点脆弱,因为如果我们重构我们的代码(比如说我们重命名文件或将其移动到其他位置),所有的补丁位置都必须更新,否则测试将会失败。

在这个例子中,notify()方法直接依赖于一个实现细节(requests模块),这是一个设计问题,也就是说,它也对单元测试产生了上述的脆弱性。

我们仍然需要用双重对象(模拟)替换这些方法,但如果我们重构代码,我们可以以更好的方式来做。让我们将这些方法分开成更小的方法,最重要的是注入依赖,而不是固定它。现在代码应用了依赖反转原则,并且期望与支持接口的东西一起工作(在这个例子中是隐式的),比如requests模块提供的接口:

from datetime import datetime

from constants import STATUS_ENDPOINT

class BuildStatus:

    endpoint = STATUS_ENDPOINT

    def __init__(self, transport):
        self.transport = transport

    @staticmethod
    def build_date() -> str:
        return datetime.utcnow().isoformat()

    def compose_payload(self, merge_request_id, status) -> dict:
        return {
            "id": merge_request_id,
            "status": status,
            "built_at": self.build_date(),
        }

    def deliver(self, payload):
        response = self.transport.post(self.endpoint, json=payload)
        response.raise_for_status()
        return response

    def notify(self, merge_request_id, status):
        return self.deliver(self.compose_payload(merge_request_id, status))

我们将方法分开(不再是 notify,而是 compose + deliver),创建了一个新的compose_payload()方法(这样我们可以替换,而不需要打补丁类),并要求注入transport依赖。现在transport是一个依赖项,更容易更改该对象为我们想要的任何双重对象。

甚至可以暴露这个对象的一个 fixture,并根据需要替换双重对象:

@pytest.fixture
def build_status():
    bstatus = BuildStatus(Mock())
    bstatus.build_date = Mock(return_value="2018-01-01T00:00:01")
    return bstatus

def test_build_notification_sent(build_status):

    build_status.notify(1234, "OK")

    expected_payload = {
        "id": 1234,
        "status": "OK",
        "built_at": build_status.build_date(),
    }

    build_status.transport.post.assert_called_with(
        build_status.endpoint, json=expected_payload
    )

生产代码并不是唯一在演变的东西

我们一直在说单元测试和生产代码一样重要。如果我们对生产代码足够小心以创建最佳的抽象,为什么我们不为单元测试做同样的事呢?

如果单元测试的代码和主要代码一样重要,那么设计时一定要考虑可扩展性,并尽可能使其易于维护。毕竟,这段代码将由原作者以外的工程师来维护,因此必须易读。

我们如此重视代码的灵活性的原因是,我们知道需求会随着时间的推移而改变和演变,最终随着领域业务规则的变化,我们的代码也将不得不改变以支持这些新需求。由于生产代码已经改变以支持新需求,测试代码也将不得不改变以支持生产代码的新版本。

在我们最初的示例中,我们为合并请求对象创建了一系列测试,尝试不同的组合并检查合并请求的状态。这是一个很好的第一步,但我们可以做得更好。

一旦我们更好地理解了问题,我们就可以开始创建更好的抽象。首先想到的是,我们可以创建一个检查特定条件的更高级抽象。例如,如果我们有一个专门针对MergeRequest类的测试套件对象,我们知道其功能将局限于这个类的行为(因为它应该符合 SRP),因此我们可以在这个测试类上创建特定的测试方法。这些方法只对这个类有意义,但可以帮助减少大量样板代码。

我们可以创建一个封装这一结构的断言的方法,并在所有测试中重复使用它,而不是重复断言:

class TestMergeRequestStatus(unittest.TestCase):
    def setUp(self):
        self.merge_request = MergeRequest()

    def assert_rejected(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestStatus.REJECTED
        )

    def assert_pending(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestStatus.PENDING
        )

    def assert_approved(self):
        self.assertEqual(
            self.merge_request.status, MergeRequestStatus.APPROVED
        )

    def test_simple_rejected(self):
        self.merge_request.downvote("maintainer")
        self.assert_rejected()

    def test_just_created_is_pending(self):
        self.assert_pending()

如果合并请求的状态检查发生变化(或者我们想要添加额外的检查),只有一个地方(assert_approved()方法)需要修改。更重要的是,通过创建这些更高级的抽象,最初只是单元测试的代码开始演变成可能最终成为具有自己 API 或领域语言的测试框架,使测试更具有声明性。

更多关于单元测试

通过我们迄今为止重新审视的概念,我们知道如何测试我们的代码,考虑我们的设计将如何进行测试,并配置项目中的工具来运行自动化测试,这将使我们对所编写软件的质量有一定程度的信心。

如果我们对代码的信心是由编写在其上的单元测试所决定的,那么我们如何知道它们足够了?我们怎么能确定我们已经在测试场景上经历了足够多的测试,而且没有漏掉一些测试?谁说这些测试是正确的?也就是说,谁来测试这些测试?

关于我们编写的测试是否彻底的问题的第一部分,通过基于属性的测试来超越我们的测试努力来回答。

问题的第二部分可能会有不同的观点给出多个答案,但我们将简要提到变异测试作为确定我们的测试确实是正确的手段。在这方面,我们认为单元测试检查我们的主要生产代码,这也对单元测试起到了控制作用。

基于属性的测试

基于属性的测试包括生成测试用例的数据,目的是找到会使代码失败的情景,而这些情景在我们之前的单元测试中没有涵盖。

这个主要的库是hypothesis,它与我们的单元测试一起配置,将帮助我们找到会使我们的代码失败的问题数据。

我们可以想象这个库的作用是找到我们代码的反例。我们编写我们的生产代码(以及针对它的单元测试!),并声称它是正确的。现在,通过这个库,我们定义了一些必须满足我们代码的hypothesis,如果有一些情况下我们的断言不成立,hypothesis将提供一组导致错误的数据。

单元测试最好的一点是它让我们更加深入地思考我们的生产代码。hypothesis最好的一点是它让我们更加深入地思考我们的单元测试。

变异测试

我们知道测试是我们确保代码正确的正式验证方法。那么是什么确保测试是正确的呢?你可能会想到生产代码,是的,在某种程度上这是正确的,我们可以将主要代码视为对我们测试的一个平衡。

编写单元测试的重点在于我们正在保护自己免受错误的侵害,并测试我们真的不希望在生产中发生的失败场景。测试通过是好事,但如果它们通过了错误的原因就不好了。也就是说,我们可以将单元测试用作自动回归工具——如果有人在代码中引入了错误,我们期望我们的至少一个测试能够捕捉到并失败。如果这没有发生,要么是缺少了一个测试,要么是我们已有的测试没有进行正确的检查。

这就是变异测试的理念。使用变异测试工具,代码将被修改为新版本(称为变异体),这些变异体是原始代码的变体,但其中一些逻辑被改变了(例如,操作符被交换,条件被倒置等)。一个良好的测试套件应该能够捕捉到这些变异体并将其消灭,这意味着我们可以依赖这些测试。如果一些变异体在实验中幸存下来,通常这是一个不好的迹象。当然,这并不是完全精确的,所以有一些中间状态我们可能想要忽略。

为了快速向您展示这是如何工作的,并让您对此有一个实际的想法,我们将使用一个不同版本的代码来计算合并请求的状态,这是基于批准和拒绝的数量。这一次,我们已经改变了代码,改为一个简单版本,根据这些数字返回结果。我们已经将包含状态常量的枚举移到一个单独的模块中,所以现在看起来更加紧凑:

# File mutation_testing_1.py
from mrstatus import MergeRequestStatus as Status

def evaluate_merge_request(upvote_count, downvotes_count):
    if downvotes_count > 0:
        return Status.REJECTED
    if upvote_count >= 2:
        return Status.APPROVED
    return Status.PENDING

现在我们将添加一个简单的单元测试,检查其中一个条件及其预期的“结果”:

# file: test_mutation_testing_1.py
class TestMergeRequestEvaluation(unittest.TestCase):
    def test_approved(self):
        result = evaluate_merge_request(3, 0)
        self.assertEqual(result, Status.APPROVED)

现在,我们将安装mutpy,一个用于 Python 的变异测试工具,使用pip install mutpy,并告诉它使用这些测试运行此模块的变异测试:

$ mut.py \
    --target mutation_testing_$N \
    --unit-test test_mutation_testing_$N \
    --operator AOD `# delete arithmetic operator` \
    --operator AOR `# replace arithmetic operator` \
    --operator COD `# delete conditional operator` \
    --operator COI `# insert conditional operator` \
    --operator CRP `# replace constant` \
    --operator ROR `# replace relational operator` \
    --show-mutants

结果将会看起来类似于这样:

[*] Mutation score [0.04649 s]: 100.0%
 - all: 4
 - killed: 4 (100.0%)
 - survived: 0 (0.0%)
 - incompetent: 0 (0.0%)
 - timeout: 0 (0.0%)

这是一个好迹象。让我们拿一个特定的实例来分析发生了什么。输出中的一行显示了以下变异体:

 - [# 1] ROR mutation_testing_1:11 : 
------------------------------------------------------
 7: from mrstatus import MergeRequestStatus as Status
 8: 
 9: 
 10: def evaluate_merge_request(upvote_count, downvotes_count):
~11:     if downvotes_count < 0:
 12:         return Status.REJECTED
 13:     if upvote_count >= 2:
 14:         return Status.APPROVED
 15:     return Status.PENDING
------------------------------------------------------
[0.00401 s] killed by test_approved (test_mutation_testing_1.TestMergeRequestEvaluation)

请注意,这个变异体由原始版本和第 11 行中操作符改变(>改为<)组成,结果告诉我们这个变异体被测试杀死了。这意味着使用这个代码版本(假设有人错误地进行了这个更改),函数的结果将是APPROVED,而测试期望它是REJECTED,所以测试失败,这是一个好迹象(测试捕捉到了引入的错误)。

变异测试是确保单元测试质量的一种好方法,但它需要一些努力和仔细的分析。在复杂的环境中使用这个工具,我们将不得不花一些时间分析每个场景。同样,运行这些测试是昂贵的,因为它需要运行不同版本的代码,这可能会占用太多资源并且可能需要更长的时间来完成。然而,手动进行这些检查会更加昂贵,并且需要更多的努力。不进行这些检查可能会更加危险,因为我们会危及测试的质量。

测试驱动开发简介

有一些专门讲述 TDD 的书籍,所以在这本书中全面涵盖这个话题是不现实的。然而,这是一个非常重要的话题,必须提到。

TDD 的理念是在编写生产代码之前编写测试,以便生产代码只是为了响应由于功能缺失而失败的测试而编写的。

我们希望先编写测试,然后编写代码的原因有多个。从实用的角度来看,我们会相当准确地覆盖我们的生产代码。由于所有的生产代码都是为了响应单元测试而编写的,很少会有功能缺失的测试(当然这并不意味着有 100%的覆盖率,但至少所有的主要函数、方法或组件都会有各自的测试,即使它们并不完全覆盖)。

这个工作流程很简单,高层次上包括三个步骤。首先,我们编写一个描述需要实现的单元测试。当我们运行这个测试时,它会失败,因为这个功能还没有被实现。然后,我们开始实现满足条件的最小代码,并再次运行测试。这次,测试应该通过。现在,我们可以改进(重构)代码。

这个循环被称为著名的红-绿-重构,意思是一开始测试失败(红色),然后我们让它们通过(绿色),然后我们进行重构并迭代。

总结

单元测试是一个非常有趣和深刻的话题,但更重要的是,它是清晰代码的关键部分。最终,单元测试决定了代码的质量。单元测试通常作为代码的镜子——当代码易于测试时,它是清晰和正确设计的,这将反映在单元测试中。

单元测试的代码和生产代码一样重要。所有适用于生产代码的原则也适用于单元测试。这意味着它们应该以同样的努力和深思熟虑来设计和维护。如果我们不关心我们的单元测试,它们将开始出现问题并变得有缺陷(或有问题),结果就是无用的。如果发生这种情况,它们很难维护,就会成为一个负担,这会使情况变得更糟,因为人们会倾向于忽视它们或完全禁用它们。这是最糟糕的情况,因为一旦发生这种情况,整个生产代码就会受到威胁。盲目前进(没有单元测试)是一种灾难。

幸运的是,Python 提供了许多用于单元测试的工具,无论是在标准库中还是通过pip可用。它们非常有帮助,花时间配置它们确实会在长远来看得到回报。

我们已经看到单元测试作为程序的正式规范以及软件按照规范工作的证明,我们也了解到在发现新的测试场景时,总是有改进的空间,我们总是可以创建更多的测试。在这个意义上,用不同的方法(比如基于属性的测试或变异测试)扩展我们的单元测试是一个很好的投资。

参考资料

以下是您可以参考的信息列表:

第九章:常见的设计模式

自从它们最初出现在著名的四人帮GoF)的书籍《设计模式:可复用面向对象软件的元素》中以来,设计模式一直是软件工程中的一个广泛讨论的话题。设计模式有助于解决一些常见的问题,这些问题是针对特定场景的抽象。当它们被正确实现时,解决方案的一般设计可以从中受益。

在本章中,我们将从最常见的设计模式的角度来看,但不是从在特定条件下应用工具的角度(一旦设计出模式),而是分析设计模式如何有助于编写清晰的代码。在介绍实现设计模式的解决方案后,我们将分析最终实现与选择不同路径相比是如何更好的。

在这个分析的过程中,我们将看到如何在 Python 中具体实现设计模式。由此产生的结果是,我们将看到 Python 的动态特性意味着在实现上存在一些差异,与其他静态类型的语言相比,许多设计模式最初是针对这些语言而设计的。这意味着在涉及 Python 时,设计模式有一些特殊之处,你应该记住,有些情况下,试图应用一个设计模式,而它实际上并不适用于 Python,是不符合 Python 风格的。

在本章中,我们将涵盖以下主题:

  • 常见的设计模式。

  • 在 Python 中不适用的设计模式,以及应该遵循的惯用替代方案。

  • 在 Python 中实现最常见的设计模式的 Python 风格。

  • 理解良好的抽象是如何自然地演变成模式的。

Python 中的设计模式考虑事项

面向对象的设计模式是软件构建的想法,在解决问题模型时出现在不同的场景中。因为它们是高层次的想法,很难将它们视为与特定编程语言相关联。相反,它们更多是关于对象在应用程序中如何交互的一般概念。当然,它们会有它们的实现细节,从语言到语言会有所不同,但这并不构成设计模式的本质。

这是设计模式的理论方面,它是一个关于解决方案中对象布局的抽象概念。关于面向对象设计和设计模式的其他书籍和资源有很多,所以在本书中,我们将专注于 Python 的这些实现细节。

鉴于 Python 的特性,一些经典的设计模式实际上并不需要。这意味着 Python 已经支持了使这些模式不再需要的功能。有人认为它们在 Python 中不存在,但请记住,不可见并不意味着不存在。它们确实存在,只是嵌入在 Python 本身中,所以我们可能甚至不会注意到它们。

其他的实现方式要简单得多,这要归功于语言的动态特性,其余的实现方式在其他平台上几乎是一样的,只有细微的差异。

无论如何,在 Python 中实现清晰的代码的重要目标是知道要实现哪些模式以及如何实现。这意味着要识别 Python 已经抽象出来的一些模式以及我们如何利用它们。例如,尝试实现迭代器模式的标准定义(就像我们在其他语言中所做的那样)是完全不符合 Python 风格的,因为(正如我们已经讨论过的)迭代在 Python 中已经深深嵌入,我们可以创建的对象可以直接在for循环中工作,这是正确的做法。

一些创建型模式也存在类似的情况。在 Python 中,类是常规对象,函数也是。正如我们之前在几个示例中看到的那样,它们可以被传递、装饰、重新分配等。这意味着无论我们想对对象进行什么样的定制,我们很可能可以在不需要任何特定的工厂类设置的情况下完成。此外,在 Python 中没有创建对象的特殊语法(例如没有 new 关键字)。这也是为什么大多数情况下,简单的函数调用就可以作为工厂。

其他模式仍然是必需的,我们将看到如何通过一些小的调整,使它们更符合 Python 的特点,充分利用语言提供的特性(魔术方法或标准库)。

在所有可用的模式中,并非所有模式都同样频繁,也不同样有用,因此我们将专注于主要的模式,那些我们期望在我们的应用程序中最常见的模式,并且我们将通过实用的方法来做到这一点。

设计模式的实际应用

作为 GoF 所写的这个主题的权威参考,介绍了 23 种设计模式,每一种都属于创建型、结构型和行为型中的一种。甚至还有更多的模式或现有模式的变体,但我们不应该死记所有这些模式,而是应该专注于牢记两件事。一些模式在 Python 中是看不见的,我们可能在不知不觉中使用它们。其次,并非所有模式都同样常见;其中一些模式非常有用,因此它们经常出现,而其他模式则更适用于特定情况。

在本节中,我们将重新审视最常见的模式,这些模式最有可能从我们的设计中出现。请注意这里使用了“出现”这个词。这很重要。我们不应该强制将设计模式应用于我们正在构建的解决方案,而是应该演变、重构和改进我们的解决方案,直到出现一个模式。

因此,设计模式并非是被发明出来的,而是被发现的。当我们的代码中反复出现的情况揭示出来时,类、对象和相关组件的一般和更抽象的布局以一种名称出现,我们通过这个名称来识别一个模式。

思考同样的事情,但现在是向后看,我们意识到设计模式的名称包含了许多概念。这可能是设计模式最好的地方;它们提供了一种语言。通过设计模式,更容易有效地传达设计思想。当两个或更多的软件工程师共享相同的词汇时,其中一个提到构建器,其他人可以立即想到所有的类,它们之间的关系,它们的机制等,而无需再次重复这个解释。

读者会注意到,本章中显示的代码与所讨论的设计模式的规范或原始构想不同。这有不止一个原因。第一个原因是示例采用了更加务实的方法,旨在解决特定场景的问题,而不是探索一般的设计理论。第二个原因是这些模式是根据 Python 的特点实现的,在某些情况下可能非常微妙,但在其他情况下,差异是明显的,通常简化了代码。

创建型模式

在软件工程中,创建型模式处理对象实例化,试图抽象掉大部分复杂性(比如确定初始化对象的参数,可能需要的所有相关对象等),以便为用户留下一个更简单、更安全的接口。对象创建的基本形式可能导致设计问题或增加设计的复杂性。创建型设计模式通过某种方式控制对象的创建来解决这个问题。

创建对象的五种模式中,我们将主要讨论用于避免单例模式并用 Borg 模式替代的变体(在 Python 应用程序中最常用),讨论它们的区别和优势。

工厂

正如在介绍中提到的,Python 的一个核心特性是一切都是对象,因此它们都可以平等对待。这意味着对类、函数或自定义对象没有特殊的区分。它们都可以作为参数传递、赋值等。

正因为如此,许多工厂模式实际上并不是真正需要的。我们只需简单地定义一个函数来构造一组对象,甚至可以通过参数传递要创建的类。

单例和共享状态(单态)

另一方面,单例模式并不是完全被 Python 抽象化的东西。事实上,大多数情况下,这种模式要么不是真正需要的,要么是一个糟糕的选择。单例存在很多问题(毕竟,它们实际上是面向对象软件的全局变量形式,因此是一种不好的实践)。它们很难进行单元测试,它们可能随时被任何对象修改,这使得它们很难预测,它们的副作用可能会带来真正的问题。

作为一个一般原则,我们应该尽量避免使用单例。如果在某些极端情况下需要它们,Python 中最简单的实现方式是使用模块。我们可以在一个模块中创建一个对象,一旦在那里,它将从模块的每个部分中可用。Python 本身确保模块已经是单例,无论它们被导入多少次,从多少地方导入,始终是相同的模块将被加载到sys.modules中。

共享状态

与其强制设计为只创建一个实例的单例,无论对象如何被调用、构造或初始化,还不如在多个实例之间复制数据。

单态模式(SNGMONO)的想法是我们可以有许多实例,它们只是普通对象,而不必关心它们是否是单例(因为它们只是对象)。这种模式的好处是这些对象的信息将以完全透明的方式同步,而无需我们担心这是如何在内部工作的。

这使得这种模式成为一个更好的选择,不仅因为它的便利性,而且因为它更少受到单例的缺点的影响(关于它们的可测试性、创建派生类等)。

我们可以在许多级别上使用这种模式,这取决于我们需要同步多少信息。

在最简单的形式中,我们可以假设只需要一个属性在所有实例中反映。如果是这种情况,实现就像使用一个类变量一样简单,我们只需要确保提供正确的接口来更新和检索属性的值。

假设我们有一个对象,必须通过最新的“标签”从 Git 存储库中拉取代码的版本。可能会有多个此对象的实例,当每个客户端调用获取代码的方法时,此对象将使用其属性中的“标签”版本。在任何时候,此“标签”都可以更新为更新版本,我们希望任何其他实例(新创建的或已创建的)在调用“获取”操作时使用这个新分支,如下面的代码所示:

class GitFetcher:
    _current_tag = None

    def __init__(self, tag):
        self.current_tag = tag

    @property
    def current_tag(self):
        if self._current_tag is None:
            raise AttributeError("tag was never set")
        return self._current_tag

    @current_tag.setter
    def current_tag(self, new_tag):
        self.__class__._current_tag = new_tag

    def pull(self):
        logger.info("pulling from %s", self.current_tag)
        return self.current_tag

读者只需验证创建具有不同版本的GitFetcher类型的多个对象将导致所有对象在任何时候都设置为最新版本,如下面的代码所示:

>>> f1 = GitFetcher(0.1)
>>> f2 = GitFetcher(0.2)
>>> f1.current_tag = 0.3
>>> f2.pull()
0.3
>>> f1.pull()
0.3

如果我们需要更多属性,或者希望更好地封装共享属性,使设计更清晰,我们可以使用描述符。

像下面代码中所示的描述符解决了问题,虽然它需要更多的代码,但它也封装了更具体的责任,部分代码实际上从我们的原始类中移开,使它们中的任何一个更具凝聚性和符合单一责任原则:

class SharedAttribute:
    def __init__(self, initial_value=None):
        self.value = initial_value
        self._name = None

    def __get__(self, instance, owner):
        if instance is None:
            return self
        if self.value is None:
            raise AttributeError(f"{self._name} was never set")
        return self.value

    def __set__(self, instance, new_value):
        self.value = new_value

    def __set_name__(self, owner, name):
        self._name = name

除了这些考虑因素外,模式现在更具可重用性。如果我们想重复这个逻辑,我们只需创建一个新的描述符对象,它将起作用(符合 DRY 原则)。

如果我们现在想做同样的事情,但是针对当前的分支,我们创建这个新的类属性,而类的其余部分保持不变,同时仍然具有所需的逻辑,如下面的代码所示:

class GitFetcher:
    current_tag = SharedAttribute()
    current_branch = SharedAttribute()

    def __init__(self, tag, branch=None):
        self.current_tag = tag
        self.current_branch = branch

    def pull(self):
        logger.info("pulling from %s", self.current_tag)
        return self.current_tag

这种新方法的平衡和权衡现在应该是清楚的。这种新实现使用了更多的代码,但它是可重用的,因此从长远来看它节省了代码行数(和重复的逻辑)。再次参考三个或更多实例的规则,以决定是否应该创建这样的抽象。

这种解决方案的另一个重要好处是它还减少了单元测试的重复。在这里重用代码将使我们对解决方案的整体质量更有信心,因为现在我们只需要为描述符对象编写单元测试,而不是为使用它的所有类编写单元测试(只要单元测试证明描述符是正确的,我们就可以安全地假设它们是正确的)。

borg 模式

前面的解决方案对大多数情况都适用,但如果我们真的必须使用单例(这必须是一个非常好的例外情况),那么还有一个更好的替代方案,尽管这是一个更有风险的选择。

这是实际的单态模式,在 Python 中被称为 borg 模式。其思想是创建一个对象,能够在同一类的所有实例之间复制其所有属性。绝对复制每个属性的事实必须警示我们要注意不良副作用。尽管如此,这种模式比单例模式有很多优点。

在这种情况下,我们将之前的对象分成两个部分——一个用于 Git 标签,另一个用于分支。我们使用的代码将使 borg 模式起作用:

class BaseFetcher:
    def __init__(self, source):
        self.source = source

class TagFetcher(BaseFetcher):
    _attributes = {}

    def __init__(self, source):
        self.__dict__ = self.__class__._attributes
        super().__init__(source)

    def pull(self):
        logger.info("pulling from tag %s", self.source)
        return f"Tag = {self.source}"

class BranchFetcher(BaseFetcher):
    _attributes = {}

    def __init__(self, source):
        self.__dict__ = self.__class__._attributes
        super().__init__(source)

    def pull(self):
        logger.info("pulling from branch %s", self.source)
        return f"Branch = {self.source}"

这两个对象都有一个基类,共享它们的初始化方法。但是它们必须再次实现它,以使 borg 逻辑起作用。其思想是使用一个类属性,它是一个字典,用于存储属性,然后使每个对象的字典(在初始化时)使用这个完全相同的字典。这意味着对一个对象的字典的任何更新都会反映在类中,因为它们的类是相同的,而字典是可变对象,是作为引用传递的。换句话说,当我们创建这种类型的新对象时,它们都将使用相同的字典,并且这个字典会不断更新。

请注意,我们不能将字典的逻辑放在基类上,因为这将使不同类的对象混合值,这不是我们想要的。这种样板解决方案会让许多人认为这实际上是一种习惯用语,而不是一种模式。

实现 DRY 原则的一种可能的抽象方式是创建一个 mixin 类,如下面的代码所示:

class SharedAllMixin:
    def __init__(self, *args, **kwargs):
        try:
            self.__class__._attributes
        except AttributeError:
            self.__class__._attributes = {}

        self.__dict__ = self.__class__._attributes
        super().__init__(*args, **kwargs)

class BaseFetcher:
    def __init__(self, source):
        self.source = source

class TagFetcher(SharedAllMixin, BaseFetcher):
    def pull(self):
        logger.info("pulling from tag %s", self.source)
        return f"Tag = {self.source}"

class BranchFetcher(SharedAllMixin, BaseFetcher):
    def pull(self):
        logger.info("pulling from branch %s", self.source)
        return f"Branch = {self.source}"

这一次,我们使用 mixin 类在每个类中创建具有属性的字典(如果它尚不存在),然后继续相同的逻辑。

这种实现在继承方面不应该有任何主要问题,因此这是一个更可行的替代方案。

建造者

建造者模式是一个有趣的模式,它抽象了对象的所有复杂初始化。这种模式不依赖于语言的任何特性,因此在 Python 中同样适用于任何其他语言。

虽然它解决了一个有效的情况,但通常也是一个更可能出现在框架、库或 API 设计中的复杂情况。与描述符的建议类似,我们应该将这种实现保留给我们期望公开的 API 将被多个用户使用的情况。

这种模式的高层思想是,我们需要创建一个复杂的对象,这个对象也需要许多其他对象一起工作。我们不希望让用户创建所有这些辅助对象,然后将它们分配给主要对象,而是希望创建一个抽象,允许所有这些在一个步骤中完成。为了实现这一点,我们将有一个构建对象,它知道如何创建所有部分并将它们链接在一起,给用户一个接口(可能是一个类方法),以参数化有关所得对象应该看起来像什么的所有信息。

结构模式

结构模式对于需要创建更简单的接口或通过扩展功能而使对象更强大而不增加接口复杂性的情况非常有用。

这些模式最好的地方在于我们可以创建更有趣的对象,具有增强的功能,并且可以以一种干净的方式实现这一点;也就是说,通过组合多个单一对象(这个最清晰的例子就是组合模式),或者通过收集许多简单而紧密的接口。

适配器

适配器模式可能是最简单的设计模式之一,同时也是最有用的设计模式之一。也被称为包装器,这种模式解决了两个或多个不兼容对象的接口适配问题。

通常情况下,我们的代码的一部分与一个模型或一组类一起工作,这些类在某个方法方面是多态的。例如,如果有多个对象用于检索具有fetch()方法的数据,那么我们希望保持这个接口,这样我们就不必对我们的代码进行重大更改。

但是,当我们需要添加一个新的数据源时,这个数据源却没有fetch()方法。更糟糕的是,这种类型的对象不仅不兼容,而且也不是我们控制的(也许是另一个团队决定了 API,我们无法修改代码)。

我们不直接使用这个对象,而是将其接口采用到我们需要的接口上。有两种方法可以做到这一点。

第一种方法是创建一个从我们想要使用的类继承的类,并为该方法创建一个别名(如果需要,还必须调整参数和签名)。

通过继承,我们导入外部类并创建一个新类,该类将定义新方法,调用具有不同名称的方法。在这个例子中,假设外部依赖项有一个名为search()的方法,它只接受一个参数进行搜索,因为它以不同的方式查询,所以我们的adapter方法不仅调用外部方法,而且还相应地转换参数,如下面的代码所示:

from _adapter_base import UsernameLookup

class UserSource(UsernameLookup):
    def fetch(self, user_id, username):
        user_namespace = self._adapt_arguments(user_id, username)
        return self.search(user_namespace)

    @staticmethod
    def _adapt_arguments(user_id, username):
        return f"{user_id}:{username}"

也许我们的类已经从另一个类派生出来了,在这种情况下,这将成为多重继承的情况,Python 支持这一点,所以这不应该是一个问题。然而,正如我们以前多次看到的那样,继承会带来更多的耦合(谁知道有多少其他方法是从外部库中继承而来的?),而且它是不灵活的。从概念上讲,这也不是正确的选择,因为我们将继承保留给规范的情况(一种是一个的关系),在这种情况下,我们完全不清楚我们的对象是否必须是第三方库提供的那种对象之一(特别是因为我们并不完全理解那个对象)。

因此,更好的方法是使用组合。假设我们可以为我们的对象提供一个UsernameLookup的实例,那么代码就会变得很简单,只需在采用参数之前重定向请求,如下面的代码所示:

class UserSource:
    ...
    def fetch(self, user_id, username):
        user_namespace = self._adapt_arguments(user_id, username)
        return self.username_lookup.search(user_namespace)

如果我们需要采用多种方法,并且我们可以想出一种通用的方法来调整它们的签名,那么使用__getattr__()魔术方法将请求重定向到包装对象可能是值得的,但是像所有通用实现一样,我们应该小心不要给解决方案增加更多的复杂性。

组合

我们的程序中将有一些部分需要我们处理由其他对象组成的对象。我们有基本对象,具有明确定义的逻辑,然后我们将有其他容器对象,将一堆基本对象分组,挑战在于我们希望处理这两种对象(基本对象和容器对象)而不会注意到任何差异。

对象按树形层次结构组织,基本对象将是树的叶子,组合对象是中间节点。客户可能希望调用它们中的任何一个来获得调用的方法的结果。然而,组合对象将充当客户端;它也将传递这个请求以及它包含的所有对象,无论它们是叶子还是其他中间节点,直到它们都被处理。

想象一个在线商店的简化版本,在这个商店里我们有产品。假设我们提供了将这些产品分组的可能性,并且我们给顾客每组产品提供折扣。产品有一个价格,当顾客来付款时,就会要求这个价格。但是一组分组的产品也有一个必须计算的价格。我们将有一个代表这个包含产品的组的对象,并且将责任委托给每个特定产品询问价格(这个产品也可能是另一组产品),等等,直到没有其他东西需要计算。这个实现如下代码所示:

class Product:
    def __init__(self, name, price):
        self._name = name
        self._price = price

    @property
    def price(self):
        return self._price

class ProductBundle:
    def __init__(
        self,
        name,
        perc_discount,
        *products: Iterable[Union[Product, "ProductBundle"]]
    ) -> None:
        self._name = name
        self._perc_discount = perc_discount
        self._products = products

    @property
    def price(self):
        total = sum(p.price for p in self._products)
        return total * (1 - self._perc_discount)

我们通过一个属性公开公共接口,并将price作为私有属性。ProductBundle类使用这个属性来计算值,并首先添加它包含的所有产品的价格。

这些对象之间唯一的差异是它们是用不同的参数创建的。为了完全兼容,我们应该尝试模仿相同的接口,然后添加额外的方法来向包中添加产品,但使用一个允许创建完整对象的接口。不需要这些额外的步骤是一个可以证明这个小差异的优势。

装饰器

不要将装饰器模式与我们在第五章中介绍的 Python 装饰器的概念混淆,使用装饰器改进我们的代码。它们有一些相似之处,但设计模式的想法是完全不同的。

这种模式允许我们动态扩展一些对象的功能,而不需要继承。这是创建更灵活对象的多重继承的一个很好的替代方案。

我们将创建一个结构,让用户定义要应用于对象的一组操作(装饰),并且我们将看到每个步骤按指定顺序进行。

以下代码示例是一个以参数形式从传递给它的参数构造字典形式的查询的对象的简化版本(例如,它可能是我们用于运行到 elasticsearch 的查询的对象,但代码省略了分散注意力的实现细节,以便专注于模式的概念)。

在其最基本的形式中,查询只返回创建时提供的数据的字典。客户端期望使用此对象的render()方法。

class DictQuery:
    def __init__(self, **kwargs):
        self._raw_query = kwargs

    def render(self) -> dict:
        return self._raw_query

现在我们想通过对数据应用转换来以不同的方式呈现查询(过滤值,标准化等)。我们可以创建装饰器并将它们应用于render方法,但这不够灵活,如果我们想在运行时更改它们怎么办?或者如果我们只想选择其中一些,而不选择其他一些呢?

设计是创建另一个对象,具有相同的接口和通过多个步骤增强(装饰)原始结果的能力,但可以组合。这些对象被链接在一起,每个对象都会做最初应该做的事情,再加上其他一些东西。这些其他东西就是特定的装饰步骤。

由于 Python 具有鸭子类型,我们不需要创建一个新的基类,并使这些新对象成为该层次结构的一部分,以及DictQuery。只需创建一个具有render()方法的新类就足够了(再次,多态性不应该需要继承)。这个过程在下面的代码中显示:

class QueryEnhancer:
    def __init__(self, query: DictQuery):
        self.decorated = query

    def render(self):
        return self.decorated.render()

class RemoveEmpty(QueryEnhancer):
    def render(self):
        original = super().render()
        return {k: v for k, v in original.items() if v}

class CaseInsensitive(QueryEnhancer):
    def render(self):
        original = super().render()
        return {k: v.lower() for k, v in original.items()}

QueryEnhancer短语具有与DictQuery的客户端期望的接口兼容的接口,因此它们是可互换的。这个对象被设计为接收一个装饰过的对象。它将从中获取值并将其转换,返回代码的修改版本。

如果我们想要删除所有评估为False的值并将它们标准化以形成我们的原始查询,我们将不得不使用以下模式:

>>> original = DictQuery(key="value", empty="", none=None, upper="UPPERCASE", title="Title")
>>> new_query = CaseInsensitive(RemoveEmpty(original))
>>> original.render()
{'key': 'value', 'empty': '', 'none': None, 'upper': 'UPPERCASE', 'title': 'Title'}
>>> new_query.render()
{'key': 'value', 'upper': 'uppercase', 'title': 'title'}

这是一个我们也可以以不同方式实现的模式,利用 Python 的动态特性和函数是对象的事实。我们可以使用提供给基本装饰器对象(QueryEnhancer)的函数来实现这种模式,并将每个装饰步骤定义为一个函数,如下面的代码所示:

class QueryEnhancer:
    def __init__(
        self,
        query: DictQuery,
        *decorators: Iterable[Callable[[Dict[str, str]], Dict[str, str]]]
    ) -> None:
        self._decorated = query
        self._decorators = decorators

    def render(self):
        current_result = self._decorated.render()
        for deco in self._decorators:
            current_result = deco(current_result)
        return current_result

就客户端而言,由于这个类通过其render()方法保持兼容性,因此没有改变。但在内部,这个对象的使用方式略有不同,如下面的代码所示:

>>> query = DictQuery(foo="bar", empty="", none=None, upper="UPPERCASE", title="Title")
>>> QueryEnhancer(query, remove_empty, case_insensitive).render()
{'foo': 'bar', 'upper': 'uppercase', 'title': 'title'}

在前面的代码中,remove_emptycase_insensitive只是转换字典的常规函数。

在这个例子中,基于函数的方法似乎更容易理解。可能存在更复杂的规则,这些规则依赖于被装饰对象的数据(不仅仅是其结果),在这种情况下,可能值得采用面向对象的方法,特别是如果我们真的想要创建一个对象层次结构,其中每个类实际上代表了我们想要在设计中明确表示的某些知识。

外观

Facade 是一个很好的模式。它在许多情况下都很有用,当我们想要简化对象之间的交互时。该模式适用于多个对象之间存在多对多关系,并且我们希望它们进行交互。我们不是创建所有这些连接,而是在它们前面放置一个作为外观的中间对象。

门面在这个布局中充当一个中心或单一的参考点。每当一个新对象想要连接到另一个对象时,它不需要为所有可能连接到的N个对象拥有N个接口,而是只需与门面交谈,门面会相应地重定向请求。门面后面的一切对外部对象完全不透明。

除了主要和明显的好处(对象的解耦),这种模式还鼓励更简单的设计,更少的接口和更好的封装。

这是一个我们不仅可以用来改进我们领域问题的代码,还可以用来创建更好的 API 的模式。如果我们使用这种模式并提供一个单一的接口,作为我们代码的单一真相点或入口点,那么我们的用户与暴露的功能交互将会更容易。不仅如此,通过暴露一个功能并隐藏一切在接口后面,我们可以自由地改变或重构底层代码,因为只要它在门面后面,它就不会破坏向后兼容性,我们的用户也不会受到影响。

注意,使用门面的这个想法不仅仅局限于对象和类,还适用于包(在技术上,包在 Python 中也是对象,但仍然)。我们可以使用门面的这个想法来决定包的布局;即,对用户可见和可导入的内容,以及内部的内容,不应该直接导入。

当我们创建一个目录来构建一个包时,我们将__init__.py文件与其余文件放在一起。这是模块的根,一种门面。其余的文件定义要导出的对象,但它们不应该被客户端直接导入。init文件应该导入它们,然后客户端应该从那里获取它们。这样创建了一个更好的接口,因为用户只需要知道一个单一的入口点来获取对象,更重要的是,包(其余的文件)可以根据需要进行重构或重新排列,只要init文件上的主要 API 得到维护,这不会影响客户端。牢记这样的原则是非常重要的,以构建可维护的软件。

Python 本身就有一个例子,使用os模块。这个模块将操作系统的功能分组在一起,但在底层,它使用posix模块来处理可移植操作系统接口POSIX)操作系统(在 Windows 平台上称为nt)。这个想法是,出于可移植性的原因,我们不应该直接导入posix模块,而应该始终导入os模块。这个模块要确定它被调用的平台,并公开相应的功能。

行为模式

行为模式旨在解决对象应该如何合作,它们应该如何通信,以及运行时它们的接口应该是什么的问题。

我们主要讨论以下行为模式:

  • 责任链

  • 模板方法

  • 命令

  • 状态

这可以通过继承静态地实现,也可以通过组合动态地实现。无论模式使用什么,我们将在接下来的例子中看到,这些模式的共同之处在于,最终的代码在某种重要的方式上更好,无论是因为它避免了重复,还是因为它创建了良好的抽象,封装了相应的行为,并解耦了我们的模型。

责任链

现在我们要再次审视我们的事件系统。我们想要从日志行(例如从我们的 HTTP 应用服务器转储的文本文件)中解析系统上发生的事件的信息,并以一种方便的方式提取这些信息。

在我们先前的实现中,我们实现了一个有趣的解决方案,符合开闭原则,并依赖于使用__subclasses__()魔术方法来发现所有可能的事件类型,并使用正确的事件处理数据,通过每个类上封装的方法解决责任。

这个解决方案对我们的目的是有效的,并且它是相当可扩展的,但正如我们将看到的,这种设计模式将带来额外的好处。

这里的想法是,我们将以稍微不同的方式创建事件。每个事件仍然具有确定是否可以处理特定日志行的逻辑,但它还将具有一个后继者。这个后继者是一个新的事件,是行中的下一个事件,它将继续处理文本行,以防第一个事件无法这样做。逻辑很简单——我们链接这些事件,每个事件都尝试处理数据。如果可以,它就返回结果。如果不能,它将把它传递给它的后继者并重复,如下所示的代码:

import re

class Event:
    pattern = None

    def __init__(self, next_event=None):
        self.successor = next_event

    def process(self, logline: str):
        if self.can_process(logline):
            return self._process(logline)

        if self.successor is not None:
            return self.successor.process(logline)

    def _process(self, logline: str) -> dict:
        parsed_data = self._parse_data(logline)
        return {
            "type": self.__class__.__name__,
            "id": parsed_data["id"],
            "value": parsed_data["value"],
        }

    @classmethod
    def can_process(cls, logline: str) -> bool:
        return cls.pattern.match(logline) is not None

    @classmethod
    def _parse_data(cls, logline: str) -> dict:
        return cls.pattern.match(logline).groupdict()

class LoginEvent(Event):
    pattern = re.compile(r"(?P<id>\d+):\s+login\s+(?P<value>\S+)")

class LogoutEvent(Event):
    pattern = re.compile(r"(?P<id>\d+):\s+logout\s+(?P<value>\S+)")

通过这种实现,我们创建了event对象,并按照它们将被处理的特定顺序排列它们。由于它们都有一个process()方法,它们对于这个消息是多态的,所以它们被排列的顺序对于客户端来说是完全透明的,它们中的任何一个也是透明的。不仅如此,process()方法也具有相同的逻辑;它尝试提取信息,如果提供的数据对于处理它的对象类型是正确的,如果不是,它就继续到下一个对象。

这样,我们可以按以下方式process登录事件:

>>> chain = LogoutEvent(LoginEvent())
>>> chain.process("567: login User")
{'type': 'LoginEvent', 'id': '567', 'value': 'User'}

注意LogoutEvent作为其后继者接收了LoginEvent,当它被要求处理无法处理的内容时,它会重定向到正确的对象。从字典的type键上可以看出,LoginEvent实际上是创建了该字典的对象。

这个解决方案足够灵活,并且与我们先前的解决方案共享一个有趣的特性——所有条件都是互斥的。只要没有冲突,没有数据有多个处理程序,以任何顺序处理事件都不会成为问题。

但是如果我们不能做出这样的假设呢?通过先前的实现,我们仍然可以将__subclasses__()调用更改为根据我们的标准制作的列表,这样也可以正常工作。如果我们希望优先顺序在运行时(例如由用户或客户端)确定呢?那将是一个缺点。

有了新的解决方案,我们可以实现这样的要求,因为我们在运行时组装链条,所以我们可以根据需要动态地操纵它。

例如,现在我们添加了一个通用类型,将登录和注销会话事件分组,如下所示的代码:

class SessionEvent(Event):
    pattern = re.compile(r"(?P<id>\d+):\s+log(in|out)\s+(?P<value>\S+)")

如果由于某种原因,在应用程序的某个部分,我们希望在登录事件之前捕获这个,可以通过以下chain来实现:

chain = SessionEvent(LoginEvent(LogoutEvent()))

通过改变顺序,我们可以,例如,说一个通用会话事件比登录事件具有更高的优先级,但不是注销事件,依此类推。

这种模式与对象一起工作的事实使它相对于我们先前的依赖于类的实现更加灵活(虽然它们在 Python 中仍然是对象,但它们并不排除一定程度的刚性)。

模板方法

template方法是一种在正确实现时产生重要好处的模式。主要是,它允许我们重用代码,而且还使我们的对象更灵活,更容易改变,同时保持多态性。

这个想法是,有一个类层次结构,定义了一些行为,比如说它的公共接口中的一个重要方法。层次结构中的所有类共享一个公共模板,并且可能只需要更改其中的某些元素。因此,这个想法是将这个通用逻辑放在父类的公共方法中,该方法将在内部调用所有其他(私有)方法,而这些方法是派生类将要修改的方法;因此,模板中的所有逻辑都被重用。

热心的读者可能已经注意到,我们在上一节中已经实现了这种模式(作为责任链示例的一部分)。请注意,从Event派生的类只实现了它们特定的模式。对于其余的逻辑,模板在Event类中。process事件是通用的,并依赖于两个辅助方法can_process()process()(后者又调用_parse_data())。

这些额外的方法依赖于类属性模式。因此,为了用新类型的对象扩展这个模式,我们只需要创建一个新的派生类并放置正则表达式。之后,其余的逻辑将继承这个新属性的变化。这样做可以重用大量的代码,因为处理日志行的逻辑只在父类中定义一次。

这使得设计变得灵活,因为保持多态性也很容易实现。如果我们需要一个新的事件类型,由于某种原因需要以不同的方式解析数据,我们只需要在子类中覆盖这个私有方法,兼容性将得到保持,只要它返回与原始类型相同的类型(符合 Liskov 的替换和开闭原则)。这是因为是父类调用派生类的方法。

如果我们正在设计自己的库或框架,这种模式也很有用。通过这种方式安排逻辑,我们使用户能够相当容易地改变其中一个类的行为。他们只需要创建一个子类并覆盖特定的私有方法,结果将是一个具有新行为的新对象,保证与原始对象的调用者兼容。

命令

命令模式为我们提供了将需要执行的操作与请求执行的时刻分开的能力。更重要的是,它还可以将客户端发出的原始请求与接收者分开,接收者可能是一个不同的对象。在本节中,我们将主要关注模式的第一个方面;我们可以将命令如何运行与它实际执行的时刻分开。

我们知道我们可以通过实现__call__()魔术方法来创建可调用对象,因此我们可以初始化对象,然后以后再调用它。事实上,如果这是唯一的要求,我们甚至可以通过一个嵌套函数来实现这一点,通过闭包创建另一个函数来实现延迟执行的效果。但是这种模式可以扩展到不那么容易实现的地方。

这个想法是命令在定义后也可以被修改。这意味着客户端指定要运行的命令,然后可能更改一些参数,添加更多选项等,直到最终有人决定执行这个动作。

这种情况的例子可以在与数据库交互的库中找到。例如,在psycopg2(一个 PostgreSQL 客户端库)中,我们建立一个连接。从这个连接中,我们得到一个游标,然后我们可以向这个游标传递要运行的 SQL 语句。当我们调用execute方法时,对象的内部表示会发生变化,但实际上并没有在数据库中运行任何东西。只有当我们调用fetchall()(或类似的方法)时,数据才会被查询并在游标中可用。

在流行的对象关系映射 SQLAlchemyORM SQLAlchemy)中也是如此。查询是通过几个步骤定义的,一旦我们有了query对象,我们仍然可以与之交互(添加或删除过滤器,更改条件,申请排序等),直到我们决定要查询的结果。在调用每个方法之后,query对象会改变其内部属性并返回self(它自己)。

这些都是类似我们想要实现的行为的示例。创建这种结构的一个非常简单的方法是拥有一个对象,该对象存储要运行的命令的参数。之后,它还必须提供与这些参数交互的方法(添加或删除过滤器等)。可选地,我们可以向该对象添加跟踪或日志记录功能,以审计已经发生的操作。最后,我们需要提供一个实际执行操作的方法。这个方法可以是__call__()或自定义的方法。让我们称之为do()

状态

状态模式是软件设计中具体化的一个明显例子,使我们的领域问题的概念成为一个显式对象,而不仅仅是一个边值。

在第八章中,单元测试和重构,我们有一个代表合并请求的对象,并且它有一个与之关联的状态(打开、关闭等)。我们使用枚举来表示这些状态,因为在那时,它们只是保存特定状态的字符串表示的数据。如果它们需要有一些行为,或者整个合并请求需要根据其状态和转换执行一些操作,这是不够的。

我们正在向代码的一部分添加行为,一个运行时结构,这让我们必须以对象的方式思考,因为毕竟这就是对象应该做的。这就是具体化的意义——现在状态不能只是一个带有字符串的枚举;它需要是一个对象。

想象一下,我们必须向合并请求添加一些规则,比如说,当它从打开状态变为关闭状态时,所有的批准都被移除(他们将不得不重新审查代码)——当合并请求刚刚打开时,批准的数量被设置为零(无论是重新打开的还是全新的合并请求)。另一个规则可能是,当合并请求被合并时,我们希望删除源分支,当然,我们还希望禁止用户执行无效的转换(例如,关闭的合并请求不能被合并等)。

如果我们把所有这些逻辑都放在一个地方,即MergeRequest类中,我们最终会得到一个责任很多(设计不佳)、可能有很多方法和非常多的if语句的类。很难跟踪代码并理解哪一部分应该代表哪个业务规则。

最好将这些分布到更小的对象中,每个对象负责更少的责任,状态对象是一个很好的地方。我们为要表示的每种状态创建一个对象,并在它们的方法中放置与上述规则的转换逻辑。然后,MergeRequest对象将有一个状态协作者,而这个协作者也将了解MergeRequest(需要双重分派机制来在MergeRequest上运行适当的操作并处理转换)。

我们定义一个基本的抽象类,其中包含要实现的方法集,然后为我们要表示的每种特定state创建一个子类。然后,MergeRequest对象将所有操作委托给state,如下面的代码所示:

class InvalidTransitionError(Exception):
    """Raised when trying to move to a target state from an unreachable 
    source
    state.
    """

class MergeRequestState(abc.ABC):
    def __init__(self, merge_request):
        self._merge_request = merge_request

    @abc.abstractmethod
    def open(self):
        ...

    @abc.abstractmethod
    def close(self):
        ...

    @abc.abstractmethod
    def merge(self):
        ...

    def __str__(self):
        return self.__class__.__name__

class Open(MergeRequestState):
    def open(self):
        self._merge_request.approvals = 0

    def close(self):
        self._merge_request.approvals = 0
        self._merge_request.state = Closed

    def merge(self):
        logger.info("merging %s", self._merge_request)
        logger.info("deleting branch %s", 
        self._merge_request.source_branch)
        self._merge_request.state = Merged

class Closed(MergeRequestState):
    def open(self):
        logger.info("reopening closed merge request %s", 
         self._merge_request)
        self._merge_request.state = Open

    def close(self):
        pass

    def merge(self):
        raise InvalidTransitionError("can't merge a closed request")

class Merged(MergeRequestState):
    def open(self):
        raise InvalidTransitionError("already merged request")

    def close(self):
        raise InvalidTransitionError("already merged request")

    def merge(self):
        pass

class MergeRequest:
    def __init__(self, source_branch: str, target_branch: str) -> None:
        self.source_branch = source_branch
        self.target_branch = target_branch
        self._state = None
        self.approvals = 0
        self.state = Open

    @property
    def state(self):
        return self._state

    @state.setter
    def state(self, new_state_cls):
        self._state = new_state_cls(self)

    def open(self):
        return self.state.open()

    def close(self):
        return self.state.close()

    def merge(self):
        return self.state.merge()

    def __str__(self):
        return f"{self.target_branch}:{self.source_branch}"

以下列表概述了一些关于实现细节和设计决策的澄清:

  • 状态是一个属性,因此不仅是公共的,而且有一个单一的地方定义了如何为合并请求创建状态,将self作为参数传递。

  • 抽象基类并不是严格需要的,但拥有它也有好处。首先,它使我们正在处理的对象类型更加明确。其次,它强制每个子状态实现接口的所有方法。对此有两种替代方案:

  • 我们本可以不放置方法,让AttributeError在尝试执行无效操作时引发,但这是不正确的,也不能表达发生了什么。

  • 与这一点相关的是,我们本可以只使用一个简单的基类并留下那些方法为空,但是这样做的默认行为并不清楚应该发生什么。如果子类中的某个方法应该什么都不做(如合并的情况),那么最好让空方法保持原样,并明确表示对于特定情况,不应该做任何事情,而不是强制所有对象都遵循这个逻辑。

  • MergeRequestMergeRequestState彼此之间有链接。一旦进行转换,前一个对象将不再有额外的引用,应该被垃圾回收,因此这种关系应该始终是 1:1。在一些小而更详细的考虑中,可以使用弱引用。

以下代码显示了如何使用对象的一些示例:

>>> mr = MergeRequest("develop", "master") 
>>> mr.open()
>>> mr.approvals
0
>>> mr.approvals = 3
>>> mr.close()
>>> mr.approvals
0
>>> mr.open()
INFO:log:reopening closed merge request master:develop
>>> mr.merge()
INFO:log:merging master:develop
INFO:log:deleting branch develop
>>> mr.close()
Traceback (most recent call last):
...
InvalidTransitionError: already merged request

状态转换的操作被委托给MergeRequest始终持有的state对象(这可以是ABC的任何子类)。它们都知道如何以不同的方式响应相同的消息,因此这些对象将根据每个转换采取相应的操作(删除分支、引发异常等),然后将MergeRequest移动到下一个状态。

由于MergeRequest将所有操作委托给其state对象,我们会发现每次需要执行的操作都是self.state.open()这种形式。我们能否删除一些样板代码?

我们可以通过__getattr__()来实现,如下面的代码所示:

class MergeRequest:
    def __init__(self, source_branch: str, target_branch: str) -> None:
        self.source_branch = source_branch
        self.target_branch = target_branch
        self._state: MergeRequestState
        self.approvals = 0
        self.state = Open

    @property
    def state(self):
        return self._state

    @state.setter
    def state(self, new_state_cls):
        self._state = new_state_cls(self)

    @property
    def status(self):
        return str(self.state)

    def __getattr__(self, method):
        return getattr(self.state, method)

    def __str__(self):
        return f"{self.target_branch}:{self.source_branch}"

一方面,我们重用一些代码并删除重复的行是好事。这使得抽象基类更有意义。在某个地方,我们希望将所有可能的操作记录下来,列在一个地方。那个地方过去是MergeRequest类,但现在这些方法都消失了,所以唯一剩下的真相来源是MergeRequestState。幸运的是,state属性上的类型注解对用户来说非常有帮助,可以知道在哪里查找接口定义。

用户可以简单地查看并了解MergeRequest没有的所有内容都将要求其state属性具有。从init定义中,注解会告诉我们这是MergeRequestState类型的对象,并通过查看此接口,我们将看到我们可以安全地要求其open()close()merge()方法。

空对象模式

空对象模式是与本书前几章提到的良好实践相关的一个想法。在这里,我们正在正式化它们,并为这个想法提供更多的背景和分析。

原则相当简单——函数或方法必须返回一致类型的对象。如果这得到保证,那么我们代码的客户端可以使用返回的对象进行多态,而无需对它们进行额外的检查。

在之前的例子中,我们探讨了 Python 的动态特性如何使大多数设计模式变得更容易。在某些情况下,它们完全消失,在其他情况下,它们更容易实现。设计模式最初的目标是,方法或函数不应该明确命名它们需要的对象的类。因此,它们提出了创建接口和重新排列对象的方法,使它们适应这些接口以修改设计。但在 Python 中大多数情况下,这是不需要的,我们可以只传递不同的对象,只要它们遵守必须具有的方法,解决方案就会起作用。

另一方面,对象不一定要遵守接口的事实要求我们更加小心,以确保从这些方法和函数返回的东西。就像我们的函数没有对它们接收到的东西做出任何假设一样,可以合理地假设我们代码的客户也不会做出任何假设(我们有责任提供兼容的对象)。这可以通过契约式设计来强制执行或验证。在这里,我们将探讨一种简单的模式,可以帮助我们避免这些问题。

考虑在前一节中探讨的责任链设计模式。我们看到了它有多么灵活以及它的许多优点,比如将责任解耦为更小的对象。它存在的问题之一是,我们实际上永远不知道哪个对象最终会处理消息,如果有的话。特别是在我们的例子中,如果没有合适的对象来处理日志行,那么该方法将简单地返回None

我们不知道用户将如何使用我们传递的数据,但我们知道他们期望得到一个字典。因此,可能会发生以下错误:

AttributeError: 'NoneType' object has no attribute 'keys'

在这种情况下,修复方法相当简单——process()方法的默认值应该是一个空字典,而不是None

确保返回一致类型的对象。

但是,如果该方法没有返回字典,而是我们领域的自定义对象呢?

为了解决这个问题,我们应该有一个代表该对象的空状态的类并返回它。如果我们有一个代表系统中用户的类,并且有一个按 ID 查询用户的函数,那么在找不到用户的情况下,它应该执行以下两种操作之一:

  • 引发异常

  • 返回一个UserUnknown类型的对象

但在任何情况下,它都不应该返回None。短语None并不代表刚刚发生的事情,调用者可能会合理地尝试向其请求方法,但会因为AttributeError而失败。

我们之前讨论过异常及其利弊,所以我们应该提到这个null对象应该只有与原始用户相同的方法,并对每个方法都不做任何操作。

使用这种结构的优势不仅在于我们在运行时避免了错误,而且这个对象可能是有用的。它可以使代码更容易测试,甚至可以帮助调试(也许我们可以在方法中放置日志以了解为什么达到了这种状态,提供了什么数据等)。

通过利用 Python 的几乎所有魔术方法,可以创建一个绝对什么都不做的通用null对象,无论如何调用它,但几乎可以从任何客户端调用。这样的对象略微类似于Mock对象。不建议走这条路,因为有以下原因:

  • 它失去了与领域问题的意义。回到我们的例子中,拥有UnknownUser类型的对象是有意义的,并且让调用者清楚地知道查询出了问题。

  • 它不尊重原始接口。这是有问题的。请记住,UnknownUser是一个用户,因此它必须具有相同的方法。如果调用者意外地要求不存在的方法,那么在这种情况下,它应该引发AttributeError异常,这是好的。使用通用的null对象,它可以做任何事情并对任何事情做出响应,我们将丢失这些信息,可能会引入错误。如果我们选择创建一个带有spec=UserMock对象,那么这种异常将被捕获,但再次使用Mock对象来表示实际上是一个空状态的东西会损害代码的意图。

这种模式是一个很好的实践,它允许我们在对象中保持多态性。

关于设计模式的最终想法

我们已经在 Python 中看到了设计模式的世界,并且在这样做时,我们找到了解决常见问题的解决方案,以及更多的技术,这些技术将帮助我们实现一个清晰的设计。

这些听起来都不错,但问题是,设计模式有多好呢?有人认为它们弊大于利,认为它们是为了那些类型系统有限(和缺乏一流函数)的语言而创建的,这些语言无法完成我们通常在 Python 中完成的事情。还有人声称设计模式强制了设计解决方案,产生了一些限制本来会出现的设计的偏见,而且本来会更好。让我们依次看看这些观点。

设计对设计的影响

设计模式,就像软件工程中的任何其他主题一样,本身并不是好坏之分,而是取决于它的实现方式。在某些情况下,实际上并不需要设计模式,一个更简单的解决方案就可以。试图在不适合的地方强行使用模式是一种过度设计的情况,这显然是不好的,但这并不意味着设计模式有问题,而且在这些情况下,问题很可能根本与模式无关。有些人试图过度设计一切,因为他们不理解灵活和适应性软件的真正含义。正如我们在本书中之前提到的,制作好的软件并不是关于预测未来的需求(进行未来学是没有意义的),而只是解决我们目前手头的问题,以一种不会阻止我们在将来对其进行更改的方式。它不必现在就处理这些变化;它只需要足够灵活,以便将来可以进行修改。当未来到来时,我们仍然必须记住三个或更多相同问题的实例才能提出通用解决方案或适当的抽象。

这通常是设计模式应该出现的时候,一旦我们正确识别了问题并能够识别模式并相应地抽象出来。

让我们回到模式与语言适应性的话题。正如我们在本章的介绍中所说,设计模式是高层次的想法。它们通常指的是对象及其相互作用的关系。很难想象这些东西会从一种语言消失到另一种语言。有些模式实际上是在 Python 中手动实现的,比如迭代器模式(正如本书前面大量讨论的那样,在 Python 中内置了迭代器模式),或者策略(因为我们可以像传递其他常规对象一样传递函数;我们不需要将策略方法封装到一个对象中,函数本身就是一个对象)。

但其他模式实际上是需要的,它们确实解决了问题,比如装饰器和组合模式。在其他情况下,Python 本身实现了设计模式,我们并不总是看到它们,就像我们在os部分讨论的外观模式一样。

至于我们的设计模式是否会导致我们的解决方案走向错误方向,我们在这里必须小心。再次强调,最好的做法是从领域问题的角度开始设计解决方案,创建正确的抽象,然后再看是否有设计模式从该设计中出现。假设确实有。那是一件坏事吗?已经有一个解决我们正在尝试解决的问题的解决方案这个事实不能是一件坏事。重复造轮子是坏事,这在我们的领域中经常发生。此外,应用一种已经被证明和验证的模式,应该让我们对我们正在构建的东西的质量更有信心。

我们模型中的名称

我们在代码中是否应该提到我们正在使用设计模式?

如果设计良好,代码干净,它应该自说明。不建议您根据您使用的设计模式来命名事物,原因有几个:

  • 我们代码的用户和其他开发人员不需要知道代码背后的设计模式,只要它按预期工作即可。

  • 说明设计模式会破坏意图揭示原则。在类名中添加设计模式的名称会使其失去部分原始含义。如果一个类代表一个查询,它应该被命名为QueryEnhancedQuery,以显示该对象应该执行的意图。 EnhancedQueryDecorator没有任何有意义的含义,Decorator后缀会带来更多混乱而不是清晰。

在文档字符串中提到设计模式可能是可以接受的,因为它们作为文档,并且在我们的设计中表达设计思想(再次交流)是一件好事。然而,这并不是必要的。大多数情况下,我们不需要知道设计模式在那里。

最好的设计是那些设计模式对用户完全透明的设计。一个例子是外观模式如何出现在标准库中,使用户完全透明地访问os模块。更优雅的例子是迭代器设计模式如何被语言完全抽象化,以至于我们甚至不必考虑它。

总结

设计模式一直被视为常见问题的成熟解决方案。这是一个正确的评估,但在本章中,我们从良好设计技术的角度探讨了它们,这些模式利用了干净的代码。在大多数情况下,我们看到它们如何提供了保留多态性、减少耦合和创建正确的抽象以封装所需细节的良好解决方案。所有这些特征都与第八章中探讨的概念相关,即“单元测试和重构”。

然而,设计模式最好的地方不是我们可以从应用它们中获得的干净设计,而是扩展的词汇。作为一种交流工具,我们可以使用它们的名称来表达我们设计的意图。有时,我们不需要应用整个模式,而是可能需要从我们的解决方案中采用特定的想法(例如子结构),在这里,它们也被证明是更有效地交流的一种方式。

当我们通过模式思考解决问题时,我们是在更一般的层面上解决问题。以设计模式思考,使我们更接近更高级别的设计。我们可以慢慢“放大”并更多地考虑架构。现在我们正在解决更一般的问题,是时候开始考虑系统如何在长期内发展和维护(如何扩展、改变、适应等)。

要使软件项目在这些目标中取得成功,它需要以干净的代码为核心,但架构也必须是干净的,这是我们将在下一章中讨论的内容。

参考资料

这里是一些你可以参考的信息列表:

  • GoF:由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 撰写的书籍,名为设计模式:可复用面向对象软件的元素

  • SNGMONO:一篇由 Robert C. Martin 于 2002 年撰写的文章,名为SINGLETON and MONOSTATE

  • 《空对象模式》,作者 Bobby Woolf

第十章:干净的架构

在本章的最后,我们将重点讨论如何在整个系统的设计中将所有内容整合在一起。这是一个更加理论化的章节。鉴于主题的性质,深入到更低级别的细节将会太复杂。此外,重点正是要摆脱这些细节,假设在前几章中探讨的所有原则都被吸收,并专注于大规模系统的设计。

本章的主要关注点和目标如下:

  • 设计可以长期维护的软件系统

  • 通过保持质量属性有效地在软件项目上工作

  • 研究所有概念如何应用于代码与系统的关系

从干净的代码到干净的架构

本节讨论了在前几章中强调的概念在考虑大型系统方面以稍微不同的形式重新出现。有趣的是,适用于更详细设计以及代码的概念也适用于大型系统和架构。

在前几章探讨的概念与单个应用程序相关,通常是一个项目,可能是一个或几个源代码控制版本系统(git)的单个存储库。这并不是说这些设计理念只适用于代码,或者在考虑架构时没有用处,有两个原因:代码是架构的基础,如果不小心编写,系统将会失败,无论架构设计得多么周密。

其次,前几章重新讨论的一些原则不适用于代码,而是设计理念。最清晰的例子来自设计模式。它们是高层次的想法。有了这个,我们可以快速了解我们架构中的一个组件可能是什么样子,而不用深入代码的细节。

但是大型企业系统通常由许多这些应用程序组成,现在是时候开始考虑更大规模的设计,以分布式系统的形式。

在接下来的章节中,我们将讨论本书中一直讨论的主要主题,但现在是从系统的角度来看。

关注点分离

在一个应用程序中,有多个组件。它们的代码被划分为其他子组件,如模块或包,模块被划分为类或函数,类被划分为方法。在整本书中,重点一直是尽可能保持这些组件尽可能小,特别是在函数的情况下——函数应该只做一件事,并且要小。

提出了几个理由来证明这种推理。小函数更容易理解、遵循和调试。它们也更容易测试。我们代码中的部分越小,编写单元测试就越容易。

对于每个应用程序的组件,我们希望具有不同的特征,主要是高内聚和低耦合。通过将组件划分为更小的单元,每个单元都有一个单一且明确定义的责任,我们实现了更好的结构,使得变更更容易管理。面对新的需求,将会有一个正确的地方来进行更改,而其余的代码可能不受影响。

当我们谈论代码时,我们说“组件”来指代这些内聚单元中的一个(例如可能是一个类)。在架构方面,组件意味着系统中可以作为一个工作单元对待的任何东西。组件本身这个术语相当模糊,因此在软件架构中没有普遍接受的更具体含义的定义。工作单元的概念是可以根据项目的不同而变化的。一个组件应该能够独立于系统的其余部分发布或部署。而它恰恰是系统的一部分,即整个应用程序。

对于 Python 项目,一个组件可以是一个包,但一个服务也可以是一个组件。请注意,两个不同的概念,具有不同级别的粒度,可以被视为同一类别。举个例子,我们在前几章中使用的事件系统可以被视为一个组件。它是一个具有明确定义目的(从日志中识别事件),可以独立部署(无论是作为 Python 包,还是如果我们公开其功能,作为一个服务),它是整个系统的一部分,但不是整个应用程序本身。

在前几章的例子中,我们已经看到了惯用代码,并且也强调了对我们的代码进行良好设计的重要性,具有单一明确定义的责任的对象,被隔离、正交并且更易于维护。这个非常相同的标准,适用于详细设计(函数、类、方法),也适用于软件架构的组件。

一个大型系统只是一个组件可能是不可取的。单体应用程序将作为唯一的真相来源,负责系统中的一切,并将带来许多不希望的后果(更难以隔离和识别变化,更难以有效测试等)。同样,如果我们不小心将所有东西放在一个地方,我们的代码也将更难以维护,如果其组件没有受到同等级别的关注,应用程序也将遭受类似的问题。

在系统中创建具有内聚力的组件的想法可能有多种实现方式,这取决于我们需要的抽象级别。

一个选择是识别可能多次重用的常见逻辑,并将其放在一个 Python 包中(我们将在本章后面讨论细节)。

另一种选择是将应用程序分解为多个较小的服务,采用微服务架构。这个想法是通过使这些服务合作和交换信息,实现具有单一和明确定义责任的组件,并通过这些服务实现与单体应用程序相同的功能。

抽象

这就是封装再次出现的地方。从我们的系统(就像我们在与代码相关的情况下一样),我们希望用领域问题的术语来交流,并尽可能隐藏实现细节。

就像代码必须表达(几乎到了自我记录的程度),并具有揭示基本问题解决方案的正确抽象(最小化偶发复杂性)一样,架构应该告诉我们系统的内容。诸如用于将数据持久化到磁盘的解决方案、首选的 Web 框架、用于连接到外部代理的库以及系统之间的交互等细节都不重要。重要的是系统的功能。像尖叫架构(SCREAM)这样的概念反映了这个想法。

依赖反转原则DIP),在第四章中解释的* SOLID 原则*,在这方面非常有帮助;我们不想依赖具体的实现,而是依赖抽象。在代码中,我们将抽象(或接口)放在边界上,依赖关系上,即应用程序的那些我们无法控制并且可能在未来发生变化的部分。我们这样做是因为我们想要反转依赖。让它们适应我们的代码(通过必须遵守一个接口),而不是相反。

创建抽象和反转依赖是良好的实践,但这还不够。我们希望整个应用程序独立并与我们无法控制的事物隔离。这甚至比仅仅使用对象进行抽象更重要——我们需要层层抽象。

这与详细设计有一个微妙但重要的区别。在 DIP 中,建议创建一个接口,可以使用标准库中的abc模块来实现。因为 Python 使用鸭子类型,虽然使用抽象类可能有所帮助,但并不是强制性的,因为只要它们符合所需的接口,我们可以很容易地用常规对象实现相同的效果。Python 的动态类型特性使我们能够有这些替代方案。在架构方面思考时,没有这样的东西。正如例子中将变得更加清晰的那样,我们需要完全抽象依赖关系,没有 Python 的特性可以为我们做到这一点。

有人可能会争论“ORM 是数据库的一个很好的抽象,不是吗?”这还不够。ORM 本身是一个依赖项,因此超出了我们的控制。最好创建一个中间层,一个适配器,介于 ORM 的 API 和我们的应用程序之间。

这意味着我们不仅仅用 ORM 来抽象数据库;我们使用我们在其上创建的抽象层,来定义属于我们领域的自己的对象。

然后应用程序导入这个组件,并使用这个层提供的实体,但不是反过来。抽象层不应该了解我们应用程序的逻辑;更确切地说,数据库不应该了解应用程序本身。如果是这样的话,数据库将与我们的应用程序耦合。目标是反转依赖关系——这个层提供一个 API,每个想要连接的存储组件都必须符合这个 API。这就是六边形架构的概念(HEX)。

软件组件

现在我们有一个庞大的系统,我们需要扩展它。它还必须是可维护的。此时的关注点不仅仅是技术上的,还包括组织上的。这意味着不仅仅是管理软件存储库;每个存储库很可能都属于一个应用程序,并且将由拥有该系统部分的团队维护。

这要求我们牢记一个大型系统是如何划分为不同组件的。这可以有许多阶段,从一个非常简单的方法,比如创建 Python 包,到微服务架构中的更复杂场景。

当涉及到不同的语言时,情况可能会更加复杂,但在本章中,我们将假设它们都是 Python 项目。

这些组件需要互相交互,团队也是如此。这在规模上能够起作用的唯一方式是,所有部分都同意一个接口,一个契约。

Python 包是一种方便的方式来分发软件并以更一般的方式重用代码。已构建的包可以发布到存储库(例如公司的内部 PyPi 服务器),其他需要它的应用程序将从中下载。

这种方法背后的动机有许多元素——它是关于在大范围内重用代码,并实现概念上的一致性。

在这里,我们讨论了打包 Python 项目的基础知识,这些项目可以发布到存储库中。默认存储库可能是 PyPi(pypi.org/),但也可以是内部的;或者自定义设置将使用相同的基础知识。

我们将模拟我们创建了一个小型图书馆,并将其用作示例来审查需要考虑的主要要点。

除了所有可用的开源库之外,有时我们可能需要一些额外的功能——也许我们的应用程序重复使用特定习惯用语,或者严重依赖某个函数或机制,团队已经为这些特定需求设计了更好的函数。为了更有效地工作,我们可以将这种抽象放入一个库中,并鼓励所有团队成员使用它提供的习惯用语,因为这样做将有助于避免错误并减少错误。

潜在地,有无数的例子可以适用于这种情况。也许应用程序需要提取许多.tag.gz文件(以特定格式)并且在过去面临过恶意文件导致路径遍历攻击的安全问题。作为缓解措施,将安全地抽象自定义文件格式的功能放入一个库中,该库包装了默认库并添加了一些额外的检查。这听起来是个好主意。

或者可能有一个必须以特定格式编写或解析的配置文件,并且这需要按顺序遵循许多步骤;同样,创建一个帮助函数来包装这个,并在所有需要它的项目中使用它,不仅是一个很好的投资,因为它节省了大量的代码重复,而且因为它使错误更难发生。

收益不仅在于遵守 DRY 原则(避免代码重复,鼓励重用),而且抽象的功能代表了如何应该做事情的单一参考点,从而有助于实现概念完整性。

一般来说,库的最小布局如下:

.
├── Makefile
├── README.rst
├── setup.py
├── src
│   └── apptool
│   ├── common.py
│   ├── __init__.py
│   └── parse.py
└── tests
    ├── integration
    └── unit

重要的部分是setup.py文件,其中包含了项目的定义。在这个文件中,指定了项目的所有重要定义(其要求、依赖关系、名称、描述等)。

src目录下的apptool目录是我们正在开发的库的名称。这是一个典型的 Python 项目,所以我们在这里放置所有需要的文件。

setup.py文件的一个例子可能是:

from setuptools import find_packages, setup

with open("README.rst", "r") as longdesc:
    long_description = longdesc.read()

setup(
    name="apptool",
    description="Description of the intention of the package",
    long_description=long_description,
    author="Dev team",
    version="0.1.0",
    packages=find_packages(where="src/"),
    package_dir={"": "src"},
)

这个最小的例子包含了项目的关键元素。setup函数中的name参数用于给包在存储库中的名称(在这个名称下,我们运行安装命令,这种情况下是pip install apptool)。它不是严格要求与项目目录的名称匹配(src/apptool),但强烈建议这样做,这样对用户来说更容易。

在这种情况下,由于两个名称匹配,更容易看到pip install apptool和在我们的代码中运行from apptool import myutil之间的关系。但后者对应于src/目录下的名称,前者对应于setup.py文件中指定的名称。

版本对于保持不同的发布很重要,然后指定包。通过使用find_packages()函数,我们自动发现所有的包,这种情况下是在src/目录下。在这个目录下搜索有助于避免混淆项目范围之外的文件,例如意外发布测试或项目的破损结构。

通过运行以下命令构建包,假设其在已安装依赖项的虚拟环境中运行:

$VIRTUAL_ENV/bin/pip install -U setuptools wheel
$VIRTUAL_ENV/bin/python setup.py sdist bdist_wheel

这将把构件放在dist/目录中,从那里它们可以稍后发布到 PyPi 或公司的内部包存储库。

打包 Python 项目的关键点是:

  • 测试和验证安装是否与平台无关,并且不依赖于任何本地设置(可以通过将源文件放置在src/目录下来实现)

  • 确保单元测试不作为构建的一部分进行发布

  • 分离依赖——项目严格需要运行的内容与开发人员需要的内容不同

  • 为即将需要的命令创建入口点是个好主意

setup.py文件支持多个其他参数和配置,并且可以以更复杂的方式生效。如果我们的包需要安装多个操作系统库,最好在setup.py文件中编写一些逻辑来编译和构建所需的扩展。这样,如果出现问题,它将在安装过程的早期失败,如果包提供了有用的错误消息,用户将能够更快地修复依赖项并继续。

安装这些依赖项代表着另一个困难的步骤,使得应用程序无处不在,并且可以在任何开发人员选择的平台上轻松运行。克服这一障碍的最佳方法是通过创建一个 Docker 镜像来抽象平台,这将在下一节中讨论。

容器

本章专门讨论架构,因此容器这个术语与 Python 容器(具有__contains__方法的对象)完全不同,这在第二章“Pythonic Code”中有所探讨。容器是在操作系统中以一组特定限制和隔离考虑运行的进程。具体来说,我们指的是 Docker 容器,它允许将应用程序(服务或进程)作为独立组件进行管理。

容器代表了另一种交付软件的方式。根据上一节中的考虑创建 Python 包更适合于库或框架,其中的目标是重用代码并利用一个特定逻辑被收集到一个地方。

在容器的情况下,目标不是创建库,而是应用程序(大部分情况下)。然而,应用程序或平台并不一定意味着整个服务。构建容器的想法是创建代表具有小而清晰目的的服务的小组件。

在本节中,当我们谈论容器时,我们将提到 Docker,并且将探讨如何为 Python 项目创建 Docker 镜像和容器的基础知识。请记住,这不是将应用程序部署到容器中的唯一技术,而且它完全独立于 Python。

Docker 容器需要一个镜像来运行,这个镜像是从其他基础镜像创建的。但是我们创建的镜像本身可以作为其他容器的基础镜像。在我们的应用程序中存在一个通用基础的情况下,我们将希望这样做,这个通用基础可以在许多容器之间共享。一个潜在的用途是创建一个基础镜像,以我们在上一节中描述的方式安装一个包(或多个包),以及所有的依赖项,包括操作系统级别的依赖项。正如在第九章“Common Design Patterns”中讨论的那样,我们创建的一个包不仅可以依赖于其他 Python 库,还可以依赖于特定平台(特定操作系统)和在该操作系统中预安装的特定库,如果没有这些库,包将无法安装并且会失败。

容器对此是一个很好的可移植性工具。它们可以帮助我们确保我们的应用程序将有一个规范的运行方式,并且也会极大地简化开发过程(在不同环境中复制场景,复制测试,新团队成员入职等)。

由于包是我们重用代码和统一标准的方式,容器代表了我们创建应用程序的不同服务的方式。它们符合架构的关注点分离SoC)原则背后的标准。每个服务都是另一种独立封装一组功能的组件,与应用程序的其余部分无关。这些容器应该被设计成有利于可维护性——如果责任被清晰划分,对服务的更改不应该对应用程序的任何其他部分产生影响。

我们将在下一节中介绍如何从 Python 项目创建 Docker 容器的基础知识。

用例

作为我们如何组织应用程序组件以及之前的概念如何在实践中工作的示例,我们提供以下简单示例。

用例是有一个用于交付食物的应用程序,这个应用程序有一个特定的服务,用于跟踪每个交付在不同阶段的状态。我们将只关注这个特定的服务,而不管应用程序的其余部分是什么样子。这个服务必须非常简单——一个 REST API,当询问特定订单的状态时,将返回一个带有描述性消息的 JSON 响应。

我们假设每个特定订单的信息都存储在数据库中,但这个细节根本不重要。

我们的服务目前有两个主要关注点:获取特定订单的信息(无论这些信息存储在何处),并以对客户有用的方式呈现这些信息(在这种情况下,以 JSON 格式呈现结果,作为 Web 服务公开)。

由于应用程序必须是可维护和可扩展的,我们希望尽可能隐藏这两个关注点,并专注于主要逻辑。因此,这两个细节被抽象和封装到 Python 包中,主应用程序与核心逻辑将使用这些包,如下图所示:

在接下来的几节中,我们将简要演示代码可能的外观,主要是包方面,以及如何从中创建服务,最终看看我们能得出什么结论。

代码

在这个例子中创建 Python 包的想法是为了说明如何有效地制作抽象和隔离的组件。实际上,这些不需要是 Python 包;我们可以将正确的抽象作为“交付服务”项目的一部分创建,而且在保持正确的隔离的同时,它将可以正常工作。

创建包在逻辑需要重复使用并且预计会被许多其他应用程序使用时更有意义(这些应用程序将从这些包中导入),因为我们希望倾向于代码重用。在这种特殊情况下,没有这样的要求,所以这可能超出了设计的范围,但这种区别仍然更清晰地表明了“可插拔架构”或组件的概念,这是一个真正的封装,抽象了我们不想处理的技术细节,更不要依赖于它。

storage包负责检索所需的数据,并以方便的格式呈现给下一层(交付服务),这对业务规则是合适的。主应用现在不应该知道这些数据来自哪里,它的格式是什么,等等。这就是为什么我们在应用程序之间有这样一个抽象的原因,这样应用程序就不会直接使用行或 ORM 实体,而是使用一些可操作的东西。

领域模型

以下定义适用于业务规则的类。请注意,它们旨在成为纯业务对象,不与任何特定内容绑定。它们不是 ORM 的模型,也不是外部框架的对象等等。应用程序应该使用这些对象(或具有相同标准的对象)。

在每种情况下,文档字符串都记录了每个类的目的,根据业务规则:

from typing import Union

class DispatchedOrder:
    """An order that was just created and notified to start its delivery."""

    status = "dispatched"

    def __init__(self, when):
        self._when = when

    def message(self) -> dict:
        return {
            "status": self.status,
            "msg": "Order was dispatched on {0}".format(
                self._when.isoformat()
            ),
        }

class OrderInTransit:
    """An order that is currently being sent to the customer."""

    status = "in transit"

    def __init__(self, current_location):
        self._current_location = current_location

    def message(self) -> dict:
        return {
            "status": self.status,
            "msg": "The order is in progress (current location: {})".format(
                self._current_location
            ),
        }

class OrderDelivered:
    """An order that was already delivered to the customer."""

    status = "delivered"

    def __init__(self, delivered_at):
        self._delivered_at = delivered_at

    def message(self) -> dict:
        return {
            "status": self.status,
            "msg": "Order delivered on {0}".format(
                self._delivered_at.isoformat()
            ),
        }

class DeliveryOrder:
    def __init__(
        self,
        delivery_id: str,
        status: Union[DispatchedOrder, OrderInTransit, OrderDelivered],
    ) -> None:
        self._delivery_id = delivery_id
        self._status = status

    def message(self) -> dict:
        return {"id": self._delivery_id, **self._status.message()}

从这段代码中,我们已经可以想象应用程序的样子了——我们想要有一个DeliveryOrder对象,它将有自己的状态(作为内部协作者),一旦我们有了这个对象,我们将调用它的message()方法将这些信息返回给用户。

从应用程序调用

这些对象将如何在应用程序中使用。请注意,这取决于先前的包(webstorage),但反之则不然:

from storage import DBClient, DeliveryStatusQuery, OrderNotFoundError
from web import NotFound, View, app, register_route

class DeliveryView(View):
    async def _get(self, request, delivery_id: int):
        dsq = DeliveryStatusQuery(int(delivery_id), await DBClient())
        try
            result = await dsq.get()
        except OrderNotFoundError as e:
             raise NotFound(str(e)) from e

        return result.message()

register_route(DeliveryView, "/status/<delivery_id:int>")

在前一节中,展示了domain对象,这里显示了应用程序的代码。我们是不是漏掉了什么?当然,但这是我们现在真的需要知道的吗?不一定。

storageweb包中的代码故意被省略了(尽管鼓励读者查看——本书的存储库包含完整的示例)。同样,这是故意的,这些包的名称被选择为不透露任何技术细节——storageweb

再次看一下前面清单中的代码。你能告诉使用了哪些框架吗?它是否说明了数据来自文本文件、数据库(如果是的话,是什么类型的?SQL?NoSQL?)或另一个服务(例如网络)?假设它来自关系数据库。有没有线索表明这些信息是如何检索的(手动 SQL 查询?通过 ORM?)?

网络呢?我们能猜出使用了哪些框架吗?

我们无法回答这些问题可能是一个好迹象。这些都是细节,细节应该被封装。除非我们查看这些包中的内容,否则我们无法回答这些问题。

回答上述问题的另一种方法是以问题的形式提出:我们为什么需要知道这个?从代码中,我们可以看到有一个DeliveryOrder,使用交付的标识符创建,它有一个get()方法,返回表示交付状态的对象。如果所有这些信息都是正确的,那就是我们应该关心的。它是如何完成的有什么区别呢?

我们创建的抽象使我们的代码具有声明性。在声明性编程中,我们声明要解决的问题,而不是如何解决它。这与命令式相反,在命令式中,我们必须明确地执行所有必需的步骤才能获得某些东西(例如连接到数据库,运行此查询,解析结果,将其加载到此对象中等等)。在这种情况下,我们声明只想知道给定某个标识符的交付状态。

这些包负责处理细节,并以方便的格式呈现应用程序需要的内容,即在上一节中呈现的对象。我们只需要知道storage包含一个对象,给定交付 ID 和存储客户端(这个依赖项被注入到这个示例中以简化,但也有其他可能的选择),它将检索DeliveryOrder,然后我们可以要求它组成消息。

这种架构提供了便利,并使其更容易适应变化,因为它保护了业务逻辑的核心,使其不受可能发生变化的外部因素的影响。

想象一下,我们想要改变信息的检索方式。这有多难?应用程序依赖于一个 API,就像下面这样:

dsq = DeliveryStatusQuery(int(delivery_id), await DBClient())

因此,只需更改get()方法的工作方式,将其适应新的实现细节。我们只需要让这个新对象在其get()方法上返回DeliveryOrder,就可以了。我们可以更改查询、ORM、数据库等等,在所有情况下,应用程序中的代码都不需要更改!

适配器

尽管没有查看包中的代码,我们可以得出结论,它们作为应用程序的技术细节的接口。

实际上,由于我们是从高层次的角度来看待应用程序,而不需要查看代码,我们可以想象这些包中必须有适配器设计模式的实现(在第九章中介绍,常见设计模式)。其中一个或多个对象正在将外部实现适配到应用程序定义的 API。这样,想要与应用程序一起工作的依赖项必须符合 API,并且必须制作一个适配器。

不过,在应用程序的代码中有一个关于适配器的线索。注意视图是如何构建的。它继承自一个名为View的类,该类来自我们的web包。我们可以推断,这个View又是从可能正在使用的一个 web 框架派生出来的类,通过继承创建了一个适配器。需要注意的是,一旦这样做了,唯一重要的对象就是我们的View类,因为在某种程度上,我们正在创建我们自己的框架,这是基于对现有框架进行适配(但再次更改框架只意味着更改适配器,而不是整个应用程序)。

服务

为了创建服务,我们将在 Docker 容器中启动 Python 应用程序。从基础镜像开始,容器将安装应用程序运行所需的依赖项,这些依赖项也存在于操作系统级别。

这实际上是一个选择,因为它取决于依赖项的使用方式。如果我们使用的一个软件包在安装时需要在操作系统上编译其他库,我们可以通过为我们的平台构建该库的 wheel 并直接安装它来避免这一点。如果这些库在运行时需要,那么除了将它们作为容器镜像的一部分之外别无选择。

现在,我们讨论准备 Python 应用程序在 Docker 容器中运行的众多方法之一。这是将 Python 项目打包到容器中的众多替代方案之一。首先,让我们看一下目录结构是什么样子的:

.
├── Dockerfile
├── libs
│   ├── README.rst
│   ├── storage
│   └── web
├── Makefile
├── README.rst
├── setup.py
└── statusweb
    ├── __init__.py
    └── service.py

libs目录可以忽略,因为它只是放置依赖项的地方(这里显示出来是为了在setup.py文件中引用它们时记住它们,但它们可以放在不同的存储库中,并通过pip远程安装)。

我们有一个Makefile,其中包含一些辅助命令,然后是setup.py文件,以及statusweb目录中的应用程序本身。打包应用程序和库之间的一个常见区别是,后者在setup.py文件中指定它们的依赖关系,而前者有一个requirements.txt文件,依赖关系通过pip install -r requirements.txt安装。通常情况下,我们会在Dockerfile中执行这些操作,但是为了在这个特定的例子中保持简单,我们将假设从setup.py文件中获取依赖关系就足够了。这是因为除了这一考虑因素之外,在处理依赖关系时还有很多其他考虑因素,比如冻结软件包的版本、跟踪间接依赖关系、使用额外的工具如pipenv,以及本章节范围之外的更多主题。此外,为了保持一致,通常也习惯让setup.py文件从requirements.txt中读取。

现在我们有了setup.py文件的内容,其中说明了应用程序的一些细节:

from setuptools import find_packages, setup

with open("README.rst", "r") as longdesc:
    long_description = longdesc.read()

install_requires = ["web", "storage"]

setup(
    name="delistatus",
    description="Check the status of a delivery order",
    long_description=long_description,
    author="Dev team",
    version="0.1.0",
    packages=find_packages(),
    install_requires=install_requires,
    entry_points={
        "console_scripts": [
            "status-service = statusweb.service:main",
        ],
    },
)

我们注意到的第一件事是应用程序声明了它的依赖项,这些依赖项是我们在libs/下创建并放置的包,即webstorage,对一些外部组件进行了抽象和适应。这些包反过来又会有依赖项,因此我们必须确保容器在创建镜像时安装所有所需的库,以便它们可以成功安装,然后再安装这个包。

我们注意到的第二件事是传递给setup函数的entry_points关键字参数的定义。这并不是严格必需的,但创建一个入口点是个好主意。当包安装在虚拟环境中时,它与所有的依赖项共享这个目录。虚拟环境是一个具有给定项目的依赖项的目录结构。它有许多子目录,但最重要的是:

  • <virtual-env-root>/lib/<python-version>/site-packages

  • <virtual-env-root>/bin

第一个包含了在该虚拟环境中安装的所有库。如果我们要为这个项目创建一个虚拟环境,那么该目录将包含webstorage包,以及它们的所有依赖项,再加上一些额外的基本依赖项和当前项目本身。

第二个/bin/包含了当虚拟环境处于活动状态时可用的二进制文件和命令。默认情况下,它只是 Python 的版本、pip和一些其他基本命令。当我们创建一个入口点时,一个具有声明名称的二进制文件将被放置在那里,结果就是当环境处于活动状态时,我们可以运行该命令。当调用此命令时,它将运行指定的函数,并且具有虚拟环境的所有上下文。这意味着它是一个可以直接调用的二进制文件,而无需担心虚拟环境是否处于活动状态,或者当前运行的路径中是否安装了依赖项。

定义如下:

"status-service = statusweb.service:main"

等号左边声明了入口点的名称。在这种情况下,我们将有一个名为status-service的命令可用。右边声明了该命令应该如何运行。它需要包含函数定义的包,后面跟着:和函数名。在这种情况下,它将运行statusweb/service.py中声明的main函数。

接下来是 Dockerfile 的定义:

FROM python:3.6.6-alpine3.6

RUN apk add --update \
    python-dev \
    gcc \
    musl-dev \
    make

WORKDIR /app
ADD . /app

RUN pip install /app/libs/web /app/libs/storage
RUN pip install /app

EXPOSE 8080
CMD ["/usr/local/bin/status-service"]

该镜像是基于轻量级的 Python 镜像构建的,然后安装操作系统依赖项,以便我们可以安装我们的库。根据之前的考虑,这个Dockerfile只是简单地复制了库,但这也可以根据requirements.txt文件进行安装。在所有pip install命令准备好之后,它将应用程序复制到工作目录中,并且 Docker 的入口点(CMD命令,不要与 Python 混淆)调用了我们放置了启动进程的函数的包的入口点。

所有的配置都是通过环境变量传递的,因此我们的服务代码必须符合这个规范。

在涉及更多服务和依赖项的更复杂场景中,我们不仅仅会运行创建的容器的镜像,而是会声明一个docker-compose.yml文件,其中包含所有服务、基础镜像的定义,以及它们是如何链接和相互连接的。

现在我们已经运行了容器,我们可以启动它并对其进行小型测试,以了解其工作原理:

$ curl http://localhost:8080/status/1
{"id":1,"status":"dispatched","msg":"Order was dispatched on 2018-08-01T22:25:12+00:00"}

分析

从先前的实现中可以得出许多结论。虽然这可能看起来是一个不错的方法,但好处也伴随着一些缺点;毕竟,没有架构或实现是完美的。这意味着这样的解决方案并不适用于所有情况,因此它在很大程度上取决于项目、团队、组织等的情况。

虽然解决方案的主要思想是尽可能抽象细节,但正如我们将看到的,有些部分无法完全抽象化,而且层之间的合同暗示了一个抽象泄漏。

依赖流

请注意,依赖关系只朝一个方向流动,因为它们越接近内核,业务规则就越明显。这可以通过查看import语句来追踪。例如,应用程序从存储中导入它所需的一切,在任何地方都没有倒置。

违反这个规则会导致耦合。现在代码的排列方式意味着应用程序和存储之间存在着弱依赖关系。API 的设计要求我们需要一个具有get()方法的对象,任何想要连接到应用程序的存储都需要根据这个规范实现这个对象。因此,依赖关系被倒置了——每个存储都需要实现这个接口,以便根据应用程序的期望创建一个对象。

限制

并非所有东西都可以被抽象化。在某些情况下,这根本不可能,而在其他情况下,可能也不方便。让我们从方便的角度开始。

在这个例子中,有一个将 Web 框架适配到一个干净的 API 以供应用程序使用的适配器。在更复杂的情况下,这样的变化可能是不可能的。即使有这种抽象,库的部分仍然对应用程序可见。适应整个框架可能不仅困难,而且在某些情况下也是不可能的。完全与 Web 框架隔离并不完全是一个问题,因为迟早我们会需要一些它的功能或技术细节。

这里的重要观点不是适配器,而是尽可能隐藏技术细节的想法。这意味着在应用程序代码的列表中显示的最好的东西不是我们的 Web 框架版本和实际框架之间有一个适配器,而是后者在可见代码的任何部分都没有被提及。服务清楚地表明了web只是一个依赖项(一个被导入的细节),并揭示了它应该做什么的意图。目标是揭示意图(如代码中所示)并尽可能推迟细节。

至于不能被隔离的东西,那些最接近代码的元素。在这种情况下,Web 应用程序以异步方式使用其中的对象。这是一个我们无法规避的硬性约束。在storage包中的任何东西都可以更改、重构和修改,但无论这些修改是什么,它仍然需要保留接口,包括异步接口。

可测试性

与代码类似,架构也可以从将部分分离为更小的组件中受益。现在依赖关系被隔离并由独立的组件控制,这为主应用程序提供了更清晰的设计,现在更容易忽略边界,专注于测试应用程序的核心。

我们可以为依赖关系创建一个补丁,并编写更简单的单元测试(它们不需要数据库),或者启动整个网络服务,例如。使用纯粹的domain对象意味着更容易理解代码和单元测试。甚至适配器也不需要太多的测试,因为它们的逻辑应该非常简单。

意图揭示

这些细节包括保持函数简短,分离关注点,隔离依赖关系,并在代码的每个部分中为抽象赋予正确的含义。意图的揭示对我们的代码来说是一个关键概念——每个名称都必须明智地选择,清晰地传达它应该做的事情。每个函数都应该讲述一个故事。

一个良好的架构应该揭示系统的意图。它不应该提到它所构建的工具;那些都是细节,正如我们长时间讨论的,细节应该被隐藏,封装起来。

摘要

良好软件设计的原则适用于所有层面。就像我们想要编写可读的代码一样,为此我们需要关注代码的意图揭示程度,架构也必须表达它试图解决的问题的意图。

所有这些想法都是相互关联的。相同的意图揭示确保我们的架构是根据领域问题来定义的,同时也导致我们尽可能地抽象细节,创建抽象层,倒置依赖关系,以及分离关注点。

在重用代码方面,Python 包是一个很好且灵活的选择。在决定创建包时,诸如内聚性和单一责任原则SRP)之类的标准是最重要的考虑因素。与具有内聚性和少责任的组件一致,微服务的概念也开始发挥作用,为此,我们已经看到了如何从打包的 Python 应用程序开始,在 Docker 容器中部署服务。

与软件工程中的一切一样,都存在局限性和例外。我们并不总是能够像我们希望的那样抽象事物,或者完全隔离依赖关系。有时,遵循本书中解释的原则可能是不可能的(或不切实际的)。但这可能是读者应该从本书中得到的最好建议——它们只是原则,而不是法律。如果从框架中抽象出来是不可能的或不切实际的,那也不应该成为问题。请记住本书中引用的 Python 禅宗本身所说的话——实用性胜过纯粹

参考资料

以下是您可以参考的信息列表:

总结一切

这本书的内容是一个参考,是通过遵循标准来实现软件解决方案的一种可能方式。这些标准是通过示例来解释的,并且为每个决定提出了理由。读者可能会对示例中采取的方法持不同意见,这实际上是可取的:观点越多,辩论就越丰富。但不管观点如何,重要的是要明确,这里呈现的内容绝不是一种强有力的指令,也不是必须坚决遵循的东西。恰恰相反,它是一种向读者呈现解决方案和一套可能有用的想法的方式。

正如在书的开头介绍的那样,这本书的目标不是给你提供可以直接应用的配方或公式,而是让你发展批判性思维。习语和句法特点会随着时间而改变。但是想法和核心软件概念是不变的。有了这些工具和提供的例子,你应该对清晰的代码意味着什么有更好的理解。

我真诚地希望这本书能帮助你成为比开始阅读之前更好的开发者,祝你在项目中好运。

posted @ 2024-05-04 21:28  绝不原创的飞龙  阅读(49)  评论(0编辑  收藏  举报