Python 代码整洁之道(一)
原文:
zh.annas-archive.org/md5/164695888A8A98C80BA0F014DEE631C7译者:飞龙
前言
这是一本关于软件工程原则应用于 Python 的书。
关于软件工程有很多书籍,也有很多关于 Python 的信息资源。然而,这两个集合的交集需要行动,这就是这本书试图弥补的差距。
在一本书中涵盖软件工程的所有可能主题是不现实的,因为这个领域是如此广阔,以至于有整本书专门讨论某些主题。这本书侧重于软件工程的主要实践或原则,这些实践或原则将帮助我们编写更易于维护的代码,并且同时利用 Python 的特性来编写它。
智者之言:解决软件问题没有单一的解决方案。通常涉及权衡。每种解决方案都有优势和劣势,必须遵循一些标准来在它们之间进行选择,接受成本并获得收益。通常没有单一的最佳解决方案,但有原则可循,只要我们遵循这些原则,我们就会走上更安全的道路。这本书的目的就是激励读者遵循原则并做出最佳选择,因为即使面临困难,如果我们遵循良好的实践,我们也会好得多。
说到良好的实践,虽然一些解释遵循已建立和经过验证的原则,其他部分则是主观的。但这并不意味着只能以这种特定的方式来完成。作者并不声称自己是任何一种清洁代码的权威,因为这样的头衔根本不存在。鼓励读者进行批判性思考:采用最适合您项目的方法,并且可以自由地提出异议。鼓励不同的观点,只要它们能产生启发性的辩论。
我写这本书的目的是分享 Python 的乐趣,以及我从经验中学到的习惯用法,希望读者会发现它们对提高他们的语言专业知识有用。
这本书通过代码示例来解释主题。这些示例假定在撰写本文时使用的是 Python 的最新版本,即 Python 3.7,尽管未来版本也应该兼容。代码中没有任何将其绑定到特定平台的特殊性,因此使用 Python 解释器,可以在任何操作系统上测试代码示例。
在大多数示例中,为了尽可能保持代码简单,实现及其测试都是使用纯 Python 编写的,只使用标准库。在一些章节中,需要额外的库,并且为了运行这些案例的示例,已经提供了相应的requirements.txt文件的说明。
在本书中,我们将发现 Python 提供的所有功能,使我们的代码更好,更易读,更易于维护。我们不仅通过探索语言的特性来做到这一点,还通过分析软件工程实践如何应用于 Python 来实现。读者会注意到 Python 中一些参考实现的不同之处,其他原则或模式略有变化,有些甚至可能根本不适用。理解每种情况都代表着更深入地了解 Python。
这本书适合谁
这本书适合所有对软件设计感兴趣或想了解更多关于 Python 的软件工程从业者。假定读者已经熟悉面向对象软件设计原则,并具有一定的编写代码经验。
就 Python 而言,本书适合所有级别的读者。它适合学习 Python,因为它以递增的复杂性组织内容。最初的章节将涵盖 Python 的基础知识,这是学习主要习语、函数和语言中可用的实用程序的好方法。这个想法不仅仅是用 Python 解决一些问题,而是以一种惯用的方式来解决问题。
有经验的程序员也会从本书的主题中受益,因为一些部分涵盖了 Python 中的高级主题,如装饰器、描述符和异步编程的介绍。它将帮助读者更多地了解 Python,因为其中一些案例是从语言本身的内部分析的。
值得强调的是本节第一句中的“从业者”一词。这是一本务实的书。示例仅限于案例研究所需的内容,但也意在类似于真实软件项目的背景。这不是一本学术书,因此所做的定义、备注和建议应以谨慎的态度对待。读者应该以批判和务实的态度来审视这些建议,而不是教条地接受。毕竟,实用性胜过纯粹。
本书涵盖的内容
第一章《介绍、代码格式和工具》是介绍你需要设置 Python 开发环境的主要工具。我们涵盖了 Python 开发人员建议了解的基础知识,以及一些维护项目中可读代码的指南,如静态分析工具、文档、类型检查和代码格式化。
第二章《Pythonic Code》探讨了 Python 中的第一个习语,我们将在接下来的章节中继续使用。我们涵盖了 Python 的特定特性,以及它们应该如何使用,并开始建立围绕 Pythonic 代码通常更高质量的代码的知识。
第三章《良好代码的一般特征》回顾了专注于编写可维护代码的软件工程的一般原则。我们探讨了这个想法,并应用了语言中的工具。
第四章《SOLID 原则》涵盖了面向对象软件设计的一组设计原则。这个首字母缩略词是软件工程的术语或行话的一部分,我们看到它们每一个如何应用到 Python 中。由于语言的性质,可以说并非所有原则都完全适用。
第五章《使用装饰器改进我们的代码》探讨了 Python 最伟大的特性之一。在理解如何创建装饰器(用于函数和类)之后,我们将它们应用于重用代码、分离责任和创建更细粒度的函数。
第六章《使用描述符更充分地利用我们的对象》探讨了 Python 中的描述符,它将面向对象设计提升到一个新的水平。虽然这是一个更与框架和工具相关的特性,但我们可以看到如何通过描述符来提高我们代码的可读性,并重用代码。
第七章《使用生成器》表明生成器可能是 Python 最好的特性。迭代是 Python 的核心组件,这可能让我们认为它导致了一种新的编程范式。通过使用生成器和迭代器,我们可以思考我们编写程序的方式。通过生成器学到的经验,我们进一步学习了 Python 中的协程和异步编程的基础知识。
第八章,单元测试和重构,讨论了在任何声称可维护的代码库中进行单元测试的重要性。该章节审查了单元测试的重要性,我们探讨了主要的框架(unittest和pytest)。
第九章,常见设计模式,审查了如何在 Python 中实现最常见的设计模式,而不是从解决问题的角度,而是通过研究它们如何通过更好和更易维护的解决方案来解决问题。该章节提到了 Python 的一些特点,使得一些设计模式变得不可见,并采取了实用的方法来实现其中一些设计模式。
第十章,干净的架构,着重于干净的代码是良好架构的基础这一观念。我们在第一章提到的所有细节,以及沿途重新审视的一切,在系统部署时将在整个设计中发挥关键作用。
充分利用本书
读者应熟悉 Python 的语法,并安装有效的 Python 解释器,可从www.python.org/downloads/下载。
建议按照本书中的示例并在本地测试代码。为此,强烈建议使用 Python 3.7 创建虚拟环境,并使用该解释器运行代码。有关创建虚拟环境的说明,请访问docs.python.org/3/tutorial/venv.html。
下载示例代码文件
您可以从www.packtpub.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,文件将直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择 SUPPORT 选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名,然后按照屏幕上的指示操作。
文件下载完成后,请确保使用最新版本的解压缩软件解压缩文件夹:
-
Windows 系统使用 WinRAR/7-Zip
-
Mac 系统使用 Zipeg/iZip/UnRarX
-
Linux 系统使用 7-Zip/PeaZip
本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Clean-Code-in-Python。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还提供来自我们丰富书籍和视频目录的其他代码包,可在**github.com/PacktPublishing/**上查看!
使用的约定
本书中使用了许多文本约定。
CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:"然后,只需运行pylint命令就足以在代码中进行检查。"
代码块设置如下:
class Point:
def __init__(self, lat, long):
self.lat = lat
self.long = long
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
setup(
name="apptool",
description="Description of the intention of the package",
long_description=long_description,
任何命令行输入或输出都以以下方式书写:
>>> locate.__annotations__
{'latitude': float, 'longitue': float, 'return': __main__.Point}
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"从管理面板中选择系统信息。"
警告或重要提示会以这种方式出现。技巧和窍门会以这种方式出现。
第一章:介绍、代码格式化和工具
在本章中,我们将探讨与干净代码相关的第一个概念,从它是什么和意味着什么开始。本章的主要观点是要理解干净代码不仅仅是软件项目中的一件好事或奢侈品。这是必需的。没有质量的代码,项目将面临由于积累的技术债务而失败的危险。
沿着同样的思路,但更详细地讨论的是格式化和文档化代码的概念。这也可能听起来像是一个多余的要求或任务,但我们将发现它在保持代码库的可维护性和可操作性方面起着基本作用。
我们将分析采用良好的编码准则对该项目的重要性。意识到保持代码与参考一致是一项持续的任务,我们将看到如何从自动化工具中获得帮助,以简化我们的工作。因此,我们迅速讨论如何配置主要工具,以便它们作为构建的一部分自动运行在项目上。
阅读本章后,您将了解干净代码是什么,为什么它很重要,为什么格式化和文档化代码是关键任务,以及如何自动化这个过程。从中,您应该获得快速组织新项目结构的思维方式,以追求良好的代码质量。
阅读本章后,您将学到以下内容:
-
干净代码在软件构建中的真正意义远远重要于格式化
-
即使如此,拥有标准的格式化是软件项目中必须具备的关键组成部分,以确保其可维护性
-
如何通过使用 Python 提供的功能使代码自我记录
-
如何配置工具以帮助以一致的方式安排代码布局,以便团队成员可以专注于问题的本质。
干净代码的含义
没有干净代码的唯一或严格定义。此外,可能没有正式衡量干净代码的方法,因此您无法在存储库上运行工具,告诉您代码的好坏、可维护性或不可维护性。当然,您可以运行检查器、linter、静态分析器等工具。这些工具非常有帮助。它们是必需的,但不够。干净的代码不是机器或脚本可以告诉的东西(到目前为止),而是我们作为专业人士可以决定的东西。
几十年来,我们一直在使用编程语言这个术语,我们认为它们是用来向机器传达我们的想法,以便它可以运行我们的程序。我们错了。这不是真相,而是真相的一部分。编程语言背后的真正语言是将我们的想法传达给其他开发人员。
这就是干净代码的真正本质所在。它取决于其他工程师能够阅读和维护代码。因此,我们作为专业人士是唯一能够判断这一点的人。想想看;作为开发人员,我们花在阅读代码上的时间要比实际编写代码的时间多得多。每当我们想要进行更改或添加新功能时,我们首先必须阅读我们需要修改或扩展的代码周围的所有内容。语言(Python)是我们用来相互交流的工具。
因此,与其给您一个干净代码的定义(或我的定义),我邀请您阅读全书,了解有关惯用 Python 的所有内容,看到好代码和坏代码之间的区别,识别好代码和良好架构的特征,然后提出您自己的定义。阅读本书后,您将能够自行判断和分析代码,并对干净代码有更清晰的理解。您将知道它是什么和意味着什么,而不受任何给定的定义的影响。
拥有干净代码的重要性
清洁代码重要的原因有很多。其中大部分都围绕着可维护性、减少技术债务、有效地与敏捷开发合作以及管理成功的项目。
我想探讨的第一个想法是关于敏捷开发和持续交付。如果我们希望我们的项目能够以稳定和可预测的速度不断成功地交付功能,那么拥有一个良好且可维护的代码库是必不可少的。
想象一下,你正在驾驶一辆汽车沿着一条通往你想在某个时间点到达的目的地的道路行驶。你必须估计到达时间,这样你就可以告诉等待你的人。如果汽车运行良好,道路平坦完美,那么我不明白为什么你会大大地错过你的估计。现在,如果道路破损,你不得不下车移动路上的石头,或者避开裂缝,每隔几公里就停下来检查引擎,等等,那么你很可能不会确定你何时到达(或者是否会到达)。我认为这个类比很清楚;道路就是代码。如果你想以稳定、持续和可预测的速度前进,代码就需要是可维护和可读的。如果不是,每当产品管理要求新功能时,你都必须停下来重构和修复技术债务。
技术债务是指由于妥协和错误决策而导致软件中的问题的概念。在某种程度上,可以从现在到过去来思考技术债务。如果我们目前面临的问题是先前编写的糟糕代码的结果,那么怎么办?从现在到未来——如果我们决定现在走捷径,而不是投入时间进行适当的解决方案,那么我们将为自己创造什么问题?
“债务”这个词是一个很好的选择。这是一种债务,因为将来更改代码将比现在更改代码更困难。这种产生的成本就是债务的利息。技术债务意味着明天,代码将比今天更难、更昂贵(甚至可能测量);后天更昂贵,以此类推。
每当团队无法按时交付某些东西并不得不停下来修复和重构代码时,就是在支付技术债务的代价。
技术债务最糟糕的地方在于它代表了一个长期和潜在的问题。这不是引起高度警报的事情。相反,它是一个潜在的问题,分散在项目的所有部分,某一天,在某个特定的时间,它将醒来并成为一个停工的原因。
代码格式在清洁代码中的作用
清洁的代码是关于根据一些标准(例如 PEP-8,或项目指南定义的自定义标准)格式化和构造代码吗?简短的答案是否定的。
清洁的代码是另一回事,远远超出了编码标准、格式化、linting 工具和其他有关代码布局的检查。清洁的代码是关于实现高质量的软件,构建一个健壮、可维护的系统,避免技术债务。一段代码或整个软件组件可以符合 PEP-8(或任何其他指南),但仍然不能满足这些要求。
然而,不关注代码结构也有一些危险。因此,我们将首先分析糟糕的代码结构问题,如何解决这些问题,然后我们将看到如何配置和使用 Python 项目工具,以便自动检查和纠正问题。
总之,我们可以说,清洁的代码与 PEP-8 或编码风格之类的东西无关。它远远超出了那些,对代码的可维护性和软件质量意味着更有意义的东西。然而,正如我们将看到的,正确格式化代码对于高效工作是重要的。
- 遵循项目的编码风格指南
编码指南是项目应该具备的最低标准,以便被认为是按照质量标准开发的项目。在本节中,我们将探讨其中的原因,以便在接下来的章节中,我们可以开始探讨通过工具自动强制执行这一点的方法。
当我试图在代码布局中找到好的特点时,我脑海中首先想到的是一致性。我希望代码能够一致地结构化,以便更容易阅读和理解。如果代码不正确或结构不一致,并且团队中的每个人都按照自己的方式做事,那么我们最终会得到需要额外努力和专注才能正确遵循的代码。它将容易出错,误导人,并且错误或微妙之处可能很容易被忽略。
我们要避免这种情况。我们想要的正是相反的情况——我们能够在一瞥之间尽快阅读和理解的代码。
如果开发团队的所有成员都同意以标准化的方式结构化代码,那么最终的代码看起来会更加熟悉。因此,你将很快识别出模式(稍后会详细介绍),并且有了这些模式,理解事物和发现错误将变得更加容易。例如,当出现问题时,你会注意到你习惯看到的模式中有些地方不对劲,这会引起你的注意。你会仔细看一看,很可能会发现错误!
正如经典著作《代码大全》中所述,对此进行了有趣的分析,即在题为《国际象棋中的感知》(1973)的论文中进行了一项实验,以确定不同人如何理解或记忆不同的国际象棋局面。实验涉及各个水平的玩家(初学者、中级和国际象棋大师),以及棋盘上的不同国际象棋局面。他们发现,当局面是随机的时,初学者和国际象棋大师的表现一样好;这只是一个任何人都可以在相当同等水平上完成的记忆练习。当局面遵循可能在真实比赛中出现的逻辑顺序时(再次强调,一致性,遵循模式),国际象棋大师的表现要远远好于其他人。
现在想象一下,将这种情况应用到软件中。作为 Python 软件工程师专家,我们就像前面例子中的国际象棋大师。当代码结构随意,没有遵循任何逻辑或标准时,我们很难像初学者开发人员那样发现错误。另一方面,如果我们习惯以结构化的方式阅读代码,并且已经学会通过遵循模式快速理解代码的思想,那么我们就处于相当大的优势。
特别是对于 Python,你应该遵循的编码风格是 PEP-8。你可以扩展它或采用它的一些部分来适应你正在工作的项目的特殊情况(例如,行的长度,关于字符串的注释等)。然而,我建议无论你是只使用 PEP-8 还是扩展它,你都应该坚持使用它,而不是试图从头开始制定另一个不同的标准。
原因是这个文档已经考虑了 Python 语法的许多特殊情况(这些特殊情况通常不适用于其他语言),而且它是由实际为 Python 语法做出贡献的核心 Python 开发人员创建的。因此,很难想象 PEP-8 的准确性可以被否定,更不用说改进了。
特别是,PEP-8 在处理代码时具有一些特点,可以带来其他良好的改进,例如:
- 可搜索性:这是在代码中搜索标记的能力;也就是说,在特定文件中(以及这些文件的哪个部分)搜索我们正在寻找的特定字符串。这个标准引入的其中一项内容是区分变量赋值的方式和传递给函数的关键字参数的方式。
为了更好地理解这一点,让我们举个例子。假设我们正在调试,我们需要找到将值传递给名为location的参数的地方。我们可以运行以下grep命令,结果将告诉我们我们正在寻找的文件和行:
$ grep -nr "location=" .
./core.py:13: location=current_location,
现在,我们想知道这个变量是在哪里被赋予这个值,下面的命令也会给我们提供我们正在寻找的信息:
$ grep -nr "location =" .
./core.py:10: current_location = get_location()
PEP-8 规定,当通过关键字将参数传递给函数时,我们不使用空格,但在赋值变量时使用。因此,我们可以调整我们的搜索标准(在第一次搜索时不使用=周围的空格,在第二次搜索时使用一个空格),并且在我们的搜索中更加高效。这是遵循约定的优势之一。
-
一致性:如果代码看起来像一个统一的格式,那么阅读它将更容易。这对于入职非常重要,如果你想欢迎新的开发人员加入你的项目,或者雇佣新的(可能经验不足)程序员加入你的团队,并且他们需要熟悉代码(甚至可能由多个存储库组成)。如果他们打开的所有文件中的代码布局、文档、命名约定等都是相同的,那么他们的生活将变得更加轻松。
-
代码质量:通过以结构化的方式查看代码,你将更加熟练地一览无余地理解它(再次,就像国际象棋中的感知),并且更容易地发现错误和错误。除此之外,检查代码质量的工具也会提示潜在的错误。对代码进行静态分析可能有助于减少每行代码的错误比率。
文档字符串和注释
这一部分是关于在 Python 中从代码内部对代码进行文档化。良好的代码是自解释的,但也要有良好的文档。解释它应该做什么(而不是如何)是一个好主意。
一个重要的区别;对代码进行文档化并不等同于在代码上添加注释。注释是不好的,应该避免。通过文档化,我们指的是解释数据类型,提供它们的示例,并对变量进行注释。
这在 Python 中很重要,因为它是动态类型的,可能很容易在函数和方法之间的变量或对象的值上迷失。因此,陈述这些信息将使未来的代码读者更容易理解。
还有另一个特别与注释相关的原因。它们还可以帮助运行一些自动检查,比如通过 Mypy 这样的工具进行类型提示。最终,我们会发现,添加注释是值得的。
文档字符串
简单来说,我们可以说文档字符串基本上是嵌入在源代码中的文档。文档字符串基本上是一个字面字符串,放在代码的某个地方,目的是对该逻辑部分进行文档化。
注意对文档一词的强调。这种微妙之处很重要,因为它的意图是代表解释,而不是证明。文档字符串不是注释;它们是文档。
在代码中添加注释是一个不好的做法,原因有多个。首先,注释代表了我们未能用代码表达我们的想法。如果我们实际上必须解释为什么或如何做某事,那么该代码可能还不够好。首先,它没有足够的自解释性。其次,它可能会误导。在阅读复杂部分时,最糟糕的情况是阅读一个注释,说明它应该如何工作,然后发现代码实际上做了不同的事情。人们往往会忘记在更改代码时更新注释,因此刚刚更改的行旁边的注释将过时,导致危险的错误方向。
有时,在极少数情况下,我们无法避免添加注释。也许第三方库上有一个错误,我们必须规避。在这些情况下,放置一个小但描述性的注释可能是可以接受的。
然而,文档字符串的情况不同。再次强调,它们不代表注释,而是代码中特定组件(模块、类、方法或函数)的文档。它们的使用不仅被接受,而且被鼓励。尽可能添加文档字符串是一个好习惯。
它们在代码中是一个好东西的原因(或者甚至可能是必需的,这取决于项目的标准)是因为 Python 是动态类型的。这意味着,例如,函数可以将任何东西作为其参数的值。Python 不会强制执行或检查任何这样的事情。因此,想象一下,在代码中找到一个您知道必须修改的函数。您甚至很幸运,该函数有一个描述性的名称,它的参数也是如此。它可能仍然不太清楚应该传递什么类型。即使是这种情况,它们应该如何使用?
这就是一个好的文档字符串可能有所帮助的地方。记录函数的预期输入和输出是一个好习惯,将帮助该函数的读者理解它应该如何工作。
考虑一下标准库中的这个很好的例子:
In [1]: dict.update??
Docstring:
D.update([E, ]**F) -> None. Update D from dict/iterable E and F.
If E is present and has a .keys() method, then does: for k in E: D[k] = E[k]
If E is present and lacks a .keys() method, then does: for k, v in E: D[k] = v
In either case, this is followed by: for k in F: D[k] = F[k]
Type: method_descriptor
在这里,字典上update方法的文档字符串为我们提供了有用的信息,并告诉我们可以以不同的方式使用它:
- 我们可以传递一个具有
.keys()方法的对象(例如,另一个字典),它将使用传递的对象的键更新原始字典:
>>> d = {}
>>> d.update({1: "one", 2: "two"})
>>> d
{1: 'one', 2: 'two'}
- 我们可以传递一个键和值的对的可迭代对象,并将它们解包到
update中:
>>> d.update([(3, "three"), (4, "four")])
>>> d
{1: 'one', 2: 'two', 3: 'three', 4: 'four'}
在任何情况下,字典将使用传递给它的其余关键字参数进行更新。
这对于必须学习和理解新函数如何工作以及如何利用它的人来说是至关重要的信息。
请注意,在第一个示例中,我们通过在其上使用双问号(dict.update??)获得了函数的文档字符串。这是 IPython 交互式解释器的一个特性。调用它时,它将打印您期望的对象的文档字符串。现在,想象一下,以同样的方式,我们从标准库的这个函数中获得帮助;如果您在编写的函数上放置文档字符串,以便其他人可以以同样的方式理解它们的工作原理,那么您可以使您的读者(代码的用户)的生活变得更加轻松多少?
文档字符串不是与代码分离或孤立的东西。它成为代码的一部分,您可以访问它。当对象有定义的文档字符串时,这通过其__doc__属性成为其一部分:
>>> def my_function():
... """Run some computation"""
... return None
...
>>> my_function.__doc__
'Run some computation'
这意味着甚至可以在运行时访问它,甚至可以从源代码生成或编译文档。实际上,有工具可以做到这一点。如果运行 Sphinx,它将为项目的文档创建基本的框架。特别是使用autodoc扩展(sphinx.ext.autodoc),该工具将从代码中获取文档字符串,并将其放置在记录函数的页面中。
一旦你有了构建文档的工具,就将其公开,使其成为项目本身的一部分。对于开源项目,你可以使用 read the docs,它将根据分支或版本(可配置)自动生成文档。对于公司或项目,你可以使用相同的工具或在本地配置这些服务,但无论做出何种决定,重要的是文档应该准备好并对团队的所有成员可用。
不幸的是,文档字符串也有一个缺点,就是和所有文档一样,它需要手动和持续的维护。随着代码的变化,文档也需要更新。另一个问题是,为了使文档字符串真正有用,它们必须详细,这就需要多行。
维护适当的文档是我们无法逃避的软件工程挑战。这也是有道理的。如果你仔细想想,文档需要手动编写的原因是因为它是打算让其他人阅读的。如果它是自动化的,可能就没有太大的用处。为了使文档有价值,团队中的每个人都必须同意它是需要手动干预的东西,因此需要付出努力。关键是要理解软件不仅仅是代码。随之而来的文档也是交付内容的一部分。因此,当有人对一个函数进行更改时,同样重要的是更新文档的相应部分,无论是维基、用户手册、README 文件还是多个文档字符串。
注解
PEP-3107 引入了注解的概念。它们的基本想法是向代码的读者提示函数参数的预期值。使用“提示”这个词并非偶然;注解使类型提示成为可能,我们将在本章后面讨论这个问题,首先介绍注解。
注解允许您指定已定义的某些变量的预期类型。实际上,这不仅仅是关于类型,还有任何可以帮助您更好地了解该变量实际代表的元数据。
考虑以下示例:
class Point:
def __init__(self, lat, long):
self.lat = lat
self.long = long
def locate(latitude: float, longitude: float) -> Point:
"""Find an object in the map by its coordinates"""
在这里,我们使用float来指示latitude和longitude的预期类型。这仅仅是为了让函数的读者了解这些预期类型。Python 不会检查这些类型,也不会强制执行它们。
我们还可以指定函数返回值的预期类型。在这种情况下,Point是一个用户定义的类,这意味着无论返回什么都将是Point的一个实例。
然而,类型或内置类型并不是我们可以用作注解的唯一类型。基本上,任何在当前 Python 解释器范围内有效的东西都可以放在那里。例如,解释变量意图的字符串,可用作回调或验证函数的可调用对象等。
随着注解的引入,还包括了一个新的特殊属性,即__annotations__。这将使我们能够访问一个字典,将注解的名称(作为字典中的键)与它们的对应值进行映射,这些值是我们为它们定义的。在我们的示例中,这将如下所示:
>>> locate.__annotations__
{'latitude': float, 'longitue': float, 'return': __main__.Point}
如果我们认为有必要,我们可以使用这些来生成文档,运行验证,或者在我们的代码中强制检查。
说到通过注解检查代码,这就是 PEP-484 发挥作用的时候。这个 PEP 指定了类型提示的基础,即通过注解检查我们函数的类型。再次明确一下,引用 PEP-484 本身:
Python 将保持为一种动态类型的语言,作者们也不希望通过约定来强制类型提示成为必须。
类型提示的想法是提供额外的工具(与解释器无关),以检查和评估代码中类型的正确使用,并在检测到任何不兼容性时提示用户。运行这些检查的工具 Mypy 将在后面的章节中详细解释,我们将讨论如何为项目使用和配置这些工具。现在,您可以将其视为一种检查代码中使用的类型语义的 linter。这有时有助于在运行测试和检查时尽早发现错误。因此,将 Mypy 配置到项目中并将其与其他静态分析工具放在同一级别是一个好主意。
然而,类型提示意味着不仅仅是代码中类型检查的工具。从 Python 3.5 开始,引入了新的 typing 模块,这显著改进了我们在 Python 代码中定义类型和注释的方式。
其基本思想是,现在语义扩展到更有意义的概念,使我们(人类)更容易理解代码的含义,或者在给定点处期望的内容。例如,您可以有一个函数,它的一个参数是列表或元组,并且您可以将这两种类型之一作为注释,甚至是一个解释字符串。但是使用这个模块,可以告诉 Python 它期望一个可迭代对象或序列。甚至可以识别类型或其中的值;例如,它需要一个整数序列。
在编写本书时,关于注释方面进行了一项额外的改进,那就是从 Python 3.6 开始,可以直接注释变量,而不仅仅是函数参数和返回类型。这是在 PEP-526 中引入的,其想法是可以声明一些变量的类型,而不一定给它们赋值,如下面的清单所示:
class Point:
lat: float
long: float
>>> Point.__annotations__
{'lat': <class 'float'>, 'long': <class 'float'>}
注释是否取代了 docstrings?
这是一个合理的问题,因为在 Python 的旧版本中,在引入注释之前很久,函数或属性的参数类型的文档方式是通过在它们上面放置 docstrings 来完成的。甚至有一些关于如何构造 docstrings 的格式的约定,以包括函数的基本信息,包括每个参数的类型和含义,结果的类型和含义,以及函数可能引发的异常。
大部分内容已经通过注释以更紧凑的方式进行了处理,因此人们可能会想知道是否真的值得使用 docstrings。答案是肯定的,因为它们互补。
确实,以前包含在 docstring 中的一部分信息现在可以移动到注释中。但这只应该为 docstring 提供更好的文档空间。特别是对于动态和嵌套数据类型,提供预期数据的示例总是一个好主意,这样我们就可以更好地了解我们正在处理的内容。
考虑以下示例。假设我们有一个函数,它期望一个字典来验证一些数据:
def data_from_response(response: dict) -> dict:
if response["status"] != 200:
raise ValueError
return {"data": response["payload"]}
在这里,我们可以看到一个接受字典并返回另一个字典的函数。可能会在键"status"下的值不是预期值时引发异常。但是,我们对此了解不多。例如,response对象的正确实例是什么样的?result的实例会是什么样的?为了回答这两个问题,最好是记录预期由参数传入并由此函数返回的数据的示例。
让我们看看是否可以通过 docstring 更好地解释这一点:
def data_from_response(response: dict) -> dict:
"""If the response is OK, return its payload.
- response: A dict like::
{
"status": 200, # <int>
"timestamp": "....", # ISO format string of the current
date time
"payload": { ... } # dict with the returned data
}
- Returns a dictionary like::
{"data": { .. } }
- Raises:
- ValueError if the HTTP status is != 200
"""
if response["status"] != 200:
raise ValueError
return {"data": response["payload"]}
现在,我们对这个函数预期接收和返回的内容有了更好的了解。文档不仅作为理解和了解传递内容的宝贵输入,还是单元测试的宝贵来源。我们可以从中获取数据作为输入,并知道测试时应该使用的正确和不正确的值。实际上,测试也可以作为我们代码的可执行文档,但这将在更详细地解释。
好处是现在我们知道键的可能值以及它们的类型,我们对数据的外观有了更具体的解释。成本是,正如我们之前提到的,它占用了很多行,并且需要冗长和详细才能有效。
配置工具以强制执行基本质量门
在这一部分,我们将探讨如何配置一些基本工具,并自动运行代码检查,目的是利用重复性验证检查的一部分。
这是一个重要的观点:记住,代码是为了我们人类理解的,所以只有我们才能确定什么是好的或坏的代码。我们应该在代码审查上投入时间,思考什么是好的代码,以及它的可读性和可理解性。当查看同事编写的代码时,你应该问这样的问题:
-
这段代码对其他程序员来说易于理解和遵循吗?
-
它是否以问题域的术语来表达?
-
一个新加入团队的人能够理解并有效地使用它吗?
正如我们之前所看到的,代码格式化、一致的布局和适当的缩进是必需的,但不足以成为代码库中具有的特征。此外,作为具有高质量意识的工程师,我们会认为这是理所当然的,因此我们会读写代码,远远超出其布局的基本概念。因此,我们不愿意浪费时间审查这些种类的项目,因此我们可以通过查看代码中的实际模式来更有效地投入时间,以理解其真正含义并提供有价值的结果。
所有这些检查都应该是自动化的。它们应该是测试或清单的一部分,反过来又应该是持续集成构建的一部分。如果这些检查未通过,构建将失败。这是确保代码结构始终连续的唯一方法。它也作为团队的客观参数参考。与其让一些工程师或团队领导在代码审查中总是说相同的关于 PEP-8 的评论,构建将自动失败,使其成为客观的事实。
Mypy 的类型提示
Mypy (mypy-lang.org/) 是 Python 中可选的静态类型检查的主要工具。其想法是,一旦安装,它将分析项目中的所有文件,检查类型的使用是否一致。这是有用的,因为大多数时候,它会及早检测到实际的错误,但有时它可能会产生误报。
你可以使用 pip 安装它,并建议将其包含在项目的设置文件中作为依赖项:
$ pip install mypy
一旦它安装在虚拟环境中,你只需运行上述命令,它将报告类型检查的所有发现。尽量遵循它的报告,因为大多数时候,它提供的见解有助于避免可能会滑入生产中的错误。然而,该工具并不完美,所以如果你认为它报告了一个误报,你可以用以下标记忽略该行作为注释:
type_to_ignore = "something" # type: ignore
使用 Pylint 检查代码
在 Python 中,有许多用于检查代码结构的工具(基本上是符合 PEP-8 的),例如 pycodestyle(以前称为 PEP-8)、Flake8 等。它们都是可配置的,并且像运行它们提供的命令一样容易使用。在所有这些工具中,我发现 Pylint 是最完整(也是最严格)的。它也是可配置的。
再次强调,您只需在虚拟环境中使用pip安装它:
$ pip install pylint
然后,只需运行pylint命令就足以检查代码。
可以通过名为pylintrc的配置文件来配置 Pylint。
在这个文件中,您可以决定要启用或禁用的规则,并对其他规则进行参数化(例如,更改列的最大长度)。
自动检查设置
在 Unix 开发环境中,最常见的工作方式是通过 makefile。Makefile是强大的工具,让我们可以配置在项目中运行的命令,主要用于编译、运行等。除此之外,我们还可以在项目的根目录中使用一个 makefile,其中配置了一些命令来自动运行代码的格式和约定检查。
一个好的方法是为测试设置目标,每个特定的测试,然后再设置一个将所有测试一起运行的目标。例如:
typehint:
mypy src/ tests/
test:
pytest tests/
lint:
pylint src/ tests/
checklist: lint typehint test
.PHONY: typehint test lint checklist
在这里,我们应该运行的命令(无论是在我们的开发机器上还是在持续集成环境构建中)是以下命令:
make checklist
这将按以下步骤运行所有内容:
-
它将首先检查是否符合编码指南(例如 PEP-8)
-
然后它将检查代码中类型的使用
-
最后,它将运行测试
如果这些步骤中的任何一个失败,都要将整个过程视为失败。
除了在构建中自动配置这些检查之外,如果团队采用了一种约定和自动化的代码结构方法,那也是一个好主意。诸如 Black(github.com/ambv/black)之类的工具会自动格式化代码。有许多工具可以自动编辑代码,但 Black 的有趣之处在于它以独特的方式进行。它是一种主观和确定性的工具,因此代码最终总是以相同的方式排列。
例如,Black 字符串总是双引号,参数的顺序总是遵循相同的结构。这可能听起来很严格,但这是确保代码差异最小的唯一方法。如果代码始终遵守相同的结构,代码中的更改将只显示在实际进行更改的拉取请求中,而不会有额外的美观修改。它比 PEP-8 更严格,但也更方便,因为通过工具直接格式化代码,我们不必真正担心这个问题,可以专注于手头问题的关键。
在撰写本书时,唯一可以配置的是行的长度。其他一切都由项目的标准来纠正。
以下代码符合 PEP-8 规范,但不符合black的约定:
def my_function(name):
"""
>>> my_function('black')
'received Black'
"""
return 'received {0}'.format(name.title())
现在,我们可以运行以下命令来格式化文件:
black -l 79 *.py
现在,我们可以看到工具写了什么:
def my_function(name):
"""
>>> my_function('black')
'received Black'
"""
return "received {0}".format(name.title())
在更复杂的代码中,会有更多的变化(尾随逗号等),但这个想法可以清楚地看到。再次强调,这是一种主观看法,但对于我们来说,拥有一个处理细节的工具也是一个好主意。这也是 Golang 社区很久以前就学会的东西,以至于有一个标准的工具库got fmt,可以根据语言的约定自动格式化代码。Python 现在也有这样的东西,这是件好事。
这些工具(Black、Pylint、Mypy 等)可以与您选择的编辑器或 IDE 集成,使事情变得更加容易。将编辑器配置为在保存文件时或通过快捷键进行这些修改是一个不错的投资。
总结
现在我们对清晰的代码有了第一个概念,以及一个可行的解释,这将成为本书其余部分的参考点。
更重要的是,我们明白了清晰的代码比代码的结构和布局更重要得多。我们必须关注代码中的思想是如何表示的,以确定它们是否正确。清晰的代码是关于代码的可读性、可维护性,将技术债务降到最低,并有效地将我们的想法传达到代码中,以便他人能够理解我们最初打算写的东西。
然而,我们讨论了遵循编码风格或指南的重要性,有多种原因。我们一致认为这是一个必要的条件,但并不充分,因为它是每个扎实项目都应该遵守的最低要求,很明显,这是我们最好留给工具的事情。因此,自动化所有这些检查变得至关重要,在这方面,我们必须记住如何配置诸如 Mypy、Pylint 等工具。
接下来的章节将更加专注于特定于 Python 的代码,以及如何用 Python 的习惯用法表达我们的想法。我们将探讨 Python 中使代码更简洁高效的习惯用法。在这个分析中,我们将看到,总的来说,Python 有不同的想法或不同的方法来完成任务,与其他语言相比。
第二章:Pythonic 代码
在本章中,我们将探索在 Python 中表达观念的方式,以及它自己的特点。如果您熟悉编程中一些任务的标准完成方式(例如获取列表的最后一个元素,迭代,搜索等),或者如果您来自更传统的编程语言(如 C、C++和 Java),那么您将发现,总的来说,Python 为大多数常见任务提供了自己的机制。
在编程中,成语是为了执行特定任务而编写代码的一种特定方式。它是一种常见的重复出现并且每次都遵循相同结构的东西。有些人甚至可能争论并称它们为一种模式,但要小心,因为它们不是设计模式(我们稍后将探讨)。主要区别在于设计模式是高级别的想法,独立于语言(在某种程度上),但它们不能立即转化为代码。另一方面,成语实际上是编码的。这是我们想要执行特定任务时应该编写的方式。
由于成语是代码,因此它们是与语言相关的。每种语言都有自己的习语,这意味着在该特定语言中完成任务的方式(例如,在 C、C++等语言中如何打开和写入文件)。当代码遵循这些习语时,它被称为成语化,而在 Python 中通常被称为Pythonic。
有多个原因要遵循这些建议并首先编写 Pythonic 代码(我们将看到并分析),以成语化的方式编写代码通常性能更好。它也更紧凑,更容易理解。这些都是我们希望在代码中始终具备的特征,以使其有效运行。其次,正如在上一章中介绍的,整个开发团队能够习惯相同的代码模式和结构非常重要,因为这将帮助他们专注于问题的真正本质,并帮助他们避免犯错。
本章的目标如下:
-
了解索引和切片,并正确实现可以进行索引的对象
-
实现序列和其他可迭代对象
-
学习上下文管理器的良好使用案例
-
通过魔术方法实现更成语化的代码
-
避免导致不良副作用的 Python 常见错误
索引和切片
与其他语言一样,在 Python 中,一些数据结构或类型支持通过索引访问其元素。它与大多数编程语言共有的另一点是,第一个元素位于索引号零。然而,与那些语言不同的是,当我们想以与通常不同的顺序访问元素时,Python 提供了额外的功能。
例如,在 C 语言中,如何访问数组的最后一个元素?这是我第一次尝试 Python 时做的事情。以与 C 语言相同的方式思考,我会得到数组长度减一的位置的元素。这可能有效,但我们也可以使用负索引号,它将从最后开始计数,如下面的命令所示:
>>> my_numbers = (4, 5, 3, 9)
>>> my_numbers[-1]
9
>>> my_numbers[-3]
5
除了获取单个元素外,我们还可以使用slice获取多个元素,如下面的命令所示:
>>> my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)
>>> my_numbers[2:5]
(2, 3, 5)
在这种情况下,方括号中的语法意味着我们获取元组中的所有元素,从第一个数字的索引开始(包括该索引),直到第二个数字的索引(不包括该索引)。在 Python 中,切片的工作方式是通过排除所选区间的末尾来实现的。
您可以排除间隔的任一端点,开始或停止,这种情况下,它将分别从序列的开头或结尾起作用,如下面的命令所示:
>>> my_numbers[:3]
(1, 1, 2)
>>> my_numbers[3:]
(3, 5, 8, 13, 21)
>>> my_numbers[::]
(1, 1, 2, 3, 5, 8, 13, 21)
>>> my_numbers[1:7:2]
(1, 3, 8)
在第一个示例中,它将获取到索引位置号为3的所有内容。在第二个示例中,它将获取从位置3(包括)开始到末尾的所有数字。在倒数第二个示例中,两端都被排除,实际上是创建了原始元组的副本。
最后一个例子包括第三个参数,即步长。这表示在迭代间隔时要跳过多少个元素。在这种情况下,它意味着获取位置为一和七之间的元素,每两个跳一次。
在所有这些情况下,当我们将间隔传递给一个序列时,实际上发生的是我们传递了slice。注意,slice是 Python 中的一个内置对象,你可以自己构建并直接传递:
>>> interval = slice(1, 7, 2)
>>> my_numbers[interval]
(1, 3, 8)
>>> interval = slice(None, 3)
>>> my_numbers[interval] == my_numbers[:3]
True
注意,当元素中的一个缺失(开始、停止或步长),它被认为是无。
你应该始终优先使用这种内置的切片语法,而不是手动尝试在for循环中迭代元组、字符串或列表,手动排除元素。
创建你自己的序列
我们刚刚讨论的功能得益于一个叫做__getitem__的魔术方法。当像myobject[key]这样的东西被调用时,传递键(方括号内的值)作为参数调用这个方法。特别是,序列是一个实现了__getitem__和__len__的对象,因此它可以被迭代。列表、元组和字符串是标准库中序列对象的例子。
在这一部分,我们更关心通过关键字从对象中获取特定元素,而不是构建序列或可迭代对象,这是第七章中探讨的主题,使用生成器。
如果你要在你的领域的自定义类中实现__getitem__,你将不得不考虑一些问题,以便遵循 Pythonic 的方法。
如果你的类是标准库对象的包装器,你可能会尽可能地将行为委托给底层对象。这意味着如果你的类实际上是列表的包装器,调用列表上的所有相同方法,以确保它保持兼容。在下面的列表中,我们可以看到一个对象如何包装一个列表的例子,对于我们感兴趣的方法,我们只是委托给list对象上对应的版本:
class Items:
def __init__(self, *values):
self._values = list(values)
def __len__(self):
return len(self._values)
def __getitem__(self, item):
return self._values.__getitem__(item)
这个例子使用了封装。另一种方法是通过继承,这种情况下,我们将不得不扩展collections.UserList基类,考虑到本章的最后部分提到的注意事项和警告。
然而,如果你正在实现自己的序列,而不是一个包装器或不依赖于任何内置对象,那么请记住以下几点:
-
当通过范围进行索引时,结果应该是与类的相同类型的实例
-
在
slice提供的范围内,遵守 Python 使用的语义,排除末尾的元素
第一点是一个微妙的错误。想想看——当你得到一个列表的slice时,结果是一个列表;当你在元组中请求一个范围时,结果是一个元组;当你请求一个子字符串时,结果是一个字符串。在每种情况下,结果与原始对象的类型相同是有道理的。如果你正在创建一个表示日期间隔的对象,并且你在该间隔上请求一个范围,返回一个列表或元组等都是错误的。相反,它应该返回一个设置了新间隔的相同类的新实例。最好的例子是在标准库中的range函数。在 Python 2 中,range函数用于构建一个列表。现在,如果你用一个间隔调用range,它将构造一个可迭代的对象,知道如何产生所选范围内的值。当你为 range 指定一个间隔时,你得到一个新的 range(这是有道理的),而不是一个列表:
>>> range(1, 100)[25:50]
range(26, 51)
第二条规则也是关于一致性 - 代码的用户会发现如果与 Python 本身保持一致,那么使用起来更加熟悉和容易。作为 Python 开发人员,我们已经习惯了切片的工作方式,range函数的工作方式等。对自定义类做出异常会造成混乱,这意味着更难记住,可能导致错误。
上下文管理器
上下文管理器是 Python 提供的一个非常有用的特性。它们之所以如此有用的原因是它们正确地响应了一种模式。这种模式实际上是我们想要运行一些代码的每种情况,并且具有前置条件和后置条件,这意味着我们想在某个主要操作之前和之后运行一些东西。
大多数情况下,我们在资源管理周围看到上下文管理器。例如,在打开文件时,我们希望在处理后确保它们被关闭(这样我们就不会泄漏文件描述符),或者如果我们打开到服务(甚至是套接字)的连接,我们也希望相应地关闭它,或者在删除临时文件时等等。
在所有这些情况下,通常需要记住释放分配的所有资源,这只是考虑最佳情况,但是异常和错误处理呢?考虑到处理程序的所有可能组合和执行路径会使调试变得更加困难,解决这个问题的最常见方法是将清理代码放在finally块中,以确保不会遗漏它。例如,一个非常简单的情况看起来像下面这样:
fd = open(filename)
try:
process_file(fd)
finally:
fd.close()
尽管如此,有一种更加优雅和 Pythonic 的方法来实现相同的功能:
with open(filename) as fd:
process_file(fd)
with语句(PEP-343)进入上下文管理器。在这种情况下,open函数实现了上下文管理器协议,这意味着文件将在块完成时自动关闭,即使发生异常也是如此。
上下文管理器由两个魔术方法组成:__enter__和__exit__。在上下文管理器的第一行,with语句将调用第一个方法__enter__,并且无论这个方法返回什么都将被分配给as后面标记的变量。这是可选的 - 我们不真的需要在__enter__方法上返回任何特定的东西,即使我们这样做了,如果不需要,也没有严格的理由将其分配给一个变量。
在执行这行之后,代码进入一个新的上下文,可以运行任何其他 Python 代码。在该块上的最后一条语句完成后,上下文将被退出,这意味着 Python 将调用我们首先调用的原始上下文管理器对象的__exit__方法。
如果在上下文管理器块内部发生异常或错误,__exit__方法仍然会被调用,这使得安全地管理清理条件变得方便。实际上,如果我们想以自定义方式处理,这个方法会接收在块上触发的异常。
尽管上下文管理器在处理资源时经常出现(比如我们提到的文件、连接等示例),但这并不是它们唯一的应用。我们可以实现自己的上下文管理器来处理我们需要的特定逻辑。
上下文管理器是分离关注点和隔离应该保持独立的代码部分的好方法,因为如果我们混合它们,那么逻辑将变得更难以维护。
举个例子,考虑这样一种情况:我们想要用一个脚本对数据库进行备份。问题在于备份是离线的,这意味着只有在数据库不运行时才能进行备份,为此我们必须停止它。备份完成后,我们希望确保无论备份过程本身如何进行,我们都要重新启动该进程。现在,第一种方法是创建一个巨大的单片函数,试图在同一个地方做所有事情,停止服务,执行备份任务,处理异常和所有可能的边缘情况,然后尝试重新启动服务。你可以想象这样一个函数,因此我将省略细节,而直接提出一种可能的解决这个问题的方式,即使用上下文管理器:
def stop_database():
run("systemctl stop postgresql.service")
def start_database():
run("systemctl start postgresql.service")
class DBHandler:
def __enter__(self):
stop_database()
return self
def __exit__(self, exc_type, ex_value, ex_traceback):
start_database()
def db_backup():
run("pg_dump database")
def main():
with DBHandler():
db_backup()
在这个例子中,我们不需要上下文管理器在块内的结果,这就是为什么我们可以认为,至少对于这种特殊情况,__enter__的返回值是无关紧要的。在设计上下文管理器时,这是需要考虑的事情——一旦块开始,我们需要什么?作为一个一般规则,总是在__enter__上返回一些东西应该是一个好的做法(尽管不是强制性的)。
在这个块中,我们只运行备份任务,独立于维护任务,就像我们之前看到的那样。我们还提到,即使备份任务出现错误,__exit__仍然会被调用。
注意__exit__方法的签名。它接收了在块上引发的异常的值。如果块上没有异常,它们都是None。
__exit__的返回值是需要考虑的。通常,我们希望保持该方法不变,不返回任何特定的内容。如果该方法返回True,这意味着潜在引发的异常不会传播到调用者那里,而是在此处停止。有时,这是期望的效果,甚至可能取决于引发的异常类型,但一般来说,吞没异常并不是一个好主意。记住:错误不应该悄悄地传递。
请记住不要在__exit__上意外返回True。如果你这样做了,请确保这确实是你想要的,并且有一个很好的理由。
实现上下文管理器
一般来说,我们可以像前面的例子一样实现上下文管理器。我们只需要一个实现__enter__和__exit__魔术方法的类,然后该对象就能支持上下文管理器协议。虽然这是实现上下文管理器最常见的方式,但并不是唯一的方式。
在本节中,我们将看到不仅实现上下文管理器的不同(有时更紧凑)的方法,还将看到如何通过使用标准库,特别是contextlib模块,充分利用它们。
contextlib模块包含了许多辅助函数和对象,可以实现上下文管理器,或者使用一些已经提供的可以帮助我们编写更紧凑代码的上下文管理器。
让我们从看contextmanager装饰器开始。
当contextlib.contextmanager装饰器应用于一个函数时,它将该函数中的代码转换为上下文管理器。所涉及的函数必须是一种特殊类型的函数,称为生成器函数,它将语句分开成分别位于__enter__和__exit__魔术方法中的内容。
如果你现在对装饰器和生成器不熟悉,这并不是问题,因为我们将要看的例子是独立的,而且这个方法或习惯可以被应用和理解。这些主题在第七章中有详细讨论,使用生成器。
前面例子的等价代码可以用contextmanager装饰器重写如下:
import contextlib
@contextlib.contextmanager
def db_handler():
stop_database()
yield
start_database()
with db_handler():
db_backup()
在这里,我们定义了生成器函数,并将@contextlib.contextmanager装饰器应用到它上面。该函数包含一个yield语句,这使它成为一个生成器函数。在这种情况下,生成器的细节并不相关。我们只需要知道,当应用这个装饰器时,yield语句之前的所有内容将被视为__enter__方法的一部分运行。然后,yield 的值将成为上下文管理器评估的结果(__enter__将返回的内容),如果我们选择像as x:这样分配它的话,将被分配给变量——在这种情况下,没有任何 yield(这意味着 yield 的值将是隐式的 none),但如果我们想要的话,我们可以 yield 一个语句,这将成为我们可能想要在上下文管理器块中使用的东西。
在那一点上,生成器函数被挂起,进入上下文管理器,在那里,我们再次运行数据库的备份代码。完成后,执行恢复,因此我们可以认为yield语句之后的每一行都将成为__exit__逻辑的一部分。
像这样编写上下文管理器的优势在于,更容易重构现有函数,重用代码,总的来说,当我们需要一个不属于任何特定对象的上下文管理器时,这是一个好主意。添加额外的魔术方法会使我们领域中的另一个对象更加耦合,责任更多,并支持一些它可能不应该支持的东西。当我们只需要一个上下文管理器函数,而不需要保留许多状态,并且完全独立于我们的其他类时,这可能是一个不错的选择。
然而,我们可以以更多的方式实现上下文管理器,再一次,答案在标准库的contextlib包中。
我们还可以使用contextlib.ContextDecorator这个辅助工具。这是一个混合基类,提供了将装饰器应用到函数的逻辑,使其在上下文管理器中运行,而上下文管理器本身的逻辑必须通过实现前面提到的魔术方法来提供。
为了使用它,我们必须扩展这个类,并在所需的方法上实现逻辑:
class dbhandler_decorator(contextlib.ContextDecorator):
def __enter__(self):
stop_database()
def __exit__(self, ext_type, ex_value, ex_traceback):
start_database()
@dbhandler_decorator()
def offline_backup():
run("pg_dump database")
你注意到和之前的例子有什么不同了吗?这里没有with语句。我们只需要调用这个函数,offline_backup()就会自动在上下文管理器中运行。这是基类提供的逻辑,用作装饰器包装原始函数,使其在上下文管理器中运行。
这种方法的唯一缺点是,对象完全独立运作(这是一个好特点)——装饰器对装饰的函数一无所知,反之亦然。然而,这意味着你无法获得一个你想要在上下文管理器中使用的对象(例如,分配with offline_backup() as bp:),所以如果你真的需要使用__exit__方法返回的对象,之前的方法将成为首选。
作为一个装饰器,这也带来了一个优势,即逻辑只定义一次,我们可以通过简单地将装饰器应用到其他需要相同不变逻辑的函数上,来重复使用它。
让我们探索contextlib的最后一个特性,看看我们可以从上下文管理器中期望什么,并了解我们可以用它们做什么样的事情。
请注意,contextlib.suppress是一个util包,它进入一个上下文管理器,如果其中一个提供的异常被触发,它不会失败。这类似于在try/except块上运行相同的代码并传递异常或记录它,但不同之处在于调用suppress方法更明确地表明那些作为我们逻辑一部分受控的异常。
例如,考虑以下代码:
import contextlib
with contextlib.suppress(DataConversionException):
parse_data(input_json_or_dict)
在这里,异常的存在意味着输入数据已经是预期格式,因此不需要转换,因此可以安全地忽略它。
属性、属性和对象的不同类型的方法
在 Python 中,对象的所有属性和函数都是公开的,这与其他语言不同,其他语言中属性可以是公共的、私有的或受保护的。也就是说,没有必要阻止调用对象调用对象具有的任何属性。这是与其他编程语言的另一个不同之处,其他编程语言可以将一些属性标记为私有或受保护。
没有严格的强制,但有一些约定。以下划线开头的属性意味着它是对象的私有属性,我们期望没有外部代理调用它(但同样,没有阻止这种情况)。
在深入了解属性的细节之前,值得提到 Python 中下划线的一些特点,理解约定和属性的范围。
Python 中的下划线
Python 中有一些约定和实现细节,使用下划线是一个有趣的话题,值得分析。
正如我们之前提到的,默认情况下,对象的所有属性都是公开的。考虑以下示例来说明这一点:
>>> class Connector:
... def __init__(self, source):
... self.source = source
... self._timeout = 60
...
>>> conn = Connector("postgresql://localhost")
>>> conn.source
'postgresql://localhost'
>>> conn._timeout
60
>>> conn.__dict__
{'source': 'postgresql://localhost', '_timeout': 60}
在这里,创建了一个Connector对象与source,并且它开始有两个属性——前面提到的source和timeout。前者是公开的,后者是私有的。然而,正如我们从以下行中看到的,当我们创建这样一个对象时,我们实际上可以访问它们两个。
这段代码的解释是,_timeout应该只在connector内部访问,而不是从调用者访问。这意味着你应该以一种安全的方式组织代码,以便在所有需要的时间安全地重构超时,依赖于它不是从对象外部调用(只在内部调用),因此保持与以前相同的接口。遵守这些规则使代码更容易维护,更健壮,因为我们在重构代码时不必担心连锁反应,如果我们保持对象的接口不变。同样的原则也适用于方法。
对象应该只公开对外部调用对象相关的属性和方法,即其接口。一切不严格属于对象接口的东西都应该以单下划线为前缀。
这是清晰地界定对象接口的 Python 方式。然而,有一个常见的误解,即一些属性和方法实际上可以被私有化。这又是一个误解。让我们想象一下,现在timeout属性定义为双下划线:
>>> class Connector:
... def __init__(self, source):
... self.source = source
... self.__timeout = 60
...
... def connect(self):
... print("connecting with {0}s".format(self.__timeout))
... # ...
...
>>> conn = Connector("postgresql://localhost")
>>> conn.connect()
connecting with 60s
>>> conn.__timeout
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Connector' object has no attribute '__timeout'
一些开发人员使用这种方法来隐藏一些属性,像在这个例子中一样,认为timeout现在是私有的,没有其他对象可以修改它。现在,看一下尝试访问__timeout时引发的异常。它是AttributeError,表示它不存在。它没有说像“这是私有的”或“这不能被访问”等等。它说它不存在。这应该给我们一个线索,实际上发生了不同的事情,这种行为只是一个副作用,而不是我们想要的真正效果。
实际上发生的是,使用双下划线,Python 为属性创建了一个不同的名称(这称为名称混淆)。它创建的属性的名称如下:"_<class-name>__<attribute-name>"。在这种情况下,将创建一个名为'_Connector__timeout'的属性,可以通过以下方式访问(和修改)这样的属性:
>>> vars(conn)
{'source': 'postgresql://localhost', '_Connector__timeout': 60}
>>> conn._Connector__timeout
60
>>> conn._Connector__timeout = 30
>>> conn.connect()
connecting with 30s
注意我们之前提到的副作用——属性只存在不同的名称,因此在我们第一次尝试访问它时引发了AttributeError。
Python 中双下划线的概念完全不同。它被创建为一种方式,用于覆盖将被多次扩展的类的不同方法,而不会出现与方法名称的冲突的风险。即使这是一个牵强的用例,也不能证明使用这种机制的必要性。
双下划线是一种非 Pythonic 的方法。如果需要将属性定义为私有的,请使用单下划线,并遵守 Pythonic 的约定,即它是一个私有属性。
不要使用双下划线。
属性
当对象只需要保存值时,我们可以使用常规属性。有时,我们可能希望根据对象的状态和其他属性的值进行一些计算。大多数情况下,属性是一个不错的选择。
当我们需要定义对象中某些属性的访问控制时,属性就应该被使用,这是 Python 在另一个方面有自己的做事方式的地方。在其他编程语言(如 Java)中,你会创建访问方法(getter 和 setter),但惯用的 Python 会使用属性。
想象一下,我们有一个用户可以注册的应用程序,我们希望保护用户的某些信息不被错误地修改,比如他们的电子邮件,如下面的代码所示:
import re
EMAIL_FORMAT = re.compile(r"[^@]+@[^@]+\.[^@]+")
def is_valid_email(potentially_valid_email: str):
return re.match(EMAIL_FORMAT, potentially_valid_email) is not None
class User:
def __init__(self, username):
self.username = username
self._email = None
@property
def email(self):
return self._email
@email.setter
def email(self, new_email):
if not is_valid_email(new_email):
raise ValueError(f"Can't set {new_email} as it's not a
valid email")
self._email = new_email
通过将电子邮件放在属性下,我们可以免费获得一些优势。在这个例子中,第一个@property方法将返回私有属性email保存的值。如前所述,前导下划线确定了这个属性是私有的,因此不应该从这个类的外部访问。
然后,第二个方法使用了@email.setter,使用了前一个方法已经定义的属性。当调用者代码中运行<user>.email = <new_email>时,将调用这个方法,<new_email>将成为这个方法的参数。在这里,我们明确定义了一个验证,如果试图设置的值不是实际的电子邮件地址,将失败。如果是,它将使用新值更新属性,如下所示:
>>> u1 = User("jsmith")
>>> u1.email = "jsmith@"
Traceback (most recent call last):
...
ValueError: Can't set jsmith@ as it's not a valid email
>>> u1.email = "jsmith@g.co"
>>> u1.email
'jsmith@g.co'
这种方法比使用以get_或set_为前缀的自定义方法要紧凑得多。因为它只是email,所以期望是清晰的。
不要为对象的所有属性编写自定义的get_*和set_*方法。大多数情况下,将它们作为常规属性留下就足够了。如果需要修改检索或修改属性时的逻辑,那么使用属性。
您可能会发现属性是实现命令和查询分离(CC08)的一种好方法。命令和查询分离表明对象的方法应该要么回答问题,要么执行操作,但不能两者兼而有之。如果对象的方法既在做某事,又同时返回一个回答该操作进行得如何的状态,那么它做了超过一件事,显然违反了函数应该只做一件事的原则。
根据方法的名称,这可能会导致更多的混淆,使读者更难理解代码的实际意图。例如,如果一个方法被称为set_email,我们使用它作为if self.set_email("a@j.com"): ...,那么这段代码在做什么?它是将电子邮件设置为a@j.com吗?它是在检查电子邮件是否已经设置为该值吗?两者(设置然后检查状态是否正确)?
通过属性,我们可以避免这种混淆。@property装饰器是回答问题的查询,@<property_name>.setter是执行命令的命令。
从这个例子中得出的另一个好建议是——不要在一个方法上做超过一件事。如果你想分配一些东西然后检查值,把它分解成两个或更多个句子。
方法应该只做一件事。如果你必须运行一个动作,然后检查状态,那么应该在不同的语句中调用不同的方法。
可迭代对象
在 Python 中,我们有默认可迭代的对象。例如,列表、元组、集合和字典不仅可以以我们想要的结构保存数据,还可以在for循环中重复获取这些值。
然而,内置的可迭代对象并不是我们在for循环中唯一可以拥有的类型。我们还可以创建自己的可迭代对象,并定义迭代的逻辑。
为了实现这一点,我们再次依赖于魔术方法。
迭代是通过 Python 自己的协议(即迭代协议)工作的。当你尝试以for e in myobject:...的形式迭代一个对象时,Python 在非常高的层次上检查以下两件事,按顺序:
-
如果对象包含迭代器方法之一——
__next__或__iter__ -
如果对象是一个序列,并且具有
__len__和__getitem__
因此,作为后备机制,序列可以被迭代,因此有两种方法可以自定义我们的对象以在for循环中工作。
创建可迭代对象
当我们尝试迭代一个对象时,Python 将在其上调用iter()函数。这个函数首先检查的是该对象是否存在__iter__方法,如果存在,将执行该方法。
以下代码创建了一个对象,允许在一系列日期上进行迭代,每次循环产生一天:
from datetime import timedelta
class DateRangeIterable:
"""An iterable that contains its own iterator object."""
def __init__(self, start_date, end_date):
self.start_date = start_date
self.end_date = end_date
self._present_day = start_date
def __iter__(self):
return self
def __next__(self):
if self._present_day >= self.end_date:
raise StopIteration
today = self._present_day
self._present_day += timedelta(days=1)
return today
该对象旨在使用一对日期创建,并在迭代时,将产生指定日期间隔内的每一天,如下代码所示:
>>> for day in DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5)):
... print(day)
...
2018-01-01
2018-01-02
2018-01-03
2018-01-04
>>>
在这里,for循环开始对我们的对象进行新的迭代。此时,Python 将在其上调用iter()函数,然后iter()函数将调用__iter__魔术方法。在这个方法中,它被定义为返回 self,表示对象本身是可迭代的,因此在每一步循环中都将在该对象上调用next()函数,该函数委托给__next__方法。在这个方法中,我们决定如何产生元素并一次返回一个。当没有其他东西可以产生时,我们必须通过引发StopIteration异常向 Python 发出信号。
这意味着实际上发生的情况类似于 Python 每次在我们的对象上调用next(),直到出现StopIteration异常,然后它知道必须停止for循环:
>>> r = DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> next(r)
datetime.date(2018, 1, 1)
>>> next(r)
datetime.date(2018, 1, 2)
>>> next(r)
datetime.date(2018, 1, 3)
>>> next(r)
datetime.date(2018, 1, 4)
>>> next(r)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File ... __next__
raise StopIteration
StopIteration
>>>
这个例子是有效的,但存在一个小问题——一旦耗尽,可迭代对象将继续为空,因此引发StopIteration。这意味着如果我们在两个或更多连续的for循环中使用它,只有第一个会起作用,而第二个会为空:
>>> r1 = DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> ", ".join(map(str, r1))
'2018-01-01, 2018-01-02, 2018-01-03, 2018-01-04'
>>> max(r1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: max() arg is an empty sequence
>>>
这是因为迭代协议的工作方式——一个可迭代对象构造一个迭代器,这个迭代器被迭代。在我们的例子中,__iter__只是返回了self,但我们可以让它每次调用时创建一个新的迭代器。修复这个问题的一种方法是创建DateRangeIterable的新实例,这不是一个可怕的问题,但我们可以让__iter__使用生成器(它是迭代器对象),每次创建一个:
class DateRangeContainerIterable:
def __init__(self, start_date, end_date):
self.start_date = start_date
self.end_date = end_date
def __iter__(self):
current_day = self.start_date
while current_day < self.end_date:
yield current_day
current_day += timedelta(days=1)
这一次,它起作用了:
>>> r1 = DateRangeContainerIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> ", ".join(map(str, r1))
'2018-01-01, 2018-01-02, 2018-01-03, 2018-01-04'
>>> max(r1)
datetime.date(2018, 1, 4)
>>>
不同之处在于每个for循环都会再次调用__iter__,并且每个for循环都会再次创建生成器。
这被称为容器可迭代对象。
一般来说,处理生成器时最好使用容器可迭代对象。
有关生成器的详细信息将在第七章中详细解释,使用生成器。
创建序列
也许我们的对象没有定义__iter__()方法,但我们仍然希望能够对其进行迭代。如果对象上没有定义__iter__,iter()函数将查找__getitem__的存在,如果找不到,将引发TypeError。
序列是一个实现__len__和__getitem__的对象,并期望能够按顺序一次获取它包含的元素,从零开始作为第一个索引。这意味着你应该在逻辑上小心,以便正确实现__getitem__以期望这种类型的索引,否则迭代将无法工作。
前一节的示例有一个优点,它使用的内存更少。这意味着它一次只保存一个日期,并且知道如何逐个生成日期。然而,它的缺点是,如果我们想要获取第 n 个元素,我们除了迭代 n 次直到达到它之外别无选择。这是计算机科学中内存和 CPU 使用之间的典型权衡。
使用可迭代的实现会占用更少的内存,但获取一个元素最多需要*O(n)*的时间,而实现一个序列会占用更多的内存(因为我们必须一次性保存所有东西),但支持常数时间的索引,O(1)。
这就是新实现可能看起来的样子:
class DateRangeSequence:
def __init__(self, start_date, end_date):
self.start_date = start_date
self.end_date = end_date
self._range = self._create_range()
def _create_range(self):
days = []
current_day = self.start_date
while current_day < self.end_date:
days.append(current_day)
current_day += timedelta(days=1)
return days
def __getitem__(self, day_no):
return self._range[day_no]
def __len__(self):
return len(self._range)
这是对象的行为:
>>> s1 = DateRangeSequence(date(2018, 1, 1), date(2018, 1, 5))
>>> for day in s1:
... print(day)
...
2018-01-01
2018-01-02
2018-01-03
2018-01-04
>>> s1[0]
datetime.date(2018, 1, 1)
>>> s1[3]
datetime.date(2018, 1, 4)
>>> s1[-1]
datetime.date(2018, 1, 4)
在前面的代码中,我们可以看到负索引也是有效的。这是因为DateRangeSequence对象将所有操作委托给其包装对象(一个list),这是保持兼容性和一致行为的最佳方式。
在决定使用哪种可能的实现时,要评估内存和 CPU 使用之间的权衡。一般来说,迭代是可取的(甚至是生成器),但要记住每种情况的要求。
容器对象
容器是实现__contains__方法的对象(通常返回一个布尔值)。在 Python 中的in关键字的存在下会调用这个方法。
类似下面这样的:
element in container
在 Python 中使用时变成这样:
container.__contains__(element)
当这种方法被正确实现时,你可以想象代码会变得更可读(并且更 Pythonic!)。
假设我们必须在一个具有二维坐标的游戏地图上标记一些点。我们可能期望找到以下函数:
def mark_coordinate(grid, coord):
if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height:
grid[coord] = MARKED
现在,检查第一个if语句条件的部分似乎很复杂;它没有显示代码的意图,不够表达,最糟糕的是它要求代码重复(在代码的每个部分在继续之前都需要重复那个if语句)。
如果地图本身(在代码中称为grid)能够回答这个问题怎么办?更好的是,如果地图能够将这个动作委托给一个更小(因此更内聚)的对象呢?因此,我们可以问地图是否包含一个坐标,地图本身可以有关于其限制的信息,并询问这个对象以下内容:
class Boundaries:
def __init__(self, width, height):
self.width = width
self.height = height
def __contains__(self, coord):
x, y = coord
return 0 <= x < self.width and 0 <= y < self.height
class Grid:
def __init__(self, width, height):
self.width = width
self.height = height
self.limits = Boundaries(width, height)
def __contains__(self, coord):
return coord in self.limits
这段代码本身就是一个更好的实现。首先,它进行了简单的组合,并使用委托来解决问题。两个对象都非常内聚,具有尽可能少的逻辑;方法很短,逻辑清晰明了——coord in self.limits基本上就是对要解决的问题的声明,表达了代码的意图。
从外部来看,我们也能看到好处。几乎就像 Python 在为我们解决问题:
def mark_coordinate(grid, coord):
if coord in grid:
grid[coord] = MARKED
对象的动态属性
可以通过__getattr__魔术方法来控制从对象中获取属性的方式。当我们调用类似<myobject>.<myattribute>的东西时,Python 会在对象的字典中查找<myattribute>,并调用__getattribute__。如果没有找到(即对象没有我们要找的属性),那么会调用额外的方法__getattr__,并将属性的名称(myattribute)作为参数传递。通过接收这个值,我们可以控制返回给我们对象的方式。我们甚至可以创建新的属性等等。
在下面的清单中,演示了__getattr__方法:
class DynamicAttributes:
def __init__(self, attribute):
self.attribute = attribute
def __getattr__(self, attr):
if attr.startswith("fallback_"):
name = attr.replace("fallback_", "")
return f"[fallback resolved] {name}"
raise AttributeError(
f"{self.__class__.__name__} has no attribute {attr}"
)
这是对该类对象的一些调用:
>>> dyn = DynamicAttributes("value")
>>> dyn.attribute
'value'
>>> dyn.fallback_test
'[fallback resolved] test'
>>> dyn.__dict__["fallback_new"] = "new value"
>>> dyn.fallback_new
'new value'
>>> getattr(dyn, "something", "default")
'default'
第一个调用很简单——我们只是请求对象具有的属性,并将其值作为结果。第二个是这个方法发挥作用的地方,因为对象没有任何叫做fallback_test的东西,所以__getattr__将以该值运行。在该方法内部,我们放置了返回一个字符串的代码,我们得到的是该转换的结果。
第三个例子很有趣,因为这里创建了一个名为fallback_new的新属性(实际上,这个调用与运行dyn.fallback_new = "new value"是一样的),所以当我们请求该属性时,注意到我们放在__getattr__中的逻辑不适用,因为那段代码根本没有被调用。
现在,最后一个例子是最有趣的。这里有一个微妙的细节,这会产生很大的差异。再看一下__getattr__方法中的代码。注意当值不可检索时它引发的异常AttributeError。这不仅是为了一致性(以及异常中的消息),而且也是内置的getattr()函数所要求的。如果这个异常是其他任何异常,它都会引发,而默认值将不会被返回。
在实现__getattr__这样动态的方法时要小心,并谨慎使用。在实现__getattr__时,要引发AttributeError。
可调用对象
定义可以作为函数的对象是可能的(而且通常很方便)。其中最常见的应用之一是创建更好的装饰器,但不仅限于此。
当我们尝试执行我们的对象,就好像它是一个常规函数一样时,魔术方法__call__将被调用。传递给它的每个参数都将传递给__call__方法。
通过这种方式实现函数的主要优势是,对象具有状态,因此我们可以在调用之间保存和维护信息。
当我们有一个对象时,类似这样的语句object(*args, **kwargs)在 Python 中被翻译为object.__call__(*args, **kwargs)。
当我们想要创建可作为带参数函数的可调用对象时,这种方法非常有用,或者在某些情况下是具有记忆功能的函数。
以下清单使用此方法构建一个对象,当使用参数调用时,返回它已经使用相同值调用的次数:
from collections import defaultdict
class CallCount:
def __init__(self):
self._counts = defaultdict(int)
def __call__(self, argument):
self._counts[argument] += 1
return self._counts[argument]
这个类的一些示例操作如下:
>>> cc = CallCount()
>>> cc(1)
1
>>> cc(2)
1
>>> cc(1)
2
>>> cc(1)
3
>>> cc("something")
1
在本书的后面,我们将发现这种方法在创建装饰器时非常方便。
魔术方法总结
我们可以总结前面描述的概念,形成一个类似下面所示的速查表。对于 Python 中的每个操作,都会呈现涉及的魔术方法,以及它所代表的概念:
| 语句 | 魔术方法 | Python 概念 |
|---|---|---|
obj[key]``obj[i:j]``obj[i:j:k] | __getitem__(key) | 可以进行下标操作的对象 |
with obj: ... | __enter__ / __exit__ | 上下文管理器 |
for i in obj: ... | __iter__ / __next__``__len__ / __getitem__ | 可迭代对象序列 |
obj.<attribute> | __getattr__ | 动态属性检索 |
obj(*args, **kwargs) | __call__(*args, **kwargs) | 可调用对象 |
Python 中的注意事项
除了理解语言的主要特性之外,能够编写惯用代码也意味着要意识到一些习语的潜在问题,以及如何避免它们。在本节中,我们将探讨一些常见问题,如果让你措手不及,可能会导致长时间的调试会话。
本节讨论的大部分观点都是要完全避免的,我敢说几乎没有可能的情况能够证明反模式(或者在这种情况下是习语)的存在是合理的。因此,如果你在你正在工作的代码库中发现了这种情况,可以随意按照建议进行重构。如果你在进行代码审查时发现了这些特征,这清楚地表明需要做出一些改变。
可变默认参数
简单来说,不要将可变对象用作函数的默认参数。如果您将可变对象用作默认参数,您将得到意料之外的结果。
考虑以下错误的函数定义:
def wrong_user_display(user_metadata: dict = {"name": "John", "age": 30}):
name = user_metadata.pop("name")
age = user_metadata.pop("age")
return f"{name} ({age})"
实际上,这有两个问题。除了默认的可变参数外,函数体正在改变一个可变对象,因此产生了副作用。但主要问题是user_medatada的默认参数。
实际上,这只会在第一次不带参数调用时起作用。第二次,我们在不明确传递任何内容给user_metadata的情况下调用它。它将失败并显示KeyError,如下所示:
>>> wrong_user_display()
'John (30)'
>>> wrong_user_display({"name": "Jane", "age": 25})
'Jane (25)'
>>> wrong_user_display()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File ... in wrong_user_display
name = user_metadata.pop("name")
KeyError: 'name'
解释很简单 - 在函数定义中将带有默认数据的字典分配给user_metadata,实际上是创建了一个字典,并且变量user_metadata指向它。函数体修改了这个对象,在程序运行时它会一直存在于内存中。当我们给它传递一个值时,这将取代我们刚刚创建的默认参数。当我们不想要这个对象时再次调用它,并且自上次运行以来它已经被修改;下一次运行它时,将不包含键,因为它们在上一次调用时被移除了。
修复也很简单 - 我们需要使用None作为默认的标记值,并在函数体中分配默认值。因为每个函数都有自己的作用域和生命周期,user_metadata将在每次出现None时被分配给字典:
def user_display(user_metadata: dict = None):
user_metadata = user_metadata or {"name": "John", "age": 30}
name = user_metadata.pop("name")
age = user_metadata.pop("age")
return f"{name} ({age})"
扩展内置类型
正确的扩展内置类型(如列表、字符串和字典)的方法是使用collections模块。
如果您直接扩展 dict 等类,您将得到可能不是您期望的结果。这是因为在 CPython 中,类的方法不会相互调用(应该调用),因此如果您覆盖其中一个方法,这不会被其他方法反映出来,导致意外的结果。例如,您可能想要覆盖__getitem__,然后当您使用for循环迭代对象时,您会注意到您在该方法中放置的逻辑没有被应用。
这一切都可以通过使用collections.UserDict来解决,它提供了对实际字典的透明接口,并且更加健壮。
假设我们想要一个最初由数字创建的列表将值转换为字符串,并添加前缀。第一种方法可能看起来解决了问题,但是是错误的:
class BadList(list):
def __getitem__(self, index):
value = super().__getitem__(index)
if index % 2 == 0:
prefix = "even"
else:
prefix = "odd"
return f"[{prefix}] {value}"
乍一看,它看起来像我们想要的对象行为。但是,如果我们尝试迭代它(毕竟,它是一个列表),我们会发现我们得不到我们想要的东西:
>>> bl = BadList((0, 1, 2, 3, 4, 5))
>>> bl[0]
'[even] 0'
>>> bl[1]
'[odd] 1'
>>> "".join(bl)
Traceback (most recent call last):
...
TypeError: sequence item 0: expected str instance, int found
join函数将尝试迭代(在列表上运行for循环),但期望的是字符串类型的值。这应该可以工作,因为这正是我们对列表所做的更改,但显然在迭代列表时,我们修改的__getitem__版本没有被调用。
这实际上是 CPython 的一个实现细节(一种 C 优化),在其他平台(如 PyPy)中不会发生(请参阅本章末尾的 PyPy 和 CPython 之间的差异)。
尽管如此,我们应该编写可移植且兼容所有实现的代码,因此我们将通过不是从list而是从UserList扩展来修复它:
from collections import UserList
class GoodList(UserList):
def __getitem__(self, index):
value = super().__getitem__(index)
if index % 2 == 0:
prefix = "even"
else:
prefix = "odd"
return f"[{prefix}] {value}"
现在事情看起来好多了:
>>> gl = GoodList((0, 1, 2))
>>> gl[0]
'[even] 0'
>>> gl[1]
'[odd] 1'
>>> "; ".join(gl)
'[even] 0; [odd] 1; [even] 2'
不要直接从 dict 扩展,而是使用collections.UserDict。对于列表,使用collections.UserList,对于字符串,使用collections.UserString。
总结
在本章中,我们已经探讨了 Python 的主要特性,目标是理解其最独特的特性,这些特性使 Python 成为与其他语言相比独特的语言。在这条道路上,我们探索了 Python 的不同方法、协议和它们的内部机制。
与上一章相反,这一章更加关注 Python。本书主题的一个关键要点是,清晰的代码不仅仅是遵循格式规则(当然,这对于良好的代码库是必不可少的)。这是一个必要条件,但不是充分条件。在接下来的几章中,我们将看到更多与代码相关的想法和原则,旨在实现更好的软件解决方案设计和实现。
通过本章的概念和想法,我们探索了 Python 的核心:其协议和魔术方法。现在应该清楚了,编写 Pythonic、惯用的代码的最佳方式不仅仅是遵循格式约定,还要充分利用 Python 提供的所有功能。这意味着有时您应该使用特定的魔术方法,实现上下文管理器等。
在下一章中,我们将把这些概念付诸实践,将软件工程的一般概念与它们在 Python 中的书写方式联系起来。
参考资料
读者将在以下参考资料中找到更多关于本章涵盖的一些主题的信息。 Python 中索引如何工作的决定是基于(EWD831),该文分析了数学和编程语言中范围的几种替代方案:
-
EWD831:为什么编号应该从零开始(
www.cs.utexas.edu/users/EWD/transcriptions/EWD08xx/EWD831.html) -
PEP-343: "with"语句(
www.python.org/dev/peps/pep-0343/) -
CC08:由 Robert C. Martin 撰写的书籍Clean Code: A Handbook of Agile Software Craftsmanship
-
Python 文档,
iter()函数(docs.python.org/3/library/functions.html#iter) -
PyPy 和 CPython 之间的区别(
pypy.readthedocs.io/en/latest/cpython_differences.html#subclasses-of-built-in-types)
第三章:良好代码的一般特征
这是一本关于 Python 软件构建的书。优秀的软件建立在良好的设计基础上。说到干净的代码之类的话,人们可能会认为我们将探讨只与软件的实现细节相关的良好实践,而不是其设计。然而,这种假设是错误的,因为代码并不是与设计不同的东西——代码就是设计。
代码可能是设计最详细的表现。在前两章中,我们讨论了为什么以一致的方式构建代码很重要,并且我们已经看到了编写更紧凑和成语化代码的习惯用法。现在是时候理解干净的代码就是这样,而且更多——最终目标是尽可能使代码健壮,并以最小化缺陷的方式编写代码,或者使它们完全显而易见,如果它们发生了。
本章和下一章侧重于更高层次的抽象设计原则。这些想法不仅与特定的 Python 相关,而且是软件工程的一般原则。
特别是对于本章,我们将回顾构建良好软件设计的不同原则。高质量的软件应该围绕这些想法构建,并且它们将作为设计工具。这并不意味着所有这些原则都应该始终应用;事实上,其中一些代表不同的观点(这就是按合同设计(DbC)方法与防御性编程相对立的情况)。其中一些取决于上下文,不一定适用于所有情况。
高质量的代码是一个具有多个维度的概念。我们可以将其类比为我们思考软件架构的质量属性。例如,我们希望我们的软件安全并且具有良好的性能、可靠性和可维护性,仅举几例。
本章的目标如下:
-
理解健壮软件背后的概念
-
学习如何在应用程序的工作流程中处理错误数据
-
设计可维护的软件,可以轻松扩展和适应新的需求
-
设计可重用的软件
-
编写有效的代码,保持开发团队的生产力高
按合同设计
我们正在处理的软件的某些部分并不是直接由用户调用的,而是由代码的其他部分调用的。当我们将应用程序的责任划分为不同的组件或层时,我们必须考虑它们之间的交互。
我们将不得不将一些功能封装在每个组件后面,并向将使用该功能的客户端公开一个接口,即应用程序编程接口(API)。我们为该组件编写的函数、类或方法在特定考虑下有一种特定的工作方式,如果不满足这些条件,我们的代码将崩溃。相反,调用该代码的客户端期望特定的响应,我们的函数未能提供这一点将代表一个缺陷。
也就是说,例如,如果我们有一个函数,预期与一系列整数类型的参数一起工作,并且另一个函数调用我们传递字符串,很明显它不应该按预期工作,但实际上,函数根本不应该运行,因为它被错误调用了(客户端犯了一个错误)。这个错误不应该悄无声息地通过。
当设计 API 时,预期的输入、输出和副作用应该有文档记录。但文档无法强制软件在运行时的行为。这些规则,代码的每个部分期望为了正常工作而满足的条件,以及调用者对它们的期望,应该成为设计的一部分,这就是合同的概念所在。
DbC 背后的想法是,与其在代码中隐含地放置每个参与方的期望,不如让双方就一个合同达成一致,如果违反了合同,就会引发异常,清楚地说明为什么不能继续。
在我们的上下文中,合同是一种强制执行软件组件通信过程中必须遵守的规则的构造。合同主要包括前置条件和后置条件,但在某些情况下,还描述了不变量和副作用:
-
前置条件:我们可以说这些是代码在运行之前进行的所有检查。它将检查函数可以继续进行之前必须满足的所有条件。一般来说,它是通过验证传递的参数中提供的数据集来实现的,但如果我们认为它们的副作用被这样的验证的重要性所掩盖,那么没有什么可以阻止我们运行各种验证(例如,验证数据库中的一个集合,一个文件,之前调用的另一个方法等)。请注意,这对调用者施加了约束。
-
后置条件:与前置条件相反,在函数调用返回后进行验证。后置条件验证用于验证调用者对该组件的期望。
-
不变量:可选地,在函数的文档字符串中记录不变量是一个好主意,这些不变量在函数运行时保持不变,作为函数逻辑正确性的表达。
-
副作用:可选地,我们可以在文档字符串中提及代码的任何副作用。
尽管在概念上,所有这些项目都构成了软件组件的合同的一部分,这也是应该放入这样一块文档的内容,但只有前两个(前置条件和后置条件)需要在低级别(代码)上执行。
我们之所以要通过合同设计,是因为如果出现错误,它们必须很容易被发现(通过注意是前置条件还是后置条件失败,我们将更容易地找到罪魁祸首),以便可以快速纠正。更重要的是,我们希望代码的关键部分避免在错误的假设下执行。这应该有助于清楚地标记责任的界限和错误的发生,而不是说——应用程序的这一部分出现了问题...但是调用者提供了错误的参数,那么我们应该在哪里应用修复?
这个想法是,前置条件约束了客户端(如果他们想要运行代码的某个部分,他们就有义务满足这些条件),而后置条件约束了相关组件对客户端可以验证和执行的某些保证。
通过这种方式,我们可以快速确定责任。如果前置条件失败,我们知道这是客户端的缺陷。另一方面,如果后置条件检查失败,我们知道问题出现在例程或类(供应商)本身。
特别是关于前置条件,重要的是要强调它们可以在运行时进行检查,如果发生,被调用的代码就不应该运行(因为它的条件不成立,而且这样做可能会使情况变得更糟)。
前置条件
前置条件是函数或方法期望接收的所有保证,以便正确工作。在一般的编程术语中,这通常意味着提供正确形式的数据,例如初始化的对象,非空值等。对于 Python 来说,特别是由于其动态类型,这也意味着有时我们需要检查所提供的数据的确切类型。这与类型检查并不完全相同,mypy会执行此操作,而是验证所需的确切值。
通过使用静态分析工具,例如mypy,可以尽早地检测到这些检查中的一部分,但这些检查还不够。函数应该对其要处理的信息进行适当的验证。
现在,这就引出了一个问题,即根据我们是让客户在调用函数之前验证所有数据,还是允许函数在运行自己的逻辑之前验证接收到的所有数据的地方放置验证逻辑。前者对应于宽容的方法(因为函数本身仍然允许任何数据,可能是格式不正确的数据),而后者对应于严格的方法。
为了分析的目的,我们更喜欢在 DbC 方面采取严格的方法,因为这通常是在健壮性方面最安全的选择,也是行业中最常见的做法。
无论我们决定采取哪种方法,我们都应该始终牢记非冗余原则,即函数的每个前置条件的执行应该由合同的两个部分中的一个来完成,而不是两者都完成。这意味着我们将验证逻辑放在客户端上,或者我们将其留给函数本身,但在任何情况下都不应该重复(这也与 DRY 原则有关,我们将在本章后面讨论)。
后置条件
后置条件是合同的一部分,负责在方法或函数返回后强制执行状态。
假设函数或方法已经以正确的属性被调用(也就是说,满足了其前置条件),那么后置条件将保证某些属性得到保持。
这个想法是使用后置条件来检查和验证客户可能需要的一切。如果方法执行正确,并且后置条件验证通过,那么调用该代码的任何客户端应该能够无问题地使用返回的对象,因为合同已经履行。
Python 的合同
在撰写本书时,名为 PEP-316 的 Python 合同编程被推迟。这并不意味着我们不能在 Python 中实现它,因为正如在本章开头介绍的那样,这是一个通用的设计原则。
可能最好的执行方法是通过向我们的方法、函数和类添加控制机制,如果它们失败会引发RuntimeError异常或ValueError。很难为正确的异常类型制定一般规则,因为这在很大程度上取决于特定的应用。前面提到的这些异常是最常见的异常类型,但如果它们不能准确地解决问题,创建自定义异常将是最佳选择。
我们还希望尽可能地保持代码的隔离。也就是说,前置条件的代码在一个部分,后置条件的代码在另一个部分,函数的核心分开。我们可以通过创建更小的函数来实现这种分离,但在某些情况下,实现装饰器可能是一个有趣的选择。
按合同设计-结论
这种设计原则的主要价值在于有效地确定问题所在。通过定义合同,当运行时出现问题时,清楚地知道代码的哪一部分出了问题,以及是什么破坏了合同。
遵循这一原则的结果是代码将更加健壮。每个组件都在强制执行自己的约束并保持一些不变量,只要这些不变量得到保持,程序就可以被证明是正确的。
它还有助于更好地澄清程序的结构。与其试图运行临时验证,或者试图克服所有可能的失败场景,合同明确指定了每个函数或方法期望正常工作的内容,以及对它们的期望。
当然,遵循这些原则也会增加额外的工作,因为我们不仅仅是在编写主要应用程序的核心逻辑,还有合同。此外,我们可能还希望考虑为这些合同添加单元测试。然而,这种方法所获得的质量在长远来看是值得的;因此,对应用程序的关键组件实施这一原则是一个好主意。
然而,为了使这种方法有效,我们应该仔细考虑我们愿意验证什么,这必须是一个有意义的值。例如,定义仅检查传递给函数的参数的正确数据类型的合同并没有太多意义。许多程序员会认为这就像试图使 Python 成为一种静态类型的语言。不管怎样,工具如 Mypy,结合注释的使用,会更好地实现这一目的,而且付出的努力更少。考虑到这一点,设计合同以便它们确实有价值,例如检查传递和返回的对象的属性,它们必须满足的条件等等。
防御性编程
防御性编程与 DbC 有些不同的方法;它不是陈述必须在合同中保持的所有条件,如果不满足将引发异常并使程序失败,而是更多地使代码的所有部分(对象、函数或方法)能够保护自己免受无效输入的影响。
防御性编程是一种具有多个方面的技术,如果与其他设计原则结合使用,尤其有用(这意味着它遵循与 DbC 不同的哲学,并不意味着要么是这种情况,要么是那种情况——它可能意味着它们可以互补)。
防御性编程的主要思想是如何处理我们可能预期发生的情况的错误,以及如何处理不应该发生的错误(当发生不可能的情况时)。前者将属于错误处理程序,而后者将是断言的情况,这两个主题我们将在接下来的部分中探讨。
错误处理
在我们的程序中,我们会采用错误处理程序来处理我们预期会导致错误的情况。这通常是数据输入的情况。
错误处理的理念是优雅地响应这些预期的错误,试图要么继续我们的程序执行,要么决定失败,如果错误被证明是无法克服的。
我们可以通过不同的方法处理程序中的错误,但并非所有方法都总是适用。以下是其中一些方法:
-
值替换
-
错误日志记录
-
异常处理
值替换
在某些情况下,当出现错误并且软件有可能产生不正确的值或完全失败时,我们可能能够用另一个更安全的值替换结果。我们称这种值替换,因为我们实际上是用一个被认为是非破坏性的值(可以是默认值、众所周知的常量、哨兵值,或者简单地是根本不影响结果的东西,比如在结果意图应用于求和的情况下返回零)来替换实际错误的结果。
然而,并非总是可能进行值替换。这种策略必须在替换值实际上是安全选项的情况下谨慎选择。做出这个决定是在健壮性和正确性之间进行权衡。当软件程序在错误场景中甚至不会失败时,它是健壮的。但这也不正确。
对于某些类型的软件来说,这可能是不可接受的。如果应用程序很关键,或者正在处理的数据太敏感,这不是一个选择,因为我们不能提供给用户(或应用程序的其他部分)错误的结果。在这些情况下,我们选择正确性,而不是在产生错误结果时让程序崩溃。
这个决定的一个稍微不同、更安全的版本是对未提供的数据使用默认值。这可能是代码的一部分可以使用默认行为的情况,例如,未设置的环境变量的默认值,配置文件中缺少的条目,或者函数的参数。我们可以在 Python 的 API 的不同方法中找到支持这一点的例子,例如,字典有一个get方法,它的(可选的)第二个参数允许您指定一个默认值:
>>> configuration = {"dbport": 5432}
>>> configuration.get("dbhost", "localhost")
'localhost'
>>> configuration.get("dbport")
5432
环境变量具有类似的 API:
>>> import os
>>> os.getenv("DBHOST")
'localhost'
>>> os.getenv("DPORT", 5432)
5432
在前面的两个例子中,如果未提供第二个参数,将返回None,因为这是这些函数定义的默认值。我们也可以为我们自己函数的参数定义默认值:
>>> def connect_database(host="localhost", port=5432):
... logger.info("connecting to database server at %s:%i", host, port)
一般来说,用默认值替换缺失的参数是可以接受的,但用合法的接近值替换错误数据更加危险,可能会掩盖一些错误。在决定采用这种方法时,要考虑这个标准。
异常处理
在存在不正确或缺失的输入数据的情况下,有时可以通过一些在前一节中提到的示例来纠正情况。然而,在其他情况下,最好是阻止程序继续以错误的数据运行,而不是让它在错误的假设下进行计算。在这些情况下,失败并通知调用者出现问题是一个很好的方法,这就是我们在 DbC 中看到的违反的前提条件的情况。
然而,错误的输入数据并不是函数出错的唯一可能方式。毕竟,函数不仅仅是传递数据;它们还具有副作用并连接到外部组件。
可能是函数调用中的错误是由于这些外部组件中的一个问题,而不是我们函数本身。如果是这种情况,我们的函数应该适当地进行通信。这将使调试变得更容易。函数应该清楚、明确地通知应用程序的其他部分有关无法忽略的错误,以便可以相应地进行处理。
实现这一点的机制是异常。重要的是要强调异常应该用于清楚地宣布异常情况,而不是根据业务逻辑改变程序的流程。
如果代码尝试使用异常来处理预期的场景或业务逻辑,程序的流程将变得更难阅读。这将导致一种情况,即异常被用作一种go-to语句,这可能会跨越调用堆栈的多个级别(直到调用者函数),违反了将逻辑封装到其正确抽象级别的封装。如果这些except块混合了业务逻辑和代码试图防御的真正异常情况,情况可能会变得更糟;在这种情况下,将更难区分我们必须维护的核心逻辑和需要处理的错误。
不要将异常用作业务逻辑的go-to机制。只有在代码出现实际问题需要调用者知道时才引发异常。
这个最后的概念是一个重要的概念;异常通常是关于通知调用者有什么问题。这意味着异常应该谨慎使用,因为它们削弱了封装性。一个函数有越多的异常,调用函数就必须预期越多,因此了解它正在调用的函数。如果一个函数引发了太多的异常,这意味着它不是那么无上下文的,因为每次我们想要调用它,我们都必须牢记它可能产生的所有副作用。
这可以作为一种启发式方法,用来判断一个函数是否不够内聚,责任太多。如果它引发了太多的异常,这可能意味着它必须被拆分成多个更小的函数。
以下是与 Python 中异常相关的一些建议。
在正确的抽象级别处理异常
异常也是主要函数的一部分,只做一件事。函数处理(或引发)的异常必须与其封装的逻辑一致。
在这个例子中,我们可以看到我们所说的混合不同抽象级别是什么意思。想象一个对象,它作为我们应用程序中一些数据的传输器。它连接到一个外部组件,在那里数据将在解码后被发送。在下面的清单中,我们将专注于deliver_event方法:
class DataTransport:
"""An example of an object handling exceptions of different levels."""
retry_threshold: int = 5
retry_n_times: int = 3
def __init__(self, connector):
self._connector = connector
self.connection = None
def deliver_event(self, event):
try:
self.connect()
data = event.decode()
self.send(data)
except ConnectionError as e:
logger.info("connection error detected: %s", e)
raise
except ValueError as e:
logger.error("%r contains incorrect data: %s", event, e)
raise
def connect(self):
for _ in range(self.retry_n_times):
try:
self.connection = self._connector.connect()
except ConnectionError as e:
logger.info(
"%s: attempting new connection in %is",
e,
self.retry_threshold,
)
time.sleep(self.retry_threshold)
else:
return self.connection
raise ConnectionError(
f"Couldn't connect after {self.retry_n_times} times"
)
def send(self, data):
return self.connection.send(data)
对于我们的分析,让我们放大并关注deliver_event()方法如何处理异常。
ValueError与ConnectionError有什么关系?不多。通过查看这两种非常不同的错误,我们可以了解责任应该如何划分。ConnectionError应该在connect方法内处理。这将允许行为的清晰分离。例如,如果这个方法需要支持重试,那将是一种方法。相反,ValueError属于事件的decode方法。通过这种新的实现,这个方法不需要捕获任何异常——它以前担心的异常要么由内部方法处理,要么故意留待抛出。
我们应该将这些片段分开成不同的方法或函数。对于连接管理,一个小函数就足够了。这个函数将负责尝试建立连接,捕获异常(如果发生),并相应地记录它们:
def connect_with_retry(connector, retry_n_times, retry_threshold=5):
"""Tries to establish the connection of <connector> retrying
<retry_n_times>.
If it can connect, returns the connection object.
If it's not possible after the retries, raises ConnectionError
:param connector: An object with a `.connect()` method.
:param retry_n_times int: The number of times to try to call
``connector.connect()``.
:param retry_threshold int: The time lapse between retry calls.
"""
for _ in range(retry_n_times):
try:
return connector.connect()
except ConnectionError as e:
logger.info(
"%s: attempting new connection in %is", e, retry_threshold
)
time.sleep(retry_threshold)
exc = ConnectionError(f"Couldn't connect after {retry_n_times} times")
logger.exception(exc)
raise exc
然后,我们将在我们的方法中调用这个函数。至于事件中的ValueError异常,我们可以用一个新对象分开它,并进行组合,但对于这个有限的情况来说,这将是过度的,所以只需将逻辑移到一个单独的方法中就足够了。有了这两个考虑,方法的新版本看起来更加简洁和易于阅读:
class DataTransport:
"""An example of an object that separates the exception handling by
abstraction levels.
"""
retry_threshold: int = 5
retry_n_times: int = 3
def __init__(self, connector):
self._connector = connector
self.connection = None
def deliver_event(self, event):
self.connection = connect_with_retry(
self._connector, self.retry_n_times, self.retry_threshold
)
self.send(event)
def send(self, event):
try:
return self.connection.send(event.decode())
except ValueError as e:
logger.error("%r contains incorrect data: %s", event, e)
raise
不要暴露 tracebacks
这是一个安全考虑。在处理异常时,如果错误太重要,甚至让程序失败,如果这是特定情况的决定,并且正确性优先于健壮性。
当有一个表示问题的异常时,重要的是要尽可能详细地记录(包括 traceback 信息、消息和我们能收集到的所有信息),以便能够有效地纠正问题。同时,我们希望为自己包含尽可能多的细节——我们绝对不希望任何这些对用户可见。
在 Python 中,异常的回溯包含非常丰富和有用的调试信息。不幸的是,这些信息对于想要尝试损害应用程序的攻击者或恶意用户也非常有用,更不用说泄漏将代表重要的信息披露,危及您的组织的知识产权(代码的部分将被公开)。
如果选择让异常传播,请确保不要披露任何敏感信息。此外,如果必须通知用户有问题,请选择通用消息(例如出现了一些问题,或找不到页面)。这是 Web 应用程序中常用的技术,当发生 HTTP 错误时显示通用的信息性消息。
避免空的 except 块
这甚至被称为最邪恶的 Python 反模式(REAL 01)。虽然预期并保护我们的程序免受一些错误的影响是好的,但过于防御可能会导致更严重的问题。特别是,过于防御的唯一问题是有一个空的except块,它会悄悄地通过而不做任何事情。
Python 非常灵活,可以让我们编写可能有错误但不会引发错误的代码,就像这样:
try:
process_data()
except:
pass
这样做的问题是它永远不会失败。即使应该失败也是如此。如果您还记得 Python 之禅,错误永远不应该悄悄地传递,这也是不符合 Python 风格的。
如果有真正的异常,这段代码将不会失败,这可能是我们最初想要的。但是如果有缺陷呢?我们需要知道我们的逻辑中是否存在错误,以便进行更正。编写这样的代码块将掩盖问题,使维护变得更加困难。
有两种选择:
-
捕获更具体的异常(不要太宽泛,例如
Exception)。实际上,一些代码检查工具和集成开发环境在代码处理过于宽泛的异常时会在某些情况下发出警告。 -
在
except块上进行一些实际的错误处理。
最好的做法是同时应用这两个项目。
处理更具体的异常(例如AttributeError或KeyError)将使程序更易于维护,因为读者将知道可以预期什么,并且可以了解原因。这也将使其他异常可以自由地被引发,如果发生这种情况,这可能意味着一个错误,只是这一次可以被发现。
处理异常本身可能意味着多种情况。在其最简单的形式中,可能只是记录异常(确保使用logger.exception或logger.error提供发生的完整上下文)。其他替代方案可能是返回默认值(替换,只是在检测到错误后,而不是在引起错误之前),或引发不同的异常。
如果您选择引发不同的异常,以包括导致问题的原始异常,这将引出我们下一个要点。
包括原始异常
作为我们的错误处理逻辑的一部分,我们可能决定引发不同的异常,甚至可能更改其消息。如果是这种情况,建议包括导致该异常的原始异常。
在 Python 3(PEP-3134)中,我们现在可以使用raise <e> from <original_exception>语法。使用此结构时,原始的回溯将嵌入到新的异常中,并且原始异常将设置在生成的异常的__cause__属性中。
例如,如果我们希望在项目内部使用自定义异常包装默认异常,我们仍然可以这样做,同时包含有关根异常的信息:
class InternalDataError(Exception):
"""An exception with the data of our domain problem."""
def process(data_dictionary, record_id):
try:
return data_dictionary[record_id]
except KeyError as e:
raise InternalDataError("Record not present") from e
在更改异常类型时,始终使用raise <e> from <o>语法。
在 Python 中使用断言
断言用于永远不应该发生的情况,因此assert语句中的表达式必须表示一个不可能的条件。如果发生这种情况,这意味着软件中存在缺陷。
与错误处理方法相比,这里没有(或不应该有)继续程序的可能性。如果发生这样的错误,程序必须停止。停止程序是有道理的,因为正如前面所述,我们面临的是一个缺陷,因此无法通过发布修复此缺陷的新软件版本来继续前进。
使用断言的想法是防止程序在出现无效场景时造成进一步的损害。有时,最好是停止并让程序崩溃,而不是让它在错误的假设下继续处理。
因此,断言不应与业务逻辑混合,也不应作为软件的控制流机制使用。以下示例是一个坏主意:
try:
assert condition.holds(), "Condition is not satisfied"
except AssertionError:
alternative_procedure()
不要捕获AssertionError异常。
确保程序在断言失败时终止。
在断言语句中包含描述性错误消息,并记录错误,以确保您可以正确调试和纠正问题。
前面的代码之所以是一个坏主意的另一个重要原因是,除了捕获AssertionError之外,断言中的语句是一个函数调用。函数调用可能会产生副作用,并且它们并不总是可重复的(我们不知道再次调用condition.holds()是否会产生相同的结果)。此外,如果我们在那一行停止调试器,我们可能无法方便地看到导致错误的结果,而且即使我们再次调用该函数,我们也不知道那是否是有问题的值。
更好的替代方案需要更少的代码行,并提供更多有用的信息:
result = condition.holds()
assert result > 0, "Error with {0}".format(result)
关注点的分离
这是一个应用在多个层次的设计原则。它不仅仅是关于低级设计(代码),而且在更高的抽象层次上也是相关的,因此当我们谈论架构时,它将在后面出现。
不同的责任应该放在应用程序的不同组件、层或模块中。程序的每个部分应该只负责一部分功能(我们称之为其关注点),并且对其余部分一无所知。
软件中关注点的分离旨在通过最小化连锁效应来增强可维护性。连锁效应意味着软件中变化的传播从一个起点开始。这可能是错误或异常触发一系列其他异常的情况,导致失败,从而导致应用程序的远程部分出现缺陷。也可能是我们不得不改变散布在代码库多个部分的大量代码,因为函数定义的简单更改的结果。
显然,我们不希望发生这些情况。软件必须易于更改。如果我们必须修改或重构代码的某个部分,这对应用程序的其余部分的影响应该最小化,实现这一点的方法是通过适当的封装。
同样,我们希望任何潜在错误都能被包含,以免造成重大损害。
这个概念与 DbC 原则相关,因为每个关注点都可以通过合同来执行。当合同被违反,并且由于这种违反而引发异常时,我们知道程序的哪一部分出现了故障,以及未能满足的责任是什么。
尽管有这种相似性,关注点的分离更进一步。我们通常认为函数、方法或类之间存在合同,虽然这也适用于需要分离的责任,但关注点分离的想法也适用于 Python 模块、包,基本上适用于任何软件组件。
内聚和耦合
这些是良好软件设计的重要概念。
一方面,内聚意味着对象应该有一个小而明确定义的目的,并且它们应该尽可能少地做事情。它遵循与 Unix 命令相似的哲学,即只做一件事,并且做得很好。我们的对象越内聚,它们就越有用和可重用,使我们的设计变得更好。
另一方面,耦合指的是两个或多个对象彼此依赖的概念。这种依赖性带来了一些不希望的后果:
-
没有代码重用:如果一个函数过于依赖特定对象,或者需要太多参数,它就与这个对象耦合,这意味着在不同的上下文中使用该函数将非常困难(为了这样做,我们必须找到一个适合的参数,符合非常严格的接口)
-
连锁反应:两个部分中的一个发生变化肯定会影响另一个,因为它们太接近了
-
抽象水平低:当两个函数如此紧密相关时,很难将它们视为解决不同抽象水平问题的不同关注点
经验法则:定义良好的软件将实现高内聚和低耦合。
生活中的缩写
在这一部分,我们将回顾一些原则,这些原则产生了一些良好的设计思想。重点是通过易于记忆的缩写快速与良好的软件实践联系起来,作为一种记忆规则。如果你记住这些词,你将更容易将它们与良好的实践联系起来,并且更快地找到你正在查看的特定代码行背后的正确思想。
这些绝不是正式或学术定义,而更像是从多年的软件行业工作中产生的经验性想法。其中一些出现在书籍中,因为它们是由重要的作者创造的(请参考参考文献以了解更多细节),其他可能源于博客文章、论文或会议演讲。
DRY/OAOO
不要重复自己(DRY)和一次且仅一次(OAOO)的想法是密切相关的,因此它们被放在一起。它们是不言自明的,你应该尽量避免重复。
代码中的事物、知识,必须只定义一次,并且在一个地方。当你需要对代码进行更改时,应该只有一个正确的位置进行修改。否则这是一个设计不良的系统的迹象。
代码重复是直接影响可维护性的问题。代码重复是非常不希望的,因为它有许多负面后果:
-
这容易出错:当某个逻辑在代码中多次重复出现,并且需要更改时,这意味着我们依赖于有效地纠正所有具有这种逻辑的实例,而不会忘记其中任何一个,因为在这种情况下将会出现错误。
-
这很昂贵:与前一点相关,对多个位置进行更改需要更多的时间(开发和测试工作),而不是只定义一次。这将拖慢团队的速度。
-
不可靠:也与第一点相关,当一个上下文需要在多个位置进行更改时,你依赖于编写代码的人记住所有需要进行修改的实例。没有一个真正的信息来源。
重复通常是由于忽视(或遗忘)代码代表知识而引起的。通过赋予代码的某些部分意义,我们正在识别和标记这些知识。
让我们通过一个例子来看看这意味着什么。想象一下,在一个学习中心,学生根据以下标准排名:通过考试得 11 分,未通过考试扣 5 分,每在该机构学习一年扣 2 分。以下不是实际代码,而只是对这在真实代码库中可能如何分散的一种表示:
def process_students_list(students):
# do some processing...
students_ranking = sorted(
students, key=lambda s: s.passed * 11 - s.failed * 5 - s.years * 2
)
# more processing
for student in students_ranking:
print(
"Name: {0}, Score: {1}".format(
student.name,
(student.passed * 11 - student.failed * 5 - student.years * 2),
)
)
注意排序函数中的 lambda 表示领域问题的一些有效知识,但它并没有反映出来(它没有名称,没有适当和合适的位置,没有赋予代码任何含义)。代码中的这种缺乏含义导致了我们在列出排名时发现的重复。
我们应该在我们的代码中反映我们对领域问题的了解,这样我们的代码就不太可能遭受重复,并且更容易理解。
def score_for_student(student):
return student.passed * 11 - student.failed * 5 - student.years * 2
def process_students_list(students):
# do some processing...
students_ranking = sorted(students, key=score_for_student)
# more processing
for student in students_ranking:
print(
"Name: {0}, Score: {1}".format(
student.name, score_for_student(student)
)
)
公平的免责声明:这只是对代码重复的一个特征进行分析。实际上,代码重复有更多的情况、类型和分类,整个章节都可以专门讨论这个主题,但在这里我们专注于一个特定的方面,以使首字母缩略词背后的思想清晰明了。
在这个例子中,我们采取了可能是消除重复最简单的方法:创建一个函数。根据情况,最好的解决方案可能是不同的。在某些情况下,可能需要创建一个全新的对象(也许整个抽象缺失)。在其他情况下,我们可以使用上下文管理器消除重复。迭代器或生成器(在第七章中描述,使用生成器)也可以帮助避免代码中的重复,装饰器(在第五章中解释,使用装饰器改进我们的代码)也会有所帮助。
不幸的是,没有一般规则或模式可以告诉你 Python 的哪些特性最适合解决代码重复的问题,但希望在看到本书中的例子以及 Python 元素的使用后,读者将能够发展自己的直觉。
YAGNI
YAGNI(You Ain't Gonna Need It的缩写)是一个想法,当你写解决方案时,如果你不想过度设计,你可能经常要记住这个想法。
我们希望能够轻松修改我们的程序,所以我们希望使它们具有未来的可扩展性。与此一致,许多开发人员认为他们必须预测所有未来的需求,并创建非常复杂的解决方案,因此创建了难以阅读、维护和理解的抽象。后来,事实证明那些预期的需求没有出现,或者以不同的方式出现(惊喜!),原来应该精确处理的原始代码也无法工作。问题是,现在更难重构和扩展我们的程序。发生的是,原始解决方案没有正确处理原始需求,当前的需求也没有,只是因为它是错误的抽象。
拥有可维护的软件并不是关于预测未来的需求(不要进行未来学!)。它是关于编写只以一种可能(和容易)后续更改的方式来满足当前需求的软件。换句话说,在设计时,确保你的决定不会束缚你,你将能够继续构建,但不要构建比必要的更多。
KIS
KIS(Keep It Simple的缩写)与前面的观点非常相关。当你设计软件组件时,避免过度设计;问问自己你的解决方案是否是最小的,适合问题的解决方案。
实现最小功能,正确解决问题,不要使解决方案变得比必要的更复杂。记住:设计越简单,维护性就越强。
这个设计原则是一个我们在所有抽象层面都要记住的想法,无论是高层设计还是处理特定行代码。
在高层次上,考虑我们正在创建的组件。我们真的需要它们吗?这个模块现在实际上需要完全可扩展吗?强调最后一部分——也许我们想要使该组件可扩展,但现在不是正确的时间,或者现在还不适合这样做,因为我们仍然没有足够的信息来创建适当的抽象,试图在这一点上提出通用接口只会导致更糟糕的问题。
在代码方面,保持简单通常意味着使用最适合问题的最小数据结构。您很可能会在标准库中找到它。
有时,我们可能会过于复杂化代码,创建比必要更多的函数或方法。以下类从一组提供的关键字参数创建一个命名空间,但它的代码接口相当复杂:
class ComplicatedNamespace:
"""An convoluted example of initializing an object with some properties."""
ACCEPTED_VALUES = ("id_", "user", "location")
@classmethod
def init_with_data(cls, **data):
instance = cls()
for key, value in data.items():
if key in cls.ACCEPTED_VALUES:
setattr(instance, key, value)
return instance
添加额外的类方法来初始化对象似乎并不是真正必要的。然后,迭代和其中的setattr调用使事情变得更加奇怪,向用户呈现的接口也不是很清晰:
>>> cn = ComplicatedNamespace.init_with_data(
... id_=42, user="root", location="127.0.0.1", extra="excluded"
... )
>>> cn.id_, cn.user, cn.location
(42, 'root', '127.0.0.1')
>>> hasattr(cn, "extra")
False
用户必须知道存在这种方法,这并不方便。保持简单,就像初始化 Python 中的任何其他对象一样(毕竟,有一个方法可以做到)使用__init__方法会更好:
class Namespace:
"""Create an object from keyword arguments."""
ACCEPTED_VALUES = ("id_", "user", "location")
def __init__(self, **data):
accepted_data = {
k: v for k, v in data.items() if k in self.ACCEPTED_VALUES
}
self.__dict__.update(accepted_data)
记住 Python 的禅意:简单胜于复杂。
EAFP/LBYL
EAFP(代表宁愿请求宽恕,而不是事先征得许可),而LBYL(代表先看后跳)。
EAFP 的理念是,我们编写代码以便直接执行操作,然后在以后处理后果,以防它不起作用。通常,这意味着尝试运行一些代码,期望它能够工作,但如果它不起作用,则捕获异常,然后在 except 块中处理纠正代码。
这是LBYL的相反。正如其名称所示,在先看后跳的方法中,我们首先检查我们将要使用的内容。例如,我们可能希望在尝试操作文件之前检查文件是否可用:
if os.path.exists(filename):
with open(filename) as f:
...
这可能对其他编程语言有好处,但这不是编写代码的 Pythonic 方式。Python 是建立在 EAFP 等思想上的,并鼓励您遵循它们(记住,显式胜于隐式)。这段代码将被重写如下:
try:
with open(filename) as f:
...
except FileNotFoundError as e:
logger.error(e)
更喜欢 EAFP 而不是 LBYL。
组合和继承
在面向对象的软件设计中,通常会讨论如何使用该范式的主要思想(多态性、继承和封装)来解决一些问题。
这些想法中最常用的可能是继承——开发人员通常首先创建一个类层次结构,其中包含他们将需要的类,并决定每个类应该实现哪些方法。
虽然继承是一个强大的概念,但它也有其危险。主要的危险是,每次我们扩展一个基类,我们都会创建一个与父类紧密耦合的新类。正如我们已经讨论过的,耦合是我们在设计软件时要尽量减少的事情之一。
开发人员将继承与代码重用联系在一起的主要用途之一。虽然我们应该始终支持代码重用,但强制设计使用继承来重用代码并不是一个好主意,只是因为我们可以免费从父类获取方法。重用代码的正确方法是拥有高度内聚的对象,这些对象可以很容易地组合,并且可以在多个上下文中工作。
当继承是一个好决定时
在创建派生类时,我们必须小心,因为这是一把双刃剑——一方面,它的优势是我们可以免费获得父类方法的所有代码,但另一方面,我们将所有这些代码都放入一个新的定义中,这意味着我们可能在新定义中放入了太多的功能。
在创建一个新的子类时,我们必须考虑它是否实际上会使用它刚刚继承的所有方法,这是一个判断类是否正确定义的启发法。如果相反,我们发现我们不需要大多数方法,并且必须重写或替换它们,这是一个设计错误,可能由多种原因引起:
-
超类定义模糊,并包含太多责任,而不是一个明确定义的接口
-
子类不是它试图扩展的超类的适当专门化
使用继承的一个很好的情况是,当你有一个类,它定义了某些组件及其行为,这些行为由这个类的接口(公共方法和属性)定义,然后你需要专门化这个类,以创建做同样事情但添加了其他东西,或者改变了一些特定部分行为的对象。
在 Python 标准库中,你可以找到继承的良好用例。例如,在http.server包中(docs.python.org/3/library/http.server.html#http.server.BaseHTTPRequestHandler),我们可以找到一个名为BaseHTTPRequestHandler的基类,以及像SimpleHTTPRequestHandler这样的子类,它们通过添加或更改其基本接口来扩展这个基类。
谈到接口定义,这是继承的另一个很好的用途。当我们想要强制一些对象的接口时,我们可以创建一个抽象基类,它本身不实现行为,而是只定义接口——每个扩展这个类的类都必须实现这些接口才能成为一个合适的子类型。
最后,继承的另一个很好的用例是异常。我们可以看到 Python 中的标准异常派生自Exception。这就是允许你有一个通用的子句,比如except Exception:,它将捕获每一个可能的错误。重要的一点是概念上的,它们是从Exception派生的类,因为它们是更具体的异常。这也适用于知名的库,比如requests,其中HTTPError是RequestException,而RequestException又是IOError。
继承的反模式
如果前面的部分必须总结成一个单词,那就是专门化。继承的正确用法是专门化对象,并从基本对象开始创建更详细的抽象。
父类(或基类)是新派生类的公共定义的一部分。这是因为继承的方法将成为这个新类的接口的一部分。因此,当我们阅读一个类的公共方法时,它们必须与父类定义的一致。
例如,如果我们看到一个从BaseHTTPRequestHandler派生的类实现了一个名为handle()的方法,这是有意义的,因为它是覆盖父类的一个方法。如果它有任何其他方法,其名称与与 HTTP 请求相关的操作有关,那么我们也可以认为它被正确放置(但如果我们在该类中找到名为process_purchase()的内容,我们就不会这样认为)。
前面的例子可能看起来很明显,但这种情况经常发生,特别是当开发人员试图使用继承来重用代码时。在下一个例子中,我们将看到一个典型的情况,代表了 Python 中的一个常见反模式——需要表示一个领域问题,并为该问题设计一个合适的数据结构,但是不是创建一个使用这样的数据结构的对象,而是对象本身成为数据结构。
让我们通过一个例子更具体地看看这些问题。假设我们有一个管理保险的系统,其中一个模块负责将策略应用于不同的客户。我们需要在内存中保存一组正在处理的客户,以便在进一步处理或持久化之前应用这些更改。我们需要的基本操作是存储一个新客户及其记录作为卫星数据,对策略进行更改,或编辑一些数据,仅举几例。我们还需要支持批量操作,也就是说,当策略本身发生变化(这个模块当前正在处理的策略),我们必须将这些更改应用到当前交易中的所有客户。
从我们需要的数据结构的角度来思考,我们意识到以恒定时间访问特定客户的记录是一个很好的特性。因此,像policy_transaction[customer_id]这样的接口看起来很不错。从这里,我们可能会认为可订阅的对象是一个好主意,进一步地,我们可能会陷入认为我们需要的对象是一个字典:
class TransactionalPolicy(collections.UserDict):
"""Example of an incorrect use of inheritance."""
def change_in_policy(self, customer_id, **new_policy_data):
self[customer_id].update(**new_policy_data)
有了这段代码,我们可以通过其标识符获取有关客户的策略的信息:
>>> policy = TransactionalPolicy({
... "client001": {
... "fee": 1000.0,
... "expiration_date": datetime(2020, 1, 3),
... }
... })
>>> policy["client001"]
{'fee': 1000.0, 'expiration_date': datetime.datetime(2020, 1, 3, 0, 0)}
>>> policy.change_in_policy("client001", expiration_date=datetime(2020, 1, 4))
>>> policy["client001"]
{'fee': 1000.0, 'expiration_date': datetime.datetime(2020, 1, 4, 0, 0)}
当然,我们在第一次实现中实现了我们想要的接口,但是代价是什么?现在,这个类有了很多额外的行为,执行了不必要的方法:
>>> dir(policy)
[ # all magic and special method have been omitted for brevity...
'change_in_policy', 'clear', 'copy', 'data', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values']
这个设计至少存在两个主要问题。一方面,层次结构是错误的。从基类创建一个新类在概念上意味着它是类的更具体的版本(因此得名)。TransactionalPolicy怎么会是一个字典呢?这有意义吗?请记住,这是对象的公共接口的一部分,所以用户会看到这个类,它们的层次结构,并且会注意到这样一个奇怪的特化,以及它的公共方法。
这带我们来到第二个问题——耦合。交易策略的接口现在包括了字典的所有方法。交易策略真的需要pop()或items()这样的方法吗?然而,它们在那里。它们也是公共的,因此任何使用此接口的用户都有权调用它们,无论它们可能带来什么不希望的副作用。更进一步——通过扩展字典,我们并没有真正获得太多好处。它实际上需要更新的唯一方法是当前策略变化对所有受影响的客户进行更新(change_in_policy()),而这个方法不在基类中,因此我们无论如何都必须自己定义它。
这是将实现对象与领域对象混合的问题。字典是一个实现对象,一个数据结构,适用于某些操作,并且像所有数据结构一样具有权衡。交易策略应该代表领域问题中的某个实体,这是我们试图解决的问题的一部分。
这样的层次结构是不正确的,仅仅因为我们从基类获得了一些魔术方法(通过扩展字典使对象可订阅)并不足以创建这样的扩展。实现类应该仅在创建其他更具体的实现类时才进行扩展。换句话说,如果要创建另一个(更具体或稍微修改的)字典,才应该扩展字典。相同的规则适用于领域问题的类。
这里的正确解决方案是使用组合。TransactionalPolicy不是一个字典——它使用一个字典。它应该在一个私有属性中存储一个字典,并通过代理从该字典实现__getitem__(),然后只实现它所需的其余公共方法:
class TransactionalPolicy:
"""Example refactored to use composition."""
def __init__(self, policy_data, **extra_data):
self._data = {**policy_data, **extra_data}
def change_in_policy(self, customer_id, **new_policy_data):
self._data[customer_id].update(**new_policy_data)
def __getitem__(self, customer_id):
return self._data[customer_id]
def __len__(self):
return len(self._data)
这种方式不仅在概念上是正确的,而且更具扩展性。如果底层数据结构(目前是字典)在将来发生变化,只要保持接口不变,对象的调用者就不会受到影响。这减少了耦合,最小化了涟漪效应,允许更好的重构(单元测试不应该被改变),并使代码更易于维护。
Python 中的多重继承
Python 支持多重继承。继承,当使用不当时,会导致设计问题,你也可以期望多重继承在实现不正确时也会产生更大的问题。
因此,多重继承是一把双刃剑。在某些情况下,它也可能非常有益。只要清楚,多重继承并没有什么问题——它唯一的问题是,当它没有正确实现时,它会放大问题。
当正确使用时,多重继承是一个完全有效的解决方案,并且这为新模式(例如我们在第九章*,常见设计模式中讨论的适配器模式)和混入打开了新的可能性。
多重继承最强大的应用之一可能是它使混入的创建成为可能。在探索混入之前,我们需要了解多重继承的工作原理,以及如何在复杂的继承结构中解析方法。
方法解析顺序(MRO)
有些人不喜欢多重继承,因为它在其他编程语言中有一些限制,例如所谓的菱形问题。当一个类从两个或更多类继承,并且所有这些类也从其他基类继承时,底层类将有多种方法来解析来自顶层类的方法。问题是,哪个实现会被使用?
考虑以下具有多重继承结构的图表。顶层类具有一个类属性并实现__str__方法。想象任何具体类,例如ConcreteModuleA12——它从BaseModule1和BaseModule2扩展,并且它们中的每一个都将从BaseModule中获取__str__的实现。这两种方法中的哪一个将成为ConcreteModuleA12的方法?
有了类属性的值,这将变得明显:
class BaseModule:
module_name = "top"
def __init__(self, module_name):
self.name = module_name
def __str__(self):
return f"{self.module_name}:{self.name}"
class BaseModule1(BaseModule):
module_name = "module-1"
class BaseModule2(BaseModule):
module_name = "module-2"
class BaseModule3(BaseModule):
module_name = "module-3"
class ConcreteModuleA12(BaseModule1, BaseModule2):
"""Extend 1 & 2"""
class ConcreteModuleB23(BaseModule2, BaseModule3):
"""Extend 2 & 3"""
现在,让我们测试一下调用了哪个方法:
>>> str(ConcreteModuleA12("test"))
'module-1:test'
没有冲突。Python 通过使用称为C3 线性化或 MRO 的算法来解决这个问题,该算法定义了方法将被调用的确定性方式。
实际上,我们可以明确地询问类的解析顺序:
>>> [cls.__name__ for cls in ConcreteModuleA12.mro()]
['ConcreteModuleA12', 'BaseModule1', 'BaseModule2', 'BaseModule', 'object']
了解在继承结构中方法将如何解析可以在设计类时对我们有利,因为我们可以利用混入。
混入
混入是一个封装了一些常见行为的基类,其目标是重用代码。通常,混入类本身并不实用,仅扩展这个类肯定不会起作用,因为大多数情况下它依赖于其他类中定义的方法和属性。想法是通过多重继承将混入类与其他类一起使用,以便混入中使用的方法或属性可用。
想象我们有一个简单的解析器,它接受一个字符串,并通过破折号(-)分隔的值提供迭代:
class BaseTokenizer:
def __init__(self, str_token):
self.str_token = str_token
def __iter__(self):
yield from self.str_token.split("-")
这非常直观:
>>> tk = BaseTokenizer("28a2320b-fd3f-4627-9792-a2b38e3c46b0")
>>> list(tk)
['28a2320b', 'fd3f', '4627', '9792', 'a2b38e3c46b0']
但现在我们希望将值发送为大写,而不改变基类。对于这个简单的例子,我们可以创建一个新类,但想象一下,许多类已经从BaseTokenizer扩展了,我们不想替换所有这些类。我们可以将一个新类混合到处理这种转换的层次结构中:
class UpperIterableMixin:
def __iter__(self):
return map(str.upper, super().__iter__())
class Tokenizer(UpperIterableMixin, BaseTokenizer):
pass
新的Tokenizer类非常简单。它不需要任何代码,因为它利用了 mixin。这种混合类型充当一种装饰器。根据我们刚才看到的,Tokenizer将从 mixin 中获取__iter__,而这个 mixin 又通过调用super()委托给了下一行中的类(即BaseTokenizer),但它将其值转换为大写,从而产生了期望的效果。
函数和方法中的参数
在 Python 中,函数可以以多种不同的方式定义接收参数,并且这些参数也可以由调用者以多种方式提供。
在软件工程中也有一套行业标准的接口定义实践,这与函数中参数的定义密切相关。
在本节中,我们将首先探讨 Python 函数中参数的机制,然后回顾与这些概念相关的软件工程的一般原则,最后将这两个概念联系起来。
Python 中函数参数的工作原理
首先,我们将探讨 Python 中函数参数传递的特殊性,然后我们将回顾与这些概念相关的良好软件工程实践的一般理论。
通过首先了解 Python 处理参数的可能性,我们将能够更容易地吸收一般规则,而且我们的想法是在这样做之后,我们可以很容易地得出关于处理参数时的良好模式或习惯用法的结论。然后,我们可以确定在哪些情况下 Pythonic 方法是正确的,在哪些情况下我们可能滥用了语言的特性。
参数如何被复制到函数中
Python 中的第一条规则是所有参数都是按值传递的。总是。这意味着当将值传递给函数时,它们被分配给函数签名定义中的变量,以便以后在函数中使用。您会注意到函数更改参数可能取决于参数的类型 - 如果我们传递mutable对象,并且函数的主体修改了它,那么当函数返回时,它们的值已经被改变了。
在接下来的内容中我们可以看到区别:
>>> def function(argument):
... argument += " in function"
... print(argument)
...
>>> immutable = "hello"
>>> function(immutable)
hello in function
>>> mutable = list("hello")
>>> immutable
'hello'
>>> function(mutable)
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
>>> mutable
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n']
>>>
这可能看起来像是一个不一致,但实际上并不是。当我们传递第一个参数,一个字符串,这个参数被分配给函数中的参数。由于字符串对象是不可变的,类似"argument += <expression>"这样的语句实际上会创建一个新对象"argument + <expression>",并将其分配回参数。在那一点上,argument只是函数范围内的一个局部变量,与调用者原始的变量无关。
另一方面,当我们传递list,这是一个mutable对象时,那个语句的意义就不同了(实际上等同于在那个list上调用.extend())。这个操作符通过在一个保存对原始list对象的引用的变量上就地修改list,从而修改它。
在处理这些类型的参数时,我们必须小心,因为它可能导致意想不到的副作用。除非你绝对确定以这种方式操纵可变参数是正确的,我们建议避免这样做,并选择没有这些问题的替代方案。
不要mutate函数参数。一般来说,尽量避免函数中的副作用。
Python 中的参数可以按位置传递,就像许多其他编程语言一样,但也可以按关键字传递。这意味着我们可以明确告诉函数我们想要哪些参数的值。唯一的注意事项是在通过关键字传递参数之后,其后的参数也必须以这种方式传递,否则将引发SyntaxError。
可变数量的参数
Python 以及其他语言都有内置函数和结构,可以接受可变数量的参数。例如,考虑字符串插值函数(无论是使用%运算符还是字符串的format方法),它们遵循与 C 语言中的printf函数类似的结构,首先是字符串格式的第一个位置参数,然后是任意数量的参数,这些参数将放置在格式化字符串的标记上。
除了利用 Python 中可用的这些函数之外,我们还可以创建自己的函数,其工作方式类似。在本节中,我们将介绍具有可变数量参数的函数的基本原则,以及一些建议,以便在下一节中,我们可以探索如何在处理函数可能具有太多参数的常见问题、问题和约束时,如何利用这些功能。
对于可变数量的位置参数,使用星号(*)作为变量名的前缀,用于打包这些参数。这通过 Python 的打包机制实现。
假设有一个函数需要三个位置参数。在代码的某个部分,我们恰好有一个列表中我们想要传递给函数的参数,顺序与函数期望的顺序相同。我们可以使用打包机制,一次性将它们全部传递给一个指令,而不是一个一个地按位置传递(即list[0]到第一个元素,list[1]到第二个元素,依此类推),这样做真的不符合 Python 的风格。
>>> def f(first, second, third):
... print(first)
... print(second)
... print(third)
...
>>> l = [1, 2, 3]
>>> f(*l)
1
2
3
打包机制的好处在于它也可以反过来使用。如果我们想要按照它们各自的位置从列表中提取变量的值,我们可以这样赋值:
>>> a, b, c = [1, 2, 3]
>>> a
1
>>> b
2
>>> c
3
部分解包也是可能的。假设我们只对序列的第一个值感兴趣(可以是列表、元组或其他内容),并且在某个点之后,我们只想保留其余的部分在一起。我们可以分配我们需要的变量,然后将其余部分放在一个打包的列表下。解包的顺序没有限制。如果没有东西放在其中一个解包的子部分中,结果将是一个空列表。鼓励读者在 Python 终端上尝试以下清单中呈现的示例,并且还要探索解包也适用于生成器:
>>> def show(e, rest):
... print("Element: {0} - Rest: {1}".format(e, rest))
...
>>> first, *rest = [1, 2, 3, 4, 5]
>>> show(first, rest)
Element: 1 - Rest: [2, 3, 4, 5]
>>> *rest, last = range(6)
>>> show(last, rest)
Element: 5 - Rest: [0, 1, 2, 3, 4]
>>> first, *middle, last = range(6)
>>> first
0
>>> middle
[1, 2, 3, 4]
>>> last
5
>>> first, last, *empty = (1, 2)
>>> first
1
>>> last
2
>>> empty
[]
解包变量的最佳用途之一可以在迭代中找到。当我们必须迭代一系列元素,并且每个元素依次是一个序列时,同时进行解包是一个很好的主意。为了看到这种情况的示例,我们假装有一个函数接收一个数据库行的列表,并且负责从这些数据中创建用户。第一个实现从行中每列的位置获取值来构造用户,这一点根本不符合惯例。第二个实现在迭代时使用了解包:
USERS = [(i, f"first_name_{i}", "last_name_{i}") for i in range(1_000)]
class User:
def __init__(self, user_id, first_name, last_name):
self.user_id = user_id
self.first_name = first_name
self.last_name = last_name
def bad_users_from_rows(dbrows) -> list:
"""A bad case (non-pythonic) of creating ``User``s from DB rows."""
return [User(row[0], row[1], row[2]) for row in dbrows]
def users_from_rows(dbrows) -> list:
"""Create ``User``s from DB rows."""
return [
User(user_id, first_name, last_name)
for (user_id, first_name, last_name) in dbrows
]
注意第二个版本要容易阅读得多。在函数的第一个版本(bad_users_from_rows)中,我们的数据以row[0]、row[1]和row[2]的形式表示,这并没有告诉我们它们是什么。另一方面,像user_id、first_name和last_name这样的变量就不言自明了。
我们可以利用这种功能来设计我们自己的函数时获得优势。
这种情况的一个例子可以在标准库中找到,就在max函数中,它的定义如下:
max(...)
max(iterable, *[, default=obj, key=func]) -> value
max(arg1, arg2, *args, *[, key=func]) -> value
With a single iterable argument, return its biggest item. The
default keyword-only argument specifies an object to return if
the provided iterable is empty.
With two or more arguments, return the largest argument.
还有一种类似的表示法,使用两个星号(**)用于关键字参数。如果我们有一个字典,并且将其带有双星号传递给函数,它将使用键作为参数的名称,并将该键的值作为该函数中该参数的值。
例如,看看这个:
function(**{"key": "value"})
这与以下内容相同:
function(key="value")
相反,如果我们定义一个以两个星号符号开头的参数的函数,将会发生相反的情况——通过关键字提供的参数将被打包成一个字典:
>>> def function(**kwargs):
... print(kwargs)
...
>>> function(key="value")
{'key': 'value'}
函数中的参数数量
在这一部分,我们同意函数或方法接受太多参数是糟糕设计的迹象(代码异味)。然后,我们提出了处理这个问题的方法。
第一个选择是软件设计的一个更一般的原则——具体化(为我们传递的所有参数创建一个新对象,这可能是我们缺少的抽象)。将多个参数压缩成一个新对象并不是 Python 特有的解决方案,而是我们可以在任何编程语言中应用的东西。
另一个选择是使用我们在前一节中看到的 Python 特定特性,利用可变位置和关键字参数创建具有动态签名的函数。虽然这可能是一种 Pythonic 的做法,但我们必须小心不要滥用这个特性,因为我们可能会创建一些非常动态的东西,很难维护。在这种情况下,我们应该看一下函数的主体。无论签名如何,以及参数是否正确,如果函数对参数的值做出了太多不同的反应,那么这就是它必须被分解成多个更小的函数的迹象(记住,函数应该只做一件事!)。
函数参数和耦合
函数签名的参数越多,这个函数与调用函数的耦合性就越高。
假设我们有两个函数,f1和f2,后者接受五个参数。f2接受的参数越多,任何试图调用该函数并收集所有信息并正确传递的人都会变得更加困难。
现在,f1似乎有了所有这些信息,因为它可以正确地调用它。由此,我们可以得出两个结论:首先,f2可能是一个泄漏的抽象,这意味着由于f1知道f2需要的一切,它几乎可以弄清楚它在内部做什么,并且能够自己做到。总而言之,f2并没有那么抽象。其次,看起来f2只对f1有用,很难想象在不同的上下文中使用这个函数,这使得它更难以重用。
当函数具有更一般的接口并且能够使用更高级的抽象时,它们变得更具重用性。
这适用于所有类型的函数和对象方法,包括类的__init__方法。这样的方法的存在通常(但并非总是)意味着应该传递一个新的更高级的抽象,或者缺少一个对象。
如果一个函数需要太多参数才能正常工作,那就应该考虑它是代码异味。
事实上,这是一个设计问题,静态分析工具(如pylint)(在第一章中讨论,介绍、代码格式和工具)会默认发出警告。当发生这种情况时,不要压制警告,而是进行重构。
压缩函数签名,接受太多参数
假设我们发现一个函数需要太多参数。我们知道我们不能让代码库保持这样,重构是必不可少的。但是,有哪些选择呢?
根据情况,以下规则可能适用。这绝不是详尽无遗的,但它确实提供了解决一些经常发生的情况的想法。
有时,如果我们发现大部分参数属于一个公共对象,改变参数可能是一种简单的方法。例如,考虑这样一个函数调用:
track_request(request.headers, request.ip_addr, request.request_id)
现在,这个函数可能会或可能不会接受额外的参数,但有一点非常明显:所有的参数都依赖于request,那么为什么不直接传递request对象呢?这是一个简单的改变,但它显著改进了代码。正确的函数调用应该是track_request(request)——更不用说,从语义上讲,这也更有意义。
虽然鼓励传递参数,但在所有传递可变对象给函数的情况下,我们必须非常小心副作用。我们调用的函数不应对我们传递的对象进行任何修改,因为那样会改变对象,产生不希望的副作用。除非这实际上是期望的效果(在这种情况下,必须明确说明),否则不鼓励这种行为。即使我们实际上想要改变我们正在处理的对象上的某些东西,更好的选择是复制它并返回(新的)修改后的版本。
使用不可变对象,并尽量避免副作用。
这将我们带到一个类似的话题:参数分组。在前面的例子中,参数已经被分组了,但这个组(在这种情况下是请求对象)并没有被使用。但其他情况并不像那个例子那么明显,我们可能希望将所有数据在参数中分组到一个充当容器的单个对象中。不用说,这种分组必须是有意义的。这里的想法是实体化:创建我们设计中缺失的抽象。
如果之前的策略不起作用,作为最后的手段,我们可以改变函数的签名以接受可变数量的参数。如果参数数量太多,使用*args或**kwargs会使事情变得更难以理解,因此我们必须确保接口被正确记录和正确使用,但在某些情况下这是值得做的。
函数定义为*args和**kwargs确实非常灵活和适应性强,但缺点是它失去了它的签名,以及部分意义,几乎所有的可读性。我们已经看到了变量(包括函数参数)的命名如何使代码更容易阅读的例子。如果一个函数将接受任意数量的参数(位置或关键字),我们可能会发现,当我们想在将来查看该函数时,我们可能不会完全知道它应该如何处理它的参数,除非它有一个非常好的文档字符串。
关于软件设计的最终建议
良好的软件设计涉及遵循软件工程的良好实践和充分利用语言的大部分特性的结合。充分利用 Python 所提供的一切是非常有价值的,但也存在滥用的风险,试图将复杂的特性适应简单的设计。
除了这个一般原则,最好还要添加一些最终建议。
软件中的正交性
这个词非常一般,可能有多种含义或解释。在数学中,正交意味着两个元素是独立的。如果两个向量正交,它们的数量积为零。这也意味着它们根本没有关系:其中一个的变化不会对另一个产生任何影响。这就是我们应该思考我们的软件的方式。
更改模块、类或函数不应对正在修改的组件的外部世界产生影响。这当然是非常理想的,但并非总是可能的。但即使在不可能的情况下,良好的设计也会尽量减少影响。我们已经看到了关注点分离、内聚性和组件隔离等概念。
在软件的运行时结构方面,正交性可以被解释为使改变(或副作用)局部化的事实。这意味着,例如,调用对象的方法不应该改变其他(无关的)对象的内部状态。我们已经(并将继续这样做)在本书中强调了在我们的代码中最小化副作用的重要性。
在混合类的示例中,我们创建了一个返回可迭代对象的tokenizer对象。__iter__方法返回一个新的生成器增加了这三个类(基类、混合类和具体类)都是正交的可能性。如果这返回了一个具体的东西(比如一个列表),这将在一定程度上创建了对其余类的依赖,因为当我们将列表更改为其他东西时,我们可能需要更新代码的其他部分,这表明这些类并不像它们应该的那样独立。
让我们给你一个快速的例子。Python 允许通过参数传递函数,因为它们只是常规对象。我们可以利用这个特性来实现一些正交性。我们有一个计算价格的函数,包括税金和折扣,但之后我们想要格式化获得的最终价格:
def calculate_price(base_price: float, tax: float, discount: float) ->
return (base_price * (1 + tax)) * (1 - discount)
def show_price(price: float) -> str:
return "$ {0:,.2f}".format(price)
def str_final_price(
base_price: float, tax: float, discount: float, fmt_function=str
) -> str:
return fmt_function(calculate_price(base_price, tax, discount))
请注意,顶层函数正在组合两个正交函数。需要注意的一件事是我们如何计算价格,这也是另一个函数将被表示的方式。改变一个不会改变另一个。如果我们没有特别传递任何内容,它将使用字符串转换作为默认表示函数,如果我们选择传递自定义函数,结果字符串将改变。但是,对show_price的更改不会影响calculate_price。我们可以对任一函数进行更改,知道另一个函数将保持原样:
>>> str_final_price(10, 0.2, 0.5)
'6.0'
>>> str_final_price(1000, 0.2, 0)
'1200.0'
>>> str_final_price(1000, 0.2, 0.1, fmt_function=show_price)
'$ 1,080.00'
正交性与质量有关。如果代码的两部分是正交的,这意味着一个可以更改而不影响另一个。这意味着更改的部分具有单元测试,这些单元测试也与应用程序的其余部分的单元测试正交。在这种假设下,如果这些测试通过,我们可以假设(在一定程度上)应用程序是正确的,而不需要进行完整的回归测试。
更广泛地说,正交性可以从功能的角度来考虑。应用程序的两个功能可以是完全独立的,以至于它们可以在不必担心一个可能破坏另一个(或代码的其余部分)的情况下进行测试和发布。想象一下,项目需要一个新的身份验证机制(比如oauth2,只是举例说明),与此同时另一个团队也在开发一个新的报告。除非系统中有根本性的问题,这两个功能都不应该相互影响。无论哪一个先合并,另一个都不应该受到任何影响。
组织代码
代码的组织方式也影响团队的性能和可维护性。
特别是,拥有大量定义(类、函数、常量等)的大文件是一种不好的做法,应该予以反对。这并不意味着要极端地将一个定义放在一个文件中,但一个良好的代码库将通过相似性来结构和安排组件。
幸运的是,在 Python 中,大文件转换为小文件通常并不困难。即使代码的多个其他部分依赖于该文件中的定义,也可以将其拆分为一个包,并保持完全兼容。这个想法是创建一个新的目录,并在其中放置一个__init__.py文件(这将使它成为一个 Python 包)。除了这个文件,我们还将有多个文件,其中包含每个文件所需的所有特定定义(按某种标准分组的较少函数和类)。然后,__init__.py文件将从所有其他文件中导入先前具有的定义(这就是保证其兼容性的内容)。此外,这些定义可以在模块的__all__变量中提及,以便导出它们。
这样做有很多好处。除了每个文件更容易浏览,事物更容易找到之外,我们还可以认为它将更有效,因为有以下原因:
-
当模块被导入时,它包含较少的对象需要解析和加载到内存中
-
模块本身可能会导入较少的模块,因为它需要较少的依赖,就像以前一样
项目还有一个约定也是有帮助的。例如,我们可以创建一个特定于项目中要使用的常量值的文件,而不是在所有文件中放置constants,然后从那里导入它:
from mypoject.constants import CONNECTION_TIMEOUT
像这样集中信息使得代码更容易重用,并有助于避免无意中的重复。
有关分离模块和创建 Python 包的更多细节将在第十章 清晰架构中讨论,当我们在软件架构的背景下探讨这个问题时。
总结
在本章中,我们探讨了几个实现清晰设计的原则。理解代码是设计的一部分是实现高质量软件的关键。这一章和下一章都专注于这一点。
有了这些想法,我们现在可以构建更健壮的代码。例如,通过应用契约设计,我们可以创建保证在其约束下工作的组件。更重要的是,如果出现错误,这不会突然发生,而是我们将清楚地知道是谁犯了错,以及代码的哪一部分违反了契约。这种分隔清晰有助于有效的调试。
在类似的情况下,如果每个组件都能够防御恶意意图或不正确的输入,那么它们就可以变得更加健壮。虽然这个想法与契约设计有所不同,但它可能会很好地补充它。防御性编程是一个好主意,特别是对于应用程序的关键部分。
对于契约设计和防御性编程,正确处理断言是很重要的。牢记它们在 Python 中应该如何使用,并且不要将断言用作程序的控制流逻辑的一部分。也不要捕获这个异常。
说到异常,重要的是要知道何时以及如何使用它们,这里最重要的概念是避免将异常用作控制流(类似于 go-to)的构造。
我们探讨了面向对象设计中一个经常出现的话题:在继承和组合之间做出决定。这里的主要教训不是要使用其中一个,而是要使用更好的选项;我们还应该避免一些常见的反模式,在 Python 中我们经常会看到这些(特别是考虑到它的高度动态性)。
最后,我们讨论了函数中参数的数量,以及关于清晰设计的启发式方法,始终牢记 Python 的特殊性。
这些概念是奠定下一章内容基础的基本设计理念。我们需要首先理解这些想法,以便能够继续进行更高级的主题,比如 SOLID 原则。
参考资料
这里是一些你可以参考的信息清单:
-
面向对象的软件构造,第二版,作者 Bertrand Meyer
-
实用程序员:从熟练工到大师,作者 Andrew Hunt 和 David Thomas,Addison-Wesley 出版,2000 年。
-
PEP-316: Python 的契约式编程 (
www.python.org/dev/peps/pep-0316/) -
REAL 01: 最邪恶的 Python 反模式:
realpython.com/blog/python/the-most-diabolical-python-antipattern/ -
PEP-3134: 异常链接和嵌入式回溯 (
www.python.org/dev/peps/pep-3134/) -
Python 的成语风格:EAFP vs. LBYL (
blogs.msdn.microsoft.com/pythonengineering/2016/06/29/idiomatic-python-eafp-versus-lbyl/) -
组合 vs. 继承:如何选择? (
www.thoughtworks.com/insights/blog/composition-vs-inheritance-how-choose) -
Python HTTP (
docs.python.org/3/library/http.server.html#http.server.BaseHTTPRequestHandler) -
requests库中异常的源参考 (docs.python-requests.org/en/master/_modules/requests/exceptions/) -
Code Complete: 软件构造的实用手册,第二版,作者 Steve McConnell