Python-代码整洁指南第二版-全-

Python 代码整洁指南第二版(全)

原文:zh.annas-archive.org/md5/11fd52292c708650cc78652c87792d70

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书面向的对象

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

这本书将吸引那些想要学习良好的 Python 编码技术以从头开始创建项目或改进遗留系统以节省成本和提高效率的团队领导、软件架构师和高级软件工程师。

本书按内容复杂度递增的顺序组织。前几章涵盖了 Python 的基础知识,这是学习语言中可用的主要惯用语、函数和实用程序的好方法。目的不仅仅是用 Python 解决问题,而是以惯用的方式解决问题。

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

使用 Python 进行数据处理的科学家也可以从本书的内容中受益,为此,本书的几个部分致力于从头开始设置项目,包括工具、环境配置和发布软件的良好实践。

在本节第一句话中强调“从业者”这个词是值得的。这是一本采取实用主义方法的书籍。例子仅限于案例研究所需的内容,但也被设计成类似于真实软件项目的背景。这不是一本学术书籍,因此所做出的定义、所提出的评论和建议应谨慎对待。读者应批判性地、实用性地而不是教条性地审视这些建议。毕竟,实用性胜过纯粹性。

本书涵盖的内容

第一章介绍、代码格式化和工具,是介绍读者在 Python 中设置开发环境所需的主要工具。我们涵盖了 Python 开发者建议了解的基本知识,以便有效地使用该语言。它还包括一些关于在项目中保持可读性代码的指南,例如静态分析工具、文档、类型检查和代码格式化。对编码标准的共同理解是好事,但仅依赖良好的意图是无法扩展的。这就是为什么本章以讨论更有效地工作的工具结束。

第二章Pythonic 代码,探讨了 Python 中的第一个惯用语,这些惯用语我们将在接下来的章节中继续使用。我们涵盖了 Python 的特殊特性,它们应该如何被使用,以及在这一章中,我们开始围绕 Pythonic 代码通常质量更高的理念构建知识。

第三章良好代码的一般特性,回顾了软件工程的一般原则,使重点放在编写更易于维护的代码上。从上一章获得的知识,我们审视了一般的清洁设计理念,以及它们如何在 Python 中实现。

第四章SOLID 原则,涵盖了一组面向对象软件设计的设计原则。这个缩写是软件工程语言或术语的一部分,我们看到它们中的每一个如何应用于 Python。特别是,读者将学习依赖注入如何使代码更易于维护,这是一个在下一章中将非常有用的概念。

第五章使用装饰器改进我们的代码,探讨了 Python 最伟大的特性之一。在理解了如何创建装饰器(用于函数和类)之后,我们将它们用于代码重用、分离责任和创建更细粒度的函数。本章的另一个有趣的学习点是,如何利用装饰器简化复杂和重复的函数签名。

第六章使用描述符让我们的对象更有价值,探讨了 Python 中的描述符,将面向对象设计提升到了一个新的水平。虽然这是一个与框架和工具更相关的特性,但我们可以看到如何使用描述符来提高代码的可读性,以及代码的重用。本章回顾的内容将使读者对 Python 达到更高的理解水平。

第七章生成器、迭代器和异步编程,首先展示了生成器是如何成为 Python 的一个绝佳特性的。迭代是 Python 的核心组件这一事实可能会让我们认为它引领了一种新的编程模型。通过一般地使用生成器和迭代器,我们可以以不同的方式思考我们编写程序的方式。从生成器中学到的经验教训,我们进一步学习 Python 中的协程和异步编程的基础。本章以解释异步编程和异步迭代的新的语法(以及新的魔法方法!)结束。

第八章单元测试和重构,讨论了单元测试在声称可维护的任何代码库中的重要性。我们讨论重构作为代码库演变和维护的先决条件,以及单元测试对此的重要性。所有这些,都有适当的工具(主要是unittestpytest模块)的支持。最后,我们了解到良好测试的秘密并不在于测试本身,而在于拥有可测试的代码。

第九章常见设计模式,回顾了如何在 Python 中实现最常见的设计模式,不是从解决问题的角度出发,而是通过考察它们如何通过利用更好、更易于维护的解决方案来解决问题。本章提到了 Python 的一些特性,使得某些设计模式变得不明显,并采取了实用主义方法来实现其中的一些模式。我们讨论了其他(不那么“传统”)的、特定于 Python 的模式。

第十章整洁架构,重点介绍了整洁代码是良好架构的基础。我们在第一章中提到的所有细节,以及沿途重新审视的所有其他内容,在系统部署时在整个设计中都将发挥关键作用。

要充分利用本书

预期读者具有一定的编程经验,并对 Python 语法的基础知识有所了解。此外,还假设读者具备基本编程知识,如结构化编程和面向对象设计知识。

要测试代码,您需要安装 Python,可以从www.python.org/downloads/下载。代码在 Python 3.9+上运行,强烈建议创建虚拟环境。另外,代码也可以在 Docker 镜像中测试。

下载示例代码文件

本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Clean-Code-in-Python-Second-Edition。我们还有其他丰富的图书和视频的代码包,可在github.com/PacktPublishing/找到。请查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781800560215_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

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

代码块设置如下:

@dataclass
class Point:
    lat: float
    long: float 

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

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

任何命令行输入或输出都如下所示:

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

粗体:表示新术语、重要词汇或您在屏幕上看到的词汇,例如在菜单或对话框中,也以这种方式出现在文本中。例如:“我们希望有一个更好的关注点分离的设计。”

警告或重要提示如下所示。

小技巧和窍门如下所示。

联系我们

我们欢迎读者的反馈。

一般反馈: 请通过feedback@packtpub.com发送邮件,并在邮件主题中提及书籍的标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送邮件给我们。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上材料的链接。

如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问packtpub.com

第一章:引言、代码格式化和工具

在本章中,我们将探讨与清洁代码相关的第一个概念,从它是什么以及它的意义开始。本章的主要目标是理解清洁代码不仅仅是一件好事或软件项目中的奢侈品。它是一种必需品。没有高质量的代码,项目将面临因技术债务积累而失败的风险(技术债务是我们将在本章后面详细讨论的内容,所以如果你之前没有听说过这个术语,请不要担心)。

同样,但更详细地,是格式化和文档化的概念。这些也可能听起来像是多余的或任务,但再次强调,我们将发现它们在保持代码库可维护和可操作方面起着基本作用。

我们将分析采用良好的编码指南对这个项目的重要性。意识到维护与参考代码对齐是一个持续的任务,我们将看到如何从自动化工具中获得帮助,这些工具将简化我们的工作。因此,我们将讨论如何配置将在项目构建过程中自动运行的工具。

本章的目标是了解清洁代码是什么,为什么它很重要,为什么格式化和文档化代码是关键任务,以及如何自动化这个过程。从这一点出发,你应该获得一种快速组织新项目结构的心态,目标是良好的代码质量。

在阅读本章之后,你将学习以下内容:

  • 清洁代码真正意味着比格式化更重要的东西

  • 拥有标准格式是软件项目维护性的关键组成部分

  • 如何通过使用 Python 提供的功能使代码自我文档化

  • 如何配置工具来自动化代码的静态验证

引言

我们首先将理解什么是清洁代码,以及为什么这对于软件工程项目的成功至关重要。在前两节中,我们将学习保持良好的代码质量对于高效工作的重要性。

然后我们将讨论这些规则的例外情况:即在某些情况下,甚至可能是有成本效益的,不重构我们的代码以偿还所有技术债务。毕竟,我们不能简单地期望一般规则适用于所有地方,因为我们知道总有例外。这里的关键是要正确理解我们为什么愿意做出例外,并正确识别这些情况。我们不想误导自己认为某些东西不应该改进,而实际上它应该改进。

清洁代码的意义

清洁代码并没有一个唯一或严格的定义。此外,可能没有一种正式的方法来衡量清洁代码,因此你无法在存储库上运行一个工具来告诉你代码是好是坏,或者是否易于维护。当然,你可以运行像checkerslintersstatic analyzers等工具,这些工具非常有帮助。它们是必要的,但并不充分。清洁代码不是机器或脚本可以识别的(到目前为止)的东西,而是一种我们作为专业人士可以决定的东西。

几十年来,我们使用编程语言这个术语,认为它们是用来将我们的想法传达给机器,以便它们可以运行我们的程序的。我们错了。这不是真相,但这是真相的一部分。编程语言中“语言”部分的真正含义是将我们的想法传达给其他开发者。

正是这里揭示了清洁代码的真正本质。它取决于其他工程师能否阅读和维护代码。因此,我们作为专业人士,是唯一能够判断这一点的人。想想看;作为开发者,我们花在阅读代码上的时间比实际编写代码的时间要多得多。每次我们想要进行更改或添加新功能时,我们首先必须阅读我们必须修改或扩展的代码的所有周围环境。语言(Python)是我们用来相互沟通的工具。

因此,而不是给你一个清洁代码的定义(或我的定义),我邀请你阅读这本书,了解所有关于惯用 Python 的知识,看到好代码和坏代码之间的区别,识别好代码和好架构的特征,然后提出你自己的定义。阅读这本书后,你将能够自己判断和分析代码,并且对清洁代码将有更清晰的理解。你将知道它是什么,它意味着什么,无论别人给你什么样的定义。

清洁代码的重要性

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

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

想象一下,你正在驾驶一辆汽车,沿着一条通往你希望在某个时间点到达的目的地的道路行驶。你必须估算你的到达时间,以便你可以告诉等待你的人。如果汽车运行良好,道路平坦完美,那么我看不到你为什么会大大超出你的估计。然而,如果道路状况糟糕,你必须下车移走石头,或者避开裂缝,每隔几公里就停下来检查引擎,那么你很可能无法确切知道你何时会到达(或者你是否能到达)。我认为这个类比很清楚;道路就是代码。如果你想以稳定、恒定和可预测的速度前进,代码就需要是可维护和可读的。如果不是这样,每当产品管理要求添加新功能时,你将不得不停下来重构和修复技术债务。

技术债务指的是软件中由于妥协或不良决策而产生的问题的概念。我们可以从现在到过去和从现在到未来两种方式来思考技术债务。从现在到过去:如果我们目前面临的问题是由于之前编写的糟糕代码造成的呢?从现在到未来:如果我们现在决定走捷径,而不是投入时间在合适的解决方案上,我们将会为自己在未来的某个时刻创造什么问题?

“债务”这个词是一个很好的选择。它之所以被称为债务,是因为代码在未来将比现在更难更改。这种产生的成本就是债务的利息。积累技术债务意味着,明天,代码将比今天更难更改,而且成本更高(甚至可以测量这一点),后天成本更高,以此类推。

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

甚至可以争论说,拥有带有技术债务的代码库的团队并没有在进行敏捷软件开发。因为,敏捷的对立面是什么?僵化。如果代码充满了代码异味,那么它就不容易更改,因此团队无法快速响应需求的变化并持续交付。

技术债务最糟糕的地方在于,它代表了一个长期和根本的问题。它不是那种会拉响警报的问题。相反,它是一个静默的问题,散布在项目的各个部分,有一天,在某个特定的时间点,会突然爆发,成为拦路虎。

在一些更令人担忧的情况下,“技术债务”甚至是一种轻描淡写,因为问题实际上要严重得多。在前面的段落中,我提到了一些场景,在这些场景中,技术债务使得团队在未来面临的问题更加困难,但现实情况是否更加危险呢?想象一下采取捷径,使得代码处于脆弱状态(一个简单的例子可能是在函数中有一个可变的默认参数,这会导致内存泄漏,我们将在后面的章节中看到)。你可以部署你的代码,并且它会在相当长的一段时间内运行良好(只要那个缺陷没有显现出来)。但实际上,这是一个等待发生的崩溃:有一天,在人们最不期待的时候,代码中某个条件将会满足,这将导致应用程序在运行时出现问题,就像代码中的一个定时炸弹,在随机的时间爆炸。

我们显然希望避免上述情况。并非所有问题都能被自动化工具捕获,但只要可能,这都是一项良好的投资。其余的则依赖于良好的、彻底的代码审查和良好的自动化测试。

软件只有在能够轻松更改的程度上是有效的。想想看。我们创建软件是为了满足某些需求(无论是购买机票、在线购物还是听音乐,仅举几个例子)。这些需求很少冻结,这意味着软件将不得不在导致该软件最初被编写的环境中的任何变化发生时立即更新。如果代码不能更改(我们知道现实是会变化的),那么它就是无用的。拥有一个干净的代码库是修改它的绝对要求,因此干净代码的重要性。

一些例外情况

在前面的章节中,我们探讨了干净的代码库在软件项目成功中的关键作用。话虽如此,请记住,这是一本面向实践者的书,因此一个务实的读者可能会合理地指出,这提出了一个问题:“是否存在合法的例外情况?”

当然,如果这本书不允许读者对其假设提出挑战,那么它就不是一个真正实用的书籍。

事实上,有些情况下,你可能想要考虑放宽一些保持代码库纯净的约束。以下是一个列表(绝非详尽无遗),列出了可能证明跳过一些质量检查的情况:

  • 黑客马拉松

  • 如果你正在编写一个用于一次性任务的简单脚本

  • 编程竞赛

  • 在开发概念验证时

  • 在开发原型时(只要确保它确实是一个将被丢弃的原型)

  • 当你与一个将被弃用且仅处于固定、短暂维护模式的遗留项目一起工作时(再次强调,只要这是确定的)

在这些情况下,常识适用。例如,如果你刚刚加入一个项目,该项目将在未来几个月内上线,然后被退役,那么修复其继承的技术债务并等待其存档可能不是值得所有麻烦的选择。

注意这些例子都有一个共同点,即它们都假设代码可以不按照良好的质量标准编写,而且我们永远不会再次查看这些代码。这与之前所讨论的内容是一致的,可以被视为我们原始前提的逆命题:我们编写干净的代码是因为我们想要实现高可维护性。如果不需要维护那段代码,那么我们可以跳过在它上面保持高质量标准的努力。

记住,我们编写干净的代码是为了能够维护项目。这意味着能够将来自己修改代码,或者,如果我们正在将代码的所有权转移到公司中的另一个团队,这将使这种过渡(以及未来维护者的生活)变得更加容易。这意味着,如果一个项目只处于维护模式,但不会过时,那么支付其技术债务可能仍然是一个好的投资。这是因为某个时候(通常是在最意想不到的时候),将会有一个需要修复的错误,并且使代码尽可能易于阅读将是有益的。

代码格式化

干净的代码仅仅是关于格式化和结构化代码吗?简短的回答是否定的。

有些编码标准,如 PEP-8(www.python.org/dev/peps/pep-0008/),规定了代码应该如何编写和格式化。在 Python 中,PEP-8 是最为人所知的标准,该文档提供了关于我们应该如何编写程序的指导,包括间距、命名约定、行长度等方面。

然而,干净的代码是另外一回事,它远远超出了编码标准、格式化、linting 工具和其他关于代码布局的检查。干净的代码是关于实现高质量的软件和构建一个既健壮又易于维护的系统。一段代码或整个软件组件可以 100%符合 PEP-8(或任何其他指南),但仍不满足这些要求。

尽管格式化不是我们的主要目标,但忽视代码结构有一些风险。因此,我们将首先分析糟糕的代码结构的问题以及如何解决这些问题。之后,我们将看到如何配置和使用工具来自动检查 Python 项目的最常见问题。

总结来说,我们可以这样说,干净的代码与 PEP-8 或编码风格等事物无关。它远远超出了这些,对代码的可维护性和软件的质量有更深远的意义。然而,正如我们将看到的,正确格式化代码对于高效工作是非常重要的。

在你的项目中遵循编码风格指南

编码规范是一个项目在被视为符合质量标准开发时应该拥有的最低要求。在本节中,我们将探讨其背后的原因。在接下来的章节中,我们可以开始探讨如何通过使用工具自动执行这些规范。

当我试图在代码布局中寻找好的特质时,首先想到的是一致性。我期望代码的结构是一致的,这样便于阅读和跟踪。如果代码既不正确也不结构一致,而且团队中的每个人都以自己的方式做事,那么我们最终会得到需要额外努力和集中精力才能理解的代码。这将导致错误,误导,并且错误或细微之处可能会轻易地被忽略。

我们希望避免这种情况。我们想要的正好相反——代码能够让我们一眼就能快速阅读和理解。

如果开发团队的所有成员都同意一种标准化的代码结构方式,那么生成的代码看起来会更为熟悉。因此,你将很快识别出模式(关于这一点将在下一节中详细介绍),并且有了这些模式在心中,理解事物和检测错误将会容易得多。例如,当某处出现问题时,你会注意到,某种方式下,你习惯看到的模式中有些奇怪,这会吸引你的注意。你会仔细查看,并且很可能发现错误!

正如经典书籍《代码大全》中所述,在 1973 年发表的论文《棋局感知》中,对这一点进行了有趣的分析。该实验旨在确定不同的人如何理解或记忆不同的棋局位置。实验针对所有级别的玩家(新手、中级和棋艺大师)以及棋盘上的不同棋局位置进行。他们发现,当位置是随机的,新手的表现和棋艺大师一样好;这仅仅是一个任何人都能在相当相同的水平上完成的记忆练习。当位置遵循可能在真实游戏中出现的逻辑顺序(再次强调,一致性,遵循模式)时,棋艺大师的表现远远优于其他人。

现在想象一下,将同样的情况应用到软件上。我们作为 Python 软件工程师专家,就像之前例子中的棋艺大师。当代码结构随机,没有遵循任何逻辑,或者不遵循任何标准时,对于我们来说,找出错误就像新手开发者一样困难。另一方面,如果我们习惯于以结构化的方式阅读代码,并且我们已经学会了通过遵循模式快速从代码中获取想法,那么我们就处于相当的优势地位。

尤其是对于 Python,你应该遵循的编码风格是 PEP-8。你可以根据你正在工作的项目的特定需求扩展它或采用其部分内容(例如,行的长度,关于字符串的注释等)。

如果你意识到你正在工作的项目没有遵循任何编码标准,那么推动在该代码库中采用 PEP-8。理想情况下,你应该有一个书面文件,解释你所在的公司或团队期望遵循的编码标准。这些编码规范可以是 PEP-8 的改编。

如果你注意到你的团队在代码风格上没有一致性,并且在代码审查期间对此进行了多次讨论,那么重新审视编码规范并投资于自动验证工具可能是个不错的想法。

尤其是 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 确立了这样的惯例,即在向函数传递关键字参数时,我们不使用空格,但在给变量赋值时使用空格。因此,我们可以调整我们的搜索标准(第一个例子中=周围没有空格,第二个例子中有一个空格),并在搜索中更加高效。这是遵循惯例的一个优点。

  • 一致性:如果代码格式统一,阅读起来会容易得多。这对于入职尤其重要,如果你希望欢迎新开发者加入你的项目,或者甚至雇佣新的(可能经验较少的)程序员到你的团队,并且他们需要熟悉代码(可能包括多个仓库)。如果所有文件中的代码布局、文档、命名约定等都是相同的,这将使他们的生活变得更加容易。

  • 更好的错误处理:PEP-8 中提出的建议之一是限制try/except块内的代码量到尽可能少。这减少了错误面,从某种意义上说,它减少了意外吞下异常并掩盖错误的可能性。这可能是难以通过自动检查执行的,但无论如何,在代码审查时值得注意。

  • 代码质量:通过以结构化的方式查看代码,你将更擅长一眼看懂它(再次,就像在《棋艺感知》中一样),并且更容易发现错误和错误。此外,检查代码质量的工具也会提示潜在的错误。代码的静态分析可能有助于降低每行代码的错误率。

如我在引言中提到的,格式化是干净代码的一个必要部分,但这并不结束。还有更多需要考虑的因素,例如在代码中记录设计决策,并尽可能使用工具来利用自动质量检查。在下一节中,我们将从第一个因素开始。

文档

本节是关于在 Python 代码内部记录代码。好的代码是自我解释的,但也是良好记录的。解释它应该做什么(而不是如何做)是一个好主意。

一个重要的区别:记录代码与向其添加注释不同。本节旨在探讨 docstrings 和注释,因为它们是 Python 中用于记录代码的工具。顺便说一句,我将简要地触及代码注释的主题,只是为了确立一些将使区分更清晰的要点。

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

另一个与注释特别相关的理由是,它们还可以通过像mypy(mypy-lang.org/)或pytype(google.github.io/pytype/)这样的工具运行一些自动检查,例如类型提示。我们会发现,最终,添加注释是值得的。

代码注释

作为一条一般规则,我们应该尽量减少代码注释的数量。这是因为我们的代码应该是自我文档化的。这意味着如果我们努力使用正确的抽象(比如通过有意义的函数或对象来划分代码中的责任),并且清晰地命名事物,那么注释就不需要了。

在写注释之前,试着看看你是否可以用只有代码(即添加一个新函数或使用更好的变量名)来表达相同的意思。

这本书中关于注释的观点与软件工程文献中的其他观点基本一致:代码中的注释是我们无法正确表达代码的迹象。

然而,在某些情况下,避免在代码中添加注释是不可能的,而且不这样做是危险的。这种情况通常发生在代码中必须完成某些特定技术细节,而这些细节乍一看并不简单(例如,如果底层外部函数中有一个错误,我们需要传递一个特殊参数来规避问题)。在这种情况下,我们的任务是尽可能简洁,以最佳方式解释问题是什么,以及为什么我们要采取这种特定的代码路径,以便读者能够理解情况。

最后,代码中还有一种评论是绝对不好的,而且根本无法为其辩护:被注释掉的代码。这种代码必须无情地删除。记住,代码是开发者之间的交流语言,是设计的最终表达。代码是知识。被注释掉的代码会带来混乱(以及很可能是矛盾),这将污染这种知识。

现在根本没有任何合理的理由,尤其是在现代版本控制系统的背景下,留下可以简单地删除(或存放在其他地方的)被注释掉的代码。

总结一下:代码注释是邪恶的。有时是必要的邪恶,但无论如何,我们都应该尽可能地避免。另一方面,代码的文档是另一回事。这指的是在代码本身中记录设计或架构,使其清晰,这是一种积极的动力(也是下一节的主题,我们将讨论文档字符串)。

文档字符串

简而言之,我们可以这样说,文档字符串是嵌入在源代码中的文档文档字符串基本上是一个字面字符串,放置在代码的某个位置,以记录该部分逻辑。

注意到对单词文档的强调。这很重要,因为它旨在代表解释,而不是辩解。文档字符串不是注释;它们是文档。

文档字符串旨在为代码中的特定组件(一个模块方法函数)提供文档,这对其他开发者将是有用的。想法是,当其他工程师想要使用你正在编写的组件时,他们很可能会查看文档字符串,以了解它应该如何工作,预期的输入和输出是什么,等等。因此,在可能的情况下添加文档字符串是一个好习惯。

文档字符串也有助于记录设计和架构决策。给最重要的 Python 模块、函数和类添加文档字符串可能是个好主意,以便向读者暗示该组件如何在整体架构中发挥作用。

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

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

要运行以下代码,你需要一个IPython(ipython.org/)交互式外壳,其 Python 版本应满足本书的要求。如果你没有IPython外壳,你仍然可以在正常的Python外壳中运行相同的命令,只需将<function>??替换为help(<function>)

考虑这个来自标准库的好例子:

Type: method_descriptor 

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

  1. 我们可以传递一个具有.keys()方法的对象(例如,另一个字典),并且它将使用通过参数传递的对象的键来更新原始字典:

    >>> d = {}
    >>> d.update({1: "one", 2: "two"})
    >>> d
    {1: "one", 2: 'two'} 
    
  2. 我们可以传递一个键值对的序列,并将它们解包以更新:

    >>> d.update([(3, "three"), (4, "four")])
    >>> d
    {1: 'one', 2: 'two', 3: 'three', 4: 'four'} 
    
  3. 它还告诉我们我们可以使用关键字参数从字典中更新值:

    >>> d.update(five=5)
    >>> d
    {1: 'one', 2: 'two', 3: 'three', 4: 'four', 'five': 5} 
    

(注意,在这种情况下,关键字参数是字符串,所以我们不能设置形如5="five"的东西,因为这将是错误的。)

对于想要学习和理解新函数的工作方式以及如何利用它的人来说,这些信息至关重要。

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

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

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

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

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

很不幸,文档字符串有一个缺点,那就是和所有文档一样,它需要手动和持续的维护。随着代码的变化,它将不得不进行更新。另一个问题是,为了使文档字符串真正有用,它们必须详细,这通常需要多行。考虑到这两个因素,如果你正在编写的函数非常简单,并且是自我解释的,那么最好避免添加冗余的文档字符串,因为这将需要在以后进行维护。

维护适当的文档是软件工程的一个挑战,我们无法逃避。这也很有道理。如果你这么想,文档需要手动编写的原因是因为它是为了被其他人类阅读。如果它是自动化的,可能就不会有多大用处。为了使文档具有任何价值,团队中的每个人都必须同意这是一项需要手动干预的工作,因此需要付出努力。关键是理解软件不仅仅是代码。与之相关的文档也是交付成果的一部分。因此,当有人对函数进行更改时,同样重要的是也要更新与刚刚更改的代码相对应的文档部分,无论它是维基、用户手册、README文件还是几个文档字符串。

注释

PEP-3107 引入了注释的概念。它们的基本思想是向代码的读者暗示函数中参数的预期值。使用“暗示”这个词并非偶然;注释使类型提示成为可能,我们将在本章稍后讨论这一点,在介绍注释之后。

注释允许你指定已定义的一些变量的预期类型。实际上,它不仅关于类型,还包括任何可以帮助你更好地理解该变量实际代表的元数据。

考虑以下示例:

@dataclass
class Point
    lat: float
    long: float

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

在这里,我们使用float来表示latitudelongitude的预期类型。这仅仅是对函数读者的信息,使他们能够了解这些预期类型。Python 不会检查这些类型也不会强制执行它们。

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

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

我们可以利用注释使我们的代码更具表达性。考虑以下示例,这是一个应该启动任务但同时也接受一个参数以延迟执行的函数:

def launch_task(delay_in_seconds):
    ... 

在这里,参数delay_in_seconds的名称似乎相当冗长,但尽管如此,它仍然没有提供太多信息。对于秒来说,哪些值是可接受的优良值?它是否考虑了分数?

我们是否应该在代码中回答这些问题?

Seconds = float
def launch_task(delay: Seconds):
    ... 

现在代码自己说话了。此外,我们可以争论,随着Seconds注释的引入,我们已经在如何解释代码中的时间方面创建了一个小的抽象,我们可以在代码库的更多部分重用这个抽象。如果我们后来决定改变秒的底层抽象(比如说,从现在开始,只允许整数),我们可以在一个地方进行这个更改。

随着注释的引入,还包含了一个新的特殊属性,即__annotations__。这将使我们能够访问一个字典,该字典将注释的名称(作为字典中的键)映射到它们对应的值,即我们为它们定义的值。在我们的例子中,它看起来如下所示:

>>> locate.__annotations__
{'latitude': <class 'float'>, 'longitude': <class 'float'>, 'return': <class 'Point'>} 

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

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

“Python 将保持动态类型语言,作者们没有意愿将类型提示强制为必须的,即使是按照惯例。”

类型提示的想法是拥有额外的工具(独立于解释器)来检查代码中类型的正确使用,并在检测到任何不兼容性时向用户提示。有一些有用的工具会在数据类型及其在我们代码中的使用方面进行检查,以找到潜在的问题。例如,mypypytype等工具在工具部分中有更详细的解释,我们将讨论如何使用和配置项目中的工具。现在,你可以将其视为一种检查代码中使用的类型语义的 linter。因此,在项目中配置mypypytype并将其用于与静态分析工具相同的级别是一个好主意。

然而,类型提示不仅仅是一个检查我们代码中类型的工具。从我们之前的例子中继续,我们可以为我们的代码中的类型创建有意义的名称和抽象。考虑以下一个处理客户列表的函数的例子。在其最简单的形式中,它可以使用一个通用的列表进行注解:

def process_clients(clients: list):
    ... 

如果我们知道在我们的当前数据建模中,客户被表示为整数和文本的元组,我们可以添加更多细节:

def process_clients(clients: list[tuple[int, str]]):
    ... 

但这仍然没有给我们提供足够的信息,所以最好明确地给这个别名起一个名字,这样我们就不必推断这个类型意味着什么:

from typing import Tuple
Client = Tuple[int, str]
def process_clients(clients: list[Client]):
    ... 

在这种情况下,含义更清晰,并且它支持数据类型的演变。也许元组是适合正确表示客户的最低数据结构,但后来我们可能希望将其更改为另一个对象或创建一个特定的类。在这种情况下,注释将保持正确,所有其他类型验证也将保持正确。

这背后的基本思想是,现在语义扩展到更有意义的概念,这使得我们(人类)更容易理解代码的含义,或者在一个特定点期望什么。

注释还带来了一些额外的优势。随着 PEP-526 和 PEP-557 的引入,有一种方便的方式来紧凑地编写类并定义小的容器对象。想法就是只在一个类中声明属性,并使用注释来设置它们的类型,借助@dataclass装饰器,它们将被处理为实例属性,而无需在__init__方法中显式声明并设置它们的值:

from dataclasses import dataclass
@dataclass
class Point:
    lat: float
    long: float 
>>> Point.__annotations__
{'lat': <class 'float'>, 'long': <class 'float'>}
>>> Point(1, 2)
Point(lat=1, long=2) 

在本书的后面部分,我们将探讨注释的其他重要用途,这些用途更多地与代码的设计相关。当我们探讨面向对象设计的良好实践时,我们可能希望使用诸如依赖注入等概念,其中我们设计代码以依赖于声明契约的接口。而声明代码依赖于特定接口的最好方法可能是使用注释。更具体地说,有一些工具专门使用 Python 注释来自动提供对依赖注入的支持。

在设计模式中,我们通常还希望将代码的某些部分与特定的实现解耦,并依赖于抽象接口或契约,以使我们的代码更加灵活和可扩展。此外,设计模式通常通过创建所需的适当抽象来解决这些问题(这通常意味着有新的类来封装部分逻辑)。在这两种情况下,注解我们的代码将提供额外的帮助。

注解是否取代了文档字符串?

这是一个合理的问题,因为在 Python 的早期版本中,在引入注解之前,记录函数或属性的参数类型的方式是在它们上面放置文档字符串。甚至还有一些关于如何结构化文档字符串的约定,以包括函数的基本信息,包括类型、每个参数的含义、返回值以及函数可能引发的异常。

大部分内容已经通过注解以更紧凑的方式得到了解决,所以有人可能会想知道是否真的有必要同时拥有文档字符串。答案是肯定的,这是因为它们是互补的。

的确,文档字符串中先前包含的部分信息现在可以移动到注解中(因为我们不再需要在文档字符串中指示参数的类型,因为我们可以使用注解)。但这应该为更好的文档字符串留下更多空间。特别是,对于动态和嵌套数据类型,提供预期数据的示例总是一个好主意,这样我们可以更好地了解我们正在处理的内容。

考虑以下示例。假设我们有一个期望接收字典以验证某些数据的函数:

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

在这里,我们可以看到一个接收字典并返回另一个字典的函数。如果键"status"下的值不是预期的值,则可能引发异常。然而,我们对此了解不多。例如,一个正确的response对象实例看起来是什么样子?result对象实例会是什么样子?为了回答这两个问题,记录预期通过参数传递并由该函数返回的数据的示例将是一个好主意。

让我们看看是否可以通过文档字符串更好地解释这一点:

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 的相同评论,构建将自动失败,使其变得客观。

本节中介绍的工具将给你一个关于可以对代码自动执行检查的想法。这些工具应该强制执行某些标准。通常,它们是可配置的,并且每个仓库都有自己的配置是完全可以接受的。

使用工具的想法是有一个可重复和自动运行某些检查的方式。这意味着每个工程师都应该能够在他们的本地开发环境中运行这些工具,并得到与团队其他成员相同的结果。此外,这些工具应该作为持续集成CI)构建的一部分进行配置。

检查类型一致性

类型一致性是我们希望自动检查的主要事项之一。Python 是动态类型的,但我们仍然可以添加类型注解来向读者(和工具)暗示代码不同部分期望的内容。尽管注解是可选的,如我们所见,添加注解是一个好主意,这不仅因为它使代码更易读,而且因为我们可以使用注解和一些工具自动检查一些常见的错误,这些错误很可能是 bug。

自从 Python 引入类型提示以来,已经开发了众多用于检查类型一致性的工具。在本节中,我们将探讨其中的两个:mypy (github.com/python/mypy) 和 pytype (github.com/google/pytype)。虽然有多种工具可供选择,甚至你可能选择使用不同的工具,但通常,无论具体使用哪种工具,原则都是相同的:重要的是要有一个自动验证更改的方法,并将这些验证作为 CI 构建的一部分。mypy是 Python 中进行可选静态类型检查的主要工具。其理念是,一旦安装,它将分析项目中的所有文件,检查类型使用中的不一致性。这很有用,因为大多数时候,它会在早期发现实际的错误,但有时它可能会给出误报。

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

$ pip install mypy 

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

type_to_ignore = "something" # type: ignore 

重要的是要注意,为了使这个或任何工具变得有用,我们必须在代码中声明的类型注解上小心谨慎。如果我们对类型设置过于泛化,我们可能会错过工具可能报告的合法问题。

在下面的示例中,有一个旨在接收要迭代的参数的函数。最初,任何可迭代的对象都可以工作,因此我们想利用 Python 的动态类型能力,允许函数使用传递列表、元组、字典键、集合或几乎任何支持for循环的对象:

def broadcast_notification(
    message: str, 
    relevant_user_emails: Iterable[str]
):
    for email in relevant_user_emails:
        logger.info("Sending %r to %r", message, email) 

问题在于,如果代码的某些部分错误地传递了这些参数,mypy不会报告错误:

broadcast_notification("welcome", "user1@domain.com") 

当然,这不是一个有效的实例,因为它会迭代字符串中的每个字符,并尝试将其用作电子邮件。

如果我们对该参数设置的类型更加严格(比如说只接受字符串的列表或元组),那么运行 mypy 就会识别出这种错误场景:

$ mypy <file-name>
error: Argument 2 to "broadcast_notification" has incompatible type "str"; expected "Union[List[str], Tuple[str]]" 

类似地,pytype 也是可配置的,并且以类似的方式工作,因此你可以根据项目的具体上下文调整这两个工具。我们可以看到这个工具报告的错误与之前的情况非常相似:

File "...", line 22, in <module>: Function broadcast_notification was called with the wrong arguments [wrong-arg-types]
         Expected: (message, relevant_user_emails: Union[List[str], Tuple[str]])
  Actually passed: (message, relevant_user_emails: str) 

然而,pytype 有一个关键的区别,那就是它不仅会检查定义与参数之间的匹配,还会尝试解释代码在运行时是否正确,并报告可能出现的运行时错误。例如,如果一个类型定义暂时被违反,只要最终结果符合声明的类型,这就不会被视为问题。虽然这是一个很好的特性,但一般来说,我建议你尽量不要破坏代码中设置的不可变特性,并尽可能避免中间无效状态,因为这会使你的代码更容易推理和依赖,并且副作用更少。

代码中的通用验证

除了使用上一节中介绍的工具来检查程序中类型管理上的错误外,我们还可以使用其他工具,这些工具将对更广泛的参数提供验证。

在 Python 中有许多用于检查代码结构的工具(基本上,这是符合 PEP-8 规范),例如 pycodestyle(在 PyPi 中曾被称为 pep8),flake8 以及更多。它们都是可配置的,并且使用起来与运行它们提供的命令一样简单。

这些工具是运行在一系列 Python 文件上的程序,检查代码是否符合 PEP-8 标准,并报告每行违反规则的情况以及相应的错误提示。

还有其他工具提供更全面的检查,这样它们不仅验证代码是否符合 PEP-8,还包括对超出 PEP-8 的更复杂情况的额外检查(记住,代码仍然可以完全符合 PEP-8,但质量并不好)。

例如,PEP-8 主要关于代码的样式和结构,但它并不强制我们为每个 public methodclassmodule 添加文档字符串。它也没有提到函数参数过多的问题(我们将在本书后面的章节中将这个问题识别为不良特性)。

这些工具的一个例子是 pylint。这是目前最完整和最严格的用于验证 Python 项目的工具之一,它也是可配置的。和之前一样,要使用它,你只需在虚拟环境中使用 pip 安装即可:

$ pip install pylint 

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

可以通过名为pylintrc的配置文件来配置pylint。在这个文件中,你可以决定你想启用或禁用的规则,并参数化其他规则(例如,更改列的最大长度)。例如,正如我们刚才讨论的,我们可能不希望每个函数都有文档字符串,因为强制这样做可能适得其反。然而,默认情况下,pylint将强制执行此限制,但我们可以通过在配置文件中声明来覆盖它:

 [DESIGN]
    disable=missing-function-docstring 

一旦这个配置文件达到了稳定状态(意味着它与编码指南保持一致,不需要进行太多进一步的调整),然后它可以复制到其他存储库,那里也应该受到版本控制。

记录开发团队同意的编码标准,并在配置文件中强制执行,这些配置文件将用于在存储库中自动运行的工具。

最后,我想提到另一个工具,那就是Coala (github.com/coala/coala)。Coala更通用一些(这意味着它支持多种语言,而不仅仅是 Python),但想法与之前类似:它接受一个配置文件,然后提供一个命令行工具,该工具将对代码执行一些检查。在运行时,如果工具在扫描文件时检测到一些错误,它可能会提示用户,如果适用,它还会建议自动应用修复补丁。

但如果我的用例没有被工具的默认规则覆盖怎么办?pylintCoala都附带了许多预定义的规则,涵盖了最常见的场景,但你可能还在你的组织中检测到一些导致错误的模式。

如果你检测到代码中反复出现的、容易出错的模式,我建议花些时间来定义你自己的规则。这两个工具都是可扩展的:在pylint的情况下,有多个插件可用,你可以编写自己的。在Coala的情况下,你可以编写自己的验证模块,以便与常规检查并行运行。

自动格式化

如本章开头所述,团队就代码的编写约定达成一致是明智的,以避免在拉取请求中讨论个人偏好,并专注于代码的本质。但协议只能带你走这么远,如果这些规则不得到执行,它们会随着时间的推移而丢失。

除了通过工具检查代码是否符合标准之外,自动直接格式化代码也会很有用。

有多个工具可以自动格式化 Python 代码(例如,大多数验证 PEP-8 的工具,如 flake8,也具有重写代码使其符合 PEP-8 标准的模式),并且它们也是可配置和适应每个特定项目的。在这些工具中,也许正因为与完全灵活和配置相反,我想强调的是:black

black (github.com/psf/black) 有一个特性,以独特和确定的方式格式化代码,不允许任何参数(除了可能行长的设置)。

这其中的一个例子是 black 总是使用双引号来格式化字符串,参数的顺序也总是遵循相同的结构。这听起来可能有些死板,但这是确保代码差异最小化的唯一方法。如果代码始终遵循相同的结构,代码的更改只会出现在包含实际更改的 pull request 中,而不会有额外的外观修改。它比 PEP-8 更为严格,但同时也方便,因为通过工具直接格式化代码,我们实际上不必担心这一点,可以专注于手头问题的核心。

这也是 black 存在的原因。PEP-8 定义了一些代码结构的指南,但有多种方式可以使代码符合 PEP-8 标准,因此仍然存在寻找风格差异的问题。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()) 

在更复杂的代码中,会有更多的变化(尾随逗号等),但基本思想可以清楚地看到。再次强调,它具有主观性,但也是一个好主意,有一个工具可以为我们处理细节。

这也是 Go 社区很久以前就学到的东西,以至于有一个标准的工具库,go fmt,它可以自动根据语言的约定格式化代码。现在 Python 也有类似的东西是件好事。

安装后,默认情况下,'black' 命令会尝试格式化代码,但它还有一个 '--check' 选项,该选项将验证文件是否符合标准,如果验证未通过,则终止进程。这个命令是作为自动检查和 CI 流程的一部分的好候选。

值得注意的是,black 会彻底格式化文件,并且不支持部分格式化(与其他工具相反)。这可能是一个问题,因为遗留项目可能已经存在不同风格的代码,如果你想在项目中采用 black 作为格式化标准,你很可能会接受以下两种情况之一:

  1. 创建一个里程碑pull请求,将black格式应用于仓库中的所有 Python 文件。这有一个缺点,就是会添加很多噪声并污染仓库的版本控制历史。在某些情况下,你的团队可能会决定接受这个风险(取决于你对git历史的依赖程度)。

  2. 或者,你可以使用black格式重写代码中的历史变更。在git中,通过在每个提交上应用一些命令,可以重写提交(从最一开始)。在这种情况下,我们可以在'black'格式化应用后重写每个提交。最终,项目看起来就像从一开始就是新的形式,但也有一些注意事项。首先,项目的历史被重写了,所以每个人都需要刷新他们的本地仓库副本。其次,根据你的仓库历史,如果有大量的提交,这个过程可能需要一段时间。

在格式化采用“全有或全无”方式不可接受的情况下,我们可以使用yapf (github.com/google/yapf),这是另一个与black有许多不同之处的工具:它高度可定制,并且也接受部分格式化(仅对文件的特定区域应用格式化)。

yapf接受一个参数来指定要应用格式的行范围。利用这个参数,你可以配置你的编辑器或 IDE(或者更好的是,设置一个git pre-commit 钩子),以自动仅对刚刚更改的代码区域进行格式化。这样,项目可以在变更过程中以分阶段的时间间隔对齐到编码标准。

要总结本节关于自动格式化代码的工具,我们可以说black是一个很好的工具,它将推动代码向一个规范标准靠拢,因此,你应该尝试在你的仓库中使用它。在创建新仓库时使用black没有任何摩擦,但对于遗留仓库,这可能会成为一个障碍。如果团队决定在遗留仓库中采用black过于繁琐,那么像yapf这样的工具可能更适合。

自动检查的设置

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

对于此,一个好的方法是为测试设置目标,并为每个特定的测试设置一个目标,然后还有一个运行所有测试的目标;例如:

.PHONY: typehint
typehint:
	mypy --ignore-missing-imports src/
.PHONY: test
test:
	pytest tests/
.PHONY: lint
lint:
	pylint src/
.PHONY: checklist
checklist: lint typehint test
.PHONY: black
black:
	black -l 79 *.py
.PHONY: clean
clean:
	find . -type f -name "*.pyc" | xargs rm -fr
	find . -type d -name __pycache__ | xargs rm -fr 

在这里,我们运行的命令(在我们的开发机器上以及 CI 环境构建中)如下:

make checklist 

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

  1. 它将首先检查是否符合编码指南(PEP-8,或者例如使用'--check'参数的black)。

  2. 然后它将检查代码中对类型的用法。

  3. 最后,它将运行测试。

如果这些步骤中的任何一个失败,请考虑整个过程为失败。

这些工具(blackpylintmypy等)可以与您选择的编辑器或 IDE 集成,使事情更加简单。配置您的编辑器,在保存文件或通过快捷键进行此类修改,这是一个很好的投资。

值得注意的是,使用Makefile有几个原因很方便:首先,有一个简单且统一的方法来自动执行最重复的任务。新加入团队的成员可以通过学习像'make format'这样的命令来自动格式化代码,而不管使用的是哪种底层工具(及其参数)。此外,如果后来决定更改工具(比如说从yapf切换到black),那么相同的命令('make format')仍然有效。

其次,尽可能利用Makefile,这意味着配置你的 CI 工具以调用Makefile中的命令。这样,就有了一个标准化的方式来运行项目中的主要任务,我们将尽可能少地在 CI 工具中放置配置(这同样可能在将来发生变化,而且不必成为一项重大负担)。

摘要

我们现在对什么是干净的代码有一个初步的了解,以及对其的一个可行的解释,这将作为本书其余部分的一个参考点。

更重要的是,我们现在明白,干净的代码比代码的结构和布局更重要。我们必须关注代码中想法的表达方式,以判断它们是否正确。干净的代码关乎代码的可读性、可维护性,将技术债务降至最低,以及有效地在代码中传达我们的想法,以便他人能够理解我们最初想要写的内容。

然而,我们讨论了遵守编码风格或指南的重要性,原因有很多。我们一致认为,这是一个必要条件,但不是充分条件,并且由于它是每个稳健项目都必须遵守的最小要求,因此很明显,这是我们应该留给工具的事情。因此,自动化所有这些检查变得至关重要,在这方面,我们必须记住如何配置mypypylintblack和其他工具。

下一章将更加专注于 Python 特定的代码,以及如何用惯用的 Python 方式表达我们的想法。我们将探讨 Python 中那些使代码更加紧凑和高效的惯用用法。在这个分析中,我们将看到,总的来说,Python 与其他语言相比,有不同的想法或不同的方式来完成事情。

参考文献

第二章:Pythonic 代码

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

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

习语是代码,它们是语言相关的。每种语言都会有自己的习语,这意味着在该特定语言中做事的方式(例如,如何在 C 或 C++中打开和写入文件)。当代码遵循这些习语时,它被称为是 idiomatic 的,在 Python 中通常被称为 Pythonic。

有多个原因需要遵循这些建议并首先编写 Pythonic 代码(正如我们将看到和分析的那样),因为以习惯的方式编写代码通常性能更好。它也更紧凑,更容易理解。这些是我们总希望在代码中实现的特性,以便它能够有效工作。

其次,正如前一章所介绍的,整个开发团队能够习惯相同的代码模式和结构是很重要的,因为这将帮助他们专注于问题的真正本质,并帮助他们避免犯错误。

本章的目标如下:

  • 理解索引和切片,并正确实现可索引的对象

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

  • 了解上下文管理器的良好用例以及如何编写有效的上下文管理器。

  • 通过魔术方法实现更符合习惯的代码

  • 避免导致不期望副作用在 Python 中的常见错误

我们将在下一节中探索列表上的第一个项目(索引和切片)。

索引和切片

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

例如,您如何在 C 中访问数组的最后一个元素?这是我第一次尝试 Python 时做的事情。像在 C 中一样思考,我会获取数组长度减一的元素位置。在 Python 中,这也会起作用,但我们也可以使用负索引号,它将从最后一个元素开始计数,如下面的命令所示:

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

这是一种首选的(Pythonic)做事方式。

除了获取单个元素之外,我们还可以通过使用 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[::]  # also my_numbers[:], returns a copy
(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 

注意,当一个元素缺失(起始、结束或步长)时,它被视为 None

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

创建自己的序列

我们刚才讨论的功能之所以有效,是因为一个名为 __getitem__ 的魔法方法(魔法方法是 Python 用来保留特殊行为的双下划线包围的方法)。这是当调用类似 myobject[key] 的内容时调用的方法,将 key(方括号内的值)作为参数传递。特别是,序列是一个实现了 __getitem____len__ 的对象,因此它可以被迭代。列表、元组和字符串是标准库中的序列对象示例。

在本节中,我们更关注通过键从一个对象中获取特定元素,而不是构建序列或可迭代对象,这是在 第七章生成器、迭代器和异步编程 中探讨的主题。

如果你打算在你的领域中的自定义类中实现 __getitem__,你必须考虑到一些因素,以便遵循 Pythonic 方法。

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

from collections.abc import Sequence
class Items(Sequence):
    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.abc 模块中的 Sequence 接口(docs.python.org/3/library/collections.abc.html)。对于你编写的旨在作为标准类型对象(容器、映射等)行为的类,实现此模块中的接口是一个好主意,因为这揭示了该类意图成为什么,并且因为使用接口将迫使你实现所需的方法。

这个例子使用了组合(因为它包含一个内部协作者,即列表,而不是从列表类继承)。另一种方法是通过类继承来实现,在这种情况下,我们将不得不扩展 collections.UserList 基类,考虑到本章最后部分提到的考虑事项和警告。

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

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

  • slice 提供的范围内,尊重 Python 使用的语义,不包括末尾的元素

第一个要点是一个微妙的错误。想想看——当你从列表中获取一个切片时,结果是列表;当你在一个元组中请求一个范围时,结果是元组;当你请求一个子字符串时,结果是字符串。在每种情况下,结果与原始对象相同类型是有意义的。如果你创建的是代表日期区间的对象,比如说,并且你请求该区间的范围,返回列表或元组或其他东西将是一个错误。相反,它应该返回一个新的具有新区间设置的相同类的实例。标准库中的最佳例子是 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() 

尽管如此,有一种更加优雅且 Python 风格的实现相同功能的方法:

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 装饰器应用于一个函数时,它将那个函数上的代码转换为一个上下文管理器。所涉及的函数必须是一种特定的函数,称为 generator 函数,它将语句分别分离到 __enter____exit__ 魔法方法上。

如果到目前为止你对装饰器和生成器还不熟悉,这不是问题,因为我们将要查看的示例将是自包含的,无论这些主题是否被讨论,都可以应用和理解的。这些主题在 第七章生成器、迭代器和异步编程 中有详细讨论。

上一个示例的等效代码可以用 contextmanager 装饰器重写,如下所示:

import contextlib
@contextlib.contextmanager
def db_handler():
    try:
        stop_database()
        yield
    finally:
       start_database()
with db_handler():
    db_backup() 

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

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

以这种方式编写上下文管理器的好处是,它更容易重构现有函数,重用代码,并且在需要不属于任何特定对象的上下文管理器时,通常是一个好主意(否则,你会在面向对象的意义上创建一个“虚假”类,没有任何实际目的)。

添加额外的魔法方法会使我们领域的另一个对象更加耦合,承担更多的责任,并支持它可能不应该支持的东西。当我们只需要一个不保留许多状态的上下文管理器函数,并且完全独立于其他类时,这可能是一个不错的选择。

然而,我们还有更多实现上下文管理器的方法,而且答案再次在标准库的 contextlib 包中。

我们还可以使用另一个辅助工具 contextlib.ContextDecorator。这是一个基类,它提供了将装饰器应用于函数的逻辑,使得该函数可以在上下文管理器中运行。上下文管理器本身的逻辑必须通过实现上述魔法方法来提供。结果是,一个既可以作为函数装饰器使用,也可以混合到其他类的类层次结构中,使它们表现得像上下文管理器的类。

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

class dbhandler_decorator(contextlib.ContextDecorator):
    def __enter__(self):
        stop_database()
        return self
    def __exit__(self, ext_type, ex_value, ex_traceback):
        start_database()
@dbhandler_decorator()
def offline_backup():
    run("pg_dump database") 

你是否注意到与前面的例子有什么不同?没有 with 语句。我们只需要调用函数,offline_backup() 将会自动在上下文管理器中运行。这正是基类提供的逻辑,以便将其用作装饰器,将原始函数包装起来,使其在上下文管理器中运行。

这种方法的唯一缺点是,由于对象的工作方式,它们是完全独立的(这是一个好的特性)——装饰器不知道被装饰的函数,反之亦然。然而,这虽然好,意味着offline_backup函数无法访问装饰器对象,如果需要的话。然而,这并没有阻止我们在函数内部调用这个装饰器来访问对象。

这可以通过以下形式完成:

def offline_backup():
    with dbhandler_decorator() as handler: ... 

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

让我们探索contextlib的一个最后特性,看看我们可以期待从上下文管理器中得到什么,以及我们可以用它们做什么。

在这个库中,我们可以找到contextlib.suppress,这是一个在已知可以安全忽略异常的情况下避免某些异常的实用工具。它与在try/except块中运行相同代码并传递异常或只是记录日志类似,但不同之处在于调用suppress方法使得那些异常作为我们逻辑的一部分被控制得更加明确。

例如,考虑以下代码:

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

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

上下文管理器是 Python 的一个相当独特的特性,它区分了 Python。因此,使用上下文管理器可以被认为是惯用的。在下一节中,我们将探讨 Python 的另一个有趣特性,这将帮助我们编写更简洁的代码;理解表达式和赋值表达式。

理解和赋值表达式

在整本书中,我们会多次看到理解表达式。这是因为它们通常是一种更简洁的编写代码的方式,而且一般来说,以这种方式编写的代码更容易阅读。我这么说是因为有时如果我们需要对收集到的数据进行一些转换,使用理解可能会导致一些更复杂的代码。在这些情况下,编写一个简单的for循环应该更受欢迎。

然而,我们还有一个最后的手段可以尝试来挽救情况:赋值表达式。在本节中,我们将讨论这些替代方案。

推荐使用理解来在单个指令中创建数据结构,而不是多个操作。例如,如果我们想创建一个包含某些数字计算的列表,而不是像这样编写:

numbers = []  
for i in range(10):  
    numbers.append(run_calculation(i)) 

我们将直接创建列表:

numbers = [run_calculation(i) for i in range(10)] 

以这种形式编写的代码通常性能更好,因为它使用单个 Python 操作,而不是反复调用list.append。如果你对代码的内部结构或不同版本之间的差异感兴趣,可以查看dis模块,并用这些示例调用它。

让我们看看一个函数的例子,该函数将接受一些表示云计算环境中资源(例如 ARN)的字符串,并返回包含在其中的账户 ID 的集合。这样的函数可能会写成最简单的方式:

from typing import Iterable, Set
def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """Given several ARNs in the form
        arn:partition:service:region:account-id:resource-id
    Collect the unique account IDs found on those strings, and return them.
    """
    collected_account_ids = set()
    for arn in arns:
        matched = re.match(ARN_REGEX, arn)
        if matched is not None:
            account_id = matched.groupdict()["account_id"]
            collected_account_ids.add(account_id)
    return collected_account_ids 

显然,代码有很多行,并且执行的是相对简单的事情。阅读此代码的读者可能会被这些多个语句搞糊涂,并且在处理该代码时可能会无意中犯错。如果我们能简化它,那就更好了。我们可以通过使用几个类似于函数式编程的推导式来在更少的行中实现相同的功能:

def collect_account_ids_from_arns(arns):
    matched_arns = filter(None, (re.match(ARN_REGEX, arn) for arn in arns))
    return {m.groupdict()["account_id"] for m in matched_arns} 

函数的第一行看起来类似于应用 mapfilter:首先,我们将尝试匹配正则表达式的结果应用于提供的所有字符串,然后过滤掉那些不是 None 的字符串。结果是我们将后来用于在集合推导式中提取账户 ID 的一个迭代器。

之前的功能应该比我们的第一个例子更容易维护,但仍然需要两个语句。在 Python 3.8 之前,无法实现更紧凑的版本。但是,随着 PEP-572([www.python.org/dev/peps/pep-0572/](https://www.python.org/dev/peps/pep-0572/))中引入赋值表达式,我们可以用单个语句重写它:

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    return {
        matched.groupdict()["account_id"]
        for arn in arns
        if (matched := re.match(ARN_REGEX, arn)) is not None
    } 

注意在列表推导式中的第三行语法。这在该作用域内设置了一个临时标识符,它是将正则表达式应用于字符串的结果,并且可以在同一作用域内的更多部分重复使用。

在这个特定的例子中,关于第三个例子是否比第二个例子更好(但应该没有疑问,这两个例子都比第一个例子好!)是有争议的。我相信这个最后的例子更具有表达性,因为它在代码中具有更少的间接引用,而且读者需要了解的关于值是如何被收集的信息都属于同一个作用域。

请记住,代码更加紧凑并不总是意味着代码更好。如果我们为了写一行代码而不得不创建一个复杂的表达式,那么这就不值得了,我们还不如采用简单的方法。这与我们在下一章将要讨论的“保持简单”原则有关。

考虑推导式的可读性,如果这样一行代码实际上不会更容易理解,就不要强迫你的代码成为一行。

使用赋值表达式的一般好处(而不仅仅是推导式)还包括性能考虑。如果我们必须将函数作为我们的转换逻辑的一部分,我们不希望调用它比必要的次数更多。将函数的结果分配给一个临时标识符(正如新作用域中的赋值表达式所做的那样)将是一种很好的优化技术,同时也能使代码更具可读性。

评估使用赋值表达式所能带来的性能提升。

在下一节中,我们将回顾 Python 的另一个惯用特性:属性。此外,我们还将讨论在 Python 对象中暴露或隐藏数据的不同方式。

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

在 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} 

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

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

类应该只暴露与外部调用者对象相关的属性和方法,即其接口。不属于对象接口的任何内容都应该以单个下划线为前缀。

以下划线开头的属性必须被视为私有的,并且不能从外部调用。另一方面,作为这个规则的例外,我们可以说在单元测试中,如果这样做可以使测试更容易进行,那么允许访问内部属性可能是允许的(但请注意,即使遵循这种实用方法,在决定重构主类时,维护成本仍然存在)。然而,请记住以下建议:

使用过多的内部方法和属性可能是类承担太多任务且不符合单一职责原则的标志。这可能表明你需要将其部分职责提取到更多协作的类中。

使用单个下划线作为前缀是 Pythonic 的方式,可以清楚地界定一个对象接口。然而,有一个常见的误解,即某些属性和方法实际上可以被设置为私有。这同样是一个误解。让我们假设现在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 为属性创建了一个不同的名字(这被称为名称混淆)。它所做的就是用以下名字创建属性:"_<类名>__<属性名>"。在这种情况下,将创建一个名为'_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 约定,即它是一个私有属性。

不要定义以双下划线开头属性的属性。

同样地,不要定义自己的“双下划线”方法(方法名被双下划线包围)

现在我们来探讨相反的情况,即当我们确实想要访问对象的一些旨在为public的属性时。通常,我们会使用properties来做这件事,我们将在下一节中探讨。

属性

通常,在面向对象设计中,我们创建对象来表示领域问题实体上的抽象。从这个意义上说,对象可以封装行为或数据。而且,很多时候,数据的准确性决定了对象是否可以创建。也就是说,某些实体只能存在于数据的一定值中,而不正确的值不应被允许。

正是因为这个原因,我们创建了验证方法,通常用于setter操作。然而,在 Python 中,有时我们可以通过使用properties来更紧凑地封装这些settergetter方法。

考虑一个需要处理坐标的地理系统示例。纬度和经度只有一定范围内的值是有意义的。超出这些值,坐标将无法存在。我们可以创建一个对象来表示坐标,但在这样做的时候,我们必须确保纬度的值始终在可接受的范围内。为此,我们可以使用properties

class Coordinate:
    def __init__(self, lat: float, long: float) -> None:
        self._latitude = self._longitude = None
        self.latitude = lat
        self.longitude = long
    @property
    def latitude(self) -> float:
        return self._latitude
    @latitude.setter
    def latitude(self, lat_value: float) -> None:
        if lat_value not in range(-90, 90 + 1):
            raise ValueError(f"{lat_value} is an invalid value for latitude")
        self._latitude = lat_value
    @property
    def longitude(self) -> float:
        return self._longitude
    @longitude.setter
    def longitude(self, long_value: float) -> None:
        if long_value not in range(-180, 180 + 1):
            raise ValueError(f"{long_value} is an invalid value for longitude")
        self._longitude = long_value 

在这里,我们使用一个属性来定义纬度和经度。这样做,我们确立了一个原则,即检索这些属性中的任何一个都将返回内部private变量中持有的值。更重要的是,当任何用户想要以下形式修改这些properties的值时:

coordinate.latitude = <new-latitude-value>  # similar for longitude 

使用@latitude.setter装饰器声明的验证方法将被自动(且透明地)调用,并将语句右侧的值(在前面代码中命名为lat_value)作为参数传递。

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

我们已经看到了当对象需要持有值时的情况,以及properties如何帮助我们以一致和透明的方式管理它们的内部数据,但有时,我们也可能需要根据对象的状态及其内部数据进行一些计算。大多数时候,属性是这一选择的好选择。

例如,如果你有一个需要以特定格式或数据类型返回值的对象,可以使用属性来完成这个计算。在先前的例子中,如果我们决定想要以最多四位小数的精度返回坐标(无论原始数字提供了多少位小数),我们可以在读取值的@property方法中进行四舍五入的计算。

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

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

使用properties,我们可以避免这种混淆。@property装饰器是查询,将回答某个问题,而@<property_name>.setter是执行某个操作的命令。

从这个例子中得到的另一条有益的建议如下——不要在一个方法中做超过一件事情。如果你想分配某个值然后检查其值,请将其分解为两个或更多个语句。

为了说明这意味着什么,使用之前的例子,我们可能需要一个settergetter方法来设置用户的电子邮件,然后另一个属性来简单地请求电子邮件。这是因为,一般来说,任何时间我们询问一个对象关于其当前状态,它都应该返回它而不产生副作用(不改变其内部表示)。或许我能想到的唯一例外是在懒属性的情况下:我们只想预先计算一次,然后使用计算出的值。对于其他情况,尽量使属性幂等,然后是允许改变对象内部表示的方法,但不要混合两者。

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

使用更紧凑的语法创建类

继续探讨有时我们需要对象来存储值的思想,在 Python 中初始化对象时有一个常见的模板,即在__init__方法中声明对象将拥有的所有属性,然后将其设置为内部变量,通常如下所示:

def __init__(self, x, y, … ):
    self.x = x
    self.y = y 

自 Python 3.7 以来,我们可以通过使用dataclasses模块来简化这一点。这个模块是在 PEP-557 中引入的。我们在上一章中已经看到了这个模块,在代码使用注解的上下文中,在这里我们将简要回顾它,从它如何帮助我们编写更紧凑的代码的角度来看。

此模块提供了一个@dataclass装饰器,当应用于一个类时,它会获取所有带有注释的类属性,并将它们视为实例属性,就像它们在初始化方法中声明一样。当使用此装饰器时,它将自动在类上生成__init__方法,因此我们不需要手动编写。

此外,此模块提供了一个field对象,它将帮助我们定义一些属性的特定特征。例如,如果我们需要的某个属性需要是可变的(例如一个list),我们将在本章后面的部分(在避免 Python 中陷阱的部分)看到,我们不能在__init__方法中传递这个默认空列表,而应该传递None,并在__init__内部将其设置为默认列表,如果提供了None

当使用field对象时,我们通常会使用default_factory参数,并将list类传递给它。这个参数是用来与不接受任何参数的可调用对象一起使用的,当该属性的值没有提供时,它将被调用以构造对象。

因为没有需要实现的__init__方法,如果我们需要运行验证或者想要有一些属性是从之前的属性计算或派生出来的,会发生什么?为了回答后者,我们可以依赖properties,正如我们在上一节中所探讨的。至于前者,数据类允许我们有一个__post_init__方法,它将在__init__自动调用,所以这是一个编写初始化后逻辑的好地方。

为了将这些内容付诸实践,让我们考虑一个例子,即建模 R-Trie 数据结构(其中 R 代表基数,这意味着它是在某个基数 R 上的索引树)的节点。这个数据结构及其相关算法超出了本书的范围,但为了本例的目的,我将提到这是一个设计用来回答关于文本或字符串的查询(如前缀和查找相似或相关单词)的数据结构。在非常基本的形式中,这个数据结构包含一个值(它保存一个字符,例如它的整数表示),然后是一个长度为 R 的数组或引用下一个节点(它是一个递归数据结构,与linked listtree等类似)。其想法是数组的每个位置隐式地定义了对下一个节点的引用。例如,假设值0映射到字符'a',那么如果下一个节点在其0位置包含一个不同于None的值,那么这意味着有一个对'a'的引用,并且它指向另一个 R-Trie 节点。

从图形上看,数据结构可能看起来像这样:

图片

图 2.1:R-Trie 节点的通用结构

我们可以编写如下代码块来表示它。在下面的代码中,名为next_的属性包含一个尾随下划线,这仅仅是为了将其与内置的next函数区分开来。我们可以争论说在这种情况下没有冲突,但如果我们需要在RTrieNode类中使用next()函数,那可能会出现问题(这些通常是难以捕捉的微妙错误):

from typing import List
from dataclasses import dataclass, field
R = 26
@dataclass
class RTrieNode:
    size = R
    value: int
    next_: List["RTrieNode"] = field(
        default_factory=lambda: [None] * R)

    def __post_init__(self):
        if len(self.next_) != self.size:
            raise ValueError(f"Invalid length provided for next list") 

前面的例子包含了几种不同的组合。首先,我们定义了一个R=26的 R-Trie 来表示英语字母表中的字符(这本身对于理解代码并不重要,但它提供了更多的上下文)。其想法是,如果我们想存储一个单词,我们为每个字母创建一个节点,从第一个字母开始。当有指向下一个字符的链接时,我们将它存储在对应字符的next_数组位置,为该字符创建另一个节点,依此类推。

注意类中的第一个属性:size。这个属性没有注释,所以它是一个常规类属性(为所有节点对象共享),而不是仅属于对象的某些东西。或者,我们也可以通过设置field(init=False)来定义它,但这个形式更紧凑。然而,如果我们想注释变量,但不将其视为__init__的一部分,那么这种语法是唯一可行的替代方案。

然后是另外两个属性,它们都有注释,但考虑不同。第一个属性value是一个整数,但它没有默认参数,因此当我们创建一个新的节点时,我们必须始终提供一个值作为第一个参数。第二个属性是一个可变参数(它自己的list),它确实有一个默认工厂:在这种情况下是一个lambda函数,它将创建一个大小为 R 的新列表,所有槽位都初始化为None。注意,如果我们使用field(default_factory=list)来处理这个问题,我们仍然会在创建每个对象时构造一个新的列表,但这会失去对该列表长度的控制。最后,我们希望验证我们不会创建具有错误长度的下一个节点列表的节点,因此在__post_init__方法中进行了验证。任何尝试创建此类列表的尝试都会在初始化时通过ValueError被阻止。

数据类提供了一种更紧凑的方式来编写类,无需在__init__方法中设置所有具有相同名称的变量,从而避免了样板代码。

当你拥有不进行许多复杂验证或数据转换的对象时,考虑这个替代方案。记住这个最后一点。注解很棒,但它们不强制数据转换。这意味着例如,如果你声明了一个需要是floatinteger的属性,那么你必须在这个__init__方法中执行这个转换。将这个作为数据类是不会做到的,这可能会隐藏微妙的错误。这是在验证不是严格必需且可以进行类型转换的情况下。例如,定义一个可以从多个其他类型创建的对象是完全可以的,比如将一个数字字符串转换为float(毕竟,这利用了 Python 的动态类型特性),只要在__init__方法中正确转换为所需的数据类型即可。

数据类可能是一个很好的用例,所有那些我们需要使用对象作为数据容器或包装器的地方,即我们使用命名元组或简单命名空间的情况。当你评估代码中的选项时,将数据类视为命名元组或命名空间的另一种替代方案。

可迭代对象

在 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 

这个对象被设计成用一对日期创建,当迭代时,它将产生指定日期间隔中的每一天,如下面的代码所示:

>>> from datetime import date
>>> 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__魔法方法。在这个方法中,它被定义为返回self,这表明对象本身就是一个iterable,所以在这个点上,循环的每一步都会调用该对象的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
>>> 

这个例子是可行的,但它有一个小问题——一旦耗尽,iterable将继续为空,因此引发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
>>> 

这是因为迭代协议的工作方式——一个iterable构建一个迭代器,这个迭代器就是正在迭代的。在我们的例子中,__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__,而每一个都会再次创建生成器。

这被称为容器iterable

通常,在处理生成器时,与容器iterable一起工作是一个好主意。

关于生成器的详细信息将在第七章生成器、迭代器和异步编程中更详细地解释。

创建序列

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

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

上一个章节的例子有一个优点,那就是它使用的内存更少。这意味着它一次只保留一个日期,并且知道如何逐个产生日期。然而,它有一个缺点,那就是如果我们想获取第 n 个元素,我们无法直接获取,只能迭代 n 次直到达到它。这是计算机科学中内存和 CPU 使用之间的典型权衡。

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

前面的符号(例如,O(n))被称为渐近符号(或“大 O”符号),它描述了算法的复杂度阶数。在非常高的层面上,这意味着算法作为输入大小(n)的函数需要执行多少操作。关于这方面的更多信息,你可以查看章节末尾列出的(ALGO01),其中包含对渐近符号的详细研究。

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

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__方法的对象(通常返回一个Boolean值)。这个方法在 Python 的in关键字存在时被调用。

犹如以下内容:

element in container 

当在 Python 中使用时,变为如下:

container.__contains__(element) 

你可以想象,当这个方法得到适当实现时,代码将多么易于阅读(并且更符合 Python 风格)!

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

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

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

如果地图本身(在代码中称为grid)能回答这个问题呢?甚至更好,如果地图能将这个动作委托给一个更小(因此更内聚)的对象呢?

我们可以用面向对象设计和魔法方法来更优雅地解决这个问题。在这种情况下,我们可以创建一个新的抽象来表示网格的界限,这可以成为一个对象本身。图 2.2有助于说明这一点:

图 2.2:使用组合、在不同类中分配责任以及使用容器魔法方法的示例

顺便提一下,确实,在一般情况下,类名指的是名词,并且通常是单数。所以,有一个名为Boundaries的类可能听起来有些奇怪,但如果我们仔细想想,也许对于这个特定的情况,说我们有一个表示网格所有边界的对象是有意义的,尤其是在它被使用的方式(在这种情况下,我们使用它来验证特定坐标是否在这些边界内)。

使用这种设计,我们可以询问map是否包含一个坐标,并且map本身可以有关其限制的信息,并将查询传递给其内部合作者:

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

__getattr__魔法方法在许多情况下都很有用。它可以用来创建另一个对象的代理。例如,如果你通过组合在另一个对象之上创建一个包装对象,并且想要将大多数方法委托给包装对象,而不是复制并定义所有这些方法,你可以实现__getattr__,它将内部调用包装对象上的相同方法。

另一个例子是当你知道你需要动态计算属性时。我在一个过去的项目中使用了GraphQL (graphql.org/) 和 Graphene (graphene-python.org/),在这个项目中。该库的工作方式是通过使用解析器方法。基本上,每当请求属性X时,都会使用名为resolve_X的方法。由于已经存在可以解析Graphene对象类中每个属性X的域对象,因此实现了__getattr__来知道从哪里获取每个属性,而不必编写大量的样板代码。

当你看到避免大量重复代码和样板代码的机会时,使用__getattr__魔法方法,但不要滥用这个方法,因为它会使代码更难以理解和推理。记住,具有未显式声明且仅动态出现的属性会使代码更难以理解。使用此方法时,你总是在代码紧凑性和可维护性之间权衡。

可调用对象

定义可以充当函数的对象是可能的(并且通常很方便)。这种用法最常见的一个应用是创建更好的装饰器,但这并不局限于这一点。

当我们尝试将对象作为普通函数执行时,将调用魔法方法__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
>>> callable(cc)
    True 

在本书的后面部分,我们将发现这个方法在创建装饰器时非常有用。

魔法方法总结

我们可以将前几节中描述的概念总结成如下形式的速查表。对于 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.<属性> __getattr__ 动态属性检索
obj(*args, **kwargs) __call__(*args, **kwargs) 可调用对象

表 2.1:魔法方法和它们在 Python 中的行为

正确实现这些方法(以及知道需要一起实现的方法集)的最佳方式是,将我们的类声明为实现collections.abc模块中定义的相应抽象基类。这些接口提供了需要实现的方法,这将使你更容易正确地定义类,并且它还会负责正确创建类型(当在对象上调用isinstance()函数时,这会工作得很好)。(docs.python.org/3/library/collections.abc.html#collections-abstract-base-classes

我们已经看到了 Python 在特殊语法方面的主要特点。通过我们学到的特性(上下文管理器、可调用对象、创建自己的序列等),我们现在能够编写与 Python 保留字很好地融合的代码(例如,我们可以使用自己的上下文管理器与with语句一起使用,或者使用in运算符与自己的容器一起使用。)

通过实践和经验,你会更加熟练地掌握 Python 的这些特性,直到它们成为你自然的行为,即用良好且小巧的接口将你正在编写的逻辑封装在抽象中。给它足够的时间,反向效果就会发生:Python 将开始为你编程。也就是说,你会在程序中自然地想到拥有小巧、干净的接口,即使你用不同的语言创建软件,你也会尝试使用这些概念。例如,如果你发现自己正在用,比如说,Java 或 C(甚至 Bash)编程,你可能会发现一个上下文管理器可能有用的场景。现在,语言本身可能不支持这一点,但这可能不会阻止你编写自己的抽象,提供类似的保证。这是一件好事。这意味着你已经在特定语言之外内化了良好的概念,并且可以在不同的情境中应用它们。

所有编程语言都有它们的注意事项,Python 也不例外,因此为了更全面地理解 Python,我们将在下一节中回顾一些注意事项。

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_metadata 的默认参数。

实际上,这只有在第一次调用时不带参数时才会起作用。第二次调用时,我们没有明确传递任何内容给 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 变量指向它。当 Python 解释器解析文件时,它会读取函数,并找到一个创建字典并将其分配给参数的语句。从那时起,字典只创建一次,在整个程序的生命周期中都是相同的。

然后,函数体修改了这个对象,只要程序在运行,这个对象就会保留在内存中。当我们向它传递一个值时,这个值将取代我们刚刚创建的默认参数。当我们不想要这个对象时,它会再次被调用,并且自从上次运行以来已经被修改;下次我们运行它时,将不会包含键,因为它们在上一次调用中被删除了。

修复方法也很简单——我们需要使用 None 作为默认哨兵值,并在函数体中分配默认值。因为每个函数都有自己的作用域和生命周期,所以每次 None 出现时,user_metadata 都会被分配到字典中:

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(一种 C 优化)中的类方法不会相互调用(正如它们应该的那样),所以如果你覆盖了其中之一,这不会反映在其他方法中,从而导致意外的结果。例如,你可能想覆盖 __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}" 

初看起来,这个对象的行为似乎是我们想要的。但是,如果我们尝试迭代它(毕竟,它是一个 list),我们会发现我们没有得到我们想要的结果:

>>> 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 函数会尝试迭代(在 list 上运行一个 for 循环),但期望值是 string 类型。我们可能会期望这会工作,因为我们修改了 __getitem__ 方法,使其总是返回一个 string。然而,根据结果,我们可以得出结论,我们的 __getitem__ 修改版本没有被调用。

这个问题实际上是 CPython 的实现细节,而在其他平台如 PyPy 中则不会发生这种情况(参见本章末尾参考文献中 PyPy 和 CPython 之间的差异)。

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

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 中与协程一起工作的语法,因为这对读者可能有用,书中可能会出现带有协程的示例。

异步编程背后的思想是让我们的代码中的一部分能够挂起,以便其他部分可以运行。通常,当我们运行 I/O 操作时,我们非常希望保持代码运行,并在那段时间内使用 CPU 做其他事情。

这改变了编程模型。我们不再以同步方式调用,而是以事件循环调用的方式编写我们的代码,事件循环负责调度协程,以便在同一个进程和线程中运行所有协程。

思想是我们创建一系列协程,并将它们添加到事件循环中。当事件循环开始时,它会在其拥有的协程中挑选,并安排它们运行。在某个时刻,当我们的协程需要执行 I/O 操作时,我们可以触发它,并通知事件循环再次接管控制权,然后安排另一个协程在这次操作保持运行的同时运行。在某个时刻,事件循环将从上次停止的地方恢复我们的协程,并从那里继续。记住,异步编程的优势是不在 I/O 操作上阻塞。这意味着代码可以在 I/O 操作进行时跳转到其他地方,然后再回来处理它,但这并不意味着有多个进程同时运行。执行模型仍然是单线程的。

为了在 Python 中实现这一点,曾经(现在仍然有)许多框架可用。但在 Python 的旧版本中,并没有特定的语法允许这样做,因此框架的工作方式在最初可能有些复杂,或者不太明显。从 Python 3.5 开始,语言中添加了用于声明协程的特定语法,这改变了我们在 Python 中编写异步代码的方式。在这之前,标准库中引入了一个默认的事件循环模块asyncio。有了这两个 Python 的里程碑,异步编程变得更加出色。

虽然本节使用asyncio作为异步处理的模块,但这并非唯一的选择。您可以使用任何库来编写异步代码(标准库之外有许多可用的库,例如trio (github.com/python-trio/trio) 和 curio (github.com/dabeaz/curio),仅举几个例子)。Python 提供的用于编写协程的语法可以被视为一个 API。只要您选择的库符合该 API,您应该能够使用它,而无需更改协程的声明方式。

与异步编程相比,语法上的差异在于协程就像函数一样,但在其名称之前使用async def进行定义。当在协程内部并且我们想要调用另一个协程(这可能是我们自己的,或者定义在第三方库中)时,我们通常会使用await关键字在其调用之前。当await被调用时,这会向事件循环发出信号以重新获取控制权。此时,事件循环将恢复其执行,协程将留在那里等待其非阻塞操作继续,同时,代码的另一个部分将运行(事件循环将调用另一个协程)。在某个时刻,事件循环将再次调用我们的原始协程,并且它将从离开的地方继续执行(紧随await语句之后的行)。

我们在代码中定义的典型协程具有以下结构:

async def mycoro(*args, **kwargs):
    # … logic
    await third_party.coroutine(…)
    # … more of our logic 

如前所述,定义协程有新的语法。这种语法引入的一个不同之处在于,与常规函数不同,当我们调用这个定义时,它不会运行其中的代码。相反,它将创建一个协程对象。该对象将被包含在事件循环中,在某个时刻,必须等待它(否则定义内部的代码将永远不会运行):

result = await mycoro(…)   #  doing result = mycoro() would be erroneous 

不要忘记等待您的协程,否则它们的代码将永远不会运行。注意asyncio给出的警告。

如前所述,Python 中有几个用于异步编程的库,具有可以运行前面定义的协程的事件循环。特别是对于asyncio,有一个内置函数可以运行一个协程直到其完成:

import asyncio
asyncio.run(mycoro(…)) 

Python 中协程的工作原理超出了本书的范围,但这个介绍应该能让读者更熟悉其语法。话虽如此,协程在技术上是在生成器之上实现的,我们将在第七章“生成器、迭代器和异步编程”中详细探讨。

摘要

在本章中,我们探讨了 Python 的主要特性,目标是理解其最独特的特性,这些特性使 Python 与其它语言相比显得独特。沿着这条路径,我们探讨了 Python 的不同方法、协议及其内部机制。

与上一章相比,这一章更侧重于 Python。本书主题的一个关键收获是,干净的代码不仅仅是遵循格式化规则(当然,这对于良好的代码库是至关重要的)。它们是一个必要条件,但不是充分条件。在接下来的几章中,我们将探讨更多与代码相关的想法和原则,目标是实现我们软件解决方案的更好设计和实现。

通过本章的概念和想法,我们探索了 Python 的核心:其协议和魔法方法。现在应该很清楚,编写 Pythonic、惯用代码的最佳方式不仅仅是遵循格式化约定,而且要充分利用 Python 提供的一切功能。这意味着您可以通过使用特定的魔法方法、上下文管理器,或者通过使用列表推导和赋值表达式来编写更简洁的语句,从而编写出更易于维护的代码。

我们还熟悉了异步编程,现在我们应该能够舒适地阅读 Python 中的异步代码。这很重要,因为异步编程越来越受欢迎,对于书中稍后探讨的主题将非常有用。

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

参考资料

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

第三章:优秀代码的一般特性

这是一本关于使用 Python 进行软件构建的书。优秀的软件是由良好的设计构建的。通过说诸如“干净代码”之类的话,你可能认为我们将探讨仅与软件实现细节相关的良好实践,而不是其设计。然而,这种假设是错误的,因为代码并不是与设计不同的东西——代码就是设计。

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

本章以及下一章将专注于更高层次的抽象设计原则。我将介绍在 Python 中应用的软件工程的一般原则。

尤其是对于本章,我们将回顾不同的原则,这些原则有助于良好的软件设计。高质量的软件应该围绕这些想法构建,并将作为设计工具。这并不意味着它们应该始终应用;实际上,其中一些代表不同的观点(例如,与设计由合同DbC)方法相比,防御性编程)。其中一些取决于上下文,并不总是适用。

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

本章的目标如下:

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

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

  • 设计可维护的软件,使其易于扩展和适应新的需求

  • 设计可重用的软件

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

设计由合同

我们正在开发的软件的一些部分并不是直接由用户调用的,而是由代码的其他部分调用。当我们将应用程序的责任划分为不同的组件或层时,这种情况就会发生,我们必须考虑它们之间的交互。

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

换句话说,如果我们有一个期望与一系列整数类型的参数一起工作的函数,而另一个函数通过传递字符串来调用我们的函数,那么很明显,它不应该按预期工作,但事实上,该函数根本不应该运行,因为它被错误地调用了(客户端犯了错误)。这种错误不应该无声地通过。

当然,在设计 API 时,应该记录预期的输入、输出和副作用。但文档不能强制软件在运行时的行为。这些规则,即代码的每个部分为了正常工作所期望的内容以及调用者从它们期望得到的内容,应该是设计的一部分,这就是合同概念发挥作用的地方。

DbC(Design by Contract)方法背后的理念是,我们不是隐式地在代码中放置各方期望的内容,而是双方同意一个合同,如果违反该合同,将引发异常,并清楚地说明为什么它不能继续。

在我们的上下文中,合同是一种强制执行某些规则的结构,这些规则必须在软件组件的通信过程中得到遵守。合同主要涉及先决条件和后置条件,但在某些情况下,也会描述不变性和副作用:

  • 先决条件:我们可以将这些检查称为代码在运行之前将执行的所有检查。它将检查在函数可以继续之前必须满足的所有条件。通常,这是通过验证在参数中提供的数据集来实现的,但如果我们认为这些验证的副作用被这种验证的重要性所掩盖,那么我们不应该停止运行各种验证(例如,验证数据库中的集合、文件或之前调用的其他方法)。请注意,这会对调用者施加约束。

  • 后置条件:与先决条件相反,在这里,验证是在函数调用返回后进行的。后置条件验证是运行以验证调用者从这个组件期望得到的内容。

  • 不变性:在函数的文档字符串中,记录不变性是一个好主意,即函数运行期间保持不变的事物,这可以作为函数逻辑正确性的表达。

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

虽然从概念上讲,所有这些项目都是软件组件合同的一部分,这也是应该写入此类组件文档的内容,但只有前两个(预设条件和后置条件)需要在低级别(代码)强制执行。

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

理念是,预设条件将客户端(如果它们想要运行代码的某些部分,就有义务满足它们),而后置条件将组件与客户端可以验证和执行的某些保证联系起来。

这样,我们就可以快速识别责任。如果预设条件失败,我们知道这是由于客户端的缺陷造成的。另一方面,如果后置条件检查失败,我们知道问题在于程序或类(供应商)本身。

具体来说,关于预设条件,重要的是要强调它们可以在运行时进行检查,如果它们发生,被调用的代码根本不应该运行(因为它的条件不成立,这样做可能会使事情变得更糟)。

预设条件

预设条件是函数或方法为了正确工作而期望接收的所有保证。在一般编程术语中,这通常意味着提供正确形成的数据,例如,初始化的对象、非空值等等。对于 Python 来说,特别是动态类型,这也意味着有时我们需要检查提供的数据的确切类型。这并不完全等同于类型检查,mypy这样的工具会做这种检查,而是验证所需的精确值。

这些检查的一部分可以通过使用静态分析工具(如mypy)在早期检测到,我们在第一章介绍、代码格式化和工具中已经介绍了它,但这些检查是不够的。一个函数应该对其将要处理的信息进行适当的验证。

现在,这提出了一个问题,即验证逻辑应该放在哪里,这取决于我们是否让客户端在调用函数之前验证所有数据,或者允许它在运行自己的逻辑之前验证它所接收到的所有内容。前者等同于宽容的方法(因为函数本身仍然允许任何数据,包括潜在的不规范数据),而后者等同于严格的方法。

在本次分析中,我们倾向于对 DbC 采用严格的方法,因为它在稳健性方面通常是最佳选择,并且在行业中通常是最常见的实践。

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

后置条件

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

假设函数或方法已经以正确的属性(即,满足其先决条件)被调用,那么后置条件将保证某些属性得到保留。

策略是使用后置条件来检查和验证客户端可能需要的所有内容。如果方法执行得当,后置条件验证通过,那么任何调用该代码的客户端都应该能够无问题地与返回的对象一起工作,因为合同已经得到履行。

Pythonic contracts

在撰写本书时,一个名为 PEP-316 的名为 Programming by Contract for 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 中看到的那样。

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

函数调用中的错误可能是由这些外部组件中的一个出现问题造成的,而不是我们自己的函数本身。如果是这种情况,我们的函数应该适当地传达这一点。这将使调试更容易。函数应该明确且无歧义地通知应用程序的其他部分有关无法忽略的错误,以便它们可以相应地处理。

实现这一机制的是异常。重要的是要强调,这就是异常应该被用于——明确宣布一个异常情况,而不是根据业务逻辑改变程序的流程。

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

不要将异常作为业务逻辑的“万能”机制。当代码出现调用者需要了解的问题时,才抛出异常。

这个最后的概念非常重要;异常通常是为了通知调用者出现了某些问题。这意味着异常应该谨慎使用,因为它们会削弱封装性。一个函数拥有的异常越多,调用函数就需要越多的预期,因此需要了解它所调用的函数。如果一个函数抛出了太多的异常,这意味着它不是那么上下文无关的,因为每次我们想要调用它时,我们都需要记住它所有的可能副作用。

这可以用作一种启发式方法来判断一个函数是否不够内聚并且承担了过多的职责。如果它抛出了太多的异常,这可能是一个信号,表明它需要被分解成多个更小的部分。

这里有一些与 Python 中的异常相关的建议。

在适当的抽象级别处理异常

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

在下面的示例中,我们可以看到我们所说的不同抽象层次混合的含义。想象一个对象,它在我们的应用程序中充当某些数据传输的角色。它连接到外部组件,数据在解码后将被发送。在下面的列表中,我们将关注 deliver_event 方法:

class DataTransport:
    """An example of an object handling exceptions of different levels."""
    _RETRY_BACKOFF: int = 5
    _RETRY_TIMES: int = 3
    def __init__(self, connector: Connector) -> None:
        self._connector = connector
        self.connection = None
    def deliver_event(self, event: 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_TIMES):
            try:
                self.connection = self._connector.connect()
            except ConnectionError as e:
                logger.info(
                    "%s: attempting new connection in %is", e, self._RETRY_BACKOFF,
                )
                time.sleep(self._RETRY_BACKOFF)
            else:
                return self.connection
        raise ConnectionError(f"Couldn't connect after {self._RETRY_TIMES} times")
    def send(self, data: bytes):
        return self.connection.send(data) 

对于我们的分析,让我们聚焦于 deliver_event() 方法如何处理异常。

ValueErrorConnectionError 有什么关系?不多。通过观察这两种高度不同的错误类型,我们可以了解责任应该如何划分。

ConnectionError 应该在 connect 方法内部处理。这允许行为有清晰的分离。例如,如果这个方法需要支持重试,那么处理这个异常就是一种实现方式。

相反,ValueError 属于事件的 decode 方法。在这个新的实现(在下一个示例中展示)中,这个方法不需要捕获任何异常——我们之前担心的异常要么被内部方法处理,要么被故意留下以引发。

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

def connect_with_retry(connector: Connector, retry_n_times: int, retry_backoff: int = 5):
    """Tries to establish the connection of <connector> retrying
    <retry_n_times>, and waiting <retry_backoff> seconds between attempts.
    If it can connect, returns the connection object.
    If it's not possible to connect after the retries have been exhausted, 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_backoff 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_backoff)
            time.sleep(retry_backoff)
    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_BACKOFF: int = 5
    _RETRY_TIMES: int = 3
    def __init__(self, connector: Connector) -> None:
        self._connector = connector
        self.connection = None
    def deliver_event(self, event: Event):
        self.connection = connect_with_retry(self._connector, self._RETRY_TIMES, self._RETRY_BACKOFF)
        self.send(event)
    def send(self, event: Event):
        try:
            return self.connection.send(event.decode())
        except ValueError as e:
            logger.error("%r contains incorrect data: %s", event, e)
            raise 

现在看看异常类的分离如何也界定了责任的分离。在第一个示例中,一切都被混合在一起,没有明确的关注点分离。然后我们决定将连接作为一个单独的关注点,所以在下一个示例中,创建了 connect_with_retry 函数,并且 ConnectionError 作为这个函数的一部分被处理,如果我们需要修改这个函数(就像我们做的那样)。另一方面,ValueError 不属于那个相同的逻辑,所以它被留在了 send 方法中,那里才是它应该存在的地方。

异常具有意义。因此,处理每种类型的异常都应在适当的抽象级别上进行(这意味着,根据它们属于我们应用程序的哪一层)。但它们有时也可能携带重要信息。由于这些信息可能敏感,我们不希望它们落入错误的手中,因此在下节中,我们将讨论异常的安全影响。

不要向最终用户暴露回溯信息

这是一个安全考虑。在处理异常时,如果错误非常重要,可能甚至允许程序在这种情况下失败,因为正确性被优先于鲁棒性。

当存在表示问题的异常时,重要的是尽可能详细地记录(包括回溯信息、消息以及我们能收集到的所有信息),以便可以有效地纠正问题。同时,我们希望尽可能详细地记录给自己——我们不希望任何这些信息对用户可见。

在 Python 中,异常的回溯包含非常丰富和有用的调试信息。不幸的是,这些信息对攻击者或恶意用户来说也非常有用,他们试图尝试并损害应用程序,更不用说这种泄露会代表重要的信息泄露,危害你组织的知识产权(因为代码的一部分将被暴露)。

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

避免空异常块

这甚至被称为最邪恶的 Python 反模式(REAL 01)。虽然预测并防御程序中的某些错误是好的,但过于防御可能会带来更糟糕的问题。特别是,过于防御的唯一问题是存在一个空的except块,它默默地通过而不做任何事情。

Python 非常灵活,允许我们编写即使有缺陷也不会引发错误的代码,如下所示:

try:
    process_data()
except:
    pass 

这个问题在于它永远不会失败,即使它应该失败。如果你还记得 Python 的禅宗,错误永远不应该默默通过,这也是非 Pythonic 的。

配置你的持续集成环境(通过使用在第一章简介、代码格式化和工具中探讨的工具),以自动报告空异常块。

在发生异常的情况下,这段代码将不会失败,这可能正是我们最初想要的。但如果有缺陷呢?当process_data()函数运行时可能会发生实际失败,我们希望知道我们的逻辑中是否存在错误,以便能够纠正它。编写这样的代码块将掩盖问题,使维护变得更加困难。

有两种替代方案:

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

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

最好的做法是应用这两项建议。处理更具体的异常(例如,AttributeErrorKeyError)将使程序更易于维护,因为读者将知道期望什么,并且可以了解其原因。它还将使其他异常能够被引发,如果发生这种情况,这通常意味着一个错误,但这次可以被发现。

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

如果你选择引发不同的异常,包括导致问题的原始异常(参见下一节)。

避免使用空的except块(使用pass)的另一个原因是它的隐含性:它没有告诉代码的读者我们实际上期望忽略该异常。一个更明确的方法是使用contextlib.suppress函数,它可以接受所有要忽略的异常作为参数,并且可以用作上下文管理器。

在我们的示例中,它可能看起来像这样:

import contextlib
with contextlib.suppress(KeyError):
    process_data() 

同样,正如前一个案例一样,尽量避免将通用的Exception传递给这个上下文管理器,因为效果将是相同的。

包含原始异常

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

我们可以使用raise <e> from <original_exception>语法(PEP-3134)。当使用这种结构时,原始的堆栈跟踪将被嵌入到新的异常中,原始异常将被设置在结果异常的__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语句上的表达式必须意味着一个不可能的条件。如果这个条件发生,这意味着软件中存在缺陷。

与错误处理方法相反,有些情况下我们不希望程序在发生特定错误时继续执行。这是因为,在某些情况下,错误无法克服,我们的程序无法纠正其执行路径(或自我修复),因此最好是快速失败,让错误被注意到,以便在下一个版本升级时得到纠正。

使用断言的目的是防止程序在出现此类无效场景时造成进一步损害。有时,停止程序并让它崩溃比让它基于错误假设继续处理要好。

根据定义,断言是代码中的一个布尔条件,程序必须为真才能正确运行。如果程序因AssertionError而失败,这意味着刚刚发现了一个缺陷。

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

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

不要捕获AssertionError异常,因为这可能会让代码的读者感到困惑。如果你期望代码的某个部分抛出异常,尝试使用更具体的一个。

之前关于捕获AssertionError的建议是不要让你的程序无声失败。但它可以优雅地失败。所以,与其让应用程序硬崩溃,不如捕获AssertionError并显示一个通用的错误消息,同时仍然将所有内部错误细节记录到公司的日志平台。关键不在于是否捕获这个异常,而在于断言错误是宝贵的信息来源,这将帮助你提高软件的质量。

确保断言失败时程序终止。这意味着断言通常被放在代码中以识别程序中的错误部分。在许多编程语言中存在一种趋势,认为当程序在生产环境中运行时可以抑制断言,但这违背了其目的,因为它们的目的正是让我们确切地知道那些需要修复的程序部分。

在 Python 中,特别是,使用–O标志将抑制assert语句,但出于上述原因,这被劝阻。

不要使用python –O运行你的生产程序,因为你想利用代码中的断言来纠正缺陷。

在断言语句中包含一个描述性的错误消息,并将错误记录下来,以确保你可以正确地调试和纠正问题。

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

一个更好的替代方案需要更多的代码行,但提供了更有用的信息:

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

在使用断言时,尽量避免直接使用函数调用,而是用局部变量的形式来编写表达式。

断言和异常处理之间有什么关系?有些人可能会问,在异常处理的光照下,断言是否已经没有意义了。如果我们可以用if语句检查并抛出异常,为什么还要断言一个条件呢?尽管如此,这里有一个细微的区别。一般来说,异常是用来处理与程序业务逻辑相关的意外情况,而断言则像是代码中的自我检查机制,用来验证(断言)其正确性。

因此,抛出异常会比使用assert语句更为常见。assert的典型用途是在算法维护一个必须始终保持的不变量逻辑的情况下:在这种情况下,你可能想要断言该不变量。如果这一点在某些地方被破坏,这意味着算法是错误的或者实现得不好。

我们已经探讨了 Python 中的防御性编程,以及一些与异常处理相关的话题。现在,我们继续讨论下一个重要主题,因为下一节将讨论关注点的分离。

关注点的分离

这是一个在多个层面上应用的设计原则。它不仅关乎低级设计(代码),而且在更高层次上的抽象也很相关,因此我们将在讨论架构时再次提到它。

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

软件中分离关注点的目标是通过对最小化涟漪效应来提高可维护性。涟漪效应意味着软件中从起点传播的变化。这可能是一个错误或异常触发一系列其他异常,导致在应用程序的远程部分产生缺陷的情况。也可能是因为我们对函数定义的简单更改,我们必须在代码库的多个部分中更改大量的代码。

显然,我们不希望出现这些场景。软件必须易于更改。如果我们必须修改或重构代码的一部分,这必须对应用程序的其他部分产生最小的影响,而实现这一点的方法是通过适当的封装。

以类似的方式,我们希望任何潜在的错误都被包含起来,以免造成重大损害。

这个概念与 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

YAGNI(缩写为You Ain't Gonna Need It)是一个你可能在编写解决方案时经常需要记住的想法,如果你不想过度设计它的话。

我们希望能够轻松修改我们的程序,因此我们希望使它们具有前瞻性。与此一致,许多开发者认为他们必须预测所有未来的需求,并创建非常复杂的解决方案,从而创建难以阅读、维护和理解的抽象。后来,发现那些预期的需求并没有出现,或者它们出现了,但以不同的方式(惊喜!),而原本应该处理这种情况的原始代码却不起作用。

问题在于现在甚至更难重构和扩展我们的程序。发生的事情是原始解决方案没有正确处理原始需求,当前解决方案也没有,仅仅是因为这是一个错误的抽象。

拥有可维护的软件并不是关于预测未来需求(不要做未来学!),而是编写只解决当前需求的软件,这样将来修改起来既可能又容易。换句话说,在设计时,确保你的决策不会限制你,并且你将能够继续构建,但不要构建超过必要性的东西。

通常情况下,我们可能会在某些情况下不遵循这个理念,因为我们知道一些我们认为可能适用或为我们节省时间的原则。例如,在本书的后面,我们将回顾设计模式,它们是面向对象设计典型情况下的常见解决方案。虽然研究设计模式很重要,但我们必须拒绝过早应用它们的诱惑,因为这可能会违反 YAGNI 原则。

例如,想象你正在创建一个类来封装组件的行为。你知道它是必要的,但后来你想到将来可能会有更多(和类似的)需求,所以可能会有创建一个基类的诱惑(为了定义必须实现的方法的接口),然后使你刚刚创建的类成为实现该接口的子类。这会有几个错误的原因。首先,你现在需要的只是最初创建的那个类(在我们不知道是否需要的情况下,投入更多时间来过度泛化解决方案并不是管理资源的好方法)。然后,那个基类被当前需求所偏颇,所以它可能不是正确的抽象。

最佳的方法是只编写现在需要的,同时不阻碍未来的改进。如果将来有更多需求,我们可以考虑创建一个基类,抽象一些方法,也许我们会发现为我们的解决方案出现的某种设计模式。这也是面向对象设计应该工作的方式:自下而上。

最后,我想强调 YAGNI 这个理念也适用于软件架构(而不仅仅是详细代码)。

KIS

KIS(代表保持简单)与前面的观点非常相关。当你设计一个软件组件时,避免过度设计。问问自己你的解决方案是否是满足问题的最小方案。

实现最小功能,正确解决问题,并且不会使你的解决方案比必要的更复杂。记住,设计越简单,可维护性就越高。

这个设计原则是我们希望在所有抽象级别都牢记在心的一个理念,无论是思考高级设计,还是处理特定的代码行。

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

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

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

class ComplicatedNamespace:
    """A 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):
        for attr_name, attr_value in data.items():
            if attr_name in self.ACCEPTED_VALUES:
                setattr(self, attr_name, attr_value) 

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

在 Python 中有许多场景,我们希望保持代码的简单性。其中之一与我们之前探讨过的问题相关:代码重复。在 Python 中抽象代码的常见方法是通过使用装饰器(我们将在第五章 使用装饰器改进我们的代码 中看到)。但如果我们在尝试避免一小段代码的重复,比如说三行代码呢?在这种情况下,编写装饰器可能需要更多的行数,并且对于我们要解决的简单重复行来说,可能更加麻烦。在这种情况下,运用常识和实用主义。接受一小部分重复可能比一个复杂的函数(当然,除非你找到了一个更简单的方法来消除重复并保持代码简单)要好。

作为保持代码简单的一部分,我建议避免使用 Python 的高级特性,如元类(或与元编程相关的一切),因为这些特性几乎很少需要(它们的使用有非常特殊的理由!),而且它们使得代码的阅读和维护变得更加复杂。

EAFP/LBYL

EAFP 代表 Easier to Ask Forgiveness than Permission(请求原谅比请求许可更容易),而 LBYL 代表 Look Before You Leap(三思而后行)。

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

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

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

之前代码的 EAFP 版本看起来会是这样:

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

如果你来自没有异常处理的语言,例如 C 语言,那么发现 LBYL(先检查后执行)方法更有用是合乎逻辑的。在其他语言,如 C++中,由于性能考虑,对异常的使用存在一些反对意见,但在 Python 中这通常并不成立。

当然,特定情况可能适用,但大多数时候,你会发现 EAFP 版本更能揭示意图。这样编写的代码更容易阅读,因为它直接进行所需任务,而不是预防性地检查条件。换句话说,在最后一个例子中,你会看到尝试打开文件并处理它的代码部分。如果文件不存在,我们将处理这种情况。在第一个例子中,我们会看到一个检查文件是否存在并尝试做某事的函数。你可能认为这也是清晰的,但我们不能确定。也许被询问的文件是另一个文件,或者是一个属于程序不同层的函数,或者是一个遗留的文件,等等。当你第一次看代码时,第二种方法更不容易出错。

你可以根据你的代码需要应用这两种思想,但一般来说,以 EAFP(先做后检查)风格编写的代码在第一眼看起来会更易于理解,所以如果有疑问,我建议你选择这个变体。

Python 中的继承

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

这些想法中最常用的是继承——开发者通常会从创建所需的类层次结构开始,并决定每个类应该实现的方法。

尽管继承是一个强大的概念,但它确实有其风险。主要的风险是,每次我们扩展基类时,我们都在创建一个新的类,它与父类紧密耦合。正如我们已经讨论过的,耦合是我们设计软件时希望减少到最小的事情之一。

开发者将继承与代码重用联系起来的主要场景之一是。虽然我们应该始终欢迎代码重用,但仅仅因为我们可以免费从父类获得方法,就强迫我们的设计使用继承来重用代码并不是一个好主意。正确重用代码的方式是拥有高度内聚的对象,它们可以轻松组合,并且可以在多个上下文中工作。

当继承是一个好的决定时

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

当创建一个新的子类时,我们必须考虑它是否真的会使用它刚刚继承的所有方法,作为一个启发式方法来查看类是否定义正确。如果我们发现我们不需要大多数方法,并且必须覆盖或替换它们,那么这是一个由多种原因造成的设计错误:

  • 超类定义得比较模糊,并且承担了过多的责任,而不是一个明确的接口

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

使用继承的一个好例子是,当你有一个类定义了某些组件及其行为(由这个类的接口定义,即其public方法和属性),然后你需要专门化这个类以创建执行相同操作但添加了其他内容或改变了某些行为特定部分的对象。

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

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

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

继承的反模式

如果将前面的部分总结成一个词,那将是专业化。正确使用继承是为了使对象专业化,并从基类创建更详细的抽象。

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

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

前面的说明可能看起来很明显,但它是非常常见的事情,尤其是在开发者试图仅为了重用代码而使用继承时。在下一个例子中,我们将看到一个典型的场景,它代表了 Python 中常见的反模式——有一个需要表示的领域问题,为此设计了一个合适的数据结构,但不是创建一个使用这种数据结构的对象,而是使对象本身成为数据结构。

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

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

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()这样的方法吗?然而,它们就在那里。它们也是public的,所以任何使用这个接口的用户都有权调用它们,无论它们可能带来什么不期望的副作用。关于这一点,我们通过扩展字典实际上并没有获得太多好处。实际上需要更新的唯一方法是针对所有受当前策略变化影响(change_in_policy())的客户的基础类,所以无论如何我们都需要自己定义它。

这是一个将实现对象与领域对象混合的问题。字典是一个实现对象,一种数据结构,适用于某些类型的操作,并且像所有数据结构一样有其权衡。事务策略应该代表领域问题中的某个东西,一个是我们试图解决的问题的组成部分。

不要在同一层级中将实现数据结构与业务领域类混合。

这样的层级结构是不正确的,仅仅因为从基类中获取了一些魔法方法(通过扩展字典来使对象可索引)并不足以成为创建这种扩展的理由。实现类应该仅在创建其他更具体的实现类时进行扩展。换句话说,如果你想创建另一个(更具体或略有修改的)字典,就扩展一个字典。同样的规则也适用于领域问题的类。

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

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

图片 1

图 3.1:方法解析顺序

通过类的属性值,这一点将变得明显:

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()]
['ConcreteModuleA', 'BaseModule1', 'BaseModule2', 'BaseModule', 'object'] 

了解方法在层次结构中如何被解决,在设计类时可以为我们带来好处,因为我们可以利用混入。

混入

混入(mixin)是一个封装了一些常见行为的基础类,目的是为了重用代码。通常,混入类本身并不有用,仅仅扩展这个类肯定不会起作用,因为大多数时候它依赖于定义在其他类中的方法和属性。想法是使用混入类与其他类一起,通过多重继承,这样混入类上使用的方 法或属性将可用。

假设我们有一个简单的解析器,它接受一个string并通过其由连字符(-)分隔的值提供迭代:

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类非常简单。它不需要任何代码,因为它利用了混入。这种类型的混入充当了一种装饰器。基于我们刚才看到的,Tokenizer将从混入中获取__iter__,然后这个混入,反过来,通过调用super()将任务委托给行上的下一个类(即BaseTokenizer),但它将值转换为大写,从而产生预期的效果。

正如我们在 Python 中讨论了继承,我们已经看到了诸如内聚性和耦合性这样的主题,这些主题对于我们的软件设计非常重要。这些概念在软件设计中反复出现,并且也可以从函数及其参数的角度进行分析,我们将在下一节中探讨这一点。

函数和方法中的参数

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

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

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

Python 中函数参数的工作原理

首先,让我们回顾一下在 Python 中如何将参数传递给函数的特定之处。

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

参数是如何传递给函数的

Python 的第一条规则是所有参数都是通过值传递的。总是这样。这意味着当将值传递给函数时,它们被分配给函数签名定义中的变量,以便稍后使用。

你会注意到,一个函数可能会也可能不会根据其类型修改它接收的参数。如果我们传递的是可变对象,并且函数体修改了它,那么当然,我们会有副作用,即它们在函数返回时已经被改变。

在以下内容中,我们可以看到差异:

>>> 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>,并将其分配回参数。在那个点上,参数只是函数作用域内的一个局部变量,与调用者中的原始参数无关。

另一方面,当我们传递列表,这是一个可变对象时,这个语句有不同的含义(它相当于在列表上调用.extend())。这个操作符通过修改一个持有原始列表对象引用的变量来就地修改列表。在这个第二种情况下,列表的引用是通过值传递给函数的。但由于它是一个引用,它正在修改原始列表对象,所以我们看到函数完成后发生了修改。这大致相当于这样:

>>> a = list(range(5))
>>> b = a  # the function call is doing something like this
>>> b.append(99)
>>> b
[0, 1, 2, 3, 4, 99]
>>> a
[0, 1, 2, 3, 4, 99] 

在处理可变对象时,我们必须小心,因为它可能导致意外的副作用。除非你绝对确定以这种方式操作可变参数是正确的,否则我建议避免这样做,寻找没有这些问题的替代方案。

不要修改函数参数。一般来说,尽可能避免在函数中产生不必要的副作用。

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
[] 

解包变量的最佳用途之一可以在迭代中找到。当我们必须遍历一个元素序列时,并且每个元素本身也是一个序列,同时解包每个正在遍历的元素是一个好主意。为了展示这个例子,我们将假装我们有一个接收数据库行列表的函数,并且它负责从这些数据中创建用户。第一个实现从每行的列位置中获取构建用户的值,这根本不符合习惯。第二个实现使用解包进行迭代:

from dataclasses import dataclass

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

@dataclass
class User:
    user_id: int
    first_name: str
    last_name: str
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这样的变量则不言自明。

我们也可以使用星号运算符在构造User对象时传递所有positional参数:

[User(*row) for row in dbrows] 

我们可以利用这种功能来设计我们自己的函数。

我们可以在标准库中找到一个这样的例子,那就是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. 

有一个类似的表示法,用于关键字参数,有两个星号(**)。如果我们有一个字典,并且用双星号将其传递给一个函数,它将执行的操作是选择键作为参数的名称,并将该keyvalue作为该函数中该参数的value传递。

例如,看看这个:

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

这与以下内容相同:

function(key="value") 

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

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

Python 的这个特性真的很强大,因为它让我们可以动态地选择我们想要传递给函数的值。然而,过度使用这个功能,将使代码更难以理解。

当我们像上一个例子那样定义一个函数,其中一个参数带有双星号,这意味着允许任意关键字参数时,Python 会将它们放置在一个我们可以随意访问的字典中。从之前定义的函数来看,kwargs参数是一个字典。一个好的建议是不要使用这个字典来从中提取特定的值。

也就是说,不要寻找字典的特定键。相反,直接在函数定义中提取这些参数。

例如,我们不必这样做:

def function(**kwargs):  # wrong
    timeout = kwargs.get("timeout", DEFAULT_TIMEOUT)
    ... 

让 Python 进行解包,并在签名处设置默认参数:

def function(timeout=DEFAULT_TIMEOUT, **kwargs):  # better
    ... 

在这个例子中,timeout 不是严格的位置唯一。我们将在接下来的几个部分中看到如何创建关键字唯一参数,但应该占主导地位的想法是不要操作kwargs字典,而是在签名级别执行适当的解包。

在深入探讨关键字唯一参数之前,让我们先从位置唯一参数开始。

位置唯一参数

正如我们已经看到的,位置参数(可变或不可变)是那些首先提供给 Python 函数的参数。这些参数的值根据它们提供给函数的位置来解释,这意味着它们分别分配给函数定义中的参数。

如果我们在定义函数参数时不使用任何特殊语法,默认情况下,它们可以通过位置或关键字传递。例如,在以下函数中,对函数的所有调用都是等效的:

>>> def my_function(x, y):
...     print(f"{x=}, {y=}")
...
>>> my_function(1, 2) 
x=1, y=2 
>>> my_function(x=1, y=2) 
x=1, y=2
>>> my_function(y=2, x=1) 
x=1, y=2
>>> my_function(1, y=2)
x=1, y=2 

这意味着,在第一种情况下,我们传递值12,根据它们的位置,分别分配给参数xy。使用这种语法,没有任何阻止我们以关键字(甚至以相反的顺序)传递相同的参数,如果需要的话(例如,为了更明确)。这里的唯一约束是,如果我们以关键字传递一个参数,所有后续的参数也必须以关键字提供(最后一个例子不能使用参数的相反顺序)。

然而,从 Python 3.8(PEP-570)版本开始,引入了新的语法,允许定义严格的位置参数(这意味着在通过传递值时我们不能提供它们的名称)。要使用这个功能,必须在最后一个仅位置参数的末尾添加一个斜杠(/)。例如:

>>> def my_function(x, y, /):
...     print(f"{x=}, {y=}")
...
>>> my_function(1, 2)
x=1, y=2
>>> my_function(x=1, y=2)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: my_function() got some positional-only arguments passed as keyword arguments: 'x, y' 

注意函数的第一个调用是如何工作的(就像之前一样),但从现在开始,任何尝试传递关键字参数的尝试都将失败。抛出的异常将在其消息中告诉我们尝试以关键字参数传递的位置参数。一般来说,使用关键字参数使代码更易读,因为你可以随时知道哪些值是为哪些参数提供的,但可能存在某些情况下这种语法是有用的,例如,在参数名称没有意义的情况下(因为它们不能有意义,不是因为我们在命名时做得不好!),尝试使用它们的名称将是徒劳的。

以一个非常简单的例子来说明,想象一个检查两个单词是否为同音异义词的函数。该函数接受两个字符串并执行一些处理。我们实际上并不关心这两个字符串的命名(坦白地说,它们的顺序也不重要,它们只是第一词和第二词)。试图为这些参数想出好的名称并没有太多意义,在调用函数时分配它们的关键字值也没有意义。

对于其他情况,应避免使用。

不要强迫有意义的参数仅作为位置参数。

在非常特殊的情况下,仅位置参数可能是个好主意,但大多数时候这并不是必需的。但一般来说,这不是你希望多次使用的功能,因为我们可以利用传递关键字参数的优势,因为这将使理解传递给哪个参数的值变得更容易。因此,相反的情况是你更愿意经常做的,即只使用关键字参数,正如我们将在下一节讨论的。

仅关键字参数

与之前的功能类似,还有可能使一些参数仅作为关键字使用。这更有意义,因为当我们在一个函数调用中分配关键字参数时,我们可以找到其含义,现在我们可以强制执行这种明确性。

在这种情况下(与上一个情况相反),我们使用*符号来表示关键字参数的开始。在函数签名中,所有在可变数量的位置参数(*args)之后的内容都将被定义为关键字参数。

例如,以下定义接受两个位置参数,然后是任意数量的位置参数,最后是两个最终参数,这些参数需要作为关键字参数传递。最后一个参数有一个默认值(尽管这不是强制性的,就像第三个例子一样):

>>> def my_function(x, y, *args, kw1, kw2=0):
...     print(f"{x=}, {y=}, {kw1=}, {kw2=}")
...
>>> my_function(1, 2, kw1=3, kw2=4)
x=1, y=2, kw1=3, kw2=4
>>> my_function(1, 2, kw1=3)
x=1, y=2, kw1=3, kw2=0 

函数调用清楚地说明了这种行为。如果我们不想在第一个两个参数之后有任何数量的位置参数,我们只需简单地用*代替*args

这个功能对于以向后兼容的方式扩展已经定义(并且正在使用)的函数或类非常有用。例如,如果你有一个接受两个参数的函数,它在代码中多次被调用(有时按位置传递参数,有时按关键字传递),而你又想添加一个第三个参数,那么你需要为它设置一个默认值,这样当前的调用才能继续工作。但更好的做法是将最后一个参数设置为关键字参数,这样新的调用就必须明确表示它们打算使用新的定义。

同样,当重构并保持兼容性时,这个功能也非常有用。想象一下,你有一个函数,你正在用新的实现来替换它,但你保留原始函数作为包装器,以保持兼容性。让我们分析以下函数调用之间的差异:

result = my_function(1, 2, True) 

另一个调用如下:

result = my_function(1, 2, use_new_implementation=True) 

很明显,第二个例子更加明确,你只需瞥一眼函数调用就能清楚地了解正在发生的事情。因此,将新参数(确定使用哪个实现)设置为关键字参数是有意义的。

在这种情况下,如果确实需要上下文才能理解某个参数,将其参数设置为关键字参数是一个好主意。

这些是关于 Python 函数中参数和参数如何工作的基础知识。现在我们可以利用这些知识来讨论良好的设计理念。

函数中的参数数量

在本节中,我们同意这样一个观点:拥有接受过多参数的函数或方法是设计不良(代码异味)的标志。然后,我们提出处理这个问题的方法。

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

另一个选择是使用我们在上一节中看到的 Python 特定功能,利用变量位置和关键字参数创建具有动态签名的函数。虽然这可能是一种 Python 风格的方法,但我们必须小心不要滥用这个特性,因为我们可能会创建出过于动态以至于难以维护的东西。在这种情况下,我们应该查看函数的主体。无论签名如何,无论参数看起来是否正确,如果函数正在执行太多不同的事情以响应参数的值,那么这是一个迹象,表明它必须被分解成多个更小的函数(记住,函数应该只做一件事,而且只做一件事!)。

函数参数和耦合

函数签名中的参数越多,它与调用函数的耦合性就越强。

假设我们有两个函数,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)——更不用说,从语义上讲,这也更有意义。

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

尽可能地与不可变对象一起工作,并尽量避免副作用。

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

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

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

当你想完美地包装另一个函数(例如,将调用super()的方法或装饰器)时,尽量只定义具有最通用参数(*args**kwargs)的函数。

关于软件设计良好实践的最终评论

良好的软件设计涉及结合遵循软件工程的良好实践和利用语言的大部分功能。充分利用 Python 提供的一切具有很大的价值,但同时也存在着滥用这种能力并试图将复杂特性纳入简单设计的巨大风险。

除了这个一般原则之外,添加一些最终的建议会更好。

软件中的正交性

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

修改模块、类或函数不应该对该组件的外部世界产生影响。这当然是高度期望的,但并不总是可能的。但即使在不可能的案例中,良好的设计也会尽可能地最小化影响。我们已经看到了一些想法,比如关注点的分离、内聚和组件的隔离。

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

在混入类示例中,我们创建了一个返回iterable的标记化对象。__iter__方法返回一个新的生成器的事实增加了所有三个类(基类、混入类和具体类)正交的可能性。如果这返回了具体的东西(比如一个list),这将导致对其他类的依赖,因为当我们把list改为其他东西时,我们可能需要更新代码的其他部分,揭示出这些类并不像它们应该的那样独立。

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

def calculate_price(base_price: float, tax: float, discount: float) -> 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)) 

注意,顶级函数是由两个正交函数组成的。值得注意的是我们如何计算price,这是另一个将要如何表示的方式。改变一个不会改变另一个。如果我们不传递任何特定内容,它将使用string转换作为默认表示函数,如果我们选择传递一个自定义函数,生成的string将改变。然而,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__变量中提及,以便它们可以被导出。

这有很多优点。除了每个文件更容易导航,事情更容易找到之外,我们还可以从以下原因来论证它将更加高效:

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

  • 该模块本身可能导入的模块更少,因为它需要的依赖更少,就像之前一样。

对于项目来说,有一个约定也很有帮助。例如,我们可以在所有文件中放置常量,而不是创建一个专门用于项目中要使用的常量值的文件,并从那里导入:

from myproject.constants import CONNECTION_TIMEOUT 

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

更多关于分离模块和创建 Python 包的细节将在第十章“干净的架构”中讨论,当我们从软件架构的角度探讨这一主题时。

摘要

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

基于这些想法,我们现在可以构建更健壮的代码。例如,通过应用 DbC,我们可以创建在约束内保证工作的组件。更重要的是,如果发生错误,这不会突然发生,而是我们将清楚地知道是谁违反了规则以及代码的哪个部分破坏了合同。这种模块化对于有效的调试至关重要。

沿着类似的思路,如果每个组件能够防御恶意意图或错误的输入,它就可以变得更加健壮。尽管这个想法与 DbC 的方向不同,但它可能与之很好地互补。防御性编程是一个好主意,特别是对于应用程序的关键部分。

对于这两种方法(DbC 和防御性编程),正确处理断言非常重要。记住它们在 Python 中的使用方式,并且不要将断言用作程序控制流逻辑的一部分。也不要捕获这个异常。

谈到异常,了解何时以及如何使用它们很重要,这里最重要的概念是避免将异常用作控制流(goto)类型的结构。

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

最后,我们讨论了函数中的参数数量,以及考虑 Python 特定性的清晰设计启发式方法。

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

参考文献

这里有一份您可以参考的信息列表:

第四章:SOLID 原则

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

  • S: 单一职责原则

  • O: 开放/封闭原则

  • L: Liskov 替换原则

  • I: 接口隔离原则

  • D: 依赖倒置原则

本章的目标如下:

  • 了解软件设计的 SOLID 原则

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

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

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

  • 通过接口隔离和依赖倒置进行设计

单一职责原则

单一职责原则SRP)指出,软件组件(通常,一个类)必须只有一个职责。类只有一个职责的事实意味着它只负责做一件具体的事情,因此我们可以得出结论,它必须只有一个改变的理由。

只有当领域问题中的某个事物发生变化时,类才需要更新。如果我们必须因为不同原因对类进行修改,这意味着抽象是不正确的,并且该类有太多的职责。这可能是至少缺少一个抽象的迹象:需要创建更多的对象来处理当前类所承受的额外职责。

如在第二章Pythonic 代码中所述,这个设计原则帮助我们构建更内聚的抽象——只做一件事,并且做得很好,遵循 Unix 哲学。我们希望避免所有情况下都有多个职责的对象(通常称为上帝对象,因为它们知道太多,或者比应该知道的还多)。这些对象组合了不同的(大多数是无关的)行为,这使得它们更难维护。

再次强调,类越小越好。

SRP 与软件设计中的内聚性概念密切相关,我们已经在第三章良好代码的一般特性中探讨了软件的关注点分离。我们在这里努力实现的是,类的设计方式使得它们的大部分属性和属性通常由其方法使用。当这种情况发生时,我们知道它们是相关概念,因此将它们归入同一抽象之下是有意义的。

从某种意义上说,这个想法与关系数据库设计中的规范化概念有些类似。当我们检测到对象的属性或方法接口上有分区时,它们可能最好被移动到其他地方——这是它们被混合成一个的多个不同抽象的迹象。

有另一种看待这个原则的方法。如果我们查看一个类时发现方法相互排斥且彼此不相关,那么它们就是需要分解成更小类的不同职责。

职责过多的类

在这个例子中,我们将为负责从源(这可能是日志文件、数据库或许多其他源)读取事件信息的应用程序创建一个案例,并确定每个特定日志对应的行为。

一个未能符合 SRP(单一职责原则)的设计可能看起来像这样:

图片 2

图 4.1:职责过多的类

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

# 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类将需要更改。问问自己这是否有意义。系统监控对象是否必须因为改变了数据的表示而改变?不是。

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

到现在为止,应该很清楚这个类相当脆弱,且不易维护。有很多不同的原因会影响这个类的变更。相反,我们希望外部因素尽可能少地影响我们的代码。解决方案,再次强调,是创建更小、更紧密的抽象。

分配职责

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

图片 B16567_04_02

图 4.2:在类之间分配职责

通过使用与这些新类的实例交互的对象,使用这些对象作为协作者,实现了相同的行为,但理念仍然是每个类封装了一组独立于其他类的特定方法。现在的想法是,对任何这些类的更改都不会影响其他类,并且它们都具有清晰和具体的意义。如果我们需要更改从数据源加载数据的方式,警报系统甚至都不会意识到这些更改,所以我们不需要在系统监控器上做任何修改(只要合同仍然保持不变),数据目标也没有被修改。

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

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

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

在本章中,我们探索的大多数(如果不是所有)原则的一个有趣观察是,我们不应该试图从一开始的设计就使其完全正确。想法是设计出易于扩展和更改的软件,并且能够向更稳定的版本进化。

尤其是你可以将 SRP(单一职责原则)作为一个思考过程。例如,如果你正在设计一个组件(比如说一个类),并且有很多不同的事情需要完成(就像之前的例子那样),从一开始你就可以预见到这不会有一个好的结果,你需要分离职责。这是一个好的开始,但接下来问题是:如何正确地划分职责的边界?为了理解这一点,你可以从编写一个单体类开始,以便理解内部协作以及职责是如何分配的。这将帮助你更清晰地了解需要创建的新抽象。

开放/封闭原则

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

例如,在设计一个类时,我们应该仔细封装实现细节,以便它具有良好的可维护性,这意味着我们希望它易于扩展但不易修改。

简单来说,这意味着我们当然希望我们的代码是可扩展的,以适应新的需求或领域问题的变化。这意味着当领域问题中出现新事物时,我们只想向我们的模型中添加新事物,而不是改变任何现有的、不易修改的部分。

如果因为某种原因,当我们需要添加新事物时,发现自己需要修改代码,那么这个逻辑可能设计得不好。理想情况下,当需求变化时,我们只想通过扩展模块来添加新的行为,而不需要显著改变当前的逻辑。

这个原则适用于多个软件抽象。这可能是一个类,甚至是一个模块,但我们讨论的想法是相同的。在接下来的两个小节中,我们将看到每个的示例。

不遵循 OCP 原则的可维护性风险示例

让我们从这样一个系统的例子开始,这个系统设计得并不遵循 OCP 原则,以便看到它带来的可维护性问题以及这种设计的僵化性。

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

图 4.3的类图中,我们看到一个与接口(一个基类,有多个可以多态使用的子类)一起工作的对象:

图片

图 4.3:一个不封闭修改的设计

初看这似乎像是一个可扩展的设计:添加一个新事件大概就是创建一个新的Event子类,然后系统监控器应该能够处理它们。然而,这并不完全准确,因为这完全取决于系统监控器类中使用的方法的实际实现。

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

# openclosed_1.py
@dataclass
class Event:
    raw_data: dict 
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' 

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

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

同样,我们可以看到此方法不是对修改封闭的。每次我们想要向系统中添加新类型的事件时,我们都需要更改此方法中的某些内容(更不用说elif语句链将是一个噩梦般的阅读体验了!)。

我们希望能够在不改变此方法的情况下添加新类型的事件(对修改封闭)。我们还希望能够支持新类型的事件(对扩展开放),这样当添加新事件时,我们只需要添加代码,而不需要更改现有的代码。

重新设计事件系统以实现可扩展性

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

为了实现一个符合开放/封闭原则的设计,我们必须面向抽象进行设计。

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

图 4.4:遵循 OCP 的设计

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

新代码应该看起来像这样:

# openclosed_2.py
class Event:
    def __init__(self, raw_data):
        self.raw_data = raw_data
    @staticmethod
    def meets_condition(event_data: dict) -> bool:
        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() 方法,根据其特定的标准。

这个例子依赖于 __subclasses__() 方法,因为它足以说明可扩展设计的理念。也可以使用其他替代方案,例如使用 abc 模块注册类,或者创建我们自己的注册表,但主要思想是相同的,对象之间的关系不会改变。

在这个设计中,原始的 identify_event 方法是封闭的:当我们向我们的领域添加新类型的事件时,它不需要被修改。相反,事件层次结构对新类型的扩展是开放的:当领域中出现新事件时,我们只需要创建一个新的实体,并定义其根据接口实现的准则。

扩展事件系统

现在,让我们证明这个设计实际上是我们想要的那么可扩展。想象一下,出现了一个新的需求,我们还需要支持用户在监控系统中执行的事务对应的事件。

设计的类图必须包括这个新的事件类型,如图 4.5 所示:

图 4.5:设计扩展图 4.5:设计扩展

我们创建新的类,在它的 meets_condition 方法上实现准则,其余的逻辑应该继续按之前的方式工作(包括新的行为)。

假设之前的所有定义都没有改变,以下是新类的代码:

# openclosed_3.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 

我们可以验证之前的案例仍然按预期工作,并且新的事件也被正确识别:

>>> 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 背后的主要思想是,对于任何类,客户端都应该能够无差别地使用其任何子类型,甚至没有注意到,因此不会在运行时破坏预期的行为。这意味着客户端完全隔离并且对类层次结构的变化一无所知。

更正式地说,这是 LSP 的原始定义(LISKOV 01):如果ST的子类型,那么T类型的对象可以被S类型的对象替换,而不会破坏程序。

这可以通过以下通用图表来理解。想象有一个客户端类需要(包含)另一个类型的对象。一般来说,我们希望这个客户端与某些类型的对象交互,也就是说,它将通过接口工作。

现在,这个类型可能只是一个通用的接口定义,一个抽象类或一个接口,而不是具有行为本身的类。可能有几个子类扩展这个类型(如图 4.6中名为Subtype的描述,直到N)。这个原则背后的思想是,如果层次结构实现正确,客户端类必须能够与任何子类的实例一起工作,甚至没有注意到。这些对象应该是可互换的,如图 4.6所示:

图片 6

图 4.6:一个通用的子类型层次结构

这与其他我们已经探讨过的设计原则相关,如面向接口的设计。一个好的类必须定义一个清晰简洁的接口,只要子类遵守这个接口,程序就会保持正确。

由于这个原因,这个原则也与设计合同背后的思想相关。给定类型和客户端之间存在一个合同。通过遵循 LSP 的规则,设计将确保子类尊重由父类定义的合同。

使用工具检测 LSP 问题

有些场景与 LSP 的关系非常明显是错误的,以至于我们可以通过我们在第一章,“介绍、代码格式化和工具”中学习到的工具轻松识别(主要是mypypylint)。

使用 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一起工作,而不会注意到任何差异。交换这两种类型的对象不应该使应用程序失败。未能做到这一点将破坏层次结构上的多态性。

如果将返回类型更改为除布尔值Boolean以外的其他类型,同样会出现相同的错误。其理由是,使用此代码的客户端期望得到一个Boolean值来工作。如果其中一个派生类更改了此返回类型,这将破坏契约,再次,我们无法期望程序能正常工作。

关于不同但共享公共接口的类型的一个快速说明:尽管这只是一个简单的示例来展示错误,但确实,字典和列表都有一些共同点;它们都是可迭代的。这意味着在某些情况下,可能有一个期望接收字典的方法和另一个期望接收列表的方法是有效的,只要两者都通过迭代器接口处理参数。在这种情况下,问题不在于逻辑本身(LSP 可能仍然适用),而在于签名类型的定义,它应该读取既不是list也不是dict,而是两者的联合。无论哪种情况,都必须进行修改,无论是方法的代码、整个设计还是类型注解,但无论如何都不应该忽略由mypy给出的警告和错误。

不要通过使用# type: ignore或类似的方式来忽略此类错误。重构或更改代码以解决真正的问题。工具正在报告一个实际的设计缺陷,这是有充分理由的。

这个原则从面向对象设计的角度来看也是合理的。记住,子类化应该创建更具体的类型,但每个子类都必须是父类声明的。以上一节中的例子为例,系统监控器希望能够与任何事件类型交互使用。但每个这些事件类型都是一个事件(一个LoginEvent必须是一个Event,其他子类也是如此)。如果这些对象中的任何一个通过没有实现基类Event的消息、实现这个类中没有声明的另一个公共方法或更改方法的签名来破坏层次结构,那么identify_event方法可能就不再起作用。

使用pylint检测不兼容的签名

LSP 的另一个强烈违反情况是,当不是通过改变层次结构中参数的类型,而是方法签名完全不同时。这看起来可能相当明显,但检测它可能并不总是那么容易记住;Python 是解释型语言,所以没有编译器在早期检测这些类型的错误,因此它们直到运行时才会被发现。幸运的是,我们有像mypypylint这样的静态代码分析器来早期捕获这类错误。

虽然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
from collections.abc import Mapping

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

    @staticmethod
    def meets_condition(event_data: dict) -> bool:
        return False

    @staticmethod
    def validate_precondition(event_data: dict):
        """Precondition of the contract of this interface.

        Validate that the ``event_data`` parameter is properly formed.
        """
        if not isinstance(event_data, Mapping):
            raise ValueError(f"{event_data!r} is not a dict")
        for moment in ("before", "after"):
            if moment not in event_data:
                raise ValueError(f"{moment} not in {event_data}")
            if not isinstance(event_data[moment], Mapping):
                raise ValueError(f"event_data[{moment!r}] is not a 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.validate_precondition(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) -> bool:
        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 中定义接口的唯一方式。后来,PEP-3119 引入了抽象基类的概念,作为以不同方式定义接口的方法。抽象基类的基本思想是,它们定义了一个基本行为或接口,一些派生类负责实现。这在我们要确保某些关键方法实际上被重写的情况下很有用,它也作为重写或扩展如isinstance()等方法功能的一种机制。

引入抽象基类是为了为开发者提供一个有用的强大工具,以指示必须实际实现的事情。例如,考虑到之前提出的原理(LSP),如果我们有一个通用的 Event 类,我们不希望直接使用这个类(因为它本身并没有什么意义),所以我们可能想处理实际的事件之一(例如 LoginEvent 这样的子类)。在这种情况下,我们可以将 Event 定义为一个抽象基类,以使这一点明确。然后系统监控器与事件类型一起工作,而 Event 类则充当接口(作为一种表示“任何具有这种行为的对象”)的方式。我们可以更进一步,并决定 meets_condition 方法的默认实现不够(或者有时,接口无法提供实现),并强制每个派生类实现它。为此,我们将使用 @abstractmethod 装饰器。

abc 模块还包含一种将某些类型注册为层次结构一部分的方法,这被称为虚拟子类。这个想法是通过添加一个新的标准——像鸭子一样走路,像鸭子一样嘎嘎叫,或者……它说它是鸭子,来进一步扩展鸭子类型的概念。

这些关于 Python 如何解释接口的概念对于理解这个原则和下一个原则非常重要。

抽象地说,ISP 声明,当我们定义一个提供多个方法的接口时,最好是将其分解成多个接口,每个接口包含较少的方法(最好是只有一个),具有非常具体和准确的范围。通过将接口分解成尽可能小的单元,以促进代码重用,每个想要实现这些接口的类很可能具有高度的内聚性,因为它们具有相当明确的行为和责任集。

提供过多功能的界面

现在,我们希望能够从多个数据源中解析事件,这些数据源有不同的格式(例如 XML 和 JSON)。遵循良好的实践,我们决定将接口作为我们的依赖项,而不是一个具体的类,并设计出如下内容:

图片 7

图 4.7:提供过多不相关功能的接口

为了在 Python 中创建这个接口,我们将使用抽象基类,并将方法(from_xml()from_json())定义为抽象的,以强制派生类实现它们。从这个抽象基类派生并实现这些方法的实体将能够与它们对应类型一起工作。

但是,如果某个特定的类不需要 XML 方法,而只能从 JSON 中构建呢?它仍然会携带接口中的 from_xml() 方法,由于它不需要它,它将不得不跳过。这并不灵活,因为它创建了耦合,并迫使接口的客户端使用他们不需要的方法。

接口越小越好

将其分为两个不同的接口,每个方法一个接口会更好。我们仍然可以通过让我们的事件解析器类实现这两个接口来达到相同的功能(因为接口或抽象基类只是具有一些增强约束的常规类,Python 支持多重继承)。现在的不同之处在于,我们可以在更具体的接口中声明每个方法,如果我们需要在代码的其他地方重用它:

图片

图 4.8:通过单独的接口实现相同的功能

在这种设计中,从XMLEventParser派生并实现from_xml()方法的对象将知道如何从 XML 构建,对于 JSON 文件也是如此,但最重要的是,我们保持了两个独立函数的正交性,并保留了系统的灵活性,而没有失去任何可以通过组合新的较小对象实现的功能。

这就是代码可能看起来像代表图 4.8的方式:

from abc import ABCMeta, abstractmethod
class XMLEventParser(metaclass=ABCMeta):
    @abstractmethod
    def from_xml(xml_data: str):
        """Parse an event from a source in XML representation."""

class JSONEventParser(metaclass=ABCMeta):
    @abstractmethod
    def from_json(json_data: str):
        """Parse an event from a source in JSON format."""

class EventParser(XMLEventParser, JSONEventParser):
    """An event parser that can create an event from source data either in XML or JSON format.
    """

    def from_xml(xml_data):
        pass

    def from_json(json_data: str):
        pass 

注意,接口所需的抽象方法必须在具体类中实现(尽管实际实现对于本例来说并不相关)。如果我们不实现它们,就会触发运行时错误,例如:

>>> from src.isp import EventParser
>>> EventParser()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class EventParser with abstract methods from_json, from_xml 

这与 SRP(单一责任原则)有些相似,但主要区别在于这里我们谈论的是接口,因此这是一个行为的抽象定义。没有理由改变,因为直到接口实际实现之前,那里什么都没有。然而,未能遵守这一原则将创建一个与正交功能耦合的接口,并且这个派生类也将未能遵守 SRP(它将有多于一个的理由去改变)。

接口应该有多小?

上一节中提到的观点是有效的,但同时也需要警告——如果被误解或走向极端,会是一条危险的道路。

基类(抽象或不抽象)为所有其他类定义了一个扩展它的接口。这个接口应该尽可能小,这一点必须从内聚性的角度来理解——它应该做一件事。这并不意味着它必须必然只有一个方法。在先前的例子中,两个方法都是做不相关的事情,因此将它们分开到不同的类中是有意义的。

但也可能存在多个方法合理地属于同一个类。想象一下,你想要提供一个混合类,该类抽象了上下文管理器中的某些逻辑,以便所有从该混合类派生的类都能免费获得上下文管理器逻辑。正如我们已经知道的,上下文管理器涉及两个方法:__enter____exit__。它们必须一起使用,否则结果将根本不是一个有效的上下文管理器!

如果没有将这两种方法放在同一个类中,会导致一个损坏的组件,不仅无用,而且危险。希望这个夸张的例子可以作为前一个章节中例子的平衡,这样你们可以更准确地了解设计接口。

依赖倒置

这是一个非常强大的想法,我们将在第九章“常见设计模式”和第十章“整洁架构”中再次探讨一些设计模式时再次提到。

依赖倒置原则DIP)通过提出一种有趣的设计原则,通过使我们的代码独立于脆弱、易变或不受我们控制的事物来保护我们的代码。依赖倒置的想法是,我们的代码不应该适应细节或具体实现,而应该是相反的:我们希望强制任何实现或细节通过某种 API 来适应我们的代码。

抽象必须以这种方式组织,它们不依赖于细节,而应该是相反的——细节(具体实现)应该依赖于抽象。

假设我们的设计中需要两个对象协作,ABAB 的一个实例一起工作,但结果是我们的模块并没有直接控制 B(它可能是一个外部库,或者由另一个团队维护的模块等)。如果我们的代码高度依赖于 B,当 B 发生变化时,代码就会崩溃。为了防止这种情况,我们必须倒置依赖:让 B 适应 A。这是通过提供一个接口并强制我们的代码不依赖于 B 的具体实现,而是依赖于我们定义的接口来实现的。然后,B 就有责任遵守该接口。

与前几节中探讨的概念一致,抽象也可以以接口(或 Python 中的抽象基类)的形式出现。

通常,我们预计具体实现比抽象组件变化得更频繁。正因为如此,我们将抽象(接口)放在我们期望系统变化、修改或扩展的地方,而无需改变抽象本身。

严格依赖的案例

我们事件监控系统最后一部分的任务是将识别出的事件发送到数据收集器进行进一步分析。这种想法的一个简单实现可能包括一个事件流类与数据目的地交互,例如,Syslog

图片 9

图 4.9:一个对另一个有强依赖的类

然而,这种设计并不很好,因为我们有一个高级类(EventStreamer)依赖于一个低级类(Syslog是一个实现细节)。如果我们想要以我们想要的方式向Syslog发送数据的方式发生变化,EventStreamer将需要被修改。如果我们想在运行时更改数据目标或添加新的目标,我们也会遇到麻烦,因为我们将发现自己需要不断修改stream()方法以适应这些要求。

反转依赖关系

解决这些问题的方法是让EventStreamer与一个接口而不是一个具体类一起工作。这样,实现这个接口的责任就交给了包含实现细节的低级类:

图片 10

图 4.10:通过反转依赖关系重构的功能。

现在有一个表示通用数据目标的界面,数据将发送到这个目标。注意,由于EventStreamer不依赖于特定数据目标的实现,因此它的依赖关系已经反转,它不需要随着这个目标的改变而改变,并且每个特定的数据目标都必须正确实现接口并在必要时适应变化。

换句话说,第一个实现中的原始EventStreamer只与Syslog类型的对象一起工作,这并不灵活。然后我们意识到它可以与任何可以响应.send()消息的对象一起工作,并确定这个方法是需要遵守的接口。现在,在这个版本中,Syslog实际上扩展了名为DataTargetClient的抽象基类,该类定义了send()方法。从现在起,每个新的数据目标类型(例如电子邮件)都必须扩展这个抽象基类并实现send()方法。

我们甚至可以在运行时修改这个属性,对于任何实现了send()方法的其它对象,它仍然可以正常工作。这就是为什么它通常被称为依赖注入:因为依赖关系可以动态提供(注入)。

聪明的读者可能会想知道为什么这是必要的。Python 足够灵活(有时过于灵活),它允许我们向EventStreamer对象提供任何特定的数据目标对象,而无需这个对象遵守任何接口,因为它具有动态类型。问题是这样的:当我们可以直接传递一个具有send()方法的对象时,为什么我们还需要定义抽象基类(接口)呢?

公平地说,这是真的;实际上没有必要这样做,程序将正常工作。毕竟,多态并不意味着(或要求)继承必须工作。然而,定义抽象基类是一种良好的实践,它带来了一些优势,首先是鸭子类型。与鸭子类型一起,我们可以提到模型变得更加可读——记住,继承遵循“是”的规则,因此通过声明抽象基类并从它扩展,我们是在说,例如,SyslogDataTargetClient,这是你的代码的用户可以阅读和理解的东西(再次强调,这是鸭子类型)。

总的来说,定义抽象基类并不是强制性的,但为了实现更干净的设计,这是可取的。这正是本书的目的之一——帮助程序员避免因为 Python 过于灵活而容易犯的错误,并且我们可以侥幸逃脱。

依赖注入

在上一节中探讨的概念给我们提供了一个强大的想法:而不是让我们的代码依赖于特定的具体实现,让我们创建一个强大的抽象,它作为中间层。在示例中,我们讨论了依赖于Syslog会导致设计僵化,因此我们为所有客户端创建了一个接口,并决定Syslog恰好是其中之一,因为它实现了DataTargetClient接口。这为未来想要添加的更多客户端打开了大门:只需创建一个新的实现该接口并定义send方法的类。现在,设计是可扩展的,对修改是封闭的(我们开始看到这些原则是如何相互关联的)。

现在,这些对象之间的协作将如何进行?在本部分,我们探讨如何将依赖提供给真正需要它的对象。

实现它的一个方法就是直接声明事件流器通过直接创建它需要的对象来工作,在这种情况下是一个Syslog对象:

class EventStreamer:
    def __init__(self):
        self._target = Syslog()
    def stream(self, events: list[Event]) -> None:
        for event in events:
            self._target.send(event.serialise()) 

然而,这种设计并不灵活,并且没有充分利用我们创建的接口。请注意,这种设计也难以测试:如果你要为这个类编写单元测试,你将不得不修补Syslog对象的创建,或者在它刚刚创建之后覆盖它。如果Syslog在创建时具有副作用(通常不是好的做法,但在某些情况下是可以接受的,例如,当你可能想要建立连接时),那么这些副作用也会带到这个初始化中。确实,这可以通过使用延迟属性来克服,但实际上控制我们提供的对象的不灵活性仍然存在。

一个更好的设计将使用依赖注入,并让目标提供给事件流器:

class EventStreamer:
    def __init__(self, target: DataTargetClient):
        self._target = target
    def stream(self, events: list[Event]) -> None:
        for event in events:
            self._target.send(event.serialise()) 

这利用了接口并实现了多态。现在我们可以在初始化时传递任何实现了这个接口的对象,这也更明确地表明事件流器与这类对象一起工作。

与前一种情况相反,这个版本也更容易测试。如果我们不想在我们的单元测试中处理Syslog,我们可以提供一个测试替身(只是一个符合接口的新类,对我们需要测试的内容有用)。

不要在初始化方法中强制创建依赖关系。相反,让你的用户通过在__init__方法中使用参数以更灵活的方式定义依赖关系。

在某些情况下,当对象有更复杂的初始化(更多参数)或有很多对象时,在依赖图中声明你的对象之间的交互可能是个好主意,然后让库为你实际创建对象(即,移除绑定不同对象的粘合代码的样板)。

这样的库的一个例子是pinject (github.com/google/pinject),它允许你声明对象之间的交互方式。在我们的简单示例中,一种可能的做法是编写如下代码:

class EventStreamer:
    def __init__(self, target: DataTargetClient):
        self.target = target
    def stream(self, events: list[Event]) -> None:
        for event in events:
            self.target.send(event.serialise())
class _EventStreamerBindingSpec(pinject.BindingSpec):
    def provide_target(self):
        return Syslog()
object_graph = pinject.new_object_graph(
    binding_specs=[_EventStreamerBindingSpec()]) 

与我们之前对类的定义相同,我们可以定义一个绑定规范,这是一个知道如何注入依赖关系的对象。在这个对象中,任何命名为provide_<dependency>的方法都应该返回具有该名称后缀的依赖关系(在我们的简单示例中,我们选择了Syslog)。

然后我们创建graph对象,我们将使用它来获取已经提供依赖关系的对象;例如

event_streamer = object_graph.provide(EventStreamer) 

将给我们一个event_streamer对象,其目标是Syslog的一个实例。

当你有多重依赖关系或对象之间的相互关系时,将它们声明化并让工具为你处理初始化可能是个好主意。在这种情况下,我们的想法是,对于这类对象,我们在一个地方定义它们是如何被创建的,然后让工具为我们完成这个工作(从这个意义上讲,它类似于一个工厂对象)。

请记住,这并不会失去我们从设计中获得的原始灵活性。对象图是一个知道如何根据定义构建其他实体的对象,但我们仍然完全控制着我们创建的EventStreamer类,并且可以像以前一样使用它,通过在初始化方法中传递任何符合所需接口的对象。

摘要

SOLID 原则是良好面向对象软件设计的核心指导原则。

构建软件是一项极其困难的任务——代码的逻辑复杂,其运行时的行为难以预测(有时甚至不可能),需求不断变化,环境也在变化,而且有多个可能出错的地方。

此外,还有多种不同的技术、范式或工具构建软件的方法,它们可以协同工作,以特定方式解决特定问题。然而,随着时间的推移,并非所有这些方法都会证明是正确的,因为需求会变化或发展。然而,到那时,对于错误的设计,已经太晚去做出改变了,因为它是僵化的、缺乏弹性的,因此很难将其重构为正确的解决方案。

这意味着,如果我们设计错误,将来会付出很大的代价。那么我们如何才能实现一个最终会带来回报的良好设计呢?答案是,我们并不确定。我们正在处理未来,未来是不确定的——没有办法确定我们的设计是否正确,我们的软件是否将在未来几年内保持灵活和适应性强。正是出于这个原因,我们必须坚持原则。

正是在这里,SOLID 原则发挥了作用。它们不是一条魔法规则(毕竟,在软件工程中没有银弹),但它们提供了良好的指导方针,这些方针在过去的项目中已被证明是有效的,并将使我们的软件更有可能成功。我们的目标不是从第一个版本就完全正确地满足所有需求,而是实现一个可扩展和灵活的设计,足以适应变化,这样我们就可以根据需要对其进行调整。

在本章中,我们探讨了 SOLID 原则,目的是为了理解清晰的设计。在接下来的章节中,我们将继续探讨语言的具体细节,并在某些情况下看看这些工具和特性如何与这些原则结合使用。

第五章利用装饰器改进我们的代码,探讨了如何通过利用装饰器来改进我们的代码。与本章更侧重于软件工程的抽象概念不同,第五章利用装饰器改进我们的代码将更侧重于 Python,但我们仍将使用我们刚刚学到的原则。

参考文献

这里有一份你可能需要参考的信息列表:

第五章:使用装饰器改进我们的代码

在本章中,我们将探讨装饰器,并了解它们在许多我们想要改进设计的情况中的有用之处。我们将首先探索装饰器是什么,它们是如何工作的,以及它们是如何实现的。

带着这些知识,我们将回顾我们在前几章中学到的关于软件设计的一般良好实践的概念,并看看装饰器如何帮助我们遵守每个原则。

本章的目标如下:

  • 理解装饰器在 Python 中的工作方式

  • 学习如何实现适用于函数和类的装饰器

  • 为了有效地实现装饰器,避免常见的实现错误

  • 分析如何使用装饰器避免代码重复(DRY 原则)

  • 研究装饰器如何有助于关注点的分离

  • 为了分析好的装饰器的例子

  • 为了回顾当装饰器是正确选择时的常见情况、习语或模式

Python 中的装饰器是什么?

装饰器是在 Python 中很久以前引入的,在 PEP-318 中,作为一种在函数和方法的原始定义之后需要修改时的简化定义方式的机制。

我们首先必须理解,在 Python 中,函数就像几乎所有其他东西一样是常规对象。这意味着你可以将它们赋给变量,通过参数传递它们,甚至将其他函数应用于它们。通常,人们会想写一个小函数,然后对其应用一些转换,生成该函数的新修改版本(类似于数学中函数组合的工作方式)。

引入装饰器的原始动机之一是因为像classmethodstaticmethod这样的函数用于转换方法的原始定义,它们需要一个额外的行,在单独的语句中修改函数的原始定义。

更普遍地说,每次我们需要对一个函数应用一个转换时,我们必须使用modifier函数来调用它,然后将其重新赋值回原来定义该函数的同一名称。

例如,如果我们有一个名为original的函数,然后我们有一个在它之上改变original行为的函数,称为modifier,我们必须编写如下内容:

def original(...):
    ...
original = modifier(original) 

注意我们是如何更改函数并将其重新赋值回同一名称的。这很令人困惑,容易出错(想象一下有人忘记重新赋值函数,或者虽然重新赋值了,但不是在函数定义后的下一行,而是在更远的地方),而且很麻烦。因此,语言增加了一些语法支持。

之前的例子可以重写如下:

@modifier
def original(...):
   ... 

这意味着装饰器只是将装饰器之后的内容作为装饰器本身的第一个参数调用的语法糖,结果将是装饰器返回的内容。

装饰器的语法大大提高了代码的可读性,因为现在代码的读者可以在一个地方找到函数的整个定义。请记住,手动修改函数如以前一样仍然是允许的。

通常情况下,尽量避免在不使用装饰器语法的情况下重新分配已经设计好的函数的值。特别是,如果函数被重新分配为其他内容,并且这种情况发生在代码的远程部分(远离函数最初定义的地方),这将使你的代码更难以阅读。

根据 Python 术语和我们的示例,modifier是我们所说的装饰器,而original是被装饰的函数,通常也称为包装对象。

虽然最初的功能是为方法和函数考虑的,但实际的语法允许任何类型的对象被装饰,因此我们将探讨应用于函数、方法、生成器和类的装饰器。

最后一点需要注意的是,虽然装饰器的名字是正确的(毕竟,装饰器正在对包装函数进行更改、扩展或在其之上工作),但它不应与装饰器设计模式混淆。

函数装饰器

函数可能是 Python 中可以装饰的最简单对象表示。我们可以使用装饰器在函数上应用各种逻辑——我们可以验证参数,检查先决条件,完全改变其行为,修改其签名,缓存结果(创建原始函数的缓存版本),等等。

作为例子,我们将创建一个基本的装饰器,实现一个retry机制,控制特定的域级别异常,并尝试一定次数:

# 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操作,在特定条件下(在这种情况下,表示可能相关的超时异常),将允许多次调用被装饰的代码。

类装饰器

在 Python 中,类也是对象(坦白说,在 Python 中几乎一切都是对象,很难找到反例;然而,有一些技术上的细微差别)。这意味着相同的考虑也适用;它们也可以通过参数传递,分配给变量,调用某些方法,或者被转换(装饰)。

类装饰器是在 PEP-3129 中引入的,并且它们与我们已经探索过的函数装饰器有非常相似的考虑。唯一的区别是,在编写这种装饰器的代码时,我们必须考虑到我们正在接收一个类作为包装方法的参数,而不是另一个函数。

当我们在“第二章”,“Pythonic 代码”中看到dataclasses.dataclass装饰器时,我们看到了如何使用类装饰器。在本章中,我们将学习如何编写我们自己的类装饰器。

一些从业者可能会认为装饰类是一种相当复杂的事情,并且这种场景可能会危及可读性,因为我们在类中声明了一些属性和方法,但幕后,装饰器可能正在应用一些会使其成为完全不同类的更改。

这种评估是正确的,但只有当这种技术被过度使用时。客观上,这与装饰函数没有区别;毕竟,类只是 Python 生态系统中的另一种类型对象,就像函数一样。我们将在标题为“装饰器和关注点分离”的章节中回顾使用装饰器的利弊,但现在,我们将探讨装饰器对类特别有益的益处:

  • 代码重用和 DRY 原则的所有好处。一个有效的类装饰器用例是强制多个类遵守某个接口或标准(通过在将被应用于许多类的装饰器中只写一次这些检查)。

  • 我们可以创建更小或更简单的类,这些类可以通过装饰器在以后进行增强。

  • 如果我们使用装饰器而不是更复杂(并且通常被正确劝阻)的方法,如元类,那么我们需要应用于特定类的转换逻辑将更容易维护。

在所有可能的装饰器应用中,我们将探索一个简单的例子来展示它们可能有用的情况。请记住,这并不是类装饰器的唯一应用类型,而且我向你展示的代码也可以有其他许多解决方案,所有这些解决方案都有其优缺点,但我选择装饰器是为了说明它们的有用性。

回顾我们的监控平台的事件系统,我们现在需要转换每个事件的 数据并将其发送到外部系统。然而,每种类型的事件在选择如何发送其数据时可能都有自己的特性。

特别是,登录事件的event可能包含敏感信息,例如我们想要隐藏的凭证。其他字段,如timestamp,也可能需要一些转换,因为我们希望以特定的格式显示它们。满足这些要求的一个初步尝试就是拥有一个映射到每个特定事件并知道如何序列化它的类:

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"),
        }
@dataclass
class LoginEvent:
    SERIALIZER = LoginEventSerializer
    username: str
    password: str
    ip: str
    timestamp: datetime
    def serialize(self) -> dict:
        return self.SERIALIZER(self).serialize() 

在这里,我们声明一个类,它将直接与登录事件映射,包含其逻辑——隐藏password字段,并按要求格式化timestamp

虽然这可行,并且可能看起来是一个好的起点,但随着时间的推移,当我们想要扩展我们的系统时,我们会发现一些问题:

  • 类太多:随着事件数量的增长,序列化类的数量将以相同的数量级增长,因为它们是一对一映射的。

  • 解决方案不够灵活:如果我们需要重用组件的部分(例如,我们需要在另一个类型的事件中隐藏密码,该事件也有密码),我们必须将其提取到一个函数中,但还需要从多个类中重复调用它,这意味着我们最终并没有重用很多代码。

  • 模板代码serialize()方法将必须存在于所有事件类中,调用相同的代码。虽然我们可以将其提取到另一个类中(创建一个混入类),但这似乎并不是继承的好用法。

另一种解决方案是动态构建一个对象,给定一组过滤器(转换函数)和事件实例,可以通过应用过滤器到其字段来序列化它。然后我们只需要定义转换每种字段类型的函数,序列化器通过组合这些函数中的许多来创建。

一旦我们有了这个对象,我们就可以装饰类来添加serialize()方法,这个方法将只调用这些Serialization对象本身:

from dataclasses import dataclass
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=str.lower, 
    password=hide_field, 
    ip=show_original, 
    timestamp=format_time, 
) 
@dataclass 
class LoginEvent: 
    username: str 
    password: str 
    ip: str 
    timestamp: datetime 

注意装饰器如何让用户更容易知道每个字段将被如何处理,而无需查看另一个类的代码。只需阅读传递给类装饰器的参数,我们就可以知道username和 IP 地址将保持不变,password将被隐藏,而timestamp将被格式化。

现在,类的代码不需要定义serialize()方法,也不需要从实现它的混入类扩展,因为装饰器会添加它。这可能是唯一可以证明创建类装饰器合理性的部分,因为否则Serialization对象可以是LoginEvent的类属性,但它是通过向其添加新方法来改变类的,这使得它变得不可能。

其他类型的装饰器

既然我们已经知道了装饰器的@语法意味着什么,我们可以得出结论,不仅仅是函数、方法或类可以被装饰;实际上,任何可以被定义的东西,比如生成器、协程,甚至已经装饰过的对象,都可以被装饰,这意味着装饰器可以堆叠。

之前的例子展示了装饰器可以如何链式使用。我们首先定义了类,然后应用@dataclass到它上面,这将其转换成了一个数据类,作为一个容器的属性。之后,@Serialization将对那个类应用逻辑,结果产生一个新的类,其中添加了新的serialize()方法。

现在我们已经了解了装饰器的基础知识以及如何编写它们,我们可以继续到更复杂的例子。在接下来的几节中,我们将看到如何拥有具有参数的更灵活的装饰器以及实现它们的不同方法。

更高级的装饰器

通过我们刚刚的介绍,我们现在已经了解了装饰器的基础知识:它们是什么,以及它们的语法和语义。

现在,我们感兴趣的是更高级的装饰器用法,这将帮助我们更干净地组织代码。

我们将看到我们可以使用装饰器将关注点分离成更小的函数,并重用代码,但为了有效地这样做,我们希望对装饰器进行参数化(否则,我们最终会重复代码)。为此,我们将探讨如何将参数传递给装饰器的不同选项。

之后,我们可以看到一些装饰器良好用法的例子。

将参数传递给装饰器

到目前为止,我们已经将装饰器视为 Python 中的一个强大工具。然而,如果我们能够向它们传递参数,以便它们的逻辑被进一步抽象化,它们可能会更加强大。

有几种实现可以接受参数的装饰器的方法,但我们将介绍最常见的一些。第一种方法是将装饰器作为嵌套函数创建,增加一个间接层,使得装饰器中的所有内容都深入一个层次。第二种方法是使用一个类作为装饰器(即实现一个仍然充当装饰器的可调用对象)。

通常,第二种方法在可读性方面更有优势,因为相对于三个或更多使用闭包工作的嵌套函数,更容易从对象的角度思考。然而,为了完整性,我们将探讨两种方法,你可以决定哪种最适合当前的问题。

带嵌套函数的装饰器

大体来说,装饰器的一般思想是创建一个返回另一个函数的函数(在函数式编程中,接受其他函数作为参数的函数被称为高阶函数,这指的是我们在这里讨论的相同概念)。装饰器体内定义的内部函数将是将被调用的函数。

现在,如果我们希望向它传递参数,我们则需要另一个间接层。第一个函数将接受参数,在该函数内部,我们将定义一个新的函数,这个新的函数将是装饰器,它反过来将定义另一个新的函数,即装饰过程的结果要返回的函数。这意味着我们将至少有三个嵌套函数的层次。

如果到目前为止这还不清楚,请不要担心。在查看即将到来的示例之后,一切都会变得清晰。

我们看到的第一批装饰器示例之一是在某些函数上实现了重试功能。这是一个好主意,但有一个问题;我们的实现不允许我们指定重试次数,而是在装饰器内部有一个固定的数字。

现在,我们希望能够表明每个实例将要尝试的次数,也许我们甚至可以为此参数添加一个默认值。为了做到这一点,我们需要另一层嵌套函数——首先是参数,然后是装饰器本身。

这是因为我们现在将会有以下形式的内容:

@retry(arg1, arg2,... ) 

而这必须返回一个装饰器,因为@语法将计算结果应用于要装饰的对象。从语义上讲,它将翻译成以下类似的内容:

 <original_function> = retry(arg1, arg2, ....)(<original_function>) 

除了想要的尝试次数之外,我们还可以指出我们希望控制的异常类型。支持新要求的代码的新版本可能看起来像这样:

_DEFAULT_RETRIES_LIMIT = 3
    def with_retry(
        retries_limit: int = _DEFAULT_RETRIES_LIMIT,
        allowed_exceptions: Optional[Sequence[Exception]] = None,
    ):
        allowed_exceptions = allowed_exceptions or
        (ControlledException,) # type: ignore
        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.warning(
                            "retrying %s due to %s",
                            operation.__qualname__, 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__魔法方法上实现装饰器的逻辑。

装饰器的代码看起来就像以下示例中的那样:

_DEFAULT_RETRIES_LIMIT = 3
class WithRetry:
    def __init__(
        self,
        retries_limit: int = _DEFAULT_RETRIES_LIMIT,
        allowed_exceptions: Optional[Sequence[Exception]] = None,
    ) -> 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.warning(
                    "retrying %s due to %s",
                    operation.__qualname__, 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_retries_limit的函数,意味着它将被传递给call魔法方法。

在这个调用魔法方法中,我们像平常一样定义了装饰器的逻辑——我们包装了原始函数,返回了一个具有我们想要逻辑的新函数。

带有默认值的装饰器

在上一个例子中,我们看到了一个接受参数的装饰器,但那些参数有默认值。之前装饰器的写法确保了只要用户在使用装饰器时没有忘记在函数调用时使用括号,它们就能正常工作。

例如,如果我们只想使用默认值,这将有效:

@retry()
def my function(): ... 

但这不会:

@retry
def my function(): ... 

你可能会争论这是否必要,并接受(可能需要适当的文档)第一个例子是装饰器预期使用的方式,第二个例子是错误的。这将是可行的,但需要密切注意,否则将发生运行时错误。

当然,如果装饰器接受没有默认值的参数,那么第二种语法就没有意义,只有一个可能性,这可能会使事情变得更简单。

或者,你可以使装饰器同时支持这两种语法。正如你可能猜到的,这需要额外的努力,而且像往常一样,你应该权衡这是否值得。

让我们用一个简单的例子来说明这一点,这个例子使用了一个带参数的装饰器来将参数注入到函数中。我们定义了一个接受两个参数的函数和一个执行相同操作的装饰器,我们的想法是在不带参数的情况下调用函数,让它使用装饰器传递的参数:

 @decorator(x=3, y=4)
        def my_function(x, y):
            return x + y
        my_function()  # 7 

当然,我们为装饰器的参数定义了默认值,这样我们就可以不带值调用它。我们也希望不带括号调用它。

最简单且最直接的方法是使用条件语句来区分这两种情况:

def decorator(function=None, *, x=DEFAULT_X, y=DEFAULT_Y):
    if function is None:  # called as `@decorator(...)`

        def decorated(function):
            @wraps(function)
            def wrapped():
                return function(x, y)

            return wrapped

        return decorated
    else:  # called as `@decorator`

        @wraps(function)
        def wrapped():
            return function(x, y)

        return wrapped 

注意关于装饰器签名的一个重要事项:参数只能是关键字参数。这大大简化了装饰器的定义,因为我们可以在没有参数调用函数时假设函数是None(否则,如果我们按位置传递值,我们传递的第一个参数会被误认为是函数)。如果我们想更加小心,而不是使用None(或任何哨兵值),我们可以检查参数类型,断言我们期望的函数对象类型,然后相应地调整参数,但这会使装饰器变得更加复杂。

另一个替代方案是将包装装饰器的一部分抽象出来,然后对函数进行部分应用(使用functools.partial)。为了更好地解释这一点,让我们考虑一个中间状态,并使用一个lambda函数来展示装饰器的参数是如何应用的,以及装饰器的参数是如何“移动”的:

def decorator(function=None, *, x=DEFAULT_X, y=DEFAULT_Y):
    if function is None:
        return lambda f: decorator(f, x=x, y=y)

    @wraps(function)
    def wrapped():
        return function(x, y)

    return wrapped 

这与前面的例子类似,从意义上讲,我们有wrapped函数的定义(它是如何被装饰的)。然后,如果没有提供函数,我们返回一个新的函数,该函数接受一个函数作为参数(f),并返回应用了该函数的装饰器以及其余绑定参数。然后,在第二次递归调用中,函数将存在,并将返回常规的装饰器函数(wrapped)。

您可以通过更改函数部分应用的lambda定义来达到相同的结果:

return partial(decorator, x=x, y=y) 

如果这对我们的用例来说太复杂,我们总是可以决定让我们的装饰器参数接受强制值。

在任何情况下,定义装饰器的参数为关键字参数(无论是否有默认值)可能是一个好主意。这是因为,通常在应用装饰器时,关于每个值的作用的上下文并不多,使用位置值可能不会产生非常有意义的表达式,因此最好更具有表达性,并将参数的名称与值一起传递。

如果您正在使用参数定义装饰器,请优先使用关键字参数。

同样,如果我们的装饰器不打算接受参数,并且我们想明确这一点,我们可以使用我们在第二章中学到的语法来定义我们的装饰器接收的单个位置参数。

对于我们的第一个例子,语法将是:

def retry(operation, /): ... 

但请记住,这并不是严格推荐的,只是让您更明确地了解装饰器应该如何被调用。

协程装饰器

如介绍中所述,由于 Python 中几乎所有东西都是一个对象,因此几乎所有东西都可以被装饰,这包括协程。

然而,这里有一个注意事项,正如前几章所解释的,Python 中的异步编程引入了一些语法上的差异。因此,这些语法差异也将传递到装饰器中。

简而言之,如果我们为协程编写装饰器,我们可以简单地适应新的语法(记得等待包装的协程并将包装对象本身定义为协程,这意味着内部函数可能需要使用 'async def' 而不是仅仅使用 'def')。

问题在于如果我们想要一个广泛适用于函数和协程的装饰器。在大多数情况下,创建两个装饰器可能是最简单(也许是最好的)的方法,但如果我们想为用户提供一个更简单的接口(通过让用户记住更少的对象),我们可以创建一个薄的包装器,充当两个内部(未公开)装饰器的调度器。这就像创建一个外观,但使用装饰器。

关于为函数和协程创建装饰器的难度,没有一般规则,因为这取决于我们想在装饰器中放入的逻辑。例如,下面的代码中有一个装饰器,它会改变它接收到的函数的参数,并且这将对常规函数或协程都有效:

X, Y = 1, 2

def decorator(callable):
    """Call <callable> with fixed values"""

    @wraps(callable)
    def wrapped():
        return callable(X, Y)

    return wrapped

@decorator
def func(x, y):
    return x + y

@decorator
async def coro(x, y):
    return x + y 

然而,关于协程,我们需要做出一个重要的区分。装饰器将接收协程作为其callable参数,然后使用这些参数调用它。这创建了协程对象(将进入事件循环的任务),但它不会等待它,这意味着调用await coro()的人最终会等待装饰器包装的结果。这意味着,在像这种情况这样的简单情况下,我们不需要用另一个协程替换协程(尽管这通常是推荐的)。

但同样,这取决于我们想要做什么。如果我们需要一个计时函数,那么我们必须等待函数或协程完成以测量时间,为此我们必须在它上面调用await,这意味着包装器对象反过来必须是一个协程(但不是主要的装饰器)。

下面的代码示例使用一个装饰器来选择性地决定如何包装调用函数来说明这一点:

import inspect
def timing(callable):
    @wraps(callable)
    def wrapped(*args, **kwargs):
        start = time.time()
        result = callable(*args, **kwargs)
        latency = time.time() - start
        return {"latency": latency, "result": result}

    @wraps(callable)
    async def wrapped_coro(*args, **kwargs):
        start = time.time()
        result = await callable(*args, **kwargs)
        latency = time.time() - start
        return {"latency": latency, "result": result}

    if inspect.iscoroutinefunction(callable):
        return wrapped_coro

    return wrapped 

第二个包装器对于协程是必需的。如果我们没有它,那么代码会有两个问题。首先,对callable的调用(没有await)实际上不会等待操作完成,这意味着结果将是错误的。更糟糕的是,字典中result键的值不会是结果本身,而是创建的协程。因此,响应将是一个字典,任何尝试调用它的人都会尝试等待一个字典,这将导致错误。

作为一条一般规则,你应该用一个同类的另一个对象来替换装饰过的对象,也就是说,用一个函数替换另一个函数,用一个协程替换另一个协程。

我们还应该研究最近添加到 Python 中的一个最后增强功能,它消除了其语法的一些限制。

装饰器的扩展语法

Python 3.9 为装饰器引入了一个新特性,即 PEP-614 (www.python.org/dev/peps/pep-0614/),因为允许了更通用的语法。在此增强之前,调用装饰器(在@之后)的语法被限制在非常有限的表达式上,并不是每个 Python 表达式都被允许。

解除这些限制后,我们现在可以编写更复杂的表达式,并在我们的装饰器中使用它们,如果我们认为这样可以节省一些代码行(但就像往常一样,要小心不要过度复杂化,得到一个更紧凑但难以阅读的行)。

例如,我们可以简化一些通常用于简单装饰器(记录函数调用及其参数)的嵌套函数。在这里(仅用于说明目的),我将装饰器中典型的嵌套函数定义替换为两个lambda表达式:

def _log(f, *args, **kwargs):
    print(f"calling {f.__qualname__!r} with {args=} and {kwargs=}")
    return f(*args, **kwargs)

@(lambda f: lambda *args, **kwargs: _log(f, *args, **kwargs))
def func(x):
    return x + 1 
>>> func(3)
calling 'func' with args=(3,) and kwargs={} 

PEP 文档引用了一些示例,说明此功能何时可能有用(例如,简化无操作函数以评估其他表达式,或避免使用eval函数)。

本书对此功能的推荐与所有可以通过更紧凑的语句实现的情况一致:只要不影响可读性,就编写更紧凑的代码版本。如果装饰器表达式难以阅读,则优先选择更冗长但更简单的替代方案,即编写两个或更多函数。

装饰器的良好用途

在本节中,我们将探讨一些常见的模式,这些模式很好地使用了装饰器。这些是装饰器是一个好选择时的常见情况。

从装饰器可以用于的无数应用中,我们将列举一些,其中最常见或相关的:

  • 转换参数:更改函数的签名以提供更友好的 API,同时在下面封装如何处理和转换参数的细节。我们必须小心使用装饰器的这种用途,因为它只有在故意使用时才是好的特性。这意味着,如果我们明确使用装饰器为具有相当复杂签名的函数提供良好的签名,那么通过装饰器实现更干净的代码是一种很好的方法。另一方面,如果函数的签名由于装饰器而意外更改,那么这是我们想要避免的(我们将在本章末尾讨论如何避免)。

  • 跟踪代码:记录带有其参数的函数执行。你可能熟悉提供跟踪功能的多个库,并且通常将这些功能作为装饰器暴露出来,以便添加到我们的函数中。这是一个很好的抽象,也是一个很好的接口,可以作为将代码与外部各方集成而不会造成太大干扰的方式。此外,它也是一个很好的灵感来源,因此我们可以编写自己的日志或跟踪功能作为装饰器。

  • 验证参数:装饰器可以以透明的方式验证参数类型(例如,与预期值或其注释进行比较)。使用装饰器,我们可以为我们的抽象强制执行先决条件,遵循设计合同的思路。

  • 实现重试操作:与我们在上一节中探讨的示例类似。

  • 通过将一些(重复的)逻辑移动到装饰器中来简化类:这与 DRY 原则相关,我们将在本章末尾重新回顾。

在接下来的几节中,我将更详细地讨论这些主题。

适配函数签名

在面向对象设计中,有时存在具有不同接口的对象需要交互的情况。解决这个问题的一个方案是适配器设计模式,我们将在第七章“生成器、迭代器和异步编程”中讨论这个模式,当我们回顾一些主要设计模式时。

然而,本节的主题在某种程度上是相似的,即有时我们需要适应的不是对象,而是函数签名。

想象一下这样一个场景:你正在处理遗留代码,并且有一个包含大量使用复杂签名(许多参数、样板代码等)定义的函数的模块。有一个更干净的接口来与这些定义交互会很不错,但改变许多函数意味着需要进行大规模重构。

我们可以使用装饰器来将更改的差异降到最低。

有时我们可以使用装饰器作为我们代码和使用的框架之间的适配器,如果,例如,该框架有上述考虑。

想象一下这样一个框架,它期望调用我们定义的函数,保持一定的接口:

def resolver_function(root, args, context, info): ... 

现在,我们到处都有这个签名,并决定最好从所有这些参数中创建一个抽象,它封装了它们,并暴露了我们在应用程序中需要的操作。

因此,现在我们有很多函数,它们的第一行重复创建相同的对象,而函数的其余部分只与我们的域对象交互:

def resolver_function(root, args, context, info):
    helper = DomainObject(root, args, context, info)
    ...
    helper.process() 

在这个例子中,我们可以有一个改变函数签名的装饰器,这样我们就可以假设直接传递helper对象来编写我们的函数。在这种情况下,装饰器的任务将是拦截原始参数,创建域对象,然后将helper对象传递给我们的函数。然后我们定义我们的函数,假设我们只接收我们需要的对象,并且已经初始化。

也就是说,我们希望以这种形式编写我们的代码:

@DomainArgs
def resolver_function(helper):
    helper.process()
   ... 

这也可以反过来,例如,如果我们有的遗留代码需要太多参数,而我们总是解构已经创建的对象,因为重构遗留代码会有风险,那么我们可以通过装饰器作为中间层来实现这一点。

这种使用装饰器的想法可以帮助你编写具有更简单、更紧凑签名的函数。

参数验证

我们之前提到过,装饰器可以用来验证参数(甚至在“设计由合同”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: str):
    """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' 

此外,原始函数的注解也丢失了:

>>> process_account.__annotations__
{} 

我们可以看到,由于装饰器实际上是将原始函数替换为一个新的函数(称为wrapped),所以我们看到的是这个函数的属性,而不是原始函数的属性。

如果我们将这样的装饰器应用于多个具有不同名称的函数,它们最终都会被调用为wrapped,这是一个主要问题(例如,如果我们想记录或跟踪函数,这将使调试更加困难)。

另一个问题是我们如果在这些函数上放置带有测试的 docstrings,它们将被装饰器的那些覆盖。结果,我们用doctest模块调用我们的代码时,我们想要的测试的 docstrings 将不会运行(如我们在第一章介绍、代码格式化和工具中看到的)。

修复方法很简单。我们只需在内部函数(wrapped)中应用wraps装饰器,告诉它这实际上是一个包装函数:

# 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 

现在,如果我们检查属性,我们将得到我们最初预期的结果。检查函数的帮助信息,如下所示:

>>> from decorator_wraps_2 import process_account
>>> help(process_account)
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()
    @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 

每次我们运行相同的函数,它所需的时间会越来越长!在这个时候,你可能已经注意到了(现在很明显)的错误。

记住装饰器的语法。@traced_function_wrong 实际上意味着以下内容:

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 

使用这个新版本,之前的问题都得到了解决。

如果装饰器的行为不同,结果可能会更加灾难性。例如,如果它要求你记录事件并将它们发送到外部服务,那么除非在导入之前已经正确运行了配置,否则它肯定会失败。即使我们可以做到,这也是一种不良的做法。如果装饰器有任何其他类型的副作用,例如从文件中读取、解析配置等,也同样适用。

需要具有副作用的装饰器

有时,装饰器的副作用是必要的,我们不应该将它们的执行延迟到最后时刻,因为这是它们正常工作所需机制的一部分。

当我们不想延迟装饰器的副作用时,一个常见的场景是我们需要将对象注册到一个公共注册表中,该注册表将在模块中可用。

例如,回到我们之前的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 实例并将其传递给函数,因此我们可以假设我们直接接收所需的对象。

在下一个列表中展示了如何在函数中使用这个例子:

# src/decorator_universal_1.py
from functools import wraps
from log import logger
class DBDriver:
    def __init__(self, dbstring: str) -> None:
        self.dbstring = dbstring
    def execute(self, query: str) -> str:
        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) -> None:
        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__方法。

装饰器和整洁代码

现在我们对装饰器有了更多的了解,如何编写它们,以及如何避免常见问题,是时候将它们提升到下一个层次,看看我们如何利用所学知识来制作更好的软件。

我们在前面几节中简要地提到了这个主题,但那些更接近代码的示例,因为建议是关于如何使特定的代码行(或部分)更易读。

从现在开始讨论的主题与更普遍的设计原则相关。其中一些想法我们在前面的章节中已经接触过,但这里的观点是理解我们如何为了这样的目的使用装饰器。

组合优于继承

我们已经简要讨论过,一般来说,组合比继承更好,因为后者会带来一些问题,使得代码组件更加耦合。

在书籍《设计模式:可复用面向对象软件元素》(DESIG01)中,围绕设计模式的大部分想法都是基于以下观点:

优先组合而非类继承

第二章Pythonic 代码中,我介绍了使用魔法方法__getattr__在对象上动态解析属性的想法。我还给出了一个例子,说明这可以用来根据命名约定自动解析属性,如果外部框架需要的话。让我们探索两种不同的解决方案。

对于这个例子,让我们假设我们正在与一个命名约定为使用前缀"resolve_"来解析属性的框架交互,但我们的领域对象只有那些没有"resolve_"前缀的属性。

显然,我们不想为每个属性编写很多重复的名为"resolve_x"的方法,所以第一个想法是利用前面提到的__getattr__魔法方法,并将其放置在基类中:

class BaseResolverMixin:
    def __getattr__(self, attr: str):
        if attr.startswith("resolve_"):
            *_, actual_attr = attr.partition("resolve_")
        else:
            actual_attr = attr
        try:
            return self.__dict__[actual_attr]
        except KeyError as e:
            raise AttributeError from e

@dataclass
class Customer(BaseResolverMixin):
    customer_id: str
    name: str
    address: str 

这将解决问题,但我们能做得更好吗?

我们可以设计一个类装饰器来直接设置这个方法:

from dataclasses import dataclass

def _resolver_method(self, attr):
    """The resolution method of attributes that will replace __getattr__."""
    if attr.startswith("resolve_"):
        *_, actual_attr = attr.partition("resolve_")
    else:
        actual_attr = attr
    try:
        return self.__dict__[actual_attr]
    except KeyError as e:
        raise AttributeError from e

def with_resolver(cls):
    """Set the custom resolver method to a class."""
    cls.__getattr__ = _resolver_method
    return cls

@dataclass
@with_resolver
class Customer:
    customer_id: str
    name: str
    address: str 

两个版本都将符合以下行为:

>>> customer = Customer("1", "name", "address")
>>> customer.resolve_customer_id
'1'
>>> customer.resolve_name
'name' 

首先,我们有一个独立的方法resolve,它遵循原始__getattr__的签名(这就是为什么我甚至保留了self作为第一个参数的名字,以便有意识地让这个函数成为一个方法)。

其余的代码看起来相当简单。我们的装饰器只将方法设置为我们通过参数接收的类,然后我们只需将装饰器应用于我们的类,就不再需要使用继承。

这与之前的例子相比有什么好处呢?首先,我们可以争论使用装饰器意味着我们正在使用组合(取一个类,修改它,然后返回一个新的类)而不是继承,因此我们的代码与最初的基本类耦合度更低。

此外,我们可以说,第一个例子中使用继承(通过混合类)是相当虚构的。我们并没有使用继承来创建类的更专用版本,而是为了利用__getattr__方法。这有两个(互补的)原因:首先,继承不是重用代码的最佳方式。好的代码是通过拥有小的、内聚的抽象来重用的,而不是创建层次结构。

其次,记得从之前的章节中,创建子类应该遵循特殊化的想法,“是一种”的关系。从概念上考虑,一个客户是否真的是一个BaseResolverMivin(顺便问一下,那是什么?)。

为了更清楚地说明这个第二点,想象你有一个像这样的层次结构:

class Connection: pass
class EncryptedConnection(Connection): pass 

在这种情况下,使用继承可以说是正确的,毕竟加密连接是一种更具体的连接类型。但什么是比BaseResolverMixin更具体的类型呢?这是一个混合类,所以它被期望与其他类(使用多重继承)一起混合在层次结构中。使用这种混合类纯粹是实用主义的,并且出于实现目的。请别误会,这是一本实用主义的书,所以你会在你的专业经验中处理混合类,使用它们是完全正常的,但如果我们可以避免这种纯粹实现上的抽象,并用不损害我们的领域对象(在这种情况下是Customer类)的东西来替换它,那就更好了。

新设计还有一个令人兴奋的能力,那就是可扩展性。我们已经看到装饰器可以参数化。想象一下,如果我们允许装饰器设置任何解析函数,而不仅仅是定义的那个,我们能在我们的设计中实现多大的灵活性。

装饰器与 DRY 原则

我们已经看到装饰器如何允许我们将某些逻辑抽象到一个单独的组件中。这样做的主要优势是,我们可以将装饰器多次应用于不同的对象,以重用代码。这遵循了不要重复自己DRY)原则,因为我们只定义某些知识一次。

在前几节中实现的retry机制是一个很好的例子,它是一个可以多次应用以重用代码的装饰器。我们不是让每个特定的函数都包含自己的retry逻辑,而是创建一个装饰器并多次应用它。一旦我们确保装饰器可以与方法和函数一样工作,这样做是有意义的。

定义事件表示方式的类装饰器也符合 DRY 原则,因为它定义了一个特定的地方来处理序列化事件的逻辑,而不需要在不同的类中重复代码。由于我们预计会重用这个装饰器并将其应用于许多类,因此其开发(及其复杂性)是有回报的。

在尝试使用装饰器重用代码时,这一点非常重要:我们必须绝对确信我们实际上会节省代码。

任何装饰器(尤其是如果它没有经过精心设计的话)都会给代码增加一个额外的间接层,从而增加更多的复杂性。代码的读者可能想要追踪装饰器的路径,以便完全理解函数的逻辑(尽管这些考虑在下一节中已经讨论过),因此请记住,这种复杂性必须得到回报。如果不太可能大量重用,那么就不应该选择装饰器,而应该选择更简单的选项(可能只是一个单独的函数或另一个小型类就足够了)。

但我们如何知道过度重用是什么意思?有没有一个规则来确定何时将现有代码重构为装饰器?Python 中并没有针对装饰器的特定内容,但我们可以应用软件工程中的一个通用经验法则(GLASS 01),即一个组件至少应该尝试三次,然后再考虑创建一个通用的抽象,即可重用组件。同样来自同一参考(GLASS 01)(我鼓励所有读者阅读《软件工程的真相与谬误》,因为它是一个极好的参考资料)的想法是,创建可重用组件比创建简单的组件难三倍。

核心观点是,通过装饰器重用代码是可以接受的,但前提是你必须考虑到以下因素:

  • 不要一开始就从头开始创建装饰器。等待模式出现,装饰器的抽象变得清晰,然后再进行重构。

  • 考虑到装饰器可能需要多次(至少三次)应用,然后再进行实现。

  • 将装饰器中的代码保持到最小。

既然我们已经从装饰器的角度重新审视了 DRY 原则,我们仍然可以讨论应用于装饰器的关注点分离,正如下一节所探讨的那样。

装饰器与关注点分离

上一个列表中的最后一点非常重要,以至于它值得单独成节。我们已经探讨了代码重用的概念,并注意到重用代码的关键要素是具有凝聚性的组件。这意味着它们应该具有最小的职责水平——只做一件事,只做一件事,并且做好。我们的组件越小,它们就越可重用,并且可以在不同的上下文中应用,而不会携带额外的行为,这会导致耦合和依赖,从而使软件变得僵化。

为了展示这意味着什么,让我们回顾一下我们在前一个示例中使用的一个装饰器。我们创建了一个装饰器,它使用类似于以下代码的方式来追踪某些函数的执行:

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():
    .... 

注意装饰器应用顺序的重要性。

不要在装饰器中放置超过一个职责。单一职责原则(SRP)同样适用于装饰器。

最后,我们可以分析优秀的装饰器,以了解它们在实际中的应用。下一节将通过分析装饰器来总结本章所学内容。

优秀装饰器的分析

作为本章的结束语,让我们回顾一些优秀的装饰器示例以及它们在 Python 本身以及流行库中的使用方式。目的是获取有关如何创建优秀装饰器的指导原则。

在进入示例之前,让我们首先确定优秀装饰器应具备的特征:

  • 封装,或关注点分离:一个好的装饰器应该有效地将其所做之事与所装饰的内容之间的不同职责分开。它不能是一个有漏洞的抽象,这意味着装饰器的客户端应该只以黑盒模式调用它,而不了解其实际实现逻辑。

  • 正交性:装饰器所做的事情应该是独立的,并且尽可能与它所装饰的对象解耦。

  • 可重用性:希望装饰器可以应用于多种类型,而不仅仅是出现在一个函数的一个实例上,因为这意味着它可能只是一个函数。它必须足够通用。

Celery项目中可以找到一个装饰器的美好例子,其中任务是通过将应用中的任务装饰器应用到函数上来定义的:

@app.task
def mytask():
   .... 

这个装饰器之所以好,其中一个原因是因为它在某一方面非常出色——封装。库的用户只需要定义函数体,装饰器就会自动将其转换为任务。@app.task装饰器无疑封装了大量的逻辑和代码,但这些都无关紧要mytask()函数的主体。这是完整的封装和关注点的分离——没有人需要查看装饰器做了什么,因此它是一个正确的抽象,不会泄露任何细节。

装饰器的另一个常见用途是在 Web 框架中(例如PyramidFlaskSanic,仅举几个例子),在这些框架中,视图的处理程序通过装饰器注册到 URL:

@route("/", method=["GET"])
def view_handler(request):
 ... 

这类装饰器与之前的有相同的考虑因素;它们也提供了完全的封装,因为使用 Web 框架的用户很少(如果有的话)需要知道@route装饰器正在做什么。在这种情况下,我们知道装饰器正在做更多的事情,比如将这些函数注册到 URL 的映射器,并且它还在改变原始函数的签名,为我们提供一个更友好的接口,该接口接收一个已经设置好所有信息的request对象。

前两个例子已经足够让我们注意到关于这种装饰器使用的一个其他方面。它们遵循一个 API。这些库和框架通过装饰器向用户公开其功能,结果证明装饰器是定义干净编程接口的一种极好的方式。

这可能是我们应该考虑装饰器的最好方式。就像在类装饰器的例子中,它告诉我们事件属性将如何被处理一样,一个好的装饰器应该提供一个干净的接口,这样代码的用户就知道从装饰器可以期待什么,而无需了解它是如何工作的,或者它的任何细节。

摘要

装饰器是 Python 中的强大工具,可以应用于许多事物,如类、方法、函数、生成器等等。我们已经展示了如何以不同的方式创建装饰器,用于不同的目的,并在过程中得出了一些结论。

在为函数创建装饰器时,尽量使它的签名与被装饰的原函数相匹配。而不是使用通用的*args**kwargs,使签名与原函数相匹配将使其更容易阅读和维护,并且会更接近原函数,因此对代码的读者来说会更加熟悉。

装饰器是重用代码和遵循 DRY 原则的一个非常有用的工具。然而,它们的实用性是有代价的,如果使用不当,复杂性可能会带来比好处更多的坏处。因此,我们强调,装饰器应该在它们将被多次应用(三次或更多次)时使用。与 DRY 原则一样,我们支持关注点分离的理念,目标是使装饰器尽可能小。

装饰器的另一个良好用途是创建更清晰的接口,例如,通过将部分逻辑提取到装饰器中来简化类的定义。从这个意义上讲,装饰器也通过向用户提供有关特定组件将执行什么操作的信息来帮助提高可读性,而无需了解如何实现(封装)。

在下一章中,我们将探讨 Python 的另一个高级特性——描述符。特别是,我们将看到如何借助描述符,我们可以创建更好的装饰器并解决本章中遇到的一些问题。

参考文献

这里是一份您可以参考的信息列表:

第六章:通过描述符让我们的对象更有价值。

本章介绍了一个在 Python 开发中更高级的新概念,因为它具有描述符功能。此外,描述符不是其他语言的程序员熟悉的东西,因此没有简单的类比或平行关系可以做出。

描述符是 Python 的另一个独特特性,它将面向对象编程提升到了另一个层次,它们的潜力允许用户构建更强大、更可重用的抽象。大多数情况下,描述符的完整潜力在库或框架中观察到。

在本章中,我们将实现与描述符相关的一些目标:

  • 理解描述符是什么,它们是如何工作的,以及如何有效地实现它们。

  • 从概念差异和实现细节分析两种类型的描述符(数据描述符和非数据描述符)。

  • 通过描述符有效地重用代码。

  • 分析描述符的良好使用示例,以及如何利用它们为我们自己的 API 库提供更多价值。

描述符的第一眼观察。

首先,我们将探索描述符背后的主要思想,以了解它们的机制和内部工作原理。一旦这一点清楚,就会更容易吸收不同类型描述符的工作方式,我们将在下一节中探讨。

一旦我们了解了描述符背后的概念,我们就会看看一个例子,其中描述符的使用给我们带来了更干净、更 Pythonic 的实现。

描述符背后的机制。

描述符的工作方式并不复杂,但问题在于它们有很多需要注意的注意事项,因此实现细节在这里至关重要。

要实现描述符,我们需要至少两个类。在这个通用示例中,client类将利用我们想在descriptor中实现的功能(这通常只是一个领域模型类,我们为解决方案创建的常规抽象),而descriptor类将实现描述符本身的逻辑。

因此,描述符只是一个实现了描述符协议的类的实例。这意味着这个类的接口必须包含以下至少一种魔法方法(Python 3.6+描述符协议的一部分):

  • __get__

  • __set__

  • __delete__

  • __set_name__

为了这个初始的高级介绍,将使用以下命名约定:

名称 含义
ClientClass 将利用描述符要实现的功能的领域级抽象。这个类被称为描述符的客户。这个类包含一个类属性(按照惯例命名为descriptor),它是DescriptorClass的一个实例。
DescriptorClass 实现描述符本身的类。此类应实现一些上述涉及描述符协议的魔法方法。
client ClientClass的实例。client = ClientClass()
descriptor DescriptorClass的实例。descriptor = DescriptorClass()。此对象是一个放置在ClientClass中的类属性。

表 6.1:本章中使用的描述符命名约定

这种关系在图 6.1中得到了说明:

图片 1

图 6.1:ClientClass 和 DescriptorClass 之间的关系

需要记住的一个非常重要的观察结果是,为了使此协议正常工作,descriptor对象必须被定义为class属性。将其作为实例属性创建将不起作用,因此它必须位于类的主体中,而不是在__init__方法中。

总是将descriptor对象作为类属性放置!

在一个稍微更关键的观点上,读者还可以注意到,可以部分实现描述符协议——不是所有方法都必须始终定义;相反,我们可以只实现我们需要的那些,正如我们很快将看到的。

因此,现在我们已经建立了结构——我们知道哪些元素被设置以及它们如何交互。我们需要一个用于descriptor的类,另一个将消费descriptor逻辑的类,这个类反过来将有一个descriptor对象(DescriptorClass的实例)作为类属性,以及当调用名为descriptor的属性时将遵循描述符协议的ClientClass的实例。但现在是怎样的情况?所有这些如何在运行时结合起来?

通常,当我们有一个常规类并访问其属性时,我们简单地获得我们期望的对象,甚至它们的属性,如下面的例子所示:

>>> class Attribute:
...     value = 42
... 
>>> class Client:
...     attribute = Attribute()
... 
>>> Client().attribute
<__main__.Attribute object at 0x...>
>>> Client().attribute.value
42 

但是,在描述符的情况下,发生了一些不同的事情。当一个对象被定义为class属性(并且这个是一个描述符),当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__方法)时,属性查找的行为会有所不同。

从这个简单但具有说明性的例子开始,我们可以开始创建更复杂的抽象和更好的装饰器,因为这里的重要提示是我们有一个新的(强大的)工具可以用来工作。注意这种方式如何完全不同地改变程序的流程控制。有了这个工具,我们可以将所有各种逻辑抽象到__get__方法之后,并使descriptor透明地执行各种转换,而客户端甚至都没有察觉。这把封装提升到了一个新的水平。

探索描述符协议的每个方法

到目前为止,我们已经看到了许多描述符在实际应用中的例子,并了解了它们是如何工作的。这些例子让我们首次领略了描述符的强大之处,但你可能还在思考一些实现细节和惯用法,这些细节我们未能详细解释。

由于描述符只是对象,这些方法将self作为第一个参数。对于所有这些方法,这仅仅意味着descriptor对象本身。

在本节中,我们将详细探讨描述符协议的每个方法,解释每个参数的含义以及它们的使用意图。

获取方法

这个魔法方法的签名如下:

__get__(self, instance, owner) 

第一个参数instance指的是调用descriptor的对象。在我们的第一个例子中,这意味着client对象。

owner参数是指该对象的类,根据我们的例子(来自图 6.1),这将是指ClientClass

从上一段中,我们可以得出结论,__get__方法签名中的参数instance是指descriptor正在对其采取行动的对象,而ownerinstance的类。敏锐的读者可能会想知道为什么签名是这样定义的。毕竟,类可以直接从instance中获取(owner = instance.__class__)。存在一个边缘情况——当descriptor是从类(ClientClass)而不是从实例(client)调用时,instance的值是None,但我们在那种情况下可能仍然想做一些处理。这就是为什么 Python 选择将类作为不同的参数传递。

通过以下简单的代码,我们可以展示从classinstance调用descriptor之间的区别。在这种情况下,__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" 

你可以注意到这种行为与之前章节中的 @property.setter 装饰器之间的一些相似性,其中设置函数的参数是语句右侧的值(在这种情况下是字符串 "value")。我们将在本章后面重新讨论这个问题。

如果 client.descriptor 没有实现 __set__(),那么 "value"(语句右侧的任何对象)将完全覆盖描述符。

在将值赋给属性时,请小心。确保它实现了 __set__ 方法,并且我们没有引起不期望的副作用。

默认情况下,此方法最常见的用途只是将数据存储在对象中。尽管如此,我们迄今为止已经看到了描述符是多么强大,我们可以利用它们,例如,如果我们创建通用的验证对象,这些对象可以多次应用(再次强调,如果我们不进行抽象,我们可能会在属性的设置方法中多次重复)。

以下列表展示了我们如何利用此方法来创建用于属性的通用 validation 对象,这些对象可以通过验证函数动态创建,并在将它们赋给对象之前进行验证:

class Validation:
    def __init__(
        self, validation_function: Callable[[Any], bool], error_msg: str
    ) -> None:
        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 方法的签名更简单,如下所示:

__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 

在看到这个对象的工作示例之前,重要的是要强调这个descriptor的一些标准。注意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异常。

通常,这个descriptor的方法不像前两个那样常用,但在这里展示它是为了完整性。

集合名称方法

这是一个相对较新的方法,它在 Python 3.6 中被添加,具有以下结构:

__set_name__(self, owner, name) 

当我们在将要使用它的类中创建descriptor对象时,我们通常需要descriptor知道它将要处理的属性名称。

这个属性名称是我们用于在__get____set__方法中从__dict__中读取和写入的。

在 Python 3.6 之前,descriptor不能自动获取这个名称,所以最通用的方法是在初始化对象时明确传递它。这没问题,但它有一个问题,即每次我们想要为新属性使用descriptor时,都需要重复名称。

如果我们没有这个方法,一个典型的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' 

现在,如果我们想避免将属性名称写两次(一次是在类内部分配的变量,一次是作为descriptor的第一个参数的名称),我们就必须求助于一些技巧,比如使用类装饰器,或者(更糟糕的是)使用元类。

在 Python 3.6 中,添加了新的方法__set_name__,它接收创建该描述符的类以及分配给该descriptor的名称。最常用的习惯用法是使用这个方法来存储所需的名称。

为了兼容性,通常在__init__方法中保留一个默认值是一个好主意,但仍然要利用__set_name__

使用这种方法,我们可以将之前的descriptor重写如下:

class DescriptorWithName:
    def __init__(self, name=None):
        self.name = name
    def __set_name__(self, owner, name):
        self.name = name
    ... 

__set_name__对于获取descriptor被分配的属性名称很有用,但如果我们要覆盖值,__init__方法仍然会优先考虑,因此我们保留了灵活性。

尽管我们可以自由地命名我们的描述符,但我们通常使用描述符的名称(属性名称)作为客户端对象__dict__的键,这意味着它将被解释为属性。因此,请尽量使用有效的 Python 标识符来命名你使用的描述符。

如果你为你的descriptor设置了一个特定的名称,请使用有效的 Python 标识符。

描述符类型

基于我们刚刚探索的方法,我们可以在descriptor的工作方式上做出一个重要的区分。理解这个区分对于有效地使用descriptor以及避免运行时常见的陷阱或错误都起着重要作用。

如果一个描述符实现了__set____delete__方法,它被称为数据描述符。否则,仅实现__get__的描述符是非数据描述符。请注意,__set_name__根本不影响这种分类。

当尝试解析对象的属性时,数据描述符将始终优先于对象的字典,而非数据描述符则不会。这意味着在非数据描述符中,如果对象在其字典中有一个与描述符相同的键,那么它将始终被调用,而描述符本身将永远不会运行。

相反,在数据描述符中,即使字典中有一个与描述符相同的键,这个键也永远不会被使用,因为描述符本身总是会最终被调用。

以下两个部分将更详细地解释这一点,包括示例,以获得对每种类型descriptor的期望有更深入的了解。

非数据描述符

我们将从只实现__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 属性的值更改为其他值,这样做的作用是将值 99 设置到 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__ 魔法方法,正如我们将在下一个示例中看到的那样。

数据描述符

现在,让我们看看使用数据描述符的差异。为此,我们将创建另一个简单的 descriptor,它实现了 __set__ 方法:

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 优先(因为它是一个覆盖描述符)。

还有一件事——删除属性将不再起作用:

>>> del client.descriptor
Traceback (most recent call last):
   ...
AttributeError: __delete__ 

原因如下——鉴于现在descriptor总是优先,对对象调用del时不会尝试从其字典(__dict__)中删除属性,而是尝试调用descriptor__delete__()方法(在这个例子中没有实现,因此引发了属性错误)。

这就是数据和非数据描述符之间的区别。如果描述符实现了__set__(),那么它将始终优先,无论对象字典中存在哪些属性。如果没有实现此方法,则首先查找字典,然后运行描述符。

你可能已经注意到的有趣观察是set方法中的这一行:

instance.__dict__["descriptor"] = value 

有很多事情值得质疑,但让我们将其分解成几个部分。

首先,为什么它只修改了"descriptor"属性的名称?这只是这个例子中的一个简化,但事实上,描述符此时并不知道它被分配的属性名称,所以我们只是使用了例子中的名称,知道它将是"descriptor"。这是一个简化,使得示例使用更少的代码,但可以通过使用我们在上一节中学习的__set_name__方法轻松解决。

在实际例子中,你会做两件事之一——要么将名称作为参数接收并在init方法中内部存储,这样这个方法就会只使用内部属性,或者,更好的是,使用__set_name__方法。

为什么它会直接访问实例的__dict__属性?另一个很好的问题是,这至少有两个解释。首先,你可能想知道为什么不直接这样做?

setattr(instance, "descriptor", value) 

记住,当我们尝试将某个值赋给属性且该属性是descriptor时,会调用这个方法(__set__)。所以,使用setattr()会再次调用这个descriptor,然后它又会再次调用,如此循环。这最终会导致无限递归。

不要在__set__方法中直接使用setattr()或赋值表达式在descriptor内部,因为这会触发无限递归。

那么,为什么描述符不能为所有对象记录属性的值呢?

client类已经有一个对descriptor的引用。如果我们从descriptor反向引用到client对象,我们就会创建循环依赖,这些对象将永远不会被垃圾回收。由于它们相互指向,它们的引用计数将永远不会低于移除的阈值,这将在我们的程序中造成内存泄漏。

当处理描述符(或一般对象)时,要小心潜在的内存泄漏。确保你没有创建循环依赖。

在这里,一个可能的替代方案是使用弱引用,通过weakref模块,如果我们想那样做,可以创建一个弱引用键字典。这种实现将在本章稍后解释,但就本书中的实现而言,我们更喜欢使用这种惯用(而不是weakref)方法,因为它在编写描述符时相当常见且被接受。

到目前为止,我们已经研究了不同类型的描述符,它们是什么,以及它们是如何工作的,我们甚至对如何利用它们有了初步的了解。下一节将重点介绍最后一点:我们将看到描述符的实际应用。从现在开始,我们将采取更实际的方法,看看我们如何使用描述符来编写更好的代码。之后,我们甚至将探索优秀描述符的示例。

描述符的实际应用

现在我们已经了解了描述符是什么,它们是如何工作的,以及它们背后的主要思想,我们可以看到它们在实际中的应用。在本节中,我们将探讨一些可以通过描述符优雅解决的问题。

在这里,我们将查看一些使用描述符的示例,我们还将涵盖它们的实现考虑因素(创建它们的不同方式,以及它们的优缺点),最后,我们将讨论描述符最合适的场景是什么。

描述符的应用

我们将从一个非常简单的例子开始,这个例子可以工作,但会导致一些代码重复。稍后,我们将设计一种方法将重复的逻辑抽象成一个描述符,这将解决重复问题,我们还将观察到我们的客户端类上的代码将大大减少。

不使用描述符的第一次尝试

我们现在想要解决的问题是我们有一个具有一些属性的常规类,但我们希望跟踪特定属性随时间变化的所有不同值,例如,在一个列表中。首先想到的解决方案是使用一个属性,并且每次在属性的 setter 方法中更改该属性的值时,我们都会将其添加到一个内部列表中,这样我们就可以按照我们想要的方式保留这个跟踪。

假设我们的类代表应用程序中的一个旅行者,该旅行者有一个当前城市,并且我们希望在程序运行期间跟踪用户访问过的所有城市。以下是一个可能的实现,它解决了这些要求:

class Traveler:
    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 = Traveler("Alice", "Barcelona")
>>> alice.current_city = "Paris"
>>> alice.current_city = "Brussels"
>>> alice.current_city = "Amsterdam"
>>> alice.cities_visited
['Barcelona', 'Paris', 'Brussels', 'Amsterdam'] 

到目前为止,这就是我们所需要的,不需要实现其他任何内容。对于这个问题的目的,属性将绰绰有余。如果我们需要在应用程序的多个地方实现完全相同的逻辑会发生什么?这意味着这实际上是一个更通用问题的实例——追踪另一个属性的所有值。如果我们想对其他属性做同样的事情,比如跟踪爱丽丝买过的所有票,或者她访问过的所有国家,会发生什么?我们就需要在所有这些地方重复逻辑。

此外,如果我们需要在不同的类中实现相同的行为会发生什么?我们可能需要重复代码或者想出一个通用解决方案(可能是一个装饰器、属性构建器或描述符)。由于属性构建器是描述符的一个特定(且更复杂)的情况,它们超出了本书的范围,因此建议使用描述符作为更干净的处理方式。

作为解决这个问题的一个另一种方案,我们可以使用在第二章Pythonic Code中引入的__setattr__魔法方法。在上一章讨论将__getattr__作为替代方案时,我们已经看到了这类解决方案。这些解决方案的考虑因素是相似的:我们需要创建一个新的基类来实现这个通用方法,然后定义一些类属性来指示需要追踪的属性,最后在方法中实现这个逻辑。这个类将是一个混入类,可以被添加到类的层次结构中,但它也具有之前讨论过的问题(更强的耦合和概念上不正确的层次结构问题)。

正如我们在上一章所看到的,我们分析了差异,并看到了类装饰器在基类中使用这个魔法方法时的优越性;在这里,我也假设描述符将提供一个更干净的解决方案,因此我们将避免使用魔法方法,并在下一节中探讨如何使用描述符解决这个问题。话虽如此,读者完全欢迎实现使用__setattr__的解决方案,并进行类似的比较分析。

习惯性实现

现在,我们将通过使用足够通用的描述符来解决上一节提出的问题,这个描述符可以应用于任何类。再次强调,这个例子实际上并不是必需的,因为要求并没有指定这种通用行为(我们甚至没有遵循创建抽象的类似模式的三实例规则),但它被展示出来是为了展示描述符的实际应用。

除非有实际证据表明我们正在尝试解决的问题存在重复,并且复杂性已被证明是值得的,否则不要实现描述符。

现在,我们将创建一个通用的描述符,给定一个用于存储另一个属性跟踪的名称,它将在列表中存储该属性的不同值。

如前所述,代码超出了我们解决问题的需要,但其意图只是展示描述符如何帮助我们在这个案例中。鉴于描述符的通用性,读者会注意到其上的逻辑(方法名称和属性)与当前的实际问题(旅行者对象)无关。这是因为描述符的理念是能够在任何类型的类中使用,可能在不同的项目中,以相同的结果。

为了解决这个问题,代码的一些部分被注释,每个部分(它做什么,以及它与原始问题的关系)的相应解释将在以下代码中描述:

class HistoryTracedAttribute:
    def __init__(self, trace_attribute_name: str) -> 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 Traveler:
    current_city = HistoryTracedAttribute("cities_visited")  # [1]
    def __init__(self, name: str, current_city: str) -> None:
        self.name = name
        self.current_city = current_city  # [5] 

描述符背后的理念是它将创建一个新的属性,负责跟踪某些其他属性所发生的变化。为了解释的目的,我们可以分别称它们为跟踪属性和被跟踪属性。

以下是对代码的一些注释和说明(列表中的数字对应于先前列表中的注释编号):

  1. 属性的名称是分配给descriptor的变量之一,在这种情况下,current_city(被跟踪的属性)。我们向descriptor传递它将存储跟踪变量的变量的名称。在这个例子中,我们告诉我们的对象跟踪所有current_city在名为cities_visited(跟踪器)的属性中曾经有的值。

  2. 第一次调用描述符时,在__init__中,用于跟踪值的属性将不存在,在这种情况下,我们将其初始化为空列表,以便稍后追加值。

  3. __init__方法中,属性current_city的名称也不存在,因此我们希望跟踪这种变化。这相当于在先前的例子中用第一个值初始化列表。

  4. 只有当新值与当前设置的值不同时,才跟踪变化。

  5. __init__方法中,descriptor已经存在,这个赋值指令触发了从步骤 2(创建空列表以开始跟踪其值)到步骤 3(将值追加到这个列表中,并将其设置为对象的键以供以后检索)的动作。

  6. 字典中的setdefault方法用于避免KeyError。在这种情况下,对于尚未可用的属性,将返回空列表(参见docs.python.org/3/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 pattern)的实现,我们希望在类的所有对象之间共享状态),但通常情况下并非如此,我们需要区分对象。这种模式在 第九章常见设计模式 中有更详细的讨论。

为了实现这一点,描述符需要知道每个 instance 的值并相应地返回它。这就是为什么我们一直在操作每个 instance 的字典,并从那里设置和检索值。

这是最常见的方法。我们已经解释了为什么不能在那些方法上使用 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) 不会返回完整的数据)。

  • 它提出了一个要求,即对象必须是可哈希的。如果不是,它们就不能成为映射的一部分。这可能对某些应用程序来说要求过于苛刻(或者可能迫使我们实现自定义的 __hash____eq__ 魔法方法)。

由于这些原因,我们更喜欢这本书中已经展示的实现方式,它使用每个实例的字典。然而,为了完整性,我们也展示了这个替代方案。

关于描述符的更多考虑

在这里,我们将讨论关于描述符的一般性考虑,包括我们可以如何使用它们,何时使用它们是明智的,以及我们最初可能认为通过另一种方法解决的问题如何通过描述符得到改进。然后,我们将分析原始实现与使用描述符后的实现的优缺点。

代码重用

描述符是一个通用的工具,也是一个强大的抽象,我们可以用它来避免代码重复。

描述符可能有用的情况之一是,如果我们发现自己处于需要编写属性(如在用@property @<property>.setter@<property>.deleter装饰的方法中)但需要多次执行相同的属性逻辑的情况下。也就是说,如果我们需要像泛型属性这样的东西,否则我们会发现自己正在编写具有相同逻辑的多个属性,并重复样板代码。属性只是描述符的一个特例(@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 dataclasses import dataclass
from datetime import 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"),
) 

这个descriptor很有趣。它是通过一个接受一个参数并返回一个值的函数创建的。这个函数将是我们要应用到字段上的转换。从定义其将如何通用的基本定义,其他descriptor类通过简单地更改每个类需要的特定函数来定义。

示例使用functools.partial (docs.python.org/3/library/functools.html#functools.partial) 作为模拟子类的一种方式,通过对该类转换函数的部分应用,留下一个可以直接实例化的新可调用对象。

为了使示例简单,我们将实现__init__()serialize()方法,尽管它们也可以被抽象化。在这些考虑下,事件类现在将定义如下:

@dataclass
class LoginEvent:
    username: str = ShowOriginal()
    password: str = HideField()
    ip: str = ShowOriginal()
    timestamp: datetime = FormatTime()
    def serialize(self) -> dict:
        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__)来访问原始值,但当我们请求值时,默认情况下,它将返回转换后的值。

在这个例子中,所有描述符都遵循一个共同的逻辑,该逻辑在基类中定义。描述符应该将值存储在对象中,然后请求它,应用它定义的转换。我们可以创建一个类的层次结构,每个类定义自己的转换函数,这样模板方法设计模式就可以工作。在这种情况下,由于派生类的变化相对较小(只有一个函数),我们选择将派生类作为基类的部分应用来创建。创建任何新的转换字段应该像定义一个新的类一样简单,该类将成为基类,并部分应用所需的功能。这甚至可以临时完成,因此可能不需要为它设置名称。

无论这种实现如何,关键点是,由于描述符是对象,我们可以创建模型,并将面向对象编程的所有规则应用于它们。设计模式也适用于描述符。我们可以定义我们的层次结构,设置自定义行为,等等。这个例子遵循了我们在第四章SOLID 原则中介绍的开放/封闭原则OCP),因为添加新的转换方法只需创建一个新的类,该类从基类派生,并具有所需的功能,而不需要修改基类本身(公平地说,之前的用装饰器的实现也符合 OCP,但没有涉及每个转换机制的类)。

让我们以一个例子来说明,我们创建一个基类,该基类实现了__init__()serialize()方法,这样我们就可以通过从它派生来简单地定义LoginEvent类,如下所示:

class LoginEvent(BaseEvent):
    username = ShowOriginal()
    password = HideField()
    ip = ShowOriginal()
    timestamp = FormatTime() 

一旦我们实现了这段代码,类看起来更简洁。它只定义了它需要的属性,并且可以通过查看每个属性的类来快速分析其逻辑。基类将只抽象出公共方法,每个事件的类看起来会更简单、更紧凑。

不仅每个事件的类看起来更简单,而且描述符本身也非常紧凑,比类装饰器简单得多。原始的用类装饰器的实现是好的,但描述符使它变得更好。

描述符分析

我们已经看到了描述符是如何工作的,并探索了一些有趣的场景,在这些场景中,它们通过简化逻辑和利用更紧凑的类来促进清洁的设计。

到目前为止,我们知道通过使用描述符,我们可以实现更干净的代码,抽象出重复的逻辑和实现细节。但我们如何知道我们的描述符实现是干净和正确的?什么使一个好的描述符?我们是否正确地使用了这个工具,或者是否过度设计?

在本节中,我们将分析描述符以回答这些问题。

Python 如何内部使用描述符

什么使一个好的描述符? 一个简单的答案可能是,一个好的描述符几乎就像任何其他好的 Python 对象一样。它与 Python 本身保持一致。遵循这个前提的想法是,分析 Python 如何使用描述符将给我们一个很好的想法,了解良好的实现,以便我们知道我们编写的描述符可以期待什么。

我们将看到 Python 自身使用描述符来解决其内部逻辑的常见场景,我们还将发现那些一直就在我们眼前的优雅描述符。

函数和方法

描述符对象最引人注目的例子可能是函数。函数实现了 __get__ 方法,因此当在类内部定义时,它们可以作为方法工作。

在 Python 中,方法只是常规函数,只是它们多了一个额外的参数。按照惯例,方法的第一个参数被命名为 self,它代表定义该方法的类的一个实例。然后,方法对 self 所做的任何操作都与其他接收对象并对其应用修改的函数相同。

换句话说,当我们定义如下内容时:

class MyClass:
    def method(self, ...):
        self.x = 1 

实际上,这就像我们定义了这个:

class MyClass: pass
def method(myclass_instance: MyClass, ...):
    myclass_instance.x = 1
 method(MyClass()) 

因此,它只是一个修改对象的函数,只是它是在类内部定义的,并且说它是绑定到对象的。

当我们以这种形式调用某个东西时:

instance = MyClass()
instance.method(...) 

实际上,Python 正在执行与此等效的操作:

instance = MyClass()
MyClass.method(instance, ...) 

注意,这只是 Python 内部处理的语法转换。它是通过描述符来实现的。

由于函数在调用方法之前实现了描述符协议(参见以下列表),因此首先调用 __get__() 方法(正如我们在本章开头所看到的,这是描述符协议的一部分:当被检索的对象实现了 __set__,则调用它并返回其结果)。然后在这个 __get__ 方法中,在运行内部可调用对象的代码之前,会发生一些转换:

>>> def function(): pass
...
>>> function.__get__
<method-wrapper '__get__' of function object at 0x...> 

instance.method(...) 语句中,在处理括号内可调用对象的全部参数之前,会评估 "instance.method" 部分。

由于 method 是作为类属性定义的对象,并且它有一个 __get__ 方法,因此会调用这个方法。这个方法所做的就是将函数转换为方法,这意味着将可调用对象绑定到它将要与之一起工作的对象实例。

让我们通过一个例子来看看,这样我们就可以了解 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 取代了 self 的位置,"first" 被传递到 instance 的位置,而 "second" 被传递到 arg1 的位置。没有为 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 first and second
Internal call: <MyClass object at 0x...> called with first and second 

我们所做的是使用 types 模块中的 MethodTypefunction(实际上是定义的可调用对象)转换为一个方法。这个类的第一个参数应该是一个可调用对象(在这个例子中,self 是一个可调用对象,因为根据定义它实现了 __call__),第二个参数是将此函数绑定到的对象。

类似于此的是 Python 中函数对象使用的,这样它们可以在类内部定义时作为方法工作。在这个例子中,MyClass 抽象试图模拟一个函数对象,因为在实际的解释器中,这是用 C 实现的,所以实验起来会更困难,但通过这个说明,我们可以了解 Python 在调用我们的对象的方法时内部做了什么。

由于这是一个非常优雅的解决方案,值得探索并记住,当定义我们自己的对象时,这是一个 Pythonic 的方法。例如,如果我们定义自己的可调用对象,那么将其也定义为一个描述符是一个好主意,这样我们就可以将其用作类属性。

方法的内置装饰器

如您从查看官方文档(PYDESCR-02)中可能已经知道的那样,所有 @property@classmethod@staticmethod 装饰器都是描述符。

我们已经多次提到,这种习惯用法使得描述符在直接从类中调用时返回自身。由于属性实际上是描述符,这就是为什么当我们从类中请求它时,我们不会得到计算属性的结果,而是整个property对象:

>>> 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' 

使其工作的代码紧凑且相对简单:

class classproperty:
    def __init__(self, fget):
        self.fget = fget

    def __get__(self, instance, owner):
        return self.fget(owner) 

正如我们在上一章中看到的,初始化方法在装饰器语法中使用时,会接受将要被装饰的函数。这里有趣的是,我们利用__get__魔法方法在它被调用时以类作为参数来调用该函数。

你可以欣赏这个例子与从类中调用__get__方法的一般样板代码的不同之处:在这些情况下,大多数时候,我们会询问instance是否为None,并返回self,但这里不是这样。在这种情况下,我们实际上期望实例为None(因为它是从类中而不是从对象中调用的),所以我们确实需要所有者参数(即被操作的类)。

插槽

__slots__是一个类属性,用于定义该类对象可以拥有的固定字段集。

从到目前为止给出的例子中,读者可能已经注意到,在 Python 中,对象的内部表示是通过字典来完成的。这就是为什么对象的属性存储在其__dict__属性中的字符串形式。这也是为什么我们可以动态地向对象添加新属性或删除现有属性的原因。对于对象要声明的属性,没有所谓的“冻结”定义。我们还可以动态地注入方法(我们已经在之前的例子中这样做过)。

所有这些都随着__slots__类属性的改变而改变。在这个属性中,我们定义了一个字符串,表示允许在类中使用的属性名称。从那时起,我们将无法动态地向这个类的实例添加任何新属性。尝试向定义了__slots__的类动态添加额外属性将导致AttributeError。通过定义这个属性,类变成了静态的,因此它将没有__dict__属性,你可以在其中动态添加更多对象。

那么,如果不从对象的字典中检索,它的属性是如何被检索的呢?通过使用描述符。在槽中定义的每个名称都将有自己的描述符,它将存储用于以后检索的值:

from dataclasses import dataclass

@dataclass
class Coordinate2D:
    __slots__ = ("lat", "long")
    lat: float
    long: float

    def __repr__(self):
        return f"{self.__class__.__name__}({self.lat}, {self.long})" 

使用__slots__,Python 将只为在创建新对象时定义的属性保留足够的内存。这将使对象没有__dict__属性,因此它们不能被动态更改,并且任何尝试使用其字典(例如,通过使用function vars(...))的操作都将导致TypeError

由于没有__dict__属性来存储实例变量的值,Python 取而代之的是为每个槽创建一个描述符,并将值存储在那里。这有一个副作用,即我们无法将类属性与实例属性混合(例如,如果我们常用的惯用用法是将类属性用作实例属性的默认值,则使用这种方法,我们将无法这样做,因为值将被覆盖)。

虽然这是一个有趣的功能,但必须谨慎使用,因为它会剥夺 Python 的动态特性。一般来说,这应该仅限于我们知道是静态的对象,并且如果我们绝对确信我们不会在其他代码部分动态地向它们添加任何属性。

作为这种方法的优点之一,使用槽定义的对象占用的内存更少,因为它们只需要一组固定的字段来存储值,而不是整个字典。

在装饰器中实现描述符

我们现在理解了 Python 如何在函数中使用描述符,使其在类内部定义时作为方法工作。我们还看到了一些例子,说明我们可以通过使用接口的__get__()方法使装饰器符合描述符协议,从而使其工作。这以与 Python 解决对象中函数作为方法的问题相同的方式解决了我们装饰器的问题。

以这种方式适应装饰器的一般方法是在其上实现__get__()方法,并使用types.MethodType将可调用对象(装饰器本身)转换为绑定到接收到的对象(__get__接收到的instance参数)的方法。

为了使这起作用,我们必须将装饰器实现为一个对象,因为否则,如果我们使用一个函数,它已经有一个__get__()方法,这将执行不同的操作,除非我们对其进行适配,否则将不起作用。更干净的方法是定义一个装饰器类。

当我们想要将装饰器应用于类方法时,使用装饰器类,并在其上实现__get__()方法。

关于描述符的最终评论

为了总结我们对描述符的分析,我想分享一些关于干净代码和良好实践或经验建议的想法。

描述符接口

当我们在第四章中回顾接口隔离原则时,SOLID 原则(SOLID 中的"I”),我们说保持接口小是一个好的实践,因此我们可能想要将它们分成更小的部分。

这个想法再次出现,不是指作为抽象基类中的接口,而是指描述符本身将呈现的接口。

如前所述,描述符协议包含四种方法,但允许部分实现。这意味着你不必总是实现所有方法。实际上,如果你只实现所需的最小方法,那会更好。

大多数时候,你会发现你只需实现__get__方法就能满足你的需求。

不要实现比必要的更多方法。你可以实现的描述符协议方法越少,越好。

此外,你会发现__delete__方法很少需要。

描述符的面向对象设计

我并不是说我们仅仅通过使用描述符就能提高我们的面向对象设计能力(我们已经讨论过这一点)。但既然描述符只是普通对象,面向对象设计的规则也适用于它们。例如,我们可以有描述符的基类,利用继承来创建更具体的类等。

请记住,所有良好的规则和实践建议也适用。例如,如果你有一个只实现__get__方法的描述符基类,那么创建一个也实现__set__方法的子类可能不是个好主意,因为它不会符合 Liskov 替换原则(因为我们会有一个更具体的类型,它实现了父类没有提供的一个增强接口)。

描述符上的类型注释

在描述符上应用类型注释可能大多数时候都很复杂。

可能存在循环依赖问题(这意味着包含描述符定义的 Python 文件将不得不读取消费者的文件以获取类型,但随后客户端需要读取包含描述符对象定义的文件来使用它)。即使你通过使用字符串而不是实际类型来克服这些问题,还有一个问题。

如果你知道确切类型来注释描述符方法,这意味着描述符可能只对一种类型的类有用。这通常违背了描述符的目的:本书的建议是使用描述符来处理我们知道可以从中受益于泛化和大量代码重用的场景。如果我们不重用代码,拥有描述符的复杂性就不再值得了。

由于这个原因,尽管始终添加注释到我们的定义通常是一个好的实践,但对于描述符的情况,可能简单地不添加注释会更简单。相反,将其视为编写有用文档字符串的好机会,这些文档字符串准确地记录了描述符的行为。

摘要

描述符是 Python 中一个更高级的特性,它将元编程的边界推向了更近的地方。它们最有趣的一个方面是它们清楚地表明 Python 中的类只是普通的对象,并且作为这样的对象,它们具有我们可以与之交互的属性。在这个意义上,描述符是类可以拥有的最有趣的属性类型,因为它们的协议促进了更高级、面向对象的可能性。

我们已经看到了描述符的机制、它们的方法以及所有这些是如何结合在一起,从而形成一个更有趣的对象式软件设计图景。通过理解描述符,我们能够创建出强大且简洁的抽象类。我们看到了如何修复我们想要应用于函数和方法的装饰器,并且我们对 Python 的内部工作原理以及描述符在语言实现中扮演的核心和关键角色有了更深入的了解。

这项研究如何在内部使用描述符的 Python 方法应该作为一个参考,以识别我们自己的代码中描述符的良好使用,目标是实现惯用的解决方案。

尽管描述符为我们提供了强大的选项,但我们必须记住何时恰当地使用它们,避免过度设计。在这方面,我们建议我们应该只为真正通用的情况保留描述符的功能,例如内部开发 API、库或框架的设计。与此相关的一个重要考虑是,通常我们不应该在描述符中放置业务逻辑,而应该放置实现技术功能以供包含业务逻辑的其他组件使用的逻辑。

谈到高级功能,下一章也涵盖了一个有趣且深入的主题:生成器。表面上,生成器相当简单(而且大多数读者可能已经熟悉它们),但它们与描述符的共同之处在于它们也可以很复杂,产生更高级和优雅的设计,并使 Python 成为一个独特的编程语言。

参考文献

这里有一些你可以参考以获取更多信息的事项:

第七章:生成器、迭代器和异步编程

生成器是 Python 区别于更传统语言的特征之一。在本章中,我们将探讨其原理,为什么它们被引入到语言中,以及它们解决的问题。我们还将介绍如何通过使用生成器以惯用的方式解决问题,以及如何使我们的生成器(或任何可迭代对象)具有 Python 风格。

我们将理解为什么迭代(以迭代器模式的形式)在语言中得到自动支持。从那里,我们将再次踏上旅程,探索生成器如何成为 Python 的一个基本特性,以支持其他功能,如协程和异步编程。

本章的目标如下:

  • 创建能够提高我们程序性能的生成器

  • 研究迭代器(特别是迭代器模式)在 Python 中如何深入嵌入

  • 为了以惯用的方式解决涉及迭代的问题

  • 理解生成器作为协程和异步编程基础的工作原理

  • 探索对协程的语法支持——yield fromawaitasync def

精通生成器将大大提高你编写惯用 Python 代码的能力,因此它们对于本书的重要性不言而喻。在本章中,我们不仅研究如何使用生成器,还探索其内部机制,以便深入理解它们是如何工作的。

技术要求

本章中的示例将适用于任何平台上的 Python 3.9 的任何版本。

本章中使用的代码可以在github.com/PacktPublishing/Clean-Code-in-Python-Second-Edition找到。说明文档在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})"
        ) 

这个对象将接收所有关于“购买”的总数,并处理所需值。现在,我们需要一个函数将这些数字加载到这个对象可以处理的东西中。以下是第一个版本:

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 

这段代码是有效的;它将文件中的所有数字加载到一个列表中,当传递给我们的自定义对象时,将产生我们想要的数字。尽管如此,它有一个性能问题。如果你用相当大的数据集运行它,它将需要一段时间才能完成,如果数据集足够大以至于无法放入主内存,它甚至可能会失败。

如果我们看一下消费这些数据的代码,它一次处理一个购买,所以我们可能会想知道为什么我们的生产者一次将所有内容放入内存。它正在创建一个列表,将文件的所有内容都放入其中,但我们知道我们可以做得更好。

解决方案是创建一个生成器。而不是将整个文件内容加载到一个列表中,我们将一次产生一个结果。现在的代码将看起来像这样:

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循环方面是多态的。只要我们保持迭代器接口,我们就可以透明地遍历该对象。

我们在本章中探讨的是另一种与 Python 本身很好地融合的惯用代码案例。在之前的章节中,我们看到了如何实现我们自己的上下文管理器来将我们的对象连接到 with 语句中,或者如何创建自定义容器对象来利用in运算符,或者布尔值用于if语句,等等。现在轮到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 风格。

之前的推荐意味着尽量避免将列表传递给已经可以与生成器一起工作的函数。下面代码中的例子是你想要避免的,而应该优先考虑之前列表中的方法:

>>> sum([x**2 for x in range(10)])  # here the list can be avoided 

当然,你还可以将生成器表达式赋值给变量,并在其他地方使用它(就像理解一样)。请注意,在这种情况下有一个重要的区别,因为我们在这里讨论的是生成器。列表可以被重复使用和迭代多次,但生成器在迭代过后就会耗尽。因此,请确保表达式的结果只被消费一次,否则你会得到意外的结果。

记住,生成器在迭代后就会耗尽,因为它们不会在内存中保存所有数据。

一种常见的方法是在代码中创建新的生成器表达式。这样,第一个在迭代后会耗尽,但随后会创建一个新的。以这种方式链式生成器表达式是有用的,并且有助于节省内存,同时使代码更具表现力,因为它在不同的步骤中解决不同的迭代。这种用法的一个场景是当你需要对可迭代对象应用多个过滤器时;你可以通过使用多个作为链式过滤器的生成器表达式来实现这一点。

现在我们工具箱中有了新的工具(迭代器),让我们看看我们如何使用它来编写更符合习惯的代码。

符合习惯的迭代

在本节中,我们将首先探索一些在处理 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 

这利用了迭代协议。类似于我们在前几章中探索的上下文管理器协议,该协议由__enter____exit__方法组成,这个协议依赖于__iter____next__方法。

在 Python 中拥有这些协议有优势:所有了解 Python 的人都会熟悉这个接口,因此存在一种“标准合同”。这意味着,我们不需要定义自己的方法并与团队(或任何潜在的代码阅读者)达成一致(就像我们在第一个例子中的自定义next()方法那样);Python 已经提供了一个接口和协议。我们只需要正确实现它。

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' 

大多数情况下,建议使用默认值,以避免在程序运行时出现异常。如果我们绝对确信我们正在处理的迭代器不能为空,仍然最好对此进行隐式(且有意)的说明,而不是依赖于内置函数的副作用(即,正确断言情况)。

next()函数可以与生成器表达式结合使用,在我们要查找满足某些条件的可迭代对象的第一元素的情况下非常有用。我们将在本章中看到这个习惯用法的例子,但主要思想是使用这个函数而不是创建一个列表推导式然后取其第一个元素。

使用生成器

通过简单地使用生成器,可以显著简化之前的代码。生成器对象是迭代器。这样,我们不需要创建一个类,而可以定义一个函数,按需产生值:

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笔超过1000的购买,我们会这样做:

>>> from itertools import islice
>>> purchases = islice(filter(lambda p: p > 1000.0, purchases), 10)
>>> stats = PurchasesStats(purchases).process()  # ... 

以这种方式进行过滤不会产生内存惩罚,因为它们都是生成器,所以评估总是延迟的。这让我们能够像一次性过滤整个集合然后传递给对象一样思考,但实际上并不需要在内存中放入所有内容。

请记住章节开头提到的权衡,即在内存和 CPU 使用之间的权衡。虽然代码可能使用更少的内存,但它可能需要更多的 CPU 时间,但大多数时候,这是可以接受的,当我们需要在内存中处理大量对象的同时保持代码的可维护性。

通过迭代器简化代码

现在,我们将简要讨论一些可以用迭代器帮助改进的情况,以及偶尔使用 itertools 模块的情况。在讨论每个案例及其提出的优化后,我们将用推论结束每个要点。

重复迭代

现在我们已经了解了更多关于迭代器的信息,并介绍了 itertools 模块,我们可以向您展示本章的第一个例子(计算一些购买数据的统计信息)可以如何显著简化:

def process_purchases(purchases):
    min_, max_, avg = itertools.tee(purchases, 3)
    return min(min_), max(max_), median(avg) 

在这个例子中,itertools.tee 将原始可迭代对象拆分为三个新的对象。我们将使用这些对象来完成不同类型的迭代,而无需对 purchases 重复三次不同的循环。

读者可以简单地验证,如果我们传递一个可迭代对象作为 purchases 参数,这个对象只会被遍历一次(多亏了 itertools.tee 函数 [TEE]),这是我们主要的要求。也可以验证这个版本如何与我们的原始实现等效。在这种情况下,没有必要手动引发 ValueError,因为将空序列传递给 min() 函数会这样做。

如果你正在考虑对同一个对象运行多次循环,请停下来思考 itertools.tee 是否能有所帮助。

itertools 模块包含许多有用的函数和方便的抽象,当处理 Python 中的迭代时非常有用。它还包含关于如何以习惯用法解决典型迭代问题的良好食谱。作为一般建议,如果你在考虑如何解决涉及迭代的具体问题,就去看看这个模块。即使答案不是字面上的,它也会是一个很好的灵感来源。

嵌套循环

在某些情况下,我们需要遍历多个维度,寻找一个值,嵌套循环是第一个想法。当找到值时,我们需要停止迭代,但 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 as e:
        raise ValueError(f"{desired_value} not found") from e
    logger.info("value %r found at [%i, %i]", desired_value, *coord)
    return coord 

值得注意的是,我们创建的辅助生成器是如何作为所需迭代抽象的。在这种情况下,我们只需要迭代两个维度,但如果我们需要更多,一个不同的对象可以处理这些,而客户端无需了解这些。这就是迭代器设计模式的核心,在 Python 中,它是透明的,因为它自动支持迭代器对象,这是下一节将要讨论的主题。

尽可能地使用尽可能多的抽象来简化迭代,在可能的地方简化循环。

希望这个例子能给你带来灵感,让你明白我们可以使用生成器做的不只是节省内存。我们可以利用迭代作为抽象。也就是说,我们不仅可以通过定义类或函数来创建抽象,还可以利用 Python 的语法。就像我们看到了如何通过上下文管理器抽象掉一些逻辑(这样我们就不需要知道with语句下发生的事情的细节),我们也可以用迭代器做到同样的事情(这样我们就可以忘记for循环的底层逻辑)。

因此,我们将从下一节开始探索 Python 中迭代器模式的工作原理。

Python 中的迭代器模式

在这里,我们将从生成器稍微偏离一下,以更深入地理解 Python 中的迭代。生成器是可迭代对象的一个特例,但 Python 中的迭代不仅仅局限于生成器,能够创建良好的可迭代对象将给我们机会编写更高效、紧凑和易于阅读的代码。

在之前的代码示例中,我们已经看到了既是可迭代对象又是迭代器的例子,因为它们实现了__iter__()__next__()魔法方法。虽然这在一般情况下是可以的,但它们并不严格需要总是实现这两个方法,在这里我们将展示可迭代对象(实现了__iter__)和迭代器(实现了__next__)之间的微妙差异。

我们还探讨了与迭代相关的一些其他主题,例如序列和容器对象。

迭代接口

一个可迭代对象是一个支持迭代的对象,在非常高的层面上,这意味着我们可以运行一个for .. in ... 循环来遍历它,而不会出现任何问题。然而,可迭代对象并不等同于迭代器。

通常来说,一个可迭代对象就是我们能够迭代的任何东西,它通过迭代器来实现这一点。这意味着在__iter__魔法方法中,我们希望返回一个迭代器,即实现了__next__()方法的对象。

迭代器是一个对象,它只知道如何在被已经探索过的内置next()函数调用时逐个产生一系列值,当迭代器没有被调用时,它只是简单地冻结,无所事事地坐着,直到再次被调用以产生下一个值。从这个意义上说,生成器是迭代器。

Python 概念 魔法方法 考虑事项
可迭代对象 __iter__ 它们使用迭代器来构建迭代逻辑。这些对象可以在for ... in ...循环中进行迭代。
迭代器 __next__ 定义逐个产生值的逻辑。StopIteration异常表示迭代结束。值可以通过内置的next()函数逐个获取。

表 7.1:可迭代对象和迭代器

在下面的代码中,我们将看到一个迭代器对象的例子,它不是可迭代的——它只支持逐个调用其值。在这里,名称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__),而不是一个偶然也可以迭代的序列。

迭代器是 Python 的重要组成部分,不仅因为它们为我们软件工程师提供的功能,还因为它们在 Python 内部起着基本的作用。

在第二章“Pythonic Code”中的“异步代码简介”中,我们看到了如何阅读异步代码。现在我们已经探索了 Python 中的迭代器,我们可以看到这两个概念是如何相关的。特别是,下一节将探讨协程,我们将看到迭代器是如何成为其核心的。

协程

协程的想法是有一个函数,其执行可以在某个特定时间点暂停,稍后可以恢复。通过这种功能,程序可能能够暂停代码的一部分,以便处理其他事情,然后返回到这个原始点继续执行。

如我们所知,生成器对象是可迭代的。它们实现了__iter__()__next__()。这是 Python 自动提供的,以便当我们创建生成器对象函数时,我们得到一个可以迭代或通过next()函数推进的对象。

除了这个基本功能之外,它们还有更多方法,以便它们可以作为协程(PEP-342)工作。在这里,我们将在下一节更详细地探讨生成器如何演变成协程以支持异步编程的基础之前,探索生成器是如何演变成协程的。在下一节中,我们将探讨 Python 的新特性和用于异步编程的语法。

PEP-342 中添加的基本方法以支持协程如下:

  • .close()

  • .throw(ex_type[, ex_value[, ex_traceback]])

  • .send(value)

Python 利用生成器来创建协程。因为生成器可以自然地暂停,所以它们是一个方便的起点。但是,生成器并不足以满足最初的想法,因此添加了这些方法。这是因为通常,仅仅能够暂停代码的一部分是不够的;你还想与之通信(传递数据,并通知上下文的变化)。

通过更详细地探索每个方法,我们将能够更多地了解 Python 协程的内部机制。在此之后,我将再次概述异步编程的工作原理,但与第二章“Pythonic Code”中介绍的不同,这一次它将与我们刚刚学到的内部概念相关。

生成器接口的方法

在本节中,我们将探讨上述每种方法的作用、工作原理以及预期如何使用。通过了解如何使用这些方法,我们将能够利用简单的协程。

之后,我们将探讨协程的更高级用法,以及如何委派给子生成器(协程)以重构代码,以及如何编排不同的协程。

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]])

此方法将在生成器当前挂起的那一行抛出异常。如果生成器处理了发送的异常,那么将调用特定于该except子句的代码;否则,异常将传播到调用者。

在这里,我们稍微修改了之前的例子,以展示当我们使用此方法处理协程处理的异常和未处理的异常时的差异:

class CustomException(Exception):
    """A type of exception that is under control."""
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语句,这是协程从数据库读取并返回数据的行。

在这个特定的例子中,它处理了所有异常,但如果最后的块(除了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()方法推进一次。未能这样做将导致异常:

>>> def coro():
...     y = yield
...
>>> c = coro()
>>> c.send(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
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语句的结果,它将被分配给包含要使用页面长度的变量,这个变量反过来将被用来从数据库中读取。

连续调用将具有这种逻辑,但重要的是现在我们可以在迭代过程中动态地改变要读取的数据长度,在任何时候都可以。

现在我们已经理解了前面的代码是如何工作的,大多数 Python 开发者都会期待一个简化版本(毕竟,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 auto_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 = auto_stream_db_records(DBHandler("testdb"))
>>> len(streamer.send(5))
5 

请记住,这些是 Python 中协程工作的基础。通过遵循这些示例,你会对 Python 在处理协程时实际发生的事情有一个概念。然而,在现代 Python 中,你通常不会自己编写这类协程,因为已经有了新的语法(我们之前提到过,但我们将重新审视,看看它们如何与我们刚刚看到的概念相关)。

在深入研究新的语法功能之前,我们需要探索协程在功能上所做的最后跳跃,以便填补缺失的空白。之后,我们将能够理解在异步编程中使用的每个关键字和语句背后的含义。

更高级的协程

到目前为止,我们对协程有了更好的理解,我们可以创建简单的协程来处理小任务。我们可以认为这些协程实际上只是更高级的生成器(这将是正确的,协程只是花哨的生成器),但如果我们实际上想要开始支持更复杂的场景,我们通常必须选择一个可以同时处理许多协程的设计,这需要更多的功能。

当处理许多协程时,我们会遇到新的问题。随着我们的应用程序的控制流变得更加复杂,我们希望在上传和下传堆栈(以及异常),能够从任何级别的子协程中捕获值,并最终安排多个协程共同实现一个目标。

为了使事情更简单,生成器不得不再次扩展。这就是 PEP-380 通过改变生成器的语义,使它们能够返回值,并引入新的yield from构造来解决的问题。

协程中的返回值

如本章开头所述,迭代是一种机制,它多次在可迭代对象上调用next(),直到抛出StopIteration异常。

到目前为止,我们一直在探索生成器的迭代特性——我们一次产生一个值,通常我们只关心在for循环的每一步产生的每个值。这是关于生成器的一种非常逻辑的思考方式,但协程有不同的想法;尽管它们在技术上也是生成器,但它们并不是以迭代的概念来构思的,而是以在稍后恢复执行时挂起代码执行为目标。

这是一个有趣的挑战;当我们设计协程时,我们通常更关心挂起状态而不是迭代(迭代协程将是一个奇怪的情况)。挑战在于很容易将它们两者混合。这是因为一个技术实现细节;Python 中对协程的支持建立在生成器的基础上。

如果我们想使用协程来处理一些信息并挂起其执行,那么将它们视为轻量级线程(在其他平台上被称为绿色线程)是有意义的。在这种情况下,如果它们能够返回值,就像调用任何其他常规函数一样,那就更有意义了。

但让我们记住,生成器不是常规函数,所以在生成器中,构造value = generator()除了创建一个生成器对象之外,不会做任何事情。生成器返回值的语义应该是什么?它必须在迭代完成后才能进行。

当生成器返回一个值时,其迭代立即停止(它不能再迭代)。为了保持语义,StopIteration异常仍然会被抛出,而要返回的值被存储在exception对象中。这是调用者的责任去捕获它。

在下面的例子中,我们创建了一个简单的生成器,它产生两个值,然后返回第三个。注意我们如何必须捕获异常才能获取这个值,以及它是如何精确地存储在异常的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(f">>>>>> returned value: {e.value}")
... 
>>>>>> returned value: 3 

正如我们稍后将会看到的,这个机制被用来使协程返回值。在 PEP-380 之前,这并没有什么意义,任何在生成器内部使用return语句的尝试都被视为语法错误。但现在,我们的想法是,当迭代结束时,我们想要返回一个最终值,而提供它的方式是将它存储在迭代结束时的异常中(StopIteration)。这可能不是最干净的方法,但它完全向后兼容,因为它没有改变生成器的接口。

委派到更小的协程 - 'yield from' 语法

前面的特性在意义上很有趣,因为它为协程(生成器)打开了大量新的可能性,现在它们可以返回值。但这个特性本身,如果没有适当的语法支持,将不会那么有用,因为以这种方式捕获返回值有点繁琐。

这是yield from语法的最主要特性之一。在其他方面(我们将在详细回顾),它可以收集子生成器返回的值。记住我们说过,在生成器中返回数据是很好的,但不幸的是,将语句写成value = generator()是不行的?好吧,将它们写成value = yield from generator()就可以。

yield from的最简单用法

在其最基本的形式中,新的yield from语法可以用来将嵌套的for循环中的生成器链式连接成一个单一的循环,最终得到一个连续流中所有值的单个字符串。

一个典型的例子是创建一个类似于itertools.chain()的函数,这个函数来自standard库。这是一个非常好的函数,因为它允许你传递任意数量的iterables,并将它们全部作为一个流返回。

天真的实现可能看起来像这样:

def chain(*iterables):
    for it in iterables:
        for value in it:
            yield value 

它接收一个可变数量的iterables,遍历它们,由于每个值都是可迭代的,它支持for... in..构造,因此我们有一个额外的for循环来获取每个特定可迭代对象中的每个值,这些值是由调用函数产生的。

这可能在多种情况下很有用,比如将生成器链式连接起来,或者尝试迭代那些通常不可能一次性比较的事物(比如列表和元组等)。

然而,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³):

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 

这是 main 代码在迭代过程中的一个可能的执行情况:

>>> 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 构造在 Python 中的工作原理。yield from 构造将获取生成器,并将其迭代传递到下游,但一旦完成,它将捕获其 StopIteration 异常,获取其值,并将该值返回给调用函数。StopIteration 异常的值属性成为该语句的结果。

这是一个强大的构造,因为它与下一节的主题(如何从子生成器发送和接收上下文信息)结合,这意味着协程可以采取类似于线程的形状。

向子生成器发送和接收数据

现在,我们将看到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协程,以便观察它们在序列中的处理方式:

>>> 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的调用者。

如预期的那样,这些方法与yield from一起为我们提供了很多新的功能(这可以类似于线程)。这为异步编程打开了大门,我们将在下一节中探讨。

异步编程

到目前为止我们所看到的构造,我们可以在 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返回的内容并不明确。它可能是一个简单的可迭代对象,例如一个string,并且可能仍然是语法正确的。或者,它可能是一个实际的协程。这个错误的代价将在很久以后,在运行时付出。

因此,Python 中的类型系统必须得到扩展。在 Python 3.5 之前,协程只是应用了@coroutine装饰器的生成器,并且它们需要使用yield from语法来调用。现在,Python 解释器识别出一种特定的对象,即协程。

这一变化也预示了语法的改变。引入了awaitasync def语法。前者旨在替代yield from,并且仅与awaitable对象(协程恰好是)一起使用。尝试用不遵守awaitable接口的东西调用await将引发异常(这是一个很好的例子,说明了接口如何有助于实现更稳固的设计,防止运行时错误)。

async def是定义协程的新方法,取代了上述装饰器,并且实际上创建了一个对象,当调用它时,将返回一个协程的实例。与调用生成器函数的方式相同,解释器将返回一个生成器对象,当你调用用async def定义的对象时,它将给你一个具有__await__方法的协程对象,因此可以在await表达式中使用。

不深入探讨 Python 异步编程的所有细节和可能性,我们可以这样说,尽管有新的语法和新类型,但这并没有做任何本质上与我们本章所讨论的概念不同的东西。

Python 中异步编程的核心理念是存在一个事件循环(通常是asyncio,因为它包含在标准库中,但还有许多其他同样可以工作的循环),它管理一系列的协程。这些协程属于事件循环,它将根据其调度机制调用它们。当这些协程中的任何一个运行时,它将调用我们的代码(根据我们在编写的协程中定义的逻辑),当我们想要将控制权交还给事件循环时,我们调用await <协程>,这将异步处理一个任务。事件循环将继续运行,并启动另一个协程,同时之前的操作仍在进行中。

这种机制代表了 Python 中异步编程工作的基本原理。你可以认为为协程添加的新语法(async def / await)只是为你编写代码的一个 API,以便由事件循环调用。默认情况下,该事件循环通常是asyncio,因为它包含在标准库中,但任何符合 API 的事件循环系统都可以工作。这意味着你可以使用像uvloop(github.com/MagicStack/uvloop)和trio(github.com/python-trio/trio)这样的库,代码将按相同的方式工作。你甚至可以注册自己的事件循环,它也应该按相同的方式工作(前提是符合 API 规范)。

实际上,还有更多特定的特性和边缘情况超出了本书的范围。然而,值得指出的是,这些概念与本章中介绍的思想相关,而且这个领域是另一个展示生成器作为语言核心概念的地方,因为许多东西都是建立在它们之上的。

魔法异步方法

我在前几章中已经提出(并希望说服你)只要有可能,我们就可以利用 Python 中的魔法方法,使我们所创建的抽象与语言的语法自然融合,从而实现更好、更紧凑、可能更干净的代码。

但如果在这些方法中的任何一个我们需要调用协程怎么办?如果我们必须在函数中调用await,这意味着该函数本身必须是一个协程(使用async def定义),否则将会出现语法错误。

然而,这与当前的语法和魔法方法如何工作呢?它不起作用。我们需要新的语法和新的魔法方法才能与异步编程一起工作。好消息是,它们与之前的类似。

这里是对新魔法方法和它们如何与新语法相关的一个总结。

概念 魔法方法 语法用法
上下文管理器 __aenter__ __aexit__ async with async_cm() as x:...
迭代 __aiter__ __anext__ async for e in aiter:...

表 7.2:异步语法及其魔法方法

这种新语法在 PEP-492 中有所提及(www.python.org/dev/peps/pep-0492/)。

异步上下文管理器

简单来说,如果我们想使用上下文管理器但需要在其上调用协程,我们不能使用正常的__enter____exit__方法,因为它们被定义为常规函数,所以我们需要使用新的__aenter____aexit__协程方法。而且,我们不仅需要使用with来调用它,还需要使用async with。

contextlib模块中甚至还有一个@asynccontextmanager装饰器,可以创建与之前所示相同的异步上下文管理器。

异步上下文管理器的async with 语法以类似的方式工作:当上下文进入时,__aenter__协程会自动调用,当它退出时,__aexit__将被触发。甚至可以在同一个async with 语句中组合多个异步上下文管理器,但不能与常规的混合使用。尝试使用常规上下文管理器与async with 语法将失败并抛出AttributeError

如果将我们的例子从第二章Pythonic Code,改编为异步编程,它将看起来像以下代码:

@contextlib.asynccontextmanager
async def db_management():
    try:
        await stop_database()
        yield
    finally:
        await start_database() 

此外,如果我们想使用多个上下文管理器,我们可以这样做,例如:

@contextlib.asynccontextmanager
async def metrics_logger():
    yield await create_metrics_logger()

async def run_db_backup():
    async with db_management(), metrics_logger():
        print("Performing DB backup...") 

如你所预期,contextlib模块提供了一个抽象基类AbstractAsyncContextManager,它要求实现__aenter____aexit__方法。

其他魔法方法

那些其他魔法方法会发生什么?它们都会得到它们的异步对应物吗?不,但我想指出的是:这不应该需要。

记住,编写干净代码的部分是确保你在代码中正确分配责任,并将事物放在适当的位置。举个例子,如果你在考虑在__getattr__方法中调用协程,那么你的设计可能存在问题,因为可能有一个更好的地方来放置那个协程。

我们等待的协程用于使代码的某些部分并发运行,因此它们通常与外部资源的管理相关,而我们在其他魔法方法((__getitem__, __getattr__, 等)中放入的逻辑应该是面向对象的代码,或者可以仅根据该对象的内部表示来解决的代码。

同样地(并且遵循良好的设计实践),将__init__设计为协程并不是一个好的选择,因为我们通常希望创建轻量级对象,这样我们可以在没有副作用的情况下安全地初始化它们。更好的是,我们已经讨论了使用依赖注入的好处,因此这更是我们不希望有一个异步初始化方法的原因:我们的对象应该与已经初始化的依赖项一起工作。

上一表的第二个案例,异步迭代,对于本章的目的来说更有兴趣,所以我们将在下一节中探讨它。

异步迭代的语法(async for)适用于任何异步迭代器,无论是我们自己创建的(我们将在下一节中看到如何做到这一点),还是异步生成器(我们将在下一节中看到)。

异步迭代

就像我们在本章开头看到的迭代器对象(即支持使用 Python 内置的for循环进行迭代的对象)一样,我们也可以这样做,但以异步的方式进行。

想象一下,我们想要创建一个迭代器来抽象我们从外部源(如数据库)读取数据的方式,但提取数据本身的操作是一个协程,所以我们不能像以前那样在已熟悉的__next__操作中调用它。这就是为什么我们需要使用__anext__协程的原因。

以下示例以简单的方式说明了如何实现这一点。不考虑外部依赖或任何其他意外复杂性,我们将专注于使此类操作成为可能的方法,以便研究它们:

import asyncio
import random

async def coroutine():
    await asyncio.sleep(0.1)
    return random.randint(1, 10000)

class RecordStreamer:
    def __init__(self, max_rows=100) -> None:
        self._current_row = 0
        self._max_rows = max_rows

    def __aiter__(self):
        return self

    async def __anext__(self):
        if self._current_row < self._max_rows:
            row = (self._current_row, await coroutine())
            self._current_row += 1
            return row
        raise StopAsyncIteration 

第一种方法,__aiter__,用于表示该对象是一个异步迭代器。正如在同步版本中,大多数情况下返回 self 就足够了,因此它不需要是一个协程。

但另一方面,__anext__正是我们代码中异步逻辑所在的部分,因此它一开始就需要是一个协程。在这种情况下,我们正在等待另一个协程以返回要返回的部分数据。

它还需要一个单独的异常来表示迭代的结束,在这种情况下,称为StopAsyncIteration

这个异常以类似的方式工作,只是它是为async for类型的循环设计的。当遇到这种情况时,解释器将结束循环。

这种对象可以用以下形式使用:

async for row in RecordStreamer(10):
    ... 

你可以清楚地看到这与我们在本章开头探索的同步版本是如何类似的。不过,有一个重要的区别是,正如我们所预期的,next()函数不会在这个对象上工作(毕竟它没有实现__next__),因此要向前推进一个异步生成器,就需要不同的语法。

通过以下方式可以向前推进异步迭代器一个位置:

await async_iterator.__anext__() 

但更有趣的结构,比如我们之前看到的,使用next()函数在生成器表达式中工作以搜索满足某些条件的第一值,将不会得到支持,因为它们无法处理异步迭代器。

受到前面语法的启发,我们可以使用异步迭代创建一个生成器表达式,然后从中获取第一个值。更好的是,我们可以创建我们自己的这个函数版本来与异步生成器一起工作,它可能看起来像这样:

NOT_SET = object()

async def anext(async_generator_expression, default=NOT_SET):
    try:
        return await async_generator_expression.__anext__()
    except StopAsyncIteration:
        if default is NOT_SET:
            raise
        return default 

从 Python 3.8 开始,asyncio模块有一个很好的功能,允许我们从 REPL 直接与协程交互。这样,我们可以交互式地测试前面的代码将如何工作:

$ python -m asyncio
>>> streamer = RecordStreamer(10)
>>> await anext(streamer)
(0, 5017)
>>> await anext(streamer)
(1, 5257)
>>> await anext(streamer)
(2, 3507)
...
>>> await anext(streamer)
(9, 5440)
>>> await anext(streamer)
Traceback (most recent call last):
    ...
    raise StopAsyncIteration
StopAsyncIteration
>>> 

你会注意到它在接口和行为上都与原始的next()函数相似。

现在我们知道了如何在异步编程中使用迭代,但我们可以做得更好。大多数时候我们只需要一个生成器,而不是整个迭代器对象。生成器的优势在于它们的语法使得它们更容易编写和理解,所以在下节中,我将提到如何为异步程序创建生成器。

异步生成器

在 Python 3.6 之前,上一节中探索的功能是 Python 中实现异步迭代的唯一方法。由于我们在前几节中探讨了协程和生成器的复杂性,尝试在协程内部使用yield语句并没有完全定义,因此不允许(例如,yield会尝试挂起协程,还是为调用者生成一个值?)。

异步生成器是在 PEP-525 中引入的 (www.python.org/dev/peps/pep-0525/)。

在这个 PEP 中解决了在协程中使用 yield 关键字的问题,现在允许使用,但具有不同的和明确的意义。与我们所看到的第一个协程示例不同,协程中的 yield(使用 async def 正确定义)并不意味着暂停或暂停该协程的执行,而是为调用者生成一个值。这是一个异步生成器:与我们在章节开头看到的生成器相同,但可以以异步方式使用(意味着它们可能在定义内部等待其他协程)。

异步生成器相对于迭代器的主要优势与常规生成器相同的优势;它们允许我们以更紧凑的方式实现相同的事情。

正如承诺的那样,使用异步生成器编写的上一个示例看起来更紧凑:

async def record_streamer(max_rows):
    current_row = 0
    while current_row < max_rows:
        row = (current_row, await coroutine())
        current_row += 1
        yield row 

它感觉更接近常规生成器,因为结构是相同的,只是多了 async def / await 构造。此外,你将不得不记住更少的细节(关于需要实现的方法和必须触发的正确异常),因此我建议,在可能的情况下,你应尽可能优先考虑异步生成器而不是迭代器。

这标志着我们通过 Python 的迭代和异步编程之旅的结束。特别是,我们刚刚探讨的最后一个主题是它的巅峰,因为它与我们在这章中学到的所有概念都有关。

摘要

生成器在 Python 中无处不在。自从它们在 Python 中很久以前引入以来,它们证明是一个伟大的补充,使程序更高效,迭代更简单。

随着时间的推移,需要添加到 Python 中的更复杂任务越来越多,生成器再次帮助支持协程。

而且,尽管在 Python 中协程是生成器,我们仍然不能忘记它们在语义上是不同的。生成器是以迭代的概念创建的,而协程的目的是异步编程(在任何给定时间暂停和恢复我们程序的一部分执行)。这种区别变得如此重要,以至于它使 Python 的语法(以及类型系统)发生了演变。

迭代和异步编程构成了 Python 编程的主要支柱的最后一部分。现在,是时候看看所有这些内容是如何结合在一起的,并将我们在过去几章中探索的所有这些概念付诸实践。这意味着,到目前为止,你已经完全理解了 Python 的功能。

现在是时候利用这个优势了,所以接下来几章,我们将看到如何将这些概念付诸实践,与更通用的软件工程思想相关,如测试、设计模式和架构。

我们将在下一章开始探索单元测试和重构这一新部分。

参考资料

这里有一份您可以参考的信息列表:

第八章:单元测试和重构

本章探讨的思想在全球范围内是本书的基本支柱,因为它们对我们最终目标的重要性:编写更好、更易于维护的软件。

单元测试(以及任何形式的自动测试)对于软件的可维护性至关重要,因此任何质量项目都不能缺少。正因为如此,本章专门致力于自动化测试作为关键策略的各个方面,以确保安全地修改代码,并逐步迭代出更好的版本。

在本章之后,我们将对以下内容有更深入的了解:

  • 为什么自动化测试对于项目的成功至关重要

  • 单元测试如何作为代码质量的启发式方法

  • 可用于开发自动化测试和设置质量门框架和工具

  • 利用单元测试更好地理解领域问题并记录代码

  • 与单元测试相关的概念,例如测试驱动开发

在前面的章节中,我们看到了 Python 特定的特性以及我们如何利用它们来实现更易于维护的代码。我们还探讨了软件工程的通用设计原则如何应用于 Python,利用其特性。在这里,我们也将回顾软件工程的一个重要概念,如自动化测试,但使用工具,其中一些是标准库中可用的(如unittest模块),还有一些是外部包(如pytest)。我们开始这段旅程,通过探索软件设计如何与单元测试相关联。

设计原则和单元测试

在本节中,我们首先将从概念上审视单元测试。我们将回顾上一章中讨论的一些软件工程原则,以了解这与清洁代码的关系。

之后,我们将更详细地讨论如何将这些概念付诸实践(在代码层面),以及我们可以利用哪些框架和工具。

首先,我们快速定义单元测试是什么。单元测试是负责验证其他代码部分的代码。通常,任何人都会倾向于说单元测试验证应用的“核心”,但这种定义将单元测试视为次要的,而这并不是本书中对它们的看法。单元测试是核心,是软件的一个关键组成部分,它们应该与业务逻辑一样受到同样的考虑。

单元测试是一段代码,它导入包含业务逻辑的部分代码,并对其逻辑进行练习,通过断言几个场景来确保某些条件。单元测试必须具备一些特性,例如:

  • 隔离:单元测试应该完全独立于任何其他外部代理,并且它们必须只关注业务逻辑。因此,它们不会连接到数据库,不会执行 HTTP 请求等。隔离还意味着测试之间是独立的:它们必须能够以任何顺序运行,而不依赖于任何先前的状态。

  • 性能:单元测试必须运行得快。它们旨在多次重复运行。

  • 可重复性:单元测试应该能够以确定性的方式客观评估软件的状态。这意味着测试产生的结果应该是可重复的。单元测试评估代码的状态:如果测试失败,它必须持续失败,直到代码被修复。如果测试通过,并且代码没有变化,它应该继续通过。测试不应该是不稳定的或随机的。

  • 自验证:单元测试的执行决定了其结果。不应需要额外的步骤来解释单元测试(更不用说手动干预)。

更具体地说,在 Python 中,这意味着我们将有新的*.py文件,我们将在这里放置我们的单元测试,并且它们将被某些工具调用。这些文件将包含import语句,以从我们的业务逻辑(我们打算测试的内容)中获取所需的内容,并在该文件内部,我们编写测试本身。之后,一个工具将收集我们的单元测试并运行它们,给出结果。

这最后部分就是自验证的实际意义。当工具调用我们的文件时,将启动一个 Python 进程,我们的测试将在其上运行。如果测试失败,进程将以错误代码退出(在 Unix 环境中,这可以是除0以外的任何数字)。标准是工具运行测试,并为每个成功的测试打印一个点(.);如果测试失败(测试条件未满足),则打印F;如果有异常,则打印E

关于其他形式自动测试的注意事项

单元测试旨在验证非常小的代码单元,例如,一个函数或一个方法。我们希望我们的单元测试达到非常详细粒度,尽可能多地测试代码。要测试更大的东西,比如一个类,我们不想只使用单元测试,而应该使用测试套件,这是一个单元测试的集合。每个测试都将测试更具体的东西,比如那个类的方法。

单元测试不是唯一的自动测试机制,我们不应该期望它们捕获所有可能的错误。还有验收集成测试,这两者都不在本书的范围之内。

在集成测试中,我们希望同时测试多个组件。在这种情况下,我们希望验证它们是否集体地按预期工作。在这种情况下,允许(甚至更希望)有副作用,并忘记隔离,这意味着我们希望发出 HTTP 请求,连接到数据库等。虽然我们希望我们的集成测试实际上像生产代码那样运行,但还有一些依赖关系我们仍然希望避免。例如,如果你的服务通过互联网连接到另一个外部依赖项,那么这部分确实会被省略。

假设你有一个使用数据库并连接到一些其他内部服务的应用程序。该应用程序将为不同的环境有不同的配置文件,当然,在生产环境中,你将设置用于真实服务的配置。然而,对于集成测试,你将希望使用专门为这些测试构建的 Docker 容器来模拟数据库,这将在特定的配置文件中进行配置。至于依赖项,你将希望尽可能使用 Docker 服务来模拟它们。

在本章稍后部分将介绍将模拟作为单元测试的一部分。当涉及到对组件进行测试时,模拟依赖关系的内容将在第十章“清洁架构”中介绍,那时我们将从软件架构的角度提到组件。

接受测试是一种自动化的测试形式,试图从用户的角度验证系统,通常执行用例。

与单元测试相比,这两种测试形式失去了一个很好的特性:速度。正如你可以想象的那样,它们将需要更多的时间来运行,因此它们将运行得较少。

在一个好的开发环境中,程序员将拥有整个测试套件,并在修改代码、迭代、重构等过程中不断重复运行单元测试。一旦更改准备就绪,并且拉取请求已打开,持续集成服务将为该分支运行构建,其中单元测试将一直运行,直到存在集成或接受测试。不用说,构建的状态在合并之前应该是成功的(绿色),但重要的是测试类型之间的差异:我们希望一直运行单元测试,而那些运行时间较长的测试则运行得较少。

因此,我们希望拥有大量的单元测试,以及一些策略性地设计的自动化测试,以尽可能覆盖单元测试无法触及的地方(例如数据库的使用)。

最后,给明智的人一句话。记住,这本书鼓励实用主义。除了这些定义和本节开头关于单元测试的要点之外,读者必须记住,根据您的标准和环境,最佳解决方案应该占主导地位。没有人比您更了解您的系统,这意味着如果出于某种原因,您必须编写一个需要启动 Docker 容器以测试数据库的单元测试,那就去做吧。正如我们在本书中反复提醒的那样,实用性胜过纯粹性

单元测试和敏捷软件开发

在现代软件开发中,我们希望不断交付价值,并且尽可能快地交付。这些目标背后的逻辑是,我们越早得到反馈,影响就越小,改变就越容易。这些根本不是新想法;其中一些类似于几十年前的原则,而另一些(如尽快从利益相关者那里获得反馈并在此基础上迭代的思想)你可以在像《大教堂与市集》(缩写为CatB)这样的文章中找到。

因此,我们希望能够有效地应对变化,为此,我们编写的软件将必须发生变化。正如我在前面的章节中提到的,我们希望我们的软件具有适应性、灵活性和可扩展性。

代码本身(无论编写和设计得有多好)不能保证我们它足够灵活以进行更改,如果没有正式的证明它在修改后仍然可以正确运行。

假设我们按照 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(f"iteration.{i}", str(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对象的客户端来测试代码(我们将在探讨单元测试工具的模拟对象部分中看到如何这样做),但这样做会运行比所需的更多代码(注意我们想要测试的部分是如何嵌套在代码中的)。此外,方法相对较小是个好事,因为如果不是这样,测试将不得不运行更多我们不希望运行的未指定部分,我们可能也需要对这些部分进行模拟。这是另一个关于良好设计(小而内聚的函数或方法)的例子,它与可测试性相关。

最后,我们决定不必过于麻烦,只测试我们需要测试的部分,所以不是直接在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对象时,我们将回顾执行这些类型断言的技术和工具。

测试工具

我们可以用来编写单元测试的工具有很多,它们各有优缺点,服务于不同的目的。我将介绍 Python 中用于单元测试的两个最常见库。它们涵盖了大多数(如果不是所有)用例,并且非常受欢迎,因此了解如何使用它们非常有用。

除了测试框架和测试运行库之外,通常还会发现配置代码覆盖率的项目,它们将其用作质量指标。由于覆盖率(当用作指标时)具有误导性,在了解如何创建单元测试之后,我们将讨论为什么它不应被轻视。

下一节将从介绍本章中我们将要使用的用于单元测试的主要库开始。

单元测试的框架和库

在本节中,我们将讨论两个用于编写和运行单元测试的框架。第一个框架是unittest,它包含在 Python 的标准库中,而第二个框架pytest则需要通过pip外部安装:

当涉及到为我们的代码覆盖测试场景时,仅使用unittest可能就足够了,因为它有大量的辅助工具。然而,对于具有多个依赖项、与外部系统连接以及可能需要修补对象、定义固定值和参数化测试用例的更复杂系统,pytest看起来是一个更完整的选项。

我们将使用一个小程序作为示例,展示如何使用两种选项进行测试,这最终将帮助我们更好地了解这两个库的比较。

展示测试工具的示例是一个支持合并请求中代码审查的版本控制工具的简化版本。我们将从以下标准开始:

  • 如果至少有一个人反对更改,合并请求将被“拒绝”。

  • 如果没有人反对,并且合并请求至少对其他两位开发者来说是好的,它就是“批准”的。

  • 在任何其他情况下,其状态是“挂起”。

下面是代码可能的样子:

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 提供了许多有用的比较方法,最常见的是assertEqual(<实际>, <预期>[, message]),它可以用来比较操作的结果与我们期望的值,可选地使用在出错时显示的消息。

我使用顺序(<actual>, <expected>)命名参数,因为在我的经验中,大多数情况下我找到的都是这种顺序。尽管我相信这可能是最常见的形式(作为一种约定)在 Python 中使用,但没有任何建议或指南。事实上,一些项目(如 gRPC)使用相反的形式(<expected>, <actual>),这实际上在其他语言中(例如 Java 和 Kotlin)是一种约定。关键是保持一致并尊重项目中已经使用的格式。

另一种有用的测试方法允许我们检查是否抛出了特定的异常(assertRaises)。

当发生异常情况时,我们在代码中抛出异常以防止在错误假设下进行进一步处理,并通知调用者调用过程中存在问题。这是逻辑中应该被测试的部分,这也是这个方法的目的。

想象一下,我们现在将我们的逻辑扩展一点,以允许用户关闭他们的合并请求,一旦发生这种情况,我们就不希望再进行任何投票(一旦已经关闭,评估合并请求就没有意义了)。为了防止这种情况发生,我们扩展了我们的代码,并在有人试图对一个已关闭的合并请求进行投票的不幸事件上抛出异常。

在添加了两个新的状态(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) 

现在,我们想要检查这个验证确实有效。为此,我们将使用 assertRaisesassertRaisesRegex 方法:

 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),如果没有这样做,它将失败,表示预期抛出的异常没有发生。后者做的是同样的事情,但它还检查抛出的异常是否包含与提供的参数匹配的正则表达式消息。即使异常被抛出,但消息不同(不匹配正则表达式),测试也会失败。

尝试检查错误信息,因为除了异常作为额外的检查将更加准确并确保触发的是我们想要的异常之外,它还会检查是否偶然触发了同一类型的另一个异常。

注意这些方法也可以用作上下文管理器。在其第一种形式(在之前的示例中使用的那种形式)中,该方法接收异常,然后是可调用对象,最后是用于该可调用对象的参数列表)。但我们也可以将异常作为方法的参数传递,将其用作上下文管理器,并在该上下文管理器的块内评估我们的代码,格式如下:

with self.assertRaises(MyException):
   test_logic() 

这种第二种形式通常更有用(有时,是唯一的选择);例如,如果我们需要测试的逻辑不能表示为一个单一的调用函数。

在某些情况下,你会注意到我们需要运行相同的测试用例,但使用不同的数据。与其重复并生成重复的测试,我们可以构建一个单一的测试用例,并使用不同的值来测试其条件。这被称为参数化测试,我们将在下一节开始探索这些内容。稍后,我们将使用pytest重新审视参数化测试。

参数化测试

现在,我们想要测试合并请求的阈值接受度是如何工作的,只需提供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()方法中,我们定义了在整个测试中要使用的数据固定值。在这种情况下,实际上并不需要,因为我们可以直接将其放在方法上,但如果我们期望在执行任何测试之前运行一些代码,这就是我们编写它的地方,因为此方法在每次运行测试之前只调用一次。

在这个特定的情况下,我们可以将这个元组定义为类的属性,因为它是一个常量(静态)值。如果我们需要运行一些代码,并执行一些计算(例如构建对象或使用工厂),那么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'> 

如果你选择参数化测试,尽量提供每个参数实例的上下文信息,尽可能多,以便更容易调试。

参数化测试背后的思想是在不同的数据集上运行相同的测试条件。这个想法是首先确定要测试的数据的等价类,然后选择每个类的值代表(关于这一点,本章后面将详细介绍)。然后,你可能会想知道你的测试在哪个等价类上失败了,而 subTest 上下文管理器提供的上下文在这种情况下很有帮助。

pytest

Pytest 是一个优秀的测试框架,可以通过 pip install pytest 安装。与 unittest 相比,有一个区别是,虽然我们仍然可以在类中分类测试场景并创建面向对象的测试模型,但这实际上不是必需的,并且我们可以通过在简单函数中使用 assert 语句来检查我们想要验证的条件,从而以更少的样板代码编写单元测试。

默认情况下,使用 assert 语句进行比较就足以让 pytest 识别单元测试并相应地报告其结果。更高级的使用,如前节中看到的,也是可能的,但它们需要使用该包中的特定函数。

一个很好的特性是,pytests 命令将运行它能够发现的所有测试,即使它们是用 unittest 编写的。这种兼容性使得从 unittestpytest 的逐步过渡变得更容易。

使用 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 

布尔相等比较不需要比简单的 assert 语句更多,而其他类型的检查,如异常检查,则需要我们使用一些函数:

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 来消除重复,尽可能使测试的主体保持一致,并明确代码必须支持的参数(测试输入或场景)。

使用参数化时的重要建议是,每个参数(每个迭代)应仅对应一个测试场景。这意味着你不应该将不同的测试条件混合到同一个参数中。如果你需要测试不同参数的组合,那么使用不同的参数化堆叠。堆叠这个装饰器将创建与装饰器中所有值的笛卡尔积一样多的测试条件。

例如,一个配置如下测试:

@pytest.mark.parametrize("x", (1, 2))
@pytest.mark.parametrize("y", ("a", "b"))
def my_test(x, y):
   … 

将为 (x=1, y=a)(x=1, y=b)(x=2, y=a)(x=2, y=b) 这些值运行。

这是一个更好的方法,因为每个测试都更小,每个参数化更具体(一致)。这将允许你以更简单的方式通过所有可能的组合的爆炸来对代码进行压力测试。

当你有需要测试的数据,或者你知道如何轻松构建它时,数据参数工作得很好,但在某些情况下,你需要为测试构建特定的对象,或者你发现自己反复编写或构建相同的对象。为了帮助解决这个问题,我们可以使用 fixtures,正如我们将在下一节中看到的。

Fixtures

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 fixtures 来实现它。

除了创建多个对象或公开将在整个测试套件中使用的数据之外,还可以使用它们来设置一些条件,例如,全局修补我们不希望被调用的函数,或者当我们想要使用修补对象时。

代码覆盖率

测试运行器支持覆盖率插件(通过 pip 安装),这些插件可以提供有关代码中哪些行在测试运行时被执行的有用信息。这些信息非常有帮助,使我们知道哪些代码部分需要被测试,以及需要改进的地方(包括生产代码和测试)。我的意思是,检测到我们生产代码中未覆盖的行将迫使我们必须为该代码部分编写测试(因为请记住,没有测试的代码应被视为有缺陷的)。在尝试覆盖代码的过程中,可能会发生几件事情:

  • 我们可能会意识到我们完全遗漏了一个测试场景。

  • 我们将尝试编写更多的单元测试或覆盖更多代码行的单元测试。

  • 我们将尝试简化我们的生产代码,去除冗余,使其更加紧凑,这意味着更容易被覆盖。

  • 我们甚至可能会意识到我们试图覆盖的代码行是不可到达的(可能是在逻辑中犯了错误),并且可以安全地删除。

请记住,尽管这些都是积极的一面,但覆盖率永远不应该是一个目标,而只是一个指标。这意味着试图达到高覆盖率,只是为了达到 100%,将不会富有成效或有效。我们应该将代码覆盖率视为一个单位,以识别需要测试的明显代码部分,并了解我们如何可以改进这一点。然而,我们可以设定一个最低阈值,比如 80%(一个普遍接受的价值),作为期望覆盖率的最低水平,以了解项目有合理数量的测试。

此外,认为高程度的代码覆盖率是健康代码库的标志也是危险的:请记住,大多数覆盖率工具都会报告已执行的生产代码行。一行被调用并不意味着它已经被正确测试(只是它运行了)。一个单独的语句可能封装了多个逻辑条件,每个条件都需要单独测试。

不要被高程度的代码覆盖率误导,并继续思考测试代码的方法,包括那些已经覆盖的行。

在此方面最广泛使用的库之一是 coverage (pypi.org/project/coverage/)。我们将在下一节中探讨如何设置这个工具。

设置 rest 覆盖率

pytest 的情况下,我们可以安装 pytest-cov 包。一旦安装,当运行测试时,我们必须告诉 pytest 运行器 pytest-cov 也会运行,以及哪些包(或包)应该被覆盖(以及其他参数和配置)。

此软件包支持多种配置,包括不同类型的输出格式,并且很容易将其与任何持续集成工具集成,但在所有这些功能中,一个高度推荐的选择是设置一个标志,它会告诉我们哪些行还没有被测试覆盖,因为这将帮助我们诊断代码并允许我们开始编写更多的测试。

为了向您展示这会是什么样子,请使用以下命令:

PYTHONPATH=src pytest \
    --cov-report term-missing \
    --cov=coverage_1 \
    tests/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 39      1  97%    44 

这里,它告诉我们有一行没有单元测试,因此我们可以看看如何为它编写单元测试。这是一个常见的场景,我们意识到要覆盖这些缺失的行,我们需要通过创建更小的方法来重构代码。结果,我们的代码将看起来好得多,就像我们在本章开头看到的例子一样。

问题在于相反的情况——我们能否相信高覆盖率?这难道意味着我们的代码是正确的吗?不幸的是,良好的测试覆盖率是干净代码的必要但不充分条件。代码的部分没有测试显然是件坏事。实际上有测试是非常好的,但我们只能对存在的测试这样说。然而,我们对缺失的测试知之甚少,即使代码覆盖率很高,我们可能仍然遗漏了许多条件。

这些是测试覆盖率的一些注意事项,我们将在下一节中提及。

测试覆盖率的注意事项

Python 是解释型语言,在非常高的层面上,覆盖率工具利用这一点来识别在测试运行期间被解释(运行)的行。然后它将在结束时报告这一点。一行被解释的事实并不意味着它得到了适当的测试,这就是为什么我们应该小心阅读最终的覆盖率报告并相信它所说的内容。

这实际上适用于任何语言。一行被执行的事实并不意味着它被所有可能的组合所压力测试。所有分支在提供的数据上成功运行的事实仅意味着代码支持该组合,但它并没有告诉我们关于任何其他可能导致程序崩溃的参数组合的信息(模糊测试)。

将覆盖率用作发现代码盲点的工具,而不是作为指标或目标。

为了用简单的例子来说明这一点,考虑以下代码:

def my_function(number: int):
    return "even" if number % 2 == 0 else "odd" 

现在,让我们假设我们为它编写以下测试:

@pytest.mark.parametrize("number,expected", [(2, "even")])
def test_my_function(number, expected):
    assert my_function(number) == expected 

如果我们运行带有覆盖率的测试,报告将给出令人眼花缭乱的 100%覆盖率。不用说,我们遗漏了对单条语句一半条件进行的测试。更令人不安的是,由于该语句的else子句没有运行,我们不知道我们的代码可能会以哪些方式出错(为了使这个例子更加夸张,想象一下有一个错误的语句,比如1/0而不是字符串"odd",或者有一个函数调用)。

可以说,我们可能更进一步地认为这仅仅是“成功路径”,因为我们向函数提供了良好的值。但是,对于不正确的类型呢?函数应该如何防御这种情况?

正如你所见,即使是单个看似无辜的语句也可能引发许多问题和测试条件,我们需要为这些问题做好准备。

检查我们的代码覆盖率是一个好主意,甚至可以将代码覆盖率阈值配置为 CI 构建的一部分,但我们必须记住,这仅仅是我们工具箱中的另一个工具。就像我们之前探索过的其他工具(代码检查器、格式化工具等)一样,它只有在更多工具和为干净代码库准备的良好环境中才有用。

另一个有助于我们测试工作的工具是使用模拟对象。我们将在下一节中探讨这些内容。

模拟对象

有时候,我们的代码并不是测试环境中唯一存在的东西。毕竟,我们设计和构建的系统必须做一些真实的事情,这通常意味着连接到外部服务(数据库、存储服务、外部 API、云服务等等)。因为它们需要那些副作用,所以它们是不可避免的。尽管我们抽象代码、面向接口编程、将代码与外部因素隔离以最小化副作用,但它们仍然会出现在我们的测试中,我们需要一种有效的方式来处理这种情况。

模拟对象是我们用来保护单元测试免受不期望的副作用影响(如本章前面所述)的最好策略之一。我们的代码可能需要执行 HTTP 请求或发送通知电子邮件,但我们肯定不希望在单元测试中发生这种情况。单元测试应该针对我们代码的逻辑,并且运行快速,因为我们希望非常频繁地运行它们,这意味着我们无法承受延迟。因此,真正的单元测试不会使用任何实际的服务——它们不会连接到任何数据库,不会发起 HTTP 请求,基本上,它们除了锻炼生产代码的逻辑之外,什么都不做。

我们需要能够执行这些操作的测试,但它们并不是单元测试。集成测试应该从更广泛的角度测试功能,几乎模仿用户的行为。但它们并不快。因为它们连接到外部系统和服务,所以它们需要更长的时间,并且运行成本更高。一般来说,我们希望有大量的单元测试可以快速运行,以便随时运行,而集成测试则运行得较少(例如,在每次新的合并请求时)。

虽然模拟对象很有用,但滥用它们介于代码异味或反模式之间。这是我们将在下一节讨论的第一个问题,在讨论使用模拟的细节之前。

关于补丁和模拟的一个公正警告

我之前说过,单元测试帮助我们编写更好的代码,因为当我们开始思考如何测试我们的代码时,我们会意识到如何改进它以使其可测试。通常,随着代码的可测试性提高,它也会变得更干净(更一致、更细粒度、分解成更小的组件等)。

另一个有趣的收获是,测试将帮助我们注意到我们认为代码正确的地方的代码异味。我们的代码有代码异味的一个主要警告是我们是否发现自己试图猴子补丁(或模拟)很多不同的事情,只是为了覆盖一个简单的测试用例。

unittest模块提供了一个工具,可以在unittest.mock.patch中修补我们的对象。

补丁意味着原始代码(由一个表示其在导入时位置的字符串给出)将被替换为其他东西,而不是其原始代码。如果没有提供替换对象,默认是一个标准的模拟对象,它将简单地接受所有方法调用或询问的属性。

补丁函数在运行时替换代码,其缺点是我们失去了与最初存在的原始代码的联系,使我们的测试变得略微肤浅。它还因为修改解释器中对象的额外开销而考虑性能问题,这可能是如果我们重构代码并移动事物时可能需要未来更改的事情(因为补丁函数中声明的字符串将不再有效)。

在我们的测试中使用猴子补丁或模拟可能是可以接受的,并且本身并不代表问题。另一方面,猴子补丁的滥用确实是一个红旗,告诉我们我们的代码中有些地方需要改进。

例如,就像在测试一个函数时遇到困难可能会让我们想到这个函数可能太大,应该分解成更小的部分一样,试图测试需要非常侵入性猴子补丁的代码片段应该告诉我们,也许代码过于依赖硬依赖,应该使用依赖注入代替。

使用模拟对象

在单元测试术语中,有几个类型的对象属于名为测试双工的类别。测试双工是一种对象,由于不同的原因(可能我们不需要实际的生成代码,只是一个哑对象就足够了,或者可能我们无法使用它,因为它需要访问服务或它有我们不希望在单元测试中出现的副作用等),将在我们的测试套件中取代真实对象。

测试双工有不同类型,如哑对象、存根、间谍或 mock。

Mocks 是最通用的对象类型,由于它们非常灵活和多功能,因此适用于所有情况,无需深入了解其他类型。正因为如此,标准库也包含这种类型的对象,这在大多数 Python 程序中很常见。这里我们将使用的就是:unittest.mock.Mock

mock是一种根据特定规格(通常类似于生产类对象)和一些配置的响应(即我们可以告诉 mock 在特定调用时应返回什么,以及其行为应如何)创建的对象。Mock对象将记录其内部状态的一部分,包括其被调用的方式(使用什么参数、调用了多少次等),我们可以使用这些信息来验证我们应用程序在后续阶段的行为。

在 Python 的情况下,标准库中可用的Mock对象提供了一个很好的 API 来执行各种行为断言,例如检查 mock 对象被调用的次数、使用什么参数等。

Mock 类型

标准库在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代替将工作。我们甚至可以配置这种类型 mock 的魔法方法,以返回我们需要以控制测试执行的内容:

def test_find_any():
    mbranch = MagicMock()
    mbranch.__getitem__.return_value = {"author": "test"}
    assert author_by_id("123", mbranch) == "test" 
测试双工的使用案例

要看到 mocks 的可能的用法,我们需要向我们的应用程序添加一个新的组件,该组件将负责通知合并请求的buildstatus。当一个build完成时,这个对象将被调用,带上合并请求的 ID 和buildstatus,并通过向特定的固定端点发送 HTTP POST请求来更新合并请求的status

# 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 连接。

作为测试目标,我们只想确保信息被正确组合,并且库请求是以适当的参数被调用的。由于这是一个外部依赖项,我们不想测试requests模块;只需检查它是否被正确调用就足够了。

当我们尝试比较发送给库的数据时,我们将会遇到另一个问题,即这个类正在计算当前的时间戳,这在单元测试中是无法预测的。直接修补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)。然后,我们再次使用这个函数,但这次作为上下文管理器来改变计算build日期的类的方法的返回值,用我们控制的值替换它,我们将在断言中使用这个值。

一旦我们把这些都设置好了,我们就可以用一些参数调用类方法,然后我们可以使用mock对象来检查它是如何被调用的。在这种情况下,我们使用这个方法来查看requests.post是否确实以我们想要的方式组合了参数被调用。

这是 mocks 的一个很好的特性——它们不仅为所有外部组件(在这种情况下是为了防止发送一些通知或发出 HTTP 请求)设定了边界,而且还提供了一个有用的 API 来验证调用及其参数。

虽然在这种情况下,我们能够通过设置相应的mock对象来测试代码,但这也意味着我们必须针对主要功能的总代码行数进行大量的修补。关于纯生产代码被测试的比例与我们必须模拟的代码部分的比例没有规则,但当然,通过使用常识,我们可以看到,如果我们必须在相同的部分修补很多东西,那么某些东西可能没有清晰地抽象出来,这看起来像是一个代码异味。

可以将外部依赖项的修补与固定值结合使用,以应用一些全局配置。例如,通常一个好的做法是防止所有单元测试执行 HTTP 调用,因此我们可以在单元测试的子目录中,在pytest的配置文件中添加一个固定值(tests/unit/conftest.py):

@pytest.fixture(autouse=True)
def no_requests():
    with patch("requests.post"):
        yield 

此函数将在所有单元测试中自动调用(因为autouse=True),当它这样做时,它将修补requests模块中的post函数。这只是一个你可以适应你项目的想法,以添加一些额外的安全性和确保你的单元测试没有副作用。

在下一节中,我们将探讨如何重构代码以克服这个问题。

重构

重构意味着通过重新排列其内部表示来改变代码的结构,而不修改其外部行为。

一个例子是,如果你发现一个具有许多职责和非常长的方法的类,然后决定通过使用更小的方法、创建新的内部协作者和将职责分配到新的、更小的对象来改变它。在这个过程中,你小心不要改变该类的原始接口,保持所有公共方法与之前相同,并且不更改任何签名。对于该类的外部观察者来说,可能看起来什么都没发生(但我们知道并非如此)。

重构是软件维护中的关键活动,但如果没有单元测试(至少不能正确地完成)就无法进行。这是因为,随着每次更改的进行,我们需要知道我们的代码仍然是正确的。从某种意义上说,你可以把我们的单元测试看作是我们代码的“外部观察者”,确保合同没有破裂。

不时地,我们需要支持新的功能或以未预料到的方式使用我们的软件。满足此类需求唯一的方法是首先重构我们的代码,使其更加通用或灵活。

通常,当我们重构代码时,我们希望改进其结构,使其更好,有时更通用、更易读或更灵活。挑战是在保持修改前代码的精确功能的同时实现这些目标。必须支持与之前相同的功能的约束意味着我们需要在修改过的代码上运行回归测试。运行回归测试的唯一经济有效的方式是如果这些测试是自动的。最经济有效的自动测试版本是单元测试。

代码的演变

在上一个示例中,我们能够将副作用从代码中分离出来,通过修补那些依赖于我们无法在单元测试中控制的代码部分,从而使代码可测试。这是一个很好的方法,因为毕竟,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_payload() 作为新方法(这样我们就可以替换,而无需修补类),并要求注入 transport 依赖。现在 transport 是一个依赖项,替换成我们想要的任何双份都变得容易得多。

甚至可以公开这个对象的固定配置,按照需要替换双份:

@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 

现在我们将添加一个简单的单元测试,检查一个条件及其预期的result

# 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) 

现在,我们将使用pip install mutpy安装mutpy,一个 Python 的突变测试工具,并告诉它运行这个模块的突变测试。以下代码针对不同的情况运行,这些情况通过更改CASE环境变量来区分:

$ PYTHONPATH=src mut.py \
    --target src/mutation_testing_${CASE}.py \
    --unit-test tests/test_mutation_testing_${CASE}.py \
    --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 

如果你运行上一个命令针对案例 2(也可以通过make mutation CASE=2来运行),结果将类似于以下内容:

[*] 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,所以测试失败,这是一个好兆头(测试捕捉到了引入的 bug)。

突变测试是确保单元测试质量的好方法,但它需要一些努力和仔细的分析。在复杂环境中使用这个工具时,我们将不得不花时间分析每个场景。同样,运行这些测试的成本也很高,因为它需要运行不同版本的代码的多次运行,这可能会消耗过多的资源,并且可能需要更长的时间来完成。然而,如果需要手动进行这些检查,成本将更高,并且需要更多的努力。完全不进行这些检查可能风险更大,因为我们可能会危及测试的质量。

测试中的常见主题

我想简要地提及一些在思考如何测试我们的代码时通常值得记住的话题,因为它们是反复出现的并且很有帮助。

当你试图为代码编写测试时,你通常会想要考虑这些点,因为它们会导致无情的测试。当你编写单元测试时,你的心态必须全部集中在破坏代码上:你想要确保你找到错误以便修复它们,并且它们不会滑入生产环境(这将更糟)。

边界或极限值

边界值通常是代码中的麻烦之源,所以这可能是一个好的起点。查看代码并检查围绕某些值设置的条件。然后,添加测试以确保你包括了这些值。

例如,在如下代码行中:

if remaining_days > 0: ... 

明确为 0 添加测试,因为这看起来是代码中的一个特殊情况。

更普遍地,在一个检查值范围的条件下,检查区间的两端。如果代码处理数据结构(如列表或栈),检查空列表或满栈,并确保索引始终设置正确,即使是对于极限值。

等价类

等价类是在集合上的一个划分,使得该划分中的所有元素在某个函数下是等价的。因为该划分内的所有元素都是等价的,所以我们只需要其中一个作为代表来测试该条件。

为了给出一个简单的例子,让我们回顾一下在演示代码覆盖率的章节中使用的上一段代码:

def my_function(number: int):
    return "even" if number % 2 == 0 else "odd" 

在这里,函数有一个单独的 if 语句,根据该条件返回不同的数据。

如果我们想通过规定输入测试值集 S 是整数集来简化这个函数的测试,我们可以争论它可以分为两个部分:偶数和奇数。

因为这段代码对偶数执行某些操作,对奇数执行其他操作,所以我们可以说这些是我们的测试条件。也就是说,我们只需要每个子集的一个元素来测试整个条件,不需要更多。换句话说,用 2 进行测试与用 4 进行测试相同(两种情况下都执行了相同的逻辑),所以我们不需要两者,只需要其中一个(任何)即可。同样适用于 1 和 3(或任何其他奇数)。

我们可以将这些代表性元素分开成不同的参数,并通过使用 @pytest.mark.parametrize 装饰器运行相同的测试。重要的是要确保我们覆盖了所有情况,并且我们没有重复元素(也就是说,我们不会添加两个具有相同分区元素的不同的参数化,因为这不会增加任何价值)。

通过等价类进行测试有两个好处:一方面,我们通过不重复那些对我们的测试场景没有增加任何东西的新值来有效地测试,另一方面,如果我们耗尽了所有等价类,那么我们对要测试的场景就有很好的覆盖率。

边缘情况

最后,尝试添加所有你能想到的边缘情况的特定测试。这很大程度上取决于你编写的业务逻辑和代码的特异之处,并且与测试边界值的概念有所重叠。

例如,如果你的代码部分处理日期,确保你测试闰年、2 月 29 日以及新年前后。

到目前为止,我们假设我们在编写代码之后编写测试。这是一个典型的情况。毕竟,大多数时候,你会发现自己在处理一个已经存在的代码库,而不是从头开始。

有一种替代方案,即在编写代码之前先编写测试。这可能是因为你正在启动一个新的项目或功能,并且希望在编写实际的生产代码之前看到它的样子。或者,可能是因为代码库中存在缺陷,你首先想要编写一个测试来重现它,然后再着手修复。这被称为测试驱动设计TDD),将在下一节中进行讨论。

测试驱动开发的简要介绍

有整本书只专注于 TDD,所以在这个书中全面覆盖这个主题是不现实的。然而,这是一个如此重要的主题,以至于它必须被提及。

TDD 背后的想法是,测试应该在生产代码之前编写,以便生产代码只编写来响应由于缺少功能实现而失败的测试。

我们之所以想要先编写测试再编写代码,有多个原因。从实用主义的角度来看,我们将非常准确地覆盖我们的生产代码。由于所有生产代码都是为响应单元测试而编写的,因此不太可能有测试遗漏了功能(当然,这并不意味着有 100%的覆盖率,但至少所有主要功能、方法或组件都将有相应的测试,即使它们并没有完全被覆盖)。

工作流程简单,从高层次来看,包括三个步骤:

  1. 编写一个单元测试来描述代码应该如何表现。这可能是指尚未存在的新功能,或者当前有问题的代码,在这种情况下,测试描述了期望的场景。第一次运行此测试必须失败。

  2. 对代码进行最小更改,使其通过那个测试。现在测试应该通过了。

  3. 改进(重构)代码,并再次运行测试,确保它仍然有效。

这个周期被普及为著名的红-绿-重构,意味着一开始测试会失败(红色),然后我们让它们通过(绿色),然后我们继续重构代码并迭代它。

摘要

单元测试是一个非常有趣且深入的主题,但更重要的是,它是干净代码的关键部分。最终,单元测试决定了代码的质量。单元测试通常充当代码的镜子——当代码易于测试时,它清晰且设计正确,这将在单元测试中得到反映。

单元测试的代码与生产代码一样重要。适用于生产代码的所有原则也适用于单元测试。这意味着它们应该以同样的努力和细致来设计和维护。如果我们不关心我们的单元测试,它们将开始出现问题并变得有缺陷(或有问题),结果变得无用。如果发生这种情况,并且难以维护,它们就变成了负担,使事情变得更糟,因为人们往往会忽略它们或完全禁用它们。这是最糟糕的情况,因为一旦发生这种情况,整个生产代码就处于危险之中。盲目地前进(没有单元测试)是灾难的配方。

幸运的是,Python 提供了许多单元测试工具,这些工具既包含在标准库中,也通过pip可用。它们非常有帮助,投入时间来配置它们从长远来看是值得的。

我们已经看到单元测试是如何作为程序的正式规范,以及证明软件按照规范工作的证据,我们还了解到,在发现新的测试场景时,总有改进的空间,我们总是可以创建更多的测试。从这个意义上说,通过不同的方法(如基于属性的测试或突变测试)扩展我们的单元测试是一个好的投资。

在下一章中,我们将学习设计模式及其在 Python 中的应用。

参考文献

这里有一份你可以参考的信息列表:

第九章:常见设计模式

设计模式自从在著名的四人帮GoF)书籍《设计模式:可复用面向对象软件元素》中首次提出以来,一直是软件工程中的一个广泛讨论的话题。设计模式通过为特定场景提供抽象来帮助解决常见问题。当它们被正确实现时,解决方案的一般设计可以从它们中受益。

在本章中,我们查看了一些最常见的设计模式,但不是从在特定条件下应用工具的视角(一旦模式被设计出来),而是分析设计模式如何有助于编写干净的代码。在展示实现设计模式的解决方案之后,我们将分析最终实现相对于选择不同路径的比较优势。

作为这次分析的一部分,我们将看到如何在 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 的特定性实现的,在某些情况下非常微妙,但在其他情况下,差异是明显的,通常简化了代码。

创建型模式

在软件工程中,创建型模式是那些处理对象实例化的模式,试图抽象掉很多复杂性(比如确定初始化对象所需的参数,所有可能需要的相关对象,等等),以便为用户提供一个更简单、更安全的接口。基本的对象创建形式可能会导致设计问题或增加设计的复杂性。创建型设计模式通过某种方式控制对象创建来解决这个问题。

在创建对象的五种模式中,我们将主要讨论用于避免单例模式并替换为博格模式(在 Python 应用中最常用)的变体,讨论它们之间的差异和优势。

工厂

如介绍中所述,Python 的一个核心特性是万物皆对象,因此它们都可以被同等对待。这意味着我们没有特殊区分的事物,无论是使用类、函数还是自定义对象,我们都可以对它们进行操作。它们都可以通过参数传递、赋值等操作。

正是因为这个原因,许多工厂模式通常并不需要。我们只需简单地定义一个函数来构建一组对象,我们甚至可以用参数传递我们想要创建的类。

当我们使用pyinject作为库来帮助我们进行依赖注入和复杂对象的初始化时,我们看到了工厂模式的一个例子。在需要处理复杂设置,并确保我们使用依赖注入初始化对象而不重复代码的情况下,我们可以使用pyinject这样的库,或者在我们的代码中创建类似的结构。

单例和共享状态(单态)

相反,单例模式并不是 Python 完全抽象化的。事实是,大多数情况下,这个模式要么根本不是必需的,要么是一个糟糕的选择。单例有很多问题(毕竟,它们实际上是面向对象软件的全局变量的形式,因此是不良实践)。它们难以进行单元测试,它们可能随时被任何对象修改,这使得它们难以预测,并且它们的副作用可能非常成问题。

作为一般原则,我们应该尽可能避免使用单例。如果在某些极端情况下需要使用单例,那么在 Python 中实现这一点的最简单方法就是使用模块。我们可以在模块中创建一个对象,一旦创建,它将可以从导入模块的任何部分访问。Python 本身确保模块已经是单例的,也就是说,无论导入多少次,以及从多少个地方导入,总是同一个模块将被加载到sys.modules中。因此,在这个 Python 模块内部初始化的对象将是唯一的。

注意,这并不完全等同于单例。单例的想法是创建一个类,无论你调用多少次,它都会给你相同的对象。前一段中提出的思想是关于拥有一个唯一对象。无论其类如何定义,我们只创建一个对象,然后多次使用同一个对象。这些有时被称为知名对象;不需要超过一个的对象。

我们已经熟悉这些对象了。考虑None。对于整个 Python 解释器,我们不需要多个。一些开发者声称“None在 Python 中是一个单例”。我对此略有不同意见。它是一个众所周知的对象:我们所有人都知道的东西,我们不需要另一个。对于TrueFalse也是如此。尝试创建不同类型的布尔值是没有意义的。

共享状态

而不是强迫我们的设计有一个单例,无论对象是如何被调用、构造或初始化的,都只创建一个实例,更好的做法是在多个实例之间复制数据。

单例模式(SNGMONO)的想法是,我们可以有许多只是普通对象而没有必要关心它们是否是单例(因为它们只是对象)。这个模式的好处是,这些对象将同步它们的信息,以一种完全透明的方式,我们不必担心它是如何内部工作的。

这使得这种模式成为一个更好的选择,不仅因为它方便,而且因为它更不容易出错,并且受单例模式(关于它们的可测试性、创建派生类等)的缺点更少。

我们可以根据需要同步多少信息来在多个层面上使用这个模式。

在最简单的情况下,我们可以假设我们只需要一个属性被反射到所有实例上。如果是这样,实现就像使用类变量一样简单,我们只需要确保提供一个正确的接口来更新和检索属性的值。

假设我们有一个对象,它必须通过最新的tagGit仓库中pull某个代码版本。可能会有多个此类对象实例,并且当每个客户端调用获取代码的方法时,此对象将使用其属性中的tag版本。在任何时候,这个tag都可以更新为更新版本,我们希望其他任何实例(无论是新创建的还是已经创建的)在调用fetch操作时使用这个新分支,如下面的代码所示:

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 

这种新方法的平衡和权衡现在应该已经很清晰了。这种新的实现方式使用了更多的代码,但它可以重用,因此从长远来看可以节省代码行数(以及重复的逻辑)。再次提醒,参考三个或更多实例规则来决定你是否应该创建这样的抽象。

这个解决方案的另一个重要好处是,它还减少了单元测试的重复(因为我们只需要测试SharedAttribute类,而不是它的所有使用)。

在这里重用代码将使我们更有信心于解决方案的整体质量,因为现在我们只需要为描述符对象编写单元测试,而不是为所有使用它的类编写单元测试(我们可以安全地假设它们是正确的,只要单元测试证明了描述符是正确的)。

博尔模式

以前的方法应该适用于大多数情况,但如果我们真的需要使用单例(这必须是一个非常好的例外),那么还有最后一个更好的替代方案,但这是一个风险更高的方案。

这实际上是单态模式,在 Python 中被称为博尔模式。想法是创建一个对象,能够在其相同类的所有实例之间复制其所有属性。绝对每个属性都在被复制的事实必须是一个警告,要记住可能的不希望出现的副作用。尽管如此,这个模式与单例相比有很多优点。

在这种情况下,我们将把之前的对象分成两个——一个用于处理Git标签,另一个用于处理分支。我们使用使博尔模式工作的代码:

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}" 

两个对象都有一个基类,它们共享初始化方法。但随后它们必须再次实现它,以便使博尔逻辑工作。想法是使用一个字典作为类属性来存储属性,然后我们让每个对象的字典(在初始化时)使用这个相同的字典。这意味着任何对对象字典的更新都会反映在类上,对于其他对象来说也将是相同的,因为它们的类是相同的,而字典是可变的对象,作为引用传递。换句话说,当我们创建这种类型的新对象时,它们都将使用同一个字典,而这个字典会不断更新。

注意,我们不能将字典的逻辑放在基类上,因为这会将不同类对象之间的值混合在一起,这不是我们想要的。这个样板解决方案会让很多人认为它实际上是一个惯用语,而不是一个模式。

一种以实现 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 的情况下。

这个模式的高级思想是我们需要创建一个复杂对象,即一个还需要许多其他对象来协同工作的对象。我们不想让用户创建所有这些辅助对象,然后将它们分配给主要对象,我们希望创建一个抽象,允许所有这些操作在一步内完成。为了实现这一点,我们将有一个builder对象,它知道如何创建所有部分并将它们连接起来,为用户提供一个接口(可能是类方法),以参数化关于结果对象应如何看起来的一切信息。

结构型模式

结构型模式适用于需要创建更简单接口或通过扩展其功能而不增加接口复杂性的更强大对象的情况。

这些模式最好的地方在于,我们可以创建更多有趣的对象,具有增强的功能,并且我们可以以干净的方式实现这一点;也就是说,通过组合多个单一对象(最明显的例子是组合模式),或者通过收集许多简单且一致的接口。

适配器

适配器模式可能是最简单的设计模式之一,同时也是最有用的一种。

也称为包装器,这个模式解决了两个或多个不兼容对象接口适配的问题。

我们通常遇到的情况是,我们的一部分代码与一个或一组类一起工作,这些类在某个方法上是多态的。例如,如果有多个对象使用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 支持多重继承的事实,我们可以用它来创建我们的适配器(甚至可以创建一个作为适配器的mixin类,就像我们在前面的章节中看到的那样)。

然而,正如我们之前多次看到的,继承伴随着更多的耦合(谁知道有多少其他方法是从外部库中携带过来的?),并且它不够灵活。从概念上讲,这也不是正确的选择,因为我们保留继承用于规范的情况(继承是一种 IS-A 类型的关系),在这种情况下,我们的对象是否必须是第三方库提供的类型之一并不清楚(尤其是我们并不完全理解那个对象)。

因此,更好的方法可能是使用组合。假设我们可以为我们提供的对象提供一个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__()魔法方法将请求重定向到包装对象可能是有价值的,但就像所有通用实现一样,我们应该小心不要给解决方案增加更多的复杂性。

使用__getattr__()可能使我们能够拥有一种“通用适配器”;一种可以包装另一个对象并通用地适配其所有方法的东西。但我们确实应该非常小心,因为这个方法将创建一个非常通用的东西,这可能甚至更危险,并产生不可预见的副作用。如果我们想在保持原始接口的同时对对象执行转换或额外功能,那么装饰器模式是一个更好的选择,正如我们将在本章后面看到的。

组合

我们程序的部分部分需要我们与由其他对象组成的对象一起工作。我们有具有良好定义逻辑的基本对象,然后我们将有其他容器对象,它们将分组一些基本对象,挑战在于我们希望在不察觉任何差异的情况下同时处理这两种对象(基本和容器对象)。

这些对象以树形层次结构组织,其中基本对象将是树的叶子,而组合对象将是中间节点。客户可能想要调用任何一个来获取被调用的方法的返回结果。然而,组合对象将充当客户端;它将把这个请求以及它包含的所有对象(无论是叶子还是其他中间节点)传递下去,直到所有对象都被处理。

想象一个简化版的在线商店,其中包含产品。比如说,我们提供将产品分组的功能,并为每组产品提供折扣。产品有一个价格,当顾客付款时,这个值会被询问。但是,一组分组产品的价格也需要计算。我们将有一个代表这个包含产品的组的对象,并将询问每个特定产品的价格的责任委托给每个产品(这可能是另一个产品组),依此类推,直到没有其他需要计算的内容。

以下代码展示了这种实现的示例:

class Product:
    def __init__(self, name: str, price: float) -> None:
        self._name = name
        self._price = price
    @property
    def price(self):
        return self._price
class ProductBundle:
    def __init__(
        self,
        name: str,
        perc_discount: float,
        *products: Iterable[Union[Product, "ProductBundle"]]
    ) -> None:
        self._name = name
        self._perc_discount = perc_discount
        self._products = products
    @property
    def price(self) -> float:
        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 只是转换字典的常规函数。

在这个例子中,基于函数的方法似乎更容易理解。可能会有一些更复杂的规则,这些规则依赖于被装饰对象的数据(不仅仅是它的结果),在这些情况下,采用面向对象的方法可能更有价值,尤其是如果我们真的想在设计中创建一个对象层次结构,其中每个类实际上代表了一些我们想要在设计中明确表达的知识。

外观

外观模式是一个优秀的模式。它在许多情况下都很有用,当我们想要简化对象之间的交互时。模式应用于存在多个对象之间多对多关系的场景,我们希望它们进行交互。而不是创建所有这些连接,我们在许多对象前面放置一个中间对象,充当外观。

在这个布局中,外观充当中心或单一参考点。每次新对象想要连接到另一个对象时,它不需要为所有可能的 N 个对象创建 N 个接口(需要 O(N²) 的总连接),它只需与外观进行通信,然后外观会相应地重定向请求。外观后面的所有内容对外部对象来说都是完全透明的。

除了主要和明显的益处(对象的解耦)之外,这种模式还鼓励设计更简单,接口更少,封装性更好的设计。

这是一个我们可以用来改进领域问题的代码,也可以用来创建更好的 API 的模式。如果我们使用这个模式并提供一个单一接口,作为我们代码的单一点真实或入口点,那么用户与暴露的功能交互将会容易得多。不仅如此,通过暴露功能并隐藏在接口背后的所有内容,我们可以自由地更改或重构底层代码,因为我们可以在外观后面进行更改,而不会破坏向后兼容性,用户也不会受到影响。

注意,使用外观模式的想法不仅限于对象和类,也适用于包(技术上,包在 Python 中是对象,但仍然如此)。我们可以使用外观模式来决定包的布局;也就是说,用户可以看到什么,可以导入什么,以及内部的内容不应该直接导入。

当我们创建一个目录来构建一个包时,我们将__init__.py文件与其他文件一起放置。这是模块的根,一种门面。其余的文件定义了要导出的对象,但它们不应该被客户端直接导入。__init__.py文件应该导入它们,然后客户端应该从那里获取它们。这创建了一个更好的接口,因为用户只需要知道一个单一的入口点来获取对象,更重要的是,包(其余的文件)可以根据需要多次重构或重新排列,只要init文件上的主要 API 保持不变。牢记这样的原则对于构建可维护的软件至关重要。

在 Python 本身中就有这样一个例子,即os模块。该模块将操作系统的功能分组,但在其下面,它使用posix模块为可移植操作系统接口POSIX)操作系统(在 Windows 平台上称为nt)。想法是,出于可移植性的原因,我们永远不应该直接导入posix模块,而应该总是导入os模块。这个模块负责确定它被哪个平台调用,并公开相应的功能。

行为模式

行为模式旨在解决对象应该如何协作、如何通信以及它们在运行时应该有什么接口的问题。

我们主要讨论以下行为模式:

  • 责任链模式

  • 模板方法

  • 命令

  • 状态

这可以通过继承或使用组合动态地完成。无论该模式使用什么,在接下来的示例中,我们将看到这些模式共有的特点是,结果代码在某些重要方面有所改进,无论是避免重复还是创建良好的抽象来相应地封装行为并解耦我们的模型。

责任链模式

现在,我们将再次审视我们的事件系统。我们希望从日志行(例如,从我们的 HTTP 应用程序服务器导出的文本文件)中解析系统上发生的事件的信息,并且我们希望以方便的方式提取这些信息。

在我们之前的实现中,我们实现了一个符合开闭原则的有趣解决方案,它依赖于使用__subclasses__()魔法方法来发现所有可能的事件类型,并使用正确的事件处理数据,通过每个类封装的方法来解决问题。

这个解决方案符合我们的需求,并且相当灵活,但正如我们将看到的,这种设计模式将带来额外的优势。

这里的想法是我们将以稍微不同的方式创建事件。每个事件仍然具有确定是否可以处理特定日志行的逻辑,但它还将有一个successor。这个successor是一个新的事件,是队列中的下一个事件,如果第一个事件无法处理文本行,它将继续处理文本行。逻辑很简单——我们链式地连接事件,每个事件都尝试处理数据。如果它可以,它就返回结果。如果它不能,它将传递给它的successor并重复,如下面的代码所示:

import re
from typing import Optional, Pattern
class Event:
    pattern: Optional[Pattern[str]] = 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 is not None and cls.pattern.match(logline) is not None
        )
    @classmethod
    def _parse_data(cls, logline: str) -> dict:
        if not cls.pattern:
            return {}
        if (parsed := cls.pattern.match(logline)) is not None:
            return parsed.groupdict()
        return {}
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()方法具有相同的逻辑;它试图提取提供的数据是否适合处理该对象类型的正确信息,如果不是,它将移动到队列中的下一个。

这样,我们可以按照以下方式处理登录事件:

>>> 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 = SessionEvent(LoginEvent(LogoutEvent())) 

通过改变顺序,例如,我们可以指定通用会话事件比登录有更高的优先级,但不是登出,等等。

事实上,这个模式与对象一起工作使其比我们之前的实现更加灵活,我们之前的实现依赖于类(虽然在 Python 中它们仍然是对象,但它们并没有完全排除一定程度上的刚性)。

模板方法

模板方法模式是一种在正确实现时能带来重要益处的模式。主要的好处是它允许我们重用代码,并且它还使我们的对象更加灵活,更容易更改,同时保持多态性。

理念是存在一个定义某些行为的类层次结构,比如说其公共接口中的一个重要方法。层次结构中的所有类共享一个公共模板,可能只需要更改其某些元素。因此,理念是将这种通用逻辑放在父类的公共方法中,该方法将内部调用所有其他(私有)方法,而这些方法正是派生类将要修改的;因此,模板中的所有逻辑都得到了重用。

聪明的读者可能已经注意到,我们在前面的章节中已经实现了这个模式(作为责任链模式示例的一部分)。请注意,从Event派生出的类在其特定模式中只实现了一件事。对于其余的逻辑,模板位于Event类中。process事件是通用的,依赖于两个辅助方法:can_process()process()(它反过来调用_parse_data())。

这些额外的方法依赖于类属性模式。因此,为了扩展这个模式以支持新的对象类型,我们只需创建一个新的派生类并将正则表达式放置其中。之后,其余的逻辑将通过这个新的属性继承过来。这重用了大量的代码,因为处理日志行的逻辑在父类中只定义了一次。

这使得设计更加灵活,因为保持多态性也容易实现。如果我们需要一种新的事件类型,由于某种原因需要以不同的方式解析数据,我们只需在那个子类中重写这个私有方法,并且兼容性将得到保持,只要它返回与原始类型相同的东西(符合 Liskov 替换原则和开闭原则)。这是因为父类正在调用派生类的方法。

如果我们在设计自己的库或框架,这种模式同样很有用。通过这种方式安排逻辑,我们使用户能够非常容易地改变其中一个类的行为。他们需要创建一个子类并重写特定的私有方法,结果将是一个具有新行为的新对象,这个新行为将保证与原始对象的先前调用者兼容。

命令

命令模式为我们提供了将需要执行的操作与其请求时刻的实际执行时刻分离的能力。不仅如此,它还可以将客户端发出的原始请求与其接收者分离,接收者可能是一个不同的对象。在本节中,我们将主要关注模式的第一个方面:我们可以将命令的执行方式与其实际执行时刻分离的事实。

我们知道可以通过实现__call__()魔法方法来创建可调用的对象,所以我们只需初始化对象,然后稍后调用它。实际上,如果这是唯一的要求,我们甚至可以通过一个嵌套函数来实现这一点,该函数通过闭包创建另一个函数,以达到延迟执行的效果。但这个模式可以被扩展到更难以实现的目的。

这个想法是命令也可能在其定义之后被修改。这意味着客户端指定要运行的命令,然后其中的一些参数可能会被更改,添加更多选项,等等,直到有人最终决定执行动作。

这种例子可以在与数据库交互的库中找到。例如,在psycopg2(一个PostgreSQL客户端库)中,我们建立了一个连接。从这一点,我们得到一个游标,并且可以向这个游标传递一个SQL语句来执行。当我们调用execute方法时,对象的内部表示会发生变化,但实际上在数据库中并没有执行任何操作。只有在调用fetchall()(或类似的方法)时,数据才会被查询并可在游标中获取。

在流行的对象关系映射器 SQLAlchemyORM SQLAlchemy)中,同样会发生这种情况。查询是通过几个步骤定义的,一旦我们有了query对象,我们仍然可以与之交互(添加或删除过滤器、更改条件、申请排序等),直到我们决定需要查询的结果。在调用每个方法后,query对象会改变其内部属性并返回self(自身)。

这些是我们想要实现的行为的类似例子。创建这种结构的一个非常简单的方法是拥有一个对象,该对象存储要运行的命令的参数。之后,它还必须提供与这些参数交互的方法(添加或删除过滤器等)。可选地,我们可以向该对象添加跟踪或日志功能以审计正在进行的操作。最后,我们需要提供一个实际执行动作的方法。这个方法可以是__call__()或自定义的。让我们称它为do()

当我们处理异步编程时,这个模式可能很有用。正如我们所看到的,异步编程有语法上的细微差别。通过将命令的准备与执行分离,我们可以使前者仍然具有同步形式,而后者具有异步语法(假设这部分需要异步运行,例如,如果我们正在使用库连接到数据库)。

状态

状态模式是软件设计中具体化的一个明显例子,它将我们的领域问题的概念明确化为一个对象,而不是仅仅是一个辅助值(例如,使用字符串或整数标志来表示值或管理状态)。

第八章单元测试和重构中,我们有一个表示merge请求的对象,并且它有一个与之关联的状态(例如openclosed等)。我们使用枚举来表示这些状态,因为那时它们只是持有值的(特定状态的字符串表示)数据。如果它们需要具有某些行为,或者整个merge请求必须根据其状态和转换执行某些操作,这就不够了。

我们向代码的一部分添加行为、运行时结构的事实,迫使我们从对象的角度思考,因为毕竟这就是对象应该做的事情。现在,状态不能仅仅是一个带有字符串的枚举;它需要是一个对象。

假设我们必须向merge请求添加一些规则,比如当它从open转换为closed时,所有批准都会被移除(他们将不得不再次审查代码)——并且当merge请求刚刚打开时,批准的数量设置为零(无论它是重新打开的还是全新的merge请求)。另一个规则可能是,当merge请求合并时,我们希望删除源分支,当然,我们希望禁止用户执行无效的转换(例如,已关闭的merge请求不能合并,等等)。

如果我们将所有这些逻辑放入一个地方,即在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):
        """Current state."""
    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):
        """Current state."""
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}" 

以下列表概述了一些关于实现细节和应做出的设计决策的澄清:

  • state是一个属性,因此它不仅是public的,而且还有一个单独的地方定义了如何为merge请求创建状态,传递self作为参数。

  • 抽象基类不是必需的,但拥有它有一些好处。首先,它使我们所处理的对象类型更加明确。其次,它强制每个子状态实现接口的所有方法。有另外两种选择:

    • 我们本可以不编写这些方法,当尝试执行无效操作时让AttributeError抛出,但这并不正确,并且它没有表达出实际发生的情况。

    • 与这一点相关的是,我们本可以使用一个简单的基类并留那些方法为空,但这样默认的不执行任何操作的行为并没有使它更清楚应该发生什么。如果子类中的某个方法应该不执行任何操作(例如合并的情况),那么最好让空方法就那样放着,并明确指出对于那个特定的情况,不应该执行任何操作,而不是强迫所有对象执行那个逻辑。

  • MergeRequestMergeRequestState之间存在关联。一旦发生转换,前一个对象将不会有额外的引用并且应该被垃圾回收,因此这种关系应该是始终为 1:1。经过一些小的和更详细的考虑,可以使用弱引用。

以下代码展示了如何使用对象的示例:

>>> mr = MergeRequest("develop", "mainline") 
>>> mr.open()
>>> mr.approvals
0
>>> mr.approvals = 3
>>> mr.close()
>>> mr.approvals
0
>>> mr.open()
INFO:log:reopening closed merge request mainline:develop
>>> mr.merge()
INFO:log:merging mainline:develop
INFO:log:deleting branch develop
>>> mr.close()
Traceback (most recent call last):
...
InvalidTransitionError: already merged request 

转换状态的操作委托给state对象,这是MergeRequest始终持有的(这可以是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) -> MergeRequestState:
        return self._state
    @state.setter
    def state(self, new_state_cls: Type[MergeRequestState]):
        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类型的对象

但在任何情况下都不应该返回NoneNone这个短语并不代表刚刚发生的事情,调用者可能会合法地尝试调用它的方法,这将导致AttributeError

我们之前已经讨论过异常及其优缺点,因此我们应该提到,这个null对象应该只拥有与原始用户相同的方法,并且对每一个方法都不做任何事情。

使用这种结构的优点是,我们不仅避免了运行时错误,而且这个对象可能是有用的。它可以使代码更容易测试,甚至可以,例如,帮助调试(我们可能需要在方法中添加日志记录,以了解为什么达到那种状态,提供了哪些数据,等等)。

通过利用 Python 几乎所有的魔法方法,我们可以创建一个通用的null对象,它绝对什么也不做,无论怎样调用它,但可以从几乎任何客户端调用。这样的对象会稍微类似于一个Mock对象。由于以下原因,不建议走这条路:

  • 它与领域问题失去了意义。回到我们的例子中,有一个UnknownUser类型的对象是有意义的,并且给调用者一个清晰的印象,即查询出了问题。

  • 它不尊重原始接口。这是问题所在。记住,目标是UnknownUser是一个用户,因此它必须具有相同的方法。如果调用者意外地请求一个不存在的方法,那么在这种情况下,它应该抛出一个AttributeError异常,这将很好。如果我们选择创建一个具有spec=UserMock对象,那么这种异常就会被捕获,但再次强调,使用Mock对象来表示实际上是一个空状态,这与我们提供清晰、易懂代码的意图不符。

这种模式是一种良好的实践,它允许我们在对象中保持多态性。

关于设计模式的最后思考

我们已经看到了 Python 中的设计模式世界,在这个过程中,我们找到了解决常见问题的方法,以及更多有助于我们实现清晰设计的技巧。

所有这些都听起来很好,但它提出了一个问题:设计模式有多好?有些人认为它们弊大于利,认为它们是为那些有限类型系统(以及缺乏一等函数)的语言而创建的,这使得我们无法完成在 Python 中通常能做的事情。另一些人声称,设计模式强迫采用一种设计方案,从而产生一些偏见,限制了本应出现的、更好的设计方案。让我们逐一审视这些观点。

模式对设计的影响

一个设计模式本身不能说是好是坏,而是取决于它的实现方式或使用方式。在某些情况下,当有更简单的解决方案时,并不需要设计模式。试图将模式强加在不适合的地方,就是过度设计,这显然是错误的,但这并不意味着设计模式本身有问题,而且在这些场景中,问题甚至可能根本与模式无关。有些人试图过度设计一切,因为他们不理解灵活和适应性软件的真正含义。

正如我们在本书前面提到的,编写好软件不是关于预测未来需求(进行未来学是没有意义的),而是仅仅解决我们现在手头的问题,以一种不会阻止我们在未来对其进行更改的方式。它现在不需要处理这些变化;它只需要足够灵活,以便将来可以进行修改。当未来来临时,我们仍然需要记住在提出通用解决方案或适当的抽象之前,必须记住三个或更多相同问题的规则。

这通常是设计模式应该出现的地方,一旦我们正确地识别了问题,能够识别模式并相应地进行抽象。

让我们回到模式对语言的适用性这个话题。正如我们在本章引言中所说,设计模式是高级思想。它们通常指的是对象及其交互的关系。很难想象这些事情可能会从一个语言消失到另一个语言。

诚然,有些模式在 Python 中可能需要更少的工作,比如迭代器模式(正如在本书前面详细讨论的那样,它是 Python 内置的),或者策略模式(因为,相反,我们只需像传递任何其他常规对象一样传递函数;我们不需要将策略方法封装到对象中,因为函数本身就是这个对象)。

但其他模式实际上是需要的,并且它们确实解决了问题,就像装饰器和组合模式的情况一样。在其他情况下,Python 本身实现了某些设计模式,我们只是没有总是看到它们,就像我们在本章前面讨论的外观模式一样。

至于我们的设计模式可能将我们的解决方案引向错误的方向,我们必须在这里小心。再一次,如果我们从考虑领域问题并创建正确的抽象开始设计我们的解决方案,然后看看是否有一个设计模式从那个设计中浮现出来,那就更好了。假设它确实如此。这是坏事吗?我们试图解决的问题已经有了现成的解决方案,这并不是坏事。在我们这个领域,重新发明轮子的情况很多,这是坏事。此外,我们正在应用一个模式,一个已经被证明和验证过的模式,这应该让我们对我们所构建的质量更有信心。

设计模式作为理论

我认为设计模式是一种软件工程理论。虽然我同意代码越自然地演变越好这个观点,但这并不意味着我们应该完全忽视设计模式。

设计模式之所以存在,是因为没有必要重新发明轮子。如果已经有一个针对特定类型问题的解决方案,那么在我们规划设计时思考这个想法可以节省我们一些时间。从这个意义上讲(以及为了重新引用第一章中的类比),我喜欢将设计模式类比为棋局的开局:职业棋手在游戏的早期阶段不会考虑每一个组合。这是理论。它已经被研究了。这与数学或物理公式是一样的。你应该第一次就深入理解它,知道如何推断它,并吸收其含义,但之后就没有必要反复发展这个理论了。

作为软件工程的从业者,我们应该利用设计模式的理论来节省脑力并更快地提出解决方案。不仅如此,设计模式应该成为不仅是语言,也是构建模块。

模型中的名称

我们是否应该在代码中提及我们正在使用设计模式?

如果设计良好且代码整洁,它应该能够自我表达。出于以下几个原因,不建议你根据你正在使用的设计模式来命名:

  • 我们代码的使用者和其他开发者不需要了解代码背后的设计模式,只要它能按预期工作即可。

  • 陈述设计模式破坏了意图揭示原则。将设计模式的名称添加到类中会使它失去部分原始含义。如果一个类代表一个查询,它应该被命名为QueryEnhancedQuery,这应该揭示该对象预期要做什么。EnhancedQueryDecorator没有任何意义,而Decorator后缀比清晰度更造成混淆。

在文档字符串中提及设计模式可能是可以接受的,因为它们充当文档,在我们的设计中表达设计思想(再次,沟通)是好事。然而,这并不是必需的。尽管如此,大多数时候,我们并不需要知道设计模式的存在。

最好的设计是那些设计模式对用户完全透明的。一个例子是外观模式在标准库中的出现,它使得用户对如何访问os模块的方式完全透明。一个更加优雅的例子是迭代器设计模式被语言完全抽象化,以至于我们甚至不需要考虑它。

摘要

设计模式一直被视为解决常见问题的有效方案。这是一个正确的评估,但在这章中,我们从良好的设计技术角度探讨了它们,这些模式利用了整洁的代码。在大多数情况下,我们研究了它们如何提供良好的解决方案来保持多态性、减少耦合,并创建所需的正确抽象来封装细节——所有这些都与第八章中探讨的概念相关,即单元测试和重构。

尽管如此,设计模式最吸引人的地方并不是应用它们所能获得的整洁设计,而是扩展的词汇量。作为沟通工具,我们可以用它们的名称来表达我们设计的意图。有时,我们并不需要应用整个模式,而可能只需要从我们的解决方案中提取模式的一个特定想法(例如,一个子结构),在这里,它们也证明是更有效地沟通的一种方式。

当我们以模式为思考方式来创建解决方案时,我们是在更高层次上解决问题。以设计模式为思考方式使我们更接近高级设计。我们可以逐渐“缩小”视野,更多地从架构的角度思考。既然我们现在正在解决更普遍的问题,那么是时候开始思考系统将如何长期演变和维护了(如何扩展、变化、适应等等)。

为了实现这些目标,一个软件项目需要在核心上拥有整洁的代码,同时架构也必须是整洁的,这就是我们将在下一章中探讨的内容。

参考文献

这里有一份您可以参考的信息列表:

  • GoF:由艾里希·伽玛理查德·赫尔姆拉尔夫·约翰逊约翰·弗利西斯合著的《设计模式:可复用面向对象软件元素》一书

  • SNGMONO:由罗伯特·C·马丁于 2002 年撰写的一篇文章,名为单例和单态staff.cs.utu.fi/~jounsmed/doos_06/material/SingletonAndMonostate.pdf

  • 空对象模式,由博比·伍尔夫撰写

第十章:清洁架构

在本章的最后,我们关注的是整个系统设计中的所有部分是如何相互配合的。这更偏向于理论性的章节。鉴于主题的性质,深入探讨更底层的细节会过于复杂。此外,目的正是要避开这些细节,假设在前几章中探索的所有原则都已吸收,并专注于大规模系统的设计。

本章的主要目标如下:

  • 设计长期可维护的软件系统

  • 通过维护质量属性有效地进行软件项目工作

  • 研究所有应用于代码的概念如何与系统总体相关联

本章探讨了清洁代码如何演变成清洁架构,反之亦然,清洁代码也是良好架构的基石。一个软件解决方案如果具有质量,则是有效的。架构需要通过实现质量属性(性能、可测试性、可维护性等)来实现这一点。但代码也需要在每个组件上实现这一点。

第一部分首先探讨代码与架构之间的关系。

从清洁代码到清洁架构

本节讨论了在前几章中强调的概念,当我们考虑大型系统的方面时,它们以略微不同的形式再次出现。这与适用于更详细设计以及代码的概念也适用于大型系统和架构有有趣的相似之处。

在前几章中探讨的概念与单一应用程序相关,通常是一个项目,可能是一个源代码版本控制系统(Git)的单个存储库(或几个)。这并不是说那些设计理念只适用于代码,或者它们在考虑架构时没有用处,原因有两个:代码是架构的基础,而且如果代码编写不仔细,无论架构考虑得多周全,系统都会失败。

其次,一些在前几章中提到的原则不仅适用于代码,而是设计理念。最明显的例子来自设计模式。它们是高级抽象。有了它们,我们可以快速了解我们的架构中某个组件可能的样子,而不必深入了解代码的细节。

但大型企业系统通常由许多这样的应用程序组成,现在是时候开始从更大设计的角度思考,即分布式系统。

在接下来的章节中,我们将讨论本书中已经讨论过的主要主题,但现在是从整个系统的角度来考虑。

如果软件架构有效,那么它就是好的。在好的架构中,最常见的方面是所谓的质量属性(如可扩展性、安全性、性能和耐用性是最常见的)。这很有道理;毕竟,你希望你的系统能够处理负载的增加而不会崩溃,并且能够在不需要维护的情况下连续工作不定期的时间,同时也能够扩展以支持新的需求。

但架构的操作方面也使其变得清晰。如可操作性、持续集成以及发布变更的难易程度等因素也会影响系统的整体质量。

关注点分离

在一个应用程序内部,存在多个组件。它们的代码被划分为其他子组件,如模块或包,模块被划分为类或函数,类被划分为方法。在整个书中,我们一直强调将这些组件保持尽可能小,尤其是在函数的情况下——函数应该只做一件事,并且要小。

提出了几个理由来证明这一论点。小的函数更容易理解、跟踪和调试。它们也更容易测试。我们代码中的块越小,编写单元测试就越容易。

对于每个应用程序的组件,我们希望有不同的特性,主要是高内聚和低耦合。通过将组件划分为更小的单元,每个单元都拥有单一且定义明确的职责,我们实现了更好的结构,使得变更更容易管理。面对新的需求时,将只有一个正确的地方进行变更,其余的代码可能不会受到影响。

当我们谈论代码时,我们用“组件”来指代这些内聚单元之一(例如,它可能是一个类)。在谈论架构时,组件意味着系统中任何可以被视为工作单元的东西。组件这个术语本身相当模糊,因此在软件架构中并没有一个普遍接受的定义来更具体地说明这意味着什么。工作单元的概念可能因项目而异。组件应该能够独立于系统其余部分进行发布或部署。

对于 Python 项目来说,一个组件可能是一个包,但一个服务也可以是一个组件。注意,两种不同概念,不同粒度级别,可以被视为同一类别。以一个例子来说明,我们在前几章中使用的事件系统可以被视为一个组件。它们是一个具有明确目的的工作单元(用于丰富从日志中识别的事件)。它们可以独立于其他部分部署(无论是作为 Python 包,还是如果我们公开其功能,作为服务;关于这一点稍后还会详细介绍),并且它们是整个系统的一部分,但不是整个应用程序本身。

在前几章的示例中,我们看到了惯用的代码,我们也强调了良好设计对我们代码的重要性,具有单一、明确职责的对象被隔离、正交,并且更容易维护。这个适用于详细设计(函数、类、方法)的标准,同样适用于软件架构的组件。

在考虑大局时,请记住良好的设计原则。

对于一个大型系统来说,仅仅是一个组件可能是不理想的。单体应用程序将作为系统的单一真相来源,负责系统中的所有内容,这将带来许多不希望看到的结果(更难隔离和识别变更,难以有效测试,等等)。

同样,如果我们不小心将所有内容都放在一个地方,我们的代码将更难维护,如果应用程序的组件没有得到同样的关注,它将面临类似的问题。

在系统中创建内聚组件的想法可以有不止一种实现方式,这取决于我们所需的抽象级别。

一个选择是将可能被多次重用的通用逻辑识别出来,并将其放置在一个 Python 包中(我们将在本章后面讨论细节)。

另一个选择是将应用程序分解成多个更小的服务,采用微服务架构。其理念是拥有单一且定义明确的职责的组件,并通过这些服务之间的合作和信息交换来实现与单体应用程序相同的功能。

单体应用程序和微服务

上一节最重要的观点是关注点的分离概念:不同的职责应该分布在不同的组件中。正如在我们的代码(更详细的设计级别)中,不应该有一个知道所有东西的巨大对象一样,在我们的架构中,不应该有一个拥有所有东西的单个组件。

然而,有一个重要的区别。不同的组件不一定意味着不同的服务。可以将应用程序划分为更小的 Python 包(我们将在本章后面讨论打包),并创建一个由许多依赖项组成的单一服务。

将职责分离到不同的服务中是一个好主意,它带来了一些好处,但也伴随着成本。

如果有代码需要在多个其他服务中重用,一个典型的做法是将这部分代码封装成一个微服务,以便公司内的其他许多服务调用。但这并不是重用代码的唯一方法。考虑将这种逻辑打包成库,以便其他组件导入。当然,这只有在所有其他组件都使用相同语言的情况下才可行;否则,是的,微服务模式是唯一的选择。

微服务架构具有完全解耦的优势:不同的服务可以用不同的语言或框架编写,甚至可以独立部署。它们也可以单独进行测试。这也有代价。它们还需要强大的客户端合同来了解如何与该服务交互,并且它们也分别受到服务级别协议SLAs)和服务级别目标SLOs)的约束。

它们也会产生更高的延迟:需要调用外部服务来获取数据(无论是通过 HTTP 还是 gRPC)都会对整体性能产生影响。

由较少服务组成的程序更加僵化,无法独立部署。它甚至可能更加脆弱,因为它可能成为单一故障点。另一方面,它可能更有效率(因为我们避免了昂贵的 I/O 调用),并且我们可以通过使用 Python 包来实现良好的组件分离。

本节的思考点是考虑在创建新服务或使用 Python 包之间选择合适的架构风格。

抽象

这就是封装再次出现的地方。当我们谈到我们的系统(就像我们对代码所做的那样)时,我们希望用领域问题的术语来谈论,并尽可能隐藏实现细节。

就像代码必须具有表现力(几乎达到自文档化的程度)并具有正确的抽象来揭示基本问题的解决方案(最小化意外复杂性)一样,架构应该告诉我们系统是关于什么的。诸如用于在磁盘上持久化数据的解决方案、选择的 Web 框架、用于连接外部代理的库以及系统之间的交互等细节并不相关。相关的是系统做什么。一个如尖叫架构(SCREAM)这样的概念反映了这一想法。

依赖倒置原则DIP),在第四章“SOLID 原则”中解释,在这方面非常有帮助;我们不想依赖于具体的实现,而是抽象。在代码中,我们在边界处放置抽象(或接口),即依赖项,那些我们无法控制且可能在未来发生变化的程序部分。我们这样做是因为我们想要反转依赖关系,并让它们适应我们的代码(通过必须遵守接口),而不是反过来。

创建抽象和反转依赖是良好的实践,但它们还不够。我们希望我们的整个应用程序独立且与不受我们控制的事物隔离。而且这甚至比仅仅用对象进行抽象还要更进一步——我们需要抽象层。

这与详细设计相比是一个微妙但重要的区别。在依赖倒置原则(DIP)中,建议创建一个接口,该接口可以用标准库中的abc模块实现,例如。因为 Python 使用鸭子类型,虽然使用抽象类可能会有所帮助,但它不是强制性的,只要它们符合所需接口,我们就可以轻松地用常规对象实现相同的效果。

Python 的动态类型特性允许我们有这些替代方案。从架构的角度思考,没有这样的东西。随着以下示例的进一步说明,我们需要完全抽象依赖项,Python 没有为我们做到这一点的特性。

有些人可能会争辩:“嗯,对象关系映射器(ORM)是数据库的一个很好的抽象,不是吗?”不。ORM 本身是一个依赖项,因此它不受我们控制。创建一个介于 ORM API 和我们的应用程序之间的中间层,一个适配器,会更好。

这意味着我们不仅仅用对象关系映射器(ORM)来抽象数据库;我们使用我们在其之上创建的抽象层来定义属于我们领域自己的对象。如果这个抽象恰好使用 ORM 作为底层,那只是一个巧合;领域层(我们的业务逻辑所在)不应该关心它。

我们自己的抽象给了我们更多的灵活性和对应用程序的控制。我们甚至可能后来决定我们根本不需要 ORM(比如说,因为我们想更多地控制我们使用的数据库引擎),如果我们把应用程序与特定的 ORM(或任何库)耦合起来,将来改变这一点会更难。想法是隔离我们应用程序的核心,使其不受我们无法控制的依赖项的影响。

应用程序随后导入这个组件,并使用这一层提供的实体,但反之则不然。抽象层不应该了解我们应用程序的逻辑;甚至更确切地说,数据库不应该了解应用程序本身。如果是那样的话,数据库就会与我们的应用程序耦合。目标是反转依赖关系——这一层提供了一个 API,任何想要连接的存储组件都必须符合这个 API。这是六边形架构(HEX)的概念。

在下一节中,我们将分析具体工具,这些工具将帮助我们创建用于我们架构的组件。

软件组件

我们现在有一个庞大的系统,我们需要对其进行扩展。它还必须易于维护。在这个阶段,问题不仅仅是技术性的,还包括组织性的。这意味着这不仅仅是管理软件仓库;每个仓库很可能会属于一个应用程序,并且将由拥有该系统部分的所有团队进行维护。

这要求我们牢记如何将大型系统划分为不同的组件。这可以有许多阶段,从创建 Python 包的非常简单的方法,到微服务架构中的更复杂场景。

当涉及不同的语言时,情况可能会更加复杂,但在这个章节中,我们将假设它们都是 Python 项目。

这些组件需要相互交互,就像团队一样。要在规模上有效工作,唯一的方法是所有部分都同意一个接口,一个合同。

Python 包是分发软件和以更通用方式重用代码的便捷方式。构建好的包可以发布到工件仓库(如公司内部的 PyPi 服务器),然后其他需要这些包的应用程序将从中下载。

这种方法的动机有很多方面——它关乎代码的重用,也关乎概念完整性。

在这里,我们讨论将 Python 项目打包成可以在仓库中发布的基本知识。默认仓库可能是 PyPi(Python 包索引,pypi.org/),但也可能是内部的;或者自定义设置也可以使用相同的原理。

我们将模拟我们已经创建了一个小型库,并将使用它作为例子来回顾需要考虑的主要点。

除了所有可用的开源库之外,有时我们可能需要一些额外的功能——也许我们的应用程序反复使用特定的习语,或者非常依赖某个函数或机制,并且团队已经为这些特定需求设计了一个更好的函数。为了更有效地工作,我们可以将这个抽象放入库中,并鼓励所有团队成员使用它提供的习语,因为这样做将有助于避免错误并减少 bug。

这通常是你拥有某个服务及其客户端库时的情况。你不想让客户端直接调用你的 API,所以相反,你为他们提供一个客户端库。这个库的代码将被封装成 Python 包并通过内部包管理系统进行分发。

可能存在无限多的例子可以适应这种情况。也许应用程序需要提取大量的.tar.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/ 目录下。在目录下搜索有助于避免将超出项目范围之外的文件混合在一起,例如,意外发布测试或项目结构的损坏。

通过运行以下命令构建包,假设它在已安装依赖项的虚拟环境中运行:

python –m venv env
source env/bin/activate
$VIRTUAL_ENV/bin/pip install -U pip wheel
$VIRTUAL_ENV/bin/python setup.py sdist bdist_wheel 

这会将工件放置在 dist/ 目录中,从这里可以稍后将其发布到 PyPi 或公司的内部包仓库。

打包 Python 项目的关键点包括:

  • 测试和验证安装是否与平台无关,并且不依赖于任何本地设置(这可以通过将源文件放在 src/ 目录下实现)。这意味着构建的包不应依赖于您本地机器上的文件,并且在分发时(或在自定义目录结构中)将不可用。

  • 确保单元测试不是作为正在构建的包的一部分进行分发。这是针对生产的。在生产环境中运行的 Docker 镜像不需要非严格必需的额外文件(例如,固定装置)。

  • 分离依赖项——项目严格需要的运行内容与开发者需要的并不相同。

  • 创建最常需要的命令的入口点是个好主意。

setup.py文件支持多种其他参数和配置,并且可以以更复杂的方式受到影响。如果我们的包需要安装几个操作系统库,那么在setup.py文件中编写一些逻辑来编译和构建所需的扩展是个好主意。这样,如果出现问题,它将在安装过程的早期失败,如果包提供了一个有用的错误消息,用户将能够更快地修复依赖项并继续。

安装这样的依赖项是使应用程序无处不在且任何开发者都能轻松运行(无论他们选择什么平台)的另一个困难步骤。克服这个障碍的最佳方法是通过创建 Docker 镜像来抽象平台,正如我们将在下一节中讨论的那样。

管理依赖项

在描述我们将如何利用 Docker 容器交付我们的应用程序之前,重要的是要审视一个软件配置管理SCM)问题,即:我们如何列出应用程序的依赖项,以便它们是可重复的?

请记住,软件问题可能不仅来自我们的代码。外部依赖项也会影响最终交付。在任何时候,你都想了解所交付的完整包列表及其版本。这被称为基线。

理念是,如果在任何时候引入的依赖项给我们的软件带来了问题,你希望能够快速定位它。更重要的是,你还希望你的构建是可重复的:在所有其他内容不变的情况下,新的构建应该产生与上一个构建完全相同的工件。

软件通过遵循开发管道被交付到生产环境中。这个过程从第一个环境开始,然后在该环境中运行测试(集成、验收等),接着通过持续集成和持续部署,它将穿过管道的不同阶段(例如,如果你有一个 beta 测试环境,或者在生产之前有一个预生产环境)。

Docker 擅长确保沿管道移动的图像完全相同,但无法保证如果你再次通过管道运行相同的代码版本(例如,相同的git commit),你会得到相同的结果。这项工作是我们自己的,也是我们在本节中要探讨的。

假设我们的网络包的setup.py文件如下所示:

from setuptools import find_packages, setup

with open("README.rst", "r") as longdesc:
    long_description = longdesc.read()
install_requires = ["sanic>=20,<21"]
setup(
    name="web",
    description="Library with helpers for the web-related functionality",
    long_description=long_description,
    author="Dev team",
    version="0.1.0",
    packages=find_packages(where="src/"),
    package_dir={"": "src"},
    install_requires=install_requires,
) 

在这种情况下,只有一个依赖项(在 install_requires 参数中声明),它控制着一个版本区间。这通常是一个好的实践:我们希望至少使用特定版本的包,但我们也对不超过下一个主要版本感兴趣(因为主要版本可能包含向后不兼容的更改)。

我们这样设置版本是因为我们对我们依赖项的更新感兴趣(有像 Dependabot (dependabot.com/) 这样的工具,它可以自动检测依赖项的新版本发布,并可以打开一个新的 pull 请求),但我们仍然想了解任何给定时间安装的确切版本。

此外,我们还想跟踪完整的依赖项树,这意味着应该列出传递依赖项。

实现这一点的其中一种方法是通过使用 pip-tools (github.com/jazzband/pip-tools) 并编译 requirements.txt 文件。

策略是使用此工具从 setup.py 文件生成需求文件,如下所示:

pip-compile setup.py 

这将生成一个 requirements.txt 文件,我们将使用它来在 Dockerfile 中安装依赖项。

为了确保从版本控制的角度来看构建的可确定性,始终应从 requirements.txt 文件安装 Dockerfile 中的依赖项。

列出需求项的文件应置于版本控制之下,每次我们想要升级依赖项时,我们再次使用带有 –U 标志的命令,并跟踪需求文件的新版本。

列出所有依赖项不仅有利于可重复性,还能增加清晰度。如果你使用了许多依赖项,可能会出现版本冲突的情况,如果我们知道哪个包导入了哪个库(以及其版本),这将更容易被发现。但再次强调,这仅仅是问题的一部分。在处理依赖项时,我们还需要考虑更多因素。

管理依赖项时的其他考虑因素

默认情况下,在安装依赖项时,pip 将使用互联网上的公共仓库(pypi.org/)。也可以从其他索引或版本控制系统安装。

这有一些问题和局限性。首先,您将依赖于这些服务的可用性。还有这样的限制,您无法在公共仓库上发布包含您公司知识产权的内部软件包。最后,还有一个问题,我们并不真正确信一些作者在保持工件版本准确和安全方面的可靠性和可信度(例如,一些作者可能想要以相同的版本号重新发布不同版本的代码,这显然是错误的,也是不允许的,但所有系统都有缺陷)。我不记得在 Python 中遇到过这样的特定问题,但几年前我确实记得在 JavaScript 社区中发生过这样的事情,有人从 NPM 注册表中删除了一个软件包(REGISTER01),通过取消发布这个库,许多其他构建都失败了。即使 PyPi 不允许这样做,我们也不想受制于他人的善意(或恶意)。

解决方案很简单:您的公司必须有一个用于依赖项的内部服务器,并且所有构建都必须针对这个内部仓库。无论这是如何实现的(本地、云上、使用开源工具或外包给提供商),想法是必须将新的、所需的依赖项添加到这个仓库中,内部软件包也在这里发布。

确保这个内部仓库得到更新,并配置所有仓库在您的依赖项有新版本可用时接收升级。请记住,这也是技术债务的另一种形式。这里有几个原因。正如我们在前面的章节中讨论的那样,技术债务不仅仅是关于代码编写得不好。当新技术可用时,您会错过那些特性,这意味着您可能能够更好地利用可用的技术。更重要的是,软件包可能存在随着时间的推移而被发现的漏洞,因此您希望升级以确保您的软件得到修补。

使用过时的依赖项版本是另一种形式的技术债务。养成使用您依赖项最新版本的习惯。

在升级依赖项之前不要让太多时间过去,因为您等待的时间越长,追赶上就越困难。毕竟,这就是持续集成的全部意义:您希望以增量方式持续集成更改(包括新的依赖项),前提是您有作为构建一部分运行的自动化测试,并作为回归的安全网。

配置一个工具,它会自动为新版本的依赖项发送拉取请求,并配置对它们的自动安全检查。

这个工作流程应该需要最少的工作。想法是,你配置你的项目的 setup.py 文件以一系列版本,并拥有需求文件。当有新版本可用时,你为你的仓库配置的工具将重建需求文件,该文件将列出所有包及其新版本(这些新版本将显示在工具打开的 pull 请求的差异中)。如果构建是绿色的,且 pull 请求显示的差异中没有可疑之处,你可以继续进行 merge,相信持续集成已经捕获了问题。另一方面,如果构建失败,这将需要你介入调整。

工件版本

在稳定性和前沿软件之间有一个权衡。拥有最新版本通常是积极的,因为这意味着我们只需升级就能获得最新的特性和错误修复。这就是新版本不会带来不兼容的更改(缺点)。因此,软件以具有明确意义的版本进行管理。

当我们确定所需版本的范围时,我们希望获得升级,但同时又不要太激进,以免破坏应用程序。

如果我们只升级依赖项并编写新的需求文件版本,我们应该发布我们工件的新版本(毕竟,我们在交付新的东西,因此是不同的)。这可以是一个小版本或微版本,但重要的是我们必须遵守与第三方库发布我们自己的定制工件时相同的规则。

在 Python 中,PEP-440 (www.python.org/dev/peps/pep-0440/) 是一个很好的参考,它描述了如何在 setup.py 文件中设置我们库的版本号。

在下一节中,我们将探讨一种不同的技术,它也将帮助我们创建组件以交付我们的代码。

Docker 容器

本章专门讨论架构,因此“容器”一词指的是与第二章“Pythonic 代码”中探讨的 Python 容器(具有 __contains__ 方法的对象)完全不同的东西。容器是在操作系统下以具有某些限制和隔离考虑的组运行的过程。具体来说,我们指的是 Docker 容器,它允许将应用程序(服务或进程)作为独立组件进行管理。

容器代表了另一种软件交付的方式。创建考虑了上一节内容的 Python 包更适合用于库或框架,在这些场景中,目标是重用代码并利用将特定逻辑集中在一个地方的优势。

在容器的情况下,目标不是创建库而是应用程序(大多数时候)。然而,一个应用程序或平台并不一定意味着一个完整的服务。构建容器的想法是创建代表具有小而明确目的的服务的小型组件。

在本节中,当我们讨论容器时,我们会提到 Docker,并探讨如何为 Python 项目创建 Docker 镜像和容器的基础知识。请记住,这并不是将应用程序部署到容器中的唯一技术,而且它与 Python 完全独立。

Docker 容器需要有一个镜像来运行,而这个镜像是由其他基础镜像创建的。但我们创建的镜像本身也可以作为其他容器的基镜像。我们希望在应用程序中存在一个共同的基,可以在许多容器之间共享的情况下这样做。一个潜在的使用案例是创建一个基镜像,按照我们在上一节中描述的方式安装一个包(或多个),以及所有依赖项,包括操作系统级别的依赖项。正如在第九章“通用设计模式”中讨论的那样,我们创建的包不仅可能依赖于其他 Python 库,也可能依赖于特定的平台(特定的操作系统),以及在该操作系统中预先安装的特定库,没有这些库,包将无法安装并会失败。

容器是这一点的绝佳便携工具。它们可以帮助我们确保我们的应用程序将以标准化的方式运行,并且它们也将极大地简化开发过程(在各个环境中重现场景、复制测试、接纳新团队成员等)。

Docker 帮助避免平台依赖问题。其理念是将我们的 Python 应用程序打包成一个 Docker 容器镜像,这对于本地开发和测试以及在生产环境中部署我们的软件都非常有用。

通常,在过去,Python 的部署很困难,因为它的本质。由于它是一种解释型语言,你编写的代码将在生产主机上的 Python 虚拟机上运行。因此,你需要确保目标平台将具有你期望的解析器版本。此外,依赖项的打包也很困难:这是通过将所有内容打包到虚拟环境中并运行来完成的。如果你有平台相关的特定需求,并且一些依赖项使用了 C 扩展,事情会变得更复杂。而且我甚至还没有提到 Windows 或 Linux;有时,即使是不同的 Linux 版本(基于 Debian 与基于 Red Hat)也需要不同的 C 库版本,以便代码能够运行,所以唯一真正测试你的应用程序并确保它能够正确运行的方法是使用虚拟机,并针对正确的架构编译一切。在现代应用程序中,这些痛苦中的大多数都应该消失。现在你将在根目录中有一个Dockerfile,其中包含构建该应用程序的指令。并且你的应用程序在生产中也是通过在 Docker 中运行来交付的。

正如包是我们在代码中重用和统一标准的方式一样,容器代表了创建应用程序不同服务的方式。它们符合架构中关注点分离SoC)原则背后的标准。每个服务都是另一种类型的组件,它将独立于应用程序的其他部分封装一组功能。这些容器应该设计得有利于可维护性——如果责任划分清晰,服务中的任何更改都不应影响应用程序的任何其他部分。

我们将在下一节中介绍如何从 Python 项目创建 Docker 容器的基础知识。

用例

作为我们可能如何组织应用程序组件的例子,以及先前概念如何在实践中工作的例子,我们提供了一个以下简单的示例。

用例是这样的:有一个用于送餐的应用程序,并且这个应用程序有一个特定服务,用于跟踪每个配送在不同阶段的状况。我们将只关注这个特定的服务,而不管应用程序的其他部分可能看起来如何。这个服务必须非常简单——一个 REST API,当询问特定订单的状态时,将返回一个包含描述性信息的 JSON 响应。

我们将假设每个特定订单的信息存储在数据库中,但这个细节根本不重要。

我们的服务目前有两个主要关注点:获取有关特定订单的信息(无论这些信息可能存储在哪里),以及以有用的方式向客户端展示这些信息(在这种情况下,以 JSON 格式交付结果,作为 Web 服务公开)。

由于应用程序必须可维护和可扩展,我们希望尽可能隐藏这两个关注点,并专注于主要逻辑。因此,这两个细节被抽象化和封装到主应用程序将使用的 Python 包中,如图10.1所示:

图片

图 10.1:一个服务应用程序(命名为“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?不是 SQL?)或另一个服务(例如,网络)?假设它来自关系数据库。是否有任何线索表明如何检索这些信息(手动 SQL 查询?通过 ORM?)?

那网络呢?我们能猜出使用了哪些框架吗?

我们无法回答这些问题的事实可能是一个好兆头。这些都是细节,而细节应该被封装起来。除非我们查看那些包内部的内容,否则我们无法回答这些问题。

另一种回答上述问题的方法是以一个问题本身的形式出现:我们为什么需要知道这个?查看代码,我们可以看到有一个DeliveryOrder,它使用一个交付的标识符创建,并且它有一个get()方法,该方法返回表示交付状态的对象。如果所有这些信息都是正确的,那么这就是我们应该关心的全部。它如何完成有什么区别呢?

我们创建的抽象使我们的代码具有声明性。在声明式编程中,我们声明我们想要解决的问题,而不是我们想要如何解决它。这与命令式相反,在命令式中,我们必须明确所有必要的步骤才能得到某些结果(例如,连接到数据库,运行此查询,解析结果,将其加载到该对象中,等等)。在这种情况下,我们声明我们只想知道由某个标识符给出的交付状态。

这些包负责处理细节并以方便的格式呈现应用程序所需的内容,即前一部分中展示的那种类型的对象。我们只需要知道storage包包含一个对象,给定一个交付的 ID 和一个存储客户端(为了简单起见,这个依赖项被注入到这个例子中,但其他替代方案也是可能的),它将检索DeliveryOrder,然后我们可以要求它组合消息。

这种架构提供了便利,并使它更容易适应变化,因为它保护了业务逻辑的核心免受可能改变的外部因素的影响。

想象一下,如果我们想要改变信息检索的方式,那会难到什么程度?应用程序依赖于一个 API,如下所示:

dsq = DeliveryStatusQuery(int(delivery_id), await DBClient()) 

因此,这仅仅涉及到改变 get() 方法的工作方式,将其适配到新的实现细节。我们需要的只是这个新对象在其 get() 方法上返回 DeliveryOrder,这就足够了。我们可以更改查询、ORM、数据库等,在所有情况下,应用程序中的代码都不需要更改!

适配器

仍然,即使不查看包中的代码,我们也可以得出结论,它们作为应用程序技术细节的接口工作。

事实上,由于我们从高层次的角度看待应用程序,而不需要查看代码,我们可以想象在这些包内部必须有适配器设计模式的实现(在第九章 常见设计模式 中介绍)。一个或多个这些对象正在将外部实现适配到应用程序定义的 API。这样,想要与应用程序一起工作的依赖项必须遵守 API,并且必须创建一个适配器。

尽管如此,在应用程序的代码中有一个关于这个适配器的线索。注意视图是如何构建的。它继承自来自我们的 web 包的 View 类。我们可以推断出这个 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==0.1.0", "storage==0.1.0"]
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.9-slim-buster
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        python-dev \
        gcc \
        musl-dev \
        make \
    && rm -rf /var/lib/apt/lists/*
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 的轻量级 Linux 镜像构建的,然后安装了操作系统依赖项,以便我们可以部署我们的库。根据之前的考虑,这个Dockerfile只是简单地复制了库,但也可以相应地从requirements.txt文件中安装。在所有pip install命令准备就绪后,它将复制工作目录中的应用程序,并且 Docker 的入口点(CMD命令,不要与 Python 的混淆)调用我们放置函数启动进程的包的入口点。对于本地开发,我们仍然会使用Dockerfile,并结合一个包含所有服务定义(包括数据库等依赖项)、基础镜像以及它们如何链接和相互关联的docker-compose.yml文件。

现在我们已经启动了容器,我们可以启动它并在其上运行一个小测试,以了解其工作原理:

$ curl http://localhost:5000/status/1
{"id":1,"status":"dispatched","msg":"Order was dispatched on 2018-08-01T22:25:12+00:00"} 

让我们分析到目前为止看到的代码的架构特性,从下一节开始。

分析

从之前的实现中可以得出许多结论。虽然这可能看起来是一个好的方法,但随之而来的缺点与好处一样明显;毕竟,没有架构或实现是完美的。这意味着这种解决方案并不适用于所有情况,所以它将很大程度上取决于项目的环境、团队、组织等等。

虽然解决方案的主要思想是尽可能抽象细节,正如我们将看到的,有些部分不能完全抽象掉,而且各层之间的合同意味着抽象泄露。

最终,技术总是悄悄地渗透进来。例如,如果我们要把我们的实现从 REST 服务更改为通过 GraphQL 提供数据,我们就必须调整应用程序服务器的配置和构建方式,但即便如此,我们仍然应该能够拥有一个非常类似的前一个结构。即使我们想要进行更激进的改变,将我们的服务转变为 gRPC 服务器,我们当然被迫要调整一些粘合代码,但我们仍然应该尽可能多地使用我们的包。所需的变化应保持在最小。

依赖关系流

注意,随着它们接近位于业务规则所在的核心,依赖关系只流向一个方向。这可以通过查看import语句来追踪。应用程序从存储中导入它所需的一切,例如,在没有任何部分是这种反转的。

违反这条规则会创建耦合。现在代码的排列方式意味着应用程序和存储之间存在弱依赖。API 是这样的,我们需要一个具有get()方法的对象,任何想要连接到应用程序的存储都需要根据这个规范实现这个对象。因此,依赖关系被反转——每个存储都必须实现这个接口,以便创建一个符合应用程序期望的对象。

局限性

并不是所有东西都可以抽象化。在某些情况下,这是不可能的,在其他情况下,可能不方便。让我们从方便性方面开始。

在这个例子中,有一个将所选网络框架适配到干净 API 的适配器,以便向应用程序展示。在更复杂的场景中,这样的改变可能不可行。即使有了这种抽象,库的一部分仍然对应用程序可见。完全与网络框架隔离并不是一个完全的问题,因为迟早我们需要它的某些功能或技术细节。

这里重要的收获不是适配器,而是尽可能隐藏技术细节的想法。这意味着在应用程序代码列表上显示的最好的事情不是我们的网络框架版本和实际版本之间存在适配器的事实,而是后者在可见代码的任何部分都没有被提及。服务已经明确指出web只是一个依赖(一个被导入的细节)并揭示了其背后的意图。目标是揭示意图(如在代码中),尽可能推迟细节。

关于那些无法隔离的事物,它们是最接近代码的元素。在这种情况下,网络应用程序正在以异步方式使用它们内部的这些对象。这是一个我们无法规避的硬性约束。确实,storage 包内的任何内容都可以被更改、重构和修改,但无论这些修改可能是什么,它仍然需要保留接口,这包括异步接口。

测试性

再次,就像代码一样,架构可以从将部分分离成更小的组件中受益。依赖关系现在被隔离并由单独的组件控制,这使得主应用程序的设计更加清晰,现在更容易忽略边界,专注于测试应用程序的核心。

我们可以为依赖关系创建补丁,编写更简单的单元测试(它们不需要数据库),或者启动整个网络服务,例如。与纯domain对象一起工作意味着将更容易理解代码和单元测试。甚至适配器也不需要太多测试,因为它们的逻辑应该非常简单。

请记住第八章中提到的软件测试金字塔,即单元测试和重构。我们希望拥有大量的单元测试,然后是较少的组件测试,最后甚至更少的集成测试。将我们的架构分为不同的组件对于组件测试大有裨益:我们可以模拟我们的依赖关系,并单独测试一些组件。

这既便宜又快,但这并不意味着我们不应该进行任何集成测试。为了确保我们的最终应用程序按预期工作,我们需要进行集成测试,这将测试我们架构的所有组件(无论是微服务还是包),协同工作。

意图揭示

意图揭示是我们代码中的一个关键概念——每个名称都必须明智选择,清楚地传达它应该做什么。每个函数都应该讲述一个故事。我们应该保持函数简短,关注点分离,依赖关系隔离,并在代码的每个部分赋予抽象正确的含义。

良好的架构应该揭示它所包含系统的意图。它不应提及它所使用的工具;这些都是细节,正如我们详细讨论的那样,细节应该被隐藏和封装。

摘要

良好软件设计的原则适用于所有层次。正如我们希望编写可读的代码,并为此需要记住代码的意图揭示方面一样,架构也必须表达它试图解决的问题的意图。

所有这些想法都是相互关联的。确保我们的架构以领域问题定义的意图揭示同样也引导我们尽可能抽象细节,创建抽象层,反转依赖关系,并分离关注点。

当谈到代码重用时,Python 包是一个优秀且灵活的选择。在决定创建包时,最重要的考虑因素是内聚性和单一职责原则。与具有内聚性和较少职责的组件相一致,微服务概念应运而生,为此,我们已经看到如何从打包的 Python 应用程序开始,在 Docker 容器中部署一个服务。

就像软件工程中的所有事情一样,都有局限性和例外。我们不可能像我们希望的那样抽象出所有事物,或者完全隔离依赖。有时,可能根本不可能(或不切实际)遵守书中解释的原则。但读者可能应该从书中吸取的最佳建议是——它们只是原则,而不是法律。如果无法或实际上无法从框架中抽象出来,这不应该成为问题。记住,书中引用了Python 之禅本身——实用性胜于纯洁性

参考文献

这里有一些您可以参考的信息列表:

总结

本书的内容是一个参考,是一个通过遵循提到的标准来实现软件解决方案的可能方式。这些标准通过示例进行解释,并展示了每个决策的合理性。读者可能会非常不同意示例中采取的方法。

事实上,我鼓励你提出不同的意见:观点越多,辩论就越丰富。但无论意见如何,重要的是要明确,这里所呈现的内容绝对不是一项强制的指令,不是必须严格遵循的东西。恰恰相反;这是一种展示解决方案和一系列可能对你有帮助的想法的方式。

如开头所述,这本书的目标不是给你提供可以直接应用的食谱或公式,而是培养你的批判性思维。习语和语法特性会来来去去;它们会随时间而变化。但思想和核心软件概念是永恒的。有了这些工具和提供的例子,你应该对什么是干净的代码有更好的理解。

我真诚地希望这本书能帮助你成为比开始阅读时更好的开发者,并祝愿你在项目中取得巨大成功。

分享你的经验

感谢您抽出时间阅读这本书。如果您喜欢这本书,请帮助他人找到它。在以下链接留下评论:www.amazon.com/dp/1800560214

posted @ 2025-09-19 10:36  绝不原创的飞龙  阅读(38)  评论(0)    收藏  举报