Python3-面向对象编程第三版-全-
Python3 面向对象编程第三版(全)
原文:
zh.annas-archive.org/md5/8550e014ce3150b910158e8b98786756译者:飞龙
前言
本书介绍了面向对象范式的术语。它侧重于通过逐步示例进行面向对象设计。它从简单的继承开始,这是面向对象程序员工具箱中最有用的工具之一,通过异常处理到设计模式,以面向对象的方式看待面向对象的概念。
在此过程中,我们将学习如何整合 Python 编程语言中面向对象和非面向对象方面的内容。我们将学习字符串和文件操作中的复杂性,强调二进制数据和文本数据之间的区别。
然后,我们将介绍单元测试的乐趣,使用不止一个单元测试框架。最后,我们将通过 Python 的各种并发范式来探索如何使对象同时良好地协同工作。
每章都包括相关的示例和案例研究,将章节内容汇集到一个工作(如果不是完整的)程序中。
本书面向对象
本书专门针对初学者面向对象编程的人。它假设您具备基本的 Python 技能。您将深入学习面向对象原则。对于将 Python 作为粘合剂语言使用的系统管理员来说,尤其有用,他们希望提高自己的编程技能。
或者,如果您熟悉其他语言中的面向对象编程,那么这本书将帮助您理解在 Python 生态系统中应用您的知识的方法。
本书涵盖内容
本书大致分为四个主要部分。在前四章中,我们将深入探讨面向对象编程的正式原则以及 Python 如何利用这些原则。在第五章“何时使用面向对象编程”,通过第八章“字符串和序列化”,我们将通过学习这些原则如何应用于 Python 的各种内置函数,来了解 Python 对这些原则的一些独特应用。第九章“迭代器模式”,通过第十一章“Python 设计模式 II”,涵盖设计模式,最后两章讨论与 Python 编程相关的两个额外主题,这些主题可能对您有所帮助。
第一章“面向对象设计”,涵盖了重要的面向对象概念。它主要涉及诸如抽象、类、封装和继承等术语。我们还简要地看了看 UML 来模拟我们的类和对象。
第二章“Python 中的对象”,讨论了在 Python 中使用类和对象。我们将了解 Python 对象的属性和行为,以及类如何组织到包和模块中。最后,我们将了解如何保护我们的数据。
第三章,当对象相似时,更深入地探讨了继承。它涵盖了多重继承,并展示了如何扩展内置类。本章还讨论了 Python 中多态和鸭子类型的工作原理。
第四章,意料之外,探讨了异常和异常处理。我们将学习如何创建自己的异常,以及如何使用异常进行程序流程控制。
第五章,何时使用面向对象编程,处理创建和使用对象。我们将看到如何使用属性封装数据并限制数据访问。本章还讨论了 DRY 原则以及如何避免代码重复。
第六章,Python 数据结构,涵盖了 Python 内置类的面向对象特性。我们将涵盖元组、字典、列表和集合,以及一些更高级的集合。我们还将看到如何扩展这些标准对象。
第七章,Python 面向对象快捷方式,正如其名所示,处理 Python 中的省时技巧。我们将探讨许多有用的内置函数,例如使用默认参数的方法重载。我们还将看到函数本身是对象,以及这如何有用。
第八章,字符串和序列化,探讨了字符串、文件和格式化。我们将讨论字符串、字节和字节数组之间的区别,以及将文本、对象和二进制数据序列化到几个规范表示的各种方法。
第九章,迭代器模式,介绍了设计模式的概念,并涵盖了 Python 的标志性迭代器模式实现。我们将了解列表、集合和字典推导式。我们还将揭示生成器和协程的神秘面纱。
第十章,Python 设计模式 I,涵盖了几个设计模式,包括装饰器、观察者、策略、状态、单例和模板模式。每个模式都通过合适的示例和用 Python 实现的程序进行讨论。
第十一章,Python 设计模式 II,通过涵盖适配器、外观、享元、命令、抽象和组合模式,总结了我们对设计模式的讨论。还提供了更多关于惯用 Python 代码与规范实现不同的例子。
第十二章,测试面向对象程序,从为什么测试在 Python 应用程序中如此重要开始。它侧重于测试驱动开发,并介绍了两个不同的测试套件:unittest和py.test。最后,它讨论了模拟测试对象和代码覆盖率。
第十三章,并发,是对 Python 对并发模式支持(以及缺乏支持)的快速浏览。它讨论了线程、多进程、未来和现代 AsyncIO 库。
要充分利用这本书
本书中的所有示例都依赖于 Python 3 解释器。请确保您没有使用 Python 2.7 或更早版本。在撰写本文时,Python 3.7 是 Python 的最新版本。许多示例将在 Python 3 的早期版本上运行,但如果您使用的是 3.5 之前的版本,您可能会遇到很多挫折。
所有示例都应在 Python 支持的任何操作系统上运行。如果这不是情况,请将其报告为错误。
一些示例需要有效的互联网连接。无论如何,你可能想要其中一个用于课外研究和调试!
此外,本书中的一些示例依赖于 Python 不包含的第三方库。它们在使用时在书中介绍,因此你不需要提前安装它们。
下载示例代码文件
您可以从www.packt.com上的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。
你可以通过以下步骤下载代码文件:
-
在www.packt.com上登录或注册。
-
选择 SUPPORT 标签。
-
点击代码下载与勘误。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
一旦文件下载完成,请确保使用最新版本的以下软件解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Python-3-Object-Oriented-Programming-Third-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们!
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”
代码块按照以下方式设置:
class Point: def __init__(self, x=0, y=0): self.move(x, y)
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
import database
db = database.Database()
# Do queries on db
任何命令行输入或输出都按照以下方式编写:
>>> print(secret_string._SecretString__plain_string)
ACME: Top Secret
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“大多数面向对象编程语言都有构造函数的概念。”
警告或重要提示看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过电子邮件联系 questions@packtpub.com。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件联系 copyright@packt.com 并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packt.com.
第一章:面向对象设计
在软件开发中,设计通常被认为是编程之前的步骤。这并不正确;实际上,分析、编程和设计往往重叠、结合和交织在一起。在本章中,我们将涵盖以下主题:
-
面向对象意味着什么
-
面向对象设计与面向对象编程之间的区别
-
面向对象设计的基本原则
-
基本统一建模语言(UML)及其不是邪恶的一面
介绍面向对象
每个人都知道什么是对象:一个我们可以感知、触摸和操作的有形物体。我们最早与之互动的对象通常是婴儿玩具。木块、塑料形状和超大的拼图块是常见的第一个对象。婴儿很快就会学到某些物体可以执行某些操作:铃铛会响,按钮会被按下,杠杆会被拉动。
软件开发中对象的定义并没有太大的不同。软件对象可能不是你可以拿起、感知或触摸的实体,但它们是能够执行某些操作并受到某些操作影响的模型。正式来说,对象是一组 数据 和相关的 行为 的集合。
那么,知道了什么是对象,面向对象意味着什么呢?在字典中,面向 意味着 指向。所以面向对象意味着功能上指向建模对象。这是用于建模复杂系统的许多技术之一。它通过描述一组通过其数据和行为的交互对象来定义。
如果你读过任何炒作,你可能已经遇到了术语 面向对象分析、面向对象设计、面向对象分析与设计 和 面向对象编程。这些都是在一般 面向对象 框架下高度相关的概念。
事实上,分析、设计和编程都是软件开发阶段。称它们为面向对象只是简单地指明了正在追求的软件开发级别。
面向对象分析(OOA)是观察一个问题、系统或任务(某人想要将其转化为应用程序)并确定这些对象及其之间交互的过程。分析阶段完全是关于 需要做什么。
分析阶段的输出是一系列需求。如果我们能够一步完成分析阶段,我们就会将一个任务,例如 我需要一个网站,转化为一系列需求。例如,以下是一些关于网站访问者可能需要执行的操作的需求(斜体 表示动作,粗体 表示对象):
-
回顾 我们的 历史
-
申请 工作
-
浏览、比较 和 订购 产品
在某些方面,分析这个术语是不恰当的。我们之前讨论的那个婴儿并不是分析积木和拼图块。相反,她探索她的环境,操作形状,并观察它们可能适合的地方。更好的说法可能是面向对象的探索。在软件开发中,分析的初始阶段包括采访客户、研究他们的流程和排除可能性。
面向对象设计(OOD)是将这些需求转换成实施规范的过程。设计者必须命名对象、定义行为,并正式指定哪些对象可以激活其他对象上的特定行为。设计阶段完全是关于如何完成事情的问题。
设计阶段的输出是实施规范。如果我们能够一次性完成设计阶段,我们将把面向对象分析期间定义的需求转换成一组可以在(理想情况下)任何面向对象编程语言中实现的类和接口。
面向对象编程(OOP)是将这种完美定义的设计转换成能够完全满足 CEO 最初要求的实际程序的过程。
哎,对吧!如果世界能够达到这个理想状态,我们能够按部就班地遵循这些阶段,就像所有老教科书告诉我们的那样,那将是多么美好。但通常情况下,现实世界要复杂得多。无论我们多么努力地试图将这些阶段分开,我们总会发现设计过程中需要进一步分析的事情。当我们编程时,我们会发现设计中的特性需要澄清。
在大多数 21 世纪的发展中,都是在迭代开发模型下进行的。在迭代开发中,一小部分任务被建模、设计和编程,然后程序被审查和扩展,以改进每个特性并包含一系列短期开发周期中的新特性。
本书剩余部分是关于面向对象编程的,但在这章中,我们将从设计的角度介绍基本面向对象原则。这使我们能够在不与软件语法或 Python 跟踪回溯争论的情况下理解这些(相当简单)的概念。
对象和类
因此,对象是一组数据及其相关行为的集合。我们如何区分不同类型的对象?苹果和橙子都是对象,但有一个常见的谚语说它们不能比较。在计算机编程中,苹果和橙子很少被建模,但让我们假设我们正在为一个水果农场开发一个库存应用程序。为了方便这个例子,我们可以假设苹果放在桶里,橙子放在篮子里。
现在,我们有四种类型的对象:苹果、橙子、篮子和桶。在面向对象建模中,用于表示对象类型的术语是类。所以,从技术角度来说,我们现在有四种对象类别。
理解对象和类之间的区别很重要。类描述对象。它们是创建对象的蓝图。你面前桌子上可能有三只橙子。每个橙子都是一个独特的对象,但所有三个都具有与一个类相关的属性和行为:橙子的一般类别。
我们库存系统中四个对象类之间的关系可以用统一建模语言(通常简称为UML,因为三字母的首字母缩略词永远不会过时)的类图来描述。这是我们第一个类图:

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

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

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

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

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

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

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

图表显示,恰好有两个玩家可以与一个棋盘进行交互。这也表明任何一位玩家一次只能与一个棋盘进行游戏。
然而,我们讨论的是组合,而不是 UML,所以让我们思考一下棋盘是由什么组成的。我们现在并不关心玩家是由什么组成的。我们可以假设玩家有一颗心和大脑,以及其他器官,但这些对我们模型来说并不相关。实际上,没有任何阻止这位玩家本身就是深蓝(Deep Blue)这样的机器人的,它既没有心也没有脑。
那么,棋盘是由棋盘和 32 个棋子组成的。棋盘进一步由 64 个位置组成。你可以争论棋子不是棋盘的一部分,因为你可以用另一套棋子替换棋盘中的棋子。虽然在计算机化的棋类游戏中这不太可能或不可能,但它引出了聚合的概念。
聚合几乎与组合完全相同。区别在于聚合对象可以独立存在。一个位置不可能与不同的棋盘相关联,所以我们说棋盘由位置组成。但是棋子,可能独立于棋盘存在,我们说它们与该棋盘处于聚合关系。
区分聚合和组合的另一种方法是思考对象的生命周期。如果组合(外部)对象控制相关(内部)对象的创建和销毁时间,那么组合是最合适的。如果相关对象独立于组合对象创建,或者可以比该对象存在更长时间,那么聚合关系更有意义。此外,请记住,组合是聚合;聚合只是组合的一种更一般的形式。任何组合关系也是聚合关系,但反之则不然。
让我们描述我们当前的棋盘组合,并为对象添加一些属性以持有组合关系:

在 UML 中,组合关系用实心菱形表示。空心菱形表示聚合关系。你会注意到,棋盘和棋子作为棋盘的一部分被存储,就像它们作为属性存储在棋盘上一样。这表明,在实践中,一旦过了设计阶段,聚合和组合之间的区别通常是不相关的。实现时,它们的行为几乎相同。然而,当你的团队讨论不同对象如何交互时,了解这两者的区别可能会有所帮助。通常,你可以将它们视为同一事物,但当你需要区分它们时(通常是在谈论相关对象存在的时间长度时),了解区别是非常有用的。
继承
我们讨论了对象之间三种类型的关系:关联、组合和聚合。然而,我们尚未完全指定我们的棋盘,这些工具似乎并没有给我们提供我们需要的所有功能。我们讨论了玩家可能是一个人类或者可能是一块具有人工智能功能的软件的可能性。说玩家与人类相关联,或者人工智能实现是玩家对象的一部分,似乎并不合适。我们真正需要的是能够说“Deep Blue 是一个玩家”,或者“Gary Kasparov 是一个玩家”。
“是一种”关系是通过继承形成的。继承是面向对象编程中最著名、最知名、也是最被过度使用的关联。继承有点像家谱。我的祖父的姓氏是菲利普斯,我父亲继承了那个姓氏。我从他那里继承了。在面向对象编程中,不是从一个人那里继承特性和行为,一个类可以继承另一个类的属性和方法。
例如,在我们的棋盘上共有 32 个棋子,但实际上只有六种不同的棋子类型(兵、车、象、马、王和后),每种棋子在移动时表现都不同。所有这些棋子类别都有属性,例如颜色和它们所属的棋盘,但它们在棋盘上绘制时也有独特的形状,并且移动方式也不同。让我们看看这六种类型的棋子是如何从棋子类中继承的:

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

拥有这张基本图和一支铅笔来交互式地改进它,我们与图书管理员会面。他们告诉我们这是一个好的开始,但图书馆不仅仅提供书籍;它们还有 DVD、杂志和 CD,这些都没有 ISBN 或 DDC 编号。不过,所有这些类型的物品都可以通过 UPC 编号唯一识别。我们提醒图书管理员他们必须找到书架上的物品,而这些物品可能不是按 UPC 编号组织的。图书管理员解释说每种类型都是按不同的方式组织的。CD 主要是有声书,库存只有二十多本,所以它们是按作者的姓氏组织的。DVD 按类型划分,并进一步按标题组织。杂志按标题组织,然后按卷号和期号细化。正如我们所猜测的,书籍是按 DDC 编号组织的。
在没有先前的面向对象设计经验的情况下,我们可能会考虑在我们的目录中添加 DVD、CD、杂志和书籍的单独列表,并依次搜索每一个。问题是,除了某些扩展属性和识别物品的物理位置之外,这些物品的行为几乎相同。这是一个继承的任务!我们迅速更新我们的 UML 图如下:

图书管理员理解我们绘制的图的大意,但对定位功能有些困惑。我们通过一个具体的用例来解释,其中用户正在搜索单词bunnies。用户首先向目录发送搜索请求。目录查询其内部项目列表,并找到标题中包含bunnies的书籍和 DVD。在此阶段,目录并不关心它持有的是 DVD、书籍、CD 还是杂志;对目录来说,所有项目都是相同的。然而,用户想知道如何找到实体物品,因此如果目录仅仅返回一个标题列表,它就会失职。所以,它会对其发现的两个物品调用定位方法。书籍的定位方法返回一个可以用来找到存放书籍的书架的 DDS 编号。DVD 通过返回 DVD 的类型和标题来定位。然后用户可以访问 DVD 区域,找到包含该类型的区域,并按标题排序找到具体的 DVD。
正如我们解释的那样,我们绘制一个 UML 序列图,解释各种对象是如何进行通信的:

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

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

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

最初,这种组合关系看起来不如基于继承的关系自然。然而,它有一个优点,即允许我们在不向设计中添加新类的情况下添加新的贡献类型。继承在子类具有某种专业化时最有用。专业化是在子类上创建或更改属性或行为,使其在某种程度上与父类不同。创建一大堆空类仅用于识别不同类型的对象似乎很愚蠢(这种态度在 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对象的reset方法,因此它会自动将那个对象传递给方法。
然而,方法实际上只是一个恰好位于类上的函数。我们可以在类上调用函数,而不是在对象上调用方法,并明确传递我们的对象作为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参数时收到的not enough arguments错误。
如果我们不希望这两个参数是必需的,我们可以使用 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 通过使用 docstrings 来支持这一点。每个类、函数或方法头都可以有一个标准的 Python 字符串作为定义之后的第一个行(即以冒号结束的行)。这一行应该与随后的代码缩进相同。
Docstrings 是用单引号 (') 或双引号 (") 字符包围的 Python 字符串。通常,docstrings 非常长,跨越多行(风格指南建议行长度不应超过 80 个字符),可以格式化为多行字符串,用匹配的三重单引号 (''') 或三重引号 (""") 字符包围。
一个 docstring 应该清楚地简洁地总结所描述的类或方法的目的。它应该解释任何使用不是立即明显的参数,并且也是包含 API 使用短示例的好地方。任何 API 的不知情用户应该注意的警告或问题也应被记录。
为了说明 docstrings 的使用,我们将以我们完全文档化的 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模块中的任何类或函数都可以使用database.<something>的表示法访问。或者,我们可以使用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 程序员会告诉你永远不应该使用这种语法(有些人会告诉你有一些非常具体的情况这种语法是有用的,但我不同意)。他们会用一些晦涩的理由来辩解,比如它会使命名空间变得混乱,这对初学者来说并没有太多意义。学习为什么要避免这种语法的一种方法就是使用它,然后尝试在两年后理解你的代码。但我们可以通过现在快速的解释来节省一些时间和避免两年糟糕的代码!
当我们使用from database import Database在文件顶部显式导入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模块的ecommerce.contact包,并且想要将send_mail函数导入我们的paypal模块,以下将是一个有效的导入:
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 名称修改在起作用。当我们使用双下划线时,属性会被前缀为_<类名>。当类内部的方法访问该变量时,它们会自动取消修改。当外部类希望访问它时,它们必须自己进行名称修改。因此,名称修改并不保证隐私;它只是强烈建议。大多数 Python 程序员不会触摸另一个对象上的双下划线变量,除非他们有极其充分的理由这样做。
然而,大多数 Python 程序员在没有充分理由的情况下不会触碰任何单个下划线变量。因此,在 Python 中使用名称混淆的变量有很少的好理由,这样做可能会引起麻烦。例如,名称混淆的变量可能对尚未知的子类有用,并且它必须自己进行混淆。如果其他对象想要访问你的隐藏信息,就让它们知道。只需使用单个下划线前缀或一些清晰的文档字符串,表明你认为这不是一个好主意。
第三方库
Python 自带了一个可爱的标准库,这是一个包含在运行 Python 的每台机器上的包和模块的集合。然而,你很快会发现它并不包含你需要的所有内容。当这种情况发生时,你有两个选择:
-
自己编写一个支持包
-
使用他人的代码
我们不会详细介绍如何将你的包转换为库,但如果你有一个需要解决的问题,你不想编写代码(最好的程序员非常懒惰,更喜欢重用现有的、经过验证的代码,而不是自己编写),你可能在pypi.python.org/上的Python 包索引(PyPI)上找到你想要的库。一旦你确定了一个你想要安装的包,你可以使用一个名为pip的工具来安装它。然而,pip并不包含在 Python 中,但 Python 3.4 及更高版本包含一个有用的工具ensurepip。你可以使用以下命令来安装它:
$python -m ensurepip
这在 Linux、macOS 或其他 Unix 系统上可能会失败,在这种情况下,你需要成为 root 用户才能使其工作。在大多数现代 Unix 系统上,这可以通过sudo python -m ensurepip来完成。
如果你使用的是 Python 3.4 之前的较旧版本的 Python,你需要自己下载并安装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将需要一些方法来与这些界面交互。我们需要能够通过id添加一个新的笔记,并修改现有的笔记,除了我们已讨论的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 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__中接受一个名称和电子邮件地址,但只有供应商有一个功能性的order方法:
>>> 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)
我们不是将普通列表作为我们的类变量实例化,而是创建一个新的ContactList类,它扩展了内置的list数据类型。然后,我们将这个子类实例化为我们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)。混入是一个不打算独立存在的超类,而是意味着被其他类继承以提供额外功能。例如,假设我们想要向我们的Contact类添加功能,允许向self.email发送电子邮件。发送电子邮件是一个常见的任务,我们可能希望在其他许多类中使用。因此,我们可以编写一个简单的混入类来为我们处理电子邮件:
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类进行猴子补丁(我们将在第七章,Python 面向对象快捷方式)中简要介绍猴子补丁),添加一个send_mail方法。这是通过定义一个接受self参数的函数,并将其设置为现有类的属性来实现的。
当混合来自不同类的方法时,多重继承工作得很好,但当我们必须调用超类的方法时,它会变得非常混乱。我们有多个超类。我们如何知道调用哪一个?我们如何知道它们的调用顺序?
让我们通过向我们的Friend类添加家庭地址来探讨这些问题。我们可以采取几种方法。地址是由表示街道、城市、国家和其他相关细节的字符串组成的集合。我们可以将这些字符串中的每一个作为参数传递给Friend类的__init__方法。我们也可以将这些字符串存储在元组、字典或数据类(我们将在第六章,Python 数据结构)中,并将它们作为一个单一参数传递给__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 方法被调用了两次。如果该方法实际执行工作,如向银行账户存钱,这可能会导致一些有害的 bug。
在多重继承中需要注意的事情是,我们只想调用类层次结构中的 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 的直接父类!这是 下一个 方法,而不是父方法。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,它需要能够将地址相关的参数传递给下一个方法,即 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['phone'] = phone语法更新kwargs字典。 -
将
phone作为一个显式的关键字参数,但使用kwargs.update方法更新kwargs字典。如果你有多个参数要更新,这很有用。你可以使用dict(phone=phone)构造函数,或者{'phone': phone}语法来创建传递给update的字典。 -
将
phone作为一个显式的关键字参数,但使用super().__init__(phone=phone, **kwargs)语法显式地将其传递给 super 调用。
我们已经讨论了 Python 中多重继承涉及到的许多注意事项。当我们需要考虑所有可能的情况时,我们必须计划它们,我们的代码会变得混乱。基本的多重继承可能很有用,但在许多情况下,我们可能希望选择一种更透明的方式来组合两个不同的类,通常使用组合或我们在第十章中将要讨论的设计模式之一,设计模式 I,以及第十一章中将要讨论的设计模式之二。
我浪费了我生命中的整个白天,试图在复杂的多重继承层次结构中寻找我需要传递给深层嵌套子类中的哪些参数。代码的作者往往不记录他的类,并且经常传递 kwargs——以防万一将来可能需要。这是一个在不需要多重继承时使用多重继承的特别糟糕的例子。多重继承是一个大而华丽的术语,新程序员喜欢炫耀,但我建议即使你认为这是一个好选择,也要避免使用它。当你以后不得不阅读代码时,你未来的自己和其他程序员会很高兴他们理解了你的代码。
多态性
我们在第一章中介绍了多态性,面向对象设计。这是一个华丽的名字,描述了一个简单的概念:不同的行为取决于正在使用哪个子类,而不需要明确知道子类实际上是什么。作为一个例子,想象一个播放音频文件的程序。媒体播放器可能需要加载一个AudioFile对象,然后播放它。我们可以在对象上放置一个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,但它可以使用与扩展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方法。更简洁地说,鸭式类型不需要提供对象可用的整个接口;它只需要满足实际访问的接口。
抽象基类
虽然鸭式类型很有用,但事先很难判断一个类是否会满足你所需的协议。因此,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
正因如此,鸭子类型(duck typing)比经典的多态性更酷。我们可以创建关系,而不需要编写设置继承(或者更糟糕的是多重继承)的代码。
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
创建一个抽象基类
如我们之前所见,不需要有抽象基类(ABC)就能启用鸭子类型。然而,想象一下,如果我们正在创建一个带有第三方插件的媒体播放器。在这种情况下,创建一个抽象基类是明智的,以记录第三方插件应该提供的 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 装饰器。我们将在第十章 Python 设计模式 I 中讨论这些。现在,只需知道,通过将方法或属性标记为抽象的,你是在声明任何这个类的子类都必须实现该方法或提供该属性,才能被认为是这个类的合适成员。
看看如果你实现了提供或不提供这些属性的子类会发生什么:
>>> 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 的 ABC(抽象基类)有助于在不牺牲鸭子类型(duck typing)的好处的情况下提供接口的功能。
揭秘魔法
如果你想创建满足这个特定契约的抽象类,你可以复制并粘贴子类代码而不必理解它。我们将在书中介绍大多数不寻常的语法,但让我们逐行过一遍,以获得一个概览:
@classmethod
这个装饰器将方法标记为类方法。它本质上表示该方法可以在类上调用,而不是在实例化对象上调用:
def __subclasshook__(cls, C):
这定义了__subclasshook__类方法。这个特殊方法由 Python 解释器调用,以回答问题:类C是这个类的子类吗?
if cls is MediaLoader:
我们检查方法是否专门在这个类上调用,而不是,比如说,这个类的子类。这防止了例如Wav类被误认为是Ogg类的父类:
attrs = set(dir(C))
这行代码所做的只是获取类拥有的方法集合和属性,包括其类层次结构中的任何父类:
if set(cls.__abstractmethods__) <= attrs:
这一行使用集合表示法来查看此类的抽象方法集合是否已在候选类中提供。我们将在第六章中详细讲解集合,Python 数据结构。请注意,它并不检查方法是否已实现;只是检查它们是否存在。因此,一个类可以是子类,同时本身仍然是抽象类。
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'"
承认,那位特定的课程作者在答案检查方面可能有点天真。如果你之前没有见过f"""语法,我们将在第八章中详细讲解,字符串和序列化。
我们可以从定义此接口的抽象基类开始,如下所示:
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 对象上的相同方法。当然,我们可以直接在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函数返回一个特殊格式的字符串,称为通用唯一标识符(universally unique identifier),也称为全球唯一标识符。它本质上代表一个非常大的随机数,几乎但并非完全不可能与另一个类似生成的标识符冲突。这是一种创建任意 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}
"""
就是这样。你会注意到这个案例研究并没有使用大量的继承,这在考虑到章节主题的情况下可能显得有些奇怪,但鸭子类型(duck typing)却非常普遍。在 Python 程序设计中,继承关系通常会被简化为更通用的结构,随着迭代过程而变得更加灵活。以另一个例子来说,我最初将AssignmentGrader定义为继承关系,但意识到进行到一半时,使用组合(composition)会更好,原因如前所述。
下面是一段测试代码,展示了所有这些对象是如何连接在一起的:
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"))
练习
在你的工作空间中环顾四周,看看你是否能描述一些物理对象,并尝试用继承层次结构来描述它们。人类已经将世界划分为这样的分类体系数百年了,所以这不应该很难。在对象类之间是否存在非显而易见的继承关系?如果你要在计算机应用程序中模拟这些对象,它们会共享哪些属性和方法?哪些需要被多态地覆盖?它们之间会有哪些完全不同的属性?
现在写一些代码。不,不是写物理层次结构的代码;那很无聊。物理实体比方法有更多的属性。只需想想你过去一年中想要解决但从未着手的项目。对于你想要解决的问题,尝试思考一些基本的继承关系,然后实现它们。确保你也注意到了那些实际上不需要使用继承的关系。你可能在哪些地方想要使用多重继承?你确定吗?你能看到任何你想使用 mixins 的地方吗?尝试快速搭建一个原型。它不必有用,甚至不必部分工作。你已经看到了如何使用python -i测试代码;只需编写一些代码,并在交互式解释器中测试它。如果它工作,就再写一些。如果它不工作,就修复它!
现在,看看案例研究中的学生评分系统。它缺失的东西很多,不仅仅是课程内容!学生是如何进入系统的?是否有定义他们应该按什么顺序学习课程的课程表?如果你将AssignmentGrader改为在Assignment对象上使用继承而不是组合,会发生什么?
最后,尝试为 mixins 想出一些好的用例,然后实验它们,直到你意识到可能存在一个更好的使用组合的设计!
摘要
我们已经从简单的继承,这是面向对象程序员工具箱中最有用的工具之一,一路发展到多重继承——这是最复杂的之一。继承可以用来通过继承向现有类和内置类添加功能。将相似代码抽象到父类中可以帮助提高可维护性。可以使用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 内置类型,并覆盖了 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,对吧?),它会抛出一个异常。如果我们使用一个空的except子句,没有指定ZeroDivisionError,它就会指责我们在发送字符串时除以零,这根本不是一种合适的行为。
裸除语法通常是不被推荐的,即使你真的想捕获所有异常的实例。使用except Exception:语法来显式捕获所有异常类型。这告诉读者你打算捕获异常对象及其所有Exception的子类。裸除语法实际上等同于使用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派生的)。如果我们先捕获Exception,然后再捕获TypeError,那么只有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语句无论发生什么都会被执行。这在我们的代码运行完成后需要执行某些任务时非常有用(即使发生了异常)。一些常见的例子包括以下内容:
-
清理一个打开的数据库连接
-
关闭一个打开的文件
-
在网络上发送关闭握手
当我们从 try 子句内部执行 return 语句时,finally 子句也非常重要。即使不执行 try...finally 子句之后的任何代码,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一样,几乎总是应该通过终止程序来响应。同样,它应该在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,这些框架、库或 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)
当然,如果我们现在不考虑异常,我们可能只想让这个方法根据登录是否成功返回 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 的新异常。
我们还可以添加一个方法来检查特定的用户名是否已登录。在这里决定是否使用异常会更复杂。如果用户名不存在,我们应该引发异常吗?如果用户未登录,我们应该引发异常吗?
为了回答这些问题,我们需要思考如何访问该方法。通常情况下,这种方法将被用来回答是/否问题,我应该允许他们访问<某物>? 答案将是,yes,用户名有效且已登录,或者 no,用户名无效或他们未登录。因此,布尔返回值就足够了。这里不需要使用异常,只是为了使用异常:
def is_logged_in(self, username):
if username in self.users:
return self.users[username].is_logged_in
return False
最后,我们可以在我们的模块中添加一个默认的认证器实例,以便客户端代码可以通过 auth.authenticator 容易地访问它:
authenticator = Authenticator()
这行代码位于模块级别,在任何类定义之外,因此 authenticator 变量可以通过 auth.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 代码,并注意是否有应该处理异常的地方。你会如何处理它们?你是否需要处理它们?有时,让异常传播到控制台是向用户传达信息的最有效方式,特别是如果用户也是脚本的编写者。有时,你可以从错误中恢复,并允许程序继续运行。有时,你只能将错误重新格式化为用户可以理解的形式,并将其显示给他们。
一些常见的地方包括文件输入/输出(你的代码是否尝试读取一个不存在的文件?),数学表达式(你正在除的值是否为零?),列表索引(列表是否为空?),以及字典(键是否存在?)。问问自己是否应该忽略问题,通过检查值来处理它,或者使用异常来处理它。特别注意那些你可能使用了 finally 和 else 的区域,以确保在所有条件下正确执行代码。
现在编写一些新的代码。想想一个需要身份验证和授权的程序,并尝试编写一些使用我们在案例研究中构建的 auth 模块的代码。如果模块不够灵活,请随意修改它。尝试处理
以合理的方式处理所有异常。如果你在构思需要身份验证的内容时遇到困难,可以尝试向 第二章 的记事本示例添加授权,Python 中的对象,或者向 auth 模块本身添加授权——如果任何人都可以开始添加权限,那么它就不是一个非常有用的模块了!也许在允许添加或更改权限之前,需要管理员用户名和密码。
最后,尝试思考在你的代码中可以抛出异常的地方。这可以是你在编写的代码中,或者你可以作为一个练习编写一个新的项目。你可能设计一个旨在供他人使用的简单框架或 API 会更有运气;异常是代码与他人的卓越沟通工具。记住,将任何自抛出的异常作为 API 的一部分进行设计和文档化,否则他们不知道如何处理它们!
摘要
在本章中,我们深入探讨了异常的创建、处理、定义和操作等细节。异常是一种强大的方式,可以在不要求调用函数显式检查返回值的情况下,传达异常情况或错误条件。Python 中有许多内置的异常,抛出它们非常简单。处理不同异常事件有不同的语法。
在下一章中,我们将讨论如何将我们迄今为止所学的一切结合起来,以探讨面向对象编程原则和结构在 Python 应用程序中应该如何最佳应用。
第五章:何时使用面向对象编程
在前几章中,我们介绍了面向对象编程的许多定义特征。我们现在知道了面向对象设计的原理和范式,并且我们已经介绍了 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在访问或更改值时调用我们刚刚创建的两个方法。这个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属性,然后可以将它作为装饰器应用于其他函数。get 和 set 方法使用相同的名称不是必需的,但它有助于将访问同一属性的多个方法分组在一起。
我们还可以使用@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
这个类与我们的早期版本操作完全相同,包括帮助文本。你可以使用你认为更易读和优雅的语法。
决定何时使用属性
由于内置的property模糊了行为和数据之间的界限,知道何时选择属性、方法或属性可能会令人困惑。我们之前看到的用例示例是属性最常见的使用之一;我们有一个类上的数据,我们稍后想添加行为。在决定使用属性时,还需要考虑其他因素。
从技术上讲,在 Python 中,数据、属性和方法都是类上的属性。一个方法是可调用的这一事实并不能将其与其他类型的属性区分开来;实际上,我们将在第七章,《Python 面向对象快捷方式》中看到,可以创建出可以像函数一样调用的普通对象。我们还将发现函数和方法本身也是普通对象。
方法只是可调用的属性,属性只是可定制的属性这一事实可以帮助我们做出这个决定。方法通常表示动作;可以对对象执行或由对象执行的事情。当你调用一个方法时,即使只有一个参数,它也应该做些事情。方法名通常是动词。
一旦确认一个属性不是动作,我们需要在标准数据属性和属性之间做出选择。一般来说,直到你需要以某种方式控制对该属性的访问时,才使用标准属性。在两种情况下,你的属性通常是名词。属性和属性之间的唯一区别是我们可以在属性被检索、设置或删除时自动调用自定义操作。
让我们看看一个更实际的例子。自定义行为的常见需求是缓存一个难以计算或查找代价高昂的值(例如,需要网络请求或数据库查询)。目标是存储该值以避免重复调用昂贵的计算。
我们可以通过在属性上使用自定义获取器来实现这一点。第一次检索值时,我们执行查找或计算。然后,我们可以在我们的对象(或专门的缓存软件)上本地缓存该值作为私有属性,下次请求该值时,我们返回存储的数据。以下是我们可能缓存网页的方式:
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 毫秒!
自定义获取器对于需要根据其他对象属性动态计算属性的情况也非常有用。例如,我们可能想要计算一系列整数的平均值:
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的属性更合适,它既容易输入也容易阅读。
自定义设置器在验证方面很有用,正如我们之前所看到的,但它们也可以用来代理一个值到另一个位置。例如,我们可以在WebPage类中添加一个内容设置器,每当值被设置时,它会自动登录我们的网络服务器并上传一个新页面。
管理对象
我们一直关注对象及其属性和方法。现在,我们将探讨设计更高级的对象;那些管理其他对象的对象——那些将一切联系在一起的对象。
这些对象与之前的大多数示例之间的区别在于,后者通常代表具体的概念。管理对象更像是办公室经理;他们不在现场做实际的可见工作,但没有他们,部门之间就没有沟通,没有人知道他们应该做什么(尽管,如果组织管理不善,这也可能是真的!)。类似地,管理类上的属性往往指的是做可见工作的其他对象;此类上的行为在适当的时候委托给其他类,并在它们之间传递消息。
例如,我们将编写一个程序,对存储在压缩 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方法。 -
分区:外部类可以创建这个类的实例,并直接在某个文件夹上调用
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,在调用extractall、rmtree和file.write在ZipFile对象上之前,你需要将路径对象转换为字符串。
示例中的最后两行允许我们通过传递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__方法中的两个特定于ZipReplace的参数(search_string和replace_string)。然后,我们将zip_find_replace方法重命名为process_zip,并让它调用一个尚未定义的process_files方法而不是find_replace;这些名称更改有助于展示我们新类更通用的特性。请注意,我们已经完全删除了find_replace方法;这段代码是特定于ZipReplace的,并且在这里没有存在的必要。
这个新的ZipProcessor类实际上并没有定义process_files方法。如果我们直接运行它,它会引发异常。因为它不是用来直接运行的,所以我们删除了原始脚本底部的 main 调用。我们可以将其制作成一个抽象基类,以表明这个方法需要在子类中定义,但我为了简洁起见省略了它。
现在,在我们继续到图像处理应用程序之前,让我们修复原始的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 功能的 bug,我们只需更改一个 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))
这段代码使用生成器表达式,我们将在第九章中讨论,迭代器模式。这是在序列中的所有对象上执行特定操作的快捷方式。
最后,我们还需要检查Character.character,而不仅仅是之前存储的字符串字符,在home和end函数中查看它是否匹配换行符时,如下所示:
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。在这里使用组合而不是继承可能是合理的。你可以在ZipReplace和ScaleZip类中扩展类,而不是将这些类的实例传递给ZipProcessor构造函数并调用它们来执行处理部分。实现这一点。
你觉得哪个版本更容易使用?哪个更优雅?哪个更容易阅读?这些问题都是主观的;每个人的答案都不尽相同。然而,知道答案是很重要的。如果你发现你更喜欢继承而不是组合,你需要注意在日常编码中不要过度使用继承。如果你更喜欢组合,确保不要错过创建基于继承的优雅解决方案的机会。
最后,为我们在案例研究中创建的各种类添加一些错误处理器。它们应该确保只输入单个字符,不要尝试将光标移动到文件的末尾或开头,不要删除不存在的字符,并且不要在没有文件名的情况下保存文件。尽量思考尽可能多的边缘情况,并考虑如何处理它们(考虑边缘情况大约是专业程序员工作的 90%)!考虑不同的处理方式;当用户尝试移动到文件末尾时,你应该抛出一个异常,还是仅仅停留在最后一个字符上?
在你的日常编码中,请注意复制和粘贴命令。每次你在编辑器中使用它们时,考虑一下是否是一个改进程序组织的好主意,这样你就可以只保留你即将复制的代码的一个版本。
摘要
在本章中,我们专注于识别对象,特别是那些不是立即显而易见的对象;那些管理和控制的对象。对象应该既有数据也有行为,但属性可以被用来模糊两者之间的区别。DRY 原则是代码质量的重要指标,继承和组合可以用来减少代码重复。
在下一章中,我们将介绍几个内置的 Python 数据结构和对象,重点关注它们的面向对象属性以及它们如何被扩展或适应。
第六章:Python 数据结构
在我们之前的例子中,我们已经看到了许多内置的 Python 数据结构在行动。你可能也在入门书籍或教程中了解了它们中的许多。在本章中,我们将讨论这些数据结构的面向对象特性,它们应该在什么情况下代替常规类使用,以及它们不应该在什么情况下使用。特别是,我们将涵盖以下主题:
-
元组和命名元组
-
数据类
-
字典
-
列表和集合
-
如何和为什么扩展内置对象
-
三种类型的队列
空对象
让我们从最基本的 Python 内置对象开始,这是我们已经多次见过的,我们在创建的每个类中都扩展了它:object。技术上,我们可以不写子类就实例化一个object,如下所示:
>>> o = object()
>>> o.x = 5
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'object' object has no attribute 'x'
不幸的是,正如你所看到的,直接实例化的object上无法设置任何属性。这并不是因为 Python 开发者想要强迫我们编写自己的类,或者任何如此邪恶的事情。他们这样做是为了节省内存;大量的内存。当 Python 允许一个对象具有任意属性时,它需要一定量的系统内存来跟踪每个对象具有哪些属性,包括存储属性名称及其值。即使没有存储属性,也会为潜在的新属性分配内存。考虑到典型的 Python 程序中可能有数十、数百或数千个对象(每个类都扩展了对象),这样一小块内存很快就会变成大量的内存。因此,Python 默认禁用了object和几个其他内置对象的任意属性。
我们可以使用槽来限制我们自己的类上的任意属性。槽超出了本书的范围,但如果你在寻找更多信息,你现在有一个搜索词。在正常使用中,使用槽并没有多少好处,但如果你正在编写将在整个系统中重复数千次的对象,它们可以帮助节省内存,就像它们对object所做的那样。
然而,创建我们自己的空对象类是非常简单的;我们在最早期的例子中看到了它:
class MyObject:
pass
正如我们之前看到的,可以在这样的类上设置属性,如下所示:
>>> m = MyObject()
>>> m.x = "hello"
>>> m.x
'hello'
如果我们想要将属性组合在一起,我们可以将它们存储在一个空对象中,如下所示。但通常我们更倾向于使用其他专为存储数据设计的内置函数。本书一直强调,只有当你想指定数据和行为时,才应该使用类和对象。编写一个空类的主要原因是为了快速排除某些内容,知道我们稍后会回来添加行为。将行为适应类比用对象替换数据结构并更改所有对其的引用要容易得多。因此,从一开始就决定数据仅仅是数据,还是伪装成对象的,这一点很重要。一旦做出这个设计决策,其余的设计就会自然而然地就位。
元组和命名元组
元组是可以按顺序存储其他特定数量对象的对象。它们是不可变的,这意味着我们无法在运行时添加、删除或替换对象。这看起来可能是一个巨大的限制,但事实是,如果你需要修改元组,你使用的数据类型是错误的(通常,列表会更合适)。元组不可变性的主要好处是我们可以将它们用作字典的键,以及在需要对象哈希值的其他位置。
元组用于存储数据;无法将行为与元组关联。如果我们需要行为来操作元组,我们必须将元组传递给一个函数(或另一个对象上的方法)以执行该操作。
元组通常应存储彼此不同的值。例如,我们不会在元组中放入三个股票代码,但我们可以创建一个包含股票代码及其当日当前价、最高价和最低价的元组。元组的主要目的是将不同的数据片段聚合到一个容器中。因此,元组可以是最容易替换“无数据对象”习语的工具。
我们可以通过用逗号分隔值来创建一个元组。通常,元组被括号包围以使其易于阅读,并与其他表达式的其他部分区分开来,但这并非总是必需的。以下两个赋值是相同的(它们记录了一家相当盈利公司的股票、当前价格、最高价和最低价):
>>> stock = "FB", 177.46, 178.67, 175.79
>>> stock2 = ("FB", 177.46, 178.67, 175.79)
如果我们在某个其他对象内部组合一个元组,例如函数调用、列表推导或生成器,则需要括号。否则,解释器将无法知道它是一个元组还是下一个函数参数。例如,以下函数接受一个元组和日期,并返回一个包含日期和股票最高价与最低价之间中间值的元组:
import datetime
def middle(stock, date):
symbol, current, high, low = stock
return (((high + low) / 2), date)
mid_value, date = middle(
("FB", 177.46, 178.67, 175.79), datetime.date(2018, 8, 27)
)
元组直接在函数调用内部通过用逗号分隔值并包围整个元组在括号中创建。然后,元组后面跟着一个逗号,以将其与第二个参数分开。
这个例子也说明了元组解包。函数内部的第 一行将stock参数解包成四个不同的变量。元组必须与变量的数量完全相同,否则会引发异常。我们还可以在最后一条语句中看到元组解包的例子,其中函数返回的元组被解包成两个值,mid_value和date。当然,这做起来很奇怪,因为我们最初已经向函数提供了日期,但这给了我们一个机会看到解包是如何工作的。
解包是 Python 中的一个非常有用的特性。我们可以将变量分组在一起,以便更容易地存储和传递它们,但当我们需要访问所有变量时,我们可以将它们解包成单独的变量。当然,有时我们只需要访问元组中的一个变量。我们可以使用与其他序列类型(例如列表和字符串)相同的语法来访问单个值:
>>> stock = "FB", 75.00, 75.03, 74.90
>>> high = stock[2]
>>> high
75.03
我们甚至可以使用切片符号来提取元组的更大部分,如下所示:
>>> stock[1:3]
(75.00, 75.03)
这些示例虽然说明了元组有多灵活,但也展示了它们的一个主要缺点:可读性。阅读这段代码的人如何知道特定元组的第二个位置是什么?他们可以猜测,从我们分配给它的变量的名字来看,它可能是某种“高”值,但如果我们在没有分配的情况下直接在计算中访问元组值,就没有这样的提示。他们必须翻遍代码,找到元组声明的地方,才能发现它的作用。
在某些情况下,直接访问元组成员是可以的,但不要养成这种习惯。这种所谓的魔法数字(似乎从空中出现,在代码中没有明显的意义)是许多编码错误的来源,并导致数小时的沮丧调试。尽量只在知道所有值都将同时有用并且通常在访问时会被解包的情况下使用元组。如果你必须直接访问成员或使用切片,并且该值的用途不是立即显而易见,至少包括一个注释说明它从何而来。
命名元组
那么,当我们想要将值分组在一起,但又知道我们经常需要单独访问它们时,我们该怎么办呢?实际上有几个选择。我们可以使用一个空对象,正如之前所讨论的(但这很少有用,除非我们预计以后会添加行为),或者我们可以使用一个字典(如果我们不知道确切的数据量或哪些具体数据将被存储,这非常有用),我们将在后面的章节中介绍。另外两种选择是命名元组,我们将在本节中讨论,以及数据类,在下一节中介绍。
如果我们不需要向对象添加行为,并且事先知道需要存储哪些属性,我们可以使用命名元组。命名元组是带有态度的元组。它们是分组只读数据的一个好方法。
构建命名元组比正常元组要复杂一些。首先,我们必须导入 namedtuple,因为它默认不在命名空间中。然后,我们通过给它一个名称并概述其属性来描述命名元组。这返回一个类对象,我们可以用所需值实例化它,并且可以多次实例化,如下所示:
from collections import namedtuple
Stock = namedtuple("Stock", ["symbol", "current", "high", "low"])
stock = Stock("FB", 177.46, high=178.67, low=175.79)
namedtuple 构造函数接受两个参数。第一个是一个用于命名元组的标识符。第二个是命名元组所需的字符串属性列表。结果是可以通过像正常类一样调用来实例化其他对象的实例。构造函数必须具有恰好正确的参数数量,这些参数可以作为参数或关键字参数传递。与正常对象一样,我们可以创建任意数量的此类实例,每个实例具有不同的值。
请注意,不要将保留关键字(例如 class)用作命名元组的属性。
结果的 namedtuple 可以像正常元组一样打包、解包、索引、切片,以及其他处理,但我们也可以像访问对象上的单个属性一样访问它:
>>> stock.high
175.79
>>> symbol, current, high, low = stock
>>> current
177.46
记住,创建命名元组是一个两步过程。首先,使用 collections.namedtuple 创建一个类,然后构建该类的实例。
命名元组非常适合许多仅用于数据的表示,但它们并不适用于所有情况。像元组和字符串一样,命名元组是不可变的,因此一旦设置属性后,我们无法修改它。例如,自从我们开始这次讨论以来,我们公司股票的当前价值已经下降,但我们无法设置新值,如下所示:
>>> stock.current = 74.98
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
如果我们需要能够更改存储的数据,数据类可能就是我们需要的东西。
数据类
数据类基本上是常规对象,但带有一些额外的特性。
提供了用于预定义属性的简洁语法。有几种创建方法,我们将在本节中逐一探讨。
最简单的方法是使用与命名元组类似的构造,如下所示:
from dataclasses import make_dataclass
Stock = make_dataclass("Stock", "symbol", "current", "high", "low")
stock = Stock("FB", 177.46, high=178.67, low=175.79)
一旦实例化,股票对象就可以像任何常规类一样使用。您可以访问和更新属性,甚至可以给对象分配其他任意属性,如下所示:
>>> stock
Stock(symbol='FB', current=177.46, high=178.67, low=175.79)
>>> stock.current
177.46
>>> stock.current=178.25
>>> stock
Stock(symbol='FB', current=178.25, high=178.67, low=175.79)
>>> stock.unexpected_attribute = 'allowed'
>>> stock.unexpected_attribute
'allowed'
乍一看,数据类似乎并没有比具有适当构造函数的正常对象带来多少好处:
class StockRegClass:
def __init__(self, name, current, high, low):
self.name = name
self.current = current
self.high = high
self.low = low
stock_reg_class = Stock("FB", 177.46, high=178.67, low=175.79)
明显的好处是,使用 make_dataclass,您可以在一行中定义类,而不是六行。如果您再仔细一点,您会发现数据类还提供了一个比常规版本更有用的字符串表示。它还免费提供等价比较。以下示例比较了常规类与这些数据类功能:
>>> stock_reg_class
<__main__.Stock object at 0x7f506bf4ec50>
>>> stock_reg_class2 = StockRegClass("FB", 177.46, 178.67, 175.79)
>>> stock_reg_class2 == stock_reg_class
False
>>> stock2 = Stock("FB", 177.46, 178.67, 175.79)
>>> stock2 == stock
True
正如我们很快就会看到的,数据类还有许多其他有用的功能。但首先,让我们看看定义数据类的另一种(更常见)方法。参考以下代码块:
from dataclasses import dataclass
@dataclass
class StockDecorated:
name: str
current: float
high: float
low: float
如果您之前没有见过类型提示,这种语法可能看起来真的很奇怪。这些所谓的变量注解是在 Python 3.6 中引入到语言中的。我将类型提示归类为本书范围之外的内容,所以如果您想了解更多关于它们的信息,请自行进行网络搜索。现在,只需知道前面的确实是合法的 Python 语法,并且它确实有效。您不必相信我的话;只需运行代码并观察是否存在语法错误!
如果您不想使用类型提示,或者您的属性接受一个复杂类型或类型集的值,请指定类型为Any。您可以使用from typing import Any将Any类型引入您的命名空间。
dataclass函数作为类装饰器应用。我们在上一章讨论属性时遇到了装饰器。我承诺在未来的章节中会详细介绍它们。我将在第十章履行这个承诺。现在,只需知道这种语法是生成数据类所必需的。
承认,这种语法与带有__init__的常规类相比并没有少多少冗余,但它给了我们访问几个额外的数据类功能。例如,您可以指定数据类的默认值。也许市场目前关闭,您不知道当天的值:
@dataclass
class StockDefaults:
name: str
current: float = 0.0
high: float = 0.0
low: float = 0.0
您可以使用股票名称来构建这个类;其余的值将采用默认值。但您仍然可以指定值,如下所示:
>>> StockDefaults('FB')
StockDefaults(name='FB', current=0.0, high=0.0, low=0.0)
>>> StockDefaults('FB', 177.46, 178.67, 175.79)
StockDefaults(name='FB', current=177.46, high=178.67, low=175.79)
我们之前看到,数据类自动支持相等比较。如果所有属性都相等,则数据类也相等。默认情况下,数据类不支持其他比较,如小于或大于,并且不能排序。但是,如果您愿意,可以轻松添加比较,如下所示:
@dataclass(order=True)
class StockOrdered:
name: str
current: float = 0.0
high: float = 0.0
low: float = 0.0
stock_ordered1 = StockDecorated("FB", 177.46, high=178.67, low=175.79)
stock_ordered2 = StockOrdered("FB")
stock_ordered3 = StockDecorated("FB", 178.42, high=179.28, low=176.39)
在这个例子中,我们所做的唯一改变是在数据类构造函数中添加了order=True关键字。但这给了我们排序和比较以下值的机会:
>>> stock_ordered1 < stock_ordered2
False
>>> stock_ordered1 > stock_ordered2
True
>>> from pprint import pprint
>>> pprint(sorted([stock_ordered1, stock_ordered2, stock_ordered3]))
[StockOrdered(name='FB', current=0.0, high=0.0, low=0.0),
StockOrdered(name='FB', current=177.46, high=178.67, low=175.79),
StockOrdered(name='FB', current=178.42, high=179.28, low=176.39)]
当数据类接收到order=True参数时,它将默认根据每个属性定义的顺序比较值。因此,在这种情况下,它首先比较两个类上的名称。如果它们相同,它将比较当前价格。如果这些也相同,它将比较最高价和最低价。您可以通过在类的__post_init__方法内提供一个sort_index属性来自定义排序顺序,但我将让您自行上网搜索以获取此和其他高级用法(如不可变性)的完整细节,因为这一部分已经相当长,我们还有许多其他数据结构要研究。
字典
字典是非常有用的容器,允许我们直接将对象映射到其他对象。一个具有属性的空对象就像是一种字典;属性的名称映射到属性值。这实际上比听起来更接近真相;内部,对象通常将属性表示为字典,其中值是对象上的属性或方法(如果你不相信我,请查看__dict__属性)。甚至模块上的属性也是内部存储在字典中的。
字典在根据特定的键对象查找值方面非常高效。当你想根据其他对象找到某个对象时,应该始终使用字典。被存储的对象称为值;用作索引的对象称为键。我们在之前的某些示例中已经看到了字典的语法。
字典可以通过使用dict()构造函数或使用{}语法快捷方式来创建。实际上,后者格式几乎总是被使用。我们可以通过使用冒号分隔键和值,以及使用逗号分隔键值对来预先填充字典。
例如,在一个股票应用程序中,我们通常会想通过股票符号来查找价格。我们可以创建一个使用股票符号作为键的字典,以及包含当前价、最高价和最低价的元组(当然,你也可以使用命名元组或数据类作为值)。如下所示:
stocks = {
"GOOG": (1235.20, 1242.54, 1231.06),
"MSFT": (110.41, 110.45, 109.84),
}
如前所述的示例,我们可以在字典中通过请求方括号内的键来查找值。如果键不在字典中,它将引发异常,如下所示:
>>> stocks["GOOG"]
(1235.20, 1242.54, 1231.06)
>>> stocks["RIM"]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'RIM'
我们当然可以捕获KeyError并处理它。但我们还有其他选择。记住,字典是对象,即使它们的目的是持有其他对象。因此,它们具有与之相关的几种行为。其中最有用的方法之一是get方法;它接受一个键作为第一个参数,如果键不存在,则接受一个可选的默认值:
>>> print(stocks.get("RIM"))
None
>>> stocks.get("RIM", "NOT FOUND")
'NOT FOUND'
为了获得更多的控制,我们可以使用setdefault方法。如果键在字典中,此方法的行为就像get一样;它返回该键的值。否则,如果键不在字典中,它不仅会返回我们在方法调用中提供的默认值(就像get做的那样);它还会将该键设置为该值。另一种思考方式是,setdefault仅在值之前未设置的情况下才在字典中设置值。然后,它返回字典中的值;要么是已经存在的值,要么是新提供的默认值,如下所示:
>>> stocks.setdefault("GOOG", "INVALID")
(613.3, 625.86, 610.5)
>>> stocks.setdefault("BBRY", (10.87, 10.76, 10.90))
(10.50, 10.62, 10.39)
>>> stocks["BBRY"]
(10.50, 10.62, 10.39)
GOOG 股票已经在字典中,所以当我们尝试将其 setdefault 到一个无效值时,它只是返回字典中已有的值。BBRY 不在字典中,所以 setdefault 返回了默认值,并为我们设置了字典中的新值。然后我们检查新股票确实在字典中。
有三个非常实用的字典方法:keys()、values() 和 items()。前两个返回字典中所有键和所有值的迭代器。如果我们想处理所有的键或值,我们可以像使用列表一样使用它们或在 for 循环中使用它们。items() 方法可能是最有用的;它返回一个迭代器,遍历字典中每个项的 (key, value) 对。这非常适合在 for 循环中进行元组解包,以遍历相关的键和值。这个例子就是这样做的,以打印出字典中每个股票及其当前值:
>>> for stock, values in stocks.items():
... print(f"{stock} last value is {values[0]}")
...
GOOG last value is 1235.2
MSFT last value is 110.41
BBRY last value is 10.5
每个键/值元组被解包成两个变量,分别命名为 stock 和 values(我们可以使用任何我们想要的变量名,但这两个似乎都很合适),然后以格式化的字符串形式打印出来。
注意,股票显示的顺序与它们被插入的顺序相同。在 Python 3.6 之前,这不是真的,直到 Python 3.7 才成为语言定义的正式部分。在此之前,底层字典实现使用了一个不同的底层数据结构,它不是有序的。在字典中需要排序的情况相当罕见,但如果确实需要,并且需要支持 Python 3.5 或更早版本,请确保使用 OrderedDict 类,它可以从 collections 模块中获取。
因此,一旦实例化了一个字典,就有许多方法可以检索数据:我们可以使用方括号作为索引语法,使用 get 方法,使用 setdefault 方法,或者遍历 items 方法,等等。
最后,正如你可能已经知道的,我们可以使用与检索值相同的索引语法在字典中设置一个值:
>>> stocks["GOOG"] = (1245.21, 1252.64, 1245.18)
>>> stocks['GOOG']
(1245.21, 1252.64, 1245.18)
由于今天谷歌的价格更高,所以我已更新了字典中的元组值。我们可以使用这种索引语法为任何键设置值,无论该键是否在字典中。如果它在字典中,旧值将被新值替换;否则,将创建一个新的键/值对。
我们到目前为止一直在使用字符串作为字典键,但我们并不局限于字符串键。使用字符串作为键很常见,特别是当我们将数据存储在字典中以收集它们时(而不是使用具有命名属性的对象或数据类)。但我们也可以使用元组、数字,甚至是我们自己定义的对象作为字典键。我们甚至可以在单个字典中使用不同类型的键,如下所示:
random_keys = {}
random_keys["astring"] = "somestring"
random_keys[5] = "aninteger"
random_keys[25.2] = "floats work too"
random_keys[("abc", 123)] = "so do tuples"
class AnObject:
def __init__(self, avalue):
self.avalue = avalue
my_object = AnObject(14)
random_keys[my_object] = "We can even store objects"
my_object.avalue = 12
try:
random_keys[[1,2,3]] = "we can't store lists though"
except:
print("unable to store list\n")
for key, value in random_keys.items():
print("{} has value {}".format(key, value))
这段代码展示了我们可以提供给字典的几种不同类型的键。它还展示了一种不能使用的对象类型。我们已经广泛使用了列表,我们将在下一节看到更多关于它们的细节。因为列表可以在任何时候改变(例如,通过添加或删除项),它们不能哈希到特定的值。
可哈希的对象基本上有一个将对象转换为唯一整数值的算法,以便在字典中进行快速查找。这个哈希值实际上是用来在字典中查找值的。例如,字符串根据字符串中字符的字节值映射到整数,而元组则结合元组内项的哈希值。任何被认为相等(如具有相同字符的字符串或具有相同值的元组)的两个对象都应该有相同的哈希值,并且对象的哈希值永远不应该改变。然而,列表的内容可以改变,这会改变其哈希值(两个列表只有在内容相同的情况下才应该相等)。正因为如此,它们不能用作字典键。出于同样的原因,字典也不能用作其他字典的键。
相比之下,用作字典值的对象类型没有限制。例如,我们可以使用一个字符串键映射到一个列表值,或者我们可以在另一个字典中有一个嵌套字典作为值。
字典使用案例
字典非常灵活,有众多用途。字典可以有两种主要的使用方式。第一种是所有键都代表类似对象的不同实例的字典;例如,我们的股票字典。这是一个索引系统。我们使用股票符号作为值的索引。值甚至可以是复杂的、自定义的对象,具有进行买卖决策或设置止损的方法,而不仅仅是我们的简单元组。
第二种设计是每个键代表单个结构的某个方面的字典;在这种情况下,我们可能会为每个对象使用一个单独的字典,并且它们都会有相似(尽管通常不是完全相同)的键集。这种后一种情况通常也可以用命名元组或数据类来解决。这可能会让人困惑;我们如何决定使用哪种?
当我们知道数据必须存储的确切属性时,我们通常会使用数据类,特别是如果我们还想将类定义作为最终用户的文档时。
数据类是 Python 标准库中的一个较新的添加(自 Python 3.7 以来)。我预计它们将取代许多命名元组的使用场景。如果打算从函数返回它们,命名元组也可能很有用。这允许调用函数在需要时使用元组解包。数据类是不可迭代的,因此不能遍历或解包它们的值。
另一方面,如果描述对象的键事先未知,或者不同的对象在键上有所差异,那么字典会是一个更好的选择。如果我们事先不知道所有键是什么,那么使用字典可能更好。
从技术上讲,大多数 Python 对象都是在底层使用字典实现的。你可以通过将一个对象加载到交互式解释器中并查看 obj.__dict__ 魔术属性来看到这一点。当你使用 obj.attr_name 在对象上访问属性时,它本质上是在底层将查找转换为 obj['attr_name']。这比那更复杂,但你能抓住要点。甚至数据类也有一个 __dict__ 属性,这仅仅表明字典有多么灵活。请注意,并非所有对象都存储在字典中。有一些特殊类型,如列表、字典和日期时间,是以不同的方式实现的,主要是为了效率。如果 dict 实例的 __dict__ 属性是一个 dict 实例,那当然会很奇怪,不是吗?
使用 defaultdict
我们已经看到了如何使用 setdefault 来设置一个默认值,如果键不存在的话,但如果每次查找值时都需要设置默认值,这可能会变得有点单调。例如,如果我们正在编写代码来计算一个句子中某个字母出现的次数,我们可以这样做:
def letter_frequency(sentence):
frequencies = {}
for letter in sentence:
frequency = frequencies.setdefault(letter, 0)
frequencies[letter] = frequency + 1
return frequencies
每次我们访问字典时,都需要检查它是否已经有了一个值,如果没有,就将其设置为零。当每次请求一个空键时都需要这样做时,我们可以使用字典的不同版本,称为 defaultdict:
from collections import defaultdict
def letter_frequency(sentence):
frequencies = defaultdict(int)
for letter in sentence:
frequencies[letter] += 1
return frequencies
这段代码看起来根本不可能工作。defaultdict 在其构造函数中接受一个函数。每当访问一个不在字典中的键时,它会调用该函数,不带任何参数,以创建一个默认值。
在这种情况下,它调用的函数是 int,这是整数对象的构造函数。通常,我们通过在代码中输入一个整数来创建整数,如果我们使用 int 构造函数创建一个,我们传递给它我们想要创建的项目(例如,将数字字符串转换为整数)。但是,如果我们不带任何参数调用 int,它将方便地返回数字零。在这段代码中,如果字母不存在于 defaultdict 中,当我们访问它时将返回数字零。然后,我们给这个数字加一,以表示我们找到了该字母的一个实例,下次我们再找到它时,这个数字将被返回,我们再次增加值。
defaultdict对于创建容器字典很有用。如果我们想创建过去 30 天的收盘价字典,我们可以使用股票代码作为键,并将价格存储在list中;当我们第一次访问股票价格时,我们希望它创建一个空列表。只需将list传递给defaultdict,每次访问空键时它都会被调用。如果我们想将一个空集合或空字典与一个键关联起来,我们可以做类似的事情。
当然,我们也可以编写自己的函数并将它们传递给defaultdict。假设我们想创建一个defaultdict,其中每个新元素都包含一个元组,表示在该时刻插入字典中的项目数量,以及一个空列表来存储其他东西。我们不太可能想创建这样的对象,但让我们看看:
from collections import defaultdict
num_items = 0
def tuple_counter():
global num_items
num_items += 1
return (num_items, [])
d = defaultdict(tuple_counter)
当我们运行此代码时,我们可以在一个单独的语句中访问空键并将它们插入到列表中:
>>> d = defaultdict(tuple_counter)
>>> d['a'][1].append("hello")
>>> d['b'][1].append('world')
>>> d
defaultdict(<function tuple_counter at 0x82f2c6c>,
{'a': (1, ['hello']), 'b': (2, ['world'])})
当我们打印dict在最后,我们看到计数器确实在起作用。
这个例子虽然简洁地展示了如何为defaultdict创建自己的函数,但实际上代码并不好;使用全局变量意味着如果我们创建了四个不同的defaultdict段,每个都使用了tuple_counter,它将计算所有字典中的条目数,而不是每个字典都有自己的计数。最好创建一个类并将该类的方法传递给defaultdict。
计数器
你可能会认为defaultdict(int)已经很简单了,但“我想在可迭代对象中计数特定实例”的使用场景足够常见,以至于 Python 开发者为它创建了一个特定的类。之前计算字符串中字符数量的代码可以很容易地在一行中计算:
from collections import Counter
def letter_frequency(sentence):
return Counter(sentence)
Counter对象的行为类似于一个增强的字典,其中键是被计数的项,值是这些项的数量。最有用的函数之一是most_common()方法。它返回一个按计数排序的(键,计数)元组列表。你可以可选地传递一个整数参数到most_common(),以请求只获取最常见的元素。例如,你可以编写一个简单的投票应用程序如下:
from collections import Counter
responses = [
"vanilla",
"chocolate",
"vanilla",
"vanilla",
"caramel",
"strawberry",
"vanilla"
]
print(
"The children voted for {} ice cream".format(
Counter(responses).most_common(1)[0][0]
)
)
想必你会从数据库中获取响应,或者通过使用计算机视觉算法来计数举手的孩子。在这里,我们将其硬编码,以便我们可以测试most_common方法。它返回一个只有一个元素的列表(因为我们请求了一个参数中的元素)。这个元素存储了位置为零的最高选择的名字,因此调用末尾的[0][0]。我认为它们看起来像惊讶的表情,不是吗?你的电脑可能对它能够如此容易地计数数据感到惊讶。它的祖先,霍勒里斯的制表机,是为 1890 年美国人口普查开发的,一定非常嫉妒!
列表
列表是 Python 数据结构中最非面向对象的。虽然列表本身是对象,但 Python 中有大量的语法来使使用它们尽可能无痛。与许多其他面向对象的语言不同,Python 中的列表是直接可用的。我们不需要导入它们,也很少需要调用它们的方法。我们可以遍历列表而不需要显式请求迭代器对象,并且我们可以使用自定义语法构造列表(就像字典一样)。此外,列表推导和生成器表达式使它们成为计算功能的瑞士军刀。
我们不会过多地介绍语法;你已经在网络上的入门教程和本书之前的例子中看到了它。你不可能长时间编写 Python 代码而不学习如何使用列表!相反,我们将讨论何时应该使用列表,以及它们作为对象的本性。如果你不知道如何创建或向列表中追加,如何从列表中检索项目,或者什么是切片表示法,我建议你立即查阅官方 Python 教程。它可以在网上找到,网址是docs.python.org/3/tutorial/。
在 Python 中,当我们想要存储同一类型的多个实例时,通常应该使用列表;字符串列表或数字列表;通常是自定义对象的列表。当我们想要按某种顺序存储项目时,应该始终使用列表。通常,这是它们被插入的顺序,但它们也可以根据其他标准进行排序。
正如我们在上一章的案例研究中看到的,当我们需要修改内容时,列表也非常有用:向列表中插入或从列表中删除任意位置的内容,或者更新列表中的值。
与字典一样,Python 列表使用一个极其高效和调优良好的内部数据结构,这样我们就可以关注我们存储的内容,而不是如何存储它。许多面向对象的语言为队列、栈、链表和基于数组的列表提供了不同的数据结构。Python 确实提供了这些类的一些特殊实例,如果需要优化对大量数据的访问。然而,通常情况下,列表数据结构可以同时满足所有这些目的,并且程序员可以完全控制它们如何访问它。
不要使用列表来收集单个项目的不同属性。我们不想,例如,有一个特定形状的属性列表。元组、命名元组、字典和对象都更适合这个目的。在某些语言中,他们可能会创建一个列表,其中每个交替的项目是不同类型;例如,他们可能会为我们的字母频率列表写['a', 1, 'b', 3]。他们必须使用一个奇怪的循环同时访问列表中的两个元素,或者使用模运算符来确定正在访问哪个位置。
在 Python 中不要这样做。我们可以使用字典,就像我们在上一节中所做的那样,或者使用元组列表来将相关项分组在一起。以下是一个相当复杂的反例,演示了我们可以如何使用列表执行频率示例。它比字典示例复杂得多,并说明了选择正确(或错误)的数据结构对我们的代码可读性的影响。这如下所示:
import string
CHARACTERS = list(string.ascii_letters) + [" "]
def letter_frequency(sentence):
frequencies = [(c, 0) for c in CHARACTERS]
for letter in sentence:
index = CHARACTERS.index(letter)
frequencies[index] = (letter,frequencies[index][1]+1)
return frequencies
这段代码从一个可能的字符列表开始。string.ascii_letters 属性提供了一个包含所有字母(大小写)的字符串,并按顺序排列。我们将这个字符串转换为列表,然后使用列表连接(+ 运算符将两个列表合并为一个)添加一个额外的字符,一个空格。这些就是我们的频率列表中可用的字符(如果我们尝试添加不在列表中的字母,代码会出错,但异常处理程序可以解决这个问题)。
函数内部的第一行使用列表推导将 CHARACTERS 列表转换为元组列表。列表推导是 Python 中一个重要的非面向对象工具;我们将在下一章详细讲解它们。
然后,我们遍历句子中的每个字符。我们首先在 CHARACTERS 列表中查找字符的索引,我们知道这个索引在我们的频率列表中也是相同的,因为我们刚刚从第一个列表创建了第二个列表。然后我们通过创建一个新的元组来更新频率列表中的那个索引,丢弃原始的元组。除了垃圾回收和内存浪费的担忧之外,这相当难以阅读!
和字典一样,列表也是对象,并且它们有多个可以在其上调用的方法。以下是一些常见的方法:
-
append(element)方法将一个元素添加到列表的末尾 -
insert(index, element)方法在指定位置插入一个项目 -
count(element)方法告诉我们一个元素在列表中出现的次数 -
index()方法告诉我们列表中项目的索引,如果找不到它则抛出异常 -
find()方法做同样的事情,但如果没有找到项目则返回-1而不是抛出异常 -
reverse()方法确实如其名所示——将列表反转 -
sort()方法有一些相当复杂的面向对象行为,我们现在将讲解
排序列表
不带任何参数,sort 通常会按预期工作。如果是一个字符串列表,它将按字母顺序排列。这个操作是区分大小写的,所以所有大写字母都会在所有小写字母之前排序;也就是说,Z 在 a 之前。如果是一个数字列表,它们将按数值顺序排序。如果提供了一个包含不可排序项的列表,排序将引发 TypeError 异常。
如果我们想要将我们定义的对象放入列表中并使这些对象可排序,我们必须做更多的工作。特殊的 __lt__ 方法,代表“小于”,应该在类中定义,以便该类的实例可以进行比较。列表上的 sort 方法将访问每个对象上的此方法以确定它在列表中的位置。此方法应该在我们类以某种方式小于传递的参数时返回 True,否则返回 False。以下是一个相当愚蠢的类,可以根据字符串或数字进行排序:
class WeirdSortee:
def __init__(self, string, number, sort_num):
self.string = string
self.number = number
self.sort_num = sort_num
def __lt__(self, object):
if self.sort_num:
return self.number < object.number
return self.string < object.string
def __repr__(self):
return f"{self.string}:{self.number}"
__repr__ 方法使得在打印列表时很容易看到两个值。__lt__ 方法的实现是将对象与同一类的另一个实例(或任何具有 string、number 和 sort_num 属性的 duck-typed 对象)进行比较(如果这些属性缺失,它将失败)。以下输出展示了当涉及到排序时这个类是如何工作的:
>>> a = WeirdSortee('a', 4, True)
>>> b = WeirdSortee('b', 3, True)
>>> c = WeirdSortee('c', 2, True)
>>> d = WeirdSortee('d', 1, True)
>>> l = [a,b,c,d]
>>> l
[a:4, b:3, c:2, d:1]
>>> l.sort()
>>> l
[d:1, c:2, b:3, a:4]
>>> for i in l:
... i.sort_num = False
...
>>> l.sort()
>>> l
[a:4, b:3, c:2, d:1]
第一次调用 sort 时,它按数字排序,因为所有被比较的对象上 sort_num 都是 True。第二次,它按字母排序。我们只需要实现 __lt__ 方法来启用排序。然而,从技术上讲,如果实现了它,类通常也应该实现类似的 __gt__、__eq__、__ne__、__ge__ 和 __le__ 方法,以便所有 <、>、==、!=、>= 和 <= 运算符也能正常工作。通过实现 __lt__ 和 __eq__,然后应用 @total_ordering 类装饰器来提供其余部分,你可以免费获得这些功能:
from functools import total_ordering
@total_ordering
class WeirdSortee:
def __init__(self, string, number, sort_num):
self.string = string
self.number = number
self.sort_num = sort_num
def __lt__(self, object):
if self.sort_num:
return self.number < object.number
return self.string < object.string
def __repr__(self):
return f"{self.string}:{self.number}"
def __eq__(self, object):
return all((
self.string == object.string,
self.number == object.number,
self.sort_num == object.number
))
这很有用,如果我们想能够在我们的对象上使用运算符。然而,如果我们只想自定义我们的排序顺序,即使是这样做也是过度的。对于这样的用例,sort 方法可以接受一个可选的 key 参数。这个参数是一个函数,可以将列表中的每个对象转换成可以比较的对象。例如,我们可以使用 str.lower 作为键参数,在字符串列表上执行不区分大小写的排序,如下所示:
>>> l = ["hello", "HELP", "Helo"]
>>> l.sort()
>>> l
['HELP', 'Helo', 'hello']
>>> l.sort(key=str.lower)
>>> l
['hello', 'Helo', 'HELP']
记住,尽管 lower 是字符串对象上的一个方法,但它也是一个可以接受单个参数 self 的函数。换句话说,str.lower(item) 等同于 item.lower()。当我们把这个函数作为键传递时,它会在小写值上执行比较,而不是执行默认的大小写敏感比较。
有一些排序键操作非常常见,Python 团队已经提供了它们,这样你就不必自己编写它们。例如,按列表中的第一个元素以外的其他元素对元组列表进行排序是很常见的。可以使用 operator.itemgetter 方法作为键来完成这个操作:
>>> from operator import itemgetter
>>> l = [('h', 4), ('n', 6), ('o', 5), ('p', 1), ('t', 3), ('y', 2)]
>>> l.sort(key=itemgetter(1))
>>> l
[('p', 1), ('y', 2), ('t', 3), ('h', 4), ('o', 5), ('n', 6)]
itemgetter 函数是最常用的一个(如果对象是字典也可以使用),但有时你也会用到 attrgetter 和 methodcaller,它们返回对象的属性和对象方法调用的结果,用于相同的目的。有关更多信息,请参阅 operator 模块文档。
集合
列表是极其多才多艺的工具,适用于许多容器对象应用。但是,当我们想要确保列表中的对象是唯一的时候,它们就不再有用。例如,一个音乐库可能包含许多同一艺术家的歌曲。如果我们想要在库中排序并创建所有艺术家的列表,我们必须在再次添加之前检查列表,看看我们是否已经添加了该艺术家。
这就是集合发挥作用的地方。集合来自数学,它们代表一个无序的(通常是)唯一数字的组。我们可以将一个数字添加到集合中五次,但它只会出现在集合中一次。
在 Python 中,集合可以包含任何可哈希的对象,而不仅仅是数字。可哈希的对象是那些可以用作字典键的对象;因此,列表和字典又排除了。像数学集合一样,它们只能存储每个对象的单个副本。所以如果我们试图创建一个歌曲艺术家的列表,我们可以创建一个字符串名称的集合,并将它们简单地添加到集合中。这个例子从一个包含(歌曲,艺术家)元组的列表开始,创建了一个艺术家集合:
song_library = [
("Phantom Of The Opera", "Sarah Brightman"),
("Knocking On Heaven's Door", "Guns N' Roses"),
("Captain Nemo", "Sarah Brightman"),
("Patterns In The Ivy", "Opeth"),
("November Rain", "Guns N' Roses"),
("Beautiful", "Sarah Brightman"),
("Mal's Song", "Vixy and Tony"),
]
artists = set()
for song, artist in song_library:
artists.add(artist)
print(artists)
与列表和字典不同,没有为空集合提供内置语法;我们使用 set() 构造函数创建集合。然而,只要集合包含值,我们就可以使用大括号(从字典语法中借用)来创建一个集合。如果我们使用冒号分隔值对,它就是一个字典,例如 {'key': 'value', 'key2': 'value2'}。如果我们只是用逗号分隔值,它就是一个集合,例如 {'value', 'value2'}。
可以使用集合的 add 方法单独向集合中添加项目。如果我们运行这个脚本,我们会看到集合按预期工作:
{'Sarah Brightman', "Guns N' Roses", 'Vixy and Tony', 'Opeth'}
如果你注意到了输出,你会注意到项目不是按照它们被添加到集合中的顺序打印的。由于基于哈希的数据结构以提高效率,集合本身是无序的。由于这种无序性,集合不能通过索引查找项目。集合的主要目的是将世界分为两组:集合中的事物和不在集合中的事物。检查一个项目是否在集合中或遍历集合中的项目很容易,但如果我们想要对它们进行排序或排序,我们必须将集合转换为列表。这个输出显示了所有这三个活动:
>>> "Opeth" in artists
True
>>> for artist in artists:
... print("{} plays good music".format(artist))
...
Sarah Brightman plays good music
Guns N' Roses plays good music
Vixy and Tony play good music
Opeth plays good music
>>> alphabetical = list(artists)
>>> alphabetical.sort()
>>> alphabetical
["Guns N' Roses", 'Opeth', 'Sarah Brightman', 'Vixy and Tony']
虽然集合的主要特征是唯一性,但这并不是它的主要用途。当两个或多个集合组合使用时,集合最有用。集合类型上的大多数方法都作用于其他集合,使我们能够有效地组合或比较两个或多个集合中的项目。这些方法有奇怪的名字,因为它们使用了数学中的术语。我们将从三个返回相同结果的方法开始,无论调用集合和被调用集合是哪一个。
union方法是最常见的,也最容易理解。它接受一个作为参数的第二个集合,并返回一个新集合,该集合包含两个集合中的所有元素;如果一个元素在原始的两个集合中,它当然只会在新集合中出现一次。Union 就像一个逻辑or操作。实际上,如果你不喜欢调用方法,可以使用|运算符对两个集合执行并集操作。
相反,交集方法接受一个第二个集合,并返回一个新集合,该集合仅包含两个集合中都有的元素。它就像一个逻辑and操作,也可以使用&运算符来引用。
最后,symmetric_difference方法告诉我们剩下的是什么;它是那些在一个集合或另一个集合中但不在两个集合中的对象的集合。以下示例通过比较两个不同的人喜欢的某些艺术家来说明这些方法:
first_artists = {
"Sarah Brightman",
"Guns N' Roses",
"Opeth",
"Vixy and Tony",
}
second_artists = {"Nickelback", "Guns N' Roses", "Savage Garden"}
print("All: {}".format(first_artists.union(second_artists)))
print("Both: {}".format(second_artists.intersection(first_artists)))
print(
"Either but not both: {}".format(
first_artists.symmetric_difference(second_artists)
)
)
如果我们运行这段代码,我们会看到这三个方法都做了打印语句所暗示的事情:
All: {'Sarah Brightman', "Guns N' Roses", 'Vixy and Tony',
'Savage Garden', 'Opeth', 'Nickelback'}
Both: {"Guns N' Roses"}
Either but not both: {'Savage Garden', 'Opeth', 'Nickelback',
'Sarah Brightman', 'Vixy and Tony'}
这些方法无论哪个集合调用另一个集合都返回相同的结果。我们可以说first_artists.union(second_artists)或second_artists.union(first_artists)并得到相同的结果。还有一些方法根据调用者和参数返回不同的结果。
这些方法包括issubset和issuperset,它们是彼此的逆。两者都返回一个布尔值。issubset方法在调用集合中的所有项目也在作为参数传递的集合中时返回True。issuperset方法在参数中的所有项目也在调用集合中时返回True。因此,s.issubset(t)和t.issuperset(s)是相同的。如果t包含s中的所有元素,它们都会返回True。
最后,difference方法返回调用集合中但在作为参数传递的集合中不存在的所有元素;这就像半个symmetric_difference。difference方法也可以用-运算符表示。以下代码展示了这些方法的作用:
first_artists = {"Sarah Brightman", "Guns N' Roses",
"Opeth", "Vixy and Tony"}
bands = {"Guns N' Roses", "Opeth"}
print("first_artists is to bands:")
print("issuperset: {}".format(first_artists.issuperset(bands)))
print("issubset: {}".format(first_artists.issubset(bands)))
print("difference: {}".format(first_artists.difference(bands)))
print("*"*20)
print("bands is to first_artists:")
print("issuperset: {}".format(bands.issuperset(first_artists)))
print("issubset: {}".format(bands.issubset(first_artists)))
print("difference: {}".format(bands.difference(first_artists)))
这段代码只是简单地打印出从一个集合调用另一个集合时每个方法的响应。运行它给出以下输出:
first_artists is to bands:
issuperset: True
issubset: False
difference: {'Sarah Brightman', 'Vixy and Tony'}
********************
bands is to first_artists:
issuperset: False
issubset: True
difference: set()
在第二种情况下,difference方法返回一个空集,因为bands中没有不在first_artists中的项目。
union、intersection和difference方法都可以接受多个集合作为参数;正如我们可能预期的,它们将返回当操作被调用在所有参数上时创建的集合。
因此,集合上的方法清楚地表明,集合旨在操作其他集合,并且它们不仅仅是容器。如果我们从两个不同的来源接收数据,并需要以某种方式快速合并它们,以确定数据重叠或不同,我们可以使用集合操作来有效地比较它们。或者,如果我们接收到的数据可能包含已经处理过的数据的重复项,我们可以使用集合来比较这两个数据集,并只处理新数据。
最后,了解集合在检查成员资格时比列表更有效率是有价值的。如果你在集合或列表上使用value in container语法,如果container中的任何一个元素等于value,它将返回True,否则返回False。然而,在列表中,它将检查容器中的每个对象,直到找到该值,而在集合中,它只是对值进行哈希并检查成员资格。这意味着无论容器有多大,集合都会在相同的时间内找到值,但列表随着包含更多值而搜索值所需的时间会越来越长。
扩展内置函数
我们在第三章中简要讨论了当对象相似时,如何使用继承扩展内置数据类型。现在,我们将更详细地讨论我们何时想要这样做。
当我们有一个想要添加功能的内置容器对象时,我们有两个选择。我们可以创建一个新的对象,它将该容器作为属性持有(组合),或者我们可以创建内置对象的子类,并添加或修改方法以实现我们想要的功能(继承)。
如果我们只想使用容器来存储一些对象并利用该容器的特性,那么组合通常是最好的选择。这样,很容易将这种数据结构传递给其他方法,它们将知道如何与之交互。但是,如果我们想要改变容器实际工作的方式,我们就需要使用继承。例如,如果我们想要确保list中的每个元素都是一个恰好有五个字符的字符串,我们需要扩展list并重写append()方法来为无效输入抛出异常。我们可能还需要最小化地重写__setitem__(self, index, value),这是一个列表上的特殊方法,每次我们使用x[index] = "value"语法时都会被调用,以及extend()方法。
是的,列表是对象。我们之前看到的用于访问列表或字典键、遍历容器和类似任务的特殊非面向对象语法,实际上是映射到面向对象范式的 syntactic sugar。我们可能会问 Python 设计者为什么这样做。面向对象编程难道不是“总是”更好的吗?这个问题很容易回答。在以下假设的例子中,作为程序员,哪个更容易阅读?哪个需要更少的输入?:
c = a + b
c = a.add(b)
l[0] = 5
l.setitem(0, 5)
d[key] = value
d.setitem(key, value)
for x in alist:
#do something with x
it = alist.iterator()
while it.has_next():
x = it.next()
#do something with x
突出的部分显示了面向对象代码可能的样子(实际上,这些方法作为特殊双下划线方法存在于相关对象上)。Python 程序员一致认为,非面向对象的语法在阅读和编写时都更容易。然而,所有前面的 Python 语法在底层都映射到面向对象的方法。这些方法有特殊的名字(前后都有双下划线),以提醒我们还有更好的语法。然而,它给了我们覆盖这些行为的方法。例如,我们可以创建一个特殊的整数,当我们将其与另一个整数相加时,它总是返回 0,如下所示:
class SillyInt(int):
def __add__(self, num):
return 0
虽然这样做非常奇怪,但它完美地说明了这些面向对象原则的实际应用:
>>> a = SillyInt(1)
>>> b = SillyInt(2)
>>> a + b
0
__add__ 方法的神奇之处在于我们可以将其添加到我们编写的任何类中,如果我们使用该类的实例上的 + 运算符,它将被调用。这就是字符串、元组和列表连接工作的方式。
这一点对所有特殊方法都适用。如果我们想为自定义对象使用 x``in``myobj 语法,我们可以实现 __contains__。如果我们想使用 myobj[i]``=``value 语法,我们提供 __setitem__ 方法,而如果我们想使用 something``=``myobj[i],我们实现 __getitem__。
列表类中有 33 个这样的特殊方法。我们可以使用 dir 函数查看所有这些方法,如下所示:
>>> dir(list)
['__add__', '__class__', '__contains__', '__delattr__','__delitem__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__iadd__', '__imul__', '__init__', '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__rmul__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'append', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort'
此外,如果我们想了解这些方法中的任何一种的工作方式,我们可以使用 help 函数:
>>> help(list.__add__)
Help on wrapper_descriptor:
__add__(self, value, /)
Return self+value.
列表上的 + 运算符将两个列表连接起来。我们没有足够的空间来讨论本书中所有可用的特殊函数,但你现在可以使用 dir 和 help 探索所有这些功能。官方在线 Python 参考 (docs.python.org/3/) 也有大量有用的信息。特别关注 collections 模块中讨论的抽象基类。
因此,回到我们之前讨论的关于何时使用组合而不是继承的问题:如果我们需要以某种方式更改类上的任何方法,包括特殊方法,我们绝对需要使用继承。如果我们使用组合,我们可以编写执行验证或更改的方法,并要求调用者使用这些方法,但没有任何阻止他们直接访问属性的方法。他们可以在我们的列表中插入一个没有五个字符的项目,这可能会使列表中的其他方法产生混淆。
通常,需要扩展内置数据类型的需求表明我们可能使用了错误类型的数据。这并不总是这种情况,但如果我们正在寻找扩展内置类型,我们应该仔细考虑是否不同的数据结构会更合适。
案例研究
为了将所有这些内容串联起来,我们将编写一个简单的链接收集器,它将访问一个网站并收集该网站上每个页面的每个链接。在我们开始之前,我们需要一些测试数据来工作。只需编写一些包含相互链接以及指向互联网上其他网站的链接的 HTML 文件即可,类似于以下内容:
<html>
<body>
<a href="contact.html">Contact us</a>
<a href="blog.html">Blog</a>
<a href="esme.html">My Dog</a>
<a href="/hobbies.html">Some hobbies</a>
<a href="/contact.html">Contact AGAIN</a>
<a href="http://www.archlinux.org/">Favorite OS</a>
</body>
</html>
将其中一个文件命名为index.html,以便在页面被提供时首先显示。确保其他文件存在,并使事情复杂化,以便它们之间有大量的链接。如果不想自己设置,本章的示例包括一个名为case_study_serve的目录(现有最糟糕的个人网站之一!)。
现在,通过进入包含所有这些文件的目录并运行以下命令来启动一个简单的 Web 服务器:
$python3 -m http.server
这将在端口 8000 上启动一个服务器;您可以通过在 Web 浏览器中访问http://localhost:8000/来查看您创建的页面。
目标是传递给我们的收集器网站的基准 URL(在这个例子中:http://localhost:8000/),并让它创建一个包含网站上每个唯一链接的列表。我们需要考虑三种类型的 URL(指向外部网站的链接,以http://开头,绝对内部链接,以/字符开头,以及相对链接,用于其他所有内容)。我们还需要意识到页面可能通过循环相互链接;我们需要确保我们不会多次处理同一页面,否则它可能永远不会结束。考虑到所有这些独特性,听起来我们可能需要一些集合。
在我们深入探讨之前,让我们从基础知识开始。以下是连接到页面并解析该页面中所有链接的代码:
from urllib.request import urlopen
from urllib.parse import urlparse
import re
import sys
LINK_REGEX = re.compile(
"<a [^>]*href='\"['\"][^>]*>")
class LinkCollector:
def __init__(self, url):
self.url = "" + urlparse(url).netloc
def collect_links(self, path="/"):
full_url = self.url + path
page = str(urlopen(full_url).read())
links = LINK_REGEX.findall(page)
print(links)
if __name__ == "__main__":
LinkCollector(sys.argv[1]).collect_links()
这段代码很短,考虑到它所做的事情。它连接到命令行参数传递的服务器,下载页面,并提取该页面上所有的链接。__init__ 方法使用 urlparse 函数从 URL 中提取主机名;因此,即使我们传递 http://localhost:8000/some/page.html,它仍然在 http://localhost:8000/ 主机的高级别上操作。这很有意义,因为我们想要收集网站上所有的链接,尽管它假设每个页面都通过一系列链接与索引相连。
collect_links 方法连接到并从服务器下载指定的页面,并使用正则表达式在该页面上找到所有链接。正则表达式是一个非常强大的字符串处理工具。不幸的是,它们的学习曲线很陡峭;如果你以前没有使用过它们,我强烈建议你研究关于这个主题的许多完整书籍或网站。如果你认为它们不值得了解,试着在不使用它们的情况下编写前面的代码,你会改变主意的。
示例还在 collect_links 方法的中间停止以打印链接的值。这是我们编写程序时测试程序的一种常见方式:停止并输出值以确保它是我们期望的值。以下是它为我们示例输出的内容:
['contact.html', 'blog.html', 'esme.html', '/hobbies.html',
'/contact.html', 'http://www.archlinux.org/']
因此,现在我们收集了第一页上所有的链接。我们能用它做什么呢?我们不能简单地将链接放入一个集合中以去除重复项,因为链接可能是相对的或绝对的。例如,contact.html 和 /contact.html 都指向同一个页面。所以我们应该做的第一件事是将所有链接规范化为它们的完整 URL,包括主机名和相对路径。我们可以通过给我们的对象添加一个 normalize_url 方法来实现这一点:
def normalize_url(self, path, link):
if link.startswith("http://"):
return link
elif link.startswith("/"):
return self.url + link
else:
return self.url + path.rpartition(
'/')[0] + '/' + link
此方法将每个 URL 转换为包含协议和主机名的完整地址。现在,两个联系页面具有相同的值,我们可以将它们存储在集合中。我们将不得不修改 __init__ 以创建集合,并将 collect_links 修改为将所有链接放入其中。
然后,我们还需要访问所有非外部链接并将它们也收集起来。但是等等;如果我们这样做,当我们遇到相同的页面两次时,我们如何避免重复访问链接呢?看起来我们实际上需要两个集合:一个是收集到的链接集合,另一个是已访问的链接集合。这表明我们选择集合来表示数据是明智的;我们知道集合在处理多个集合时最有用。让我们按照以下方式设置它们:
class LinkCollector:
def __init__(self, url):
self.url = "http://+" + urlparse(url).netloc
self.collected_links = set()
self.visited_links = set()
def collect_links(self, path="/"):
full_url = self.url + path
self.visited_links.add(full_url)
page = str(urlopen(full_url).read())
links = LINK_REGEX.findall(page)
links = {self.normalize_url(path, link
) for link in links}
self.collected_links = links.union(
self.collected_links)
unvisited_links = links.difference(
self.visited_links)
print(links, self.visited_links,
self.collected_links, unvisited_links)
创建包含链接的标准化列表的行使用了一个set推导式(我们将在下一章详细讲解这些内容)。再次强调,该方法会停止打印当前值,以便我们可以验证我们没有混淆集合,并且确实调用了difference方法来收集unvisited_links。然后我们可以添加几行代码,遍历所有未访问的链接并将它们添加到集合中,如下所示:
for link in unvisited_links:
if link.startswith(self.url):
self.collect_links(urlparse(link).path)
if语句确保我们只从单个网站收集链接;我们不希望离开并从互联网上的所有页面收集所有链接(除非我们是谷歌或互联网档案馆!)。如果我们修改程序底部的主体代码以输出收集到的链接,我们可以看到它似乎已经收集了它们,如下面的代码块所示:
if __name__ == "__main__":
collector = LinkCollector(sys.argv[1])
collector.collect_links()
for link in collector.collected_links:
print(link)
它显示了所有收集到的链接,并且只显示一次,尽管在我的示例中许多页面相互链接多次,如下所示:
$ python3 link_collector.py http://localhost:8000
http://localhost:8000/
http://en.wikipedia.org/wiki/Cavalier_King_Charles_Spaniel
http://beluminousyoga.com
http://archlinux.me/dusty/
http://localhost:8000/blog.html
http://ccphillips.net/
http://localhost:8000/contact.html
http://localhost:8000/taichi.html
http://www.archlinux.org/
http://localhost:8000/esme.html
http://localhost:8000/hobbies.html
尽管它收集了指向外部页面的链接,但它并没有从我们链接到的任何外部页面收集链接。如果我们想收集网站上所有的链接,这是一个很棒的程序。但它并没有给我提供我可能需要构建网站地图的所有信息;它告诉我我有哪些页面,但它没有告诉我哪些页面链接到其他页面。如果我们想做到这一点,我们不得不做一些修改。
我们首先应该查看我们的数据结构。收集到的链接集合不再起作用;我们想知道哪些链接是从哪些页面链接过来的。我们可以将这个集合转换成每个访问的页面的集合字典。字典的键将代表当前集合中确切相同的数据。值将是该页面上所有链接的集合。以下是更改内容:
from urllib.request import urlopen
from urllib.parse import urlparse
import re
import sys
LINK_REGEX = re.compile(
"<a [^>]*href='\"['\"][^>]*>")
class LinkCollector:
def __init__(self, url):
self.url = "http://%s" % urlparse(url).netloc
self.collected_links = {}
self.visited_links = set()
def collect_links(self, path="/"):
full_url = self.url + path
self.visited_links.add(full_url)
page = str(urlopen(full_url).read())
links = LINK_REGEX.findall(page)
links = {self.normalize_url(path, link
) for link in links}
self.collected_links[full_url] = links
for link in links:
self.collected_links.setdefault(link, set())
unvisited_links = links.difference(
self.visited_links)
for link in unvisited_links:
if link.startswith(self.url):
self.collect_links(urlparse(link).path)
def normalize_url(self, path, link):
if link.startswith("http://"):
return link
elif link.startswith("/"):
return self.url + link
else:
return self.url + path.rpartition('/'
)[0] + '/' + link
if __name__ == "__main__":
collector = LinkCollector(sys.argv[1])
collector.collect_links()
for link, item in collector.collected_links.items():
print("{}: {}".format(link, item))
改变很少;原本创建两个集合并集的行已被替换为三条更新字典的行。第一条简单地告诉字典该页面的收集链接是什么。第二条使用setdefault为字典中尚未添加到字典中的任何项创建一个空集合。结果是包含所有链接作为键的字典,映射到所有内部链接的链接集合,以及外部链接的空集合。
最后,我们不再递归调用collect_links,而是可以使用队列来存储尚未处理的链接。这种实现不会支持并发,但这将是创建一个多线程版本的良好第一步,该版本可以并行发送多个请求以节省时间:
from urllib.request import urlopen
from urllib.parse import urlparse
import re
import sys
from queue import Queue
LINK_REGEX = re.compile("<a [^>]*href='\"['\"][^>]*>")
class LinkCollector:
def __init__(self, url):
self.url = "http://%s" % urlparse(url).netloc
self.collected_links = {}
self.visited_links = set()
def collect_links(self):
queue = Queue()
queue.put(self.url)
while not queue.empty():
url = queue.get().rstrip('/')
self.visited_links.add(url)
page = str(urlopen(url).read())
links = LINK_REGEX.findall(page)
links = {
self.normalize_url(urlparse(url).path, link)
for link in links
}
self.collected_links[url] = links
for link in links:
self.collected_links.setdefault(link, set())
unvisited_links = links.difference(self.visited_links)
for link in unvisited_links:
if link.startswith(self.url):
queue.put(link)
def normalize_url(self, path, link):
if link.startswith("http://"):
return link.rstrip('/')
elif link.startswith("/"):
return self.url + link.rstrip('/')
else:
return self.url + path.rpartition('/')[0] + '/' + link.rstrip('/')
if __name__ == "__main__":
collector = LinkCollector(sys.argv[1])
collector.collect_links()
for link, item in collector.collected_links.items():
print("%s: %s" % (link, item))
我不得不手动在normalize_url方法中删除任何尾随的正斜杠,以消除此代码版本中的重复项。
因为最终结果是未排序的字典,所以没有限制链接应该以什么顺序处理。因此,我们也可以同样容易地使用LifoQueue而不是Queue。在这种情况下,优先队列可能没有太多意义,因为没有明显的优先级可以附加到链接上。
练习
学习如何选择正确的数据结构的最佳方式是先错误地做几次(故意或意外地!)!取一些你最近写的代码,或者写一些使用列表的新代码。尝试使用不同的数据结构重写它。哪一些更有意义?哪一些没有?哪一些的代码最优雅?
用几对不同数据结构对的方法试一试。你可以查看之前章节练习中做的例子。有没有对象有方法,你可以使用数据类、namedtuple或dict来代替?尝试两种方法,看看。有没有字典可以变成集合,因为你实际上并没有访问值?你有没有检查重复的列表?一个集合是否足够?或者可能是几个集合?队列实现中的一个是否更高效?将 API 限制在栈顶而不是允许随机访问列表是否有用?
如果你想要一些具体的例子来操作,尝试调整链接收集器,使其也保存每个链接使用的标题。也许你可以生成一个 HTML 网站地图,列出网站上的所有页面,并包含一个指向其他页面的链接列表,这些链接使用相同的链接标题。
你最近是否编写过任何可以通过继承内置类型并重写一些特殊双下划线方法来改进的容器对象?你可能需要做一些研究(使用dir和help,或者 Python 库参考)来找出哪些方法需要重写。你确定继承是正确的工具吗?基于组合的解决方案可能更有效?在你决定之前,尝试两种方法(如果可能的话)。尝试找到不同的情况下,每种方法比另一种方法更好的情况。
如果你在这章开始之前就已经熟悉了各种 Python 数据结构和它们的用途,你可能感到无聊。但如果是这样的话,你很可能过度使用了数据结构!看看你的一些旧代码,并尝试将其重写为使用更多自定义类。仔细考虑替代方案,并尝试所有方案;哪一个能让你构建出最易读和可维护的系统?
总是批判性地评估你的代码和设计决策。养成回顾旧代码的习惯,并注意自从你编写它以来你对“良好设计”的理解是否发生了变化。软件设计有很大的美学成分,就像在画布上用油画的艺术家一样,我们都需要找到最适合我们的风格。
摘要
我们已经介绍了几个内置的数据结构,并尝试理解如何为特定的应用选择一个。有时,我们能做的最好的事情就是创建一个新的对象类,但通常,内置的其中一个就能提供我们所需的一切。当它不能满足需求时,我们总能使用继承或组合来适应我们的使用场景。我们甚至可以覆盖特殊方法来完全改变内置语法的行为。
在下一章中,我们将讨论如何整合 Python 的面向对象和非面向对象方面。在这个过程中,我们会发现 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,这两个函数接受一个可迭代对象,如果所有或任何项评估为真(例如非空字符串或列表、非零数字、非None对象或字面量True),则返回True。 -
eval、exec和compile,这三个函数在解释器内部执行字符串作为代码。对这些函数要小心;它们不安全,因此不要执行未知用户提供的代码(通常,假设所有未知用户都是恶意的、愚蠢的或两者兼而有之)。 -
hasattr、getattr、setattr和delattr,这些函数允许通过对象的字符串名称来操作其属性。 -
zip函数接受两个或更多序列,并返回一个新的元组序列,其中每个元组包含每个序列的单个值。 -
以及更多!请参阅
dir(__builtins__)中列出的每个函数的解释器帮助文档。
文件输入/输出
我们迄今为止的示例已经涉及到文件系统,但几乎没有考虑底层发生了什么。然而,操作系统实际上将文件表示为字节序列,而不是文本。我们将在第八章深入探讨字节和文本之间的关系,字符串和序列化。现在,请注意,从文件中读取文本数据是一个相当复杂的过程。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对象,而当我们写入它时,如果我们尝试传递一个文本对象,它将失败。
用于控制文件打开方式的这些模式字符串相当晦涩,既不符合 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 是否成功。这并不太符合 Python 风格。当然,还有更优雅的方式来处理。
如果我们在一个文件对象上运行 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 语句中使用来清理套接字,当我们完成时。在 threading 模块中的锁可以自动释放锁,当语句被执行完毕。
最有趣的是,因为with语句可以应用于具有适当特殊方法的任何对象,我们可以在自己的框架中使用它。例如,记住字符串是不可变的,但有时你需要从多个部分构建一个字符串。为了效率,这通常是通过将组件字符串存储在列表中并在最后连接它们来完成的。让我们创建一个简单的上下文管理器,允许我们构建一个字符序列,并在退出时自动将其转换为字符串:
class StringJoiner(list):
def __enter__(self):
return self
def __exit__(self, type, value, tb):
self.result = "".join(self)
此代码将上下文管理器所需的两个特殊方法添加到它继承的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__方法,并且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}
因为字典是从左到右按顺序解包的,所以生成的字典将包含所有默认选项,其中任何关键字参数选项将替换一些键。这里有一个例子:
>>> x = {'a': 1, 'b': 2}
>>> y = {'b': 11, 'c': 3}
>>> z = {**x, **y}
>>> z
{'a': 1, 'b': 11, 'c': 3}
函数也是对象
过度强调面向对象原则的编程语言往往对不是方法的函数持批评态度。在这些语言中,你被期望创建一个对象来包装涉及的单个方法。有许多情况下,我们希望传递一个简单的对象来执行某个动作。这在事件驱动编程中最为常见,例如图形工具包或异步服务器;我们将在第十章设计模式 I 和第十一章设计模式 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)
在生产中,此代码应该使用 docstrings 添加额外的文档!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参数。这将改变该对象的所有实例的方法,即使是已经实例化的实例。显然,以这种方式替换方法既可能危险也可能令人困惑,难以维护。阅读代码的人会看到调用了某个方法,并会在原始类上查找该方法。但原始类上的方法并不是被调用的那个方法。弄清楚实际发生了什么可能成为一个棘手、令人沮丧的调试会话。
虽然它确实有其用途。通常,在运行时替换或添加方法(称为猴子补丁)用于自动化测试。如果我们正在测试客户端-服务器应用程序,我们可能不想在测试客户端时实际连接到服务器;这可能会导致意外转账或尴尬的测试邮件发送给真实的人。相反,我们可以设置我们的测试代码来替换对象上发送请求到服务器的一些关键方法,这样它只会记录这些方法已被调用。
猴子补丁也可以用来修复与我们交互的第三方代码中的错误或添加功能,而这些代码并不完全按照我们的需求运行。然而,应该谨慎使用;它几乎总是一团糟的解决方案。有时,尽管如此,它是唯一一种适应现有库以满足我们需求的方法。
可调用对象
正如函数是可以设置属性的物体一样,我们也可以创建一个可以被当作函数调用的对象。
任何对象都可以通过简单地给它一个接受所需参数的__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 更美观。
然后,加载方法使用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 通常通过提供传统面向对象语法的语法快捷方式来实现这些功能。了解这些工具背后的面向对象原则使我们能够更有效地在我们的类中使用它们。
我们讨论了一系列内置函数和文件输入输出操作。当我们调用带有参数、关键字参数和可变参数列表的函数时,有大量的不同语法可供选择。上下文管理器对于在两个方法调用之间嵌入代码的常见模式非常有用。甚至函数也是对象,反之,任何普通对象都可以被赋予可调用性。
在下一章中,我们将学习更多关于字符串和文件操作的知识,甚至还会花一些时间探讨标准库中最不面向对象的主题之一:正则表达式。
第八章:字符串与序列化
在我们深入研究高级设计模式之前,让我们深入探讨 Python 中最常见的对象之一:字符串。我们会看到字符串远不止表面看起来那么简单,还会涵盖在字符串中搜索模式以及序列化数据以进行存储或传输。
尤其是我们将探讨以下主题:
-
字符串、字节和字节数组的复杂性
-
字符串格式化的来龙去脉
-
几种序列化数据的方法
-
神秘的正则表达式
字符串
字符串是 Python 中的一个基本原始类型;我们已经在迄今为止的几乎所有示例中都使用了它们。它们所做的只是表示一个不可变的字符序列。然而,尽管你可能之前没有考虑过,字符这个词有点模糊;Python 字符串能否表示带重音的字符序列?中文字符?那么希腊文、西里尔文或波斯文呢?
在 Python 3 中,答案是肯定的。Python 字符串全部以 Unicode 表示,这是一个字符定义标准,可以表示地球上任何语言的几乎所有字符(以及一些虚构语言和随机字符)。这是无缝完成的。因此,让我们将 Python 3 字符串视为一个不可变的 Unicode 字符序列。我们在之前的示例中已经触及了许多字符串操作的方法,但让我们在这里快速总结一下:字符串理论的快速入门!
字符串操作
如你所知,在 Python 中,可以通过将字符序列用单引号或双引号括起来来创建字符串。使用三个引号字符可以轻松创建多行字符串,并且可以通过将它们并排放置来连接多个硬编码的字符串。以下是一些示例:
a = "hello"
b = 'world'
c = '''a multiple
line string'''
d = """More
multiple"""
e = ("Three " "Strings "
"Together")
那最后一个字符串会被解释器自动组合成一个单一的字符串。也可以使用+运算符来连接字符串(如"hello " + "world")。当然,字符串不必是硬编码的。它们也可以来自各种外部来源,例如文本文件、用户输入,或者可以在网络上编码。
当遗漏逗号时,相邻字符串的自动连接可能会产生一些令人捧腹的错误。然而,当需要将长字符串放入函数调用中而不超过 Python 风格指南建议的 79 个字符行长度限制时,这却非常有用。
与其他序列一样,字符串可以逐个字符迭代(按字符索引),切片或连接。语法与列表相同。
str类上有许多方法来简化字符串操作。Python 解释器中的dir和help命令可以告诉我们如何使用它们的所有方法;我们将直接考虑一些更常见的方法。
几个布尔便利方法帮助我们确定字符串中的字符是否匹配某种模式。以下是这些方法的总结。其中大多数,如 isalpha、isupper/islower、startswith/endswith,都有明显的解释。isspace 方法也很明显,但请记住,所有空白字符(包括制表符和换行符)都被考虑在内,而不仅仅是空格字符。
istitle 方法返回 True,如果每个单词的首字母都大写且所有其他字母都小写。请注意,它并不严格遵循英语语法对标题格式的定义。例如,利·亨特的诗作《手套与狮子》应该是一个有效的标题,即使不是所有单词都大写。罗伯特·塞尔的《萨姆·麦基的火化》也应该是一个有效的标题,即使最后一个单词的中间有一个大写字母。
在使用 isdigit、isdecimal 和 isnumeric 方法时要小心,因为它们比我们预期的要复杂。除了我们习惯的 10 个数字之外,许多 Unicode 字符也被认为是数字。更糟糕的是,我们用来从字符串构造浮点数的点字符不被认为是十进制字符,所以 '45.2'.isdecimal() 返回 False。真正的十进制字符由 Unicode 值 0660 表示,如 45.2(或 45\u06602)。此外,这些方法不验证字符串是否是有效的数字;127.0.0.1 对所有三种方法都返回 True。我们可能会认为我们应该用那个十进制字符而不是点来表示所有的数值,但将那个字符传递给 float() 或 int() 构造函数会将那个十进制字符转换为零:
>>> float('45\u06602')
4502.0
所有这些不一致的结果是,布尔数值检查几乎没有任何用处。我们通常使用正则表达式(本章后面将讨论)来确认字符串是否匹配特定的数值模式会更好。
其他用于模式匹配的方法不返回布尔值。count 方法告诉我们给定子字符串在字符串中出现的次数,而 find、index、rfind 和 rindex 告诉我们在原始字符串中给定子字符串的位置。两个 r(代表 right 或 reverse)方法从字符串的末尾开始搜索。find 方法在找不到子字符串时返回 -1,而 index 在这种情况下会引发 ValueError。看看这些方法在实际中的应用:
>>> s = "hello world"
>>> s.count('l')
3
>>> s.find('l')
2
>>> s.rindex('m')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: substring not found
大多数剩余的字符串方法返回字符串的转换。upper、lower、capitalize 和 title 方法创建具有给定格式的新字符串,其中包含所有字母字符。translate 方法可以使用字典将任意输入字符映射到指定的输出字符。
对于所有这些方法,请注意输入字符串保持不变;而是返回一个新的str实例。如果我们需要操作结果字符串,我们应该将其分配给一个新的变量,例如new_value = value.capitalize()。通常,一旦我们完成了转换,我们就不再需要旧值了,所以一个常见的习惯是将它分配给同一个变量,例如value = value.title()。
最后,有一些字符串方法返回或操作列表。split方法接受一个子字符串,并将字符串分割成一个字符串列表,其中该子字符串出现的位置。你可以传递一个数字作为第二个参数来限制结果字符串的数量。rsplit方法在没有限制字符串数量时与split行为相同,但如果你提供了限制,它将从字符串的末尾开始分割。partition和rpartition方法仅在子字符串的第一个或最后一个出现处分割字符串,并返回一个包含三个值的元组:子字符串之前的字符、子字符串本身以及子字符串之后的字符。
作为split的逆操作,join方法接受一个字符串列表,并返回所有这些字符串通过在它们之间放置原始字符串组合在一起。replace方法接受两个参数,并返回一个字符串,其中每个第一个参数的实例都被第二个参数替换。以下是一些这些方法在实际中的应用:
>>> s = "hello world, how are you"
>>> s2 = s.split(' ')
>>> s2
['hello', 'world,', 'how', 'are', 'you']
>>> '#'.join(s2)
'hello#world,#how#are#you'
>>> s.replace(' ', '**')
'hello**world,**how**are**you'
>>> s.partition(' ')
('hello', ' ', 'world, how are you')
就这样,我们快速浏览了str类中最常见的方法!现在,让我们看看 Python 3 的字符串和变量组合方法来创建新的字符串。
字符串格式化
Python 3 拥有强大的字符串格式化和模板机制,允许我们构建由硬编码文本和穿插变量组成的字符串。我们已经在许多之前的例子中使用过它,但它比我们使用的简单格式化说明符要灵活得多。
一个字符串可以通过在开引号前加上 f 来转换成一个格式字符串(也称为f-string),例如f"hello world"。如果这样的字符串包含特殊字符{和},可以使用周围作用域中的变量来替换它们,如下例所示:
name = "Dusty"
activity = "writing"
formatted = f"Hello {name}, you are currently {activity}."
print(formatted)
如果我们运行这些语句,它会按照以下顺序替换花括号中的变量:
Hello Dusty, you are currently writing.
转义花括号
花括号字符在字符串中除了格式化之外通常很有用。我们需要一种方法来在它们需要作为自身显示而不是被替换的情况下进行转义。这可以通过加倍花括号来实现。例如,我们可以使用 Python 格式化一个基本的 Java 程序:
classname = "MyClass"
python_code = "print('hello world')"
template = f"""
public class {classname} {{
public static void main(String[] args) {{
System.out.println("{python_code}");
}}
}}"""
print(template)
在模板中我们看到{{或}}序列——即包围 Java 类和方法定义的花括号——我们知道 f-string 将用单个花括号替换它们,而不是周围方法中的某个参数。以下是输出:
public class MyClass {
public static void main(String[] args) {
System.out.println("print('hello world')");
}
}
输出类的名称和内容已被两个参数替换,而双花括号已被单花括号替换,从而生成一个有效的 Java 文件。结果证明,这是打印最简单的 Java 程序(该程序可以打印最简单的 Python 程序)的 Python 程序中最简单的一种。
f-string 可以包含 Python 代码
我们不仅可以将简单的字符串变量传递给 f-string 方法。任何原始数据类型,如整数或浮点数,都可以进行格式化。更有趣的是,包括列表、元组、字典和任意对象在内的复杂对象也可以使用,并且我们可以在 format 字符串中访问这些对象的索引和变量或调用这些对象上的函数。
例如,如果我们的电子邮件消息将 From 和 To 电子邮件地址组合成一个元组,并将主题和消息放入一个字典中,出于某种原因(可能是因为我们需要使用现有的 send_mail 函数作为输入),我们可以这样格式化:
emails = ("a@example.com", "b@example.com")
message = {
"subject": "You Have Mail!",
"message": "Here's some mail for you!",
}
formatted = f"""
From: <{emails[0]}>
To: <{emails[1]}>
Subject: {message['subject']}
{message['message']}"""
print(formatted)
模板字符串中花括号内的变量看起来有点奇怪,让我们看看它们在做什么。两个电子邮件地址是通过 emails[x] 查找的,其中 x 要么是 0 要么是 1。方括号内带有数字的索引查找与我们在常规 Python 代码中看到的是同一类型的,所以 emails[0] 指的是 emails 元组中的第一个元素。索引语法适用于任何可索引的对象,因此当我们访问 message[subject] 时,我们看到类似的行为,只不过这次我们在字典中查找一个字符串键。请注意,与 Python 代码不同,在字典查找中我们不需要在字符串周围加上引号。
如果我们有嵌套的数据结构,我们甚至可以进行多级查找。如果我们修改上面的代码,将 emails 元组放入 message 字典中,我们可以使用以下索引查找:
message["emails"] = emails
formatted = f"""
From: <{message['emails'][0]}>
To: <{message['emails'][1]}>
Subject: {message['subject']}
{message['message']}"""
print(formatted)
我不建议经常这样做,因为模板字符串很快就会变得难以理解。
或者,如果您有一个对象或类,您可以在 f-string 中执行对象查找或甚至调用方法。让我们再次更改我们的电子邮件消息数据,这次到一个类:
class EMail:
def __init__(self, from_addr, to_addr, subject, message):
self.from_addr = from_addr
self.to_addr = to_addr
self.subject = subject
self._message = message
def message(self):
return self._message
email = EMail(
"a@example.com",
"b@example.com",
"You Have Mail!",
"Here's some mail for you!",
)
formatted = f"""
From: <{email.from_addr}>
To: <{email.to_addr}>
Subject: {email.subject}
{email.message()}"""
print(formatted)
与之前的示例相比,这个示例中的模板可能更容易阅读,但创建一个 email 类的开销增加了 Python 代码的复杂性。为了将对象包含在模板中而创建一个类是愚蠢的。通常,我们会使用这种查找,如果我们试图格式化的对象已经存在。
几乎任何你期望返回字符串(或可以由 str() 函数转换为字符串的值)的 Python 代码都可以在 f-string 中执行。作为一个例子,看看它有多强大,你甚至可以在格式字符串参数中使用列表推导式或三元运算符:
>>> f"['a' for a in range(5)]"
"['a' for a in range(5)]"
>>> f"{'yes' if True else 'no'}"
'yes'
使其看起来正确
能够在模板字符串中包含变量是件好事,但有时变量需要一点强制才能在输出中看起来像我们想要的那样。例如,如果我们正在执行货币计算,我们可能会得到一个我们不希望在模板中显示的长小数:
subtotal = 12.32
tax = subtotal * 0.07
total = subtotal + tax
print(
"Sub: ${0} Tax: ${1} Total: ${total}".format(
subtotal, tax, total=total
)
)
如果我们运行这段格式化代码,输出结果并不完全像正确的货币格式:
Sub: $12.32 Tax: $0.8624 Total: $13.182400000000001
技术上,我们永远不应该在这种货币计算中使用浮点数;我们应该构建decimal.Decimal()对象。浮点数是危险的,因为它们的计算在特定精度水平以上是不准确的。但我们正在看字符串,而不是浮点数,货币是一个很好的格式化例子!
为了修复前面的format字符串,我们可以在大括号内包含一些额外的信息来调整参数的格式。我们可以自定义很多东西,但大括号内的基本语法是相同的。在提供模板值后,我们包括一个冒号,然后是一些特定的格式化语法。这是一个改进的版本:
print(
"Sub: ${0:0.2f} Tax: ${1:0.2f} "
"Total: ${total:0.2f}".format(subtotal, tax, total=total)
)
冒号后面的0.2f格式说明符基本上是这样说的,从左到右:
-
0:对于小于一的值,确保在十进制点的左侧显示零 -
.:显示小数点 -
2:显示两位小数 -
f:将输入值格式化为浮点数
我们还可以指定每个数字应该在屏幕上占用特定数量的字符,通过在点号之前放置一个值。这可以用于输出表格数据,例如:
orders = [("burger", 2, 5), ("fries", 3.5, 1), ("cola", 1.75, 3)]
print("PRODUCT QUANTITY PRICE SUBTOTAL")
for product, price, quantity in orders:
subtotal = price * quantity
print(
f"{product:10s}{quantity: ⁹d} "
f"${price: <8.2f}${subtotal: >7.2f}"
)
好吧,这个格式字符串看起来相当吓人,所以在我们将其分解成可理解的部分之前,让我们先看看它是如何工作的:
PRODUCT QUANTITY PRICE SUBTOTAL
burger 5 $2.00 $ 10.00
fries 1 $3.50 $ 3.50
cola 3 $1.75 $ 5.25
真棒!那么,这实际上是如何发生的呢?我们有四个变量需要格式化,在for循环的每一行中。第一个变量是一个使用{product:10s}格式化的字符串。从右到左读起来更容易:
-
s表示这是一个字符串变量。 -
10表示它应该占用 10 个字符。默认情况下,对于字符串,如果字符串的长度小于指定的字符数,它会在字符串的右侧添加空格,使其足够长(但是请注意:如果原始字符串太长,它不会被截断!)。 -
product:, 当然,是正在格式化的变量或 Python 表达式的名称。
quantity值的格式化器是{quantity: ⁹d}。你可以从右到左这样解释这个格式:
-
d代表一个整数值。 -
9告诉我们值应该在屏幕上占用九个字符。 -
^告诉我们数字应该在这个可用填充区的中心对齐;这使得列看起来更专业。 -
(空格)告诉格式化器使用空格作为填充字符。对于整数,默认情况下,额外的字符是零。
-
quantity:是正在格式化的变量。
所有这些指定符都必须按照正确的顺序排列,尽管它们都是可选的:首先填充,然后对齐,接着是大小,最后是类型。
我们对price和subtotal的指定符做类似处理。对于price,我们使用{2:<8.2f};对于subtotal,我们使用{3:>7.2f}。在这两种情况下,我们指定空格作为填充字符,但分别使用<和>符号来表示数字应在至少八或七个字符的最小空间内左对齐或右对齐。此外,每个浮点数应格式化为两位小数。
对于不同类型的类型字符可以影响格式化输出。我们已经看到了s、d和f类型,分别用于字符串、整数和浮点数。大多数其他格式指定符都是这些类型的替代版本;例如,o代表八进制格式,X代表十六进制格式,如果格式化整数。n类型指定符可以用于在当前区域设置的格式中格式化整数分隔符。对于浮点数,%类型将乘以 100 并将浮点数格式化为百分比。
自定义格式化程序
虽然这些标准格式化程序适用于大多数内置对象,但其他对象也可以定义非标准指定符。例如,如果我们将一个datetime对象传递给format,我们可以使用datetime.strftime函数中使用的指定符,如下所示:
import datetime
print("{the_date:%Y-%m-%d %I:%M%p }".format(
datetime.datetime.now()))
甚至可以为我们自己创建的对象编写自定义格式化程序,但这超出了本书的范围。如果你需要在代码中这样做,请查看是否需要重写__format__特殊方法。
Python 的格式化语法非常灵活,但它是一个难以记住的小型语言。我每天都在使用它,仍然偶尔需要查阅文档中忘记的概念。它也不够强大,无法满足严肃的模板需求,例如生成网页。如果你需要做更多基本的字符串格式化之外的事情,可以查看几个第三方模板库。
格式化方法
有一些情况下你将无法使用 f-string。首先,你不能用不同的变量重用单个模板字符串。其次,f-string 是在 Python 3.6 中引入的。如果你卡在 Python 的旧版本上或需要重用模板字符串,可以使用较旧的str.format方法。它使用与 f-string 相同的格式指定符,但可以在一个字符串上多次调用。以下是一个示例:
>>> template = "abc {number:*¹⁰d}"
>>> template.format(number=32)
'abc ****32****'
>>> template.format(number=84)
'abc ****84****'
format方法的行为与 f-string 类似,但有一些区别:
-
它在可以查找的内容上有限制。你可以访问对象上的属性或在列表或字典中查找索引,但不能在模板字符串内部调用函数。
-
你可以使用整数来访问传递给格式化方法的定位参数:
"{0} world".format('bonjour')。如果你按顺序指定变量,索引是可选的:"{} {}".format('hello', 'world')。
字符串是 Unicode 编码
在本节的开始,我们将字符串定义为不可变 Unicode 字符的集合。这实际上在某些时候使事情变得非常复杂,因为 Unicode 并不是真正的存储格式。例如,如果你从一个文件或套接字中获取一个字节字符串,它们不会是 Unicode。实际上,它们将是内置类型bytes。字节是...字节的不可变序列。字节是计算中的基本存储格式。它们代表 8 位,通常描述为介于 0 和 255 之间的整数,或介于 0 和 FF 之间的十六进制等效值。字节不表示任何特定内容;字节序列可能存储编码字符串的字符,或图像中的像素。
如果我们打印一个字节对象,任何映射到 ASCII 表示的字节将被打印为其原始字符,而非 ASCII 字节(无论是二进制数据还是其他字符)将被打印为\x转义序列逃逸的十六进制代码。你可能觉得奇怪,一个表示为整数的字节可以映射到一个 ASCII 字符。但 ASCII 实际上是一种代码,其中每个字母都由不同的字节模式表示,因此,不同的整数。字符a由与整数 97 相同的字节表示,这是十六进制数 0x61。具体来说,这些都是对二进制模式 01100001 的解释。
许多 I/O 操作只知道如何处理字节,即使字节对象引用的是文本数据。因此,了解如何在字节和 Unicode 之间进行转换至关重要。
问题在于有许多方法可以将字节映射到 Unicode 文本。字节是机器可读的值,而文本是供人类阅读的格式。介于两者之间的是一种编码,它将给定的字节序列映射到给定的文本字符序列。
然而,存在多种这样的编码(ASCII 只是其中之一)。当使用不同的编码映射时,相同的字节序列会表示完全不同的文本字符!因此,字节必须使用与它们编码时相同的字符集进行解码。如果不了解字节应该如何解码,就无法从字节中获取文本。如果我们收到未指定编码的未知字节,我们最好的做法是猜测它们编码的格式,我们可能会出错。
将字节转换为文本
如果我们从某个地方有一个字节数组,我们可以使用bytes类的.decode方法将其转换为 Unicode。此方法接受一个字符串作为字符编码的名称。有许多这样的名称;用于西方语言的常见名称包括 ASCII、UTF-8 和 latin-1。
字节序列(以十六进制表示),63 6c 69 63 68 e9,实际上代表了拉丁-1 编码中单词 cliché的字符。以下示例将使用 latin-1 编码对这个字节序列进行编码,并将其转换为 Unicode 字符串:
characters = b'\x63\x6c\x69\x63\x68\xe9'
print(characters)
print(characters.decode("latin-1"))
第一行创建了一个bytes对象。类似于 f-string,字符串前面的b字符告诉我们我们正在定义一个bytes对象,而不是普通的 Unicode 字符串。在字符串中,每个字节都使用——在这种情况下——十六进制数指定。\x字符在字节字符串中转义,每个表示——在这种情况下——使用十六进制数字表示一个字节。
只要我们使用的 shell 支持 latin-1 编码,两个print调用将输出以下字符串:
b'clich\xe9'
cliché
第一个print语句将 ASCII 字符的字节渲染为其自身。未知(对 ASCII 而言)的字符保持其转义十六进制格式。输出包括行首的b字符,以提醒我们这是一个bytes表示,而不是字符串。
下一个调用使用 latin-1 编码解码字符串。decode方法返回一个带有正确字符的正常(Unicode)字符串。然而,如果我们使用西里尔文iso8859-5编码解码这个相同的字符串,我们最终会得到'clichщ'字符串!这是因为\xe9字节在这两种编码中映射到不同的字符。
将文本转换为字节
如果我们需要将传入的字节转换为 Unicode,我们显然也会遇到将输出的 Unicode 转换为字节序列的情况。这是通过str类的encode方法完成的,它,就像decode方法一样,需要一个字符集。以下代码创建了一个 Unicode 字符串,并在不同的字符集中对其进行编码:
characters = "cliché"
print(characters.encode("UTF-8"))
print(characters.encode("latin-1"))
print(characters.encode("CP437"))
print(characters.encode("ascii"))
前三种编码为带音标的字符创建了一组不同的字节。第四种甚至无法处理该字节:
b'clich\xc3\xa9'
b'clich\xe9'
b'clich\x82'
Traceback (most recent call last):
File "1261_10_16_decode_unicode.py", line 5, in <module>
print(characters.encode("ascii"))
UnicodeEncodeError: 'ascii' codec can't encode character '\xe9' in position 5: ordinal not in range(128)
现在你应该理解编码的重要性了!带音标的字符在每种编码中表示为不同的字节;如果我们解码字节到文本时使用错误的编码,我们会得到错误的字符。
最后一种情况下的异常并不总是我们期望的行为;可能存在我们希望以不同方式处理未知字符的情况。encode方法接受一个可选的字符串参数errors,可以定义如何处理此类字符。此字符串可以是以下之一:
-
strict -
replace -
ignore -
xmlcharrefreplace
strict替换策略是我们刚刚看到的默认策略。当遇到一个在请求的编码中没有有效表示的字节序列时,会引发异常。当使用replace策略时,字符会被替换为不同的字符;在 ASCII 中,它是一个问号;其他编码可能使用不同的符号,例如一个空框。ignore策略简单地丢弃它不理解的任何字节,而xmlcharrefreplace策略创建一个表示 Unicode 字符的xml实体。这在将未知字符串转换为用于 XML 文档时可能很有用。以下是每种策略如何影响我们的示例单词:
| 策略 | 应用 "cliché".encode("ascii", strategy) 的结果 |
|---|---|
replace |
b'clich?' |
ignore |
b'clich' |
xmlcharrefreplace |
b'cliché' |
可以调用 str.encode 和 bytes.decode 方法而不传递编码名称。编码将被设置为当前平台的默认编码。这取决于当前的操作系统和区域设置;你可以使用 sys.getdefaultencoding() 函数来查找它。尽管如此,通常最好明确指定编码,因为平台的默认编码可能会更改,或者程序可能有一天会被扩展以处理来自更广泛来源的文本。
如果你正在编码文本,但不知道要使用哪种编码,最好使用 UTF-8 编码。UTF-8 能够表示任何 Unicode 字符。在现代软件中,它是事实上的标准编码,以确保任何语言(甚至多种语言)的文档可以交换。其他可能的编码对于旧文档或在默认使用不同字符集的区域是有用的。
UTF-8 编码使用一个字节来表示 ASCII 和其他常见字符,对于更复杂的字符则使用最多四个字节。UTF-8 是特殊的,因为它与 ASCII 兼容;任何使用 UTF-8 编码的 ASCII 文档都将与原始 ASCII 文档相同。
我总是记不清是使用 encode 还是 decode 将二进制字节转换为 Unicode。我总是希望这些方法被命名为 to_binary 和 from_binary。如果你也有同样的问题,试着在心中将单词 code 替换为 binary;enbinary 和 debinary 与 to_binary 和 from_binary 非常接近。自从想出这个助记符以来,我没有查阅方法帮助文件就节省了很多时间。
可变字节字符串
bytes 类型,像 str 一样,是不可变的。我们可以在 bytes 对象上使用索引和切片表示法来搜索特定的字节序列,但我们不能扩展或修改它们。在处理 I/O 时,这可能会非常不方便,因为通常需要缓冲传入或传出的字节,直到它们准备好发送。例如,如果我们从套接字接收数据,可能需要多次 recv 调用才能接收到整个消息。
这就是内置的 bytearray 类型发挥作用的地方。这种类型的行为类似于列表,但它只包含字节。该类的构造函数可以接受一个 bytes 对象来初始化它。可以使用 extend 方法将另一个 bytes 对象追加到现有数组中(例如,当从套接字或其他 I/O 通道接收更多数据时)。
可以在 bytearray 上使用切片表示法来修改项。例如,此代码从一个 bytes 对象构建一个 bytearray,然后替换两个字节:
b = bytearray(b"abcdefgh")
b[4:6] = b"\x15\xa3"
print(b)
输出看起来像这样:
bytearray(b'abcd\x15\xa3gh')
如果我们要在 bytearray 中操作单个元素,我们必须传递一个介于 0 和 255(包含)之间的整数作为值。这个整数代表一个特定的 bytes 模式。如果我们尝试传递一个字符或 bytes 对象,它将引发异常。
可以使用 ord(意为序数)函数将单个字节字符转换为整数。此函数返回单个字符的整数表示:
b = bytearray(b"abcdef")
b[3] = ord(b"g")
b[4] = 68
print(b)
输出看起来像这样:
bytearray(b'abcgDf')
在构建数组后,我们将索引 3(第四个字符,因为索引从 0 开始,就像列表一样)处的字符替换为字节 103。这个整数是由 ord 函数返回的,是小写 g 的 ASCII 字符。为了说明,我们还用字节编号 68 替换了下一个字符,它映射到大写 D 的 ASCII 字符。
bytearray 类型有允许其表现得像列表(例如,我们可以向其中追加整数字节)的方法,但也可以像 bytes 对象一样使用;我们可以使用 count 和 find 等方法,就像它们在 bytes 或 str 对象上表现一样。区别在于 bytearray 是一个可变类型,这对于从特定输入源构建复杂的字节序列非常有用。
正则表达式
你知道使用面向对象原则真正难以做什么吗?解析字符串以匹配任意模式,就是这样。已经有许多学术论文被撰写,其中使用面向对象设计来设置字符串解析,但结果总是非常冗长且难以阅读,并且在实践中并不广泛使用。
在现实世界中,大多数编程语言的字符串解析都是由正则表达式处理的。这些表达式并不冗长,但哇,它们确实很难读,至少在你学会语法之前是这样。尽管正则表达式不是面向对象的,但 Python 正则表达式库提供了一些类和对象,你可以使用它们来构建和运行正则表达式。
正则表达式用于解决一个常见问题:给定一个字符串,确定该字符串是否与给定的模式匹配,并且可选地收集包含相关信息子串。它们可以用来回答以下问题:
-
这个字符串是否是一个有效的 URL?
-
日志文件中所有警告消息的日期和时间是什么?
-
/etc/passwd中的哪些用户属于给定的组? -
访问者输入的 URL 请求了哪个用户名和文档?
有许多类似的场景,其中正则表达式是正确的答案。许多程序员因为不知道或不学习正则表达式而错误地实现了复杂且脆弱的字符串解析库。在本节中,我们将获得足够的正则表达式知识,以避免犯这样的错误。
匹配模式
正则表达式是一个复杂的迷你语言。它们依赖于特殊字符来匹配未知字符串,但让我们从字面字符开始,例如字母、数字和空格字符,这些字符总是匹配自身。让我们看一个基本示例:
import re
search_string = "hello world"
pattern = "hello world"
match = re.match(pattern, search_string)
if match:
print("regex matches")
Python 标准库中的正则表达式模块被称为 re。我们导入它并设置一个搜索字符串和搜索模式;在这种情况下,它们是相同的字符串。由于搜索字符串与给定的模式匹配,条件通过并且执行了 print 语句。
请记住,match 函数会将模式与字符串的开始部分进行匹配。因此,如果模式是 "ello world",则不会找到匹配项。由于令人困惑的不对称性,解析器一旦找到匹配项就会停止搜索,所以模式 "hello wo" 可以成功匹配。让我们构建一个小型示例程序来展示这些差异,并帮助我们学习其他正则表达式语法:
import sys
import re
pattern = sys.argv[1]
search_string = sys.argv[2]
match = re.match(pattern, search_string)
if match:
template = "'{}' matches pattern '{}'"
else:
template = "'{}' does not match pattern '{}'"
print(template.format(search_string, pattern))
这只是之前示例的通用版本,它从命令行接受模式和搜索字符串。我们可以看到模式的开始必须匹配,但一旦在以下命令行交互中找到匹配项,就会返回一个值:
$ python regex_generic.py "hello worl" "hello world"
'hello world' matches pattern 'hello worl'
$ python regex_generic.py "ello world" "hello world"
'hello world' does not match pattern 'ello world'
我们将在接下来的几节中使用这个脚本。虽然脚本总是使用 python regex_generic.py "<pattern>" "<string>" 命令来调用,但我们将只看到以下示例中的输出,以节省空间。
如果你需要控制项目是否出现在行首或行尾(或者字符串中没有换行符,或者在字符串的开始和结束处),你可以使用 ^ 和 $ 字符分别表示字符串的开始和结束。如果你想使模式匹配整个字符串,包含这两个字符是个好主意:
'hello world' matches pattern '^hello world$'
'hello worl' does not match pattern '^hello world$'
匹配一组字符
让我们从匹配任意字符开始。在正则表达式模式中使用点字符可以匹配任何单个字符。在字符串中使用点意味着你不在乎字符是什么,只要那里有一个字符即可。以下是一些示例:
'hello world' matches pattern 'hel.o world'
'helpo world' matches pattern 'hel.o world'
'hel o world' matches pattern 'hel.o world'
'helo world' does not match pattern 'hel.o world'
注意到最后一个示例没有匹配,因为在模式的点位置上没有字符。
那么一切都很好,但如果我们只想匹配几个特定的字符怎么办?我们可以在方括号内放置一组字符来匹配这些字符中的任何一个。所以,如果我们在一个正则表达式模式中遇到字符串 [abc],我们知道这五个字符(包括两个方括号)将只匹配正在搜索的字符串中的一个字符,并且进一步地,这个字符将是 a、b 或 c 中的一个。让我们看几个示例:
'hello world' matches pattern 'hel[lp]o world'
'helpo world' matches pattern 'hel[lp]o world'
'helPo world' does not match pattern 'hel[lp]o world'
这些方括号集合应该被称为字符集,但它们更常被称为字符类。通常,我们希望在集合内包含大量字符,输入它们可能会很单调且容易出错。幸运的是,正则表达式的设计者想到了这一点,并给了我们一个快捷方式。在字符集中,破折号字符将创建一个范围。如果你想要匹配所有小写字母、所有字母或所有数字,这特别有用,如下所示:
'hello world' does not match pattern 'hello [a-z] world'
'hello b world' matches pattern 'hello [a-z] world'
'hello B world' matches pattern 'hello [a-zA-Z] world'
'hello 2 world' matches pattern 'hello [a-zA-Z0-9] world'
还有其他方法可以匹配或排除单个字符,但如果你想知道它们是什么,你需要通过网络搜索找到更全面的教程!
转义字符
如果在模式中放置一个点字符可以匹配任何任意字符,那么我们如何匹配字符串中的单个点呢?一种可能的方法是将点放在方括号内以创建一个字符类,但一个更通用的方法是使用反斜杠来转义它。以下是一个匹配 0.00 到 0.99 之间两位小数的正则表达式:
'0.05' matches pattern '0\.[0-9][0-9]'
'005' does not match pattern '0\.[0-9][0-9]'
'0,05' does not match pattern '0\.[0-9][0-9]'
对于这个模式,两个字符\.匹配单个.字符。如果点字符缺失或是一个不同的字符,它将不会匹配。
这个反斜杠转义序列用于正则表达式中的各种特殊字符。你可以使用\[来插入一个方括号而不开始一个字符类,并且使用\(来插入一个括号,我们稍后会看到它也是一个特殊字符。
更有趣的是,我们还可以使用转义符号后跟一个字符来表示特殊字符,如换行符(\n)和制表符(\t)。此外,一些字符类可以使用转义字符串更简洁地表示:\s表示空白字符;\w表示字母、数字和下划线;\d表示数字:
'(abc]' matches pattern '\(abc\]'
' 1a' matches pattern '\s\d\w'
'\t5n' does not match pattern '\s\d\w'
'5n' matches pattern '\s\d\w'
匹配多个字符
使用这些信息,我们可以匹配大多数已知长度的字符串,但大多数时候,我们不知道在模式内要匹配多少个字符。正则表达式也可以处理这个问题。我们可以通过在模式后附加几个难以记住的标点符号之一来修改模式,以匹配多个字符。
星号(*)字符表示前面的模式可以匹配零次或多次。这听起来可能有些荒谬,但它是最有用的重复字符之一。在我们探索为什么之前,考虑一些荒谬的例子以确保我们理解它的作用:
'hello' matches pattern 'hel*o'
'heo' matches pattern 'hel*o'
'helllllo' matches pattern 'hel*o'
因此,模式中的*字符表示前面的模式(l字符)是可选的,如果存在,可以尽可能多地重复以匹配模式。其余的字符(h、e和o)必须恰好出现一次。
通常情况下,我们并不需要多次匹配单个字母,但如果我们将星号与匹配多个字符的模式结合起来,情况就变得更有趣了。例如,.* 将匹配任何字符串,而 [a-z]* 则匹配任何由小写字母组成的单词集合,包括空字符串。以下是一些示例:
'A string.' matches pattern '[A-Z][a-z]* [a-z]*\.'
'No .' matches pattern '[A-Z][a-z]* [a-z]*\.'
'' matches pattern '[a-z]*.*'
模式中的加号(+)与星号的行为类似;它表示前面的模式可以重复一次或多次,但与星号不同的是,它不是可选的。问号(?)确保模式恰好出现零次或一次,但不超过一次。让我们通过玩数字来探索一些这些模式(记住 \d 匹配与 [0-9] 相同的字符类):
'0.4' matches pattern '\d+\.\d+'
'1.002' matches pattern '\d+\.\d+'
'1.' does not match pattern '\d+\.\d+'
'1%' matches pattern '\d?\d%'
'99%' matches pattern '\d?\d%'
'999%' does not match pattern '\d?\d%'
将模式分组
到目前为止,我们已经看到我们可以多次重复一个模式,但我们受到可以重复的模式类型的限制。如果我们想重复单个字符,我们没问题,但如果我们想重复字符序列呢?将任何一组模式括起来,在应用重复操作时,可以将它们视为一个单独的模式。比较以下模式:
'abccc' matches pattern 'abc{3}'
'abccc' does not match pattern '(abc){3}'
'abcabcabc' matches pattern '(abc){3}'
结合复杂的模式,这种分组功能极大地扩展了我们的模式匹配能力。以下是一个匹配简单英语句子的正则表达式:
'Eat.' matches pattern '[A-Z][a-z]*( [a-z]+)*\.$'
'Eat more good food.' matches pattern '[A-Z][a-z]*( [a-z]+)*\.$'
'A good meal.' matches pattern '[A-Z][a-z]*( [a-z]+)*\.$'
第一个单词以大写字母开头,后面跟着零个或多个小写字母。然后,我们进入一个匹配单个空格后跟一个由一个或多个小写字母组成的单词的括号表达式。这个括号表达式可以重复零次或多次,并且模式以句号结束。句号之后不能有其他字符,正如 $ 匹配字符串的结尾所示。
我们已经看到了许多最基本模式,但正则表达式语言支持许多其他模式。我在使用正则表达式的最初几年里,每次需要做某事时都会查找语法。值得将 Python 的 re 模块文档添加到书签并经常查阅。正则表达式几乎可以匹配任何内容,它们应该是解析字符串时首先考虑的工具。
从正则表达式获取信息
现在,让我们关注 Python 方面的事情。正则表达式语法与面向对象编程相差甚远。然而,Python 的 re 模块提供了一个面向对象的接口来访问正则表达式引擎。
我们一直在检查 re.match 函数是否返回一个有效的对象。如果模式不匹配,该函数返回 None。如果它匹配,则返回一个有用的对象,我们可以用它来获取有关模式的信息。
到目前为止,我们的正则表达式已经回答了诸如“这个字符串是否与这个模式匹配?”等问题。匹配模式很有用,但在许多情况下,一个更有趣的问题是,“如果这个字符串与这个模式匹配,相关子串的值是什么?”如果你使用组来标识稍后想要引用的模式部分,你可以从匹配返回值中获取它们,如下一个示例所示:
pattern = "^[a-zA-Z.]+@([a-z.]*\.[a-z]+)$"
search_string = "some.user@example.com"
match = re.match(pattern, search_string)
if match:
domain = match.groups()[0]
print(domain)
描述有效电子邮件地址的规范极其复杂,能够准确匹配所有可能性的正则表达式非常长。因此,我们采取了欺骗手段,创建了一个简单的正则表达式来匹配一些常见的电子邮件地址;目的是我们想要访问域名(在@符号之后),以便我们可以连接到该地址。通过将模式中这部分内容用括号括起来,并在match方法返回的对象上调用groups()方法,可以轻松实现这一点。
groups方法返回一个包含模式内部所有匹配组的元组,你可以通过索引来访问特定的值。组是从左到右排序的。然而,请注意,组可以嵌套,这意味着你可以在另一个组内部有一个或多个组。在这种情况下,组是按照它们的左括号顺序返回的,所以最外层的组将先于其内部匹配组返回。
除了match函数外,re模块还提供了一些其他有用的函数,search和findall。search函数找到匹配模式的第一个实例,放宽了模式应该从字符串的第一个字母开始的限制。请注意,你可以通过使用match并在模式前面加上^.*字符来达到类似的效果,以匹配字符串开始和你要查找的模式之间的任何字符。
findall函数的行为与搜索类似,但它找到的是匹配模式的全部非重叠实例,而不仅仅是第一个。基本上,它会找到第一个匹配项,然后重置搜索到匹配字符串的末尾,并找到下一个匹配项。
与你预期的返回匹配对象列表不同,它返回一个匹配字符串或元组的列表。有时是字符串,有时是元组。这根本不是一个很好的 API!与所有糟糕的 API 一样,你必须记住差异,不要依赖直觉。返回值的类型取决于正则表达式内部括号组的数量:
-
如果模式中没有组,
re.findall将返回一个字符串列表,其中每个值都是从源字符串中匹配模式的完整子串。 -
如果模式中恰好有一个组,
re.findall将返回一个字符串列表,其中每个值是那个组的全部内容。 -
如果模式中有多个组,
re.findall将返回一个元组列表,其中每个元组包含匹配组中的一个值,按顺序排列。
当你在自己的 Python 库中设计函数调用时,尽量让函数总是返回一致的数据结构。设计能够接受任意输入并处理它们的函数通常是个好主意,但返回值不应该根据输入从单个值切换到列表,或者从值列表切换到元组列表。re.findall 就是一个教训!
下面的交互式会话中的示例有望阐明这些差异:
>>> import re
>>> re.findall('a.', 'abacadefagah')
['ab', 'ac', 'ad', 'ag', 'ah']
>>> re.findall('a(.)', 'abacadefagah')
['b', 'c', 'd', 'g', 'h']
>>> re.findall('(a)(.)', 'abacadefagah')
[('a', 'b'), ('a', 'c'), ('a', 'd'), ('a', 'g'), ('a', 'h')]
>>> re.findall('((a)(.))', 'abacadefagah')
[('ab', 'a', 'b'), ('ac', 'a', 'c'), ('ad', 'a', 'd'), ('ag', 'a', 'g'), ('ah', 'a',
'h')]
使重复的正则表达式更高效
每次调用正则表达式的一个方法时,引擎都必须将模式字符串转换为一种内部结构,这使得字符串搜索变得快速。这种转换需要相当多的时间。如果一个正则表达式模式将被多次使用(例如,在 for 或 while 循环中),那么这个转换步骤只做一次会更好。
这可以通过 re.compile 方法实现。它返回一个面向对象的正则表达式版本,该版本已被编译并具有我们已探索的方法(如 match、search 和 findall 等)。我们将在案例研究中看到这方面的例子。
这肯定是对正则表达式的一个浓缩介绍。到目前为止,我们对基础知识有了很好的感觉,并且会在需要进一步研究时识别出来。如果我们有一个字符串模式匹配问题,正则表达式几乎肯定能够为我们解决这些问题。然而,我们可能需要在一个更全面的正则表达式主题覆盖中查找新的语法。但现在我们知道该寻找什么了!让我们继续到一个完全不同的主题:文件系统路径。
文件系统路径
所有操作系统都提供了一个 文件系统,一种将 文件夹(或 目录)和 文件 的逻辑抽象映射到硬盘或其它存储设备上存储的位和字节的方法。作为人类,我们通常通过文件夹和不同类型的文件的拖放界面,或者通过 cp、mv 和 mkdir 等命令行程序与文件系统交互。
作为程序员,我们必须通过一系列系统调用来与文件系统交互。你可以把它们看作是操作系统提供的库函数,以便程序可以调用它们。它们有一个笨拙的接口,包括整数文件句柄和缓冲读取和写入,而且这个接口取决于你使用的操作系统。Python 在 os.path 模块中提供了对这些系统调用的操作系统无关的抽象。与直接访问操作系统相比,这要容易一些,但并不直观。它需要大量的字符串连接,并且你必须意识到在目录之间使用正斜杠还是反斜杠,这取决于操作系统。有一个 os.sep 文件表示路径分隔符,但使用它需要像这样的代码:
>>> path = os.path.abspath(os.sep.join(['.', 'subdir', 'subsubdir', 'file.ext']))
>>> print(path)
/home/dusty/subdir/subsubdir/file.ext
在整个标准库中,与文件系统路径一起工作可能是最令人烦恼的字符串使用之一。在命令行上容易输入的路径在 Python 代码中变得难以辨认。当你必须操作和访问多个路径时(例如,在处理机器学习计算机视觉问题的数据管道中的图像时),仅仅管理这些目录就变成了一项艰巨的任务。
因此,Python 语言设计者将一个名为 pathlib 的模块包含在标准库中。它是对路径和文件的面向对象表示,与它一起工作要愉快得多。使用 pathlib 的前一个路径看起来像这样:
>>> path = (pathlib.Path(".") / "subdir" / " subsubdir" / "file.ext").absolute()
>>> print(path)
/home/dusty/subdir/subsubdir/file.ext
如您所见,它要容易得多,可以清楚地看到发生了什么。注意除法运算符作为路径分隔符的独特使用,这样你就不需要做任何与 os.sep 相关的事情。
在一个更实际的例子中,考虑一些代码,它计算当前目录及其子目录中所有 Python 文件的代码行数(不包括空白和注释):
import pathlib
def count_sloc(dir_path):
sloc = 0
for path in dir_path.iterdir():
if path.name.startswith("."):
continue
if path.is_dir():
sloc += count_sloc(path)
continue
if path.suffix != ".py":
continue
with path.open() as file:
for line in file:
line = line.strip()
if line and not line.startswith("#"):
sloc += 1
return sloc
root_path = pathlib.Path(".")
print(f"{count_sloc(root_path)} lines of python code")
在典型的 pathlib 使用中,我们很少需要构建超过一个或两个路径。通常,其他文件或目录相对于一个通用路径。这个例子演示了这一点。我们只构建一个路径,即使用 pathlib.Path(".") 从当前目录开始。然后,其他路径基于这个路径创建。
count_sloc 函数首先将 sloc(源代码行数)计数器初始化为零。然后,它使用 dir_path.iterdir 生成器遍历函数传入路径中的所有文件和目录(我们将在下一章详细讨论生成器;现在,可以将其视为一种动态列表)。iterdir 返回给 for 循环的每个路径本身也是一个路径。我们首先测试这个路径是否以 . 开头,这在大多数操作系统上代表一个隐藏目录(如果你使用版本控制,这将防止它计算 .git 目录中的任何文件)。然后,我们使用 isdir() 方法检查它是否是目录。如果是,我们递归调用 count_sloc 来计算子包中模块的代码行数。
如果它不是一个目录,我们假设它是一个普通文件,并使用 suffix 属性跳过任何不以 .py 扩展名结尾的文件。现在,我们知道我们有一个指向 Python 文件的路径,我们使用 open() 方法打开文件,该方法返回一个上下文管理器。我们将其包裹在一个 with 块中,这样当我们完成时文件会自动关闭。
Path.open 方法与内置的 open 函数具有类似的参数,但它使用更面向对象的语法。如果你更喜欢函数版本,你可以将一个 Path 对象作为第一个参数传递给它(换句话说,with open(Path('./README.md')):),就像传递一个字符串一样。但我觉得如果路径已经存在,Path('./README.md').open() 的可读性更好。
我们然后遍历文件中的每一行,并将其添加到计数中。我们跳过空白行和注释行,因为这些并不代表实际的源代码。总计数返回给调用函数,这可能是最初的调用或递归的父调用。
pathlib 模块中的 Path 类有一个方法或属性来覆盖你可能会对路径做的几乎所有操作。除了我们在示例中提到的那些之外,这里还有一些我最喜欢的:
-
.absolute()返回从文件系统根目录的完整路径。我通常在构建每个路径时都调用这个方法,因为我对可能会忘记相对路径的来源有一点点偏执。 -
.parent返回父目录的路径。 -
.exists()检查文件或目录是否存在。 -
.mkdir()在当前路径创建一个目录。它接受布尔参数parents和exist_ok来指示如果需要,它应该递归地创建目录,并且如果目录已存在,它不应该引发异常。
更多关于 pathlib 的特殊用法,请参阅标准库文档:docs.python.org/3/library/pathlib.html。
大多数接受字符串路径的标准库模块也可以接受 pathlib.Path 对象。例如,你可以通过传递一个路径到它来打开一个 ZIP 文件:
>>> zipfile.ZipFile(Path('nothing.zip'), 'w').writestr('filename', 'contents')
这并不总是有效,特别是如果你正在使用作为 C 扩展实现的第三方库。在这些情况下,你必须使用 str(pathname) 将路径转换为字符串。
对象序列化
现在,我们把将数据写入文件并在任意晚些时候检索它的能力视为理所当然。尽管这样做很方便(想象一下如果我们不能存储任何东西的计算状态!),我们经常发现自己将存储在内存中一个漂亮的对象或设计模式中的数据转换为某种笨拙的文本或二进制格式以进行存储、通过网络传输或在远程服务器上远程调用。
Python 的 pickle 模块是一种面向对象的方式来直接以特殊存储格式存储对象。它本质上将一个对象(以及它作为属性持有的所有对象)转换为一个字节序列,我们可以按我们的需要存储或传输。
对于基本任务,pickle 模块有一个极其简单的接口。它包括四个基本函数用于存储和加载数据:两个用于操作文件对象,两个用于操作 bytes 对象(后者只是文件对象接口的快捷方式,因此我们不必自己创建 BytesIO 文件对象)。
dump 方法接受要写入的对象和一个文件对象,将序列化的字节写入该对象。此对象必须有一个 write 方法(否则它就不是文件对象),该方法必须知道如何处理 bytes 参数(因此,用于文本输出的打开的文件不会工作)。
load 方法正好相反;它从一个文件类似的对象中读取一个序列化的对象。此对象必须具有适当的文件类似 read 和 readline 参数,每个参数当然必须返回 bytes。pickle 模块将从这些字节中加载对象,load 方法将返回完全重建的对象。以下是一个示例,它在一个列表对象中存储和加载一些数据:
import pickle
some_data = ["a list", "containing", 5,
"values including another list",
["inner", "list"]]
with open("pickled_list", 'wb') as file:
pickle.dump(some_data, file)
with open("pickled_list", 'rb') as file:
loaded_data = pickle.load(file)
print(loaded_data)
assert loaded_data == some_data
这段代码按预期工作:对象被存储在文件中,然后从同一文件中加载。在这种情况下,我们使用 with 语句打开文件,以便它自动关闭。文件首先用于写入,然后再次用于读取,具体取决于我们是存储还是加载数据。
在末尾的 assert 语句会在新加载的对象不等于原始对象时引发错误。相等性并不意味着它们是同一个对象。实际上,如果我们打印两个对象的 id(),我们会发现它们是不同的。然而,由于它们都是内容相等的列表,这两个列表也被认为是相等的。
dumps 和 loads 函数的行为与它们的文件类似,除了它们返回或接受 bytes 而不是文件类似的对象。dumps 函数只需要一个参数,即要存储的对象,并返回一个序列化的 bytes 对象。loads 函数需要一个 bytes 对象,并返回恢复的对象。方法名中的 's' 字符代表字符串;这是 Python 早期版本中的一个遗留名称,当时使用 str 对象而不是 bytes。
可以多次在单个打开的文件上调用 dump 或 load。每次调用 dump 都会存储一个对象(以及它由或包含的任何对象),而调用 load 将加载并返回一个对象。因此,对于单个文件,在存储对象时对 dump 的每次单独调用都应该在稍后日期恢复时有一个相关的 load 调用。
自定义 pickles
对于大多数常见的 Python 对象,pickling 只需“正常工作”。基本原语,如整数、浮点数和字符串可以 picklable,任何容器对象,如列表或字典也可以,只要这些容器的内容也是 picklable。更重要的是,任何对象都可以被 picklable,只要它的所有属性也是 picklable。
那么,是什么让一个属性不可 picklable?通常,这与未来加载时没有意义的时敏属性有关。例如,如果我们有一个打开的网络套接字、打开的文件、正在运行的线程或数据库连接作为对象的属性存储,那么将这些对象 picklable 就没有意义;当我们尝试稍后重新加载它们时,大量的操作系统状态将简单地消失。我们不能假装线程或套接字连接存在并使其出现!不,我们需要以某种方式自定义此类瞬态数据的存储和恢复方式。
这是一个每小时加载网页内容的类,以确保它们保持最新。它使用threading.Timer类来安排下一次更新:
from threading import Timer
import datetime
from urllib.request import urlopen
class UpdatedURL:
def __init__(self, url):
self.url = url
self.contents = ''
self.last_updated = None
self.update()
def update(self):
self.contents = urlopen(self.url).read()
self.last_updated = datetime.datetime.now()
self.schedule()
def schedule(self):
self.timer = Timer(3600, self.update)
self.timer.setDaemon(True)
self.timer.start()
url、contents和last_updated都是可 pickle 的,但如果我们尝试 pickle 这个类的实例,self.timer实例会变得有些疯狂:
>>> u = UpdatedURL("http://dusty.phillips.codes")
^[[Apickle.dumps(u)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can't pickle _thread.lock objects
这不是一个非常有用的错误,但看起来我们正在尝试对不应该进行 pickle 操作的东西进行操作。那将是Timer实例;我们在 schedule 方法中存储了对self.timer的引用,而这个属性不能被序列化。
当pickle尝试序列化一个对象时,它只是尝试存储对象的__dict__属性;__dict__是一个字典,将对象上的所有属性名映射到它们的值。幸运的是,在检查__dict__之前,pickle会检查是否存在__getstate__方法。如果存在,它将存储该方法的返回值而不是__dict__。
让我们在UpdatedURL类中添加一个__getstate__方法,该方法简单地返回一个没有计时器的__dict__的副本:
def __getstate__(self):
new_state = self.__dict__.copy()
if 'timer' in new_state:
del new_state['timer']
return new_state
如果我们现在 pickle 对象,它将不再失败。我们甚至可以使用loads成功恢复该对象。然而,恢复的对象没有计时器属性,所以它不会像设计的那样刷新内容。我们需要在对象反序列化时以某种方式创建一个新的计时器(以替换缺失的计时器)。
如我们所预期,有一个互补的__setstate__方法可以实现来自定义反序列化。该方法接受一个参数,即__getstate__返回的对象。如果我们实现了这两个方法,__getstate__不需要返回一个字典,因为__setstate__将知道如何处理__getstate__选择返回的任何对象。在我们的情况下,我们只想恢复__dict__,然后创建一个新的计时器:
def __setstate__(self, data): self.__dict__ = data self.schedule()
pickle模块非常灵活,并提供其他工具来进一步自定义序列化过程,如果你需要的话。然而,这些超出了本书的范围。我们介绍的工具对于许多基本的序列化任务已经足够了。要序列化的对象通常是相对简单的数据对象;我们可能不会 pickle 整个运行中的程序或复杂的设计模式,例如。
序列化网络对象
从未知或不可信的来源加载 pickle 对象不是一个好主意。通过 pickle,可以注入任意代码到 pickle 文件中,恶意攻击计算机。pickle 的另一个缺点是它们只能被其他 Python 程序加载,不能轻易与其他语言编写的服务共享。
这些年来,已经使用了许多用于此目的的格式。可扩展标记语言(XML)曾经非常流行,尤其是在 Java 开发者中。另一种标记语言(YAML)是另一种偶尔会提到的格式。表格数据通常以 逗号分隔值(CSV)格式交换。其中许多正在逐渐消失,你将在未来遇到更多。Python 为所有这些格式都提供了坚实的标准或第三方库。
在使用这些库处理不可信数据之前,请确保调查每个库的安全问题。例如,XML 和 YAML 都有一些不为人知的特性,如果被恶意使用,可以在主机机器上执行任意命令。这些特性可能默认并未关闭。请做好研究。
JavaScript 对象表示法(JSON)是一种用于交换原始数据的人可读格式。JSON 是一种标准格式,可以被各种异构客户端系统解释。因此,JSON 对于在完全解耦的系统之间传输数据非常有用。此外,JSON 不支持可执行代码,只能序列化数据;因此,将其注入恶意语句更加困难。
由于 JSON 可以很容易地被 JavaScript 引擎解释,它通常用于在 Web 服务器和具有 JavaScript 功能的 Web 浏览器之间传输数据。如果提供数据的服务器端应用程序是用 Python 编写的,它需要一种方法将内部数据转换为 JSON 格式。
有一个模块可以完成这个任务,其名称很自然地被命名为 json。这个模块提供了一个与 pickle 模块类似的接口,包括 dump、load、dumps 和 loads 函数。这些函数的默认调用几乎与 pickle 中的调用完全相同,所以这里就不重复细节了。存在一些差异;显然,这些调用的输出是有效的 JSON 语法,而不是被序列化的对象。此外,json 函数操作的是 str 对象,而不是 bytes。因此,在向文件写入或从文件读取时,我们需要创建文本文件而不是二进制文件。
JSON 序列化器不如 pickle 模块健壮;它只能序列化基本类型,如整数、浮点数和字符串,以及简单的容器,如字典和列表。这些都有直接映射到 JSON 表示的直接映射,但 JSON 无法表示类、方法或函数。无法以这种格式传输完整的对象。因为我们将对象序列化到 JSON 格式后,接收者通常不是 Python 对象,它无论如何也无法像 Python 那样理解类或方法。尽管其名称中有 O 代表对象,但 JSON 是一种 数据 语法;如你所知,对象由数据和行为组成。
如果我们有只想序列化数据的对象,我们始终可以序列化对象的__dict__属性。或者,我们可以通过提供自定义代码来半自动化此任务,从某些类型的对象创建或解析一个可序列化的 JSON 字典。
在json模块中,存储和加载对象的功能都接受可选参数以自定义行为。dump和dumps方法接受一个命名不佳的cls(简称 class,是一个保留关键字)关键字参数。如果传递了,这应该是一个JSONEncoder类的子类,并且重写了default方法。此方法接受任意对象并将其转换为json可以消化的字典。如果它不知道如何处理该对象,我们应该调用super()方法,这样它就可以以正常方式处理基本类型。
load和loads方法也接受这样的cls参数,它可以是一个逆类JSONDecoder的子类。然而,通常只需使用object_hook关键字参数将这些方法传递给一个函数就足够了。此函数接受一个字典并返回一个对象;如果它不知道如何处理输入字典,它可以返回未修改的字典。
让我们看看一个例子。假设我们有一个以下简单的联系人类,我们想要序列化:
class Contact:
def __init__(self, first, last):
self.first = first
self.last = last
@property
def full_name(self):
return("{} {}".format(self.first, self.last))
我们可以直接序列化__dict__属性:
>>> c = Contact("John", "Smith")
>>> json.dumps(c.__dict__)
'{"last": "Smith", "first": "John"}'
但是以这种方式访问特殊(双下划线)属性有点粗糙。此外,如果接收到的代码(可能是一些网页上的 JavaScript)需要full_name属性,该怎么办?当然,我们可以手动构造字典,但让我们创建一个自定义编码器:
import json
class ContactEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Contact):
return {
"is_contact": True,
"first": obj.first,
"last": obj.last,
"full": obj.full_name,
}
return super().default(obj)
default方法基本上检查我们正在尝试序列化的对象类型。如果是联系人,我们手动将其转换为字典。否则,我们让父类处理序列化(假设它是一个基本类型,json知道如何处理)。请注意,我们传递一个额外的属性来识别这个对象是一个联系人,因为在加载时无法区分。这只是一个约定;对于更通用的序列化机制,可能更有意义在字典中存储字符串类型,甚至可能是包括包和模块的全类名。记住,字典的格式取决于接收端的代码;必须就如何指定数据达成一致。
我们可以通过将类(而不是实例化对象)传递给dump或dumps函数来使用此类来编码联系人:
>>> c = Contact("John", "Smith")
>>> json.dumps(c, cls=ContactEncoder)
'{"is_contact": true, "last": "Smith", "full": "John Smith",
"first": "John"}'
对于解码,我们可以编写一个函数,该函数接受一个字典并检查is_contact变量的存在以决定是否将其转换为联系人:
def decode_contact(dic):
if dic.get("is_contact"):
return Contact(dic["first"], dic["last"])
else:
return dic
我们可以通过使用object_hook关键字参数将此函数传递给load或loads函数:
>>> data = ('{"is_contact": true, "last": "smith",'
'"full": "john smith", "first": "john"}')
>>> c = json.loads(data, object_hook=decode_contact)
>>> c
<__main__.Contact object at 0xa02918c>
>>> c.full_name
'john smith'
案例研究
让我们在 Python 中构建一个基于正则表达式的模板引擎。这个引擎将解析一个文本文件(例如 HTML 页面),并将某些指令替换为从这些指令的输入计算出的文本。这是我们可能想要用正则表达式完成的最为复杂的任务;实际上,这个功能的完整版本可能会利用适当的语言解析机制。
考虑以下输入文件:
/** include header.html **/
<h1>This is the title of the front page</h1>
/** include menu.html **/
<p>My name is /** variable name **/.
This is the content of my front page. It goes below the menu.</p>
<table>
<tr><th>Favourite Books</th></tr>
/** loopover book_list **/
<tr><td>/** loopvar **/</td></tr>
/** endloop **/
</table>
/** include footer.html **/
Copyright © Today
此文件包含形式为/** <directive> <data> **/的标签,其中数据是可选的单个单词,指令如下:
-
include: 在此处复制另一个文件的内容 -
variable: 在此处插入变量的内容 -
loopover: 对于列表变量重复循环的内容 -
endloop: 信号循环文本的结束 -
loopvar: 从正在循环的列表中插入单个值
这个模板将根据传入的变量渲染不同的页面。这些变量将从所谓的上下文文件传入。这将编码为一个json对象,其中的键代表所涉及的变量。我的上下文文件可能看起来像这样,但你会根据自己的需求进行修改:
{
"name": "Dusty",
"book_list": [
"Thief Of Time",
"The Thief",
"Snow Crash",
"Lathe Of Heaven"
]
}
在我们进行实际的字符串处理之前,让我们为处理文件和从命令行获取数据编写一些面向对象的基础代码:
import re
import sys
import json
from pathlib import Path
DIRECTIVE_RE = re.compile(
r'/\*\*\s*(include|variable|loopover|endloop|loopvar)'
r'\s*([^ *]*)\s*\*\*/')
class TemplateEngine:
def __init__(self, infilename, outfilename, contextfilename):
self.template = open(infilename).read()
self.working_dir = Path(infilename).absolute().parent
self.pos = 0
self.outfile = open(outfilename, 'w')
with open(contextfilename) as contextfile:
self.context = json.load(contextfile)
def process(self):
print("PROCESSING...")
if __name__ == '__main__':
infilename, outfilename, contextfilename = sys.argv[1:]
engine = TemplateEngine(infilename, outfilename, contextfilename)
engine.process()
这一切都很基础,我们创建一个类,并通过命令行传入一些变量来初始化它。
注意我们如何尝试通过将正则表达式拆分为两行来使其更易于阅读?我们使用原始字符串(r 前缀),因此我们不需要对所有的反斜杠进行双重转义。这在正则表达式中很常见,但仍然很混乱。(正则表达式总是这样,但它们通常值得这么做。)
pos表示我们正在处理的内容中的当前字符;我们很快就会看到更多关于它的内容。
现在剩下的只是实现process方法。有几种方法可以做到这一点。让我们以一种相当明确的方式来做。
process方法必须找到与正则表达式匹配的每个指令,并对其进行适当处理。然而,它还必须注意将每个指令之前、之后和之间的正常文本输出到输出文件,且不进行修改。
正则表达式的编译版本的一个好特点是,我们可以通过传递pos关键字参数来告诉search方法从特定位置开始搜索。如果我们暂时将使用指令执行适当工作定义为忽略指令并从输出文件中删除它,那么我们的过程循环看起来相当简单:
def process(self):
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
while match:
self.outfile.write(self.template[self.pos:match.start()])
self.pos = match.end()
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
self.outfile.write(self.template[self.pos:])
在英语中,这个函数在文本中找到第一个与正则表达式匹配的字符串,输出从当前位置到该匹配开始的所有内容,然后将位置移动到上述匹配的末尾。一旦没有更多匹配项,它将输出从上次位置以来的所有内容。
当然,在模板引擎中忽略指令是非常无用的,所以让我们用委托到类上不同方法的代码替换那个位置推进行:
def process(self):
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
while match:
self.outfile.write(self.template[self.pos:match.start()])
directive, argument = match.groups()
method_name = 'process_{}'.format(directive)
getattr(self, method_name)(match, argument)
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
self.outfile.write(self.template[self.pos:])
因此,我们从正则表达式中提取指令和单个参数。指令变成了方法名,我们动态地在self对象上查找该方法名(如果模板编写者提供了无效的指令,这里会有一些错误处理,会更好)。我们将match对象和参数传递给该方法,并假设该方法将适当地处理所有事情,包括移动pos指针。
现在我们已经实现了面向对象架构的这一步,实现被委托的方法实际上相当简单。include和variable指令完全直接:
def process_include(self, match, argument):
with (self.working_dir / argument).open() as includefile:
self.outfile.write(includefile.read())
self.pos = match.end()
def process_variable(self, match, argument):
self.outfile.write(self.context.get(argument, ''))
self.pos = match.end()
第一个指令简单地查找包含的文件并插入文件内容,而第二个指令在上下文字典(在__init__方法中从json加载)中查找变量名,如果不存在则默认为空字符串。
处理循环的三个方法稍微复杂一些,因为它们必须在三者之间共享状态。为了简单起见(我敢肯定你急于看到这个漫长章节的结尾——我们几乎到了!),我们将使用类本身的实例变量来处理这种情况。作为一个练习,你可能想要考虑更好的架构方式,尤其是在阅读接下来的三个章节之后:
def process_loopover(self, match, argument):
self.loop_index = 0
self.loop_list = self.context.get(argument, [])
self.pos = self.loop_pos = match.end()
def process_loopvar(self, match, argument):
self.outfile.write(self.loop_list[self.loop_index])
self.pos = match.end()
def process_endloop(self, match, argument):
self.loop_index += 1
if self.loop_index >= len(self.loop_list):
self.pos = match.end()
del self.loop_index
del self.loop_list
del self.loop_pos
else:
self.pos = self.loop_pos
当我们遇到loopover指令时,我们不需要输出任何内容,但我们必须设置三个变量的初始状态。loop_list变量假设是从上下文字典中拉取的列表。loop_index变量指示在这个循环迭代中应该输出列表中的哪个位置,而loop_pos被存储起来,以便我们知道在到达循环末尾时跳回的位置。
loopvar指令输出loop_list变量当前位置的价值,并跳转到指令的末尾。请注意,它不会增加循环索引,因为loopvar指令可以在循环内部多次调用。
endloop指令更复杂。它确定loop_list中是否有更多元素;如果有,它就跳回循环的开始,增加索引。否则,它重置用于处理循环的所有变量,并跳转到指令的末尾,以便引擎可以继续进行下一个匹配。
注意,这个特定的循环机制非常脆弱;如果模板设计者尝试嵌套循环或忘记调用 endloop,结果可能会很糟糕。我们需要更多的错误检查,并且可能需要存储更多的循环状态,以便将其作为一个生产平台。但我承诺本章的结尾即将到来,所以让我们在看到我们的示例模板及其上下文如何渲染后,直接进入练习:
<html>
<body>
<h1>This is the title of the front page</h1>
<a href="link1.html">First Link</a>
<a href="link2.html">Second Link</a>
<p>My name is Dusty. This is the content of my front page. It goes below the menu.</p>
<table>
<tr>
<th>Favourite Books</th>
</tr>
<tr>
<td>Thief Of Time</td>
</tr>
<tr>
<td>The Thief</td>
</tr>
<tr>
<td>Snow Crash</td>
</tr>
<tr>
<td>Lathe Of Heaven</td>
</tr>
</table>
</body>
</html>
Copyright © Today
由于我们计划模板的方式,有一些奇怪的换行效果,但它们按预期工作。
练习
在本章中,我们涵盖了广泛的主题,从字符串到正则表达式,再到对象序列化,现在考虑一下如何将这些想法应用到你的代码中。
Python 字符串非常灵活,Python 是一个用于字符串操作的极其强大的工具。如果你在日常工作中不经常进行字符串处理,尝试设计一个专门用于字符串操作的工具。尝试提出一些创新的想法,如果你卡住了,可以考虑编写一个网络日志分析器(每小时有多少请求?有多少人访问了五个以上的页面?)或一个模板工具,该工具用其他文件的内容替换某些变量名。
投入大量时间去玩转字符串格式化运算符,直到你记住了它们的语法。编写一些模板字符串和对象,将它们传递给格式化函数,看看能得到什么样的输出。尝试一些异国风情的格式化运算符,比如百分比或十六进制表示法。尝试使用填充和对齐运算符,看看它们在整数、字符串和浮点数上的表现有何不同。考虑编写一个包含 __format__ 方法的类;我们之前没有详细讨论这一点,但探索一下你可以如何自定义格式化。
确保你理解 bytes 和 str 对象之间的区别。在 Python 的旧版本中,这种区别非常复杂(那时没有 bytes,str 既可以作为 bytes 也可以作为 str,除非我们需要非 ASCII 字符,在这种情况下有一个单独的 unicode 对象,它类似于 Python 3 的 str 类。这比听起来还要复杂!)。现在它更清晰了;bytes 用于二进制数据,而 str 用于字符数据。唯一棘手的部分是知道何时以及如何在这两者之间进行转换。为了练习,尝试将文本数据写入一个以 bytes 写入模式打开的文件(你将不得不自己编码文本),然后从同一个文件中读取。
对 bytearray 进行一些实验。看看它如何同时像 bytes 对象、列表或容器对象一样工作。尝试写入一个缓冲区,该缓冲区在数据达到一定长度之前保持数据在字节数组中。你可以通过使用 time.sleep 调用来模拟将数据放入缓冲区的代码,以确保数据不会太快到达。
在线学习正则表达式。再深入一些。特别是学习关于命名组、贪婪匹配与懒惰匹配以及正则表达式标志,这三个我们在本章中没有涉及到的特性。有意识地决定何时不使用它们。很多人对正则表达式有非常强烈的看法,要么过度使用,要么完全拒绝使用。试着让自己只在适当的时候使用它们,并找出何时是适当的时候。
如果你曾经编写过适配器,从文件或数据库中加载少量数据并将其转换为对象,考虑使用 pickle。pickle 不适合存储大量数据,但它们可以用于加载配置或其他简单对象。尝试用多种方式编码:使用 pickle、文本文件或小型数据库。你发现哪种最容易使用?
尝试对数据进行序列化实验,然后修改包含数据的类,并将 pickle 加载到新类中。哪些可行?哪些不可行?是否有方法可以对类进行重大更改,例如重命名属性或将它拆分为两个新属性,同时还能从旧的 pickle 中提取数据?(提示:在每个对象上放置一个私有的 pickle 版本号,并在更改类时更新它;你可以在__setstate__中添加迁移路径。)
如果你进行过任何网络开发,尝试使用 JSON 序列化器做一些实验。我个人更喜欢只序列化标准 JSON 可序列化对象,而不是编写自定义编码器或object_hooks,但期望的效果实际上取决于前端(通常是 JavaScript)和后端代码之间的交互。
在模板引擎中创建一些接受多个或任意数量参数的新指令。你可能需要修改正则表达式或添加新的。查看 Django 项目的在线文档,看看是否有其他你想要与之合作的模板标签。尝试模仿它们的过滤器语法,而不是使用variable标签。
当你学习了迭代和协程后,回顾本章内容,看看你是否能想出一个更紧凑的方式来表示相关指令之间的状态,例如循环。
摘要
我们在本章中介绍了字符串操作、正则表达式和对象序列化。可以使用强大的字符串格式化系统将硬编码的字符串和程序变量组合成可输出的字符串。区分二进制和文本数据非常重要,bytes和str有特定的用途,必须理解。两者都是不可变的,但在操作字节时可以使用bytearray类型。
正则表达式是一个复杂的话题,我们只是触及了表面。有许多方法可以序列化 Python 数据;pickle 和 JSON 是最受欢迎的两种。
在下一章中,我们将探讨一个对 Python 编程至关重要的设计模式,它甚至得到了特殊的语法支持:迭代器模式。
第九章:迭代器模式
我们已经讨论了 Python 的许多内置函数和惯用法乍一看似乎与面向对象原则相悖,但实际上是在底层提供对真实对象的访问。在本章中,我们将讨论看似结构化的for循环实际上是如何围绕一组面向对象原则的轻量级包装。我们还将看到对这个语法的各种扩展,这些扩展可以自动创建更多类型的对象。我们将涵盖以下主题:
-
什么是设计模式
-
迭代器协议——最强大的设计模式之一
-
列表、集合和字典推导式
-
生成器和协程
简要介绍设计模式
当工程师和建筑师决定建造一座桥梁、一座塔或一座建筑时,他们会遵循某些原则以确保结构完整性。桥梁(例如,悬索桥和悬臂桥)有各种可能的设计,但如果工程师不使用标准设计,也没有一个出色的全新设计,那么他/她设计的桥梁很可能会倒塌。
设计模式试图将这种对正确设计的正式定义应用到软件工程中。有许多不同的设计模式来解决不同的普遍问题。设计模式通常解决开发者在某些特定情况下面临的具体常见问题。设计模式随后是对该问题的理想解决方案的建议,从面向对象设计的角度来说。
然而,了解设计模式并选择在我们的软件中使用它并不能保证我们正在创建一个正确的解决方案。1907 年,魁北克桥(至今仍是世界上最长的悬臂桥)在建设完成前就倒塌了,因为设计它的工程师们极大地低估了用于建造它的钢材重量。同样,在软件开发中,我们可能会错误地选择或应用设计模式,并创建出在正常操作情况下或在超出原始设计极限的压力下会崩溃的软件。
任何一种设计模式都提出了一组以特定方式交互的对象,以解决一个普遍问题。程序员的任务是识别他们面临的是这种问题的特定版本,然后选择并调整通用设计以适应他们的精确需求。
在本章中,我们将介绍迭代器设计模式。这个模式非常强大且普遍,以至于 Python 开发者提供了多种语法来访问模式背后的面向对象原则。我们将在下一章中介绍其他设计模式。其中一些有语言支持,而另一些则没有,但没有任何一个模式像迭代器模式那样内在地成为 Python 程序员日常生活的组成部分。
迭代器
在典型的设计模式术语中,迭代器是一个具有 next() 方法和 done() 方法的对象;后者如果序列中没有剩余的项目则返回 True。在没有内置迭代器支持的编程语言中,迭代器会被像这样遍历:
while not iterator.done():
item = iterator.next()
# do something with the item
在 Python 中,迭代是一个特殊特性,因此该方法有一个特殊名称,即 __next__。此方法可以通过 next(iterator) 内置函数访问。而不是 done 方法,Python 的迭代器协议通过抛出 StopIteration 来通知循环它已经完成。最后,我们有更易读的 for item in iterator 语法来实际访问迭代器中的项目,而不是在 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.
流行网络服务器、数据库或电子邮件服务器的日志文件可以包含许多 GB 的数据(我曾经不得不从一个行为不端的系统中清理近 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 处开始,并继续到下一个。
从另一个可迭代对象中产生项目
通常,当我们构建一个生成器函数时,我们会陷入一个想要从另一个可迭代对象(可能是我们在生成器内部构建的列表推导式或生成器表达式,或者可能是传递给函数的外部项目)产生数据的局面。这始终是通过遍历可迭代对象并逐个产生每个项目来实现的。然而,在 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
首先,我们为每个队伍构建两个 计数器 对象。是的,它们看起来像函数,但就像前一部分中的生成器对象一样,函数内部存在 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 搜索一个序列号。
在指示匹配器找到另一个设备 ID 并重复循环之前,它先无用地将序列号产生到外部的for循环中。
基本上,协程的任务是搜索文件中的下一个重要行,而生成器(使用yield语法但不进行赋值的get_serial)的任务是决定哪一行是重要的。生成器有关于这个特定问题的信息,比如文件中行的出现顺序。另一方面,协程可以插入到任何需要搜索文件中给定正则表达式的任何问题中。
关闭协程和抛出异常
正常生成器通过抛出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关键字的不同语法。这种语法使得代码是协程的事实更加清晰,并进一步打破了协程和生成器之间欺骗性的对称性。这种语法在没有构建完整的事件循环的情况下工作得不是很好,所以我们将在并发章节介绍asyncio之前跳过它。
案例研究
目前 Python 最受欢迎的领域之一是数据科学。为了纪念这一事实,让我们实现一个基本的机器学习算法。
机器学习是一个巨大的主题,但基本思想是利用从过去数据中获得的知识来对未来的数据进行预测或分类。此类算法的应用非常广泛,数据科学家每天都在寻找新的应用机器学习的方法。一些重要的机器学习应用包括计算机视觉(如图像分类或人脸识别)、产品推荐、识别垃圾邮件和自动驾驶汽车。
为了避免深入到一本关于机器学习的整本书,我们将看看一个更简单的问题:给定一个 RGB 颜色定义,人类会将其识别为哪种颜色?
标准 RGB 颜色空间中有超过 1600 万种颜色,而人类只为其中的一小部分想出了名字。虽然有一些名字(有些相当荒谬;只需去任何汽车经销商或油漆店看看),但让我们构建一个分类器,尝试将 RGB 空间划分为基本颜色:
-
红色
-
紫色
-
蓝色
-
绿色
-
黄色
-
橙色
-
灰色
-
粉色
(在我的测试中,我将白色和黑色颜色分类为灰色,将棕色颜色分类为橙色。)
我们首先需要的是一个用于训练我们算法的数据集。在生产系统中,你可能需要抓取一个 颜色列表 网站,或者调查数千人。相反,我创建了一个简单的应用程序,它会渲染一个随机颜色,并要求用户从前面的八个选项中选择一个来对其进行分类。我使用 tkinter(Python 附带的用户界面工具包)实现了它。我不会详细介绍这个脚本的细节,但为了完整性,这里提供它的全部内容(它有点长,所以你可能想从 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告诉函数在转换时使用十六进制(十六进制)而不是通常的十进制(十进制)基数。
由于生成器表达式难以阅读,你认为它应该以不同的格式表示吗?它可以创建为多个生成器表达式的序列,例如,或者展开为一个带有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 最近邻实现。这个例程可以被认为是消费和组合我们之前看到的两个生成器(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最近邻中。许多数学家和数据科学家都有一种糟糕的倾向,使用难以理解的单一字母变量名)最近颜色的列表。
列表推导式中的每个元素内容都是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__()方法。基本上,生成器语法是一种语法快捷方式,用于创建一种在没有它的情况下会相当冗长的特定类型的对象。
作为一种历史性的注释,本书的第二版使用协程而不是基本生成器来解决此问题。我决定在第三版中将它改为生成器,原因有几个:
-
除了
asyncio之外,没有人会在现实生活中使用协程,我们将在并发章节中介绍asyncio。我觉得我错误地鼓励人们使用协程来解决问题,而实际上协程极其罕见地是正确的工具。 -
与生成器版本相比,协程版本更长、更复杂,并且有更多的模板代码。
-
协程版本没有展示出本章讨论的其他足够多的特性,例如列表推导和生成器表达式。
如果你可能对基于协程的实现感到历史上有兴趣,我在本章的下载示例代码中包含了这个代码的副本。
练习
如果你平时很少在代码中使用推导,你应该首先搜索一些现有的代码,找到一些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 图中的示例:

在这里,核心和所有装饰器实现了一个特定的接口。装饰器通过组合维护对该接口另一个实例的引用。当被调用时,装饰器在其包装的接口之前或之后执行一些附加处理。包装的对象可能是另一个装饰器,或者是核心功能。虽然多个装饰器可以相互包装,但所有这些装饰器的中心对象提供了核心功能。
装饰器示例
让我们看看网络编程中的一个示例。我们将使用 TCP 套接字。socket.send()方法接收一个输入字节的字符串,并将其输出到另一端的接收套接字。有许多库接受套接字并使用此功能在流中发送数据。让我们创建这样一个对象;它将是一个交互式外壳,等待客户端的连接,然后提示用户输入字符串响应:
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对象。我们将创建一对装饰器,以定制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对象,并向客户端套接字提供了send和close接口。更好的装饰器还会实现(并可能定制)所有剩余的socket方法。它应该正确实现所有send的参数(实际上接受一个可选的标志参数),但让我们保持示例简单。每当在这个对象上调用send时,它都会在将数据发送到客户端之前将输出记录到屏幕上。
我们只需要在我们的原始代码中更改一行来使用这个装饰器。我们不是用套接字调用respond,而是用装饰过的套接字调用它:
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。如果它被启用,它将套接字包裹在LogSocket装饰器中。同样,它检查连接的客户端是否在已知接受压缩内容的地址列表中。如果是这样,它将客户端包裹在GzipSocket装饰器中。注意,根据配置和连接的客户端,可能没有,一个,或者两个装饰器被启用。尝试使用多重继承来编写这个,看看你会多么困惑!
Python 中的装饰器
装饰器模式在 Python 中很有用,但还有其他选择。例如,我们可能能够使用猴子补丁(例如,socket.socket.send = log_send)来达到类似的效果。单继承,其中可选的计算在一个大方法中完成,可能是一个选择,而且多重继承不应该因为之前看到的特定示例不适合而被放弃。
在 Python 中,在函数上使用这种模式非常常见。正如我们在前面的章节中看到的,函数也是对象。事实上,函数装饰如此常见,以至于 Python 提供了一种特殊的语法,使其能够轻松地将这样的装饰器应用于函数。
例如,我们可以更一般地看待日志记录的例子。除了在套接字上发送日志调用之外;我们可能会发现记录对某些函数或方法的全部调用是有帮助的。以下示例实现了一个执行此操作的装饰器:
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)
这个装饰器函数与我们之前探索的例子非常相似;在那些情况下,装饰器接受一个类似套接字的对象并创建了一个类似套接字的对象。这次,我们的装饰器接受一个函数对象并返回一个新的函数对象。这段代码包括三个独立任务:
-
一个名为
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 方法来构建树的其余部分。
现在,让我们看看 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 中是可以接受的。如果有人有合理的理由改变这些变量,我们为什么要阻止他们?它也不会阻止人们实例化该对象的多实例,但同样,如果他们有合理的理由这样做,我们为什么要干涉?
理想情况下,我们应该提供一个机制让他们能够访问默认的单例值,同时允许他们在需要时创建其他实例。虽然技术上根本不是单例,但它提供了最符合 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 将其包含在其标准库中,因此不需要额外的模块。
我们有两个常见的任务需要执行:
-
选择所有新车的销售并按逗号分隔的格式输出到屏幕
-
输出所有销售人员的逗号分隔列表,包括他们的毛销售额,并将其保存到可以导入电子表格的文件中
这些任务看起来相当不同,但它们有一些共同特征。在两种情况下,我们需要执行以下步骤:
-
连接到数据库。
-
构建针对新车或毛销售额的查询。
-
执行查询。
-
将结果格式化为逗号分隔的字符串。
-
将数据输出到文件或电子邮件。
对于这两个任务,查询构建和输出步骤不同,但剩余步骤是相同的。我们可以使用模板模式将共同步骤放在基类中,将不同的步骤放在两个子类中。
在我们开始之前,让我们创建一个数据库,并使用几行 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 发送。这不是一个复杂的任务,但需要对传递到门面(facade)的自然输入参数进行相当多的调整,以便将它们转换为正确的格式,从而使得 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 图:

每个享元都没有特定的状态。任何需要在对具体状态执行操作时,都需要由调用代码将此状态传递给享元。传统上,返回享元的工厂是一个单独的对象;其目的是为给定键标识的享元返回享元。它的工作方式类似于我们在第十章中讨论的单例模式;如果享元存在,我们返回它;否则,我们创建一个新的。在许多语言中,工厂不是作为一个单独的对象实现,而是作为Flyweight类本身的静态方法实现。
想象一下汽车销售的库存系统。每辆单独的汽车都有一个特定的序列号和特定的颜色。但关于那辆汽车的大部分细节对于同一型号的所有汽车都是相同的。例如,本田飞度 DX 型号是一款功能简单的汽车。LX 型号配备了空调、倾斜、定速巡航和电动门窗锁。运动型号配备了花哨的轮子、USB 充电器和尾翼。如果没有使用享元模式,每辆单独的汽车对象都必须存储一个长长的列表,列出它有哪些功能和没有哪些功能。考虑到本田每年销售的汽车数量,这将导致大量的内存浪费。
使用享元模式,我们可以为与型号相关的功能列表拥有共享对象,然后只需简单地引用该型号,以及序列号和颜色,用于单个车辆。在 Python 中,享元工厂通常使用那个奇特的__new__构造函数实现,类似于我们之前在单例模式中使用的。
与只返回一个类实例的单例模式不同,我们需要能够根据键返回不同的实例。我们可以在字典中存储项目并基于键查找它们。然而,这个解决方案是有问题的,因为项目将保留在内存中,只要它在字典中。如果我们卖完了 LX 型号的飞度,飞度享元就不再必要了,但它仍然会在字典中。我们可以在卖掉汽车时清理它,但这不就是垃圾收集器的作用吗?
我们可以通过利用 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
基本上,每当我们使用给定的名称构建一个新的飞 weight 时,我们首先在弱引用字典中查找该名称;如果存在,我们返回该模型;如果不存在,我们创建一个新的。无论哪种方式,我们知道在飞 weight 上每次都会调用__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__时初始化对象。这意味着我们可以在稍后仅使用模型名称调用工厂,并获取相同的飞 weight 对象。然而,因为如果不存在外部引用,飞 weight 将被垃圾回收,我们必须小心不要意外地使用空值创建一个新的飞 weight。
让我们在飞 weight 中添加一个方法,这个方法假设性地查找特定车型上的序列号,并确定它是否参与过任何事故。此方法需要访问汽车的序列号,该序列号因车而异;它不能与飞 weight 一起存储。因此,这些数据必须通过调用代码以如下方式传递到方法中:
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)
)
我们可以定义一个类来存储额外的信息,以及飞 weight 的引用,如下所示:
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。这意味着对象第二次没有被初始化,正如我们设计的。
显然,使用飞 weight 模式比仅在单个汽车类上存储特征要复杂得多。我们应该在什么情况下选择使用它?飞 weight 模式是为了节省内存;如果我们有成千上万的相似对象,将相似属性组合到飞 weight 中可以在内存消耗上产生巨大的影响。
对于优化 CPU、内存或磁盘空间的编程解决方案,通常会导致比未优化的版本更复杂的代码。因此,在决定代码可维护性和优化之间权衡时,非常重要。在选择优化时,尽量使用如飞 weight 之类的模式,以确保优化引入的复杂性仅限于代码的一个(良好文档化的)部分。
如果你在一个程序中有许多 Python 对象,通过使用__slots__来节省内存是一种快速的方法。__slots__魔法方法超出了本书的范围,但如果你在网上查找,会有很多信息。如果你仍然内存不足,轻量级模式可能是一个合理的解决方案。
命令模式
命令模式在必须执行的动作和执行这些动作的对象之间添加了一个抽象层,通常在稍后的时间执行。在命令模式中,客户端代码创建一个可以在以后执行的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)
这些类使用一些基本的字符串操作来尝试将各种可能的输入(整数、不同长度的字符串等)转换为以下格式:
| USA | France | |
|---|---|---|
| 日期 | 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来获取当前国家地区的日期格式。最终结果比原始的抽象工厂模式更加 Pythonic,并且在典型使用中,它同样灵活。
组合模式
组合模式允许从简单的组件构建复杂的树状结构。这些组件被称为组合对象,它们能够根据是否有子组件而表现得像容器或变量。组合对象是容器对象,其内容实际上可能是另一个组合对象。
传统上,复合对象中的每个组件必须是叶节点(不能包含其他对象)或组合节点。关键是组合和叶节点可以具有相同的接口。以下 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 的鸭子类型可以使得将其他对象添加到组合层次结构中变得容易,只要它们具有正确的接口。
练习
在深入到每个设计模式的练习之前,花点时间实现上一节中 File 和 Folder 对象的 copy 方法。File 的方法应该相当简单;只需创建一个具有相同名称和内容的新的节点,并将其添加到新的父文件夹中。Folder 的 copy 方法要复杂得多,因为你首先必须复制文件夹,然后将每个子节点递归地复制到新位置。你可以无差别地调用子节点的 copy() 方法,无论它们是文件还是文件夹对象。这将充分展示组合模式有多么强大。
现在,就像上一章一样,看看我们讨论过的模式,并考虑你可能实现它们的理想位置。你可能希望将适配器模式应用于现有代码,因为它通常适用于与现有库接口,而不是新代码。你如何使用适配器来强制两个接口正确地相互交互?
你能想到一个足够复杂的系统,以证明使用外观模式的合理性吗?考虑外观在实际生活中的应用,比如汽车的驾驶员界面,或者工厂的控制面板。在软件中,这类似,只是外观接口的用户是其他程序员,而不是受过培训使用它们的人。在你的最新项目中,有复杂到足以从外观模式中受益的系统吗?
可能你没有大量消耗内存的代码,但你能想到可能有用的情况吗?任何需要处理大量重叠数据的地方,都可能有享元模式等待被使用。在银行业务中会有用吗?在 Web 应用程序中呢?在什么情况下采用享元模式是有意义的?什么时候又过度了呢?
那么,命令模式呢?你能想到任何常见的(或者更好的,不常见的)例子,说明从调用中解耦动作会有用吗?看看你每天使用的程序,想象一下它们是如何内部实现的。很可能其中许多程序出于某种目的使用了命令模式。
抽象工厂模式,或者我们讨论过的稍微更 Python 化的衍生模式,对于创建一键可配置的系统非常有用。你能想到这样的系统在哪些地方有用吗?
最后,考虑组合模式。在编程中,我们周围到处都是树状结构;其中一些,比如我们的文件层次结构示例,很明显;其他则相当微妙。在什么情况下组合模式可能会很有用?你能想到在你的代码中可以使用它的地方吗?如果你稍微调整一下模式;例如,为不同类型的对象包含不同类型的叶节点或组合节点,会怎样?
摘要
在本章中,我们详细介绍了几个更多的设计模式,包括它们的规范描述以及如何在 Python 中实现它们,Python 通常比传统的面向对象语言更加灵活和多功能。适配器模式用于匹配接口,而外观模式适合简化它们。享元模式是一个复杂的设计模式,只有在需要内存优化时才有效。在 Python 中,命令模式通常更合适地通过一等函数作为回调来实现。抽象工厂允许根据配置或系统信息在运行时分离实现。组合模式被普遍用于树状结构。
这是本书中真正面向对象的章节的最后一章,但我还加入了一些我非常关心的主题的免费内容。在下一章中,我们将讨论测试 Python 程序的重要性以及如何进行测试,重点关注面向对象的原则。
第十二章:测试面向对象程序
熟练的 Python 程序员一致认为,测试是软件开发最重要的方面之一。尽管这一章被放置在书的末尾附近,但这并不是一个事后想法;我们迄今为止所学的所有内容都将帮助我们编写测试。在本章中,我们将探讨以下主题:
-
单元测试和测试驱动开发的重要性
-
标准的
unittest模块 -
pytest自动化测试套件 -
mock模块 -
代码覆盖率
-
使用
tox进行跨平台测试
为什么需要测试?
许多程序员已经知道测试代码的重要性。如果你是其中之一,请随意浏览这一节。你会发现下一节——我们实际上将看到如何在 Python 中创建测试——要有趣得多。如果你还没有确信测试的重要性,我保证你的代码有缺陷,你只是不知道而已。继续阅读!
有些人认为,由于 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 |
确保两个容器对象具有相同的元素,忽略顺序。 |
assertSequenceEqual assertDictEqual 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()
如果我们运行此示例,它表明所有测试都通过。首先要注意的是,在三个 test_* 方法中从未显式调用 setUp 方法。测试套件代表我们这样做。更重要的是,注意 test_median 如何通过向其中添加额外的 4 来改变列表,但在随后的 test_mode 被调用时,列表已返回 setUp 中指定的值。如果不是这样,列表中会有两个 4,并且 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 的主页 pytest.org/ 获取它。该网站提供了针对各种解释器和平台的全面安装说明,但通常您可以使用更常见的 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 12: 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 中使用的设置和清理方法,但它提供了更多的灵活性。由于这些方法很熟悉,我们将简要讨论它们,但它们在 pytest 模块中的使用并不像在 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提供了一种完全不同的方法来做这件事,使用的是所谓的测试设置。测试设置基本上是在测试配置文件中预定义的命名变量。这允许我们将配置与测试执行分开,并允许测试设置在多个类和模块之间使用。
要使用它们,我们在测试函数中添加参数。参数的名称用于在特别命名的函数中查找特定参数。例如,如果我们想测试我们在演示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的参数;这个参数是通过调用装饰了@pytest.fixture的valid_stats函数创建的。
测试设置可以做很多比返回基本变量更多的事情。可以将request对象传递给测试设置工厂,以提供修改 funcarg 行为极其有用的方法和属性。module、cls和function属性允许我们确切地看到哪个测试正在请求测试设置。config属性允许我们检查命令行参数和大量其他配置数据。
如果我们将测试设置实现为一个生成器,我们可以在每个测试运行后运行清理代码。这提供了类似于清理方法的等效功能,但基于每个测试设置。我们可以用它来清理文件、关闭连接、清空列表或重置队列。例如,以下代码通过创建一个临时目录测试设置来测试os.mkdir功能:
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
该测试设置创建一个用于创建文件的新空临时目录。它在测试中使用这个目录,但在测试完成后会删除该目录(使用shutil.rmtree,它递归地删除目录及其内部的所有内容)。然后文件系统会保持与开始时相同的状态。
我们可以传递一个scope参数来创建一个比一个测试更持久的测试设置。当设置一个可以由多个测试重用的昂贵操作时,这很有用,只要资源重用不会破坏测试的原子性或单元性(这样,一个测试就不会依赖于前一个测试,也不会受到前一个测试的影响)。例如,如果我们想要测试以下回声服务器,我们可能只想在单独的进程中运行一个服务器实例,然后让多个测试连接到该实例:
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()
所有这些代码所做的只是监听特定端口并等待客户端套接字的输入。当它收到输入时,它会发送相同的值回送。为了测试这一点,我们可以在单独的进程中启动服务器并缓存结果以供多个测试使用。以下是测试代码可能的样子:
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"
我们在这里创建了两个 fixture。第一个在一个单独的进程中运行 echo 服务器,并在完成后清理它,产生进程对象。第二个为每个测试实例化一个新的套接字对象,并在测试完成后关闭套接字。
第一个 fixture 是我们目前感兴趣的。从传递给装饰器构造函数的 scope="session" 关键字参数,pytest 知道我们只想在这个单元测试会话期间初始化和终止这个 fixture 一次。
范围可以是字符串 class、module、package 或 session 之一。它决定了参数将被缓存多长时间。在这个例子中,我们将其设置为 session,因此它将在整个 pytest 运行期间被缓存。只有在所有测试都运行完毕后,进程才会被终止或重启。module 范围只会为该模块中的测试缓存它,而 class 范围则更像是正常的类设置和销毁。
当这本书的第三版准备印刷时,package 范围在 pytest 中被标记为实验性。请小心使用它,并请求您提供错误报告。
使用 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 报告,我们可以在网页浏览器中查看。网页甚至会突出显示哪些源代码行被测试过,哪些没有被测试。以下是它的样子:

我们也可以使用coverage.py模块与pytest一起。我们需要安装pytest的代码覆盖率插件,使用pip install pytest-coverage。该插件为pytest添加了几个命令行选项,其中最有用的是--cover-report,可以设置为html、report或annotate(后者实际上修改了原始源代码,以突出显示任何未覆盖的行)。
不幸的是,如果我们能以某种方式运行本章这一部分的覆盖率报告,我们会发现我们没有涵盖关于代码覆盖率的大部分知识!我们可以使用覆盖率 API 从我们的程序(或测试套件)内部管理代码覆盖率,coverage.py接受许多我们尚未涉及的配置选项。我们也没有讨论语句覆盖率和分支覆盖率之间的区别(后者更有用,且是coverage.py最新版本的默认设置),或其他代码覆盖率风格。
请记住,尽管 100%的代码覆盖率是一个崇高的目标,我们都应该为之努力,但 100%的覆盖率是不够的!仅仅因为一个语句被测试了,并不意味着它对所有可能的输入都进行了适当的测试。
案例研究
让我们通过编写一个小型、经过测试的加密应用程序来了解测试驱动开发。别担心——你不需要理解复杂现代加密算法(如 AES 或 RSA)背后的数学。相反,我们将实现一个十六世纪的算法,称为维吉尼亚密码。该应用程序只需要能够使用这个密码,给定一个编码关键字,对消息进行编码和解码。
如果你想深入了解 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"
如果我们在测试类的顶部添加一行fromvigenere_cipherimportVigenereCipher并运行pytest`,前面的测试就会通过!我们已经完成了我们的第一个测试驱动开发周期。
这可能看起来像是一个荒谬可笑的测试,但实际上它验证了很多内容。第一次我实现它时,我在类名中将 cipher 误拼为cypher。即使是我的基本单元测试也帮助捕捉到了一个错误。即便如此,返回一个硬编码的字符串显然不是 cipher 类最明智的实现方式,所以让我们添加第二个测试,如下所示:
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)
这是测试驱动开发的最大好处,也是最重要的。一旦编写了测试,我们就可以尽可能多地改进我们的代码,并确信我们的更改没有破坏我们一直在测试的内容。此外,我们知道我们的重构何时完成:当所有测试都通过时。
当然,我们的测试可能并没有全面测试我们所需要的一切;维护或代码重构仍然可能导致未诊断的 bug,这些 bug 在测试中不会出现。自动测试并不是万无一失的。然而,如果确实出现了 bug,仍然可以遵循以下测试驱动计划:
-
编写一个测试(或多个测试)来复制或证明所讨论的 bug 正在发生。当然,这会失败。
-
然后编写代码使测试停止失败。如果测试是全面的,bug 将被修复,我们将在运行测试套件后知道它是否再次发生。
最后,我们可以尝试确定我们的测试在这个代码上的运行效果。安装了pytest覆盖率插件后,pytest -coverage-report=report告诉我们我们的测试套件有 100%的代码覆盖率。这是一个很好的统计数据,但我们不应该过于自满。当编码包含数字的消息时,我们的代码还没有被测试,因此其对这些输入的行为是未定义的。
练习
练习测试驱动开发。这是你的第一个练习。如果你开始一个新的项目,这样做会更容易,但如果你需要处理现有的代码,你可以从为每个新实现的功能编写测试开始。随着你对自动化测试越来越着迷,这可能会变得令人沮丧。旧的未测试代码将开始感觉僵硬且紧密耦合,维护起来会不舒服;你开始感觉所做的更改正在破坏代码,而你又无法知道,因为没有测试。但如果你从小处着手,逐渐向代码库添加测试,随着时间的推移,代码质量会得到改善。
因此,为了开始测试驱动开发,开始一个新的项目。一旦你开始欣赏其好处(你会的)并意识到编写测试所花费的时间很快就会在更易于维护的代码中得到回报,你将想要开始为现有代码编写测试。这就是你应该开始这样做的时候,而不是之前。为已知工作的代码编写测试是无聊的。直到你意识到我们以为工作的代码实际上有多糟糕,你才可能对项目产生兴趣。
尝试使用内置的unittest模块和pytest编写相同的一组测试。你更喜欢哪一个?unittest与其他语言的测试框架更相似,而pytest则可以说是更 Pythonic。两者都允许我们编写面向对象的测试,并轻松地测试面向对象的程序。
在我们的案例研究中,我们使用了pytest,但我们没有触及到任何使用unittest不容易测试的特性。尝试调整测试以使用跳过测试或固定值(VignereCipher的一个实例会有所帮助)。尝试各种设置和清理方法,并将它们与 funcargs 的使用进行比较。哪一个对你来说更自然?
尝试运行你编写的测试的覆盖率报告。你是否遗漏了测试任何代码行?即使你有 100%的覆盖率,你是否测试了所有可能的输入?如果你在做测试驱动开发,100%的覆盖率应该会相当自然地跟随,因为你会在满足测试的代码之前编写测试。然而,如果你在为现有代码编写测试,更有可能存在未被测试的边缘情况。
仔细思考那些以某种方式不同的值,例如以下内容:
-
当你期望满列表时却得到了空列表
-
与正整数相比,负数、零、一或无穷大
-
不四舍五入到精确小数位的浮点数
-
当你期望数字时却得到了字符串
-
当你期望 ASCII 字符串时却得到了 Unicode 字符串
-
当你期望有意义的内容时却遇到了无处不在的
None值
如果你的测试覆盖了这样的边缘情况,你的代码将处于良好状态。
摘要
我们终于涵盖了 Python 编程中最重要的话题:自动化测试。测试驱动开发被认为是一种最佳实践。标准库中的unittest模块提供了一个出色的开箱即用的测试解决方案,而pytest框架则提供了一些更符合 Python 语法的语法。在测试中,我们可以使用模拟来模拟复杂的类。代码覆盖率可以给我们提供一个估计,即我们的代码中有多少是被我们的测试运行的,但它并不能告诉我们我们已经测试了正确的事情。
在下一章中,我们将跳入一个完全不同的主题:并发。
第十三章:并发
并发是让计算机同时做(或看起来同时做)多件事情的艺术。从历史上看,这意味着邀请处理器每秒多次在多个任务之间切换。在现代系统中,这也可以字面意义上意味着在单独的处理器核心上同时做两件或多件事。
并发本身并不是一个面向对象的主题,但 Python 的并发系统提供了面向对象的接口,正如我们在整本书中提到的。本章将介绍以下主题:
-
线程
-
多进程
-
Futures
-
AsyncIO
并发很复杂。基本概念相当简单,但可能出现的错误却难以追踪。然而,对于许多项目来说,并发是获得所需性能的唯一途径。想象一下,如果网络服务器不能在另一个用户完成之前响应用户的请求会怎样!我们不会深入探讨这有多么困难(需要另一本完整的书),但我们将看到如何在 Python 中实现基本的并发,以及一些常见的陷阱要避免。
线程
通常,并发是为了在程序等待 I/O 操作发生时继续执行工作。例如,服务器可以在等待前一个请求的数据到达时开始处理新的网络请求。或者,一个交互式程序可能在等待用户按下一个键时渲染动画或执行计算。记住,虽然一个人每分钟可以输入超过 500 个字符,但计算机每秒可以执行数十亿条指令。因此,在快速输入时,即使在单个按键之间,也可能发生大量的处理。
从理论上讲,你可以在程序内部管理所有这些活动之间的切换,但这几乎是不可能的。相反,我们可以依赖 Python 和操作系统来处理复杂的切换部分,同时我们创建看起来似乎是独立运行但实际上是同时运行的对象。这些对象被称为线程。让我们看看一个基本的例子:
from threading import Thread
class InputReader(Thread):
def run(self):
self.line_of_text = input()
print("Enter some text and press enter: ")
thread = InputReader()
thread.start()
count = result = 1
while thread.is_alive():
result = count * count
count += 1
print("calculated squares up to {0} * {0} = {1}".format(count, result))
print("while you typed '{}'".format(thread.line_of_text))
这个例子运行了两个线程。你能看到它们吗?每个程序都有(至少)一个线程,称为主线程。从启动执行的代码就在这个线程中。更明显的第二个线程是InputReader类。
要构建一个线程,我们必须扩展Thread类并实现run方法。任何由run方法执行的代码都在一个单独的线程中执行。
新的线程在我们在对象上调用start()方法之前不会开始运行。在这种情况下,线程立即暂停以等待键盘输入。与此同时,原始线程从start被调用的地方继续执行。它开始在一个while循环中计算平方。while循环中的条件检查InputReader线程是否已经从其run方法退出;一旦它退出,它就会在屏幕上输出一些总结信息。
如果我们运行这个例子并输入字符串hello world,输出看起来如下:
Enter some text and press enter:
hello world
calculated squares up to 2448265 * 2448265 = 5993996613696
当然,在输入字符串时,你会计算更多或更少的平方,因为数字与我们的相对打字速度以及我们运行的计算机的处理器速度都有关。当我在第一版和第三版之间更新这个例子时,我的新系统能够计算比之前多两倍的平方。
线程只有在调用start方法时才会以并发模式开始运行。如果我们想取消并发调用以比较其效果,我们可以在原始调用thread.start()的地方调用thread.run()。正如这里所示,输出是说明性的:
Enter some text and press enter:
hello world
calculated squares up to 1 * 1 = 1
while you typed 'hello world'
在这种情况下,没有第二个线程,while 循环从未执行。当我们输入时,我们浪费了很多 CPU 资源处于空闲状态。
使用线程有效有很多不同的模式。我们不会涵盖所有这些模式,但我们会查看一个常见的模式,这样我们就可以了解join方法。让我们检查加拿大每个省和地区的首府的当前温度:
from threading import Thread
import time
from urllib.request import urlopen
from xml.etree import ElementTree
CITIES = {
"Charlottetown": ("PE", "s0000583"),
"Edmonton": ("AB", "s0000045"),
"Fredericton": ("NB", "s0000250"),
"Halifax": ("NS", "s0000318"),
"Iqaluit": ("NU", "s0000394"),
"Québec City": ("QC", "s0000620"),
"Regina": ("SK", "s0000788"),
"St. John's": ("NL", "s0000280"),
"Toronto": ("ON", "s0000458"),
"Victoria": ("BC", "s0000775"),
"Whitehorse": ("YT", "s0000825"),
"Winnipeg": ("MB", "s0000193"),
"Yellowknife": ("NT", "s0000366"),
}
class TempGetter(Thread):
def __init__(self, city):
super().__init__()
self.city = city
self.province, self.code = CITIES[self.city]
def run(self):
url = (
"http://dd.weatheroffice.ec.gc.ca/citypage_weather/xml/"
f"{self.province}/{self.code}_e.xml"
)
with urlopen(url) as stream:
xml = ElementTree.parse(stream)
self.temperature = xml.find(
"currentConditions/temperature"
).text
threads = [TempGetter(c) for c in CITIES]
start = time.time()
for thread in threads:
thread.start()
for thread in threads:
thread.join()
for thread in threads:
print(f"it is {thread.temperature}°C in {thread.city}")
print(
"Got {} temps in {} seconds".format(
len(threads), time.time() - start
)
)
这段代码在启动线程之前构建了 10 个线程。注意我们如何覆盖构造函数将它们传递给Thread对象,同时记得调用super以确保Thread被正确初始化。
我们在一个线程中构建的数据可以从其他正在运行的线程中访问。run 方法中全局变量的引用说明了这一点。不那么明显的是,传递给构造函数的数据正在主线程中分配给self,但在第二个线程中被访问。这可能会让人困惑;仅仅因为一个方法在Thread实例上,并不意味着它会在那个线程中神奇地执行。
在启动 10 个线程之后,我们再次遍历它们,对每个线程调用join()方法。这个方法表示等待线程完成后再做任何事情。我们依次调用十次;这个for循环不会退出,直到所有十个线程都完成。
在这个阶段,我们可以打印出存储在每个线程对象上的温度。请注意,再次强调,我们可以从主线程访问在线程中构建的数据。在线程中,所有状态默认是共享的。
在我的 100 兆比特连接上执行前面的代码大约需要三分之一的秒,我们得到以下输出:
it is 18.5°C in Charlottetown
it is 1.6°C in Edmonton
it is 16.6°C in Fredericton
it is 18.0°C in Halifax
it is -2.4°C in Iqaluit
it is 18.4°C in Québec City
it is 7.4°C in Regina
it is 11.8°C in St. John's
it is 20.4°C in Toronto
it is 9.2°C in Victoria
it is -5.1°C in Whitehorse
it is 5.1°C in Winnipeg
it is 1.6°C in Yellowknife
Got 13 temps in 0.29401135444641113 seconds
我现在是在九月写作,但北方已经低于冰点!如果我用单个线程(通过将start()调用改为run()并注释掉join()循环)运行这段代码,它需要接近四秒钟的时间,因为每个 0.3 秒的请求必须完成,下一个请求才能开始。这种数量级的速度提升仅仅展示了并发编程有多么有用。
线程的许多问题
线程可能很有用,尤其是在其他编程语言中,但现代的 Python 程序员倾向于避免使用它们,有几个原因。正如我们将看到的,还有其他方法可以编写并发编程,这些方法正在得到 Python 社区的更多关注。在我们继续之前,让我们讨论一些这些陷阱。
共享内存
线程的主要问题也是它们的最大优势。线程可以访问程序的所有内存和所有变量。这很容易导致程序状态的不一致性。
你是否遇到过这样一个房间,一个灯有两个开关,两个人同时打开它们?每个人(线程)都期望他们的动作会将灯(一个变量)打开,但结果是灯是关的,这与他们的期望不一致。现在想象如果这两个线程是在银行账户之间转账或管理车辆的巡航控制。
在线程编程中,解决这个问题的方法是对任何读取或(尤其是)写入共享变量的代码进行同步。有几种不同的方法可以做到这一点,但我们不会在这里深入讨论,以便我们可以专注于更 Pythonic 的结构。
同步解决方案是可行的,但很容易忘记应用它。更糟糕的是,由于不当使用同步而导致的错误很难追踪,因为线程执行操作的顺序不一致。我们无法轻易地重现错误。通常,最安全的方法是强制线程通过使用已经适当使用锁的轻量级数据结构进行通信。Python 提供了 queue.Queue 类来完成这项任务;其功能基本上与 multiprocessing.Queue 相同,我们将在下一节中讨论。
在某些情况下,这些缺点可能被允许共享内存的一个优点所抵消:它速度快。如果有多个线程需要访问一个巨大的数据结构,共享内存可以快速提供这种访问。然而,在 Python 中,由于两个在不同的 CPU 核心上运行的线程不可能同时执行计算,这个优势通常被抵消。这把我们带到了线程的第二个问题。
全局解释器锁
为了有效地管理内存、垃圾回收以及在本地库中对机器代码的调用,Python 有一个名为全局解释器锁或GIL的实用工具。它无法关闭,这意味着在 Python 中,线程对于其他语言中它们擅长的某一方面(并行处理)是无用的。对于我们的目的而言,GIL 的主要作用是防止任何两个线程在确切相同的时间进行工作,即使它们有工作要做。在这种情况下,“做工作”意味着使用 CPU,所以多个线程访问磁盘或网络是完全正常的;一旦线程开始等待某事,GIL 就会释放。这就是为什么天气示例可以工作。
GIL(全局解释器锁)受到了高度批评,主要是由那些不理解它是什么或者它给 Python 带来所有好处的人。如果我们的语言没有这种限制,那当然会很棒,但 Python 开发团队已经确定,它带来的价值大于其成本。它使得参考实现更容易维护和开发,而且在 Python 最初开发的单核处理器时代,它实际上使解释器运行得更快。然而,GIL 的净结果是限制了线程带来的好处,而没有减轻其成本。
虽然 GIL 是大多数人们使用的 Python 参考实现中的问题,但在一些非标准实现中,如 IronPython 和 Jython,这个问题已经被解决了。不幸的是,在出版时,这些实现中没有一个支持 Python 3。
线程开销
与我们稍后将要讨论的异步系统相比,线程的一个最终限制是维护每个线程的成本。每个线程都需要占用一定量的内存(在 Python 进程和操作系统内核中)来记录该线程的状态。在线程之间切换也会使用(少量)CPU 时间。这项工作在没有额外编码的情况下无缝发生(我们只需调用start(),其余的都会处理),但这项工作仍然需要发生。
这可以通过结构化我们的工作负载来在一定程度上缓解,使得线程可以被重用来执行多个任务。Python 提供了一个ThreadPool功能来处理这个问题。它作为多进程库的一部分提供,其行为与我们将要讨论的ProcessPool相同,所以让我们将这个讨论推迟到下一节。
多进程
多进程 API 最初是为了模仿线程 API 而设计的。然而,它已经发展,在 Python 3 的最新版本中,它更稳健地支持更多功能。多进程库是为了当需要并行执行 CPU 密集型任务且有多核可用时(几乎所有的计算机,甚至一个小巧的智能手表,都有多个核心)而设计的。当进程的大部分时间都在等待 I/O(例如,网络、磁盘、数据库或键盘)时,多进程并不有用,但对于并行计算来说,这是必经之路。
多进程模块会启动新的操作系统进程来完成工作。这意味着每个进程都运行着一个完全独立的 Python 解释器副本。让我们尝试使用与 threading API 提供的类似构造来并行化一个计算密集型操作,如下所示:
from multiprocessing import Process, cpu_count
import time
import os
class MuchCPU(Process):
def run(self):
print(os.getpid())
for i in range(200000000):
pass
if __name__ == "__main__":
procs = [MuchCPU() for f in range(cpu_count())]
t = time.time()
for p in procs:
p.start()
for p in procs:
p.join()
print("work took {} seconds".format(time.time() - t))
这个例子只是让 CPU 进行 2 亿次迭代。你可能不会认为这是有用的工作,但它可以在寒冷的日子里给你的笔记本电脑加热!
API 应该是熟悉的;我们实现了一个 Process(而不是 Thread)的子类,并实现了 run 方法。这个方法在执行一些激烈(如果方向错误)的工作之前,会打印出进程 ID(操作系统分配给机器上每个进程的唯一数字)。
请特别注意模块级别代码周围的 if __name__ == '__main__': 保护,这可以防止模块被导入时运行,而应该作为程序运行。这通常是一种良好的做法,但使用某些操作系统上的多进程时,这是至关重要的。幕后,多进程可能需要在新的进程中重新导入模块以执行 run() 方法。如果我们允许整个模块在那个时刻执行,它将开始递归地创建新进程,直到操作系统耗尽资源,导致你的电脑崩溃。
我们为机器上的每个处理器核心构建一个进程,然后启动并加入这些进程。在我的 2017 年代的 8 核 ThinkCenter 上,输出如下:
25812
25813
25814
25815
25816
25817
25818
25819
work took 6.97506308555603 seconds
前四行是每个 MuchCPU 实例内部打印的进程 ID。最后一行显示在我的机器上,2 亿次迭代大约需要 13 秒。在这 13 秒内,我的进程监控显示我的四个核心都在以 100% 的速度运行。
如果我们在 MuchCPU 中使用 threading.Thread 而不是 multiprocessing.Process 作为子类,输出如下:
26083
26083
26083
26083
26083
26083
26083
26083
work took 26.710845470428467 seconds
这次,四个线程在同一个进程中运行,运行时间超过三倍。这是 GIL 的代价;在其他语言中,线程版本至少会与多进程版本一样快。
我们可能期望它至少需要四倍的时间,但请记住,我的笔记本电脑上还运行着许多其他程序。在多进程版本中,这些程序也需要四个 CPU 中的一份。在多线程版本中,那些程序可以使用其他七个 CPU。
多进程池
通常,没有必要拥有比计算机上的处理器更多的进程。这有几个原因:
-
只有
cpu_count()个进程可以同时运行 -
每个进程都消耗着 Python 解释器的完整副本的资源
-
进程间的通信代价高昂
-
创建进程需要一定的时间
考虑到这些限制,当程序启动时创建最多cpu_count()个进程,然后根据需要执行任务是有意义的。这比为每个任务启动一个新的进程要少得多开销。
实现这样一个基本的通信进程系列并不困难,但调试、测试和正确实现可能会很棘手。当然,其他 Python 开发者已经为我们以多进程池的形式实现了这一点。
池抽象化了确定主进程中执行什么代码以及子进程中运行什么代码的开销。池抽象限制了不同进程中的代码交互的地点,这使得跟踪变得更加容易。
与线程不同,多进程无法直接访问其他线程设置的变量。多进程提供了一些不同的方式来实现进程间通信。池无缝地隐藏了数据在进程间传递的过程。使用池看起来就像一个函数调用:你将数据传递给一个函数,它在另一个进程或多个进程中执行,当工作完成时,返回一个值。重要的是要理解,在底层,有很多工作正在进行以支持这一点:一个进程中的对象正在被序列化并通过操作系统进程管道传递。然后,另一个进程从管道中检索数据并反序列化它。请求的工作在子进程中完成,并产生一个结果。结果被序列化并通过管道返回。最终,原始进程反序列化并返回它。
所有这些序列化和将数据传递到管道中的操作都需要时间和内存。因此,将传递到池中和从池中返回的数据的数量和大小保持在最低限度是理想的,并且只有在需要对相关数据进行大量处理时,使用池才有利。
Pickling 对于即使是中等规模的 Python 操作来说也是一个昂贵的操作。将大对象序列化以在单独的进程中使用,通常比在原始进程中使用线程来完成工作要昂贵得多。确保您对程序进行性能分析,以确保多进程的开销实际上值得实现和维护的开销。
带着这些知识,让所有这些机械运转的代码出人意料地简单。让我们看看计算一组随机数的所有质因数的问题。这是各种密码学算法(更不用说对这些算法的攻击!)中常见且昂贵的部分。要破解用于保护您的银行账户的极其大的数字,需要多年的处理能力。以下实现虽然可读,但效率并不高,但这没关系,因为我们想看到它使用大量的 CPU 时间:
import random
from multiprocessing.pool import Pool
def prime_factor(value):
factors = []
for divisor in range(2, value - 1):
quotient, remainder = divmod(value, divisor)
if not remainder:
factors.extend(prime_factor(divisor))
factors.extend(prime_factor(quotient))
break
else:
factors = [value]
return factors
if __name__ == "__main__":
pool = Pool()
to_factor = [random.randint(100000, 50000000) for i in range(20)]
results = pool.map(prime_factor, to_factor)
for value, factors in zip(to_factor, results):
print("The factors of {} are {}".format(value, factors))
让我们专注于并行处理方面,因为计算因子的暴力递归算法已经很清晰了。我们首先构建一个多进程池实例。默认情况下,此池为运行在其上的机器中的每个 CPU 核心创建一个单独的进程。
map方法接受一个函数和一个可迭代对象。池将可迭代中的每个值序列化,并将其传递给一个可用的进程,该进程在它上面执行函数。当该进程完成其工作后,它将结果列表的因数序列化,并将其返回给池。然后,如果池有更多工作可用,它将承担下一项工作。
一旦所有池完成处理工作(这可能需要一些时间),结果列表就返回到原始进程,该进程一直在耐心地等待所有这些工作完成。
通常更有用使用类似的map_async方法,即使进程仍在工作,它也会立即返回。在这种情况下,结果变量将不会是一个值列表,而是一个承诺,稍后通过调用results.get()来返回一个值列表。这个承诺对象还具有ready()和wait()等方法,允许我们检查是否所有结果都已就绪。我将让您查阅 Python 文档以了解更多关于它们的使用方法。
或者,如果我们事先不知道我们想要获取结果的值的全部,我们可以使用apply_async方法来排队一个单独的任务。如果池有一个尚未工作的进程,它将立即开始;否则,它将保留任务,直到有可用的进程。
池也可以被closed,这拒绝接受任何进一步的任务,但会处理队列中当前的所有任务,或者terminated,这更进一步,拒绝启动队列中仍然存在的任何任务,尽管当前正在运行的任务仍然允许完成。
队列
如果我们需要对进程之间的通信有更多的控制,我们可以使用一个Queue。Queue数据结构对于从一个进程向一个或多个其他进程发送消息非常有用。任何可序列化的对象都可以发送到Queue中,但请记住,序列化可能是一个昂贵的操作,因此保持这样的对象小巧。为了说明队列,让我们构建一个小型搜索引擎,用于存储所有相关条目在内存中。
这不是构建基于文本的搜索引擎的最明智的方式,但我已经使用这种模式查询需要使用 CPU 密集型过程构建图表的数值数据,然后将其渲染给用户。
这个特定的搜索引擎并行扫描当前目录中的所有文件。为 CPU 上的每个核心构建一个进程。每个进程都被指示将一些文件加载到内存中。让我们看看执行加载和搜索的函数:
def search(paths, query_q, results_q):
lines = []
for path in paths:
lines.extend(l.strip() for l in path.open())
query = query_q.get()
while query:
results_q.put([l for l in lines if query in l])
query = query_q.get()
记住,这个函数是在不同的进程(实际上,它是在cpucount()不同的进程中运行的)中运行的,与主线程不同。它传递一个path.path对象的列表,以及两个multiprocessing.Queue对象;一个用于传入查询,一个用于发送输出结果。这些队列自动将数据序列化到队列中,并通过管道传递到子进程。这两个队列在主进程中设置,并通过管道传递到子进程中的搜索函数。
搜索代码在效率和功能方面都很愚蠢;它遍历存储在内存中的每一行,并将匹配的行放入列表中。这个列表被放入队列中,并返回给主进程。
让我们看看main进程,它设置了这些队列:
if __name__ == '__main__':
from multiprocessing import Process, Queue, cpu_count
from path import path
cpus = cpu_count()
pathnames = [f for f in path('.').listdir() if f.isfile()]
paths = [pathnames[i::cpus] for i in range(cpus)]
query_queues = [Queue() for p in range(cpus)]
results_queue = Queue()
search_procs = [
Process(target=search, args=(p, q, results_queue))
for p, q in zip(paths, query_queues)
]
for proc in search_procs: proc.start()
为了更容易描述,让我们假设cpu_count是四。注意import语句是如何放在if保护中的?这是一个小的优化,可以防止在某些操作系统上在每个子进程中(它们不需要)导入它们。我们列出当前目录中的所有路径,然后将列表分成四个大致相等的部分。我们还构建了一个包含四个Queue对象的列表,用于将数据发送到每个子进程。最后,我们构建了一个单个的结果队列。这个队列被传递到所有四个子进程中。每个子进程都可以将数据放入队列中,它将在主进程中汇总。
现在让我们看看使搜索真正发生的代码:
for q in query_queues:
q.put("def")
q.put(None) # Signal process termination
for i in range(cpus):
for match in results_queue.get():
print(match)
for proc in search_procs:
proc.join()
这段代码执行单个搜索,搜索"def"(因为它是充满 Python 文件的目录中的常见短语!)。
这种队列的使用实际上是可能成为分布式系统的本地版本。想象一下,如果搜索被发送到多台计算机,然后重新组合。现在想象一下,如果你可以访问谷歌数据中心数百万台计算机,你可能会理解为什么他们可以如此快速地返回搜索结果!
我们在这里不会讨论它,但多进程模块包含一个管理类,可以消除前面代码中的许多样板代码。甚至有一个版本的multiprocessing.Manager可以管理远程系统上的子进程,以构建一个基本的分布式应用程序。如果你对此感兴趣,请查看 Python 多进程文档。
多进程的问题
与线程一样,多进程也存在问题,其中一些我们已经讨论过。没有一种做并发的最佳方式;这在 Python 中尤其如此。我们总是需要检查并行问题,以确定众多可用解决方案中哪一个最适合该问题。有时,可能没有最佳解决方案。
在多进程的情况下,主要缺点是进程间共享数据成本高昂。正如我们讨论的,所有进程间的通信,无论是通过队列、管道还是更隐式的机制,都需要序列化对象。过度的序列化很快就会主导处理时间。多进程在需要将相对较小的对象在进程间传递,并且每个对象都需要完成大量工作时效果最好。另一方面,如果进程间不需要通信,可能根本不需要使用该模块;我们可以启动四个独立的 Python 进程(例如,通过在单独的终端中运行每个进程)并独立使用它们。
多进程的另一个主要问题是,与线程一样,很难确定变量或方法是在哪个进程中访问的。在多进程中,如果你从一个进程访问变量,它通常会覆盖当前运行进程中的变量,而另一个进程则保留旧值。这真的很令人困惑,因此不要这样做。
Futures
让我们开始探讨一种更异步的并发实现方式。根据我们需要哪种类型的并发(倾向于 I/O 还是倾向于 CPU),Futures 会包装多进程或线程。它们并不能完全解决意外改变共享状态的问题,但它们允许我们以这样的方式组织代码,使得在发生这种情况时更容易追踪。
Futures 在不同的线程或进程之间提供了明确的边界。类似于多进程池,它们对于调用和响应类型的交互很有用,在这种交互中,处理可以在另一个线程中进行,然后在某个时刻(毕竟,它们的名字很合适),你可以请求结果。它实际上只是多进程池和线程池的包装,但它提供了一个更干净的 API,并鼓励编写更好的代码。
未来是一个封装函数调用的对象。该函数调用在后台运行,在一个线程或进程中。future对象有主线程可以使用的方法来检查未来是否完成,并在完成后获取结果。
让我们看看另一个文件搜索的例子。在上一个部分,我们实现了一个unix grep命令的版本。这次,我们将创建一个简单的find命令版本。该示例将搜索整个文件系统,查找包含给定字符序列的路径,如下所示:
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from os.path import sep as pathsep
from collections import deque
def find_files(path, query_string):
subdirs = []
for p in path.iterdir():
full_path = str(p.absolute())
if p.is_dir() and not p.is_symlink():
subdirs.append(p)
if query_string in full_path:
print(full_path)
return subdirs
query = '.py'
futures = deque()
basedir = Path(pathsep).absolute()
with ThreadPoolExecutor(max_workers=10) as executor:
futures.append(
executor.submit(find_files, basedir, query))
while futures:
future = futures.popleft()
if future.exception():
continue
elif future.done():
subdirs = future.result()
for subdir in subdirs:
futures.append(executor.submit(
find_files, subdir, query))
else:
futures.append(future)
这段代码由一个名为 find_files 的函数组成,它在单独的线程(或如果我们使用 ProcessPoolExecutor 代替,则是进程)中运行。这个函数没有什么特别之处,但请注意它没有访问任何全局变量。所有与外部环境的交互都传递到函数中或从函数返回。这不是一个技术要求,但这是在使用未来编程时保持你的大脑在头骨内的最佳方式。
在没有适当同步的情况下访问外部变量会导致称为 竞争条件 的情况。例如,想象有两个并发写入尝试增加一个整数计数器。它们同时开始,并且都读取值为 5。然后,它们都增加值并将结果写回为 6。但如果两个进程都在尝试增加一个变量,预期的结果应该是它增加两次,所以结果应该是 7。现代的智慧是,避免这样做最简单的方法是尽可能多地保持状态私有,并通过已知安全的结构,如队列或未来,来共享。
在开始之前,我们设置了一些变量;在这个例子中,我们将搜索包含字符 '.py' 的所有文件。我们有一个未来队列,我们稍后会讨论。basedir 变量指向文件系统的根目录:在 Unix 机器上是 '/',在 Windows 机器上可能是 C:\。
首先,让我们简要地学习一下搜索理论。这个算法实现了并行广度优先搜索。而不是递归地使用深度优先搜索搜索每个目录,它将当前文件夹中的所有子目录添加到队列中,然后是每个这些文件夹的子目录,依此类推。
程序的核心部分被称为事件循环。我们可以将 ThreadPoolExecutor 作为上下文管理器来构建,这样它就会在完成后自动清理并关闭其线程。它需要一个 max_workers 参数来指示同时运行的线程数。如果有超过这个数量的工作提交,它将剩余的工作排队,直到有工作线程可用。当使用 ProcessPoolExecutor 时,这通常限制在机器上的 CPU 数量,但使用线程时,它可以高得多,这取决于一次有多少线程在等待 I/O。每个线程占用一定量的内存,所以它不应该太高。在磁盘速度而不是并行请求数量成为瓶颈之前,不需要太多线程。
一旦构建了执行器,我们就会使用根目录向其提交一个任务。submit() 方法立即返回一个 Future 对象,它承诺最终会给我们一个结果。这个未来对象被放入队列中。然后循环会反复从队列中移除第一个未来对象并检查它。如果它仍在运行,它会被添加回队列的末尾。否则,我们会通过调用 future.exception() 来检查函数是否抛出了异常。如果抛出了异常,我们只需忽略它(通常是一个权限错误,尽管真正的应用程序需要更小心地处理异常)。如果我们没有在这里检查这个异常,它会在我们调用 result() 时抛出,并且可以通过正常的 try...except 机制来处理。
假设没有发生异常,我们可以调用 result() 来获取返回值。由于函数返回一个不包含符号链接的子目录列表(我防止无限循环的懒惰方式),result() 返回相同的内容。这些新的子目录被提交给执行器,产生的未来对象被扔到队列中,以便在后续迭代中搜索其内容。
这就是开发基于未来的 I/O 密集型应用程序所需的所有内容。在底层,它使用的是我们之前讨论过的相同的线程或进程 API,但它提供了一个更易于理解的接口,并使得查看并发运行函数之间的边界更容易(只是不要尝试从未来内部访问全局变量!)。
AsyncIO
AsyncIO 是 Python 并发编程的当前最佳实践。它将我们讨论过的未来的概念和事件循环与 第九章 中讨论的协程结合在一起,迭代器模式。结果是尽可能优雅和易于理解,尽管这并不是说很多!
AsyncIO 可以用于几种不同的并发任务,但它专门设计用于网络 I/O。大多数网络应用程序,尤其是在服务器端,花费大量时间等待从网络传入的数据。这可以通过为每个客户端使用单独的线程来解决,但线程会消耗内存和其他资源。AsyncIO 使用协程作为一种轻量级的线程。
该库提供自己的事件循环,消除了在前面示例中需要几行长的 while 循环的需求。然而,事件循环也有代价。当我们在一个事件循环上运行 async 任务中的代码时,该代码必须立即返回,既不阻塞 I/O 也不阻塞长时间运行的计算。当我们自己编写代码时,这是一件小事,但这意味着任何在 I/O 上阻塞的标准库或第三方函数都必须有非阻塞版本。
AsyncIO 通过创建一组使用async和await语法来立即将控制权返回给事件循环的协程来解决此问题。这些关键字替换了我们之前在原始协程中使用的yield、yield from和send语法,以及手动前进到第一个send位置的需要。结果是并发代码,我们可以像处理顺序代码一样推理它。事件循环负责检查阻塞调用是否完成,并执行任何后续任务,就像我们在上一节中手动执行的那样。
AsyncIO 的实际应用
阻塞函数的一个典型例子是time.sleep调用。让我们使用这个调用的异步版本来展示 AsyncIO 事件循环的基本原理,如下所示:
import asyncio
import random
async def random_sleep(counter):
delay = random.random() * 5
print("{} sleeps for {:.2f} seconds".format(counter, delay))
await asyncio.sleep(delay)
print("{} awakens".format(counter))
async def five_sleepers():
print("Creating five tasks")
tasks = [asyncio.create_task(random_sleep(i)) for i in range(5)]
print("Sleeping after starting five tasks")
await asyncio.sleep(2)
print("Waking and waiting for five tasks")
await asyncio.gather(*tasks)
asyncio.get_event_loop().run_until_complete(five_sleepers())
print("Done five tasks")
这是一个相当基础的例子,但它涵盖了 AsyncIO 编程的几个特性。最容易理解的是它的执行顺序,这基本上是从下到上。
下面是如何执行脚本的一个示例:
Creating five tasks
Sleeping after starting five tasks
0 sleeps for 3.42 seconds
1 sleeps for 4.16 seconds
2 sleeps for 0.75 seconds
3 sleeps for 3.55 seconds
4 sleeps for 0.39 seconds
4 awakens
2 awakens
Waking and waiting for five tasks
0 awakens
3 awakens
1 awakens
Done five tasks
最后一行获取事件循环并指示它运行一个任务,直到它完成。相关的任务是five_sleepers。一旦该任务完成其工作,循环将退出,我们的代码将终止。作为异步程序员,我们不需要了解太多关于run_until_complete调用内部发生的事情,但请注意,有很多事情在进行中。这是一个增强版的协程版本,我们之前在章节中编写的未来循环,它知道如何处理迭代、异常、函数返回、并行调用等等。
在这个上下文中,任务是一个asyncio知道如何在事件循环上安排的对象。这包括以下内容:
-
使用
async和await语法定义的协程。 -
使用
@asyncio.coroutine装饰器和yield from语法(这是一个较旧的模型,已被async和await所取代)装饰的协程。 -
asyncio.Future对象。这些与我们在上一节中看到的concurrent.futures几乎相同,但用于asyncio。 -
任何可等待的对象,即具有
__await__函数的对象。
在这个例子中,所有任务都是协程;我们将在后面的例子中看到一些其他的例子。
仔细观察一下那个five_sleepers未来。协程首先构建了五个random_sleep协程的实例。这些实例每个都被asyncio.create_task调用所包装,这会将未来添加到循环的任务队列中,以便它们可以在控制权返回到循环时立即执行。
每当我们调用await时,控制权都会返回。在这种情况下,我们调用await asyncio.sleep来暂停协程的执行两秒钟。在暂停期间,事件循环执行它已经排队的任务:即五个random_sleep任务。
当five_sleepers任务中的睡眠调用唤醒时,它调用asyncio.gather。这个函数接受任务作为可变参数,并在返回之前等待每个任务(以及其他事情,以保持循环安全运行)。
每个random_sleep协程打印一条启动信息,然后使用自己的await调用将控制权返回给事件循环一段时间。当睡眠完成时,事件循环将控制权返回给相关的random_sleep任务,该任务在返回之前打印其唤醒信息。
注意,任何少于两秒即可完成的任务都会在原始的five_sleepers协程唤醒并运行到调用gather任务之前输出它们自己的唤醒信息。由于事件队列现在为空(所有六个协程都已运行完成且不再等待任何任务),run_until_complete调用能够终止,程序结束。
async关键字作为文档,通知 Python 解释器(和程序员)协程包含await调用。它还做一些工作来准备协程在事件循环上运行。它的工作方式很像装饰器;事实上,在 Python 3.4 中,这被实现为一个@asyncio.coroutine装饰器。
读取 AsyncIO 未来对象
AsyncIO 协程按顺序执行每一行,直到遇到await语句,此时,它将控制权返回给事件循环。事件循环随后执行任何其他准备就绪的任务,包括原始协程等待的任务。每当子任务完成时,事件循环将结果发送回协程,以便它可以继续执行,直到遇到另一个await语句或返回。
这允许我们编写同步执行的代码,直到我们明确需要等待某事。因此,没有线程的非确定性行为,所以我们不必那么担心共享状态。
仍然建议避免在协程内部访问共享状态。这会使你的代码更容易推理。更重要的是,尽管理想的世界可能所有异步执行都在协程内部进行,但现实是有些未来对象在后台线程或进程中执行。坚持“无共享”哲学以避免大量难以调试的错误。
此外,AsyncIO 允许我们将代码的逻辑部分组合在一个单独的协程中,即使我们在等待其他地方的工作。作为一个具体的例子,即使在random_sleep协程中的await asyncio.sleep调用允许在事件循环中发生大量事情,但协程本身看起来像是有序地执行所有操作。这种无需担心等待任务完成的机制即可读取相关异步代码的能力是 AsyncIO 模块的主要优势。
AsyncIO 网络编程
AsyncIO 是专门为与网络套接字一起使用而设计的,所以让我们实现一个 DNS 服务器。更准确地说,让我们实现 DNS 服务器的一个非常基础的功能。
DNS 的基本目的是将域名,例如www.python.org/,翻译成 IP 地址,例如 IPv4 地址(例如23.253.135.79)或 IPv6 地址(例如2001:4802:7901:0:e60a:1375:0:6)。它必须能够执行许多类型的查询,并且知道如何联系其他 DNS 服务器,如果它没有所需的答案。我们不会实现这些功能,但以下示例能够直接响应标准的 DNS 查询,查找几个网站的 IP 地址:
import asyncio
from contextlib import suppress
ip_map = {
b"facebook.com.": "173.252.120.6",
b"yougov.com.": "213.52.133.246",
b"wipo.int.": "193.5.93.80",
b"dataquest.io.": "104.20.20.199",
}
def lookup_dns(data):
domain = b""
pointer, part_length = 13, data[12]
while part_length:
domain += data[pointer : pointer + part_length] + b"."
pointer += part_length + 1
part_length = data[pointer - 1]
ip = ip_map.get(domain, "127.0.0.1")
return domain, ip
def create_response(data, ip):
ba = bytearray
packet = ba(data[:2]) + ba([129, 128]) + data[4:6] * 2
packet += ba(4) + data[12:]
packet += ba([192, 12, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4])
for x in ip.split("."):
packet.append(int(x))
return packet
class DNSProtocol(asyncio.DatagramProtocol):
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
print("Received request from {}".format(addr[0]))
domain, ip = lookup_dns(data)
print(
"Sending IP {} for {} to {}".format(
domain.decode(), ip, addr[0]
)
)
self.transport.sendto(create_response(data, ip), addr)
loop = asyncio.get_event_loop()
transport, protocol = loop.run_until_complete(
loop.create_datagram_endpoint(
DNSProtocol, local_addr=("127.0.0.1", 4343)
)
)
print("DNS Server running")
with suppress(KeyboardInterrupt):
loop.run_forever()
transport.close()
loop.close()
这个示例设置了一个字典,将几个域名愚蠢地映射到 IPv4 地址。随后是两个函数,它们从二进制 DNS 查询数据包中提取信息并构建响应。我们不会讨论这些;如果你想了解更多关于 DNS 的信息,请阅读 RFC(请求评论,定义大多数 IP 的格式)1034和1035。
你可以通过在另一个终端运行以下命令来测试这个服务:
nslookup -port=4343 facebook.com localhost
让我们继续深入主题。AsyncIO 网络围绕紧密相连的概念——传输和协议。协议是一个类,当发生相关事件时,会调用其特定的方法。由于 DNS 运行在UDP(用户数据报协议)之上,我们构建我们的协议类作为DatagramProtocol的子类。这个类可以响应各种事件。我们特别关注初始连接的发生(仅此而已,这样我们就可以存储传输以供将来使用)以及datagram_received事件。对于 DNS,每个接收到的数据报都必须被解析并响应,此时,交互就结束了。
因此,当接收到数据报时,我们处理数据包,查找 IP,并使用我们未讨论的函数(它们是这个家族中的黑羊)构建响应。然后,我们指示底层传输使用其sendto方法将生成的数据包发送回请求的客户机。
传输本质上代表了一种通信流。在这种情况下,它抽象掉了在事件循环上使用 UDP 套接字发送和接收数据的所有繁琐操作。例如,还有类似的传输用于与 TCP 套接字和子进程交互。
UDP 传输是通过调用循环的create_datagram_endpoint协程来构建的。这会构建适当的 UDP 套接字并开始监听它。我们传递给它套接字需要监听的地址,以及,重要的是,我们创建的协议类,这样传输就知道在接收到数据时应该调用什么。
由于初始化套接字的过程需要相当多的时间,并且会阻塞事件循环,因此create_datagram_endpoint函数是一个协程。在我们的示例中,在等待这个初始化的过程中我们不需要做任何事情,所以我们用loop.run_until_complete来包装这个调用。事件循环负责管理未来,当它完成时,它会返回一个包含两个值的元组:新初始化的传输和从我们传入的类中构建的协议对象。
在幕后,传输已经在事件循环上设置了一个任务,该任务正在监听传入的 UDP 连接。然后我们只需要通过调用loop.run_forever()来启动事件循环的运行,以便任务可以处理这些数据包。当数据包到达时,它们在协议中被处理,一切正常工作。
唯一需要特别注意的另一件大事是,当我们完成传输(以及确实,事件循环)时,应该关闭它们。在这种情况下,代码运行得很好,不需要两个close()调用,但如果我们是在实时构建传输(或者只是做适当的错误处理!),我们就需要对此有更多的意识。
你可能对设置协议类和底层传输所需的样板代码感到沮丧。AsyncIO 在这些两个关键概念之上提供了一个抽象,称为流。我们将在下一个示例中看到流的示例。
使用执行器包装阻塞代码
AsyncIO 提供了一个自己的 futures 库版本,允许我们在没有适当的非阻塞调用可进行时在单独的线程或进程中运行代码。这允许我们将线程和进程与异步模型结合起来。这个特性的一个更有用的应用是在应用程序有 I/O 密集型和 CPU 密集型活动爆发时,能够获得两者的最佳效果。I/O 密集型部分可以在事件循环中发生,而 CPU 密集型工作可以分配到不同的进程中。为了说明这一点,让我们使用 AsyncIO 来实现排序作为一项服务:
import asyncio
import json
from concurrent.futures import ProcessPoolExecutor
def sort_in_process(data):
nums = json.loads(data.decode())
curr = 1
while curr < len(nums):
if nums[curr] >= nums[curr - 1]:
curr += 1
else:
nums[curr], nums[curr - 1] = nums[curr - 1], nums[curr]
if curr > 1:
curr -= 1
return json.dumps(nums).encode()
async def sort_request(reader, writer):
print("Received connection")
length = await reader.read(8)
data = await reader.readexactly(int.from_bytes(length, "big"))
result = await asyncio.get_event_loop().run_in_executor(
None, sort_in_process, data
)
print("Sorted list")
writer.write(result)
writer.close()
print("Connection closed")
loop = asyncio.get_event_loop()
loop.set_default_executor(ProcessPoolExecutor())
server = loop.run_until_complete(
asyncio.start_server(sort_request, "127.0.0.1", 2015)
)
print("Sort Service running")
loop.run_forever()
server.close()
loop.run_until_complete(server.wait_closed())
loop.close()
这是一个实现了一些非常愚蠢想法的好代码的例子。将排序作为一项服务整个想法相当荒谬。使用我们自己的排序算法而不是调用 Python 的sorted甚至更糟糕。我们使用的算法被称为冒泡排序,或者在某些情况下,愚蠢排序。这是一个纯 Python 实现的慢速排序算法。我们定义了自己的协议而不是使用野外存在的许多完全合适的应用协议之一。甚至使用多进程来实现并行性的想法也可能值得怀疑;我们最终还是将所有数据传递到和从子进程中。有时,从你正在编写的程序中退一步,问问自己你是否在尝试达到正确的目标,这很重要。
但忽略工作负载,让我们看看这个设计的一些智能特性。首先,我们在子进程中传入和传出字节。这比在主进程中解码 JSON 智能得多。这意味着(相对昂贵的)解码可以在不同的 CPU 上发生。此外,序列化的 JSON 字符串通常比序列化的列表小,因此进程间传递的数据更少。
第二,这两个方法非常线性;看起来代码是一行一行执行的。当然,在 AsyncIO 中,这是一个错觉,但我们不必担心共享内存或并发原语。
流
现在的排序服务示例应该已经很熟悉了,因为它与其他 AsyncIO 程序有类似的模板。然而,也有一些不同之处。我们使用了 start_server 而不是 create_server。这种方法通过 AsyncIO 的流而不是使用底层的传输/协议代码进行挂钩。它允许我们传入一个普通的协程,该协程接收读取器和写入器参数。这两个参数都代表可以读取和写入的字节流,就像文件或套接字一样。其次,因为这是一个 TCP 服务器而不是 UDP,当程序结束时需要一些套接字清理。这个清理是一个阻塞调用,所以我们必须在事件循环上运行 wait_closed 协程。
流相对简单易懂。读取是一个可能阻塞的调用,所以我们必须使用 await 来调用它。写入不会阻塞;它只是将数据放入队列,然后 AsyncIO 在后台发送出去。
我们在 sort_request 方法内部的代码进行了两次读取请求。首先,它从线路上读取 8 个字节,并使用大端表示法将它们转换为整数。这个整数表示客户端打算发送的字节数。因此,在下一个调用 readexactly 时,它读取这么多字节。read 和 readexactly 之间的区别在于,前者将读取请求的字节数,而后者将缓冲读取,直到接收到所有字节,或者直到连接关闭。
执行器
现在让我们看看执行器代码。我们导入与上一节中使用的完全相同的 ProcessPoolExecutor。注意,我们不需要一个特殊的 AsyncIO 版本。事件循环有一个方便的 run_in_executor 协程,我们可以用它来运行未来。默认情况下,循环在 ThreadPoolExecutor 中运行代码,但如果我们愿意,我们可以传入不同的执行器。或者,就像在这个例子中那样,我们可以在设置事件循环时通过调用 loop.set_default_executor() 来设置不同的默认值。
如你所回忆的那样,使用执行器与 futures 一起使用时,没有太多的样板代码。然而,当我们与 AsyncIO 一起使用时,则完全没有!协程会自动将函数调用包装在 future 中,并将其提交给执行器。我们的代码在 future 完成之前会阻塞,而事件循环会继续处理其他连接、任务或 futures。当 future 完成时,协程会唤醒并继续将数据写回客户端。
你可能想知道,是否在事件循环内部运行多个进程,而不是运行多个事件循环在不同进程中会更好。答案是:可能吧。然而,根据具体的问题空间,我们可能更倾向于运行具有单个事件循环的独立程序副本,而不是试图通过主多进程来协调一切。
AsyncIO 客户端
由于它能够处理数以千计的并发连接,AsyncIO 非常常见于实现服务器。然而,它是一个通用的网络库,并为客户端进程提供全面支持。这非常重要,因为许多微服务运行的服务器充当其他服务器的客户端。
客户端可以比服务器简单得多,因为它们不需要设置来等待传入的连接。像大多数网络库一样,你只需打开一个连接,提交你的请求,并处理任何响应。主要区别在于,每次你进行可能阻塞的调用时,都需要使用 await。以下是我们在上一个章节中实现的排序服务的一个示例客户端:
import asyncio
import random
import json
async def remote_sort():
reader, writer = await asyncio.open_connection("127.0.0.1", 2015)
print("Generating random list...")
numbers = [random.randrange(10000) for r in range(10000)]
data = json.dumps(numbers).encode()
print("List Generated, Sending data")
writer.write(len(data).to_bytes(8, "big"))
writer.write(data)
print("Waiting for data...")
data = await reader.readexactly(len(data))
print("Received data")
sorted_values = json.loads(data.decode())
print(sorted_values)
print("\n")
writer.close()
loop = asyncio.get_event_loop()
loop.run_until_complete(remote_sort())
loop.close()
在本节中,我们已经涵盖了 AsyncIO 的多数要点,本章也涵盖了其他许多并发原语。并发是一个难以解决的问题,没有一种解决方案适合所有用例。设计并发系统最重要的部分是决定哪种可用的工具是解决该问题的正确选择。我们已经看到了几个并发系统的优缺点,现在对哪些是不同类型需求更好的选择有一些了解。
案例研究
为了总结本章和本书,让我们构建一个基本的图像压缩工具。它将接受黑白图像(每个像素 1 位,开或关),并尝试使用一种称为运行长度编码的非常基础的压缩形式来压缩它。你可能觉得黑白图像有点牵强。如果是这样,你可能还没有在 xkcd.com 上享受足够的时间!
我已经为本章的示例代码包含了一些黑白 BMP 图像(这些图像易于读取数据并提供了大量改进文件大小的机会)。
运行长度编码将位序列替换为任何重复位字符串的数量。例如,字符串 000011000 可能被替换为 04 12 03,以表示四个零后面跟着两个一,然后是三个更多的零。为了使事情更有趣,我们将每行分解为 127 位块。
我并不是随意选择 127 位。127 个不同的值可以编码到 7 位中,这意味着如果一个行包含全部为 1 或全部为 0,我们可以将其存储在一个字节中,第一个位表示它是一个 0 行还是一个 1 行,其余七个位表示该位存在多少。
将图像分解成块有另一个优点:我们可以并行处理单个块,而无需它们相互依赖。然而,也存在一个主要的缺点:如果一个运行只有少数一或零,那么它在压缩文件中会占用更多的空间。当我们将长运行分解成块时,我们可能会创建更多的这些小运行,并使文件的大小膨胀。
我们有权根据需要设计压缩文件中字节的布局。在这个简单的例子中,我们的压缩文件将在文件开头存储两个字节的小端整数,代表完成文件的宽度和高度。然后,它将写入代表每行 127 位块的字节。
在我们开始设计一个并发系统来构建这样的压缩图像之前,我们应该问一个基本的问题:这个应用程序是 I/O 密集型还是 CPU 密集型?
实话实说,我的答案是我不知道。我不确定应用程序是否会花费更多的时间从磁盘加载数据并将其写回,还是在内存中进行压缩。我怀疑它本质上是一个 CPU 密集型应用程序,但一旦我们开始将图像字符串传递到子进程中,我们可能会失去任何并行化的好处。
我们将使用自下而上的设计来构建这个应用程序。这样,我们将有一些构建块可以组合成不同的并发模式,以比较它们。让我们从使用运行长度编码压缩 127 位块的代码开始:
from bitarray import bitarray
def compress_chunk(chunk):
compressed = bytearray()
count = 1
last = chunk[0]
for bit in chunk[1:]:
if bit != last:
compressed.append(count | (128 * last))
count = 0
last = bit
count += 1
compressed.append(count | (128 * last))
return compressed
这段代码使用bitarray类来操作单个 0 和 1。它作为一个第三方模块分发,你可以使用pip install bitarray命令进行安装。传递给compress_chunks的块是这个类的一个实例(尽管示例也可以用布尔值列表工作)。在这种情况下,bitarray的主要好处是,在进程间序列化时,它们占用的空间是布尔值列表或 1s 和 0s 的字节字符串的八分之一。因此,它们序列化得更快。它们也比进行大量位操作要容易一些。
该方法使用运行长度编码压缩数据,并返回包含打包数据的bytearray。就像bitarray是一个一和零的列表一样,bytearray就像是一个字节对象的列表(当然,每个字节当然包含八个一或零)。
执行压缩的算法相当简单(虽然我想指出,实现和调试它花了我两天时间——易于理解并不一定意味着容易编写!)。它首先将last变量设置为当前运行中位的类型(要么是True要么是False)。然后,它遍历位,逐个计数,直到找到一个不同的位。当找到时,它通过将字节的最左边位(128 位位置)设置为last变量包含的零或一,来构建一个新的字节。然后,它重置计数器并重复操作。一旦循环完成,它为最后一个运行创建一个最后的字节并返回结果。
当我们创建构建块时,让我们创建一个函数来压缩一行图像数据,如下所示:
def compress_row(row):
compressed = bytearray()
chunks = split_bits(row, 127)
for chunk in chunks:
compressed.extend(compress_chunk(chunk))
return compressed
此函数接受一个名为row的bitarray。它使用我们将很快定义的函数将其分割成每个宽度为 127 位的块。然后,它使用先前定义的compress_chunk压缩这些块,将结果连接到bytearray中,并返回它。
我们将split_bits定义为生成器,如下所示:
def split_bits(bits, width):
for i in range(0, len(bits), width):
yield bits[i:i+width]
现在,由于我们还不确定这将在线程或进程中运行得更有效,让我们将这些函数包装在一个方法中,该方法在提供的执行器中运行一切:
def compress_in_executor(executor, bits, width):
row_compressors = []
for row in split_bits(bits, width):
compressor = executor.submit(compress_row, row)
row_compressors.append(compressor)
compressed = bytearray()
for compressor in row_compressors:
compressed.extend(compressor.result())
return compressed
这个例子几乎不需要解释;它使用我们已定义的相同的split_bits函数将传入的位分割成基于图像宽度的行(为自下而上的设计欢呼!)。
注意,此代码可以压缩任何位序列,尽管它会使具有频繁位值变化的二进制数据膨胀,而不是压缩。黑白图像无疑是所讨论压缩算法的良好候选者。现在,让我们创建一个函数,使用第三方 pillow 模块加载图像文件,将其转换为位,并压缩它。我们可以通过使用古老的注释语句轻松地在执行器之间切换,如下所示:
from PIL import Image
def compress_image(in_filename, out_filename, executor=None):
executor = executor if executor else ProcessPoolExecutor()
with Image.open(in_filename) as image:
bits = bitarray(image.convert('1').getdata())
width, height = image.size
compressed = compress_in_executor(executor, bits, width)
with open(out_filename, 'wb') as file:
file.write(width.to_bytes(2, 'little'))
file.write(height.to_bytes(2, 'little'))
file.write(compressed)
def single_image_main():
in_filename, out_filename = sys.argv[1:3]
#executor = ThreadPoolExecutor(4)
executor = ProcessPoolExecutor()
compress_image(in_filename, out_filename, executor)
image.convert()调用将图像转换为黑白(一位)模式,而getdata()返回这些值的迭代器。我们将结果打包到bitarray中,以便它们可以更快地通过电线传输。当我们输出压缩文件时,我们首先写入图像的宽度和高度,然后是压缩数据,它作为bytearray到达,可以直接写入二进制文件。
编写完所有这些代码后,我们终于能够测试线程池或进程池是否提供了更好的性能。我创建了一个大(7200 x 5600 像素)的黑白图像,并通过这两个池运行它。ProcessPool在我的系统上处理图像大约需要 7.5 秒,而ThreadPool则始终需要大约 9 秒。因此,正如我们所怀疑的,在多个处理器上运行时,序列化和反序列化位和字节之间的成本几乎消耗了所有效率提升(尽管,查看我的 CPU 监视器,它确实完全利用了我机器上的所有四个核心)。
因此,看起来压缩单个图像最有效地是在单独的进程中完成的,但只是略微,因为我们需要在父进程和子进程之间传递大量数据。当进程间传递的数据量相当低时,多进程更有效。
因此,让我们扩展这个应用程序,以并行压缩目录中的所有位图。我们唯一需要传递给子进程的是文件名,因此与使用线程相比,我们应该获得速度上的提升。此外,为了有点疯狂,我们将使用现有的代码来压缩单个图像。这意味着我们将在每个子进程中运行ProcessPoolExecutor以创建更多的子进程,如下所示(我不建议在实际生活中这样做!):
from pathlib import Path
def compress_dir(in_dir, out_dir):
if not out_dir.exists():
out_dir.mkdir()
executor = ProcessPoolExecutor()
for file in (
f for f in in_dir.iterdir() if f.suffix == '.bmp'):
out_file = (out_dir / file.name).with_suffix('.rle')
executor.submit(
compress_image, str(file), str(out_file))
def dir_images_main():
in_dir, out_dir = (Path(p) for p in sys.argv[1:3])
compress_dir(in_dir, out_dir)
此代码使用我们之前定义的compress_image函数,但为每个图像在单独的进程中运行它。它没有将执行器传递到函数中,因此compress_image在新的进程开始运行后创建ProcessPoolExecutor。
现在我们正在执行器内部运行执行器,我们可以使用四种线程和进程池的组合来压缩图像。它们各自有不同的时间特性,如下所示:
| 每图像进程池 | 每图像线程池 | |
|---|---|---|
| 每行进程池 | 42 秒 | 53 秒 |
| 每行线程池 | 34 秒 | 64 秒 |
如我们所预期,为每个图像使用线程,然后再为每行使用线程是最慢的配置,因为全局解释器锁(GIL)阻止我们在并行中进行任何工作。鉴于我们在使用单个图像时使用单独的进程稍微快一点,你可能会惊讶地看到,如果我们为每个图像在单独的进程中处理,使用ThreadPool功能处理行会更快。花点时间理解为什么会这样。
我的机器只有四个处理器核心。每个图像的每一行都在一个单独的池中处理,这意味着所有这些行都在争夺处理能力。当只有一个图像时,通过并行运行每一行,我们可以获得(非常适度的)加速。然而,当我们同时处理多个图像时,将所有这些行数据传递到和从子进程中的成本会积极地从每个其他图像中窃取处理时间。所以,如果我们可以在单独的处理器上处理每个图像,唯一需要序列化到子进程管道中的是几个文件名,我们就可以获得稳定的加速。
因此,我们看到不同的工作负载需要不同的并发范式。即使我们只是使用未来(futures),我们也必须就使用哪种执行器做出明智的决定。
还要注意,对于通常大小的图像,程序运行得足够快,以至于我们使用哪种并发结构实际上并不重要。事实上,即使我们根本不使用任何并发,我们可能也会得到几乎相同的使用体验。
此问题也可以直接使用线程和/或进程池模块来解决,尽管需要编写相当多的模板代码。你可能想知道是否 AsyncIO 在这里会有用。答案是:可能没有。大多数操作系统都没有很好的方法从文件系统中执行非阻塞读取,因此库最终仍然会将所有调用包装在未来的包装中。
为了完整性,以下是我在解压缩运行长度编码(RLE)图像时使用的代码,以确认算法是否正确工作(实际上,直到我修复了压缩和解压缩中的错误,我仍然不确定它是否完美——我应该使用测试驱动开发!):
from PIL import Image
import sys
def decompress(width, height, bytes):
image = Image.new('1', (width, height))
col = 0
row = 0
for byte in bytes:
color = (byte & 128) >> 7
count = byte & ~128
for i in range(count):
image.putpixel((row, col), color)
row += 1
if not row % width:
col += 1
row = 0
return image
with open(sys.argv[1], 'rb') as file:
width = int.from_bytes(file.read(2), 'little')
height = int.from_bytes(file.read(2), 'little')
image = decompress(width, height, file.read())
image.save(sys.argv[2], 'bmp')
这段代码相当直接。每个运行被编码在一个单独的字节中。它使用一些位运算来提取像素的颜色和运行的长度。然后,它将图像中该运行中的每个像素设置好,并在适当的间隔增加下一个要分析的像素的行和列。
练习
我们在本章中介绍了几种不同的并发范式,但仍不清楚何时使用哪一个。正如我们在案例研究中看到的,在做出承诺之前原型化几个不同的策略通常是一个好主意。
Python 3 中的并发是一个巨大的主题,一本这么大的书也无法涵盖关于它的所有知识。作为你的第一个练习,我鼓励你上网搜索,了解被认为是最新的 Python 并发最佳实践。
如果你最近在应用程序中使用了线程,请查看代码,看看如何通过使用未来(futures)使其更易于阅读且更少出错。比较线程和进程池中的未来(futures),看看是否可以通过使用多个 CPU 获得任何好处。
尝试实现一个用于基本 HTTP 请求的 AsyncIO 服务。如果您能将其做到网页浏览器可以渲染简单 GET 请求的程度,您就会对 AsyncIO 网络传输和协议有一个很好的理解。
确保您理解在访问共享数据时线程中发生的竞态条件。尝试编写一个使用多个线程以这种方式设置共享值的程序,使得数据故意变得损坏或无效。
记得我们在第六章“Python 数据结构”中讨论的链接收集器吗?您能否通过并行请求使其运行更快?对于这个任务,使用原始线程、未来对象还是 AsyncIO 更好?
尝试直接使用线程或多进程编写运行长度编码的示例。您是否获得了速度提升?代码是否更容易或更难理解?有没有办法通过并发或并行化来加快解压缩脚本的执行速度?
摘要
本章以一个不太面向对象的题目结束了我们对面向对象编程的探索。并发是一个难题,我们只是触及了表面。虽然底层操作系统的进程和线程抽象并没有提供一个接近面向对象的 API,但 Python 围绕它们提供了一些真正优秀的面向对象抽象。线程和多进程包都提供了对底层机制的面向对象接口。未来对象能够将许多杂乱的细节封装成一个单一的对象。AsyncIO 使用协程对象使我们的代码看起来像同步运行,同时在非常简单的循环抽象后面隐藏了丑陋和复杂的实现细节。
感谢您阅读《Python 3 面向对象编程》,第三版。我希望您享受了这次旅程,并渴望在您未来的所有项目中开始实现面向对象软件!


浙公网安备 33010602011771号