Python-入门指南-全-
Python 入门指南(全)
原文:
zh.annas-archive.org/md5/97bc15629f1b51a0671040c56db61b92译者:飞龙
前言
这个学习路径帮助你在 Python 的世界中感到舒适。它从对 Python 的全面和实用的介绍开始。你将很快开始在学习路径的第一部分编写程序。借助链表、二分搜索和排序算法的力量,你将轻松地创建复杂的数据结构,如图、栈和队列。在理解了协同继承之后,你将熟练地引发、处理和操纵异常。你将轻松地整合 Python 的面向对象和非面向对象的方面,并使用更高级的设计模式创建可维护的应用程序。一旦你掌握了核心主题,你将理解单元测试的乐趣,以及创建单元测试有多么容易。
通过这个学习路径,你将构建易于理解、调试并可用于不同应用的组件。
这个学习路径包括以下 Packt 产品的内容:
-
《学习 Python 编程-第二版》作者:法布里齐奥·罗马诺
-
《Python 数据结构和算法》作者:本杰明·巴卡
-
《Python 3 面向对象编程》作者:达斯蒂·菲利普斯
这本书适合谁
如果你相对新手,并希望使用 Python 编写脚本或程序来完成任务,或者如果你是其他语言的面向对象程序员,并希望在 Python 的世界中有所提升,那么这个学习路径适合你。虽然不是必需的,但具有编程和面向对象编程的基本知识会对你有所帮助。
这本书涵盖了什么
第一章《Python 的初步介绍》向你介绍了基本的编程概念。它指导你如何在计算机上运行 Python,并向你介绍了一些构造。
第二章《内置数据类型》向你介绍了 Python 的内置数据类型。Python 拥有非常丰富的本地数据类型,本章将为你介绍每种类型的描述和简短示例。
第三章《迭代和决策》教你如何通过检查条件、应用逻辑和执行循环来控制代码的流程。
第四章《函数,代码的构建块》教你如何编写函数。函数是重用代码、减少调试时间以及编写更好代码的关键。
第五章《文件和数据持久性》教你如何处理文件、流、数据交换格式和数据库等内容。
第六章《算法设计原则》涵盖了如何使用现有的 Python 数据结构构建具有特定功能的结构。一般来说,我们创建的数据结构需要符合一些原则。这些原则包括健壮性、适应性、可重用性和将结构与功能分离。我们将探讨迭代的作用,并介绍递归数据结构。
第七章《列表和指针结构》涵盖了链表,这是最常见的数据结构之一,通常用于实现其他结构,如栈和队列。在本章中,我们描述了它们的操作和实现。我们比较它们与数组的行为,并讨论了各自的相对优势和劣势。
第八章,“栈和队列”,讨论了这些线性数据结构的行为,并演示了一些实现。我们给出了典型应用的例子。
第九章,“树”,将讨论如何实现二叉树。树是许多最重要的高级数据结构的基础。我们将研究如何遍历树、检索和插入值。我们还将看看如何创建堆等结构。
第十章,“哈希和符号表”,描述了符号表,给出了一些典型的实现,并讨论了各种应用。我们将研究哈希的过程,给出哈希表的实现,并讨论各种设计考虑因素。
第十一章,“图和其他算法”,介绍了一些更专业的结构,包括图和空间结构。将数据表示为一组节点和顶点在许多应用中很方便,从中我们可以创建结构,如有向图和无向图。我们还将介绍一些其他结构和概念,如优先队列、堆和选择算法。
第十二章,“搜索”,讨论了最常见的搜索算法,并举例说明它们在各种数据结构中的使用。搜索数据结构是一项基本任务,有许多方法。
第十三章,“排序”,探讨了最常见的排序方法。这将包括冒泡排序、插入排序和选择排序。
第十四章,“选择算法”,涵盖了涉及查找统计数据的算法,例如列表中的最小值、最大值或中位数元素。有许多方法,其中最常见的方法之一是首先应用排序操作。其他方法包括分区和线性选择。
第十五章,“面向对象设计”,涵盖了重要的面向对象概念。主要涉及抽象、类、封装和继承等术语。我们还简要介绍了使用 UML 来建模我们的类和对象。
第十六章,“Python 中的对象”,讨论了在 Python 中使用的类和对象。我们将了解 Python 对象的属性和行为,以及类组织成包和模块。最后,我们将看到如何保护我们的数据。
第十七章,“当对象相似时”,更深入地介绍了继承。它涵盖了多重继承,并向我们展示了如何扩展内置。本章还涵盖了多态性和鸭子类型在 Python 中的工作原理。
第十八章,“意料之外”,深入研究了异常和异常处理。我们将学习如何创建自己的异常,以及如何利用异常来控制程序流程。
第十九章,“何时使用面向对象编程”,涉及创建和使用对象。我们将看到如何使用属性包装数据并限制数据访问。本章还讨论了 DRY 原则以及如何避免重复代码。
第二十章,“Python 面向对象的快捷方式”,顾名思义,涉及 Python 中的节省时间的方法。我们将研究许多有用的内置函数,例如使用默认参数进行方法重载。我们还将看到函数本身是对象,以及这如何有用。
第二十一章,迭代器模式,介绍了设计模式的概念,并涵盖了 Python 对迭代器模式的标志性实现。我们将学习列表、集合和字典推导式。我们还将揭开生成器和协程的神秘面纱。
第二十二章,Python 设计模式 I,涵盖了几种设计模式,包括装饰器、观察者、策略、状态、单例和模板模式。每种模式都有适当的示例和 Python 中的程序实现。
第二十三章,Python 设计模式 II,以适配器、外观、享元、命令、抽象和组合模式结束了我们对设计模式的讨论。提供了更多关于惯用 Python 代码与规范实现的示例。
第二十四章,测试面向对象的程序,从为什么在 Python 应用程序中进行测试如此重要开始。它专注于测试驱动开发,并介绍了两种不同的测试套件:unittest 和 py.test。最后,它讨论了模拟测试对象和代码覆盖。
为了充分利用本书
本书中的代码将需要您运行 Python 2.7.x 或更高版本。Python 的默认交互环境也可以用于运行代码片段。
本书中的一些示例依赖于不随 Python 一起发布的第三方库。它们在使用时会在书中介绍,因此您不需要提前安装它们。
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packt.com/support并注册,文件将直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packt.com。
-
选择“支持”选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
下载文件后,请确保使用以下最新版本解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Getting-Started-with-Python。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上查看!
使用的约定
本书中使用了一些文本约定。
CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"if、else和elif语句控制语句的条件执行。"
代码块设置如下:
a=10; b=20
def my_function():
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
if "WARNING" in l:
**yield l.replace("\tWARNING", "")**
任何命令行输入或输出都是这样写的:
>>> print(warnings_filter([]))
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"然后,如果标签与颜色匹配,您必须手动点击是或否。"
警告或重要说明会这样显示。提示和技巧会这样显示。
第一章:Python 的初步介绍
“授人以鱼不如授人以渔。”- 中国谚语
根据维基百科的说法,计算机编程是:
“...从计算问题的原始表述到可执行的计算机程序的过程。编程涉及活动,如分析,开发理解,生成算法,验证算法的要求,包括它们的正确性和资源消耗,以及在目标编程语言中实现(通常称为编码)算法。”
简而言之,编码就是用计算机能理解的语言告诉计算机做某事。
计算机是非常强大的工具,但不幸的是,它们无法自行思考。它们需要被告知一切:如何执行任务,如何评估条件以决定要遵循哪条路径,如何处理来自设备的数据,比如网络或磁盘,以及在发生意外情况时如何做出反应,比如,某物坏了或丢失了。
你可以用许多不同的风格和语言编写代码。难吗?我会说是和不是。这有点像写作。每个人都可以学会写作,你也可以。但是,如果你想成为一名诗人呢?那么仅仅写作是不够的。你必须获得一整套其他技能,这需要更长时间和更大的努力。
最终,一切都取决于你想走多远。编码不仅仅是将一些有效的指令组合在一起。它远不止如此!
良好的代码是简短、快速、优雅、易于阅读和理解、简单、易于修改和扩展、易于扩展和重构、易于测试。要能够同时具备所有这些品质的代码需要时间,但好消息是,通过阅读这本书,你正在迈出迈向这个目标的第一步。我毫无疑问你能做到。任何人都可以;事实上,我们都在不知不觉中一直在编程。
你想要一个例子吗?
假设你想泡速溶咖啡。你需要准备一个杯子,速溶咖啡罐,一把茶匙,水和水壶。即使你没有意识到,你正在评估大量的数据。你要确保水壶里有水,水壶已插好电,杯子干净,并且咖啡罐里有足够的咖啡。然后,你烧开水,也许在此期间,你把一些咖啡放进杯子里。当水准备好时,你把它倒进杯子里,然后搅拌。
那么,这就是编程吗?
好吧,我们收集了资源(水壶,咖啡,水,茶匙和杯子),并验证了一些关于它们的条件(水壶已插好电,杯子干净,并且有足够的咖啡)。然后我们开始了两个动作(烧水和把咖啡放进杯子里),当它们都完成时,我们最终通过把水倒进杯子里并搅拌来结束了这个过程。
你能看到吗?我刚刚描述了一个咖啡程序的高级功能。这并不难,因为这就是大脑整天在做的事情:评估条件,决定采取行动,执行任务,重复其中一些,并在某个时候停下来。清理物品,把它们放回去,等等。
现在你需要学会如何分解你在现实生活中自动完成的所有这些动作,以便计算机实际上能够理解它们。而且你还需要学习一种语言,来指导它。
所以这本书就是为此而写的。我会告诉你如何做,我会尝试通过许多简单但专注的例子来做到这一点(这是我最喜欢的一种)。
在这一章中,我们将涵盖以下内容:
-
Python 的特点和生态系统
-
关于如何开始并运行 Python 和虚拟环境的指南
-
如何运行 Python 程序
-
如何组织 Python 代码和 Python 的执行模型
一个合适的介绍
我喜欢在教编码时引用现实世界;我相信这有助于人们更好地理解概念。然而,现在是时候更严谨地从技术角度看待编码是什么了。
当我们编写代码时,我们在指示计算机要做的事情。行动发生在哪里?在很多地方:计算机内存、硬盘、网络电缆、CPU 等等。这是一个完整的世界,大部分时间是真实世界的一个子集的表示。
如果你写了一个允许人们在线购买服装的软件,你将不得不在程序的范围内代表真实的人、真实的服装、真实的品牌、尺寸等等。
为了做到这一点,你需要在你编写的程序中创建和处理对象。一个人可以是一个对象。一辆车是一个对象。一双袜子是一个对象。幸运的是,Python 非常了解对象。
任何对象具有的两个主要特征是属性和方法。让我们以一个人的对象为例。在计算机程序中,你通常将人表示为顾客或员工。你存储在他们身上的属性是姓名、社会安全号码、年龄、是否有驾照、电子邮件、性别等等。在计算机程序中,你存储了所有你需要的数据,以便使用对象来服务你的目的。如果你正在编写一个销售服装的网站,你可能还想存储顾客的身高、体重以及其他尺寸,以便为他们推荐合适的服装。因此,属性是对象的特征。我们经常使用它们:你能把那支笔递给我吗?—哪一支?—黑色的那支。在这里,我们使用了笔的黑色属性来识别它(很可能是在蓝色和红色中)。
方法是对象可以做的事情。作为一个人,我有诸如说话、走路、睡觉、醒来、吃饭、做梦、写、读等方法。我能做的所有事情都可以看作是代表我的对象的方法。
现在你知道了对象是什么,它们公开了可以运行的方法和可以检查的属性,你已经准备好开始编码了。实际上,编码就是简单地管理我们在软件中再现的世界子集中的那些对象。你可以随意创建、使用、重用和删除对象。
根据官方 Python 文档的数据模型章节(docs.python.org/3/reference/datamodel.html):
“对象是 Python 对数据的抽象。Python 程序中的所有数据都由对象或对象之间的关系表示。”
我们将在后面的章节中更仔细地看 Python 对象。目前,我们需要知道的是 Python 中的每个对象都有一个 ID(或身份)、一个类型和一个值。
一旦创建,对象的 ID 就永远不会改变。这是它的唯一标识符,并且在幕后被 Python 用来在我们想要使用它时检索对象。
类型也永远不会改变。类型告诉对象支持哪些操作以及可以分配给它的可能值。
我们将在第二章中看到 Python 最重要的数据类型,内置数据类型。
值可以改变,也可以不改变。如果可以改变,对象被称为可变,而当它不能改变时,对象被称为不可变。
我们如何使用一个对象?当然是给它一个名字!当你给一个对象一个名字,然后你可以使用这个名字来检索对象并使用它。
在更一般的意义上,诸如数字、字符串(文本)、集合等对象都与一个名称相关联。通常,我们说这个名称是变量的名称。你可以把变量看作是一个盒子,你可以用它来存储数据。
所以,你拥有了所有你需要的对象;现在呢?好吧,我们需要使用它们,对吧?我们可能想要通过网络连接发送它们,或者将它们存储在数据库中。也许在网页上显示它们,或者将它们写入文件。为了做到这一点,我们需要对用户填写表单、按下按钮、打开网页并执行搜索做出反应。我们通过运行我们的代码来做出反应,评估条件以选择执行哪些部分,多少次,以及在什么情况下。
为了做到所有这些,基本上我们需要一种语言。这就是 Python 的用途。在本书中,我们将一起使用 Python 来指导计算机为我们做一些事情。
现在,够了这些理论的东西;让我们开始吧。
进入 Python
Python 是 Guido Van Rossum 的奇迹创造,他是一位荷兰计算机科学家和数学家,决定在 1989 年圣诞节期间向世界赠送他正在玩耍的项目。这种语言大约在 1991 年左右出现在公众面前,从那时起,它已经发展成为全球范围内使用的主要编程语言之一。
我 7 岁开始编程,用的是 Commodore VIC-20,后来换成了它的大哥 Commodore 64。它的语言是 BASIC。后来,我接触了 Pascal、Assembly、C、C++、Java、JavaScript、Visual Basic、PHP、ASP、ASP .NET、C#,以及其他一些我甚至都记不起来的小语言,但直到我接触到 Python,我才有了那种在商店里找到合适的沙发时的感觉。当你的所有身体部位都在喊着,“买这个!这个对我们来说完美!”
我大约花了一天时间适应它。它的语法与我习惯的有点不同,但在克服了最初的不适感之后(就像穿上新鞋一样),我就深深地爱上了它。让我们看看为什么。
关于 Python
在我们深入了解细节之前,让我们了解一下为什么有人会想要使用 Python(我建议你阅读维基百科上的 Python 页面,以获得更详细的介绍)。
在我看来,Python 体现了以下特质。
可移植性
Python 可以在任何地方运行,将程序从 Linux 移植到 Windows 或 Mac 通常只是修复路径和设置的问题。Python 被设计用于可移植性,并且它会处理特定操作系统(OS)的怪癖,这些接口会让你免于编写针对特定平台的代码的痛苦。
连贯性
Python 非常逻辑和连贯。你可以看出它是由一位杰出的计算机科学家设计的。大多数时候,如果你不知道一个方法的调用方式,你可以猜出来。
你现在可能没有意识到这一点有多重要,特别是如果你还处在起步阶段,但这是一个重要的特性。这意味着你的头脑中没有那么多杂乱,也不需要在编码时浏览文档,也不需要在大脑中进行映射。
开发者生产力
根据 Mark Lutz(《学习 Python,第 5 版》,O'Reilly Media)的说法,Python 程序通常只有等效的 Java 或 C++代码的五分之一到三分之一大小。这意味着工作可以更快地完成。更快是好的。更快意味着市场上更快的反应。更少的代码不仅意味着写的代码更少,而且意味着阅读的代码更少(专业程序员阅读的比写的要多得多),维护的代码更少,调试的代码更少,重构的代码更少。
另一个重要的方面是,Python 可以在不需要冗长和耗时的编译和链接步骤的情况下运行,因此你不必等待看到你的工作成果。
一个广泛的库
Python 拥有一个非常广泛的标准库(据说它是带有内置电池)。如果这还不够,全世界的 Python 社区维护着一系列第三方库,专门针对特定需求定制,你可以在Python Package Index(PyPI)上自由获取。当你编写 Python 代码时,你意识到你需要某个特定功能时,在大多数情况下,至少有一个库已经为你实现了该功能。
软件质量
Python 非常注重可读性、连贯性和质量。语言的统一性使得它具有很高的可读性,这在当今编码更多是集体努力而不是个人努力的情况下至关重要。Python 的另一个重要方面是其固有的多范式特性。你可以将其用作脚本语言,但也可以利用面向对象、命令式和函数式编程风格。它是多才多艺的。
软件集成
另一个重要的方面是 Python 可以扩展和集成许多其他语言,这意味着即使一家公司使用不同的语言作为他们的主流工具,Python 也可以作为一个粘合剂在某种程度上连接需要相互通信的复杂应用程序。这是一个比较高级的话题,但在现实世界中,这个特性非常重要。
满意和享受
最后但并非最不重要的是,这很有趣!使用 Python 很有趣。我可以编写 8 小时的代码,离开办公室时感到快乐和满足,对于其他使用不提供同样数量精心设计的数据结构和构造的编码人员所遭受的挣扎毫不知情。毫无疑问,Python 让编码变得有趣。而有趣则促进了动力和生产力。
有什么缺点吗?
也许,人们在 Python 中唯一可能找到的缺点(不是由于个人偏好)是它的执行速度。通常情况下,Python 比它的编译兄弟慢。Python 的标准实现在运行应用程序时会生成源代码的编译版本,称为字节码(扩展名为.pyc),然后由 Python 解释器运行。这种方法的优势是可移植性,但由于 Python 没有像其他语言那样编译到机器级别,我们需要付出减速的代价。
然而,Python 的速度在今天很少是一个问题,因此它被广泛使用,尽管有这个次优特性。在现实生活中,硬件成本不再是一个问题,通常很容易通过并行化任务来提高速度。此外,许多程序花费大部分时间等待 IO 操作完成;因此,原始执行速度通常是整体性能的次要因素。不过,当涉及到大量计算时,人们可以切换到更快的 Python 实现,比如 PyPy,通过实现先进的编译技术,它提供了平均五倍的加速(参考pypy.org/)。
在进行数据科学时,你很可能会发现,你使用的 Python 库,如Pandas和NumPy,由于它们的实现方式,实现了本地速度。
如果这还不足以说服你,你可以考虑 Python 已被用于驱动 Spotify 和 Instagram 等服务的后端,其中性能是一个问题。尽管如此,Python 已经完全胜任了它的工作。
今天谁在使用 Python?
还不够说服?让我们简要看一下今天正在使用 Python 的公司:谷歌、YouTube、Dropbox、雅虎、Zope 公司、工业光与魔法、华特迪士尼特效动画、Blender 3D、皮克斯、NASA、NSA、红帽、诺基亚、IBM、Netflix、Yelp、英特尔、思科、惠普、高通和摩根大通,仅举几例。
甚至像战地 2、文明 4和QuArK这样的游戏都是用 Python 实现的。
Python 在许多不同的环境中使用,如系统编程、Web 编程、GUI 应用程序、游戏和机器人技术、快速原型设计、系统集成、数据科学、数据库应用等。一些知名的大学也已经将 Python 作为他们计算机科学课程的主要语言。
设置环境
在谈论如何在你的系统上安装 Python 之前,让我告诉你我在本书中将使用的 Python 版本。
Python 2 与 Python 3
Python 有两个主要版本:Python 2 是过去,Python 3 是现在。这两个版本虽然非常相似,但在某些方面是不兼容的。
在现实世界中,Python 2 实际上离过去还相当遥远。简而言之,尽管 Python 3 自 2008 年以来就已经发布,但从版本 2 过渡到版本 3 的阶段仍然远未结束。这主要是因为 Python 2 在工业界被广泛使用,当然,公司并不急于仅仅为了更新而更新他们的系统,遵循“如果它没坏,就不要修理”的理念。你可以在网络上阅读关于这两个版本之间过渡的所有信息。
另一个妨碍过渡的问题是第三方库的可用性。通常,Python 项目依赖于数十个外部库,当你开始一个新项目时,你需要确保已经有一个兼容 Version-3 的库来满足任何可能出现的业务需求。如果不是这样,那么在 Python 3 中启动一个全新的项目意味着引入潜在的风险,而许多公司并不愿意冒这种风险。
在撰写本文时,大多数最广泛使用的库已经移植到 Python 3,并且对于大多数情况来说,在 Python 3 中启动项目是相当安全的。许多库已经重写,以便与两个版本兼容,主要利用了six库的功能(名称来源于 2 x 3 的乘法,因为从版本 2 到 3 的移植),它有助于根据使用的版本进行内省和行为调整。根据 PEP 373(legacy.python.org/dev/peps/pep-0373/),Python 2.7 的生命周期结束(EOL)已经设定为 2020 年,不会有 Python 2.8,因此对于在 Python 2 中运行项目的公司来说,现在是需要开始制定升级策略并在太迟之前转移到 Python 3 的时候了。
在我的电脑上(MacBook Pro),这是我拥有的最新 Python 版本:
>>> import sys
>>> print(sys.version)
3.7.0a3 (default, Jan 27 2018, 00:46:45)
[Clang 9.0.0 (clang-900.0.39.2)]
因此,你可以看到这个版本是 Python 3.7 的 alpha 版本,将于 2018 年 6 月发布。前面的文本是我在控制台中输入的一小段 Python 代码。我们稍后会谈论它。
本书中的所有示例都将使用 Python 3.7 运行。尽管目前最终版本可能与我所拥有的略有不同,但我会确保所有的代码和示例在书籍出版时都是最新的 3.7 版本。
一些代码也可以在 Python 2.7 中运行,要么就是原样,要么稍作调整,但在这个时候,我认为最好先学习 Python 3,然后再了解它与 Python 2 的区别,而不是反过来。
不过,不要担心这个版本的问题;实际上并不是那么大的问题。
安装 Python
我从来没有真正理解为什么书中需要有一个设置部分,不管你需要设置什么。大多数情况下,作者写下指示的时间和你实际尝试它们的时间之间已经过去了几个月。如果你幸运的话。一旦版本发生变化,书中描述的方法可能就不再奏效了。幸运的是,现在我们有了网络,所以为了帮助你启动和运行,我只会给你一些指引和目标。
我知道大多数读者可能更喜欢在书中获得指导。但我怀疑这是否会让他们的生活变得更容易,因为我坚信,如果你想开始学习 Python,你必须付出最初的努力,以熟悉这个生态系统。这非常重要,它将增强你面对接下来章节中的材料的信心。如果遇到困难,请记住,谷歌是你的朋友。
设置 Python 解释器
首先,让我们谈谈你的操作系统。Python 已经完全集成,并且很可能已经安装在几乎每个 Linux 发行版中。如果你使用 macOS,很可能 Python 也已经安装好了(但可能只有 Python 2.7),而如果你使用 Windows,你可能需要安装它。
获取 Python 和你需要的库并使其运行需要一些技巧。Linux 和 macOS 似乎是最适合 Python 程序员的用户友好操作系统;而 Windows 则需要更大的努力。
我的当前系统是 MacBook Pro,这也是我在整本书中将使用的系统,搭配 Python 3.7。
你要开始的地方是官方 Python 网站:www.python.org。这个网站托管了官方 Python 文档和许多其他资源,你会发现非常有用。花点时间去探索一下。
另一个关于 Python 及其生态系统的优秀、富有资源的网站是docs.python-guide.org。你可以找到使用不同方法在不同操作系统上设置 Python 的说明。
找到下载部分,选择适合你操作系统的安装程序。如果你使用 Windows,在运行安装程序时,请确保勾选“安装 pip”选项(实际上,我建议进行完整安装,以确保安装程序包含的所有组件都安装了)。我们稍后会讨论pip。
现在 Python 已经安装在你的系统中,目标是能够打开控制台并通过键入python运行 Python 交互式 shell。
请注意,我通常简称Python 交互式 shell为Python 控制台。
在 Windows 中打开控制台,转到“开始”菜单,选择“运行”,然后键入cmd。如果在本书的示例中遇到类似权限问题的情况,请确保以管理员权限运行控制台。
在 macOS X 上,你可以通过转到“应用程序”|“实用工具”|“终端”来启动终端。
如果你使用 Linux,你对控制台的了解就足够了。
我将使用术语控制台来交替指代 Linux 控制台、Windows 命令提示符和 Macintosh 终端。我还将用 Linux 默认格式指示命令行提示符,就像这样:
$ sudo apt-get update
如果你对此不熟悉,请花些时间学习控制台的基础知识。简而言之,在$符号后,通常会有一个你需要输入的指令。注意大小写和空格,它们非常重要。
无论你打开哪个控制台,都在提示符处键入python,确保 Python 交互式 shell 显示出来。键入exit()退出。请记住,如果你的操作系统预装了 Python 2.*,可能需要指定python3。
这大致是你运行 Python 时应该看到的情况(根据版本和操作系统的不同,细节可能会有所变化):
$ python3.7
Python 3.7.0a3 (default, Jan 27 2018, 00:46:45)
[Clang 9.0.0 (clang-900.0.39.2)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
现在 Python 已经设置好,你可以运行它了,是时候确保你还有另一个工具,这个工具将是跟随本书示例不可或缺的:virtualenv。
关于 virtualenv
正如你可能已经猜到的那样,virtualenv关乎虚拟环境。让我通过一个简单的例子来解释它们是什么,为什么我们需要它们。
你在系统上安装了 Python,并开始为 X 客户工作。你创建了一个项目文件夹并开始编码。在此过程中,你还安装了一些库,例如 Django 框架。假设你为 X 项目安装的 Django 版本是 1.7.1。
现在,你的网站做得很好,你又得到了另一个客户 Y。她希望你为她建立另一个网站,所以你开始了 Y 项目,但在此过程中,你需要再次安装 Django。唯一的问题是现在 Django 的版本是 1.8,你不能在系统上安装它,因为这会替换你为 X 项目安装的版本。你不想冒险引入不兼容的问题,所以你有两个选择:要么你继续使用你当前机器上的版本,要么你升级它,并确保第一个项目仍然能够正确地使用新版本。
说实话,这些选项都不是很吸引人,对吧?绝对不是。所以,这里有解决方案:virtualenv!
virtualenv 是一个允许你创建虚拟环境的工具。换句话说,它是一个用于创建隔离的 Python 环境的工具,每个环境都是一个包含所有必要可执行文件的文件夹,以便使用 Python 项目所需的包(暂时将包视为库)。
所以你为 X 项目创建一个虚拟环境,安装所有依赖项,然后为 Y 项目创建一个虚拟环境,安装所有依赖项,而不用担心,因为你安装的每个库最终都会在适当的虚拟环境范围内。在我们的例子中,X 项目将使用 Django 1.7.1,而 Y 项目将使用 Django 1.8。
绝对不能直接在系统级别安装库非常重要。例如,Linux 依赖于 Python 执行许多不同的任务和操作,如果你在系统安装的 Python 上进行操作,就会有破坏整个系统完整性的风险(猜猜这是发生在谁身上的...)。所以把这当作一条规则,就像睡前刷牙一样重要:每次开始新项目时,一定要创建虚拟环境。
要在系统上安装 virtualenv,有几种不同的方法。例如,在基于 Debian 的 Linux 发行版上,你可以使用以下命令安装:
$ sudo apt-get install python-virtualenv
可能最简单的方法是按照 virtualenv 官方网站上的说明进行操作:virtualenv.pypa.io。
你会发现,安装 virtualenv 最常见的方法之一是使用pip,这是一个用于安装和管理用 Python 编写的软件包的软件包管理系统。
从 Python 3.5 开始,创建虚拟环境的建议方式是使用venv模块。更多信息请参阅官方文档。然而,在撰写本文时,virtualenv 仍然是创建虚拟环境最常用的工具。
你的第一个虚拟环境
创建虚拟环境非常容易,但根据系统配置和想要虚拟环境运行的 Python 版本,你需要正确运行命令。另一件你需要做的事情是激活虚拟环境。激活虚拟环境基本上是在后台进行一些路径操作,这样当你调用 Python 解释器时,实际上是调用激活的虚拟环境,而不是系统的。
我将在我的 Macintosh 控制台上展示一个完整的示例。我们将:
-
在你的项目根目录下创建一个名为
learn.pp的文件夹(在我的情况下是一个名为srv的文件夹,在我的主目录下)。请根据你在计算机上喜欢的设置调整路径。 -
在
learn.pp文件夹中,我们将创建一个名为learnpp的虚拟环境。
一些开发人员更喜欢使用相同的名称来称呼所有虚拟环境(例如,.venv)。这样他们就可以通过知道项目所在的名称来运行脚本。在 Linux/macOS 中,以点开头的名称会使文件或文件夹变得不可见。
-
创建虚拟环境后,我们将激活它。在 Linux、macOS 和 Windows 之间的方法略有不同。
-
然后,我们将确保我们正在运行所需的 Python 版本(3.7.*),通过运行 Python 交互式 shell。
-
最后,我们将使用
deactivate命令来停用虚拟环境。
这五个简单的步骤将向您展示开始和使用项目所需做的一切。
以下是这些步骤可能看起来的一个示例(请注意,根据您的操作系统、Python 版本等,可能会得到略有不同的结果)在 macOS 上(以#开头的命令是注释,空格是为了可读性引入的,⇢表示由于空间不足而换行):
fabmp:srv fab$ # step 1 - create folder
fabmp:srv fab$ mkdir learn.pp
fabmp:srv fab$ cd learn.pp
fabmp:learn.pp fab$ # step 2 - create virtual environment
fabmp:learn.pp fab$ which python3.7
/Users/fab/.pyenv/shims/python3.7
fabmp:learn.pp fab$ virtualenv -p
⇢ /Users/fab/.pyenv/shims/python3.7 learnpp
Running virtualenv with interpreter /Users/fab/.pyenv/shims/python3.7
Using base prefix '/Users/fab/.pyenv/versions/3.7.0a3'
New python executable in /Users/fab/srv/learn.pp/learnpp/bin/python3.7
Also creating executable in /Users/fab/srv/learn.pp/learnpp/bin/python
Installing setuptools, pip, wheel...done.
fabmp:learn.pp fab$ # step 3 - activate virtual environment
fabmp:learn.pp fab$ source learnpp/bin/activate
(learnpp) fabmp:learn.pp fab$ # step 4 - verify which python
(learnpp) fabmp:learn.pp fab$ which python
/Users/fab/srv/learn.pp/learnpp/bin/python
(learnpp) fabmp:learn.pp fab$ python
Python 3.7.0a3 (default, Jan 27 2018, 00:46:45)
[Clang 9.0.0 (clang-900.0.39.2)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> exit()
(learnpp) fabmp:learn.pp fab$ # step 5 - deactivate
(learnpp) fabmp:learn.pp fab$ deactivate
fabmp:learn.pp fab$
请注意,我不得不明确告诉 virtualenv 使用 Python 3.7 解释器,因为在我的系统上 Python 2.7 是默认的。如果我没有这样做,我将得到一个带有 Python 2.7 而不是 Python 3.7 的虚拟环境。
你可以将第 2 步的两条指令合并成一条命令,就像这样:
$ virtualenv -p $( which python3.7 ) learnpp
在这种情况下,我选择明确详细,以帮助您理解每个步骤。
另一件需要注意的事情是,为了激活虚拟环境,我们需要运行/bin/activate脚本,这需要被源化。当脚本被源化时,意味着它在当前 shell 中执行,因此其效果会在执行后持续存在。这非常重要。还要注意,在我们激活虚拟环境后,提示符会发生变化,左边会显示其名称(在我们停用它时会消失)。在 Linux 上,步骤是相同的,所以我不会在这里重复。在 Windows 上,事情略有不同,但概念是相同的。请参考官方 virtualenv 网站获取指导。
在这一点上,您应该能够创建和激活一个虚拟环境。请尝试创建另一个虚拟环境,而无需我的指导。熟悉这个过程,因为这是您将始终在做的事情:我们永远不会在系统范围内使用 Python,记住吗?这非常重要。
所以,一旦搭建好,我们就准备好更多地谈谈 Python 以及如何使用它。在我们开始之前,让我稍微谈谈控制台。
你的朋友,控制台
在这个 GUI 和触摸屏设备的时代,当一切都只是一个点击之遥时,不得不求助于控制台这样的工具似乎有点荒谬。
但事实是,每当你把右手(或者如果你是左撇子,是左手)从键盘上移开,去抓鼠标并将光标移动到你想点击的位置时,你都会浪费时间。用控制台完成任务,尽管可能有些违反直觉,但会提高生产力和速度。我知道,你必须相信我。
速度和生产力很重要,个人而言,我对鼠标没有任何意见,但还有另一个非常好的理由,你可能希望熟悉控制台:当你开发的代码最终部署到服务器上时,控制台可能是唯一可用的工具。如果你和它交朋友,我向你保证,在最重要的时候你永远不会迷失方向(通常是在网站宕机时,你需要迅速调查发生了什么)。
所以这真的取决于你。如果你还没有决定,请给我一点怀疑的好处,试一试。这比你想象的要容易,你永远不会后悔。没有什么比一个优秀的开发者因为习惯了自己定制的工具而在 SSH 连接到服务器中迷失更可悲的了。
现在,让我们回到 Python。
如何运行 Python 程序
有几种不同的方法可以运行 Python 程序。
运行 Python 脚本
Python 可以用作脚本语言。事实上,它总是非常有用的。脚本是文件(通常很小),通常用来执行一些任务。许多开发人员最终都会拥有自己的工具库,当他们需要执行任务时就会使用。例如,你可以有脚本来解析数据格式并将其呈现为另一种不同的格式。或者你可以使用脚本来处理文件和文件夹。你可以创建或修改配置文件,等等。从技术上讲,几乎没有什么是不能在脚本中完成的。
在服务器上定时运行脚本是非常常见的。例如,如果你的网站数据库每 24 小时需要清理一次(例如,存储用户会话的表,这些会话很快就会过期,但不会自动清理),你可以设置一个 Cron 作业,每天凌晨 3 点运行你的脚本。
根据维基百科,软件实用程序 Cron 是 Unix 类计算机操作系统中基于时间的作业调度程序。设置和维护软件环境的人使用 Cron 定期安排作业(命令或 shell 脚本)在固定的时间、日期或间隔运行。
运行 Python 交互式 shell
另一种运行 Python 的方法是调用交互式 shell。这是我们在控制台命令行中输入 python 时已经看到的东西。
所以,打开控制台,激活你的虚拟环境(现在应该已经成为你的第二天性了,对吧?),然后输入 python。你会看到几行文字,应该是这样的:
$ python
Python 3.7.0a3 (default, Jan 27 2018, 00:46:45)
[Clang 9.0.0 (clang-900.0.39.2)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
那些 >>> 是 shell 的提示符。它们告诉你 Python 正在等待你输入。如果你输入一个简单的指令,一个适合一行的东西,那就是你会看到的。然而,如果你输入需要多于一行代码的东西,shell 会把提示符改成 ...,给你一个视觉线索,告诉你正在输入一个多行语句(或者任何需要多于一行代码的东西)。
来吧,试一试;让我们做一些基本的数学:
>>> 2 + 4
6
>>> 10 / 4
2.5
>>> 2 ** 1024
179769313486231590772930519078902473361797697894230657273430081157732675805500963132708477322407536021120113879871393357658789768814416622492847430639474124377767893424865485276302219601246094119453082952085005768838150682342462881473913110540827237163350510684586298239947245938479716304835356329624224137216
最后的操作向你展示了一些不可思议的东西。我们将2的1024次方,Python 毫无困难地处理了这个任务。试着在 Java、C++或 C#中做这个操作。除非你使用特殊的库来处理这样大的数字,否则是行不通的。
我每天都使用交互式 shell。它非常有用,可以快速调试,例如,检查数据结构是否支持某个操作。或者检查或运行一段代码。
当你使用 Django(一个 Web 框架)时,交互式 shell 与之相结合,允许你通过框架工具来检查数据库中的数据,以及其他许多事情。你会发现交互式 shell 很快会成为你在这段旅程中最亲密的朋友之一。
另一种解决方案,以更好的图形布局呈现,是使用集成开发环境(IDLE)。这是一个相当简单的集成开发环境,主要面向初学者。它比你在控制台中得到的裸交互式 shell 具有稍微更多的功能,所以你可能想要探索一下。它在 Windows Python 安装程序中免费提供,你也可以轻松地在任何其他系统中安装它。你可以在 Python 网站上找到有关它的信息。
Guido Van Rossum 将 Python 命名为英国喜剧团体 Monty Python,因此有传言称 IDLE 的名称是为了纪念 Monty Python 的创始成员之一 Eric Idle 而选择的。
将 Python 作为服务运行
除了作为脚本运行之外,在 shell 的边界内,Python 还可以编码并作为应用程序运行。我们将在整本书中看到许多关于这种模式的例子。当我们谈论 Python 代码是如何组织和运行的时候,我们将更多地了解它。
将 Python 作为 GUI 应用程序运行
Python 也可以作为图形用户界面(GUI)运行。有几个可用的框架,其中一些是跨平台的,另一些是特定于平台的。
在其他 GUI 框架中,我们发现以下是最广泛使用的:
-
PyQt
-
Tkinter
-
wxPython
-
PyGTK
详细描述它们超出了本书的范围,但您可以在 Python 网站上找到所有您需要的信息(docs.python.org/3/faq/gui.html),在Python 存在哪些平台无关的 GUI 工具包?部分。如果您正在寻找 GUI,请记住根据一些原则选择您想要的。确保它们:
-
提供您可能需要开发项目的所有功能
-
在您可能需要支持的所有平台上运行
-
依靠尽可能广泛和活跃的社区
-
包装图形驱动程序/工具,您可以轻松安装/访问
Python 代码是如何组织的?
让我们谈一下 Python 代码是如何组织的。在本节中,我们将更深入地探讨一些技术名称和概念。
从基础知识开始,Python 代码是如何组织的?当然,您将代码写入文件中。当您使用扩展名.py保存文件时,该文件被称为 Python 模块。
如果您使用的是 Windows 或 macOS,通常会向用户隐藏文件扩展名,请确保更改配置,以便您可以看到文件的完整名称。这不是严格的要求,而是一个建议。
将所有软件所需的代码保存在一个文件中是不切实际的。这种解决方案适用于脚本,通常不超过几百行(通常比这短得多)。
一个完整的 Python 应用程序可能由数十万行代码组成,因此您将不得不将其分散到不同的模块中,这是更好的,但还不够好。事实证明,即使这样,使用代码仍然是不切实际的。因此,Python 为您提供了另一种结构,称为包,它允许您将模块组合在一起。包只是一个文件夹,必须包含一个特殊文件__init__.py,它不需要包含任何代码,但其存在是为了告诉 Python 该文件夹不仅仅是一个文件夹,而实际上是一个包(请注意,从 Python 3.3 开始,__init__.py模块不再严格需要)。
像往常一样,一个例子将使所有这些更加清晰。我在我的书项目中创建了一个示例结构,当我在控制台中输入时:
$ tree -v example
我得到了ch1/example文件夹内容的树形表示,其中包含本章示例的代码。这是一个真正简单应用程序结构的样子:
example
├── core.py
├── run.py
└── util
├── __init__.py
├── db.py
├── math.py
└── network.py
您可以看到,在这个例子的根目录中,我们有两个模块,core.py和run.py,以及一个包:util。在core.py中,可能有我们应用程序的核心逻辑。另一方面,在run.py模块中,我们可能会找到启动应用程序的逻辑。在util包中,我希望找到各种实用工具,实际上,我们可以猜测那里的模块是根据它们所持有的工具类型命名的:db.py将持有与数据库工作相关的工具,math.py当然将持有数学工具(也许我们的应用程序涉及财务数据),network.py可能将持有在网络上发送/接收数据的工具。
如前所述,__init__.py文件只是告诉 Pythonutil是一个包,而不仅仅是一个普通的文件夹。
如果这个软件只是组织在模块中,推断其结构将会更加困难。我在ch1/files_only文件夹下放了一个仅模块的例子;自己看看:
$ tree -v files_only
这给我们展示了一个完全不同的画面:
files_only/
├── core.py
├── db.py
├── math.py
├── network.py
└── run.py
猜测每个模块的功能可能有点困难,对吧?现在,考虑这只是一个简单的例子,所以您可以猜想如果我们无法将代码组织成包和模块,要理解一个真实应用程序会有多困难。
我们如何使用模块和包?
当开发人员编写应用程序时,很可能需要在不同的部分应用相同的逻辑。例如,当编写一个解析器来解析用户可以在网页上填写的表单数据时,应用程序将需要验证某个字段是否包含数字。无论这种验证逻辑如何编写,很可能会在多个地方需要使用。
例如,在一个调查应用程序中,用户被问及许多问题,很可能其中几个问题需要数字答案。例如:
-
你多大了?
-
你有多少宠物?
-
你有多少孩子?
-
你结婚多少次了?
在每个期望得到数字答案的地方复制/粘贴(或者更恰当地说:重复)验证逻辑将是非常糟糕的做法。这将违反不要重复自己(DRY)原则,该原则规定您在应用程序中不应该重复相同的代码。我感到有必要强调这个原则的重要性:您在应用程序中不应该重复相同的代码(有意思的双关语)。
重复相同的逻辑可能非常糟糕的原因有几个,其中最重要的原因是:
-
逻辑中可能存在错误,因此,您将不得不在应用逻辑的每个地方进行更正。
-
您可能希望修改验证的方式,然后您将不得不在应用的每个地方进行更改。
-
您可能会忘记修复/修改某个逻辑,因为在搜索所有出现的时候错过了它。这将在您的应用程序中留下错误/不一致的行为。
-
您的代码会比需要的更长,没有好的理由。
Python 是一种很棒的语言,并为您提供了应用所有编码最佳实践所需的所有工具。对于这个特定的例子,我们需要能够重用一段代码。为了能够重用一段代码,我们需要有一个构造,可以为我们保存代码,以便我们可以在需要重复其中的逻辑时调用该构造。这个构造存在,它被称为函数。
我在这里不会深入讨论具体细节,所以请记住,函数是一块有组织的可重用代码,用于执行任务。函数可以根据它们所属的环境的不同形式和名称,但现在这并不重要。我们将在书的后面能够欣赏到它们时,再看到细节。函数是应用程序中模块化的构建块,几乎是不可或缺的。除非你在写一个非常简单的脚本,否则你会一直使用函数。我们将在第四章中探讨函数,函数,代码的构建块。
Python 自带一个非常庞大的库,就像我几页前已经说过的那样。现在,也许是定义库的好时机:库是提供丰富功能的函数和对象的集合,丰富了语言的能力。
例如,在 Python 的math库中,我们可以找到大量的函数,其中之一就是factorial函数,它当然可以计算一个数的阶乘。
在数学中,非负整数N的阶乘,表示为N!,被定义为小于或等于N的所有正整数的乘积。例如,计算 5 的阶乘如下:
5!= 5 * 4 * 3 * 2 * 1 = 120
0 的阶乘是 0!= 1,以遵守空乘积的约定。
因此,如果你想在你的代码中使用这个函数,你只需要导入它并用正确的输入值调用它。如果现在输入值和调用的概念不太清楚,不要担心,只需专注于导入部分。我们通过从中导入需要的内容来使用库,然后使用它。
在 Python 中,要计算数字 5 的阶乘,我们只需要以下代码:
>>> from math import factorial
>>> factorial(5)
120
无论我们在 shell 中输入什么,只要它有可打印的表示,就会在控制台上打印出来(在这种情况下,是函数调用的结果:120)。
所以,让我们回到我们的例子,那个有core.py、run.py、util等的例子。
在我们的例子中,包util是我们的实用库。我们的自定义实用工具包,其中包含我们应用程序中需要的所有可重用工具(即函数)。其中一些将处理数据库(db.py),一些将处理网络(network.py),一些将执行数学计算(math.py),这些都超出了 Python 标准math库的范围,因此我们必须自己编写它们。
我们将在专门的章节详细讨论如何导入函数并在其中使用。现在让我们谈谈另一个非常重要的概念:Python 的执行模型。
Python 的执行模型
在这一部分,我想向你介绍一些非常重要的概念,比如作用域、名称和命名空间。当然,你可以在官方语言参考中阅读关于 Python 执行模型的所有内容,但我认为那相当技术化和抽象,所以让我先给你一个不太正式的解释。
名称和命名空间
假设你正在找一本书,所以你去图书馆问有人要找的书。他们告诉你类似二楼,X 区,第三排这样的信息。于是你上楼,找到 X 区,依此类推。
进入一个图书馆,所有的书都堆在一起,没有楼层,没有区域,没有排列,没有秩序,那将是非常不同的。找一本书将会非常困难。
当我们编写代码时,我们也会遇到同样的问题:我们必须尝试组织它,以便对于没有先验知识的人来说,能够轻松找到他们要找的东西。当软件结构正确时,它也促进了代码重用。另一方面,杂乱无章的软件更有可能暴露出分散的重复逻辑片段。
首先,让我们从书开始。我们通过书的标题来引用一本书,在 Python 术语中,那将是一个名称。Python 名称是其他语言称为变量的最接近的抽象。名称基本上是指向对象的,并且是通过名称绑定操作引入的。让我们举个快速的例子(注意,跟在#后面的任何内容都是注释):
>>> n = 3 # integer number
>>> address = "221b Baker Street, NW1 6XE, London" # Sherlock Holmes' address
>>> employee = {
... 'age': 45,
... 'role': 'CTO',
... 'SSN': 'AB1234567',
... }
>>> # let's print them
>>> n
3
>>> address
'221b Baker Street, NW1 6XE, London'
>>> employee
{'age': 45, 'role': 'CTO', 'SSN': 'AB1234567'}
>>> other_name
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'other_name' is not defined
我们在前面的代码中定义了三个对象(你还记得每个 Python 对象具有的三个特征是什么吗?):
-
一个整数
n(类型:int,值:3) -
一个字符串
address(类型:str,值:福尔摩斯的地址) -
一个字典
employee(类型:dict,值:包含三个键/值对的字典)
别担心,我知道你不应该知道什么是字典。我们将在第二章“内置数据类型”中看到,它是 Python 数据结构的王者。
你有没有注意到,当我输入 employee 的定义时,提示从>>>变成了...?这是因为定义跨越了多行。
那么,n、address和employee是什么?它们是名称。我们可以使用这些名称在我们的代码中检索数据。它们需要被保存在某个地方,这样每当我们需要检索这些对象时,我们可以使用它们的名称来获取它们。我们需要一些空间来保存它们,因此:命名空间!
命名空间因此是名称到对象的映射。例如内置名称集(包含在任何 Python 程序中始终可访问的函数)、模块中的全局名称和函数中的局部名称。甚至对象的属性集也可以被视为命名空间。
命名空间的美妙之处在于它们允许你以清晰的方式定义和组织你的名称,而不会重叠或干扰。例如,与我们在图书馆中寻找的书相关联的命名空间可以用来导入书本本身,就像这样:
from library.second_floor.section_x.row_three import book
我们从library命名空间开始,通过点(.)运算符,我们进入该命名空间。在这个命名空间中,我们寻找second_floor,再次使用.运算符进入它。然后我们进入section_x,最后在最后一个命名空间row_three中找到了我们正在寻找的名称:book。
当我们处理真实的代码示例时,通过命名空间的遍历将更加清晰。现在,只需记住命名空间是名称与对象相关联的地方。
还有一个概念,与命名空间的概念密切相关,我想简要谈谈:作用域。
作用域
根据 Python 的文档:
“作用域是 Python 程序的文本区域,其中命名空间是直接可访问的。”
直接可访问意味着当你寻找一个未经修饰的名称引用时,Python 会尝试在命名空间中找到它。
作用域是静态确定的,但实际上,在运行时,它们是动态使用的。这意味着通过检查源代码,你可以知道对象的作用域是什么,但这并不阻止软件在运行时改变它。Python 提供了四种不同的作用域(当然,不一定同时都存在):
-
局部作用域,它是最内层的作用域,包含局部名称。
-
封闭作用域,也就是任何封闭函数的作用域。它包含非局部名称和非全局名称。
-
全局作用域包含全局名称。
-
内置作用域包含内置名称。Python 带有一组函数,你可以直接使用,比如
print、all、abs等。它们存在于内置作用域中。
规则如下:当我们引用一个名称时,Python 会从当前命名空间开始查找它。如果找不到该名称,Python 会继续搜索封闭范围,直到搜索内置范围。如果在搜索内置范围后没有找到名称,则 Python 会引发一个NameError异常,这基本上意味着该名称尚未定义(您在前面的示例中看到了这一点)。
当查找名称时,命名空间的扫描顺序是:本地,封闭,全局,内置(LEGB)。
这都是非常理论的,所以让我们看一个例子。为了向您展示本地和封闭命名空间,我将不得不定义一些函数。如果您暂时不熟悉它们的语法,不要担心。我们将在第四章中学习函数,代码的构建块。只需记住,在下面的代码中,当您看到def时,这意味着我正在定义一个函数:
# scopes1.py
# Local versus Global
# we define a function, called local
def local():
m = 7
print(m)
m = 5
print(m)
# we call, or `execute` the function local
local()
在前面的示例中,我们在全局范围和本地范围(由local函数定义)中定义了相同的名称m。当我们使用以下命令执行此程序时(您已激活了您的虚拟环境吗?):
$ python scopes1.py
我们在控制台上看到两个数字打印出来:5和7。
Python 解释器解析文件时,从上到下。首先找到一对注释行,然后解析函数local的定义。调用时,此函数执行两件事:它设置一个名称到代表数字7的对象,并将其打印出来。Python 解释器继续前进,找到另一个名称绑定。这次绑定发生在全局范围中,值为5。下一行是对print函数的调用,它被执行(因此我们在控制台上得到了第一个打印出的值:5)。
在此之后,调用函数local。此时,Python 执行该函数,因此此时发生绑定m = 7并打印出来。
非常重要的一点是代码的一部分属于local函数的定义,右侧缩进了四个空格。事实上,Python 通过缩进代码来定义作用域。通过缩进进入作用域,通过取消缩进退出作用域。一些编码人员使用两个空格,其他人使用三个空格,但建议使用的空格数是四个。这是最大化可读性的一个很好的措施。我们稍后会更多地讨论编写 Python 代码时应该采用的所有惯例。
如果我们删除m = 7这一行会发生什么?记住 LEGB 规则。Python 会开始在本地范围(函数local)中查找m,找不到,它会转到下一个封闭范围。在这种情况下,下一个是全局范围,因为没有包裹在local周围的封闭函数。因此,我们会在控制台上看到两个数字5。让我们实际看一下代码会是什么样子:
# scopes2.py
# Local versus Global
def local():
# m doesn't belong to the scope defined by the local function
# so Python will keep looking into the next enclosing scope.
# m is finally found in the global scope
print(m, 'printing from the local scope')
m = 5
print(m, 'printing from the global scope')
local()
运行scopes2.py将打印出:
$ python scopes2.py
5 printing from the global scope
5 printing from the local scope
正如预期的那样,Python 首次打印m,然后当调用函数local时,m在其范围内找不到,因此 Python 按照 LEGB 链继续寻找,直到在全局范围中找到m。
让我们看一个带有额外层次的示例,封闭范围:
# scopes3.py
# Local, Enclosing and Global
def enclosing_func():
m = 13
def local():
# m doesn't belong to the scope defined by the local
# function so Python will keep looking into the next
# enclosing scope. This time m is found in the enclosing
# scope
print(m, 'printing from the local scope')
# calling the function local
local()
m = 5
print(m, 'printing from the global scope')
enclosing_func()
运行scopes3.py将在控制台上打印:
$ python scopes3.py
(5, 'printing from the global scope')
(13, 'printing from the local scope')
正如您所看到的,函数local中的print指令与以前一样引用m。m在函数本身中仍未定义,因此 Python 按照 LEGB 顺序开始遍历范围。这次m在封闭范围中找到。
现在如果这还不是很清楚,不要担心。随着我们在书中的例子,你会慢慢理解的。Python 教程的Classes部分(docs.python.org/3/tutorial/classes.html)有一段有趣的关于作用域和命名空间的段落。如果你想更深入地理解这个主题,一定要在某个时候阅读一下。
在我们结束这一章之前,我想再多谈谈对象。毕竟,基本上 Python 中的一切都是对象,所以我认为它们值得更多的关注。
对象和类
当我之前在章节A proper introduction中介绍对象时,我说我们用它们来代表现实生活中的对象。例如,现在我们在网上销售各种商品,我们需要能够正确地处理、存储和表示它们。但对象实际上远不止于此。在 Python 中,你将要做的大部分事情都与操作对象有关。
因此,不要深入细节(我们将在以后的章节中做到这一点),我想给你一个关于类和对象的简而言之的解释。
我们已经看到对象是 Python 对数据的抽象。事实上,Python 中的一切都是对象,包括数字,字符串(保存文本的数据结构),容器,集合,甚至函数。你可以把它们想象成至少有三个特征的盒子:一个 ID(唯一),一个类型和一个值。
但它们是如何产生的?我们如何创建它们?我们如何编写我们自己的自定义对象?答案就在一个简单的词中:类。
事实上,对象是类的实例。Python 的美妙之处在于类本身也是对象,但我们不要深入这个领域。这会导致这种语言中最高级的概念之一:元类。现在,你理解类和对象之间的区别最好的方法是通过一个例子。
比如一个朋友告诉你,我买了一辆新自行车! 你立刻明白她在说什么。你看到了这辆自行车吗?没有。你知道它是什么颜色吗?不知道。品牌?不知道。你对它了解多少?不知道。但与此同时,你知道你需要了解的一切,以理解你的朋友告诉你她买了一辆新自行车。你知道自行车有两个轮子连接到一个框架上,有一个鞍座,踏板,把手,刹车等等。换句话说,即使你没有看到自行车本身,你知道自行车的概念。一组抽象的特征和特性共同形成了一个叫做自行车的东西。
在计算机编程中,这就是所谓的类。就是这么简单。类用于创建对象。事实上,对象被称为类的实例。
换句话说,我们都知道自行车是什么;我们知道这个类。但是我有自己的自行车,它是自行车类的一个实例。我的自行车是一个具有自己特征和方法的对象。你也有自己的自行车。同样的类,但不同的实例。世界上制造的每辆自行车都是自行车类的一个实例。
让我们看一个例子。我们将编写一个定义自行车的类,然后我们将创建两辆自行车,一辆红色的,一辆蓝色的。我会保持代码非常简单,但如果你不完全理解它,不要担心;你现在需要关心的是理解类和对象(或类的实例)之间的区别:
# bike.py
# let's define the class Bike
class Bike:
def __init__(self, colour, frame_material):
self.colour = colour
self.frame_material = frame_material
def brake(self):
print("Braking!")
# let's create a couple of instances
red_bike = Bike('Red', 'Carbon fiber')
blue_bike = Bike('Blue', 'Steel')
# let's inspect the objects we have, instances of the Bike class.
print(red_bike.colour) # prints: Red
print(red_bike.frame_material) # prints: Carbon fiber
print(blue_bike.colour) # prints: Blue
print(blue_bike.frame_material) # prints: Steel
# let's brake!
red_bike.brake() # prints: Braking!
我希望现在我不需要再告诉你每次都要运行文件了,对吧?文件名在代码块的第一行中指定。只需运行$ python filename,一切都会很好。但记得要激活你的虚拟环境!
这里有很多有趣的事情要注意。首先,类的定义是通过class语句完成的。无论class语句之后的代码是什么,并且缩进,都被称为类的主体。在我们的例子中,属于类定义的最后一行是print("Braking!")。
在定义了类之后,我们准备创建实例。你可以看到类主体承载了两个方法的定义。方法基本上(和简单地)是属于类的函数。
第一个方法__init__是一个初始化器。它使用一些 Python 魔术来使用我们在创建时传递的值设置对象。
在 Python 中,每个具有前导和尾随双下划线的方法都被称为魔术方法。魔术方法被 Python 用于多种不同的目的;因此,使用两个前导和尾随下划线命名自定义方法从来都不是一个好主意。这种命名约定最好留给 Python。
我们定义的另一种方法brake只是一个额外方法的示例,如果我们想要刹车自行车,我们可以调用它。当然,它只包含一个print语句;这只是一个例子。
我们创建了两辆自行车。一辆是红色的,有碳纤维框架,另一辆是蓝色的,有钢框架。我们在创建时传递这些值。创建后,我们打印出红色自行车的颜色属性和框架类型,以及蓝色自行车的框架类型,这只是一个例子。我们还调用了red_bike的brake方法。
最后要注意的一件事。你还记得我告诉过你对象的属性集被认为是一个命名空间吗?我希望现在我说的更清楚了。你可以看到通过不同的命名空间(red_bike,blue_bike)获取frame_type属性,我们得到不同的值。没有重叠,没有混淆。
点(.)运算符当然是我们用来进入命名空间的手段,在对象的情况下也是如此。
如何编写良好代码的指南
编写良好的代码并不像看起来那么容易。正如我之前所说,良好的代码展示了一长串相当难以组合的特质。编写良好的代码在某种程度上是一种艺术。无论你愿意在哪个阶段停下来,有一件事你可以接受,那就是让你的代码立即变得更好的东西:PEP 8。
根据维基百科:
"Python 的发展主要通过 Python Enhancement Proposal (PEP) 过程进行。PEP 过程是提出主要新功能、收集社区对问题的意见和记录 Python 设计决策的主要机制。"
PEP 8 可能是所有 PEP 中最著名的。它提出了一套简单但有效的指南,定义了 Python 的美学,以便我们编写优美的 Python 代码。如果你从这一章中得到一个建议,请让它成为这个:使用它。拥抱它。你以后会感谢我的。
今天的编码不再是一个签入/签出的业务。相反,它更多的是一种社会努力。几个开发人员通过 Git 和 Mercurial 等工具共同协作一段代码,结果是由许多不同的手所创造的代码。
Git 和 Mercurial 可能是今天使用最多的分布式版本控制系统。它们是设计帮助开发团队共同协作在同一软件上的基本工具。
如今,我们更需要有一种一致的编写代码的方式,以便最大限度地提高可读性。当公司的所有开发人员遵守 PEP 8 时,他们中的任何一个人落在一段代码上,都不会觉得他们自己写的。这实际上经常发生在我身上(我总是忘记我写的代码)。
这有一个巨大的优势:当您阅读自己可以写出的代码时,您会轻松阅读。没有约定,每个编码人员都会按照他们最喜欢的方式或者他们被教导或者习惯的方式来构建代码,这意味着必须根据别人的风格来解释每一行。这意味着要花费更多的时间来理解它。由于 PEP 8,我们可以避免这种情况。我是它的忠实粉丝,如果代码不遵守它,我就不会签署代码审查。因此,请花时间学习它;这非常重要。
在本书的例子中,我会尽量尊重它。不幸的是,我没有 79 个字符的奢侈(这是 PEP 8 建议的最大行长度),我将不得不减少空行和其他东西,但我向你保证我会尽量布置我的代码,使其尽可能可读。
Python 文化
Python 已经被广泛应用于所有编码行业。它被许多不同的公司用于许多不同的目的,也被用于教育(因为它有许多优点,而且易于学习,所以它是一个非常出色的语言)。
Python 今天如此受欢迎的一个原因是,围绕它的社区是庞大、充满活力且充满杰出人才的。世界各地都组织了许多活动,主要是围绕 Python 或其主要的 Web 框架 Django。
Python 是开放的,而且通常拥抱它的人的思想也是开放的。在 Python 网站的社区页面上查看更多信息并参与其中!
Python 还有另一个方面,围绕着“Pythonic”的概念。这与 Python 允许您使用一些在其他地方找不到的习语有关,至少不是以相同的形式或者不是那么容易使用(现在我在使用不是 Python 的语言编码时感到相当幽闭恐惧)。
无论如何,多年来,这种 Pythonic 的概念已经出现了,我理解的方式是按照 Python 应该完成的方式来做事。
为了帮助您更好地了解 Python 的文化和 Pythonic,我将向您展示Python 之禅。这是一个非常受欢迎的可爱彩蛋。打开 Python 控制台,键入import this。接下来的内容就是这行的结果:
>>> import this
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!
这里有两个阅读层次。一个是将其视为一组以有趣的方式提出的指导方针。另一个是牢记在心,并可能偶尔阅读一下,试图理解它是如何指涉更深层次的东西的:一些 Python 特性,您将不得不深入理解,以便按照它应该被编写的方式编写 Python。从有趣的层次开始,然后深入挖掘。始终深入挖掘。
关于 IDE 的说明
关于 IDE 的一些话。要遵循本书中的示例,您不需要 IDE;任何文本编辑器都可以。如果您想要更多高级功能,比如语法着色和自动完成,您就需要获取一个 IDE。您可以在 Python 网站上找到一份全面的开源 IDE 列表(只需搜索 Python IDEs)。我个人使用 Sublime Text 编辑器。它是免费试用的,成本只需几美元。我一生中尝试过许多 IDE,但这是让我最有效率的一个。
两个重要的建议:
-
无论您选择使用什么 IDE,都要努力学会它,以便能够充分利用它的优势,但不要依赖它。偶尔练习使用 VIM(或任何其他文本编辑器);学会能够在任何平台上使用任何一套工具进行工作。
-
无论您使用什么文本编辑器/IDE,写 Python 时,缩进是四个空格。不要使用制表符,也不要将它们与空格混合使用。使用四个空格,不是两个,不是三个,也不是五个。就使用四个。整个世界都是这样的,你不想因为你喜欢三个空格的布局而成为局外人。
总结
在这一章中,我们开始探索编程和 Python 的世界。我们只是初步了解了一点点,涉及到的概念将在本书的后面更详细地讨论。
我们谈到了 Python 的主要特性,谁在使用它以及为什么,以及我们可以用哪些不同的方式编写 Python 程序。
在本章的最后部分,我们简要介绍了命名空间、作用域、类和对象的基本概念。我们还看到了 Python 代码如何使用模块和包进行组织。
在实际操作中,我们学会了如何在系统上安装 Python,如何确保我们拥有所需的工具pip和 virtualenv,并创建并激活了我们的第一个虚拟环境。这将使我们能够在一个独立的环境中工作,而不会危及 Python 系统的安装。
现在你已经准备好和我一起开始这段旅程了。你所需要的只是热情、激活的虚拟环境、这本书、你的手指和一些咖啡。
尝试跟着例子走;我会让它们简单而简短。如果你能熟练掌握它们,你会比仅仅阅读更好地记住它们。
在下一章中,我们将探索 Python 丰富的内置数据类型。有很多内容需要涵盖和学习!
第二章:内置数据类型
“数据!数据!数据!”他不耐烦地喊道。“没有黏土,我无法制砖。”– 福尔摩斯 – 铜山丛林的冒险
您使用计算机的一切都是在管理数据。数据有许多不同的形状和口味。这是您听的音乐,您流媒体的电影,您打开的 PDF。甚至您正在阅读的本章的来源只是一个文件,即数据。
数据可以是简单的,用整数表示年龄,也可以是复杂的,比如在网站上下的订单。它可以是关于单个对象或关于它们的集合。数据甚至可以是关于数据,即元数据。描述其他数据结构的设计或描述应用程序数据或其上下文的数据。在 Python 中,对象是数据的抽象,Python 有各种各样的数据结构,您可以使用它们来表示数据,或者将它们组合起来创建自己的自定义数据。
在本章中,我们将涵盖以下内容:
-
Python 对象的结构
-
可变性和不可变性
-
内置数据类型:数字、字符串、序列、集合和映射类型
-
集合模块
-
枚举
一切皆为对象
在我们深入具体内容之前,我希望您对 Python 中的对象非常清楚,所以让我们再多谈谈它们。正如我们已经说过的,Python 中的一切都是对象。但是当您在 Python 模块中键入age = 42这样的指令时,实际上发生了什么?
如果您转到pythontutor.com/,您可以在文本框中键入该指令并获得其可视化表示。记住这个网站;它对于巩固您对幕后发生的事情的理解非常有用。
因此,创建了一个对象。它获得了id,type设置为int(整数),value设置为42。一个名为age的名称被放置在全局命名空间中,指向该对象。因此,每当我们在全局命名空间中,在执行该行之后,我们可以通过简单地通过其名称访问它来检索该对象:age。
如果你要搬家,你会把所有的刀、叉和勺子放在一个箱子里,贴上标签餐具。你能看到这完全是相同的概念吗?这是一个可能看起来像的屏幕截图(你可能需要调整设置以获得相同的视图):

因此,在本章的其余部分,每当您读到诸如name = some_value之类的内容时,都要想象一个放置在命名空间中并与编写该指令的范围相关联的名称,指向具有id、type和value的对象的漂亮箭头。关于这个机制还有一些要说的地方,但是通过一个例子来谈论它要容易得多,所以我们稍后会回到这个问题。
可变还是不可变?这是个问题
Python 对数据做出的第一个基本区分是关于对象的值是否会改变。如果值可以改变,则对象称为可变,而如果值不能改变,则对象称为不可变。
非常重要的是,您理解可变和不可变之间的区别,因为它会影响您编写的代码,所以这里有一个问题:
>>> age = 42
>>> age
42
>>> age = 43 #A
>>> age
43
在前面的代码中,在#A行,我改变了年龄的值吗?嗯,没有。但现在是43(我听到你说...)。是的,是43,但42是一个整数,类型为int,是不可变的。所以,真正发生的是,在第一行,age是一个名称,它被设置为指向一个int对象,其值为42。当我们键入age = 43时,发生的是创建另一个对象,类型为int,值为43(另外,id将不同),并且名称age被设置为指向它。因此,我们没有将42更改为43。实际上,我们只是将age指向了一个不同的位置:值为43的新int对象。让我们看看相同的代码也打印出 ID:
>>> age = 42
>>> id(age)
4377553168
>>> age = 43
>>> id(age)
4377553200
请注意,我们通过调用内置的id函数打印 ID。如你所见,它们是不同的,正如预期的那样。请记住,age一次只指向一个对象:首先是42,然后是43。从不同时。
现在,让我们看一个使用可变对象的相同示例。对于这个示例,让我们只使用一个Person对象,该对象具有一个age属性(现在不用担心类声明;它只是为了完整性):
>>> class Person():
... def __init__(self, age):
... self.age = age
...
>>> fab = Person(age=42)
>>> fab.age
42
>>> id(fab)
4380878496
>>> id(fab.age)
4377553168
>>> fab.age = 25 # I wish!
>>> id(fab) # will be the same
4380878496
>>> id(fab.age) # will be different
4377552624
在这种情况下,我设置了一个type为Person(自定义类)的对象fab。在创建时,对象被赋予age为42。我打印它,以及对象id,以及age的 ID。请注意,即使我将age更改为25,fab的 ID 仍然保持不变(当然,age的 ID 已经改变了)。Python 中的自定义对象是可变的(除非你编码使它们不可变)。记住这个概念;这是非常重要的。我会在本章的其余部分提醒你。
数字
让我们从探索 Python 的内置数字数据类型开始。Python 是由一位数学和计算机科学硕士设计的,所以它对数字有很好的支持是很合理的。
数字是不可变对象。
整数
Python 整数具有无限范围,仅受可用虚拟内存的限制。这意味着实际上并不重要要存储多大的数字:只要它能够适应计算机的内存,Python 就会处理它。整数可以是正数、负数和 0(零)。它们支持所有基本的数学运算,如下例所示:
>>> a = 14
>>> b = 3
>>> a + b # addition
17
>>> a - b # subtraction
11
>>> a * b # multiplication
42
>>> a / b # true division
4.666666666666667
>>> a // b # integer division
4
>>> a % b # modulo operation (reminder of division)
2
>>> a ** b # power operation
2744
前面的代码应该很容易理解。只要注意一件重要的事情:Python 有两个除法运算符,一个执行所谓的真除法(/),它返回操作数的商,另一个是所谓的整数除法(//),它返回操作数的向下取整商。值得注意的是,在 Python 2 中,除法运算符/的行为与 Python 3 中不同。看看对于正数和负数是如何不同的:
>>> 7 / 4 # true division
1.75
>>> 7 // 4 # integer division, truncation returns 1
1
>>> -7 / 4 # true division again, result is opposite of previous
-1.75
>>> -7 // 4 # integer div., result not the opposite of previous
-2
这是一个有趣的例子。如果你在最后一行期望得到-1,不要感到难过,这只是 Python 的工作方式。在 Python 中,整数除法的结果总是朝负无穷大方向舍入。如果你想将一个数字截断为整数,而不是向下取整,你可以使用内置的int函数,如下例所示:
>>> int(1.75)
1
>>> int(-1.75)
-1
注意截断是朝0方向进行的。
还有一个运算符可以计算除法的余数。它被称为模运算符,用百分号(%)表示:
>>> 10 % 3 # remainder of the division 10 // 3
1
>>> 10 % 4 # remainder of the division 10 // 4
2
Python 3.6 中引入的一个很好的特性是能够在数字文字中添加下划线(在数字或基数说明符之间,但不能在前导或尾随)。其目的是帮助使一些数字更易读,例如1_000_000_000:
>>> n = 1_024
>>> n
1024
>>> hex_n = 0x_4_0_0 # 0x400 == 1024
>>> hex_n
1024
布尔值
布尔代数是代数的一个子集,在其中变量的值是真值:真和假。在 Python 中,True和False是两个关键字,用于表示真值。布尔值是整数的一个子类,分别像1和0一样行为。布尔值的等价int类是bool类,它返回True或False。在布尔上下文中,每个内置的 Python 对象都有一个值,这意味着当它们被传递给bool函数时,它们基本上会评估为True或False。我们将在第三章中详细了解这一切,迭代和做决定。
布尔值可以使用逻辑运算符and、or和not在布尔表达式中组合。再次,我们将在下一章中全面了解它们,所以现在让我们只看一个简单的例子:
>>> int(True) # True behaves like 1
1
>>> int(False) # False behaves like 0
0
>>> bool(1) # 1 evaluates to True in a boolean context
True
>>> bool(-42) # and so does every non-zero number
True
>>> bool(0) # 0 evaluates to False
False
>>> # quick peak at the operators (and, or, not)
>>> not True
False
>>> not False
True
>>> True and True
True
>>> False or True
True
当你尝试将True和False相加时,你会发现它们是整数的子类。Python 将它们向上转换为整数并执行加法:
>>> 1 + True
2
>>> False + 42
42
>>> 7 - True
6
Upcasting是一种从子类到其父类的类型转换操作。在这里呈现的例子中,True和False属于从整数类派生的类,当需要时会转换回整数。
实数
实数,或浮点数,根据 IEEE 754 双精度二进制浮点格式在 Python 中表示,它存储在 64 位信息中,分为三个部分:符号、指数和尾数。
满足你对这种格式的知识渴望:en.wikipedia.org/wiki/Double-precision_floating-point_format。
通常,编程语言给程序员提供两种不同的格式:单精度和双精度。前者占用 32 位内存,后者占用 64 位。Python 只支持双精度格式。让我们看一个简单的例子:
>>> pi = 3.1415926536 # how many digits of PI can you remember?
>>> radius = 4.5
>>> area = pi * (radius ** 2)
>>> area
63.617251235400005
在计算面积时,我在括号中包裹了radius ** 2。尽管这并不是必要的,因为幂运算符的优先级高于乘法运算符,但我认为这样公式读起来更容易。此外,如果你得到的面积稍有不同,不要担心。这可能取决于你的操作系统、Python 的编译方式等等。只要前几位小数正确,你就知道这是正确的结果。
sys.float_info结构序列包含有关浮点数在你的系统上的行为的信息。这是我在我的电脑上看到的:
>>> import sys
>>> sys.float_info
sys.float_info(max=1.7976931348623157e+308, max_exp=1024, max_10_exp=308, min=2.2250738585072014e-308, min_exp=-1021, min_10_exp=-307, dig=15, mant_dig=53, epsilon=2.220446049250313e-16, radix=2, rounds=1)
在这里我们需要考虑一些事情:我们有 64 位来表示浮点数。这意味着我们最多可以用2 ** 64 == 18,446,744,073,709,551,616个位来表示这些数字。看一下浮点数的max和epsilon值,你会意识到不可能表示它们所有。空间不够,所以它们被近似到最接近的可表示的数字。你可能认为只有极大或极小的数字才会受到这个问题的影响。好吧,再想一想,然后在你的控制台上尝试一下:
>>> 0.3 - 0.1 * 3 # this should be 0!!!
-5.551115123125783e-17
这告诉你什么?它告诉你,双精度数字即使在处理简单的数字如0.1或0.3时也会受到近似问题的影响。为什么这很重要?如果你处理价格、金融计算或任何不需要近似的数据,这可能是一个大问题。不用担心,Python 给你decimal类型,它不会受到这些问题的影响;我们马上会看到它们。
复数
Python 为你提供了复数支持。如果你不知道复数是什么,它们是可以用形式a + ib表示的数字,其中a和b是实数,i(或者如果你是工程师,j)是虚数单位,即-1的平方根。a和b分别被称为数字的实部和虚部。
实际上,你可能不太会用到它们,除非你在编写科学代码。让我们看一个小例子:
>>> c = 3.14 + 2.73j
>>> c.real # real part
3.14
>>> c.imag # imaginary part
2.73
>>> c.conjugate() # conjugate of A + Bj is A - Bj
(3.14-2.73j)
>>> c * 2 # multiplication is allowed
(6.28+5.46j)
>>> c ** 2 # power operation as well
(2.4067000000000007+17.1444j)
>>> d = 1 + 1j # addition and subtraction as well
>>> c - d
(2.14+1.73j)
分数和小数
让我们以分数和小数结束数字部分的介绍。分数以最简形式包含有理数的分子和分母。让我们看一个快速的例子:
>>> from fractions import Fraction
>>> Fraction(10, 6) # mad hatter?
Fraction(5, 3) # notice it's been simplified
>>> Fraction(1, 3) + Fraction(2, 3) # 1/3 + 2/3 == 3/3 == 1/1
Fraction(1, 1)
>>> f = Fraction(10, 6)
>>> f.numerator
5
>>> f.denominator
3
尽管它们有时可能非常有用,但在商业软件中并不常见。更容易的是,在所有需要精度的情况下使用小数;例如,在科学和金融计算中。
重要的是要记住,任意精度的十进制数当然会带来性能上的代价。每个数字存储的数据量远远大于分数或浮点数,以及它们的处理方式,这使得 Python 解释器在幕后做了更多的工作。另一个有趣的事情是,你可以通过访问decimal.getcontext().prec来获取和设置精度。
让我们看一个十进制数的快速例子:
>>> from decimal import Decimal as D # rename for brevity
>>> D(3.14) # pi, from float, so approximation issues
Decimal('3.140000000000000124344978758017532527446746826171875')
>>> D('3.14') # pi, from a string, so no approximation issues
Decimal('3.14')
>>> D(0.1) * D(3) - D(0.3) # from float, we still have the issue
Decimal('2.775557561565156540423631668E-17')
>>> D('0.1') * D(3) - D('0.3') # from string, all perfect
Decimal('0.0')
>>> D('1.4').as_integer_ratio() # 7/5 = 1.4 (isn't this cool?!)
(7, 5)
请注意,当我们从float构造一个Decimal数字时,它会继承所有可能来自float的近似问题。另一方面,当Decimal没有近似问题时(例如,当我们将int或string表示传递给构造函数时),计算就没有奇怪的行为。在涉及到货币时,请使用小数。
这就结束了我们对内置数值类型的介绍。现在让我们来看看序列。
不可变序列
让我们从不可变序列开始:字符串、元组和字节。
字符串和字节
Python 中的文本数据是通过str对象处理的,更常见的是字符串。它们是Unicode 代码点的不可变序列。Unicode 代码点可以表示一个字符,但也可以有其他含义,比如格式化数据。与其他语言不同,Python 没有char类型,因此一个单个字符只是一个长度为1的字符串。
Unicode 是处理数据的一个很好的方式,应该用于任何应用程序的内部。但是,当涉及到存储文本数据或在网络上传输文本数据时,您可能希望对其进行编码,使用适合您使用的介质的适当编码。编码的结果会产生一个bytes对象,其语法和行为类似于字符串。Python 中的字符串字面量使用单引号、双引号或三引号(单引号或双引号)编写。如果使用三引号构建,字符串可以跨多行。一个例子将澄清这一点:
>>> # 4 ways to make a string
>>> str1 = 'This is a string. We built it with single quotes.'
>>> str2 = "This is also a string, but built with double quotes."
>>> str3 = '''This is built using triple quotes,
... so it can span multiple lines.'''
>>> str4 = """This too
... is a multiline one
... built with triple double-quotes."""
>>> str4 #A
'This too\nis a multiline one\nbuilt with triple double-quotes.'
>>> print(str4) #B
This too
is a multiline one
built with triple double-quotes.
在#A和#B中,我们打印str4,首先是隐式地,然后是显式地,使用print函数。一个很好的练习是找出它们为什么不同。您敢挑战吗?(提示:查找str函数。)
字符串,就像任何序列一样,都有一个长度。您可以通过调用len函数来获得这个长度:
>>> len(str1)
49
编码和解码字符串
使用encode/decode方法,我们可以对 Unicode 字符串进行编码和解码字节对象。UTF-8是一种可变长度的字符编码,能够编码所有可能的 Unicode 代码点。它是网络的主要编码。还要注意,通过在字符串声明前添加一个字面量b,我们创建了一个bytes对象:
>>> s = "This is üŋíc0de" # unicode string: code points
>>> type(s)
<class 'str'>
>>> encoded_s = s.encode('utf-8') # utf-8 encoded version of s
>>> encoded_s
b'This is \xc3\xbc\xc5\x8b\xc3\xadc0de' # result: bytes object
>>> type(encoded_s) # another way to verify it
<class 'bytes'>
>>> encoded_s.decode('utf-8') # let's revert to the original
'This is üŋíc0de'
>>> bytes_obj = b"A bytes object" # a bytes object
>>> type(bytes_obj)
<class 'bytes'>
索引和切片字符串
在操作序列时,很常见需要在一个精确的位置(索引)访问它们,或者从中获取一个子序列(切片)。在处理不可变序列时,这两个操作都是只读的。
虽然索引以一种形式出现,即在序列中的任何位置进行基于零的访问,但切片以不同的形式出现。当您获取序列的一个切片时,您可以指定start和stop位置以及step。它们用冒号(:)分隔,就像这样:my_sequence[start:stop:step]。所有参数都是可选的,start是包含的,stop是不包含的。最好通过示例来展示,而不是用更多的文字来解释它们:
>>> s = "The trouble is you think you have time."
>>> s[0] # indexing at position 0, which is the first char
'T'
>>> s[5] # indexing at position 5, which is the sixth char
'r'
>>> s[:4] # slicing, we specify only the stop position
'The '
>>> s[4:] # slicing, we specify only the start position
'trouble is you think you have time.'
>>> s[2:14] # slicing, both start and stop positions
'e trouble is'
>>> s[2:14:3] # slicing, start, stop and step (every 3 chars)
'erb '
>>> s[:] # quick way of making a copy
'The trouble is you think you have time.'
在所有行中,最后一行可能是最有趣的。如果您不指定参数,Python 会为您填充默认值。在这种情况下,start将是字符串的开始,stop将是字符串的结束,step将是默认的1。这是一种简单快捷的方法来获取字符串s的副本(相同的值,但不同的对象)。您能找到一种使用切片获取字符串的反向副本的方法吗(不要查找,自己找到)?
字符串格式化
字符串的一个特性是能够用作模板。有几种不同的格式化字符串的方法,对于所有可能性的完整列表,我鼓励您查阅文档。以下是一些常见的例子:
>>> greet_old = 'Hello %s!'
>>> greet_old % 'Fabrizio'
'Hello Fabrizio!'
>>> greet_positional = 'Hello {} {}!'
>>> greet_positional.format('Fabrizio', 'Romano')
'Hello Fabrizio Romano!'
>>> greet_positional_idx = 'This is {0}! {1} loves {0}!'
>>> greet_positional_idx.format('Python', 'Fabrizio')
'This is Python! Fabrizio loves Python!'
>>> greet_positional_idx.format('Coffee', 'Fab')
'This is Coffee! Fab loves Coffee!'
>>> keyword = 'Hello, my name is {name} {last_name}'
>>> keyword.format(name='Fabrizio', last_name='Romano')
'Hello, my name is Fabrizio Romano'
在前面的示例中,您可以看到四种不同的格式化字符串的方式。第一种依赖于%运算符的方式已被弃用,不应再使用。当前,格式化字符串的现代方式是使用format字符串方法。您可以从不同的示例中看到,一对大括号在字符串中充当占位符。当我们调用format时,我们向其提供替换占位符的数据。我们可以在大括号内指定索引(以及更多内容),甚至名称,这意味着我们将不得不使用关键字参数而不是位置参数来调用format。
注意通过向format调用提供不同数据来呈现greet_positional_idx的方式不同。显然,我喜欢 Python 和咖啡...大惊喜!
我想向您展示的最后一个功能是 Python 的一个相对较新的添加(版本 3.6),它被称为格式化字符串文字。这个功能非常酷:字符串以f为前缀,并包含用大括号括起来的替换字段。替换字段是在运行时评估的表达式,然后使用format协议进行格式化:
>>> name = 'Fab'
>>> age = 42
>>> f"Hello! My name is {name} and I'm {age}"
"Hello! My name is Fab and I'm 42"
>>> from math import pi
>>> f"No arguing with {pi}, it's irrational..."
"No arguing with 3.141592653589793, it's irrational..."
查看官方文档,了解有关字符串格式和其强大功能的一切。
元组
我们将要看到的最后一个不可变序列类型是元组。元组是任意 Python 对象的序列。在元组中,项目用逗号分隔。它们在 Python 中随处可见,因为它们允许在其他语言中难以复制的模式。有时元组会被隐式使用;例如,一次设置多个变量,或者允许函数返回多个不同的对象(通常函数只返回一个对象,在许多其他语言中),甚至在 Python 控制台中,您可以隐式使用元组以一条指令打印多个元素。我们将为所有这些情况看到示例:
>>> t = () # empty tuple
>>> type(t)
<class 'tuple'>
>>> one_element_tuple = (42, ) # you need the comma!
>>> three_elements_tuple = (1, 3, 5) # braces are optional here
>>> a, b, c = 1, 2, 3 # tuple for multiple assignment
>>> a, b, c # implicit tuple to print with one instruction
(1, 2, 3)
>>> 3 in three_elements_tuple # membership test
True
请注意,成员运算符in也可以与列表、字符串、字典以及一般的集合和序列对象一起使用。
请注意,要创建一个只有一个项目的元组,我们需要在项目后面加上逗号。原因是没有逗号,该项目就是自己包裹在括号中,有点像冗余的数学表达式。还要注意,在赋值时,括号是可选的,因此my_tuple = 1, 2, 3与my_tuple = (1, 2, 3)是相同的。
元组赋值允许我们做的一件事是一行交换,不需要第三个临时变量。让我们首先看一下更传统的做法:
>>> a, b = 1, 2
>>> c = a # we need three lines and a temporary var c
>>> a = b
>>> b = c
>>> a, b # a and b have been swapped
(2, 1)
现在让我们看看我们如何在 Python 中完成它:
>>> a, b = 0, 1
>>> a, b = b, a # this is the Pythonic way to do it
>>> a, b
(1, 0)
看一下显示 Python 交换两个值的 Pythonic 方式。您还记得我在第一章中写的吗,Python 的初步介绍?Python 程序通常是等效 Java 或 C++代码大小的五分之一到三分之一,并且像一行交换这样的功能有助于实现这一点。Python 是优雅的,这里的优雅也意味着经济。
由于它们是不可变的,元组可以用作字典的键(我们很快会看到这一点)。对我来说,元组是 Python 中最接近数学向量的内置数据。这并不意味着这就是它们被创建的原因。元组通常包含异构元素序列,而另一方面,列表大多数情况下是同构的。此外,元组通常通过解包或索引访问,而列表通常是迭代访问。
可变序列
可变序列与其不可变的姐妹们不同,它们可以在创建后进行更改。Python 中有两种可变序列类型:列表和字节数组。我之前说过字典是 Python 中数据结构的王者。我想这使得列表成为它合法的女王。
列表
Python 列表是可变序列。它们与元组非常相似,但没有不可变性的限制。列表通常用于存储同类对象的集合,但没有什么阻止你存储异类集合。列表可以以许多不同的方式创建。让我们看一个例子:
>>> [] # empty list
[]
>>> list() # same as []
[]
>>> [1, 2, 3] # as with tuples, items are comma separated
[1, 2, 3]
>>> [x + 5 for x in [2, 3, 4]] # Python is magic
[7, 8, 9]
>>> list((1, 3, 5, 7, 9)) # list from a tuple
[1, 3, 5, 7, 9]
>>> list('hello') # list from a string
['h', 'e', 'l', 'l', 'o']
在前面的例子中,我向你展示了如何使用不同的技术创建列表。我希望你仔细看一下那一行,它说Python is magic,我不指望你在这一点上完全理解它(除非你作弊了,你不是新手!)。这被称为列表推导,是 Python 的一个非常强大的函数特性。
创建列表是好的,但真正的乐趣是在使用它们时,所以让我们看看它们赋予我们的主要方法:
>>> a = [1, 2, 1, 3]
>>> a.append(13) # we can append anything at the end
>>> a
[1, 2, 1, 3, 13]
>>> a.count(1) # how many `1` are there in the list?
2
>>> a.extend([5, 7]) # extend the list by another (or sequence)
>>> a
[1, 2, 1, 3, 13, 5, 7]
>>> a.index(13) # position of `13` in the list (0-based indexing)
4
>>> a.insert(0, 17) # insert `17` at position 0
>>> a
[17, 1, 2, 1, 3, 13, 5, 7]
>>> a.pop() # pop (remove and return) last element
7
>>> a.pop(3) # pop element at position 3
1
>>> a
[17, 1, 2, 3, 13, 5]
>>> a.remove(17) # remove `17` from the list
>>> a
[1, 2, 3, 13, 5]
>>> a.reverse() # reverse the order of the elements in the list
>>> a
[5, 13, 3, 2, 1]
>>> a.sort() # sort the list
>>> a
[1, 2, 3, 5, 13]
>>> a.clear() # remove all elements from the list
>>> a
[]
前面的代码为你总结了列表的主要方法。我想向你展示它们有多么强大,以extend为例。你可以使用任何序列类型来扩展列表:
>>> a = list('hello') # makes a list from a string
>>> a
['h', 'e', 'l', 'l', 'o']
>>> a.append(100) # append 100, heterogeneous type
>>> a
['h', 'e', 'l', 'l', 'o', 100]
>>> a.extend((1, 2, 3)) # extend using tuple
>>> a
['h', 'e', 'l', 'l', 'o', 100, 1, 2, 3]
>>> a.extend('...') # extend using string
>>> a
['h', 'e', 'l', 'l', 'o', 100, 1, 2, 3, '.', '.', '.']
现在,让我们看看你可以用列表做哪些最常见的操作:
>>> a = [1, 3, 5, 7]
>>> min(a) # minimum value in the list
1
>>> max(a) # maximum value in the list
7
>>> sum(a) # sum of all values in the list
16
>>> len(a) # number of elements in the list
4
>>> b = [6, 7, 8]
>>> a + b # `+` with list means concatenation
[1, 3, 5, 7, 6, 7, 8]
>>> a * 2 # `*` has also a special meaning
[1, 3, 5, 7, 1, 3, 5, 7]
前面代码中的最后两行非常有趣,因为它们向我们介绍了一个叫做运算符重载的概念。简而言之,它意味着诸如+、-、*、%等运算符,根据它们所用的上下文,可能代表不同的操作。对两个列表求和没有任何意义,对吧?因此,+号用于将它们连接起来。因此,*号用于根据右操作数将列表连接到自身。
现在,让我们再进一步,看一些更有趣的东西。我想向你展示sorted方法有多么强大,以及在 Python 中实现需要在其他语言中付出很大努力才能实现的结果有多容易:
>>> from operator import itemgetter
>>> a = [(5, 3), (1, 3), (1, 2), (2, -1), (4, 9)]
>>> sorted(a)
[(1, 2), (1, 3), (2, -1), (4, 9), (5, 3)]
>>> sorted(a, key=itemgetter(0))
[(1, 3), (1, 2), (2, -1), (4, 9), (5, 3)]
>>> sorted(a, key=itemgetter(0, 1))
[(1, 2), (1, 3), (2, -1), (4, 9), (5, 3)]
>>> sorted(a, key=itemgetter(1))
[(2, -1), (1, 2), (5, 3), (1, 3), (4, 9)]
>>> sorted(a, key=itemgetter(1), reverse=True)
[(4, 9), (5, 3), (1, 3), (1, 2), (2, -1)]
前面的代码值得解释一下。首先,a是一个元组的列表。这意味着a中的每个元素都是一个元组(准确地说是一个 2 元组)。当我们调用sorted(some_list)时,我们得到了some_list的排序版本。在这种情况下,对 2 元组的排序是通过对元组中的第一个项目进行排序,当第一个项目相同时,再对第二个项目进行排序。你可以在sorted(a)的结果中看到这种行为,得到了[(1, 2), (1, 3), ...]。Python 还给了我们控制排序必须运行的元组元素的能力。注意,当我们指示sorted函数在每个元组的第一个元素上工作(通过key=itemgetter(0))时,结果是不同的:[(1, 3), (1, 2), ...]。排序仅在每个元组的第一个元素上进行(即在位置 0 上)。如果我们想复制简单的sorted(a)调用的默认行为,我们需要使用key=itemgetter(0, 1),这告诉 Python 首先对元组中位置 0 的元素进行排序,然后对位置 1 的元素进行排序。比较结果,你会看到它们是匹配的。
为了完整起见,我包括了一个仅在位置 1 的元素上进行排序的示例,以及相同的示例,但是按相反的顺序。如果你曾经见过 Java 中的排序,我希望你此刻会感到非常印象深刻。
Python 的排序算法非常强大,由 Tim Peters 编写(我们已经见过这个名字,你还记得是在什么时候吗?)。它被称为Timsort,它是merge和insertion sort的混合体,比大多数其他用于主流编程语言的算法具有更好的时间性能。Timsort 是一种稳定的排序算法,这意味着当多个记录具有相同的键时,它们的原始顺序被保留。我们在sorted(a, key=itemgetter(0))的结果中看到了这一点,它产生了[(1, 3), (1, 2), ...],其中这两个元组的顺序被保留,因为它们在位置 0 上具有相同的值。
字节数组
总结可变序列类型的概述,让我们花几分钟时间来了解bytearray类型。基本上,它们代表了bytes对象的可变版本。它们公开了大多数可变序列的常规方法,以及bytes类型的大多数方法。项目是范围[0, 256)内的整数。
关于间隔,我将使用开/闭范围的标准表示法。一端的方括号表示该值包括在内,而圆括号表示该值不包括在内。通常根据边缘元素的类型来推断粒度,例如,区间[3, 7]表示 3 和 7 之间的所有整数,包括 3 和 7。另一方面,(3, 7)表示 3 和 7 之间的所有整数,不包括 3 和 7(因此为 4、5 和 6)。bytearray类型中的项目是 0 到 256 之间的整数;0 包括在内,256 不包括在内。通常以这种方式表达间隔的一个原因是为了便于编码。如果我们将范围[a, b)分成N个连续范围,我们可以轻松地表示原始范围的连接,如下所示:
[a,k[1])+[k[1],k[2])+[k[2],k[3])+...+[k[N-1],b)
中间点(k[i])在一端被排除,在另一端被包括,这样在处理代码中处理间隔时就可以轻松进行连接和拆分。
让我们看一个bytearray类型的快速示例:
>>> bytearray() # empty bytearray object
bytearray(b'')
>>> bytearray(10) # zero-filled instance with given length
bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00')
>>> bytearray(range(5)) # bytearray from iterable of integers
bytearray(b'\x00\x01\x02\x03\x04')
>>> name = bytearray(b'Lina') #A - bytearray from bytes
>>> name.replace(b'L', b'l')
bytearray(b'lina')
>>> name.endswith(b'na')
True
>>> name.upper()
bytearray(b'LINA')
>>> name.count(b'L')
1
如您在前面的代码中所看到的,有几种方法可以创建bytearray对象。它们在许多情况下都很有用;例如,当通过套接字接收数据时,它们消除了在轮询时连接数据的需要,因此它们可能非常方便。在#A行,我创建了一个名为name的bytearray,从字节文字b'Lina'中显示了bytearray对象如何公开来自序列和字符串的方法,这非常方便。如果您仔细考虑,它们可以被认为是可变字符串。
集合类型
Python 还提供了两种集合类型,set和frozenset。set类型是可变的,而frozenset是不可变的。它们是不可变对象的无序集合。可哈希性是一个特性,允许对象用作字典键和集合成员,正如我们很快将看到的。
从官方文档中:如果对象具有在其生命周期内永远不会更改的哈希值,并且可以与其他对象进行比较,则对象是可哈希的。可哈希性使对象可用作字典键和集合成员,因为这些数据结构在内部使用哈希值。所有 Python 的不可变内置对象都是可哈希的,而可变容器则不是。
比较相等的对象必须具有相同的哈希值。集合非常常用于测试成员资格,因此让我们在以下示例中介绍in运算符:
>>> small_primes = set() # empty set
>>> small_primes.add(2) # adding one element at a time
>>> small_primes.add(3)
>>> small_primes.add(5)
>>> small_primes
{2, 3, 5}
>>> small_primes.add(1) # Look what I've done, 1 is not a prime!
>>> small_primes
{1, 2, 3, 5}
>>> small_primes.remove(1) # so let's remove it
>>> 3 in small_primes # membership test
True
>>> 4 in small_primes
False
>>> 4 not in small_primes # negated membership test
True
>>> small_primes.add(3) # trying to add 3 again
>>> small_primes
{2, 3, 5} # no change, duplication is not allowed
>>> bigger_primes = set([5, 7, 11, 13]) # faster creation
>>> small_primes | bigger_primes # union operator `|`
{2, 3, 5, 7, 11, 13}
>>> small_primes & bigger_primes # intersection operator `&`
{5}
>>> small_primes - bigger_primes # difference operator `-`
{2, 3}
在前面的代码中,您可以看到创建集合的两种不同方法。一种是创建一个空集合,然后逐个添加元素。另一种是使用数字列表作为构造函数的参数来创建集合,这样就可以为我们完成所有工作。当然,您可以从列表或元组(或任何可迭代对象)创建集合,然后随意添加和删除集合成员。
我们将在下一章中查看可迭代对象和迭代。现在,只需知道可迭代对象是可以按方向迭代的对象。
创建集合的另一种方法是简单地使用花括号表示法,如下所示:
>>> small_primes = {2, 3, 5, 5, 3}
>>> small_primes
{2, 3, 5}
请注意,我添加了一些重复以强调结果集不会有任何重复。让我们看一个关于集合类型的不可变对应物frozenset的示例:
>>> small_primes = frozenset([2, 3, 5, 7])
>>> bigger_primes = frozenset([5, 7, 11])
>>> small_primes.add(11) # we cannot add to a frozenset
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'frozenset' object has no attribute 'add'
>>> small_primes.remove(2) # neither we can remove
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'frozenset' object has no attribute 'remove'
>>> small_primes & bigger_primes # intersect, union, etc. allowed
frozenset({5, 7})
如您所见,frozenset对象在其可变对应物方面相当有限。它们仍然非常有效,用于成员测试、并集、交集和差集操作,以及出于性能原因。
映射类型-字典
在所有内置的 Python 数据类型中,字典很容易是最有趣的一个。它是唯一的标准映射类型,也是每个 Python 对象的支柱。
字典将键映射到值。键需要是可哈希的对象,而值可以是任意类型。字典是可变对象。有很多不同的方法来创建字典,所以让我给你一个简单的例子,演示如何以五种不同的方式创建一个等于{'A': 1, 'Z': -1}的字典:
>>> a = dict(A=1, Z=-1)
>>> b = {'A': 1, 'Z': -1}
>>> c = dict(zip(['A', 'Z'], [1, -1]))
>>> d = dict([('A', 1), ('Z', -1)])
>>> e = dict({'Z': -1, 'A': 1})
>>> a == b == c == d == e # are they all the same?
True # They are indeed
你注意到那些双等号了吗?赋值是用一个等号完成的,而要检查一个对象是否与另一个对象相同(或者在这种情况下一次检查五个对象),我们使用双等号。还有另一种比较对象的方法,涉及到is运算符,并检查两个对象是否相同(如果它们具有相同的 ID,而不仅仅是值),但除非你有充分的理由使用它,否则应该使用双等号。在前面的代码中,我还使用了一个很好的函数:zip。它是以现实生活中的拉链命名的,它将两个东西粘合在一起,每次从每个东西中取一个元素。让我给你举个例子:
>>> list(zip(['h', 'e', 'l', 'l', 'o'], [1, 2, 3, 4, 5]))
[('h', 1), ('e', 2), ('l', 3), ('l', 4), ('o', 5)]
>>> list(zip('hello', range(1, 6))) # equivalent, more Pythonic
[('h', 1), ('e', 2), ('l', 3), ('l', 4), ('o', 5)]
在前面的例子中,我以两种不同的方式创建了相同的列表,一种更明确,另一种更符合 Python 的风格。暂时忘记我不得不在zip调用周围包装list构造函数(原因是因为zip返回一个迭代器,而不是list,所以如果我想看到结果,我需要将该迭代器耗尽到某个东西中——在这种情况下是一个列表),并专注于结果。看看zip是如何将其两个参数的第一个元素配对在一起,然后是第二个元素,然后是第三个元素,依此类推?看看你的裤子(或者你的钱包,如果你是女士),你会看到你的拉链也有相同的行为。但让我们回到字典,看看它们暴露了多少精彩的方法,让我们可以按照我们的意愿来操作它们。
让我们从基本操作开始:
>>> d = {}
>>> d['a'] = 1 # let's set a couple of (key, value) pairs
>>> d['b'] = 2
>>> len(d) # how many pairs?
2
>>> d['a'] # what is the value of 'a'?
1
>>> d # how does `d` look now?
{'a': 1, 'b': 2}
>>> del d['a'] # let's remove `a`
>>> d
{'b': 2}
>>> d['c'] = 3 # let's add 'c': 3
>>> 'c' in d # membership is checked against the keys
True
>>> 3 in d # not the values
False
>>> 'e' in d
False
>>> d.clear() # let's clean everything from this dictionary
>>> d
{}
注意访问字典的键,无论我们执行的操作类型如何,都是通过方括号完成的。你还记得字符串、列表和元组吗?我们也是通过方括号访问某个位置的元素,这是 Python 一致性的又一个例子。
现在让我们看看三个特殊的对象,称为字典视图:keys、values和items。这些对象提供了字典条目的动态视图,并且在字典更改时会发生变化。keys()返回字典中的所有键,values()返回字典中的所有值,items()返回字典中的所有(键,值)对。
根据 Python 文档:“键和值以任意顺序进行迭代,这个顺序是非随机的,在 Python 的不同实现中会有所不同,并且取决于字典插入和删除的历史。如果在没有对字典进行干预修改的情况下对键、值和项视图进行迭代,那么项的顺序将直接对应”。
够了这些废话;让我们把这些都写成代码:
>>> d = dict(zip('hello', range(5)))
>>> d
{'h': 0, 'e': 1, 'l': 3, 'o': 4}
>>> d.keys()
dict_keys(['h', 'e', 'l', 'o'])
>>> d.values()
dict_values([0, 1, 3, 4])
>>> d.items()
dict_items([('h', 0), ('e', 1), ('l', 3), ('o', 4)])
>>> 3 in d.values()
True
>>> ('o', 4) in d.items()
True
在前面的代码中有几件事情需要注意。首先,注意我们是通过迭代字符串'hello'和列表[0, 1, 2, 3, 4]的 zip 版本来创建字典的。字符串'hello'中有两个'l'字符,它们分别与2和3的值配对。请注意,在字典中,'l'键的第二次出现(值为3)覆盖了第一次出现(值为2)。另一个需要注意的是,当要求任何视图时,原始顺序现在被保留,而在 3.6 版本之前是不能保证的。
从 Python 3.6 开始,dict类型已经重新实现以使用更紧凑的表示。这导致与 Python 3.5 相比,字典使用的内存减少了 20%到 25%。此外,在 Python 3.6 中,作为一个副作用,字典是本地有序的。这个特性受到社区的热烈欢迎,以至于在 3.7 中它已经成为语言的合法特性,而不再是实现的副作用。如果一个dict记住了键首次插入的顺序,那么它就是有序的。
当我们讨论对集合进行迭代时,我们将看到这些视图是基本工具。现在让我们来看看 Python 字典暴露的一些其他方法;它们有很多,并且非常有用:
>>> d
{'e': 1, 'h': 0, 'o': 4, 'l': 3}
>>> d.popitem() # removes a random item (useful in algorithms)
('o', 4)
>>> d
{'h': 0, 'e': 1, 'l': 3}
>>> d.pop('l') # remove item with key `l`
3
>>> d.pop('not-a-key') # remove a key not in dictionary: KeyError
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'not-a-key'
>>> d.pop('not-a-key', 'default-value') # with a default value?
'default-value' # we get the default value
>>> d.update({'another': 'value'}) # we can update dict this way
>>> d.update(a=13) # or this way (like a function call)
>>> d
{'h': 0, 'e': 1, 'another': 'value', 'a': 13}
>>> d.get('a') # same as d['a'] but if key is missing no KeyError
13
>>> d.get('a', 177) # default value used if key is missing
13
>>> d.get('b', 177) # like in this case
177
>>> d.get('b') # key is not there, so None is returned
所有这些方法都很简单易懂,但值得一提的是None。Python 中的每个函数都返回None,除非明确使用return语句返回其他内容,但我们将在探索函数时看到这一点。None经常用于表示值的缺失,并且它经常用作函数声明中参数的默认值。一些经验不足的编程人员有时会编写返回False或None的代码。False和None在布尔上下文中都会被评估为False,因此它们之间似乎没有太大的区别。但实际上,我认为它们之间有一个非常重要的区别:False表示我们有信息,而我们拥有的信息是False。None表示没有信息。没有信息与信息为False是非常不同的。通俗地说,如果你问你的机械师,“我的车准备好了吗?”,答案“不,还没有”(False)和“我不知道”(None)之间有很大的区别。
我非常喜欢字典的最后一个方法是setdefault。它的行为类似于get,但如果键不存在,它还会设置具有给定值的键。让我们看一个例子:
>>> d = {}
>>> d.setdefault('a', 1) # 'a' is missing, we get default value
1
>>> d
{'a': 1} # also, the key/value pair ('a', 1) has now been added
>>> d.setdefault('a', 5) # let's try to override the value
1
>>> d
{'a': 1} # no override, as expected
所以,我们现在已经到了这次旅行的尽头。通过尝试预测这行代码之后d的样子来测试你对字典的了解:
>>> d = {}
>>> d.setdefault('a', {}).setdefault('b', []).append(1)
如果你不能立刻理解,不要担心。我只是想鼓励你尝试使用字典。
这就结束了我们对内置数据类型的介绍。在我讨论本章中所见内容的一些考虑之前,我想简要地看一下collections模块。
collections 模块
当 Python 通用内置容器(tuple,list,set和dict)不够用时,我们可以在collections模块中找到专门的容器数据类型。它们是:
| 数据类型 | 描述 |
|---|---|
namedtuple() |
用于创建具有命名字段的元组子类的工厂函数 |
deque |
具有快速追加和弹出的类似列表的容器 |
ChainMap |
用于创建多个映射的单个视图的类似字典的类 |
Counter |
用于计算可散列对象的字典子类 |
OrderedDict |
记住条目添加顺序的字典子类 |
defaultdict |
调用工厂函数以提供缺失值的字典子类 |
UserDict |
用于更容易地对字典进行子类化的字典对象包装器 |
UserList |
用于更容易地对列表进行子类化的列表对象包装器 |
UserString |
用于更容易地对字符串进行子类化的字符串对象包装器 |
我们没有足够的空间来涵盖所有这些内容,但你可以在官方文档中找到大量的例子,所以在这里我只给出一个小例子来展示namedtuple,defaultdict和ChainMap。
namedtuple
namedtuple是一个类似元组的对象,可以通过属性查找访问字段,也可以通过索引和迭代(实际上是tuple的子类)。这在完全自定义对象和元组之间达成了一种妥协,可以在不需要完全自定义对象的情况下使用,但又希望通过避免奇怪的索引使代码更易读的情况下使用。另一个用例是在重构后元组中的项目可能需要改变位置的情况下,这会迫使编码人员也重构所有涉及的逻辑,这可能非常棘手。通常情况下,例子胜过千言万语(还是图片?)。假设我们正在处理关于患者左眼和右眼的数据。我们在常规元组中保存左眼(位置 0)和右眼(位置 1)的一个值。如下所示:
>>> vision = (9.5, 8.8)
>>> vision
(9.5, 8.8)
>>> vision[0] # left eye (implicit positional reference)
9.5
>>> vision[1] # right eye (implicit positional reference)
8.8
现在假设我们一直处理vision对象,并且在某个时候设计师决定通过添加组合视觉信息来增强它们,以便vision对象以这种格式存储数据:(左眼,组合,右眼)。
现在您看到我们遇到的问题了吗?我们可能有很多依赖于vision[0]是左眼信息(它仍然是)和vision[1]是右眼信息(这不再是情况)的代码。我们必须在处理这些对象的任何地方重构我们的代码,将vision[1]更改为vision[2],这可能很痛苦。我们可能最初可以更好地处理这个问题,使用namedtuple来解决。让我告诉你我的意思:
>>> from collections import namedtuple
>>> Vision = namedtuple('Vision', ['left', 'right'])
>>> vision = Vision(9.5, 8.8)
>>> vision[0]
9.5
>>> vision.left # same as vision[0], but explicit
9.5
>>> vision.right # same as vision[1], but explicit
8.8
如果在我们的代码中,我们使用vision.left和vision.right来引用左眼和右眼,那么要解决新的设计问题,我们只需要更改我们的工厂和创建实例的方式。代码的其余部分不需要更改:
>>> Vision = namedtuple('Vision', ['left', 'combined', 'right'])
>>> vision = Vision(9.5, 9.2, 8.8)
>>> vision.left # still correct
9.5
>>> vision.right # still correct (though now is vision[2])
8.8
>>> vision.combined # the new vision[1]
9.2
您可以看到按名称引用这些值比按位置引用更方便。毕竟,有人曾经写道,明确胜于隐晦(你能想起在哪里吗?如果你想不起来,想想禅)。这个例子可能有点极端;当然,我们的代码设计师不太可能做出这样的改变,但您会惊讶地看到在专业环境中经常发生类似这种问题,以及重构它们是多么痛苦。
defaultdict
defaultdict数据类型是我最喜欢的之一。它允许您通过在第一次访问时插入一个默认值来避免检查字典中是否存在键,其类型是您在创建时传递的默认值。在某些情况下,这个工具可能非常方便,并且可以稍微缩短您的代码。让我们看一个快速的例子。假设我们正在更新age的值,增加一岁。如果age不存在,我们假设它是0,并将其更新为1:
>>> d = {}
>>> d['age'] = d.get('age', 0) + 1 # age not there, we get 0 + 1
>>> d
{'age': 1}
>>> d = {'age': 39}
>>> d['age'] = d.get('age', 0) + 1 # age is there, we get 40
>>> d
{'age': 40}
现在让我们看看它如何与defaultdict数据类型一起工作。第二行实际上是一个四行长的if子句的简短版本,如果字典没有get方法,我们将不得不编写它(我们将在第三章中看到所有关于if子句的内容,迭代和做决定):
>>> from collections import defaultdict
>>> dd = defaultdict(int) # int is the default type (0 the value)
>>> dd['age'] += 1 # short for dd['age'] = dd['age'] + 1
>>> dd
defaultdict(<class 'int'>, {'age': 1}) # 1, as expected
请注意,我们只需要指示defaultdict工厂我们希望在键丢失时使用int数字(我们将得到0,这是int类型的默认值)。还要注意,即使在这个例子中行数没有增加,可读性确实有所提高,这非常重要。您还可以使用不同的技术来实例化defaultdict数据类型,这涉及创建一个工厂对象。要深入了解,请参考官方文档。
ChainMap
ChainMap是 Python 3.3 中引入的一个非常好的数据类型。它的行为类似于普通的字典,但根据 Python 文档的说法:“用于快速链接多个映射,以便它们可以被视为单个单元。” 这通常比创建一个字典并在其上运行多个更新调用要快得多。ChainMap可以用来模拟嵌套作用域,在模板中很有用。底层映射存储在一个列表中。该列表是公共的,可以使用 maps 属性访问或更新。查找依次搜索底层映射,直到找到一个键。相比之下,写入、更新和删除只对第一个映射进行操作。
一个非常常见的用例是提供默认值,让我们看一个例子:
>>> from collections import ChainMap
>>> default_connection = {'host': 'localhost', 'port': 4567}
>>> connection = {'port': 5678}
>>> conn = ChainMap(connection, default_connection) # map creation
>>> conn['port'] # port is found in the first dictionary
5678
>>> conn['host'] # host is fetched from the second dictionary
'localhost'
>>> conn.maps # we can see the mapping objects
[{'port': 5678}, {'host': 'localhost', 'port': 4567}]
>>> conn['host'] = 'packtpub.com' # let's add host
>>> conn.maps
[{'port': 5678, 'host': 'packtpub.com'},
{'host': 'localhost', 'port': 4567}]
>>> del conn['port'] # let's remove the port information
>>> conn.maps
[{'host': 'packtpub.com'}, {'host': 'localhost', 'port': 4567}]
>>> conn['port'] # now port is fetched from the second dictionary
4567
>>> dict(conn) # easy to merge and convert to regular dictionary
{'host': 'packtpub.com', 'port': 4567}
我只是喜欢 Python 是如何让你的生活变得轻松。你在一个ChainMap对象上工作,配置第一个映射,当你需要一个包含所有默认项以及自定义项的完整字典时,你只需将ChainMap对象提供给dict构造函数。如果你从未在其他语言(如 Java 或 C++)中编写过代码,你可能无法完全欣赏到这一点,以及 Python 是如何让你的生活变得更加轻松。我可以,每当我不得不在其他语言中编写代码时,我都感到有一种幽闭恐惧症。
枚举
从技术上讲,枚举不是一个内置的数据类型,因为你必须从enum模块中导入它们,但绝对值得一提。它们是在 Python 3.4 中引入的,尽管在专业代码中看到它们并不那么常见(但),我还是想给你举个例子。
官方定义是这样的:“枚举是一组 符号 名称(成员)绑定到唯一的、常量值。在枚举中,成员可以通过标识进行比较,并且枚举本身可以被迭代。”
假设你需要表示交通信号灯。在你的代码中,你可能会这样做:
>>> GREEN = 1
>>> YELLOW = 2
>>> RED = 4
>>> TRAFFIC_LIGHTS = (GREEN, YELLOW, RED)
>>> # or with a dict
>>> traffic_lights = {'GREEN': 1, 'YELLOW': 2, 'RED': 4}
前面的代码没有什么特别之处。实际上,这是非常常见的。但是,考虑改用这种方式:
>>> from enum import Enum
>>> class TrafficLight(Enum):
... GREEN = 1
... YELLOW = 2
... RED = 4
...
>>> TrafficLight.GREEN
<TrafficLight.GREEN: 1>
>>> TrafficLight.GREEN.name
'GREEN'
>>> TrafficLight.GREEN.value
1
>>> TrafficLight(1)
<TrafficLight.GREEN: 1>
>>> TrafficLight(4)
<TrafficLight.RED: 4>
暂时忽略类定义的(相对)复杂性,你可以欣赏到这可能更有优势。数据结构更清晰,提供的 API 更强大。我鼓励你查看官方文档,探索在enum模块中可以找到的所有出色功能。我认为值得探索,至少一次。
最后的考虑
就是这样。现在你已经看到了 Python 中将要使用的数据结构的很大一部分。我鼓励你深入研究 Python 文档,并进一步尝试本章中所见的每一种数据类型。相信我,这是值得的。你将写的一切都与处理数据有关,所以确保你对它的了解是非常牢固的。
在我们跳入第三章 迭代和做决定之前,我想分享一些关于我认为重要且不容忽视的不同方面的最终考虑。
小值缓存
当我们在本章的开头讨论对象时,我们看到当我们将一个名称分配给一个对象时,Python 会创建对象,设置它的值,然后将名称指向它。我们可以将不同的名称分配给相同的值,并且我们期望创建不同的对象,就像这样:
>>> a = 1000000
>>> b = 1000000
>>> id(a) == id(b)
False
在前面的例子中,a和b被分配给两个int对象,它们具有相同的值,但它们不是相同的对象,正如你所看到的,它们的id不相同。所以让我们再做一次:
>>> a = 5
>>> b = 5
>>> id(a) == id(b)
True
哦,哦!Python 出问题了吗?为什么现在两个对象是相同的?我们没有做a = b = 5,我们分别设置它们。嗯,答案是性能。Python 缓存短字符串和小数字,以避免它们的副本堵塞系统内存。一切都在幕后正确处理,所以你不需要担心,但确保你记住这种行为,如果你的代码需要处理 ID 的话。
如何选择数据结构
正如我们所见,Python 为你提供了几种内置数据类型,有时,如果你不是很有经验,选择最适合你的可能会很棘手,特别是当涉及到集合时。例如,假设你有许多字典要存储,每个字典代表一个客户。在每个客户字典中,有一个'id': 'code'唯一标识代码。你会把它们放在什么样的集合中?嗯,除非我更多地了解这些客户,很难回答。我需要什么样的访问?我将对每个客户执行什么样的操作,以及多少次?集合会随时间改变吗?我需要以任何方式修改客户字典吗?我将在集合上执行的最频繁的操作是什么?
如果你能回答上述问题,那么你就会知道该选择什么。如果集合从不缩小或增长(换句话说,在创建后不需要添加/删除任何客户对象)或洗牌,那么元组是一个可能的选择。否则,列表是一个很好的选择。不过,每个客户字典都有一个唯一标识符,所以甚至字典也可以工作。让我为你列出这些选项:
# example customer objects
customer1 = {'id': 'abc123', 'full_name': 'Master Yoda'}
customer2 = {'id': 'def456', 'full_name': 'Obi-Wan Kenobi'}
customer3 = {'id': 'ghi789', 'full_name': 'Anakin Skywalker'}
# collect them in a tuple
customers = (customer1, customer2, customer3)
# or collect them in a list
customers = [customer1, customer2, customer3]
# or maybe within a dictionary, they have a unique id after all
customers = {
'abc123': customer1,
'def456': customer2,
'ghi789': customer3,
}
我们有一些客户在那里,对吧?我可能不会选择元组选项,除非我想强调集合不会改变。我会说通常列表更好,因为它更灵活。
另一个要记住的因素是元组和列表是有序集合。如果使用字典(Python 3.6 之前)或集合,就会失去顺序,因此需要知道在你的应用程序中是否重要。
那性能呢?例如,在列表中,插入和成员操作可能需要O(n)的时间,而在字典中则为O(1)。不过,如果我们不能保证可以通过其属性之一唯一标识集合中的每个项目,并且该属性是可散列的(因此可以成为dict中的键),则不一定总是能使用字典。
如果你想知道O(n)和O(1)是什么意思,请谷歌大 O 符号。在这个语境中,让我们说,如果在数据结构上执行一个操作Op需要O(f(n))的时间,那么意味着Op最多需要时间t ≤ c * f(n)来完成,其中c是某个正常数,n是输入的大小,f是某个函数。所以,把O(...)看作是操作运行时间的上限(当然也可以用来衡量其他可测量的数量)。
另一种了解你是否选择了正确的数据结构的方法是看你必须编写的代码。如果一切都很容易并且自然流畅,那么你可能已经选择正确了,但如果你发现自己认为你的代码变得不必要地复杂,那么你可能应该尝试并决定是否需要重新考虑你的选择。不过,没有实际案例很难给出建议,所以当你为你的数据选择数据结构时,试着记住易用性和性能,并优先考虑你所处环境中最重要的事情。
关于索引和切片
在本章的开头,我们看到了字符串上应用的切片。切片通常适用于序列:元组、列表、字符串等。对于列表,切片也可以用于赋值。我几乎从未在专业代码中看到过这种用法,但是,您知道您可以这样做。您能对字典或集合进行切片吗?我听到您尖叫,当然不能!。太棒了;我看到我们在同一个页面上,所以让我们谈谈索引。
关于 Python 索引的一个特点我之前没有提到。我将通过一个例子来向您展示。如何解决集合的最后一个元素?让我们看看:
>>> a = list(range(10)) # `a` has 10 elements. Last one is 9.
>>> a
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> len(a) # its length is 10 elements
10
>>> a[len(a) - 1] # position of last one is len(a) - 1
9
>>> a[-1] # but we don't need len(a)! Python rocks!
9
>>> a[-2] # equivalent to len(a) - 2
8
>>> a[-3] # equivalent to len(a) - 3
7
如果列表a有 10 个元素,由于 Python 的 0 索引定位系统,第一个元素位于位置 0,最后一个元素位于位置 9。在前面的例子中,元素方便地放置在与其值相等的位置:0位于位置 0,1位于位置 1,依此类推。
因此,为了获取最后一个元素,我们需要知道整个列表(或元组,或字符串等)的长度,然后减去1。因此:len(a) - 1。这是一个如此常见的操作,Python 为您提供了一种使用负索引检索元素的方法。这在进行数据操作时非常有用。这是关于字符串"HelloThere"(这是 Obi-Wan Kenobi 讽刺地向 Grievous 将军问候)索引工作的一个很好的图表:

尝试解决大于9或小于-10的索引将引发IndexError,这是预期的。
关于名称
您可能已经注意到,为了使示例尽可能简短,我使用了简单的字母来调用许多对象,如a、b、c、d等。当您在控制台上调试或显示a + b == 7时,这是完全可以的,但是当涉及专业编码(或任何类型的编码)时,这是不好的做法。我希望您能原谅我有时这样做;原因是为了以更紧凑的方式呈现代码。
然而,在真实环境中,当您为数据选择名称时,您应该仔细选择,并且它们应该反映数据的内容。因此,如果您有一组Customer对象,customers是一个完全合适的名称。customers_list、customers_tuple或customers_collection也能起作用吗?请考虑一下。将集合的名称与数据类型绑定是好的吗?我认为在大多数情况下不是。因此,我会说,如果您有充分的理由这样做,那就去做;否则,不要这样做。原因是,一旦customers_tuple开始在代码的不同位置使用,并且您意识到实际上您想要使用列表而不是元组,您将需要进行一些有趣的重构(也称为浪费时间)。数据的名称应该是名词,函数的名称应该是动词。名称应该尽可能具有表现力。实际上,Python 在命名方面是一个很好的例子。大多数情况下,如果您知道函数的作用,您可以猜到函数的名称。疯狂,对吧?
《代码整洁之道》的第二章《有意义的命名》,Robert C. Martin,Prentice Hall*完全致力于命名。这是一本了不起的书,它以许多不同的方式帮助我改进了我的编码风格,如果您想将编码提升到更高的水平,这是一本必读的书。
总结
在本章中,我们已经探讨了 Python 的内置数据类型。我们已经看到了有多少种类型,并且只需使用它们的不同组合就可以实现多少。
我们已经看到了数字类型、序列、集合、映射、集合(以及Enum的特别嘉宾亮相),我们已经看到了一切都是对象,我们已经学会了可变和不可变之间的区别,我们还学会了切片和索引(以及自豪地学会了负索引)。
我们提供了简单的例子,但是关于这个主题,您还可以学到更多,所以请查阅官方文档并进行探索。
最重要的是,我鼓励你自己尝试所有的练习,让你的手指使用那些代码,建立一些肌肉记忆,并进行实验,实验,实验。学习当你除以零时会发生什么,当你将不同的数字类型组合成一个表达式时会发生什么,当你处理字符串时会发生什么。玩转所有的数据类型。锻炼它们,打破它们,发现它们所有的方法,享受它们,并且非常非常好地学习它们。
如果你的基础不是非常牢固的,你的代码能有多好呢?数据是一切的基础。数据塑造了周围的一切。
随着你在书中的进展,很可能会发现我的代码(或你的代码)中有一些不一致或可能是一些小的拼写错误。你会收到一个错误消息,某些东西会出错。这太棒了!编码时,事情总是会出错,你会一直进行调试和修复,所以把错误视为学习有关你正在使用的语言的新知识的有用练习,而不是失败或问题。错误会一直出现,直到你的最后一行代码,这是肯定的,所以最好现在就开始接受它们。
下一章是关于迭代和做决策的。我们将看到如何实际利用这些集合,并根据我们所提供的数据做出决策。现在你的知识正在积累,我们将开始加快速度,所以在你进入下一章之前,请确保你对本章的内容感到舒适。再一次,玩得开心,探索,打破事物。这是学习的一个非常好的方式。
第三章:迭代和做决定
“疯狂就是一遍又一遍地做同样的事情,却期待不同的结果。”- 阿尔伯特·爱因斯坦
在上一章中,我们看过了 Python 的内置数据类型。现在你已经熟悉了数据的各种形式和类型,是时候开始看看程序如何使用它了。
根据维基百科:
在计算机科学中,控制流(或者另一种说法是控制流程)指的是规定命令式程序的各个语句、指令或函数调用的执行或评估顺序。
为了控制程序的流程,我们有两个主要的武器:条件编程(也称为分支)和循环。我们可以以许多不同的组合和变化来使用它们,但在本章中,我不想以文档的方式介绍这两个结构的所有可能形式,我宁愿先给你一些基础知识,然后和你一起编写一些小脚本。在第一个脚本中,我们将看到如何创建一个基本的质数生成器,而在第二个脚本中,我们将看到如何根据优惠券为客户提供折扣。这样,你应该更好地了解条件编程和循环如何被使用。
在本章中,我们将涵盖以下内容:
-
条件编程
-
Python 中的循环
-
快速浏览 itertools 模块
条件编程
条件编程,或者分支,是你每天、每时每刻都在做的事情。它涉及评估条件:如果交通灯是绿色的,那么我可以过马路; 如果下雨了,那么我就带伞; 如果我上班迟到了,那么我会打电话给我的经理。
主要工具是if语句,它有不同的形式和颜色,但基本上它评估一个表达式,并根据结果选择要执行的代码部分。像往常一样,让我们看一个例子:
# conditional.1.py
late = True
if late:
print('I need to call my manager!')
这可能是最简单的例子:当late被传递给if语句时,late充当条件表达式,在布尔上下文中进行评估(就像我们调用bool(late)一样)。如果评估的结果是True,那么我们就进入if语句后面的代码体。请注意,print指令是缩进的:这意味着它属于由if子句定义的作用域。执行这段代码会产生:
$ python conditional.1.py
I need to call my manager!
由于late是True,print语句被执行了。让我们扩展一下这个例子:
# conditional.2.py
late = False
if late:
print('I need to call my manager!') #1
else:
print('no need to call my manager...') #2
这次我将late = False,所以当我执行代码时,结果是不同的:
$ python conditional.2.py
no need to call my manager...
根据评估late表达式的结果,我们可以进入块#1或块#2,但不能同时进入。当late评估为True时,执行块#1,而当late评估为False时,执行块#2。尝试为late名称分配False/True值,并看看这段代码的输出如何相应地改变。
前面的例子还介绍了else子句,当我们想要在if子句中的表达式求值为False时提供一组替代指令时,它就非常方便。else子句是可选的,通过比较前面的两个例子就可以看出来。
一个特殊的 else - elif
有时,您只需要在满足条件时执行某些操作(简单的if子句)。在其他时候,您需要提供一个替代方案,以防条件为False(if/else子句),但有时候您可能有多于两条路径可供选择,因此,由于调用经理(或不调用他们)是一种二进制类型的示例(要么您打电话,要么您不打电话),让我们改变示例的类型并继续扩展。这次,我们决定税收百分比。如果我的收入低于$10,000,我将不支付任何税款。如果在$10,000 和$30,000 之间,我将支付 20%的税款。如果在$30,000 和$100,000 之间,我将支付 35%的税款,如果超过$100,000,我将(很高兴)支付 45%的税款。让我们把这一切都写成漂亮的 Python 代码:
# taxes.py
income = 15000
if income < 10000:
tax_coefficient = 0.0 #1
elif income < 30000:
tax_coefficient = 0.2 #2
elif income < 100000:
tax_coefficient = 0.35 #3
else:
tax_coefficient = 0.45 #4
print('I will pay:', income * tax_coefficient, 'in taxes')
执行上述代码产生:
$ python taxes.py
I will pay: 3000.0 in taxes
让我们逐行来看这个例子:我们首先设置收入值。在这个例子中,我的收入是$15,000。我们进入if子句。请注意,这次我们还引入了elif子句,它是else-if的缩写,与裸的else子句不同,它还有自己的条件。因此,income < 10000的if表达式评估为False,因此块#1不会被执行。
控制权转移到下一个条件评估器:elif income < 30000。这个评估为True,因此块#2被执行,因此,Python 在整个if/elif/elif/else子句之后恢复执行(我们现在可以称之为if子句)。if子句之后只有一条指令,即print调用,它告诉我们我今年将支付3000.0的税款(15,000 * 20%)。请注意,顺序是强制性的:if首先出现,然后(可选)是您需要的尽可能多的elif子句,然后(可选)是一个else子句。
有趣,对吧?无论每个块内有多少行代码,只要其中一个条件评估为True,相关块就会被执行,然后在整个子句之后执行。如果没有一个条件评估为True(例如,income = 200000),那么else子句的主体将被执行(块#4)。这个例子扩展了我们对else子句行为的理解。当前面的if/elif/.../elif表达式没有评估为True时,它的代码块将被执行。
尝试修改income的值,直到您可以轻松地按需执行所有块(每次执行一个块,当然)。然后尝试边界。这是至关重要的,每当您将条件表达为相等或不等式(==,!=,<,>,<=,>=)时,这些数字代表边界。彻底测试边界是至关重要的。我是否允许您在 18 岁或 17 岁时开车?我是否用age < 18或age <= 18检查您的年龄?您无法想象有多少次我不得不修复由于使用错误运算符而产生的微妙错误,因此继续并尝试使用上述代码进行实验。将一些<更改为<=,并将收入设置为边界值之一(10,000,30,000,100,000)以及之间的任何值。看看结果如何变化,并在继续之前对其有一个很好的理解。
现在让我们看另一个例子,它向我们展示了如何嵌套if子句。假设您的程序遇到错误。如果警报系统是控制台,我们打印错误。如果警报系统是电子邮件,我们根据错误的严重程度发送它。如果警报系统不是控制台或电子邮件,我们不知道该怎么办,因此我们什么也不做。让我们把这写成代码:
# errorsalert.py
alert_system = 'console' # other value can be 'email'
error_severity = 'critical' # other values: 'medium' or 'low'
error_message = 'OMG! Something terrible happened!'
if alert_system == 'console':
print(error_message) #1
elif alert_system == 'email':
if error_severity == 'critical':
send_email('admin@example.com', error_message) #2
elif error_severity == 'medium':
send_email('support.1@example.com', error_message) #3
else:
send_email('support.2@example.com', error_message) #4
上面的例子非常有趣,因为它很愚蠢。它向我们展示了两个嵌套的if子句(外部和内部)。它还向我们展示了外部if子句没有任何else,而内部if子句有。请注意,缩进是允许我们将一个子句嵌套在另一个子句中的原因。
如果alert_system == 'console',则执行#1部分,其他情况下,如果alert_system == 'email',则进入另一个if子句,我们称之为内部。在内部if子句中,根据error_severity,我们向管理员、一级支持或二级支持发送电子邮件(块#2、#3和#4)。在本例中,send_email函数未定义,因此尝试运行它会导致错误。在书的源代码中,您可以从网站上下载,我包含了一个技巧,将该调用重定向到常规的print函数,这样您就可以在控制台上进行实验,而不必实际发送电子邮件。尝试更改值,看看它是如何工作的。
三元运算符
在继续下一个主题之前,我想向您展示的最后一件事是三元运算符,或者通俗地说,if/else子句的简短版本。当根据某些条件分配名称的值时,有时使用三元运算符而不是适当的if子句更容易和更可读。在以下示例中,两个代码块完全相同:
# ternary.py
order_total = 247 # GBP
# classic if/else form
if order_total > 100:
discount = 25 # GBP
else:
discount = 0 # GBP
print(order_total, discount)
# ternary operator
discount = 25 if order_total > 100 else 0
print(order_total, discount)
对于这种简单情况,我发现能够用一行代码来表达逻辑非常好,而不是用四行。记住,作为编码人员,您花在阅读代码上的时间要比编写代码多得多,因此 Python 的简洁性是无价的。
您清楚三元运算符的工作原理吗?基本上,name = something if condition else something-else。因此,如果condition评估为True,则将name分配为something,如果condition评估为False,则将something-else分配给name。
现在您已经了解了如何控制代码的路径,让我们继续下一个主题:循环。
循环
如果您在其他编程语言中有循环的经验,您会发现 Python 的循环方式有些不同。首先,什么是循环?循环意味着能够根据给定的循环参数多次重复执行代码块。有不同的循环结构,用于不同的目的,Python 已将它们全部简化为只有两种,您可以使用它们来实现您需要的一切。这些是for和while语句。
虽然使用任何一种都可以实现您需要的一切,但它们用途不同,因此通常在不同的上下文中使用。我们将在本章中彻底探讨这种差异。
for循环
for循环用于循环遍历序列,例如列表、元组或一组对象。让我们从一个简单的例子开始,扩展概念,看看 Python 语法允许我们做什么:
# simple.for.py
for number in [0, 1, 2, 3, 4]:
print(number)
当执行时,这段简单的代码打印出从0到4的所有数字。for循环接收列表[0, 1, 2, 3, 4],在每次迭代时,number从序列中获得一个值(按顺序迭代),然后执行循环体(打印行)。number的值在每次迭代时都会改变,根据序列中接下来的值。当序列耗尽时,for循环终止,代码的执行会在循环后恢复正常。
迭代范围
有时我们需要迭代一系列数字,如果在某处硬编码列表将会很不方便。在这种情况下,range函数就派上用场了。让我们看看前面代码片段的等价物:
# simple.for.py
for number in range(5):
print(number)
在 Python 程序中,当涉及创建序列时,range函数被广泛使用:您可以通过传递一个值来调用它,该值充当stop(从0开始计数),或者您可以传递两个值(start和stop),甚至三个值(start、stop和step)。看看以下示例:
>>> list(range(10)) # one value: from 0 to value (excluded)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list(range(3, 8)) # two values: from start to stop (excluded)
[3, 4, 5, 6, 7]
>>> list(range(-10, 10, 4)) # three values: step is added
[-10, -6, -2, 2, 6]
暂时忽略我们需要在list中包装range(...)的事实。range对象有点特殊,但在这种情况下,我们只是想了解它将向我们返回什么值。您可以看到,切片的处理方式也是一样的:start包括在内,stop不包括在内,还可以添加一个step参数,其默认值为1。
尝试修改我们simple.for.py代码中range()调用的参数,并查看打印出什么。熟悉一下。
在序列上进行迭代
现在我们有了所有迭代序列的工具,让我们在此基础上构建示例:
# simple.for.2.py
surnames = ['Rivest', 'Shamir', 'Adleman']
for position in range(len(surnames)):
print(position, surnames[position])
前面的代码给游戏增加了一些复杂性。执行将显示以下结果:
$ python simple.for.2.py
0 Rivest
1 Shamir
2 Adleman
让我们使用从内到外的技术来分解它,好吗?我们从我们试图理解的最内部部分开始,然后向外扩展。因此,len(surnames)是surnames列表的长度:3。因此,range(len(surnames))实际上被转换为range(3)。这给我们一个范围[0, 3),基本上是一个序列(0,1,2)。这意味着for循环将运行三次迭代。在第一次迭代中,position将取值0,而在第二次迭代中,它将取值1,最后在第三次和最后一次迭代中取值2。如果不是(0,1,2),那么对surnames列表的可能索引位置是什么?在位置0,我们找到'Rivest',在位置1,'Shamir',在位置2,'Adleman'。如果您对这三个人一起创造了什么感到好奇,请将print(position, surnames[position])更改为print(surnames[position][0], end=''),在循环外添加最后一个print(),然后再次运行代码。
现在,这种循环方式实际上更接近于 Java 或 C++等语言。在 Python 中,很少看到这样的代码。您可以只是迭代任何序列或集合,因此无需获取位置列表并在每次迭代时从序列中检索元素。这是昂贵的,没有必要的昂贵。让我们将示例更改为更符合 Python 风格的形式:
# simple.for.3.py
surnames = ['Rivest', 'Shamir', 'Adleman']
for surname in surnames:
print(surname)
现在这就是!它几乎是英语。for循环可以迭代surnames列表,并且它会在每次交互中按顺序返回每个元素。运行此代码将打印出三个姓氏,一个接一个。阅读起来更容易,对吧?
但是,如果您想要打印位置呢?或者如果您确实需要它呢?您应该回到range(len(...))形式吗?不。您可以使用enumerate内置函数,就像这样:
# simple.for.4.py
surnames = ['Rivest', 'Shamir', 'Adleman']
for position, surname in enumerate(surnames):
print(position, surname)
这段代码也很有趣。请注意,enumerate在每次迭代时返回一个二元组(position, surname),但仍然比range(len(...))示例更可读(更有效)。您可以使用start参数调用enumerate,例如enumerate(iterable, start),它将从start开始,而不是从0开始。这只是另一个小事情,表明 Python 在设计时考虑了多少,以便使您的生活更轻松。
您可以使用for循环来迭代列表、元组和一般 Python 称为可迭代的任何东西。这是一个非常重要的概念,所以让我们再谈谈它。
迭代器和可迭代对象
根据 Python 文档(docs.python.org/3/glossary.html)的说法,可迭代对象是:
一个能够逐个返回其成员的对象。可迭代对象的示例包括所有序列类型(如列表、字符串和元组)和一些非序列类型,比如字典、文件对象和你用 iter()或 getitem()方法定义的任何类的对象。可迭代对象可以在 for 循环和许多其他需要序列的地方使用(zip()、map()等)。当将可迭代对象作为参数传递给内置函数 iter()时,它会返回该对象的迭代器。这个迭代器对一组值进行一次遍历。在使用可迭代对象时,通常不需要调用 iter()或自己处理迭代器对象。for 语句会自动为你创建一个临时的未命名变量来保存迭代器,以便在循环期间使用。
简而言之,当你写for k in sequence: ... body ...时,for循环会询问sequence下一个元素,得到返回值后,将其命名为k,然后执行其主体。然后,for循环再次询问sequence下一个元素,再次将其命名为k,再次执行主体,依此类推,直到序列耗尽。空序列将导致主体不执行。
一些数据结构在迭代时按顺序产生它们的元素,比如列表、元组和字符串,而另一些则不会,比如集合和字典(Python 3.6 之前)。Python 给了我们迭代可迭代对象的能力,使用一种称为迭代器的对象类型。
根据官方文档(docs.python.org/3/glossary.html),迭代器是:
表示数据流的对象。对迭代器的 next()方法进行重复调用(或将其传递给内置函数 next())会返回数据流中的连续项。当没有更多数据可用时,会引发 StopIteration 异常。此时,迭代器对象已耗尽,任何进一步调用其 next()方法都会再次引发 StopIteration。迭代器需要有一个返回迭代器对象本身的 iter()方法,因此每个迭代器也是可迭代的,并且可以在大多数接受其他可迭代对象的地方使用。一个值得注意的例外是尝试多次迭代的代码。容器对象(如列表)每次传递给 iter()函数或在 for 循环中使用时都会产生一个全新的迭代器。尝试对迭代器进行这样的操作只会返回相同的已耗尽的迭代器对象,使其看起来像一个空容器。
如果你不完全理解前面的法律术语,不要担心,你以后会理解的。我把它放在这里作为将来的方便参考。
实际上,整个可迭代/迭代器机制在代码后面有些隐藏。除非出于某种原因需要编写自己的可迭代或迭代器,否则你不必过多担心这个问题。但理解 Python 如何处理这一关键的控制流方面非常重要,因为它将塑造你编写代码的方式。
迭代多个序列
让我们看另一个例子,如何迭代两个相同长度的序列,以便处理它们各自的元素对。假设我们有一个人员列表和一个代表第一个列表中人员年龄的数字列表。我们想要打印所有人员的姓名/年龄对。让我们从一个例子开始,然后逐渐完善它:
# multiple.sequences.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
for position in range(len(people)):
person = people[position]
age = ages[position]
print(person, age)
到目前为止,这段代码应该对你来说非常简单。我们需要迭代位置列表(0、1、2、3),因为我们想要从两个不同的列表中检索元素。执行后我们得到以下结果:
$ python multiple.sequences.py
Conrad 29
Deepak 30
Heinrich 34
Tom 36
这段代码既低效又不符合 Python 的风格。它是低效的,因为根据位置检索元素可能是一个昂贵的操作,并且我们在每次迭代时都是从头开始做这个操作。邮递员在递送信件时不会每次都回到路的起点,对吧?他们是从一户到另一户。让我们尝试使用enumerate来改进一下:
# multiple.sequences.enumerate.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
for position, person in enumerate(people):
age = ages[position]
print(person, age)
这样好一些,但还不完美。而且还有点丑。我们在people上进行了适当的迭代,但我们仍然使用位置索引来获取age,我们也想摆脱这一点。别担心,Python 给了你zip函数,记得吗?让我们使用它:
# multiple.sequences.zip.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
for person, age in zip(people, ages):
print(person, age)
啊!好多了!再次比较前面的代码和第一个例子,欣赏 Python 的优雅之处。我想展示这个例子的原因有两个。一方面,我想给你一个概念,即 Python 中更短的代码可以与其他语言相比,其中的语法不允许你像这样轻松地迭代序列或集合。另一方面,更重要的是,注意当for循环请求zip(sequenceA, sequenceB)的下一个元素时,它会得到一个元组,而不仅仅是一个单一对象。它会得到一个元组,其中包含与我们提供给zip函数的序列数量相同的元素。让我们通过两种方式扩展前面的例子,使用显式和隐式赋值:
# multiple.sequences.explicit.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
nationalities = ['Poland', 'India', 'South Africa', 'England']
for person, age, nationality in zip(people, ages, nationalities):
print(person, age, nationality)
在前面的代码中,我们添加了 nationalities 列表。现在我们向zip函数提供了三个序列,for 循环在每次迭代时都会返回一个三元组。请注意,元组中元素的位置与zip调用中序列的位置相对应。执行代码将产生以下结果:
$ python multiple.sequences.explicit.py
Conrad 29 Poland
Deepak 30 India
Heinrich 34 South Africa
Tom 36 England
有时,出于某些在简单示例中可能不太清楚的原因,你可能希望在for循环的主体中分解元组。如果这是你的愿望,完全可以这样做:
# multiple.sequences.implicit.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
nationalities = ['Poland', 'India', 'South Africa', 'England']
for data in zip(people, ages, nationalities):
person, age, nationality = data
print(person, age, nationality)
基本上,它在某些情况下会自动为你做for循环所做的事情。但在某些情况下,你可能希望自己做。在这里,来自zip(...)的三元组data在for循环的主体中被分解为三个变量:person、age和nationality。
while 循环
在前面的页面中,我们看到了for循环的运行情况。当你需要循环遍历一个序列或集合时,它非常有用。需要记住的关键一点是,当你需要能够区分使用哪种循环结构时,for循环在你需要迭代有限数量的元素时非常有效。它可以是一个巨大的数量,但仍然是在某个点结束的东西。
然而,还有其他情况,当你只需要循环直到满足某个条件,甚至是无限循环直到应用程序停止时,比如我们真的没有东西可以迭代的情况,因此for循环会是一个不好的选择。但不用担心,对于这些情况,Python 为我们提供了while循环。
while循环类似于for循环,因为它们都会循环,并且在每次迭代时执行一系列指令。它们之间的不同之处在于while循环不会循环遍历一个序列(它可以,但你必须手动编写逻辑,而且没有任何意义,你只想使用for循环),而是在某个条件满足时循环。当条件不再满足时,循环结束。
和往常一样,让我们看一个例子,这将为我们澄清一切。我们想要打印一个正数的二进制表示。为了做到这一点,我们可以使用一个简单的算法,它收集除以2的余数(逆序),结果就是数字本身的二进制表示:
6 / 2 = 3 (remainder: 0)
3 / 2 = 1 (remainder: 1)
1 / 2 = 0 (remainder: 1)
List of remainders: 0, 1, 1\.
Inverse is 1, 1, 0, which is also the binary representation of 6: 110
让我们写一些代码来计算数字 39 的二进制表示:100111[2]:
# binary.py
n = 39
remainders = []
while n > 0:
remainder = n % 2 # remainder of division by 2
remainders.insert(0, remainder) # we keep track of remainders
n //= 2 # we divide n by 2
print(remainders)
在上面的代码中,我突出了n > 0,这是保持循环的条件。我们可以通过使用divmod函数使代码变得更短(更符合 Python 风格),该函数使用一个数字和一个除数调用,并返回一个包含整数除法结果及其余数的元组。例如,divmod(13, 5)将返回(2, 3),确实5 * 2 + 3 = 13。
# binary.2.py
n = 39
remainders = []
while n > 0:
n, remainder = divmod(n, 2)
remainders.insert(0, remainder)
print(remainders)
在上面的代码中,我们已经将n重新分配为除以2的结果和余数,一行代码完成。
请注意,在while循环中的条件是继续循环的条件。如果条件评估为True,则执行主体,然后进行另一个评估,依此类推,直到条件评估为False。当发生这种情况时,循环立即退出,而不执行其主体。
如果条件永远不会评估为False,则循环将成为所谓的无限循环。无限循环的用途包括从网络设备轮询时使用:您询问套接字是否有任何数据,如果有,则对其进行某些操作,然后您休眠一小段时间,然后再次询问套接字,一遍又一遍,永不停止。
能够循环遍历条件或无限循环是for循环单独不足的原因,因此 Python 提供了while循环。
顺便说一句,如果您需要数字的二进制表示,请查看bin函数。
只是为了好玩,让我们使用 while 逻辑来调整一个示例(multiple.sequences.py):
# multiple.sequences.while.py
people = ['Conrad', 'Deepak', 'Heinrich', 'Tom']
ages = [29, 30, 34, 36]
position = 0
while position < len(people):
person = people[position]
age = ages[position]
print(person, age)
position += 1
在上面的代码中,我突出了position变量的初始化、条件和更新,这使得可以通过手动处理迭代变量来模拟等效的for循环代码。所有可以使用for循环完成的工作也可以使用while循环完成,尽管您可以看到为了实现相同的结果,需要经历一些样板文件。相反的也是如此,但除非您有理由这样做,否则您应该使用正确的工具,99.9%的时间您都会没问题。
因此,总结一下,当您需要遍历可迭代对象时,请使用for循环,当您需要根据满足或不满足条件来循环时,请使用while循环。如果您记住这两种目的之间的区别,您将永远不会选择错误的循环结构。
现在让我们看看如何改变循环的正常流程。
中断和继续语句
根据手头的任务,有时您需要改变循环的正常流程。您可以跳过单个迭代(多次),也可以完全退出循环。跳过迭代的常见用例是,例如,当您遍历一个项目列表并且只有在验证了某些条件时才需要处理每个项目时。另一方面,如果您正在遍历一组项目,并且找到了满足某些需求的项目,您可能决定不再继续整个循环,因此退出循环。有无数可能的情景,因此最好看一些例子。
假设您想要对购物篮列表中所有今天到期的产品应用 20%的折扣。您实现这一点的方式是使用continue语句,该语句告诉循环结构(for或while)立即停止执行主体并转到下一个迭代(如果有的话)。这个例子将让我们深入了解一点,所以准备好跳下去:
# discount.py
from datetime import date, timedelta
today = date.today()
tomorrow = today + timedelta(days=1) # today + 1 day is tomorrow
products = [
{'sku': '1', 'expiration_date': today, 'price': 100.0},
{'sku': '2', 'expiration_date': tomorrow, 'price': 50},
{'sku': '3', 'expiration_date': today, 'price': 20},
]
for product in products:
if product['expiration_date'] != today:
continue
product['price'] *= 0.8 # equivalent to applying 20% discount
print(
'Price for sku', product['sku'],
'is now', product['price'])
我们首先导入date和timedelta对象,然后设置我们的产品。那些sku为1和3的产品具有今天的到期日,这意味着我们希望对它们应用 20%的折扣。我们循环遍历每个产品并检查到期日。如果它不是(不等运算符,!=)today,我们不希望执行其余的主体套件,因此我们continue。
注意,continue语句放在主体套件的哪里并不重要(你甚至可以使用它多次)。当你到达它时,执行停止并返回到下一个迭代。如果我们运行discount.py模块,输出如下:
$ python discount.py
Price for sku 1 is now 80.0
Price for sku 3 is now 16.0
这向你展示了主体的最后两行没有被执行给sku编号为2。
现在让我们看一个退出循环的例子。假设我们想要判断列表中是否至少有一个元素在传递给bool函数时评估为True。鉴于我们需要知道是否至少有一个,当我们找到它时,就不需要继续扫描列表。在 Python 代码中,这意味着使用break语句。让我们把这写成代码:
# any.py
items = [0, None, 0.0, True, 0, 7] # True and 7 evaluate to True
found = False # this is called "flag"
for item in items:
print('scanning item', item)
if item:
found = True # we update the flag
break
if found: # we inspect the flag
print('At least one item evaluates to True')
else:
print('All items evaluate to False')
前面的代码在编程中是一个常见的模式,你会经常看到它。当你以这种方式检查项目时,基本上你是设置一个flag变量,然后开始检查。如果你找到一个符合你条件的元素(在这个例子中,评估为True),然后你更新flag并停止迭代。迭代后,你检查flag并相应地采取行动。执行结果是:
$ python any.py
scanning item 0
scanning item None
scanning item 0.0
scanning item True
At least one item evaluates to True
看到True被找到后执行停止了吗?break语句的作用和continue一样,它立即停止执行循环主体,但也阻止其他迭代运行,有效地跳出循环。continue和break语句可以在for和while循环结构中一起使用,没有数量限制。
顺便说一下,没有必要编写代码来检测序列中是否至少有一个元素评估为True。只需要查看内置的any函数。
特殊的 else 子句
在 Python 语言中我看到的一个特性是在while和for循环后面能够有else子句的能力。这种用法非常少见,但是确实很有用。简而言之,你可以在for或while循环后面有一个else子句。如果循环正常结束,因为迭代器耗尽(for循环)或者条件最终不满足(while循环),那么else子句(如果存在)会被执行。如果执行被break语句中断,else子句就不会被执行。让我们举一个例子,一个for循环遍历一组项目,寻找满足某个条件的项目。如果我们找不到至少一个满足条件的项目,我们想要引发一个异常。这意味着我们想要中止程序的正常执行,并且表示出现了一个我们无法处理的错误或异常。异常将在后面的章节中讨论,所以如果你现在不完全理解它们也不用担心。只需要记住它们会改变代码的正常流程。
现在让我展示给你两个做同样事情的例子,但其中一个使用了特殊的for...else语法。假设我们想在一组人中找到一个可以开车的人:
# for.no.else.py
class DriverException(Exception):
pass
people = [('James', 17), ('Kirk', 9), ('Lars', 13), ('Robert', 8)]
driver = None
for person, age in people:
if age >= 18:
driver = (person, age)
break
if driver is None:
raise DriverException('Driver not found.')
再次注意flag模式。我们将驱动程序设置为None,然后如果我们找到一个,我们更新driver标志,然后在循环结束时,我们检查它是否找到了一个。我有一种感觉,那些孩子会开一辆非常金属的车,但无论如何,注意如果没有找到驱动程序,将会引发DriverException,表示程序无法继续执行(我们缺少驱动程序)。
相同的功能可以用以下代码更加优雅地重写:
# for.else.py
class DriverException(Exception):
pass
people = [('James', 17), ('Kirk', 9), ('Lars', 13), ('Robert', 8)]
for person, age in people:
if age >= 18:
driver = (person, age)
break
else:
raise DriverException('Driver not found.')
请注意,我们不再被迫使用flag模式。异常是作为for循环逻辑的一部分引发的,这是有道理的,因为for循环正在检查某些条件。我们只需要设置一个driver对象,以防我们找到一个,因为代码的其余部分将在某个地方使用这些信息。请注意,代码更短更优雅,因为逻辑现在正确地组合在一起。
在将代码转换为美观的 Python视频中,Raymond Hettinger 建议将与 for 循环关联的else语句的名称改为nobreak。如果你在记住else如何用于for循环时感到困难,只需记住这个事实就应该帮助你了。
把这一切放在一起
现在你已经看到关于条件和循环的所有内容,是时候稍微调剂一下,看看我在本章开头预期的那两个例子了。我们将在这里混合搭配,这样你就可以看到如何将所有这些概念结合起来使用。让我们先写一些代码来生成一个质数列表,直到某个限制为止。请记住,我将写一个非常低效和基本的算法来检测质数。对你来说重要的是要集中精力关注本章主题的代码部分。
一个质数生成器
根据维基百科:
质数(或质数)是大于 1 的自然数,除了 1 和它本身之外没有正的除数。大于 1 的自然数,如果不是质数,则称为合数。
根据这个定义,如果我们考虑前 10 个自然数,我们可以看到 2、3、5 和 7 是质数,而 1、4、6、8、9 和 10 不是。为了让计算机告诉你一个数N是否是质数,你可以将该数除以范围[2,N)内的所有自然数。如果其中任何一个除法的余数为零,那么这个数就不是质数。废话够多了,让我们开始做生意吧。我将写两个版本的代码,第二个版本将利用for...else语法:
# primes.py
primes = [] # this will contain the primes in the end
upto = 100 # the limit, inclusive
for n in range(2, upto + 1):
is_prime = True # flag, new at each iteration of outer for
for divisor in range(2, n):
if n % divisor == 0:
is_prime = False
break
if is_prime: # check on flag
primes.append(n)
print(primes)
在前面的代码中有很多值得注意的地方。首先,我们建立了一个空的primes列表,它将在最后包含质数。限制是100,你可以看到我们在外部循环中调用range()的方式是包容的。如果我们写range(2, upto),那就是[2, upto),对吧?因此range(2, upto + 1)给我们[2, upto + 1) == [2, upto]。
所以,有两个for循环。在外部循环中,我们循环遍历候选质数,也就是从2到upto的所有自然数。在外部循环的每次迭代中,我们设置一个标志(在每次迭代时设置为True),然后开始将当前的n除以从2到n - 1的所有数字。如果我们找到n的一个适当的除数,这意味着n是合数,因此我们将标志设置为False并中断循环。请注意,当我们中断内部循环时,外部循环会继续正常进行。我们在找到n的适当除数后中断的原因是,我们不需要任何进一步的信息就能告诉n不是质数。
当我们检查is_prime标志时,如果它仍然是True,这意味着我们在[2,n)中找不到任何适当的除数,因此n是一个质数。我们将n附加到primes列表中,然后进行另一个迭代,直到n等于100。
运行这段代码会产生:
$ python primes.py
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]
在我们继续之前,有一个问题:在外部循环的所有迭代中,有一个与其他所有迭代都不同。你能告诉哪一个,以及为什么吗?想一想,回到代码,试着自己找出答案,然后继续阅读。
你搞清楚了吗?如果没有,不要感到难过,这很正常。我让你做这个小练习,因为这是程序员一直在做的事情。通过简单地查看代码就能理解代码的功能是一种随着时间积累的技能。这非常重要,所以尽量在你能做的时候进行练习。我现在告诉你答案:与所有其他迭代不同的是第一个迭代。原因是因为在第一次迭代中,n是2。因此,最内层的for循环甚至不会运行,因为它是一个遍历range(2, 2)的for循环,那不就是[2, 2)吗?自己试一下,用这个可迭代对象写一个简单的for循环,放一个print在主体套件中,看看是否发生了什么(不会...)。
现在,从算法的角度来看,这段代码效率低下,所以让我们至少让它更美观:
# primes.else.py
primes = []
upto = 100
for n in range(2, upto + 1):
for divisor in range(2, n):
if n % divisor == 0:
break
else:
primes.append(n)
print(primes)
更好了,对吧?is_prime标志已经消失,当我们知道内部的for循环没有遇到任何break语句时,我们将n附加到primes列表中。看看代码是不是更清晰,读起来更好了?
应用折扣
在这个例子中,我想向你展示一个我非常喜欢的技巧。在许多编程语言中,除了if/elif/else结构之外,无论以什么形式或语法,你都可以找到另一个语句,通常称为switch/case,在 Python 中缺少。它相当于一系列if/elif/.../elif/else子句,语法类似于这样(警告!JavaScript 代码!):
/* switch.js */
switch (day_number) {
case 1:
case 2:
case 3:
case 4:
case 5:
day = "Weekday";
break;
case 6:
day = "Saturday";
break;
case 0:
day = "Sunday";
break;
default:
day = "";
alert(day_number + ' is not a valid day number.')
}
在上面的代码中,我们在一个名为day_number的变量上进行switch。这意味着我们获取它的值,然后决定它适用于哪种情况(如果有的话)。从1到5有一个级联,这意味着无论数字是多少,[1, 5]都会进入将day设置为"Weekday"的逻辑部分。然后我们有0和6的单个情况,以及一个default情况来防止错误,它警告系统day_number不是有效的日期数字,即不在[0, 6]中。Python 完全能够使用if/elif/else语句实现这样的逻辑:
# switch.py
if 1 <= day_number <= 5:
day = 'Weekday'
elif day_number == 6:
day = 'Saturday'
elif day_number == 0:
day = 'Sunday'
else:
day = ''
raise ValueError(
str(day_number) + ' is not a valid day number.')
在上面的代码中,我们使用if/elif/else语句在 Python 中复制了 JavaScript 片段的相同逻辑。我只是举了一个例子,如果day_number不在[0, 6]中,就会引发ValueError异常。这是一种可能的转换switch/case逻辑的方式,但还有另一种方式,有时称为分派,我将在下一个例子的最后版本中向你展示。
顺便问一下,你有没有注意到前面片段的第一行?你有没有注意到 Python 可以进行双重(实际上甚至是多重)比较?这太棒了!
让我们通过简单地编写一些代码来开始新的例子,根据顾客的优惠券价值为他们分配折扣。我会保持逻辑最低限度,记住我们真正关心的是理解条件和循环:
# coupons.py
customers = [
dict(id=1, total=200, coupon_code='F20'), # F20: fixed, £20
dict(id=2, total=150, coupon_code='P30'), # P30: percent, 30%
dict(id=3, total=100, coupon_code='P50'), # P50: percent, 50%
dict(id=4, total=110, coupon_code='F15'), # F15: fixed, £15
]
for customer in customers:
code = customer['coupon_code']
if code == 'F20':
customer['discount'] = 20.0
elif code == 'F15':
customer['discount'] = 15.0
elif code == 'P30':
customer['discount'] = customer['total'] * 0.3
elif code == 'P50':
customer['discount'] = customer['total'] * 0.5
else:
customer['discount'] = 0.0
for customer in customers:
print(customer['id'], customer['total'], customer['discount'])
我们首先设置一些顾客。他们有一个订单总额,一个优惠券代码和一个 ID。我编造了四种不同类型的优惠券,两种是固定的,两种是基于百分比的。你可以看到,在if/elif/else级联中,我相应地应用折扣,并将其设置为customer字典中的'discount'键。
最后,我只是打印出部分数据,看看我的代码是否正常工作:
$ python coupons.py
1 200 20.0
2 150 45.0
3 100 50.0
4 110 15.0
这段代码很容易理解,但所有这些子句有点混乱。一眼看去很难看出发生了什么,我不喜欢。在这种情况下,你可以利用字典来优化,就像这样:
# coupons.dict.py
customers = [
dict(id=1, total=200, coupon_code='F20'), # F20: fixed, £20
dict(id=2, total=150, coupon_code='P30'), # P30: percent, 30%
dict(id=3, total=100, coupon_code='P50'), # P50: percent, 50%
dict(id=4, total=110, coupon_code='F15'), # F15: fixed, £15
]
discounts = {
'F20': (0.0, 20.0), # each value is (percent, fixed)
'P30': (0.3, 0.0),
'P50': (0.5, 0.0),
'F15': (0.0, 15.0),
}
for customer in customers:
code = customer['coupon_code']
percent, fixed = discounts.get(code, (0.0, 0.0))
customer['discount'] = percent * customer['total'] + fixed
for customer in customers:
print(customer['id'], customer['total'], customer['discount'])
运行前面的代码产生了与之前片段完全相同的结果。我们节省了两行,但更重要的是,我们在可读性方面获得了很多好处,因为现在for循环的主体只有三行,非常容易理解。这里的概念是使用字典作为分发器。换句话说,我们尝试根据一个代码(我们的coupon_code)从字典中获取一些东西,并通过使用dict.get(key, default),我们确保当code不在字典中并且我们需要一个默认值时,我们也能满足。
请注意,我必须应用一些非常简单的线性代数来正确计算折扣。每个折扣在字典中都有一个百分比和固定部分,由一个二元组表示。通过应用percent * total + fixed,我们得到正确的折扣。当percent为0时,该公式只给出固定金额,当固定为0时,它给出percent * total。
这种技术很重要,因为它也用于其他上下文中,例如函数,它实际上比我们在前面的片段中看到的要强大得多。使用它的另一个优势是,您可以以这样的方式编写代码,使得discounts字典的键和值可以动态获取(例如,从数据库中获取)。这将允许代码适应您拥有的任何折扣和条件,而无需修改任何内容。
如果它对您不是完全清楚,我建议您花时间进行实验。更改值并添加打印语句,以查看程序运行时发生了什么。
快速浏览itertools模块
关于可迭代对象、迭代器、条件逻辑和循环的章节,如果没有提到itertools模块,就不完整。如果您喜欢迭代,这是一种天堂。
根据 Python 官方文档(docs.python.org/2/library/itertools.html),itertools模块是:
这个模块实现了一些迭代器构建块,受到 APL、Haskell 和 SML 中的构造的启发。每个都以适合 Python 的形式重新表达。该模块标准化了一组核心的快速、内存高效的工具,这些工具本身或组合在一起都很有用。它们一起构成了一个“迭代器代数”,使得可以在纯 Python 中简洁高效地构建专门的工具。
在这里我无法向您展示在这个模块中可以找到的所有好东西,所以我鼓励您自己去查看,我保证您会喜欢它。简而言之,它为您提供了三种广泛的迭代器类别。我将给您展示每一种迭代器中取出的一个非常小的例子,只是为了让您稍微流口水。
无限迭代器
无限迭代器允许您以不同的方式使用for循环,就像它是一个while循环一样:
# infinite.py
from itertools import count
for n in count(5, 3):
if n > 20:
break
print(n, end=', ') # instead of newline, comma and space
运行代码会得到这个结果:
$ python infinite.py
5, 8, 11, 14, 17, 20,
count工厂类创建一个不断计数的迭代器。它从5开始,然后不断加3。如果我们不想陷入无限循环,我们需要手动中断它。
在最短输入序列上终止的迭代器
这个类别非常有趣。它允许您基于多个迭代器创建一个迭代器,并根据某种逻辑组合它们的值。这里的关键是,在这些迭代器中,如果有任何一个比其他迭代器短,那么生成的迭代器不会中断,它将在最短的迭代器耗尽时停止。我知道这很理论化,所以让我用compress给您举个例子。这个迭代器根据选择器中的相应项目是True还是False,将数据返回给您:
compress('ABC', (1, 0, 1))会返回'A'和'C',因为它们对应于1。让我们看一个简单的例子:
# compress.py
from itertools import compress
data = range(10)
even_selector = [1, 0] * 10
odd_selector = [0, 1] * 10
even_numbers = list(compress(data, even_selector))
odd_numbers = list(compress(data, odd_selector))
print(odd_selector)
print(list(data))
print(even_numbers)
print(odd_numbers)
请注意,odd_selector 和 even_selector 长度为 20,而 data 只有 10 个元素。compress 会在 data 产生最后一个元素时停止。运行此代码会产生以下结果:
$ python compress.py
[0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[0, 2, 4, 6, 8]
[1, 3, 5, 7, 9]
这是一种非常快速和方便的方法,可以从可迭代对象中选择元素。代码非常简单,只需注意,我们使用 list() 而不是 for 循环来迭代压缩调用返回的每个值,它们的作用是相同的,但是 list() 不执行一系列指令,而是将所有值放入列表并返回。
组合生成器
最后但并非最不重要的,组合生成器。如果你喜欢这种东西,这些真的很有趣。让我们来看一个关于排列的简单例子。
根据 Wolfram Mathworld:
排列,也称为“排列数”或“顺序”,是将有序列表 S 的元素重新排列,使其与 S 本身形成一一对应的重新排列。
例如,ABC 有六种排列:ABC、ACB、BAC、BCA、CAB 和 CBA。
如果一个集合有 N 个元素,那么它们的排列数是 N! (N 阶乘)。对于 ABC 字符串,排列数为 3! = 3 * 2 * 1 = 6。让我们用 Python 来做一下:
# permutations.py
from itertools import permutations
print(list(permutations('ABC')))
这段非常简短的代码片段产生了以下结果:
$ python permutations.py
[('A', 'B', 'C'), ('A', 'C', 'B'), ('B', 'A', 'C'), ('B', 'C', 'A'), ('C', 'A', 'B'), ('C', 'B', 'A')]
当你玩排列时要非常小心。它们的数量增长速度与你要排列的元素的阶乘成比例,而这个数字可能会变得非常大,非常快。
总结
在本章中,我们迈出了扩展我们编码词汇的又一步。我们看到了如何通过评估条件来驱动代码的执行,以及如何循环和迭代序列和对象集合。这赋予了我们控制代码运行时发生的事情的能力,这意味着我们正在了解如何塑造它,使其做我们想要的事情,并对动态变化的数据做出反应。
我们还看到了如何在几个简单的例子中将所有东西组合在一起,最后,我们简要地看了一下 itertools 模块,其中充满了可以进一步丰富我们使用 Python 的有趣迭代器。
现在是时候换个方式,向前迈进一步,谈谈函数。下一章将全面讨论它们,因为它们非常重要。确保你对到目前为止所涵盖的内容感到舒适。我想给你提供一些有趣的例子,所以我会快一点。准备好了吗?翻页吧。
第四章:函数,代码的构建块
创建架构就是把东西放在一起。把什么放在一起?函数和对象。"– 勒·柯布西耶
在前几章中,我们已经看到 Python 中的一切都是对象,函数也不例外。但是,函数究竟是什么?函数是一系列执行任务的指令,打包成一个单元。然后可以在需要的地方导入并使用这个单元。使用函数在代码中有许多优点,我们很快就会看到。
在本章中,我们将涵盖以下内容:
-
函数——它们是什么,为什么我们应该使用它们
-
作用域和名称解析
-
函数签名——输入参数和返回值
-
递归和匿名函数
-
导入对象以重用代码
我相信这句话,一张图胜过千言万语,在向一个对这个概念新手解释函数时尤其正确,所以请看一下下面的图表:

如您所见,函数是一组指令,作为一个整体打包,就像一个盒子。函数可以接受输入参数并产生输出值。这两者都是可选的,我们将在本章的示例中看到。
在 Python 中,使用def关键字定义函数,随后是函数名称,后面跟着一对括号(可能包含或不包含输入参数),冒号(:)表示函数定义行的结束。紧接着,缩进四个空格,我们找到函数的主体,这是函数在调用时将执行的一组指令。
请注意,缩进四个空格不是强制性的,但这是PEP 8建议的空格数量,在实践中是最广泛使用的间距度量。
函数可能会返回输出,也可能不会。如果函数想要返回输出,它会使用return关键字,后面跟着期望的输出。如果您有鹰眼,您可能已经注意到在前面图表的输出部分的Optional后面有一个小*****。这是因为在 Python 中,函数总是返回一些东西,即使您没有明确使用return子句。如果函数在其主体中没有return语句,或者return语句本身没有给出值,函数将返回None。这种设计选择背后的原因超出了介绍性章节的范围,所以您需要知道的是,这种行为会让您的生活更轻松。一如既往,感谢 Python。
为什么要使用函数?
函数是任何语言中最重要的概念和构造之一,所以让我给你几个需要它们的原因:
-
它们减少了程序中的代码重复。通过将特定任务由一个良好的打包代码块处理,我们可以在需要时导入并调用它,无需重复其实现。
-
它们有助于将复杂的任务或过程分解为更小的块,每个块都成为一个函数。
-
它们隐藏了实现细节,使其用户不可见。
-
它们提高了可追溯性。
-
它们提高可读性。
让我们看一些示例,以更好地理解每一点。
减少代码重复
想象一下,您正在编写一段科学软件,需要计算素数直到一个限制,就像我们在上一章中所做的那样。您有一个很好的算法来计算它们,所以您将它复制并粘贴到需要的任何地方。然而,有一天,您的朋友B.黎曼给了您一个更好的算法来计算素数,这将为您节省大量时间。在这一点上,您需要检查整个代码库,并用新的代码替换旧的代码。
这实际上是一个不好的做法。这容易出错,你永远不知道你是不是误删或者误留了哪些代码行,当你把代码剪切粘贴到其他代码中时,你也可能会错过其中一个计算质数的地方,导致你的软件处于不一致的状态,同样的操作在不同的地方以不同的方式执行。如果你需要修复一个 bug 而不是用更好的版本替换代码,而你错过了其中一个地方呢?那将更糟糕。
那么,你应该怎么做?简单!你写一个函数get_prime_numbers(upto),在任何需要质数列表的地方使用它。当B. Riemann给你新代码时,你只需要用新实现替换该函数的主体,就完成了!其余的软件将自动适应,因为它只是调用函数。
你的代码会更短,不会受到旧方法和新方法执行任务的不一致性的影响,也不会因为复制粘贴失败或疏忽而导致未检测到的 bug。使用函数,你只会从中获益,我保证。
拆分复杂任务
函数还非常有用,可以将长或复杂的任务分解为较小的任务。最终结果是,代码从中受益,例如可读性、可测试性和重用性。举个简单的例子,想象一下你正在准备一份报告。你的代码需要从数据源获取数据,解析数据,过滤数据,整理数据,然后对其运行一系列算法,以产生将供Report类使用的结果。阅读这样的程序并不罕见,它们只是一个大大的do_report(data_source)函数。有数十行或数百行代码以return report结束。
这些情况在科学代码中更常见,这些代码在算法上可能很出色,但有时在编写风格上缺乏经验丰富的程序员的触觉。现在,想象一下几百行代码。很难跟进,找到事情改变上下文的地方(比如完成一个任务并开始下一个任务)。你有这个画面了吗?好了。不要这样做!相反,看看这段代码:
# data.science.example.py
def do_report(data_source):
# fetch and prepare data
data = fetch_data(data_source)
parsed_data = parse_data(data)
filtered_data = filter_data(parsed_data)
polished_data = polish_data(filtered_data)
# run algorithms on data
final_data = analyse(polished_data)
# create and return report
report = Report(final_data)
return report
前面的例子当然是虚构的,但你能看出来如果需要检查代码会有多容易吗?如果最终结果看起来不对,逐个调试do_report函数中的单个数据输出将会非常容易。此外,暂时从整个过程中排除部分过程也更容易(你只需要注释掉需要暂停的部分)。这样的代码更容易处理。
隐藏实现细节
让我们继续使用前面的例子来谈谈这一点。你可以看到,通过阅读do_report函数的代码,你可以在不阅读一行实现的情况下获得相当好的理解。这是因为函数隐藏了实现细节。这意味着,如果你不需要深入了解细节,你就不必强制自己去了解,就像如果do_report只是一个庞大的函数一样。为了理解发生了什么,你必须阅读每一行代码。而使用函数,你就不需要这样做。这减少了你阅读代码的时间,而在专业环境中,阅读代码所花费的时间比实际编写代码的时间要多得多,因此尽可能减少这部分时间非常重要。
提高可读性
程序员有时候看不出来为什么要写一个只有一两行代码的函数,所以让我们看一个例子,告诉你为什么你应该这样做。
想象一下,你需要计算两个矩阵的乘积:

你更喜欢阅读这段代码吗:
# matrix.multiplication.nofunc.py
a = [[1, 2], [3, 4]]
b = [[5, 1], [2, 1]]
c = [[sum(i * j for i, j in zip(r, c)) for c in zip(*b)]
for r in a]
或者你更喜欢这个:
# matrix.multiplication.func.py
# this function could also be defined in another module
def matrix_mul(a, b):
return [[sum(i * j for i, j in zip(r, c)) for c in zip(*b)]
for r in a]
a = [[1, 2], [3, 4]]
b = [[5, 1], [2, 1]]
c = matrix_mul(a, b)
在第二个例子中,更容易理解c是a和b之间的乘法结果。通过代码更容易阅读,如果您不需要修改该乘法逻辑,甚至不需要深入了解实现细节。因此,在这里提高了可读性,而在第一个片段中,您将不得不花时间尝试理解那个复杂的列表推导在做什么。
提高可追溯性
想象一下,您已经编写了一个电子商务网站。您在页面上展示了产品价格。假设您的数据库中的价格是不含增值税(销售税)的,但是您希望在网站上以 20%的增值税显示它们。以下是从不含增值税价格计算含增值税价格的几种方法:
# vat.py
price = 100 # GBP, no VAT
final_price1 = price * 1.2
final_price2 = price + price / 5.0
final_price3 = price * (100 + 20) / 100.0
final_price4 = price + price * 0.2
这四种不同的计算含增值税价格的方式都是完全可以接受的,我向您保证,这些年来我在同事的代码中找到了它们。现在,想象一下,您已经开始在不同的国家销售您的产品,其中一些国家有不同的增值税率,因此您需要重构您的代码(整个网站)以使增值税计算动态化。
您如何追踪所有进行增值税计算的地方?今天的编码是一个协作任务,您无法确定增值税是否仅使用了这些形式中的一种。相信我,这将是一场噩梦。
因此,让我们编写一个函数,该函数接受输入值vat和price(不含增值税),并返回含增值税的价格:
# vat.function.py
def calculate_price_with_vat(price, vat):
return price * (100 + vat) / 100
现在您可以导入该函数,并在您的网站的任何地方使用它,需要计算含增值税的价格,并且当您需要跟踪这些调用时,您可以搜索calculate_price_with_vat。
请注意,在前面的例子中,假定price是不含增值税的,vat是一个百分比值(例如 19、20 或 23)。
作用域和名称解析
您还记得我们在第一章中谈到的作用域和命名空间吗,Python 的初级介绍?我们现在将扩展这个概念。最后,我们可以谈论函数,这将使一切更容易理解。让我们从一个非常简单的例子开始:
# scoping.level.1.py
def my_function():
test = 1 # this is defined in the local scope of the function
print('my_function:', test)
test = 0 # this is defined in the global scope
my_function()
print('global:', test)
在前面的例子中,我在两个不同的地方定义了test名称。实际上它在两个不同的作用域中。一个是全局作用域(test = 0),另一个是my_function函数的局部作用域(test = 1)。如果您执行该代码,您会看到这个:
$ python scoping.level.1.py
my_function: 1
global: 0
很明显,test = 1覆盖了my_function中的test = 0赋值。在全局上下文中,test仍然是0,正如您从程序的输出中所看到的,但是我们在函数体中再次定义了test名称,并将其指向值为1的整数。因此,这两个test名称都存在,一个在全局作用域中,指向值为0的int对象,另一个在my_function作用域中,指向值为1的int对象。让我们注释掉test = 1的那一行。Python 会在下一个封闭的命名空间中搜索test名称(回想一下LEGB规则:local,enclosing,global,built-in,在第一章中描述,Python 的初级介绍),在这种情况下,我们将看到值0被打印两次。在您的代码中尝试一下。
现在,让我们提高一下难度:
# scoping.level.2.py
def outer():
test = 1 # outer scope
def inner():
test = 2 # inner scope
print('inner:', test)
inner()
print('outer:', test)
test = 0 # global scope
outer()
print('global:', test)
在前面的代码中,我们有两个级别的遮蔽。一个级别在函数outer中,另一个级别在函数inner中。这并不是什么难事,但可能会有些棘手。如果我们运行代码,我们会得到:
$ python scoping.level.2.py
inner: 2
outer: 1
global: 0
尝试注释掉test = 1行。您能猜到结果会是什么吗?嗯,当达到print('outer:', test)行时,Python 将不得不在下一个封闭范围中查找test,因此它将找到并打印0,而不是1。确保您也注释掉test = 2,以查看您是否理解发生了什么,以及 LEGB 规则是否清晰,然后再继续。
另一个需要注意的事情是,Python 允许您在另一个函数中定义一个函数。内部函数的名称在外部函数的命名空间中定义,就像任何其他名称一样。
global 和 nonlocal 语句
回到前面的例子,我们可以通过使用这两个特殊语句之一来更改对test名称的遮蔽:global和nonlocal。正如您从前面的例子中看到的,当我们在inner函数中定义test = 2时,我们既不会覆盖outer函数中的test,也不会覆盖全局范围中的test。如果我们在不定义它们的嵌套范围中使用它们,我们可以读取这些名称,但是我们不能修改它们,因为当我们编写赋值指令时,实际上是在当前范围中定义一个新名称。
我们如何改变这种行为呢?嗯,我们可以使用nonlocal语句。根据官方文档:
“nonlocal 语句使列出的标识符引用最近的封闭范围中先前绑定的变量,不包括全局变量。”
让我们在inner函数中引入它,看看会发生什么:
# scoping.level.2.nonlocal.py
def outer():
test = 1 # outer scope
def inner():
nonlocal test
test = 2 # nearest enclosing scope (which is 'outer')
print('inner:', test)
inner()
print('outer:', test)
test = 0 # global scope
outer()
print('global:', test)
请注意,在inner函数的主体中,我已经声明了test名称为nonlocal。运行此代码会产生以下结果:
$ python scoping.level.2.nonlocal.py
inner: 2
outer: 2
global: 0
哇,看看那个结果!这意味着,通过在inner函数中声明test为nonlocal,我们实际上得到了将test名称绑定到在outer函数中声明的名称。如果我们从inner函数中删除nonlocal test行并尝试在outer函数中尝试相同的技巧,我们将得到一个SyntaxError,因为nonlocal语句在封闭范围上运行,不包括全局范围。
那么有没有办法到达全局命名空间中的test = 0呢?当然,我们只需要使用global语句:
# scoping.level.2.global.py
def outer():
test = 1 # outer scope
def inner():
global test
test = 2 # global scope
print('inner:', test)
inner()
print('outer:', test)
test = 0 # global scope
outer()
print('global:', test)
请注意,我们现在已经声明了test名称为global,这基本上将其绑定到我们在全局命名空间中定义的名称(test = 0)。运行代码,您应该会得到以下结果:
$ python scoping.level.2.global.py
inner: 2
outer: 1
global: 2
这表明受test = 2赋值影响的名称现在是global。这个技巧在outer函数中也会起作用,因为在这种情况下,我们是在引用全局范围。自己尝试一下,看看有什么变化,熟悉作用域和名称解析,这非常重要。此外,您能告诉在前面的例子中如果在outer之外定义inner会发生什么吗?
输入参数
在本章的开始,我们看到函数可以接受输入参数。在我们深入讨论所有可能类型的参数之前,让我们确保您清楚地理解了向函数传递参数的含义。有三个关键点需要记住:
-
参数传递只不过是将对象分配给本地变量名称
-
在函数内部将对象分配给参数名称不会影响调用者
-
更改函数中的可变对象参数会影响调用者
让我们分别看一下这些要点的例子。
参数传递
看一下以下代码。我们在全局范围内声明一个名称x,然后我们声明一个函数func(y),最后我们调用它,传递x:
# key.points.argument.passing.py
x = 3
def func(y):
print(y)
func(x) # prints: 3
当使用x调用func时,在其局部范围内,创建了一个名为y的名称,并且它指向与x指向的相同对象。这可以通过以下图表更清楚地解释(不用担心 Python 3.3,这是一个未更改的功能):

前面图的右侧描述了程序在执行到结束后的状态,即func返回(None)后的状态。看一下 Frames 列,注意我们在全局命名空间(全局帧)中有两个名称,x和func,分别指向一个int(值为3)和一个function对象。在下面的名为func的矩形中,我们可以看到函数的局部命名空间,其中只定义了一个名称:y。因为我们用x调用了func(图的左侧第 5 行),y指向与x指向的相同的对象。这就是在将参数传递给函数时发生的情况。如果我们在函数定义中使用名称x而不是y,情况将完全相同(可能一开始有点混乱),函数中会有一个局部的x,而外部会有一个全局的x,就像我们在本章前面的作用域和名称解析部分中看到的那样。
总之,实际发生的是函数在其局部范围内创建了作为参数定义的名称,当我们调用它时,我们基本上告诉 Python 这些名称必须指向哪些对象。
分配给参数名称不会影响调用者
这一点一开始可能有点难以理解,所以让我们看一个例子:
# key.points.assignment.py
x = 3
def func(x):
x = 7 # defining a local x, not changing the global one
func(x)
print(x) # prints: 3
在前面的代码中,当执行x = 7行时,在func函数的局部范围内,名称x指向一个值为7的整数,而全局的x保持不变。
更改可变对象会影响调用者
这是最后一点,非常重要,因为 Python 在处理可变对象时表现得似乎有所不同(尽管只是表面上)。让我们看一个例子:
# key.points.mutable.py
x = [1, 2, 3]
def func(x):
x[1] = 42 # this affects the caller!
func(x)
print(x) # prints: [1, 42, 3]
哇,我们实际上改变了原始对象!如果你仔细想想,这种行为并不奇怪。函数中的x名称被设置为通过函数调用指向调用者对象,在函数体内,我们没有改变x,也就是说,我们没有改变它的引用,换句话说,我们没有改变x指向的对象。我们访问该对象在位置 1 的元素,并更改其值。
记住输入参数部分下的第 2 点:在函数内将对象分配给参数名称不会影响调用者。如果这对你来说很清楚,下面的代码就不会让你感到惊讶:
# key.points.mutable.assignment.py
x = [1, 2, 3]
def func(x):
x[1] = 42 # this changes the caller!
x = 'something else' # this points x to a new string object
func(x)
print(x) # still prints: [1, 42, 3]
看一下我标记的两行。一开始,就像以前一样,我们再次访问调用者对象,位于位置 1,并将其值更改为数字42。然后,我们重新分配x指向'something else'字符串。这样留下了调用者不变,实际上,输出与前面片段的输出相同。
花点时间来玩弄这个概念,并尝试使用打印和调用id函数,直到你的思路清晰。这是 Python 的一个关键方面,必须非常清楚,否则你可能会在代码中引入微妙的错误。再次强调,Python Tutor 网站(www.pythontutor.com/)将通过可视化这些概念来帮助你很多。
现在我们对输入参数及其行为有了很好的理解,让我们看看如何指定它们。
如何指定输入参数
指定输入参数的五种不同方式:
-
位置参数
-
关键字参数
-
变量位置参数
-
可变关键字参数
-
仅关键字参数
让我们逐一来看看它们。
位置参数
位置参数从左到右读取,它们是最常见的参数类型:
# arguments.positional.py
def func(a, b, c):
print(a, b, c)
func(1, 2, 3) # prints: 1 2 3
没有什么别的要说的。它们可以是任意多个,按位置分配。在函数调用中,1排在前面,2排在第二,3排在第三,因此它们分别分配给a、b和c。
关键字参数和默认值
关键字参数通过使用name=value语法进行分配:
# arguments.keyword.py
def func(a, b, c):
print(a, b, c)
func(a=1, c=2, b=3) # prints: 1 3 2
关键字参数是按名称匹配的,即使它们不遵守定义的原始位置(当我们混合和匹配不同类型的参数时,稍后我们将看到这种行为的限制)。
关键字参数的对应物,在定义方面,是默认值。 语法是相同的,name=value,并且允许我们不必提供参数,如果我们对给定的默认值感到满意:
# arguments.default.py
def func(a, b=4, c=88):
print(a, b, c)
func(1) # prints: 1 4 88
func(b=5, a=7, c=9) # prints: 7 5 9
func(42, c=9) # prints: 42 4 9
func(42, 43, 44) # prints: 42, 43, 44
有两件非常重要的事情需要注意。 首先,你不能在位置参数的左边指定默认参数。 其次,在这些例子中,注意当参数在列表中没有使用argument_name=value语法时,它必须是列表中的第一个参数,并且总是分配给a。 还要注意,以位置方式传递值仍然有效,并且遵循函数签名顺序(示例的最后一行)。
尝试混淆这些参数,看看会发生什么。 Python 错误消息非常擅长告诉你出了什么问题。 例如,如果你尝试了这样的东西:
# arguments.default.error.py
def func(a, b=4, c=88):
print(a, b, c)
func(b=1, c=2, 42) # positional argument after keyword one
你会得到以下错误:
$ python arguments.default.error.py
File "arguments.default.error.py", line 4
func(b=1, c=2, 42) # positional argument after keyword one
^
SyntaxError: positional argument follows keyword argument
这会告诉你你错误地调用了函数。
可变位置参数
有时候你可能想要向函数传递可变数量的位置参数,Python 为你提供了这样的能力。 让我们看一个非常常见的用例,minimum函数。 这是一个计算其输入值的最小值的函数:
# arguments.variable.positional.py
def minimum(*n):
# print(type(n)) # n is a tuple
if n: # explained after the code
mn = n[0]
for value in n[1:]:
if value < mn:
mn = value
print(mn)
minimum(1, 3, -7, 9) # n = (1, 3, -7, 9) - prints: -7
minimum() # n = () - prints: nothing
正如你所看到的,当我们在参数名前面加上*时,我们告诉 Python 该参数将根据函数的调用方式收集可变数量的位置参数。 在函数内部,n是一个元组。 取消注释print(type(n)),自己看看并玩一会儿。
你是否注意到我们如何用简单的if n:检查n是否为空? 这是因为在 Python 中,集合对象在非空时求值为True,否则为False。 这对于元组,集合,列表,字典等都是成立的。
还有一件事要注意的是,当我们在没有参数的情况下调用函数时,我们可能希望抛出错误,而不是默默地什么都不做。 在这种情况下,我们不关心使这个函数健壮,而是理解可变位置参数。
让我们举个例子,展示两件事,根据我的经验,对于新手来说是令人困惑的:
# arguments.variable.positional.unpacking.py
def func(*args):
print(args)
values = (1, 3, -7, 9)
func(values) # equivalent to: func((1, 3, -7, 9))
func(*values) # equivalent to: func(1, 3, -7, 9)
好好看看前面例子的最后两行。 在第一个例子中,我们用一个参数调用func,一个四元组。 在第二个例子中,通过使用*语法,我们正在做一种叫做解包的事情,这意味着四元组被解包,函数被调用时有四个参数:1, 3, -7, 9。
这种行为是 Python 为你做的魔术的一部分,允许你在动态调用函数时做一些惊人的事情。
变量关键字参数
可变关键字参数与可变位置参数非常相似。 唯一的区别是语法(**而不是*),以及它们被收集在一个字典中。 集合和解包的工作方式相同,所以让我们看一个例子:
# arguments.variable.keyword.py
def func(**kwargs):
print(kwargs)
# All calls equivalent. They print: {'a': 1, 'b': 42}
func(a=1, b=42)
func(**{'a': 1, 'b': 42})
func(**dict(a=1, b=42))
在前面的例子中,所有的调用都是等价的。 你可以看到,在函数定义中在参数名前面添加**告诉 Python 使用该名称来收集可变数量的关键字参数。 另一方面,当我们调用函数时,我们可以显式地传递name=value参数,或者使用相同的**语法解包字典。
能够传递可变数量的关键字参数之所以如此重要的原因可能目前还不明显,那么,来一个更现实的例子怎么样?让我们定义一个连接到数据库的函数。我们希望通过简单调用这个函数而连接到默认数据库。我们还希望通过传递适当的参数来连接到任何其他数据库。在继续阅读之前,试着花几分钟时间自己想出一个解决方案:
# arguments.variable.db.py
def connect(**options):
conn_params = {
'host': options.get('host', '127.0.0.1'),
'port': options.get('port', 5432),
'user': options.get('user', ''),
'pwd': options.get('pwd', ''),
}
print(conn_params)
# we then connect to the db (commented out)
# db.connect(**conn_params)
connect()
connect(host='127.0.0.42', port=5433)
connect(port=5431, user='fab', pwd='gandalf')
注意在函数中,我们可以准备一个连接参数的字典(conn_params),使用默认值作为回退,允许在函数调用中提供时进行覆盖。有更好的方法可以用更少的代码行来实现,但我们现在不关心这个。运行上述代码会产生以下结果:
$ python arguments.variable.db.py
{'host': '127.0.0.1', 'port': 5432, 'user': '', 'pwd': ''}
{'host': '127.0.0.42', 'port': 5433, 'user': '', 'pwd': ''}
{'host': '127.0.0.1', 'port': 5431, 'user': 'fab', 'pwd': 'gandalf'}
注意函数调用和输出之间的对应关系。注意默认值是如何根据传递给函数的参数进行覆盖的。
仅限关键字参数
Python 3 允许一种新类型的参数:仅限关键字参数。我们只会简要地研究它们,因为它们的使用情况并不那么频繁。有两种指定它们的方式,要么在可变位置参数之后,要么在单独的*之后。让我们看一个例子:
# arguments.keyword.only.py
def kwo(*a, c):
print(a, c)
kwo(1, 2, 3, c=7) # prints: (1, 2, 3) 7
kwo(c=4) # prints: () 4
# kwo(1, 2) # breaks, invalid syntax, with the following error
# TypeError: kwo() missing 1 required keyword-only argument: 'c'
def kwo2(a, b=42, *, c):
print(a, b, c)
kwo2(3, b=7, c=99) # prints: 3 7 99
kwo2(3, c=13) # prints: 3 42 13
# kwo2(3, 23) # breaks, invalid syntax, with the following error
# TypeError: kwo2() missing 1 required keyword-only argument: 'c'
如预期的那样,函数kwo接受可变数量的位置参数(a)和一个仅限关键字参数c。调用的结果很直接,您可以取消注释第三个调用以查看 Python 返回的错误。
相同的规则适用于函数kwo2,它与kwo不同之处在于它接受一个位置参数a,一个关键字参数b,然后是一个仅限关键字参数c。您可以取消注释第三个调用以查看错误。
现在你知道如何指定不同类型的输入参数了,让我们看看如何在函数定义中结合它们。
结合输入参数
只要遵循以下顺序规则,就可以结合输入参数:
-
在定义函数时,普通的位置参数先出现(
name),然后是任何默认参数(name=value),然后是可变位置参数(*name或简单的*),然后是任何关键字参数(name或name=value形式都可以),最后是任何可变关键字参数(**name)。 -
另一方面,在调用函数时,参数必须按照以下顺序给出:先是位置参数(
value),然后是任意组合的关键字参数(name=value),可变位置参数(*name),然后是可变关键字参数(**name)。
由于这在理论世界中留下来可能有点棘手,让我们看几个快速的例子:
# arguments.all.py
def func(a, b, c=7, *args, **kwargs):
print('a, b, c:', a, b, c)
print('args:', args)
print('kwargs:', kwargs)
func(1, 2, 3, *(5, 7, 9), **{'A': 'a', 'B': 'b'})
func(1, 2, 3, 5, 7, 9, A='a', B='b') # same as previous one
注意函数定义中参数的顺序,两个调用是等价的。在第一个调用中,我们使用了可迭代对象和字典的解包操作符,而在第二个调用中,我们使用了更明确的语法。执行这个函数会产生以下结果(我只打印了一个调用的结果,另一个结果相同):
$ python arguments.all.py
a, b, c: 1 2 3
args: (5, 7, 9)
kwargs: {'A': 'a', 'B': 'b'}
现在让我们看一个关键字参数的例子:
# arguments.all.kwonly.py
def func_with_kwonly(a, b=42, *args, c, d=256, **kwargs):
print('a, b:', a, b)
print('c, d:', c, d)
print('args:', args)
print('kwargs:', kwargs)
# both calls equivalent
func_with_kwonly(3, 42, c=0, d=1, *(7, 9, 11), e='E', f='F')
func_with_kwonly(3, 42, *(7, 9, 11), c=0, d=1, e='E', f='F')
请注意我在函数声明中突出显示了仅限关键字参数。它们出现在*args可变位置参数之后,如果它们直接出现在单个*之后,情况也是一样的(在这种情况下就没有可变位置参数了)。
执行这个函数会产生以下结果(我只打印了一个调用的结果):
$ python arguments.all.kwonly.py
a, b: 3 42
c, d: 0 1
args: (7, 9, 11)
kwargs: {'e': 'E', 'f': 'F'}
还要注意的一件事是我给可变位置和关键字参数起的名字。你可以选择不同的名字,但要注意args和kwargs是这些参数的通用约定名称,至少是通用的。
额外的解包概括
Python 3.5 中引入的最近的新功能之一是能够扩展可迭代(*)和字典(**)解包操作符,以允许在更多位置、任意次数和额外情况下解包。我将给你一个关于函数调用的例子:
# additional.unpacking.py
def additional(*args, **kwargs):
print(args)
print(kwargs)
args1 = (1, 2, 3)
args2 = [4, 5]
kwargs1 = dict(option1=10, option2=20)
kwargs2 = {'option3': 30}
additional(*args1, *args2, **kwargs1, **kwargs2)
在前面的例子中,我们定义了一个简单的函数,打印它的输入参数args和kwargs。新功能在于我们调用这个函数的方式。注意我们如何解包多个可迭代对象和字典,并且它们在args和kwargs下正确地合并。这个功能之所以重要的原因在于,它允许我们不必在代码中合并args1和args2,以及kwargs1和kwargs2。运行代码会产生:
$ python additional.unpacking.py
(1, 2, 3, 4, 5)
{'option1': 10, 'option2': 20, 'option3': 30}
请参考 PEP 448(www.python.org/dev/peps/pep-0448/)来了解这一新功能的全部内容,并查看更多示例。
避免陷阱!可变默认值
在 Python 中需要非常注意的一件事是,默认值是在def时创建的,因此,对同一个函数的后续调用可能会根据它们的默认值的可变性而表现得不同。让我们看一个例子:
# arguments.defaults.mutable.py
def func(a=[], b={}):
print(a)
print(b)
print('#' * 12)
a.append(len(a)) # this will affect a's default value
b[len(a)] = len(a) # and this will affect b's one
func()
func()
func()
两个参数都有可变的默认值。这意味着,如果你影响这些对象,任何修改都会在后续的函数调用中保留下来。看看你能否理解这些调用的输出:
$ python arguments.defaults.mutable.py
[]
{}
############
[0]
{1: 1}
############
[0, 1]
{1: 1, 2: 2}
############
很有趣,不是吗?虽然这种行为一开始可能看起来很奇怪,但实际上是有道理的,而且非常方便,例如,在使用记忆化技术时(如果你感兴趣,可以搜索一下)。更有趣的是,当我们在调用之间引入一个不使用默认值的调用时,会发生什么:
# arguments.defaults.mutable.intermediate.call.py
func()
func(a=[1, 2, 3], b={'B': 1})
func()
当我们运行这段代码时,输出如下:
$ python arguments.defaults.mutable.intermediate.call.py
[]
{}
############
[1, 2, 3]
{'B': 1}
############
[0]
{1: 1}
############
这个输出告诉我们,即使我们用其他值调用函数,默认值仍然保留。一个让人想到的问题是,我怎样才能每次都得到一个全新的空值?嗯,惯例是这样的:
# arguments.defaults.mutable.no.trap.py
def func(a=None):
if a is None:
a = []
# do whatever you want with `a` ...
请注意,通过使用前面的技术,如果在调用函数时没有传递a,你总是会得到一个全新的空列表。
好了,输入就到此为止,让我们看看另一面,输出。
返回值
函数的返回值是 Python 领先于大多数其他语言的地方之一。通常函数允许返回一个对象(一个值),但在 Python 中,你可以返回一个元组,这意味着你可以返回任何你想要的东西。这个特性允许程序员编写在其他语言中要难得多或者肯定更加繁琐的软件。我们已经说过,要从函数中返回一些东西,我们需要使用return语句,后面跟着我们想要返回的东西。在函数体中可以有多个 return 语句。
另一方面,如果在函数体内部我们没有返回任何东西,或者调用了一个裸的return语句,函数将返回None。这种行为是无害的,尽管我在这里没有足够的空间详细解释为什么 Python 被设计成这样,但我只想告诉你,这个特性允许了几种有趣的模式,并且证实了 Python 是一种非常一致的语言。
我说这是无害的,因为你从来不会被迫收集函数调用的结果。我将用一个例子来说明我的意思:
# return.none.py
def func():
pass
func() # the return of this call won't be collected. It's lost.
a = func() # the return of this one instead is collected into `a`
print(a) # prints: None
请注意,函数的整个主体只由pass语句组成。正如官方文档告诉我们的那样,pass是一个空操作。当它被执行时,什么都不会发生。当语法上需要一个语句,但不需要执行任何代码时,它是有用的。在其他语言中,我们可能会用一对花括号({})来表示这一点,定义一个空作用域,但在 Python 中,作用域是通过缩进代码来定义的,因此pass这样的语句是必要的。
还要注意,func函数的第一个调用返回一个值(None),我们没有收集。正如我之前所说,收集函数调用的返回值并不是强制性的。
现在,这很好但不是很有趣,那么我们来写一个有趣的函数吧?记住,在第一章中,Python 的初步介绍,我们谈到了函数的阶乘。让我们在这里写一个(为简单起见,我将假设函数总是以适当的值正确调用,因此我不会对输入参数进行健全性检查):
# return.single.value.py
def factorial(n):
if n in (0, 1):
return 1
result = n
for k in range(2, n):
result *= k
return result
f5 = factorial(5) # f5 = 120
请注意我们有两个返回点。如果n是0或1(在 Python 中通常使用in类型的检查,就像我所做的那样,而不是更冗长的if n == 0 or n == 1:),我们返回1。否则,我们执行所需的计算并返回result。让我们尝试以更简洁的方式编写这个函数:
# return.single.value.2.py from functools import reduce
from operator import mul
def factorial(n):
return reduce(mul, range(1, n + 1), 1)
f5 = factorial(5) # f5 = 120
我知道你在想什么:一行?Python 是优雅而简洁的!我认为这个函数即使你从未见过reduce或mul,也是可读的,但如果你无法阅读或理解它,请花几分钟时间在 Python 文档上做一些研究,直到它的行为对你清晰为止。能够在文档中查找函数并理解他人编写的代码是每个开发人员都需要能够执行的任务,所以把它当作一个挑战。
为此,请确保查找help函数,在控制台探索时会非常有帮助。
返回多个值
与大多数其他语言不同,在 Python 中很容易从函数返回多个对象。这个特性打开了一个全新的可能性世界,并允许你以其他语言难以复制的风格编码。我们的思维受到我们使用的工具的限制,因此当 Python 给予你比其他语言更多的自由时,实际上也在提高你自己的创造力。返回多个值非常容易,你只需使用元组(显式或隐式)。让我们看一个简单的例子,模仿divmod内置函数:
# return.multiple.py
def moddiv(a, b):
return a // b, a % b
print(moddiv(20, 7)) # prints (2, 6)
我本可以将前面代码中的突出部分包装在括号中,使其成为一个显式的元组,但没有必要。前面的函数同时返回了结果和除法的余数。
在这个示例的源代码中,我留下了一个简单的测试函数的示例,以确保我的代码进行了正确的计算。
一些建议
在编写函数时,遵循指南非常有用,这样你就可以写得更好。我将快速指出其中一些:
-
函数应该只做一件事:只做一件事的函数很容易用一句简短的话来描述。做多件事的函数可以拆分成做一件事的较小函数。这些较小的函数通常更容易阅读和理解。还记得我们几页前看到的数据科学示例吗?
-
函数应该尽可能小:它们越小,测试和编写它们就越容易,以便它们只做一件事。
-
输入参数越少越好:接受大量参数的函数很快就变得难以管理(除其他问题外)。
-
函数的返回值应该是一致的:返回
False或None并不相同,即使在布尔上下文中它们都会评估为False。False表示我们有信息(False),而None表示没有信息。尝试编写函数以一致的方式返回,无论其主体发生了什么。 -
函数不应该有副作用:换句话说,函数不应该影响你调用它们的值。这可能是目前最难理解的陈述,所以我将给你一个使用列表的例子。在下面的代码中,请注意
sorted函数没有对numbers进行排序,它实际上返回了一个已排序的numbers的副本。相反,list.sort()方法是在numbers对象本身上操作,这是可以的,因为它是一个方法(属于对象的函数,因此有权修改它):
>>> numbers = [4, 1, 7, 5]
>>> sorted(numbers) # won't sort the original `numbers` list
[1, 4, 5, 7]
>>> numbers # let's verify
[4, 1, 7, 5] # good, untouched
>>> numbers.sort() # this will act on the list
>>> numbers
[1, 4, 5, 7]
遵循这些准则,你将编写更好的函数,这将为你服务。
递归函数
当一个函数调用自身来产生结果时,它被称为递归。有时,递归函数非常有用,因为它们使编写代码变得更容易。有些算法使用递归范式编写起来非常容易,而其他一些则不是。没有递归函数无法以迭代方式重写,因此通常由程序员选择处理当前情况的最佳方法。
递归函数的主体通常有两个部分:一个是返回值取决于对自身的后续调用,另一个是不取决于后续调用的情况(称为基本情况)。
例如,我们可以考虑(希望现在已经熟悉的)factorial函数,N!。基本情况是当N为0或1时。函数返回1,无需进一步计算。另一方面,在一般情况下,N!返回乘积1 * 2 * ... * (N-1) * N。如果你仔细想想,N!可以这样重写:N! = (N-1)! * N。作为一个实际的例子,考虑5! = 1 * 2 * 3 * 4 * 5 = (1 * 2 * 3 * 4) * 5 = 4! * 5。
让我们用代码写下来:
# recursive.factorial.py
def factorial(n):
if n in (0, 1): # base case
return 1
return factorial(n - 1) * n # recursive case
在编写递归函数时,始终考虑你进行了多少嵌套调用,因为有一个限制。有关此信息的更多信息,请查看sys.getrecursionlimit()和sys.setrecursionlimit()。
在编写算法时经常使用递归函数,它们编写起来非常有趣。作为练习,尝试使用递归和迭代方法解决一些简单问题。
匿名函数
我想谈谈的最后一种函数类型是匿名函数。这些函数在 Python 中称为lambda,通常在需要一个完全成熟的带有自己名称的函数会显得有些多余时使用,我们只需要一个快速、简单的一行代码来完成任务。
假设你想要一个包含* N *的所有倍数的列表。假设你想使用filter函数进行筛选,该函数接受一个函数和一个可迭代对象,并构造一个筛选对象,你可以对其进行迭代,从可迭代对象中返回True的元素。如果不使用匿名函数,你可以这样做:
# filter.regular.py
def is_multiple_of_five(n):
return not n % 5
def get_multiples_of_five(n):
return list(filter(is_multiple_of_five, range(n)))
注意我们如何使用is_multiple_of_five来过滤前n个自然数。这似乎有点多余,任务很简单,我们不需要保留is_multiple_of_five函数以备其他用途。让我们使用 lambda 函数重新编写它:
# filter.lambda.py
def get_multiples_of_five(n):
return list(filter(lambda k: not k % 5, range(n)))
逻辑完全相同,但现在过滤函数是一个 lambda。定义 lambda 非常简单,遵循这种形式:func_name = lambda [parameter_list]: expression。返回一个函数对象,等同于这个:def func_name([parameter_list]): return expression。
请注意,可选参数在常见的语法中用方括号括起来表示。
让我们再看看两种形式定义的等价函数的另外一些例子:
# lambda.explained.py
# example 1: adder
def adder(a, b):
return a + b
# is equivalent to:
adder_lambda = lambda a, b: a + b
# example 2: to uppercase
def to_upper(s):
return s.upper()
# is equivalent to:
to_upper_lambda = lambda s: s.upper()
前面的例子非常简单。第一个例子是两个数字相加,第二个例子是生成字符串的大写版本。请注意,我将lambda表达式返回的内容分配给了一个名称(adder_lambda,to_upper_lambda),但在我们在filter示例中使用 lambda 时,没有必要这样做。
函数属性
每个函数都是一个完整的对象,因此它们有很多属性。其中一些是特殊的,可以用一种内省的方式在运行时检查函数对象。下面的脚本是一个例子,展示了其中一部分属性以及如何显示它们的值,用于一个示例函数:
# func.attributes.py
def multiplication(a, b=1):
"""Return a multiplied by b. """
return a * b
special_attributes = [
"__doc__", "__name__", "__qualname__", "__module__",
"__defaults__", "__code__", "__globals__", "__dict__",
"__closure__", "__annotations__", "__kwdefaults__",
]
for attribute in special_attributes:
print(attribute, '->', getattr(multiplication, attribute))
我使用内置的getattr函数来获取这些属性的值。getattr(obj, attribute)等同于obj.attribute,在我们需要在运行时使用字符串名称获取属性时非常方便。运行这个脚本会产生:
$ python func.attributes.py
__doc__ -> Return a multiplied by b.
__name__ -> multiplication
__qualname__ -> multiplication
__module__ -> __main__
__defaults__ -> (1,)
__code__ -> <code object multiplication at 0x10caf7660, file "func.attributes.py", line 1>
__globals__ -> {...omitted...}
__dict__ -> {}
__closure__ -> None
__annotations__ -> {}
__kwdefaults__ -> None
我省略了__globals__属性的值,因为它太大了。关于这个属性的含义的解释可以在Python 数据模型文档页面的Callable types部分找到(docs.python.org/3/reference/datamodel.html#the-standard-type-hierarchy)。如果你想看到一个对象的所有属性,只需调用dir(object_name),你将得到所有属性的列表。
内置函数
Python 自带了很多内置函数。它们可以在任何地方使用,你可以通过检查builtins模块的dir(__builtins__)来获取它们的列表,或者查看官方 Python 文档。不幸的是,我没有足够的空间在这里介绍所有这些函数。我们已经见过其中一些,比如any、bin、bool、divmod、filter、float、getattr、id、int、len、list、min、print、set、tuple、type和zip,但还有很多,你至少应该阅读一次。熟悉它们,进行实验,为每个函数编写一小段代码,并确保你能随时使用它们。
最后一个例子
在我们结束本章之前,最后一个例子怎么样?我在想我们可以编写一个函数来生成一个小于某个限制的质数列表。我们已经见过这个代码了,所以让我们把它变成一个函数,并且为了保持趣味性,让我们稍微优化一下。
事实证明,你不需要将一个数N除以从2到N-1 的所有数字来判断它是否是质数。你可以停在√N。此外,你不需要测试从2到√N的所有数字的除法,你可以只使用该范围内的质数。如果你感兴趣,我会留给你去弄清楚为什么这样可以,让我们看看代码如何改变:
# primes.py
from math import sqrt, ceil
def get_primes(n):
"""Calculate a list of primes up to n (included). """
primelist = []
for candidate in range(2, n + 1):
is_prime = True
root = ceil(sqrt(candidate)) # division limit
for prime in primelist: # we try only the primes
if prime > root: # no need to check any further
break
if candidate % prime == 0:
is_prime = False
break
if is_prime:
primelist.append(candidate)
return primelist
代码和上一章的一样。我们改变了除法算法,所以我们只使用先前计算出的质数来测试可除性,并且一旦测试除数大于候选数的平方根,我们就停止了。我们使用primelist结果列表来获取除法的质数。我们使用一个花哨的公式来计算根值,即候选数的根的天花板的整数值。虽然一个简单的int(k ** 0.5) + 1也可以满足我们的目的,但我选择的公式更简洁,需要我使用一些导入,我想向你展示。查看math模块中的函数,它们非常有趣!
文档化你的代码
我是一个不需要文档的代码的忠实粉丝。当你正确编程,选择正确的名称并注意细节时,你的代码应该是不言自明的,不需要文档。有时注释是非常有用的,文档也是如此。你可以在PEP 257 - Docstring conventions中找到 Python 文档的指南(www.python.org/dev/peps/pep-0257/),但我会在这里向你展示基础知识。
Python 使用字符串进行文档化,这些字符串被称为docstrings。任何对象都可以被文档化,你可以使用单行或多行 docstrings。单行的非常简单。它们不应该为函数提供另一个签名,而是清楚地说明其目的:
# docstrings.py
def square(n):
"""Return the square of a number n. """
return n ** 2
def get_username(userid):
"""Return the username of a user given their id. """
return db.get(user_id=userid).username
使用三个双引号的字符串允许您以后轻松扩展。使用以句点结尾的句子,并且不要在前后留下空行。
多行注释的结构方式类似。应该有一个简短的一行说明对象大意的描述,然后是更详细的描述。例如,我已经使用 Sphinx 符号记录了一个虚构的connect函数,在下面的示例中:
def connect(host, port, user, password):
"""Connect to a database.
Connect to a PostgreSQL database directly, using the given
parameters.
:param host: The host IP.
:param port: The desired port.
:param user: The connection username.
:param password: The connection password.
:return: The connection object.
"""
# body of the function here...
return connection
Sphinx 可能是创建 Python 文档最广泛使用的工具。事实上,官方 Python 文档就是用它编写的。值得花一些时间去了解它。
导入对象
现在您已经对函数有了很多了解,让我们看看如何使用它们。编写函数的整个目的是能够以后重用它们,在 Python 中,这意味着将它们导入到需要它们的命名空间中。有许多不同的方法可以将对象导入命名空间,但最常见的是import module_name和from module_name import function_name。当然,这些都是相当简单的例子,但请暂时忍耐。
import module_name 形式会找到module_name模块,并在执行import语句的本地命名空间中为其定义一个名称。from module_name import identifier 形式比这略微复杂一些,但基本上做的是相同的事情。它找到module_name并搜索属性(或子模块),并在本地命名空间中存储对identifier的引用。
两种形式都可以使用as子句更改导入对象的名称:
from mymodule import myfunc as better_named_func
只是为了让您了解导入的样子,这里有一个来自我的一个项目的测试模块的示例(请注意,导入块之间的空行遵循 PEP 8 的指南:标准库、第三方库和本地代码):
from datetime import datetime, timezone # two imports on the same line
from unittest.mock import patch # single import
import pytest # third party library
from core.models import ( # multiline import
Exam,
Exercise,
Solution,
)
当您有一个从项目根目录开始的文件结构时,您可以使用点符号来获取您想要导入到当前命名空间的对象,无论是包、模块、类、函数还是其他任何东西。from module import语法还允许使用一个通配符子句,from module import *,有时用于一次性将模块中的所有名称导入当前命名空间,但出于多种原因,如性能和潜在的静默屏蔽其他名称的风险,这是不被赞成的。您可以在官方 Python 文档中阅读关于导入的所有内容,但在我们离开这个主题之前,让我给您一个更好的例子。
假设您在lib文件夹中的模块funcdef.py中定义了一对函数:square(n)和cube(n)。您希望在与lib文件夹处于相同级别的几个模块中使用它们,这些模块称为func_import.py和func_from.py。显示该项目的树结构会产生以下内容:
├── func_from.py
├── func_import.py
├── lib
├── funcdef.py
└── __init__.py
在我展示每个模块的代码之前,请记住,为了告诉 Python 它实际上是一个包,我们需要在其中放置一个__init__.py模块。
关于__init__.py文件有两件事需要注意。首先,它是一个完整的 Python 模块,因此您可以像对待任何其他模块一样将代码放入其中。其次,从 Python 3.3 开始,不再需要它的存在来使文件夹被解释为 Python 包。
代码如下:
# funcdef.py
def square(n):
return n ** 2
def cube(n):
return n ** 3
# func_import.py import lib.funcdef
print(lib.funcdef.square(10))
print(lib.funcdef.cube(10))
# func_from.py
from lib.funcdef import square, cube
print(square(10))
print(cube(10))
这两个文件在执行时都会打印100和1000。您可以看到我们如何根据当前范围中导入的内容以及导入的方式和内容来访问square和cube函数的不同方式。
相对导入
到目前为止,我们所见过的导入被称为绝对导入,即它们定义了我们要导入的模块的整个路径,或者我们要从中导入对象的模块。在 Python 中还有另一种导入对象的方式,称为相对导入。在我们想要重新排列大型包的结构而不必编辑子包时,或者当我们想要使包内的模块能够自我导入时,这种方式非常有帮助。相对导入是通过在模块前面添加与我们需要回溯的文件夹数量相同数量的前导点来完成的,以便找到我们正在搜索的内容。简而言之,它就是这样的。
from .mymodule import myfunc
有关相对导入的完整解释,请参阅 PEP 328 (www.python.org/dev/peps/pep-0328/)。
总结
在本章中,我们探讨了函数的世界。它们非常重要,从现在开始,我们基本上会在任何地方使用它们。我们谈到了使用它们的主要原因,其中最重要的是代码重用和实现隐藏。
我们看到函数对象就像一个接受可选输入并产生输出的盒子。我们可以以许多不同的方式向函数提供输入值,使用位置参数和关键字参数,并对两种类型都使用变量语法。
现在你应该知道如何编写一个函数,对它进行文档化,将它导入到你的代码中,并调用它。
在下一章中,我们将看到如何处理文件以及如何以多种不同的方式和格式持久化数据。
第五章:文件和数据持久性
“持久性是我们称之为生活的冒险的关键。” - Torsten Alexander Lange
在之前的章节中,我们已经探索了 Python 的几个不同方面。由于示例具有教学目的,我们在简单的 Python shell 中运行它们,或者以 Python 模块的形式运行它们。它们运行,可能在控制台上打印一些内容,然后终止,留下了它们短暂存在的痕迹。
然而,现实世界的应用通常大不相同。它们当然仍然在内存中运行,但它们与网络、磁盘和数据库进行交互。它们还使用适合情况的格式与其他应用程序和设备交换信息。
在本章中,我们将开始逐渐接近现实世界,探索以下内容:
-
文件和目录
-
压缩
-
网络和流量
-
JSON 数据交换格式
-
使用 pickle 和 shelve 进行数据持久化,来自标准库
-
使用 SQLAlchemy 进行数据持久化
和往常一样,我会努力平衡广度和深度,这样在本章结束时,你将对基本原理有扎实的理解,并且知道如何在网络上获取更多信息。
处理文件和目录
在处理文件和目录时,Python 提供了许多有用的工具。特别是在以下示例中,我们将利用os和shutil模块。因为我们将在磁盘上读写数据,我将使用一个名为fear.txt的文件,其中包含了《恐惧》(Fear)的节选,作者是 Thich Nhat Hanh,作为我们一些示例的实验对象。
打开文件
在 Python 中打开文件非常简单和直观。实际上,我们只需要使用open函数。让我们看一个快速的例子:
# files/open_try.py
fh = open('fear.txt', 'rt') # r: read, t: text
for line in fh.readlines():
print(line.strip()) # remove whitespace and print
fh.close()
前面的代码非常简单。我们调用open,传递文件名,并告诉open我们要以文本模式读取它。在文件名之前没有路径信息;因此,open会假定文件在运行脚本的同一文件夹中。这意味着如果我们从files文件夹外部运行此脚本,那么fear.txt将找不到。
一旦文件被打开,我们就会得到一个文件对象fh,我们可以用它来处理文件的内容。在这种情况下,我们使用readlines()方法来迭代文件中的所有行,并打印它们。我们对每一行调用strip()来去除内容周围的任何额外空格,包括末尾的行终止字符,因为print会为我们添加一个。这是一个快速而粗糙的解决方案,在这个例子中有效,但是如果文件的内容包含需要保留的有意义的空格,你将需要在清理数据时更加小心。在脚本的结尾,我们刷新并关闭流。
关闭文件非常重要,因为我们不希望冒着释放文件句柄的风险。因此,我们需要采取一些预防措施,并将之前的逻辑包装在try/finally块中。这样做的效果是,无论我们尝试打开和读取文件时可能发生什么错误,我们都可以放心close()会被调用:
# files/open_try.py
try:
fh = open('fear.txt', 'rt')
for line in fh.readlines():
print(line.strip())
finally:
fh.close()
逻辑完全相同,但现在也是安全的。
如果你现在不理解try/finally,不要担心。我们将在后面的章节中探讨如何处理异常。现在,只需说将代码放在try块的主体内会在该代码周围添加一个机制,允许我们检测错误(称为异常)并决定发生错误时该怎么办。在这种情况下,如果发生错误,我们实际上并不做任何事情,但通过在finally块中关闭文件,我们确保该行被执行,无论是否发生了任何错误。
我们可以这样简化前面的例子:
# files/open_try.py
try:
fh = open('fear.txt') # rt is default
for line in fh: # we can iterate directly on fh
print(line.strip())
finally:
fh.close()
正如你所看到的,rt是打开文件的默认模式,因此我们不需要指定它。此外,我们可以直接在fh上进行迭代,而不需要显式调用readlines()。Python 非常好,给了我们简化代码的快捷方式,使我们的代码更短、更容易阅读。
所有前面的例子都在控制台上打印文件(查看源代码以阅读整个内容):
An excerpt from Fear - By Thich Nhat Hanh
The Present Is Free from Fear
When we are not fully present, we are not really living. We’re not really there, either for our loved ones or for ourselves. If we’re not there, then where are we? We are running, running, running, even during our sleep. We run because we’re trying to escape from our fear.
...
使用上下文管理器打开文件
让我们承认吧:不得不用try/finally块来传播我们的代码并不是最好的选择。像往常一样,Python 给了我们一个更好的方式以安全的方式打开文件:使用上下文管理器。让我们先看看代码:
# files/open_with.py
with open('fear.txt') as fh:
for line in fh:
print(line.strip())
前面的例子等同于之前的例子,但读起来更好。with语句支持由上下文管理器定义的运行时上下文的概念。这是使用一对方法__enter__和__exit__来实现的,允许用户定义的类定义在语句体执行之前进入的运行时上下文,并在语句结束时退出。open函数在由上下文管理器调用时能够生成一个文件对象,但它真正的美妙之处在于fh.close()会自动为我们调用,即使出现错误也是如此。
上下文管理器在几种不同的场景中使用,比如线程同步、文件或其他对象的关闭,以及网络和数据库连接的管理。您可以在contextlib文档页面中找到有关它们的信息(docs.python.org/3.7/library/contextlib.html)。
读写文件
现在我们知道如何打开文件了,让我们看看我们有几种不同的方式来读取和写入文件:
# files/print_file.py
with open('print_example.txt', 'w') as fw:
print('Hey I am printing into a file!!!', file=fw)
第一种方法使用了print函数,你在前几章中已经见过很多次。在获取文件对象之后,这次指定我们打算写入它("w"),我们可以告诉print调用将其效果定向到文件,而不是默认的sys.stdout,当在控制台上执行时,它会映射到它。
前面的代码的效果是:如果print_example.txt文件不存在,则创建它,或者如果存在,则将其截断,并将行Hey I am printing into a file!!!写入其中。
这很简单易懂,但不是我们通常写文件时所采用的方式。让我们看一个更常见的方法:
# files/read_write.py
with open('fear.txt') as f:
lines = [line.rstrip() for line in f]
with open('fear_copy.txt', 'w') as fw:
fw.write('\n'.join(lines))
在前面的例子中,我们首先打开fear.txt并将其内容逐行收集到一个列表中。请注意,这次我调用了一个更精确的方法rstrip(),作为一个例子,以确保我只去掉每行右侧的空白。
在代码片段的第二部分中,我们创建了一个新文件fear_copy.txt,并将原始文件中的所有行写入其中,用换行符\n连接起来。Python 很慷慨,并且默认使用通用换行符,这意味着即使原始文件的换行符与\n不同,它也会在返回行之前自动转换为\n。当然,这种行为是可以自定义的,但通常它正是你想要的。说到换行符,你能想到副本中可能缺少的换行符吗?
以二进制模式读写
请注意,通过在选项中传递t来打开文件(或者省略它,因为它是默认值),我们是以文本模式打开文件。这意味着文件的内容被视为文本并进行解释。如果您希望向文件写入字节,可以以二进制模式打开它。当您处理不仅包含原始文本的文件时,这是一个常见的要求,比如图像、音频/视频和一般的任何其他专有格式。
要处理二进制模式的文件,只需在打开文件时指定b标志,就像下面的例子一样:
# files/read_write_bin.py
with open('example.bin', 'wb') as fw:
fw.write(b'This is binary data...')
with open('example.bin', 'rb') as f:
print(f.read()) # prints: b'This is binary data...'
在这个例子中,我仍然使用文本作为二进制数据,但它可以是任何你想要的东西。你可以看到它被视为二进制数据的事实,因为在输出中你得到了b'This ...'前缀。
防止覆盖现有文件
Python 让我们有能力打开文件进行写入。通过使用w标志,我们打开一个文件并截断其内容。这意味着文件被覆盖为一个空文件,并且原始内容丢失。如果您希望仅在文件不存在时打开文件进行写入,可以改用x标志,如下例所示:
# files/write_not_exists.py
with open('write_x.txt', 'x') as fw:
fw.write('Writing line 1') # this succeeds
with open('write_x.txt', 'x') as fw:
fw.write('Writing line 2') # this fails
如果您运行前面的片段,您将在您的目录中找到一个名为write_x.txt的文件,其中只包含一行文本。实际上,片段的第二部分未能执行。这是我在控制台上得到的输出:
$ python write_not_exists.py
Traceback (most recent call last):
File "write_not_exists.py", line 6, in <module>
with open('write_x.txt', 'x') as fw:
FileExistsError: [Errno 17] File exists: 'write_x.txt'
检查文件和目录是否存在
如果您想确保文件或目录存在(或不存在),则需要使用os.path模块。让我们看一个小例子:
# files/existence.py
import os
filename = 'fear.txt'
path = os.path.dirname(os.path.abspath(filename))
print(os.path.isfile(filename)) # True
print(os.path.isdir(path)) # True
print(path) # /Users/fab/srv/lpp/ch5/files
前面的片段非常有趣。在使用相对引用声明文件名之后(因为缺少路径信息),我们使用abspath来计算文件的完整绝对路径。然后,我们通过调用dirname来获取路径信息(删除末尾的文件名)。结果如您所见,打印在最后一行。还要注意我们如何通过调用isfile和isdir来检查文件和目录的存在。在os.path模块中,您可以找到处理路径名所需的所有函数。
如果您需要以不同的方式处理路径,可以查看pathlib。虽然os.path使用字符串,但pathlib提供了表示适合不同操作系统的文件系统路径的类。这超出了本章的范围,但如果您感兴趣,请查看 PEP428(www.python.org/dev/peps/pep-0428/)及其在标准库中的页面。
操作文件和目录
让我们看一些关于如何操作文件和目录的快速示例。第一个示例操作内容:
# files/manipulation.py
from collections import Counter
from string import ascii_letters
chars = ascii_letters + ' '
def sanitize(s, chars):
return ''.join(c for c in s if c in chars)
def reverse(s):
return s[::-1]
with open('fear.txt') as stream:
lines = [line.rstrip() for line in stream]
with open('raef.txt', 'w') as stream:
stream.write('\n'.join(reverse(line) for line in lines))
# now we can calculate some statistics
lines = [sanitize(line, chars) for line in lines]
whole = ' '.join(lines)
cnt = Counter(whole.lower().split())
print(cnt.most_common(3))
前面的例子定义了两个函数:sanitize和reverse。它们是简单的函数,其目的是从字符串中删除任何不是字母或空格的内容,并分别生成字符串的反转副本。
我们打开fear.txt,并将其内容读入列表。然后我们创建一个新文件raef.txt,其中将包含原始文件的水平镜像版本。我们使用join在新行字符上写入lines的所有内容。也许更有趣的是最后的部分。首先,我们通过列表推导将lines重新分配为其经过清理的版本。然后我们将它们放在whole字符串中,最后将结果传递给Counter。请注意,我们拆分字符串并将其转换为小写。这样,每个单词都将被正确计数,而不管其大小写,而且由于split,我们不需要担心任何额外的空格。当我们打印出最常见的三个单词时,我们意识到真正的 Thich Nhat Hanh 的重点在于其他人,因为we是文本中最常见的单词:
$ python manipulation.py
[('we', 17), ('the', 13), ('were', 7)]
现在让我们看一个更加面向磁盘操作的操作示例,其中我们使用shutil模块:
# files/ops_create.py
import shutil
import os
BASE_PATH = 'ops_example' # this will be our base path
os.mkdir(BASE_PATH)
path_b = os.path.join(BASE_PATH, 'A', 'B')
path_c = os.path.join(BASE_PATH, 'A', 'C')
path_d = os.path.join(BASE_PATH, 'A', 'D')
os.makedirs(path_b)
os.makedirs(path_c)
for filename in ('ex1.txt', 'ex2.txt', 'ex3.txt'):
with open(os.path.join(path_b, filename), 'w') as stream:
stream.write(f'Some content here in {filename}\n')
shutil.move(path_b, path_d)
shutil.move(
os.path.join(path_d, 'ex1.txt'),
os.path.join(path_d, 'ex1d.txt')
)
在前面的代码中,我们首先声明一个基本路径,该路径将安全地包含我们将要创建的所有文件和文件夹。然后我们使用makedirs创建两个目录:ops_example/A/B和ops_example/A/C。(您能想到使用map来创建这两个目录的方法吗?)。
我们使用os.path.join来连接目录名称,因为使用/会使代码专门在目录分隔符为/的平台上运行,但是在具有不同分隔符的平台上,代码将失败。让我们委托给join来确定哪个是适当的分隔符的任务。
在创建目录之后,在一个简单的for循环中,我们放入一些代码,创建目录B中的三个文件。然后,我们将文件夹B及其内容移动到另一个名称D,最后,我们将ex1.txt重命名为ex1d.txt。如果你打开那个文件,你会看到它仍然包含来自for循环的原始文本。在结果上调用tree会产生以下结果:
$ tree ops_example/
ops_example/
└── A
├── C
└── D
├── ex1d.txt
├── ex2.txt
└── ex3.txt
操作路径名
让我们通过一个简单的例子来更多地探索os.path的能力:
# files/paths.py
import os
filename = 'fear.txt'
path = os.path.abspath(filename)
print(path)
print(os.path.basename(path))
print(os.path.dirname(path))
print(os.path.splitext(path))
print(os.path.split(path))
readme_path = os.path.join(
os.path.dirname(path), '..', '..', 'README.rst')
print(readme_path)
print(os.path.normpath(readme_path))
阅读结果可能是对这个简单例子的足够好的解释:
/Users/fab/srv/lpp/ch5/files/fear.txt # path
fear.txt # basename
/Users/fab/srv/lpp/ch5/files # dirname
('/Users/fab/srv/lpp/ch5/files/fear', '.txt') # splitext
('/Users/fab/srv/lpp/ch5/files', 'fear.txt') # split
/Users/fab/srv/lpp/ch5/files/../../README.rst # readme_path
/Users/fab/srv/lpp/README.rst # normalized
临时文件和目录
有时,在运行一些代码时,能够创建临时目录或文件是非常有用的。例如,在编写影响磁盘的测试时,你可以使用临时文件和目录来运行你的逻辑并断言它是正确的,并确保在测试运行结束时,测试文件夹中没有任何剩余物。让我们看看在 Python 中如何做到这一点:
# files/tmp.py
import os
from tempfile import NamedTemporaryFile, TemporaryDirectory
with TemporaryDirectory(dir='.') as td:
print('Temp directory:', td)
with NamedTemporaryFile(dir=td) as t:
name = t.name
print(os.path.abspath(name))
上面的例子非常简单:我们在当前目录(.)中创建一个临时目录,并在其中创建一个命名的临时文件。我们打印文件名,以及它的完整路径:
$ python tmp.py
Temp directory: ./tmpwa9bdwgo
/Users/fab/srv/lpp/ch5/files/tmpwa9bdwgo/tmp3d45hm46
运行这个脚本将每次产生不同的结果。毕竟,我们在这里创建的是一个临时的随机名称,对吧?
目录内容
使用 Python,你也可以检查目录的内容。我将向你展示两种方法:
# files/listing.py
import os
with os.scandir('.') as it:
for entry in it:
print(
entry.name, entry.path,
'File' if entry.is_file() else 'Folder'
)
这个片段使用os.scandir,在当前目录上调用。我们对结果进行迭代,每个结果都是os.DirEntry的一个实例,这是一个暴露有用属性和方法的好类。在代码中,我们访问了其中的一部分:name、path和is_file()。运行代码会产生以下结果(为了简洁起见,我省略了一些结果):
$ python listing.py
fixed_amount.py ./fixed_amount.py File
existence.py ./existence.py File
...
ops_example ./ops_example Folder
...
扫描目录树的更强大的方法是由os.walk提供的。让我们看一个例子:
# files/walking.py
import os
for root, dirs, files in os.walk('.'):
print(os.path.abspath(root))
if dirs:
print('Directories:')
for dir_ in dirs:
print(dir_)
print()
if files:
print('Files:')
for filename in files:
print(filename)
print()
运行上面的片段将产生当前所有文件和目录的列表,并且对每个子目录都会执行相同的操作。
文件和目录压缩
在我们离开这一部分之前,让我给你举个例子,说明如何创建一个压缩文件。在本书的源代码中,我有两个例子:一个创建一个 ZIP 文件,而另一个创建一个tar.gz文件。Python 允许你以几种不同的方式和格式创建压缩文件。在这里,我将向你展示如何创建最常见的一种,ZIP:
# files/compression/zip.py
from zipfile import ZipFile
with ZipFile('example.zip', 'w') as zp:
zp.write('content1.txt')
zp.write('content2.txt')
zp.write('subfolder/content3.txt')
zp.write('subfolder/content4.txt')
with ZipFile('example.zip') as zp:
zp.extract('content1.txt', 'extract_zip')
zp.extract('subfolder/content3.txt', 'extract_zip')
在上面的代码中,我们导入ZipFile,然后在上下文管理器中,我们向其中写入四个虚拟上下文文件(其中两个在子文件夹中,以显示 ZIP 保留了完整路径)。之后,作为一个例子,我们打开压缩文件并从中提取一些文件到extract_zip目录中。如果你有兴趣了解更多关于数据压缩的知识,一定要查看标准库中的数据压缩和归档部分(docs.python.org/3.7/library/archiving.html),在那里你将能够学习到关于这个主题的所有知识。
数据交换格式
现代软件架构倾向于将应用程序分成几个组件。无论你是否采用面向服务的架构范式,或者将其推进到微服务领域,这些组件都必须交换数据。但即使你正在编写一个单体应用程序,其代码库包含在一个项目中,也有可能你必须与 API、其他程序交换数据,或者简单地处理网站前端和后端部分之间的数据流,这些部分很可能不会说相同的语言。
选择正确的格式来交换信息至关重要。特定于语言的格式的优势在于,语言本身很可能会为您提供所有工具,使序列化和反序列化变得轻而易举。然而,您将失去与使用不同版本的相同语言或完全不同语言编写的其他组件进行交流的能力。无论未来如何,只有在给定情况下这是唯一可能的选择时,才应选择特定于语言的格式。
一个更好的方法是选择一种与语言无关的格式,可以被所有(或至少大多数)语言使用。在我领导的团队中,我们有来自英格兰、波兰、南非、西班牙、希腊、印度、意大利等国家的人。我们都说英语,因此无论我们的母语是什么,我们都可以彼此理解(嗯...大多数情况下!)。
在软件世界中,一些流行的格式近年来已成为事实上的标准。最著名的可能是 XML、YAML 和 JSON。Python 标准库包括xml和json模块,而在 PyPI(docs.python.org/3.7/library/archiving.html)上,您可以找到一些不同的包来处理 YAML。
在 Python 环境中,JSON 可能是最常用的。它胜过其他两种格式,因为它是标准库的一部分,而且它很简单。如果您曾经使用过 XML,您就知道它可能是多么可怕。
使用 JSON
JSON是JavaScript 对象表示法的缩写,它是 JavaScript 语言的一个子集。它已经存在了将近二十年,因此它是众所周知的,并且基本上被所有语言广泛采用,尽管它实际上是与语言无关的。您可以在其网站上阅读有关它的所有信息(www.json.org/),但我现在将为您快速介绍一下。
JSON 基于两种结构:名称/值对的集合和值的有序列表。您会立即意识到这两个对象分别映射到 Python 中的字典和列表数据类型。作为数据类型,它提供字符串、数字、对象和值,例如 true、false 和 null。让我们看一个快速的示例来开始:
# json_examples/json_basic.py
import sys
import json
data = {
'big_number': 2 ** 3141,
'max_float': sys.float_info.max,
'a_list': [2, 3, 5, 7],
}
json_data = json.dumps(data)
data_out = json.loads(json_data)
assert data == data_out # json and back, data matches
我们首先导入sys和json模块。然后我们创建一个包含一些数字和一个列表的简单字典。我想测试使用非常大的数字进行序列化和反序列化,包括int和float,所以我放入了2³¹⁴¹和我的系统可以处理的最大浮点数。
我们使用json.dumps进行序列化,它将数据转换为 JSON 格式的字符串。然后将该数据输入到json.loads中,它执行相反的操作:从 JSON 格式的字符串中,将数据重构为 Python。在最后一行,我们确保原始数据和通过 JSON 进行序列化/反序列化的结果匹配。
让我们在下一个示例中看看,如果我们打印 JSON 数据会是什么样子:
# json_examples/json_basic.py
import json
info = {
'full_name': 'Sherlock Holmes',
'address': {
'street': '221B Baker St',
'zip': 'NW1 6XE',
'city': 'London',
'country': 'UK',
}
}
print(json.dumps(info, indent=2, sort_keys=True))
在这个示例中,我们创建了一个包含福尔摩斯的数据的字典。如果您和我一样是福尔摩斯的粉丝,并且在伦敦,您会在那个地址找到他的博物馆(我建议您去参观,它虽小但非常好)。
请注意我们如何调用json.dumps。我们已经告诉它用两个空格缩进,并按字母顺序排序键。结果是这样的:
$ python json_basic.py
{
"address": {
"city": "London",
"country": "UK",
"street": "221B Baker St",
"zip": "NW1 6XE"
},
"full_name": "Sherlock Holmes"
}
与 Python 的相似性非常大。唯一的区别是,如果您在字典的最后一个元素上放置逗号,就像我在 Python 中所做的那样(因为这是习惯的做法),JSON 会抱怨。
让我给你展示一些有趣的东西:
# json_examples/json_tuple.py
import json
data_in = {
'a_tuple': (1, 2, 3, 4, 5),
}
json_data = json.dumps(data_in)
print(json_data) # {"a_tuple": [1, 2, 3, 4, 5]}
data_out = json.loads(json_data)
print(data_out) # {'a_tuple': [1, 2, 3, 4, 5]}
在这个例子中,我们放了一个元组,而不是一个列表。有趣的是,从概念上讲,元组也是一个有序的项目列表。它没有列表的灵活性,但从 JSON 的角度来看,它仍然被认为是相同的。因此,正如你可以从第一个print中看到的那样,在 JSON 中,元组被转换为列表。因此,丢失了它是元组的信息,当反序列化发生时,在data_out中,a_tuple实际上是一个列表。在处理数据时,重要的是要记住这一点,因为经历一个涉及只包括你可以使用的数据结构子集的格式转换过程意味着会有信息丢失。在这种情况下,我们丢失了类型(元组与列表)的信息。
这实际上是一个常见的问题。例如,你不能将所有的 Python 对象序列化为 JSON,因为不清楚 JSON 是否应该还原它(或者如何还原)。想想datetime,例如。该类的实例是 JSON 不允许序列化的 Python 对象。如果我们将其转换为字符串,比如2018-03-04T12:00:30Z,这是带有时间和时区信息的日期的 ISO 8601 表示,当进行反序列化时,JSON 应该怎么做?它应该说这实际上可以反序列化为一个 datetime 对象,所以最好这样做,还是应该简单地将其视为字符串并保持原样?那些可以以多种方式解释的数据类型呢?
答案是,在处理数据交换时,我们经常需要在将对象序列化为 JSON 之前将其转换为更简单的格式。这样,当我们对它们进行反序列化时,我们将知道如何正确地重构它们。
然而,在某些情况下,主要是为了内部使用,能够序列化自定义对象是有用的,因此,只是为了好玩,我将向您展示两个例子:复数(因为我喜欢数学)和datetime对象。
使用 JSON 进行自定义编码/解码
在 JSON 世界中,我们可以将编码/解码等术语视为序列化/反序列化的同义词。它们基本上都意味着转换为 JSON,然后再从 JSON 转换回来。在下面的例子中,我将向您展示如何对复数进行编码:
# json_examples/json_cplx.py
import json
class ComplexEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, complex):
return {
'_meta': '_complex',
'num': [obj.real, obj.imag],
}
return json.JSONEncoder.default(self, obj)
data = {
'an_int': 42,
'a_float': 3.14159265,
'a_complex': 3 + 4j,
}
json_data = json.dumps(data, cls=ComplexEncoder)
print(json_data)
def object_hook(obj):
try:
if obj['_meta'] == '_complex':
return complex(*obj['num'])
except (KeyError, TypeError):
return obj
data_out = json.loads(json_data, object_hook=object_hook)
print(data_out)
首先,我们定义一个ComplexEncoder类,它需要实现default方法。这个方法被传递给所有需要被序列化的对象,一个接一个地,在obj变量中。在某个时候,obj将是我们的复数3+4j。当这种情况发生时,我们返回一个带有一些自定义元信息的字典,以及一个包含实部和虚部的列表。这就是我们需要做的,以避免丢失复数的信息。
然后我们调用json.dumps,但这次我们使用cls参数来指定我们的自定义编码器。结果被打印出来:
{"an_int": 42, "a_float": 3.14159265, "a_complex": {"_meta": "_complex", "num": [3.0, 4.0]}}
一半的工作已经完成。对于反序列化部分,我们本可以编写另一个类,它将继承自JSONDecoder,但是,只是为了好玩,我使用了一种更简单的技术,并使用了一个小函数:object_hook。
在object_hook的主体内,我们找到另一个try块。重要的部分是try块本身内的两行。该函数接收一个对象(注意,只有当obj是一个字典时才调用该函数),如果元数据与我们的复数约定匹配,我们将实部和虚部传递给complex函数。try/except块只是为了防止格式不正确的 JSON 破坏程序(如果发生这种情况,我们只需返回对象本身)。
最后一个打印返回:
{'an_int': 42, 'a_float': 3.14159265, 'a_complex': (3+4j)}
你可以看到a_complex已经被正确反序列化。
现在让我们看一个稍微更复杂(没有刻意的意思)的例子:处理datetime对象。我将把代码分成两个块,序列化部分和反序列化部分:
# json_examples/json_datetime.py
import json
from datetime import datetime, timedelta, timezone
now = datetime.now()
now_tz = datetime.now(tz=timezone(timedelta(hours=1)))
class DatetimeEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime):
try:
off = obj.utcoffset().seconds
except AttributeError:
off = None
return {
'_meta': '_datetime',
'data': obj.timetuple()[:6] + (obj.microsecond, ),
'utcoffset': off,
}
return json.JSONEncoder.default(self, obj)
data = {
'an_int': 42,
'a_float': 3.14159265,
'a_datetime': now,
'a_datetime_tz': now_tz,
}
json_data = json.dumps(data, cls=DatetimeEncoder)
print(json_data)
这个例子略微复杂的原因在于 Python 中的datetime对象可以是时区感知的,也可以不是;因此,我们需要更加小心。流程基本上与之前相同,只是处理的是不同的数据类型。我们首先获取当前的日期和时间信息,分别使用不带时区信息的(now)和带时区信息的(now_tz),只是为了确保我们的脚本能够正常工作。然后我们像之前一样定义自定义编码器,并再次实现default方法。在该方法中的重要部分是如何获取时间偏移(off)信息(以秒为单位),以及如何构造返回数据的字典。这次,元数据表示它是datetime信息,然后我们将时间元组的前六个项目(年、月、日、小时、分钟和秒)以及微秒保存在data键中,然后是偏移。您能看出data的值是元组的连接吗?如果您能,干得好!
当我们有了自定义的编码器后,我们继续创建一些数据,然后进行序列化。print语句返回(在我进行了一些美化之后):
{
"a_datetime": {
"_meta": "_datetime",
"data": [2018, 3, 18, 17, 57, 27, 438792],
"utcoffset": null
},
"a_datetime_tz": {
"_meta": "_datetime",
"data": [2018, 3, 18, 18, 57, 27, 438810],
"utcoffset": 3600
},
"a_float": 3.14159265,
"an_int": 42
}
有趣的是,我们发现None被翻译为null,这是它的 JavaScript 等效项。此外,我们可以看到我们的数据似乎已经被正确编码。让我们继续脚本的第二部分:
# json_examples/json_datetime.py
def object_hook(obj):
try:
if obj['_meta'] == '_datetime':
if obj['utcoffset'] is None:
tz = None
else:
tz = timezone(timedelta(seconds=obj['utcoffset']))
return datetime(*obj['data'], tzinfo=tz)
except (KeyError, TypeError):
return obj
data_out = json.loads(json_data, object_hook=object_hook)
再次,我们首先验证元数据告诉我们这是一个datetime,然后我们继续获取时区信息。一旦我们有了时区信息,我们将 7 元组(使用*来解包其值)和时区信息传递给datetime调用,得到我们的原始对象。让我们通过打印data_out来验证一下:
{
'a_datetime': datetime.datetime(2018, 3, 18, 18, 1, 46, 54693),
'a_datetime_tz': datetime.datetime(
2018, 3, 18, 19, 1, 46, 54711,
tzinfo=datetime.timezone(datetime.timedelta(seconds=3600))),
'a_float': 3.14159265,
'an_int': 42
}
正如您所看到的,我们正确地得到了所有的东西。作为一个练习,我想挑战您编写相同逻辑,但针对一个date对象,这应该更简单。
在我们继续下一个主题之前,我想提个小小的警告。也许这有违直觉,但是处理datetime对象可能是最棘手的事情之一,所以,尽管我非常确定这段代码正在按照预期的方式运行,我还是想强调我只进行了非常轻微的测试。所以,如果您打算使用它,请务必进行彻底的测试。测试不同的时区,测试夏令时的开启和关闭,测试纪元前的日期等等。您可能会发现,本节中的代码需要一些修改才能适应您的情况。
让我们现在转到下一个主题,IO。
IO、流和请求
IO代表输入/输出,它广泛地指的是计算机与外部世界之间的通信。有几种不同类型的 IO,这章节的范围之外,无法解释所有,但我仍然想给您提供一些例子。
使用内存流
第一个将向您展示io.StringIO类,这是一个用于文本 IO 的内存流。而第二个则会逃离我们计算机的局限,向您展示如何执行 HTTP 请求。让我们看看第一个例子:
# io_examples/string_io.py
import io
stream = io.StringIO()
stream.write('Learning Python Programming.\n')
print('Become a Python ninja!', file=stream)
contents = stream.getvalue()
print(contents)
stream.close()
在前面的代码片段中,我们从标准库中导入了io模块。这是一个非常有趣的模块,其中包含许多与流和 IO 相关的工具。其中之一是StringIO,它是一个内存缓冲区,我们将在其中使用两种不同的方法写入两个句子,就像我们在本章的第一个例子中处理文件一样。我们既可以调用StringIO.write,也可以使用print,并告诉它将数据传送到我们的流中。
通过调用getvalue,我们可以获取流的内容(并打印它),最后我们关闭它。调用close会立即丢弃文本缓冲区。
有一种更加优雅的方法来编写前面的代码(在您查看之前,您能猜到吗?):
# io_examples/string_io.py
with io.StringIO() as stream:
stream.write('Learning Python Programming.\n')
print('Become a Python ninja!', file=stream)
contents = stream.getvalue()
print(contents)
是的,这又是一个上下文管理器。就像open一样,io.StringIO在上下文管理器块内工作得很好。注意与open的相似之处:在这种情况下,我们也不需要手动关闭流。
内存对象在许多情况下都很有用。内存比磁盘快得多,对于少量数据来说,可能是完美的选择。
运行脚本时,输出为:
$ python string_io.py
Learning Python Programming.
Become a Python ninja!
进行 HTTP 请求
现在让我们探索一些关于 HTTP 请求的例子。我将使用requests库进行这些示例,你可以使用pip进行安装。我们将对httpbin.org API 执行 HTTP 请求,有趣的是,这个 API 是由 Kenneth Reitz 开发的,他是requests库的创建者。这个库在全世界范围内被广泛采用:
import requests
urls = {
'get': 'https://httpbin.org/get?title=learn+python+programming',
'headers': 'https://httpbin.org/headers',
'ip': 'https://httpbin.org/ip',
'now': 'https://now.httpbin.org/',
'user-agent': 'https://httpbin.org/user-agent',
'UUID': 'https://httpbin.org/uuid',
}
def get_content(title, url):
resp = requests.get(url)
print(f'Response for {title}')
print(resp.json())
for title, url in urls.items():
get_content(title, url)
print('-' * 40)
前面的片段应该很容易理解。我声明了一个 URL 字典,我想要执行“请求”。我已经将执行请求的代码封装到一个小函数中:get_content。正如你所看到的,我们非常简单地执行了一个 GET 请求(使用requests.get),并打印了响应的标题和 JSON 解码版本的正文。让我多说一句关于最后一点。
当我们对网站或 API 执行请求时,我们会得到一个响应对象,这个对象非常简单,就是服务器返回的内容。所有来自httpbin.org的响应正文都是 JSON 编码的,所以我们不需要通过resp.text获取正文然后手动解码,而是通过响应对象上的json方法将两者结合起来。requests包变得如此广泛被采用有很多原因,其中一个绝对是它的易用性。
现在,当你在应用程序中执行请求时,你会希望有一个更加健壮的方法来处理错误等,但在本章中,一个简单的例子就足够了。
回到我们的代码,最后,我们运行一个for循环并获取所有的 URL。当你运行它时,你会在控制台上看到每个调用的结果,就像这样(为了简洁起见,进行了美化和修剪):
$ python reqs.py
Response for get
{
"args": {
"title": "learn python programming"
},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Connection": "close",
"Host": "httpbin.org",
"User-Agent": "python-requests/2.19.0"
},
"origin": "82.47.175.158",
"url": "https://httpbin.org/get?title=learn+python+programming"
}
... rest of the output omitted ...
请注意,你可能会在版本号和 IP 方面得到略有不同的输出,这是正常的。现在,GET 只是 HTTP 动词中的一个,它绝对是最常用的。第二个是无处不在的 POST,当你需要向服务器发送数据时,就会发起这种类型的请求。每当你在网上提交表单时,你基本上就是在发起一个 POST 请求。所以,让我们尝试以编程方式进行一个:
# io_examples/reqs_post.py
import requests
url = 'https://httpbin.org/post'
data = dict(title='Learn Python Programming')
resp = requests.post(url, data=data)
print('Response for POST')
print(resp.json())
前面的代码与我们之前看到的代码非常相似,只是这一次我们不调用get,而是调用post,因为我们想要发送一些数据,我们在调用中指定了这一点。requests库提供的远不止这些,它因其提供的美丽 API 而受到社区的赞扬。这是一个我鼓励你去了解和探索的项目,因为你最终会一直使用它。
运行上一个脚本(并对输出进行一些美化处理)得到了以下结果:
$ python reqs_post.py
Response for POST
{ 'args': {},
'data': '',
'files': {},
'form': {'title': 'Learn Python Programming'},
'headers': { 'Accept': '*/*',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'close',
'Content-Length': '30',
'Content-Type': 'application/x-www-form-urlencoded',
'Host': 'httpbin.org',
'User-Agent': 'python-requests/2.7.0 CPython/3.7.0b2 '
'Darwin/17.4.0'},
'json': None,
'origin': '82.45.123.178',
'url': 'https://httpbin.org/post'}
请注意,现在标头已经不同了,我们在响应正文的form键值对中找到了我们发送的数据。
我希望这些简短的例子足以让你开始,特别是对于请求部分。网络每天都在变化,所以值得学习基础知识,然后不时地进行复习。
现在让我们继续讨论本章的最后一个主题:以不同格式将数据持久化到磁盘上。
将数据持久化到磁盘
在本章的最后一节中,我们将探讨如何以三种不同的格式将数据持久化到磁盘上。我们将探索pickle、shelve,以及一个涉及使用 SQLAlchemy 访问数据库的简短示例,SQLAlchemy 是 Python 生态系统中最广泛采用的 ORM 库。
使用 pickle 对数据进行序列化
Python 标准库中的pickle模块提供了将 Python 对象转换为字节流以及反之的工具。尽管pickle和json公开的 API 存在部分重叠,但两者是完全不同的。正如我们在本章中之前看到的,JSON 是一种文本格式,人类可读,与语言无关,并且仅支持 Python 数据类型的受限子集。另一方面,pickle模块不是人类可读的,转换为字节,是特定于 Python 的,并且由于 Python 的出色内省能力,它支持大量的数据类型。
尽管存在这些差异,但当您考虑使用其中一个时,您应该知道最重要的问题是pickle存在的安全威胁。从不受信任的来源unpickling错误或恶意数据可能非常危险,因此如果您决定在应用程序中使用它,您需要格外小心。
也就是说,让我们通过一个简单的例子来看看它的运作方式:
# persistence/pickler.py
import pickle
from dataclasses import dataclass
@dataclass
class Person:
first_name: str
last_name: str
id: int
def greet(self):
print(f'Hi, I am {self.first_name} {self.last_name}'
f' and my ID is {self.id}'
)
people = [
Person('Obi-Wan', 'Kenobi', 123),
Person('Anakin', 'Skywalker', 456),
]
# save data in binary format to a file
with open('data.pickle', 'wb') as stream:
pickle.dump(people, stream)
# load data from a file
with open('data.pickle', 'rb') as stream:
peeps = pickle.load(stream)
for person in peeps:
person.greet()
在前面的例子中,我们使用dataclass装饰器创建了一个Person类(我们将在后面的章节中介绍如何做到这一点)。我写这个数据类的例子的唯一原因是向您展示pickle如何毫不费力地处理它,而无需我们为更简单的数据类型做任何事情。
该类有三个属性:first_name,last_name和id。它还公开了一个greet方法,它只是打印一个带有数据的问候消息。
我们创建了一个实例列表,然后将其保存到文件中。为此,我们使用pickle.dump,将要pickled的内容和要写入的流传递给它。就在那之后,我们从同一文件中读取,并通过使用pickle.load将整个流内容转换回 Python。为了确保对象已正确转换,我们在两个对象上都调用了greet方法。结果如下:
$ python pickler.py
Hi, I am Obi-Wan Kenobi and my ID is 123
Hi, I am Anakin Skywalker and my ID is 456
pickle模块还允许您通过dumps和loads函数(注意两个名称末尾的s)将对象转换为(和从)字节对象。在日常应用中,当我们需要持久保存不应与另一个应用程序交换的 Python 数据时,通常会使用pickle。我最近遇到的一个例子是flask插件中的会话管理,它在将会话对象发送到Redis之前对其进行pickle。但实际上,您不太可能经常使用这个库。
另一个可能使用得更少但在资源短缺时非常有用的工具是shelve。
使用shelve保存数据
shelf是一种持久的类似字典的对象。它的美妙之处在于,您保存到shelf中的值可以是您可以pickle的任何对象,因此您不像使用数据库时那样受限。尽管有趣且有用,但在实践中shelve模块很少使用。为了完整起见,让我们快速看一下它的工作原理:
# persistence/shelf.py
import shelve
class Person:
def __init__(self, name, id):
self.name = name
self.id = id
with shelve.open('shelf1.shelve') as db:
db['obi1'] = Person('Obi-Wan', 123)
db['ani'] = Person('Anakin', 456)
db['a_list'] = [2, 3, 5]
db['delete_me'] = 'we will have to delete this one...'
print(list(db.keys())) # ['ani', 'a_list', 'delete_me', 'obi1']
del db['delete_me'] # gone!
print(list(db.keys())) # ['ani', 'a_list', 'obi1']
print('delete_me' in db) # False
print('ani' in db) # True
a_list = db['a_list']
a_list.append(7)
db['a_list'] = a_list
print(db['a_list']) # [2, 3, 5, 7]
除了围绕它的布线和样板之外,前面的例子类似于使用字典进行练习。我们创建一个简单的Person类,然后在上下文管理器中打开一个shelve文件。如您所见,我们使用字典语法存储四个对象:两个Person实例,一个列表和一个字符串。如果我们打印keys,我们会得到一个包含我们使用的四个键的列表。打印完后,我们从shelf中删除(恰当命名的)delete_me键/值对。再次打印keys会显示删除成功删除。然后我们测试了一对键的成员资格,最后,我们将数字7附加到a_list。请注意,我们必须从shelf中提取列表,修改它,然后再次保存它。
如果不希望出现这种行为,我们可以采取一些措施:
# persistence/shelf.py
with shelve.open('shelf2.shelve', writeback=True) as db:
db['a_list'] = [11, 13, 17]
db['a_list'].append(19) # in-place append!
print(db['a_list']) # [11, 13, 17, 19]
通过以writeback=True打开架子,我们启用了writeback功能,这使我们可以简单地将a_list追加到其中,就好像它实际上是常规字典中的一个值。这个功能默认情况下不激活的原因是,它会以内存消耗和更慢的架子关闭为代价。
现在我们已经向与数据持久性相关的标准库模块致敬,让我们来看看 Python 生态系统中最广泛采用的 ORM:SQLAlchemy。
将数据保存到数据库
对于这个例子,我们将使用内存数据库,这将使事情对我们来说更简单。在书的源代码中,我留下了一些注释,以向您展示如何生成一个 SQLite 文件,所以我希望您也会探索这个选项。
您可以在sqlitebrowser.org找到一个免费的 SQLite 数据库浏览器。如果您对此不满意,您将能够找到各种工具,有些免费,有些不免费,可以用来访问和操作数据库文件。
在我们深入代码之前,让我简要介绍一下关系数据库的概念。
关系数据库是一种允许您按照 1969 年 Edgar F. Codd 发明的关系模型保存数据的数据库。在这个模型中,数据存储在一个或多个表中。每个表都有行(也称为记录或元组),每个行代表表中的一个条目。表还有列(也称为属性),每个列代表记录的一个属性。每个记录通过一个唯一键来标识,更常见的是主键,它是表中一个或多个列的联合。举个例子:想象一个名为Users的表,具有列id、username、password、name和surname。这样的表非常适合包含我们系统的用户。每一行代表一个不同的用户。例如,具有值3、gianchub、my_wonderful_pwd、Fabrizio和Romano的行将代表我在系统中的用户。
这个模型被称为关系,是因为您可以在表之间建立关系。例如,如果您向我们虚构的数据库添加一个名为PhoneNumbers的表,您可以向其中插入电话号码,然后通过关系建立哪个电话号码属于哪个用户。
为了查询关系数据库,我们需要一种特殊的语言。主要标准称为SQL,代表结构化查询语言。它源于一种称为关系代数的东西,这是一组用于模拟按照关系模型存储的数据并对其进行查询的非常好的代数。您通常可以执行的最常见操作包括对行或列进行过滤,连接表,根据某些标准对结果进行聚合等。举个英语的例子,我们想要查询我们想象中的数据库:获取所有用户名以“m”开头且最多有一个电话号码的用户(用户名、名字、姓氏)。在这个查询中,我们要求获取User表中的一部分列。我们通过用户名以字母m开头进行过滤,并且进一步筛选出最多有一个电话号码的用户。
在我还是帕多瓦的学生时,我花了整个学期学习关系代数语义和标准 SQL(还有其他东西)。如果不是我在考试当天遇到了一次重大的自行车事故,我会说这是我准备过的最有趣的考试之一。
现在,每个数据库都有自己的 SQL风味。它们都在某种程度上遵守标准,但没有一个完全遵守,并且它们在某些方面都不同。这在现代软件开发中是一个问题。如果我们的应用程序包含 SQL 代码,那么如果我们决定使用不同的数据库引擎,或者可能是同一引擎的不同版本,很可能我们会发现我们的 SQL 代码需要修改。
这可能会很痛苦,特别是因为 SQL 查询可能会变得非常复杂。为了稍微减轻这种痛苦,计算机科学家(感谢他们)已经创建了将特定语言的对象映射到关系数据库表的代码。毫不奇怪,这种工具的名称是对象关系映射(ORM)。
在现代应用程序开发中,通常会通过使用 ORM 来开始与数据库交互,如果你发现自己无法通过 ORM 执行需要执行的查询,那么你将会直接使用 SQL。这是在完全没有 SQL 和不使用 ORM 之间的一个很好的折衷,这最终意味着专门化与数据库交互的代码,具有前面提到的缺点。
在这一部分,我想展示一个利用 SQLAlchemy 的例子,这是最流行的 Python ORM。我们将定义两个模型(Person和Address),它们分别映射到一个表,然后我们将填充数据库并对其执行一些查询。
让我们从模型声明开始:
# persistence/alchemy_models.py
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import (
Column, Integer, String, ForeignKey, create_engine)
from sqlalchemy.orm import relationship
一开始,我们导入一些函数和类型。然后我们需要做的第一件事是创建一个引擎。这个引擎告诉 SQLAlchemy 我们选择的数据库类型是什么:
# persistence/alchemy_models.py
engine = create_engine('sqlite:///:memory:')
Base = declarative_base()
class Person(Base):
__tablename__ = 'person'
id = Column(Integer, primary_key=True)
name = Column(String)
age = Column(Integer)
addresses = relationship(
'Address',
back_populates='person',
order_by='Address.email',
cascade='all, delete-orphan'
)
def __repr__(self):
return f'{self.name}(id={self.id})'
class Address(Base):
__tablename__ = 'address'
id = Column(Integer, primary_key=True)
email = Column(String)
person_id = Column(ForeignKey('person.id'))
person = relationship('Person', back_populates='addresses')
def __str__(self):
return self.email
__repr__ = __str__
Base.metadata.create_all(engine)
然后每个模型都继承自Base表,在这个例子中,它由declarative_base()返回的默认值组成。我们定义了Person,它映射到一个名为person的表,并公开id、name和age属性。我们还声明了与Address模型的关系,通过声明访问addresses属性将获取与我们正在处理的特定Person实例相关的address表中的所有条目。cascade选项影响创建和删除的工作方式,但这是一个更高级的概念,所以我建议你现在先略过它,也许以后再进行更深入的调查。
我们声明的最后一件事是__repr__方法,它为我们提供了对象的官方字符串表示。这应该是一个可以用来完全重建对象的表示,但在这个例子中,我只是用它来提供一些输出。Python 将repr(obj)重定向到对obj.__repr__()的调用。
我们还声明了Address模型,其中包含电子邮件地址,以及它们所属的人的引用。你可以看到person_id和person属性都是用来设置Address和Person实例之间关系的。注意我如何在Address上声明了__str__方法,然后给它分配了一个别名,叫做__repr__。这意味着在Address对象上调用repr和str最终将导致调用__str__方法。这在 Python 中是一种常见的技术,所以我抓住机会在这里向你展示。
在最后一行,我们告诉引擎根据我们的模型在数据库中创建表。
对这段代码的更深入理解需要比我能承受的空间更多,所以我鼓励你阅读有关数据库管理系统(DBMS)、SQL、关系代数和 SQLAlchemy 的资料。
现在我们有了我们的模型,让我们用它们来保存一些数据!
让我们看看下面的例子:
# persistence/alchemy.py
from alchemy_models import Person, Address, engine
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=engine)
session = Session()
首先我们创建session,这是我们用来管理数据库的对象。接下来,我们继续创建两个人:
anakin = Person(name='Anakin Skywalker', age=32)
obi1 = Person(name='Obi-Wan Kenobi', age=40)
然后我们向它们两个添加了电子邮件地址,使用了两种不同的技术。一种是将它们分配给一个列表,另一种是简单地将它们附加到列表中:
obi1.addresses = [
Address(email='obi1@example.com'),
Address(email='wanwan@example.com'),
]
anakin.addresses.append(Address(email='ani@example.com'))
anakin.addresses.append(Address(email='evil.dart@example.com'))
anakin.addresses.append(Address(email='vader@example.com'))
我们还没有触及数据库。只有当我们使用会话对象时,它才会真正发生变化:
session.add(anakin)
session.add(obi1)
session.commit()
添加这两个Person实例就足以添加它们的地址(这要归功于级联效应)。调用commit实际上告诉 SQLAlchemy 提交事务并将数据保存到数据库中。事务是提供类似于沙盒的操作,但在数据库上下文中。只要事务尚未提交,我们就可以回滚对数据库所做的任何修改,从而恢复到事务开始之前的状态。SQLAlchemy 提供了更复杂和细粒度的处理事务的方式,你可以在其官方文档中学习,因为这是一个非常高级的主题。
我们现在使用like查询所有以Obi开头的人,这将连接到 SQL*中的LIKE运算符:
obi1 = session.query(Person).filter(
Person.name.like('Obi%')
).first()
print(obi1, obi1.addresses)
我们获取该查询的第一个结果(我们知道我们只有 Obi-Wan),并打印它。然后我们通过使用他的名字进行精确匹配来获取anakin(只是为了向你展示另一种过滤方式):
anakin = session.query(Person).filter(
Person.name=='Anakin Skywalker'
).first()
print(anakin, anakin.addresses)
然后我们捕获了 Anakin 的 ID,并从全局框架中删除了anakin对象:
anakin_id = anakin.id
del anakin
我们这样做是因为我想向你展示如何通过其 ID 获取对象。在我们这样做之前,我们编写了display_info函数,我们将使用它来显示数据库的全部内容(从地址开始获取,以演示如何通过使用 SQLAlchemy 中的关系属性来获取对象):
def display_info():
# get all addresses first
addresses = session.query(Address).all()
# display results
for address in addresses:
print(f'{address.person.name} <{address.email}>')
# display how many objects we have in total
print('people: {}, addresses: {}'.format(
session.query(Person).count(),
session.query(Address).count())
)
display_info函数打印所有地址,以及相应人的姓名,并在最后产生关于数据库中对象数量的最终信息。我们调用该函数,然后获取并删除anakin(想想Darth Vader,你就不会因删除他而感到难过),然后再次显示信息,以验证他确实已经从数据库中消失了。
display_info()
anakin = session.query(Person).get(anakin_id)
session.delete(anakin)
session.commit()
display_info()
所有这些片段一起运行的输出如下(为了方便起见,我已将输出分成四个块,以反映实际产生该输出的四个代码块):
$ python alchemy.py
Obi-Wan Kenobi(id=2) [obi1@example.com, wanwan@example.com]
Anakin Skywalker(id=1) [ani@example.com, evil.dart@example.com, vader@example.com]
Anakin Skywalker <ani@example.com>
Anakin Skywalker <evil.dart@example.com>
Anakin Skywalker <vader@example.com>
Obi-Wan Kenobi <obi1@example.com>
Obi-Wan Kenobi <wanwan@example.com>
people: 2, addresses: 5
Obi-Wan Kenobi <obi1@example.com>
Obi-Wan Kenobi <wanwan@example.com>
people: 1, addresses: 2
从最后两个块中可以看出,删除anakin已经删除了一个Person对象,以及与之关联的三个地址。这是因为在删除anakin时发生了级联。
这结束了我们对数据持久性的简要介绍。这是一个广阔而且有时复杂的领域,我鼓励你尽可能多地探索学习理论。在涉及数据库系统时,缺乏知识或适当的理解可能会带来真正的困扰。
总结
在本章中,我们已经探讨了如何处理文件和目录。我们已经学会了如何打开文件进行读写,以及如何通过使用上下文管理器更优雅地进行操作。我们还探讨了目录:如何递归和非递归地列出它们的内容。我们还学习了路径名,这是访问文件和目录的入口。
然后我们简要地看到了如何创建 ZIP 存档,并提取其内容。该书的源代码还包含了一个不同压缩格式的示例:tar.gz。
我们谈到了数据交换格式,并深入探讨了 JSON。我们乐在其中为特定的 Python 数据类型编写自定义编码器和解码器。
然后我们探索了 IO,包括内存流和 HTTP 请求。
最后,我们看到了如何使用pickle、shelve和 SQLAlchemy ORM 库来持久化数据。
现在你应该对处理文件和数据持久性有了相当好的了解,我希望你会花时间自己更深入地探索这些主题。
从下一章开始,我们将开始探索数据结构和算法,首先从算法设计原则开始。
第六章:算法设计原则
我们为什么要学习算法设计?当然有很多原因,我们学习某事的动机很大程度上取决于我们自己的情况。对于对算法设计感兴趣有重要的职业原因。算法是所有计算的基础。我们认为计算机是硬件,硬盘、内存芯片、处理器等等。然而,如果缺少的是算法,现代技术将不可能存在。
算法的理论基础是图灵机,几十年前就建立了这种机器的数学模型,而数字逻辑电路实际上能够实现这样的机器。图灵机本质上是一个数学模型,它使用预定义的一组规则,将一组输入转换为一组输出。图灵机的第一批实现是机械的,下一代可能会看到数字逻辑电路被量子电路或类似的东西所取代。无论平台如何,算法都起着中心主导的作用。
算法的另一个方面是它对技术创新的影响。显而易见的例子是页面排名搜索算法,谷歌搜索引擎就是基于它的变体。使用这种算法和类似的算法允许研究人员、科学家、技术人员和其他人能够快速地搜索大量信息。这对新研究的速度、新发现的速度以及新的创新技术的发展速度都有巨大影响。
算法的研究也很重要,因为它训练我们对某些问题进行非常具体的思考。它可以通过帮助我们分离问题的组成部分并定义这些组成部分之间的关系,来增强我们的思维和问题解决能力。总之,学习算法有四个主要原因:
-
它们对计算机科学和智能系统至关重要。
-
它们在许多其他领域(计算生物学、经济学、生态学、通信、生态学、物理学等)中都很重要。
-
它们在技术创新中发挥作用。
-
它们改善问题解决和分析思维能力。
算法在最简单的形式中只是一系列操作,一系列指令。它可能只是一个线性结构,形式为做x,然后做y,然后做z,然后完成。然而,为了使事情更有用,我们添加了诸如在 Python 中的if-else语句的子句。在这里,未来的行动取决于某些条件;比如数据结构的状态。我们还添加了操作、迭代,while 和 for 语句。进一步扩展我们的算法素养,我们添加了递归。递归通常可以实现与迭代相同的结果,但它们在根本上是不同的。递归函数调用自身,将相同的函数应用于逐渐减小的输入。任何递归步骤的输入都是前一个递归步骤的输出。
基本上,我们可以说算法由以下四个元素组成:
-
顺序操作
-
基于数据结构状态的操作
-
迭代,重复执行一定次数的操作
-
递归,在一组输入上调用自身
算法设计范式
总的来说,我们可以分辨出三种广泛的算法设计方法。它们是:
-
分而治之
-
贪婪算法
-
动态规划
顾名思义,分而治之范式涉及将问题分解为较小的子问题,然后以某种方式将结果组合起来以获得全局解。这是一种非常常见和自然的问题解决技术,可以说是最常用的算法设计方法。
贪婪算法通常涉及优化和组合问题;经典的例子是将其应用于旅行推销员问题,贪婪方法总是首先选择最近的目的地。这种最短路径策略涉及在希望这将导致全局解决方案的情况下找到局部问题的最佳解决方案。
动态规划方法在我们的子问题重叠时非常有用。这与分治不同。与将问题分解为独立的子问题不同,动态规划中间结果被缓存并可以在后续操作中使用。与分治一样,它使用递归;然而,动态规划允许我们在不同阶段比较结果。对于某些问题,这可能比分治具有性能优势,因为通常更快地从内存中检索先前计算的结果,而不必重新计算它。
递归和回溯
递归特别适用于分治问题;然而,要准确理解发生了什么可能有些困难,因为每次递归调用都会产生其他递归调用。递归函数的核心是两种类型的情况:基本情况,告诉递归何时终止,和递归情况,调用它们所在的函数。一个自然适合递归解决方案的简单问题是计算阶乘。递归阶乘算法定义了两种情况:当n为零时的基本情况,和当n大于零时的递归情况。一个典型的实现如下:
def factorial(n):
#test for a base case
if n==0:
return 1
# make a calculation and a recursive call
f= n*factorial(n-1)
print(f)
return(f)
factorial(4)
这段代码打印出数字 1、2、4、24. 要计算 4 需要进行四次递归调用加上初始的父调用。在每次递归中,方法的变量副本都存储在内存中。一旦方法返回,它就会从内存中移除。以下是我们可以将这个过程可视化的一种方式:

可能并不清楚递归还是迭代对于特定问题更好的解决方案;毕竟它们都重复一系列操作,并且都非常适合分治算法设计。迭代一直运行直到问题完成。递归将问题分解为越来越小的块,然后将结果组合起来。迭代对程序员来说通常更容易,因为控制保持在循环内部,而递归可以更接近数学概念,比如阶乘。递归调用存储在内存中,而迭代不会。这在处理器周期和内存使用之间产生了一种权衡,因此选择使用哪种可能取决于任务是处理器密集型还是内存密集型。以下表格概述了递归和迭代之间的主要区别:
| 递归 | 迭代 |
|---|---|
| 当达到基本情况时终止 | 当满足定义的条件时终止 |
| 每次递归调用都需要内存空间 | 每次迭代都不会存储在内存中 |
| 无限递归会导致堆栈溢出错误 | 无限迭代将在硬件通电时运行 |
| 有些问题自然更适合递归解决方案 | 迭代解决方案可能并不总是显而易见 |
回溯
回溯是一种特别适用于遍历树结构等问题类型的递归形式,在每个节点我们都有多个选项可供选择。随后我们会面临不同的选项,并根据所做的选择系列达到目标状态或死胡同。如果是后者,我们必须回溯到上一个节点并遍历不同的分支。回溯是一种穷举搜索的分治方法。重要的是,回溯会剪枝不能给出结果的分支。
在下面的示例中给出了回溯的一个例子。在这里,我们使用了递归方法来生成给定长度n的给定字符串s的所有可能的排列:
def bitStr(n, s):
if n == 1: return s
return [ digit + bits for digit in bitStr(1,s)for bits in bitStr(n - 1,s)]
print (bitStr(3,'abc'))
这产生了以下输出:

注意这个双列表压缩和这个理解中的两个递归调用。这个递归地连接了初始序列的每个元素,当*n* = 1时返回,与前一个递归调用中生成的字符串的每个元素。在这个意义上,它是回溯,以揭示先前未生成的组合。返回的最终字符串是初始字符串的所有n个字母组合。
分而治之 - 长乘法
为了使递归不仅仅是一个聪明的技巧,我们需要了解如何将其与其他方法进行比较,比如迭代,以及了解何时使用它将导致更快的算法。我们都熟悉的迭代算法是我们在小学数学课上学到的程序,用于相乘两个大数。也就是说,长乘法。如果你记得的话,长乘法涉及迭代相乘和进位操作,然后是移位和加法操作。
我们的目标是在这里检查如何测量这个过程的效率,并尝试回答这个问题:这是我们用来相乘两个大数的最有效的程序吗?
在下图中,我们可以看到将两个 4 位数相乘需要 16 次乘法运算,我们可以推广说,一个n位数需要大约n²次乘法运算:

以计算原语的数量,如乘法和加法,来分析算法的方法很重要,因为它为我们提供了一种理解完成某个计算所需的时间与该计算的输入大小之间的关系的方法。特别是,我们想知道当输入,即数字的位数n非常大时会发生什么。这个主题,称为渐近分析,或时间复杂度,对我们研究算法至关重要,我们将在本章和本书的其余部分经常回顾它。
我们能做得更好吗?递归方法
事实证明,在长乘法的情况下,答案是肯定的,实际上有几种算法可以用于乘法大数,需要更少的操作。最著名的长乘法替代方案之一是Karatsuba 算法,首次发表于 1962 年。这采用了一种基本不同的方法:而不是迭代地相乘单个数字,它以递归的方式对逐渐变小的输入进行乘法运算。递归程序在输入的较小子集上调用自己。构建递归算法的第一步是将一个大数分解为几个较小的数。这样做的最自然的方式是将数字简单地分成两半,前半部分是最重要的数字,后半部分是最不重要的数字。例如,我们的四位数 2345 变成了一对两位数 23 和 45。我们可以使用以下更一般的分解来分解任何 2 n位数x和y,其中m是小于n的任何正整数:


现在我们可以将我们的乘法问题x,y重写如下:

当我们展开并收集同类项时,我们得到以下结果:

更方便的是,我们可以这样写:

其中:

应该指出,这表明了一种递归方法来计算两个数字的乘法,因为这个过程本身涉及乘法。具体来说,乘积ac、ad、bc和bd都涉及比输入数字小的数字,因此我们可以将相同的操作作为整体问题的部分解决方案。到目前为止,这个算法包括四个递归乘法步骤,目前还不清楚它是否比经典的长乘法方法更快。
到目前为止,关于递归乘法的讨论对数学家来说自 19 世纪末就已经很熟悉了。卡拉茨巴算法改进了这一点,方法是做出以下观察。我们实际上只需要知道三个量:z[2]= ac;z[1]=ad +bc,和z[0]= bd 来解方程 3.1。我们只需要知道a, b, c, d的值,因为它们对计算z[2], z[1], 和z[0]所涉及的总和和乘积有贡献。这表明也许我们可以减少递归步骤的数量。事实证明的确是这种情况。
由于乘积ac和bd已经处于最简形式,似乎我们不太可能消除这些计算。然而,我们可以做出以下观察:

当我们减去我们在上一个递归步骤中计算的ac和bd时,我们得到我们需要的数量,即(ad + bc):

这表明我们确实可以计算ad + bc的和,而无需单独计算每个单独的数量。总之,我们可以通过将递归步骤从四步减少到三步来改进方程 3.1。这三个步骤如下:
-
递归计算ac。
-
递归计算bd。
-
递归计算(a +b)(c + d)并减去ac和bd。
以下示例展示了卡拉茨巴算法的 Python 实现:
from math import log10
def karatsuba(x,y):
# The base case for recursion
if x < 10 or y < 10:
return x*y
#sets n, the number of digits in the highest input number
n = max(int(log10(x)+1), int(log10(y)+1))
# rounds up n/2
n_2 = int(math.ceil(n / 2.0))
#adds 1 if n is uneven
n = n if n % 2 == 0 else n + 1
#splits the input numbers
a, b = divmod(x, 10**n_2)
c, d = divmod(y, 10**n_2)
#applies the three recursive steps
ac = karatsuba(a,c)
bd = karatsuba(b,d)
ad_bc = karatsuba((a+b),(c+d)) - ac - bd
#performs the multiplication
return (((10**n)*ac) + bd + ((10**n_2)*(ad_bc)))
为了确保这确实有效,我们可以运行以下测试函数:
import random
def test():
for i in range(1000):
x = random.randint(1,10**5)
y = random.randint(1,10**5)
expected = x * y
result = karatsuba(x, y)
if result != expected:
return("failed")
return('ok')
运行时间分析
很明显,算法设计的一个重要方面是评估效率,无论是在空间(内存)还是时间(操作次数)方面。第二个度量,称为运行性能,是本节的主题。值得一提的是,用于衡量算法内存性能的度量标准与此相同。我们可以以多种方式衡量运行时间,最明显的可能是简单地测量算法完成所需的时间。这种方法的主要问题在于算法运行所需的时间很大程度上取决于其运行的硬件。衡量算法运行时间的一个与平台无关的方法是计算所涉及的操作次数。然而,这也存在问题,因为没有明确的方法来量化一个操作。这取决于编程语言、编码风格以及我们决定如何计算操作。然而,如果我们将这种计算操作的想法与一个期望相结合,即随着输入规模的增加,运行时间将以特定方式增加,那么我们就可以使用这个想法。也就是说,输入规模n与算法运行时间之间存在数学关系。
接下来的讨论大部分将围绕以下三个指导原则展开。随着我们的进行,这些原则的合理性和重要性将变得更加清晰。这些原则如下:
-
最坏情况分析。不对输入数据做任何假设。
-
忽略或抑制常数因子和低阶项。在大输入中,高阶项占主导地位。
-
关注输入规模较大的问题。
最坏情况分析是有用的,因为它给出了我们算法保证不会超过的严格上界。忽略小的常数因子和低阶项实际上就是忽略那些在输入大小n的大值时并不对总运行时间有很大贡献的事物。这不仅使我们的工作在数学上更容易,也使我们能够专注于对性能影响最大的事物。
我们在 Karatsuba 算法中看到,乘法操作的数量增加到了输入大小n的平方。如果我们有一个四位数,乘法操作的数量是 16;一个八位数需要 64 次操作。通常,我们并不真正关心算法在n的小值上的行为,所以我们通常忽略那些随着n线性增长的因素。这是因为在较大的n值上,随着n的增加,增长最快的操作将占主导地位。
我们将通过一个例子,归并排序算法,更详细地解释这一点。排序是第十三章的主题,排序,然而,作为一个前导和了解运行时性能的有用方式,我们将在这里介绍归并排序。
归并排序算法是一个经典的算法,已经发展了 60 多年。它仍然广泛应用于许多最流行的排序库中。它相对简单而高效。它是一个使用分治法的递归算法。这涉及将问题分解为更小的子问题,递归地解决它们,然后以某种方式将结果合并。归并排序是分治范式的最明显的演示之一。
归并排序算法由三个简单的步骤组成:
-
递归地对输入数组的左半部分进行排序。
-
递归地对输入数组的右半部分进行排序。
-
将两个排序好的子数组合并成一个。
一个典型的问题是将一组数字按数字顺序排序。归并排序通过将输入分成两半并同时处理每一半来工作。我们可以用以下图表来形象地说明这个过程:

以下是归并排序算法的 Python 代码:
def mergeSort(A):
#base case if the input array is one or zero just return.
if len(A) > 1:
# splitting input array
print('splitting ', A )
mid = len(A)//2
left = A[:mid]
right = A[mid:]
#recursive calls to mergeSort for left and right sub arrays
mergeSort(left)
mergeSort(right)
#initalizes pointers for left (i) right (j) and output array (k)
# 3 initalization operations
i = j = k = 0
#Traverse and merges the sorted arrays
while i <len(left) and j<len(right):
# if left < right comparison operation
if left[i] < right[j]:
# if left < right Assignment operation
A[k]=left[i]
i=i+1
else:
#if right <= left assignment
A[k]= right[j]
j=j+1
k=k+1
while i<len(left):
#Assignment operation
A[k]=left[i]
i=i+1
k=k+1
while j<len(right):
#Assignment operation
A[k]=right[j]
j=j+1
k=k+1
print('merging ', A)
return(A)
我们运行这个程序得到以下结果:

我们感兴趣的问题是如何确定运行时间性能,也就是说,算法完成所需的时间与n的大小相关的增长率是多少。为了更好地理解这一点,我们可以将每个递归调用映射到一个树结构上。
树中的每个节点都是一个递归调用,处理逐渐变小的子问题:

每次调用归并排序都会随后创建两个递归调用,因此我们可以用二叉树表示这一点。每个子节点都接收输入的一个子集。最终,我们想知道算法完成所需的总时间与n的大小相关。首先,我们可以计算树的每一层的工作量和操作数量。
关注运行时分析,在第 1 层,问题被分成两个n/2 的子问题,在第 2 层,有四个n/4 的子问题,依此类推。问题是递归何时结束,也就是说,何时达到基本情况。这只是当数组要么是零要么是一时。
递归级别的数量正好是你需要将n除以 2 的次数,直到得到最多为 1 的数字。这恰好是 log2 的定义。由于我们将初始递归调用计为级别 0,总级别数是 log[2]n + 1。
让我们暂停一下,重新定义一下。到目前为止,我们一直用字母n来描述输入中的元素数量。这指的是递归的第一级中元素的数量,也就是初始输入的长度。我们需要区分后续递归级别的输入大小。为此,我们将使用字母m或者特定的m[j]来表示递归级别j的输入长度。
还有一些细节我们忽略了,我相信你也开始好奇了。例如,当m/2 不是整数时会发生什么,或者当我们的输入数组中有重复元素时会发生什么。事实证明,这对我们的分析没有重要影响。
使用递归树来分析算法的优势在于我们可以计算每个递归级别的工作量。定义这个工作量就是操作的总数,这当然与输入的大小有关。以平台无关的方式来衡量和比较算法的性能是很重要的。实际运行时间当然取决于运行的硬件。计算操作次数很重要,因为它给了我们一个与算法性能直接相关的度量标准,与平台无关。
一般来说,由于归并排序的每次调用都会进行两次递归调用,调用次数在每个级别都会翻倍。同时,每次调用都会处理其父级别大小一半的输入。我们可以形式化地表达为:
对于级别j,其中j是整数 0、1、2... log[2]n,每个大小为n/2^j 的子问题有 2^j 个。
为了计算总操作次数,我们需要知道单个合并两个子数组所包含的操作次数。让我们来数一下之前 Python 代码中的操作次数。我们感兴趣的是在进行两次递归调用后的所有代码。首先,我们有三个赋值操作。然后是三个 while 循环。在第一个循环中,我们有一个 if else 语句,每个 if else 语句中有两个操作,一个比较,一个赋值。由于在 if else 语句中只有一个这样的操作集,我们可以将这段代码计为每次递归执行 2 次。接下来是两个 while 循环,每个有一个赋值操作。这使得每次归并排序递归的总操作次数为 4m + 3。
由于m至少为 1,操作次数的上限为 7m。必须指出,这并不是一个精确的数字。当然,我们可以决定以不同的方式计算操作次数。我们没有计算增量操作或任何维护操作;然而,在n的高值时,我们更关心运行时间的增长速度。
这可能看起来有点令人生畏,因为每次递归调用本身都会产生更多的递归调用,似乎呈指数级增长。使这个问题可控的关键事实是,随着递归调用次数翻倍,每个子问题的大小减半。这两股相反的力量会很好地抵消,我们可以证明这一点。
为了计算递归树每个级别的最大操作次数,我们只需将子问题的数量乘以每个子问题中的操作次数,如下所示:
(图片)
重要的是,这表明,因为 2^j 取消了每个级别上的操作数,所以每个级别上的操作数与级别无关。这给我们每个级别上执行的操作数的上限,例如,在这个例子中,是 7n。应该指出,这包括该级别上每个递归调用执行的操作数,而不是在后续级别上进行的递归调用。这表明,随着每个级别的递归调用数量翻倍,所做的工作正好被每个子问题的输入大小减半所抵消。
要找到完整归并排序的总操作数,我们只需将每个级别上的操作数乘以级别数。这给我们以下结果:

当我们展开这个式子时,我们得到以下结果:

从中要得出的关键点是,输入大小和总运行时间之间存在对数关系。如果您还记得学校数学,对数函数的显著特征是它非常快地变平。作为输入变量,x增大,输出变量y增加的幅度越来越小。例如,将对数函数与线性函数进行比较:

在前面的例子中,将nlog[2]n组件与n²进行比较。

请注意,对于非常小的n值,完成时间t实际上比运行时间为 n²的算法更短。然而,对于大约 40 以上的值,对数函数开始占主导地位,使输出变平,直到在相对适中的大小n=100 时,性能比运行时间为 n²的算法高出两倍以上。还要注意,在高n值时,常数因子+7 的消失是无关紧要的。
生成这些图表所使用的代码如下:
import matplotlib.pyplot as plt
import math
x=list(range(1,100))
l =[]; l2=[]; a = 1
plt.plot(x , [y * y for y in x] )
plt.plot(x, [(7 *y )* math.log(y, 2) for y in x])
plt.show()
如果尚未安装 matplotlib 库,您需要安装它才能运行。有关详细信息,请访问以下地址;我鼓励您尝试使用列表推导式表达式来生成图表。例如,添加以下绘图语句:
plt.plot(x, [(6 *y )* math.log(y, 2) for y in x])
得到以下输出:

前面的图表显示了计算六次操作或七次操作之间的差异。我们可以看到这两种情况是如何分歧的,这在谈论应用程序的具体情况时很重要。然而,我们在这里更感兴趣的是一种表征增长率的方法。我们更关心的不是绝对值,而是这些值随着n的增加而变化的方式。通过这种方式,我们可以看到这两条较低的曲线具有相似的增长率,与顶部(x²)曲线相比。我们说这两条较低的曲线具有相同的复杂度类。这是一种理解和描述不同运行时行为的方法。我们将在下一节正式化这个性能指标。
渐近分析
基本上有三个特征来表征算法的运行时间性能。它们是:
-
最坏情况 - 使用能够获得最慢性能的输入
-
最佳情况 - 使用能够给出最佳结果的输入
-
平均情况 - 假设输入是随机的
为了计算这些,我们需要知道上限和下限。我们已经看到了用数学表达式来表示算法的运行时间的方法,基本上是加法和乘法运算。要使用渐近分析,我们只需创建两个表达式,一个用于最佳情况,一个用于最坏情况。
大 O 符号
大 O 符号中的字母“O”代表顺序,以承认增长速度被定义为函数的顺序。我们说一个函数T(n)是另一个函数F(n)的大 O,我们将其定义如下:

输入大小n的函数g(n)基于这样的观察:对于所有足够大的n值,g(n)都受到F(n)的常数倍的上界限制。目标是找到小于或等于F(n)的增长速度。我们只关心n的较高值发生了什么。变量n[0]表示增长速度不重要的阈值。函数 T(n)表示紧密上界F(n)。在下图中,我们看到T(n) = n² + 500 = O(n²),其中C = 2,n[0]约为 23:

您还会看到符号f(n) = O(g(n))。这描述了O(g(n))实际上是一个包含所有增长速度与f(n)相同或更小的函数的集合。例如,O(n²)也包括函数O(n),O(nlogn),等等。
在下表中,我们按从最低到最高的顺序列出了最常见的增长率。我们有时称这些增长率为函数的时间复杂度,或者函数的复杂度类:
| 复杂度类 | 名称 | 示例操作 |
|---|---|---|
| O(1) | 常数 | 追加,获取项目,设置项目。 |
| O(logn) | 对数 | 在排序数组中查找元素。 |
| O(n) | 线性 | 复制,插入,删除,迭代。 |
| nLogn | 线性对数 | 对列表进行排序,合并 - 排序。 |
| n² | 二次 | 在图中找到两个节点之间的最短路径。嵌套循环。 |
| n³ | 立方 | 矩阵乘法。 |
| 2^n | 指数 | '汉诺塔'问题,回溯。 |
组合复杂度类
通常,我们需要找到一系列基本操作的总运行时间。事实证明,我们可以将简单操作的复杂度类组合起来,以找到更复杂的组合操作的复杂度类。目标是分析函数或方法中的组合语句,以了解执行多个操作的总时间复杂度。组合两个复杂度类的最简单方法是将它们相加。这发生在我们有两个连续的操作时。例如,考虑将元素插入列表,然后对该列表进行排序的两个操作。我们可以看到插入一个项目需要 O(n)时间,排序需要 O(nlogn)时间。我们可以将总时间复杂度写为 O(n + nlogn),也就是说,我们将两个函数放在 O(...)中。我们只关心最高阶项,因此这让我们只剩下 O(nlogn)。
如果我们重复一个操作,例如在 while 循环中,那么我们将复杂度类乘以操作执行的次数。如果一个时间复杂度为 O(f(n))的操作重复执行 O(n)次,那么我们将这两个复杂度相乘:
O(f(n) * O(n)) = O(nf(n))。
例如,假设函数 f(...)的时间复杂度为 O(n²),并且在 while 循环中执行n次,如下所示:
for i n range(n):
f(...)
然后,这个循环的时间复杂度变成了 O(n²) * O(n) = O(n * n²) = O(n³)。在这里,我们只是将操作的时间复杂度乘以这个操作执行的次数。循环的运行时间最多是循环内语句的运行时间乘以迭代次数。一个单独的嵌套循环,也就是一个循环嵌套在另一个循环中,假设两个循环都运行n次,那么运行时间就是n²。例如:
for i in range(0,n):
for j in range(0,n)
#statements
每个语句是一个常数 c,执行n**n次,因此我们可以将运行时间表示为;c**n n = cn² = O(n2)。
对于嵌套循环中的连续语句,我们将每个语句的时间复杂度相加,并乘以语句执行的次数。例如:
n = 500 #c0
#executes n times
for i in range(0,n):
print(i) #c1
#executes n times
for i in range(0,n):
#executes n times
for j in range(0,n):
print(j) #c2
这可以写成c[0] +c[1]n + cn² = O(n²)。
我们可以定义(以 2 为底)对数复杂度,将问题的大小减少一半,所需的时间是常数。例如,考虑以下代码片段:
i = 1
while i <= n:
i=i * 2
print(i)
注意i在每次迭代中都会加倍,如果我们以n=10 运行这个程序,我们会看到它打印出四个数字;2、4、8 和 16。如果我们将n加倍,我们会看到它打印出五个数字。随着n的每次加倍,迭代次数只增加了 1。如果我们假设k次迭代,我们可以写成如下:

由此我们可以得出总时间 = O(log(n))。
尽管大 O 是渐近分析中最常用的符号,但还有两个相关的符号应该简要提到。它们是 Omega 符号和 Theta 符号。
Omega 符号(Ω)
大 O 符号描述了上界的情况,Omega 符号描述了紧密的下界。定义如下:

目标是找到与给定算法 T(n)的增长率相等或小于的最大增长率。
Theta 符号(ϴ)
通常情况下,给定函数的上界和下界是相同的,Theta 符号的目的就是确定这种情况是否存在。定义如下:

虽然 Omega 和 Theta 符号需要完全描述增长率,但最实用的是大 O 符号,这是你经常会看到的。
摊销分析
通常我们对单个操作的时间复杂度不太感兴趣,而是更关注操作序列的平均运行时间。这被称为摊销分析。它与平均情况分析不同,我们很快会讨论,因为它不对输入值的数据分布做任何假设。但是,它考虑了数据结构的状态变化。例如,如果列表已排序,则任何后续查找操作都应该更快。摊销分析可以考虑数据结构的状态变化,因为它分析操作序列,而不仅仅是聚合单个操作。
摊销分析通过对操作序列中的每个操作施加人为成本,然后组合这些成本,找到运行时间的上界。序列的人为成本考虑到初始昂贵的操作可能使后续操作变得更便宜。
当我们有少量昂贵的操作,比如排序,和大量更便宜的操作,比如查找时,标准的最坏情况分析可能导致过于悲观的结果,因为它假设每次查找都必须比较列表中的每个元素直到找到匹配项。我们应该考虑到一旦我们对列表进行排序,我们可以使后续的查找操作变得更便宜。
到目前为止,在我们的运行时间分析中,我们假设输入数据是完全随机的,并且只关注输入大小对运行时间的影响。算法分析还有另外两种常见的方法:
-
平均情况分析
-
基准测试
平均情况分析根据对各种输入值的相对频率的一些假设,找到平均运行时间。使用真实世界的数据,或者模拟真实世界数据的分布,往往是基于特定数据分布的,然后计算平均运行时间。
基准测试就是有一组约定的典型输入,用于衡量性能。基准测试和平均时间分析都依赖于一些领域知识。我们需要知道典型或预期的数据集是什么。最终,我们将尝试通过微调到一个非常具体的应用设置来改善性能。
让我们看一种简单的方法来衡量算法的运行时间性能。这可以通过简单地计算算法在不同输入大小下完成所需的时间来实现。正如我们之前提到的,这种衡量运行时间性能的方式取决于它运行的硬件。显然,更快的处理器会给出更好的结果,然而,随着输入大小的增加,它们的相对增长率仍将保留算法本身的特征,而不是它运行的硬件。绝对时间值会因硬件(和软件)平台的不同而有所不同;然而,它们的相对增长仍将受到算法的时间复杂度的限制。
让我们以一个嵌套循环的简单例子来说明。很明显,这个算法的时间复杂度是 O(n²),因为在外部循环的每 n 次迭代中,内部循环也有 n 次迭代。例如,我们简单的嵌套 for 循环由内部循环上执行的简单语句组成:
def nest(n):
for i in range(n):
for j in range(n):
i+j
以下代码是一个简单的测试函数,它使用不断增加的n值运行嵌套函数。在每次迭代中,我们使用timeit.timeit函数计算该函数完成所需的时间。在这个例子中,timeit函数接受三个参数,一个表示要计时的函数的字符串表示,一个导入嵌套函数的设置函数,以及一个int参数,表示执行主语句的次数。由于我们对嵌套函数完成所需的时间相对于输入大小n感兴趣,因此对于我们的目的来说,在每次迭代中调用一次嵌套函数就足够了。以下函数返回每个 n 值的计算运行时间的列表:
import timeit
def test2(n):
ls=[]
for n in range(n):
t=timeit.timeit("nest(" + str(n) +")", setup="from __main__ import nest", number = 1)
ls.append(t)
return ls
在以下代码中,我们运行 test2 函数并绘制结果,以及适当缩放的 n²函数进行比较,用虚线表示:
import matplotlib.pyplot as plt
n=1000
plt.plot(test2(n))
plt.plot([x*x/10000000 for x in range(n)])
这给出了以下结果:

正如我们所看到的,这基本上符合我们的预期。应该记住,这既代表了算法本身的性能,也代表了底层软件和硬件平台的行为,这一点可以从测量运行时间的变化和运行时间的相对大小看出。显然,更快的处理器会导致更快的运行时间,而性能也会受到其他运行进程、内存限制、时钟速度等的影响。
摘要
在本章中,我们对算法设计进行了一般概述。重要的是,我们看到了一种平台无关的方法来衡量算法的性能。我们看了一些不同的算法问题解决方法。我们看了一种递归相乘大数的方法,也看了归并排序的递归方法。我们看到了如何使用回溯进行穷举搜索和生成字符串。我们还介绍了基准测试的概念以及衡量运行时间的简单平台相关方法。在接下来的章节中,我们将参考特定的数据结构重新讨论这些想法。在下一章中,我们将讨论链表和其他指针结构。
第七章:列表和指针结构
你已经在 Python 中看到了列表。它们方便而强大。通常,每当你需要在列表中存储东西时,你使用 Python 的内置列表实现。然而,在本章中,我们更感兴趣的是理解列表的工作原理。因此,我们将研究列表的内部。正如你将注意到的,有不同类型的列表。
Python 的列表实现旨在强大并包含几种不同的用例。我们将对列表的定义更加严格。节点的概念对列表非常重要。我们将在本章讨论它们,但这个概念将以不同的形式在本书的其余部分中再次出现。
本章的重点将是以下内容:
-
了解 Python 中的指针
-
处理节点的概念
-
实现单向、双向和循环链表
在本章中,我们将处理相当多的指针。因此,提醒自己这些是有用的。首先,想象一下你有一所房子想要出售。由于时间不够,你联系了一个中介来寻找感兴趣的买家。所以你拿起你的房子,把它带到中介那里,中介会把房子带给任何可能想要购买它的人。你觉得荒谬吗?现在想象一下你有一些 Python 函数,用于处理图像。所以你在函数之间传递高分辨率图像数据。
当然,你不会把你的房子随身携带。你会把房子的地址写在一张废纸上,交给中介。房子还在原地,但包含房子方向的纸条在传递。你甚至可能在几张纸上写下来。每张纸都足够小,可以放在钱包里,但它们都指向同一所房子。
事实证明,在 Python 领域并没有太大的不同。那些大型图像文件仍然在内存中的一个地方。你所做的是创建变量,保存这些图像在内存中的位置。这些变量很小,可以在不同的函数之间轻松传递。
这就是指针的巨大好处:它们允许你用简单的内存地址指向潜在的大内存段。
指针存在于计算机的硬件中,被称为间接寻址。
在 Python 中,你不直接操作指针,不像其他一些语言,比如 C 或 Pascal。这导致一些人认为 Python 中不使用指针。这是大错特错。考虑一下在 Python 交互式 shell 中的这个赋值:
>>> s = set()
我们通常会说s是 set 类型的变量。也就是说,s是一个集合。然而,这并不严格正确。变量s实际上是一个引用(一个“安全”的指针)指向一个集合。集合构造函数在内存中创建一个集合,并返回该集合开始的内存位置。这就是存储在s中的内容。
Python 将这种复杂性隐藏起来。我们可以安全地假设s是一个集合,并且一切都运行正常。
数组
数组是数据的顺序列表。顺序意味着每个元素都存储在前一个元素的后面。如果你的数组非常大,而且内存不足,可能找不到足够大的存储空间来容纳整个数组。这将导致问题。
当然,硬币的另一面是数组非常快。由于每个元素都紧随前一个元素在内存中,不需要在不同的内存位置之间跳转。在选择列表和数组在你自己的实际应用程序中时,这可能是一个非常重要的考虑因素。
指针结构
与数组相反,指针结构是可以在内存中分散的项目列表。这是因为每个项目包含一个或多个链接到结构中其他项目的链接。这些链接的类型取决于我们拥有的结构类型。如果我们处理的是链表,那么我们将有链接到结构中下一个(可能是上一个)项目的链接。在树的情况下,我们有父子链接以及兄弟链接。在基于瓦片的游戏中,游戏地图由六边形构建,每个节点将链接到最多六个相邻的地图单元。
指针结构有几个好处。首先,它们不需要顺序存储空间。其次,它们可以从小开始,随着向结构中添加更多节点而任意增长。
然而,这是有代价的。如果你有一个整数列表,每个节点将占据一个整数的空间,以及额外的整数用于存储指向下一个节点的指针。
节点
在列表(以及其他几种数据结构)的核心是节点的概念。在我们进一步之前,让我们考虑一下这个想法。
首先,我们将创建一些字符串:
>>> a = "eggs"
>>> b = "ham"
>>> c = "spam"
现在你有三个变量,每个变量都有一个唯一的名称、类型和值。我们没有的是一种方法来说明变量之间的关系。节点允许我们这样做。节点是数据的容器,以及一个或多个指向其他节点的链接。链接是一个指针。
一个简单类型的节点只有一个指向下一个节点的链接。
当然,根据我们对指针的了解,我们意识到这并不完全正确。字符串并没有真正存储在节点中,而是指向实际字符串的指针:

因此,这个简单节点的存储需求是两个内存地址。节点的数据属性是指向字符串eggs和ham的指针。
查找终点
我们创建了三个节点:一个包含eggs,一个ham,另一个spam。eggs节点指向ham节点,ham节点又指向spam节点。但spam节点指向什么?由于这是列表中的最后一个元素,我们需要确保它的下一个成员有一个清晰的值。
如果我们使最后一个元素指向空,则我们使这一事实清楚。在 Python 中,我们将使用特殊值None来表示空:

最后一个节点的下一个指针指向 None。因此它是节点链中的最后一个节点。
节点
这是我们迄今为止讨论的一个简单节点实现:
class Node:
def __init__(self, data=None):
self.data = data
self.next = None
不要将节点的概念与 Node.js 混淆,Node.js 是一种使用 JavaScript 实现的服务器端技术。
next指针被初始化为None,这意味着除非你改变next的值,否则节点将成为一个终点。这是一个好主意,这样我们就不会忘记正确终止列表。
你可以根据需要向node类添加其他内容。只需记住节点和数据之间的区别。如果你的节点将包含客户数据,那么创建一个Customer类并将所有数据放在那里。
你可能想要实现__str__方法,这样当节点对象传递给 print 时,它调用包含对象的__str__方法:
def __str__(self):
return str(data)
其他节点类型
我们假设节点具有指向下一个节点的指针。这可能是最简单的节点类型。然而,根据我们的要求,我们可以创建许多其他类型的节点。
有时我们想从 A 到 B,但同时也想从 B 到 A。在这种情况下,我们除了下一个指针外还添加了一个前一个指针:

从图中可以看出,我们让最后一个节点和第一个节点都指向None,表示我们已经到达它们作为列表端点的边界。第一个节点的前指针指向 None,因为它没有前任,就像最后一个项目的后指针指向None一样,因为它没有后继节点。
您可能还在为基于瓦片的游戏创建瓦片。在这种情况下,您可能使用北、南、东和西代替前一个和后一个。指针的类型更多,但原理是相同的。地图末尾的瓦片将指向None:

您可以根据需要扩展到任何程度。如果您需要能够向西北、东北、东南和西南移动,您只需将这些指针添加到您的node类中。
单链表
单链表是一个只有两个连续节点之间的指针的列表。它只能以单个方向遍历,也就是说,您可以从列表中的第一个节点移动到最后一个节点,但不能从最后一个节点移动到第一个节点。
实际上,我们可以使用之前创建的node类来实现一个非常简单的单链表:
>>> n1 = Node('eggs')
>>> n2 = Node('ham')
>>> n3 = Node('spam')
接下来,我们将节点链接在一起,使它们形成一个链:
>>> n1.next = n2
>>> n2.next = n3
要遍历列表,您可以执行以下操作。我们首先将变量current设置为列表中的第一个项目:
current = n1
while current:
print(current.data)
current = current.next
在循环中,我们打印当前元素,然后将当前设置为指向列表中的下一个元素。我们一直这样做,直到我们到达列表的末尾。
但是,这种简单的列表实现存在几个问题:
-
程序员需要太多的手动工作
-
这太容易出错了(这是第一个问题的结果)
-
列表的内部工作方式对程序员暴露得太多
我们将在以下部分解决所有这些问题。
单链表类
列表显然是一个与节点不同的概念。因此,我们首先创建一个非常简单的类来保存我们的列表。我们将从一个持有对列表中第一个节点的引用的构造函数开始。由于此列表最初为空,因此我们将首先将此引用设置为None:
class SinglyLinkedList:
def __init__(self):
self.tail = None
附加操作
我们需要执行的第一个操作是向列表附加项目。这个操作有时被称为插入操作。在这里,我们有机会隐藏Node类。我们的list类的用户实际上不应该与 Node 对象进行交互。这些纯粹是内部使用。
第一次尝试append()方法可能如下所示:
class SinglyLinkedList:
# ...
def append(self, data):
# Encapsulate the data in a Node
node = Node(data)
if self.tail == None:
self.tail = node
else:
current = self.tail
while current.next:
current = current.next
current.next = node
我们将数据封装在一个节点中,因此它现在具有下一个指针属性。从这里开始,我们检查列表中是否存在任何现有节点(即self.tail指向一个节点)。如果没有,我们将新节点设置为列表的第一个节点;否则,通过遍历列表找到插入点,将最后一个节点的下一个指针更新为新节点。
我们可以附加一些项目:
>>> words = SinglyLinkedList()
>>> words.append('egg')
>>> words.append('ham')
>>> words.append('spam')
列表遍历将更多或更少地像以前一样工作。您将从列表本身获取列表的第一个元素:
>>> current = words.tail
>>> while current:
print(current.data)
current = current.next
更快的附加操作
在上一节中,附加方法存在一个大问题:它必须遍历整个列表以找到插入点。当列表中只有几个项目时,这可能不是问题,但等到您需要添加成千上万个项目时再等等。每次附加都会比上一次慢一点。一个O(n)证明了我们当前的append方法实际上会有多慢。
为了解决这个问题,我们将存储的不仅是列表中第一个节点的引用,还有最后一个节点的引用。这样,我们可以快速地在列表的末尾附加一个新节点。附加操作的最坏情况运行时间现在从O(n)减少到O(1)。我们所要做的就是确保前一个最后一个节点指向即将附加到列表中的新节点。以下是我们更新后的代码:
class SinglyLinkedList:
def __init__(self):
# ...
self.tail = None
def append(self, data):
node = Node(data)
if self.head:
self.head.next = node
self.head = node
else:
self.tail = node
self.head = node
注意正在使用的约定。我们附加新节点的位置是通过self.head。self.tail变量指向列表中的第一个节点。
获取列表的大小
我们希望通过计算节点数来获取列表的大小。我们可以通过遍历整个列表并在遍历过程中增加一个计数器来实现这一点:
def size(self):
count = 0
current = self.tail
while current:
count += 1
current = current.next
return count
这样做是可以的,但列表遍历可能是一个昂贵的操作,我们应该尽量避免。因此,我们将选择另一种重写方法。我们在SinglyLinkedList类中添加一个 size 成员,在构造函数中将其初始化为 0。然后我们在append方法中将 size 增加一:
class SinglyLinkedList:
def __init__(self):
# ...
self.size = 0
def append(self, data):
# ...
self.size += 1
因为我们现在只读取节点对象的 size 属性,而不使用循环来计算列表中的节点数,所以我们可以将最坏情况的运行时间从O(n)减少到O(1)。
改进列表遍历
如果您注意到我们如何遍历我们的列表。那里我们仍然暴露给node类的地方。我们需要使用node.data来获取节点的内容和node.next来获取下一个节点。但我们之前提到客户端代码不应该需要与 Node 对象进行交互。我们可以通过创建一个返回生成器的方法来实现这一点。它看起来如下:
def iter(self):
current = self.tail
while current:
val = current.data
current = current.next
yield val
现在列表遍历变得简单得多,看起来也好得多。我们可以完全忽略列表之外有一个叫做 Node 的东西:
for word in words.iter():
print(word)
请注意,由于iter()方法产生节点的数据成员,我们的客户端代码根本不需要担心这一点。
删除节点
列表上的另一个常见操作是删除节点。这可能看起来很简单,但我们首先必须决定如何选择要删除的节点。是按索引号还是按节点包含的数据?在这里,我们将选择按节点包含的数据删除节点。
以下是从列表中删除节点时考虑的一个特殊情况的图示:

当我们想要删除两个其他节点之间的节点时,我们所要做的就是将前一个节点直接指向其下一个节点的后继节点。也就是说,我们只需像前面的图像中那样将要删除的节点从链中切断。
以下是delete()方法的实现可能是这样的:
def delete(self, data):
current = self.tail
prev = self.tail
while current:
if current.data == data:
if current == self.tail:
self.tail = current.next
else:
prev.next = current.next
self.size -= 1
return
prev = current
current = current.next
删除节点应该需要O(n)的时间。
列表搜索
我们可能还需要一种方法来检查列表是否包含某个项目。由于我们之前编写的iter()方法,这种方法实现起来相当容易。循环的每一次通过都将当前数据与正在搜索的数据进行比较。如果找到匹配项,则返回True,否则返回False:
def search(self, data):
for node in self.iter():
if data == node:
return True
return False
清空列表
我们可能希望快速清空列表。幸运的是,这非常简单。我们只需将指针head和tail设置为None即可:
def clear(self):
""" Clear the entire list. """
self.tail = None
self.head = None
一举两得,我们将列表的tail和head指针上的所有节点都变成了孤立的。这会导致中间所有的节点都变成了孤立的。
双向链表
现在我们对单向链表有了扎实的基础,知道了可以对其执行的操作类型,我们现在将把注意力转向更高一级的双向链表主题。
双向链表在某种程度上类似于单向链表,因为我们利用了将节点串联在一起的相同基本思想。在单向链表中,每个连续节点之间存在一个链接。双向链表中的节点有两个指针:指向下一个节点和指向前一个节点的指针:

单向链表中的节点只能确定与其关联的下一个节点。但是被引用的节点或下一个节点无法知道是谁在引用它。方向的流动是单向的。
在双向链表中,我们为每个节点添加了不仅引用下一个节点而且引用前一个节点的能力。
让我们检查一下两个连续节点之间存在的连接性质,以便更好地理解:

由于存在指向下一个和前一个节点的两个指针,双向链表具有某些能力。
双向链表可以在任何方向遍历。根据正在执行的操作,双向链表中的节点可以在必要时轻松地引用其前一个节点,而无需指定变量来跟踪该节点。因为单向链表只能在一个方向上遍历,有时可能意味着移动到列表的开始或开头,以便影响列表中隐藏的某些更改。
由于立即可以访问下一个和前一个节点,删除操作要容易得多,后面在本章中会看到。
双向链表节点
创建一个类来捕获双向链表节点的 Python 代码,在其初始化方法中包括prev、next和data实例变量。当新创建一个节点时,所有这些变量默认为None:
class Node(object):
def __init__(self, data=None, next=None, prev=None):
self.data = data
self.next = next
self.prev = prev
prev变量保存对前一个节点的引用,而next变量继续保存对下一个节点的引用。
双向链表
仍然很重要的是创建一个类,以捕获我们的函数将要操作的数据:
class DoublyLinkedList(object):
def __init__(self):
self.head = None
self.tail = None
self.count = 0
为了增强size方法,我们还将count实例变量设置为 0。当我们开始向列表中插入节点时,head和tail将指向列表的头部和尾部。
我们采用了一个新的约定,其中self.head指向列表的起始节点,而self.tail指向列表中最新添加的节点。这与我们在单向链表中使用的约定相反。关于头部和尾部节点指针的命名没有固定的规则。
双向链表还需要提供返回列表大小、插入列表和从列表中删除节点的函数。我们将检查一些执行此操作的代码。让我们从append操作开始。
追加操作
在append操作期间,重要的是检查head是否为None。如果是None,则意味着列表为空,并且应该将head设置为指向刚创建的节点。通过头部,列表的尾部也指向新节点。在这一系列步骤结束时,head和tail现在将指向同一个节点:
def append(self, data):
""" Append an item to the list. """
new_node = Node(data, None, None)
if self.head is None:
self.head = new_node
self.tail = self.head
else:
new_node.prev = self.tail
self.tail.next = new_node
self.tail = new_node
self.count += 1
以下图表说明了在向空列表添加新节点时,双向链表的头部和尾部指针。

算法的else部分仅在列表不为空时执行。新节点的前一个变量设置为列表的尾部:
new_node.prev = self.tail
尾部的下一个指针(或变量)设置为新节点:
self.tail.next = new_node
最后,我们更新尾部指针指向新节点:
self.tail = new_node
由于append操作将节点数增加了一个,我们将计数器增加了一个:
self.count += 1
append操作的视觉表示如下:

删除操作
与单向链表不同,我们需要在遍历整个列表的时候跟踪先前遇到的节点,双向链表避免了这一步。这是通过使用前一个指针实现的。
从双向链表中删除节点的算法在完成节点删除之前,为基本上四种情况提供了支持。这些是:
-
当根本找不到搜索项时
-
当搜索项在列表的开头找到时
-
当搜索项在列表的尾部找到时
-
当搜索项在列表的中间找到时
当其data实例变量与传递给用于搜索节点的方法的数据匹配时,将识别要移除的节点。如果找到匹配的节点并随后删除,则将变量node_deleted设置为True。任何其他结果都会导致node_deleted被设置为False:
def delete(self, data):
current = self.head
node_deleted = False
...
在delete方法中,current变量被设置为列表的头部(即指向列表的self.head)。然后使用一组if...else语句搜索列表的各个部分,以找到具有指定数据的节点。
首先搜索head节点。由于current指向head,如果current为 None,则假定列表没有节点,甚至无法开始搜索要删除的节点:
if current is None:
node_deleted = False
然而,如果current(现在指向头部)包含正在搜索的数据,那么self.head被设置为指向current的下一个节点。由于现在头部后面没有节点了,self.head.prev被设置为None:
elif current.data == data:
self.head = current.next
self.head.prev = None
node_deleted = True
如果要删除的节点位于列表的尾部,将采用类似的策略。这是第三个语句,搜索要删除的节点可能位于列表末尾的可能性:
elif self.tail.data == data:
self.tail = self.tail.prev
self.tail.next = None
node_deleted = True
最后,查找并删除节点的算法循环遍历节点列表。如果找到匹配的节点,current的前一个节点将连接到current的下一个节点。在这一步之后,current的下一个节点将连接到current的前一个节点:
else
while current:
if current.data == data:
current.prev.next = current.next
current.next.prev = current.prev
node_deleted = True
current = current.next
然后在评估所有if-else语句之后检查node_delete变量。如果任何if-else语句更改了这个变量,那么意味着从列表中删除了一个节点。因此,计数变量减 1:
if node_deleted:
self.count -= 1
作为删除列表中嵌入的节点的示例,假设存在三个节点 A、B 和 C。要删除列表中间的节点 B,我们将使 A 指向 C 作为它的下一个节点,同时使 C 指向 A 作为它的前一个节点:

在这样的操作之后,我们得到以下列表:

列表搜索
搜索算法类似于单向链表中search方法的算法。我们调用内部方法iter()返回所有节点中的数据。当我们循环遍历数据时,每个数据都与传入contain方法的数据进行匹配。如果匹配,则返回True,否则返回False以表示未找到匹配项:
def contain(self, data):
for node_data in self.iter():
if data == node_data:
return True
return False
我们的双向链表对于append操作具有O(1),对于delete操作具有O(n)。
循环列表
循环列表是链表的一种特殊情况。它是一个端点连接的列表。也就是说,列表中的最后一个节点指向第一个节点。循环列表可以基于单向链表和双向链表。对于双向循环链表,第一个节点还需要指向最后一个节点。
在这里,我们将看一个单向循环链表的实现。一旦你掌握了基本概念,实现双向循环链表就应该很简单了。
我们可以重用我们在单链表部分创建的node类。事实上,我们也可以重用SinglyLinkedList类的大部分部分。因此,我们将专注于循环列表实现与普通单链表不同的方法。
附加元素
当我们向循环列表附加一个元素时,我们需要确保新节点指向尾节点。这在以下代码中得到了证明。与单链表实现相比,多了一行额外的代码:
def append(self, data):
node = Node(data)
if self.head:
self.head.next = node
self.head = node
else:
self.head = node
self.tail = node
self.head.next = self.tail
self.size += 1
删除元素
我们可能认为我们可以遵循与附加相同的原则,并确保头部指向尾部。这将给我们以下实现:
def delete(self, data):
current = self.tail
prev = self.tail
while current:
if current.data == data:
if current == self.tail:
self.tail = current.next
self.head.next = self.tail
else:
prev.next = current.next
self.size -= 1
return
prev = current
current = current.next
与以前一样,只有一行需要更改。只有在删除尾节点时,我们需要确保头节点被更新为指向新的尾节点。
然而,这段代码存在一个严重的问题。在循环列表的情况下,我们不能循环直到当前变为None,因为那永远不会发生。如果您删除一个现有节点,您不会看到这一点,但是尝试删除一个不存在的节点,您将陷入无限循环。
因此,我们需要找到一种不同的方法来控制while循环。我们不能检查当前是否已经到达头部,因为那样它就永远不会检查最后一个节点。但是我们可以使用prev,因为它落后于当前一个节点。然而,有一个特殊情况。在第一个循环迭代中,current和prev将指向同一个节点,即尾节点。我们希望确保循环在这里运行,因为我们需要考虑只有一个节点的情况。更新后的delete方法现在如下所示:
def delete(self, data):
current = self.tail
prev = self.tail
while prev == current or prev != self.head:
if current.data == data:
if current == self.tail:
self.tail = current.next
self.head.next = self.tail
else:
prev.next = current.next
self.size -= 1
return
prev = current
current = current.next
遍历循环列表
您不需要修改iter()方法。它对于我们的循环列表可以完美地工作。但是在遍历循环列表时,您需要设置一个退出条件,否则您的程序将陷入循环。以下是一种方法,可以使用计数器变量来实现:
words = CircularList()
words.append('eggs')
words.append('ham')
words.append('spam')
counter = 0
for word in words.iter():
print(word)
counter += 1
if counter > 1000:
break
一旦我们打印出 1,000 个元素,我们就跳出循环。
总结
在本章中,我们已经研究了链表。我们研究了构成列表的概念,如节点和指向其他节点的指针。我们实现了在这些类型的列表上发生的主要操作,并看到了它们的最坏情况运行时间是如何比较的。
在下一章中,我们将看两种通常使用列表实现的其他数据结构:栈和队列。
第八章:堆栈和队列
在本章中,我们将在上一章中学到的技能的基础上构建,以创建特殊的列表实现。我们仍然坚持线性结构。在接下来的章节中,我们将介绍更复杂的数据结构。
在本章中,我们将研究以下内容:
-
实现堆栈和队列
-
堆栈和队列的一些应用
堆栈
堆栈是一种经常被比作一堆盘子的数据结构。如果你刚刚洗了一个盘子,你把它放在堆叠的顶部。当你需要一个盘子时,你从堆叠的顶部取出它。因此,最后添加到堆叠的盘子将首先从堆叠中移除。因此,堆栈是后进先出(LIFO)结构:

上图描述了一堆盘子的堆栈。只有将一个盘子放在堆叠的顶部才可能添加一个盘子。从盘子堆中移除一个盘子意味着移除堆顶上的盘子。
堆栈上执行的两个主要操作是push和pop。当元素添加到堆栈顶部时,它被推送到堆栈上。当元素从堆栈顶部取出时,它被弹出堆栈。有时使用的另一个操作是peek,它可以查看堆栈上的元素而不将其弹出。
堆栈用于许多事情。堆栈的一个非常常见的用途是在函数调用期间跟踪返回地址。让我们想象一下我们有以下小程序:
def b():
print('b')
def a():
b()
a()
print("done")
当程序执行到对a()的调用时,首先将以下指令的地址推送到堆栈上,然后跳转到a。在a内部,调用b(),但在此之前,返回地址被推送到堆栈上。一旦在b()中,函数完成后,返回地址就会从堆栈中弹出,这将带我们回到a()。当a完成时,返回地址将从堆栈中弹出,这将带我们回到print语句。
实际上,堆栈也用于在函数之间传递数据。假设你的代码中的某处有以下函数调用:
somefunc(14, 'eggs', 'ham', 'spam')
将发生的是14, 'eggs', 'ham'和'spam'将依次被推送到堆栈上:

当代码跳转到函数时,a, b, c, d的值将从堆栈中弹出。首先将spam元素弹出并分配给d,然后将"ham"分配给c,依此类推:
def somefunc(a, b, c, d):
print("function executed")
堆栈实现
现在让我们来学习 Python 中堆栈的实现。我们首先创建一个node类,就像我们在上一章中使用列表一样:
class Node:
def __init__(self, data=None):
self.data = data
self.next = None
现在这对你来说应该很熟悉:一个节点保存数据和列表中下一个项目的引用。我们将实现一个堆栈而不是列表,但节点链接在一起的原则仍然适用。
现在让我们来看一下stack类。它开始类似于单链表。我们需要知道堆栈顶部的节点。我们还想跟踪堆栈中节点的数量。因此,我们将向我们的类添加这些字段:
class Stack:
def __init__(self):
self.top = None
self.size = 0
推送操作
push操作用于将元素添加到堆栈的顶部。以下是一个实现:
def push(self, data):
node = Node(data)
if self.top:
node.next = self.top
self.top = node
else:
self.top = node
self.size += 1
在下图中,在创建新节点后没有现有节点。因此self.top将指向这个新节点。if语句的else部分保证了这一点:

在我们有一个现有的堆栈的情况下,我们移动self.top,使其指向新创建的节点。新创建的节点必须有其next指针,指向堆栈上原来的顶部节点:

弹出操作
现在我们需要一个pop方法来从堆栈中移除顶部元素。在这样做的同时,我们需要返回顶部元素。如果没有更多元素,我们将使堆栈返回None:
def pop(self):
if self.top:
data = self.top.data
self.size -= 1
if self.top.next:
self.top = self.top.next
else:
self.top = None
return data
else:
return None
这里需要注意的是内部的if语句。如果顶部节点的next属性指向另一个节点,那么我们必须将堆栈的顶部指向该节点:

当堆栈中只有一个节点时,pop操作将按以下方式进行:

移除这样的节点会导致self.top指向None:

Peek
正如我们之前所说,我们也可以添加一个peek方法。这将只返回堆栈的顶部而不将其从堆栈中移除,使我们能够查看堆栈的顶部元素而不改变堆栈本身。这个操作非常简单。如果有一个顶部元素,返回它的数据,否则返回None(以便peek的行为与pop的行为相匹配):
def peek(self):
if self.top
return self.top.data
else:
return None
括号匹配应用程序
现在让我们看一个例子,说明我们如何使用我们的堆栈实现。我们将编写一个小函数,用于验证包含括号((,[或{)的语句是否平衡,也就是说,闭合括号的数量是否与开放括号的数量匹配。它还将确保一个括号对确实包含在另一个括号中:
def check_brackets(statement):
stack = Stack()
for ch in statement:
if ch in ('{', '[', '('):
stack.push(ch)
if ch in ('}', ']', ')'):
last = stack.pop()
if last is '{' and ch is '}':
continue
elif last is '[' and ch is ']':
continue
elif last is '(' and ch is ')':
continue
else:
return False
if stack.size > 0:
return False
else:
return True
我们的函数解析传递给它的语句中的每个字符。如果它得到一个开放括号,它将其推送到堆栈上。如果它得到一个闭合括号,它将堆栈的顶部元素弹出并比较两个括号,以确保它们的类型匹配:(应该匹配),[应该匹配],{应该匹配}。如果它们不匹配,我们返回False,否则我们继续解析。
一旦我们到达语句的末尾,我们需要进行最后一次检查。如果堆栈为空,那么一切正常,我们可以返回True。但是如果堆栈不为空,那么我们有一些没有匹配的闭合括号,我们将返回False。我们可以用以下小代码测试括号匹配器:
sl = (
"{(foo)(bar)}hellois)a)test",
"{(foo)(bar)}hellois)atest",
"{(foo)(bar)}hellois)a)test))"
)
for s in sl:
m = check_brackets(s)
print("{}: {}".format(s, m))
只有三个语句中的第一个应该匹配。当我们运行代码时,我们得到以下输出:

True,False,False。代码有效。总之,堆栈数据结构的push和pop操作吸引了O(1)。堆栈数据结构非常简单,但在现实世界中用于实现整个范围的功能。浏览器上的后退和前进按钮是由堆栈实现的。为了能够在文字处理器中具有撤销和重做功能,也使用了堆栈。
队列
另一种特殊类型的列表是队列数据结构。这种数据结构与你在现实生活中习惯的常规队列没有什么不同。如果你曾经在机场排队或者在邻里商店等待你最喜欢的汉堡,那么你应该知道队列是如何工作的。
队列也是一个非常基本和重要的概念,因为许多其他数据结构都是基于它们构建的。
队列的工作方式是,通常第一个加入队列的人会首先得到服务,一切条件相同。首先进入,先出的首字母缩写FIFO最好地解释了这一点。当人们站在队列中等待轮到他们接受服务时,服务只在队列的前面提供。人们离开队列的唯一时机是在他们被服务时,这只发生在队列的最前面。严格定义来说,人们加入队列的前面是不合法的,因为那里正在为人们提供服务:

要加入队列,参与者必须首先移动到队列中最后一个人的后面。队列的长度并不重要。这是队列接受新参与者的唯一合法或允许的方式。
我们作为人,所形成的队列并不遵循严格的规则。可能有人已经在队列中决定退出,甚至有其他人替代他们。我们的目的不是模拟真实队列中发生的所有动态。抽象出队列是什么以及它的行为方式使我们能够解决大量的挑战,特别是在计算方面。
我们将提供各种队列的实现,但所有实现都将围绕 FIFO 的相同思想。我们将称添加元素到队列的操作为 enqueue。要从队列中删除元素,我们将创建一个dequeue操作。每次入队一个元素时,队列的长度或大小增加一个。相反,出队项目会减少队列中的元素数量。
为了演示这两个操作,以下表格显示了从队列中添加和移除元素的效果:
| 队列操作 | 大小 | 内容 | 操作结果 |
|---|---|---|---|
Queue() |
0 | [] |
创建队列对象 |
Enqueue "Mark" |
1 | ['mark'] |
Mark 添加到队列中 |
Enqueue "John" |
2 | ['mark','john'] |
John 添加到队列中 |
Size() |
2 | ['mark','john'] |
返回队列中的项目数 |
Dequeue() |
1 | ['mark'] |
John 被出队并返回 |
Dequeue() |
0 | [] |
Mark 被出队并返回 |
基于列表的队列
为了将到目前为止讨论的有关队列的一切内容转化为代码,让我们继续使用 Python 的list类实现一个非常简单的队列。这有助于我们快速开发并了解队列。必须在队列上执行的操作封装在ListQueue类中:
class ListQueue:
def __init__(self):
self.items = []
self.size = 0
在初始化方法__init__中,items实例变量设置为[],这意味着创建时队列为空。队列的大小也设置为zero。更有趣的方法是enqueue和dequeue方法。
入队操作
enqueue操作或方法使用list类的insert方法在列表的前面插入项目(或数据):
def enqueue(self, data):
self.items.insert(0, data)
self.size += 1
请注意我们如何将插入到队列末尾的操作实现。索引 0 是任何列表或数组中的第一个位置。但是,在我们使用 Python 列表实现队列时,数组索引 0 是新数据元素插入队列的唯一位置。insert操作将列表中现有的数据元素向上移动一个位置,然后将新数据插入到索引 0 处创建的空间中。以下图形可视化了这个过程:

为了使我们的队列反映新元素的添加,大小增加了一个:
self.size += 1
我们可以使用 Python 的shift方法在列表上实现“在 0 处插入”的另一种方法。归根结底,实现是练习的总体目标。
出队操作
dequeue操作用于从队列中移除项目。参考队列主题的介绍,此操作捕获了我们为首次加入队列并等待时间最长的客户提供服务的地方:
def dequeue(self):
data = self.items.pop()
self.size -= 1
return data
Python 的list类有一个名为pop()的方法。pop方法执行以下操作:
-
从列表中删除最后一个项目。
-
将从列表中删除的项目返回给调用它的用户或代码。
列表中的最后一个项目被弹出并保存在data变量中。在方法的最后一行,返回数据。
考虑下图中的隧道作为我们的队列。执行dequeue操作时,从队列前面移除数据1的节点:

队列中的结果元素如下所示:
对于enqueue操作,我们能说些什么呢?它在多个方面都非常低效。该方法首先必须将所有元素向后移动一个空间。想象一下,当列表中有 100 万个元素需要在每次向队列添加新元素时进行移动。这通常会使大型列表的 enqueue 过程非常缓慢。
基于堆栈的队列
使用两个堆栈的另一种队列实现方式。再次,Python 的list类将被用来模拟一个堆栈:
class Queue:
def __init__(self):
self.inbound_stack = []
self.outbound_stack = []
前述的queue类在初始化时将两个实例变量设置为空列表。这些堆栈将帮助我们实现队列。在这种情况下,堆栈只是允许我们在它们上面调用push和pop方法的 Python 列表。
inbound_stack 仅用于存储添加到队列中的元素。在此堆栈上不能执行其他操作。
入队操作
enqueue方法是向队列添加元素的方法:
def enqueue(self, data):
self.inbound_stack.append(data)
该方法是一个简单的方法,只接收客户端想要追加到队列中的data。然后将此数据传递给queue类中的inbound_stack的append方法。此外,append方法用于模拟push操作,将元素推送到堆栈顶部。
要将数据enqueue到inbound_stack,以下代码可以胜任:
queue = Queue()
queue.enqueue(5)
queue.enqueue(6)
queue.enqueue(7)
print(queue.inbound_stack)
队列中inbound_stack的命令行输出如下:
[5, 6, 7]
出队操作
dequeue操作比其enqueue对应操作更复杂一些。添加到我们的队列中的新元素最终会出现在inbound_stack中。我们不是从inbound_stack中删除元素,而是将注意力转向outbound_stack。正如我们所说,只能通过outbound_stack从我们的队列中删除元素:
if not self.outbound_stack:
while self.inbound_stack:
self.outbound_stack.append(self.inbound_stack.pop())
return self.outbound_stack.pop()
if语句首先检查outbound_stack是否为空。如果不为空,我们继续通过执行以下操作来移除队列前端的元素:
return self.outbound_stack.pop()
如果outbound_stack为空,那么在弹出队列的前端元素之前,inbound_stack中的所有元素都将移动到outbound_stack中:
while self.inbound_stack:
self.outbound_stack.append(self.inbound_stack.pop())
只要inbound_stack中有元素,while循环将继续执行。
语句self.inbound_stack.pop()将删除最新添加到inbound_stack中的元素,并立即将弹出的数据传递给self.outbound_stack.append()方法调用。
最初,我们的inbound_stack填充了元素5,6和7:

执行while循环的主体后,outbound_stack如下所示:


dequeue方法中的最后一行将返回5,作为对outbound_stack上的pop操作的结果:
return self.outbound_stack.pop()
这将使outbound_stack只剩下两个元素:

下次调用dequeue操作时,while循环将不会被执行,因为outbound_stack中没有元素,这使得外部的if语句失败。
在这种情况下,立即调用pop操作,以便只返回队列中等待时间最长的元素。
使用此队列实现的典型代码运行如下:
queue = Queue()
queue.enqueue(5)
queue.enqueue(6)
queue.enqueue(7)
print(queue.inbound_stack)
queue.dequeue()
print(queue.inbound_stack)
print(queue.outbound_stack)
queue.dequeue()
print(queue.outbound_stack)
前述代码的输出如下:
[5, 6, 7]
[]
[7, 6]
[7]
代码示例向队列添加元素,并打印队列中的元素。调用dequeue方法后,再次打印队列时观察到元素数量的变化。
使用两个堆栈实现队列是面试中经常提出的一个问题。
基于节点的队列
使用 Python 列表来实现队列是一个很好的起点,可以让我们感受队列的工作原理。我们完全可以利用指针结构的知识来实现自己的队列数据结构。
可以使用双向链表实现队列,对该数据结构的插入和删除操作的时间复杂度为O(1)。
node类的定义与我们在双向链表中定义的Node相同,如果双向链表能够实现 FIFO 类型的数据访问,那么它可以被视为队列,其中添加到列表中的第一个元素是第一个被移除的。
队列类
queue类与双向链表list类非常相似:
class Queue:
def __init__(self):
self.head = None
self.tail = None
self.count = 0
在创建queue类的实例时,self.head和self.tail指针被设置为None。为了保持Queue中节点数量的计数,这里也维护了count实例变量,并将其设置为0。
入队操作
元素通过enqueue方法添加到Queue对象中。在这种情况下,元素是节点:
def enqueue(self, data):
new_node = Node(data, None, None)
if self.head is None:
self.head = new_node
self.tail = self.head
else:
new_node.prev = self.tail
self.tail.next = new_node
self.tail = new_node
self.count += 1
enqueue方法的代码与双向链表的append操作中已经解释过的代码相同。它从传递给它的数据创建一个节点,并将其附加到队列的尾部,或者如果队列为空,则将self.head和self.tail都指向新创建的节点。队列中元素的总数增加了一行self.count += 1。
出队操作
使我们的双向链表作为队列的另一个操作是dequeue方法。这个方法是用来移除队列前面的节点。
要移除由self.head指向的第一个元素,使用if语句:
def dequeue(self):
current = self.head
if self.count == 1:
self.count -= 1
self.head = None
self.tail = None
elif self.count > 1:
self.head = self.head.next
self.head.prev = None
self.count -= 1
current通过指向self.head来初始化。如果self.count为 1,则意味着列表中只有一个节点,也就是队列中只有一个节点。因此,要移除相关联的节点(由self.head指向),需要将self.head和self.tail变量设置为None。
另一方面,如果队列有许多节点,那么头指针将被移动以指向self.head的下一个节点。
在运行if语句之后,该方法返回被head指向的节点。self.count在if语句执行路径流程中的任何一种方式中都会减少一。
有了这些方法,我们成功地实现了一个队列,大量借鉴了双向链表的思想。
还要记住,将我们的双向链表转换为队列的唯一方法是两种方法,即enqueue和dequeue。
队列的应用
队列在计算机领域中用于实现各种功能。例如,网络上的每台计算机都不提供自己的打印机,可以通过排队来共享一个打印机。当打印机准备好打印时,它将选择队列中的一个项目(通常称为作业)进行打印。
操作系统还将进程排队以供 CPU 执行。让我们创建一个应用程序,利用队列来创建一个简单的媒体播放器。
媒体播放器队列
大多数音乐播放器软件允许用户将歌曲添加到播放列表中。点击播放按钮后,主播放列表中的所有歌曲都会依次播放。歌曲的顺序播放可以使用队列来实现,因为排队的第一首歌曲是首先播放的。这符合 FIFO 首字母缩写。我们将实现自己的播放列表队列,以 FIFO 方式播放歌曲。
基本上,我们的媒体播放器队列只允许添加曲目以及播放队列中的所有曲目。在一个完整的音乐播放器中,线程将被用来改进与队列的交互方式,同时音乐播放器继续用于选择下一首要播放、暂停或停止的歌曲。
track类将模拟音乐曲目:
from random import randint
class Track:
def __init__(self, title=None):
self.title = title
self.length = randint(5, 10)
每个音轨都包含对歌曲标题的引用,以及歌曲的长度。长度是在 5 到 10 之间的随机数。随机模块提供了randint方法,使我们能够生成随机数。该类表示包含音乐的任何 MP3 音轨或文件。音轨的随机长度用于模拟播放歌曲或音轨所需的秒数。
要创建几个音轨并打印出它们的长度,我们需要做以下操作:
track1 = Track("white whistle")
track2 = Track("butter butter")
print(track1.length)
print(track2.length)
上述代码的输出如下:
6
7
由于为两个音轨生成的随机长度可能不同,因此您的输出可能会有所不同。
现在,让我们创建我们的队列。使用继承,我们只需从queue类继承:
import time
class MediaPlayerQueue(Queue):
def __init__(self):
super(MediaPlayerQueue, self).__init__()
通过调用super来正确初始化队列。该类本质上是一个队列,其中包含队列中的多个音轨对象。要将音轨添加到队列中,需要创建一个add_track方法:
def add_track(self, track):
self.enqueue(track)
该方法将track对象传递给队列super类的enqueue方法。这将实际上使用track对象(作为节点的数据)创建一个Node,并将尾部(如果队列不为空)或头部和尾部(如果队列为空)指向这个新节点。
假设队列中的音轨是按照先进先出的顺序播放的,那么play函数必须循环遍历队列中的元素:
def play(self):
while self.count > 0:
current_track_node = self.dequeue()
print("Now playing {}".format(current_track_node.data.title))
time.sleep(current_track_node.data.length)
self.count用于计算音轨何时被添加到我们的队列以及何时被出队。如果队列不为空,对dequeue方法的调用将返回队列前面的节点(其中包含track对象)。然后,print语句通过节点的data属性访问音轨的标题。为了进一步模拟播放音轨,time.sleep()方法将暂停程序执行,直到音轨的秒数已经过去:
time.sleep(current_track_node.data.length)
媒体播放器队列由节点组成。当音轨被添加到队列时,该音轨会隐藏在一个新创建的节点中,并与节点的数据属性相关联。这就解释了为什么我们通过对dequeue的调用返回的节点的数据属性来访问节点的track对象:

您可以看到,node对象不仅仅存储任何数据,而是在这种情况下存储音轨。
让我们来试试我们的音乐播放器:
track1 = Track("white whistle")
track2 = Track("butter butter")
track3 = Track("Oh black star")
track4 = Track("Watch that chicken")
track5 = Track("Don't go")
我们使用随机单词创建了五个音轨对象的标题:
print(track1.length)
print(track2.length)
>> 8 >> 9
由于随机长度的原因,输出应该与您在您的机器上获得的结果不同。
接下来,创建MediaPlayerQueue类的一个实例:
media_player = MediaPlayerQueue()
音轨将被添加,并且play函数的输出应该按照我们排队的顺序打印出正在播放的音轨:
media_player.add_track(track1)
media_player.add_track(track2)
media_player.add_track(track3)
media_player.add_track(track4)
media_player.add_track(track5)
media_player.play()
上述代码的输出如下:
>>Now playing white whistle
>>Now playing butter butter
>>Now playing Oh black star
>>Now playing Watch that chicken
>>Now playing Don't go
在程序执行时,可以看到音轨是按照它们排队的顺序播放的。在播放音轨时,系统还会暂停与音轨长度相等的秒数。
摘要
在本章中,我们利用了将节点链接在一起来创建其他数据结构的知识,即栈和队列。我们已经看到了这些数据结构如何紧密地模仿现实世界中的栈和队列。具体的实现,以及它们不同的类型,都已经展示出来。我们随后将栈和队列的概念应用于编写现实生活中的程序。
我们将在下一章中讨论树。将讨论树的主要操作,以及在哪些领域应用数据结构。
第九章:树
树是一种分层的数据结构。当我们处理列表、队列和栈时,项目是相互跟随的。但在树中,项目之间存在着父子关系。
为了形象化树的外观,想象一棵树从地面长出。现在把这个形象从你的脑海中移除。树通常是向下绘制的,所以你最好想象树的根结构向下生长。
在每棵树的顶部是所谓的根节点。这是树中所有其他节点的祖先。
树被用于许多事情,比如解析表达式和搜索。某些文档类型,如 XML 和 HTML,也可以以树形式表示。在本章中,我们将看一些树的用途。
在本章中,我们将涵盖以下领域:
-
树的术语和定义
-
二叉树和二叉搜索树
-
树的遍历
术语
让我们考虑一些与树相关的术语。
为了理解树,我们首先需要理解它们所依赖的基本思想。下图包含了一个典型的树,由字母 A 到 M 的字符节点组成。

以下是与树相关的术语列表:
-
节点:每个圈起来的字母代表一个节点。节点是任何包含数据的结构。
-
根节点:根节点是所有其他节点都来自的唯一节点。一个没有明显根节点的树不能被认为是一棵树。我们树中的根节点是节点 A。
-
子树:树的子树是一棵树,其节点是另一棵树的后代。节点 F、K 和 L 形成了原始树的子树,包括所有节点。
-
度:给定节点的子树数。只有一个节点的树的度为 0。这个单个树节点也被所有标准视为一棵树。节点 A 的度为 2。
-
叶节点:这是一个度为 0 的节点。节点 J、E、K、L、H、M 和 I 都是叶节点。
-
边:两个节点之间的连接。有时边可以将一个节点连接到自身,使边看起来像一个循环。
-
父节点:树中具有其他连接节点的节点是这些节点的父节点。节点 B 是节点 D、E 和 F 的父节点。
-
子节点:这是一个连接到其父节点的节点。节点 B 和 C 是节点 A 的子节点和根节点。
-
兄弟节点:所有具有相同父节点的节点都是兄弟节点。这使得节点 B 和 C 成为兄弟节点。
-
级别:节点的级别是从根节点到节点的连接数。根节点位于级别 0。节点 B 和 C 位于级别 1。
-
树的高度:这是树中的级别数。我们的树的高度为 4。
-
深度:节点的深度是从树的根到该节点的边数。节点 H 的深度为 2。
我们将从考虑树中的节点并抽象一个类开始对树的处理。
树节点
就像我们遇到的其他数据结构一样,如列表和栈,树是由节点构建而成的。但构成树的节点需要包含我们之前提到的关于父子关系的数据。
现在让我们看看如何在 Python 中构建一个二叉树node类:
class Node:
def __init__(self, data):
self.data = data
self.right_child = None
self.left_child = None
就像我们以前的实现一样,一个节点是一个包含数据并持有对其他节点的引用的容器。作为二叉树节点,这些引用是指左右子节点。
为了测试这个类,我们首先创建了一些节点:
n1 = Node("root node")
n2 = Node("left child node")
n3 = Node("right child node")
n4 = Node("left grandchild node")
接下来,我们将节点连接到彼此。我们让n1成为根节点,n2和n3成为它的子节点。最后,我们将n4作为n2的左子节点连接,这样当我们遍历左子树时,我们会得到一些迭代:
n1.left_child = n2
n1.right_child = n3
n2.left_child = n4
一旦我们设置好了树的结构,我们就准备好遍历它了。如前所述,我们将遍历左子树。我们打印出节点并向下移动树到下一个左节点。我们一直这样做,直到我们到达左子树的末尾:
current = n1
while current:
print(current.data)
current = current.left_child
正如你可能已经注意到的,这需要客户端代码中相当多的工作,因为你必须手动构建树结构。
二叉树
二叉树是每个节点最多有两个子节点的树。二叉树非常常见,我们将使用它们来构建 Python 中的 BST 实现。
以下图是一个以 5 为根节点的二叉树的示例:

每个子节点都被标识为其父节点的右子节点或左子节点。由于父节点本身也是一个节点,即使节点不存在,每个节点也会保存对右子节点和左子节点的引用。
常规二叉树没有关于如何排列树中元素的规则。它只满足每个节点最多有两个子节点的条件。
二叉搜索树
二叉搜索树(BST)是一种特殊类型的二叉树。也就是说,它在结构上是一棵二叉树。在功能上,它是一棵以一种能够高效搜索树的方式存储其节点的树。
BST 有一种结构。对于具有值的给定节点,左子树中的所有节点都小于或等于该节点的值。此外,该节点的右子树中的所有节点都大于父节点的值。例如,考虑以下树:

这是 BST 的一个示例。测试我们的树是否具有 BST 的属性,你会意识到根节点左子树中的所有节点的值都小于 5。同样,右子树中的所有节点的值都大于 5。这个属性适用于 BST 中的所有节点,没有例外:

尽管前面的图看起来与之前的图相似,但它并不符合 BST 的条件。节点 7 大于根节点 5;然而,它位于根节点的左侧。节点 4 位于其父节点 7 的右子树中,这是不正确的。
二叉搜索树实现
让我们开始实现 BST。我们希望树能够保存对其自己根节点的引用:
class Tree:
def __init__(self):
self.root_node = None
这就是维护树状态所需的全部内容。让我们在下一节中检查树上的主要操作。
二叉搜索树操作
基本上有两个操作对于使用 BST 是必要的。这些是“插入”和“删除”操作。这些操作必须遵循一个规则,即它们必须保持给 BST 赋予结构的原则。
在我们处理节点的插入和删除之前,让我们讨论一些同样重要的操作,这些操作将帮助我们更好地理解“插入”和“删除”操作。
查找最小和最大节点
BST 的结构使得查找具有最大和最小值的节点非常容易。
要找到具有最小值的节点,我们从树的根开始遍历,并在到达子树时每次访问左节点。我们做相反的操作来找到树中具有最大值的节点:

我们从节点 6 到 3 到 1 向下移动,以找到具有最小值的节点。同样,我们向下移动 6、8 到节点 10,这是具有最大值的节点。
查找最小和最大节点的相同方法也适用于子树。具有根节点 8 的子树中的最小节点是 7。该子树中具有最大值的节点是 10。
返回最小节点的方法如下:
def find_min(self):
current = self.root_node
while current.left_child:
current = current.left_child
return current
while循环继续获取左节点并访问它,直到最后一个左节点指向None。这是一个非常简单的方法。返回最大节点的方法相反,其中current.left_child现在变为current.right_child。
在 BST 中查找最小值或最大值需要O(h),其中h是树的高度。
插入节点
BST 的操作之一是需要将数据插入为节点。在我们的第一个实现中,我们必须自己插入节点,但在这里,我们将让树负责存储其数据。
为了使搜索成为可能,节点必须以特定的方式存储。对于每个给定的节点,其左子节点将保存小于其自身值的数据,如前所述。该节点的右子节点将保存大于其父节点的数据。
我们将通过使用数据 5 来创建一个新的整数 BST。为此,我们将创建一个数据属性设置为 5 的节点。
现在,要添加值为 3 的第二个节点,3 与根节点 5 进行比较:

由于 5 大于 3,它将放在节点 5 的左子树中。我们的 BST 将如下所示:

树满足 BST 规则,左子树中的所有节点都小于其父节点。
要向树中添加值为 7 的另一个节点,我们从值为 5 的根节点开始比较:

由于 7 大于 5,值为 7 的节点位于此根节点的右侧。
当我们想要添加一个等于现有节点的节点时会发生什么?我们将简单地将其添加为左节点,并在整个结构中保持此规则。
如果一个节点已经有一个子节点在新节点应该放置的位置,那么我们必须沿着树向下移动并将其附加。
让我们添加另一个值为 1 的节点。从树的根开始,我们比较 1 和 5:

比较表明 1 小于 5,因此我们将注意力转向 5 的左节点,即值为 3 的节点:

我们将 1 与 3 进行比较,由于 1 小于 3,我们向下移动到节点 3 的下一级并向左移动。但那里没有节点。因此,我们创建一个值为 1 的节点,并将其与节点 3 的左指针关联,以获得以下结构:

到目前为止,我们只处理包含整数或数字的节点。对于数字,大于和小于的概念是清晰定义的。字符串将按字母顺序比较,因此在那里也没有太大的问题。但是,如果您想在 BST 中存储自定义数据类型,您必须确保您的类支持排序。
现在让我们创建一个函数,使我们能够将数据作为节点添加到 BST 中。我们从函数声明开始:
def insert(self, data):
到现在为止,你已经习惯了我们将数据封装在节点中的事实。这样,我们将node类隐藏在客户端代码中,客户端代码只需要处理树:
node = Node(data)
首先检查是否有根节点。如果没有,新节点将成为根节点(我们不能没有根节点的树):
if self.root_node is None:
self.root_node = node
else:
当我们沿着树走时,我们需要跟踪我们正在处理的当前节点以及其父节点。变量current始终用于此目的:
current = self.root_node
parent = None
while True:
parent = current
在这里,我们必须进行比较。如果新节点中保存的数据小于当前节点中保存的数据,则我们检查当前节点是否有左子节点。如果没有,这就是我们插入新节点的地方。否则,我们继续遍历:
if node.data < current.data:
current = current.left_child
if current is None:
parent.left_child = node
return
现在我们处理大于或等于的情况。如果当前节点没有右子节点,则新节点将插入为右子节点。否则,我们继续向下移动并继续寻找插入点:
else:
current = current.right_child
if current is None:
parent.right_child = node
return
在 BST 中插入一个节点需要O(h),其中h是树的高度。
删除节点
BST 上的另一个重要操作是节点的删除或移除。在此过程中,我们需要考虑三种情况。我们要删除的节点可能有以下情况:
-
没有子节点
-
一个子节点
-
两个子节点
第一种情况是最容易处理的。如果要删除的节点没有子节点,我们只需将其与其父节点分离:

因为节点 A 没有子节点,所以我们只需将其与其父节点节点 Z 分离。
另一方面,当我们想要删除的节点有一个子节点时,该节点的父节点将指向该特定节点的子节点:

为了删除只有一个子节点节点 5 的节点 6,我们将节点 9 的左指针指向节点 5。父节点和子节点之间的关系必须得到保留。这就是为什么我们需要注意子节点如何连接到其父节点(即要删除的节点)。存储要删除节点的子节点。然后我们将要删除节点的父节点连接到该子节点。
当我们想要删除的节点有两个子节点时,会出现一个更复杂的情况:

我们不能简单地用节点 6 或 13 替换节点 9。我们需要找到节点 9 的下一个最大后代。这是节点 12。要到达节点 12,我们移动到节点 9 的右节点。然后向左移动以找到最左节点。节点 12 被称为节点 9 的中序后继。第二步类似于查找子树中的最大节点。
我们用节点 9 的值替换节点 9 的值,并删除节点 12。删除节点 12 后,我们得到了一个更简单的节点删除形式,这已经在之前进行过处理。节点 12 没有子节点,因此我们相应地应用删除没有子节点的节点的规则。
我们的node类没有父引用。因此,我们需要使用一个辅助方法来搜索并返回具有其父节点的节点。该方法类似于search方法:
def get_node_with_parent(self, data):
parent = None
current = self.root_node
if current is None:
return (parent, None)
while True:
if current.data == data:
return (parent, current)
elif current.data > data:
parent = current
current = current.left_child
else:
parent = current
current = current.right_child
return (parent, current)
唯一的区别是,在我们更新循环内的当前变量之前,我们使用parent = current存储其父级。执行实际删除节点的方法始于这个搜索:
def remove(self, data):
parent, node = self.get_node_with_parent(data)
if parent is None and node is None:
return False
# Get children count
children_count = 0
if node.left_child and node.right_child:
children_count = 2
elif (node.left_child is None) and (node.right_child is None):
children_count = 0
else:
children_count = 1
我们将父节点和找到的节点传递给parent和node,代码为parent, node = self.get_node_with_parent(data)。了解要删除的节点有多少子节点是有帮助的。这就是if语句的目的。
之后,我们需要开始处理节点可以被删除的各种条件。if语句的第一部分处理节点没有子节点的情况:
if children_count == 0:
if parent:
if parent.right_child is node:
parent.right_child = None
else:
parent.left_child = None
else:
self.root_node = None
if parent: 用于处理只有一个节点的 BST 的情况。
在要删除的节点只有一个子节点的情况下,if语句的elif部分执行以下操作:
elif children_count == 1:
next_node = None
if node.left_child:
next_node = node.left_child
else:
next_node = node.right_child
if parent:
if parent.left_child is node:
parent.left_child = next_node
else:
parent.right_child = next_node
else:
self.root_node = next_node
next_node用于跟踪节点指向的单个节点的位置。然后我们将parent.left_child或parent.right_child连接到next_node。
最后,我们处理了要删除的节点有两个子节点的情况:
...
else:
parent_of_leftmost_node = node
leftmost_node = node.right_child
while leftmost_node.left_child:
parent_of_leftmost_node = leftmost_node
leftmost_node = leftmost_node.left_child
node.data = leftmost_node.data
在查找中序后继时,我们使用leftmost_node = node.right_child移动到右节点。只要存在左节点,leftmost_node.left_child将计算为True,while循环将运行。当我们到达最左节点时,它要么是叶节点(意味着它没有子节点),要么有一个右子节点。
我们使用node.data = leftmost_node.data更新即将被移除的节点的值:
if parent_of_leftmost_node.left_child == leftmost_node:
parent_of_leftmost_node.left_child = leftmost_node.right_child
else:
parent_of_leftmost_node.right_child = leftmost_node.right_child
前面的陈述使我们能够正确地将最左节点的父节点与任何子节点正确连接。请注意等号右侧保持不变。这是因为中序后继只能有一个右子节点作为其唯一子节点。
remove操作的时间复杂度为O(h),其中h是树的高度。
搜索树
由于insert方法以特定方式组织数据,我们将遵循相同的过程来查找数据。在这个实现中,如果找到了数据,我们将简单地返回数据,如果没有找到数据,则返回None:
def search(self, data):
我们需要从最顶部开始搜索,也就是从根节点开始:
current = self.root_node
while True:
我们可能已经经过了一个叶节点,这种情况下数据不存在于树中,我们将返回None给客户端代码:
if current is None:
return None
我们也可能已经找到了数据,这种情况下我们会返回它:
elif current.data is data:
return data
根据 BST 中数据存储的规则,如果我们正在搜索的数据小于当前节点的数据,我们需要向树的左侧移动:
elif current.data > data:
current = current.left_child
现在我们只剩下一个选择:我们正在寻找的数据大于当前节点中保存的数据,这意味着我们需要向树的右侧移动:
else:
current = current.right_child
最后,我们可以编写一些客户端代码来测试 BST 的工作原理。我们创建一棵树,并在 1 到 10 之间插入一些数字。然后我们搜索该范围内的所有数字。存在于树中的数字将被打印出来:
tree = Tree()
tree.insert(5)
tree.insert(2)
tree.insert(7)
tree.insert(9)
tree.insert(1)
for i in range(1, 10):
found = tree.search(i)
print("{}: {}".format(i, found))
树的遍历
访问树中的所有节点可以通过深度优先或广度优先完成。这种遍历方式不仅适用于二叉搜索树,而是适用于树的一般情况。
深度优先遍历
在这种遍历方式中,我们会在向上继续遍历之前,沿着一个分支(或边)到达其极限。我们将使用递归方法进行遍历。深度优先遍历有三种形式,即中序、前序和后序。
中序遍历和中缀表示法
我们大多数人可能习惯用这种方式表示算术表达式,因为这是我们通常在学校里学到的方式。操作符被插入(中缀)在操作数之间,如3 + 4。必要时,可以使用括号来构建更复杂的表达式:(4 + 5) * (5 - 3)。
在这种遍历方式中,您将访问左子树、父节点,最后是右子树。
返回树中节点的中序列表的递归函数如下:
def inorder(self, root_node):
current = root_node
if current is None:
return
self.inorder(current.left_child)
print(current.data)
self.inorder(current.right_child)
我们通过打印节点并使用current.left_child和current.right_child进行两次递归调用来访问节点。
前序遍历和前缀表示法
前缀表示法通常被称为波兰表示法。在这里,操作符在其操作数之前,如+ 3 4。由于没有优先级的歧义,因此不需要括号:* + 4 5 - 5 3。
要以前序方式遍历树,您将按照节点、左子树和右子树节点的顺序访问。
前缀表示法是 LISP 程序员所熟知的。
用于此遍历的递归函数如下:
def preorder(self, root_node):
current = root_node
if current is None:
return
print(current.data)
self.preorder(current.left_child)
self.preorder(current.right_child)
注意递归调用的顺序。
后序遍历和后缀表示法。
后缀或逆波兰表示法(RPN)将操作符放在其操作数之后,如3 4 +。与波兰表示法一样,操作符的优先级永远不会引起混淆,因此不需要括号:4 5 + 5 3 - *。
在这种遍历方式中,您将访问左子树、右子树,最后是根节点。
后序遍历方法如下:
def postorder(self, root_node):
current = root_node
if current is None:
return
self.postorder(current.left_child)
self.postorder(current.right_child)
print(current.data)
广度优先遍历
这种遍历方式从树的根开始,并从树的一个级别访问节点到另一个级别:

第 1 级的节点是节点 4。我们通过打印其值来访问此节点。接下来,我们移动到第 2 级并访问该级别上的节点,即节点 2 和 8。在最后一级,第 3 级,我们访问节点 1、3、5 和 10。
这种遍历的完整输出是 4、2、8、1、3、5 和 10。
这种遍历模式是通过使用队列数据结构实现的。从根节点开始,我们将其推入队列。队列前端的节点被访问(出队),然后打印并存储以备后用。左节点被添加到队列中,然后是右节点。由于队列不为空,我们重复这个过程。
算法的干运行将根节点 4 入队,出队并访问节点。节点 2 和 8 被入队,因为它们分别是左节点和右节点。节点 2 被出队以进行访问。它的左节点和右节点,即 1 和 3,被入队。此时,队列前端的节点是 8。我们出队并访问节点 8,之后我们入队其左节点和右节点。因此,这个过程一直持续,直到队列为空。
算法如下:
from collections import deque
class Tree:
def breadth_first_traversal(self):
list_of_nodes = []
traversal_queue = deque([self.root_node])
我们将根节点入队,并在list_of_nodes列表中保留一个访问过的节点列表。dequeue类用于维护队列:
while len(traversal_queue) > 0:
node = traversal_queue.popleft()
list_of_nodes.append(node.data)
if node.left_child:
traversal_queue.append(node.left_child)
if node.right_child:
traversal_queue.append(node.right_child)
return list_of_nodes
如果traversal_queue中的元素数量大于零,则执行循环体。队列前端的节点被弹出并附加到list_of_nodes列表。第一个if语句将node的左子节点入队,如果存在左节点。第二个if语句对右子节点执行相同的操作。
list_of_nodes在最后一个语句中返回。
二叉搜索树的好处
我们现在简要地看一下,为什么使用 BST 比使用列表进行搜索更好。假设我们有以下数据集:5、3、7、1、4、6 和 9。使用列表,最坏的情况需要在找到搜索项之前搜索整个包含七个元素的列表:

搜索9需要六次跳跃。
使用树,最坏的情况是三次比较:

搜索9需要两步。
然而请注意,如果你按照 1、2、3、5、6、7、9 的顺序将元素插入树中,那么这棵树将不会比列表更有效。我们需要首先平衡树:

因此,重要的不仅是使用 BST,而且选择自平衡树有助于改进search操作。
表达式树
树结构也用于解析算术和布尔表达式。例如,3 + 4的表达式树如下所示:

对于稍微复杂的表达式(4 + 5) * (5-3),我们将得到以下结果:

解析逆波兰表达式
现在我们将为后缀表示法中的表达式构建一棵树。然后我们将计算结果。我们将使用一个简单的树实现。为了保持简单,因为我们将通过合并较小的树来增长树,我们只需要一个树节点实现:
class TreeNode:
def __init__(self, data=None):
self.data = data
self.right = None
self.left = None
为了构建树,我们将寻求栈的帮助。很快你就会明白为什么。但目前,让我们创建一个算术表达式并设置我们的栈:
expr = "4 5 + 5 3 - *".split()
stack = Stack()
由于 Python 是一种试图具有合理默认值的语言,它的split()方法默认情况下会在空格上拆分。(如果你仔细想想,这很可能也是你期望的。)结果将是expr是一个包含值 4、5、+、5、3、-和*的列表。
expr 列表的每个元素都可能是操作符或操作数。如果我们得到一个操作数,那么我们将其嵌入到一个树节点中并将其推入堆栈。另一方面,如果我们得到一个操作符,那么我们将操作符嵌入到一个树节点中,并将其两个操作数弹出到节点的左右子节点中。在这里,我们必须小心确保第一个弹出的操作数进入右子节点,否则我们将在减法和除法中出现问题。
以下是构建树的代码:
for term in expr:
if term in "+-*/":
node = TreeNode(term)
node.right = stack.pop()
node.left = stack.pop()
else:
node = TreeNode(int(term))
stack.push(node)
请注意,在操作数的情况下,我们执行了从字符串到整数的转换。如果需要支持浮点数操作数,可以使用float()。
在这个操作结束时,我们应该在堆栈中只有一个元素,它包含了完整的树。
现在我们可能想要评估表达式。我们构建了以下小函数来帮助我们:
def calc(node):
if node.data is "+":
return calc(node.left) + calc(node.right)
elif node.data is "-":
return calc(node.left) - calc(node.right)
elif node.data is "*":
return calc(node.left) * calc(node.right)
elif node.data is "/":
return calc(node.left) / calc(node.right)
else:
return node.data
这个函数非常简单。我们传入一个节点。如果节点包含一个操作数,那么我们就简单地返回该值。然而,如果我们得到一个操作符,那么我们就对节点的两个子节点执行操作符代表的操作。然而,由于一个或多个子节点也可能包含操作符或操作数,我们在两个子节点上递归调用calc()函数(要记住每个节点的所有子节点也都是节点)。
现在我们只需要从堆栈中弹出根节点并将其传递给calc()函数,我们就应该得到计算的结果:
root = stack.pop()
result = calc(root)
print(result)
运行这个程序应该得到结果 18,这是(4 + 5) * (5 - 3)的结果。
平衡树
之前我们提到,如果节点按顺序插入树中,那么树的行为就更像是一个列表,也就是说,每个节点恰好有一个子节点。我们通常希望尽量减少树的高度,填满树中的每一行。这个过程称为平衡树。
有许多类型的自平衡树,例如红黑树、AA 树和替罪羊树。这些树在修改树的每个操作(如插入或删除)期间平衡树。
还有一些外部算法可以平衡树。这样做的好处是你不需要在每次操作时都平衡树,而是可以在需要时才进行平衡。
堆
在这一点上,我们简要介绍堆数据结构。堆是树的一种特殊形式,其中节点以特定的方式排序。堆分为最大堆和最小堆。在最大堆中,每个父节点必须始终大于或等于其子节点。因此,根节点必须是树中最大的值。最小堆则相反。每个父节点必须小于或等于其两个子节点。因此,根节点保存最小的值。
堆用于许多不同的事情。首先,它们用于实现优先队列。还有一种非常高效的排序算法,称为堆排序,使用了堆。我们将在后续章节中深入研究这些内容。
总结
在本章中,我们看了树结构和它们的一些示例用途。我们特别研究了二叉树,这是树的一个子类型,其中每个节点最多有两个子节点。
我们看到了二叉树如何作为可搜索的数据结构与 BST 一起使用。我们发现,在大多数情况下,在 BST 中查找数据比在链表中更快,尽管如果数据按顺序插入,情况就不同了,除非树是平衡的。
广度优先和深度优先搜索遍历模式也使用队列递归实现了。
我们还看了二叉树如何用来表示算术或布尔表达式。我们构建了一个表达式树来表示算术表达式。我们展示了如何使用栈来解析以逆波兰表示法编写的表达式,构建表达式树,最后遍历它以获得算术表达式的结果。
最后,我们提到了堆,这是树结构的一种特殊形式。在本章中,我们试图至少奠定堆的理论基础,以便在接下来的章节中为不同的目的实现堆。
第十章:哈希和符号表
我们之前看过列表,其中项目按顺序存储并通过索引号访问。索引号对计算机来说很有效。它们是整数,因此它们快速且易于操作。但是,它们并不总是对我们很有效。例如,如果我们有一个地址簿条目,索引号为 56,那个数字并没有告诉我们太多。没有什么可以将特定联系人与编号 56 联系起来。它只是列表中的下一个可用位置。
在本章中,我们将研究一个类似的结构:字典。字典使用关键字而不是索引号。因此,如果该联系人被称为James,我们可能会使用关键字James来定位该联系人。也就是说,我们不再通过调用contacts [56]来访问联系人,而是使用contacts ["james"]。
字典通常是使用哈希表构建的。顾名思义,哈希表依赖于一个称为哈希的概念。这就是我们将开始讨论的地方。
在本章中,我们将涵盖以下主题:
-
哈希
-
哈希表
-
具有元素的不同函数
哈希
哈希是将任意大小的数据转换为固定大小的数据的概念。更具体地说,我们将使用这个概念将字符串(或可能是其他数据类型)转换为整数。这可能听起来比实际复杂,所以让我们看一个例子。我们想要对表达式 hello world 进行哈希,也就是说,我们想要得到一个数值,可以说代表这个字符串。
通过使用 ord() 函数,我们可以得到任何字符的序数值。例如,ord('f') 函数返回 102。要得到整个字符串的哈希值,我们可以简单地对字符串中每个字符的序数值求和:
>>> sum(map(ord, 'hello world'))
1116

这很好地运行了。但是,请注意我们可以改变字符串中字符的顺序并得到相同的哈希值:
>>> sum(map(ord, 'world hello'))
1116
字符的序数值的总和对于字符串 gello xorld 也是相同的,因为 g 的序数值比 h 小 1,x 的序数值比 w 大 1,因此:
>>> sum(map(ord, 'gello xorld'))
1116

完美的哈希函数
完美的哈希函数是指每个字符串(目前我们只讨论字符串)都保证是唯一的。在实践中,哈希函数通常需要非常快,因此通常不可能创建一个能给每个字符串一个唯一哈希值的函数。相反,我们要接受有时会发生碰撞(两个或更多个字符串具有相同的哈希值),当发生这种情况时,我们需要想出一种解决策略。
与此同时,我们至少可以想出一种避免一些碰撞的方法。例如,我们可以添加一个乘数,使得每个字符的哈希值成为乘数值乘以字符的序数值。随着我们在字符串中的进展,乘数会增加。这在下面的函数中显示:
def myhash(s):
mult = 1
hv = 0
for ch in s:
hv += mult * ord(ch)
mult += 1
return hv
我们可以在先前使用的字符串上测试这个函数:
for item in ('hello world', 'world hello', 'gello xorld'):
print("{}: {}".format(item, myhash(item)))
运行程序,我们得到以下输出:
% python hashtest.py
hello world: 6736
world hello: 6616
gello xorld: 6742

请注意,最后一行是将第 2 行和第 3 行的值相乘得到的,例如 104 x 1 等于 104。
这次我们得到了不同的字符串的哈希值。当然,这并不意味着我们有一个完美的哈希。让我们尝试字符串 ad 和 ga:
% python hashtest.py
ad: 297
ga: 297
在这里,我们仍然得到了两个不同字符串相同的哈希值。正如我们之前所说的,这并不一定是一个问题,但我们需要想出一种解决碰撞的策略。我们很快将会看到这一点,但首先我们将研究哈希表的实现。
哈希表
哈希表是一种列表形式,其中元素是通过关键字而不是索引号访问的。至少,这是客户端代码将看到的方式。在内部,它将使用我们稍微修改过的哈希函数的版本,以便找到应该插入元素的索引位置。这给了我们快速查找,因为我们使用的是与键的哈希值对应的索引号。
我们首先创建一个类来保存哈希表的项目。这些项目需要有一个键和一个值,因为我们的哈希表是一个键-值存储:
class HashItem:
def __init__(self, key, value):
self.key = key
self.value = value
这给了我们一种非常简单的存储项目的方式。接下来,我们开始着手处理哈希表类本身。和往常一样,我们从构造函数开始:
class HashTable:
def __init__(self):
self.size = 256
self.slots = [None for i in range(self.size)]
self.count = 0
哈希表使用标准的 Python 列表来存储其元素。我们也可以使用之前开发的链表,但现在我们的重点是理解哈希表,所以我们将使用我们手头上的东西。
我们将哈希表的大小设置为 256 个元素。稍后,我们将研究如何在开始填充哈希表时扩展表的策略。现在,我们初始化一个包含 256 个元素的列表。这些元素通常被称为槽或桶。最后,我们添加一个计数器,用于记录实际哈希表元素的数量:

重要的是要注意表的大小和计数之间的区别。表的大小是指表中槽的总数(已使用或未使用)。表的计数,另一方面,只是指填充的槽的数量,或者换句话说,我们已经添加到表中的实际键-值对的数量。
现在,我们将把我们的哈希函数添加到表中。它将类似于我们在哈希函数部分演变的内容,但有一个小小的不同:我们需要确保我们的哈希函数返回一个介于 1 和 256 之间的值(表的大小)。一个很好的方法是返回哈希除以表的大小的余数,因为余数总是一个介于 0 和 255 之间的整数值。
哈希函数只是用于类内部的,我们在名称前面加下划线(_)来表示这一点。这是 Python 中用于表示某些内容是内部使用的常规约定:
def _hash(self, key):
mult = 1
hv = 0
for ch in key:
hv += mult * ord(ch)
mult += 1
return hv % self.size
目前,我们将假设键是字符串。我们将讨论如何稍后使用非字符串键。现在,只需记住_hash()函数将生成字符串的哈希值。
放置元素
我们使用put()函数添加元素到哈希表,并使用get()函数检索。首先,我们将看一下put()函数的实现。我们首先将键和值嵌入到HashItem类中,并计算键的哈希:
def put(self, key, value):
item = HashItem(key, value)
h = self._hash(key)
现在我们需要找到一个空槽。我们从与键的哈希值对应的槽开始。如果该槽为空,我们就在那里插入我们的项目。
然而,如果槽不为空并且项目的键与我们当前的键不同,那么我们就会发生冲突。这就是我们需要想办法处理冲突的地方。我们将通过在先前的哈希值上加一,并取除以哈希表的大小的余数来解决这个问题。这是一种线性解决冲突的方法,非常简单:

while self.slots[h] is not None:
if self.slots[h].key is key:
break
h = (h + 1) % self.size
我们已经找到了插入点。如果这是一个新元素(即先前包含None),那么我们将计数增加一。最后,我们将项目插入到所需位置的列表中:
if self.slots[h] is None:
self.count += 1
self.slots[h] = item
获取元素
get()方法的实现应该返回与键对应的值。我们还必须决定在表中不存在键时该怎么办。我们首先计算键的哈希:
def get(self, key):
h = self._hash(key)
现在,我们只需开始在列表中寻找具有我们正在搜索的键的元素,从具有传入键的哈希值的元素开始。如果当前元素不是正确的元素,那么就像在put()方法中一样,我们在先前的哈希值上加一,并取除以列表大小的余数。这就成为我们的新索引。如果我们找到包含None的元素,我们停止寻找。如果我们找到我们的键,我们返回值:

while self.slots[h] is not None:
if self.slots[h].key is key:
return self.slots[h].value
h = (h+ 1) % self.size
最后,我们决定如果在表中找不到键要做什么。在这里,我们将选择返回None。另一个好的选择可能是引发一个异常:
return None
测试哈希表
为了测试我们的哈希表,我们创建一个HashTable,把一些元素放进去,然后尝试检索这些元素。我们还将尝试get()一个不存在的键。还记得我们的哈希函数返回相同的哈希值的两个字符串 ad 和 ga 吗?为了确保,我们也把它们放进去,看看冲突是如何正确解决的:
ht = HashTable()
ht.put("good", "eggs")
ht.put("better", "ham")
ht.put("best", "spam")
ht.put("ad", "do not")
ht.put("ga", "collide")
for key in ("good", "better", "best", "worst", "ad", "ga"):
v = ht.get(key)
print(v)
运行这个代码返回以下结果:
% python hashtable.py
eggs
ham
spam
None
do not
collide
如你所见,查找键 worst 返回None,因为该键不存在。键ad和ga也返回它们对应的值,显示它们之间的冲突是如何处理的。
使用哈希表的[]
然而,使用put()和get()方法看起来并不是很好。我们希望能够将我们的哈希表视为一个列表,也就是说,我们希望能够使用ht["good"]而不是ht.get("good")。这可以很容易地通过特殊方法__setitem__()和__getitem__()来实现:
def __setitem__(self, key, value):
self.put(key, value)
def __getitem__(self, key):
return self.get(key)
我们的测试代码现在可以这样写:
ht = HashTable()
ht["good"] = "eggs"
ht["better"] = "ham"
ht["best"] = "spam"
ht["ad"] = "do not"
ht["ga"] = "collide"
for key in ("good", "better", "best", "worst", "ad", "ga"):
v = ht[key]
print(v)
print("The number of elements is: {}".format(ht.count))
注意,我们还打印了哈希表中的元素数量。这对我们接下来的讨论很有用。
非字符串键
在大多数情况下,只使用字符串作为键更有意义。但是,如果必要,你可以使用任何其他的 Python 类型。如果你创建了自己的类来用作键,你可能需要重写该类的特殊__hash__()函数,以便获得可靠的哈希值。
注意,你仍然需要计算哈希值的模运算和哈希表的大小,以获得插槽。这个计算应该发生在哈希表中,而不是在键类中,因为表知道自己的大小(键类不应该知道它所属的表的任何信息)。
扩大哈希表
在我们的示例中,哈希表的大小设置为 256。显然,随着我们向列表中添加元素,我们开始填满空插槽。在某个时候,所有的插槽都将被填满,表也将被填满。为了避免这种情况,我们可以在表快要填满时扩大表。
为了做到这一点,我们比较大小和计数。记住size保存了插槽的总数,count保存了包含元素的插槽的数量?如果count等于size,那么我们已经填满了表。
哈希表的负载因子给了我们一个指示,表中有多大比例的可用插槽正在被使用。它的定义如下:

当负载因子接近 1 时,我们需要扩大表格。实际上,我们应该在它达到那里之前就这样做,以避免变得太慢。0.75 可能是一个很好的值,用来扩大表格。
下一个问题是要扩大表多少。一个策略是简单地将表的大小加倍。
开放寻址
我们在示例中使用的冲突解决机制,线性探测,是开放寻址策略的一个例子。线性探测非常简单,因为我们在探测之间使用固定的间隔。还有其他的开放寻址策略,但它们都共享一个想法,即有一个插槽数组。当我们想要插入一个键时,我们会检查插槽是否已经有项目。如果有,我们会寻找下一个可用的插槽。
如果我们有一个包含 256 个插槽的哈希表,那么 256 就是哈希中最大的元素数量。此外,随着负载因子的增加,找到新元素的插入点将需要更长的时间。
由于这些限制,我们可能更喜欢使用不同的策略来解决冲突,例如链接。
链接
链接是一种解决冲突并避免哈希表中元素数量限制的策略。在链接中,哈希表中的插槽初始化为空列表:

当插入元素时,它将被追加到与该元素的哈希值对应的列表中。也就是说,如果您有两个具有相同哈希值 1167 的元素,这两个元素都将被添加到哈希表的插槽 1167 中存在的列表中:

上图显示了具有哈希值 51 的条目列表。
然后通过允许多个元素具有相同的哈希值来避免冲突。它还避免了插入的问题,因为负载因子增加时,我们不必寻找插槽。此外,哈希表可以容纳比可用插槽数量更多的值,因为每个插槽都包含一个可以增长的列表。
当然,如果特定插槽有很多项,搜索它们可能会变得非常缓慢,因为我们必须通过列表进行线性搜索,直到找到具有所需键的元素。这可能会减慢检索速度,这并不好,因为哈希表的目的是高效的:

上图演示了通过列表项进行线性搜索,直到找到匹配项。
我们可以在表插槽中使用另一个允许快速搜索的结构,而不是使用列表。我们已经看过二叉搜索树(BSTs)。我们可以简单地在每个插槽中放置一个(最初为空的)BST:

插槽 51 包含我们搜索键的 BST。但我们仍然可能会遇到一个问题:根据将项添加到 BST 的顺序,我们可能会得到一个搜索树,其效率与列表一样低。也就是说,树中的每个节点都只有一个子节点。为了避免这种情况,我们需要确保我们的 BST 是自平衡的。
符号表
符号表被编译器和解释器用来跟踪已声明的符号及其相关信息。符号表通常使用哈希表构建,因为高效地检索表中的符号很重要。
让我们看一个例子。假设我们有以下 Python 代码:
name = "Joe"
age = 27
这里有两个符号,名称和年龄。它们属于一个命名空间,可以是__main__,但如果您将其放在那里,它也可以是模块的名称。每个符号都有一个值;名称的值为Joe,年龄的值为27。符号表允许编译器或解释器查找这些值。符号名称和年龄成为哈希表中的键。与之关联的所有其他信息,例如值,都成为符号表条目的一部分。
不仅变量是符号,函数和类也是。它们都将被添加到我们的符号表中,因此当需要访问它们中的任何一个时,它们都可以从符号表中访问:

在 Python 中,每个加载的模块都有自己的符号表。符号表被赋予该模块的名称。这样,模块就充当了命名空间。我们可以有多个名为年龄的符号,只要它们存在于不同的符号表中。要访问其中任何一个,我们通过适当的符号表进行访问:

总结
在本章中,我们已经研究了哈希表。我们研究了如何编写哈希函数将字符串数据转换为整数数据。然后我们看了如何使用哈希键快速高效地查找对应于键的值。
我们还注意到哈希函数并不完美,可能会导致多个字符串具有相同的哈希值。这促使我们研究了冲突解决策略。
我们研究了如何扩展哈希表,以及如何查看表的负载因子,以确定何时扩展哈希表。
在本章的最后一节中,我们学习了符号表,通常使用哈希表构建。符号表允许编译器或解释器查找已定义的符号(变量、函数、类等)并检索有关其所有信息。
在下一章中,我们将讨论图和其他算法。
第十一章:图和其他算法
在本章中,我们将讨论图。 这是来自称为图论的数学分支的概念。
图用于解决许多计算问题。 它们的结构比我们所看到的其他数据结构要少得多,遍历等操作可能更加不寻常,我们将会看到。
在本章结束时,您应该能够做到以下几点:
-
了解图是什么
-
了解图的类型及其组成部分
-
知道如何表示图并遍历它
-
对优先队列有一个基本的概念
-
能够实现优先队列
-
能够确定列表中第 i 个最小元素
图
图是一组顶点和边,它们之间形成连接。 在更正式的方法中,图 G 是顶点集 V 和边集 E 的有序对,以G = (V, E)的形式给出。
这里给出了一个图的示例:

现在让我们来看一些图的定义:
-
节点或顶点:图中通常由一个点表示。 顶点或节点是 A、B、C、D 和 E。
-
边:这是两个顶点之间的连接。 连接 A 和 B 的线就是边的一个例子。
-
环:当来自节点的边与自身相交时,该边形成一个环。
-
顶点的度:这是与给定顶点相交的顶点数。 顶点 B 的度为
4。 -
邻接:这指的是节点与其邻居之间的连接。 节点 C 与节点 A 相邻,因为它们之间有一条边。
-
路径:一系列顶点,其中每对相邻的顶点都由一条边连接。
有向和无向图
图可以根据它们是无向的还是有向的进行分类。 无向图简单地将边表示为节点之间的线。 除了它们连接在一起这一事实之外,关于节点之间关系的其他信息都没有:

在有向图中,边除了连接节点外还提供方向。 也就是说,边将被绘制为带有箭头的线,箭头指示边连接两个节点的方向:

边的箭头确定了方向的流动。 在上图中,只能从A到B。 而不能从B到A。
加权图
加权图在边上添加了一些额外的信息。 这可以是指示某些内容的数值。 例如,假设以下图表表示从点A到点D的不同路径。 您可以直接从A到D,也可以选择通过B和C。 与每条边相关的是到达下一个节点所需的时间(以分钟为单位):

也许旅程AD需要您骑自行车(或步行)。 B和C可能代表公交车站。 在B,您需要换乘另一辆公交车。 最后,CD可能是到达D的短途步行。
在这个例子中,AD和ABCD代表两条不同的路径。 路径只是两个节点之间穿过的边的序列。 沿着这些路径,您会发现总共需要40分钟的旅程AD,而旅程ABCD需要25分钟。 如果您唯一关心的是时间,即使需要换乘公交车,您也最好沿着ABCD行驶。
边可以是有向的,并且可能包含其他信息,例如所花费的时间或路径上关联的其他值,这表明了一些有趣的事情。在我们之前使用的数据结构中,我们绘制的线只是连接器。即使它们有箭头从一个节点指向另一个节点,也可以通过在节点类中使用next或previous、parent或child来表示。
对于图来说,将边视为对象与节点一样是有意义的。就像节点一样,边可以包含跟随特定路径所必需的额外信息。
图表示
图可以用两种主要形式表示。一种方法是使用邻接矩阵,另一种方法是使用邻接表。
我们将使用以下图来开发图的两种表示形式:

邻接表
可以使用简单的列表来表示图。列表的索引将表示图中的节点或顶点。在每个索引处,可以存储该顶点的相邻节点:

盒子中的数字代表顶点。索引0代表顶点A,其相邻节点为B和C。
使用列表进行表示相当受限,因为我们缺乏直接使用顶点标签的能力。因此,使用字典更合适。为了表示图中的图表,我们可以使用以下语句:
graph = dict()
graph['A'] = ['B', 'C']
graph['B'] = ['E','A']
graph['C'] = ['A', 'B', 'E','F']
graph['E'] = ['B', 'C']
graph['F'] = ['C']
现在我们很容易确定顶点A有相邻顶点B和C。顶点 F 只有顶点C作为其邻居。
邻接矩阵
图可以使用邻接矩阵来表示的另一种方法。矩阵是一个二维数组。这里的想法是用 1 或 0 来表示两个顶点是否通过一条边连接。
给定邻接表,应该可以创建邻接矩阵。需要一个图的键的排序列表:
matrix_elements = sorted(graph.keys())
cols = rows = len(matrix_elements)
键的长度用于提供矩阵的维度,这些维度存储在cols和rows中。这些值在cols和rows中是相等的:
adjacency_matrix = [[0 for x in range(rows)] for y in range(cols)]
edges_list = []
然后我们设置了一个cols乘以rows的数组,并用零填充它。edges_list变量将存储构成图中边的元组。例如,节点 A 和 B 之间的边将存储为(A, B)。
使用嵌套的 for 循环填充多维数组:
for key in matrix_elements:
for neighbor in graph[key]:
edges_list.append((key,neighbor))
顶点的邻居是通过graph[key]获得的。然后,结合neighbor使用edges_list中存储的元组。
迭代的输出如下:
>>> [('A', 'B'), ('A', 'C'), ('B', 'E'), ('B', 'A'), ('C', 'A'),
('C', 'B'), ('C', 'E'), ('C', 'F'), ('E', 'B'), ('E', 'C'),
('F', 'C')]
现在需要做的是通过使用 1 来填充我们的多维数组,以标记边的存在,使用行adjacency_matrix[index_of_first_vertex][index_of_second_vertex] = 1:
for edge in edges_list:
index_of_first_vertex = matrix_elements.index(edge[0])
index_of_second_vertex = matrix_elements.index(edge[1])
adjacecy_matrix[index_of_first_vertex][index_of_second_vertex] = 1
matrix_elements数组的rows和cols从 A 到 E,索引从 0 到 5。for循环遍历我们的元组列表,并使用index方法获取要存储边的相应索引。
生成的邻接矩阵如下所示:
>>>
[0, 1, 1, 0, 0]
[1, 0, 0, 1, 0]
[1, 1, 0, 1, 1]
[0, 1, 1, 0, 0]
[0, 0, 1, 0, 0]
在第 1 列和第 1 行,0 表示 A 和 A 之间没有边。在第 2 列和第 3 行,C 和 B 之间有一条边。
图遍历
由于图不一定具有有序结构,遍历图可能更加复杂。遍历通常涉及跟踪已经访问过的节点或顶点以及尚未访问过的节点或顶点。一个常见的策略是沿着一条路径走到死胡同,然后向上走,直到有一个可以选择的路径。我们也可以迭代地从一个节点移动到另一个节点,以遍历整个图或部分图。在下一节中,我们将讨论用于图遍历的广度优先搜索和深度优先搜索算法。
广度优先搜索
广度优先搜索算法从一个节点开始,选择该节点或顶点作为其根节点,并访问相邻节点,然后探索图的下一级邻居。
考虑以下图表作为图:

该图是一个无向图的示例。我们继续使用这种类型的图来帮助解释,而不会太啰嗦。
图的邻接列表如下:
graph = dict()
graph['A'] = ['B', 'G', 'D']
graph['B'] = ['A', 'F', 'E']
graph['C'] = ['F', 'H']
graph['D'] = ['F', 'A']
graph['E'] = ['B', 'G']
graph['F'] = ['B', 'D', 'C']
graph['G'] = ['A', 'E']
graph['H'] = ['C']
为了以广度优先的方式遍历这个图,我们将使用队列。算法创建一个列表来存储已访问的节点,随着遍历过程的进行。我们将从节点 A 开始遍历。
节点 A 被排队并添加到已访问节点的列表中。之后,我们使用while循环来实现对图的遍历。在while循环中,节点 A 被出队。它未访问的相邻节点 B、G 和 D 按字母顺序排序并排队。队列现在包含节点 B、D 和 G。这些节点也被添加到已访问节点的列表中。此时,我们开始while循环的另一个迭代,因为队列不为空,这也意味着我们并没有真正完成遍历。
节点 B 被出队。在它的相邻节点 A、F 和 E 中,节点 A 已经被访问。因此,我们只按字母顺序排队节点 E 和 F。然后将节点 E 和 F 添加到已访问节点的列表中。
此时,我们的队列中包含以下节点:D、G、E 和 F。已访问节点的列表包含 A、B、D、G、E、F。
节点 D 被出队,但是它的所有相邻节点都已经被访问过,所以我们只是出队它。队列前面的下一个节点是 G。我们出队节点 G,但是我们也发现它的所有相邻节点都已经被访问,因为它们在已访问节点的列表中。节点 G 也被出队。我们也出队节点 E,因为它的所有节点都已经被访问。现在队列中唯一的节点是节点 F。
节点 F 被出队,我们意识到它的相邻节点 B、D 和 C 中,只有节点 C 还没有被访问。然后我们将节点 C 排队并将其添加到已访问节点的列表中。节点 C 被出队。节点 C 有相邻节点 F 和 H,但 F 已经被访问,只剩下节点 H。节点 H 被排队并添加到已访问节点的列表中。
最后,while循环的最后一次迭代将导致节点 H 被出队。它唯一的相邻节点 C 已经被访问过。一旦队列完全为空,循环就会中断。
在图中遍历的输出是 A、B、D、G、E、F、C、H。
广度优先搜索的代码如下所示:
from collections import deque
def breadth_first_search(graph, root):
visited_vertices = list()
graph_queue = deque([root])
visited_vertices.append(root)
node = root
while len(graph_queue) > 0:
node = graph_queue.popleft()
adj_nodes = graph[node]
remaining_elements =
set(adj_nodes).difference(set(visited_vertices))
if len(remaining_elements) > 0:
for elem in sorted(remaining_elements):
visited_vertices.append(elem)
graph_queue.append(elem)
return visited_vertices
当我们想要找出一组节点是否在已访问节点的列表中时,我们使用语句remaining_elements = set(adj_nodes).difference(set(visited_vertices))。这使用了集合对象的差异方法来找出在adj_nodes中但不在visited_vertices中的节点。
在最坏的情况下,每个顶点或节点和边都将被遍历,因此算法的时间复杂度是O(|V| + |E|),其中|V|是顶点或节点的数量,而|E|是图中边的数量。
深度优先搜索
正如其名称所示,该算法在遍历广度之前遍历图中任何特定路径的深度。因此,首先访问子节点,然后访问兄弟节点。它适用于有限图,并需要使用堆栈来维护算法的状态:
def depth_first_search(graph, root):
visited_vertices = list()
graph_stack = list()
graph_stack.append(root)
node = root
算法首先创建一个列表来存储已访问的节点。graph_stack堆栈变量用于辅助遍历过程。为了连贯起见,我们使用普通的 Python 列表作为堆栈。
起始节点称为root,并与图的邻接矩阵graph一起传递。root被推入堆栈。node = root保存堆栈中的第一个节点:
while len(graph_stack) > 0:
if node not in visited_vertices:
visited_vertices.append(node)
adj_nodes = graph[node]
if set(adj_nodes).issubset(set(visited_vertices)):
graph_stack.pop()
if len(graph_stack) > 0:
node = graph_stack[-1]
continue
else:
remaining_elements =
set(adj_nodes).difference(set(visited_vertices))
first_adj_node = sorted(remaining_elements)[0]
graph_stack.append(first_adj_node)
node = first_adj_node
return visited_vertices
while循环的主体将被执行,前提是堆栈不为空。如果node不在已访问节点的列表中,我们将其添加进去。node的所有相邻节点都被adj_nodes = graph[node]收集起来。如果所有相邻节点都已经被访问过,我们就从堆栈中弹出该节点,并将node设置为graph_stack[-1]。graph_stack[-1]是堆栈顶部的节点。continue语句跳回到while循环的测试条件的开头。
另一方面,如果并非所有相邻节点都已经被访问,那么通过使用语句remaining_elements = set(adj_nodes).difference(set(visited_vertices))来找到尚未访问的节点。
在sorted(remaining_elements)中的第一个项目被分配给first_adj_node,并被推入堆栈。然后我们将堆栈的顶部指向这个节点。
当while循环结束时,我们将返回visited_vertices。
对算法进行干扰运行将会很有用。考虑以下图表:

这样一个图的邻接列表如下所示:
graph = dict()
graph['A'] = ['B', 'S']
graph['B'] = ['A']
graph['S'] = ['A','G','C']
graph['D'] = ['C']
graph['G'] = ['S','F','H']
graph['H'] = ['G','E']
graph['E'] = ['C','H']
graph['F'] = ['C','G']
graph['C'] = ['D','S','E','F']
节点 A 被选择为我们的起始节点。节点 A 被推入堆栈,并添加到visisted_vertices列表中。这样做时,我们标记它已经被访问。堆栈graph_stack是用简单的 Python 列表实现的。我们的堆栈现在只有 A 一个元素。我们检查节点 A 的相邻节点 B 和 S。为了测试节点 A 的所有相邻节点是否都已经被访问,我们使用 if 语句:
if set(adj_nodes).issubset(set(visited_vertices)):
graph_stack.pop()
if len(graph_stack) > 0:
node = graph_stack[-1]
continue
如果所有节点都已经被访问,我们就弹出堆栈的顶部。如果堆栈graph_stack不为空,我们就将堆栈顶部的节点赋给node,并开始另一个while循环主体的执行。语句set(adj_nodes).issubset(set(visited_vertices))将在adj_nodes中的所有节点都是visited_vertices的子集时评估为True。如果 if 语句失败,这意味着还有一些节点需要被访问。我们可以通过remaining_elements = set(adj_nodes).difference(set(visited_vertices))获得这些节点的列表。
从图表中,节点B和S将被存储在remaining_elements中。我们将按字母顺序访问列表:
first_adj_node = sorted(remaining_elements)[0]
graph_stack.append(first_adj_node)
node = first_adj_node
我们对remaining_elements进行排序,并将第一个节点返回给first_adj_node。这将返回 B。我们通过将其附加到graph_stack来将节点 B 推入堆栈。我们通过将其分配给node来准备访问节点 B。
在while循环的下一次迭代中,我们将节点 B 添加到visited nodes列表中。我们发现 B 的唯一相邻节点 A 已经被访问过。因为 B 的所有相邻节点都已经被访问,我们将其从堆栈中弹出,只留下节点 A。我们返回到节点 A,并检查它的所有相邻节点是否都已经被访问。现在节点 A 只有 S 是未访问的节点。我们将 S 推入堆栈,并重新开始整个过程。
遍历的输出是 A-B-S-C-D-E-H-G-F。
深度优先搜索在解决迷宫问题、查找连通分量和查找图的桥梁等方面有应用。
其他有用的图方法
您经常关心的是找到两个节点之间的路径。您可能还希望找到节点之间的所有路径。另一个有用的方法是找到节点之间的最短路径。在无权图中,这只是它们之间边的最小数量的路径。在加权图中,正如您所见,这可能涉及计算通过一组边的总权重。
当然,在不同的情况下,您可能希望找到最长或最短的路径。
优先队列和堆
优先队列基本上是一种按优先级顺序返回项目的队列类型。这个优先级可以是,例如,最低的项目总是先弹出。虽然它被称为队列,但优先队列通常使用堆来实现,因为对于这个目的来说非常高效。
考虑到,在商店中,顾客排队等候,服务只在队列的前面提供。每个顾客都会花一些时间在队列中等待服务。如果队列中顾客的等待时间分别为 4、30、2 和 1,那么队列中的平均等待时间变为(4 + 34 + 36 + 37)/4,即27.75。然而,如果我们改变服务顺序,使等待时间最短的顾客先接受服务,那么我们会得到不同的平均等待时间。这样做,我们通过(1 + 3 + 7 + 37)/4计算我们的新平均等待时间,现在等于12,一个更好的平均等待时间。显然,按照等待时间最少的顾客开始服务是有益的。按照优先级或其他标准选择下一个项目的方法是创建优先队列的基础。
堆是满足堆属性的数据结构。堆属性规定父节点和其子节点之间必须存在一定的关系。这个属性必须适用于整个堆。
在最小堆中,父节点和子节点之间的关系是父节点必须始终小于或等于其子节点。由于这个关系,堆中最小的元素必须是根节点。
另一方面,在最大堆中,父节点大于或等于其子节点。由此可知,最大值组成了根节点。
从我们刚刚提到的内容中可以看出,堆是树,更具体地说,是二叉树。
虽然我们将使用二叉树,但实际上我们将使用一个列表来表示它。这是可能的,因为堆将存储一个完全二叉树。完全二叉树是指在开始填充下一行之前,每一行必须完全填满:

为了使索引的数学运算更容易,我们将保留列表中的第一项(索引 0)为空。之后,我们将树节点从上到下、从左到右放入列表中:

如果你仔细观察,你会注意到你可以很容易地检索任何节点 n 的子节点。左子节点位于2n,右子节点位于2n + 1。这总是成立的。
我们将看一个最小堆的实现。反过来得到最大堆的逻辑应该不难。
class Heap:
def __init__(self):
self.heap = [0]
self.size = 0
我们用零初始化我们的堆列表,以表示虚拟的第一个元素(请记住,我们只是为了简化数学而这样做)。我们还创建一个变量来保存堆的大小。这不是必需的,因为我们可以检查列表的大小,但我们总是要记得减去一个。所以我们选择保持一个单独的变量。
插入
插入一个项目本身非常简单。我们将新元素添加到列表的末尾(我们理解为树的底部)。然后我们将堆的大小增加一。
但是在每次插入后,如果需要,我们需要将新元素浮动起来。请记住,最小堆中最小的元素需要是根元素。我们首先创建一个名为float的辅助方法来处理这个问题。让我们看看它应该如何行为。想象一下我们有以下堆,并且想要插入值2:

新元素占据了第三行或级别中的最后一个插槽。它的索引值是7。现在我们将该值与其父元素进行比较。父元素位于索引7/2 = 3(整数除法)。该元素持有6,所以我们交换2:

我们的新元素已经被交换并移动到索引3。我们还没有到达堆的顶部(3 / 2 > 0),所以我们继续。我们元素的新父节点在索引3/2 = 1。所以我们比较并且如果需要的话再次交换:

在最后一次交换之后,我们得到了以下堆。请注意它如何符合堆的定义:

接下来是我们刚刚描述的实现:
def float(self, k):
我们将循环直到达到根节点,以便我们可以将元素浮动到需要到达的最高位置。由于我们使用整数除法,一旦我们低于 2,循环就会中断:
while k // 2 > 0:
比较父节点和子节点。如果父节点大于子节点,则交换两个值:
if self.heap[k] < self.heap[k//2]:
self.heap[k], self.heap[k//2] = self.heap[k//2],
self.heap[k]
最后,不要忘记向上移动树:
k //= 2
这个方法确保元素被正确排序。现在我们只需要从我们的insert方法中调用它:
def insert(self, item):
self.heap.append(item)
self.size += 1
self.float(self.size)
注意insert中的最后一行调用了float()方法来根据需要重新组织堆。
弹出
就像插入一样,pop()本身是一个简单的操作。我们移除根节点并将堆的大小减一。然而,一旦根节点被弹出,我们需要一个新的根节点。
为了尽可能简单,我们只需取列表中的最后一个项目并将其作为新的根。也就是说,我们将它移动到列表的开头。但现在我们可能不会在堆的顶部有最小的元素,所以我们执行与float操作相反的操作:让新的根节点根据需要下沉。
与插入一样,让我们看看整个操作在现有堆上是如何工作的。想象一下以下堆。我们弹出root元素,暂时使堆没有根:

由于我们不能有一个没有根的堆,我们需要用某物填充这个位置。如果我们选择将一个子节点移上去,我们将不得不弄清楚如何重新平衡整个树结构。所以,我们做一些非常有趣的事情。我们将列表中的最后一个元素移上去填充root元素的位置:

现在这个元素显然不是堆中最小的。这就是我们开始将其下沉的地方。首先我们需要确定将其下沉到哪里。我们比较两个子节点,以便较小的元素将作为根节点下沉时浮动上去:

右子节点显然更小。它的索引是3,代表根索引* 2 + 1。我们继续将我们的新根节点与该索引处的值进行比较:

现在我们的节点跳到了索引3。我们需要将其与较小的子节点进行比较。然而,现在我们只有一个子节点,所以我们不需要担心要与哪个子节点进行比较(对于最小堆来说,它总是较小的子节点):

这里不需要交换。由于没有更多的行,我们完成了。请再次注意,在sink()操作完成后,我们的堆符合堆的定义。
现在我们可以开始实现这个。在执行sink()方法之前,注意我们需要确定要将父节点与哪个子节点进行比较。好吧,让我们把这个选择放在自己的小方法中,这样代码看起来会简单一些:
def minindex(self, k):
我们可能会超出列表的末尾,在这种情况下,我们返回左子节点的索引:
if k * 2 + 1 > self.size:
return k * 2
否则,我们只需返回较小的两个子节点的索引:
elif self.heap[k*2] < self.heap[k*2+1]:
return k * 2
else:
return k * 2 + 1
现在我们可以创建sink函数:
def sink(self, k):
与之前一样,我们将循环以便将我们的元素下沉到所需的位置:
while k * 2 <= self.size:
接下来,我们需要知道左侧还是右侧的子节点进行比较。这就是我们使用minindex()函数的地方:
mi = self.minindex(k)
就像我们在float()方法中所做的那样,我们比较父节点和子节点,看看是否需要交换:
if self.heap[k] > self.heap[mi]:
self.heap[k], self.heap[mi] = self.heap[mi],
self.heap[k]
我们需要确保向下移动树,以免陷入循环:
k = mi
现在唯一剩下的就是实现pop()本身。这非常简单,因为sink()方法执行了大部分工作:
def pop(self):
item = self.heap[1]
self.heap[1] = self.heap[self.size]
self.size -= 1
self.heap.pop()
self.sink(1)
return item
测试堆
现在我们只需要一些代码来测试堆。我们首先创建我们的堆并插入一些数据:
h = Heap()
for i in (4, 8, 7, 2, 9, 10, 5, 1, 3, 6):
h.insert(i)
我们可以打印堆列表,只是为了检查元素的排序方式。如果你将其重新绘制为树形结构,你应该注意到它满足堆的所需属性:
print(h.heap)
现在我们将一个一个地弹出项目。注意项目是如何按照从低到高的顺序出来的。还要注意每次弹出后堆列表是如何改变的。最好拿出纸和笔,在每次弹出后重新绘制这个列表作为一棵树,以充分理解sink()方法的工作原理:
for i in range(10):
n = h.pop()
print(n)
print(h.heap)
在排序算法的章节中,我们将重新组织堆排序算法的代码。
一旦你的最小堆正常工作并且了解它的工作原理,实现最大堆应该是一项简单的任务。你所需要做的就是颠倒逻辑。
选择算法
选择算法属于一类算法,旨在解决在列表中找到第 i 小元素的问题。当列表按升序排序时,列表中的第一个元素将是列表中最小的项。列表中的第二个元素将是列表中第二小的元素。列表中的最后一个元素将是列表中最小的元素,但也将符合列表中最大的元素。
在创建堆数据结构时,我们已经了解到调用pop方法将返回堆中最小的元素。从最小堆中弹出的第一个元素是列表中第一个最小的元素。同样,从最小堆中弹出的第七个元素将是列表中第七小的元素。因此,要找到列表中第 i 小的元素,我们需要弹出堆i次。这是在列表中找到第 i 小的元素的一种非常简单和高效的方法。
但在第十四章中,选择算法,我们将学习另一种方法,通过这种方法我们可以在列表中找到第 i 小的元素。
选择算法在过滤嘈杂数据、查找列表中的中位数、最小和最大元素以及甚至可以应用于计算机国际象棋程序中。
摘要
本章讨论了图和堆。我们研究了使用列表和字典在 Python 中表示图的方法。为了遍历图,我们研究了广度优先搜索和深度优先搜索。
然后我们将注意力转向堆和优先队列,以了解它们的实现。本章以使用堆的概念来找到列表中第 i 小的元素而结束。
图的主题非常复杂,仅仅一章是不够的。与节点的旅程将在本章结束。下一章将引领我们进入搜索的领域,以及我们可以有效搜索列表中项目的各种方法。
第十二章:搜索
在前面章节中开发的数据结构中,对所有这些数据结构执行的一个关键操作是搜索。在本章中,我们将探讨可以用来在项目集合中查找元素的不同策略。
另一个利用搜索的重要操作是排序。在没有某种搜索操作的情况下,几乎不可能进行排序。搜索的“搜索方式”也很重要,因为它影响了排序算法的执行速度。
搜索算法分为两种广义类型。一种类型假定要对其应用搜索操作的项目列表已经排序,而另一种类型则没有。
搜索操作的性能受到即将搜索的项目是否已经排序的影响,我们将在后续主题中看到。
线性搜索
让我们把讨论重点放在线性搜索上,这是在典型的 Python 列表上执行的。

前面的列表中的元素可以通过列表索引访问。为了在列表中找到一个元素,我们使用线性搜索技术。这种技术通过使用索引从列表的开头移动到末尾来遍历元素列表。检查每个元素,如果它与搜索项不匹配,则检查下一个元素。通过从一个元素跳到下一个元素,列表被顺序遍历。
在处理本章和其他章节中的部分时,我们使用包含整数的列表来增强我们的理解,因为整数易于比较。
无序线性搜索
包含元素60、1、88、10和100的列表是无序列表的一个示例。列表中的项目没有按大小顺序排列。要在这样的列表上执行搜索操作,首先从第一个项目开始,将其与搜索项目进行比较。如果没有匹配,则检查列表中的下一个元素。这将继续进行,直到我们到达列表中的最后一个元素或找到匹配为止。
def search(unordered_list, term):
unordered_list_size = len(unordered_list)
for i in range(unordered_list_size):
if term == unordered_list[i]:
return i
return None
search函数的参数是包含我们数据的列表和我们要查找的项目,称为搜索项。
数组的大小被获取,并决定for循环执行的次数。
if term == unordered_list[i]:
...
在for循环的每次迭代中,我们测试搜索项是否等于索引指向的项目。如果为真,则无需继续搜索。我们返回发生匹配的位置。
如果循环运行到列表的末尾,没有找到匹配项,则返回None表示列表中没有这样的项目。
在无序项目列表中,没有关于如何插入元素的指导规则。这影响了搜索的方式。缺乏顺序意味着我们不能依赖任何规则来执行搜索。因此,我们必须逐个访问列表中的项目。如下图所示,对于术语66的搜索是从第一个元素开始的,然后移动到列表中的下一个元素。因此60与66进行比较,如果不相等,我们将66与1、88等进行比较,直到在列表中找到搜索项。

无序线性搜索的最坏情况运行时间为O(n)。在找到搜索项之前,可能需要访问所有元素。如果搜索项位于列表的最后位置,就会出现这种情况。
有序线性搜索
在列表的元素已经排序的情况下,我们的搜索算法可以得到改进。假设元素已按升序排序,搜索操作可以利用列表的有序性使搜索更有效。
算法简化为以下步骤:
-
顺序移动列表。
-
如果搜索项大于循环中当前检查的对象或项目,则退出并返回
None。
在迭代列表的过程中,如果搜索项大于当前项目,则没有必要继续搜索。

当搜索操作开始并且第一个元素与(5)进行比较时,没有匹配。但是因为列表中还有更多元素,搜索操作继续检查下一个元素。继续进行的更有力的原因是,我们知道搜索项可能与大于2的任何元素匹配。
经过第 4 次比较,我们得出结论,搜索项不能在6所在的位置之上找到。换句话说,如果当前项目大于搜索项,那么就意味着没有必要进一步搜索列表。
def search(ordered_list, term):
ordered_list_size = len(ordered_list)
for i in range(ordered_list_size):
if term == ordered_list[i]:
return i
elif ordered_list[i] > term:
return None
return None
if语句现在适用于此检查。elif部分测试ordered_list[i] > term的条件。如果比较结果为True,则该方法返回None。
方法中的最后一行返回None,因为循环可能会遍历列表,但仍然找不到与搜索项匹配的任何元素。
有序线性搜索的最坏情况时间复杂度为O(n)。一般来说,这种搜索被认为是低效的,特别是在处理大型数据集时。
二进制搜索
二进制搜索是一种搜索策略,通过不断减少要搜索的数据量,从而提高搜索项被找到的速度,用于在列表中查找元素。
要使用二进制搜索算法,要操作的列表必须已经排序。
二进制这个术语有很多含义,它帮助我们正确理解算法。
在每次尝试在列表中查找项目时,必须做出二进制决策。一个关键的决定是猜测列表的哪一部分可能包含我们正在寻找的项目。搜索项是否在列表的前半部分还是后半部分,也就是说,如果我们总是将列表视为由两部分组成?
如果我们不是从列表的一个单元移动到另一个单元,而是采用一个经过教育的猜测策略,我们很可能会更快地找到项目的位置。
举个例子,假设我们想要找到一本 1000 页书的中间页。我们已经知道每本书的页码是从 1 开始顺序编号的。因此可以推断,第 500 页应该正好在书的中间,而不是从第 1 页、第 2 页翻到第 500 页。假设我们现在决定寻找第 250 页。我们仍然可以使用我们的策略轻松找到这一页。我们猜想第 500 页将书分成两半。第 250 页将位于书的左侧。不需要担心我们是否能在第 500 页和第 1000 页之间找到第 250 页,因为它永远不会在那里找到。因此,使用第 500 页作为参考,我们可以打开大约在第 1 页和第 500 页之间的一半页面。这让我们更接近找到第 250 页。
以下是对有序项目列表进行二进制搜索的算法:
def binary_search(ordered_list, term):
size_of_list = len(ordered_list) - 1
index_of_first_element = 0
index_of_last_element = size_of_list
while index_of_first_element <= index_of_last_element:
mid_point = (index_of_first_element + index_of_last_element)/2
if ordered_list[mid_point] == term:
return mid_point
if term > ordered_list[mid_point]:
index_of_first_element = mid_point + 1
else:
index_of_last_element = mid_point - 1
if index_of_first_element > index_of_last_element:
return None
假设我们要找到列表中项目10的位置如下:

该算法使用while循环来迭代地调整列表中用于查找搜索项的限制。只要起始索引index_of_first_element和index_of_last_element索引之间的差异为正,while循环就会运行。
算法首先通过将第一个元素(0)的索引与最后一个元素(4)的索引相加,然后除以2找到列表的中间索引mid_point。
mid_point = (index_of_first_element + index_of_last_element)/2
在这种情况下,10并不在列表中间位置或索引上被找到。如果我们搜索的是120,我们将不得不将index_of_first_element调整为mid_point +1。但是因为10位于列表的另一侧,我们将index_of_last_element调整为mid_point-1:

现在我们的index_of_first_element和index_of_last_element的新索引分别为0和1,我们计算中点(0 + 1)/2,得到0。新的中点是0,我们找到中间项并与搜索项进行比较,ordered_list[0]得到值10。哇!我们找到了搜索项。
通过将index_of_first_element和index_of_last_element的索引重新调整,将列表大小减半,这一过程会持续到index_of_first_element小于index_of_last_element为止。当这种情况不成立时,很可能我们要搜索的项不在列表中。
这里的实现是迭代的。我们也可以通过应用移动标记搜索列表开头和结尾的相同原则,开发算法的递归变体。
def binary_search(ordered_list, first_element_index, last_element_index, term):
if (last_element_index < first_element_index):
return None
else:
mid_point = first_element_index + ((last_element_index - first_element_index) / 2)
if ordered_list[mid_point] > term:
return binary_search(ordered_list, first_element_index, mid_point-1,term)
elif ordered_list[mid_point] < term:
return binary_search(ordered_list, mid_point+1, last_element_index, term)
else:
return mid_point
对二分查找算法的这种递归实现的调用及其输出如下:
store = [2, 4, 5, 12, 43, 54, 60, 77]
print(binary_search(store, 0, 7, 2))
Output:
>> 0
递归二分查找和迭代二分查找之间唯一的区别是函数定义,以及计算mid_point的方式。在((last_element_index - first_element_index) / 2)操作之后,mid_point的计算必须将其结果加到first_element_index上。这样我们就定义了要尝试搜索的列表部分。
二分查找算法的最坏时间复杂度为O(log n)。每次迭代将列表减半,遵循元素数量的 log n 进展。
不言而喻,log x假定是指以 2 为底的对数。
插值搜索
二分查找算法的另一个变体可能更接近于模拟人类在任何项目列表上执行搜索的方式。它仍然基于尝试对排序的项目列表进行良好猜测,以便找到搜索项目的可能位置。
例如,检查以下项目列表:

要找到120,我们知道要查看列表的右侧部分。我们对二分查找的初始处理通常会首先检查中间元素,以确定是否与搜索项匹配。
更人性化的做法是选择一个中间元素,不仅要将数组分成两半,还要尽可能接近搜索项。中间位置是根据以下规则计算的:
mid_point = (index_of_first_element + index_of_last_element)/2
我们将用一个更好的公式替换这个公式,这个公式将使我们更接近搜索项。mid_point将接收nearest_mid函数的返回值。
def nearest_mid(input_list, lower_bound_index, upper_bound_index, search_value):
return lower_bound_index + (( upper_bound_index -lower_bound_index)/ (input_list[upper_bound_index] -input_list[lower_bound_index])) * (search_value -input_list[lower_bound_index])
nearest_mid函数的参数是要执行搜索的列表。lower_bound_index和upper_bound_index参数表示我们希望在其中找到搜索项的列表范围。search_value表示正在搜索的值。
这些值用于以下公式:
lower_bound_index + (( upper_bound_index - lower_bound_index)/ (input_list[upper_bound_index] - input_list[lower_bound_index])) * (search_value - input_list[lower_bound_index])
给定我们的搜索列表,44,60,75,100,120,230和250,nearest_mid将使用以下值进行计算:
lower_bound_index = 0
upper_bound_index = 6
input_list[upper_bound_index] = 250
input_list[lower_bound_index] = 44
search_value = 230
现在可以看到,mid_point将接收值5,这是我们搜索项位置的索引。二分查找将选择100作为中点,这将需要再次运行算法。
以下是典型二分查找与插值查找的更直观的区别。对于典型的二分查找,找到中点的方式如下:

可以看到,中点实际上大致站在前面列表的中间位置。这是通过列表 2 的除法得出的结果。
另一方面,插值搜索会这样移动:

在插值搜索中,我们的中点更倾向于左边或右边。这是由于在除法时使用的乘数的影响。从前面的图片可以看出,我们的中点已经偏向右边。
插值算法的其余部分与二分搜索的方式相同,只是中间位置的计算方式不同。
def interpolation_search(ordered_list, term):
size_of_list = len(ordered_list) - 1
index_of_first_element = 0
index_of_last_element = size_of_list
while index_of_first_element <= index_of_last_element:
mid_point = nearest_mid(ordered_list, index_of_first_element, index_of_last_element, term)
if mid_point > index_of_last_element or mid_point < index_of_first_element:
return None
if ordered_list[mid_point] == term:
return mid_point
if term > ordered_list[mid_point]:
index_of_first_element = mid_point + 1
else:
index_of_last_element = mid_point - 1
if index_of_first_element > index_of_last_element:
return None
nearest_mid函数使用了乘法操作。这可能产生大于upper_bound_index或小于lower_bound_index的值。当发生这种情况时,意味着搜索项term不在列表中。因此返回None表示这一点。
那么当ordered_list[mid_point]不等于搜索项时会发生什么呢?好吧,我们现在必须重新调整index_of_first_element和index_of_last_element,使算法专注于可能包含搜索项的数组部分。这就像我们在二分搜索中所做的一样。
if term > ordered_list[mid_point]:
index_of_first_element = mid_point + 1
如果搜索项大于ordered_list[mid_point]处存储的值,那么我们只需要调整index_of_first_element变量指向索引mid_point + 1。
下面的图片展示了调整的过程。index_of_first_element被调整并指向mid_point+1的索引。
这张图片只是说明了中点的调整。在插值中,中点很少将列表均分为两半。
另一方面,如果搜索项小于ordered_list[mid_point]处存储的值,那么我们只需要调整index_of_last_element变量指向索引mid_point - 1。这个逻辑被捕捉在 if 语句的 else 部分中index_of_last_element = mid_point - 1。

这张图片展示了重新计算index_of_last_element对中点位置的影响。
让我们使用一个更实际的例子来理解二分搜索和插值算法的内部工作原理。
假设列表中有以下元素:
[ 2, 4, 5, 12, 43, 54, 60, 77]
索引 0 存储了 2,索引 7 找到了值 77。现在,假设我们想在列表中找到元素 2。这两种不同的算法会如何处理?
如果我们将这个列表传递给插值search函数,nearest_mid函数将返回一个等于0的值。仅仅通过一次比较,我们就可以找到搜索项。
另一方面,二分搜索算法需要三次比较才能找到搜索项,如下图所示:

第一个计算出的mid_point是3。第二个mid_point是1,最后一个找到搜索项的mid_point是0。
选择搜索算法
二分搜索和插值搜索操作的性能比有序和无序线性搜索函数都要好。由于在列表中顺序探测元素以找到搜索项,有序和无序线性搜索的时间复杂度为O(n)。当列表很大时,这会导致性能非常差。
另一方面,二分搜索操作在尝试搜索时会将列表切成两半。在每次迭代中,我们比线性策略更快地接近搜索项。时间复杂度为O(log n)。尽管使用二分搜索可以获得速度上的提升,但它不能用于未排序的项目列表,也不建议用于小型列表。
能够找到包含搜索项的列表部分在很大程度上决定了搜索算法的性能。在插值搜索算法中,计算中间值以获得更高概率的搜索项。插值搜索的时间复杂度为O(log(log n)),这比其变体二分搜索更快。
摘要
在本章中,我们考察了两种搜索算法。讨论了线性搜索和二分搜索算法的实现以及它们的比较。本节还讨论了二分搜索变体——插值搜索。在接下来的章节中,知道使用哪种搜索操作将是相关的。
在下一章中,我们将利用所学知识对项目列表执行排序操作。
第十三章:排序
当收集到数据时,总会有必要对数据进行排序。排序操作对所有数据集都是常见的,无论是名称集合、电话号码还是简单的待办事项列表。
在本章中,我们将学习一些排序技术,包括以下内容:
-
冒泡排序
-
插入排序
-
选择排序
-
快速排序
-
堆排序
在我们对这些排序算法的处理中,我们将考虑它们的渐近行为。一些算法相对容易开发,但性能可能较差。其他一些稍微复杂的算法将表现出色。
排序后,对一组项目进行搜索操作变得更加容易。我们将从最简单的排序算法开始--冒泡排序算法。
排序算法
在本章中,我们将介绍一些排序算法,这些算法的实现难度各不相同。排序算法根据它们的内存使用、复杂性、递归性质、是否基于比较等等因素进行分类。
一些算法使用更多的 CPU 周期,因此具有较差的渐近值。其他算法在对一些值进行排序时会消耗更多的内存和其他计算资源。另一个考虑因素是排序算法如何适合递归或迭代表达。有些算法使用比较作为排序元素的基础。冒泡排序算法就是一个例子。非比较排序算法的例子包括桶排序和鸽巢排序。
冒泡排序
冒泡排序算法的思想非常简单。给定一个无序列表,我们比较列表中的相邻元素,每次只放入正确的大小顺序,只有两个元素。该算法依赖于一个交换过程。
取一个只有两个元素的列表:

要对这个列表进行排序,只需将它们交换到正确的位置,2 占据索引 0,5 占据索引 1。为了有效地交换这些元素,我们需要一个临时存储区域:

冒泡排序算法的实现从交换方法开始,如前面的图像所示。首先,元素5将被复制到临时位置temp。然后元素2将被移动到索引0。最后,5将从 temp 移动到索引1。最终,元素将被交换。列表现在将包含元素:[2, 5]。以下代码将交换unordered_list[j]的元素与unordered_list[j+1]的元素,如果它们不是按正确顺序排列的:
temp = unordered_list[j]
unordered_list[j] = unordered_list[j+1]
unordered_list[j+1] = temp
现在我们已经能够交换一个两元素数组,使用相同的思想对整个列表进行排序应该很简单。
我们将在一个双重嵌套循环中运行这个交换操作。内部循环如下:
for j in range(iteration_number):
if unordered_list[j] > unordered_list[j+1]:
temp = unordered_list[j]
unordered_list[j] = unordered_list[j+1]
unordered_list[j+1] = temp
在实现冒泡排序算法时,知道交换的次数是很重要的。要对诸如[3, 2, 1]的数字列表进行排序,我们需要最多交换两次元素。这等于列表长度减 1,iteration_number = len(unordered_list)-1。我们减去1是因为它恰好给出了最大迭代次数:

通过在精确两次迭代中交换相邻元素,最大的数字最终位于列表的最后位置。
if 语句确保如果两个相邻元素已经按正确顺序排列,则不会发生不必要的交换。内部的 for 循环只会在我们的列表中精确发生两次相邻元素的交换。
然而,你会意识到第一次运行 for 循环并没有完全排序我们的列表。这个交换操作必须发生多少次,才能使整个列表排序好呢?如果我们重复整个交换相邻元素的过程多次,列表就会排序好。外部循环用于实现这一点。列表中元素的交换会导致以下动态变化:

我们意识到最多需要四次比较才能使我们的列表排序好。因此,内部和外部循环都必须运行 len(unordered_list)-1 次,才能使所有元素都排序好:
iteration_number = len(unordered_list)-1
for i in range(iteration_number):
for j in range(iteration_number):
if unordered_list[j] > unordered_list[j+1]:
temp = unordered_list[j]
unordered_list[j] = unordered_list[j+1]
unordered_list[j+1] = temp
即使列表包含许多元素,也可以使用相同的原则。冒泡排序也有很多变体,可以最小化迭代和比较的次数。
冒泡排序是一种高度低效的排序算法,时间复杂度为 O(n2),最佳情况为 O(n)。通常情况下,不应该使用冒泡排序算法来对大型列表进行排序。然而,在相对较小的列表上,它的性能还是相当不错的。
有一种冒泡排序算法的变体,如果在内部循环中没有比较,我们就会简单地退出整个排序过程。在内部循环中不需要交换元素的情况下,表明列表已经排序好了。在某种程度上,这可以帮助加快通常被认为是缓慢的算法。
插入排序
通过交换相邻元素来对一系列项目进行排序的想法也可以用于实现插入排序。在插入排序算法中,我们假设列表的某个部分已经排序好了,而另一部分仍然未排序。在这种假设下,我们遍历列表的未排序部分,一次选择一个元素。对于这个元素,我们遍历列表的排序部分,并按正确的顺序将其插入,以使列表的排序部分保持排序。这是很多语法。让我们通过一个例子来解释一下。
考虑以下数组:

该算法首先使用 for 循环在索引 1 和 4 之间运行。我们从索引 1 开始,因为我们假设索引 0 的子数组已经按顺序排序好了:

在循环执行开始时,我们有以下情况:
for index in range(1, len(unsorted_list)):
search_index = index
insert_value = unsorted_list[index]
在每次运行 for 循环时,unsorted_list[index] 处的元素被存储在 insert_value 变量中。稍后,当我们找到列表排序部分的适当位置时,insert_value 将被存储在该索引或位置上:
for index in range(1, len(unsorted_list)):
search_index = index
insert_value = unsorted_list[index]
while search_index > 0 and unsorted_list[search_index-1] >
insert_value :
unsorted_list[search_index] = unsorted_list[search_index-1]
search_index -= 1
unsorted_list[search_index] = insert_value
search_index 用于向 while 循环提供信息--确切地指出在列表的排序部分中需要插入的下一个元素的位置。
while 循环向后遍历列表,受两个条件的控制:首先,如果 search_index > 0,那么意味着列表的排序部分还有更多的元素;其次,while 循环运行时,unsorted_list[search_index-1] 必须大于 insert_value。unsorted_list[search_index-1] 数组将执行以下操作之一:
-
在第一次执行
while循环之前,指向unsorted_list[search_index]之前的一个元素 -
在第一次运行
while循环后,指向unsorted_list[search_index-1]之前的一个元素
在我们的列表示例中,while 循环将被执行,因为 5 > 1。在 while 循环的主体中,unsorted_list[search_index-1] 处的元素被存储在 unsorted_list[search_index] 处。search_index -= 1 使列表遍历向后移动,直到它的值为 0。
我们的列表现在是这样的:

while循环退出后,search_index的最后已知位置(在这种情况下为0)现在帮助我们知道在哪里插入insert_value:

在for循环的第二次迭代中,search_index将具有值2,这是数组中第三个元素的索引。此时,我们从左向右(朝向索引0)开始比较。100将与5进行比较,但由于100大于5,while循环将不会执行。100将被自己替换,因为search_index变量从未被减少。因此,unsorted_list[search_index] = insert_value将不会产生任何效果。
当search_index指向索引3时,我们将2与100进行比较,并将100移动到2所存储的位置。然后我们将2与5进行比较,并将5移动到最初存储100的位置。此时,while循环将中断,2将存储在索引1中。数组将部分排序,值为[1, 2, 5, 100, 10]。
前面的步骤将再次发生一次,以便对列表进行排序。
插入排序算法被认为是稳定的,因为它不会改变具有相等键的元素的相对顺序。它也只需要的内存不多于列表消耗的内存,因为它是原地交换。
它的最坏情况值为O(n²),最佳情况为O(n)。
选择排序
另一个流行的排序算法是选择排序。这种排序算法简单易懂,但效率低下,其最坏和最佳渐近值为O(n²)。它首先找到数组中最小的元素,并将其与数据交换,例如,数组索引[0]处的数据。然后再次执行相同的操作;然而,在找到第一个最小元素后,列表剩余部分中的最小元素将与索引[1]处的数据交换。
为了更好地解释算法的工作原理,让我们对一组数字进行排序:

从索引0开始,我们搜索列表中在索引1和最后一个元素的索引之间存在的最小项。找到这个元素后,它将与索引0处找到的数据交换。我们只需重复此过程,直到列表变得有序。
在列表中搜索最小项是一个递增的过程:

对元素2和5进行比较,选择2作为较小的元素。这两个元素被交换。
交换操作后,数组如下所示:

仍然在索引0处,我们将2与65进行比较:

由于65大于2,所以这两个元素不会交换。然后在索引0处的元素2和索引3处的元素10之间进行了进一步的比较。不会发生交换。当我们到达列表中的最后一个元素时,最小的元素将占据索引0。
一个新的比较集将开始,但这一次是从索引1开始。我们重复整个比较过程,将存储在那里的元素与索引2到最后一个索引之间的所有元素进行比较。
第二次迭代的第一步将如下所示:

以下是选择排序算法的实现。函数的参数是我们想要按升序排列的未排序项目列表的大小:
def selection_sort(unsorted_list):
size_of_list = len(unsorted_list)
for i in range(size_of_list):
for j in range(i+1, size_of_list):
if unsorted_list[j] < unsorted_list[i]:
temp = unsorted_list[i]
unsorted_list[i] = unsorted_list[j]
unsorted_list[j] = temp
算法从使用外部for循环开始遍历列表size_of_list,多次。因为我们将size_of_list传递给range方法,它将产生一个从0到size_of_list-1的序列。这是一个微妙的注释。
内部循环负责遍历列表,并在遇到小于unsorted_list[i]指向的元素时进行必要的交换。注意,内部循环从i+1开始,直到size_of_list-1。内部循环开始在i+1之间搜索最小的元素,但使用j索引:

上图显示了算法搜索下一个最小项的方向。
快速排序
快速排序算法属于分治算法类,其中我们将问题分解为更简单的小块来解决。在这种情况下,未排序的数组被分解成部分排序的子数组,直到列表中的所有元素都处于正确的位置,此时我们的未排序列表将变为已排序。
列表分区
在我们将列表分成更小的块之前,我们必须对其进行分区。这是快速排序算法的核心。要对数组进行分区,我们必须首先选择一个枢轴。数组中的所有元素将与此枢轴进行比较。在分区过程结束时,小于枢轴的所有元素将位于枢轴的左侧,而大于枢轴的所有元素将位于数组中枢轴的右侧。
枢轴选择
为了简单起见,我们将任何数组中的第一个元素作为枢轴。这种枢轴选择会降低性能,特别是在对已排序列表进行排序时。随机选择数组中间或最后一个元素作为枢轴也不会改善情况。在下一章中,我们将采用更好的方法来选择枢轴,以帮助我们找到列表中的最小元素。
实施
在深入代码之前,让我们通过使用快速排序算法对列表进行排序的步骤。首先要理解分区步骤非常重要,因此我们将首先解决该操作。
考虑以下整数列表。我们将使用以下分区函数对此列表进行分区:

def partition(unsorted_array, first_index, last_index):
pivot = unsorted_array[first_index]
pivot_index = first_index
index_of_last_element = last_index
less_than_pivot_index = index_of_last_element
greater_than_pivot_index = first_index + 1
...
分区函数接收我们需要分区的数组作为参数:其第一个元素的索引和最后一个元素的索引。
枢轴的值存储在pivot变量中,而其索引存储在pivot_index中。我们没有使用unsorted_array[0],因为当调用未排序数组参数时,索引0不一定指向该数组中的第一个元素。枢轴的下一个元素的索引,first_index + 1,标记了我们开始在数组中寻找大于pivot的元素的位置,greater_than_pivot_index = first_index + 1。
less_than_pivot_index = index_of_last_element标记了列表中最后一个元素的位置,即我们开始搜索小于枢轴的元素的位置:
while True:
while unsorted_array[greater_than_pivot_index] < pivot and
greater_than_pivot_index < last_index:
greater_than_pivot_index += 1
while unsorted_array[less_than_pivot_index] > pivot and
less_than_pivot_index >= first_index:
less_than_pivot_index -= 1
在执行主while循环之前,数组如下所示:

第一个内部while循环每次向右移动一个索引,直到落在索引2上,因为该索引处的值大于43。此时,第一个while循环中断并不再继续。在第一个while循环的条件测试中,只有当while循环的测试条件评估为True时,才会评估greater_than_pivot_index += 1。这使得对大于枢轴的元素的搜索向右侧的下一个元素进行。
第二个内部while循环每次向左移动一个索引,直到落在索引5上,其值20小于43:

此时,内部while循环都无法继续执行:
if greater_than_pivot_index < less_than_pivot_index:
temp = unsorted_array[greater_than_pivot_index]
unsorted_array[greater_than_pivot_index] =
unsorted_array[less_than_pivot_index]
unsorted_array[less_than_pivot_index] = temp
else:
break
由于greater_than_pivot_index < less_than_pivot_index,if 语句的主体交换了这些索引处的元素。else 条件在任何时候greater_than_pivot_index变得大于less_than_pivot_index时打破无限循环。在这种情况下,这意味着greater_than_pivot_index和less_than_pivot_index已经交叉。
我们的数组现在是这样的:

当less_than_pivot_index等于3且greater_than_pivot_index等于4时,执行 break 语句。
一旦我们退出while循环,我们就会交换unsorted_array[less_than_pivot_index]的元素和less_than_pivot_index的元素,后者作为枢轴的索引返回:
unsorted_array[pivot_index]=unsorted_array[less_than_pivot_index]
unsorted_array[less_than_pivot_index]=pivot
return less_than_pivot_index
下面的图片显示了代码在分区过程的最后一步中如何交换 4 和 43:

回顾一下,第一次调用快速排序函数时,它是围绕索引0的元素进行分区的。在分区函数返回后,我们得到数组[4, 3, 20, 43, 89, 77]。
正如你所看到的,元素43右边的所有元素都更大,而左边的元素更小。分区完成了。
使用分割点 43 和索引 3,我们将递归地对两个子数组[4, 30, 20]和[89, 77]进行排序,使用刚刚经历的相同过程。
主quick sort函数的主体如下:
def quick_sort(unsorted_array, first, last):
if last - first <= 0:
return
else:
partition_point = partition(unsorted_array, first, last)
quick_sort(unsorted_array, first, partition_point-1)
quick_sort(unsorted_array, partition_point+1, last)
quick sort函数是一个非常简单的方法,不超过 6 行代码。繁重的工作由partition函数完成。当调用partition方法时,它返回分区点。这是unsorted_array中的一个点,左边的所有元素都小于枢轴,右边的所有元素都大于它。
当我们在分区进度之后立即打印unsorted_array的状态时,我们清楚地看到了分区是如何进行的:
Output:
[43, 3, 20, 89, 4, 77]
[4, 3, 20, 43, 89, 77]
[3, 4, 20, 43, 89, 77]
[3, 4, 20, 43, 77, 89]
[3, 4, 20, 43, 77, 89]
退一步,让我们在第一次分区发生后对第一个子数组进行排序。[4, 3, 20]子数组的分区将在greater_than_pivot_index在索引2和less_than_pivot_index在索引1时停止。在那一点上,两个标记被认为已经交叉。因为greater_than_pivot_index大于less_than_pivot_index,while循环的进一步执行将停止。枢轴 4 将与3交换,而索引1将作为分区点返回。
快速排序算法的最坏情况复杂度为O(n²),但在对大量数据进行排序时效率很高。
堆排序
在第十一章中,图和其他算法,我们实现了(二叉)堆数据结构。我们的实现始终确保在元素被移除或添加到堆后,使用 sink 和 float 辅助方法来维护堆顺序属性。
堆数据结构可以用来实现称为堆排序的排序算法。回顾一下,让我们创建一个简单的堆,其中包含以下项目:
h = Heap()
unsorted_list = [4, 8, 7, 2, 9, 10, 5, 1, 3, 6]
for i in unsorted_list:
h.insert(i)
print("Unsorted list: {}".format(unsorted_list))
堆h被创建,并且unsorted_list中的元素被插入。在每次调用insert方法后,堆顺序属性都会通过随后调用float方法得到恢复。循环终止后,我们的堆顶部将是元素4。
我们堆中的元素数量是10。如果我们在堆对象h上调用pop方法 10 次并存储实际弹出的元素,我们最终得到一个排序好的列表。每次pop操作后,堆都会重新调整以保持堆顺序属性。
heap_sort方法如下:
class Heap:
...
def heap_sort(self):
sorted_list = []
for node in range(self.size):
n = self.pop()
sorted_list.append(n)
return sorted_list
for循环简单地调用pop方法self.size次。循环终止后,sorted_list将包含一个排序好的项目列表。
insert方法被调用n次。与float方法一起,insert操作的最坏情况运行时间为O(n log n),pop方法也是如此。因此,这种排序算法的最坏情况运行时间为O(n log n)。
总结
在本章中,我们探讨了许多排序算法。快速排序比其他排序算法表现要好得多。在讨论的所有算法中,快速排序保留了它所排序的列表的索引。在下一章中,我们将利用这一特性来探讨选择算法。
第十四章:选择算法
与在无序项目列表中查找元素相关的一组有趣的算法是选择算法。通过这样做,我们将回答与选择一组数字的中位数和选择列表中第 i 个最小或最大元素等问题有关的问题。
在本章中,我们将涵盖以下主题:
-
通过排序进行选择
-
随机选择
-
确定性选择
通过排序进行选择
列表中的项目可能会经历统计查询,如查找平均值、中位数和众数值。查找平均值和众数值不需要对列表进行排序。但是,要在数字列表中找到中位数,必须首先对列表进行排序。查找中位数需要找到有序列表中间位置的元素。但是,如果我们想要找到列表中的最后一个最小的项目或列表中的第一个最小的项目呢?
要找到无序项目列表中的第 i 个最小数字,重要的是要获得该项目出现的位置的索引。但是因为元素尚未排序,很难知道列表中索引为 0 的元素是否真的是最小的数字。
处理无序列表时要做的一个实际和明显的事情是首先对列表进行排序。一旦列表排序完成,就可以确保列表中的第零个元素将包含列表中的第一个最小元素。同样,列表中的最后一个元素将包含列表中的最后一个最小元素。
假设也许在执行搜索之前无法负担排序的奢侈。是否可能在不必首先对列表进行排序的情况下找到第 i 个最小的元素?
随机选择
在上一章中,我们研究了快速排序算法。快速排序算法允许我们对无序项目列表进行排序,但在排序算法运行时有一种保留元素索引的方式。一般来说,快速排序算法执行以下操作:
-
选择一个枢轴。
-
围绕枢轴对未排序的列表进行分区。
-
使用步骤 1和步骤 2递归地对分区列表的两半进行排序。
一个有趣且重要的事实是,在每个分区步骤之后,枢轴的索引在列表变得排序后也不会改变。正是这个属性使我们能够在一个不太完全排序的列表中工作,以获得第 i 个最小的数字。因为随机选择是基于快速排序算法的,它通常被称为快速选择。
快速选择
快速选择算法用于获取无序项目列表中的第 i 个最小元素,即数字。我们将算法的主要方法声明如下:
def quick_select(array_list, left, right, k):
split = partition(array_list, left, right)
if split == k:
return array_list[split]
elif split < k:
return quick_select(array_list, split + 1, right, k)
else:
return quick_select(array_list, left, split-1, k)
quick_select函数的参数是列表中第一个元素的索引和最后一个元素的索引。第三个参数k指定了第 i 个元素。允许大于或等于零(0)的值,这样当k为 0 时,我们知道要在列表中搜索第一个最小的项目。其他人喜欢处理k参数,使其直接映射到用户正在搜索的索引,以便第一个最小的数字映射到排序列表的 0 索引。这都是个人偏好的问题。
对partition函数的方法调用,split = partition(array_list, left, right),返回split索引。split数组的这个索引是无序列表中的位置,其中right到split-1之间的所有元素都小于split数组中包含的元素,而split+1到left之间的所有元素都大于split数组中包含的元素。
当partition函数返回split值时,我们将其与k进行比较,以找出split是否对应于第 k 个项目。
如果split小于k,那么意味着第 k 个最小的项目应该存在或者在split+1和right之间被找到:

在前面的例子中,一个想象中的无序列表在索引 5 处发生了分割,而我们正在寻找第二小的数字。由于 5<2 为false,因此进行递归调用以搜索列表的另一半:quick_select(array_list, left, split-1, k)。
如果split索引小于k,那么我们将调用quick_select:

分区步骤
分区步骤与快速排序算法中的步骤完全相同。有几点值得注意:
def partition(unsorted_array, first_index, last_index):
if first_index == last_index:
return first_index
pivot = unsorted_array[first_index]
pivot_index = first_index
index_of_last_element = last_index
less_than_pivot_index = index_of_last_element
greater_than_pivot_index = first_index + 1
while True:
while unsorted_array[greater_than_pivot_index] < pivot and
greater_than_pivot_index < last_index:
greater_than_pivot_index += 1
while unsorted_array[less_than_pivot_index] > pivot and
less_than_pivot_index >= first_index:
less_than_pivot_index -= 1
if greater_than_pivot_index < less_than_pivot_index:
temp = unsorted_array[greater_than_pivot_index]
unsorted_array[greater_than_pivot_index] =
unsorted_array[less_than_pivot_index]
unsorted_array[less_than_pivot_index] = temp
else:
break
unsorted_array[pivot_index] =
unsorted_array[less_than_pivot_index]
unsorted_array[less_than_pivot_index] = pivot
return less_than_pivot_index
在函数定义的开头插入了一个 if 语句,以处理first_index等于last_index的情况。在这种情况下,这意味着我们的子列表中只有一个元素。因此,我们只需返回函数参数中的任何一个,即first_index。
总是选择第一个元素作为枢轴。这种选择使第一个元素成为枢轴是一个随机决定。通常不会产生良好的分割和随后的良好分区。然而,最终将找到第 i 个元素,即使枢轴是随机选择的。
partition函数返回由less_than_pivot_index指向的枢轴索引,正如我们在前一章中看到的。
从这一点开始,您需要用铅笔和纸跟随程序执行,以更好地了解如何使用分割变量来确定要搜索第 i 小项的列表的部分。
确定性选择
随机选择算法的最坏情况性能为O(n²)。可以改进随机选择算法的一部分以获得O(n)的最坏情况性能。这种算法称为确定性选择。
确定性算法的一般方法如下:
-
选择一个枢轴:
-
将无序项目列表分成每组五个元素。
-
对所有组进行排序并找到中位数。
-
递归重复步骤 1和步骤 2,以获得列表的真实中位数。
-
使用真实中位数来分区无序项目列表。
-
递归进入可能包含第 i 小元素的分区列表的部分。
枢轴选择
在随机选择算法中,我们选择第一个元素作为枢轴。我们将用一系列步骤替换该步骤,以便获得真实或近似中位数。这将改善关于枢轴的列表的分区:
def partition(unsorted_array, first_index, last_index):
if first_index == last_index:
return first_index
else:
nearest_median =
median_of_medians(unsorted_array[first_index:last_index])
index_of_nearest_median =
get_index_of_nearest_median(unsorted_array, first_index,
last_index, nearest_median)
swap(unsorted_array, first_index, index_of_nearest_median)
pivot = unsorted_array[first_index]
pivot_index = first_index
index_of_last_element = last_index
less_than_pivot_index = index_of_last_element
greater_than_pivot_index = first_index + 1
现在让我们来研究分区函数的代码。nearest_median变量存储给定列表的真实或近似中位数:
def partition(unsorted_array, first_index, last_index):
if first_index == last_index:
return first_index
else:
nearest_median =
median_of_medians(unsorted_array[first_index:last_index])
....
如果unsorted_array参数只有一个元素,则first_index和last_index将相等。因此无论如何都会返回first_index。
然而,如果列表大小大于 1,我们将使用数组的部分调用median_of_medians函数,由first_index和last_index标记。返回值再次存储在nearest_median中。
中位数的中位数
median_of_medians函数负责找到任何给定项目列表的近似中位数。该函数使用递归返回真实中位数:
def median_of_medians(elems):
sublists = [elems[j:j+5] for j in range(0, len(elems), 5)]
medians = []
for sublist in sublists:
medians.append(sorted(sublist)[len(sublist)/2])
if len(medians) <= 5:
return sorted(medians)[len(medians)/2]
else:
return median_of_medians(medians)
该函数首先将列表elems分成每组五个元素。这意味着如果elems包含 100 个项目,则语句sublists = [elems[j:j+5] for j in range(0, len(elems), 5)]将创建 20 个组,每个组包含五个或更少的元素:
medians = []
for sublist in sublists:
medians.append(sorted(sublist)[len(sublist)/2])
创建一个空数组并将其分配给medians,它存储分配给sublists的每个五个元素数组中的中位数。
for 循环遍历sublists中的列表列表。对每个子列表进行排序,找到中位数,并将其存储在medians列表中。
medians.append(sorted(sublist)[len(sublist)/2])语句将对列表进行排序,并获取存储在其中间索引的元素。这成为五个元素列表的中位数。由于列表的大小较小,使用现有的排序函数不会影响算法的性能。
我们从一开始就明白,我们不会对列表进行排序以找到第 i 小的元素,那么为什么要使用 Python 的排序方法呢?嗯,由于我们只对五个或更少的非常小的列表进行排序,因此该操作对算法的整体性能的影响被认为是可以忽略的。
此后,如果列表现在包含五个或更少的元素,我们将对medians列表进行排序,并返回位于其中间索引的元素:
if len(medians) <= 5:
return sorted(medians)[len(medians)/2]
另一方面,如果列表的大小大于五,我们将再次递归调用median_of_medians函数,并向其提供存储在medians中的中位数列表。
例如,以下数字列表:
[2, 3, 5, 4, 1, 12, 11, 13, 16, 7, 8, 6, 10, 9, 17, 15, 19, 20, 18, 23, 21, 22, 25, 24, 14]
我们可以使用代码语句sublists = [elems[j:j+5] for j in range(0, len(elems), 5)]将此列表分成每个五个元素一组,以获得以下列表:
[[2, 3, 5, 4, 1], [12, 11, 13, 16, 7], [8, 6, 10, 9, 17], [15, 19, 20, 18, 23], [21, 22, 25, 24, 14]]
对每个五个元素的列表进行排序并获取它们的中位数,得到以下列表:
[3, 12, 9, 19, 22]
由于列表的大小为五个元素,我们只返回排序列表的中位数,或者我们将再次调用median_of_median函数。
分区步骤
现在我们已经获得了近似中位数,get_index_of_nearest_median函数使用first和last参数指示的列表边界:
def get_index_of_nearest_median(array_list, first, second, median):
if first == second:
return first
else:
return first + array_list[first:second].index(median)
如果列表中只有一个元素,我们再次只返回第一个索引。arraylist[first:second]返回一个索引为 0 到list-1大小的数组。当我们找到中位数的索引时,由于新的范围索引[first:second]代码返回,我们会丢失它所在的列表部分。因此,我们必须将arraylist[first:second]返回的任何索引添加到first中,以获得找到中位数的真实索引:
swap(unsorted_array, first_index, index_of_nearest_median)
然后,我们使用交换函数将unsorted_array中的第一个元素与index_of_nearest_median进行交换。
这里显示了交换两个数组元素的实用函数:
def swap(array_list, first, second):
temp = array_list[first]
array_list[first] = array_list[second]
array_list[second] = temp
我们的近似中位数现在存储在未排序列表的first_index处。
分区函数将继续进行,就像快速选择算法的代码一样。分区步骤之后,数组看起来是这样的:

def deterministic_select(array_list, left, right, k):
split = partition(array_list, left, right)
if split == k:
return array_list[split]
elif split < k :
return deterministic_select(array_list, split + 1, right, k)
else:
return deterministic_select(array_list, left, split-1, k)
正如您已经观察到的那样,确定选择算法的主要功能看起来与其随机选择对应物完全相同。在初始array_list围绕近似中位数进行分区后,将与第 k 个元素进行比较。
如果split小于k,则会递归调用deterministic_select(array_list, split + 1, right, k)。这将在数组的一半中寻找第 k 个元素。否则,将调用deterministic_select(array_list, left, split-1, k)函数。
总结
本章已经探讨了如何在列表中找到第 i 小的元素的方法。已经探讨了简单地对列表进行排序以执行查找第 i 小元素的操作的平凡解决方案。
还有可能不一定要在确定第 i 小的元素之前对列表进行排序。随机选择算法允许我们修改快速排序算法以确定第 i 小的元素。
为了进一步改进随机选择算法,以便我们可以获得O(n)的时间复杂度,我们着手寻找中位数的中位数,以便在分区期间找到一个良好的分割点。
从下一章开始,我们将改变重点,深入探讨 Python 的面向对象编程概念。
第十五章:面向对象设计
在软件开发中,设计通常被认为是编程之前的步骤。这并不正确;实际上,分析、编程和设计往往重叠、结合和交织。在本章中,我们将涵盖以下主题:
-
面向对象的含义
-
面向对象设计和面向对象编程之间的区别
-
面向对象设计的基本原则
-
基本的统一建模语言(UML)及其不邪恶的时候
介绍面向对象
每个人都知道什么是对象:我们可以感知、感觉和操作的有形物体。我们最早接触的对象通常是婴儿玩具。木块、塑料形状和超大拼图块是常见的第一个对象。婴儿很快学会了某些对象会做某些事情:铃响、按钮被按下,杠杆被拉动。
在软件开发中,对象的定义并没有太大的不同。软件对象可能不是可以拿起、感知或感觉的有形物体,但它们是能够做某些事情并且可以对它们做某些事情的模型。形式上,一个对象是一组数据和相关行为。
那么,知道了什么是对象,什么是面向对象呢?在词典中,oriented的意思是朝向。因此,面向对象意味着在功能上朝向建模对象。这是用于建模复杂系统的众多技术之一。它通过描述一组通过它们的数据和行为相互作用的对象来定义。
如果你读过一些炒作,你可能会遇到面向对象分析、面向对象设计、面向对象分析与设计和面向对象编程等术语。这些都是与面向对象相关的概念。
事实上,分析、设计和编程都是软件开发的各个阶段。将它们称为面向对象只是指定了正在追求的软件开发水平。
面向对象分析(OOA)是查看一个问题、系统或任务(某人想要将其转化为应用程序)并识别对象和对象之间交互的过程。分析阶段关乎于需要做什么。
分析阶段的输出是一组需求。如果我们能够在一个步骤中完成分析阶段,我们将把一个任务,比如我需要一个网站,转化为一组需求。例如,这里有一些关于网站访问者可能需要做的需求(斜体表示动作,粗体表示对象):
-
回顾我们的历史
-
申请 工作
-
浏览、比较和订购 产品
在某种程度上,分析是一个误称。我们之前讨论的婴儿并不分析木块和拼图块。相反,她探索她的环境,操纵形状,并看看它们可能适合在哪里。一个更好的说法可能是面向对象的探索。在软件开发中,分析的初始阶段包括采访客户,研究他们的流程,并排除可能性。
面向对象设计(OOD)是将这些要求转化为实现规范的过程。设计师必须命名对象,定义行为,并正式指定哪些对象可以激活其他对象上的特定行为。设计阶段关乎于如何完成事情。
设计阶段的输出是一个实现规范。如果我们能够在一个步骤中完成设计阶段,我们将把面向对象分析期间定义的需求转化为一组类和接口,这些类和接口可以在(理想情况下)任何面向对象编程语言中实现。
面向对象编程(OOP)是将这个完全定义的设计转化为一个完全满足 CEO 最初要求的工作程序的过程。
是的,没错!如果世界符合这个理想,我们可以按照这些阶段一步一步地按照完美的顺序进行,就像所有旧教科书告诉我们的那样。通常情况下,现实世界要复杂得多。无论我们多么努力地分隔这些阶段,我们总会发现在设计时需要进一步分析的事情。当我们编程时,我们会发现设计中需要澄清的特性。
21 世纪的大部分开发都是以迭代开发模型进行的。在迭代开发中,任务的一小部分被建模、设计和编程,然后程序被审查和扩展,以改进每个功能并在一系列短期开发周期中包括新功能。
本书的其余部分是关于面向对象编程的,但在本章中,我们将在设计的背景下介绍基本的面向对象原则。这使我们能够理解这些(相当简单的)概念,而不必与软件语法或 Python 的错误信息争论。
对象和类
因此,对象是具有相关行为的数据集合。我们如何区分对象的类型?苹果和橙子都是对象,但有一个常见的谚语说它们不能相提并论。苹果和橙子在计算机编程中并不经常被建模,但让我们假设我们正在为一个水果农场做库存应用。为了便于理解,我们可以假设苹果放在桶里,橙子放在篮子里。
现在,我们有四种对象:苹果、橙子、篮子和桶。在面向对象建模中,用于表示对象类型的术语是类。因此,在技术术语中,我们现在有四个对象类。
理解对象和类之间的区别很重要。类描述对象。它们就像创建对象的蓝图。你可能在桌子上看到三个橙子。每个橙子都是一个独特的对象,但所有三个都具有与一个类相关的属性和行为:橙子的一般类。
我们库存系统中的四个对象类之间的关系可以使用统一建模语言(通常简称为UML,因为三个字母的缩写永远不会过时)类图来描述。这是我们的第一个类图:

这张图表显示橙子与篮子以某种方式相关联,而苹果也以某种方式与桶相关联。关联是两个类相关的最基本方式。
UML 在经理中非常受欢迎,有时会受到程序员的贬低。UML 图表的语法通常相当明显;当你看到一个 UML 图表时,你不必阅读教程就能(大部分)理解发生了什么。UML 也相当容易绘制,而且相当直观。毕竟,许多人在描述类及其关系时,自然会画出盒子和它们之间的线。基于这些直观图表的标准使程序员能够与设计师、经理和彼此进行轻松的沟通。
然而,一些程序员认为 UML 是浪费时间。他们引用迭代开发,他们会认为用花哨的 UML 图表制定的正式规范在实施之前就会变得多余,并且维护这些正式图表只会浪费时间,对任何人都没有好处。
这取决于所涉及的公司结构,这可能是真的,也可能不是真的。然而,每个由多个人组成的编程团队都会偶尔坐下来详细讨论他们当前正在处理的子系统的细节。在这些头脑风暴会议中,UML 非常有用,可以进行快速而轻松的沟通。即使那些嘲笑正式类图的组织也倾向于在设计会议或团队讨论中使用某种非正式版本的 UML。
此外,你将要与之交流的最重要的人是你自己。我们都认为自己能记住我们所做的设计决策,但在未来总会有我为什么那样做?的时刻。如果我们保存我们在开始设计时做初始图表的纸屑,最终我们会发现它们是有用的参考资料。
然而,本章并不意味着是 UML 的教程。互联网上有许多这方面的教程,以及大量关于这个主题的书籍。UML 涵盖的远不止类和对象图表;它还有用例、部署、状态变化和活动的语法。在这个面向对象设计的讨论中,我们将处理一些常见的类图表语法。你可以通过示例了解结构,并在你自己的团队或个人设计会议中下意识地选择受 UML 启发的语法。
我们的初始图表虽然是正确的,但没有提醒我们苹果是放在桶里的,或者一个苹果可以放在多少个桶里。它只告诉我们苹果与桶子有某种关联。类之间的关联通常是显而易见的,不需要进一步解释,但我们可以根据需要添加进一步的说明。
UML 的美妙之处在于大多数东西都是可选的。我们只需要在图表中指定与当前情况相符的信息。在一个快速的白板会议中,我们可能只是快速地在方框之间画线。在正式文件中,我们可能会更详细地说明。在苹果和桶子的情况下,我们可以相当有信心地说这个关联是许多苹果放在一个桶里,但为了确保没有人将其与一个苹果糟蹋一个桶混淆,我们可以增强图表如下所示:

这个图表告诉我们橙子放在篮子里,有一个小箭头显示了什么放在什么里。它还告诉我们在关联的两端可以使用的对象的数量。一个Basket可以容纳许多(用表示)Orange对象。任何一个Orange可以放在一个Basket*里。这个数字被称为对象的多重性。你可能也听说过它被描述为基数。这些实际上是稍微不同的术语。基数是指集合中实际的项目数量,而多重性指定了集合可以有多小或多大。
我有时会忘记关系线的哪一端应该有哪个多重性数字。靠近类的多重性是该类的对象可以与关联的另一端的任何一个对象相关联的数量。对于苹果放在桶子的关联,从左到右阅读,Apple类的许多实例(即许多Apple对象)可以放在任何一个Barrel中。从右到左阅读,一个Barrel可以与任何一个Apple相关联。
指定属性和行为
我们现在对一些基本的面向对象术语有了了解。对象是可以相互关联的类的实例。对象实例是具有自己一组数据和行为的特定对象;我们面前桌子上的一个特定的橙子被称为是橙子类的一个实例。这已经足够简单了,但让我们深入探讨一下这两个词的含义,数据和行为。
数据描述对象
让我们从数据开始。数据代表特定对象的个体特征。一个类可以定义所有该类对象共享的特定特征集。任何特定对象可以对给定特征具有不同的数据值。例如,我们桌子上的三个橙子(如果我们没有吃掉)可能每个重量都不同。橙子类可以有一个重量属性来表示这个数据。橙子类的所有实例都有一个重量属性,但是每个橙子对于这个属性有不同的值。属性不必是唯一的,任何两个橙子可能重量相同。作为一个更现实的例子,代表不同客户的两个对象可能对于名字属性有相同的值。
属性经常被称为成员或属性。一些作者认为这些术语有不同的含义,通常是属性是可设置的,而属性是只读的。在 Python 中,只读的概念相当无意义,所以在本书中,我们会看到这两个术语可以互换使用。此外,正如我们将在第十九章中讨论的那样,property关键字在 Python 中对于特定类型的属性有特殊的含义。
在我们的水果库存应用程序中,果农可能想要知道橙子来自哪个果园,何时采摘,以及重量是多少。他们可能还想跟踪每个篮子存放在哪里。苹果可能有颜色属性,桶可能有不同的大小。这些属性中的一些也可能属于多个类(我们可能也想知道何时采摘苹果),但是对于这个第一个例子,让我们只向我们的类图添加一些不同的属性:

根据我们的设计需要多么详细,我们还可以为每个属性指定类型。属性类型通常是大多数编程语言中标准的原始数据类型,例如整数、浮点数、字符串、字节或布尔值。然而,它们也可以表示数据结构,如列表、树或图,或者更重要的是其他类。这是设计阶段可以与编程阶段重叠的一个领域。一个编程语言中可用的各种原始数据类型或对象可能与另一个编程语言中可用的不同:

通常,在设计阶段我们不需要过于关注数据类型,因为在编程阶段会选择实现特定的细节。对于设计来说,通用名称通常足够了。如果我们的设计需要列表容器类型,Java 程序员可以选择在实现时使用LinkedList或ArrayList,而 Python 程序员(就是我们!)可能会在list内置和tuple之间进行选择。
到目前为止,在我们的水果种植示例中,我们的属性都是基本的原始数据类型。然而,有一些隐含的属性我们可以明确表示——关联。对于给定的橙子,我们可能有一个属性指向包含该橙子的篮子。
行为是动作
现在我们知道了数据是什么,最后一个未定义的术语是行为。行为是可以在对象上发生的动作。可以在特定对象类上执行的行为称为方法。在编程级别上,方法就像结构化编程中的函数,但是它们神奇地可以访问与该对象关联的所有数据。与函数一样,方法也可以接受参数并返回值。
方法的参数以对象列表的形式提供给它。在特定调用期间传递给方法的实际对象实例通常被称为参数。这些对象被方法用于执行其所需的行为或任务。返回的值是该任务的结果。
我们已经将我们比较苹果和橙子的例子扩展成了一个基本的(虽然牵强)库存应用程序。让我们再扩展一下,看看是否会出现问题。可以与橙子相关联的一个动作是采摘。如果考虑实现,采摘需要做两件事:
-
通过更新橙子的篮子属性将橙子放入篮子中
-
将橙子添加到给定篮子上的橙子列表中。
因此,采摘需要知道它正在处理的篮子是哪一个。我们通过给采摘方法一个篮子参数来实现这一点。由于我们的果农还销售果汁,我们可以在橙子类中添加一个挤方法。当调用时,挤方法可能会返回所取得的果汁量,同时将橙子从其所在的篮子中移除。
篮子类可以有一个卖的动作。当篮子被卖出时,我们的库存系统可能会更新一些尚未指定的对象的数据,用于会计和利润计算。或者,我们的橙子篮在我们卖出之前可能会变坏,所以我们添加了一个丢弃的方法。让我们把这些方法添加到我们的图表中:

向个别对象添加属性和方法使我们能够创建一个相互作用的对象系统。系统中的每个对象都是某个类的成员。这些类指定了对象可以保存的数据类型以及可以在其上调用的方法。每个对象中的数据可能与同一类的其他实例处于不同的状态;由于状态的不同,每个对象对方法调用的反应可能会有所不同。
面向对象的分析和设计主要是弄清楚这些对象是什么,以及它们应该如何相互作用。接下来的部分描述了可以用来使这些交互尽可能简单和直观的原则。
隐藏细节并创建公共接口
在面向对象设计中对对象进行建模的关键目的是确定该对象的公共接口。接口是其他对象可以访问以与该对象交互的属性和方法的集合。它们不需要,通常也不允许访问对象的内部工作。
一个常见的现实世界的例子是电视。我们与电视的接口是遥控器。遥控器上的每个按钮代表着可以在电视对象上调用的方法。当我们作为调用对象访问这些方法时,我们不知道也不关心电视是通过有线连接、卫星接收器还是互联网设备接收信号。我们不关心调整音量时发送的电子信号,或者声音是发往扬声器还是耳机。如果我们打开电视以访问内部工作,例如将输出信号分成外部扬声器和一副耳机,我们将会失去保修。
这种隐藏对象实现的过程称为信息隐藏。有时也被称为封装,但封装实际上是一个更全面的术语。封装的数据不一定是隐藏的。封装,字面上来说,是创建一个胶囊(想象一下制作一个时间胶囊)。如果你把一堆信息放进一个时间胶囊里,然后锁上并埋起来,它既被封装又被隐藏。另一方面,如果时间胶囊没有被埋起来,是解锁的或者是由透明塑料制成的,里面的物品仍然被封装,但没有信息隐藏。
封装和信息隐藏之间的区别在设计层面上基本上是无关紧要的。许多实际参考资料都将这些术语互换使用。作为 Python 程序员,我们实际上并不需要真正的信息隐藏(我们将在《Python 对象》一章中讨论这一点的原因),因此更全面的封装定义是合适的。
然而,公共接口非常重要。它需要仔细设计,因为将来很难更改它。更改接口将破坏任何正在访问它的客户对象。我们可以随意更改内部,例如使其更有效,或者在本地和网络上访问数据,客户对象仍然可以使用公共接口与之通信,而无需修改。另一方面,如果我们通过更改公开访问的属性名称或方法可以接受的参数的顺序或类型来更改接口,所有客户类也必须进行修改。在设计公共接口时,保持简单。始终根据使用的便捷性而不是编码的难度来设计对象的接口(这个建议也适用于用户界面)。
记住,程序对象可能代表真实对象,但这并不意味着它们是真实对象。它们是模型。建模的最大好处之一是能够忽略不相关的细节。我小时候制作的模型汽车外观看起来像一辆真正的 1956 年的雷鸟,但显然它不能跑。当我还太小不能开车时,这些细节过于复杂和无关紧要。这个模型是对真实概念的抽象。
抽象是与封装和信息隐藏相关的另一个面向对象的术语。抽象意味着处理最适合特定任务的细节级别。这是从内部细节中提取公共接口的过程。汽车的驾驶员需要与转向、油门和刹车进行交互。发动机、传动系统和刹车子系统的工作对驾驶员并不重要。另一方面,技工在不同的抽象级别上工作,调整发动机和排气刹车。这是汽车的两个抽象级别的例子:

现在,我们有几个指涉相似概念的新术语。让我们用几句话总结所有这些行话:抽象是使用单独的公共和私有接口封装信息的过程。私有接口可能会受到信息隐藏的影响。
从所有这些定义中得出的重要教训是使我们的模型能够被必须与其交互的其他对象理解。这意味着要特别注意细节。确保方法和属性具有合理的名称。在分析系统时,对象通常代表原始问题中的名词,而方法通常是动词。属性可能显示为形容词或更多名词。相应地为您的类、属性和方法命名。
在设计接口时,想象自己是对象,并且你非常注重隐私。除非你认为让其他对象访问关于你的数据对你最有利,否则不要让它们访问。除非你确定你希望它们能够这样做,否则不要给它们一个接口来强迫你执行特定的任务。
组合
到目前为止,我们已经学会了将系统设计为一组相互作用的对象,其中每个交互都涉及以适当的抽象级别查看对象。但我们还不知道如何创建这些抽象级别。有多种方法可以做到这一点;我们将在第二十一章中讨论一些高级设计模式,迭代器模式。但是,大多数设计模式都依赖于两个基本的面向对象原则,即组合和继承。组合更简单,所以我们从它开始。
组合是将几个对象收集在一起创建一个新对象的行为。当一个对象是另一个对象的一部分时,组合通常是一个不错的选择。我们已经在机械示例中看到了组合的第一个迹象。燃油汽车由发动机、变速器、起动机、前灯和挡风玻璃等众多部件组成。发动机又由活塞、曲轴和气门组成。在这个例子中,组合是提供抽象级别的一种好方法。汽车对象可以提供驾驶员所需的接口,同时也可以访问其组件部件,这为技师提供了更深层次的抽象,适合于诊断问题或调整发动机时进一步分解这些组件部件。
汽车是一个常见的组合示例,但在设计计算机系统时并不是特别有用。物理对象很容易分解成组件对象。人们至少自古希腊时代以来一直在做这件事,最初假设原子是物质的最小单位(当然,他们当时无法接触到粒子加速器)。计算机系统通常比物理对象更简单,但是在这种系统中识别组件对象并不会自然发生。
面向对象系统中的对象有时代表诸如人、书籍或电话等物理对象。然而更多时候,它们代表抽象的概念。人有名字,书有标题,电话用于打电话。电话、标题、账户、名字、约会和付款通常不被认为是物理世界中的对象,但它们在计算机系统中经常被建模为组件。
让我们尝试建模一个更加面向计算机的例子来看看组合是如何发挥作用的。我们将研究一个计算机化的国际象棋游戏的设计。这在 80 年代和 90 年代是学者们非常受欢迎的消遣。人们曾经预测计算机有一天会能够击败人类国际象棋大师。当这在 1997 年发生时(IBM 的深蓝击败了世界国际象棋冠军加里·卡斯帕罗夫),人们对这个问题的兴趣减弱了。如今,计算机总是赢。
作为基本的高层分析,国际象棋是由两个玩家之间进行的,使用一个包含八个 8x8 网格中的六十四个位置的棋盘的国际象棋套装。棋盘上可以有两组十六个棋子,可以以不同的方式由两个玩家交替轮流 移动。每个棋子可以吃掉其他棋子。棋盘将需要在每个回合之后在计算机屏幕上绘制自己。
我用斜体标识了描述中一些可能的对象,并使用粗体标识了一些关键方法。这是将面向对象分析转化为设计的常见第一步。在这一点上,为了强调组合,我们将专注于棋盘,而不会过多担心玩家或不同类型的棋子。
让我们从可能的最高抽象级别开始。我们有两个玩家通过轮流走棋与国际象棋棋盘交互:

这看起来不太像我们早期的类图,这是一件好事,因为它不是一个!这是一个对象图,也称为实例图。它描述了系统在特定时间点的状态,并描述了对象的特定实例,而不是类之间的交互。请记住,两个玩家都是同一个类的成员,所以类图看起来有点不同:

该图表明只有两个玩家可以与一个国际象棋棋盘交互。这也表明任何一个玩家一次只能玩一个国际象棋棋盘。
然而,我们正在讨论组合,而不是 UML,所以让我们考虑一下国际象棋棋盘由什么组成。我们暂时不关心玩家由什么组成。我们可以假设玩家有心脏和大脑等器官,但这些对我们的模型无关紧要。事实上,没有什么能阻止说的玩家本身就是深蓝,它既没有心脏也没有大脑。
然后,国际象棋棋盘由棋盘和 32 个棋子组成。棋盘又包括 64 个位置。你可以争辩说棋子不是国际象棋棋盘的一部分,因为你可以用不同的棋子替换国际象棋棋盘中的棋子。虽然在计算机版本的国际象棋中这是不太可能或不可能的,但这向我们介绍了聚合。
聚合几乎与组合完全相同。不同之处在于聚合对象可以独立存在。一个位置不可能与不同的国际象棋棋盘相关联,所以我们说棋盘由位置组成。但是,棋子可能独立于国际象棋棋盘存在,因此被称为与该棋盘处于聚合关系。
区分聚合和组合的另一种方法是考虑对象的生命周期。如果组合(外部)对象控制相关(内部)对象的创建和销毁,那么组合是最合适的。如果相关对象独立于组合对象创建,或者可以比组合对象存在更久,那么聚合关系更合理。另外,请记住,组合是聚合;聚合只是组合的一种更一般的形式。任何组合关系也是聚合关系,但反之则不然。
让我们描述我们当前的国际象棋棋盘组合,并为对象添加一些属性来保存组合关系:

组合关系在 UML 中表示为实心菱形。空心菱形代表聚合关系。你会注意到棋盘和棋子以与将它们的引用存储为国际象棋棋盘的一部分,方式完全相同。这表明,一旦再次实践中,聚合和组合之间的区别通常在设计阶段过后就不再重要。在实现时,它们的行为方式大致相同。然而,当你的团队讨论不同对象如何交互时,区分它们可能有所帮助。通常情况下,你可以将它们视为相同的东西,但当你需要区分它们时(通常是在谈论相关对象存在多长时间时),了解区别是很重要的。
继承
我们讨论了对象之间的三种关系:关联、组合和聚合。然而,我们还没有完全指定我们的国际象棋棋盘,而这些工具似乎并不能给我们提供所有我们需要的功能。我们讨论了玩家可能是人类,也可能是具有人工智能的软件。说玩家与人类关联,或者说人工智能实现是玩家对象的一部分,似乎都不太合适。我们真正需要的是能够说Deep Blue 是一个玩家,或者加里·卡斯帕罗夫是一个玩家的能力。
is a关系是由继承形成的。继承是面向对象编程中最著名、最知名和最常用的关系。继承有点像家谱。我的祖父姓菲利普斯,我父亲继承了这个姓氏。我从他那里继承了它。在面向对象编程中,一个类可以从另一个类继承属性和方法,而不是从一个人那里继承特征和行为。
例如,我们的国际象棋棋盘上有 32 个棋子,但只有六种不同类型的棋子(兵、车、象、马、国王和皇后),每种棋子在移动时的行为都不同。所有这些棋子类都有属性,比如颜色和它们所属的国际象棋棋盘,但它们在国际象棋棋盘上绘制时也有独特的形状,并且移动方式不同。让我们看看这六种类型的棋子如何从Piece类继承:

空心箭头表示各个棋子类从Piece类继承。所有子类都自动从基类继承chess_set和color属性。每个棋子提供一个不同的形状属性(在渲染棋盘时绘制在屏幕上),以及一个不同的move方法,以在每一轮中将棋子移动到棋盘上的新位置。
我们实际上知道Piece类的所有子类都需要有一个move方法;否则,当棋盘试图移动棋子时,它会感到困惑。我们可能希望创建国际象棋的一个新版本,其中有一个额外的棋子(巫师)。我们当前的设计将允许我们设计这个棋子,而不给它一个move方法。然后当棋盘要求棋子移动时,它会出错。
我们可以通过在Piece类上创建一个虚拟的移动方法来解决这个问题。然后子类可以用更具体的实现覆盖这个方法。默认实现可能会弹出一个错误消息,说该棋子无法移动。
在子类中重写方法可以开发非常强大的面向对象系统。例如,如果我们想要实现一个具有人工智能的Player类,我们可以提供一个calculate_move方法,该方法接受一个Board对象,并决定将哪个棋子移动到哪里。一个非常基本的类可能会随机选择一个棋子和方向,然后相应地移动它。然后我们可以在子类中重写这个方法,使用 Deep Blue 的实现。第一个类适合与一个新手玩;后者将挑战一个国际象棋大师。重要的是,类中的其他方法,比如通知棋盘选择了哪个移动的方法,不需要改变;这个实现可以在两个类之间共享。
在国际象棋棋子的情况下,提供移动方法的默认实现并没有太多意义。我们只需要指定移动方法在任何子类中都是必需的。这可以通过使Piece成为一个抽象类,并声明abstract的移动方法来实现。抽象方法基本上是这样说的:
我们要求这种方法存在于任何非抽象子类中,但我们拒绝在这个类中指定实现。
事实上,可能会创建一个根本不实现任何方法的类。这样的类只会告诉我们类应该做什么,但绝对不会提供如何做的建议。在面向对象的术语中,这样的类被称为接口。
继承提供了抽象
让我们来探讨面向对象术语中最长的单词。多态性是指根据实现了哪个子类来对待一个类的能力。我们已经在我们描述的棋子系统中看到了它的作用。如果我们进一步设计,我们可能会发现Board对象可以接受玩家的移动并调用棋子的move函数。棋盘不需要知道它正在处理什么类型的棋子。它只需要调用move方法,适当的子类将负责将其移动为Knight或Pawn。
多态性很酷,但在 Python 编程中很少使用这个词。Python 在允许将对象的子类视为父类的基础上又迈出了一步。在 Python 中实现的棋盘可以接受任何具有move方法的对象,无论是主教棋子、汽车还是鸭子。当调用move时,Bishop将在棋盘上对角线移动,汽车将驾驶到某个地方,而鸭子将根据心情游泳或飞行。
在 Python 中,这种多态性通常被称为鸭子类型:如果它走起来像鸭子或游泳像鸭子,那它就是鸭子。我们不在乎它是否真的是一只鸭子(是一个是继承的基石),只在乎它是游泳还是走路。雁和天鹅可能很容易提供我们所寻找的鸭子般的行为。这使得未来的设计者可以创建新类型的鸟类,而无需实际指定水鸟的继承层次结构。它还允许他们创建完全不同的插入行为,原始设计者从未计划过。例如,未来的设计者可能能够创建一个行走、游泳的企鹅,它可以使用相同的接口,而从未暗示企鹅是鸭子。
多重继承
当我们想到我们自己家族谱中的继承时,我们会发现我们不仅从一个父类那里继承特征。当陌生人告诉一个骄傲的母亲她的儿子有他父亲的眼睛时,她通常会回答类似于,是的,但他有我的鼻子。
面向对象设计也可以包括这种多重继承,它允许子类从多个父类中继承功能。在实践中,多重继承可能会很棘手,一些编程语言(最著名的是 Java)严格禁止它。然而,多重继承也有其用途。最常见的用途是创建具有两组不同行为的对象。例如,一个设计用于连接扫描仪并发送扫描文档的传真的对象可能是通过从两个独立的scanner和faxer对象继承而创建的。
只要两个类具有不同的接口,子类从它们两者继承通常不会有害。但是,如果我们从提供重叠接口的两个类继承,情况就会变得混乱。例如,如果我们有一个具有move方法的摩托车类,还有一个同样具有move方法的船类,我们想将它们合并成终极两栖车时,当我们调用move时,结果类如何知道该做什么?在设计层面上,这需要解释,在实现层面上,每种编程语言都有不同的方式来决定调用哪个父类的方法,或以什么顺序调用。
通常,处理它的最佳方式是避免它。如果你的设计出现这样的情况,你很可能做错了。退一步,重新分析系统,看看是否可以取消多重继承关系,转而使用其他关联或组合设计。
继承是扩展行为的一个非常强大的工具。它也是面向对象设计相对于早期范例的最具市场潜力的进步之一。因此,它通常是面向对象程序员首先使用的工具。然而,重要的是要认识到拥有一把锤子并不会把螺丝钉变成钉子。继承是明显的“是一个”关系的完美解决方案,但它可能会被滥用。程序员经常使用继承来在两种只有遥远关联的对象之间共享代码,而看不到“是一个”关系。虽然这不一定是一个坏设计,但这是一个很好的机会去问他们为什么决定以这种方式设计,以及是否不同的关系或设计模式更合适。
案例研究
让我们通过对一个现实世界的例子进行几次迭代的面向对象设计,将我们所有新的面向对象的知识联系在一起。我们将要建模的系统是一个图书馆目录。图书馆几个世纪以来一直在跟踪他们的库存,最初使用卡片目录,最近使用电子库存。现代图书馆有基于网络的目录,我们可以在家里查询。
让我们从分析开始。当地的图书管理员要求我们编写一个新的卡片目录程序,因为他们古老的基于 Windows XP 的程序既难看又过时。这并没有给我们太多细节,但在我们开始寻求更多信息之前,让我们考虑一下我们已经对图书馆目录了解的情况。
目录包含书籍列表。人们搜索它们以找到特定主题的书籍,特定标题的书籍,或者特定作者的书籍。书籍可以通过国际标准书号(ISBN)来唯一标识。每本书都有一个杜威十进制分类法(DDS)号码,用于帮助在特定书架上找到它。
这个简单的分析告诉我们系统中一些明显的对象。我们很快确定Book是最重要的对象,其中已经提到了几个属性,比如作者、标题、主题、ISBN 和 DDS 号码,以及作为书籍管理者的编目。
我们还注意到一些其他可能需要或不需要在系统中建模的对象。为了编目的目的,我们只需要在书上搜索作者的author_name属性。然而,作者也是对象,我们可能想要存储一些关于作者的其他数据。当我们思考这一点时,我们可能会记起一些书籍有多个作者的情况。突然间,在对象上有一个单一的author_name属性的想法似乎有点愚蠢。与每本书相关联的作者列表显然是一个更好的想法。
作者和书籍之间的关系显然是关联,因为你永远不会说“一本书是一个作者”(这不是继承),而说“一本书有一个作者”,虽然在语法上是正确的,但并不意味着作者是书籍的一部分(这不是聚合)。事实上,任何一个作者可能与多本书相关联。
我们还应该注意名词(名词总是对象的好候选者)shelf。书架是需要在编目系统中建模的对象吗?我们如何识别单独的书架?如果一本书存放在一个书架的末尾,后来因为前一个书架插入了一本新书而移到了下一个书架的开头,会发生什么?
DDS 旨在帮助在图书馆中找到实体书籍。因此,将 DDS 属性与书籍一起存储应该足以找到它,无论它存放在哪个书架上。因此,我们可以暂时将书架从我们竞争对象的列表中移除。
系统中的另一个有问题的对象是用户。我们需要了解特定用户的任何信息吗,比如他们的姓名、地址或逾期书目清单?到目前为止,图书管理员只告诉我们他们想要一个目录;他们没有提到跟踪订阅或逾期通知。在我们的脑海中,我们还注意到作者和用户都是特定类型的人;在未来可能会有一个有用的继承关系。
为了编目的目的,我们决定暂时不需要识别用户。我们可以假设用户将搜索目录,但我们不必在系统中积极对他们进行建模,只需提供一个允许他们搜索的界面即可。
我们已经确定了书上的一些属性,但目录有什么属性?任何一个图书馆有多个目录吗?我们需要对它们进行唯一标识吗?显然,目录必须有它包含的书的集合,但这个列表可能不是公共接口的一部分。
行为呢?目录显然需要一个搜索方法,可能是作者、标题和主题的分开搜索。书上有什么行为?它需要一个预览方法吗?或者预览可以通过第一页属性而不是方法来识别吗?
前面讨论中的问题都是面向对象分析阶段的一部分。但在这些问题中,我们已经确定了一些设计中的关键对象。事实上,你刚刚看到的是分析和设计之间的几个微迭代。
很可能,这些迭代都会在与图书管理员的初次会议中发生。然而,在这次会议之前,我们已经可以为我们已经明确定义的对象勾勒出一个最基本的设计,如下所示:

拿着这个基本的图表和一支铅笔,我们与图书管理员会面。他们告诉我们这是一个很好的开始,但图书馆不仅仅提供书籍;他们还有 DVD、杂志和 CD,这些都没有 ISBN 或 DDS 号码。所有这些类型的物品都可以通过 UPC 号码唯一识别。我们提醒图书管理员,他们必须在书架上找到物品,而且这些物品可能不是按 UPC 号码组织的。
图书管理员解释说每种类型都是以不同的方式组织的。CD 主要是有声书,他们只有两打库存,所以它们是按作者的姓氏组织的。DVD 根据类型划分,然后按标题进一步组织。杂志按标题组织,然后按卷和期号进一步细分。书籍,正如我们猜测的那样,是按 DDS 号码组织的。
没有以前的面向对象设计经验,我们可能会考虑将 DVD、CD、杂志和书籍分别添加到我们的目录中,并依次搜索每一个。问题是,除了某些扩展属性和识别物品的物理位置之外,这些物品的行为都大致相同。这就是继承的工作!我们迅速更新我们的 UML 图表如下:

图书管理员理解了我们勾画的图表的要点,但对locate功能有点困惑。我们使用了一个特定的用例来解释,用户正在搜索单词bunnies。用户首先向目录发送搜索请求。目录查询其内部项目列表,找到了一个标题中带有bunnies的书和一个 DVD。此时,目录并不关心它是否持有 DVD、书、CD 还是杂志;在目录看来,所有项目都是一样的。然而,用户想知道如何找到这些实体项目,因此如果目录只返回一个标题列表,那就不够完善了。因此,它调用了两个发现的项目的locate方法。书的locate方法返回一个 DDS 号码,可以用来找到放置书的书架。DVD 通过返回 DVD 的流派和标题来定位。然后用户可以访问 DVD 部分,找到包含该流派的部分,并按标题排序找到特定的 DVD。
当我们解释时,我们勾画了一个 UML序列图,解释了各种对象是如何进行通信的:

虽然类图描述了类之间的关系,序列图描述了对象之间传递的特定消息序列。从每个对象悬挂的虚线是描述对象的生命周期的生命线。每个生命线上的较宽的框表示对象中的活动处理(没有框的地方,对象基本上是空闲的,等待发生某些事情)。生命线之间的水平箭头表示特定的消息。实线箭头表示被调用的方法,而带有实心头的虚线箭头表示方法返回值。
半箭头表示发送到对象或从对象发送的异步消息。异步消息通常意味着第一个对象调用第二个对象的方法,该方法立即返回。经过一些处理后,第二个对象调用第一个对象的方法来给它一个值。这与正常的方法调用相反,正常的方法调用在方法中进行处理,并立即返回一个值。
与所有 UML 图表一样,序列图只有在需要时才能最好使用。为了画图而画图是没有意义的。但是,当您需要传达两个对象之间的一系列交互时,序列图是一个非常有用的工具。
很遗憾,到目前为止,我们的类图仍然是一种混乱的设计。我们注意到 DVD 上的演员和 CD 上的艺术家都是人的类型,但与书籍作者的处理方式不同。图书管理员还提醒我们,他们的大部分 CD 都是有声书,有作者而不是艺术家。
我们如何处理为标题做出贡献的不同类型的人?一个明显的实现是创建一个Person类,包括人的姓名和其他相关细节,然后为艺术家、作者和演员创建这个类的子类。然而,在这里真的需要继承吗?对于搜索和编目的目的,我们并不真的关心表演和写作是两种非常不同的活动。如果我们正在进行经济模拟,给予单独的演员和作者类,并不同的calculate_income和perform_job方法是有意义的,但对于编目的目的,知道这个人如何为项目做出贡献就足够了。经过深思熟虑,我们意识到所有项目都有一个或多个Contributor对象,因此我们将作者关系从书籍移动到其父类中:

Contributor/LibraryItem关系的多重性是多对多,如一个关系两端的*****字符所示。任何一个图书馆项目可能有多个贡献者(例如,DVD 上的几位演员和一位导演)。许多作者写了很多书,所以他们可以附属于多个图书馆项目。
这个小改变,虽然看起来更清洁、更简单,但丢失了一些重要的信息。我们仍然可以知道谁为特定的图书馆项目做出了贡献,但我们不知道他们是如何贡献的。他们是导演还是演员?他们是写了有声书,还是为书朗读的声音?
如果我们可以在Contributor类上添加一个contributor_type属性就好了,但是当处理多才多艺的人既写书又导演电影时,这种方法就会失效。
一个选择是向我们的LibraryItem子类中添加属性来保存我们需要的信息,比如Book上的Author,或者CD上的Artist,然后将这些属性的关系都指向Contributor类。问题在于,我们失去了很多多态的优雅。如果我们想列出项目的贡献者,我们必须寻找该项目上的特定属性,比如Authors或Actors。我们可以通过在LibraryItem类上添加一个GetContributors方法来解决这个问题,子类可以重写这个方法。然后目录永远不必知道对象正在查询的属性;我们已经抽象了公共接口:

仅仅看这个类图,就感觉我们在做错事。它又臃肿又脆弱。它可能做了我们需要的一切,但感觉很难维护或扩展。关系太多,任何一个类的修改都会影响太多的类。看起来就像意大利面和肉丸。
现在我们已经探讨了继承作为一个选项,并发现它不够理想,我们可能会回顾我们之前基于组合的图表,其中Contributor直接附属于LibraryItem。经过一些思考,我们可以看到,实际上我们只需要再添加一个关系到一个全新的类,来标识贡献者的类型。这是面向对象设计中的一个重要步骤。我们现在正在向设计中添加一个旨在支持其他对象的类,而不是对初始需求的任何部分进行建模。我们正在重构设计,以便系统中的对象,而不是现实生活中的对象。重构是程序或设计维护中的一个重要过程。重构的目标是通过移动代码、删除重复代码或复杂关系,来改进设计,以获得更简单、更优雅的设计。
这个新类由一个贡献者和一个额外的属性组成,用于标识该人对给定LibraryItem所做贡献的类型。对于特定的LibraryItem可以有许多这样的贡献,一个贡献者可以以相同的方式为不同的项目做出贡献。以下的图表很好地传达了这个设计:

首先,这种组合关系看起来不如基于继承的关系自然。然而,它的优势在于允许我们添加新类型的贡献,而不必在设计中添加一个新类。当子类有某种专业化时,继承是最有用的。专业化是在子类上创建或更改属性或行为,使其在某种程度上与父类不同。创建一堆空类仅用于识别不同类型的对象似乎有些愚蠢(这种态度在 Java 和其他一切都是对象的程序员中不太普遍,但在更务实的 Python 设计师中很常见)。如果我们看继承版本的图表,我们会看到一堆实际上什么都不做的子类:

有时候,重要的是要认识到何时不使用面向对象的原则。这个不使用继承的例子很好地提醒我们,对象只是工具,而不是规则。
练习
这是一本实用书,不是教科书。因此,我不会为你创建一堆虚假的面向对象分析问题,让你分析和设计。相反,我想给你一些可以应用到自己项目中的想法。如果你有以前的面向对象经验,你就不需要在这一章节上花太多精力。然而,如果你已经使用 Python 一段时间,但从来没有真正关心过所有的类的东西,这些都是有用的心理锻炼。
首先,想想你最近完成的一个编程项目。确定设计中最突出的对象。尽量想出这个对象的尽可能多的属性。它有以下属性吗:颜色?重量?大小?利润?成本?名称?ID 号码?价格?风格?
思考属性类型。它们是基本类型还是类?其中一些属性实际上是伪装成行为?有时,看起来像数据的东西实际上是从对象的其他数据计算出来的,你可以使用一个方法来进行这些计算。这个对象还有哪些其他方法或行为?哪些对象调用了这些方法?它们与这个对象有什么样的关系?
现在,想想即将开始的项目。项目是什么并不重要;它可能是一个有趣的业余项目,也可能是一个价值数百万美元的合同。它不必是一个完整的应用程序;它可能只是一个子系统。进行基本的面向对象分析。确定需求和相互作用的对象。勾画出一个包含该系统最高抽象级别的类图。确定主要相互作用的对象。确定次要支持对象。详细了解一些最有趣的对象的属性和方法。将不同的对象带入不同的抽象级别。寻找可以使用继承或组合的地方。寻找应该避免使用继承的地方。
目标不是设计一个系统(尽管如果你的兴趣和时间允许,你当然可以这样做)。目标是思考面向对象的设计。专注于你曾经参与过的项目,或者未来打算参与的项目,这样做就更真实了。
最后,访问你最喜欢的搜索引擎,查找一些关于 UML 的教程。有数十种教程,找一个适合你的学习方法的。为你之前确定的对象勾画一些类图或序列图。不要太过于纠结于记忆语法(毕竟,如果重要的话,你总是可以再次查阅);只需对这种语言有所了解。你的大脑中会留下一些东西,如果你能快速勾画出下一个面向对象讨论的图表,那么交流会变得更容易一些。
总结
在本章中,我们快速浏览了面向对象范式的术语,重点放在面向对象设计上。我们可以将不同的对象分为不同的类别,并通过类接口描述这些对象的属性和行为。抽象、封装和信息隐藏是高度相关的概念。对象之间有许多不同类型的关系,包括关联、组合和继承。UML 语法对于乐趣和沟通可能会有用。
在下一章中,我们将探讨如何在 Python 中实现类和方法。
第十六章:Python 中的对象
因此,我们现在手头上有一个设计,并且准备将该设计转化为一个可工作的程序!当然,通常情况下不会这样。我们将在整本书中看到好的软件设计示例和提示,但我们的重点是面向对象的编程。因此,让我们来看一下 Python 语法,它允许我们创建面向对象的软件。
完成本章后,我们将了解以下内容:
-
如何在 Python 中创建类和实例化对象
-
如何向 Python 对象添加属性和行为
-
如何将类组织成包和模块
-
如何建议人们不要破坏我们的数据
创建 Python 类
我们不必写太多 Python 代码就能意识到 Python 是一种非常干净的语言。当我们想做某事时,我们可以直接做,而不必设置一堆先决条件代码。Python 中无处不在的hello world,正如你可能已经看到的,只有一行。
同样,Python 3 中最简单的类如下所示:
class MyFirstClass:
pass
这是我们的第一个面向对象的程序!类定义以class关键字开头。然后是一个名称(我们选择的)来标识类,并以冒号结束。
类名必须遵循标准的 Python 变量命名规则(必须以字母或下划线开头,只能由字母、下划线或数字组成)。此外,Python 风格指南(在网上搜索PEP 8)建议使用CapWords表示法来命名类(以大写字母开头;任何后续的单词也应以大写字母开头)。
类定义行后面是类内容,缩进。与其他 Python 结构一样,缩进用于界定类,而不是大括号、关键字或括号,就像许多其他语言使用的那样。同样符合风格指南,除非有充分的理由不这样做(比如适应使用制表符缩进的其他人的代码),否则使用四个空格进行缩进。
由于我们的第一个类实际上并没有添加任何数据或行为,我们只需在第二行使用pass关键字表示不需要采取进一步的行动。
我们可能会认为这个最基本的类没有太多可以做的,但它确实允许我们实例化该类的对象。我们可以将该类加载到 Python 3 解释器中,这样我们就可以交互式地使用它。为了做到这一点,将前面提到的类定义保存在一个名为first_class.py的文件中,然后运行python -i first_class.py命令。-i参数告诉 Python运行代码然后转到交互式解释器。以下解释器会话演示了与这个类的基本交互:
>>> a = MyFirstClass()
>>> b = MyFirstClass()
>>> print(a)
<__main__.MyFirstClass object at 0xb7b7faec>
>>> print(b)
<__main__.MyFirstClass object at 0xb7b7fbac>
>>>
这段代码从新类实例化了两个对象,命名为a和b。创建一个类的实例只需要输入类名,后面跟着一对括号。它看起来很像一个普通的函数调用,但 Python 知道我们调用的是一个类而不是一个函数,所以它知道它的工作是创建一个新对象。当打印时,这两个对象告诉我们它们属于哪个类以及它们所在的内存地址。在 Python 代码中很少使用内存地址,但在这里,它们表明有两个不同的对象参与其中。
添加属性
现在,我们有一个基本的类,但它相当无用。它不包含任何数据,也不做任何事情。我们需要做什么来为给定的对象分配属性?
实际上,在类定义中我们不必做任何特殊的事情。我们可以使用点符号在实例化的对象上设置任意属性:
class Point:
pass
p1 = Point()
p2 = Point()
p1.x = 5
p1.y = 4
p2.x = 3
p2.y = 6
print(p1.x, p1.y)
print(p2.x, p2.y)
如果我们运行这段代码,结尾的两个print语句会告诉我们两个对象上的新属性值:
5 4
3 6
这段代码创建了一个没有数据或行为的空Point类。然后,它创建了该类的两个实例,并分别为这些实例分配x和y坐标,以标识二维空间中的一个点。我们只需要使用<object>.<attribute> = <value>语法为对象的属性分配一个值。这有时被称为点符号表示法。在阅读标准库或第三方库提供的对象属性时,你可能已经遇到过这种表示法。值可以是任何东西:Python 原语、内置数据类型或另一个对象。甚至可以是一个函数或另一个类!
让它做点什么
现在,拥有属性的对象很棒,但面向对象编程实际上是关于对象之间的交互。我们感兴趣的是调用会影响这些属性的动作。我们有数据;现在是时候为我们的类添加行为了。
让我们在我们的Point类上建模一些动作。我们可以从一个名为reset的方法开始,它将点移动到原点(原点是x和y都为零的地方)。这是一个很好的介绍性动作,因为它不需要任何参数:
class Point:
def reset(self):
self.x = 0
self.y = 0
p = Point()
p.reset()
print(p.x, p.y)
这个print语句显示了属性上的两个零:
0 0
在 Python 中,方法的格式与函数完全相同。它以def关键字开头,后面跟着一个空格,然后是方法的名称。然后是一组包含参数列表的括号(我们将在接下来讨论self参数),并以冒号结束。下一行缩进包含方法内部的语句。这些语句可以是任意的 Python 代码,对对象本身和传入的任何参数进行操作,方法会自行决定。
自言自语
在方法和普通函数之间的一个语法上的区别是,所有方法都有一个必需的参数。这个参数通常被命名为self;我从未见过 Python 程序员使用其他名称来命名这个变量(约定是一件非常有力的事情)。但是没有什么能阻止你将其命名为this甚至Martha。
方法中的self参数是对调用该方法的对象的引用。我们可以访问该对象的属性和方法,就好像它是另一个对象一样。这正是我们在reset方法中所做的,当我们设置self对象的x和y属性时。
在这个讨论中,注意类和对象之间的区别。我们可以将方法视为附加到类的函数。self参数是该类的特定实例。当你在两个不同的对象上调用方法时,你调用了相同的方法两次,但是将两个不同的对象作为self参数传递。
请注意,当我们调用p.reset()方法时,我们不必将self参数传递给它。Python 会自动为我们处理这部分。它知道我们在调用p对象上的方法,所以会自动将该对象传递给方法。
然而,方法实际上只是一个恰好在类上的函数。我们可以不在对象上调用方法,而是显式地在类上调用函数,将我们的对象作为self参数传递:
>>> p = Point()
>>> Point.reset(p)
>>> print(p.x, p.y)
输出与前面的例子相同,因为在内部发生了完全相同的过程。
如果我们在类定义中忘记包括self参数会发生什么?Python 会报错,如下所示:
>>> class Point:
... def reset():
... pass
...
>>> p = Point()
>>> p.reset()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: reset() takes 0 positional arguments but 1 was given
错误消息并不像它本应该的那样清晰(嘿,傻瓜,你忘了self参数会更有信息量)。只要记住,当你看到指示缺少参数的错误消息时,首先要检查的是你是否在方法定义中忘记了self。
更多参数
那么,我们如何将多个参数传递给一个方法呢?让我们添加一个新的方法,允许我们将一个点移动到任意位置,而不仅仅是原点。我们还可以包括一个接受另一个Point对象作为输入并返回它们之间距离的方法:
import math
class Point:
def move(self, x, y):
self.x = x
self.y = y
def reset(self):
self.move(0, 0)
def calculate_distance(self, other_point):
return math.sqrt(
(self.x - other_point.x) ** 2
+ (self.y - other_point.y) ** 2
)
# how to use it:
point1 = Point()
point2 = Point()
point1.reset()
point2.move(5, 0)
print(point2.calculate_distance(point1))
assert point2.calculate_distance(point1) == point1.calculate_distance(
point2
)
point1.move(3, 4)
print(point1.calculate_distance(point2))
print(point1.calculate_distance(point1))
结尾处的print语句给出了以下输出:
5.0
4.47213595499958
0.0
这里发生了很多事情。这个类现在有三个方法。move方法接受两个参数x和y,并在self对象上设置值,就像前面示例中的旧reset方法一样。旧的reset方法现在调用move,因为重置只是移动到一个特定的已知位置。
calculate_distance方法使用不太复杂的勾股定理来计算两点之间的距离。我希望你能理解这个数学(**2表示平方,math.sqrt计算平方根),但这并不是我们当前重点的要求,我们的当前重点是学习如何编写方法。
前面示例的结尾处的示例代码显示了如何调用带有参数的方法:只需将参数包含在括号内,并使用相同的点表示法来访问方法。我只是随机选择了一些位置来测试这些方法。测试代码调用每个方法并在控制台上打印结果。assert函数是一个简单的测试工具;如果assert后面的语句评估为False(或零、空或None),程序将退出。在这种情况下,我们使用它来确保无论哪个点调用另一个点的calculate_distance方法,距离都是相同的。
初始化对象
如果我们不显式设置Point对象上的x和y位置,要么使用move,要么直接访问它们,我们就会得到一个没有真实位置的破碎点。当我们尝试访问它时会发生什么呢?
好吧,让我们试试看。试一试是 Python 学习中非常有用的工具。打开你的交互式解释器,然后开始输入。以下交互式会话显示了如果我们尝试访问一个缺失属性会发生什么。如果你将前面的示例保存为文件,或者正在使用本书分发的示例,你可以使用python -i more_arguments.py命令将其加载到 Python 解释器中:
>>> point = Point()
>>> point.x = 5
>>> print(point.x)
5
>>> print(point.y)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Point' object has no attribute 'y'
好吧,至少它抛出了一个有用的异常。我们将在第十八章中详细介绍异常,预料之外的情况。你可能以前见过它们(特别是无处不在的 SyntaxError,它意味着你输入了错误的东西!)。在这一点上,只需意识到它意味着出了问题。
输出对于调试是有用的。在交互式解释器中,它告诉我们错误发生在第 1 行,这只是部分正确的(在交互式会话中,一次只执行一行)。如果我们在文件中运行脚本,它会告诉我们确切的行号,这样很容易找到错误的代码。此外,它告诉我们错误是AttributeError,并给出一个有用的消息告诉我们这个错误是什么意思。
我们可以捕获并从这个错误中恢复,但在这种情况下,感觉我们应该指定某种默认值。也许每个新对象默认应该被reset(),或者也许当用户创建对象时,我们可以强制用户告诉我们这些位置应该是什么。
大多数面向对象的编程语言都有构造函数的概念,这是一个特殊的方法,用于在创建对象时创建和初始化对象。Python 有点不同;它有一个构造函数和一个初始化器。构造函数很少使用,除非你在做一些非常奇特的事情。所以,我们将从更常见的初始化方法开始讨论。
Python 的初始化方法与任何其他方法相同,只是它有一个特殊的名称__init__。前导和尾随的双下划线意味着这是一个特殊的方法,Python 解释器将把它视为一个特殊情况。
永远不要以双下划线开头和结尾命名自己的方法。它可能对 Python 今天无关紧要,但总有可能 Python 的设计者将来会添加一个具有该名称特殊目的的函数,当他们这样做时,你的代码将会出错。
让我们在我们的Point类上添加一个初始化函数,当实例化Point对象时需要用户提供x和y坐标:
class Point:
def __init__(self, x, y):
self.move(x, y)
def move(self, x, y):
self.x = x
self.y = y
def reset(self):
self.move(0, 0)
# Constructing a Point
point = Point(3, 5)
print(point.x, point.y)
现在,我们的点永远不会没有y坐标!如果我们尝试构造一个点而没有包括正确的初始化参数,它将失败,并显示一个类似于我们之前忘记self参数时收到的参数不足错误。
如果我们不想使这两个参数成为必需的,我们可以使用与 Python 函数使用的相同语法来提供默认参数。关键字参数语法在每个变量名称后附加一个等号。如果调用对象没有提供此参数,则将使用默认参数。变量仍然可用于函数,但它们将具有参数列表中指定的值。这是一个例子:
class Point:
def __init__(self, x=0, y=0):
self.move(x, y)
大多数情况下,我们将初始化语句放在__init__函数中。但正如前面提到的,Python 除了初始化函数外还有一个构造函数。你可能永远不需要使用另一个 Python 构造函数(在十多年的专业 Python 编码中,我只想到了两种情况,在其中一种情况下,我可能不应该使用它!),但知道它的存在是有帮助的,所以我们将简要介绍一下。
构造函数被称为__new__,而不是__init__,并且只接受一个参数;正在构造的类(在构造对象之前调用,因此没有self参数)。它还必须返回新创建的对象。在涉及复杂的元编程时,这具有有趣的可能性,但在日常 Python 中并不是非常有用。实际上,你几乎永远不需要使用__new__。__init__方法几乎总是足够的。
自我解释
Python 是一种非常易于阅读的编程语言;有些人可能会说它是自我记录的。然而,在进行面向对象编程时,编写清楚总结每个对象和方法功能的 API 文档是很重要的。保持文档的最新状态是困难的;最好的方法是将其直接写入我们的代码中。
Python 通过使用文档字符串来支持这一点。每个类、函数或方法头部都可以有一个标准的 Python 字符串作为定义后面的第一行(以冒号结尾的行)。这一行应与随后的代码缩进相同。
文档字符串只是用撇号(')或引号(")括起来的 Python 字符串。通常,文档字符串非常长,跨越多行(风格指南建议行长不超过 80 个字符),可以格式化为多行字符串,用匹配的三个撇号(''')或三引号(""")字符括起来。
文档字符串应清楚而简洁地总结所描述的类或方法的目的。它应解释任何使用不明显的参数,并且还是包含如何使用 API 的简短示例的好地方。还应注意任何使用 API 的不知情用户应该注意的注意事项或问题。
为了说明文档字符串的用法,我们将以完全记录的Point类结束本节:
import math
class Point:
"Represents a point in two-dimensional geometric coordinates"
def __init__(self, x=0, y=0):
"""Initialize the position of a new point. The x and y
coordinates can be specified. If they are not, the
point defaults to the origin."""
self.move(x, y)
def move(self, x, y):
"Move the point to a new location in 2D space."
self.x = x
self.y = y
def reset(self):
"Reset the point back to the geometric origin: 0, 0"
self.move(0, 0)
def calculate_distance(self, other_point):
"""Calculate the distance from this point to a second
point passed as a parameter.
This function uses the Pythagorean Theorem to calculate
the distance between the two points. The distance is
returned as a float."""
return math.sqrt(
(self.x - other_point.x) ** 2
+ (self.y - other_point.y) ** 2
)
尝试在交互式解释器中键入或加载(记住,是python -i point.py)这个文件。然后,在 Python 提示符下输入help(Point)<enter>。
你应该看到类的格式良好的文档,如下面的屏幕截图所示:

模块和包
现在我们知道如何创建类和实例化对象了。在开始失去追踪之前,你不需要写太多的类(或者非面向对象的代码)。对于小程序,我们可以把所有的类放在一个文件中,并在文件末尾添加一个小脚本来启动它们的交互。然而,随着项目的增长,要在我们定义的许多类中找到需要编辑的类可能会变得困难。这就是模块的用武之地。模块只是 Python 文件,没有别的。我们小程序中的单个文件就是一个模块。两个 Python 文件就是两个模块。如果我们有两个文件在同一个文件夹中,我们可以从一个模块中加载一个类以在另一个模块中使用。
例如,如果我们正在构建一个电子商务系统,我们可能会在数据库中存储大量数据。我们可以把所有与数据库访问相关的类和函数放在一个单独的文件中(我们将其称为一个合理的名字:database.py)。然后,我们的其他模块(例如,客户模型、产品信息和库存)可以导入该模块中的类以访问数据库。
import语句用于导入模块或特定类或函数。我们在前一节的Point类中已经看到了一个例子。我们使用import语句获取 Python 的内置math模块,并在distance计算中使用它的sqrt函数。
这里有一个具体的例子。假设我们有一个名为database.py的模块,其中包含一个名为Database的类。第二个名为products.py的模块负责与产品相关的查询。在这一点上,我们不需要太多考虑这些文件的内容。我们知道的是products.py需要从database.py中实例化Database类,以便它可以在数据库中的产品表上执行查询。
有几种import语句的变体语法可以用来访问这个类:
import database
db = database.Database()
# Do queries on db
这个版本将database模块导入到products命名空间(模块或函数中当前可访问的名称列表),因此可以使用database.<something>的表示法访问database模块中的任何类或函数。或者,我们可以使用from...import语法只导入我们需要的一个类:
from database import Database
db = Database()
# Do queries on db
如果由于某种原因,products已经有一个名为Database的类,我们不希望这两个名称混淆,我们可以在products模块中使用时重命名该类:
from database import Database as DB
db = DB()
# Do queries on db
我们也可以在一个语句中导入多个项目。如果我们的database模块还包含一个Query类,我们可以使用以下代码导入两个类:
from database import Database, Query
一些来源称我们可以使用以下语法从database模块中导入所有类和函数:
from database import *
不要这样做。 大多数有经验的 Python 程序员会告诉你,你不应该使用这种语法(有些人会告诉你有一些非常具体的情况下它是有用的,但我不同意)。他们会使用模糊的理由,比如它会使命名空间混乱,这对初学者来说并不太有意义。避免使用这种语法的一个方法是使用它并在两年后尝试理解你的代码。但我们可以通过一个简单的解释来节省一些时间和两年的糟糕代码!
当我们在文件顶部明确导入database类时,使用from database import Database,我们可以很容易地看到Database类来自哪里。我们可能会在文件的后面 400 行使用db = Database(),我们可以快速查看导入来看Database类来自哪里。然后,如果我们需要澄清如何使用Database类,我们可以访问原始文件(或者在交互式解释器中导入模块并使用help(database.Database)命令)。然而,如果我们使用from database import *语法,要找到该类的位置就要花费更多的时间。代码维护变成了一场噩梦。
此外,大多数代码编辑器能够提供额外的功能,比如可靠的代码补全、跳转到类的定义或内联文档,如果使用普通的导入。import *语法通常会完全破坏它们可靠地执行这些功能的能力。
最后,使用import *语法可能会将意外的对象带入我们的本地命名空间。当然,它会导入从被导入的模块中定义的所有类和函数,但它也会导入任何被导入到该文件中的类或模块!
模块中使用的每个名称都应该来自一个明确定义的地方,无论它是在该模块中定义的,还是从另一个模块中明确导入的。不应该有看起来像是凭空出现的魔术变量。我们应该总是能够立即确定我们当前命名空间中的名称来自哪里。我保证,如果你使用这种邪恶的语法,总有一天你会非常沮丧地发现这个类到底是从哪里来的?
玩一下,尝试在交互式解释器中输入import this。它会打印一首很好的诗(其中有一些你可以忽略的笑话),总结了一些 Python 程序员倾向于实践的习惯用法。特别是在这次讨论中,注意到了明确胜于隐式这一句。将名称明确导入到你的命名空间中,比隐式的import *语法使你的代码更容易浏览。
模块组织
随着项目逐渐发展成为越来越多模块的集合,我们可能会发现我们想要在模块的层次上添加另一层抽象,一种嵌套的层次结构。然而,我们不能将模块放在模块内;毕竟,一个文件只能包含一个文件,而模块只是文件。
然而,文件可以放在文件夹中,模块也可以。包是文件夹中模块的集合。包的名称就是文件夹的名称。我们需要告诉 Python 一个文件夹是一个包,以区别于目录中的其他文件夹。为此,在文件夹中放置一个(通常是空的)名为__init__.py的文件。如果我们忘记了这个文件,我们将无法从该文件夹导入模块。
让我们将我们的模块放在一个名为ecommerce的包中,该包还将包含一个main.py文件来启动程序。此外,让我们在ecommerce包内添加另一个用于各种支付选项的包。文件夹层次结构将如下所示:
parent_directory/
main.py
ecommerce/
__init__.py
database.py
products.py
payments/
__init__.py
square.py
stripe.py
在包之间导入模块或类时,我们必须注意语法。在 Python 3 中,有两种导入模块的方式:绝对导入和相对导入。
绝对导入
绝对导入指定要导入的模块、函数或类的完整路径。如果我们需要访问products模块内的Product类,我们可以使用以下任何一种语法来执行绝对导入:
import ecommerce.products
product = ecommerce.products.Product()
//or
from ecommerce.products import Product
product = Product()
//or
from ecommerce import products
product = products.Product()
import语句使用句点运算符来分隔包或模块。
这些语句将从任何模块中起作用。我们可以在main.py、database模块中或两个支付模块中的任何一个中使用这种语法实例化Product类。确实,假设包对 Python 可用,它将能够导入它们。例如,这些包也可以安装在 Python 站点包文件夹中,或者PYTHONPATH环境变量可以被定制为动态地告诉 Python 要搜索哪些文件夹以及它要导入的模块。
那么,在这些选择中,我们选择哪种语法呢?这取决于你的个人喜好和手头的应用。如果products模块中有数十个类和函数我想要使用,我通常使用from ecommerce import products语法导入模块名称,然后使用products.Product访问单个类。如果我只需要products模块中的一个或两个类,我可以直接使用from ecommerce.products import Product语法导入它们。我个人不经常使用第一种语法,除非我有某种名称冲突(例如,我需要访问两个完全不同的名为products的模块并且需要将它们分开)。做任何你认为使你的代码看起来更优雅的事情。
相对导入
在包内使用相关模块时,指定完整路径似乎有些多余;我们知道父模块的名称。这就是相对导入的用武之地。相对导入基本上是一种说法,即按照当前模块的位置来查找类、函数或模块。例如,如果我们在products模块中工作,并且想要从旁边的database模块导入Database类,我们可以使用相对导入:
from .database import Database
database前面的句点表示使用当前包内的数据库模块。在这种情况下,当前包是包含我们当前正在编辑的products.py文件的包,也就是ecommerce包。
如果我们正在编辑ecommerce.payments包内的paypal模块,我们可能会希望使用父包内的数据库包。这很容易通过两个句点来实现,如下所示:
from ..database import Database
我们可以使用更多句点来进一步上溯层次。当然,我们也可以沿着一边下去,然后沿着另一边上来。我们没有足够深的示例层次结构来正确说明这一点,但是如果我们有一个包含email模块并且想要将send_mail函数导入到我们的paypal模块的ecommerce.contact包,以下将是一个有效的导入:
from ..contact.email import send_mail
这个导入使用两个句点,表示父级支付包,然后使用正常的package.module语法返回到联系包。
最后,我们可以直接从包中导入代码,而不仅仅是包内的模块。在这个例子中,我们有一个名为ecommerce的包,其中包含两个名为database.py和products.py的模块。数据库模块包含一个db变量,可以从许多地方访问。如果可以像import ecommerce.db而不是import ecommerce.database.db这样导入,那不是很方便吗?
还记得__init__.py文件定义目录为包吗?这个文件可以包含我们喜欢的任何变量或类声明,并且它们将作为包的一部分可用。在我们的例子中,如果ecommerce/__init__.py文件包含以下行:
from .database import db
然后我们可以从main.py或任何其他文件中使用以下导入访问db属性:
from ecommerce import db
将__init__.py文件视为一个ecommerce.py文件可能有所帮助,如果该文件是一个模块而不是一个包。如果您将所有代码放在一个单独的模块中,然后决定将其拆分为多个模块的包,这也可能很有用。新包的__init__.py文件仍然可以是其他模块与其交流的主要联系点,但代码可以在几个不同的模块或子包中进行内部组织。
我建议不要在__init__.py文件中放太多代码。程序员不希望在这个文件中发生实际逻辑,就像from x import *一样,如果他们正在寻找特定代码的声明并且找不到直到他们检查__init__.py,它可能会让他们困惑。
组织模块内容
在任何一个模块内,我们可以指定变量、类或函数。它们可以是一种方便的方式来存储全局状态,而不会发生命名空间冲突。例如,我们一直在将Database类导入各种模块,然后实例化它,但也许更合理的是只有一个database对象全局可用于database模块。database模块可能是这样的:
class Database:
# the database implementation
pass
database = Database()
然后我们可以使用我们讨论过的任何导入方法来访问database对象,例如:
from ecommerce.database import database
前面的类的一个问题是,database对象在模块第一次被导入时就被立即创建,通常是在程序启动时。这并不总是理想的,因为连接到数据库可能需要一些时间,会减慢启动速度,或者数据库连接信息可能尚未可用。我们可以通过调用initialize_database函数来延迟创建数据库,以创建一个模块级变量:
class Database:
# the database implementation
pass
database = None
def initialize_database():
global database
database = Database()
global关键字告诉 Python,initialize_database内部的数据库变量是我们刚刚定义的模块级变量。如果我们没有将变量指定为全局的,Python 会创建一个新的局部变量,当方法退出时会被丢弃,从而保持模块级别的值不变。
正如这两个例子所说明的,所有模块级代码都会在导入时立即执行。但是,如果它在方法或函数内部,函数会被创建,但其内部代码直到调用函数时才会被执行。对于执行脚本(比如我们电子商务示例中的主要脚本)来说,这可能是一个棘手的问题。有时,我们编写一个执行有用操作的程序,然后后来发现我们想要从该模块导入一个函数或类到另一个程序中。然而,一旦我们导入它,模块级别的任何代码都会立即执行。如果我们不小心,我们可能会在真正只想访问该模块中的一些函数时运行第一个程序。
为了解决这个问题,我们应该总是将启动代码放在一个函数中(通常称为main),并且只有在知道我们正在作为脚本运行模块时才执行该函数,而不是在我们的代码被从另一个脚本导入时执行。我们可以通过在条件语句中保护对main的调用来实现这一点,如下所示:
class UsefulClass:
"""This class might be useful to other modules."""
pass
def main():
"""Creates a useful class and does something with it for our module."""
useful = UsefulClass()
print(useful)
if __name__ == "__main__":
main()
每个模块都有一个__name__特殊变量(记住,Python 使用双下划线表示特殊变量,比如类的__init__方法),它指定了模块在导入时的名称。当模块直接用python module.py执行时,它不会被导入,所以__name__会被任意设置为"__main__"字符串。制定一个规则,将所有脚本都包裹在if __name__ == "__main__":测试中,以防万一你写了一个以后可能想被其他代码导入的函数。
那么,方法放在类中,类放在模块中,模块放在包中。这就是全部吗?
实际上,不是。这是 Python 程序中的典型顺序,但不是唯一可能的布局。类可以在任何地方定义。它们通常在模块级别定义,但也可以在函数或方法内部定义,就像这样:
def format_string(string, formatter=None):
"""Format a string using the formatter object, which
is expected to have a format() method that accepts
a string."""
class DefaultFormatter:
"""Format a string in title case."""
def format(self, string):
return str(string).title()
if not formatter:
formatter = DefaultFormatter()
return formatter.format(string)
hello_string = "hello world, how are you today?"
print(" input: " + hello_string)
print("output: " + format_string(hello_string))
输出如下:
input: hello world, how are you today?
output: Hello World, How Are You Today?
format_string函数接受一个字符串和可选的格式化器对象,然后将格式化器应用于该字符串。如果没有提供格式化器,它会创建一个自己的格式化器作为本地类并实例化它。由于它是在函数范围内创建的,这个类不能从函数外部访问。同样,函数也可以在其他函数内部定义;一般来说,任何 Python 语句都可以在任何时候执行。
这些内部类和函数偶尔对于不需要或不值得在模块级别拥有自己的作用域的一次性项目是有用的,或者只在单个方法内部有意义。然而,通常不会看到频繁使用这种技术的 Python 代码。
谁可以访问我的数据?
大多数面向对象的编程语言都有访问控制的概念。这与抽象有关。对象上的一些属性和方法被标记为私有,意味着只有该对象可以访问它们。其他的被标记为受保护,意味着只有该类和任何子类才能访问。其余的是公共的,意味着任何其他对象都可以访问它们。
Python 不这样做。Python 实际上不相信强制执行可能在某一天妨碍你的法律。相反,它提供了未强制执行的指南和最佳实践。从技术上讲,类上的所有方法和属性都是公开可用的。如果我们想表明一个方法不应该公开使用,我们可以在文档字符串中放置一个注释,指出该方法仅用于内部使用(最好还要解释公共 API 的工作原理!)。
按照惯例,我们还应该使用下划线字符_作为内部属性或方法的前缀。Python 程序员会将其解释为这是一个内部变量,在直接访问之前要三思。但是,如果他们认为这样做符合他们的最佳利益,解释器内部没有任何东西可以阻止他们访问它。因为,如果他们这样认为,我们为什么要阻止他们呢?我们可能不知道我们的类将来可能被用于什么用途。
还有另一件事可以强烈建议外部对象不要访问属性或方法:用双下划线__作为前缀。这将对属性进行名称混淆。实质上,名称混淆意味着如果外部对象真的想这样做,仍然可以调用该方法,但这需要额外的工作,并且强烈表明您要求您的属性保持私有。以下是一个示例代码片段:
class SecretString:
"""A not-at-all secure way to store a secret string."""
def __init__(self, plain_string, pass_phrase):
self.__plain_string = plain_string
self.__pass_phrase = pass_phrase
def decrypt(self, pass_phrase):
"""Only show the string if the pass_phrase is correct."""
if pass_phrase == self.__pass_phrase:
return self.__plain_string
else:
return ""
如果我们在交互式解释器中加载这个类并测试它,我们可以看到它将明文字符串隐藏在外部世界之外:
>>> secret_string = SecretString("ACME: Top Secret", "antwerp")
>>> print(secret_string.decrypt("antwerp"))
ACME: Top Secret
>>> print(secret_string.__plain_string)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'SecretString' object has no attribute
'__plain_string'
看起来好像可以了;没有人可以在没有口令的情况下访问我们的plain_string属性,所以应该是安全的。然而,在我们过于兴奋之前,让我们看看有多容易破解我们的安全性:
>>> print(secret_string._SecretString__plain_string)
ACME: Top Secret
哦不!有人发现了我们的秘密字符串。好在我们检查了。
这就是 Python 名称混淆的工作原理。当我们使用双下划线时,属性前缀为_<classname>。当类中的方法内部访问变量时,它们会自动取消混淆。当外部类希望访问它时,它们必须自己进行名称混淆。因此,名称混淆并不保证隐私;它只是强烈建议。除非有极其充分的理由,大多数 Python 程序员不会触碰另一个对象上的双下划线变量。
然而,大多数 Python 程序员不会在没有充分理由的情况下触碰单个下划线变量。因此,在 Python 中使用名称混淆的变量的很少有很好的理由,这样做可能会引起麻烦。例如,名称混淆的变量可能对尚未知道的子类有用,它必须自己进行混淆。如果其他对象想要访问您的隐藏信息,就让它们知道,使用单下划线前缀或一些清晰的文档字符串,表明您认为这不是一个好主意。
第三方库
Python 附带了一个可爱的标准库,这是一个包和模块的集合,可以在运行 Python 的每台机器上使用。然而,您很快会发现它并不包含您所需的一切。当这种情况发生时,您有两个选择:
-
自己编写一个支持包
-
使用别人的代码
我们不会详细介绍如何将您的软件包转换为库,但是如果您有需要解决的问题,而且不想编写代码(最好的程序员非常懒惰,更喜欢重用现有的经过验证的代码,而不是编写自己的代码),您可能可以在Python 软件包索引(PyPI)pypi.python.org/上找到您想要的库。确定要安装的软件包后,您可以使用一个名为pip的工具来安装它。但是,pip不随 Python 一起提供,但 Python 3.4 及更高版本包含一个称为ensurepip的有用工具。您可以使用此命令来安装它:
$python -m ensurepip
这可能在 Linux、macOS 或其他 Unix 系统上失败,这种情况下,您需要成为 root 用户才能使其工作。在大多数现代 Unix 系统上,可以使用sudo python -m ensurepip来完成此操作。
如果您使用的 Python 版本早于 Python 3.4,您需要自己下载并安装pip,因为ensurepip不可用。您可以按照以下网址的说明进行操作:pip.readthedocs.org/。
一旦安装了pip并且知道要安装的软件包的名称,您可以使用以下语法来安装它:
$pip install requests
然而,如果这样做,您要么会直接将第三方库安装到系统 Python 目录中,要么更有可能会收到您没有权限这样做的错误。您可以以管理员身份强制安装,但 Python 社区的共识是,您应该只使用系统安装程序将第三方库安装到系统 Python 目录中。
相反,Python 3.4(及更高版本)提供了venv工具。该实用程序基本上为您的工作目录提供了一个名为虚拟环境的迷你 Python 安装。当您激活迷你 Python 时,与 Python 相关的命令将在该目录上运行,而不是在系统目录上运行。因此,当您运行pip或python时,它根本不会触及系统 Python。以下是如何使用它:
cd project_directory
python -m venv env
source env/bin/activate # on Linux or macOS
env/bin/activate.bat # on Windows
通常,您会为您工作的每个 Python 项目创建一个不同的虚拟环境。您可以将虚拟环境存储在任何地方,但我传统上将它们保存在与项目文件相同的目录中(但在版本控制中被忽略),因此我们首先cd进入该目录。然后,我们运行venv实用程序来创建名为env的虚拟环境。最后,我们使用最后两行中的一行(取决于操作系统,如注释中所示)来激活环境。每次想要使用特定的虚拟环境时,我们都需要执行此行,然后在完成该项目的工作时使用deactivate命令。
虚拟环境是保持第三方依赖项分开的绝佳方式。通常会有不同的项目依赖于特定库的不同版本(例如,旧网站可能在 Django 1.8 上运行,而更新的版本则在 Django 2.1 上运行)。将每个项目放在单独的虚拟环境中可以轻松地在 Django 的任一版本中工作。此外,如果您尝试使用不同的工具安装相同的软件包,它还可以防止系统安装的软件包和pip安装的软件包之间发生冲突。
有几种有效管理虚拟环境的第三方工具。其中一些包括pyenv、virtualenvwrapper和conda。我个人在撰写本文时更偏好pyenv,但这里没有明显的赢家。快速搜索一下,看看哪种适合您。
案例研究
为了将所有这些联系在一起,让我们构建一个简单的命令行笔记本应用程序。这是一个相当简单的任务,所以我们不会尝试使用多个软件包。但是,我们将看到类、函数、方法和文档字符串的常见用法。
让我们先进行快速分析:笔记是存储在笔记本中的简短备忘录。每个笔记应记录写入的日期,并可以添加标签以便轻松查询。应该可以修改笔记。我们还需要能够搜索笔记。所有这些事情都应该从命令行完成。
一个明显的对象是Note对象;一个不太明显的对象是Notebook容器对象。标签和日期似乎也是对象,但我们可以使用 Python 标准库中的日期和逗号分隔的字符串来表示标签。为了避免复杂性,在原型中,我们不需要为这些对象定义单独的类。
Note对象具有memo本身,tags和creation_date的属性。每个笔记还需要一个唯一的整数id,以便用户可以在菜单界面中选择它们。笔记可以有一个修改笔记内容的方法和另一个标签的方法,或者我们可以让笔记本直接访问这些属性。为了使搜索更容易,我们应该在Note对象上放置一个match方法。这个方法将接受一个字符串,并且可以告诉我们一个笔记是否与字符串匹配,而不直接访问属性。这样,如果我们想修改搜索参数(例如,搜索标签而不是笔记内容,或者使搜索不区分大小写),我们只需要在一个地方做就可以了。
Notebook对象显然具有笔记列表作为属性。它还需要一个搜索方法,返回一个经过筛选的笔记列表。
但是我们如何与这些对象交互?我们已经指定了一个命令行应用程序,这可能意味着我们以不同的选项运行程序来添加或编辑命令,或者我们有某种菜单,允许我们选择对笔记本做不同的事情。我们应该尽量设计它,以便支持任一接口,并且未来的接口,比如 GUI 工具包或基于 Web 的接口,可以在未来添加。
作为一个设计决策,我们现在将实现菜单界面,但会牢记命令行选项版本,以确保我们设计Notebook类时考虑到可扩展性。
如果我们有两个命令行界面,每个界面都与Notebook对象交互,那么Notebook将需要一些方法供这些界面与之交互。我们需要能够add一个新的笔记,并且通过id来modify一个现有的笔记,除了我们已经讨论过的search方法。界面还需要能够列出所有笔记,但它们可以通过直接访问notes列表属性来实现。
我们可能会错过一些细节,但我们对需要编写的代码有一个很好的概述。我们可以用一个简单的类图总结所有这些分析:

在编写任何代码之前,让我们为这个项目定义文件夹结构。菜单界面应该明确地放在自己的模块中,因为它将是一个可执行脚本,并且我们将来可能会有其他可执行脚本访问笔记本。Notebook和Note对象可以放在一个模块中。这些模块可以都存在于同一个顶级目录中,而不必将它们放在一个包中。一个空的command_option.py模块可以帮助我们在未来提醒自己,我们计划添加新的用户界面:
parent_directory/
notebook.py
menu.py
command_option.py
现在让我们看一些代码。我们首先定义Note类,因为它似乎最简单。以下示例完整呈现了Note。示例中的文档字符串解释了它们如何组合在一起,如下所示:
import datetime
# Store the next available id for all new notes
last_id = 0
class Note:
"""Represent a note in the notebook. Match against a
string in searches and store tags for each note."""
def __init__(self, memo, tags=""):
"""initialize a note with memo and optional
space-separated tags. Automatically set the note's
creation date and a unique id."""
self.memo = memo
self.tags = tags
self.creation_date = datetime.date.today()
global last_id
last_id += 1
self.id = last_id
def match(self, filter):
"""Determine if this note matches the filter
text. Return True if it matches, False otherwise.
Search is case sensitive and matches both text and
tags."""
return filter in self.memo or filter in self.tags
在继续之前,我们应该快速启动交互式解释器并测试我们到目前为止的代码。经常测试,因为事情从来不按照你的期望工作。事实上,当我测试这个例子的第一个版本时,我发现我在match函数中忘记了self参数!我们将在第二十四章中讨论自动化测试,测试面向对象的程序。目前,只需使用解释器检查一些东西就足够了:
>>> from notebook import Note
>>> n1 = Note("hello first")
>>> n2 = Note("hello again")
>>> n1.id
1
>>> n2.id
2
>>> n1.match('hello')
True
>>> n2.match('second')
False
看起来一切都表现如预期。让我们接下来创建我们的笔记本:
class Notebook:
"""Represent a collection of notes that can be tagged,
modified, and searched."""
def __init__(self):
"""Initialize a notebook with an empty list."""
self.notes = []
def new_note(self, memo, tags=""):
"""Create a new note and add it to the list."""
self.notes.append(Note(memo, tags))
def modify_memo(self, note_id, memo):
"""Find the note with the given id and change its
memo to the given value."""
for note in self.notes:
if note.id == note_id:
note.memo = memo
break
def modify_tags(self, note_id, tags):
"""Find the note with the given id and change its
tags to the given value."""
for note in self.notes:
if note.id == note_id:
note.tags = tags
break
def search(self, filter):
"""Find all notes that match the given filter
string."""
return [note for note in self.notes if note.match(filter)]
我们将很快整理一下。首先,让我们测试一下以确保它能正常工作:
>>> from notebook import Note, Notebook
>>> n = Notebook()
>>> n.new_note("hello world")
>>> n.new_note("hello again")
>>> n.notes
[<notebook.Note object at 0xb730a78c>, <notebook.Note object at 0xb73103ac>]
>>> n.notes[0].id
1
>>> n.notes[1].id
2
>>> n.notes[0].memo
'hello world'
>>> n.search("hello")
[<notebook.Note object at 0xb730a78c>, <notebook.Note object at 0xb73103ac>]
>>> n.search("world")
[<notebook.Note object at 0xb730a78c>]
>>> n.modify_memo(1, "hi world")
>>> n.notes[0].memo
'hi world'
它确实有效。但是代码有点混乱;我们的modify_tags和modify_memo方法几乎是相同的。这不是良好的编码实践。让我们看看如何改进它。
两种方法都试图在对笔记做某事之前识别具有给定 ID 的笔记。因此,让我们添加一个方法来定位具有特定 ID 的笔记。我们将在方法名称前加下划线以表明该方法仅供内部使用,但是,当然,我们的菜单界面可以访问该方法,如果它想要的话:
def _find_note(self, note_id):
"""Locate the note with the given id."""
for note in self.notes:
if note.id == note_id:
return note
return None
def modify_memo(self, note_id, memo):
"""Find the note with the given id and change its
memo to the given value."""
self._find_note(note_id).memo = memo
def modify_tags(self, note_id, tags):
"""Find the note with the given id and change its
tags to the given value."""
self._find_note(note_id).tags = tags
现在应该可以工作了。让我们看看菜单界面。界面需要呈现菜单并允许用户输入选择。这是我们的第一次尝试:
import sys
from notebook import Notebook
class Menu:
"""Display a menu and respond to choices when run."""
def __init__(self):
self.notebook = Notebook()
self.choices = {
"1": self.show_notes,
"2": self.search_notes,
"3": self.add_note,
"4": self.modify_note,
"5": self.quit,
}
def display_menu(self):
print(
"""
Notebook Menu
1\. Show all Notes
2\. Search Notes
3\. Add Note
4\. Modify Note
5\. Quit
"""
)
def run(self):
"""Display the menu and respond to choices."""
while True:
self.display_menu()
choice = input("Enter an option: ")
action = self.choices.get(choice)
if action:
action()
else:
print("{0} is not a valid choice".format(choice))
def show_notes(self, notes=None):
if not notes:
notes = self.notebook.notes
for note in notes:
print("{0}: {1}\n{2}".format(note.id, note.tags, note.memo))
def search_notes(self):
filter = input("Search for: ")
notes = self.notebook.search(filter)
self.show_notes(notes)
def add_note(self):
memo = input("Enter a memo: ")
self.notebook.new_note(memo)
print("Your note has been added.")
def modify_note(self):
id = input("Enter a note id: ")
memo = input("Enter a memo: ")
tags = input("Enter tags: ")
if memo:
self.notebook.modify_memo(id, memo)
if tags:
self.notebook.modify_tags(id, tags)
def quit(self):
print("Thank you for using your notebook today.")
sys.exit(0)
if __name__ == "__main__":
Menu().run()
这段代码首先使用绝对导入导入笔记本对象。相对导入不起作用,因为我们还没有将我们的代码放在一个包内。Menu类的run方法重复显示菜单,并通过调用笔记本上的函数来响应选择。这是使用 Python 特有的一种习惯用法;它是命令模式的一个轻量级版本,我们将在第二十二章中讨论,Python 设计模式 I。用户输入的选择是字符串。在菜单的__init__方法中,我们创建一个将字符串映射到菜单对象本身的函数的字典。然后,当用户做出选择时,我们从字典中检索对象。action变量实际上是指特定的方法,并且通过在变量后附加空括号(因为没有一个方法需要参数)来调用它。当然,用户可能输入了不合适的选择,所以我们在调用之前检查动作是否真的存在。
各种方法中的每一个都请求用户输入,并调用与之关联的Notebook对象上的适当方法。对于search实现,我们注意到在过滤了笔记之后,我们需要向用户显示它们,因此我们让show_notes函数充当双重职责;它接受一个可选的notes参数。如果提供了,它只显示过滤后的笔记,但如果没有提供,它会显示所有笔记。由于notes参数是可选的,show_notes仍然可以被调用而不带参数作为空菜单项。
如果我们测试这段代码,我们会发现如果我们尝试修改一个笔记,它会失败。有两个错误,即:
-
当我们输入一个不存在的笔记 ID 时,笔记本会崩溃。我们永远不应该相信用户输入正确的数据!
-
即使我们输入了正确的 ID,它也会崩溃,因为笔记 ID 是整数,但我们的菜单传递的是字符串。
后一个错误可以通过修改Notebook类的_find_note方法,使用字符串而不是存储在笔记中的整数来比较值来解决,如下所示:
def _find_note(self, note_id):
"""Locate the note with the given id."""
for note in self.notes:
if str(note.id) == str(note_id):
return note
return None
在比较它们之前,我们只需将输入(note_id)和笔记的 ID 都转换为字符串。我们也可以将输入转换为整数,但是如果用户输入字母a而不是数字1,那么我们会遇到麻烦。
用户输入不存在的笔记 ID 的问题可以通过更改笔记本上的两个modify方法来解决,检查_find_note是否返回了一个笔记,如下所示:
def modify_memo(self, note_id, memo):
"""Find the note with the given id and change its
memo to the given value."""
note = self._find_note(note_id)
if note:
note.memo = memo
return True
return False
这个方法已更新为返回True或False,取决于是否找到了一个笔记。菜单可以使用这个返回值来显示错误,如果用户输入了一个无效的笔记。
这段代码有点笨拙。如果它引发异常会好一些。我们将在第十八章中介绍这些,预料之外。
练习
编写一些面向对象的代码。目标是使用本章学到的原则和语法,确保你理解我们所涵盖的主题。如果你一直在做一个 Python 项目,回过头来看看,是否有一些对象可以创建,并添加属性或方法。如果项目很大,尝试将其分成几个模块,甚至包,并玩弄语法。
如果你没有这样的项目,尝试开始一个新的项目。它不一定要是你打算完成的东西;只需勾勒出一些基本的设计部分。你不需要完全实现所有内容;通常,只需要print("这个方法将做一些事情")就足以让整体设计就位。这被称为自顶向下设计,在这种设计中,你先解决不同的交互,并描述它们应该如何工作,然后再实际实现它们所做的事情。相反,自底向上设计首先实现细节,然后将它们全部联系在一起。这两种模式在不同的时候都很有用,但对于理解面向对象的原则,自顶向下的工作流更合适。
如果你在想法上遇到困难,可以尝试编写一个待办事项应用程序。(提示:它将类似于笔记本应用程序的设计,但具有额外的日期管理方法。)它可以跟踪你每天想做的事情,并允许你标记它们为已完成。
现在尝试设计一个更大的项目。与之前一样,它不一定要真正做任何事情,但确保你尝试使用包和模块导入语法。在各个模块中添加一些函数,并尝试从其他模块和包中导入它们。使用相对和绝对导入。看看它们之间的区别,并尝试想象你想要使用每种导入方式的场景。
总结
在本章中,我们学习了在 Python 中创建类并分配属性和方法是多么简单。与许多语言不同,Python 区分构造函数和初始化程序。它对访问控制有一种放松的态度。有许多不同级别的作用域,包括包、模块、类和函数。我们理解了相对导入和绝对导入之间的区别,以及如何管理不随 Python 一起提供的第三方包。
在下一章中,我们将学习如何使用继承来共享实现。
第十七章:当对象相似时
在编程世界中,重复的代码被认为是邪恶的。我们不应该在不同的地方有相同或相似的代码的多个副本。
有许多方法可以合并具有类似功能的代码或对象。在本章中,我们将介绍最著名的面向对象原则:继承。正如在第十五章中讨论的那样,面向对象设计,继承允许我们在两个或多个类之间创建 is a 关系,将通用逻辑抽象到超类中,并在子类中管理特定细节。特别是,我们将介绍以下内容的 Python 语法和原则:
-
基本继承
-
从内置类型继承
-
多重继承
-
多态和鸭子类型
基本继承
从技术上讲,我们创建的每个类都使用继承。所有 Python 类都是名为object的特殊内置类的子类。这个类在数据和行为方面提供的很少(它提供的行为都是为了内部使用的双下划线方法),但它确实允许 Python 以相同的方式对待所有对象。
如果我们不明确从不同的类继承,我们的类将自动从object继承。然而,我们可以明确声明我们的类从object派生,使用以下语法:
class MySubClass(object):
pass
这就是继承!从技术上讲,这个例子与我们在第十六章中的第一个例子没有什么不同,Python 中的对象,因为如果我们不明确提供不同的超类,Python 3 会自动从object继承。超类或父类是被继承的类。子类是从超类继承的类。在这种情况下,超类是object,而MySubClass是子类。子类也被称为从其父类派生,或者说子类扩展了父类。
从示例中你可能已经发现,继承需要比基本类定义多出一点额外的语法。只需在类名和后面的冒号之间的括号内包含父类的名称。这就是我们告诉 Python 新类应该从给定的超类派生的所有内容。
我们如何在实践中应用继承?继承最简单和最明显的用途是向现有类添加功能。让我们从一个简单的联系人管理器开始,跟踪几个人的姓名和电子邮件地址。Contact类负责在类变量中维护所有联系人的列表,并为单个联系人初始化姓名和地址:
class Contact:
all_contacts = []
def __init__(self, name, email):
self.name = name
self.email = email
Contact.all_contacts.append(self)
这个例子向我们介绍了类变量。all_contacts列表,因为它是类定义的一部分,被这个类的所有实例共享。这意味着只有一个Contact.all_contacts列表。我们也可以在Contact类的任何实例方法中作为self.all_contacts访问它。如果在对象(通过self)上找不到字段,那么它将在类上找到,并且因此将引用相同的单个列表。
对于这个语法要小心,因为如果你使用self.all_contacts来设置变量,你实际上会创建一个新的与该对象关联的实例变量。类变量仍然不变,并且可以作为Contact.all_contacts访问。
这是一个简单的类,允许我们跟踪每个联系人的一些数据。但是如果我们的一些联系人也是我们需要从中订购物品的供应商呢?我们可以在Contact类中添加一个order方法,但这将允许人们意外地从客户或家庭朋友的联系人那里订购东西。相反,让我们创建一个新的Supplier类,它的行为类似于我们的Contact类,但有一个额外的order方法:
class Supplier(Contact):
def order(self, order):
print(
"If this were a real system we would send "
f"'{order}' order to '{self.name}'"
)
现在,如果我们在我们可靠的解释器中测试这个类,我们会发现所有联系人,包括供应商,在它们的__init__中都接受名称和电子邮件地址,但只有供应商有一个功能性的订单方法:
>>> c = Contact("Some Body", "somebody@example.net")
>>> s = Supplier("Sup Plier", "supplier@example.net")
>>> print(c.name, c.email, s.name, s.email)
Some Body somebody@example.net Sup Plier supplier@example.net
>>> c.all_contacts
[<__main__.Contact object at 0xb7375ecc>,
<__main__.Supplier object at 0xb7375f8c>]
>>> c.order("I need pliers")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Contact' object has no attribute 'order'
>>> s.order("I need pliers")
If this were a real system we would send 'I need pliers' order to
'Sup Plier '
所以,现在我们的Supplier类可以做所有联系人可以做的事情(包括将自己添加到all_contacts列表中)以及作为供应商需要处理的所有特殊事情。这就是继承的美妙之处。
扩展内置类
这种继承的一个有趣用途是向内置类添加功能。在前面看到的Contact类中,我们正在将联系人添加到所有联系人的列表中。如果我们还想按名称搜索该列表怎么办?嗯,我们可以在Contact类上添加一个搜索方法,但感觉这个方法实际上属于列表本身。我们可以使用继承来实现这一点:
class ContactList(list):
def search(self, name):
"""Return all contacts that contain the search value
in their name."""
matching_contacts = []
for contact in self:
if name in contact.name:
matching_contacts.append(contact)
return matching_contacts
class Contact:
all_contacts = ContactList()
def __init__(self, name, email):
self.name = name
self.email = email
Contact.all_contacts.append(self)
我们不是实例化一个普通列表作为我们的类变量,而是创建一个扩展内置list数据类型的新ContactList类。然后,我们将这个子类实例化为我们的all_contacts列表。我们可以测试新的搜索功能如下:
>>> c1 = Contact("John A", "johna@example.net")
>>> c2 = Contact("John B", "johnb@example.net")
>>> c3 = Contact("Jenna C", "jennac@example.net")
>>> [c.name for c in Contact.all_contacts.search('John')]
['John A', 'John B']
你是否想知道我们如何将内置语法[]改变成我们可以继承的东西?使用[]创建一个空列表实际上是使用list()创建一个空列表的快捷方式;这两种语法的行为是相同的:
>>> [] == list()
True
实际上,[]语法实际上是所谓的语法糖,在幕后调用list()构造函数。list数据类型是一个我们可以扩展的类。事实上,列表本身扩展了object类:
>>> isinstance([], object)
True
作为第二个例子,我们可以扩展dict类,它与列表类似,是在使用{}语法缩写时构造的类:
class LongNameDict(dict):
def longest_key(self):
longest = None
for key in self:
if not longest or len(key) > len(longest):
longest = key
return longest
这在交互式解释器中很容易测试:
>>> longkeys = LongNameDict()
>>> longkeys['hello'] = 1
>>> longkeys['longest yet'] = 5
>>> longkeys['hello2'] = 'world'
>>> longkeys.longest_key()
'longest yet'
大多数内置类型都可以类似地扩展。常见的扩展内置类包括object、list、set、dict、file和str。数值类型如int和float有时也会被继承。
重写和 super
因此,继承非常适合向现有类添加新行为,但是改变行为呢?我们的Contact类只允许名称和电子邮件地址。这对大多数联系人可能已经足够了,但是如果我们想为我们的亲密朋友添加电话号码呢?
正如我们在第十六章中看到的,Python 中的对象,我们可以很容易地在构造后在联系人上设置phone属性。但是,如果我们想在初始化时使这个第三个变量可用,我们必须重写__init__。重写意味着用子类中的新方法(具有相同名称)更改或替换超类的方法。不需要特殊的语法来做到这一点;子类的新创建的方法会自动被调用,而不是超类的方法。如下面的代码所示:
class Friend(Contact):
def __init__(self, name, email, phone): self.name = name
self.email = email
self.phone = phone
任何方法都可以被重写,不仅仅是__init__。然而,在继续之前,我们需要解决这个例子中的一些问题。我们的Contact和Friend类有重复的代码来设置name和email属性;这可能会使代码维护复杂化,因为我们必须在两个或更多地方更新代码。更令人担忧的是,我们的Friend类忽略了将自己添加到我们在Contact类上创建的all_contacts列表中。
我们真正需要的是一种方法,可以从我们的新类内部执行Contact类上的原始__init__方法。这就是super函数的作用;它将对象作为父类的实例返回,允许我们直接调用父类方法:
class Friend(Contact):
def __init__(self, name, email, phone):
super().__init__(name, email)
self.phone = phone
这个例子首先使用super获取父对象的实例,并在该对象上调用__init__,传入预期的参数。然后进行自己的初始化,即设置phone属性。
super()调用可以在任何方法内部进行。因此,所有方法都可以通过覆盖和调用super进行修改。super的调用也可以在方法的任何地方进行;我们不必将调用作为第一行。例如,我们可能需要在将传入参数转发给超类之前操纵或验证传入参数。
多重继承
多重继承是一个敏感的话题。原则上,它很简单:从多个父类继承的子类能够访问它们两者的功能。实际上,这并没有听起来那么有用,许多专家程序员建议不要使用它。
作为一个幽默的经验法则,如果你认为你需要多重继承,你可能是错的,但如果你知道你需要它,你可能是对的。
最简单和最有用的多重继承形式被称为mixin。mixin 是一个不打算独立存在的超类,而是打算被其他类继承以提供额外的功能。例如,假设我们想要为我们的Contact类添加功能,允许向self.email发送电子邮件。发送电子邮件是一个常见的任务,我们可能希望在许多其他类上使用它。因此,我们可以编写一个简单的 mixin 类来为我们发送电子邮件:
class MailSender:
def send_mail(self, message):
print("Sending mail to " + self.email)
# Add e-mail logic here
为了简洁起见,我们不会在这里包含实际的电子邮件逻辑;如果你有兴趣学习如何做到这一点,请参阅 Python 标准库中的smtplib模块。
这个类并没有做任何特别的事情(实际上,它几乎不能作为一个独立的类运行),但它确实允许我们定义一个新的类,描述了Contact和MailSender,使用多重继承:
class EmailableContact(Contact, MailSender):
pass
多重继承的语法看起来像类定义中的参数列表。在括号内不是包含一个基类,而是包含两个(或更多),用逗号分隔。我们可以测试这个新的混合体,看看 mixin 的工作情况:
>>> e = EmailableContact("John Smith", "jsmith@example.net")
>>> Contact.all_contacts
[<__main__.EmailableContact object at 0xb7205fac>]
>>> e.send_mail("Hello, test e-mail here")
Sending mail to jsmith@example.net
Contact初始化器仍然将新联系人添加到all_contacts列表中,mixin 能够向self.email发送邮件,所以我们知道一切都在运行。
这并不难,你可能想知道关于多重继承的严重警告是什么。我们将在一分钟内讨论复杂性,但让我们考虑一下我们在这个例子中的其他选择,而不是使用 mixin:
-
我们本可以使用单一继承,并将
send_mail函数添加到子类中。这里的缺点是,邮件功能必须为任何其他需要邮件的类重复。 -
我们可以创建一个独立的 Python 函数来发送电子邮件,并在需要发送电子邮件时以参数的形式调用该函数并提供正确的电子邮件地址(这将是我的选择)。
-
我们本可以探索一些使用组合而不是继承的方法。例如,
EmailableContact可以将MailSender对象作为属性,而不是继承它。 -
我们可以在创建类之后对
Contact类进行 monkey patching(我们将在第二十章中简要介绍 monkey patching,Python 面向对象的快捷方式)。这是通过定义一个接受self参数的函数,并将其设置为现有类的属性来完成的。
当混合来自不同类的方法时,多重继承效果还不错,但当我们必须在超类上调用方法时,情况就变得非常混乱。有多个超类。我们怎么知道该调用哪一个?我们怎么知道以什么顺序调用它们?
让我们通过向我们的Friend类添加家庭地址来探讨这些问题。我们可能会采取一些方法。地址是一组表示联系人的街道、城市、国家和其他相关细节的字符串。我们可以将这些字符串中的每一个作为参数传递给Friend类的__init__方法。我们也可以将这些字符串存储在元组、字典或数据类中,并将它们作为单个参数传递给__init__。如果没有需要添加到地址的方法,这可能是最好的做法。
另一个选择是创建一个新的Address类来保存这些字符串,然后将这个类的实例传递给我们的Friend类的__init__方法。这种解决方案的优势在于,我们可以为数据添加行为(比如,一个给出方向或打印地图的方法),而不仅仅是静态存储。这是组合的一个例子,正如我们在第十五章中讨论的那样,面向对象设计。组合是这个问题的一个完全可行的解决方案,它允许我们在其他实体中重用Address类,比如建筑物、企业或组织。
然而,继承也是一个可行的解决方案,这就是我们想要探讨的。让我们添加一个新的类来保存地址。我们将这个新类称为AddressHolder,而不是Address,因为继承定义了一种是一个关系。说Friend类是Address类是不正确的,但由于朋友可以有一个Address类,我们可以说Friend类是AddressHolder类。稍后,我们可以创建其他实体(公司,建筑物)也持有地址。然而,这种复杂的命名是一个很好的指示,我们应该坚持组合,而不是继承。但出于教学目的,我们将坚持使用继承。这是我们的AddressHolder类:
class AddressHolder:
def __init__(self, street, city, state, code):
self.street = street
self.city = city
self.state = state
self.code = code
我们只需在初始化时将所有数据放入实例变量中。
菱形问题
我们可以使用多重继承将这个新类添加为现有Friend类的父类。棘手的部分是现在我们有两个父__init__方法,它们都需要被初始化。而且它们需要用不同的参数进行初始化。我们该怎么做呢?嗯,我们可以从一个天真的方法开始:
class Friend(Contact, AddressHolder):
def __init__(
self, name, email, phone, street, city, state, code):
Contact.__init__(self, name, email)
AddressHolder.__init__(self, street, city, state, code)
self.phone = phone
在这个例子中,我们直接调用每个超类的__init__函数,并显式传递self参数。这个例子在技术上是有效的;我们可以直接在类上访问不同的变量。但是有一些问题。
首先,如果我们忽略显式调用初始化程序,超类可能会未初始化。这不会破坏这个例子,但在常见情况下可能会导致难以调试的程序崩溃。例如,想象一下尝试将数据插入未连接的数据库。
一个更隐匿的可能性是由于类层次结构的组织而多次调用超类。看看这个继承图:

Friend类的__init__方法首先调用Contact的__init__,这隐式地初始化了object超类(记住,所有类都派生自object)。然后Friend调用AddressHolder的__init__,这又隐式地初始化了object超类。这意味着父类已经被设置了两次。对于object类来说,这相对无害,但在某些情况下,这可能会带来灾难。想象一下,每次请求都要尝试两次连接到数据库!
基类应该只被调用一次。是的,但是何时呢?我们先调用Friend,然后Contact,然后Object,然后AddressHolder?还是Friend,然后Contact,然后AddressHolder,然后Object?
方法的调用顺序可以通过修改类的__mro__(方法解析顺序)属性来动态调整。这超出了本书的范围。如果您认为您需要了解它,我们建议阅读Expert Python Programming,Tarek Ziadé,Packt Publishing,或者阅读有关该主题的原始文档(注意,它很深!)www.python.org/download/releases/2.3/mro/。
让我们看一个更清楚地说明这个问题的第二个刻意的例子。在这里,我们有一个基类,它有一个名为call_me的方法。两个子类重写了该方法,然后另一个子类使用多重继承扩展了这两个子类。这被称为菱形继承,因为类图的形状是菱形:

让我们将这个图转换成代码;这个例子展示了方法何时被调用:
class BaseClass:
num_base_calls = 0
def call_me(self):
print("Calling method on Base Class")
self.num_base_calls += 1
class LeftSubclass(BaseClass):
num_left_calls = 0
def call_me(self):
BaseClass.call_me(self)
print("Calling method on Left Subclass")
self.num_left_calls += 1
class RightSubclass(BaseClass):
num_right_calls = 0
def call_me(self):
BaseClass.call_me(self)
print("Calling method on Right Subclass")
self.num_right_calls += 1
class Subclass(LeftSubclass, RightSubclass):
num_sub_calls = 0
def call_me(self):
LeftSubclass.call_me(self)
RightSubclass.call_me(self)
print("Calling method on Subclass")
self.num_sub_calls += 1
这个例子确保每个重写的call_me方法直接调用具有相同名称的父方法。它通过将信息打印到屏幕上来告诉我们每次调用方法。它还更新了类的静态变量,以显示它被调用的次数。如果我们实例化一个Subclass对象并调用它的方法一次,我们会得到输出:
>>> s = Subclass()
>>> s.call_me()
Calling method on Base Class
Calling method on Left Subclass
Calling method on Base Class
Calling method on Right Subclass
Calling method on Subclass
>>> print(
... s.num_sub_calls,
... s.num_left_calls,
... s.num_right_calls,
... s.num_base_calls)
1 1 1 2
因此,我们可以清楚地看到基类的call_me方法被调用了两次。如果该方法正在执行实际工作,比如两次存入银行账户,这可能会导致一些隐匿的错误。
多重继承要记住的一件事是,我们只想调用类层次结构中的next方法,而不是parent方法。实际上,下一个方法可能不在当前类的父类或祖先上。super关键字再次拯救了我们。事实上,super最初是为了使复杂的多重继承形式成为可能。以下是使用super编写的相同代码:
class BaseClass:
num_base_calls = 0
def call_me(self):
print("Calling method on Base Class")
self.num_base_calls += 1
class LeftSubclass(BaseClass):
num_left_calls = 0
def call_me(self):
super().call_me()
print("Calling method on Left Subclass")
self.num_left_calls += 1
class RightSubclass(BaseClass):
num_right_calls = 0
def call_me(self):
super().call_me()
print("Calling method on Right Subclass")
self.num_right_calls += 1
class Subclass(LeftSubclass, RightSubclass):
num_sub_calls = 0
def call_me(self):
super().call_me()
print("Calling method on Subclass")
self.num_sub_calls += 1
更改非常小;我们只用super()调用替换了天真的直接调用,尽管底部子类只调用了一次super,而不是必须为左侧和右侧都进行调用。更改足够简单,但是当我们执行它时,看看差异:
>>> s = Subclass()
>>> s.call_me()
Calling method on Base Class
Calling method on Right Subclass
Calling method on Left Subclass
Calling method on Subclass
>>> print(s.num_sub_calls, s.num_left_calls, s.num_right_calls,
s.num_base_calls)
1 1 1 1
看起来不错;我们的基本方法只被调用了一次。但是super()在这里实际上是在做什么呢?由于print语句是在super调用之后执行的,打印输出的顺序是每个方法实际执行的顺序。让我们从后往前看输出,看看是谁在调用什么。
首先,Subclass的call_me调用了super().call_me(),这恰好是在引用
到LeftSubclass.call_me()。然后LeftSubclass.call_me()方法调用super().call_me(),但在这种情况下,super()指的是RightSubclass.call_me()。
特别注意:super调用不调用LeftSubclass的超类(即BaseClass)上的方法。相反,它调用RightSubclass,即使它不是LeftSubclass的直接父类!这是next方法,而不是父方法。然后RightSubclass调用BaseClass,并且super调用确保了类层次结构中的每个方法都被执行一次。
不同的参数集
当我们返回到我们的Friend多重继承示例时,这将使事情变得复杂。在Friend的__init__方法中,我们最初调用了两个父类的__init__,使用不同的参数集:
Contact.__init__(self, name, email)
AddressHolder.__init__(self, street, city, state, code)
在使用super时如何管理不同的参数集?我们不一定知道super将尝试首先初始化哪个类。即使我们知道,我们也需要一种方法来传递extra参数,以便后续对其他子类的super调用接收正确的参数。
具体来说,如果对super的第一个调用将name和email参数传递给Contact.__init__,然后Contact.__init__调用super,它需要能够将与地址相关的参数传递给next方法,即AddressHolder.__init__。
每当我们想要调用具有相同名称但不同参数集的超类方法时,就会出现这个问题。通常情况下,您只会在__init__中想要使用完全不同的参数集,就像我们在这里做的那样。即使在常规方法中,我们可能也想要添加仅对一个子类或一组子类有意义的可选参数。
遗憾的是,解决这个问题的唯一方法是从一开始就计划好。我们必须设计基类参数列表,以接受任何不是每个子类实现所需的参数的关键字参数。最后,我们必须确保该方法自由接受意外的参数并将它们传递给其super调用,以防它们对继承顺序中的后续方法是必要的。
Python 的函数参数语法提供了我们需要做到这一点的所有工具,但它使整体代码看起来笨重。请看下面Friend多重继承代码的正确版本:
class Contact:
all_contacts = []
def __init__(self, name="", email="", **kwargs):
super().__init__(**kwargs)
self.name = name
self.email = email
self.all_contacts.append(self)
class AddressHolder:
def __init__(self, street="", city="", state="", code="", **kwargs):
super().__init__(**kwargs)
self.street = street
self.city = city
self.state = state
self.code = code
class Friend(Contact, AddressHolder):
def __init__(self, phone="", **kwargs):
super().__init__(**kwargs)
self.phone = phone
我们通过给它们一个空字符串作为默认值,将所有参数都更改为关键字参数。我们还确保包含一个**kwargs参数来捕获我们特定方法不知道如何处理的任何额外参数。它将这些参数传递给super调用的下一个类。
如果您不熟悉**kwargs语法,它基本上会收集传递给方法的任何未在参数列表中明确列出的关键字参数。这些参数存储在一个名为kwargs的字典中(我们可以随意命名变量,但约定建议使用kw或kwargs)。当我们使用**kwargs语法调用不同的方法(例如super().__init__)时,它会解包字典并将结果作为普通关键字参数传递给方法。我们将在第二十章中详细介绍这一点,Python 面向对象的快捷方式。
前面的例子做了它应该做的事情。但是它开始看起来凌乱,很难回答问题,“我们需要传递什么参数到Friend.__init__中?”这是任何计划使用该类的人首要考虑的问题,因此应该在方法中添加一个文档字符串来解释发生了什么。
此外,即使使用这种实现方式,如果我们想要在父类中重用变量,它仍然是不够的。当我们将**kwargs变量传递给super时,字典不包括任何作为显式关键字参数包含的变量。例如,在Friend.__init__中,对super的调用在kwargs字典中没有phone。如果其他类中需要phone参数,我们需要确保它包含在传递的字典中。更糟糕的是,如果我们忘记这样做,调试将变得非常令人沮丧,因为超类不会抱怨,而只会简单地将默认值(在这种情况下为空字符串)分配给变量。
有几种方法可以确保变量向上传递。假设Contact类出于某种原因需要使用phone参数进行初始化,并且Friend类也需要访问它。我们可以采取以下任一方法:
-
不要将
phone作为显式关键字参数包含在内。相反,将其留在kwargs字典中。Friend可以使用kwargs['phone']语法查找它。当它将**kwargs传递给super调用时,phone仍将存在于字典中。 -
将
phone作为显式关键字参数,但在将其传递给super之前更新kwargs字典,使用标准字典kwargs['phone'] = phone语法。 -
将
phone作为一个显式关键字参数,但使用kwargs.update方法更新kwargs字典。如果有多个参数需要更新,这是很有用的。您可以使用dict(phone=phone)构造函数或{'phone': phone}语法创建传递给update的字典。 -
将
phone作为一个显式关键字参数,但使用super().__init__(phone=phone, **kwargs)语法将其明确传递给 super 调用。
我们已经涵盖了 Python 中多重继承的许多注意事项。当我们需要考虑所有可能的情况时,我们必须为它们做计划,我们的代码会变得混乱。基本的多重继承可能很方便,但在许多情况下,我们可能希望选择一种更透明的方式来组合两个不同的类,通常使用组合或我们将在第二十二章和第二十三章中介绍的设计模式之一。
我已经浪费了我生命中的整整一天,搜索复杂的多重继承层次结构,试图弄清楚我需要传递到其中一个深度嵌套的子类的参数。代码的作者倾向于不记录他的类,并经常传递 kwargs——以防万一将来可能会需要。这是一个特别糟糕的例子,使用了不需要的多重继承。多重继承是一个新编码者喜欢炫耀的大而复杂的术语,但我建议避免使用它,即使你认为它是一个好选择。当他们以后不得不阅读代码时,你未来的自己和其他编码者会很高兴他们理解你的代码。
多态性
我们在《面向对象设计》的第十五章中介绍了多态性。这是一个华丽的名字,描述了一个简单的概念:不同的行为发生取决于使用哪个子类,而不必明确知道子类实际上是什么。举个例子,想象一个播放音频文件的程序。媒体播放器可能需要加载一个AudioFile对象,然后play它。我们可以在对象上放一个play()方法,负责解压或提取音频并将其路由到声卡和扬声器。播放AudioFile的行为可能是非常简单的:
audio_file.play()
然而,解压和提取音频文件的过程对不同类型的文件来说是非常不同的。虽然.wav文件是未压缩存储的,.mp3、.wma和.ogg文件都使用完全不同的压缩算法。
我们可以使用多态性的继承来简化设计。每种类型的文件可以由AudioFile的不同子类表示,例如WavFile和MP3File。每个子类都会有一个play()方法,为了确保正确的提取过程,每个文件的实现方式都会有所不同。媒体播放器对象永远不需要知道它正在引用哪个AudioFile的子类;它只是调用play(),并以多态的方式让对象处理实际的播放细节。让我们看一个快速的骨架,展示这可能是什么样子:
class AudioFile:
def __init__(self, filename):
if not filename.endswith(self.ext):
raise Exception("Invalid file format")
self.filename = filename
class MP3File(AudioFile):
ext = "mp3"
def play(self):
print("playing {} as mp3".format(self.filename))
class WavFile(AudioFile):
ext = "wav"
def play(self):
print("playing {} as wav".format(self.filename))
class OggFile(AudioFile):
ext = "ogg"
def play(self):
print("playing {} as ogg".format(self.filename))
所有音频文件都会检查初始化时是否给出了有效的扩展名。但你是否注意到父类中的__init__方法如何能够从不同的子类访问ext类变量?这就是多态性的工作原理。如果文件名不以正确的名称结尾,它会引发异常(异常将在下一章中详细介绍)。AudioFile父类实际上并没有存储对ext变量的引用,但这并不妨碍它能够在子类上访问它。
此外,AudioFile的每个子类以不同的方式实现play()(这个例子实际上并不播放音乐;音频压缩算法确实值得单独一本书!)。这也是多态的实现。媒体播放器可以使用完全相同的代码来播放文件,无论它是什么类型;它不关心它正在查看的AudioFile的子类是什么。解压音频文件的细节被封装。如果我们测试这个例子,它会按照我们的期望工作。
>>> ogg = OggFile("myfile.ogg")
>>> ogg.play()
playing myfile.ogg as ogg
>>> mp3 = MP3File("myfile.mp3")
>>> mp3.play()
playing myfile.mp3 as mp3
>>> not_an_mp3 = MP3File("myfile.ogg")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "polymorphic_audio.py", line 4, in __init__
raise Exception("Invalid file format")
Exception: Invalid file format
看看AudioFile.__init__如何能够检查文件类型,而不实际知道它指的是哪个子类?
多态实际上是面向对象编程中最酷的东西之一,它使一些在早期范式中不可能的编程设计变得显而易见。然而,由于鸭子类型,Python 使多态看起来不那么令人敬畏。Python 中的鸭子类型允许我们使用任何提供所需行为的对象,而无需强制它成为子类。Python 的动态性使这变得微不足道。下面的例子不扩展AudioFile,但可以使用完全相同的接口在 Python 中与之交互:
class FlacFile:
def __init__(self, filename):
if not filename.endswith(".flac"):
raise Exception("Invalid file format")
self.filename = filename
def play(self):
print("playing {} as flac".format(self.filename))
我们的媒体播放器可以像扩展AudioFile的对象一样轻松地播放这个对象。
在许多面向对象的上下文中,多态是使用继承的最重要原因之一。因为在 Python 中可以互换使用任何提供正确接口的对象,所以减少了对多态公共超类的需求。继承仍然可以用于共享代码,但如果所有被共享的只是公共接口,那么只需要鸭子类型。这种对继承的需求减少也减少了对多重继承的需求;通常,当多重继承似乎是一个有效的解决方案时,我们可以使用鸭子类型来模仿多个超类中的一个。
当然,只因为一个对象满足特定接口(通过提供所需的方法或属性)并不意味着它在所有情况下都能简单地工作。它必须以在整个系统中有意义的方式满足该接口。仅仅因为一个对象提供了play()方法并不意味着它会自动与媒体播放器一起工作。例如,我们在第十五章中的国际象棋 AI 对象,面向对象设计,可能有一个play()方法来移动国际象棋棋子。即使它满足了接口,这个类在我们试图将它插入媒体播放器时可能会以惊人的方式崩溃!
鸭子类型的另一个有用特性是,鸭子类型的对象只需要提供实际被访问的方法和属性。例如,如果我们需要创建一个假的文件对象来读取数据,我们可以创建一个具有read()方法的新对象;如果将与假对象交互的代码不会调用write方法,那么我们就不必覆盖write方法。简而言之,鸭子类型不需要提供可用对象的整个接口;它只需要满足实际被访问的接口。
抽象基类
虽然鸭子类型很有用,但事先很难判断一个类是否能够满足你所需的协议。因此,Python 引入了抽象基类(ABC)的概念。抽象基类定义了一组类必须实现的方法和属性,以便被视为该类的鸭子类型实例。该类可以扩展抽象基类本身,以便用作该类的实例,但必须提供所有适当的方法。
实际上,很少需要创建新的抽象基类,但我们可能会发现需要实现现有 ABC 的实例的情况。我们将首先介绍实现 ABC,然后简要介绍如何创建自己的 ABC,如果你有需要的话。
使用抽象基类
Python 标准库中存在的大多数抽象基类都位于collections模块中。其中最简单的之一是Container类。让我们在 Python 解释器中检查一下这个类需要哪些方法:
>>> from collections import Container
>>> Container.__abstractmethods__
frozenset(['__contains__'])
因此,Container类确切地有一个需要被实现的抽象方法,__contains__。你可以发出help(Container.__contains__)来查看这个函数签名应该是什么样子的:
Help on method __contains__ in module _abcoll:
__contains__(self, x) unbound _abcoll.Container method
我们可以看到__contains__需要接受一个参数。不幸的是,帮助文件并没有告诉我们这个参数应该是什么,但从 ABC 的名称和它实现的单个方法来看,很明显这个参数是用户要检查的容器是否包含的值。
这个方法由list、str和dict实现,用于指示给定的值是否在该数据结构中。然而,我们也可以定义一个愚蠢的容器,告诉我们给定的值是否在奇数集合中:
class OddContainer:
def __contains__(self, x):
if not isinstance(x, int) or not x % 2:
return False
return True
有趣的是:我们可以实例化一个OddContainer对象,并确定,即使我们没有扩展Container,该类也是一个Container对象。
>>> from collections import Container
>>> odd_container = OddContainer()
>>> isinstance(odd_container, Container)
True
>>> issubclass(OddContainer, Container)
True
这就是为什么鸭子类型比经典多态更棒的原因。我们可以创建关系而不需要编写设置继承(或更糟的是多重继承)的代码的开销。
Container ABC 的一个很酷的地方是,任何实现它的类都可以免费使用in关键字。实际上,in只是语法糖,委托给__contains__方法。任何具有__contains__方法的类都是Container,因此可以通过in关键字查询,例如:
>>> 1 in odd_container
True
>>> 2 in odd_container
False
>>> 3 in odd_container
True
>>> "a string" in odd_container
False
创建一个抽象基类
正如我们之前看到的,要启用鸭子类型并不需要有一个抽象基类。然而,想象一下我们正在创建一个带有第三方插件的媒体播放器。在这种情况下,最好创建一个抽象基类来记录第三方插件应该提供的 API(文档是 ABC 的一个更强大的用例)。abc模块提供了你需要做到这一点的工具,但我提前警告你,这利用了 Python 中一些最深奥的概念,就像下面的代码块中所演示的那样:
import abc
class MediaLoader(metaclass=abc.ABCMeta):
@abc.abstractmethod
def play(self):
pass
@abc.abstractproperty
def ext(self):
pass
@classmethod
def __subclasshook__(cls, C):
if cls is MediaLoader:
attrs = set(dir(C))
if set(cls.__abstractmethods__) <= attrs:
return True
return NotImplemented
这是一个复杂的例子,包括了几个 Python 特性,这些特性在本书的后面才会被解释。它被包含在这里是为了完整性,但你不需要理解所有这些来了解如何创建你自己的 ABC。
第一件奇怪的事情是metaclass关键字参数被传递到类中,而在通常情况下你会看到父类列表。这是来自元类编程的神秘艺术中很少使用的构造。我们不会在本书中涵盖元类,所以你需要知道的是,通过分配ABCMeta元类,你为你的类赋予了超级英雄(或至少是超类)的能力。
接下来,我们看到了@abc.abstractmethod和@abc.abstractproperty构造。这些是 Python 装饰器。我们将在第二十二章中讨论这些。现在,只需要知道通过将方法或属性标记为抽象,你声明了这个类的任何子类必须实现该方法或提供该属性,才能被视为该类的合格成员。
看看如果你实现了提供或不提供这些属性的子类会发生什么:
>>> class Wav(MediaLoader):
... pass
...
>>> x = Wav()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Wav with abstract methods ext, play
>>> class Ogg(MediaLoader):
... ext = '.ogg'
... def play(self):
... pass
...
>>> o = Ogg()
由于Wav类未实现抽象属性,因此无法实例化该类。该类仍然是一个合法的抽象类,但你必须对其进行子类化才能实际执行任何操作。Ogg类提供了这两个属性,因此可以干净地实例化。
回到MediaLoader ABC,让我们解剖一下__subclasshook__方法。它基本上是说,任何提供了这个 ABC 所有抽象属性的具体实现的类都应该被认为是MediaLoader的子类,即使它实际上并没有继承自MediaLoader类。
更常见的面向对象语言在接口和类的实现之间有明确的分离。例如,一些语言提供了一个明确的interface关键字,允许我们定义一个类必须具有的方法,而不需要任何实现。在这样的环境中,抽象类是提供了接口和一些但不是所有方法的具体实现的类。任何类都可以明确声明它实现了给定的接口。
Python 的 ABCs 有助于提供接口的功能,而不会影响鸭子类型的好处。
解密魔术
如果你想要创建满足这个特定契约的抽象类,你可以复制并粘贴子类代码而不必理解它。我们将在本书中涵盖大部分不寻常的语法,但让我们逐行地概述一下:
@classmethod
这个装饰器标记方法为类方法。它基本上表示该方法可以在类上调用,而不是在实例化的对象上调用:
def __subclasshook__(cls, C):
这定义了__subclasshook__类方法。这个特殊的方法是由 Python 解释器调用来回答这个问题:类C是这个类的子类吗?
if cls is MediaLoader:
我们检查方法是否是在这个类上专门调用的,而不是在这个类的子类上调用。例如,这可以防止Wav类被认为是Ogg类的父类:
attrs = set(dir(C))
这一行所做的只是获取类的方法和属性集,包括其类层次结构中的任何父类:
if set(cls.__abstractmethods__) <= attrs:
这一行使用集合符号来查看候选类中是否提供了这个类中的抽象方法。请注意,它不检查方法是否已经被实现;只是检查它们是否存在。因此,一个类可能是一个子类,但仍然是一个抽象类本身。
return True
如果所有的抽象方法都已经提供,那么候选类是这个类的子类,我们返回True。该方法可以合法地返回三个值之一:True,False或NotImplemented。True和False表示该类是否明确是这个类的子类:
return NotImplemented
如果任何条件都没有被满足(也就是说,这个类不是MediaLoader,或者没有提供所有的抽象方法),那么返回NotImplemented。这告诉 Python 机制使用默认机制(候选类是否明确扩展了这个类?)来检测子类。
简而言之,我们现在可以将Ogg类定义为MediaLoader类的子类,而不实际扩展MediaLoader类:
>>> class Ogg(): ... ext = '.ogg' ... def play(self): ... print("this will play an ogg file") ... >>> issubclass(Ogg, MediaLoader) True >>> isinstance(Ogg(), MediaLoader) True
案例研究
让我们尝试用一个更大的例子把我们学到的东西联系起来。我们将为编程作业开发一个自动评分系统,类似于 Dataquest 或 Coursera 使用的系统。该系统需要为课程作者提供一个简单的基于类的接口,以便创建他们的作业,并且如果不满足该接口,应该提供有用的错误消息。作者需要能够提供他们的课程内容,并编写自定义答案检查代码,以确保他们的学生得到正确的答案。他们还可以访问学生的姓名,使内容看起来更友好一些。
评分系统本身需要跟踪学生当前正在进行的作业。学生可能在得到正确答案之前尝试几次作业。我们希望跟踪尝试次数,以便课程作者可以改进更难的课程内容。
让我们首先定义课程作者需要使用的接口。理想情况下,除了课程内容和答案检查代码之外,它将要求课程作者写入最少量的额外代码。以下是我能想到的最简单的类:
class IntroToPython:
def lesson(self):
return f"""
Hello {self.student}. define two variables,
an integer named a with value 1
and a string named b with value 'hello'
"""
def check(self, code):
return code == "a = 1\nb = 'hello'"
诚然,该课程作者可能对他们的答案检查方式有些天真。
我们可以从定义这个接口的抽象基类开始,如下所示:
class Assignment(metaclass=abc.ABCMeta):
@abc.abstractmethod
def lesson(self, student):
pass
@abc.abstractmethod
def check(self, code):
pass
@classmethod
def __subclasshook__(cls, C):
if cls is Assignment:
attrs = set(dir(C))
if set(cls.__abstractmethods__) <= attrs:
return True
return NotImplemented
这个 ABC 定义了两个必需的抽象方法,并提供了魔术__subclasshook__方法,允许一个类被视为子类,而无需明确扩展它(我通常只是复制并粘贴这段代码。不值得记忆。)
我们可以使用issubclass(IntroToPython, Assignment)来确认IntroToPython类是否满足这个接口,这应该返回True。当然,如果愿意,我们也可以明确扩展Assignment类,就像在第二个作业中所看到的那样:
class Statistics(Assignment):
def lesson(self):
return (
"Good work so far, "
+ self.student
+ ". Now calculate the average of the numbers "
+ " 1, 5, 18, -3 and assign to a variable named 'avg'"
)
def check(self, code):
import statistics
code = "import statistics\n" + code
local_vars = {}
global_vars = {}
exec(code, global_vars, local_vars)
return local_vars.get("avg") == statistics.mean([1, 5, 18, -3])
不幸的是,这位课程作者也相当天真。exec调用将在评分系统内部执行学生的代码,使他们可以访问整个系统。显然,他们将首先对系统进行黑客攻击,使他们的成绩达到 100%。他们可能认为这比正确完成作业更容易!
接下来,我们将创建一个类,用于管理学生在特定作业上尝试的次数:
class AssignmentGrader:
def __init__(self, student, AssignmentClass):
self.assignment = AssignmentClass()
self.assignment.student = student
self.attempts = 0
self.correct_attempts = 0
def check(self, code):
self.attempts += 1
result = self.assignment.check(code)
if result:
self.correct_attempts += 1
return result
def lesson(self):
return self.assignment.lesson()
这个类使用组合而不是继承。乍一看,这些方法存在于Assignment超类似乎是有道理的。这将消除令人讨厌的lesson方法,它只是代理到作业对象上的相同方法。当然,可以直接在Assignment抽象基类上放置所有这些逻辑,甚至可以让 ABC 从这个AssignmentGrader类继承。事实上,我通常会推荐这样做,但在这种情况下,这将强制所有课程作者明确扩展该类,这违反了我们尽可能简单地请求内容创作的要求。
最后,我们可以开始组建Grader类,该类负责管理哪些作业是可用的,每个学生当前正在进行哪个作业。最有趣的部分是注册方法:
import uuid
class Grader:
def __init__(self):
self.student_graders = {}
self.assignment_classes = {}
def register(self, assignment_class):
if not issubclass(assignment_class, Assignment):
raise RuntimeError(
"Your class does not have the right methods"
)
id = uuid.uuid4()
self.assignment_classes[id] = assignment_class
return id
这个代码块包括初始化器,其中包括我们将在一分钟内讨论的两个字典。register方法有点复杂,所以我们将彻底剖析它。
第一件奇怪的事是这个方法接受的参数:assignment_class。这个参数意味着是一个实际的类,而不是类的实例。记住,类也是对象,可以像其他类一样传递。鉴于我们之前定义的IntroToPython类,我们可以在不实例化的情况下注册它,如下所示:
from grader import Grader
from lessons import IntroToPython, Statistics
grader = Grader()
itp_id = grader.register(IntroToPython)
该方法首先检查该类是否是Assignment类的子类。当然,我们实现了一个自定义的__subclasshook__方法,因此这包括了不明确地作为Assignment子类的类。命名可能有点欺骗性!如果它没有这两个必需的方法,它会引发一个异常。异常是我们将在下一章详细讨论的一个主题;现在,只需假设它会使程序生气并退出。
然后,我们生成一个随机标识符来表示特定的作业。我们将assignment_class存储在一个由该 ID 索引的字典中,并返回该 ID,以便调用代码将来可以查找该作业。据推测,另一个对象将在某种课程大纲中放置该 ID,以便学生按顺序完成作业,但在项目的这一部分我们不会这样做。
uuid函数返回一个称为通用唯一标识符的特殊格式字符串,也称为全局唯一标识符。它基本上代表一个几乎不可能与另一个类似生成的标识符冲突的极大随机数。这是创建用于跟踪项目的任意 ID 的一种很好、快速和干净的方法。
接下来,我们有start_assignment函数,它允许学生开始做一项作业,给定该作业的 ID。它所做的就是构造我们之前定义的AssignmentGrader类的一个实例,并将其放入存储在Grader类上的字典中,如下所示:
def start_assignment(self, student, id):
self.student_graders[student] = AssignmentGrader(
student, self.assignment_classes[id]
)
之后,我们编写了一些代理方法,用于获取学生当前正在进行的课程或检查作业的代码:
def get_lesson(self, student):
assignment = self.student_graders[student]
return assignment.lesson()
def check_assignment(self, student, code):
assignment = self.student_graders[student]
return assignment.check(code)
最后,我们创建了一个方法,用于总结学生当前作业的进展情况。它查找作业对象,并创建一个格式化的字符串,其中包含我们对该学生的所有信息:
def assignment_summary(self, student):
grader = self.student_graders[student]
return f"""
{student}'s attempts at {grader.assignment.__class__.__name__}:
attempts: {grader.attempts}
correct: {grader.correct_attempts}
passed: {grader.correct_attempts > 0}
"""
就是这样。您会注意到,这个案例研究并没有使用大量的继承,这可能看起来有点奇怪,因为这一章的主题,但鸭子类型非常普遍。Python 程序通常被设计为使用继承,随着迭代的进行,它会简化为更多功能的构造。举个例子,我最初将AssignmentGrader定义为继承关系,但中途意识到最好使用组合,原因如前所述。
以下是一些测试代码,展示了所有这些对象是如何连接在一起的:
grader = Grader()
itp_id = grader.register(IntroToPython)
stat_id = grader.register(Statistics)
grader.start_assignment("Tammy", itp_id)
print("Tammy's Lesson:", grader.get_lesson("Tammy"))
print(
"Tammy's check:",
grader.check_assignment("Tammy", "a = 1 ; b = 'hello'"),
)
print(
"Tammy's other check:",
grader.check_assignment("Tammy", "a = 1\nb = 'hello'"),
)
print(grader.assignment_summary("Tammy"))
grader.start_assignment("Tammy", stat_id)
print("Tammy's Lesson:", grader.get_lesson("Tammy"))
print("Tammy's check:", grader.check_assignment("Tammy", "avg=5.25"))
print(
"Tammy's other check:",
grader.check_assignment(
"Tammy", "avg = statistics.mean([1, 5, 18, -3])"
),
)
print(grader.assignment_summary("Tammy"))
练习
看看你的工作空间中的一些物理物体,看看你能否用继承层次结构描述它们。人类几个世纪以来一直在将世界划分为这样的分类法,所以这应该不难。在对象类之间是否存在一些非明显的继承关系?如果你要在计算机应用程序中对这些对象进行建模,它们会共享哪些属性和方法?哪些属性需要多态地重写?它们之间有哪些完全不同的属性?
现在写一些代码。不是为了物理层次结构;那很无聊。物理物品比方法更多。只是想想你过去一年想要解决的宠物编程项目。无论你想解决什么问题,都试着想出一些基本的继承关系,然后实现它们。确保你也注意到了实际上不需要使用继承的关系。有哪些地方你可能想要使用多重继承?你确定吗?你能看到任何你想使用混入的地方吗?试着拼凑一个快速的原型。它不必有用,甚至不必部分工作。你已经看到了如何使用python -i测试代码;只需编写一些代码并在交互式解释器中测试它。如果它有效,再写一些。如果不行,修复它!
现在,看看案例研究中的学生评分系统。它缺少很多东西,不仅仅是良好的课程内容!学生如何进入系统?是否有一个课程大纲规定他们应该按照什么顺序学习课程?如果你将AssignmentGrader更改为在Assignment对象上使用继承而不是组合,会发生什么?
最后,尝试想出一些使用混入的好用例,然后尝试使用混入,直到意识到可能有更好的设计使用组合!
总结
我们已经从简单的继承,这是面向对象程序员工具箱中最有用的工具之一,一直到多重继承——最复杂的之一。继承可以用来通过继承向现有类和内置类添加功能。将类似的代码抽象成父类可以帮助增加可维护性。父类上的方法可以使用super进行调用,并且在使用多重继承时,参数列表必须安全地格式化以使这些调用起作用。抽象基类允许您记录一个类必须具有哪些方法和属性才能满足特定接口,并且甚至允许您更改子类的定义。
在下一章中,我们将介绍处理特殊情况的微妙艺术。
第十八章:预料之外的情况
程序非常脆弱。如果代码总是返回有效的结果,那将是理想的,但有时无法计算出有效的结果。例如,不能除以零,或者访问五项列表中的第八项。
在过去,唯一的解决方法是严格检查每个函数的输入,以确保它们是有意义的。通常,函数有特殊的返回值来指示错误条件;例如,它们可以返回一个负数来表示无法计算出正值。不同的数字可能表示不同的错误。调用这个函数的任何代码都必须明确检查错误条件并相应地采取行动。许多开发人员不愿意这样做,程序就会崩溃。然而,在面向对象的世界中,情况并非如此。
在本章中,我们将学习异常,这是特殊的错误对象,只有在有意义处理它们时才需要处理。特别是,我们将涵盖以下内容:
-
如何引发异常
-
在异常发生时如何恢复
-
如何以不同的方式处理不同类型的异常
-
在异常发生时进行清理
-
创建新类型的异常
-
使用异常语法进行流程控制
引发异常
原则上,异常只是一个对象。有许多不同的异常类可用,我们也可以很容易地定义更多我们自己的异常。它们所有的共同之处是它们都继承自一个名为BaseException的内置类。当这些异常对象在程序的控制流中被处理时,它们就变得特殊起来。当异常发生时,除非在异常发生时应该发生,否则一切都不会发生。明白了吗?别担心,你会明白的!
引发异常的最简单方法是做一些愚蠢的事情。很有可能你已经这样做过,并看到了异常输出。例如,每当 Python 遇到无法理解的程序行时,它就会以SyntaxError退出,这是一种异常。这是一个常见的例子:
>>> print "hello world"
File "<stdin>", line 1
print "hello world"
^
SyntaxError: invalid syntax
这个print语句在 Python 2 和更早的版本中是一个有效的命令,但在 Python 3 中,因为print是一个函数,我们必须用括号括起参数。因此,如果我们将前面的命令输入 Python 3 解释器,我们会得到SyntaxError。
除了SyntaxError,以下示例中还显示了一些其他常见的异常:
>>> x = 5 / 0
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: int division or modulo by zero
>>> lst = [1,2,3]
>>> print(lst[3])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range
>>> lst + 2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can only concatenate list (not "int") to list
>>> lst.add
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'list' object has no attribute 'add'
>>> d = {'a': 'hello'}
>>> d['b']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'b'
>>> print(this_is_not_a_var)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'this_is_not_a_var' is not defined
有时,这些异常是我们程序中出现问题的指示器(在这种情况下,我们会去到指示的行号并进行修复),但它们也会在合法的情况下发生。ZeroDivisionError错误并不总是意味着我们收到了无效的输入。它也可能意味着我们收到了不同的输入。用户可能误输入了零,或者故意输入了零,或者它可能代表一个合法的值,比如一个空的银行账户或者一个新生儿的年龄。
你可能已经注意到所有前面的内置异常都以Error结尾。在 Python 中,error和Exception这两个词几乎可以互换使用。错误有时被认为比异常更严重,但它们的处理方式完全相同。事实上,前面示例中的所有错误类都有Exception(它继承自BaseException)作为它们的超类。
引发异常
我们将在一分钟内开始回应这些异常,但首先,让我们发现如果我们正在编写一个需要通知用户或调用函数输入无效的程序应该做什么。我们可以使用 Python 使用的完全相同的机制。这里有一个简单的类,只有当它们是偶数的整数时才向列表添加项目:
class EvenOnly(list):
def append(self, integer):
if not isinstance(integer, int):
raise TypeError("Only integers can be added")
if integer % 2:
raise ValueError("Only even numbers can be added")
super().append(integer)
这个类扩展了内置的list,就像我们在第十六章中讨论的那样,Python 中的对象,并覆盖了append方法以检查两个条件,以确保项目是偶数。我们首先检查输入是否是int类型的实例,然后使用模运算符确保它可以被 2 整除。如果两个条件中的任何一个不满足,raise关键字会引发异常。raise关键字后面跟着作为异常引发的对象。在前面的例子中,从内置的TypeError和ValueError类构造了两个对象。引发的对象也可以很容易地是我们自己创建的新Exception类的实例(我们很快就会看到),在其他地方定义的异常,甚至是先前引发和处理的Exception对象。
如果我们在 Python 解释器中测试这个类,我们可以看到在异常发生时输出了有用的错误信息,就像以前一样:
>>> e = EvenOnly()
>>> e.append("a string")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "even_integers.py", line 7, in add
raise TypeError("Only integers can be added")
TypeError: Only integers can be added
>>> e.append(3)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "even_integers.py", line 9, in add
raise ValueError("Only even numbers can be added")
ValueError: Only even numbers can be added
>>> e.append(2)
虽然这个类对于演示异常的作用是有效的,但它并不擅长其工作。仍然可以使用索引表示法或切片表示法将其他值添加到列表中。通过覆盖其他适当的方法,一些是魔术双下划线方法,所有这些都可以避免。
异常的影响
当引发异常时,似乎会立即停止程序执行。在引发异常之后应该运行的任何行都不会被执行,除非处理异常,否则程序将以错误消息退出。看一下这个基本函数:
def no_return():
print("I am about to raise an exception")
raise Exception("This is always raised")
print("This line will never execute")
return "I won't be returned"
如果我们执行这个函数,我们会看到第一个print调用被执行,然后引发异常。第二个print函数调用不会被执行,return语句也不会被执行:
>>> no_return()
I am about to raise an exception
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "exception_quits.py", line 3, in no_return
raise Exception("This is always raised")
Exception: This is always raised
此外,如果我们有一个调用另一个引发异常的函数的函数,那么在调用第二个函数的地方之后,第一个函数中的任何内容都不会被执行。引发异常会立即停止所有执行,直到函数调用堆栈,直到它被处理或强制解释器退出。为了演示,让我们添加一个调用先前函数的第二个函数:
def call_exceptor():
print("call_exceptor starts here...")
no_return()
print("an exception was raised...")
print("...so these lines don't run")
当我们调用这个函数时,我们会看到第一个print语句被执行,以及no_return函数中的第一行。但一旦引发异常,就不会执行其他任何内容:
>>> call_exceptor()
call_exceptor starts here...
I am about to raise an exception
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "method_calls_excepting.py", line 9, in call_exceptor
no_return()
File "method_calls_excepting.py", line 3, in no_return
raise Exception("This is always raised")
Exception: This is always raised
我们很快就会看到,当解释器实际上没有采取捷径并立即退出时,我们可以在任一方法内部对异常做出反应并处理。事实上,异常可以在最初引发后的任何级别进行处理。
从下到上查看异常的输出(称为回溯),注意两种方法都被列出。在no_return内部,异常最初被引发。然后,在其上方,我们看到在call_exceptor内部,那个讨厌的no_return函数被调用,异常冒泡到调用方法。从那里,它再上升一级到主解释器,由于不知道该如何处理它,放弃并打印了一个回溯。
处理异常
现在让我们看一下异常硬币的反面。如果我们遇到异常情况,我们的代码应该如何对其做出反应或恢复?我们通过在try...except子句中包装可能引发异常的任何代码(无论是异常代码本身,还是调用可能在其中引发异常的任何函数或方法)来处理异常。最基本的语法如下:
try:
no_return()
except:
print("I caught an exception")
print("executed after the exception")
如果我们使用现有的no_return函数运行这个简单的脚本——正如我们非常清楚的那样,它总是会引发异常——我们会得到这个输出:
I am about to raise an exception
I caught an exception
executed after the exception
no_return函数愉快地通知我们它即将引发异常,但我们欺骗了它并捕获了异常。一旦捕获,我们就能够清理自己(在这种情况下,通过输出我们正在处理的情况),并继续前进,而不受那个冒犯性的函数的干扰。no_return函数中剩余的代码仍未执行,但调用函数的代码能够恢复并继续。
请注意try和except周围的缩进。try子句包装可能引发异常的任何代码。然后except子句回到与try行相同的缩进级别。处理异常的任何代码都在except子句之后缩进。然后正常代码在原始缩进级别上恢复。
上述代码的问题在于它会捕获任何类型的异常。如果我们编写的代码可能引发TypeError和ZeroDivisionError,我们可能希望捕获ZeroDivisionError,但让TypeError传播到控制台。你能猜到语法是什么吗?
这是一个相当愚蠢的函数,它就是这样做的:
def funny_division(divider):
try:
return 100 / divider
except ZeroDivisionError:
return "Zero is not a good idea!"
print(funny_division(0))
print(funny_division(50.0))
print(funny_division("hello"))
通过print语句测试该函数,显示它的行为符合预期:
Zero is not a good idea!
2.0
Traceback (most recent call last):
File "catch_specific_exception.py", line 9, in <module>
print(funny_division("hello"))
File "catch_specific_exception.py", line 3, in funny_division
return 100 / divider
TypeError: unsupported operand type(s) for /: 'int' and 'str'.
输出的第一行显示,如果我们输入0,我们会得到适当的模拟。如果使用有效的数字(请注意,它不是整数,但仍然是有效的除数),它会正确运行。但是,如果我们输入一个字符串(你一定想知道如何得到TypeError,不是吗?),它会出现异常。如果我们使用了一个未指定ZeroDivisionError的空except子句,当我们发送一个字符串时,它会指责我们除以零,这根本不是正确的行为。
裸 except语法通常不受欢迎,即使你真的想捕获所有异常实例。使用except Exception:语法显式捕获所有异常类型。这告诉读者你的意思是捕获异常对象和所有Exception的子类。裸 except 语法实际上与使用except BaseException:相同,它实际上捕获了非常罕见的系统级异常,这些异常很少有意想要捕获,正如我们将在下一节中看到的。如果你真的想捕获它们,明确使用except BaseException:,这样任何阅读你的代码的人都知道你不只是忘记指定想要的异常类型。
我们甚至可以捕获两个或更多不同的异常,并用相同的代码处理它们。以下是一个引发三种不同类型异常的示例。它使用相同的异常处理程序处理TypeError和ZeroDivisionError,但如果您提供数字13,它也可能引发ValueError错误:
def funny_division2(divider):
try:
if divider == 13:
raise ValueError("13 is an unlucky number")
return 100 / divider
except (ZeroDivisionError, TypeError):
return "Enter a number other than zero"
for val in (0, "hello", 50.0, 13):
print("Testing {}:".format(val), end=" ")
print(funny_division2(val))
底部的for循环循环遍历几个测试输入并打印结果。如果你对print语句中的end参数感到疑惑,它只是将默认的尾随换行符转换为空格,以便与下一行的输出连接在一起。以下是程序的运行:
Testing 0: Enter a number other than zero
Testing hello: Enter a number other than zero
Testing 50.0: 2.0
Testing 13: Traceback (most recent call last):
File "catch_multiple_exceptions.py", line 11, in <module>
print(funny_division2(val))
File "catch_multiple_exceptions.py", line 4, in funny_division2
raise ValueError("13 is an unlucky number")
ValueError: 13 is an unlucky number
数字0和字符串都被except子句捕获,并打印出合适的错误消息。数字13的异常没有被捕获,因为它是一个ValueError,它没有包括在正在处理的异常类型中。这一切都很好,但如果我们想捕获不同的异常并对它们采取不同的措施怎么办?或者也许我们想对异常做一些处理,然后允许它继续冒泡到父函数,就好像它从未被捕获过?
我们不需要任何新的语法来处理这些情况。可以堆叠except子句,只有第一个匹配项将被执行。对于第二个问题,raise关键字,没有参数,将重新引发最后一个异常,如果我们已经在异常处理程序中。观察以下代码:
def funny_division3(divider):
try:
if divider == 13:
raise ValueError("13 is an unlucky number")
return 100 / divider
except ZeroDivisionError:
return "Enter a number other than zero"
except TypeError:
return "Enter a numerical value"
except ValueError:
print("No, No, not 13!")
raise
最后一行重新引发了ValueError错误,因此在输出No, No, not 13!之后,它将再次引发异常;我们仍然会在控制台上得到原始的堆栈跟踪。
如果我们像前面的例子中那样堆叠异常子句,只有第一个匹配的子句将被执行,即使有多个子句符合条件。为什么会有多个子句匹配?请记住,异常是对象,因此可以被子类化。正如我们将在下一节中看到的,大多数异常都扩展了Exception类(它本身是从BaseException派生的)。如果我们在捕获TypeError之前捕获Exception,那么只有Exception处理程序将被执行,因为TypeError是通过继承的Exception。
这在一些情况下非常有用,比如我们想要专门处理一些异常,然后将所有剩余的异常作为更一般的情况处理。在捕获所有特定异常后,我们可以简单地捕获Exception并在那里处理一般情况。
通常,当我们捕获异常时,我们需要引用Exception对象本身。这最常发生在我们使用自定义参数定义自己的异常时,但也可能与标准异常相关。大多数异常类在其构造函数中接受一组参数,我们可能希望在异常处理程序中访问这些属性。如果我们定义自己的Exception类,甚至可以在捕获时调用自定义方法。捕获异常作为变量的语法使用as关键字:
try:
raise ValueError("This is an argument")
except ValueError as e:
print("The exception arguments were", e.args)
如果我们运行这个简单的片段,它会打印出我们传递给ValueError初始化的字符串参数。
我们已经看到了处理异常的语法的几种变体,但我们仍然不知道如何执行代码,无论是否发生异常。我们也无法指定仅在不发生异常时执行的代码。另外两个关键字,finally和else,可以提供缺失的部分。它们都不需要额外的参数。以下示例随机选择一个要抛出的异常并引发它。然后运行一些不那么复杂的异常处理代码,演示了新引入的语法:
import random
some_exceptions = [ValueError, TypeError, IndexError, None]
try:
choice = random.choice(some_exceptions)
print("raising {}".format(choice))
if choice:
raise choice("An error")
except ValueError:
print("Caught a ValueError")
except TypeError:
print("Caught a TypeError")
except Exception as e:
print("Caught some other error: %s" %
( e.__class__.__name__))
else:
print("This code called if there is no exception")
finally:
print("This cleanup code is always called")
如果我们运行这个例子——它几乎涵盖了每种可能的异常处理场景——几次,每次都会得到不同的输出,这取决于random选择的异常。以下是一些示例运行:
$ python finally_and_else.py
raising None
This code called if there is no exception
This cleanup code is always called
$ python finally_and_else.py
raising <class 'TypeError'>
Caught a TypeError
This cleanup code is always called
$ python finally_and_else.py
raising <class 'IndexError'>
Caught some other error: IndexError
This cleanup code is always called
$ python finally_and_else.py
raising <class 'ValueError'>
Caught a ValueError
This cleanup code is always called
请注意finally子句中的print语句无论发生什么都会被执行。当我们需要在我们的代码运行结束后执行某些任务时(即使发生异常),这是非常有用的。一些常见的例子包括以下情况:
-
清理打开的数据库连接
-
关闭打开的文件
-
通过网络发送关闭握手
finally子句在我们从try子句内部执行return语句时也非常重要。在返回值之前,finally处理程序将仍然被执行,而不会执行try...finally子句后面的任何代码。
此外,当没有引发异常时,请注意输出:else和finally子句都会被执行。else子句可能看起来多余,因为应该在没有引发异常时执行的代码可以直接放在整个try...except块之后。不同之处在于,如果捕获并处理了异常,else块将不会被执行。当我们讨论后续使用异常作为流程控制时,我们将会更多地了解这一点。
在try块之后可以省略任何except、else和finally子句(尽管单独的else是无效的)。如果包含多个子句,则必须先是except子句,然后是else子句,最后是finally子句。except子句的顺序通常从最具体到最一般。
异常层次结构
我们已经看到了几个最常见的内置异常,你可能会在你的常规 Python 开发过程中遇到其余的异常。正如我们之前注意到的,大多数异常都是Exception类的子类。但并非所有异常都是如此。Exception本身实际上是继承自一个叫做BaseException的类。事实上,所有异常都必须扩展BaseException类或其子类之一。
有两个关键的内置异常类,SystemExit和KeyboardInterrupt,它们直接从BaseException而不是Exception派生。SystemExit异常是在程序自然退出时引发的,通常是因为我们在代码中的某个地方调用了sys.exit函数(例如,当用户选择退出菜单项,单击窗口上的关闭按钮,或输入命令关闭服务器时)。该异常旨在允许我们在程序最终退出之前清理代码。但是,我们通常不需要显式处理它,因为清理代码可以发生在finally子句中。
如果我们处理它,我们通常会重新引发异常,因为捕获它会阻止程序退出。当然,也有一些情况下,我们可能希望阻止程序退出;例如,如果有未保存的更改,我们希望提示用户是否真的要退出。通常,如果我们处理SystemExit,那是因为我们想对其进行特殊处理,或者直接预期它。我们尤其不希望它在捕获所有正常异常的通用子句中被意外捕获。这就是它直接从BaseException派生的原因。
KeyboardInterrupt异常在命令行程序中很常见。当用户使用与操作系统相关的组合键(通常是Ctrl + C)明确中断程序执行时,就会抛出该异常。这是用户有意中断运行中程序的标准方式,与SystemExit一样,它几乎总是应该通过终止程序来响应。同样,像SystemExit一样,它应该在finally块中处理任何清理任务。
这是一个完全说明了层次结构的类图:

当我们使用except:子句而没有指定任何异常类型时,它将捕获BaseException的所有子类;也就是说,它将捕获所有异常,包括这两个特殊的异常。由于我们几乎总是希望这些得到特殊处理,因此不明智地使用except:语句而不带参数。如果你想捕获除SystemExit和KeyboardInterrupt之外的所有异常,明确地捕获Exception。大多数 Python 开发人员认为没有指定类型的except:是一个错误,并会在代码审查中标记它。如果你真的想捕获所有异常,只需明确使用except BaseException:。
定义我们自己的异常
偶尔,当我们想要引发一个异常时,我们发现没有一个内置的异常适合。幸运的是,定义我们自己的新异常是微不足道的。类的名称通常设计为传达出了什么问题,我们可以在初始化程序中提供任意参数以包含额外的信息。
我们所要做的就是继承Exception类。我们甚至不必向类中添加任何内容!当然,我们可以直接扩展BaseException,但我从未遇到过这种情况。
这是我们在银行应用程序中可能使用的一个简单的异常:
class InvalidWithdrawal(Exception):
pass
raise InvalidWithdrawal("You don't have $50 in your account")
最后一行说明了如何引发新定义的异常。我们能够将任意数量的参数传递给异常。通常使用字符串消息,但可以存储任何在以后的异常处理程序中可能有用的对象。Exception.__init__方法设计为接受任何参数并将它们存储为名为args的属性中的元组。这使得异常更容易定义,而无需覆盖__init__。
当然,如果我们确实想要自定义初始化程序,我们是可以自由这样做的。这里有一个异常,它的初始化程序接受当前余额和用户想要提取的金额。此外,它添加了一个方法来计算请求透支了多少。
class InvalidWithdrawal(Exception):
def __init__(self, balance, amount):
super().__init__(f"account doesn't have ${amount}")
self.amount = amount
self.balance = balance
def overage(self):
return self.amount - self.balance
raise InvalidWithdrawal(25, 50)
结尾的raise语句说明了如何构造这个异常。正如你所看到的,我们可以对异常做任何其他对象可以做的事情。
这是我们如何处理InvalidWithdrawal异常的方法,如果有异常被引发:
try:
raise InvalidWithdrawal(25, 50)
except InvalidWithdrawal as e:
print("I'm sorry, but your withdrawal is "
"more than your balance by "
f"${e.overage()}")
在这里,我们看到了as关键字的有效使用。按照惯例,大多数 Python 程序员将异常命名为e或ex变量,尽管通常情况下,你可以自由地将其命名为exception,或者如果你愿意的话,可以称之为aunt_sally。
定义自己的异常有很多原因。通常,向异常中添加信息或以某种方式记录异常是很有用的。但是,自定义异常的实用性在创建面向其他程序员访问的框架、库或 API 时才真正显现出来。在这种情况下,要小心确保代码引发的异常对客户程序员有意义。它们应该易于处理,并清楚地描述发生了什么。客户程序员应该很容易看到如何修复错误(如果它反映了他们代码中的错误)或处理异常(如果这是他们需要知道的情况)。
异常并不是异常的。新手程序员倾向于认为异常只对异常情况有用。然而,异常情况的定义可能模糊不清,而且可能会有不同的解释。考虑以下两个函数:
def divide_with_exception(number, divisor):
try:
print(f"{number} / {divisor} = {number / divisor}")
except ZeroDivisionError:
print("You can't divide by zero")
def divide_with_if(number, divisor):
if divisor == 0:
print("You can't divide by zero")
else:
print(f"{number} / {divisor} = {number / divisor}")
这两个函数的行为是相同的。如果divisor为零,则打印错误消息;否则,显示除法结果的消息。我们可以通过使用if语句来避免抛出ZeroDivisionError。同样,我们可以通过明确检查参数是否在列表范围内来避免IndexError,并通过检查键是否在字典中来避免KeyError。
但我们不应该这样做。首先,我们可能会编写一个if语句,检查索引是否低于列表的参数,但忘记检查负值。
记住,Python 列表支持负索引;-1指的是列表中的最后一个元素。
最终,我们会发现这一点,并不得不找到我们检查代码的所有地方。但如果我们简单地捕获IndexError并处理它,我们的代码就可以正常工作。
Python 程序员倾向于遵循“宁可请求原谅,而不是事先征得许可”的模式,也就是说,他们执行代码,然后处理任何出现的问题。相反,先“三思而后行”的做法通常不太受欢迎。这样做的原因有几个,但主要原因是不应该需要消耗 CPU 周期来寻找在正常代码路径中不会出现的异常情况。因此,明智的做法是将异常用于异常情况,即使这些情况只是稍微异常。进一步地,我们实际上可以看到异常语法对于流程控制也是有效的。与if语句一样,异常可以用于决策、分支和消息传递。
想象一家销售小部件和小工具的公司的库存应用程序。当客户购买商品时,商品可以是有库存的,这种情况下商品会从库存中移除并返回剩余商品数量,或者可能是缺货的。现在,缺货在库存应用程序中是一件完全正常的事情。这绝对不是一个异常情况。但如果缺货了,我们应该返回什么呢?一个显示缺货的字符串?一个负数?在这两种情况下,调用方法都必须检查返回值是正整数还是其他值,以确定是否缺货。这似乎有点混乱,特别是如果我们在代码中忘记做这个检查。
相反,我们可以引发OutOfStock并使用try语句来控制程序流程。有道理吗?此外,我们还要确保不会将同一商品卖给两个不同的客户,或者出售还未备货的商品。促进这一点的一种方法是锁定每种商品,以确保一次只有一个人可以更新它。用户必须锁定商品,操作商品(购买、补充库存、计算剩余商品数量...),然后解锁商品。以下是一个带有描述部分方法应该做什么的文档字符串的不完整的Inventory示例:
class Inventory:
def lock(self, item_type):
"""Select the type of item that is going to
be manipulated. This method will lock the
item so nobody else can manipulate the
inventory until it's returned. This prevents
selling the same item to two different
customers."""
pass
def unlock(self, item_type):
"""Release the given type so that other
customers can access it."""
pass
def purchase(self, item_type):
"""If the item is not locked, raise an
exception. If the item_type does not exist,
raise an exception. If the item is currently
out of stock, raise an exception. If the item
is available, subtract one item and return
the number of items left."""
pass
我们可以将这个对象原型交给开发人员,并让他们实现方法,确保它们按照我们说的那样工作,而我们则可以继续编写需要进行购买的代码。我们将使用 Python 强大的异常处理来考虑不同的分支,具体取决于购买是如何进行的。
item_type = "widget"
inv = Inventory()
inv.lock(item_type)
try:
num_left = inv.purchase(item_type)
except InvalidItemType:
print("Sorry, we don't sell {}".format(item_type))
except OutOfStock:
print("Sorry, that item is out of stock.")
else:
print("Purchase complete. There are {num_left} {item_type}s left")
finally:
inv.unlock(item_type)
注意所有可能的异常处理子句是如何用来确保在正确的时间发生正确的操作。尽管OutOfStock并不是一个非常异常的情况,但我们能够使用异常来适当地处理它。这段代码也可以用if...elif...else结构来编写,但这样不容易阅读和维护。
我们还可以使用异常来在不同的方法之间传递消息。例如,如果我们想要告知客户商品预计何时会再次有货,我们可以确保我们的OutOfStock对象在构造时需要一个back_in_stock参数。然后,当我们处理异常时,我们可以检查该值并向客户提供额外的信息。附加到对象的信息可以很容易地在程序的两个不同部分之间传递。异常甚至可以提供一个方法,指示库存对象重新订购或预订商品。
使用异常来进行流程控制可以设计出一些方便的程序。从这次讨论中要记住的重要事情是异常并不是我们应该尽量避免的坏事。发生异常并不意味着你应该阻止这种异常情况的发生。相反,这只是一种在两个可能不直接调用彼此的代码部分之间传递信息的强大方式。
案例研究
我们一直在比较低级的细节层面上看异常的使用和处理——语法和定义。这个案例研究将帮助我们将这一切与之前的章节联系起来,这样我们就能看到异常在对象、继承和模块的更大背景下是如何使用的。
今天,我们将设计一个简单的中央认证和授权系统。整个系统将放置在一个模块中,其他代码将能够查询该模块对象以进行认证和授权。我们应该承认,从一开始,我们并不是安全专家,我们设计的系统可能存在许多安全漏洞。
我们的目的是研究异常,而不是保护系统。然而,对于其他代码可以与之交互的基本登录和权限系统来说,这是足够的。以后,如果其他代码需要更安全,我们可以请安全或密码专家审查或重写我们的模块,最好不要改变 API。
认证是确保用户确实是他们所说的人的过程。我们将遵循当今常见的网络系统的做法,使用用户名和私人密码组合。其他的认证方法包括语音识别、指纹或视网膜扫描仪以及身份证。
授权,另一方面,完全取决于确定特定(经过身份验证的)用户是否被允许执行特定操作。我们将创建一个基本的权限列表系统,该系统存储了允许执行每个操作的特定人员的列表。
此外,我们将添加一些管理功能,以允许新用户加入系统。为简洁起见,我们将省略密码编辑或一旦添加后更改权限,但是这些(非常必要的)功能当然可以在将来添加。
这是一个简单的分析;现在让我们继续设计。显然,我们需要一个存储用户名和加密密码的User类。这个类还将允许用户通过检查提供的密码是否有效来登录。我们可能不需要一个Permission类,因为可以将这些类别映射到使用字典的用户列表。我们应该有一个中央的Authenticator类,负责用户管理和登录或注销。拼图的最后一块是一个Authorizor类,处理权限和检查用户是否能执行某项活动。我们将在auth模块中提供这些类的单个实例,以便其他模块可以使用这个中央机制来满足其所有的身份验证和授权需求。当然,如果它们想要实例化这些类的私有实例,用于非中央授权活动,它们是可以自由这样做的。
随着我们的进行,我们还将定义几个异常。我们将从一个特殊的AuthException基类开始,它接受username和可选的user对象作为参数;我们自定义的大多数异常将继承自这个类。
让我们首先构建User类;这似乎足够简单。可以使用用户名和密码初始化一个新用户。密码将被加密存储,以减少被盗的可能性。我们还需要一个check_password方法来测试提供的密码是否正确。以下是完整的类:
import hashlib
class User:
def __init__(self, username, password):
"""Create a new user object. The password
will be encrypted before storing."""
self.username = username
self.password = self._encrypt_pw(password)
self.is_logged_in = False
def _encrypt_pw(self, password):
"""Encrypt the password with the username and return
the sha digest."""
hash_string = self.username + password
hash_string = hash_string.encode("utf8")
return hashlib.sha256(hash_string).hexdigest()
def check_password(self, password):
"""Return True if the password is valid for this
user, false otherwise."""
encrypted = self._encrypt_pw(password)
return encrypted == self.password
由于在__init__和check_password中需要加密密码的代码,我们将其提取到自己的方法中。这样,如果有人意识到它不安全并需要改进,它只需要在一个地方进行更改。这个类可以很容易地扩展到包括强制或可选的个人详细信息,比如姓名、联系信息和出生日期。
在编写代码添加用户之前(这将在尚未定义的Authenticator类中进行),我们应该检查一些用例。如果一切顺利,我们可以添加一个带有用户名和密码的用户;User对象被创建并插入到字典中。但是,有哪些情况可能不顺利呢?显然,我们不希望添加一个已经存在于字典中的用户名的用户。
如果这样做,我们将覆盖现有用户的数据,新用户可能会访问该用户的权限。因此,我们需要一个UsernameAlreadyExists异常。另外,出于安全考虑,如果密码太短,我们可能应该引发一个异常。这两个异常都将扩展AuthException,我们之前提到过。因此,在编写Authenticator类之前,让我们定义这三个异常类:
class AuthException(Exception):
def __init__(self, username, user=None):
super().__init__(username, user)
self.username = username
self.user = user
class UsernameAlreadyExists(AuthException):
pass
class PasswordTooShort(AuthException):
pass
AuthException需要用户名,并且有一个可选的用户参数。第二个参数应该是与该用户名关联的User类的实例。我们正在定义的两个具体异常只需要通知调用类发生了异常情况,因此我们不需要为它们添加任何额外的方法。
现在让我们开始Authenticator类。它可以简单地是用户名到用户对象的映射,因此我们将从初始化函数中的字典开始。添加用户的方法需要在将新的User实例添加到字典之前检查两个条件(密码长度和先前存在的用户):
class Authenticator:
def __init__(self):
"""Construct an authenticator to manage
users logging in and out."""
self.users = {}
def add_user(self, username, password):
if username in self.users:
raise UsernameAlreadyExists(username)
if len(password) < 6:
raise PasswordTooShort(username)
self.users[username] = User(username, password)
当然,如果需要,我们可以扩展密码验证以引发其他方式太容易破解的密码的异常。现在让我们准备login方法。如果我们现在不考虑异常,我们可能只希望该方法根据登录是否成功返回True或False。但我们正在考虑异常,这可能是一个不那么异常的情况使用它们的好地方。我们可以引发不同的异常,例如,如果用户名不存在或密码不匹配。这将允许尝试登录用户的任何人使用try/except/else子句优雅地处理情况。因此,首先我们添加这些新的异常:
class InvalidUsername(AuthException):
pass
class InvalidPassword(AuthException):
pass
然后我们可以为我们的Authenticator类定义一个简单的login方法,如果必要的话引发这些异常。如果不是,它会标记user已登录并返回以下内容:
def login(self, username, password):
try:
user = self.users[username]
except KeyError:
raise InvalidUsername(username)
if not user.check_password(password):
raise InvalidPassword(username, user)
user.is_logged_in = True
return True
请注意KeyError的处理方式。这可以使用if username not in self.users:来处理,但我们选择直接处理异常。我们最终吞掉了这个第一个异常,并引发了一个更适合用户界面 API 的全新异常。
我们还可以添加一个方法来检查特定用户名是否已登录。在这里决定是否使用异常更加棘手。如果用户名不存在,我们应该引发异常吗?如果用户未登录,我们应该引发异常吗?
要回答这些问题,我们需要考虑该方法如何被访问。大多数情况下,这种方法将用于回答是/否的问题,我应该允许他们访问
def is_logged_in(self, username):
if username in self.users:
return self.users[username].is_logged_in
return False
最后,我们可以向我们的模块添加一个默认的认证实例,以便客户端代码可以使用auth.authenticator轻松访问它:
authenticator = Authenticator()
这一行放在模块级别,不在任何类定义之外,因此可以通过auth.authenticator访问authenticator变量。现在我们可以开始Authorizor类,它将权限映射到用户。Authorizor类不应允许用户访问权限,如果他们未登录,因此它们将需要引用特定的认证实例。我们还需要在初始化时设置权限字典:
class Authorizor:
def __init__(self, authenticator):
self.authenticator = authenticator
self.permissions = {}
现在我们可以编写方法来添加新的权限,并设置哪些用户与每个权限相关联:
def add_permission(self, perm_name):
'''Create a new permission that users
can be added to'''
try:
perm_set = self.permissions[perm_name]
except KeyError:
self.permissions[perm_name] = set()
else:
raise PermissionError("Permission Exists")
def permit_user(self, perm_name, username):
'''Grant the given permission to the user'''
try:
perm_set = self.permissions[perm_name]
except KeyError:
raise PermissionError("Permission does not exist")
else:
if username not in self.authenticator.users:
raise InvalidUsername(username)
perm_set.add(username)
第一个方法允许我们创建一个新的权限,除非它已经存在,否则会引发异常。第二个方法允许我们将用户名添加到权限中,除非权限或用户名尚不存在。
我们使用set而不是list来存储用户名,这样即使您多次授予用户权限,集合的性质意味着用户只会在集合中出现一次。
这两种方法都引发了PermissionError错误。这个新错误不需要用户名,所以我们将它直接扩展为Exception,而不是我们自定义的AuthException:
class PermissionError(Exception):
pass
最后,我们可以添加一个方法来检查用户是否具有特定的permission。为了让他们获得访问权限,他们必须同时登录到认证器并在被授予该特权访问的人员集合中。如果这两个条件中有一个不满足,就会引发异常:
def check_permission(self, perm_name, username):
if not self.authenticator.is_logged_in(username):
raise NotLoggedInError(username)
try:
perm_set = self.permissions[perm_name]
except KeyError:
raise PermissionError("Permission does not exist")
else:
if username not in perm_set:
raise NotPermittedError(username)
else:
return True
这里有两个新的异常;它们都使用用户名,所以我们将它们定义为AuthException的子类:
class NotLoggedInError(AuthException):
pass
class NotPermittedError(AuthException):
pass
最后,我们可以添加一个默认的authorizor来与我们的默认认证器配对:
authorizor = Authorizor(authenticator)
这完成了一个基本的身份验证/授权系统。我们可以在 Python 提示符下测试系统,检查用户joe是否被允许在油漆部门执行任务:
>>> import auth
>>> auth.authenticator.add_user("joe", "joepassword")
>>> auth.authorizor.add_permission("paint")
>>> auth.authorizor.check_permission("paint", "joe")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 109, in check_permission
raise NotLoggedInError(username)
auth.NotLoggedInError: joe
>>> auth.authenticator.is_logged_in("joe")
False
>>> auth.authenticator.login("joe", "joepassword")
True
>>> auth.authorizor.check_permission("paint", "joe")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 116, in check_permission
raise NotPermittedError(username)
auth.NotPermittedError: joe
>>> auth.authorizor.check_permission("mix", "joe")
Traceback (most recent call last):
File "auth.py", line 111, in check_permission
perm_set = self.permissions[perm_name]
KeyError: 'mix'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 113, in check_permission
raise PermissionError("Permission does not exist")
auth.PermissionError: Permission does not exist
>>> auth.authorizor.permit_user("mix", "joe")
Traceback (most recent call last):
File "auth.py", line 99, in permit_user
perm_set = self.permissions[perm_name]
KeyError: 'mix'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "auth.py", line 101, in permit_user
raise PermissionError("Permission does not exist")
auth.PermissionError: Permission does not exist
>>> auth.authorizor.permit_user("paint", "joe")
>>> auth.authorizor.check_permission("paint", "joe")
True
虽然冗长,前面的输出显示了我们所有的代码和大部分异常的运行情况,但要真正理解我们定义的 API,我们应该编写一些实际使用它的异常处理代码。这里有一个基本的菜单界面,允许特定用户更改或测试程序:
import auth
# Set up a test user and permission
auth.authenticator.add_user("joe", "joepassword")
auth.authorizor.add_permission("test program")
auth.authorizor.add_permission("change program")
auth.authorizor.permit_user("test program", "joe")
class Editor:
def __init__(self):
self.username = None
self.menu_map = {
"login": self.login,
"test": self.test,
"change": self.change,
"quit": self.quit,
}
def login(self):
logged_in = False
while not logged_in:
username = input("username: ")
password = input("password: ")
try:
logged_in = auth.authenticator.login(username, password)
except auth.InvalidUsername:
print("Sorry, that username does not exist")
except auth.InvalidPassword:
print("Sorry, incorrect password")
else:
self.username = username
def is_permitted(self, permission):
try:
auth.authorizor.check_permission(permission, self.username)
except auth.NotLoggedInError as e:
print("{} is not logged in".format(e.username))
return False
except auth.NotPermittedError as e:
print("{} cannot {}".format(e.username, permission))
return False
else:
return True
def test(self):
if self.is_permitted("test program"):
print("Testing program now...")
def change(self):
if self.is_permitted("change program"):
print("Changing program now...")
def quit(self):
raise SystemExit()
def menu(self):
try:
answer = ""
while True:
print(
"""
Please enter a command:
\tlogin\tLogin
\ttest\tTest the program
\tchange\tChange the program
\tquit\tQuit
"""
)
answer = input("enter a command: ").lower()
try:
func = self.menu_map[answer]
except KeyError:
print("{} is not a valid option".format(answer))
else:
func()
finally:
print("Thank you for testing the auth module")
Editor().menu()
这个相当长的例子在概念上非常简单。 is_permitted 方法可能是最有趣的;这是一个主要是内部方法,被test和change调用,以确保用户在继续之前被允许访问。当然,这两种方法都是存根,但我们这里不是在写编辑器;我们是通过测试身份验证和授权框架来说明异常和异常处理的使用。
练习
如果你以前从未处理过异常,你需要做的第一件事是查看你写过的任何旧的 Python 代码,并注意是否有应该处理异常的地方。你会如何处理它们?你需要完全处理它们吗?有时,让异常传播到控制台是与用户沟通的最佳方式,特别是如果用户也是脚本的编码者。有时,你可以从错误中恢复并允许程序继续。有时,你只能将错误重新格式化为用户可以理解的内容并显示给他们。
一些常见的查找地方是文件 I/O(你的代码是否可能尝试读取一个不存在的文件?),数学表达式(你要除以的值是否可能为零?),列表索引(列表是否为空?)和字典(键是否存在?)。问问自己是否应该忽略问题,通过先检查值来处理它,还是通过异常来处理它。特别注意可能使用finally和else来确保在所有条件下执行正确代码的地方。
现在写一些新代码。想想一个需要身份验证和授权的程序,并尝试编写一些使用我们在案例研究中构建的auth模块的代码。如果模块不够灵活,可以随意修改模块。尝试处理
以明智的方式处理所有异常。如果你在想出需要身份验证的东西时遇到麻烦,可以尝试在第十六章的记事本示例中添加授权,Python 中的对象,或者在auth模块本身添加授权——如果任何人都可以开始添加权限,这个模块就不是一个非常有用的模块!也许在允许添加或更改权限之前需要管理员用户名和密码。
最后,试着想想你的代码中可以引发异常的地方。可以是你写过或正在处理的代码;或者你可以编写一个新的项目作为练习。你可能最容易设计一个小型框架或 API,供其他人使用;异常是你的代码和别人之间的绝妙沟通工具。记得设计和记录任何自引发的异常作为 API 的一部分,否则他们将不知道是否以及如何处理它们!
总结
在这一章中,我们深入讨论了引发、处理、定义和操纵异常的细节。异常是一种强大的方式,可以在不要求调用函数显式检查返回值的情况下,传达异常情况或错误条件。有许多内置的异常,引发它们非常容易。处理不同异常事件有几种不同的语法。
在下一章中,我们将讨论到目前为止所学的一切如何结合在一起,讨论面向对象编程原则和结构在 Python 应用程序中应该如何最好地应用。
第十九章:何时使用面向对象编程
在之前的章节中,我们已经涵盖了面向对象编程的许多定义特性。我们现在知道面向对象设计的原则和范例,并且我们已经涵盖了 Python 中面向对象编程的语法。
然而,我们并不确切知道如何,尤其是何时在实践中利用这些原则和语法。在本章中,我们将讨论我们所获得的知识的一些有用应用,同时查看一些新的主题:
-
如何识别对象
-
数据和行为,再次
-
使用属性封装数据行为
-
使用行为限制数据
-
不要重复自己的原则
-
识别重复的代码
将对象视为对象
这可能看起来很明显;你通常应该在代码中为问题域中的单独对象给予特殊的类。我们在之前章节的案例研究中已经看到了这样的例子:首先,我们确定问题中的对象,然后对其数据和行为进行建模。
在面向对象分析和编程中,识别对象是一项非常重要的任务。但这并不总是像计算短段落中的名词那样容易,坦率地说,我明确为此目的构建了。记住,对象是既有数据又有行为的东西。如果我们只处理数据,通常最好将其存储在列表、集合、字典或其他 Python 数据结构中。另一方面,如果我们只处理行为,但没有存储的数据,一个简单的函数更合适。
然而,对象既有数据又有行为。熟练的 Python 程序员使用内置数据结构,除非(或直到)明显需要定义一个类。如果这并没有帮助组织我们的代码,那么没有理由添加额外的抽象级别。另一方面,明显的需要并不总是不言自明的。
我们通常可以通过将数据存储在几个变量中来启动我们的 Python 程序。随着程序的扩展,我们将会发现我们正在将相同的一组相关变量传递给一组函数。这是思考将变量和函数组合成一个类的时候了。如果我们正在设计一个在二维空间中模拟多边形的程序,我们可能会从将每个多边形表示为点列表开始。这些点将被建模为两个元组(x,y),描述该点的位置。这是所有的数据,存储在一组嵌套的数据结构中(具体来说,是一个元组列表):
square = [(1,1), (1,2), (2,2), (2,1)]
现在,如果我们想要计算多边形周长的距离,我们需要计算每个点之间的距离。为此,我们需要一个函数来计算两点之间的距离。以下是两个这样的函数:
import math
def distance(p1, p2):
return math.sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)
def perimeter(polygon):
perimeter = 0
points = polygon + [polygon[0]]
for i in range(len(polygon)):
perimeter += distance(points[i], points[i+1])
return perimeter
现在,作为面向对象的程序员,我们清楚地认识到polygon类可以封装点的列表(数据)和perimeter函数(行为)。此外,point类,就像我们在第十六章中定义的那样,Python 中的对象,可能封装x和y坐标以及distance方法。问题是:这样做有价值吗?
对于以前的代码,也许是,也许不是。有了我们最近在面向对象原则方面的经验,我们可以以创纪录的速度编写面向对象的版本。让我们进行比较:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def distance(self, p2):
return math.sqrt((self.x-p2.x)**2 + (self.y-p2.y)**2)
class Polygon:
def __init__(self):
self.vertices = []
def add_point(self, point):
self.vertices.append((point))
def perimeter(self):
perimeter = 0
points = self.vertices + [self.vertices[0]]
for i in range(len(self.vertices)):
perimeter += points[i].distance(points[i+1])
return perimeter
正如我们从突出显示的部分所看到的,这里的代码量是我们之前版本的两倍,尽管我们可以争辩说add_point方法并不是严格必要的。
现在,为了更好地理解这两种 API 之间的差异,让我们比较一下两种使用情况。以下是使用面向对象的代码计算正方形的周长:
>>> square = Polygon()
>>> square.add_point(Point(1,1))
>>> square.add_point(Point(1,2))
>>> square.add_point(Point(2,2))
>>> square.add_point(Point(2,1))
>>> square.perimeter()
4.0
这可能看起来相当简洁和易读,但让我们将其与基于函数的代码进行比较:
>>> square = [(1,1), (1,2), (2,2), (2,1)]
>>> perimeter(square)
4.0
嗯,也许面向对象的 API 并不那么紧凑!也就是说,我认为它比函数示例更容易阅读。我们怎么知道第二个版本中的元组列表应该表示什么?我们怎么记得我们应该传递到perimeter函数的对象是什么?(两个元组的列表?这不直观!)我们需要大量的文档来解释这些函数应该如何使用。
相比之下,面向对象的代码相对自我说明。我们只需要查看方法列表及其参数,就可以知道对象的功能和如何使用它。当我们为函数版本编写所有文档时,它可能会比面向对象的代码还要长。
最后,代码长度并不是代码复杂性的良好指标。一些程序员会陷入复杂的一行代码中,这一行代码可以完成大量工作。这可能是一个有趣的练习,但结果通常是令人难以阅读的,即使对于原始作者来说,第二天也是如此。最小化代码量通常可以使程序更易于阅读,但不要盲目地假设这是正确的。
幸运的是,这种权衡是不必要的。我们可以使面向对象的Polygon API 与函数实现一样易于使用。我们只需要修改我们的Polygon类,使其可以用多个点构造。让我们给它一个接受Point对象列表的初始化器。事实上,让我们也允许它接受元组,如果需要,我们可以自己构造Point对象:
def __init__(self, points=None):
points = points if points else []
self.vertices = []
for point in points:
if isinstance(point, tuple):
point = Point(*point)
self.vertices.append(point)
这个初始化器遍历列表,并确保任何元组都转换为点。如果对象不是元组,我们将其保留,假设它已经是Point对象,或者是一个未知的鸭子类型对象,可以像Point对象一样工作。
如果您正在尝试上述代码,您可以对Polygon进行子类化,并覆盖__init__函数,而不是替换初始化器或复制add_point和perimeter方法。
然而,在面向对象和更注重数据的版本之间没有明显的赢家。它们都做同样的事情。如果我们有新的函数接受多边形参数,比如area(polygon)或point_in_polygon(polygon, x, y),面向对象代码的好处变得越来越明显。同样,如果我们为多边形添加其他属性,比如color或texture,将这些数据封装到一个类中就变得更有意义。
区别是一个设计决策,但一般来说,数据集越重要,就越有可能具有针对该数据的多个特定功能,使用具有属性和方法的类会更有用。
在做出这个决定时,考虑类将如何使用也是很重要的。如果我们只是试图在更大的问题的背景下计算一个多边形的周长,使用函数可能会是编码最快且最容易仅一次使用。另一方面,如果我们的程序需要以各种方式操作大量多边形(计算周长、面积和与其他多边形的交集、移动或缩放它们等),我们几乎肯定已经确定了一个对象;一个需要非常灵活的对象。
此外,要注意对象之间的交互。寻找继承关系;继承无法在没有类的情况下优雅地建模,因此一定要使用它们。寻找我们在第十五章中讨论的其他类型的关系,面向对象设计,关联和组合。组合在技术上可以使用只有数据结构来建模;例如,我们可以有一个包含元组值的字典列表,但有时创建几个对象类会更不复杂,特别是如果与数据相关联的行为。
不要急于使用对象,只是因为你可以使用对象,但是当你需要使用类时,不要忽视创建一个类。
使用属性为类数据添加行为
在整本书中,我们一直专注于行为和数据的分离。这在面向对象编程中非常重要,但是我们将看到,在 Python 中,这种区别是模糊的。Python 非常擅长模糊界限;它并不完全帮助我们打破思维定势。相反,它教会我们停止思考盒子。
在我们深入细节之前,让我们讨论一些糟糕的面向对象理论。许多面向对象的语言教导我们永远不要直接访问属性(Java 是最臭名昭著的)。他们坚持我们应该像这样写属性访问:
class Color:
def __init__(self, rgb_value, name):
self._rgb_value = rgb_value
self._name = name
def set_name(self, name):
self._name = name
def get_name(self):
return self._name
变量以下划线开头,表示它们是私有的(其他语言实际上会强制它们为私有)。然后,get和set方法提供对每个变量的访问。这个类将在实践中使用如下:
>>> c = Color("#ff0000", "bright red")
>>> c.get_name()
'bright red'
>>> c.set_name("red")
>>> c.get_name()
'red'
这不像 Python 青睐的直接访问版本那样易读:
class Color:
def __init__(self, rgb_value, name):
self.rgb_value = rgb_value
self.name = name
c = Color("#ff0000", "bright red")
print(c.name) c.name = "red"
print(c.name)
那么,为什么有人坚持使用基于方法的语法呢?他们的理由是,有一天,我们可能希望在设置或检索值时添加额外的代码。例如,我们可以决定缓存一个值以避免复杂的计算,或者我们可能希望验证给定的值是否是合适的输入。
例如,在代码中,我们可以决定将set_name()方法更改如下:
def set_name(self, name):
if not name:
raise Exception("Invalid Name")
self._name = name
现在,在 Java 和类似的语言中,如果我们最初为直接属性访问编写了原始代码,然后稍后将其更改为像前面的方法,我们会有问题:任何访问属性的代码现在都必须访问一个方法。如果他们没有将访问样式从属性访问更改为函数调用,他们的代码将会出错。
这些语言中的口头禅是我们永远不应该将公共成员变为私有成员。这在 Python 中并没有太多意义,因为 Python 没有真正的私有成员的概念!
Python 给了我们property关键字,可以使方法看起来像属性。因此,我们可以编写代码来直接访问成员,如果我们需要在获取或设置属性值时进行一些计算,我们可以在不改变接口的情况下进行修改。让我们看看它是什么样子:
class Color:
def __init__(self, rgb_value, name):
self.rgb_value = rgb_value
self._name = name
def _set_name(self, name):
if not name:
raise Exception("Invalid Name")
self._name = name
def _get_name(self):
return self._name
name = property(_get_name, _set_name)
与之前的类相比,我们首先将name属性更改为(半)私有的_name属性。然后,我们添加了两个更多的(半)私有方法来获取和设置该变量,在设置时执行验证。
最后,我们在底部有property声明。这就是 Python 的魔力。它在Color类上创建了一个名为name的新属性,以替换直接的name属性。它将此属性设置为property。在幕后,property在访问或更改值时调用我们刚刚创建的两个方法。这个新版本的Color类可以像以前的版本一样使用,但是现在在设置name属性时执行验证:
>>> c = Color("#0000ff", "bright red")
>>> print(c.name)
bright red
>>> c.name = "red"
>>> print(c.name)
red
>>> c.name = ""
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "setting_name_property.py", line 8, in _set_name
raise Exception("Invalid Name")
Exception: Invalid Name
因此,如果我们以前编写了访问name属性的代码,然后更改为使用基于property的对象,以前的代码仍然可以工作,除非它发送了一个空的property值,这正是我们想要在第一次禁止的行为。成功!
请记住,即使有了name属性,以前的代码也不是 100%安全的。人们仍然可以直接访问_name属性,并将其设置为空字符串。但是,如果他们访问了我们明确标记为下划线的变量,暗示它是私有的,他们就必须处理后果,而不是我们。
属性详解
将property函数视为返回一个对象,通过我们指定的方法代理对设置或访问属性值的任何请求。内置的property就像这样的对象的构造函数,并且该对象被设置为给定属性的公共成员。
这个property构造函数实际上可以接受两个额外的参数,一个delete函数和一个属性的文档字符串。在实践中很少提供delete函数,但如果我们有理由这样做,它可能对记录已删除的值或可能否决删除很有用。文档字符串只是描述属性功能的字符串,与我们在第十六章中讨论的文档字符串没有什么不同,Python 中的对象。如果我们不提供此参数,文档字符串将从第一个参数的文档字符串复制:getter方法。这是一个愚蠢的例子,说明每当调用任何方法时:
class Silly:
def _get_silly(self):
print("You are getting silly")
return self._silly
def _set_silly(self, value):
print("You are making silly {}".format(value))
self._silly = value
def _del_silly(self):
print("Whoah, you killed silly!")
del self._silly
silly = property(_get_silly, _set_silly, _del_silly, "This is a silly property")
如果我们实际使用这个类,当我们要求它时,它确实会打印出正确的字符串:
>>> s = Silly()
>>> s.silly = "funny"
You are making silly funny
>>> s.silly
You are getting silly
'funny'
>>> del s.silly
Whoah, you killed silly!
此外,如果我们查看Silly类的帮助文件(通过在解释器提示符处发出help(Silly)),它会显示我们的silly属性的自定义文档字符串:
Help on class Silly in module __main__:
class Silly(builtins.object)
| Data descriptors defined here:
|
| __dict__
| dictionary for instance variables (if defined)
|
| __weakref__
| list of weak references to the object (if defined)
|
| silly
| This is a silly property
再次,一切都按我们计划的那样运行。在实践中,属性通常只使用前两个参数进行定义:getter和setter函数。如果我们想为属性提供文档字符串,我们可以在getter函数上定义它;属性代理将把它复制到自己的文档字符串中。delete函数通常为空,因为对象属性很少被删除。如果程序员尝试删除没有指定delete函数的属性,它将引发异常。因此,如果有正当理由删除我们的属性,我们应该提供该函数。
装饰器-创建属性的另一种方法
如果您以前从未使用过 Python 装饰器,您可能希望跳过本节,在我们讨论第二十二章中的装饰器模式之后再回来,Python 设计模式 I。但是,您不需要理解正在发生的事情,以使用装饰器语法来使属性方法更易读。
property函数可以与装饰器语法一起使用,将get函数转换为property函数,如下所示:
class Foo:
@property
def foo(self):
return "bar"
这将property函数应用为装饰器,并且等同于以前的foo = property(foo)语法。从可读性的角度来看,主要区别在于我们可以在方法的顶部将foo函数标记为属性,而不是在定义之后,这样很容易被忽视。这也意味着我们不必创建带有下划线前缀的私有方法来定义属性。
更进一步,我们可以为新属性指定一个setter函数,如下所示:
class Foo:
@property
def foo(self):
return self._foo
@foo.setter
def foo(self, value):
self._foo = value
这个语法看起来很奇怪,尽管意图是明显的。首先,我们将foo方法装饰为 getter。然后,我们通过应用最初装饰的foo方法的setter属性,装饰了第二个同名方法!property函数返回一个对象;这个对象总是带有自己的setter属性,然后可以将其应用为其他函数的装饰器。使用相同的名称来命名获取和设置方法并不是必需的,但它确实有助于将访问一个属性的多个方法分组在一起。
我们还可以使用@foo.deleter指定一个delete函数。我们不能使用property装饰器来指定文档字符串,因此我们需要依赖于属性从初始 getter 方法复制文档字符串。下面是我们之前的Silly类重写,以使用property作为装饰器:
class Silly:
@property
def silly(self):
"This is a silly property"
print("You are getting silly")
return self._silly
@silly.setter
def silly(self, value):
print("You are making silly {}".format(value))
self._silly = value
@silly.deleter
def silly(self):
print("Whoah, you killed silly!")
del self._silly
这个类的操作完全与我们之前的版本相同,包括帮助文本。您可以使用您认为更可读和优雅的任何语法。
决定何时使用属性
由于内置的属性模糊了行为和数据之间的区分,很难知道何时选择属性、方法或属性。我们之前看到的用例示例是属性的最常见用法之一;我们在类上有一些数据,然后希望添加行为。在决定使用属性时,还有其他因素需要考虑。
在 Python 中,数据、属性和方法在类上都是属性。方法可调用的事实并不能将其与其他类型的属性区分开;事实上,我们将在第二十章中看到,Python 面向对象的快捷方式,可以创建可以像函数一样调用的普通对象。我们还将发现函数和方法本身也是普通对象。
方法只是可调用的属性,属性只是可定制的属性,这可以帮助我们做出这个决定。方法通常应该表示动作;可以对对象执行的操作。当你调用一个方法时,即使只有一个参数,它也应该做一些事情。方法名称通常是动词。
确认属性不是一个动作后,我们需要在标准数据属性和属性之间做出选择。通常情况下,始终使用标准属性,直到需要以某种方式控制对该属性的访问。无论哪种情况,您的属性通常是一个名词。属性和属性之间唯一的区别是,当检索、设置或删除属性时,我们可以自动调用自定义操作。
让我们看一个更现实的例子。自定义行为的常见需求是缓存难以计算或昂贵的查找值(例如,需要网络请求或数据库查询)。目标是将值存储在本地,以避免重复调用昂贵的计算。
我们可以通过属性的自定义 getter 来实现这一点。第一次检索值时,我们执行查找或计算。然后,我们可以将值作为对象的私有属性(或专用缓存软件中)进行本地缓存,下次请求值时,我们返回存储的数据。以下是我们可能缓存网页的方法:
from urllib.request import urlopen
class WebPage:
def __init__(self, url):
self.url = url
self._content = None
@property
def content(self):
if not self._content:
print("Retrieving New Page...")
self._content = urlopen(self.url).read()
return self._content
我们可以测试这段代码,以查看页面只被检索一次:
>>> import time
>>> webpage = WebPage("http://ccphillips.net/")
>>> now = time.time()
>>> content1 = webpage.content
Retrieving New Page...
>>> time.time() - now
22.43316888809204
>>> now = time.time()
>>> content2 = webpage.content
>>> time.time() - now
1.9266459941864014
>>> content2 == content1
True
我在 2010 年首次测试这段代码时使用的是糟糕的卫星连接,第一次加载内容花了 20 秒。第二次,我在 2 秒内得到了结果(实际上只是在解释器中输入这些行所花费的时间)。在我更现代的连接上,情况如下:
>>> webpage = WebPage("https://dusty.phillips.codes")
>>> import time
>>> now = time.time() ; content1 = webpage.content ; print(time.time() - now)
Retrieving New Page...
0.6236202716827393
>>> now = time.time() ; content2 = webpage.content ; print(time.time() - now)
1.7881393432617188e-05M
从我的网络主机检索页面大约需要 620 毫秒。从我的笔记本电脑的 RAM 中,只需要 0.018 毫秒!
自定义 getter 也适用于需要根据其他对象属性动态计算的属性。例如,我们可能想要计算整数列表的平均值:
class AverageList(list):
@property
def average(self):
return sum(self) / len(self)
这个非常简单的类继承自list,所以我们可以免费获得类似列表的行为。我们只需向类添加一个属性,就可以得到列表的平均值。
>>> a = AverageList([1,2,3,4])
>>> a.average
2.5
当然,我们也可以将其制作成一个方法,但那样我们应该将其命名为calculate_average(),因为方法代表动作。但名为average的属性更合适,而且更容易输入和阅读。
自定义 setter 对于验证是有用的,正如我们已经看到的,但它们也可以用于将值代理到另一个位置。例如,我们可以为WebPage类添加一个内容 setter,以便在设置值时自动登录到我们的 Web 服务器并上传新页面。
管理对象
我们一直专注于对象及其属性和方法。现在,我们将看看如何设计更高级的对象;管理其他对象的对象 - 将所有东西联系在一起的对象。
这些对象与大多数先前的示例之间的区别在于,后者通常代表具体的想法。管理对象更像办公室经理;他们不会在现场进行实际的可见工作,但没有他们,部门之间就不会有沟通,也没有人知道他们应该做什么(尽管如果组织管理不善,这也可能是真的!)。类似地,管理类上的属性倾向于引用做可见工作的其他对象;这样的类上的行为在适当的时候委托给这些其他类,并在它们之间传递消息。
例如,我们将编写一个程序,对存储在压缩的 ZIP 文件中的文本文件执行查找和替换操作。我们需要对象来表示 ZIP 文件和每个单独的文本文件(幸运的是,我们不必编写这些类,因为它们在 Python 标准库中可用)。管理对象将负责确保以下三个步骤按顺序发生:
-
解压缩压缩文件
-
执行查找和替换操作
-
压缩新文件
该类使用.zip文件名、搜索和替换字符串进行初始化。我们创建一个临时目录来存储解压后的文件,以便文件夹保持干净。pathlib库在文件和目录操作中提供帮助。接口在以下示例中应该很清楚:
import sys
import shutil
import zipfile
from pathlib import Path
class ZipReplace:
def __init__(self, filename, search_string, replace_string):
self.filename = filename
self.search_string = search_string
self.replace_string = replace_string
self.temp_directory = Path(f"unzipped-{filename}")
然后,我们为三个步骤创建一个整体管理方法。该方法将责任委托给其他对象:
def zip_find_replace(self):
self.unzip_files()
self.find_replace()
self.zip_files()
显然,我们可以在一个方法中完成所有三个步骤,或者在一个脚本中完成,而不必创建对象。将三个步骤分开有几个优点:
-
可读性:每个步骤的代码都在一个易于阅读和理解的自包含单元中。方法名称描述了方法的作用,需要更少的额外文档来理解正在发生的事情。
-
可扩展性:如果子类想要使用压缩的 TAR 文件而不是 ZIP 文件,它可以重写
zip和unzip方法,而无需复制find_replace方法。 -
分区:外部类可以创建此类的实例,并在不必
zip内容的情况下直接在某个文件夹上调用find_replace方法。
委托方法是以下代码中的第一个;其余方法包括在内是为了完整性:
def unzip_files(self):
self.temp_directory.mkdir()
with zipfile.ZipFile(self.filename) as zip:
zip.extractall(self.temp_directory)
def find_replace(self):
for filename in self.temp_directory.iterdir():
with filename.open() as file:
contents = file.read()
contents = contents.replace(self.search_string, self.replace_string)
with filename.open("w") as file:
file.write(contents)
def zip_files(self):
with zipfile.ZipFile(self.filename, "w") as file:
for filename in self.temp_directory.iterdir():
file.write(filename, filename.name)
shutil.rmtree(self.temp_directory)
if __name__ == "__main__":
ZipReplace(*sys.argv[1:4]).zip_find_replace()
为了简洁起见,对于压缩和解压缩文件的代码文档很少。我们目前关注的是面向对象的设计;如果您对zipfile模块的内部细节感兴趣,请参考标准库中的文档,可以在线查看,也可以在交互式解释器中输入import zipfile ; help(zipfile)。请注意,此玩具示例仅搜索 ZIP 文件中的顶层文件;如果解压后的内容中有任何文件夹,它们将不会被扫描,也不会扫描这些文件夹中的任何文件。
如果您使用的是早于 3.6 的 Python 版本,则需要在调用ZipFile对象上的extractall、rmtree和file.write之前将路径对象转换为字符串。
示例中的最后两行允许我们通过传递zip文件名、搜索字符串和替换字符串作为参数来从命令行运行程序,如下所示:
$python zipsearch.py hello.zip hello hi
当然,这个对象不一定要从命令行创建;它可以从另一个模块导入(执行批量 ZIP 文件处理),或者作为 GUI 界面的一部分访问,甚至作为一个更高级别的管理对象的一部分,该对象知道从哪里获取 ZIP 文件(例如,从 FTP 服务器检索它们或将它们备份到外部磁盘)。
随着程序变得越来越复杂,被建模的对象变得越来越不像物理对象。属性是其他抽象对象,方法是改变这些抽象对象状态的行为。但无论多么复杂,每个对象的核心都是一组具体数据和明确定义的行为。
删除重复的代码
通常,诸如ZipReplace之类的管理样式类中的代码非常通用,可以以各种方式应用。可以使用组合或继承来帮助将此代码放在一个地方,从而消除重复代码。在我们查看任何此类示例之前,让我们讨论一点理论。具体来说,为什么重复代码是一件坏事?
有几个原因,但归根结底都是可读性和可维护性。当我们编写类似于早期代码的新代码时,最容易的方法是复制旧代码并更改需要更改的内容(变量名称、逻辑、注释),使其在新位置上运行。或者,如果我们正在编写似乎类似但不完全相同的新代码,与项目中的其他代码相比,通常更容易编写具有类似行为的新代码,而不是弄清楚如何提取重叠功能。
但是,一旦有人阅读和理解代码,并且遇到重复的代码块,他们就面临着两难境地。可能看起来有意义的代码突然必须被理解。一个部分与另一个部分有何不同?它们如何相同?在什么条件下调用一个部分?我们什么时候调用另一个部分?你可能会争辩说你是唯一阅读你的代码的人,但是如果你八个月不碰那段代码,它对你来说将和对一个新手编程人员一样难以理解。当我们试图阅读两个相似的代码部分时,我们必须理解它们为何不同,以及它们如何不同。这浪费了读者的时间;代码应始终被编写为首要可读性。
我曾经不得不尝试理解某人的代码,其中有三个完全相同的 300 行非常糟糕的代码副本。在我最终理解这三个相同版本实际上执行略有不同的税收计算之前,我已经与这段代码一起工作了一个月。一些微妙的差异是有意的,但也有明显的地方,某人在一个函数中更新了一个计算,而没有更新其他两个。代码中难以理解的微妙错误数量不计其数。最终,我用一个大约 20 行的易于阅读的函数替换了所有 900 行。
阅读这样的重复代码可能很烦人,但代码维护更加痛苦。正如前面的故事所示,保持两个相似的代码部分最新可能是一场噩梦。每当我们更新其中一个部分时,我们必须记住更新两个部分,并且我们必须记住多个部分的不同之处,以便在编辑每个部分时修改我们的更改。如果我们忘记更新所有部分,我们最终会遇到非常恼人的错误,通常表现为“但我已经修复了,为什么还在发生*?”
结果是,阅读或维护我们的代码的人们必须花费天文数字的时间来理解和测试它,而不是在第一次编写时以非重复的方式编写它所需的时间。当我们自己进行维护时,这更加令人沮丧;我们会发现自己说,“为什么我第一次就没做对呢?”通过复制和粘贴现有代码节省的时间在第一次进行维护时就丢失了。代码被阅读和修改的次数比编写的次数多得多,而且频率也更高。可理解的代码应始终是优先考虑的。
这就是为什么程序员,尤其是 Python 程序员(他们倾向于比普通开发人员更重视优雅的代码),遵循所谓的不要重复自己(DRY)原则。DRY 代码是可维护的代码。我给初学者的建议是永远不要使用编辑器的复制粘贴功能。对于中级程序员,我建议他们在按下Ctrl + C之前三思。
但是,我们应该怎么做才能避免代码重复呢?最简单的解决方案通常是将代码移到一个函数中,该函数接受参数以解决不同的部分。这不是一个非常面向对象的解决方案,但通常是最佳的解决方案。
例如,如果我们有两段代码,它们将 ZIP 文件解压缩到两个不同的目录中,我们可以很容易地用一个接受目录参数的函数来替换它。这可能会使函数本身稍微难以阅读,但一个好的函数名称和文档字符串很容易弥补这一点,任何调用该函数的代码都会更容易阅读。
这就足够的理论了!故事的寓意是:始终努力重构代码,使其更易读,而不是编写可能看起来更容易的糟糕代码。
在实践中
让我们探讨两种重用现有代码的方法。在编写代码以替换 ZIP 文件中的文本文件中的字符串后,我们后来受托将 ZIP 文件中的所有图像缩放到 640 x 480。看起来我们可以使用与我们在ZipReplace中使用的非常相似的范例。我们的第一反应可能是保存该文件的副本,并将find_replace方法更改为scale_image或类似的内容。
但是,这是次优的。如果有一天我们想要更改unzip和zip方法以打开 TAR 文件呢?或者也许我们想要为临时文件使用一个保证唯一的目录名称。在任何一种情况下,我们都必须在两个不同的地方进行更改!
我们将从展示基于继承的解决方案开始解决这个问题。首先,我们将修改我们原始的ZipReplace类,将其变成一个用于处理通用 ZIP 文件的超类:
import sys
import shutil
import zipfile
from pathlib import Path
class ZipProcessor:
def __init__(self, zipname):
self.zipname = zipname
self.temp_directory = Path(f"unzipped-{zipname[:-4]}")
def process_zip(self):
self.unzip_files()
self.process_files()
self.zip_files()
def unzip_files(self):
self.temp_directory.mkdir()
with zipfile.ZipFile(self.zipname) as zip:
zip.extractall(self.temp_directory)
def zip_files(self):
with zipfile.ZipFile(self.zipname, "w") as file:
for filename in self.temp_directory.iterdir():
file.write(filename, filename.name)
shutil.rmtree(self.temp_directory)
我们将filename属性更改为zipname,以避免与各种方法内部的filename本地变量混淆。这有助于使代码更易读,尽管实际上并没有改变设计。
我们还删除了__init__中的两个参数(search_string和replace_string),这些参数是特定于ZipReplace的。然后,我们将zip_find_replace方法重命名为process_zip,并让它调用一个(尚未定义的)process_files方法,而不是find_replace;这些名称更改有助于展示我们新类的更一般化特性。请注意,我们已经完全删除了find_replace方法;该代码是特定于ZipReplace,在这里没有业务。
这个新的ZipProcessor类实际上并没有定义process_files方法。如果我们直接运行它,它会引发异常。因为它不是用来直接运行的,我们删除了原始脚本底部的主要调用。我们可以将其作为抽象基类,以便传达这个方法需要在子类中定义,但出于简洁起见,我将其省略了。
现在,在我们转向图像处理应用程序之前,让我们修复我们原始的zipsearch类,以利用这个父类,如下所示:
class ZipReplace(ZipProcessor):
def __init__(self, filename, search_string, replace_string):
super().__init__(filename)
self.search_string = search_string
self.replace_string = replace_string
def process_files(self):
"""perform a search and replace on all files in the
temporary directory"""
for filename in self.temp_directory.iterdir():
with filename.open() as file:
contents = file.read()
contents = contents.replace(self.search_string, self.replace_string)
with filename.open("w") as file:
file.write(contents)
这段代码比原始版本要短,因为它继承了父类的 ZIP 处理能力。我们首先导入我们刚刚编写的基类,并使ZipReplace扩展该类。然后,我们使用super()来初始化父类。find_replace方法仍然存在,但我们将其重命名为process_files,以便父类可以从其管理界面调用它。因为这个名称不像旧名称那样描述性强,我们添加了一个文档字符串来描述它正在做什么。
现在,考虑到我们现在所做的工作量相当大,而我们现在的程序在功能上与我们开始的程序并无不同!但是经过这样的工作,我们现在可以更容易地编写其他操作 ZIP 存档文件的类,比如(假设请求的)照片缩放器。此外,如果我们想要改进或修复 ZIP 功能,我们只需更改一个ZipProcessor基类,就可以同时为所有子类进行操作。因此维护工作将更加有效。
看看现在创建一个利用ZipProcessor功能的照片缩放类有多简单:
from PIL import Image
class ScaleZip(ZipProcessor):
def process_files(self):
'''Scale each image in the directory to 640x480'''
for filename in self.temp_directory.iterdir():
im = Image.open(str(filename))
scaled = im.resize((640, 480))
scaled.save(filename)
if __name__ == "__main__":
ScaleZip(*sys.argv[1:4]).process_zip()
看看这个类有多简单!我们之前所做的所有工作都得到了回报。我们所做的就是打开每个文件(假设它是一个图像;如果文件无法打开或不是图像,程序将崩溃),对其进行缩放,然后保存。ZipProcessor类负责压缩和解压,而我们无需额外工作。
案例研究
对于这个案例研究,我们将尝试进一步探讨一个问题,即何时应该选择对象而不是内置类型?我们将建模一个可能在文本编辑器或文字处理器中使用的Document类。它应该具有哪些对象、函数或属性?
我们可能会从Document内容开始使用str,但在 Python 中,字符串是不可变的。一旦定义了一个str,它就永远存在。我们无法在其中插入字符或删除字符,而不创建全新的字符串对象。这将导致大量的str对象占用内存,直到 Python 的垃圾收集器决定清理它们。
因此,我们将使用字符列表而不是字符串,这样我们可以随意修改。此外,我们需要知道列表中的当前光标位置,并且可能还需要存储文档的文件名。
真正的文本编辑器使用一种名为rope的基于二叉树的数据结构来模拟其文档内容。本书的标题不是高级数据结构,所以如果你对这个迷人的主题感兴趣,你可能想在网上搜索rope 数据结构了解更多信息。
我们可能想对文本文档进行许多操作,包括插入、删除和选择字符;剪切、复制和粘贴所选内容;以及保存或关闭文档。看起来有大量的数据和行为,因此将所有这些内容放入自己的Document类是有道理的。
一个相关的问题是:这个类应该由一堆基本的 Python 对象组成,比如str文件名、int光标位置和字符的list?还是应该将其中一些或全部内容定义为自己的特定对象?单独的行和字符呢?它们需要有自己的类吗?
我们将在进行过程中回答这些问题,但让我们先从最简单的Document类开始,看看它能做什么:
class Document:
def __init__(self):
self.characters = []
self.cursor = 0
self.filename = ''
def insert(self, character):
self.characters.insert(self.cursor, character)
self.cursor += 1
def delete(self):
del self.characters[self.cursor]
def save(self):
with open(self.filename, 'w') as f:
f.write(''.join(self.characters))
def forward(self):
self.cursor += 1
def back(self):
self.cursor -= 1
这个基本类允许我们完全控制编辑基本文档。看看它的运行情况:
>>> doc = Document()
>>> doc.filename = "test_document"
>>> doc.insert('h')
>>> doc.insert('e')
>>> doc.insert('l')
>>> doc.insert('l')
>>> doc.insert('o')
>>> "".join(doc.characters)
'hello'
>>> doc.back()
>>> doc.delete()
>>> doc.insert('p')
>>> "".join(doc.characters)
'hellp'
看起来它正在工作。我们可以将键盘的字母和箭头键连接到这些方法,文档将正常跟踪一切。
但是,如果我们想要连接的不仅仅是箭头键。如果我们还想连接Home和End键怎么办?我们可以向Document类添加更多方法,用于在字符串中向前或向后搜索换行符(换行符,转义为\n,表示一行的结束和新行的开始),并跳转到它们,但如果我们为每种可能的移动操作(按单词移动,按句子移动,Page Up,Page Down,行尾,空格开头等)都这样做,那么这个类将会很庞大。也许把这些方法放在一个单独的对象上会更好。因此,让我们将Cursor属性转换为一个对象,该对象知道自己的位置并可以操纵该位置。我们可以将向前和向后的方法移到该类中,并为Home和End键添加另外两个方法,如下所示:
class Cursor:
def __init__(self, document):
self.document = document
self.position = 0
def forward(self):
self.position += 1
def back(self):
self.position -= 1
def home(self):
while self.document.characters[self.position - 1].character != "\n":
self.position -= 1
if self.position == 0:
# Got to beginning of file before newline
break
def end(self):
while (
self.position < len(self.document.characters)
and self.document.characters[self.position] != "\n"
):
self.position += 1
这个类将文档作为初始化参数,以便方法可以访问文档字符列表的内容。然后提供了向后和向前移动的简单方法,以及移动到home和end位置的方法。
这段代码并不是很安全。你很容易越过结束位置,如果你试图在空文件上回到开头,它会崩溃。这些示例被保持简短以便阅读,但这并不意味着它们是防御性的!你可以通过练习来改进这段代码的错误检查;这可能是扩展你的异常处理技能的绝佳机会。
Document类本身几乎没有改变,只是删除了移动到Cursor类的两个方法:
class Document:
def __init__(self):
self.characters = []
self.cursor = Cursor(self)
self.filename = ''
def insert(self, character):
self.characters.insert(self.cursor.position,
character)
self.cursor.forward()
def delete(self):
del self.characters[self.cursor.position]
def save(self):
with open(self.filename, "w") as f:
f.write("".join(self.characters))
我们刚刚更新了访问旧光标整数的任何内容,以使用新对象代替。我们现在可以测试home方法是否真的移动到换行符,如下所示:
>>> d = Document()
>>> d.insert('h')
>>> d.insert('e')
>>> d.insert('l')
>>> d.insert('l')
>>> d.insert('o')
>>> d.insert('\n')
>>> d.insert('w')
>>> d.insert('o')
>>> d.insert('r')
>>> d.insert('l')
>>> d.insert('d')
>>> d.cursor.home()
>>> d.insert("*")
>>> print("".join(d.characters))
hello
*world
现在,由于我们一直在大量使用字符串join函数(将字符连接起来,以便查看实际文档内容),我们可以向Document类添加一个属性,以便得到完整的字符串,如下所示:
@property
def string(self):
return "".join(self.characters)
这使得我们的测试变得更简单:
>>> print(d.string)
hello
world
这个框架很容易扩展,创建和编辑完整的纯文本文档(尽管可能会有点耗时!)现在,让我们将其扩展到适用于富文本的工作;可以具有粗体、下划线或斜体字符的文本。
我们可以以两种方式处理这个问题。第一种是在字符列表中插入虚假字符,这些字符就像指令一样,比如粗体字符,直到找到停止粗体字符。第二种是向每个字符添加信息,指示它应该具有什么格式。虽然前一种方法在真实编辑器中更常见,但我们将实现后一种解决方案。为此,我们显然需要一个字符类。这个类将具有表示字符的属性,以及三个布尔属性,表示它是否粗体、斜体或下划线。
嗯,等等!这个Character类会有任何方法吗?如果没有,也许我们应该使用许多 Python 数据结构之一;元组或命名元组可能就足够了。有没有任何操作我们想要在字符上执行或调用?
嗯,显然,我们可能想对字符进行一些操作,比如删除或复制它们,但这些是需要在Document级别处理的事情,因为它们实际上是在修改字符列表。是否有需要对单个字符进行处理的事情?
实际上,现在我们在思考Character类实际上是什么……它是什么?可以肯定地说Character类是一个字符串吗?也许我们应该在这里使用继承关系?然后我们可以利用str实例带来的众多方法。
我们在谈论什么样的方法?有startswith、strip、find、lower等等。这些方法中的大多数都希望在包含多个字符的字符串上工作。相比之下,如果Character是str的子类,我们可能最好重写__init__,以便在提供多字符字符串时引发异常。由于我们将免费获得的所有这些方法实际上并不适用于我们的Character类,因此似乎我们不应该使用继承。
这让我们回到了最初的问题;Character甚至应该是一个类吗?object类上有一个非常重要的特殊方法,我们可以利用它来表示我们的字符。这个方法叫做__str__(两端都有两个下划线,就像__init__一样),它在字符串操作函数中被使用,比如print和str构造函数,将任何类转换为字符串。默认实现做了一些无聊的事情,比如打印模块和类的名称,以及它在内存中的地址。但如果我们重写它,我们可以让它打印任何我们喜欢的东西。
对于我们的实现,我们可以使用特殊字符作为前缀来表示字符是否为粗体、斜体或下划线。因此,我们将创建一个表示字符的类,如下所示:
class Character:
def __init__(self, character,
bold=False, italic=False, underline=False):
assert len(character) == 1
self.character = character
self.bold = bold
self.italic = italic
self.underline = underline
def __str__(self):
bold = "*" if self.bold else ''
italic = "/" if self.italic else ''
underline = "_" if self.underline else ''
return bold + italic + underline + self.character
这个类允许我们创建字符,并在应用str()函数时用特殊字符作为前缀。没有太多激动人心的地方。我们只需要对Document和Cursor类进行一些小修改,以便与这个类一起工作。在Document类中,我们在insert方法的开头添加以下两行:
def insert(self, character):
if not hasattr(character, 'character'):
character = Character(character)
这是一段相当奇怪的代码。它的基本目的是检查传入的字符是Character还是str。如果是字符串,它就会被包装在Character类中,以便列表中的所有对象都是Character对象。然而,完全有可能有人使用我们的代码想要使用既不是Character也不是字符串的类,使用鸭子类型。如果对象有一个字符属性,我们假设它是类似Character的对象。但如果没有,我们假设它是类似str的对象,并将其包装在Character中。这有助于程序利用鸭子类型和多态性;只要对象具有字符属性,它就可以在Document类中使用。
这种通用检查可能非常有用。例如,如果我们想要制作一个带有语法高亮的程序员编辑器,我们需要字符的额外数据,比如字符属于哪种类型的语法标记。请注意,如果我们要做很多这种比较,最好实现Character作为一个带有适当__subclasshook__的抽象基类,如第十七章中讨论的那样,当对象相似。
此外,我们需要修改Document上的字符串属性,以接受新的Character值。我们只需要在连接之前对每个字符调用str(),如下所示:
@property
def string(self):
return "".join((str(c) for c in self.characters))
这段代码使用了一个生成器表达式,我们将在第二十一章中讨论,迭代器模式。这是一个在序列中对所有对象执行特定操作的快捷方式。
最后,我们还需要检查home和end函数中的Character.character,而不仅仅是我们之前存储的字符串字符,看它是否匹配换行符,如下所示:
def home(self):
while self.document.characters[
self.position-1].character != '\n':
self.position -= 1
if self.position == 0:
# Got to beginning of file before newline
break
def end(self):
while self.position < len(
self.document.characters) and \
self.document.characters[
self.position
].character != '\n':
self.position += 1
这完成了字符的格式化。我们可以测试它,看它是否像下面这样工作:
>>> d = Document()
>>> d.insert('h')
>>> d.insert('e')
>>> d.insert(Character('l', bold=True))
>>> d.insert(Character('l', bold=True))
>>> d.insert('o')
>>> d.insert('\n')
>>> d.insert(Character('w', italic=True))
>>> d.insert(Character('o', italic=True))
>>> d.insert(Character('r', underline=True))
>>> d.insert('l')
>>> d.insert('d')
>>> print(d.string)
he*l*lo
/w/o_rld
>>> d.cursor.home()
>>> d.delete()
>>> d.insert('W')
>>> print(d.string)
he*l*lo
W/o_rld
>>> d.characters[0].underline = True
>>> print(d.string)
_he*l*lo
W/o_rld
正如预期的那样,每当我们打印字符串时,每个粗体字符前面都有一个*字符,每个斜体字符前面都有一个/字符,每个下划线字符前面都有一个_字符。我们所有的函数似乎都能工作,并且我们可以在事后修改列表中的字符。我们有一个可以插入到适当的图形用户界面中并与键盘进行输入和屏幕进行输出的工作的富文本文档对象。当然,我们希望在 UI 中显示真正的粗体、斜体和下划线字体,而不是使用我们的__str__方法,但它对我们要求的基本测试是足够的。
练习
我们已经看过了在面向对象的 Python 程序中对象、数据和方法可以相互交互的各种方式。和往常一样,您的第一个想法应该是如何将这些原则应用到您自己的工作中。您是否有一些混乱的脚本横七竖八地散落在那里,可以使用面向对象的管理器进行重写?浏览一下您的旧代码,寻找一些不是动作的方法。如果名称不是动词,尝试将其重写为属性。
思考您用任何语言编写的代码。它是否违反了 DRY 原则?是否有任何重复的代码?您是否复制和粘贴了代码?您是否编写了两个类似代码的版本,因为您不想理解原始代码?现在回顾一下您最近的一些代码,看看是否可以使用继承或组合重构重复的代码。尝试选择一个您仍然有兴趣维护的项目;不要选择那些您永远不想再碰的代码。这将有助于在您进行改进时保持您的兴趣!
现在,回顾一下本章中我们看过的一些例子。从使用属性缓存检索数据的缓存网页示例开始。这个示例的一个明显问题是缓存从未被刷新。在属性的 getter 中添加一个超时,并且只有在页面在超时过期之前被请求时才返回缓存的页面。您可以使用time模块(time.time() - an_old_time返回自an_old_time以来经过的秒数)来确定缓存是否已过期。
还要看看基于继承的ZipProcessor。在这里使用组合而不是继承可能是合理的。您可以在ZipProcessor构造函数中传递这些类的实例,并调用它们来执行处理部分。实现这一点。
您觉得哪个版本更容易使用?哪个更优雅?哪个更容易阅读?这些都是主观问题;答案因人而异。然而,了解答案是重要的。如果您发现自己更喜欢继承而不是组合,那么您需要注意不要在日常编码中过度使用继承。如果您更喜欢组合,请确保不要错过创建优雅的基于继承的解决方案的机会。
最后,在案例研究中为各种类添加一些错误处理程序。它们应确保输入单个字符,不要尝试将光标移动到文件的末尾或开头,不要删除不存在的字符,也不要保存没有文件名的文件。尽量考虑尽可能多的边缘情况,并对其进行考虑(考虑边缘情况大约占专业程序员工作的 90%!)。考虑不同的处理方式;当用户尝试移动到文件末尾时,您应该引发异常,还是只停留在最后一个字符?
在您的日常编码中,注意复制和粘贴命令。每次在编辑器中使用它们时,考虑是否改进程序的组织结构,以便您只有要复制的代码的一个版本。
总结
在这一章中,我们专注于识别对象,特别是那些不太明显的对象;管理和控制对象。对象应该既有数据又有行为,但属性可以用来模糊两者之间的区别。 DRY 原则是代码质量的重要指标,继承和组合可以用来减少代码重复。
在下一章中,我们将讨论如何整合 Python 的面向对象和非面向对象的方面。在这个过程中,我们会发现它比起初看起来更加面向对象!
第二十章:Python 面向对象的快捷方式
Python 的许多方面看起来更像结构化或函数式编程,而不是面向对象编程。尽管面向对象编程在过去的二十年中是最可见的范式,但旧模型最近又出现了。与 Python 的数据结构一样,这些工具大多是在基础面向对象实现之上的一层语法糖;我们可以将它们看作是建立在(已经抽象化的)面向对象范式之上的进一步抽象层。在本章中,我们将涵盖一些不严格面向对象的 Python 特性:
-
内置函数可以一次性处理常见任务
-
文件 I/O 和上下文管理器
-
方法重载的替代方法
-
函数作为对象
Python 内置函数
Python 中有许多函数可以在某些类型的对象上执行任务或计算结果,而不是作为基础类的方法。它们通常抽象出适用于多种类型的类的常见计算。这是鸭子类型的最佳体现;这些函数接受具有某些属性或方法的对象,并能够使用这些方法执行通用操作。我们已经使用了许多内置函数,但让我们快速浏览一下重要的函数,并学习一些巧妙的技巧。
len()函数
最简单的例子是len()函数,它计算某种容器对象中的项目数量,比如字典或列表。你之前已经见过它,演示如下:
>>> len([1,2,3,4])
4
你可能会想为什么这些对象没有一个长度属性,而是必须在它们上调用一个函数。从技术上讲,它们是有的。大多数len()适用的对象都有一个名为__len__()的方法,返回相同的值。所以len(myobj)似乎调用了myobj.__len__()。
为什么我们应该使用len()函数而不是__len__方法?显然,__len__是一个特殊的双下划线方法,这表明我们不应该直接调用它。这一定有一个解释。Python 开发人员不会轻易做出这样的设计决定。
主要原因是效率。当我们在对象上调用__len__时,对象必须在其命名空间中查找该方法,并且如果该对象上定义了特殊的__getattribute__方法(每次访问对象的属性或方法时都会调用),它也必须被调用。此外,该方法的__getattribute__可能被编写为执行一些不好的操作,比如拒绝让我们访问特殊方法,比如__len__!len()函数不会遇到这些问题。它实际上调用了基础类的__len__函数,所以len(myobj)映射到了MyObj.__len__(myobj)。
另一个原因是可维护性。将来,Python 开发人员可能希望更改len(),以便它可以计算没有__len__的对象的长度,例如,通过计算迭代器返回的项目数量。他们只需要更改一个函数,而不是在整个对象中无数的__len__方法。
len()作为外部函数还有一个极其重要且经常被忽视的原因:向后兼容性。这经常在文章中被引用为出于历史原因,这是作者用来表示某事之所以是某种方式是因为很久以前犯了一个错误,我们现在被困在这种方式中的一种委婉的说法。严格来说,len()并不是一个错误,而是一个设计决定,但这个决定是在一个不太面向对象的时代做出的。它经受住了时间的考验,并且有一些好处,所以要习惯它。
反转
reversed()函数接受任何序列作为输入,并返回该序列的一个副本,顺序相反。通常在for循环中使用,当我们想要从后向前循环遍历项目时。
与len类似,reversed在参数的类上调用__reversed__()函数。如果该方法不存在,reversed将使用对__len__和__getitem__的调用来构建反转的序列,这些方法用于定义序列。如果我们想要以某种方式自定义或优化过程,我们只需要重写__reversed__,就像下面的代码所示:
normal_list = [1, 2, 3, 4, 5]
class CustomSequence:
def __len__(self):
return 5
def __getitem__(self, index):
return f"x{index}"
class FunkyBackwards:
def __reversed__(self):
return "BACKWARDS!"
for seq in normal_list, CustomSequence(), FunkyBackwards():
print(f"\n{seq.__class__.__name__}: ", end="")
for item in reversed(seq):
print(item, end=", ")
最后的for循环打印了正常列表的反转版本,以及两个自定义序列的实例。输出显示reversed适用于它们三个,但当我们自己定义__reversed__时,结果却大不相同:
list: 5, 4, 3, 2, 1,
CustomSequence: x4, x3, x2, x1, x0,
FunkyBackwards: B, A, C, K, W, A, R, D, S, !,
当我们反转CustomSequence时,__getitem__方法会为每个项目调用,它只是在索引之前插入一个x。对于FunkyBackwards,__reversed__方法返回一个字符串,其中每个字符在for循环中单独输出。
前面的两个类不是很好的序列,因为它们没有定义一个适当版本的__iter__,所以对它们进行正向for循环永远不会结束。
枚举
有时,当我们在for循环中循环遍历容器时,我们希望访问当前正在处理的项目的索引(列表中的当前位置)。for循环不提供索引,但enumerate函数给了我们更好的东西:它创建了一个元组序列,其中每个元组中的第一个对象是索引,第二个对象是原始项目。
如果我们需要直接使用索引号,这是很有用的。考虑一些简单的代码,输出文件中的每一行及其行号:
import sys
filename = sys.argv[1]
with open(filename) as file:
for index, line in enumerate(file):
print(f"{index+1}: {line}", end="")
使用自己的文件名作为输入文件运行此代码,可以显示它是如何工作的:
1: import sys
2:
3: filename = sys.argv[1]
4:
5: with open(filename) as file:
6: for index, line in enumerate(file):
7: print(f"{index+1}: {line}", end="")
enumerate函数返回一个元组序列,我们的for循环将每个元组拆分为两个值,并且print语句将它们格式化在一起。对于每行号,它会将索引加一,因为enumerate,像所有序列一样,是从零开始的。
我们只是涉及了一些更重要的 Python 内置函数。正如你所看到的,其中许多调用面向对象的概念,而其他一些则遵循纯函数式或过程式范例。标准库中还有许多其他函数;一些更有趣的包括以下内容:
-
all和any,它们接受一个可迭代对象,并在所有或任何项目评估为 true 时返回True(例如非空字符串或列表,非零数,不是None的对象,或文字True)。 -
eval、exec和compile,它们将字符串作为代码在解释器中执行。对于这些要小心;它们不安全,所以不要执行未知用户提供给你的代码(一般来说,假设所有未知用户都是恶意的、愚蠢的,或两者兼有)。 -
hasattr、getattr、setattr和delattr,它们允许通过它们的字符串名称操作对象的属性。 -
zip接受两个或多个序列,并返回一个新的元组序列,其中每个元组包含来自每个序列的单个值。 -
还有更多!查看
dir(__builtins__)中列出的每个函数的解释器帮助文档。
文件 I/O
到目前为止,我们的示例都是在文件系统上操作文本文件,而没有考虑底层发生了什么。然而,操作系统实际上将文件表示为一系列字节,而不是文本。从文件中读取文本数据是一个相当复杂的过程。Python,特别是 Python 3,在幕后为我们处理了大部分工作。我们真是幸运!
文件的概念早在有人创造术语“面向对象编程”之前就已经存在。然而,Python 已经将操作系统提供的接口包装成一个甜蜜的抽象,使我们能够使用文件(或类似文件,即鸭子类型)对象。
open()内置函数用于打开文件并返回文件对象。要从文件中读取文本,我们只需要将文件名传递给函数。文件将被打开以进行读取,并且字节将使用平台默认编码转换为文本。
当然,我们并不总是想要读取文件;通常我们想要向其中写入数据!要打开文件进行写入,我们需要将mode参数作为第二个位置参数传递,并将其值设置为"w":
contents = "Some file contents"
file = open("filename", "w")
file.write(contents)
file.close()
我们还可以将值"a"作为模式参数提供,以便将其附加到文件的末尾,而不是完全覆盖现有文件内容。
这些具有内置包装器以将字节转换为文本的文件非常好,但是如果我们要打开的文件是图像、可执行文件或其他二进制文件,那将非常不方便,不是吗?
要打开二进制文件,我们修改模式字符串以附加'b'。因此,'wb'将打开一个用于写入字节的文件,而'rb'允许我们读取它们。它们将像文本文件一样运行,但不会自动将文本编码为字节。当我们读取这样的文件时,它将返回bytes对象而不是str,当我们向其写入时,如果尝试传递文本对象,它将失败。
这些用于控制文件打开方式的模式字符串相当神秘,既不符合 Python 的风格,也不是面向对象的。但是,它们与几乎所有其他编程语言一致。文件 I/O 是操作系统必须处理的基本工作之一,所有编程语言都必须使用相同的系统调用与操作系统进行通信。只要 Python 返回一个带有有用方法的文件对象,而不是大多数主要操作系统用于标识文件句柄的整数,就应该感到高兴!
一旦文件被打开以进行读取,我们就可以调用read、readline或readlines方法来获取文件的内容。read方法返回文件的整个内容作为str或bytes对象,具体取决于模式中是否有'b'。不要在大文件上不带参数地使用此方法。您不希望知道如果尝试将这么多数据加载到内存中会发生什么!
还可以从文件中读取固定数量的字节;我们将整数参数传递给read方法,描述我们要读取多少字节。对read的下一次调用将加载下一个字节序列,依此类推。我们可以在while循环中执行此操作,以以可管理的块读取整个文件。
readline方法返回文件中的一行(每行以换行符、回车符或两者结尾,具体取决于创建文件的操作系统)。我们可以重复调用它以获取其他行。复数readlines方法返回文件中所有行的列表。与read方法一样,它不适用于非常大的文件。这两种方法甚至在文件以bytes模式打开时也可以使用,但只有在解析具有合理位置的换行符的文本数据时才有意义。例如,图像或音频文件不会包含换行符(除非换行符字节恰好表示某个像素或声音),因此应用readline是没有意义的。
为了可读性,并且避免一次将大文件读入内存,通常最好直接在文件对象上使用for循环。对于文本文件,它将一次读取每一行,我们可以在循环体内处理它。对于二进制文件,最好使用read()方法读取固定大小的数据块,传递一个参数以读取的最大字节数。
写入文件同样简单;文件对象上的write方法将一个字符串(或字节,用于二进制数据)对象写入文件。可以重复调用它来写入多个字符串,一个接着一个。writelines方法接受一个字符串序列,并将迭代的每个值写入文件。writelines方法在序列中的每个项目后面不添加新行。它基本上是一个命名不当的便利函数,用于写入字符串序列的内容,而无需使用for循环显式迭代它。
最后,我是指最后,我们来到close方法。当我们完成读取或写入文件时,应调用此方法,以确保任何缓冲写入都写入磁盘,文件已经得到适当清理,并且与文件关联的所有资源都已释放回操作系统。从技术上讲,当脚本退出时,这将自动发生,但最好是明确地清理自己,特别是在长时间运行的进程中。
放在上下文中
当我们完成文件时需要关闭文件,这可能会使我们的代码变得非常丑陋。因为在文件 I/O 期间可能会发生异常,我们应该将对文件的所有调用都包装在try...finally子句中。文件应该在finally子句中关闭,无论 I/O 是否成功。这并不是很 Pythonic。当然,有一种更优雅的方法来做。
如果我们在类似文件的对象上运行dir,我们会发现它有两个名为__enter__和__exit__的特殊方法。这些方法将文件对象转换为所谓的上下文管理器。基本上,如果我们使用一个称为with语句的特殊语法,这些方法将在嵌套代码执行之前和之后被调用。对于文件对象,__exit__方法确保文件被关闭,即使发生异常。我们不再需要显式地管理文件的关闭。下面是with语句在实践中的样子:
with open('filename') as file:
for line in file:
print(line, end='')
open调用返回一个文件对象,该对象具有__enter__和__exit__方法。返回的对象通过as子句分配给名为file的变量。我们知道当代码返回到外部缩进级别时,文件将被关闭,即使发生异常也会发生这种情况。
with语句在标准库中的几个地方使用,需要执行启动或清理代码。例如,urlopen调用返回一个对象,可以在with语句中使用,以在完成后清理套接字。线程模块中的锁可以在语句执行后自动释放锁。
最有趣的是,因为with语句可以应用于具有适当特殊方法的任何对象,我们可以在自己的框架中使用它。例如,记住字符串是不可变的,但有时需要从多个部分构建字符串。出于效率考虑,通常通过将组件字符串存储在列表中并在最后将它们连接起来来完成。让我们创建一个简单的上下文管理器,允许我们构建一个字符序列,并在退出时自动将其转换为字符串:
class StringJoiner(list):
def __enter__(self):
return self
def __exit__(self, type, value, tb):
self.result = "".join(self)
这段代码将list类中所需的两个特殊方法添加到它继承的list类中。__enter__方法执行任何必需的设置代码(在本例中没有),然后返回将分配给with语句中as后面的变量的对象。通常,就像我们在这里做的那样,这只是上下文管理器对象本身。__exit__方法接受三个参数。在正常情况下,它们都被赋予None的值。然而,如果with块内发生异常,它们将被设置为与异常类型、值和回溯相关的值。这允许__exit__方法执行可能需要的任何清理代码,即使发生异常。在我们的例子中,我们采取了不负责任的路径,并通过连接字符串中的字符创建了一个结果字符串,而不管是否抛出异常。
虽然这是我们可以编写的最简单的上下文管理器之一,它的用处是可疑的,但它确实可以与with语句一起使用。看看它的运行情况:
import random, string
with StringJoiner() as joiner:
for i in range(15):
joiner.append(random.choice(string.ascii_letters))
print(joiner.result)
这段代码构造了一个包含 15 个随机字符的字符串。它使用从list继承的append方法将这些字符附加到StringJoiner上。当with语句超出范围(回到外部缩进级别)时,将调用__exit__方法,并且joiner对象上的result属性变得可用。然后我们打印这个值来看一个随机字符串。
方法重载的替代方法
许多面向对象的编程语言的一个显著特点是一个称为方法重载的工具。方法重载简单地指的是具有相同名称的多个方法,这些方法接受不同的参数集。在静态类型的语言中,如果我们想要一个方法既可以接受整数也可以接受字符串,这是很有用的。在非面向对象的语言中,我们可能需要两个函数,称为add_s和add_i,来适应这种情况。在静态类型的面向对象语言中,我们需要两个方法,都称为add,一个接受字符串,一个接受整数。
在 Python 中,我们已经看到我们只需要一个方法,它接受任何类型的对象。它可能需要对对象类型进行一些测试(例如,如果它是一个字符串,将其转换为整数),但只需要一个方法。
然而,方法重载在我们希望一个方法接受不同数量或一组不同的参数时也很有用。例如,电子邮件消息方法可能有两个版本,其中一个接受from电子邮件地址的参数。另一个方法可能会查找默认的from电子邮件地址。Python 不允许使用相同名称的多个方法,但它提供了一个不同的、同样灵活的接口。
我们已经在之前的例子中看到了向方法和函数传递参数的一些可能方式,但现在我们将涵盖所有细节。最简单的函数不接受任何参数。我们可能不需要一个例子,但为了完整起见,这里有一个:
def no_args():
pass
这就是它的名字:
no_args()
接受参数的函数将在逗号分隔的列表中提供这些参数的名称。只需要提供每个参数的名称。
在调用函数时,这些位置参数必须按顺序指定,不能遗漏或跳过任何一个。这是我们在之前的例子中指定参数的最常见方式:
def mandatory_args(x, y, z):
pass
要调用它,输入以下内容:
mandatory_args("a string", a_variable, 5)
任何类型的对象都可以作为参数传递:对象、容器、原始类型,甚至函数和类。前面的调用显示了一个硬编码的字符串、一个未知的变量和一个整数传递到函数中。
默认参数
如果我们想要使一个参数变为可选的,而不是创建一个带有不同参数集的第二个方法,我们可以在单个方法中指定一个默认值,使用等号。如果调用代码没有提供这个参数,它将被分配一个默认值。但是,调用代码仍然可以选择通过传递不同的值来覆盖默认值。通常,None、空字符串或空列表是合适的默认值。
以下是带有默认参数的函数定义:
def default_arguments(x, y, z, a="Some String", b=False):
pass
前三个参数仍然是必需的,并且必须由调用代码传递。最后两个参数有默认参数。
我们可以以多种方式调用这个函数。我们可以按顺序提供所有参数,就好像所有参数都是位置参数一样,如下所示:
default_arguments("a string", variable, 8, "", True)
或者,我们可以按顺序只提供必需的参数,将关键字参数分配为它们的默认值:
default_arguments("a longer string", some_variable, 14)
我们还可以在调用函数时使用等号语法,以不同的顺序提供值,或者跳过我们不感兴趣的默认值。例如,我们可以跳过第一个关键字参数并提供第二个参数:
default_arguments("a string", variable, 14, b=True)
令人惊讶的是,我们甚至可以使用等号语法来改变位置参数的顺序,只要所有参数都被提供:
>>> default_arguments(y=1,z=2,x=3,a="hi")
3 1 2 hi False
偶尔你可能会发现创建一个仅限关键字参数很有用,也就是说,必须作为关键字参数提供的参数。你可以通过在关键字参数前面加上*来实现这一点:
def kw_only(x, y='defaultkw', *, a, b='only'):
print(x, y, a, b)
这个函数有一个位置参数x,和三个关键字参数y、a和b。x和y都是必需的,但是a只能作为关键字参数传递。y和b都是可选的,默认值是,但是如果提供了b,它只能作为关键字参数。
如果你不传递a,这个函数会失败:
>>> kw_only('x')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: kw_only() missing 1 required keyword-only argument: 'a'
如果你将a作为位置参数传递,也会失败:
>>> kw_only('x', 'y', 'a')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: kw_only() takes from 1 to 2 positional arguments but 3 were given
但是你可以将a和b作为关键字参数传递:
>>> kw_only('x', a='a', b='b')
x defaultkw a b
有这么多的选项,可能很难选择一个,但是如果你把位置参数看作是一个有序列表,关键字参数看作是一种字典,你会发现正确的布局往往会自然而然地形成。如果你需要要求调用者指定一个参数,那就把它设为必需的;如果有一个合理的默认值,那就把它设为关键字参数。根据需要提供哪些值,以及哪些可以保持默认值,选择如何调用方法通常会自行解决。关键字参数相对较少见,但是当使用情况出现时,它们可以使 API 更加优雅。
需要注意的一点是,关键字参数的默认值是在函数首次解释时进行评估的,而不是在调用时进行的。这意味着我们不能有动态生成的默认值。例如,以下代码的行为不会完全符合预期:
number = 5
def funky_function(number=number):
print(number)
number=6
funky_function(8)
funky_function()
print(number)
如果我们运行这段代码,首先输出数字8,但是后来对没有参数的调用输出数字5。我们已经将变量设置为数字6,这可以从输出的最后一行看出,但是当调用函数时,打印出的是数字5;默认值是在函数定义时计算的,而不是在调用时。
这在空容器(如列表、集合和字典)中有些棘手。例如,通常会要求调用代码提供一个我们的函数将要操作的列表,但是列表是可选的。我们希望将一个空列表作为默认参数。我们不能这样做;它只会在代码首次构建时创建一个列表,如下所示:
//DON'T DO THIS
>>> def hello(b=[]):
... b.append('a')
... print(b)
...
>>> hello()
['a']
>>> hello()
['a', 'a']
哎呀,这不是我们预期的结果!通常的解决方法是将默认值设为None,然后在方法内部使用iargument = argument if argument else []这种习惯用法。请注意!
可变参数列表
仅仅使用默认值并不能让我们获得方法重载的所有灵活优势。使 Python 真正灵活的一件事是能够编写接受任意数量的位置或关键字参数而无需显式命名它们的方法。我们还可以将任意列表和字典传递给这样的函数。
例如,一个接受链接或链接列表并下载网页的函数可以使用这样的可变参数,或varargs。我们可以接受任意数量的参数,其中每个参数都是不同的链接,而不是接受一个预期为链接列表的单个值。我们可以通过在函数定义中指定*运算符来实现这一点:
def get_pages(*links):
for link in links:
#download the link with urllib
print(link)
*links参数表示,“我将接受任意数量的参数,并将它们全部放入一个名为links的列表中”。如果我们只提供一个参数,它将是一个只有一个元素的列表;如果我们不提供参数,它将是一个空列表。因此,所有这些函数调用都是有效的:
get_pages()
get_pages('http://www.archlinux.org')
get_pages('http://www.archlinux.org',
'http://ccphillips.net/')
我们还可以接受任意关键字参数。这些参数以字典的形式传递给函数。它们在函数声明中用两个星号(如**kwargs)指定。这个工具通常用于配置设置。下面的类允许我们指定一组具有默认值的选项:
class Options:
default_options = {
'port': 21,
'host': 'localhost',
'username': None,
'password': None,
'debug': False,
}
def __init__(self, **kwargs):
self.options = dict(Options.default_options)
self.options.update(kwargs)
def __getitem__(self, key):
return self.options[key]
这个类中所有有趣的东西都发生在__init__方法中。我们在类级别有一个默认选项和值的字典。__init__方法做的第一件事就是复制这个字典。我们这样做是为了避免直接修改字典,以防我们实例化两组不同的选项。(记住,类级别的变量在类的实例之间是共享的。)然后,__init__方法使用新字典上的update方法将任何非默认值更改为提供的关键字参数。__getitem__方法简单地允许我们使用索引语法使用新类。下面是一个演示该类运行情况的会话:
>>> options = Options(username="dusty", password="drowssap",
debug=True)
>>> options['debug']
True
>>> options['port']
21
>>> options['username']
'dusty'
我们能够使用字典索引语法访问我们的options实例,字典中包括默认值和我们使用关键字参数设置的值。
关键字参数语法可能是危险的,因为它可能违反“明确胜于隐式”的规则。在前面的例子中,可以向Options初始化程序传递任意关键字参数,以表示默认字典中不存在的选项。这可能不是一件坏事,取决于类的目的,但它使得使用该类的人很难发现有哪些有效选项可用。它还使得很容易输入令人困惑的拼写错误(例如Debug而不是debug),从而添加了两个选项,而本应只有一个选项存在。
当我们需要接受要传递给第二个函数的任意参数时,关键字参数也非常有用,但我们不知道这些参数是什么。我们在第十七章中看到了这一点,当对象相似,当我们为多重继承构建支持时。当然,我们可以在一个函数调用中结合使用可变参数和可变关键字参数语法,并且我们也可以使用普通的位置参数和默认参数。下面的例子有些牵强,但演示了这四种类型的作用:
import shutil
import os.path
def augmented_move(
target_folder, *filenames, verbose=False, **specific
):
"""Move all filenames into the target_folder, allowing
specific treatment of certain files."""
def print_verbose(message, filename):
"""print the message only if verbose is enabled"""
if verbose:
print(message.format(filename))
for filename in filenames:
target_path = os.path.join(target_folder, filename)
if filename in specific:
if specific[filename] == "ignore":
print_verbose("Ignoring {0}", filename)
elif specific[filename] == "copy":
print_verbose("Copying {0}", filename)
shutil.copyfile(filename, target_path)
else:
print_verbose("Moving {0}", filename)
shutil.move(filename, target_path)
此示例处理一个任意文件列表。第一个参数是目标文件夹,默认行为是将所有剩余的非关键字参数文件移动到该文件夹中。然后是一个仅限关键字参数verbose,它告诉我们是否要打印每个处理的文件的信息。最后,我们可以提供一个包含要对特定文件名执行的操作的字典;默认行为是移动文件,但如果在关键字参数中指定了有效的字符串操作,它可以被忽略或复制。请注意函数参数的排序;首先指定位置参数,然后是*filenames列表,然后是任何特定的仅限关键字参数,最后是一个**specific字典来保存剩余的关键字参数。
我们创建一个内部辅助函数print_verbose,它只在设置了verbose键时才打印消息。通过将此功能封装在一个单一位置中,该函数使代码易于阅读。
在常见情况下,假设所涉及的文件存在,可以调用此函数如下:
>>> augmented_move("move_here", "one", "two")
这个命令将文件one和two移动到move_here目录中,假设它们存在(函数中没有错误检查或异常处理,因此如果文件或目标目录不存在,它将失败)。移动将在没有任何输出的情况下发生,因为verbose默认为False。
如果我们想要看到输出,我们可以使用以下命令调用它:
>>> augmented_move("move_here", "three", verbose=True)
Moving three
这将移动名为three的一个文件,并告诉我们它在做什么。请注意,在此示例中不可能将verbose指定为位置参数;我们必须传递关键字参数。否则,Python 会认为它是*filenames列表中的另一个文件名。
如果我们想要复制或忽略列表中的一些文件,而不是移动它们,我们可以传递额外的关键字参数,如下所示:
>>> augmented_move("move_here", "four", "five", "six",
four="copy", five="ignore")
这将移动第六个文件并复制第四个文件,但不会显示任何输出,因为我们没有指定verbose。当然,我们也可以这样做,关键字参数可以以任何顺序提供,如下所示:
>>> augmented_move("move_here", "seven", "eight", "nine",
seven="copy", verbose=True, eight="ignore")
Copying seven
Ignoring eight
Moving nine
解压参数
还有一个关于可变参数和关键字参数的巧妙技巧。我们在之前的一些示例中使用过它,但现在解释一下也不算晚。给定一个值列表或字典,我们可以将这些值传递到函数中,就好像它们是普通的位置或关键字参数一样。看看这段代码:
def show_args(arg1, arg2, arg3="THREE"):
print(arg1, arg2, arg3)
some_args = range(3)
more_args = {
"arg1": "ONE",
"arg2": "TWO"}
print("Unpacking a sequence:", end=" ")
show_args(*some_args)
print("Unpacking a dict:", end=" ")
show_args(**more_args)
当我们运行它时,它看起来像这样:
Unpacking a sequence: 0 1 2
Unpacking a dict: ONE TWO THREE
该函数接受三个参数,其中一个具有默认值。但是当我们有一个包含三个参数的列表时,我们可以在函数调用内部使用*运算符将其解压为三个参数。如果我们有一个参数字典,我们可以使用**语法将其解压缩为一组关键字参数。
这在将从用户输入或外部来源(例如互联网页面或文本文件)收集的信息映射到函数或方法调用时最常用。
还记得我们之前的例子吗?它使用文本文件中的标题和行来创建包含联系信息的字典列表。我们可以使用关键字解压缩将这些字典传递给专门构建的Contact对象上的__init__方法,该对象接受相同的参数集。看看你是否可以调整示例使其正常工作。
这种解压缩语法也可以在函数调用之外的某些领域中使用。Options类之前有一个__init__方法,看起来像这样:
def __init__(self, **kwargs):
self.options = dict(Options.default_options)
self.options.update(kwargs)
更简洁的方法是解压缩这两个字典,如下所示:
def __init__(self, **kwargs):
self.options = {**Options.default_options, **kwargs}
因为字典按从左到右的顺序解压缩,结果字典将包含所有默认选项,并且任何 kwarg 选项都将替换一些键。以下是一个示例:
>>> x = {'a': 1, 'b': 2}
>>> y = {'b': 11, 'c': 3}
>>> z = {**x, **y}
>>> z
{'a': 1, 'b': 11, 'c': 3}
函数也是对象
过分强调面向对象原则的编程语言往往不赞成不是方法的函数。在这样的语言中,你应该创建一个对象来包装涉及的单个方法。有许多情况下,我们希望传递一个简单的对象,只需调用它执行一个动作。这在事件驱动编程中最常见,比如图形工具包或异步服务器;我们将在第二十二章 Python 设计模式 I 和第二十三章 Python 设计模式 II 中看到一些使用它的设计模式。
在 Python 中,我们不需要将这样的方法包装在对象中,因为函数本身就是对象!我们可以在函数上设置属性(尽管这不是常见的活动),并且我们可以传递它们以便在以后的某个日期调用它们。它们甚至有一些可以直接访问的特殊属性。这里是另一个刻意的例子:
def my_function():
print("The Function Was Called")
my_function.description = "A silly function"
def second_function():
print("The second was called")
second_function.description = "A sillier function."
def another_function(function):
print("The description:", end=" ")
print(function.description)
print("The name:", end=" ")
print(function.__name__)
print("The class:", end=" ")
print(function.__class__)
print("Now I'll call the function passed in")
function()
another_function(my_function)
another_function(second_function)
如果我们运行这段代码,我们可以看到我们能够将两个不同的函数传递给我们的第三个函数,并为每个函数获得不同的输出:
The description: A silly function
The name: my_function
The class: <class 'function'>
Now I'll call the function passed in
The Function Was Called
The description: A sillier function.
The name: second_function
The class: <class 'function'>
Now I'll call the function passed in
The second was called
我们在函数上设置了一个属性,名为 description(诚然不是很好的描述)。我们还能看到函数的 __name__ 属性,并访问它的类,证明函数确实是一个带有属性的对象。然后,我们使用可调用语法(括号)调用了函数。
函数是顶级对象的事实最常用于传递它们以便在以后的某个日期执行,例如,当某个条件已满足时。让我们构建一个事件驱动的定时器,就是这样做的:
import datetime
import time
class TimedEvent:
def __init__(self, endtime, callback):
self.endtime = endtime
self.callback = callback
def ready(self):
return self.endtime <= datetime.datetime.now()
class Timer:
def __init__(self):
self.events = []
def call_after(self, delay, callback):
end_time = datetime.datetime.now() + datetime.timedelta(
seconds=delay
)
self.events.append(TimedEvent(end_time, callback))
def run(self):
while True:
ready_events = (e for e in self.events if e.ready())
for event in ready_events:
event.callback(self)
self.events.remove(event)
time.sleep(0.5)
在生产中,这段代码肯定应该使用文档字符串进行额外的文档化!call_after 方法至少应该提到 delay 参数是以秒为单位的,并且 callback 函数应该接受一个参数:调用者定时器。
我们这里有两个类。TimedEvent 类实际上并不是其他类可以访问的;它只是存储 endtime 和 callback。我们甚至可以在这里使用 tuple 或 namedtuple,但是为了方便给对象一个行为,告诉我们事件是否准备好运行,我们使用了一个类。
Timer 类简单地存储了一个即将到来的事件列表。它有一个 call_after 方法来添加一个新事件。这个方法接受一个 delay 参数,表示在执行回调之前等待的秒数,以及 callback 函数本身:在正确的时间执行的函数。这个 callback 函数应该接受一个参数。
run 方法非常简单;它使用生成器表达式来过滤出任何时间到达的事件,并按顺序执行它们。定时器 循环然后无限继续,因此必须使用键盘中断(Ctrl + C,或 Ctrl + Break)来中断。我们在每次迭代后睡眠半秒,以免使系统停滞。
这里需要注意的重要事情是涉及回调函数的行。函数像任何其他对象一样被传递,定时器从不知道或关心函数的原始名称是什么,或者它是在哪里定义的。当该函数被调用时,定时器只是将括号语法应用于存储的变量。
这是一组测试定时器的回调:
def format_time(message, *args):
now = datetime.datetime.now()
print(f"{now:%I:%M:%S}: {message}")
def one(timer):
format_time("Called One")
def two(timer):
format_time("Called Two")
def three(timer):
format_time("Called Three")
class Repeater:
def __init__(self):
self.count = 0
def repeater(self, timer):
format_time(f"repeat {self.count}")
self.count += 1
timer.call_after(5, self.repeater)
timer = Timer()
timer.call_after(1, one)
timer.call_after(2, one)
timer.call_after(2, two)
timer.call_after(4, two)
timer.call_after(3, three)
timer.call_after(6, three)
repeater = Repeater()
timer.call_after(5, repeater.repeater)
format_time("Starting")
timer.run()
这个例子让我们看到多个回调是如何与定时器交互的。第一个函数是 format_time 函数。它使用格式字符串语法将当前时间添加到消息中;我们将在下一章中了解它们。接下来,我们创建了三个简单的回调方法,它们只是输出当前时间和一个简短的消息,告诉我们哪个回调已经被触发。
Repeater类演示了方法也可以用作回调,因为它们实际上只是绑定到对象的函数。它还展示了回调函数中的timer参数为什么有用:我们可以在当前运行的回调内部向计时器添加新的定时事件。然后,我们创建一个计时器,并向其添加几个在不同时间后调用的事件。最后,我们启动计时器;输出显示事件按预期顺序运行:
02:53:35: Starting
02:53:36: Called One
02:53:37: Called One
02:53:37: Called Two
02:53:38: Called Three
02:53:39: Called Two
02:53:40: repeat 0
02:53:41: Called Three
02:53:45: repeat 1
02:53:50: repeat 2
02:53:55: repeat 3
02:54:00: repeat 4
Python 3.4 引入了类似于这种通用事件循环架构。
使用函数作为属性
函数作为对象的一个有趣效果是它们可以被设置为其他对象的可调用属性。可以向已实例化的对象添加或更改函数,如下所示:
class A:
def print(self):
print("my class is A")
def fake_print():
print("my class is not A")
a = A()
a.print()
a.print = fake_print
a.print()
这段代码创建了一个非常简单的类,其中包含一个不告诉我们任何新信息的print方法。然后,我们创建了一个告诉我们一些我们不相信的新函数。
当我们在A类的实例上调用print时,它的行为符合预期。如果我们将print方法指向一个新函数,它会告诉我们一些不同的东西:
my class is A
my class is not A
还可以替换类的方法而不是对象的方法,尽管在这种情况下,我们必须将self参数添加到参数列表中。这将更改该对象的所有实例的方法,即使已经实例化了。显然,这样替换方法可能既危险又令人困惑。阅读代码的人会看到已调用一个方法,并查找原始类上的该方法。但原始类上的方法并不是被调用的方法。弄清楚到底发生了什么可能会变成一个棘手而令人沮丧的调试过程。
尽管如此,它确实有其用途。通常,在运行时替换或添加方法(称为monkey patching)在自动化测试中使用。如果测试客户端-服务器应用程序,我们可能不希望在测试客户端时实际连接到服务器;这可能导致意外转账或向真实人发送尴尬的测试电子邮件。相反,我们可以设置我们的测试代码,以替换发送请求到服务器的对象上的一些关键方法,以便它只记录已调用这些方法。
Monkey-patching 也可以用于修复我们正在交互的第三方代码中的错误或添加功能,并且不会以我们需要的方式运行。但是,应该谨慎使用;它几乎总是一个混乱的黑客。不过,有时它是适应现有库以满足我们需求的唯一方法。
可调用对象
正如函数是可以在其上设置属性的对象一样,也可以创建一个可以像函数一样被调用的对象。
通过简单地给它一个接受所需参数的__call__方法,任何对象都可以被调用。让我们通过以下方式使我们的计时器示例中的Repeater类更易于使用:
class Repeater:
def __init__(self):
self.count = 0
def __call__(self, timer):
format_time(f"repeat {self.count}")
self.count += 1
timer.call_after(5, self)
timer = Timer()
timer.call_after(5, Repeater())
format_time("{now}: Starting")
timer.run()
这个例子与之前的类并没有太大不同;我们只是将repeater函数的名称更改为__call__,并将对象本身作为可调用对象传递。请注意,当我们进行call_after调用时,我们传递了参数Repeater()。这两个括号创建了一个类的新实例;它们并没有显式调用该类。这发生在稍后,在计时器内部。如果我们想要在新实例化的对象上执行__call__方法,我们将使用一个相当奇怪的语法:Repeater()()。第一组括号构造对象;第二组执行__call__方法。如果我们发现自己这样做,可能没有使用正确的抽象。只有在对象需要被视为函数时才实现__call__函数。
案例研究
为了将本章介绍的一些原则联系起来,让我们构建一个邮件列表管理器。该管理器将跟踪分类为命名组的电子邮件地址。当发送消息时,我们可以选择一个组,并将消息发送到分配给该组的所有电子邮件地址。
在我们开始这个项目之前,我们应该有一个安全的方法来测试它,而不是向一群真实的人发送电子邮件。幸运的是,Python 在这方面有所帮助;就像测试 HTTP 服务器一样,它有一个内置的简单邮件传输协议(SMTP)服务器,我们可以指示它捕获我们发送的任何消息,而不实际发送它们。我们可以使用以下命令运行服务器:
$python -m smtpd -n -c DebuggingServer localhost:1025
在命令提示符下运行此命令将在本地机器上的端口 1025 上启动运行 SMTP 服务器。但我们已经指示它使用DebuggingServer类(这个类是内置 SMTP 模块的一部分),它不是将邮件发送给预期的收件人,而是在接收到邮件时简单地在终端屏幕上打印它们。
现在,在编写我们的邮件列表之前,让我们编写一些实际发送邮件的代码。当然,Python 也支持这一点在标准库中,但它的接口有点奇怪,所以我们将编写一个新的函数来清晰地包装它,如下面的代码片段所示:
import smtplib
from email.mime.text import MIMEText
def send_email(
subject,
message,
from_addr,
*to_addrs,
host="localhost",
port=1025,
**headers
):
email = MIMEText(message)
email["Subject"] = subject
email["From"] = from_addr
for header, value in headers.items():
email[header] = value
sender = smtplib.SMTP(host, port)
for addr in to_addrs:
del email["To"]
email["To"] = addr
sender.sendmail(from_addr, addr, email.as_string())
sender.quit()
我们不会过分深入讨论此方法内部的代码;标准库中的文档可以为您提供使用smtplib和email模块所需的所有信息。
在函数调用中使用了变量参数和关键字参数语法。变量参数列表允许我们在默认情况下提供单个to地址的字符串,并允许在需要时提供多个地址。任何额外的关键字参数都映射到电子邮件标头。这是变量参数和关键字参数的一个令人兴奋的用法,但实际上并不是对调用函数的人来说一个很好的接口。事实上,它使程序员想要做的许多事情都变得不可能。
传递给函数的标头表示可以附加到方法的辅助标头。这些标头可能包括Reply-To、Return-Path或X-pretty-much-anything。但是为了在 Python 中成为有效的标识符,名称不能包括-字符。一般来说,该字符表示减法。因此,不可能使用Reply-To``=``my@email.com调用函数。通常情况下,我们太急于使用关键字参数,因为它们是我们刚学会的一个闪亮的新工具。
我们将不得不将参数更改为普通字典;这将起作用,因为任何字符串都可以用作字典中的键。默认情况下,我们希望这个字典是空的,但我们不能使默认参数为空字典。因此,我们将默认参数设置为None,然后在方法的开头设置字典,如下所示:
def send_email(subject, message, from_addr, *to_addrs,
host="localhost", port=1025, headers=None):
headers = headers if headers else {}
如果我们在一个终端中运行我们的调试 SMTP 服务器,我们可以在 Python 解释器中测试这段代码:
>>> send_email("A model subject", "The message contents",
"from@example.com", "to1@example.com", "to2@example.com")
然后,如果我们检查调试 SMTP 服务器的输出,我们会得到以下结果:
---------- MESSAGE FOLLOWS ----------
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: A model subject
From: from@example.com
To: to1@example.com
X-Peer: 127.0.0.1
The message contents
------------ END MESSAGE ------------
---------- MESSAGE FOLLOWS ----------
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: A model subject
From: from@example.com
To: to2@example.com
X-Peer: 127.0.0.1
The message contents
------------ END MESSAGE ------------
很好,它已经发送了我们的电子邮件到两个预期地址,并包括主题和消息内容。现在我们可以发送消息了,让我们来完善电子邮件组管理系统。我们需要一个对象,以某种方式将电子邮件地址与它们所在的组匹配起来。由于这是多对多的关系(任何一个电子邮件地址可以在多个组中;任何一个组可以与多个电子邮件地址相关联),我们学习过的数据结构似乎都不太理想。我们可以尝试一个将组名与相关电子邮件地址列表匹配的字典,但这样会重复电子邮件地址。我们也可以尝试一个将电子邮件地址与组匹配的字典,这样会重复组。两者都不太理想。出于好玩,让我们尝试后一种版本,尽管直觉告诉我,将组与电子邮件地址的解决方案可能更加直接。
由于字典中的值始终是唯一电子邮件地址的集合,我们可以将它们存储在一个 set 容器中。我们可以使用 defaultdict 来确保每个键始终有一个 set 容器可用,如下所示:
from collections import defaultdict
class MailingList:
"""Manage groups of e-mail addresses for sending e-mails."""
def __init__(self):
self.email_map = defaultdict(set)
def add_to_group(self, email, group):
self.email_map[email].add(group)
现在,让我们添加一个方法,允许我们收集一个或多个组中的所有电子邮件地址。这可以通过将组列表转换为集合来完成:
def emails_in_groups(self, *groups): groups = set(groups) emails = set() for e, g in self.email_map.items(): if g & groups: emails.add(e) return emails
首先,看一下我们正在迭代的内容:self.email_map.items()。当然,这个方法返回字典中每个项目的键值对元组。值是表示组的字符串集合。我们将这些拆分成两个变量,命名为 e 和 g,分别代表电子邮件和组。只有当传入的组与电子邮件地址的组相交时,我们才将电子邮件地址添加到返回值的集合中。g``&``groups 语法是 g.intersection(groups) 的快捷方式;set 类通过实现特殊的 __and__ 方法来调用 intersection。
使用集合推导式可以使这段代码更加简洁,我们将在第二十一章 迭代器模式 中讨论。
现在,有了这些基本组件,我们可以轻松地向我们的 MailingList 类添加一个发送消息到特定组的方法:
def send_mailing(
self, subject, message, from_addr, *groups, headers=None
):
emails = self.emails_in_groups(*groups)
send_email(
subject, message, from_addr, *emails, headers=headers
)
这个函数依赖于可变参数列表。作为输入,它接受可变参数作为组的列表。它获取指定组的电子邮件列表,并将它们作为可变参数传递到 send_email 中,以及传递到这个方法中的其他参数。
可以通过确保 SMTP 调试服务器在一个命令提示符中运行,并在第二个提示符中使用以下命令加载代码来测试程序:
$python -i mailing_list.py
使用以下命令创建一个 MailingList 对象:
>>> m = MailingList()
然后,创建一些虚假的电子邮件地址和组,如下所示:
>>> m.add_to_group("friend1@example.com", "friends")
>>> m.add_to_group("friend2@example.com", "friends")
>>> m.add_to_group("family1@example.com", "family")
>>> m.add_to_group("pro1@example.com", "professional")
最后,使用以下命令发送电子邮件到特定组:
>>> m.send_mailing("A Party",
"Friends and family only: a party", "me@example.com", "friends",
"family", headers={"Reply-To": "me2@example.com"})
指定组中的每个地址的电子邮件应该显示在 SMTP 服务器的控制台上。
邮件列表目前运行良好,但有点无用;一旦我们退出程序,我们的信息数据库就会丢失。让我们修改它,添加一些方法来从文件中加载和保存电子邮件组的列表。
一般来说,当将结构化数据存储在磁盘上时,最好仔细考虑它的存储方式。存在众多数据库系统的原因之一是,如果其他人已经考虑过数据的存储方式,那么你就不必再去考虑。我们将在下一章中研究一些数据序列化机制,但在这个例子中,让我们保持简单,选择可能有效的第一个解决方案。
我心目中的数据格式是存储每个电子邮件地址,后跟一个空格,再跟着一个逗号分隔的组列表。这个格式看起来是合理的,我们将采用它,因为数据格式化不是本章的主题。然而,为了说明为什么你需要认真考虑如何在磁盘上格式化数据,让我们强调一下这种格式的一些问题。
首先,空格字符在技术上是电子邮件地址中合法的。大多数电子邮件提供商禁止它(有充分的理由),但定义电子邮件地址的规范说,如果在引号中,电子邮件可以包含空格。如果我们要在我们的数据格式中使用一个空格作为标记,我们应该在技术上能够区分该空格和电子邮件中的空格。为了简单起见,我们将假装这不是真的,但是现实生活中的数据编码充满了这样的愚蠢问题。
其次,考虑逗号分隔的组列表。如果有人决定在组名中放一个逗号会发生什么?如果我们决定在组名中将逗号设为非法字符,我们应该添加验证来强制在我们的add_to_group方法中执行这样的命名。为了教学上的清晰,我们也将忽略这个问题。最后,我们需要考虑许多安全性问题:有人是否可以通过在他们的电子邮件地址中放一个假逗号来将自己放入错误的组?如果解析器遇到无效文件会怎么做?
从这次讨论中得出的要点是,尽量使用经过现场测试的数据存储方法,而不是设计我们自己的数据序列化协议。你可能会忽视很多奇怪的边缘情况,最好使用已经遇到并解决了这些边缘情况的代码。
但是忘了这些。让我们只写一些基本的代码,使用大量的一厢情愿来假装这种简单的数据格式是安全的,如下所示:
email1@mydomain.com group1,group2
email2@mydomain.com group2,group3
执行此操作的代码如下:
def save(self):
with open(self.data_file, "w") as file:
for email, groups in self.email_map.items():
file.write("{} {}\n".format(email, ",".join(groups)))
def load(self):
self.email_map = defaultdict(set)
with suppress(IOError):
with open(self.data_file) as file:
for line in file:
email, groups = line.strip().split(" ")
groups = set(groups.split(","))
self.email_map[email] = groups
在save方法中,我们在上下文管理器中打开文件并将文件写为格式化字符串。记住换行符;Python 不会为我们添加它。load方法首先重置字典(以防它包含来自先前调用load的数据)。它添加了对标准库suppress上下文管理器的调用,可用作from contextlib import suppress。这个上下文管理器捕获任何 I/O 错误并忽略它们。这不是最好的错误处理,但比 try...finally...pass 更美观。
然后,load 方法使用for...in语法,循环遍历文件中的每一行。同样,换行符包含在行变量中,所以我们必须调用.strip()来去掉它。我们将在下一章中学习更多关于这种字符串操作的知识。
在使用这些方法之前,我们需要确保对象有一个self.data_file属性,可以通过修改__init__来实现:
def __init__(self, data_file):
self.data_file = data_file
self.email_map = defaultdict(set)
我们可以在解释器中测试这两种方法:
>>> m = MailingList('addresses.db')
>>> m.add_to_group('friend1@example.com', 'friends')
>>> m.add_to_group('family1@example.com', 'friends')
>>> m.add_to_group('family1@example.com', 'family')
>>> m.save()
生成的addresses.db文件包含如下行,如预期的那样:
friend1@example.com friends
family1@example.com friends,family
我们也可以成功地将这些数据加载回MailingList对象中:
>>> m = MailingList('addresses.db')
>>> m.email_map
defaultdict(<class 'set'>, {})
>>> m.load()
>>> m.email_map
defaultdict(<class 'set'>, {'friend2@example.com': {'friends\n'},
'family1@example.com': {'family\n'}, 'friend1@example.com': {'friends\n'}})
正如你所看到的,我忘记了添加load命令,也可能很容易忘记save命令。为了让任何想要在自己的代码中使用我们的MailingList API 的人更容易一些,让我们提供支持上下文管理器的方法:
def __enter__(self):
self.load()
return self
def __exit__(self, type, value, tb):
self.save()
这些简单的方法只是将它们的工作委托给加载和保存,但是现在我们可以在交互式解释器中编写这样的代码,并知道以前存储的所有地址都已经被加载,当我们完成时整个列表将被保存到文件中:
>>> with MailingList('addresses.db') as ml:
... ml.add_to_group('friend2@example.com', 'friends')
... ml.send_mailing("What's up", "hey friends, how's it going", 'me@example.com',
'friends')
练习
如果你之前没有遇到with语句和上下文管理器,我鼓励你像往常一样,浏览你的旧代码,找到所有打开文件的地方,并确保它们使用with语句安全关闭。还要寻找编写自己的上下文管理器的地方。丑陋或重复的try...finally子句是一个很好的起点,但你可能会发现在任何需要在上下文中执行之前和/或之后任务的地方都很有用。
你可能之前已经使用过许多基本的内置函数。我们涵盖了其中几个,但没有详细讨论。尝试使用enumerate、zip、reversed、any和all,直到你记住在合适的时候使用它们为止。enumerate函数尤其重要,因为不使用它会导致一些非常丑陋的while循环。
还要探索一些将函数作为可调用对象传递的应用,以及使用__call__方法使自己的对象可调用。您可以通过将属性附加到函数或在对象上创建__call__方法来实现相同的效果。在哪种情况下会使用一种语法,什么时候更适合使用另一种语法呢?
如果有大量邮件需要发送,我们的邮件列表对象可能会压倒邮件服务器。尝试重构它,以便你可以为不同的目的使用不同的send_email函数。其中一个函数可能是我们在这里使用的版本。另一个版本可能会将邮件放入队列,由不同的线程或进程发送。第三个版本可能只是将数据输出到终端,从而避免了需要虚拟的 SMTP 服务器。你能构建一个带有回调的邮件列表,以便send_mailing函数使用传入的任何内容吗?如果没有提供回调,它将默认使用当前版本。
参数、关键字参数、可变参数和可变关键字参数之间的关系可能有点令人困惑。当我们涵盖多重继承时,我们看到它们如何痛苦地相互作用。设计一些其他示例,看看它们如何很好地协同工作,以及了解它们何时不起作用。
总结
在本章中,我们涵盖了一系列主题。每个主题都代表了 Python 中流行的重要非面向对象的特性。仅仅因为我们可以使用面向对象的原则,并不总是意味着我们应该这样做!
然而,我们也看到 Python 通常通过提供语法快捷方式来实现这些功能,以传统的面向对象语法。了解这些工具背后的面向对象原则使我们能够更有效地在自己的类中使用它们。
我们讨论了一系列内置函数和文件 I/O 操作。在调用带参数、关键字参数和可变参数列表的函数时,我们有许多不同的语法可用。上下文管理器对于在两个方法调用之间夹入一段代码的常见模式非常有用。甚至函数本身也是对象,反之亦然,任何普通对象都可以被调用。
在下一章中,我们将学习更多关于字符串和文件操作的知识,甚至花一些时间来了解标准库中最不面向对象的主题之一:正则表达式。
第二十一章:迭代器模式
我们已经讨论了 Python 的许多内置功能和习语,乍一看似乎违反了面向对象的原则,但实际上在幕后提供了对真实对象的访问。在本章中,我们将讨论for循环,它似乎如此结构化,实际上是一组面向对象原则的轻量级包装。我们还将看到一系列扩展到这种语法,自动创建更多类型的对象。我们将涵盖以下主题:
-
设计模式是什么
-
迭代器协议-最强大的设计模式之一
-
列表、集合和字典推导
-
生成器和协程
简要介绍设计模式
当工程师和建筑师决定建造一座桥、一座塔或一座建筑时,他们遵循某些原则以确保结构完整性。桥梁有各种可能的设计(例如悬索和悬臂),但如果工程师不使用标准设计之一,并且没有一个杰出的新设计,那么他/她设计的桥梁可能会坍塌。
设计模式是试图将同样的正确设计结构的正式定义引入到软件工程中。有许多不同的设计模式来解决不同的一般问题。设计模式通常解决开发人员在某些特定情况下面临的特定常见问题。然后,设计模式是对该问题的理想解决方案的建议,从面向对象设计的角度来看。
了解设计模式并选择在软件中使用它并不保证我们正在创建一个正确的解决方案。1907 年,魁北克大桥(至今仍是世界上最长的悬臂桥)在建设完成之前坍塌,因为设计它的工程师严重低估了用于建造它的钢材重量。同样,在软件开发中,我们可能会错误地选择或应用设计模式,并创建在正常操作情况下或在超出原始设计限制时崩溃的软件。
任何一个设计模式都提出了一组以特定方式相互作用的对象,以解决一般问题。程序员的工作是识别何时面临这样一个特定版本的问题,然后选择和调整通用设计以满足其精确需求。
在本章中,我们将介绍迭代器设计模式。这种模式如此强大和普遍,以至于 Python 开发人员提供了多种语法来访问该模式的基础面向对象原则。我们将在接下来的两章中介绍其他设计模式。其中一些具有语言支持,而另一些则没有,但没有一个像迭代器模式那样成为 Python 程序员日常生活中的固有部分。
迭代器
在典型的设计模式术语中,迭代器是一个具有next()方法和done()方法的对象;后者如果序列中没有剩余项目,则返回True。在没有内置迭代器支持的编程语言中,迭代器将像这样循环:
while not iterator.done():
item = iterator.next()
# do something with the item
在 Python 中,迭代是一种特殊的特性,因此该方法得到了一个特殊的名称__next__。可以使用内置的next(iterator)来访问此方法。Python 的迭代器协议不是使用done方法,而是引发StopIteration来通知循环已完成。最后,我们有更易读的foriteminiterator语法来实际访问迭代器中的项目,而不是使用while循环。让我们更详细地看看这些。
迭代器协议
Iterator抽象基类在collections.abc模块中定义了 Python 中的迭代器协议。正如前面提到的,它必须有一个__next__方法,for循环(以及其他支持迭代的功能)可以调用它来从序列中获取一个新元素。此外,每个迭代器还必须满足Iterable接口。任何提供__iter__方法的类都是可迭代的。该方法必须返回一个Iterator实例,该实例将覆盖该类中的所有元素。
这可能听起来有点混乱,所以看看以下示例,但请注意,这是解决这个问题的一种非常冗长的方式。它清楚地解释了迭代和所讨论的两个协议,但在本章的后面,我们将看到几种更易读的方法来实现这种效果:
class CapitalIterable:
def __init__(self, string):
self.string = string
def __iter__(self):
return CapitalIterator(self.string)
class CapitalIterator:
def __init__(self, string):
self.words = [w.capitalize() for w in string.split()]
self.index = 0
def __next__(self):
if self.index == len(self.words):
raise StopIteration()
word = self.words[self.index]
self.index += 1
return word
def __iter__(self):
return self
这个例子定义了一个CapitalIterable类,其工作是循环遍历字符串中的每个单词,并输出它们的首字母大写。这个可迭代对象的大部分工作都交给了CapitalIterator实现。与这个迭代器互动的规范方式如下:
>>> iterable = CapitalIterable('the quick brown fox jumps over the lazy dog')
>>> iterator = iter(iterable)
>>> while True:
... try:
... print(next(iterator))
... except StopIteration:
... break
...
The
Quick
Brown
Fox
Jumps
Over
The
Lazy
Dog
这个例子首先构造了一个可迭代对象,并从中检索了一个迭代器。这种区别可能需要解释;可迭代对象是一个可以循环遍历的对象。通常,这些元素可以被多次循环遍历,甚至可能在同一时间或重叠的代码中。另一方面,迭代器代表可迭代对象中的特定位置;一些项目已被消耗,一些尚未被消耗。两个不同的迭代器可能在单词列表中的不同位置,但任何一个迭代器只能标记一个位置。
每次在迭代器上调用next()时,它都会按顺序从可迭代对象中返回另一个标记。最终,迭代器将被耗尽(不再有任何元素返回),在这种情况下会引发Stopiteration,然后我们跳出循环。
当然,我们已经知道了一个更简单的语法,用于从可迭代对象构造迭代器:
>>> for i in iterable:
... print(i)
...
The
Quick
Brown
Fox
Jumps
Over
The
Lazy
Dog
正如你所看到的,for语句,尽管看起来并不像面向对象,实际上是一种显而易见的面向对象设计原则的快捷方式。在讨论理解时,请记住这一点,因为它们似乎是面向对象工具的完全相反。然而,它们使用与for循环完全相同的迭代协议,只是另一种快捷方式。
理解
理解是一种简单但强大的语法,允许我们在一行代码中转换或过滤可迭代对象。结果对象可以是一个完全正常的列表、集合或字典,也可以是一个生成器表达式,可以在保持一次只有一个元素在内存中的情况下高效地消耗。
列表理解
列表理解是 Python 中最强大的工具之一,所以人们倾向于认为它们是高级的。事实并非如此。事实上,我已经在以前的例子中使用了理解,假设你会理解它们。虽然高级程序员确实经常使用理解,但并不是因为它们很高级。而是因为它们很简单,并处理了软件开发中最常见的一些操作。
让我们来看看其中一个常见操作;即,将一个项目列表转换为相关项目列表。具体来说,假设我们刚刚从文件中读取了一个字符串列表,现在我们想将其转换为整数列表。我们知道列表中的每个项目都是整数,并且我们想对这些数字进行一些操作(比如计算平均值)。以下是一种简单的方法:
input_strings = ["1", "5", "28", "131", "3"]
output_integers = []
for num in input_strings:
output_integers.append(int(num))
这个方法很好用,而且只有三行代码。如果你不习惯理解,你可能不会觉得它看起来很丑陋!现在,看看使用列表理解的相同代码:
input_strings = ["1", "5", "28", "131", "3"]
output_integers = [int(num) for num in input_strings]
我们只剩下一行,而且,对于性能来说很重要的是,我们已经放弃了列表中每个项目的append方法调用。总的来说,即使你不习惯推导式语法,也很容易理解发生了什么。
方括号表示,我们正在创建一个列表。在这个列表中是一个for循环,它遍历输入序列中的每个项目。唯一可能令人困惑的是在列表的左大括号和for循环开始之间发生了什么。这里发生的事情应用于输入列表中的每个项目。所讨论的项目由循环中的num变量引用。因此,它对每个元素调用int函数,并将结果整数存储在新列表中。
这就是基本列表推导式的全部内容。推导式是高度优化的 C 代码;当循环遍历大量项目时,列表推导式比for循环要快得多。如果仅仅从可读性的角度来看,不能说服你尽可能多地使用它们,那么速度应该是一个令人信服的理由。
将一个项目列表转换为相关列表并不是列表推导式唯一能做的事情。我们还可以选择通过在推导式中添加if语句来排除某些值。看一下:
output_integers = [int(num) for num in input_strings if len(num) < 3]
这个例子和前面的例子唯一不同的地方是if len(num) < 3部分。这个额外的代码排除了任何超过两个字符的字符串。if语句应用于在int函数之前的每个元素,因此它测试字符串的长度。由于我们的输入字符串在本质上都是整数,它排除了任何超过 99 的数字。
列表推导式用于将输入值映射到输出值,并在途中应用过滤器以包括或排除满足特定条件的任何值。
任何可迭代对象都可以成为列表推导式的输入。换句话说,任何我们可以放入for循环中的东西也可以放入推导式中。例如,文本文件是可迭代的;对文件的迭代器每次调用__next__都会返回文件的一行。我们可以使用zip函数将第一行是标题行的制表符分隔文件加载到字典中:
import sys
filename = sys.argv[1]
with open(filename) as file:
header = file.readline().strip().split("\t")
contacts = [
dict(
zip(header, line.strip().split("\t")))
for line in file
]
for contact in contacts:
print("email: {email} -- {last}, {first}".format(**contact))
这一次,我添加了一些空白以使其更易读(列表推导式不一定要放在一行上)。这个例子从压缩的标题和分割行中创建了一个字典列表,对文件中的每一行进行了处理。
嗯,什么?如果那段代码或解释没有意义,不要担心;它很令人困惑。一个列表推导式在这里做了一堆工作,代码很难理解、阅读,最终也很难维护。这个例子表明,列表推导式并不总是最好的解决方案;大多数程序员都会同意,for循环比这个版本更可读。
记住:我们提供的工具不应该被滥用!始终选择适合工作的正确工具,这总是编写可维护代码。
集合和字典推导式
理解并不局限于列表。我们也可以使用类似的语法来创建集合和字典。让我们从集合开始。创建集合的一种方法是将列表推导式放入set()构造函数中,将其转换为集合。但是,为什么要浪费内存在一个被丢弃的中间列表上,当我们可以直接创建一个集合呢?
这是一个使用命名元组来模拟作者/标题/流派三元组的例子,然后检索写作特定流派的所有作者的集合:
from collections import namedtuple
Book = namedtuple("Book", "author title genre")
books = [
Book("Pratchett", "Nightwatch", "fantasy"),
Book("Pratchett", "Thief Of Time", "fantasy"),
Book("Le Guin", "The Dispossessed", "scifi"),
Book("Le Guin", "A Wizard Of Earthsea", "fantasy"),
Book("Turner", "The Thief", "fantasy"),
Book("Phillips", "Preston Diamond", "western"),
Book("Phillips", "Twice Upon A Time", "scifi"),
]
fantasy_authors = {b.author for b in books if b.genre == "fantasy"}
与演示数据设置相比,突出显示的集合推导式确实很短!如果我们使用列表推导式,特里·普拉切特当然会被列出两次。事实上,集合的性质消除了重复项,我们最终得到了以下结果:
>>> fantasy_authors
{'Turner', 'Pratchett', 'Le Guin'}
仍然使用大括号,我们可以引入冒号来创建字典理解。这将使用键:值对将序列转换为字典。例如,如果我们知道标题,可能会很快地在字典中查找作者或流派。我们可以使用字典理解将标题映射到books对象:
fantasy_titles = {b.title: b for b in books if b.genre == "fantasy"}
现在,我们有了一个字典,并且可以使用正常的语法按标题查找书籍。
总之,理解不是高级的 Python,也不是应该避免使用的非面向对象工具。它们只是一种更简洁和优化的语法,用于从现有序列创建列表、集合或字典。
生成器表达式
有时我们想处理一个新的序列,而不将新的列表、集合或字典拉入系统内存。如果我们只是一个接一个地循环遍历项目,并且实际上并不关心是否创建了一个完整的容器(如列表或字典),那么创建该容器就是浪费内存。当一次处理一个项目时,我们只需要当前对象在内存中的可用性。但是当我们创建一个容器时,所有对象都必须在开始处理它们之前存储在该容器中。
例如,考虑一个处理日志文件的程序。一个非常简单的日志文件可能以这种格式包含信息:
Jan 26, 2015 11:25:25 DEBUG This is a debugging message. Jan 26, 2015 11:25:36 INFO This is an information method. Jan 26, 2015 11:25:46 WARNING This is a warning. It could be serious. Jan 26, 2015 11:25:52 WARNING Another warning sent. Jan 26, 2015 11:25:59 INFO Here's some information. Jan 26, 2015 11:26:13 DEBUG Debug messages are only useful if you want to figure something out. Jan 26, 2015 11:26:32 INFO Information is usually harmless, but helpful. Jan 26, 2015 11:26:40 WARNING Warnings should be heeded. Jan 26, 2015 11:26:54 WARNING Watch for warnings.
流行的网络服务器、数据库或电子邮件服务器的日志文件可能包含大量的数据(我曾经不得不清理近 2TB 的日志文件)。如果我们想处理日志中的每一行,我们不能使用列表理解;它会创建一个包含文件中每一行的列表。这可能不适合在 RAM 中,并且可能会使计算机陷入困境,这取决于操作系统。
如果我们在日志文件上使用for循环,我们可以在将下一行读入内存之前一次处理一行。如果我们能使用理解语法来获得相同的效果,那不是很好吗?
这就是生成器表达式的用武之地。它们使用与理解相同的语法,但不创建最终的容器对象。要创建生成器表达式,将理解包装在()中,而不是[]或{}。
以下代码解析了以前介绍的格式的日志文件,并输出了一个只包含WARNING行的新日志文件:
import sys
inname = sys.argv[1]
outname = sys.argv[2]
with open(inname) as infile:
with open(outname, "w") as outfile:
warnings = (l for l in infile if 'WARNING' in l)
for l in warnings:
outfile.write(l)
该程序在命令行上获取两个文件名,使用生成器表达式来过滤警告(在这种情况下,它使用if语法并保持行不变),然后将警告输出到另一个文件。如果我们在示例文件上运行它,输出如下:
Jan 26, 2015 11:25:46 WARNING This is a warning. It could be serious.
Jan 26, 2015 11:25:52 WARNING Another warning sent.
Jan 26, 2015 11:26:40 WARNING Warnings should be heeded.
Jan 26, 2015 11:26:54 WARNING Watch for warnings.
当然,对于这样一个简短的输入文件,我们可以安全地使用列表理解,但是如果文件有数百万行,生成器表达式将对内存和速度产生巨大影响。
将for表达式括在括号中会创建一个生成器表达式,而不是元组。
生成器表达式通常在函数调用内最有用。例如,我们可以在生成器表达式上调用sum、min或max,而不是列表,因为这些函数一次处理一个对象。我们只对聚合结果感兴趣,而不关心任何中间容器。
总的来说,在四个选项中,尽可能使用生成器表达式。如果我们实际上不需要列表、集合或字典,而只需要过滤或转换序列中的项目,生成器表达式将是最有效的。如果我们需要知道列表的长度,或对结果进行排序、去除重复项或创建字典,我们将不得不使用理解语法。
生成器
生成器表达式实际上也是一种理解;它将更高级(这次确实更高级!)的生成器语法压缩成一行。更高级的生成器语法看起来甚至不那么面向对象,但我们将再次发现,这只是一种简单的语法快捷方式,用于创建一种对象。
让我们进一步考虑一下日志文件示例。如果我们想要从输出文件中删除WARNING列(因为它是多余的:这个文件只包含警告),我们有几种不同级别的可读性选项。我们可以使用生成器表达式来实现:
import sys
# generator expression
inname, outname = sys.argv[1:3]
with open(inname) as infile:
with open(outname, "w") as outfile:
warnings = (
l.replace("\tWARNING", "") for l in infile if "WARNING" in l
)
for l in warnings:
outfile.write(l)
尽管如此,这是完全可读的,但我不想使表达式比这更复杂。我们也可以使用普通的for循环来实现:
with open(inname) as infile:
with open(outname, "w") as outfile:
for l in infile:
if "WARNING" in l:
outfile.write(l.replace("\tWARNING", ""))
这显然是可维护的,但在如此少的行数中有如此多级缩进有点丑陋。更令人担忧的是,如果我们想要做一些其他事情而不是简单地打印出行,我们还必须复制循环和条件代码。
现在让我们考虑一个真正面向对象的解决方案,没有任何捷径:
class WarningFilter:
def __init__(self, insequence):
self.insequence = insequence
def __iter__(self):
return self
def __next__(self):
l = self.insequence.readline()
while l and "WARNING" not in l:
l = self.insequence.readline()
if not l:
raise StopIteration
return l.replace("\tWARNING", "")
with open(inname) as infile:
with open(outname, "w") as outfile:
filter = WarningFilter(infile)
for l in filter:
outfile.write(l)
毫无疑问:这太丑陋和难以阅读了,你甚至可能无法理解发生了什么。我们创建了一个以文件对象为输入的对象,并提供了一个像任何迭代器一样的__next__方法。
这个__next__方法从文件中读取行,如果不是WARNING行,则将其丢弃。当我们遇到WARNING行时,我们修改并返回它。然后我们的for循环再次调用__next__来处理后续的WARNING行。当我们用完行时,我们引发StopIteration来告诉循环我们已经完成了迭代。与其他示例相比,这相当丑陋,但也很强大;现在我们手头有一个类,我们可以随心所欲地使用它。
有了这样的背景,我们终于可以看到真正的生成器在起作用了。下一个示例完全与前一个示例相同:它创建了一个具有__next__方法的对象,当输入用完时会引发StopIteration:
def warnings_filter(insequence):
for l in insequence:
if "WARNING" in l:
yield l.replace("\tWARNING", "")
with open(inname) as infile:
with open(outname, "w") as outfile:
filter = warnings_filter(infile)
for l in filter:
outfile.write(l)
好吧,那可能相当容易阅读...至少很简短。但这到底是怎么回事?这根本毫无意义。而且yield到底是什么?
实际上,yield是生成器的关键。当 Python 在函数中看到yield时,它会将该函数包装在一个对象中,类似于我们之前示例中的对象。将yield语句视为类似于return语句;它退出函数并返回一行。但与return不同的是,当函数再次被调用(通过next())时,它将从上次离开的地方开始——在yield语句之后的行——而不是从函数的开头开始。
在这个示例中,yield语句之后没有行,因此它会跳到for循环的下一个迭代。由于yield语句位于if语句内,它只会产生包含WARNING的行。
虽然看起来这只是一个循环遍历行的函数,但实际上它创建了一种特殊类型的对象,即生成器对象:
>>> print(warnings_filter([]))
<generator object warnings_filter at 0xb728c6bc>
我将一个空列表传递给函数,充当迭代器。函数所做的就是创建并返回一个生成器对象。该对象上有__iter__和__next__方法,就像我们在前面的示例中创建的那样。(你可以调用内置的dir函数来确认。)每当调用__next__时,生成器运行函数,直到找到yield语句。然后它返回yield的值,下一次调用__next__时,它会从上次离开的地方继续。
这种生成器的使用并不那么高级,但如果你没有意识到函数正在创建一个对象,它可能看起来像魔术一样。这个示例非常简单,但通过在单个函数中多次调用yield,你可以获得非常强大的效果;在每次循环中,生成器将简单地从最近的yield处继续到下一个yield处。
从另一个可迭代对象中产生值
通常,当我们构建一个生成器函数时,我们会陷入一种情况,我们希望从另一个可迭代对象中产生数据,可能是我们在生成器内部构造的列表推导或生成器表达式,或者可能是一些传递到函数中的外部项目。以前可以通过循环遍历可迭代对象并逐个产生每个项目来实现。然而,在 Python 3.3 版本中,Python 开发人员引入了一种新的语法,使其更加优雅一些。
让我们稍微调整一下生成器的例子,使其不再接受一系列行,而是接受一个文件名。这通常会被视为不好的做法,因为它将对象与特定的范例联系在一起。如果可能的话,我们应该在输入上操作迭代器;这样,同一个函数可以在日志行来自文件、内存或网络的情况下使用。
这个代码版本说明了你的生成器可以在从另一个可迭代对象(在本例中是一个生成器表达式)产生信息之前做一些基本的设置:
def warnings_filter(infilename):
with open(infilename) as infile:
yield from (
l.replace("\tWARNING", "") for l in infile if "WARNING" in l
)
filter = warnings_filter(inname)
with open(outname, "w") as outfile:
for l in filter:
outfile.write(l)
这段代码将前面示例中的for循环合并为一个生成器表达式。请注意,这种转换并没有帮助任何事情;前面的示例中使用for循环更易读。
因此,让我们考虑一个比其替代方案更易读的例子。构建一个生成器,从多个其他生成器中产生数据可能是有用的。例如,itertools.chain函数按顺序从可迭代对象中产生数据,直到它们全部耗尽。这可以使用yield from语法非常容易地实现,因此让我们考虑一个经典的计算机科学问题:遍历一棵通用树。
通用树数据结构的一个常见实现是计算机的文件系统。让我们模拟 Unix 文件系统中的一些文件夹和文件,这样我们就可以有效地使用yield from来遍历它们:
class File:
def __init__(self, name):
self.name = name
class Folder(File):
def __init__(self, name):
super().__init__(name)
self.children = []
root = Folder("")
etc = Folder("etc")
root.children.append(etc)
etc.children.append(File("passwd"))
etc.children.append(File("groups"))
httpd = Folder("httpd")
etc.children.append(httpd)
httpd.children.append(File("http.conf"))
var = Folder("var")
root.children.append(var)
log = Folder("log")
var.children.append(log)
log.children.append(File("messages"))
log.children.append(File("kernel"))
这个设置代码看起来很费力,但在一个真实的文件系统中,它会更加复杂。我们需要从硬盘读取数据并将其结构化成树。然而,一旦在内存中,输出文件系统中的每个文件的代码就非常优雅:
def walk(file):
if isinstance(file, Folder):
yield file.name + "/"
for f in file.children:
yield from walk(f)
else:
yield file.name
如果这段代码遇到一个目录,它会递归地要求walk()生成每个子目录下所有文件的列表,然后产生所有这些数据以及它自己的文件名。在它遇到一个普通文件的简单情况下,它只会产生那个文件名。
顺便说一句,解决前面的问题而不使用生成器是相当棘手的,以至于它是一个常见的面试问题。如果你像这样回答,准备好让你的面试官既印象深刻又有些恼火,因为你回答得如此轻松。他们可能会要求你解释到底发生了什么。当然,凭借你在本章学到的原则,你不会有任何问题。祝你好运!
yield from语法在编写链式生成器时是一个有用的快捷方式。它被添加到语言中是出于不同的原因,以支持协程。然而,它现在并没有被那么多地使用,因为它的用法已经被async和await语法所取代。我们将在下一节看到两者的例子。
协程
协程是非常强大的构造,经常被误解为生成器。许多作者不恰当地将协程描述为带有一些额外语法的生成器。这是一个容易犯的错误,因为在 Python 2.5 中引入协程时,它们被介绍为我们在生成器语法中添加了一个 send 方法。实际上,区别要更微妙一些,在看到一些例子之后会更有意义。
协程是相当难以理解的。在asyncio模块之外,它们在野外并不经常使用。你绝对可以跳过这一部分,快乐地在 Python 中开发多年,而不必遇到协程。有一些库广泛使用协程(主要用于并发或异步编程),但它们通常是这样编写的,以便你可以使用协程而不必真正理解它们是如何工作的!所以,如果你在这一部分迷失了方向,不要绝望。
如果我还没有吓到你,让我们开始吧!这是一个最简单的协程之一;它允许我们保持一个可以通过任意值增加的累加值:
def tally():
score = 0
while True:
increment = yield score
score += increment
这段代码看起来像是不可能工作的黑魔法,所以在逐行描述之前,让我们证明它可以工作。这个简单的对象可以被棒球队的记分应用程序使用。可以为每个队伍分别保留计分,并且他们的得分可以在每个半局结束时累加的得分增加。看看这个交互式会话:
>>> white_sox = tally()
>>> blue_jays = tally()
>>> next(white_sox)
0
>>> next(blue_jays)
0
>>> white_sox.send(3)
3
>>> blue_jays.send(2)
2
>>> white_sox.send(2)
5
>>> blue_jays.send(4)
6
首先,我们构建了两个tally对象,一个用于每个队伍。是的,它们看起来像函数,但与上一节中的生成器对象一样,函数内部有yield语句告诉 Python 要付出很大的努力将简单的函数转换为对象。
然后我们对每个协程对象调用next()。这与调用任何生成器的next()做的事情是一样的,也就是说,它执行每一行代码,直到遇到yield语句,返回该点的值,然后暂停,直到下一个next()调用。
到目前为止,没有什么新鲜的。但是回顾一下我们协程中的yield语句:
increment = yield score
与生成器不同,这个yield函数看起来像是要返回一个值并将其赋给一个变量。事实上,这正是发生的事情。协程仍然在yield语句处暂停,等待被另一个next()调用再次激活。
除了我们不调用next()。正如你在交互式会话中看到的,我们调用一个名为send()的方法。send()方法和next()做完全相同的事情,只是除了将生成器推进到下一个yield语句之外,它还允许你从生成器外部传入一个值。这个值被分配给yield语句的左侧。
对于许多人来说,真正令人困惑的是这发生的顺序:
-
yield发生,生成器暂停 -
send()发生在函数外部,生成器被唤醒 -
传入的值被分配给
yield语句的左侧 -
生成器继续处理,直到遇到另一个
yield语句
因此,在这个特定的例子中,我们构建了协程并通过单次调用next()将其推进到yield语句,然后每次调用send()都将一个值传递给协程。我们将这个值加到它的得分上。然后我们回到while循环的顶部,并继续处理,直到我们遇到yield语句。yield语句返回一个值,这个值成为我们最近一次调用send的返回值。不要错过这一点:像next()一样,send()方法不仅提交一个值给生成器,还返回即将到来的yield语句的值。这就是我们定义生成器和协程之间的区别的方式:生成器只产生值,而协程也可以消耗值。
next(i)、i.__next__()和i.send(value)的行为和语法相当不直观和令人沮丧。第一个是普通函数,第二个是特殊方法,最后一个是普通方法。但这三个都是做同样的事情:推进生成器直到它产生一个值并暂停。此外,next()函数和相关的方法可以通过调用i.send(None)来复制。在这里有两个不同的方法名是有价值的,因为它有助于我们的代码读者轻松地看到他们是在与协程还是生成器交互。我只是觉得在某些情况下它是一个函数调用,而在另一种情况下它是一个普通方法有点令人恼火。
回到日志解析
当然,前面的例子可以很容易地使用一对整数变量编码,并在它们上调用x += increment。让我们看一个第二个例子,其中协程实际上节省了我们一些代码。这个例子是我在 Facebook 工作时不得不解决的问题的一个简化版本(出于教学目的)。
Linux 内核日志包含几乎看起来与此类似但又不完全相同的行:
unrelated log messages
sd 0:0:0:0 Attached Disk Drive
unrelated log messages
sd 0:0:0:0 (SERIAL=ZZ12345)
unrelated log messages
sd 0:0:0:0 [sda] Options
unrelated log messages
XFS ERROR [sda]
unrelated log messages
sd 2:0:0:1 Attached Disk Drive
unrelated log messages
sd 2:0:0:1 (SERIAL=ZZ67890)
unrelated log messages
sd 2:0:0:1 [sdb] Options
unrelated log messages
sd 3:0:1:8 Attached Disk Drive
unrelated log messages
sd 3:0:1:8 (SERIAL=WW11111)
unrelated log messages
sd 3:0:1:8 [sdc] Options
unrelated log messages
XFS ERROR [sdc]
unrelated log messages
有一大堆交错的内核日志消息,其中一些与硬盘有关。硬盘消息可能与其他消息交错,但它们以可预测的格式和顺序出现。对于每个硬盘,已知的序列号与总线标识符(如0:0:0:0)相关联。块设备标识符(如sda)也与该总线相关联。最后,如果驱动器的文件系统损坏,它可能会出现 XFS 错误。
现在,考虑到前面的日志文件,我们需要解决的问题是如何获取任何出现 XFS 错误的驱动器的序列号。这个序列号可能稍后会被数据中心的技术人员用来识别并更换驱动器。
我们知道我们可以使用正则表达式识别单独的行,但是我们将不得不在循环遍历行时更改正则表达式,因为我们将根据先前找到的内容寻找不同的东西。另一个困难的地方是,如果我们找到一个错误字符串,包含该字符串的总线以及序列号的信息已经被处理过。这可以通过以相反的顺序迭代文件的行来轻松解决。
在查看这个例子之前,请注意——基于协程的解决方案所需的代码量非常少:
import re
def match_regex(filename, regex):
with open(filename) as file:
lines = file.readlines()
for line in reversed(lines):
match = re.match(regex, line)
if match:
regex = yield match.groups()[0]
def get_serials(filename):
ERROR_RE = "XFS ERROR (\[sd[a-z]\])"
matcher = match_regex(filename, ERROR_RE)
device = next(matcher)
while True:
try:
bus = matcher.send(
"(sd \S+) {}.*".format(re.escape(device))
)
serial = matcher.send("{} \(SERIAL=([^)]*)\)".format(bus))
yield serial
device = matcher.send(ERROR_RE)
except StopIteration:
matcher.close()
return
for serial_number in get_serials("EXAMPLE_LOG.log"):
print(serial_number)
这段代码将工作分成了两个独立的任务。第一个任务是循环遍历所有行并输出与给定正则表达式匹配的任何行。第二个任务是与第一个任务交互,并为其提供指导,告诉它在任何给定时间应该搜索什么正则表达式。
首先看match_regex协程。记住,它在构造时不执行任何代码;相反,它只创建一个协程对象。一旦构造完成,协程外部的某人最终会调用next()来启动代码运行。然后它存储两个变量filename和regex的状态。然后它读取文件中的所有行并以相反的顺序对它们进行迭代。将传入的每一行与正则表达式进行比较,直到找到匹配项。当找到匹配项时,协程会产生正则表达式的第一个组并等待。
在将来的某个时候,其他代码将发送一个新的正则表达式来搜索。请注意,协程从不关心它试图匹配的正则表达式是什么;它只是循环遍历行并将它们与正则表达式进行比较。决定提供什么正则表达式是别人的责任。
在这种情况下,其他人是get_serials生成器。它不关心文件中的行;事实上,它甚至不知道它们。它做的第一件事是从match_regex协程构造函数创建一个matcher对象,给它一个默认的正则表达式来搜索。它将协程推进到它的第一个yield并存储它返回的值。然后它进入一个循环,指示matcher对象基于存储的设备 ID 搜索总线 ID,然后基于该总线 ID 搜索序列号。
它在向外部for循环空闲地产生该序列号之前指示匹配器找到另一个设备 ID 并重复循环。
基本上,协程的工作是在文件中搜索下一个重要的行,而生成器(get_serial,它使用yield语法而不进行赋值)的工作是决定哪一行是重要的。生成器有关于这个特定问题的信息,比如文件中行的顺序。
另一方面,协程可以插入到需要搜索文件以获取给定正则表达式的任何问题中。
关闭协程和引发异常
普通的生成器通过引发StopIteration来信号它们的退出。如果我们将多个生成器链接在一起(例如,通过在另一个生成器内部迭代一个生成器),StopIteration异常将向外传播。最终,它将遇到一个for循环,看到异常并知道是时候退出循环了。
尽管它们使用类似的语法,协程通常不遵循迭代机制。通常不是通过一个直到遇到异常的数据,而是通常将数据推送到其中(使用send)。通常是负责推送的实体告诉协程何时完成。它通过在相关协程上调用close()方法来做到这一点。
当调用close()方法时,它将在协程等待发送值的点引发GeneratorExit异常。通常,协程应该将它们的yield语句包装在try...finally块中,以便执行任何清理任务(例如关闭关联文件或套接字)。
如果我们需要在协程内部引发异常,我们可以类似地使用throw()方法。它接受一个异常类型,可选的value和traceback参数。当我们在一个协程中遇到异常并希望在相邻的协程中引发异常时,后者是有用的,同时保持回溯。
前面的例子可以在没有协程的情况下编写,并且读起来几乎一样。事实上,正确地管理协程之间的所有状态是相当困难的,特别是当你考虑到上下文管理器和异常等因素时。幸运的是,Python 标准库包含一个名为asyncio的包,可以为您管理所有这些。一般来说,我建议您避免使用裸协程,除非您专门为 asyncio 编写代码。日志示例几乎可以被认为是一种反模式;一种应该避免而不是拥抱的设计模式。
协程、生成器和函数之间的关系
我们已经看到了协程的运行,现在让我们回到讨论它们与生成器的关系。在 Python 中,就像经常发生的情况一样,这种区别是相当模糊的。事实上,所有的协程都是生成器对象,作者经常交替使用这两个术语。有时,他们将协程描述为生成器的一个子集(只有从yield返回值的生成器被认为是协程)。这在 Python 中是技术上正确的,正如我们在前面的部分中看到的。
然而,在更广泛的理论计算机科学领域,协程被认为是更一般的原则,生成器是协程的一种特定类型。此外,普通函数是协程的另一个独特子集。
协程是一个可以在一个或多个点传入数据并在一个或多个点获取数据的例程。在 Python 中,数据传入和传出的点是yield语句。
函数,或子例程,是协程的最简单类型。您可以在一个点传入数据,并在函数返回时在另一个点获取数据。虽然函数可以有多个return语句,但对于任何给定的函数调用,只能调用其中一个。
最后,生成器是一种可以在一个点传入数据的协程,但可以在多个点传出数据的协程。在 Python 中,数据将在yield语句处传出,但无法再传入数据。如果调用send,数据将被悄悄丢弃。
因此,理论上,生成器是协程的一种类型,函数是协程的一种类型,还有一些既不是函数也不是生成器的协程。够简单了吧?那么,为什么在 Python 中感觉更复杂呢?
在 Python 中,生成器和协程都是使用类似于构造函数的语法构造的。但是生成的对象根本不是函数;它是一种完全不同类型的对象。函数当然也是对象。但它们有不同的接口;函数是可调用的并返回值,生成器使用next()提取数据,协程使用send推入数据。
还有一种使用async和await关键字的协程的替代语法。这种语法使得代码更清晰,表明代码是一个协程,并进一步打破了协程和生成器之间的欺骗性对称性。
案例研究
Python 目前最流行的领域之一是数据科学。为了纪念这一事实,让我们实现一个基本的机器学习算法。
机器学习是一个庞大的主题,但总体思想是利用从过去数据中获得的知识对未来数据进行预测或分类。这些算法的用途层出不穷,数据科学家每天都在找到应用机器学习的新方法。一些重要的机器学习应用包括计算机视觉(如图像分类或人脸识别)、产品推荐、识别垃圾邮件和自动驾驶汽车。
为了不偏离整本关于机器学习的书,我们将看一个更简单的问题:给定一个 RGB 颜色定义,人们会将该颜色定义为什么名字?
标准 RGB 颜色空间中有超过 1600 万种颜色,人类只为其中的一小部分取了名字。虽然有成千上万种名称(有些相当荒谬;只需去任何汽车经销商或油漆商店),让我们构建一个试图将 RGB 空间划分为基本颜色的分类器:
-
红色
-
紫色
-
蓝色
-
绿色
-
黄色
-
橙色
-
灰色
-
粉色
(在我的测试中,我将白色和黑色的颜色分类为灰色,棕色的颜色分类为橙色。)
我们需要的第一件事是一个数据集来训练我们的算法。在生产系统中,您可能会从颜色列表网站上获取数据,或者对成千上万的人进行调查。相反,我创建了一个简单的应用程序,它会呈现一个随机颜色,并要求用户从前面的八个选项中选择一个来分类。我使用了 Python 附带的用户界面工具包tkinter来实现它。我不打算详细介绍这个脚本的内容,但为了完整起见,这是它的全部内容(它有点长,所以您可能想从 Packt 的 GitHub 存储库中获取本书示例的完整内容,而不是自己输入):
import random
import tkinter as tk
import csv
class Application(tk.Frame):
def __init__(self, master=None):
super().__init__(master)
self.grid(sticky="news")
master.columnconfigure(0, weight=1)
master.rowconfigure(0, weight=1)
self.create_widgets()
self.file = csv.writer(open("colors.csv", "a"))
def create_color_button(self, label, column, row):
button = tk.Button(
self, command=lambda: self.click_color(label), text=label
)
button.grid(column=column, row=row, sticky="news")
def random_color(self):
r = random.randint(0, 255)
g = random.randint(0, 255)
b = random.randint(0, 255)
return f"#{r:02x}{g:02x}{b:02x}"
def create_widgets(self):
self.color_box = tk.Label(
self, bg=self.random_color(), width="30", height="15"
)
self.color_box.grid(
column=0, columnspan=2, row=0, sticky="news"
)
self.create_color_button("Red", 0, 1)
self.create_color_button("Purple", 1, 1)
self.create_color_button("Blue", 0, 2)
self.create_color_button("Green", 1, 2)
self.create_color_button("Yellow", 0, 3)
self.create_color_button("Orange", 1, 3)
self.create_color_button("Pink", 0, 4)
self.create_color_button("Grey", 1, 4)
self.quit = tk.Button(
self, text="Quit", command=root.destroy, bg="#ffaabb"
)
self.quit.grid(column=0, row=5, columnspan=2, sticky="news")
def click_color(self, label):
self.file.writerow([label, self.color_box["bg"]])
self.color_box["bg"] = self.random_color()
root = tk.Tk()
app = Application(master=root)
app.mainloop()
如果您愿意,可以轻松添加更多按钮以获取其他颜色。您可能会在布局上遇到问题;create_color_button的第二个和第三个参数表示按钮所在的两列网格的行和列。一旦您将所有颜色放在位,您将希望将退出按钮移动到最后一行。
对于这个案例研究,了解这个应用程序的重要事情是输出。它创建了一个名为colors.csv的逗号分隔值(CSV)文件。该文件包含两个 CSV:用户为颜色分配的标签和颜色的十六进制 RGB 值。以下是一个示例:
Green,#6edd13
Purple,#814faf
Yellow,#c7c26d
Orange,#61442c
Green,#67f496
Purple,#c757d5
Blue,#106a98
Pink,#d40491
.
.
.
Blue,#a4bdfa
Green,#30882f
Pink,#f47aad
Green,#83ddb2
Grey,#baaec9
Grey,#8aa28d
Blue,#533eda
在我厌倦并决定开始对我的数据集进行机器学习之前,我制作了 250 多个数据点。如果您想使用它,我的数据点已经与本章的示例一起提供(没有人告诉我我是色盲,所以它应该是合理的)。
我们将实现一种更简单的机器学习算法,称为k 最近邻。该算法依赖于数据集中点之间的某种距离计算(在我们的情况下,我们可以使用三维版本的毕达哥拉斯定理)。给定一个新的数据点,它找到一定数量(称为k,这是k 最近邻中的k)的数据点,这些数据点在通过该距离计算进行测量时最接近它。然后以某种方式组合这些数据点(对于线性计算,平均值可能有效;对于我们的分类问题,我们将使用模式),并返回结果。
我们不会详细介绍算法的工作原理;相反,我们将专注于如何将迭代器模式或迭代器协议应用于这个问题。
现在让我们编写一个程序,按顺序执行以下步骤:
-
从文件中加载示例数据并构建模型。
-
生成 100 种随机颜色。
-
对每种颜色进行分类,并以与输入相同的格式输出到文件。
第一步是一个相当简单的生成器,它加载 CSV 数据并将其转换为符合我们需求的格式:
import csv
dataset_filename = "colors.csv"
def load_colors(filename):
with open(filename) as dataset_file:
lines = csv.reader(dataset_file)
for line in lines:
label, hex_color = line
yield (hex_to_rgb(hex_color), label)
我们以前没有见过csv.reader函数。它返回文件中行的迭代器。迭代器返回的每个值都是一个由逗号分隔的字符串列表。因此,行Green,#6edd13返回为["Green", "#6edd13"]。
然后load_colors生成器逐行消耗该迭代器,并产生 RGB 值的元组以及标签。这种方式将生成器链接在一起是非常常见的,其中一个迭代器调用另一个迭代器,依此类推。您可能希望查看 Python 标准库中的itertools模块,其中有许多等待您的现成生成器。
在这种情况下,RGB 值是 0 到 255 之间的整数元组。从十六进制到 RGB 的转换有点棘手,因此我们将其提取到一个单独的函数中:
def hex_to_rgb(hex_color):
return tuple(int(hex_color[i : i + 2], 16) for i in range(1, 6, 2))
这个生成器表达式正在做很多工作。它以“#12abfe”这样的字符串作为输入,并返回一个类似(18, 171, 254)的元组。让我们从后往前分解。
range调用将返回数字[1, 3, 5]。这些数字代表十六进制字符串中三个颜色通道的索引。索引0被跳过,因为它代表字符“#”,而我们不关心这个字符。对于这三个数字中的每一个,它提取i和i+2之间的两个字符的字符串。对于前面的示例字符串,这将是12,ab和fe。然后将此字符串值转换为整数。作为int函数的第二个参数传递的16告诉函数使用基数 16(十六进制)而不是通常的基数 10(十进制)进行转换。
考虑到生成器表达式的阅读难度,您认为它应该以不同的格式表示吗?例如,它可以被创建为多个生成器表达式的序列,或者展开为一个带有yield语句的普通生成器函数。您更喜欢哪种?
在这种情况下,我相信函数名称能够解释这行丑陋代码在做什么。
现在我们已经加载了训练数据(手动分类的颜色),我们需要一些新数据来测试算法的工作效果。我们可以通过生成一百种随机颜色来实现这一点,每种颜色由 0 到 255 之间的三个随机数字组成。
有很多方法可以做到这一点:
-
一个带有嵌套生成器表达式的列表推导:
[tuple(randint(0,255) for c in range(3)) for r in range(100)] -
一个基本的生成器函数
-
实现
__iter__和__next__协议的类 -
通过一系列协同程序将数据传递
-
即使只是一个基本的
for循环
生成器版本似乎最易读,所以让我们将该函数添加到我们的程序中:
from random import randint
def generate_colors(count=100):
for i in range(count):
yield (randint(0, 255), randint(0, 255), randint(0, 255))
注意我们如何对要生成的颜色数量进行参数化。现在我们可以在将来重用这个函数来执行其他生成颜色的任务。
现在,在进行分类之前,我们需要一个计算两种颜色之间距离的函数。由于可以将颜色看作是三维的(例如,红色、绿色和蓝色可以映射到x、y和z轴),让我们使用一些基本的数学:
def color_distance(color1, color2):
channels = zip(color1, color2)
sum_distance_squared = 0
for c1, c2 in channels:
sum_distance_squared += (c1 - c2) ** 2
return sum_distance_squared
这是一个看起来非常基本的函数;它看起来甚至没有使用迭代器协议。没有yield函数,也没有推导。但是,有一个for循环,zip函数的调用也在进行一些真正的迭代(如果您不熟悉它,zip会产生元组,每个元组包含来自每个输入迭代器的一个元素)。
这个距离计算是你可能从学校记得的勾股定理的三维版本:a² + b² = c²。由于我们使用了三个维度,我猜实际上应该是a² + b² + c² = d²。距离在技术上是a² + b² + c²的平方根,但没有必要执行相对昂贵的sqrt计算,因为平方距离在大小上都是相同的。
现在我们已经有了一些基本的管道,让我们来实现实际的 k-nearest neighbor。这个例程可以被认为是消耗和组合我们已经看到的两个生成器(load_colors和generate_colors):
def nearest_neighbors(model_colors, target_colors, num_neighbors=5):
model_colors = list(model_colors)
for target in target_colors:
distances = sorted(
((color_distance(c[0], target), c) for c in model_colors)
)
yield target, distances[:5]
首先,我们将model_colors生成器转换为列表,因为它必须被多次使用,每次用于target_colors中的一个。如果我们不这样做,就必须重复从源文件加载颜色,这将执行大量不必要的磁盘读取。
这种决定的缺点是整个列表必须一次性全部存储在内存中。如果我们有一个无法放入内存的大型数据集,实际上需要每次从磁盘重新加载生成器(尽管在这种情况下,我们实际上会考虑不同的机器学习算法)。
nearest_neighbors生成器循环遍历每个目标颜色(例如(255, 14, 168)的三元组),并在生成器表达式中调用color_distance函数。然后,sorted调用对该生成器表达式的结果按其第一个元素进行排序,即距离。这是一段复杂的代码,一点也不面向对象。您可能需要将其分解为普通的for循环,以确保您理解生成器表达式在做什么。
yield语句稍微复杂一些。对于target_colors生成器中的每个 RGB 三元组,它产生目标和num_neighbors(这是k在k-nearest中,顺便说一下,许多数学家和数据科学家倾向于使用难以理解的单字母变量名)最接近的颜色的列表推导。
列表推导中的每个元素的内容是model_colors生成器的一个元素;也就是说,一个包含三个 RGB 值和手动输入的字符串名称的元组。因此,一个元素可能看起来像这样:((104, 195, 77), 'Green')。当我看到嵌套元组时,我首先想到的是,这不是正确的数据结构。RGB 颜色可能应该表示为一个命名元组,并且这两个属性可能应该放在一个数据类上。
我们现在可以添加另一个生成器到链中,以找出我们应该给这个目标颜色起什么名字:
from collections import Counter
def name_colors(model_colors, target_colors, num_neighbors=5):
for target, near in nearest_neighbors(
model_colors, target_colors, num_neighbors=5
):
print(target, near)
name_guess = Counter(n[1] for n in near).most_common()[0][0]
yield target, name_guess
这个生成器将nearest_neighbors返回的元组解包成三元组目标和五个最近的数据点。它使用Counter来找到在返回的颜色中最常出现的名称。在Counter构造函数中还有另一个生成器表达式;这个生成器表达式从每个数据点中提取第二个元素(颜色名称)。然后它产生一个 RGB 值和猜测的名称的元组。返回值的一个例子是(91, 158, 250) Blue。
我们可以编写一个函数,接受name_colors生成器的输出,并将其写入 CSV 文件,RGB 颜色表示为十六进制值:
def write_results(colors, filename="output.csv"):
with open(filename, "w") as file:
writer = csv.writer(file)
for (r, g, b), name in colors:
writer.writerow([name, f"#{r:02x}{g:02x}{b:02x}"])
这是一个函数,而不是一个生成器。它在for循环中消耗生成器,但它不产生任何东西。它构造了一个 CSV 写入器,并为每个目标颜色输出名称、十六进制值(例如Purple,#7f5f95)对的行。这里可能会让人困惑的唯一一件事是格式字符串的内容。与每个r、g和b通道一起使用的:02x修饰符将数字输出为前导零填充的两位十六进制数。
现在我们所要做的就是将这些不同的生成器和管道连接在一起,并通过一个函数调用启动整个过程:
def process_colors(dataset_filename="colors.csv"):
model_colors = load_colors(dataset_filename)
colors = name_colors(model_colors, generate_colors(), 5)
write_results(colors)
if __name__ == "__main__":
process_colors()
因此,这个函数与我们定义的几乎所有其他函数不同,它是一个完全正常的函数,没有yield语句或for循环。它根本不进行任何迭代。
然而,它构造了三个生成器。你能看到所有三个吗?:
-
load_colors返回一个生成器 -
generate_colors返回一个生成器 -
name_guess返回一个生成器
name_guess生成器消耗了前两个生成器。然后,它又被write_results函数消耗。
我写了第二个 Tkinter 应用程序来检查算法的准确性。它与第一个应用程序类似,只是它会渲染每种颜色及与该颜色相关联的标签。然后你必须手动点击是或否,以确定标签是否与颜色匹配。对于我的示例数据,我得到了大约 95%的准确性。通过实施以下内容,这个准确性可以得到提高:
-
添加更多颜色名称
-
通过手动分类更多颜色来添加更多的训练数据
-
调整
num_neighbors的值 -
使用更高级的机器学习算法
这是输出检查应用的代码,不过我建议下载示例代码。这样打字会很麻烦:
import tkinter as tk
import csv
class Application(tk.Frame):
def __init__(self, master=None):
super().__init__(master)
self.grid(sticky="news")
master.columnconfigure(0, weight=1)
master.rowconfigure(0, weight=1)
self.csv_reader = csv.reader(open("output.csv"))
self.create_widgets()
self.total_count = 0
self.right_count = 0
def next_color(self):
return next(self.csv_reader)
def mk_grid(self, widget, column, row, columnspan=1):
widget.grid(
column=column, row=row, columnspan=columnspan, sticky="news"
)
def create_widgets(self):
color_text, color_bg = self.next_color()
self.color_box = tk.Label(
self, bg=color_bg, width="30", height="15"
)
self.mk_grid(self.color_box, 0, 0, 2)
self.color_label = tk.Label(self, text=color_text, height="3")
self.mk_grid(self.color_label, 0, 1, 2)
self.no_button = tk.Button(
self, command=self.count_next, text="No"
)
self.mk_grid(self.no_button, 0, 2)
self.yes_button = tk.Button(
self, command=self.count_yes, text="Yes"
)
self.mk_grid(self.yes_button, 1, 2)
self.percent_accurate = tk.Label(self, height="3", text="0%")
self.mk_grid(self.percent_accurate, 0, 3, 2)
self.quit = tk.Button(
self, text="Quit", command=root.destroy, bg="#ffaabb"
)
self.mk_grid(self.quit, 0, 4, 2)
def count_yes(self):
self.right_count += 1
self.count_next()
def count_next(self):
self.total_count += 1
percentage = self.right_count / self.total_count
self.percent_accurate["text"] = f"{percentage:.0%}"
try:
color_text, color_bg = self.next_color()
except StopIteration:
color_text = "DONE"
color_bg = "#ffffff"
self.color_box["text"] = "DONE"
self.yes_button["state"] = tk.DISABLED
self.no_button["state"] = tk.DISABLED
self.color_label["text"] = color_text
self.color_box["bg"] = color_bg
root = tk.Tk()
app = Application(master=root)
app.mainloop()
你可能会想,这与面向对象编程有什么关系?这段代码中甚至没有一个类! 从某些方面来说,你是对的;生成器通常不被认为是面向对象的。然而,创建它们的函数返回对象;实际上,你可以把这些函数看作构造函数。构造的对象有一个适当的__next__()方法。基本上,生成器语法是一种特定类型的对象的语法快捷方式,如果没有它,创建这种对象会非常冗长。
练习
如果你在日常编码中很少使用推导,那么你应该做的第一件事是搜索一些现有的代码,找到一些for循环。看看它们中是否有任何可以轻松转换为生成器表达式或列表、集合或字典推导的。
测试列表推导是否比for循环更快。这可以通过内置的timeit模块来完成。使用timeit.timeit函数的帮助文档找出如何使用它。基本上,编写两个做同样事情的函数,一个使用列表推导,一个使用for循环来迭代数千个项目。将每个函数传入timeit.timeit,并比较结果。如果你感到有冒险精神,也可以比较生成器和生成器表达式。使用timeit测试代码可能会让人上瘾,所以请记住,除非代码被执行了大量次数,比如在一个巨大的输入列表或文件上,否则代码不需要非常快。
玩转生成器函数。从需要多个值的基本迭代器开始(数学序列是典型的例子;如果你想不出更好的例子,斐波那契数列已经被过度使用了)。尝试一些更高级的生成器,比如接受多个输入列表并以某种方式产生合并值的生成器。生成器也可以用在文件上;你能否编写一个简单的生成器,显示两个文件中相同的行?
协程滥用迭代器协议,但实际上并不符合迭代器模式。你能否构建一个非协程版本的代码,从日志文件中获取序列号?采用面向对象的方法,以便在类上存储额外的状态。如果你能创建一个对象,它可以完全替代现有的协程,你将学到很多关于协程的知识。
本章的案例研究中有很多奇怪的元组传递,很难跟踪。看看是否可以用更面向对象的解决方案替换这些返回值。另外,尝试将一些共享数据的函数(例如model_colors和target_colors)移入一个类中进行实验。这样可以减少大多数生成器需要传入的参数数量,因为它们可以在self上查找。
总结
在本章中,我们了解到设计模式是有用的抽象,为常见的编程问题提供最佳实践解决方案。我们介绍了我们的第一个设计模式,迭代器,以及 Python 使用和滥用这种模式的多种方式。原始的迭代器模式非常面向对象,但在代码上也相当丑陋和冗长。然而,Python 的内置语法将丑陋抽象化,为我们留下了这些面向对象构造的清晰接口。
理解推导和生成器表达式可以将容器构造与迭代结合在一行中。生成器对象可以使用yield语法构造。协程在外部看起来像生成器,但用途完全不同。
我们将在接下来的两章中介绍几种设计模式。
第二十二章:Python 设计模式 I
在上一章中,我们简要介绍了设计模式,并介绍了迭代器模式,这是一个非常有用和常见的模式,以至于它已经被抽象成了编程语言本身的核心。在本章中,我们将回顾其他常见的模式,以及它们在 Python 中的实现方式。与迭代一样,Python 通常提供另一种语法来使处理这些问题更简单。我们将涵盖这些模式的传统设计和 Python 版本。
总之,我们将看到:
-
许多特定的模式
-
Python 中每种模式的典型实现
-
用 Python 语法替换某些模式
装饰器模式
装饰器模式允许我们用其他对象包装提供核心功能的对象。使用装饰过的对象的任何对象将以与未装饰的对象完全相同的方式与其交互(即,装饰过的对象的接口与核心对象的接口相同)。
装饰器模式的两个主要用途:
-
增强组件发送数据到第二个组件的响应
-
支持多个可选行为
第二个选项通常是多重继承的一个合适的替代方案。我们可以构建一个核心对象,然后创建一个装饰器包装该核心。由于装饰器对象具有与核心对象相同的接口,我们甚至可以将新对象包装在其他装饰器中。以下是它在 UML 图中的样子:

在这里,Core和所有的装饰器都实现了特定的接口。装饰器通过组合维护对接口的另一个实例的引用。当调用时,装饰器在调用其包装的接口之前或之后进行一些附加处理。被包装的对象可以是另一个装饰器,也可以是核心功能。虽然多个装饰器可以相互包装,但是所有这些装饰器中心的对象提供了核心功能。
一个装饰器的例子
让我们看一个来自网络编程的例子。我们将使用 TCP 套接字。socket.send()方法接受一串输入字节并将它们输出到另一端的接收套接字。有很多库可以接受套接字并访问这个函数来在流上发送数据。让我们创建这样一个对象;它将是一个交互式 shell,等待客户端的连接,然后提示用户输入一个字符串响应:
import socket
def respond(client):
response = input("Enter a value: ")
client.send(bytes(response, "utf8"))
client.close()
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("localhost", 2401))
server.listen(1)
try:
while True:
client, addr = server.accept()
respond(client)
finally:
server.close()
respond函数接受一个socket参数并提示要发送的数据作为回复,然后发送它。要使用它,我们构建一个服务器套接字,并告诉它在本地计算机上的端口2401上进行监听(我随机选择了端口)。当客户端连接时,它调用respond函数,该函数交互式地请求数据并做出适当的响应。需要注意的重要事情是,respond函数只关心套接字接口的两种方法:send和close。
为了测试这一点,我们可以编写一个非常简单的客户端,连接到相同的端口并在退出之前输出响应:
import socket
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("localhost", 2401))
print("Received: {0}".format(client.recv(1024)))
client.close()
要使用这些程序,请按照以下步骤进行:
-
在一个终端中启动服务器。
-
打开第二个终端窗口并运行客户端。
-
在服务器窗口的“输入值:”提示处,输入一个值并按Enter键。
-
客户端将接收您输入的内容,将其打印到控制台上,并退出。再次运行客户端;服务器将提示输入第二个值。
结果将看起来像这样:

现在,回顾我们的服务器代码,我们看到了两个部分。respond函数将数据发送到一个socket对象中。剩下的脚本负责创建该socket对象。我们将创建一对装饰器,定制套接字的行为,而无需扩展或修改套接字本身。
让我们从一个logging装饰器开始。这个对象在将数据发送到客户端之前,将任何数据输出到服务器的控制台上:
class LogSocket:
def __init__(self, socket):
self.socket = socket
def send(self, data):
print(
"Sending {0} to {1}".format(
data, self.socket.getpeername()[0]
)
)
self.socket.send(data)
def close(self):
self.socket.close()
这个类装饰了一个socket对象,并向客户端 socket 呈现send和close接口。一个更好的装饰器还应该实现(可能定制)所有剩余的socket方法。它还应该正确地实现send的所有参数(实际上还接受一个可选的 flags 参数),但让我们保持我们的例子简单。每当在这个对象上调用send时,它都会在将数据发送到客户端之前将输出记录到屏幕上,使用原始 socket。
我们只需要改变原始代码中的一行,就可以使用这个装饰器。我们不再用 socket 调用respond,而是用一个装饰过的 socket 调用它:
respond(LogSocket(client))
虽然这很简单,但我们必须问自己,为什么我们不只是扩展socket类并覆盖send方法。我们可以调用super().send在记录后进行实际发送。这种设计也没有问题。
当面临装饰器和继承之间的选择时,只有在我们需要根据某些条件动态修改对象时,才应该使用装饰器。例如,我们可能只想在服务器当前处于调试模式时启用日志装饰器。当我们有多个可选行为时,装饰器也比多重继承更胜一筹。例如,我们可以编写第二个装饰器,每当调用send时,就使用gzip压缩数据:
import gzip
from io import BytesIO
class GzipSocket:
def __init__(self, socket):
self.socket = socket
def send(self, data):
buf = BytesIO()
zipfile = gzip.GzipFile(fileobj=buf, mode="w")
zipfile.write(data)
zipfile.close()
self.socket.send(buf.getvalue())
def close(self):
self.socket.close()
在这个版本中,send方法在发送到客户端之前压缩传入的数据。
现在我们有了这两个装饰器,我们可以编写代码,在响应时动态地在它们之间切换。这个例子并不完整,但它说明了我们可能遵循的逻辑来混合和匹配装饰器:
client, addr = server.accept()
if log_send:
client = LogSocket(client)
if client.getpeername()[0] in compress_hosts:
client = GzipSocket(client)
respond(client)
这段代码检查一个名为log_send的假设配置变量。如果启用了,它会将 socket 包装在LogSocket装饰器中。类似地,它检查连接的客户端是否在已知接受压缩内容的地址列表中。如果是,它会将客户端包装在GzipSocket装饰器中。请注意,这两个装饰器中的任何一个、两个或全部都可能被启用,这取决于配置和连接的客户端。尝试使用多重继承来编写这个,并看看你会有多困惑!
Python 中的装饰器
装饰器模式在 Python 中很有用,但也有其他选择。例如,我们可以使用 monkey-patching(例如,socket.socket.send = log_send)来获得类似的效果。单继承,其中可选的计算在一个大方法中完成,可能是一个选择,而多继承不应该被写入,只是因为它不适用于先前看到的特定示例。
在 Python 中,很常见在函数上使用这种模式。正如我们在前一章中看到的,函数也是对象。事实上,函数装饰是如此常见,以至于 Python 提供了一种特殊的语法,使得将这种装饰器应用到函数变得容易。
例如,我们可以更一般地看待日志示例。我们可能会发现,不仅仅是在 socket 上发送调用时记录,记录所有对某些函数或方法的调用可能会有所帮助。以下示例实现了一个装饰器,正是这样做的:
import time
def log_calls(func):
def wrapper(*args, **kwargs):
now = time.time()
print(
"Calling {0} with {1} and {2}".format(
func.__name__, args, kwargs
)
)
return_value = func(*args, **kwargs)
print(
"Executed {0} in {1}ms".format(
func.__name__, time.time() - now
)
)
return return_value
return wrapper
def test1(a, b, c):
print("\ttest1 called")
def test2(a, b):
print("\ttest2 called")
def test3(a, b):
print("\ttest3 called")
time.sleep(1)
test1 = log_calls(test1)
test2 = log_calls(test2)
test3 = log_calls(test3)
test1(1, 2, 3)
test2(4, b=5)
test3(6, 7)
这个装饰器函数与我们之前探讨的示例非常相似;在这些情况下,装饰器接受一个类似 socket 的对象并创建一个类似 socket 的对象。这次,我们的装饰器接受一个函数对象并返回一个新的函数对象。这段代码包括三个单独的任务:
-
一个函数,
log_calls,接受另一个函数 -
这个函数定义了(内部)一个名为
wrapper的新函数,在调用原始函数之前做一些额外的工作 -
内部函数从外部函数返回
三个示例函数演示了装饰器的使用。第三个函数包括一个sleep调用来演示定时测试。我们将每个函数传递给装饰器,它返回一个新函数。我们将这个新函数赋给原始变量名,有效地用装饰后的函数替换了原始函数。
这种语法允许我们动态构建装饰函数对象,就像我们在套接字示例中所做的那样。如果我们不替换名称,我们甚至可以为不同情况保留装饰和非装饰版本。
通常,这些装饰器是应用于不同函数的永久性通用修改。在这种情况下,Python 支持一种特殊的语法,在函数定义时应用装饰器。我们已经在一些地方看到了这种语法;现在,让我们了解一下它是如何工作的。
我们可以使用@decorator语法一次完成所有操作,而不是在方法定义之后应用装饰器函数:
@log_calls
def test1(a,b,c):
print("\ttest1 called")
这种语法的主要好处是,我们可以很容易地看到在阅读函数定义时函数已经被装饰。如果装饰器是后来应用的,那么阅读代码的人可能会错过函数已经被修改的事实。回答类似“为什么我的程序将函数调用记录到控制台?”这样的问题可能会变得更加困难!但是,这种语法只能应用于我们定义的函数,因为我们无法访问其他模块的源代码。如果我们需要装饰第三方库中的函数,我们必须使用之前的语法。
装饰器语法还有更多我们在这里没有看到的内容。我们没有足够的空间来涵盖这里的高级主题,所以请查看 Python 参考手册或其他教程以获取更多信息。装饰器可以被创建为可调用对象,而不仅仅是返回函数的函数。类也可以被装饰;在这种情况下,装饰器返回一个新类,而不是一个新函数。最后,装饰器可以接受参数,以便根据每个函数的情况进行自定义。
观察者模式
观察者模式对于状态监控和事件处理非常有用。这种模式允许一个给定的对象被未知和动态的观察者对象监视。
每当核心对象上的值发生变化时,它都会通过调用update()方法来通知所有观察者对象发生了变化。每个观察者在核心对象发生变化时可能负责不同的任务;核心对象不知道也不关心这些任务是什么,观察者通常也不知道也不关心其他观察者在做什么。
这是它在 UML 中的表示:

观察者模式示例
观察者模式可能在冗余备份系统中很有用。我们可以编写一个维护特定值的核心对象,然后有一个或多个观察者创建该对象的序列化副本。例如,这些副本可以存储在数据库中,存储在远程主机上,或者存储在本地文件中。让我们使用属性来实现核心对象:
class Inventory:
def __init__(self):
self.observers = []
self._product = None
self._quantity = 0
def attach(self, observer):
self.observers.append(observer)
@property
def product(self):
return self._product
@product.setter
def product(self, value):
self._product = value
self._update_observers()
@property
def quantity(self):
return self._quantity
@quantity.setter
def quantity(self, value):
self._quantity = value
self._update_observers()
def _update_observers(self):
for observer in self.observers:
observer()
这个对象有两个属性,当设置时,会调用自身的_update_observers方法。这个方法所做的就是循环遍历任何注册的观察者,并让每个观察者知道发生了一些变化。在这种情况下,我们直接调用观察者对象;对象将必须实现__call__来处理更新。这在许多面向对象的编程语言中是不可能的,但在 Python 中是一个有用的快捷方式,可以帮助我们使我们的代码更易读。
现在让我们实现一个简单的观察者对象;这个对象只会将一些状态打印到控制台上:
class ConsoleObserver:
def __init__(self, inventory):
self.inventory = inventory
def __call__(self):
print(self.inventory.product)
print(self.inventory.quantity)
这里没有什么特别激动人心的东西;观察到的对象在初始化程序中设置,当观察者被调用时,我们会执行某些操作。我们可以在交互式控制台中测试观察者:
>>> i = Inventory()
>>> c = ConsoleObserver(i)
>>> i.attach(c)
>>> i.product = "Widget"
Widget
0
>>> i.quantity = 5
Widget
5
将观察者附加到Inventory对象后,每当我们更改两个观察属性中的一个时,观察者都会被调用并执行其操作。我们甚至可以添加两个不同的观察者实例:
>>> i = Inventory()
>>> c1 = ConsoleObserver(i)
>>> c2 = ConsoleObserver(i)
>>> i.attach(c1)
>>> i.attach(c2)
>>> i.product = "Gadget"
Gadget
0
Gadget
0
这次当我们改变产品时,有两套输出,每个观察者一个。这里的关键思想是我们可以轻松地添加完全不同类型的观察者,同时备份数据到文件、数据库或互联网应用程序。
观察者模式将被观察的代码与观察的代码分离。如果我们不使用这种模式,我们将不得不在每个属性中放置代码来处理可能出现的不同情况;记录到控制台、更新数据库或文件等。所有这些任务的代码都将与被观察的对象混在一起。维护它将是一场噩梦,并且在以后添加新的监视功能将是痛苦的。
策略模式
策略模式是面向对象编程中抽象的常见演示。该模式实现了单个问题的不同解决方案,每个解决方案都在不同的对象中。客户端代码可以在运行时动态选择最合适的实现。
通常,不同的算法有不同的权衡;一个可能比另一个更快,但使用了更多的内存,而第三个算法可能在多个 CPU 存在或提供分布式系统时最合适。以下是 UML 中的策略模式:

用户连接到策略模式的代码只需要知道它正在处理抽象接口。所选择的实际实现以不同的方式执行相同的任务;无论如何,接口都是相同的。
策略示例
策略模式的典型示例是排序例程;多年来,已经发明了许多用于对对象集合进行排序的算法;快速排序、归并排序和堆排序都是快速排序算法,具有不同的特性,每种都有其自身的用途,取决于输入的大小和类型,它们的顺序有多乱,以及系统的要求。
如果我们有需要对集合进行排序的客户端代码,我们可以将其传递给具有sort()方法的对象。这个对象可以是QuickSorter或MergeSorter对象,但结果在任何情况下都是相同的:一个排序好的列表。用于进行排序的策略被抽象出来,使其模块化和可替换。
当然,在 Python 中,我们通常只是调用sorted函数或list.sort方法,并相信它会以接近最佳的方式进行排序。因此,我们确实需要看一个更好的例子。
让我们考虑一个桌面壁纸管理器。当图像显示在桌面背景上时,可以以不同的方式调整到屏幕大小。例如,假设图像比屏幕小,可以在屏幕上平铺、居中或缩放以适应。
还有其他更复杂的策略可以使用,例如缩放到最大高度或宽度,将其与实心、半透明或渐变背景颜色相结合,或其他操作。虽然我们可能希望稍后添加这些策略,但让我们从基本的开始。
我们的策略对象需要两个输入;要显示的图像和屏幕宽度和高度的元组。它们每个都返回一个新的屏幕大小的图像,图像根据给定的策略进行调整。您需要使用pip3 install pillow安装pillow模块才能使此示例工作:
from PIL import Image
class TiledStrategy:
def make_background(self, img_file, desktop_size):
in_img = Image.open(img_file)
out_img = Image.new("RGB", desktop_size)
num_tiles = [
o // i + 1 for o, i in zip(out_img.size, in_img.size)
]
for x in range(num_tiles[0]):
for y in range(num_tiles[1]):
out_img.paste(
in_img,
(
in_img.size[0] * x,
in_img.size[1] * y,
in_img.size[0] * (x + 1),
in_img.size[1] * (y + 1),
),
)
return out_img
class CenteredStrategy:
def make_background(self, img_file, desktop_size):
in_img = Image.open(img_file)
out_img = Image.new("RGB", desktop_size)
left = (out_img.size[0] - in_img.size[0]) // 2
top = (out_img.size[1] - in_img.size[1]) // 2
out_img.paste(
in_img,
(left, top, left + in_img.size[0], top + in_img.size[1]),
)
return out_img
class ScaledStrategy:
def make_background(self, img_file, desktop_size):
in_img = Image.open(img_file)
out_img = in_img.resize(desktop_size)
return out_img
在这里,我们有三种策略,每种策略都使用PIL来执行它们的任务。各个策略都有一个make_background方法,接受相同的参数集。一旦选择,就可以调用适当的策略来创建正确大小的桌面图像。TiledStrategy循环遍历可以适应图像宽度和高度的输入图像数量,并将其重复复制到每个位置。CenteredStrategy计算出需要在图像的四个边缘留下多少空间来使其居中。ScaledStrategy将图像强制缩放到输出大小(忽略纵横比)。
考虑一下,如果没有策略模式,如何在这些选项之间进行切换的实现。我们需要把所有的代码放在一个很大的方法中,并使用一个笨拙的if语句来选择预期的选项。每次我们想要添加一个新的策略,我们都必须使方法变得更加笨拙。
Python 中的策略
策略模式的前面的经典实现,在大多数面向对象的库中非常常见,但在 Python 编程中很少见。
这些类分别代表什么都不做,只提供一个函数的对象。我们可以轻松地将该函数称为__call__,并直接使对象可调用。由于对象没有与之关联的其他数据,我们只需要创建一组顶层函数并将它们作为我们的策略传递。
因此,设计模式哲学的反对者会说,因为 Python 具有一流函数,策略模式是不必要的。事实上,Python 的一流函数使我们能够以更直接的方式实现策略模式。知道这种模式的存在仍然可以帮助我们选择程序的正确设计,但是使用更可读的语法来实现它。当我们需要允许客户端代码或最终用户从相同接口的多个实现中进行选择时,应该使用策略模式或其顶层函数实现。
状态模式
状态模式在结构上类似于策略模式,但其意图和目的非常不同。状态模式的目标是表示状态转换系统:在这些系统中,很明显对象可以处于特定状态,并且某些活动可能会将其驱动到不同的状态。
为了使其工作,我们需要一个管理器或上下文类,提供切换状态的接口。在内部,这个类包含对当前状态的指针。每个状态都知道它被允许处于什么其他状态,并且将根据在其上调用的操作而转换到这些状态。
因此,我们有两种类型的类:上下文类和多个状态类。上下文类维护当前状态,并将操作转发给状态类。状态类通常对于调用上下文的任何其他对象都是隐藏的;它就像一个黑匣子,恰好在内部执行状态管理。在 UML 中的样子如下:

状态示例
为了说明状态模式,让我们构建一个 XML 解析工具。上下文类将是解析器本身。它将以字符串作为输入,并将工具置于初始解析状态。各种解析状态将吃掉字符,寻找特定的值,当找到该值时,转换到不同的状态。目标是为每个标签及其内容创建一个节点对象树。为了使事情更容易管理,我们只解析 XML 的一个子集 - 标签和标签名称。我们将无法处理标签上的属性。它将解析标签的文本内容,但不会尝试解析混合内容,其中包含文本内的标签。这是一个我们将能够解析的简化 XML文件的示例:
<book>
<author>Dusty Phillips</author>
<publisher>Packt Publishing</publisher>
<title>Python 3 Object Oriented Programming</title>
<content>
<chapter>
<number>1</number>
<title>Object Oriented Design</title>
</chapter>
<chapter>
<number>2</number>
<title>Objects In Python</title>
</chapter>
</content>
</book>
在我们查看状态和解析器之前,让我们考虑一下这个程序的输出。我们知道我们想要一个Node对象的树,但Node是什么样子呢?它显然需要知道它正在解析的标签的名称,而且由于它是一棵树,它可能应该保持对父节点的指针和按顺序列出节点的子节点的列表。有些节点有文本值,但不是所有节点都有。让我们首先看看这个Node类:
class Node:
def __init__(self, tag_name, parent=None):
self.parent = parent
self.tag_name = tag_name
self.children = []
self.text = ""
def __str__(self):
if self.text:
return self.tag_name + ": " + self.text
else:
return self.tag_name
这个类在初始化时设置默认属性值。提供__str__方法来帮助在完成时可视化树结构。
现在,看看示例文档,我们需要考虑我们的解析器可以处于哪些状态。显然,它将开始于尚未处理任何节点的状态。我们需要一个用于处理开放标签和关闭标签的状态。当我们在具有文本内容的标签内部时,我们还需要将其处理为单独的状态。
状态转换可能会很棘手;我们如何知道下一个节点是开放标签、关闭标签还是文本节点?我们可以在每个状态中放入一些逻辑来解决这个问题,但实际上创建一个唯一目的是确定下一个状态的新状态更有意义。如果我们将这个过渡状态称为ChildNode,我们最终得到以下状态:
-
FirstTag -
ChildNode -
OpenTag -
CloseTag -
Text
FirstTag状态将切换到ChildNode,它负责决定要切换到其他三个状态中的哪一个;当这些状态完成时,它们将切换回ChildNode。以下状态转换图显示了可用的状态变化:

状态负责获取字符串的剩余部分,处理尽可能多的内容,然后告诉解析器处理其余部分。让我们首先构建Parser类:
class Parser:
def __init__(self, parse_string):
self.parse_string = parse_string
self.root = None
self.current_node = None
self.state = FirstTag()
def process(self, remaining_string):
remaining = self.state.process(remaining_string, self)
if remaining:
self.process(remaining)
def start(self):
self.process(self.parse_string)
初始化程序在类上设置了一些变量,这些变量将由各个状态访问。parse_string实例变量是我们试图解析的文本。root节点是 XML 结构中的顶部节点。current_node实例变量是我们当前正在向其添加子节点的节点。
这个解析器的重要特性是process方法,它接受剩余的字符串,并将其传递给当前状态。解析器(self参数)也被传递到状态的process方法中,以便状态可以操作它。当状态完成处理时,预期状态将返回未解析字符串的剩余部分。然后解析器递归调用这个剩余字符串上的process方法来构造树的其余部分。
现在让我们来看一下FirstTag状态:
class FirstTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find("<")
i_end_tag = remaining_string.find(">")
tag_name = remaining_string[i_start_tag + 1 : i_end_tag]
root = Node(tag_name)
parser.root = parser.current_node = root
parser.state = ChildNode()
return remaining_string[i_end_tag + 1 :]
这个状态找到了第一个标签的开放和关闭尖括号的索引(i_代表索引)。您可能认为这个状态是多余的,因为 XML 要求在开放标签之前没有文本。然而,可能需要消耗空白字符;这就是为什么我们搜索开放尖括号而不是假设它是文档中的第一个字符。
请注意,此代码假定输入文件有效。一个正确的实现将严格测试无效输入,并尝试恢复或显示极具描述性的错误消息。
该方法提取标签的名称并将其分配给解析器的根节点。它还将其分配给current_node,因为那是我们接下来要添加子节点的节点。
然后是重要的部分:该方法将解析器对象上的当前状态更改为ChildNode状态。然后返回字符串的剩余部分(在开放标签之后)以允许其被处理。
看起来相当复杂的ChildNode状态实际上只需要一个简单的条件:
class ChildNode:
def process(self, remaining_string, parser):
stripped = remaining_string.strip()
if stripped.startswith("</"):
parser.state = CloseTag()
elif stripped.startswith("<"):
parser.state = OpenTag()
else:
parser.state = TextNode()
return stripped
strip()调用从字符串中删除空白。然后解析器确定下一个项是开放标签、关闭标签还是文本字符串。根据发生的可能性,它将解析器设置为特定状态,然后告诉它解析字符串的其余部分。
OpenTag状态类似于FirstTag状态,只是它将新创建的节点添加到先前的current_node对象的children中,并将其设置为新的current_node。然后继续将处理器放回ChildNode状态:
class OpenTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find("<")
i_end_tag = remaining_string.find(">")
tag_name = remaining_string[i_start_tag + 1 : i_end_tag]
node = Node(tag_name, parser.current_node)
parser.current_node.children.append(node)
parser.current_node = node
parser.state = ChildNode()
return remaining_string[i_end_tag + 1 :]
CloseTag状态基本上做相反的事情;它将解析器的current_node设置回父节点,以便在外部标签中添加任何进一步的子节点:
class CloseTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find("<")
i_end_tag = remaining_string.find(">")
assert remaining_string[i_start_tag + 1] == "/"
tag_name = remaining_string[i_start_tag + 2 : i_end_tag]
assert tag_name == parser.current_node.tag_name
parser.current_node = parser.current_node.parent
parser.state = ChildNode()
return remaining_string[i_end_tag + 1 :].strip()
两个assert语句有助于确保解析字符串是一致的。
最后,TextNode状态非常简单地提取下一个关闭标签之前的文本,并将其设置为当前节点的值:
class TextNode:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
text = remaining_string[:i_start_tag]
parser.current_node.text = text
parser.state = ChildNode()
return remaining_string[i_start_tag:]
现在我们只需要在创建的解析器对象上设置初始状态。初始状态是一个FirstTag对象,所以只需将以下内容添加到__init__方法中:
self.state = FirstTag()
为了测试这个类,让我们添加一个主脚本,从命令行打开一个文件,解析它,并打印节点:
if __name__ == "__main__":
import sys
with open(sys.argv[1]) as file:
contents = file.read()
p = Parser(contents)
p.start()
nodes = [p.root]
while nodes:
node = nodes.pop(0)
print(node)
nodes = node.children + nodes
这段代码打开文件,加载内容,并解析结果。然后按顺序打印每个节点及其子节点。我们最初在node类上添加的__str__方法负责格式化节点以供打印。如果我们在之前的示例上运行脚本,它将输出树如下:
book
author: Dusty Phillips
publisher: Packt Publishing
title: Python 3 Object Oriented Programming
content
chapter
number: 1
title: Object Oriented Design
chapter
number: 2
title: Objects In Python
将这与原始简化的 XML 文档进行比较告诉我们解析器正在工作。
状态与策略
状态模式看起来与策略模式非常相似;实际上,两者的 UML 图是相同的。实现也是相同的。我们甚至可以将我们的状态编写为一等函数,而不是将它们包装在对象中,就像为策略建议的那样。
虽然这两种模式具有相同的结构,但它们解决完全不同的问题。策略模式用于在运行时选择算法;通常,只有一个算法会被选择用于特定用例。另一方面,状态模式旨在允许在某个过程发展时动态地在不同状态之间切换。在代码中,主要区别在于策略模式通常不知道其他策略对象。在状态模式中,状态或上下文需要知道它可以切换到哪些其他状态。
状态转换作为协程
状态模式是解决状态转换问题的经典面向对象解决方案。然而,您可以通过将对象构建为协程来获得类似的效果。还记得我们在第二十一章中构建的正则表达式日志文件解析器吗?那实际上是一个伪装的状态转换问题。该实现与定义状态模式中使用的所有对象(或函数)的实现之间的主要区别在于,协程解决方案允许我们在语言构造中编码更多的样板。有两种实现,但没有一种本质上比另一种更好。状态模式实际上是我考虑在asyncio之外使用协程的唯一场合。
单例模式
单例模式是最具争议的模式之一;许多人指责它是一种反模式,一种应该避免而不是推广的模式。在 Python 中,如果有人使用单例模式,他们几乎肯定是在做错事情,可能是因为他们来自一个更严格的编程语言。
那么,为什么要讨论它呢?单例是所有设计模式中最著名的之一。它在过度面向对象的语言中很有用,并且是传统面向对象编程的重要部分。更相关的是,单例背后的想法是有用的,即使我们在 Python 中以完全不同的方式实现了这个概念。
单例模式背后的基本思想是允许某个对象的确切实例只存在一个。通常,这个对象是一种类似于我们在第十九章中讨论的管理类。这些对象通常需要被各种其他对象引用,并且将对管理对象的引用传递给需要它们的方法和构造函数可能会使代码难以阅读。
相反,当使用单例时,独立的对象从类中请求管理对象的单个实例,因此无需传递对它的引用。UML 图表并没有完全描述它,但为了完整起见,这里是它:

在大多数编程环境中,通过使构造函数私有(这样就没有人可以创建它的其他实例),然后提供一个静态方法来检索单个实例来强制实施单例。这个方法在第一次调用时创建一个新实例,然后对所有后续调用返回相同的实例。
单例实现
Python 没有私有构造函数,但为了这个目的,我们可以使用__new__类方法来确保只创建一个实例:
class OneOnly:
_singleton = None
def __new__(cls, *args, **kwargs):
if not cls._singleton:
cls._singleton = super(OneOnly, cls
).__new__(cls, *args, **kwargs)
return cls._singleton
当调用__new__时,通常会构造该类的一个新实例。当我们重写它时,我们首先检查我们的单例实例是否已经创建;如果没有,我们使用super调用来创建它。因此,每当我们在OneOnly上调用构造函数时,我们总是得到完全相同的实例:
>>> o1 = OneOnly()
>>> o2 = OneOnly()
>>> o1 == o2
True
>>> o1
<__main__.OneOnly object at 0xb71c008c>
>>> o2
<__main__.OneOnly object at 0xb71c008c>
这两个对象是相等的,并且位于相同的地址;因此,它们是同一个对象。这个特定的实现并不是很透明,因为很难看出一个单例对象已经被创建。每当我们调用一个构造函数,我们期望得到该对象的一个新实例;在这种情况下,这个约定被违反了。也许,如果我们真的认为需要一个单例,类的良好文档字符串可以缓解这个问题。
但我们并不需要它。Python 程序员不喜欢强迫他们的代码用户进入特定的思维方式。我们可能认为一个类只需要一个实例,但其他程序员可能有不同的想法。单例可能会干扰分布式计算、并行编程和自动化测试,例如。在所有这些情况下,拥有特定对象的多个或替代实例可能非常有用,即使正常操作可能永远不需要一个。
模块变量可以模仿单例
通常,在 Python 中,可以使用模块级变量来充分模拟单例模式。它不像单例那样安全,因为人们随时可以重新分配这些变量,但就像我们在第十六章中讨论的私有变量一样,在 Python 中这是可以接受的。如果有人有充分的理由更改这些变量,我们为什么要阻止他们呢?它也不会阻止人们实例化对象的多个实例,但同样,如果他们有充分的理由这样做,为什么要干涉呢?
理想情况下,我们应该给它们一个机制来访问默认的单例值,同时也允许它们在需要时创建其他实例。虽然从技术上讲根本不是单例,但它提供了最符合 Python 风格的单例行为机制。
为了使用模块级变量而不是单例,我们在定义类之后实例化类的实例。我们可以改进我们的状态模式以使用单例。我们可以创建一个始终可访问的模块级变量,而不是在每次更改状态时创建一个新对象:
class Node:
def __init__(self, tag_name, parent=None):
self.parent = parent
self.tag_name = tag_name
self.children = []
self.text = ""
def __str__(self):
if self.text:
return self.tag_name + ": " + self.text
else:
return self.tag_name
class FirstTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find("<")
i_end_tag = remaining_string.find(">")
tag_name = remaining_string[i_start_tag + 1 : i_end_tag]
root = Node(tag_name)
parser.root = parser.current_node = root
parser.state = child_node
return remaining_string[i_end_tag + 1 :]
class ChildNode:
def process(self, remaining_string, parser):
stripped = remaining_string.strip()
if stripped.startswith("</"):
parser.state = close_tag
elif stripped.startswith("<"):
parser.state = open_tag
else:
parser.state = text_node
return stripped
class OpenTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find("<")
i_end_tag = remaining_string.find(">")
tag_name = remaining_string[i_start_tag + 1 : i_end_tag]
node = Node(tag_name, parser.current_node)
parser.current_node.children.append(node)
parser.current_node = node
parser.state = child_node
return remaining_string[i_end_tag + 1 :]
class TextNode:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find("<")
text = remaining_string[:i_start_tag]
parser.current_node.text = text
parser.state = child_node
return remaining_string[i_start_tag:]
class CloseTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find("<")
i_end_tag = remaining_string.find(">")
assert remaining_string[i_start_tag + 1] == "/"
tag_name = remaining_string[i_start_tag + 2 : i_end_tag]
assert tag_name == parser.current_node.tag_name
parser.current_node = parser.current_node.parent
parser.state = child_node
return remaining_string[i_end_tag + 1 :].strip()
first_tag = FirstTag()
child_node = ChildNode()
text_node = TextNode()
open_tag = OpenTag()
close_tag = CloseTag()
我们所做的只是创建可以重用的各种状态类的实例。请注意,即使在变量被定义之前,我们也可以在类内部访问这些模块变量?这是因为类内部的代码直到调用方法时才会执行,而到这个时候,整个模块都已经被定义了。
在这个例子中的不同之处在于,我们不是浪费内存创建一堆必须进行垃圾回收的新实例,而是为每个状态重用一个单一的状态对象。即使多个解析器同时运行,只需要使用这些状态类。
当我们最初创建基于状态的解析器时,您可能会想知道为什么我们没有将解析器对象传递给每个单独状态的__init__,而是像我们所做的那样将其传递给process方法。然后状态可以被引用为self.parser。这是状态模式的一个完全有效的实现,但它将不允许利用单例模式。如果状态对象保持对解析器的引用,那么它们就不能同时用于引用其他解析器。
请记住,这是两种不同目的的模式;单例模式的目的可能对实现状态模式有用,但这并不意味着这两种模式有关联。
模板模式
模板模式对于消除重复代码非常有用;它旨在支持我们在第十九章中讨论的“不要重复自己”的原则,何时使用面向对象编程。它设计用于我们需要完成几个不同任务,这些任务有一些但不是全部步骤相同的情况。共同的步骤在基类中实现,不同的步骤在子类中被覆盖以提供自定义行为。在某些方面,它类似于一般化的策略模式,只是使用基类共享算法的相似部分。以下是它的 UML 格式:

一个模板示例
让我们以创建一个汽车销售报告为例。我们可以在 SQLite 数据库表中存储销售记录。SQLite 是一个简单的基于文件的数据库引擎,允许我们使用 SQL 语法存储记录。Python 在其标准库中包含了 SQLite,因此不需要额外的模块。
我们有两个需要执行的共同任务:
-
选择所有新车销售并以逗号分隔的格式输出到屏幕
-
输出一个逗号分隔的所有销售人员及其总销售额的列表,并将其保存到可以导入电子表格的文件中
这些看起来是非常不同的任务,但它们有一些共同的特征。在这两种情况下,我们都需要执行以下步骤:
-
连接到数据库。
-
构造一个新车或总销售的查询。
-
发出查询。
-
将结果格式化为逗号分隔的字符串。
-
将数据输出到文件或电子邮件。
查询构造和输出步骤对于这两个任务是不同的,但其余步骤是相同的。我们可以使用模板模式将共同的步骤放在一个基类中,将不同的步骤放在两个子类中。
在开始之前,让我们创建一个数据库并放入一些示例数据,使用几行 SQL:
import sqlite3
conn = sqlite3.connect("sales.db")
conn.execute(
"CREATE TABLE Sales (salesperson text, "
"amt currency, year integer, model text, new boolean)"
)
conn.execute(
"INSERT INTO Sales values"
" ('Tim', 16000, 2010, 'Honda Fit', 'true')"
)
conn.execute(
"INSERT INTO Sales values"
" ('Tim', 9000, 2006, 'Ford Focus', 'false')"
)
conn.execute(
"INSERT INTO Sales values"
" ('Gayle', 8000, 2004, 'Dodge Neon', 'false')"
)
conn.execute(
"INSERT INTO Sales values"
" ('Gayle', 28000, 2009, 'Ford Mustang', 'true')"
)
conn.execute(
"INSERT INTO Sales values"
" ('Gayle', 50000, 2010, 'Lincoln Navigator', 'true')"
)
conn.execute(
"INSERT INTO Sales values"
" ('Don', 20000, 2008, 'Toyota Prius', 'false')"
)
conn.commit()
conn.close()
希望您能看出这里发生了什么,即使您不懂 SQL;我们创建了一个用于保存数据的表,并使用了六个insert语句来添加销售记录。数据存储在名为sales.db的文件中。现在我们有一个示例可以用来开发我们的模板模式。
既然我们已经概述了模板必须执行的步骤,我们可以开始定义包含这些步骤的基类。每个步骤都有自己的方法(这样可以轻松地选择性地覆盖任何一个步骤),而且我们还有一个管理方法依次调用这些步骤。没有任何方法内容的话,它可能会是这样的:
class QueryTemplate:
def connect(self):
pass
def construct_query(self):
pass
def do_query(self):
pass
def format_results(self):
pass
def output_results(self):
pass
def process_format(self):
self.connect()
self.construct_query()
self.do_query()
self.format_results()
self.output_results()
process_format方法是外部客户端调用的主要方法。它确保每个步骤按顺序执行,但它不关心该步骤是在这个类中实现的还是在子类中实现的。对于我们的示例,我们知道两个类之间会有三个方法是相同的:
import sqlite3
class QueryTemplate:
def connect(self):
self.conn = sqlite3.connect("sales.db")
def construct_query(self):
raise NotImplementedError()
def do_query(self):
results = self.conn.execute(self.query)
self.results = results.fetchall()
def format_results(self):
output = []
for row in self.results:
row = [str(i) for i in row]
output.append(", ".join(row))
self.formatted_results = "\n".join(output)
def output_results(self):
raise NotImplementedError()
为了帮助实现子类,两个未指定的方法会引发NotImplementedError。这是在 Python 中指定抽象接口的常见方式,当抽象基类看起来太重量级时。这些方法可以有空实现(使用pass),或者可以完全未指定。然而,引发NotImplementedError有助于程序员理解该类是用于派生子类和覆盖这些方法的。空方法或不存在的方法更难以识别需要实现和调试,如果我们忘记实现它们。
现在我们有一个模板类,它处理了繁琐的细节,但足够灵活,可以执行和格式化各种查询。最好的部分是,如果我们想要将数据库引擎从 SQLite 更改为另一个数据库引擎(比如py-postgresql),我们只需要在这个模板类中进行修改,而不需要触及我们可能编写的两个(或两百个)子类。
现在让我们来看看具体的类:
import datetime
class NewVehiclesQuery(QueryTemplate):
def construct_query(self):
self.query = "select * from Sales where new='true'"
def output_results(self):
print(self.formatted_results)
class UserGrossQuery(QueryTemplate):
def construct_query(self):
self.query = (
"select salesperson, sum(amt) "
+ " from Sales group by salesperson"
)
def output_results(self):
filename = "gross_sales_{0}".format(
datetime.date.today().strftime("%Y%m%d")
)
with open(filename, "w") as outfile:
outfile.write(self.formatted_results)
这两个类实际上相当简短,考虑到它们的功能:连接到数据库,执行查询,格式化结果并输出。超类处理了重复的工作,但让我们可以轻松指定在任务之间变化的步骤。此外,我们还可以轻松地更改在基类中提供的步骤。例如,如果我们想要输出除逗号分隔字符串之外的其他内容(例如:要上传到网站的 HTML 报告),我们仍然可以覆盖format_results。
练习
在撰写本章的示例时,我发现想出应该使用特定设计模式的好例子可能非常困难,但也非常有教育意义。与其去审查当前或旧项目,看看你可以在哪里应用这些模式,正如我在之前的章节中建议的那样,不如考虑这些模式以及可能出现这些模式的不同情况。试着超越你自己的经验。如果你当前的项目是银行业务,考虑一下在零售或销售点应用这些设计模式。如果你通常编写 Web 应用程序,考虑在编写编译器时使用设计模式。
看看装饰器模式,并想出一些适用它的好例子。专注于模式本身,而不是我们讨论的 Python 语法。它比实际模式要更一般一些。然而,装饰器的特殊语法是你可能想要寻找现有项目中适用的地方。
有哪些适合使用观察者模式的领域?为什么?不仅考虑如何应用模式,还要考虑如何在不使用观察者的情况下实现相同的任务?选择使用它会得到什么,或者失去什么?
考虑策略模式和状态模式之间的区别。在实现上,它们看起来非常相似,但它们有不同的目的。你能想到可以互换使用这些模式的情况吗?重新设计一个基于状态的系统以使用策略,或者反之,是否合理?设计实际上会有多大的不同?
模板模式是继承的一个明显应用,可以减少重复的代码,你可能以前就使用过它,只是不知道它的名字。试着想出至少半打不同的场景,它在哪些情况下会有用。如果你能做到这一点,你将会在日常编码中经常找到它的用武之地。
总结
本章详细讨论了几种常见的设计模式,包括示例、UML 图表,以及 Python 和静态类型面向对象语言之间的差异讨论。装饰器模式通常使用 Python 的更通用的装饰器语法来实现。观察者模式是一种有用的方式,可以将事件与对这些事件采取的行动分离。策略模式允许选择不同的算法来完成相同的任务。状态模式看起来类似,但实际上是用来表示系统可以使用明确定义的操作在不同状态之间移动。单例模式在一些静态类型的语言中很受欢迎,但在 Python 中几乎总是反模式。
在下一章中,我们将结束对设计模式的讨论。
第二十三章:Python 设计模式 II
在本章中,我们将介绍更多的设计模式。我们将再次介绍经典示例以及 Python 中常见的替代实现。我们将讨论以下内容:
-
适配器模式
-
外观模式
-
延迟初始化和享元模式
-
命令模式
-
抽象工厂模式
-
组合模式
适配器模式
与我们在上一章中审查的大多数模式不同,适配器模式旨在与现有代码交互。我们不会设计一组全新的实现适配器模式的对象。适配器用于允许两个现有对象一起工作,即使它们的接口不兼容。就像显示适配器允许您将 Micro USB 充电线插入 USB-C 手机一样,适配器对象位于两个不同接口之间,在其间进行实时翻译。适配器对象的唯一目的是执行这种翻译。适配可能涉及各种任务,例如将参数转换为不同格式,重新排列参数的顺序,调用不同命名的方法或提供默认参数。
在结构上,适配器模式类似于简化的装饰器模式。装饰器通常提供与其替代物相同的接口,而适配器在两个不同的接口之间进行映射。这在以下图表中以 UML 形式表示:

在这里,Interface1期望调用一个名为make_action(some, arguments)的方法。我们已经有了完美的Interface2类,它做了我们想要的一切(为了避免重复,我们不想重写它!),但它提供的方法名为different_action(other, arguments)。Adapter类实现了make_action接口,并将参数映射到现有接口。
这里的优势在于,从一个接口映射到另一个接口的代码都在一个地方。另一种选择将会非常丑陋;每当我们需要访问这段代码时,我们都必须在多个地方执行翻译。
例如,假设我们有以下现有类,它接受格式为YYYY-MM-DD的字符串日期并计算该日期时的人的年龄:
class AgeCalculator:
def __init__(self, birthday):
self.year, self.month, self.day = (
int(x) for x in birthday.split("-")
)
def calculate_age(self, date):
year, month, day = (int(x) for x in date.split("-"))
age = year - self.year
if (month, day) < (self.month, self.day):
age -= 1
return age
这是一个非常简单的类,它完成了它应该完成的工作。但我们不得不思考程序员当时在想什么,为什么要使用特定格式的字符串,而不是使用 Python 中非常有用的内置datetime库。作为尽可能重用代码的负责任的程序员,我们编写的大多数程序将与datetime对象交互,而不是字符串。
我们有几种选择来解决这种情况。我们可以重写类以接受datetime对象,这可能更准确。但如果这个类是由第三方提供的,我们不知道如何或不能改变它的内部结构,我们需要另一种选择。我们可以使用原样的类,每当我们想要计算datetime.date对象上的年龄时,我们可以调用datetime.date.strftime('%Y-%m-%d')将其转换为正确的格式。但这种转换会发生在很多地方,更糟糕的是,如果我们将%m误写为%M,它会给我们当前的分钟而不是输入的月份。想象一下,如果您在十几个不同的地方写了这个,然后当您意识到错误时不得不返回并更改它。这不是可维护的代码,它违反了 DRY 原则。
相反,我们可以编写一个适配器,允许将普通日期插入普通的AgeCalculator类,如下面的代码所示:
import datetime
class DateAgeAdapter:
def _str_date(self, date):
return date.strftime("%Y-%m-%d")
def __init__(self, birthday):
birthday = self._str_date(birthday)
self.calculator = AgeCalculator(birthday)
def get_age(self, date):
date = self._str_date(date)
return self.calculator.calculate_age(date)
这个适配器将datetime.date和datetime.time(它们具有相同的接口到strftime)转换为一个字符串,以便我们原始的AgeCalculator可以使用。现在我们可以使用原始代码与我们的新接口。我将方法签名更改为get_age,以演示调用接口可能也在寻找不同的方法名称,而不仅仅是不同类型的参数。
创建一个类作为适配器是实现这种模式的常规方法,但是,通常情况下,在 Python 中还有其他方法可以实现。继承和多重继承可以用于向类添加功能。例如,我们可以在date类上添加一个适配器,以便它与原始的AgeCalculator类一起使用,如下所示:
import datetime
class AgeableDate(datetime.date):
def split(self, char):
return self.year, self.month, self.day
像这样的代码让人怀疑 Python 是否应该合法。我们已经为我们的子类添加了一个split方法,它接受一个参数(我们忽略),并返回一个年、月和日的元组。这与原始的AgeCalculator类完美配合,因为代码在一个特殊格式的字符串上调用strip,而在这种情况下,strip返回一个年、月和日的元组。AgeCalculator代码只关心strip是否存在并返回可接受的值;它并不关心我们是否真的传入了一个字符串。以下代码确实有效:
>>> bd = AgeableDate(1975, 6, 14)
>>> today = AgeableDate.today()
>>> today
AgeableDate(2015, 8, 4)
>>> a = AgeCalculator(bd)
>>> a.calculate_age(today)
40
它有效,但这是一个愚蠢的想法。在这种特定情况下,这样的适配器将很难维护。我们很快会忘记为什么需要向date类添加一个strip方法。方法名称是模糊的。这可能是适配器的性质,但是显式创建一个适配器而不是使用继承通常可以澄清其目的。
除了继承,有时我们还可以使用猴子补丁来向现有类添加方法。它不适用于datetime对象,因为它不允许在运行时添加属性。然而,在普通类中,我们可以添加一个新方法,提供调用代码所需的适配接口。或者,我们可以扩展或猴子补丁AgeCalculator本身,以用更符合我们需求的东西替换calculate_age方法。
最后,通常可以将函数用作适配器;这显然不符合适配器模式的实际设计,但是如果我们记得函数本质上是具有__call__方法的对象,那么它就成为一个明显的适配器适应。
外观模式
外观模式旨在为复杂的组件系统提供一个简单的接口。对于复杂的任务,我们可能需要直接与这些对象交互,但通常对于系统的典型使用,这些复杂的交互并不是必要的。外观模式允许我们定义一个新对象,封装系统的典型使用。每当我们想要访问常见功能时,我们可以使用单个对象的简化接口。如果项目的另一部分需要访问更复杂的功能,它仍然可以直接与系统交互。外观模式的 UML 图表实际上取决于子系统,但在模糊的方式下,它看起来像这样:

外观在许多方面类似于适配器。主要区别在于,外观试图从复杂的接口中抽象出一个简单的接口,而适配器只试图将一个现有的接口映射到另一个接口。
让我们为一个电子邮件应用程序编写一个简单的外观。Python 中用于发送电子邮件的低级库,正如我们在第二十章中看到的那样,Python 面向对象的快捷方式,非常复杂。用于接收消息的两个库甚至更糟。
有一个简单的类可以让我们发送单封电子邮件,并列出当前在 IMAP 或 POP3 连接中收件箱中的电子邮件,这将是很好的。为了让我们的例子简短,我们将坚持使用 IMAP 和 SMTP:两个完全不同的子系统,碰巧处理电子邮件。我们的外观只执行两个任务:向特定地址发送电子邮件,并在 IMAP 连接上检查收件箱。它对连接做了一些常见的假设,比如 SMTP 和 IMAP 的主机位于同一个地址,用户名和密码相同,并且它们使用标准端口。这涵盖了许多电子邮件服务器的情况,但如果程序员需要更灵活性,他们总是可以绕过外观直接访问这两个子系统。
该类使用电子邮件服务器的主机名、用户名和密码进行初始化:
import smtplib
import imaplib
class EmailFacade:
def __init__(self, host, username, password):
self.host = host
self.username = username
self.password = password
send_email方法格式化电子邮件地址和消息,并使用smtplib发送。这不是一个复杂的任务,但需要相当多的调整来将传递到外观的自然输入参数正确格式化,以使smtplib能够发送消息,如下所示:
def send_email(self, to_email, subject, message):
if not "@" in self.username:
from_email = "{0}@{1}".format(self.username, self.host)
else:
from_email = self.username
message = (
"From: {0}\r\n" "To: {1}\r\n" "Subject: {2}\r\n\r\n{3}"
).format(from_email, to_email, subject, message)
smtp = smtplib.SMTP(self.host)
smtp.login(self.username, self.password)
smtp.sendmail(from_email, [to_email], message)
方法开头的if语句捕获了username是否是整个from电子邮件地址,或者只是@符号左边的部分;不同的主机对登录详细信息的处理方式不同。
最后,获取当前收件箱中的消息的代码是一团糟。IMAP 协议过度设计,imaplib标准库只是协议的薄层。但我们可以简化它,如下所示:
def get_inbox(self):
mailbox = imaplib.IMAP4(self.host)
mailbox.login(
bytes(self.username, "utf8"), bytes(self.password, "utf8")
)
mailbox.select()
x, data = mailbox.search(None, "ALL")
messages = []
for num in data[0].split():
x, message = mailbox.fetch(num, "(RFC822)")
messages.append(message[0][1])
return messages
现在,如果我们把所有这些加在一起,我们就有了一个简单的外观类,可以以相当直接的方式发送和接收消息;比起直接与这些复杂的库进行交互,要简单得多。
虽然在 Python 社区很少提到它的名字,但外观模式是 Python 生态系统的一个组成部分。因为 Python 强调语言的可读性,语言及其库往往提供了易于理解的接口来处理复杂的任务。例如,for循环,list推导和生成器都是更复杂的迭代器协议的外观。defaultdict实现是一个外观,它在字典中键不存在时抽象出烦人的边缘情况。第三方的requests库是一个强大的外观,可以使 HTTP 请求的库更易读,它们本身是管理基于文本的 HTTP 协议的外观。
轻量级模式
轻量级模式是一种内存优化模式。新手 Python 程序员往往忽视内存优化,认为内置的垃圾收集器会处理它们。这通常是完全可以接受的,但是在开发具有许多相关对象的较大应用程序时,关注内存问题可能会有巨大的回报。
轻量级模式确保共享状态的对象可以使用相同的内存来存储该共享状态。通常只有在程序显示出内存问题后才会实现它。在某些情况下,从一开始设计一个最佳配置是有意义的,但请记住,过早优化是创建一个过于复杂以至于无法维护的程序的最有效方式。
让我们看一下轻量级模式的以下 UML 图表:

每个享元都没有特定的状态。每当它需要对具体状态执行操作时,该状态都需要被调用代码传递给享元。传统上,返回享元的工厂是一个单独的对象;它的目的是为了根据标识该享元的给定键返回一个享元。它的工作原理类似于我们在第二十二章中讨论的单例模式,Python 设计模式 I;如果享元存在,我们就返回它;否则,我们创建一个新的。在许多语言中,工厂被实现为Flyweight类本身上的静态方法,而不是作为一个单独的对象。
想象一下汽车销售的库存系统。每辆汽车都有特定的序列号和特定的颜色。但是对于特定模型的所有汽车来说,大部分关于汽车的细节都是相同的。例如,本田 Fit DX 型号是一辆几乎没有功能的汽车。LX 型号有空调、倾斜、巡航和电动窗户和锁。Sport 型号有时尚的轮毂、USB 充电器和扰流板。如果没有享元模式,每个单独的汽车对象都必须存储一个长长的列表,其中包含它具有或不具有的功能。考虑到本田一年销售的汽车数量,这将导致大量的内存浪费。
使用享元模式,我们可以为与模型相关的功能列表共享对象,然后只需为单个车辆引用该模型,以及序列号和颜色。在 Python 中,享元工厂通常使用那个奇怪的__new__构造函数来实现,类似于我们在单例模式中所做的。
与只需要返回类的一个实例的单例模式不同,我们需要能够根据键返回不同的实例。我们可以将项目存储在字典中,并根据键查找它们。然而,这种解决方案存在问题,因为只要项目在字典中,它就会一直保留在内存中。如果我们卖完了 LX 型号的 Fit,那么 Fit 享元将不再需要,但它仍然会留在字典中。我们可以在卖车时清理这些内容,但这不是垃圾收集器的作用吗?
我们可以利用 Python 的weakref模块来解决这个问题。该模块提供了一个WeakValueDictionary对象,基本上允许我们在字典中存储项目,而垃圾收集器不会关心它们。如果一个值在一个弱引用字典中,并且在应用程序的任何其他地方都没有对该对象的其他引用(也就是说,我们已经卖完了 LX 型号),垃圾收集器最终会为我们清理掉它。
首先让我们构建我们汽车享元的工厂,如下所示:
import weakref
class CarModel:
_models = weakref.WeakValueDictionary()
def __new__(cls, model_name, *args, **kwargs):
model = cls._models.get(model_name)
if not model:
model = super().__new__(cls)
cls._models[model_name] = model
return model
基本上,每当我们使用给定名称构造一个新的享元时,我们首先在弱引用字典中查找该名称;如果存在,我们就返回该模型;如果不存在,我们就创建一个新的。无论哪种方式,我们都知道__init__方法在每次都会被调用,无论它是一个新的还是现有的对象。因此,我们的__init__方法可以看起来像以下代码片段:
def __init__(
self,
model_name,
air=False,
tilt=False,
cruise_control=False,
power_locks=False,
alloy_wheels=False,
usb_charger=False,
):
if not hasattr(self, "initted"):
self.model_name = model_name
self.air = air
self.tilt = tilt
self.cruise_control = cruise_control
self.power_locks = power_locks
self.alloy_wheels = alloy_wheels
self.usb_charger = usb_charger
self.initted = True
if语句确保我们只在第一次调用__init__时初始化对象。这意味着我们以后可以只用模型名称调用工厂,并得到相同的享元对象。然而,如果享元没有外部引用存在,它将被垃圾收集,我们必须小心不要意外地创建一个具有空值的新享元。
让我们为我们的享元添加一个假设的方法,该方法查找特定车型的车辆上的序列号,并确定它是否曾经参与过任何事故。这个方法需要访问汽车的序列号,这个序列号因车而异;它不能与享元一起存储。因此,这些数据必须由调用代码传递给方法,如下所示:
def check_serial(self, serial_number):
print(
"Sorry, we are unable to check "
"the serial number {0} on the {1} "
"at this time".format(serial_number, self.model_name)
)
我们可以定义一个类,该类存储附加信息,以及对 flyweight 的引用,如下所示:
class Car:
def __init__(self, model, color, serial):
self.model = model
self.color = color
self.serial = serial
def check_serial(self):
return self.model.check_serial(self.serial)
我们还可以跟踪可用的模型,以及停车场上的各个汽车,如下所示:
>>> dx = CarModel("FIT DX")
>>> lx = CarModel("FIT LX", air=True, cruise_control=True,
... power_locks=True, tilt=True)
>>> car1 = Car(dx, "blue", "12345")
>>> car2 = Car(dx, "black", "12346")
>>> car3 = Car(lx, "red", "12347")
现在,让我们在以下代码片段中演示弱引用的工作:
>>> id(lx)
3071620300
>>> del lx
>>> del car3
>>> import gc
>>> gc.collect()
0
>>> lx = CarModel("FIT LX", air=True, cruise_control=True,
... power_locks=True, tilt=True)
>>> id(lx)
3071576140
>>> lx = CarModel("FIT LX")
>>> id(lx)
3071576140
>>> lx.air
True
id函数告诉我们对象的唯一标识符。当我们在删除对 LX 型号的所有引用并强制进行垃圾回收后第二次调用它,我们发现 ID 已经改变。CarModel __new__工厂字典中的值被删除,然后创建了一个新的值。然后,如果我们尝试构建第二个CarModel实例,它会返回相同的对象(ID 相同),即使在第二次调用中没有提供任何参数,air变量仍然设置为True。这意味着对象第二次没有被初始化,就像我们设计的那样。
显然,使用 flyweight 模式比只在单个汽车类上存储特性更复杂。我们应该在什么时候选择使用它?flyweight 模式旨在节省内存;如果我们有成千上万个相似的对象,将相似的属性合并到 flyweight 中对内存消耗会产生巨大影响。
对于优化 CPU、内存或磁盘空间的编程解决方案来说,通常会导致比未经优化的代码更复杂。因此,在决定代码可维护性和优化之间的权衡时,权衡是很重要的。在选择优化时,尝试使用 flyweight 等模式,以确保优化引入的复杂性局限于代码的一个(有良好文档的)部分。
如果一个程序中有很多 Python 对象,通过使用__slots__是节省内存的最快方法之一。__slots__魔术方法超出了本书的范围,但是如果您查看在线信息,会有很多信息可用。如果内存仍然不足,flyweight 可能是一个合理的解决方案。
命令模式
命令模式在必须执行的操作和调用这些操作的对象之间增加了一个抽象级别,通常是在以后的某个时间。在命令模式中,客户端代码创建一个可以在以后执行的Command对象。这个对象知道一个接收者对象,在命令在其上执行时管理自己的内部状态。Command对象实现了一个特定的接口(通常有一个execute或do_action方法,并且还跟踪执行操作所需的任何参数。最后,一个或多个Invoker对象在正确的时间执行命令。
这是 UML 图:

命令模式的一个常见示例是对图形窗口的操作。通常,操作可以通过菜单栏上的菜单项、键盘快捷键、工具栏图标或上下文菜单来调用。这些都是Invoker对象的示例。实际发生的操作,如Exit、Save或Copy,是CommandInterface的实现。接收退出的 GUI 窗口,接收保存的文档,接收复制命令的ClipboardManager,都是可能的Receivers的示例。
让我们实现一个简单的命令模式,为Save和Exit操作提供命令。我们将从一些适度的接收者类开始,它们本身具有以下代码:
import sys
class Window:
def exit(self):
sys.exit(0)
class Document:
def __init__(self, filename):
self.filename = filename
self.contents = "This file cannot be modified"
def save(self):
with open(self.filename, 'w') as file:
file.write(self.contents)
这些模拟类模拟了在工作环境中可能会做更多工作的对象。窗口需要处理鼠标移动和键盘事件,文档需要处理字符插入、删除和选择。但是对于我们的示例,这两个类将做我们需要的事情。
现在让我们定义一些调用者类。这些将模拟可能发生的工具栏、菜单和键盘事件;同样,它们实际上并没有连接到任何东西,但我们可以看到它们如何与命令、接收者和客户端代码解耦在以下代码片段中:
class ToolbarButton:
def __init__(self, name, iconname):
self.name = name
self.iconname = iconname
def click(self):
self.command.execute()
class MenuItem:
def __init__(self, menu_name, menuitem_name):
self.menu = menu_name
self.item = menuitem_name
def click(self):
self.command.execute()
class KeyboardShortcut:
def __init__(self, key, modifier):
self.key = key
self.modifier = modifier
def keypress(self):
self.command.execute()
注意各种操作方法如何调用其各自命令的execute方法?这段代码没有显示command属性被设置在每个对象上。它们可以传递到__init__函数中,但因为它们可能会被更改(例如,使用可自定义的键绑定编辑器),所以更合理的是在对象之后设置属性。
现在,让我们使用以下代码连接命令本身:
class SaveCommand:
def __init__(self, document):
self.document = document
def execute(self):
self.document.save()
class ExitCommand:
def __init__(self, window):
self.window = window
def execute(self):
self.window.exit()
这些命令很简单;它们演示了基本模式,但重要的是要注意,如果必要,我们可以存储状态和其他信息。例如,如果我们有一个插入字符的命令,我们可以维护当前正在插入的字符的状态。
现在我们所要做的就是连接一些客户端和测试代码,使命令生效。对于基本测试,我们只需在脚本的末尾包含以下代码:
window = Window()
document = Document("a_document.txt")
save = SaveCommand(document)
exit = ExitCommand(window)
save_button = ToolbarButton('save', 'save.png')
save_button.command = save
save_keystroke = KeyboardShortcut("s", "ctrl")
save_keystroke.command = save
exit_menu = MenuItem("File", "Exit")
exit_menu.command = exit
首先,我们创建两个接收者和两个命令。然后,我们创建几个可用的调用者,并在每个调用者上设置正确的命令。为了测试,我们可以使用python3 -i filename.py并运行诸如exit_menu.click()的代码,这将结束程序,或者save_keystroke.keystroke(),这将保存虚拟文件。
不幸的是,前面的例子并不像 Python。它们有很多“样板代码”(不完成任何任务,只提供模式结构),而且Command类彼此之间都非常相似。也许我们可以创建一个通用的命令对象,以函数作为回调?
事实上,为什么要麻烦呢?我们可以为每个命令使用函数或方法对象吗?我们可以编写一个函数,直接将其用作命令,而不是具有execute()方法的对象。以下是 Python 中命令模式的常见范例:
import sys
class Window:
def exit(self):
sys.exit(0)
class MenuItem:
def click(self):
self.command()
window = Window()
menu_item = MenuItem()
menu_item.command = window.exit
现在看起来更像 Python 了。乍一看,它看起来好像我们完全删除了命令模式,并且紧密连接了menu_item和Window类。但是如果我们仔细观察,我们会发现根本没有紧密耦合。任何可调用对象都可以设置为MenuItem上的命令,就像以前一样。而Window.exit方法可以附加到任何调用者上。命令模式的大部分灵活性都得到了保留。我们为可读性牺牲了完全解耦,但在我看来,以及许多 Python 程序员看来,这段代码比完全抽象的版本更易维护。
当然,由于我们可以向任何对象添加__call__方法,我们不限于函数。当被调用的方法不必维护状态时,前面的例子是一个有用的快捷方式,但在更高级的用法中,我们也可以使用以下代码:
class Document:
def __init__(self, filename):
self.filename = filename
self.contents = "This file cannot be modified"
def save(self):
with open(self.filename, "w") as file:
file.write(self.contents)
class KeyboardShortcut:
def keypress(self):
self.command()
class SaveCommand:
def __init__(self, document):
self.document = document
def __call__(self):
self.document.save()
document = Document("a_file.txt")
shortcut = KeyboardShortcut()
save_command = SaveCommand(document)
shortcut.command = save_command
在这里,我们有一个看起来像第一个命令模式的东西,但更符合习惯。正如你所看到的,让调用者调用一个可调用对象而不是具有执行方法的command对象并没有限制我们的任何方式。事实上,这给了我们更多的灵活性。当适用时,我们可以直接链接到函数,但是当情况需要时,我们可以构建一个完整的可调用command对象。
命令模式通常扩展为支持可撤销的命令。例如,文本程序可能将每个插入操作包装在一个单独的命令中,不仅有一个execute方法,还有一个undo方法,用于删除该插入操作。图形程序可能将每个绘图操作(矩形、线条、自由像素等)包装在一个命令中,该命令具有一个undo方法,用于将像素重置为其原始状态。在这种情况下,命令模式的解耦显然更有用,因为每个操作都必须保持足够的状态以便在以后的某个日期撤消该操作。
抽象工厂模式
抽象工厂模式通常在我们有多种可能的系统实现取决于某些配置或平台问题时使用。调用代码从抽象工厂请求对象,不知道将返回什么类的对象。返回的底层实现可能取决于各种因素,如当前区域设置、操作系统或本地配置。
抽象工厂模式的常见示例包括操作系统无关的工具包、数据库后端和特定国家的格式化程序或计算器的代码。操作系统无关的 GUI 工具包可能使用抽象工厂模式,在 Windows 下返回一组 WinForm 小部件,在 Mac 下返回一组 Cocoa 小部件,在 Gnome 下返回一组 GTK 小部件,在 KDE 下返回一组 QT 小部件。Django 提供了一个抽象工厂,根据当前站点的配置设置,返回一组用于与特定数据库后端(MySQL、PostgreSQL、SQLite 等)交互的对象关系类。如果应用程序需要部署到多个地方,每个地方可以通过仅更改一个配置变量来使用不同的数据库后端。不同的国家有不同的零售商品税、小计和总计计算系统;抽象工厂可以返回特定的税收计算对象。
抽象工厂模式的 UML 类图很难理解,没有具体的示例,因此让我们改变一下,首先创建一个具体的示例。在我们的示例中,我们将创建一组取决于特定区域设置的格式化程序,帮助我们格式化日期和货币。将有一个选择特定工厂的抽象工厂类,以及一些示例具体工厂,一个用于法国,一个用于美国。这些工厂将为日期和时间创建格式化程序对象,可以查询以格式化特定值。如下图所示:

将这个图像与之前更简单的文本进行比较,可以看出图片并不总是价值千言万语,尤其是考虑到我们甚至没有在这里允许工厂选择代码。
当然,在 Python 中,我们不必实现任何接口类,因此我们可以丢弃DateFormatter、CurrencyFormatter和FormatterFactory。这些格式化类本身非常简单,但冗长,如下所示:
class FranceDateFormatter:
def format_date(self, y, m, d):
y, m, d = (str(x) for x in (y, m, d))
y = "20" + y if len(y) == 2 else y
m = "0" + m if len(m) == 1 else m
d = "0" + d if len(d) == 1 else d
return "{0}/{1}/{2}".format(d, m, y)
class USADateFormatter:
def format_date(self, y, m, d):
y, m, d = (str(x) for x in (y, m, d))
y = "20" + y if len(y) == 2 else y
m = "0" + m if len(m) == 1 else m
d = "0" + d if len(d) == 1 else d
return "{0}-{1}-{2}".format(m, d, y)
class FranceCurrencyFormatter:
def format_currency(self, base, cents):
base, cents = (str(x) for x in (base, cents))
if len(cents) == 0:
cents = "00"
elif len(cents) == 1:
cents = "0" + cents
digits = []
for i, c in enumerate(reversed(base)):
if i and not i % 3:
digits.append(" ")
digits.append(c)
base = "".join(reversed(digits))
return "{0}€{1}".format(base, cents)
class USACurrencyFormatter:
def format_currency(self, base, cents):
base, cents = (str(x) for x in (base, cents))
if len(cents) == 0:
cents = "00"
elif len(cents) == 1:
cents = "0" + cents
digits = []
for i, c in enumerate(reversed(base)):
if i and not i % 3:
digits.append(",")
digits.append(c)
base = "".join(reversed(digits))
return "${0}.{1}".format(base, cents)
这些类使用一些基本的字符串操作来尝试将各种可能的输入(整数、不同长度的字符串等)转换为以下格式:
| 美国 | 法国 | |
|---|---|---|
| 日期 | mm-dd-yyyy | dd/mm/yyyy |
| 货币 | $14,500.50 | 14 500€50 |
在这段代码中,输入显然可以进行更多的验证,但是为了这个例子,让我们保持简单。
现在我们已经设置好了格式化程序,我们只需要创建格式化程序工厂,如下所示:
class USAFormatterFactory:
def create_date_formatter(self):
return USADateFormatter()
def create_currency_formatter(self):
return USACurrencyFormatter()
class FranceFormatterFactory:
def create_date_formatter(self):
return FranceDateFormatter()
def create_currency_formatter(self):
return FranceCurrencyFormatter()
现在我们设置选择适当格式化程序的代码。由于这种事情只需要设置一次,我们可以将其设置为单例模式——但是单例模式在 Python 中并不是非常有用。让我们将当前格式化程序作为模块级变量:
country_code = "US"
factory_map = {"US": USAFormatterFactory, "FR": FranceFormatterFactory}
formatter_factory = factory_map.get(country_code)()
在这个例子中,我们硬编码了当前的国家代码;在实践中,它可能会内省区域设置、操作系统或配置文件来选择代码。这个例子使用字典将国家代码与工厂类关联起来。然后,我们从字典中获取正确的类并实例化它。
当我们想要为更多的国家添加支持时,很容易看出需要做什么:创建新的格式化类和抽象工厂本身。请记住,Formatter类可能会被重用;例如,加拿大的货币格式与美国相同,但其日期格式比其南部邻居更合理。
抽象工厂通常返回一个单例对象,但这并非必需。在我们的代码中,每次调用时都返回每个格式化程序的新实例。没有理由不能将格式化程序存储为实例变量,并为每个工厂返回相同的实例。
回顾这些例子,我们再次看到,对于工厂来说,似乎有很多样板代码在 Python 中并不感到必要。通常,可能需要抽象工厂的要求可以通过为每种工厂类型(例如:美国和法国)使用单独的模块,并确保在工厂模块中访问正确的模块来更轻松地实现。这些模块的包结构可能如下所示:
localize/
__init__.py
backends/
__init__.py
USA.py
France.py
...
技巧在于localize包中的__init__.py可以包含将所有请求重定向到正确后端的逻辑。有多种方法可以实现这一点。
如果我们知道后端永远不会动态更改(即在没有程序重新启动的情况下),我们可以在__init__.py中放一些if语句来检查当前的国家代码,并使用(通常不可接受的)from``.backends.USA``import``*语法从适当的后端导入所有变量。或者,我们可以导入每个后端并设置一个current_backend变量指向特定的模块,如下所示:
from .backends import USA, France
if country_code == "US":
current_backend = USA
根据我们选择的解决方案,我们的客户端代码将不得不调用localize.format_date或localize.current_backend.format_date来获取以当前国家区域设置格式化的日期。最终结果比原始的抽象工厂模式更符合 Python 的风格,并且在典型的使用情况下同样灵活。
组合模式
组合模式允许从简单组件构建复杂的树状结构。这些组件,称为复合对象,能够表现得像容器,也能像变量一样,具体取决于它们是否有子组件。复合对象是容器对象,其中的内容实际上可能是另一个复合对象。
传统上,复合对象中的每个组件必须是叶节点(不能包含其他对象)或复合节点。关键在于复合和叶节点都可以具有相同的接口。以下的 UML 图表非常简单:

然而,这种简单的模式使我们能够创建复杂的元素排列,所有这些元素都满足组件对象的接口。以下图表描述了这样一个复杂排列的具体实例:

组合模式通常在文件/文件夹样式的树中非常有用。无论树中的节点是普通文件还是文件夹,它仍然受到移动、复制或删除节点等操作的影响。我们可以创建一个支持这些操作的组件接口,然后使用复合对象来表示文件夹,使用叶节点来表示普通文件。
当然,在 Python 中,我们可以再次利用鸭子类型来隐式提供接口,因此我们只需要编写两个类。让我们首先在以下代码中定义这些接口:
class Folder:
def __init__(self, name):
self.name = name
self.children = {}
def add_child(self, child):
pass
def move(self, new_path):
pass
def copy(self, new_path):
pass
def delete(self):
pass
class File:
def __init__(self, name, contents):
self.name = name
self.contents = contents
def move(self, new_path):
pass
def copy(self, new_path):
pass
def delete(self):
pass
对于每个文件夹(复合)对象,我们维护一个子对象的字典。对于许多复合实现来说,列表就足够了,但在这种情况下,使用字典来按名称查找子对象会很有用。我们的路径将被指定为由/字符分隔的节点名称,类似于 Unix shell 中的路径。
考虑涉及的方法,我们可以看到移动或删除节点的行为方式相似,无论它是文件节点还是文件夹节点。然而,复制对于文件夹节点来说必须进行递归复制,而对于文件节点来说,复制是一个微不足道的操作。
为了利用相似的操作,我们可以将一些常见的方法提取到一个父类中。让我们将被丢弃的Component接口改为一个基类,使用以下代码:
class Component:
def __init__(self, name):
self.name = name
def move(self, new_path):
new_folder = get_path(new_path)
del self.parent.children[self.name]
new_folder.children[self.name] = self
self.parent = new_folder
def delete(self):
del self.parent.children[self.name]
class Folder(Component):
def __init__(self, name):
super().__init__(name)
self.children = {}
def add_child(self, child):
pass
def copy(self, new_path):
pass
class File(Component):
def __init__(self, name, contents):
super().__init__(name)
self.contents = contents
def copy(self, new_path):
pass
root = Folder("")
def get_path(path):
names = path.split("/")[1:]
node = root
for name in names:
node = node.children[name]
return node
我们在Component类上创建了move和delete方法。它们都访问一个我们尚未设置的神秘的parent变量。move方法使用一个模块级别的get_path函数,该函数根据给定的路径从预定义的根节点中找到一个节点。所有文件都将被添加到此根节点或该节点的子节点。对于move方法,目标应该是一个现有的文件夹,否则我们将会得到一个错误。就像技术书籍中的许多示例一样,错误处理是非常缺乏的,以帮助专注于正在考虑的原则。
让我们在文件夹的add_child方法中设置那个神秘的parent变量,如下所示:
def add_child(self, child):
child.parent = self
self.children[child.name] = child
好吧,这足够简单了。让我们看看我们的复合文件层次结构是否能够正常工作,使用以下代码片段:
$ python3 -i 1261_09_18_add_child.py
>>> folder1 = Folder('folder1')
>>> folder2 = Folder('folder2')
>>> root.add_child(folder1)
>>> root.add_child(folder2)
>>> folder11 = Folder('folder11')
>>> folder1.add_child(folder11)
>>> file111 = File('file111', 'contents')
>>> folder11.add_child(file111)
>>> file21 = File('file21', 'other contents')
>>> folder2.add_child(file21)
>>> folder2.children
{'file21': <__main__.File object at 0xb7220a4c>}
>>> folder2.move('/folder1/folder11')
>>> folder11.children
{'folder2': <__main__.Folder object at 0xb722080c>, 'file111': <__main__.File object at
0xb72209ec>}
>>> file21.move('/folder1')
>>> folder1.children
{'file21': <__main__.File object at 0xb7220a4c>, 'folder11': <__main__.Folder object at
0xb722084c>}
是的,我们可以创建文件夹,将文件夹添加到其他文件夹中,将文件添加到文件夹中,并在它们之间移动!在文件层次结构中,我们还能要求什么呢?
好吧,我们可以要求实现复制,但为了节约树木,让我们把它作为一个练习留下来。
复合模式对于各种类似树结构的结构非常有用,包括 GUI 小部件层次结构,文件层次结构,树集,图形和 HTML DOM。当按照传统实现方式在 Python 中实现时,它可以成为 Python 中的一个有用模式,就像之前演示的例子一样。有时,如果只创建了一个浅树,我们可以使用列表的列表或字典的字典,并且不需要实现自定义组件、叶子和复合类。其他时候,我们可以只实现一个复合类,并将叶子和复合对象视为一个类。另外,Python 的鸭子类型可以很容易地将其他对象添加到复合层次结构中,只要它们具有正确的接口。
练习
在深入研究每个设计模式的练习之前,花点时间为上一节中的File和Folder对象实现copy方法。File方法应该非常简单;只需创建一个具有相同名称和内容的新节点,并将其添加到新的父文件夹中。Folder上的copy方法要复杂得多,因为您首先必须复制文件夹,然后递归地将其每个子对象复制到新位置。您可以不加区分地在子对象上调用copy()方法,无论每个子对象是文件还是文件夹。这将彰显出复合模式有多么强大。
现在,就像在上一章中一样,看看我们讨论过的模式,并考虑您可能实现它们的理想位置。您可能希望将适配器模式应用于现有代码,因为它通常适用于与现有库进行接口,而不是新代码。您如何使用适配器来强制两个接口正确地相互交互?
你能想到一个足够复杂的系统,可以证明使用外观模式是合理的吗?考虑一下外观在现实生活中的使用情况,比如汽车的驾驶员界面,或者工厂的控制面板。在软件中也是类似的,只不过外观接口的用户是其他程序员,而不是受过培训的人。在你最新的项目中,是否有复杂的系统可以从外观模式中受益?
可能你没有任何巨大的、占用内存的代码会从享元模式中受益,但你能想到哪些情况下它可能会有用吗?任何需要处理大量重叠数据的地方,都可以使用享元模式。在银行业会有用吗?在 Web 应用程序中呢?采用享元模式何时是明智的?什么时候又是画蛇添足呢?
命令模式呢?你能想到任何常见(或更好的是,不常见)的例子,其中将动作与调用解耦会有用吗?看看你每天使用的程序,想象它们内部是如何实现的。很可能其中许多都会以某种方式使用命令模式。
抽象工厂模式,或者我们讨论过的更加 Pythonic 的衍生模式,对于创建一键配置系统非常有用。你能想到这样的系统有用的地方吗?
最后,考虑一下组合模式。在编程中,我们周围都有类似树状结构的东西;其中一些,比如我们的文件层次结构示例,是明显的;其他一些则相当微妙。可能会出现哪些情况,组合模式会有用呢?你能想到在自己的代码中可以使用它的地方吗?如果你稍微调整一下模式;例如,包含不同类型的叶子或组合节点,用于不同类型的对象,会怎样?
总结
在本章中,我们详细介绍了几种设计模式,包括它们的经典描述以及在 Python 中实现它们的替代方法,Python 通常比传统的面向对象语言更灵活、多才多艺。适配器模式用于匹配接口,而外观模式适用于简化接口。享元模式是一种复杂的模式,只有在需要内存优化时才有用。在 Python 中,命令模式通常更适合使用一等函数作为回调来实现。抽象工厂允许根据配置或系统信息在运行时分离实现。组合模式通常用于类似树状结构的情况。
在下一章中,我们将讨论测试 Python 程序的重要性,以及如何进行测试,重点放在面向对象的原则上。
第二十四章:测试面向对象的程序
技术娴熟的 Python 程序员一致认为测试是软件开发中最重要的方面之一。即使这一章放在书的最后,它也不是一个事后补充;到目前为止我们学习的一切都将帮助我们在编写测试时。在本章中,我们将讨论以下主题:
-
单元测试和测试驱动开发的重要性
-
标准的
unittest模块 -
pytest自动化测试套件 -
mock模块 -
代码覆盖率
-
使用
tox进行跨平台测试
为什么要测试?
许多程序员已经知道测试他们的代码有多重要。如果你是其中之一,请随意略过本节。你会发现下一节——我们实际上如何在 Python 中创建测试——更加有趣。如果你还不相信测试的重要性,我保证你的代码是有问题的,只是你不知道而已。继续阅读!
有人认为在 Python 代码中测试更重要,因为它的动态特性;而像 Java 和 C++这样的编译语言偶尔被认为在编译时强制执行类型检查,所以在某种程度上更“安全”。然而,Python 测试很少检查类型。它们检查值。它们确保正确的属性在正确的时间设置,或者序列具有正确的长度、顺序和值。这些更高级的概念需要在任何语言中进行测试。
Python 程序员测试比其他语言的程序员更多的真正原因是在 Python 中测试是如此容易!
但是为什么要测试?我们真的需要测试吗?如果我们不测试会怎样?要回答这些问题,从头开始编写一个没有任何测试的井字棋游戏。在完全编写完成之前不要运行它,从头到尾。如果让两个玩家都是人类玩家(没有人工智能),井字棋实现起来相当简单。你甚至不必尝试计算谁是赢家。现在运行你的程序。然后修复所有的错误。有多少错误?我在我的井字棋实现中记录了八个,我不确定是否都捕捉到了。你呢?
我们需要测试我们的代码以确保它正常工作。像我们刚才做的那样运行程序并修复错误是一种粗糙的测试形式。Python 的交互式解释器和几乎零编译时间使得编写几行代码并运行程序以确保这些行正在按预期工作变得容易。但是改变几行代码可能会影响我们没有意识到会受到更改影响的程序的部分,因此忽略测试这些部分。此外,随着程序的增长,解释器可以通过代码的路径数量也在增加,手动测试所有这些路径很快就变得不可能。
为了解决这个问题,我们编写自动化测试。这些是自动运行某些输入通过其他程序或程序部分的程序。我们可以在几秒钟内运行这些测试程序,并覆盖比一个程序员每次更改某些东西时想到的潜在输入情况要多得多。
有四个主要原因要编写测试:
-
确保代码按照开发人员的预期工作
-
确保在进行更改时代码仍然正常工作
-
确保开发人员理解了需求
-
确保我们正在编写的代码具有可维护的接口
第一点真的不能证明写测试所花费的时间;我们可以在交互式解释器中直接测试代码,用同样或更少的时间。但是当我们必须多次执行相同的测试操作序列时,自动化这些步骤一次,然后在需要时运行它们需要的时间更少。每次更改代码时运行测试是个好主意,无论是在初始开发阶段还是在维护版本发布时。当我们有一套全面的自动化测试时,我们可以在代码更改后运行它们,并知道我们没有无意中破坏任何被测试的东西。
前面两点更有趣。当我们为代码编写测试时,它有助于设计代码所采用的 API、接口或模式。因此,如果我们误解了需求,编写测试可以帮助突出这种误解。另一方面,如果我们不确定如何设计一个类,我们可以编写一个与该类交互的测试,这样我们就可以知道与之交互的最自然方式。事实上,通常在编写我们要测试的代码之前编写测试是有益的。
测试驱动开发
先写测试是测试驱动开发的口头禅。测试驱动开发将未经测试的代码是有问题的代码的概念推进了一步,并建议只有未编写的代码才应该未经测试。在我们编写测试之前,我们不会编写任何代码来证明它有效。第一次运行测试时,它应该失败,因为代码还没有被编写。然后,我们编写确保测试通过的代码,然后为下一段代码编写另一个测试。
测试驱动开发很有趣;它允许我们构建小谜题来解决。然后,我们实现解决这些谜题的代码。然后,我们制作一个更复杂的谜题,然后编写解决新谜题的代码,而不会解决以前的谜题。
测试驱动方法有两个目标。第一个是确保测试真的被编写。在我们编写代码之后,很容易说:
嗯,看起来好像可以。我不需要为这个写任何测试。这只是一个小改变;什么都不可能出错。
如果测试在我们编写代码之前已经编写好了,我们将确切地知道它何时有效(因为测试将通过),并且在将来,如果我们或其他人对其进行了更改,我们将知道它是否被破坏。
其次,先编写测试迫使我们考虑代码将如何使用。它告诉我们对象需要具有哪些方法,以及如何访问属性。它帮助我们将初始问题分解为更小的、可测试的问题,然后将经过测试的解决方案重新组合成更大的、也经过测试的解决方案。编写测试因此可以成为设计过程的一部分。通常,当我们为一个新对象编写测试时,我们会发现设计中的异常,这迫使我们考虑软件的新方面。
作为一个具体的例子,想象一下编写使用对象关系映射器将对象属性存储在数据库中的代码。在这种对象中使用自动分配的数据库 ID 是很常见的。我们的代码可能会为各种目的使用这个 ID。如果我们为这样的代码编写测试,在我们编写测试之前,我们可能会意识到我们的设计有缺陷,因为对象在保存到数据库之前不会被分配 ID。如果我们想在测试中操作一个对象而不保存它,那么在我们基于错误的前提编写代码之前,它会突出显示这个问题。
测试使软件更好。在发布软件之前编写测试可以使软件在最终用户看到或购买有错误的版本之前变得更好(我曾为那些以用户可以测试它为理念的公司工作过;这不是一个健康的商业模式)。在编写软件之前编写测试可以使软件第一次编写时变得更好。
单元测试
让我们从 Python 内置的测试库开始探索。这个库为单元测试提供了一个通用的面向对象的接口。单元测试专注于在任何一个测试中测试尽可能少的代码。每个测试都测试可用代码的一个单元。
这个 Python 库的名称是unittest,毫不奇怪。它提供了几个用于创建和运行单元测试的工具,其中最重要的是TestCase类。这个类提供了一组方法,允许我们比较值,设置测试,并在测试完成时进行清理。
当我们想要为特定任务编写一组单元测试时,我们创建一个TestCase的子类,并编写单独的方法来进行实际测试。这些方法都必须以test开头的名称。遵循这个约定时,测试会自动作为测试过程的一部分运行。通常,测试会在对象上设置一些值,然后运行一个方法,并使用内置的比较方法来确保正确的结果被计算出来。这里有一个非常简单的例子:
import unittest
class CheckNumbers(unittest.TestCase):
def test_int_float(self):
self.assertEqual(1, 1.0)
if __name__ == "__main__":
unittest.main()
这段代码简单地继承了TestCase类,并添加了一个调用TestCase.assertEqual方法的方法。这个方法将根据两个参数是否相等而成功或引发异常。如果我们运行这段代码,unittest的main函数将给出以下输出:
.
--------------------------------------------------------------
Ran 1 test in 0.000s
OK
你知道浮点数和整数可以被比较为相等吗?让我们添加一个失败的测试,如下:
def test_str_float(self):
self.assertEqual(1, "1")
这段代码的输出更加阴险,因为整数和字符串不是
被认为是相等的:
.F
============================================================
FAIL: test_str_float (__main__.CheckNumbers)
--------------------------------------------------------------
Traceback (most recent call last):
File "first_unittest.py", line 9, in test_str_float
self.assertEqual(1, "1")
AssertionError: 1 != '1'
--------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
第一行的点表示第一个测试(我们之前写的那个)成功通过;其后的字母F表示第二个测试失败。然后,在最后,它会给出一些信息性的输出,告诉我们测试失败的原因和位置,以及失败的数量总结。
我们可以在一个TestCase类上有尽可能多的测试方法。只要方法名以test开头,测试运行器就会将每个方法作为一个单独的、隔离的测试执行。每个测试应该完全独立于其他测试。先前测试的结果或计算不应该对当前测试产生影响。编写良好的单元测试的关键是尽可能保持每个测试方法的长度短小,每个测试用例测试一小部分代码。如果我们的代码似乎无法自然地分解成这样可测试的单元,这可能是代码需要重新设计的迹象。
断言方法
测试用例的一般布局是将某些变量设置为已知的值,运行一个或多个函数、方法或进程,然后使用TestCase的断言方法证明正确的预期结果是通过的或者被计算出来的。
有几种不同的断言方法可用于确认已经实现了特定的结果。我们刚刚看到了assertEqual,如果两个参数不能通过相等检查,它将导致测试失败。相反,assertNotEqual如果两个参数比较为相等,则会失败。assertTrue和assertFalse方法分别接受一个表达式,并且如果表达式不能通过if测试,则会失败。这些测试不检查布尔值True或False。相反,它们测试与使用if语句相同的条件:False、None、0或空列表、字典、字符串、集合或元组会通过调用assertFalse方法。非零数、包含值的容器,或值True在调用assertTrue方法时会成功。
有一个assertRaises方法,可以用来确保特定的函数调用引发特定的异常,或者可以选择作为上下文管理器来包装内联代码。如果with语句内的代码引发了正确的异常,则测试通过;否则,测试失败。以下代码片段是两个版本的示例:
import unittest
def average(seq):
return sum(seq) / len(seq)
class TestAverage(unittest.TestCase):
def test_zero(self):
self.assertRaises(ZeroDivisionError, average, [])
def test_with_zero(self):
with self.assertRaises(ZeroDivisionError):
average([])
if __name__ == "__main__":
unittest.main()
上下文管理器允许我们以通常的方式编写代码(通过调用函数或直接执行代码),而不必在另一个函数调用中包装函数调用。
还有几种其他断言方法,总结在下表中:
| 方法 | 描述 |
|---|---|
assertGreater``assertGreaterEqual``assertLess``assertLessEqual |
接受两个可比较的对象,并确保命名的不等式成立。 |
assertIn``assertNotIn |
确保元素是(或不是)容器对象中的一个元素。 |
assertIsNone``assertIsNotNone |
确保一个元素是(或不是)确切的None值(而不是其他假值)。 |
assertSameElements |
确保两个容器对象具有相同的元素,忽略顺序。 |
assertSequenceEqualassertDictEqual``assertSetEqual``assertListEqual``assertTupleEqual |
确保两个容器以相同的顺序具有相同的元素。如果失败,显示一个比较两个列表的代码差异,以查看它们的不同之处。最后四种方法还测试了列表的类型。 |
每个断言方法都接受一个名为msg的可选参数。如果提供了,它将包含在错误消息中,如果断言失败,这对于澄清预期的内容或解释可能导致断言失败的错误的地方非常有用。然而,我很少使用这种语法,更喜欢为测试方法使用描述性的名称。
减少样板代码和清理
编写了一些小测试之后,我们经常发现我们必须为几个相关的测试编写相同的设置代码。例如,以下list子类有三种用于统计计算的方法:
from collections import defaultdict
class StatsList(list):
def mean(self):
return sum(self) / len(self)
def median(self):
if len(self) % 2:
return self[int(len(self) / 2)]
else:
idx = int(len(self) / 2)
return (self[idx] + self[idx-1]) / 2
def mode(self):
freqs = defaultdict(int)
for item in self:
freqs[item] += 1
mode_freq = max(freqs.values())
modes = []
for item, value in freqs.items():
if value == mode_freq:
modes.append(item)
return modes
显然,我们将要测试这三种方法中的每一种情况,这些情况具有非常相似的输入。我们将要看到空列表、包含非数字值的列表,或包含正常数据集的列表等情况下会发生什么。我们可以使用TestCase类上的setUp方法来为每个测试执行初始化。这个方法不接受任何参数,并允许我们在每个测试运行之前进行任意的设置。例如,我们可以在相同的整数列表上测试所有三种方法,如下所示:
from stats import StatsList
import unittest
class TestValidInputs(unittest.TestCase):
def setUp(self):
self.stats = StatsList([1, 2, 2, 3, 3, 4])
def test_mean(self):
self.assertEqual(self.stats.mean(), 2.5)
def test_median(self):
self.assertEqual(self.stats.median(), 2.5)
self.stats.append(4)
self.assertEqual(self.stats.median(), 3)
def test_mode(self):
self.assertEqual(self.stats.mode(), [2, 3])
self.stats.remove(2)
self.assertEqual(self.stats.mode(), [3])
if __name__ == "__main__":
unittest.main()
如果我们运行这个例子,它表明所有测试都通过了。首先注意到setUp方法从未在三个test_*方法中显式调用过。测试套件会代表我们执行这个操作。更重要的是,注意test_median如何改变了列表,通过向其中添加一个额外的4,但是当随后调用test_mode时,列表已经恢复到了setUp中指定的值。如果没有恢复,列表中将会有两个四,而mode方法将会返回三个值。这表明setUp在每个测试之前都会被单独调用,确保测试类从一个干净的状态开始。测试可以以任何顺序执行,一个测试的结果绝不能依赖于其他测试。
除了setUp方法,TestCase还提供了一个无参数的tearDown方法,它可以用于在类的每个测试运行后进行清理。如果清理需要除了让对象被垃圾回收之外的其他操作,这个方法就很有用。
例如,如果我们正在测试进行文件 I/O 的代码,我们的测试可能会在测试的副作用下创建新文件。tearDown方法可以删除这些文件,并确保系统处于与测试运行之前相同的状态。测试用例绝不能有副作用。通常,我们根据它们共同的设置代码将测试方法分组到单独的TestCase子类中。需要相同或相似设置的几个测试将被放置在一个类中,而需要不相关设置的测试将被放置在另一个类中。
组织和运行测试
对于一个单元测试集合来说,很快就会变得非常庞大和难以控制。一次性加载和运行所有测试可能会变得非常复杂。这是单元测试的主要目标:在程序上轻松运行所有测试,并快速得到一个“是”或“否”的答案,来回答“我的最近的更改是否有问题?”的问题。
与正常的程序代码一样,我们应该将测试类分成模块和包,以保持它们的组织。如果您将每个测试模块命名为以四个字符test开头,就可以轻松找到并运行它们。Python 的discover模块会查找当前文件夹或子文件夹中以test开头命名的任何模块。如果它在这些模块中找到任何TestCase对象,就会执行测试。这是一种无痛的方式来确保我们不会错过运行任何测试。要使用它,请确保您的测试模块命名为test_<something>.py,然后运行python3 -m unittest discover命令。
大多数 Python 程序员选择将他们的测试放在一个单独的包中(通常命名为tests/,与他们的源目录并列)。但这并不是必需的。有时,将不同包的测试模块放在该包旁边的子包中是有意义的,例如。
忽略损坏的测试
有时,我们知道测试会失败,但我们不希望测试套件报告失败。这可能是因为一个损坏或未完成的功能已经编写了测试,但我们目前并不专注于改进它。更常见的情况是,因为某个功能仅在特定平台、Python 版本或特定库的高级版本上可用。Python 为我们提供了一些装饰器,用于标记测试为预期失败或在已知条件下跳过。
这些装饰器如下:
-
expectedFailure() -
skip(reason) -
skipIf(condition, reason) -
skipUnless(condition, reason)
这些是使用 Python 装饰器语法应用的。第一个不接受参数,只是告诉测试运行器在测试失败时不记录测试失败。skip方法更进一步,甚至不会运行测试。它期望一个描述为什么跳过测试的字符串参数。另外两个装饰器接受两个参数,一个是布尔表达式,指示是否应该运行测试,另一个是类似的描述。在使用时,这三个装饰器可能会像下面的代码中所示一样应用:
import unittest
import sys
class SkipTests(unittest.TestCase):
@unittest.expectedFailure
def test_fails(self):
self.assertEqual(False, True)
@unittest.skip("Test is useless")
def test_skip(self):
self.assertEqual(False, True)
@unittest.skipIf(sys.version_info.minor == 4, "broken on 3.4")
def test_skipif(self):
self.assertEqual(False, True)
@unittest.skipUnless(
sys.platform.startswith("linux"), "broken unless on linux"
)
def test_skipunless(self):
self.assertEqual(False, True)
if __name__ == "__main__":
unittest.main()
第一个测试失败,但被报告为预期的失败;第二个测试从未运行。其他两个测试可能会运行,也可能不会,这取决于当前的 Python 版本和操作系统。在我的 Linux 系统上,运行 Python 3.7,输出如下:
xssF
======================================================================
FAIL: test_skipunless (__main__.SkipTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_skipping.py", line 22, in test_skipunless
self.assertEqual(False, True)
AssertionError: False != True
----------------------------------------------------------------------
Ran 4 tests in 0.001s
FAILED (failures=1, skipped=2, expected failures=1)
第一行上的x表示预期的失败;两个s字符表示跳过的测试,F表示真正的失败,因为在我的系统上skipUnless的条件为True。
使用 pytest 进行测试
Python 的unittest模块需要大量样板代码来设置和初始化测试。它基于非常流行的 Java 的 JUnit 测试框架。它甚至使用相同的方法名称(您可能已经注意到它们不符合 PEP-8 命名标准,该标准建议使用 snake_case 而不是 CamelCase 来表示方法名称)和测试布局。虽然这对于在 Java 中进行测试是有效的,但不一定是 Python 测试的最佳设计。我实际上发现unittest框架是过度使用面向对象原则的一个很好的例子。
因为 Python 程序员喜欢他们的代码简洁而简单,所以在标准库之外开发了其他测试框架。其中两个较受欢迎的是pytest和nose。前者更为健壮,并且支持 Python 3 的时间更长,因此我们将在这里讨论它。
由于pytest不是标准库的一部分,您需要自己下载并安装它。您可以从pytest.org/的pytest主页获取它。该网站提供了各种解释器和平台的全面安装说明,但通常您可以使用更常见的 Python 软件包安装程序 pip。只需在命令行上输入pip install pytest,就可以开始使用了。
pytest的布局与unittest模块有很大不同。它不要求测试用例是类。相反,它利用了 Python 函数是对象的事实,并允许任何命名正确的函数像测试一样行为。它不是提供一堆用于断言相等的自定义方法,而是使用assert语句来验证结果。这使得测试更易读和易维护。
当我们运行pytest时,它会从当前文件夹开始搜索以test_开头的任何模块或子包。如果该模块中的任何函数也以test开头,它们将作为单独的测试执行。此外,如果模块中有任何以Test开头的类,该类上以test_开头的任何方法也将在测试环境中执行。
使用以下代码,让我们将之前编写的最简单的unittest示例移植到pytest:
def test_int_float():
assert 1 == 1.0
对于完全相同的测试,我们写了两行更易读的代码,而不是我们第一个unittest示例中需要的六行。
但是,我们并没有禁止编写基于类的测试。类可以用于将相关测试分组在一起,或者用于需要访问类上相关属性或方法的测试。下面的示例显示了一个扩展类,其中包含一个通过和一个失败的测试;我们将看到错误输出比unittest模块提供的更全面:
class TestNumbers:
def test_int_float(self):
assert 1 == 1.0
def test_int_str(self):
assert 1 == "1"
请注意,类不必扩展任何特殊对象才能被识别为测试(尽管pytest可以很好地运行标准的unittest TestCases)。如果我们运行pytest <filename>,输出如下所示:
============================== test session starts ==============================
platform linux -- Python 3.7.0, pytest-3.8.0, py-1.6.0, pluggy-0.7.1
rootdir: /home/dusty/Py3OOP/Chapter 24: Testing Object-oriented Programs, inifile:
collected 3 items
test_with_pytest.py ..F [100%]
=================================== FAILURES ====================================
___________________________ TestNumbers.test_int_str ____________________________
self = <test_with_pytest.TestNumbers object at 0x7fdb95e31390>
def test_int_str(self):
> assert 1 == "1"
E AssertionError: assert 1 == '1'
test_with_pytest.py:10: AssertionError
====================== 1 failed, 2 passed in 0.03 seconds =======================
输出以有关平台和解释器的一些有用信息开始。这对于在不同系统之间共享或讨论错误很有用。第三行告诉我们正在测试的文件的名称(如果有多个测试模块被识别,它们都将显示出来),然后是在unittest模块中看到的熟悉的.F;.字符表示通过的测试,而字母F表示失败。
所有测试运行完毕后,将显示每个测试的错误输出。它呈现了局部变量的摘要(在本例中只有一个:传递给函数的self参数),发生错误的源代码以及错误消息的摘要。此外,如果引发的异常不是AssertionError,pytest将向我们呈现完整的回溯,包括源代码引用。
默认情况下,如果测试成功,pytest会抑制print语句的输出。这对于测试调试很有用;当测试失败时,我们可以向测试中添加print语句来检查特定变量和属性的值。如果测试失败,这些值将被输出以帮助诊断。但是,一旦测试成功,print语句的输出就不会显示出来,很容易被忽略。我们不必通过删除print语句来清理输出。如果由于将来的更改而再次失败,调试输出将立即可用。
进行设置和清理的一种方法
pytest支持类似于unittest中使用的设置和拆卸方法,但它提供了更多的灵活性。我们将简要讨论这些,因为它们很熟悉,但它们并没有像在unittest模块中那样被广泛使用,因为pytest为我们提供了一个强大的固定设施,我们将在下一节中讨论。
如果我们正在编写基于类的测试,我们可以使用两个名为setup_method和teardown_method的方法,就像在unittest中调用setUp和tearDown一样。它们在类中的每个测试方法之前和之后被调用,以执行设置和清理任务。但是,与unittest方法不同的是,这两种方法都接受一个参数:表示被调用的方法的函数对象。
此外,pytest提供了其他设置和拆卸函数,以便更好地控制设置和清理代码的执行时间。setup_class和teardown_class方法预期是类方法;它们接受一个表示相关类的单个参数(没有self参数)。这些方法仅在类被初始化时运行,而不是在每次测试运行时运行。
最后,我们有setup_module和teardown_module函数,它们在该模块中的所有测试(在函数或类中)之前和之后立即运行。这些可以用于一次性设置,例如创建一个将被模块中所有测试使用的套接字或数据库连接。对于这一点要小心,因为如果对象存储了在测试之间没有正确清理的状态,它可能会意外地引入测试之间的依赖关系。
这个简短的描述并没有很好地解释这些方法究竟在什么时候被调用,所以让我们看一个例子,确切地说明了它们何时被调用:
def setup_module(module):
print("setting up MODULE {0}".format(module.__name__))
def teardown_module(module):
print("tearing down MODULE {0}".format(module.__name__))
def test_a_function():
print("RUNNING TEST FUNCTION")
class BaseTest:
def setup_class(cls):
print("setting up CLASS {0}".format(cls.__name__))
def teardown_class(cls):
print("tearing down CLASS {0}\n".format(cls.__name__))
def setup_method(self, method):
print("setting up METHOD {0}".format(method.__name__))
def teardown_method(self, method):
print("tearing down METHOD {0}".format(method.__name__))
class TestClass1(BaseTest):
def test_method_1(self):
print("RUNNING METHOD 1-1")
def test_method_2(self):
print("RUNNING METHOD 1-2")
class TestClass2(BaseTest):
def test_method_1(self):
print("RUNNING METHOD 2-1")
def test_method_2(self):
print("RUNNING METHOD 2-2")
BaseTest类的唯一目的是提取四个方法,否则这些方法与测试类相同,并使用继承来减少重复代码的数量。因此,从pytest的角度来看,这两个子类不仅每个有两个测试方法,还有两个设置和两个拆卸方法(一个在类级别,一个在方法级别)。
如果我们使用pytest运行这些测试,并且禁用了print函数的输出抑制(通过传递-s或--capture=no标志),它们会告诉我们各种函数在与测试本身相关的时候被调用:
setup_teardown.py
setting up MODULE setup_teardown
RUNNING TEST FUNCTION
.setting up CLASS TestClass1
setting up METHOD test_method_1
RUNNING METHOD 1-1
.tearing down METHOD test_method_1
setting up METHOD test_method_2
RUNNING METHOD 1-2
.tearing down METHOD test_method_2
tearing down CLASS TestClass1
setting up CLASS TestClass2
setting up METHOD test_method_1
RUNNING METHOD 2-1
.tearing down METHOD test_method_1
setting up METHOD test_method_2
RUNNING METHOD 2-2
.tearing down METHOD test_method_2
tearing down CLASS TestClass2
tearing down MODULE setup_teardown
模块的设置和拆卸方法在会话开始和结束时执行。然后运行单个模块级别的测试函数。接下来,执行第一个类的设置方法,然后是该类的两个测试。这些测试分别包装在单独的setup_method和teardown_method调用中。测试执行完毕后,调用类的拆卸方法。在第二个类之前,发生了相同的顺序,最后调用teardown_module方法,确切地一次。
设置变量的完全不同的方法
各种设置和拆卸函数的最常见用途之一是确保在运行每个测试方法之前,某些类或模块变量可用且具有已知值。
pytest提供了一个完全不同的设置变量的方法,使用所谓的fixtures。Fixture 基本上是预定义在测试配置文件中的命名变量。这允许我们将配置与测试的执行分开,并允许 fixtures 在多个类和模块中使用。
为了使用它们,我们向我们的测试函数添加参数。参数的名称用于在特别命名的函数中查找特定的参数。例如,如果我们想测试我们在演示unittest时使用的StatsList类,我们再次想要重复测试一个有效整数列表。但是,我们可以编写我们的测试如下,而不是使用设置方法:
import pytest
from stats import StatsList
@pytest.fixture
def valid_stats():
return StatsList([1, 2, 2, 3, 3, 4])
def test_mean(valid_stats):
assert valid_stats.mean() == 2.5
def test_median(valid_stats):
assert valid_stats.median() == 2.5
valid_stats.append(4)
assert valid_stats.median() == 3
def test_mode(valid_stats):
assert valid_stats.mode() == [2, 3]
valid_stats.remove(2)
assert valid_stats.mode() == [3]
这三个测试方法中的每一个都接受一个名为valid_stats的参数;这个参数是通过调用valid_stats函数创建的,该函数被装饰为@pytest.fixture。
Fixture 可以做的远不止返回基本变量。可以将request对象传递到 fixture 工厂中,以提供非常有用的方法和属性来修改 funcarg 的行为。module、cls和function属性允许我们准确地查看请求 fixture 的测试。config属性允许我们检查命令行参数和大量其他配置数据。
如果我们将 fixture 实现为生成器,我们可以在每次测试运行后运行清理代码。这提供了类似于拆卸方法的功能,但是在每个 fixture 的基础上。我们可以用它来清理文件、关闭连接、清空列表或重置队列。例如,以下代码测试了os.mkdir功能,通过创建一个临时目录 fixture:
import pytest
import tempfile
import shutil
import os.path
@pytest.fixture
def temp_dir(request):
dir = tempfile.mkdtemp()
print(dir)
yield dir
shutil.rmtree(dir)
def test_osfiles(temp_dir):
os.mkdir(os.path.join(temp_dir, "a"))
os.mkdir(os.path.join(temp_dir, "b"))
dir_contents = os.listdir(temp_dir)
assert len(dir_contents) == 2
assert "a" in dir_contents
assert "b" in dir_contents
该 fixture 为文件创建一个新的空临时目录。它将此目录提供给测试使用,但在测试完成后删除该目录(使用shutil.rmtree,递归删除目录及其中的所有内容)。文件系统将保持与开始时相同的状态。
我们可以传递一个scope参数来创建一个持续时间超过一个测试的 fixture。当设置一个昂贵的操作,可以被多个测试重复使用时,这是很有用的,只要资源重用不会破坏测试的原子性或单元性(以便一个测试不依赖于前一个测试,也不受其影响)。例如,如果我们要测试以下回显服务器,我们可能只想在单独的进程中运行一个服务器实例,然后让多个测试连接到该实例:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('localhost',1028))
s.listen(1)
while True:
client, address = s.accept()
data = client.recv(1024)
client.send(data)
client.close()
这段代码的作用只是监听特定端口,并等待来自客户端 socket 的输入。当它接收到输入时,它会将相同的值发送回去。为了测试这个,我们可以在单独的进程中启动服务器,并缓存结果供多个测试使用。测试代码可能如下所示:
import subprocess
import socket
import time
import pytest
@pytest.fixture(scope="session")
def echoserver():
print("loading server")
p = subprocess.Popen(["python3", "echo_server.py"])
time.sleep(1)
yield p
p.terminate()
@pytest.fixture
def clientsocket(request):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("localhost", 1028))
yield s
s.close()
def test_echo(echoserver, clientsocket):
clientsocket.send(b"abc")
assert clientsocket.recv(3) == b"abc"
def test_echo2(echoserver, clientsocket):
clientsocket.send(b"def")
assert clientsocket.recv(3) == b"def"
我们在这里创建了两个 fixtures。第一个在单独的进程中运行回显服务器,并在完成时清理进程对象。第二个为每个测试实例化一个新的 socket 对象,并在测试完成时关闭 socket。
第一个 fixture 是我们目前感兴趣的。通过传递给装饰器构造函数的scope="session"关键字参数,pytest知道我们只希望在单元测试会话期间初始化和终止一次这个 fixture。
作用域可以是字符串class、module、package或session中的一个。它决定了参数将被缓存多长时间。在这个例子中,我们将其设置为session,因此它将在整个pytest运行期间被缓存。进程将在所有测试运行完之前不会被终止或重新启动。当然,module作用域仅为该模块中的测试缓存,class作用域将对象视为普通的类设置和拆卸。
在本书第三版印刷时,pytest中的package作用域被标记为实验性质。请小心使用,并要求您提供 bug 报告。
使用 pytest 跳过测试
与unittest模块一样,经常需要在pytest中跳过测试,原因各种各样:被测试的代码尚未编写,测试仅在某些解释器或操作系统上运行,或者测试耗时且只应在特定情况下运行。
我们可以在代码的任何地方跳过测试,使用pytest.skip函数。它接受一个参数:描述为什么要跳过的字符串。这个函数可以在任何地方调用。如果我们在测试函数内调用它,测试将被跳过。如果我们在模块级别调用它,那个模块中的所有测试都将被跳过。如果我们在 fixture 内调用它,所有调用该 funcarg 的测试都将被跳过。
当然,在所有这些位置,通常希望只有在满足或不满足某些条件时才跳过测试。由于我们可以在 Python 代码的任何地方执行skip函数,我们可以在if语句内执行它。因此,我们可能编写一个如下所示的测试:
import sys
import pytest
def test_simple_skip():
if sys.platform != "fakeos":
pytest.skip("Test works only on fakeOS")
fakeos.do_something_fake()
assert fakeos.did_not_happen
这实际上是一些相当愚蠢的代码。没有名为fakeos的 Python 平台,因此这个测试将在所有操作系统上跳过。它展示了我们如何有条件地跳过测试,由于if语句可以检查任何有效的条件,我们对测试何时被跳过有很大的控制权。通常,我们检查sys.version_info来检查 Python 解释器版本,sys.platform来检查操作系统,或者some_library.__version__来检查我们是否有足够新的给定 API 版本。
由于基于某个条件跳过单个测试方法或函数是测试跳过的最常见用法之一,pytest提供了一个方便的装饰器,允许我们在一行中执行此操作。装饰器接受一个字符串,其中可以包含任何可执行的 Python 代码,该代码求值为布尔值。例如,以下测试只在 Python 3 或更高版本上运行:
@pytest.mark.skipif("sys.version_info <= (3,0)")
def test_python3():
assert b"hello".decode() == "hello"
pytest.mark.xfail装饰器的行为类似,只是它标记一个测试预期失败,类似于unittest.expectedFailure()。如果测试成功,它将被记录为失败。如果失败,它将被报告为预期行为。在xfail的情况下,条件参数是可选的。如果没有提供,测试将被标记为在所有条件下都预期失败。
pytest除了这里描述的功能之外,还有很多其他功能,开发人员不断添加创新的新方法,使您的测试体验更加愉快。他们在网站上有详尽的文档docs.pytest.org/。
pytest可以找到并运行使用标准unittest库定义的测试,除了它自己的测试基础设施。这意味着如果你想从unittest迁移到pytest,你不必重写所有旧的测试。
模拟昂贵的对象
有时,我们想要测试需要提供一个昂贵或难以构建的对象的代码。在某些情况下,这可能意味着您的 API 需要重新思考,以具有更可测试的接口(通常意味着更可用的接口)。但我们有时发现自己编写的测试代码有大量样板代码来设置与被测试代码只是偶然相关的对象。
例如,想象一下我们有一些代码,它在外部键值存储中(如redis或memcache)跟踪航班状态,以便我们可以存储时间戳和最新状态。这样的基本版本代码可能如下所示:
import datetime
import redis
class FlightStatusTracker:
ALLOWED_STATUSES = {"CANCELLED", "DELAYED", "ON TIME"}
def __init__(self):
self.redis = redis.StrictRedis()
def change_status(self, flight, status):
status = status.upper()
if status not in self.ALLOWED_STATUSES:
raise ValueError("{} is not a valid status".format(status))
key = "flightno:{}".format(flight)
value = "{}|{}".format(
datetime.datetime.now().isoformat(), status
)
self.redis.set(key, value)
有很多我们应该为change_status方法测试的事情。我们应该检查如果传入了错误的状态,它是否引发了适当的错误。我们需要确保它将状态转换为大写。我们可以看到当在redis对象上调用set()方法时,键和值的格式是否正确。
然而,在我们的单元测试中,我们不必检查redis对象是否正确存储数据。这是绝对应该在集成或应用程序测试中进行测试的事情,但在单元测试级别,我们可以假设 py-redis 开发人员已经测试过他们的代码,并且这个方法可以按我们的要求工作。一般来说,单元测试应该是自包含的,不应依赖于外部资源的存在,比如运行中的 Redis 实例。
相反,我们只需要测试set()方法被调用的次数和使用的参数是否正确。我们可以在测试中使用Mock()对象来替换麻烦的方法,以便我们可以内省对象。以下示例说明了Mock的用法:
from flight_status_redis import FlightStatusTracker
from unittest.mock import Mock
import pytest
@pytest.fixture
def tracker():
return FlightStatusTracker()
def test_mock_method(tracker):
tracker.redis.set = Mock()
with pytest.raises(ValueError) as ex:
tracker.change_status("AC101", "lost")
assert ex.value.args[0] == "LOST is not a valid status"
assert tracker.redis.set.call_count == 0
这个使用pytest语法编写的测试断言在传入不合适的参数时会引发正确的异常。此外,它为set方法创建了一个Mock对象,并确保它从未被调用。如果被调用了,这意味着我们的异常处理代码中存在错误。
在这种情况下,简单地替换方法效果很好,因为被替换的对象最终被销毁了。然而,我们经常希望仅在测试期间替换函数或方法。例如,如果我们想测试Mock方法中的时间戳格式,我们需要确切地知道datetime.datetime.now()将返回什么。然而,这个值会随着运行的不同而改变。我们需要一种方法将其固定到一个特定的值,以便我们可以进行确定性测试。
临时将库函数设置为特定值是猴子补丁的少数有效用例之一。模拟库提供了一个补丁上下文管理器,允许我们用模拟对象替换现有库上的属性。当上下文管理器退出时,原始属性会自动恢复,以免影响其他测试用例。以下是一个例子:
import datetime
from unittest.mock import patch
def test_patch(tracker):
tracker.redis.set = Mock()
fake_now = datetime.datetime(2015, 4, 1)
with patch("datetime.datetime") as dt:
dt.now.return_value = fake_now
tracker.change_status("AC102", "on time")
dt.now.assert_called_once_with()
tracker.redis.set.assert_called_once_with(
"flightno:AC102", "2015-04-01T00:00:00|ON TIME"
)
在前面的例子中,我们首先构造了一个名为fake_now的值,我们将其设置为datetime.datetime.now函数的返回值。我们必须在补丁datetime.datetime之前构造这个对象,否则我们会在构造它之前调用已经补丁的now函数。
with语句邀请补丁用模拟对象替换datetime.datetime模块,返回为dt值。模拟对象的好处是,每次访问该对象的属性或方法时,它都会返回另一个模拟对象。因此,当我们访问dt.now时,它会给我们一个新的模拟对象。我们将该对象的return_value设置为我们的fake_now对象。现在,每当调用datetime.datetime.now函数时,它将返回我们的对象,而不是一个新的模拟对象。但是当解释器退出上下文管理器时,原始的datetime.datetime.now()功能会被恢复。
在使用已知值调用我们的change_status方法后,我们使用Mock类的assert_called_once_with函数来确保now函数确实被调用了一次,且没有参数。然后我们再次调用它,以证明redis.set方法被调用时,参数的格式与我们期望的一样。
模拟日期以便获得确定性的测试结果是一个常见的补丁场景。如果你处于这种情况,你可能会喜欢 Python 包索引中提供的freezegun和pytest-freezegun项目。
前面的例子很好地说明了编写测试如何指导我们的 API 设计。FlightStatusTracker对象乍一看似乎很合理;我们在对象构造时构建了一个redis连接,并在需要时调用它。然而,当我们为这段代码编写测试时,我们发现即使我们在FlightStatusTracker上模拟了self.redis变量,redis连接仍然必须被构造。如果没有运行 Redis 服务器,这个调用实际上会失败,我们的测试也会失败。
我们可以通过在setUp方法中模拟redis.StrictRedis类来解决这个问题,以返回一个模拟对象。然而,一个更好的想法可能是重新思考我们的实现。与其在__init__中构造redis实例,也许我们应该允许用户传入一个,就像下面的例子一样:
def __init__(self, redis_instance=None):
self.redis = redis_instance if redis_instance else redis.StrictRedis()
这样我们就可以在测试时传入一个模拟对象,这样StrictRedis方法就不会被构造。此外,它允许任何与FlightStatusTracker交互的客户端代码传入他们自己的redis实例。他们可能有各种原因这样做:他们可能已经为代码的其他部分构造了一个;他们可能已经创建了redis API 的优化实现;也许他们有一个将指标记录到内部监控系统的实现。通过编写单元测试,我们发现了一个使用案例,使我们的 API 从一开始就更加灵活,而不是等待客户要求我们支持他们的异类需求。
这是对模拟代码奇迹的简要介绍。自 Python 3.3 以来,模拟是标准的unittest库的一部分,但正如你从这些例子中看到的,它们也可以与pytest和其他库一起使用。模拟还有其他更高级的功能,你可能需要利用这些功能,因为你的代码变得更加复杂。例如,你可以使用spec参数邀请模拟模仿现有类,以便在尝试访问模仿类上不存在的属性时引发错误。你还可以构造模拟方法,每次调用时返回不同的参数,通过将列表作为side_effect参数。side_effect参数非常灵活;你还可以使用它在调用模拟时执行任意函数或引发异常。
一般来说,我们应该对模拟非常吝啬。如果我们发现自己在给定的单元测试中模拟了多个元素,我们可能最终测试的是模拟框架而不是我们的真实代码。这毫无用处;毕竟,模拟已经经过了充分测试!如果我们的代码做了很多这样的事情,这可能是另一个迹象,表明我们正在测试的 API 设计得很糟糕。模拟应该存在于被测试代码和它们接口的库之间的边界上。如果这种情况没有发生,我们可能需要改变 API,以便在不同的地方重新划定边界。
测试多少是足够的?
我们已经确定了未经测试的代码是有问题的代码。但我们如何知道我们的代码被测试得有多好?我们如何知道我们的代码有多少被测试,有多少是有问题的?第一个问题更重要,但很难回答。即使我们知道我们已经测试了应用程序中的每一行代码,我们也不知道我们是否已经适当地测试了它。例如,如果我们编写了一个只检查当我们提供一个整数列表时会发生什么的统计测试,如果用于浮点数、字符串或自制对象的列表,它可能仍然会失败得很惨。设计完整测试套件的责任仍然在程序员身上。
第二个问题——我们的代码有多少被测试——很容易验证。代码覆盖率是程序执行的代码行数的估计。如果我们知道这个数字和程序中的代码行数,我们就可以估算出实际被测试或覆盖的代码百分比。如果我们另外有一个指示哪些行没有被测试的指标,我们就可以更容易地编写新的测试来确保这些行不会出错。
用于测试代码覆盖率的最流行的工具叫做coverage.py。它可以像大多数其他第三方库一样安装,使用pip install coverage命令。
我们没有空间来涵盖覆盖 API 的所有细节,所以我们只看一些典型的例子。如果我们有一个运行所有单元测试的 Python 脚本(例如,使用unittest.main、discover、pytest或自定义测试运行器),我们可以使用以下命令执行覆盖分析:
$coverage run coverage_unittest.py
这个命令将正常退出,但它会创建一个名为.coverage的文件,其中保存了运行的数据。现在我们可以使用coverage report命令来获取代码覆盖的分析:
$coverage report
生成的输出应该如下所示:
Name Stmts Exec Cover
--------------------------------------------------
coverage_unittest 7 7 100%
stats 19 6 31%
--------------------------------------------------
TOTAL 26 13 50%
这份基本报告列出了执行的文件(我们的单元测试和一个导入的模块)。还列出了每个文件中的代码行数以及测试执行的代码行数。然后将这两个数字合并以估算代码覆盖量。如果我们在report命令中传递-m选项,它还会添加一个如下所示的列:
Missing
-----------
8-12, 15-23
这里列出的行范围标识了在测试运行期间未执行的stats模块中的行。
我们刚刚对代码覆盖工具运行的示例使用了我们在本章早些时候创建的相同的 stats 模块。但是,它故意使用了一个失败的测试来测试文件中的大量代码。以下是测试:
from stats import StatsList
import unittest
class TestMean(unittest.TestCase):
def test_mean(self):
self.assertEqual(StatsList([1,2,2,3,3,4]).mean(), 2.5)
if __name__ == "__main__":
unittest.main()
这段代码没有测试中位数或模式函数,这些函数对应于覆盖输出告诉我们缺失的行号。
文本报告提供了足够的信息,但如果我们使用coverage html命令,我们可以获得一个更有用的交互式 HTML 报告,我们可以在 Web 浏览器中查看。网页甚至会突出显示源代码中哪些行已经测试过,哪些行没有测试过。看起来是这样的:

我们也可以使用pytest模块的coverage.py模块。我们需要安装pytest插件以进行代码覆盖率,使用pip install pytest-coverage。该插件为pytest添加了几个命令行选项,其中最有用的是--cover-report,可以设置为html,report或annotate(后者实际上修改了原始源代码以突出显示未覆盖的任何行)。
不幸的是,如果我们可以在本章的这一部分上运行覆盖率报告,我们会发现我们并没有覆盖大部分关于代码覆盖率的知识!可以使用覆盖 API 来从我们自己的程序(或测试套件)中管理代码覆盖率,coverage.py接受了许多我们没有涉及的配置选项。我们还没有讨论语句覆盖和分支覆盖之间的区别(后者更有用,并且是最近版本的coverage.py的默认值),或者其他风格的代码覆盖。
请记住,虽然 100%的代码覆盖率是我们所有人都应该努力追求的一个远大目标,但 100%的覆盖率是不够的!仅仅因为一个语句被测试了并不意味着它被正确地测试了所有可能的输入。
案例研究
让我们通过编写一个小的、经过测试的密码应用程序来了解测试驱动开发。不用担心-您不需要了解复杂的现代加密算法(如 AES 或 RSA)背后的数学。相反,我们将实现一个称为 Vigenère 密码的 16 世纪算法。该应用程序只需要能够使用此密码对消息进行编码和解码,给定一个编码关键字。
如果您想深入了解 RSA 算法的工作原理,我在我的博客上写了一篇文章dusty.phillips.codes/。
首先,我们需要了解密码是如何工作的,如果我们手动应用它(没有计算机)。我们从以下表格开始:
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
B C D E F G H I J K L M N O P Q R S T U V W X Y Z A
C D E F G H I J K L M N O P Q R S T U V W X Y Z A B
D E F G H I J K L M N O P Q R S T U V W X Y Z A B C
E F G H I J K L M N O P Q R S T U V W X Y Z A B C D
F G H I J K L M N O P Q R S T U V W X Y Z A B C D E
G H I J K L M N O P Q R S T U V W X Y Z A B C D E F
H I J K L M N O P Q R S T U V W X Y Z A B C D E F G
I J K L M N O P Q R S T U V W X Y Z A B C D E F G H
J K L M N O P Q R S T U V W X Y Z A B C D E F G H I
K L M N O P Q R S T U V W X Y Z A B C D E F G H I J
L M N O P Q R S T U V W X Y Z A B C D E F G H I J K
M N O P Q R S T U V W X Y Z A B C D E F G H I J K L
N O P Q R S T U V W X Y Z A B C D E F G H I J K L M
O P Q R S T U V W X Y Z A B C D E F G H I J K L M N
P Q R S T U V W X Y Z A B C D E F G H I J K L M N O
Q R S T U V W X Y Z A B C D E F G H I J K L M N O P
R S T U V W X Y Z A B C D E F G H I J K L M N O P Q
S T U V W X Y Z A B C D E F G H I J K L M N O P Q R
T U V W X Y Z A B C D E F G H I J K L M N O P Q R S
U V W X Y Z A B C D E F G H I J K L M N O P Q R S T
V W X Y Z A B C D E F G H I J K L M N O P Q R S T U
W X Y Z A B C D E F G H I J K L M N O P Q R S T U V
X Y Z A B C D E F G H I J K L M N O P Q R S T U V W
Y Z A B C D E F G H I J K L M N O P Q R S T U V W X
Z A B C D E F G H I J K L M N O P Q R S T U V W X Y
给定关键字 TRAIN,我们可以对消息 ENCODED IN PYTHON 进行编码如下:
- 将关键字和消息一起重复,这样很容易将一个字母映射到另一个字母:
E N C O D E D I N P Y T H O N
T R A I N T R A I N T R A I N
-
对于明文中的每个字母,找到以该字母开头的表中的行。
-
找到与所选明文字母的关键字字母相关联的列。
-
编码字符位于该行和列的交点处。
例如,以 E 开头的行与以 T 开头的列相交于字符 X。因此,密文中的第一个字母是 X。以 N 开头的行与以 R 开头的列相交于字符 E,导致密文 XE。C 与 A 相交于 C,O 与 I 相交于 W。D 和 N 映射到 Q,而 E 和 T 映射到 X。完整的编码消息是 XECWQXUIVCRKHWA。
解码遵循相反的过程。首先,找到具有共享关键字字符(T 行)的行,然后找到该行中编码字符(X)所在的位置。明文字符位于该行的列顶部(E)。
实施它
我们的程序将需要一个encode方法,该方法接受关键字和明文并返回密文,以及一个decode方法,该方法接受关键字和密文并返回原始消息。
但我们不只是写这些方法,让我们遵循测试驱动开发策略。我们将使用pytest进行单元测试。我们需要一个encode方法,我们知道它必须做什么;让我们首先为该方法编写一个测试,如下所示:
def test_encode():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("ENCODEDINPYTHON")
assert encoded == "XECWQXUIVCRKHWA"
这个测试自然会失败,因为我们没有在任何地方导入VigenereCipher类。让我们创建一个新的模块来保存该类。
让我们从以下VigenereCipher类开始:
class VigenereCipher:
def __init__(self, keyword):
self.keyword = keyword
def encode(self, plaintext):
return "XECWQXUIVCRKHWA"
如果我们在测试类的顶部添加一行from``vigenere_cipher``import``VigenereCipher并运行pytest,前面的测试将通过!我们完成了第一个测试驱动开发周期。
这可能看起来像一个荒谬的测试,但实际上它验证了很多东西。第一次我实现它时,在类名中我把 cipher 拼错成了cypher。即使是我基本的单元测试也帮助捕捉了一个错误。即便如此,返回一个硬编码的字符串显然不是密码类的最明智的实现,所以让我们添加第二个测试,如下所示:
def test_encode_character():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("E")
assert encoded == "X"
啊,现在那个测试会失败。看来我们要更加努力了。但我突然想到了一件事:如果有人尝试用空格或小写字符对字符串进行编码会怎么样?在我们开始实现编码之前,让我们为这些情况添加一些测试,这样我们就不会忘记它们。预期的行为是去除空格,并将小写字母转换为大写,如下所示:
def test_encode_spaces():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("ENCODED IN PYTHON")
assert encoded == "XECWQXUIVCRKHWA"
def test_encode_lowercase():
cipher = VigenereCipher("TRain")
encoded = cipher.encode("encoded in Python")
assert encoded == "XECWQXUIVCRKHWA"
如果我们运行新的测试套件,我们会发现新的测试通过了(它们期望相同的硬编码字符串)。但如果我们忘记考虑这些情况,它们以后应该会失败。
现在我们有了一些测试用例,让我们考虑如何实现我们的编码算法。编写代码使用像我们在早期手动算法中使用的表是可能的,但考虑到每一行只是一个按偏移字符旋转的字母表,这似乎很复杂。事实证明(我问了维基百科),我们可以使用模运算来组合字符,而不是进行表查找。
给定明文和关键字字符,如果我们将这两个字母转换为它们的数字值(根据它们在字母表中的位置,A 为 0,Z 为 25),将它们相加,并取余数模 26,我们就得到了密文字符!这是一个简单的计算,但由于它是逐个字符进行的,我们应该把它放在自己的函数中。在我们这样做之前,我们应该为新函数编写一个测试,如下所示:
from vigenere_cipher import combine_character
def test_combine_character():
assert combine_character("E", "T") == "X"
assert combine_character("N", "R") == "E"
现在我们可以编写代码使这个函数工作。老实说,我在完全正确地编写这个函数之前,不得不多次运行测试。首先,我不小心返回了一个整数,然后我忘记将字符从基于零的比例转换回正常的 ASCII 比例。有了测试可用,很容易测试和调试这些错误。这是测试驱动开发的另一个好处。代码的最终工作版本如下所示:
def combine_character(plain, keyword):
plain = plain.upper()
keyword = keyword.upper()
plain_num = ord(plain) - ord('A')
keyword_num = ord(keyword) - ord('A')
return chr(ord('A') + (plain_num + keyword_num) % 26)
现在combine_characters已经经过测试,我以为我们准备好实现我们的encode函数了。然而,在该函数内部我们首先需要一个与明文长度相同的关键字字符串的重复版本。让我们首先实现一个函数。哎呀,我是说让我们首先实现测试,如下所示:
def test_extend_keyword(): cipher = VigenereCipher("TRAIN") extended = cipher.extend_keyword(16) assert extended == "TRAINTRAINTRAINT"
在编写这个测试之前,我原本打算将extend_keyword作为一个独立的函数,接受一个关键字和一个整数。但当我开始起草测试时,我意识到更合理的做法是将它作为VigenereCipher类的辅助方法,这样它就可以访问self.keyword属性。这显示了测试驱动开发如何帮助设计更合理的 API。以下是方法的实现:
def extend_keyword(self, number):
repeats = number // len(self.keyword) + 1
return (self.keyword * repeats)[:number]
再次,这需要几次运行测试才能做对。我最终添加了一个修改后的测试副本,一个有十五个字母,一个有十六个字母,以确保它在整数除法有偶数的情况下也能工作。
现在我们终于准备好编写我们的encode方法了,如下所示:
def encode(self, plaintext):
cipher = []
keyword = self.extend_keyword(len(plaintext))
for p,k in zip(plaintext, keyword):
cipher.append(combine_character(p,k))
return "".join(cipher)
看起来正确。我们的测试套件现在应该通过了,对吗?
实际上,如果我们运行它,我们会发现仍然有两个测试失败。先前失败的编码测试实际上已经通过了,但我们完全忘记了空格和小写字符!幸好我们写了这些测试来提醒我们。我们将不得不在方法的开头添加以下行:
plaintext = plaintext.replace(" ", "").upper()
如果我们在实现某些功能的过程中想到一个边界情况,我们可以创建一个描述该想法的测试。我们甚至不必实现测试;我们只需运行assert False来提醒我们以后再实现它。失败的测试永远不会让我们忘记边界情况,它不像问题跟踪器中的工单那样容易被忽视。如果花费一段时间来修复实现,我们可以将测试标记为预期失败。
现在所有的测试都通过了。这一章非常长,所以我们将压缩解码的示例。以下是一些测试:
def test_separate_character():
assert separate_character("X", "T") == "E"
assert separate_character("E", "R") == "N"
def test_decode():
cipher = VigenereCipher("TRAIN")
decoded = cipher.decode("XECWQXUIVCRKHWA")
assert decoded == "ENCODEDINPYTHON"
以下是separate_character函数:
def separate_character(cypher, keyword):
cypher = cypher.upper()
keyword = keyword.upper()
cypher_num = ord(cypher) - ord('A')
keyword_num = ord(keyword) - ord('A')
return chr(ord('A') + (cypher_num - keyword_num) % 26)
现在我们可以添加decode方法:
def decode(self, ciphertext):
plain = []
keyword = self.extend_keyword(len(ciphertext))
for p,k in zip(ciphertext, keyword):
plain.append(separate_character(p,k))
return "".join(plain)
这些方法与编码所使用的方法非常相似。有了所有这些编写并通过的测试,我们现在可以回过头修改我们的代码,知道它仍然安全地通过测试。例如,如果我们用以下重构后的方法替换现有的encode和decode方法,我们的测试仍然通过:
def _code(self, text, combine_func):
text = text.replace(" ", "").upper()
combined = []
keyword = self.extend_keyword(len(text))
for p,k in zip(text, keyword):
combined.append(combine_func(p,k))
return "".join(combined)
def encode(self, plaintext):
return self._code(plaintext, combine_character)
def decode(self, ciphertext):
return self._code(ciphertext, separate_character)
这是测试驱动开发的最终好处,也是最重要的。一旦测试编写完成,我们可以尽情改进我们的代码,而且可以确信我们的更改没有破坏我们一直在测试的任何东西。此外,我们确切地知道我们的重构何时完成:当所有测试都通过时。
当然,我们的测试可能并不全面测试我们需要的一切;维护或代码重构仍然可能导致未经诊断的错误,这些错误在测试中不会显示出来。自动化测试并不是绝对可靠的。然而,如果出现错误,仍然可以按照测试驱动的计划进行,如下所示:
-
编写一个测试(或多个测试),复制或证明出现的错误。当然,这将失败。
-
然后编写代码使测试停止失败。如果测试全面,错误将被修复,我们将知道它是否再次发生,只要运行测试套件。
最后,我们可以尝试确定我们的测试在这段代码上的运行情况。安装了pytest覆盖插件后,pytest -coverage-report=report告诉我们,我们的测试套件覆盖了 100%的代码。这是一个很好的统计数据,但我们不应该对此过于自负。我们的代码在对包含数字的消息进行编码时还没有经过测试,因此其行为是未定义的。
练习
练习测试驱动开发。这是你的第一个练习。如果你开始一个新项目,这样做会更容易,但如果你有现有的代码需要处理,你可以通过为每个新功能编写测试来开始。随着你对自动化测试的热爱增加,这可能会变得令人沮丧。未经测试的旧代码将开始感觉僵化和紧密耦合,并且维护起来会变得不舒服;你会开始感觉自己的更改正在破坏代码,而你却无法知道,因为没有测试。但是如果你从小处开始,随着时间的推移,为代码库添加测试会改进它。
因此,要开始尝试测试驱动开发,可以开始一个全新的项目。一旦你开始意识到这些好处(你会的),并意识到编写测试所花费的时间很快就能以更易维护的代码来回报,你就会想要开始为现有代码编写测试。这就是你应该开始做的时候,而不是之前。为我们知道有效的代码编写测试是无聊的。在意识到我们认为有效的代码实际上有多破碎之前,很难对项目产生兴趣。
尝试使用内置的unittest模块和pytest编写相同的一组测试。您更喜欢哪个?unittest更类似于其他语言中的测试框架,而pytest可以说更符合 Python 的风格。两者都允许我们编写面向对象的测试,并轻松测试面向对象的程序。
在我们的案例研究中,我们使用了pytest,但我们没有涉及任何使用unittest不容易进行测试的功能。尝试调整测试以使用测试跳过或固定装置(VignereCipher的一个实例将会很有帮助)。尝试各种设置和拆卸方法,并将它们的使用与 funcargs 进行比较。哪种对您来说更自然?
尝试对您编写的测试运行覆盖报告。您是否错过了测试任何代码行?即使您有 100%的覆盖率,您是否测试了所有可能的输入?如果您正在进行测试驱动的开发,100%的覆盖率应该是很自然的,因为您会在满足该测试的代码之前编写测试。但是,如果为现有代码编写测试,很可能会有未经测试的边缘条件。
仔细考虑一下那些在某种程度上不同的值,例如:
-
当您期望完整列表时得到空列表
-
负数、零、一或无穷大与正整数相比
-
不能精确舍入到小数位的浮点数
-
当您期望数字时得到字符串
-
当您期望 ASCII 时得到 Unicode 字符串
-
当您期望有意义的东西时得到无处不在的
None值
如果您的测试涵盖了这些边缘情况,您的代码将会很完善。
总结
我们最终涵盖了 Python 编程中最重要的主题:自动化测试。测试驱动开发被认为是最佳实践。标准库unittest模块提供了一个出色的开箱即用的测试解决方案,而pytest框架具有一些更符合 Python 风格的语法。模拟可以用于在我们的测试中模拟复杂的类。代码覆盖率给我们一个估计,我们的代码有多少被我们的测试运行,但它并不告诉我们我们已经测试了正确的东西。
感谢阅读《Python 入门指南》。我希望您享受了这段旅程,并渴望开始在未来的所有项目中实现面向对象的软件!


浙公网安备 33010602011771号