Python-面向对象编程第四版-全-

Python 面向对象编程第四版(全)

原文:zh.annas-archive.org/md5/353ef0fd6277039461875c05cacbf2c6

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Python 编程语言非常流行,用于各种应用。Python 语言设计得相对容易创建小型程序。为了创建更复杂的软件,我们需要掌握一些重要的编程和软件开发技能。

本书描述了在 Python 中创建程序的面向对象方法。它介绍了面向对象编程的术语,通过逐步示例展示软件设计和 Python 编程。它描述了如何利用继承和组合从单个元素构建软件。它展示了如何使用 Python 的内置异常和数据结构,以及 Python 标准库中的元素。本书用详细示例描述了多种常见设计模式。

本书涵盖了如何编写自动化测试以确认我们的软件是否正常工作。它还展示了如何使用 Python 中可用的各种并发库;这使得我们能够编写能够利用现代计算机中的多个核心和多个处理器的软件。一个扩展案例研究涵盖了一个简单的机器学习示例,展示了针对一个中等复杂问题的多种替代解决方案。

本书面向对象

本书针对的是对 Python 面向对象编程新手。它假设读者具备基本的 Python 技能。对于有其他面向对象编程语言背景的读者,本书将展示 Python 方法中的许多独特特性。

由于 Python 在数据科学和数据分析中的应用,本书涉及相关的数学和统计学概念。对这些领域的某些知识可以帮助使概念的应用更加具体。

本书涵盖的内容

本书分为四个主要部分。前六章提供了面向对象编程的核心原则和概念,以及这些如何在 Python 中实现。接下来的三章通过面向对象编程的视角深入研究了 Python 的内置功能。第十章、第十一章和第十二章探讨了多种常见设计模式以及如何在 Python 中处理这些模式。最后一部分涵盖了两个额外的主题:测试和并发。

第一章面向对象设计,介绍了面向对象设计背后的核心概念。这为状态和行为、属性和方法以及对象如何分组为类提供了路线图。本章还探讨了封装、继承和组合。本章的案例研究引入了机器学习问题,这是一个k-最近邻(k-NN)分类器的实现。

第二章Python 中的对象,展示了 Python 中类定义是如何工作的。这包括类型注解,称为类型提示,类定义,模块和包。我们将讨论类定义和封装的实际考虑因素。案例研究将开始实现k-NN 分类器的一些类。

第三章当对象相似时,讨论了类之间是如何相互关联的。这包括如何利用继承和多继承。我们将探讨类层次结构中类的多态概念。案例研究将探讨用于寻找最近邻的距离计算的不同设计方案。

第四章预期意外情况,仔细研究了 Python 的异常和异常处理。我们将探讨内置的异常层次结构。我们还将探讨如何定义独特的异常来反映独特的问题域或应用。在案例研究中,我们将应用异常来进行数据验证。

第五章何时使用面向对象编程,更深入地探讨了设计技术。本章将探讨如何通过 Python 的属性实现属性。我们还将探讨用于处理对象集合的管理器的一般概念。案例研究将应用这些思想来扩展k-NN 分类器的实现。

第六章抽象基类和运算符重载,深入探讨了抽象的概念以及 Python 如何支持抽象基类。这包括比较鸭子类型与更正式的Protocol定义方法。它还包括重载 Python 内置运算符的技术。它还将探讨元类以及如何使用它们来修改类构造。案例研究将重新定义一些现有类,以展示如何谨慎使用抽象来简化设计。

第七章Python 数据结构,考察了 Python 的一些内置集合。本章探讨了元组、字典、列表和集合。它还探讨了如何通过提供类的一些常见功能来简化设计的数据类和命名元组。案例研究将修订一些早期的类定义,以利用这些新技术。

第八章面向对象编程与函数式编程的交集,考察了 Python 中不是简单的类定义的结构。虽然 Python 的所有内容都是面向对象的,但函数定义允许我们创建可调用的对象,而不需要类定义的杂乱。我们还将探讨 Python 的上下文管理器结构和with语句。在案例研究中,我们将探讨避免一些类杂乱的不同设计方案。

第九章字符串、序列化和文件路径,涵盖了对象如何序列化为字符串以及如何解析字符串来创建对象。我们将探讨几种物理格式,包括 Pickle、JSON 和 CSV。案例研究将回顾如何通过k-NN 分类器加载和处理样本数据。

第十章迭代器模式,描述了 Python 中普遍存在的迭代概念。所有内置的集合都是可迭代的,这种设计模式是 Python 工作方式的核心。我们还将探讨 Python 的列表推导式和生成器函数。案例研究将回顾一些早期使用生成器表达式和列表推导式来划分测试和训练样本的设计。

第十一章常见设计模式,探讨了常见的面向对象设计。这包括装饰器、观察者、策略、命令、状态和单例设计模式。

第十二章高级设计模式,探讨了更高级的面向对象设计。这包括适配器、外观、享元、抽象工厂、组合和模板模式。

第十三章测试面向对象程序,展示了如何使用unittestpytest为 Python 应用程序提供一个自动化的单元测试套件。这将探讨一些更高级的测试技术,例如使用模拟对象来隔离待测试的单元。案例研究将展示如何为第三章中涵盖的距离计算创建测试用例。

第十四章并发,探讨了如何利用多核和多处理器计算机系统快速进行计算,并编写对外部事件响应的软件。我们将探讨线程和进程多线程,以及 Python 的asyncio模块。案例研究将展示如何使用这些技术对k-NN 模型进行超参数调整。

为了充分利用本书

所有示例均使用 Python 3.9.5 进行测试。mypy工具,版本 0.812,用于确认类型提示的一致性。

一些示例依赖于互联网连接来收集数据。这些与网站的交互通常涉及小规模下载。

一些示例涉及 Python 内置标准库之外的包。在相关章节中,我们注明了这些包并提供了安装说明。所有这些额外的包都在 Python 包索引中,网址为pypi.org

下载示例代码文件

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Python-Object-Oriented-Programming---4th-edition

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!

下载彩色图像

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

使用的约定

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

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“您可以通过在>>>提示符处导入antigravity模块来确认 Python 正在运行。”

代码块设置如下:

class Fizz:
    def member(self, v: int) -> bool:
        return v % 5 == 0 

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

class Fizz:
    **def member(self, v: int) -> bool:**
        return v % 5 == 0 

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

python -m pip install tox 

粗体:表示新术语、重要单词或您在屏幕上看到的单词,例如在菜单或对话框中。例如:“正式来说,一个对象是一组数据和相关的行为。”

警告或重要注意事项如下所示。

技巧和窍门如下所示。

联系我们

我们始终欢迎读者的反馈。

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

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

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

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

分享您的想法

一旦您阅读了《Python 面向对象编程,第四版》,我们很乐意听听您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

第一章:面向对象设计

在软件开发中,设计通常被认为是编程之前进行的步骤。这并不正确;实际上,分析、编程和设计往往相互重叠、结合和交织。在这本书中,我们将涵盖设计和编程的混合问题,而不会试图将它们解析成单独的类别。Python 语言的一个优点是能够清晰地表达设计。

在本章中,我们将简要讨论如何从好的想法过渡到编写软件。我们将创建一些设计工件——如图表——在开始编写代码之前帮助我们澄清思路。我们将涵盖以下主题:

  • 面向对象的意义

  • 面向对象设计与面向对象编程之间的区别

  • 面向对象设计的基本原则

  • 基本的统一建模语言UML)及其不是邪恶的一面

我们还将介绍这本书的面向对象设计案例研究,使用“4+1”架构视图模型。在这里,我们将涉及以下主题:

  • 经典机器学习应用概述,著名的鸢尾花分类问题

  • 此分类器的一般处理上下文

  • 绘制两个看起来足以解决问题的类层次结构视图

介绍面向对象

每个人都知道什么是对象:一个我们可以感知、触摸和操作的有形事物。我们最早与之互动的对象通常是婴儿玩具。木块、塑料形状和超大的拼图块是常见的第一种对象。婴儿很快就会学会某些对象会做某些事情:铃铛会响,按钮会被按下,杠杆会被拉动。

软件开发中对象的定义与实际物体并没有太大的区别。软件对象可能不是你可以拿起、感知或触摸的有形事物,但它们是某些可以执行特定操作和被施加特定操作的模型。正式来说,一个对象是一组数据和相关的行为的集合。

考虑到对象是什么,面向对象意味着什么?在词典中,“面向”意味着“指向”。面向对象编程意味着编写指向建模对象的代码。这是描述复杂系统行为所使用的技术之一。它通过描述一组通过其数据和行为的交互对象来定义。

如果你读过任何炒作内容,你可能已经遇到过诸如面向对象分析面向对象设计面向对象分析与设计面向对象编程等术语。这些都是属于一般面向对象范畴下的相关概念。

事实上,分析、设计和编程都是软件开发的不同阶段。将它们称为面向对象只是简单地指明了正在追求的软件开发类型。

面向对象分析OOA)是查看一个问题、系统或任务(某人希望将其转换成可工作的软件应用)并识别这些对象及其之间交互的过程。分析阶段完全是关于 需要做什么

分析阶段的输出是对系统的描述,通常以 需求 的形式呈现。如果我们一次性完成分析阶段,我们将一个任务,例如 作为一个植物学家,我需要一个网站来帮助用户分类植物,以便我能帮助进行正确的识别,转换成一系列所需的功能。以下是一些可能需要网站访问者执行的要求。每一项都是一个与对象相关的动作;我们用 斜体 来突出动作,用 粗体 来突出对象:

  • 浏览 之前的上传

  • 上传新的 已知示例

  • 测试 质量

  • 浏览 产品

  • 查看 推荐

在某些方面,术语 分析 是一个误称。我们之前讨论的那个婴儿并不是分析积木和拼图块。相反,她探索她的环境,操作形状,并看看它们可能适合在哪里。更好的说法可能是 面向对象探索。在软件开发中,分析的初始阶段包括采访客户、研究他们的流程和排除可能性。

面向对象设计OOD)是将此类需求转换成实现规范的过程。设计者必须命名对象、定义行为,并正式指定哪些对象可以激活其他对象上的特定行为。设计阶段完全是关于将 应该做什么 转换为 应该如何做

设计阶段的输出是实现规范。如果我们一次性完成设计阶段,我们将面向对象分析期间定义的需求转换成一组可以在(理想情况下)任何面向对象编程语言中实现的面类和接口。

面向对象编程OOP)是将设计转换成能够执行产品所有者最初请求的功能的工作程序的过程。

哎,对吧!如果世界符合这个理想,我们能够按部就班地遵循这些阶段,就像所有老教科书告诉我们的那样,那将是多么美好。但通常情况下,现实世界要复杂得多。无论我们多么努力地试图将这些阶段分开,我们总会发现设计过程中需要进一步分析的事情。当我们编程时,我们会发现设计中的某些功能需要澄清。

21 世纪的大多数开发都认识到这种阶段(或瀑布)的级联并不奏效。似乎更好的是一个迭代的开发模型。在迭代开发中,一小部分任务被建模、设计和编程,然后产品被审查和扩展,以改进每个特性并在一系列短暂的开发周期中包含新特性。

本书剩余部分将介绍面向对象编程,但本章我们将从设计角度介绍基本面向对象原则。这使我们能够在不与软件语法或 Python 跟踪回溯争论的情况下理解概念。

对象和类

对象是一组具有相关行为的数据集合。我们如何区分不同类型的对象?苹果和橙子都是对象,但有一个常见的谚语说它们不能比较。在计算机编程中,苹果和橙子模型化得并不常见,但让我们假设我们正在为一个水果农场开发一个库存应用程序。为了方便这个例子,我们可以假设苹果放在桶里,橙子放在篮子里。

我们迄今为止发现的问题域有四种类型的对象:苹果、橙子、篮子和桶。在面向对象建模中,用于表示“对象类型”的术语是。因此,从技术角度讲,我们现在有四个对象类。

理解对象和类之间的区别很重要。类描述相关对象。它们是创建对象的蓝图。你面前桌子上可能有三只橙子。每只橙子都是一个独特的对象,但它们都具有与一个类相关联的属性和行为:橙子的一般类别。

我们库存系统中四个对象类之间的关系可以使用统一建模语言(通常简称为UML,因为三字母的首字母缩略词永远不会过时)类图来描述。这是我们第一个类图

图示  描述自动生成

图 1.1:类图

此图显示橙子类(通常称为“橙子”)与某种方式关联着篮子,而苹果类(“苹果”)也与某种方式关联着关联是两个类实例之间关系最基本的方式。

UML 图(统一建模语言)的语法通常很直观;你不需要阅读教程就能(大部分)理解你看到的内容。UML 也相当容易绘制,而且非常直观。毕竟,当描述类及其关系时,许多人会自然地画上带有线条的盒子。基于这些直观的图的标准使得程序员与设计师、经理和彼此之间的沟通变得容易。

注意,UML 图通常表示类定义,但我们描述的是对象的属性。该图显示了苹果类和桶类,告诉我们一个特定的苹果位于一个特定的桶中。虽然我们可以使用 UML 来表示单个对象,但这通常是不必要的。显示这些类足以让我们了解每个类成员对象的属性。

一些程序员贬低 UML 为浪费时间。引用迭代开发,他们会争辩说,用花哨的 UML 图做出来的正式规范在实施之前就会变得冗余,而且维护这些正式图只会浪费时间,对任何人都没有好处。

每个由多个人组成的编程团队都偶尔需要坐下来讨论正在构建的组件的细节。UML 对于确保快速、简单和一致的沟通非常有用。即使那些嘲笑正式类图的组织,在设计会议或团队讨论中也倾向于使用一些非正式版本的 UML。

此外,你将不得不与之沟通的最重要的人是你的未来的自己。我们都认为我们可以记住我们做出的设计决策,但总会有隐藏在我们未来的“我为什么这么做?”的时刻。如果我们保留我们在开始设计时进行初始绘图时所用的纸张碎片,我们最终会发现它们是有用的参考资料。

然而,本章的目的并不是要成为 UML 的教程。互联网上有许多这样的教程,以及关于该主题的许多书籍。UML 不仅涵盖类和对象图,还有用例、部署、状态变化和活动的语法。在本章讨论面向对象设计时,我们将处理一些常见的类图语法。你可以通过示例来掌握结构,然后你会在自己的团队或个人设计笔记中无意识地选择受 UML 启发的语法。

我们的初始图虽然正确,但没有提醒我们苹果放入桶中,或者一个苹果可以放入多少个桶中。它只告诉我们苹果以某种方式与桶相关联。类之间的关联通常是显而易见的,不需要进一步解释,但我们有选择添加进一步说明的选项。

UML 的美在于大多数事情都是可选的。我们只需要在图中指定当前情况下有意义的信息。在快速的白板会议中,我们可能只是简单地画一些盒子之间的线条。在正式的文档中,我们可能会更详细一些。

在苹果和桶的情况下,我们可以相当有信心地认为关联是许多苹果放入一个桶中,但为了确保没有人将其与一个苹果毁了一个桶混淆,我们可以增强该图,如图所示:

图描述自动生成,置信度中等

图 1.2:带有更多详细信息的类图

这个图表告诉我们橙子放入篮子里,有一个小箭头显示是什么放入什么。它还告诉我们该对象在关系两边的关联中可以使用的数量。一个篮子可以容纳许多(用****表示)橙子对象。任何一个橙子可以放入恰好一个篮子。这个数字被称为对象的多重性。你也可能听到它被描述为基数;将基数视为一个特定的数字或范围,而在这里我们使用的多重性是一个广义的“多于一个实例”。

我们有时可能会忘记关系线哪一端应该有哪个多重性数字。离类最近的多重性是该类对象可以与关系另一端的任何单个对象关联的对象数量。对于苹果进桶的关联,从左到右读取,许多苹果类的实例(即许多苹果对象)可以放入任何一个。从右到左读取,恰好一个可以与任何一个苹果关联。

我们已经看到了类的基本知识,以及它们如何指定对象之间的关系。现在,我们需要谈论定义对象状态的属性,以及可能涉及状态变化或与其他对象交互的对象行为。

指定属性和行为

我们现在已经掌握了某些基本的面向对象术语。对象是类的实例,它们可以相互关联。类实例是一个具有自己数据集和行为的具体对象;我们面前桌子上的一个特定橙子被称为橙子这一通用类的一个实例。

橙子有一个状态,例如,成熟或生;我们通过特定属性值来实现对象的状态。橙子也有行为。单独来看,橙子通常是被动无反应的。状态变化是强加给它们的。让我们深入探讨这两个词的含义,即状态行为

数据描述对象状态

让我们从数据开始。数据代表某个对象的个别特征;它的当前状态。一个类可以定义一组特定的特征,这些特征是那个类所有成员对象的一部分。任何特定对象都可以为给定的特征有不同的数据值。例如,我们桌子上的三个橙子(如果我们没有吃掉任何的话)可能每个的重量都不同。橙子类可以有一个重量属性来表示这个数据。橙子类的所有实例都有一个重量属性,但每个橙子这个属性的值都不同。属性不必是唯一的;任何两个橙子可能重量相同。

属性通常被称为成员属性。一些作者建议这两个术语有不同的含义,通常认为属性是可设置的,而属性是只读的。Python 的属性可以被定义为只读的,但值将基于最终可写的属性值,这使得只读的概念变得相当没有意义;在这本书中,我们将看到这两个术语被交替使用。此外,正如我们将在第五章中讨论的,何时使用面向对象编程,Python 中的property关键字对于特定类型的属性有特殊的意义。

在 Python 中,我们也可以将属性称为实例变量。这有助于阐明属性的工作方式。它们是具有每个类实例唯一值的变量。Python 有其他类型的属性,但我们将专注于最常见的类型以开始。

在我们的水果库存应用程序中,水果农民可能想知道橙子来自哪个果园,什么时候采摘的,以及它的重量。他们还可能想要跟踪每个篮子的存放位置。苹果可能有颜色属性,而桶可能有不同的大小。

其中一些属性可能属于多个类(我们可能还想知道苹果何时采摘),但在这个第一个例子中,让我们只为我们的类图添加一些不同的属性:

图示  描述自动生成

图 1.3:具有属性的类图

根据我们的设计需要有多详细,我们还可以指定每个属性值的类型。在 UML 中,属性类型通常是许多编程语言通用的通用名称,如整数、浮点数、字符串、字节或布尔值。然而,它们也可以表示通用集合,如列表、树或图,或者最值得注意的是,其他非通用、特定于应用程序的类。这是设计阶段可以与编程阶段重叠的一个领域。一种编程语言中可用的各种原语和内置集合可能与另一种语言中可用的不同。

这里有一个(主要)针对 Python 特定类型提示的版本:

图示  描述自动生成

图 1.4:具有属性及其类型的类图

通常,在设计阶段,我们不需要过分关注数据类型,因为实现特定的细节是在编程阶段选择的。通用名称通常足以用于设计;这就是为什么我们包括了date作为 Python 类型datetime.datetime的占位符。如果我们的设计需要列表容器类型,Java 程序员可以选择在实现时使用LinkedListArrayList,而 Python 程序员(也就是我们!)可能会指定List[Apple]作为类型提示,并使用list类型进行实现。

在我们之前的果农例子中,我们的属性都是基本原语。然而,还有一些隐含的属性我们可以使其显式化——这些是关联。对于一个特定的橙子,我们有一个指向包含该橙子的篮子的属性,即basket属性,其类型提示为Basket

行为是动作

现在我们知道了数据如何定义对象的状态,我们需要查看的最后一个未定义的术语是行为。行为是在对象上可以发生的行为。可以在特定对象类上执行的行为作为该类的方法来表示。在编程层面,方法类似于结构化编程中的函数,但它们可以访问属性——特别是与该对象关联的数据的实例变量。像函数一样,方法也可以接受参数并返回

方法参数作为需要传递到该方法中的对象集合提供给它。在特定调用期间传递到方法中的实际对象实例通常被称为参数。这些对象绑定到方法体内的参数变量上。它们被方法用来执行它打算做的任何行为或任务。返回值是该任务的结果。评估方法时的内部状态变化是另一种可能的影响。

我们已经将我们的“比较苹果和橙子”例子扩展成了一个基本的(如果有些牵强)库存应用程序。让我们再进一步扩展它,看看它是否会破裂。可以与橙子关联的一个动作是pick动作。如果你考虑实现,pick需要做两件事:

  • 通过更新橙子的**Basket**属性,将橙子放入篮子中。

  • 将橙子添加到给定的Basket上的**Orange**列表中。

因此,**pick**需要知道它正在处理哪个篮子。我们通过给**pick**方法提供一个**Basket**参数来实现这一点。由于我们的果农还卖果汁,我们可以在**Orange**类中添加一个**squeeze**方法。当被调用时,**squeeze**方法可能会返回提取的果汁量,同时也会将该**Orange**从它所在的**Basket**中移除。

**Basket**可以有一个sell动作。当一个篮子被出售时,我们的库存系统可能会更新一些尚未指定的对象上的数据,用于会计和利润计算。或者,我们的橙子篮可能在我们可以出售之前就变质了,所以我们添加了一个discard方法。让我们将这些方法添加到我们的图中:

图解 描述自动生成,置信度中等

图 1.5:包含属性和方法的类图

向单个对象添加属性和方法允许我们创建一个系统,其中包含相互交互的对象。系统中的每个对象都是某个类的成员。这些类指定了对象可以持有哪些类型的数据以及可以对其调用的方法。每个对象中的数据可能与其他相同类的实例处于不同的状态;由于状态的不同,每个对象可能对方法调用的反应也不同。

面向对象分析和设计全部关于弄清楚那些对象是什么以及它们应该如何交互。每个类都有责任和协作。下一节将描述可以用来使这些交互尽可能简单直观的原则。

注意,出售一个篮子并不一定是篮子类的一个无条件特性。可能存在其他一些(未展示的)类关心各种篮子及其位置。我们在设计时常常会有边界。我们也会对分配给各个类的责任有所疑问。责任分配问题并不总是有一个整洁的技术解决方案,这迫使我们多次绘制(和重新绘制)我们的 UML 图来检查替代设计。

隐藏细节和创建公共接口

对象面向设计建模的关键目的是确定该对象的公共接口将是什么。接口是其他对象可以访问以与该对象交互的属性和方法集合。其他对象不需要,在某些语言中甚至不允许访问对象的内部工作原理。

一个常见的现实世界例子是电视。我们与电视的接口是遥控器。遥控器上的每个按钮都代表可以在电视对象上调用的一个方法。当我们作为调用对象访问这些方法时,我们不知道或关心电视是从有线电视连接、卫星天线还是互联网设备接收信号。我们不在乎调整音量的电子信号是什么,或者声音是针对扬声器还是耳机。如果我们打开电视以访问其内部工作原理,例如,将输出信号分割到外部扬声器和一套耳机,我们可能会使保修失效。

这个隐藏对象实现的过程通常被称为信息隐藏。它有时也被称为封装,但封装实际上是一个更广泛的概念。封装的数据不一定被隐藏。封装字面上是指在属性上创建一个胶囊(或包装器)。电视的外壳封装了电视的状态和行为。我们可以访问外部屏幕、扬声器和遥控器。我们无法直接访问电视外壳内部的放大器或接收器的线路。

当我们购买一个组件娱乐系统时,我们改变了封装级别,暴露了组件之间的更多接口。如果我们是物联网制造商,我们甚至可以进一步分解它,打开外壳并打破制造商试图隐藏的信息。

封装和信息隐藏之间的区别在很大程度上是不相关的,尤其是在设计层面。许多实用参考手册将这些术语互换使用。作为 Python 程序员,我们实际上没有或不需要通过完全私有、不可访问的变量来实现信息隐藏(我们将在第二章,Python 中的对象中讨论这一原因),因此封装的更广泛定义是合适的。

然而,公共接口非常重要。它需要精心设计,因为它在其他类依赖它时很难更改。更改接口可能会破坏任何依赖它的客户端对象。我们可以随意更改内部结构,例如,使其更高效,或者通过网络以及本地访问数据,客户端对象仍然可以通过公共接口与之通信,无需修改。另一方面,如果我们通过更改公开访问的属性名称或方法可以接受的参数的顺序或类型来更改接口,所有客户端类也必须进行修改。在设计公共接口时,请保持简单。始终根据使用难度来设计对象的接口,而不是编码难度(此建议也适用于用户界面)。因此,有时你会看到 Python 变量名以 _ 开头,作为这些不是公共接口的警告。

记住,程序对象可能代表真实对象,但这并不意味着它们是真实对象。它们是模型。建模的最大礼物之一就是能够忽略无关的细节。作者小时候制作的模型车在外观上看起来像 1956 年的雷鸟,但显然不能行驶。当他们太小无法驾驶时,这些细节过于复杂且无关紧要。这个模型是对真实概念的抽象。

抽象是另一个与封装和信息隐藏相关的面向对象术语。抽象意味着处理与给定任务最合适的细节级别。它是从内部细节中提取公共接口的过程。汽车的驾驶员需要与方向盘、油门和刹车交互。发动机、传动系统和制动子系统的运作对驾驶员来说并不重要。另一方面,机械师在另一个抽象级别上工作,调整发动机和放刹车的空气。以下是一个关于汽车的两种抽象层次的例子:

图 1.6 描述自动生成图 1.6:汽车的抽象层次

现在,我们有几个新术语指的是类似的概念。让我们用几句话总结所有这些术语:抽象是将信息封装在独立的公共接口中的过程。任何私有元素都可以受到信息隐藏的影响。在 UML 图中,我们可能会使用一个前导破折号(-)而不是前导加号(+)来表示它不是公共接口的一部分。

从所有这些定义中吸取的重要教训是使我们的模型对必须与之交互的其他对象可理解。这意味着要仔细关注细节。

确保方法和属性有合理的名称。在分析系统时,对象通常代表原始问题中的名词,而方法通常是动词。属性可能表现为形容词或更多的名词。相应地命名你的类、属性和方法。

在设计界面时,想象你自己是对象;你希望对自己的责任有清晰的定义,并且你非常重视隐私来满足这些责任。除非你觉得自己有最好的利益让他们拥有这些信息,否则不要让其他对象访问关于你的数据。除非你确定这是你的责任去做,否则不要给他们一个界面来强迫你执行特定的任务。

组合

到目前为止,我们已经学会了将系统设计为一组相互作用的对象,其中每个交互都涉及在适当的抽象级别上查看对象。但我们还不知道如何创建这些抽象级别。有各种方法可以做到这一点;我们将在第十章、第十一章和第十二章中讨论一些高级设计模式。但即使大多数设计模式也依赖于两个基本面向对象的原则,即组合继承。组合更简单,所以让我们从它开始。

组合是将几个对象收集在一起以创建一个新的对象的行为。当其中一个对象是另一个对象的一部分时,组合通常是一个好的选择。当我们谈论汽车时,我们已经看到了组合的第一个暗示。一个化石燃料汽车由发动机、变速箱、起动机、前灯和挡风玻璃等众多部件组成。反过来,发动机由活塞、曲轴和阀门组成。在这个例子中,组合是一种提供抽象级别的良好方式。Car对象可以提供司机所需的接口,同时也可以访问其组成部分,这提供了适合机械师更深层次的抽象。当然,如果机械师需要更多信息来诊断问题或调整发动机,这些组成部分可以进一步分解成细节。

汽车是组合的一个常见入门例子,但在设计计算机系统时并不特别有用。物理对象很容易分解成组件对象。人们至少从古希腊最初提出原子是物质的最小单位(他们当然没有粒子加速器)以来就在做这件事。由于计算机系统涉及许多特殊概念,识别组件对象并不像现实世界中的阀门和活塞那样自然发生。

面向对象系统中的对象有时代表物理对象,如人、书籍或电话。然而,更常见的是,它们代表概念。人们有名字,书籍有标题,电话用于打电话。通话、标题、账户、名字、约会和付款通常不被视为物理世界中的对象,但它们都是计算机系统中经常建模的组件。

让我们尝试模拟一个更面向计算机的例子,看看组合是如何发挥作用的。我们将研究一个计算机化棋盘游戏的设计。这在 80 年代和 90 年代是一个非常受欢迎的消遣活动。人们预测计算机有一天能够击败人类棋手。当 1997 年发生这种情况时(IBM 的 Deep Blue 击败了世界棋王加里·卡斯帕罗夫),对棋类问题的兴趣减弱了。如今,Deep Blue 的后代总是能赢。

一场棋局是在两个玩家之间进行的,使用一个包含 64 个位置的 8×8 网格的棋盘。棋盘可以有两套 16 个棋子,可以由两个玩家交替移动,以不同的方式。每个棋子可以吃掉其他棋子。在每一步之后,棋盘都需要在计算机屏幕绘制自己。

我已经使用斜体标识了一些描述中可能的对象,并使用粗体标识了一些关键方法。这是将面向对象的分析转化为设计时的一个常见第一步。在这个阶段,为了强调组合,我们将专注于棋盘,而不太关心玩家或不同类型的棋子。

让我们从可能的最高的抽象层次开始。我们有两个玩家通过轮流移动棋子与棋盘进行交互:

图描述自动生成

图 1.7:棋盘游戏的对象/实例图

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

图片 B17070_01_08.png

图 1.8:棋盘游戏的类图

此图显示恰好两名玩家可以与一个棋盘互动。这也表明任何一名玩家一次只能玩一个 棋盘

然而,我们正在讨论组合,而不是 UML,所以让我们考虑一下 棋盘 由什么组成。我们现在不关心玩家由什么组成。我们可以假设玩家有一颗心和大脑,以及其他器官,但这些对我们模型来说是不相关的。实际上,没有任何阻止该玩家成为没有心脏和大脑的 Deep Blue 的理由。

因此,棋盘由一个棋盘和 32 个棋子组成。棋盘进一步由 64 个位置组成。你可以争论这些棋子不是棋盘的一部分,因为你可以用另一套棋子替换棋盘的棋子。虽然在计算机化的棋盘游戏中这不太可能或不可能,但它让我们了解了 聚合

聚合几乎与组合完全相同。区别在于聚合对象可以独立存在。一个位置与不同的棋盘相关联是不可能的,所以我们说棋盘由位置组成。但是,棋子可能独立于棋盘存在,因此我们说棋子与该棋盘处于聚合关系。

区分聚合和组合的另一种方法是考虑对象的生命周期:

  • 如果复合(外部)对象控制相关(内部)对象的创建和销毁时间,则组合是最合适的。

  • 如果相关对象独立于复合对象创建,或者可以比该对象存活更久,则聚合关系更有意义。

此外,请记住,组合是聚合;聚合只是组合的一种更一般的形式。任何组合关系也是聚合关系,但反之则不然。

让我们描述我们当前的 棋盘 组合,并为对象添加一些属性以保持复合关系:

图描述自动生成

图 1.9:棋局类图

组合关系在 UML 中以实心菱形表示。空心菱形表示聚合关系。你会注意到棋盘和棋子作为棋盘的一部分存储,就像它们作为属性存储在棋盘上一样。这表明,在实践中,一旦过了设计阶段,聚合与组合的区别通常无关紧要。实现后,它们的行为几乎相同。

这种区别可以帮助你在团队讨论不同对象如何相互作用时区分两者。在谈论相关对象存在的时间长度时,你通常会需要区分它们。在许多情况下,删除一个复合对象(如棋盘)会删除所有位置。然而,聚合对象则不会自动删除。

继承

我们讨论了三种对象之间的关系:关联、组合和聚合。然而,我们尚未完全指定我们的棋盘,这些工具似乎并没有给我们提供所有需要的功能。我们讨论了玩家可能是一个人类或者可能是一块具有人工智能功能的软件的可能性。说玩家与人类关联,或者人工智能实现是玩家对象的部分,似乎并不合适。我们真正需要的是能够说Deep Blue 是一个玩家,或者Gary Kasparov 是一个玩家

继承关系形成了is a关系。继承是面向对象编程中最著名、最知名且被过度使用的关联。继承有点像家谱。Dusty Phillips 是本书的作者之一。

他的祖父的姓氏是 Phillips,他的父亲继承了那个名字。Dusty 从他那里继承了它。在面向对象编程中,不是从一个人那里继承特性和行为,一个类可以继承另一个类的属性和方法。

例如,在我们的棋盘上共有 32 个棋子,但只有六种不同的棋子类型(兵、车、象、马、王和后),每种棋子在移动时表现都不同。所有这些棋子类别都有属性,例如颜色和它们所属的棋盘,但它们在棋盘上绘制时也有独特的形状,并且移动方式不同。让我们看看六种类型的棋子如何从棋子类中继承:

图描述自动生成

图 1.10:棋子如何从棋子类继承

空心箭头表示各个棋子类别从棋子类继承。所有子类自动从基类继承了一个棋盘颜色属性。每个棋子提供不同的形状属性(在渲染棋盘时绘制),以及不同的移动方法,在每个回合将棋子移动到棋盘上的新位置。

实际上,我们知道所有棋子类的子类都需要有一个移动方法;否则,当棋盘尝试移动棋子时,它会感到困惑。我们可能想要创建一个新版本的棋类游戏,增加一个额外的棋子(法师)。我们当前的设计将允许我们不给它一个移动方法来设计这个棋子。然后,当棋盘要求棋子移动自己时,它就会卡住。

我们可以通过在棋子类上创建一个虚拟的移动方法来解决这个问题。子类可以随后用更具体的实现来重写这个方法。默认实现可能,例如,弹出一个错误消息,说那个棋子不能移动

在子类中重写方法可以开发出非常强大的面向对象系统。例如,如果我们想实现一个具有人工智能的Player类,我们可能会提供一个calculate_move方法,该方法接受一个Board对象并决定将哪个棋子移动到哪个位置。一个非常基本的类可能会随机选择一个棋子和方向,并相应地移动它。然后我们可以在子类中重写这个方法,使用 Deep Blue 的实现。第一个类适合与初学者对弈;后者则可以挑战大师。重要的是,类中的其他方法,比如通知棋盘选择了哪个移动的方法,不需要改变;这种实现可以在两个类之间共享。

在棋子的例子中,提供移动方法的默认实现实际上并没有太多意义。我们只需要指定任何子类都需要实现移动方法。这可以通过将Piece类声明为抽象类并声明move方法为抽象方法来实现。抽象方法基本上是这样说的:

“我们要求任何非抽象子类都必须实现这个方法,但我们不打算在这个类中指定实现方式。”

实际上,可以创建一个完全不实现任何方法的高度抽象。这样的类只会告诉我们这个类应该做什么,但不会提供任何关于如何做的建议。在某些语言中,这些纯粹抽象的类被称为接口。在 Python 中,可以定义一个只包含抽象方法占位符的类,但这非常罕见。

继承提供了抽象。

让我们探索面向对象术语中最长的单词。多态性是指根据实现的子类不同而以不同的方式处理类的能力。我们已经在我们描述的棋子系统中看到了它的实际应用。如果我们进一步扩展设计,我们可能会看到Board对象可以接受玩家的移动并调用棋子的move函数。棋盘不需要知道它正在处理的是什么类型的棋子。它只需要调用move方法,正确的子类就会负责将其移动为骑士

多态性非常酷,但在 Python 编程中这个词很少被使用。Python 允许一个对象的子类被当作父类来处理,但它在这一点上做得更多。在 Python 中实现的棋盘可以接受任何具有move方法的对象,无论是棋子、汽车还是鸭子。当调用move时,主教会在棋盘上斜着移动,汽车会驶向某个地方,鸭子会根据它的心情游泳或飞翔。

这种在 Python 中的多态通常被称为鸭子类型如果它像鸭子走路或游泳,我们就称它为鸭子。我们不在乎它是否真的是一只鸭子(“是”是一个继承的核心),只在乎它是否能游泳或走路。鹅和天鹅可能很容易提供我们想要的类似鸭子的行为。这允许未来的设计者创建新的鸟类类型,而无需为所有可能的鸟类指定正式的继承层次结构。上面的棋类示例使用正式的继承来涵盖棋盘上的所有可能的棋子。鸭子类型还允许程序员扩展设计,创建原始设计者从未计划过的完全不同的即插即用行为。例如,未来的设计者可能能够制作一个既能行走又能游泳的企鹅,并且与相同的接口一起工作,而无需建议企鹅与鸭子有一个共同的超类。

多继承

当我们思考我们家族树中的继承时,我们可以看到我们不仅仅从父母一方继承了特征。当陌生人告诉一位自豪的母亲她的儿子有他父亲的眼睛时,她通常会这样回答,是的,但他继承了了我的鼻子

面向对象的设计也可以有这种多继承,它允许子类从多个父类继承功能。在实践中,多继承可能是一个棘手的问题,一些编程语言(最著名的是 Java)严格禁止它。然而,多继承有其用途。最常见的是,它可以用来创建具有两个不同行为集的对象。例如,一个设计用来连接扫描仪以制作图像并发送扫描图像的传真对象可能通过从两个不同的scannerfaxer对象继承而创建。

只要两个类有明确的接口,子类从它们两个继承通常不会造成伤害。然而,如果我们从提供重叠接口的两个类继承,就会变得混乱。扫描仪和传真机没有任何重叠的功能,所以结合它们的功能是容易的。我们的反例是一个具有move方法的摩托车类,以及一个也具有move方法的船类。

如果我们想将它们合并成终极两栖车辆,当调用move时,结果类知道该做什么?在设计层面,这需要解释。(作为一个在船上生活的水手,一位作者真的想知道这是如何工作的。)

Python 有一个定义的方法解析顺序MRO)来帮助我们理解哪些替代方法将被使用。虽然 MRO 规则很简单,但避免重叠甚至更简单。将多继承作为一种“混合”技术来组合不相关的方面可能是有帮助的。然而,在许多情况下,组合对象可能更容易设计。

继承是扩展行为和重用功能的有力工具。它也是面向对象设计相对于早期范式的最具市场潜力的进步之一。因此,它通常是面向对象程序员首先寻求的工具。然而,重要的是要认识到,拥有锤子并不意味着螺丝就会变成钉子。继承是解决明显“是”关系的完美方案。除此之外,它也可能被滥用。程序员经常使用继承在两种只有远亲关系的对象之间共享代码,而看不到任何“是”关系。虽然这不一定是一个坏的设计,但它是一个很好的机会去问为什么他们决定那样设计,以及是否另一种关系或设计模式可能更适合。

案例研究

我们的案例研究将涵盖这本书的许多章节。我们将从多个角度仔细研究一个问题。查看替代设计和设计模式非常重要;不止一次,我们会指出没有唯一的正确答案:有多个好的答案。我们的意图是提供一个涉及现实深度和复杂性的真实示例,并导致艰难的权衡决策。我们的目标是帮助读者应用面向对象编程和设计概念。这意味着在技术替代品中选择,以创建有用的东西。

案例研究的第一部分是对问题的概述以及我们为什么要解决这个问题。这部分背景将涵盖问题的多个方面,为后续章节中解决方案的设计和构建奠定基础。概述的一部分将包括一些 UML 图来捕捉需要解决的问题的元素。这些图将在后续章节中随着我们深入设计选择的影响和修改这些设计选择而演变。

与许多现实问题一样,作者们会带入个人的偏见和假设。关于这一点的后果,可以参考 Sara Wachter-Boettcher 所著的《Technically Wrong》一书。

我们的用户希望自动化一个常被称为分类的工作。这是产品推荐背后的基本理念:上次,顾客购买了产品 X,所以他们可能对类似的产品 Y 感兴趣。我们已经对他们的需求进行了分类,并可以定位到那个产品类中的其他项目。这个问题可能涉及复杂的数据组织问题。

从小而易于管理的事情开始是有帮助的。用户最终想要处理复杂的消费产品,但认识到解决一个难题并不是学习如何构建这类应用程序的好方法。最好是从小规模且复杂度可控的事情开始,然后逐步精炼和扩展,直到它满足他们的所有需求。因此,在本案例研究中,我们将构建一个用于鸢尾花物种的分类器。这是一个经典问题,关于如何对鸢尾花进行分类的方法有很多论述。

需要一个训练数据集,分类器将其用作正确分类的鸢尾花示例。我们将在下一节讨论训练数据的样子。

我们将使用统一建模语言UML)创建一系列图表,以帮助描述和总结我们将要构建的软件。

我们将使用称为4+1 视图的技术来检查这个问题。这些视图包括:

  • 逻辑视图展示了数据实体、它们的静态属性以及它们之间的关系。这是面向对象设计的核心。

  • 过程视图描述了数据是如何被处理的。这可以采取多种形式,包括状态模型、活动图和序列图。

  • 开发视图展示了将要构建的代码组件。这张图显示了软件组件之间的关系。这用于展示类定义是如何被收集到模块和包中的。

  • 物理视图展示了将要集成和部署的应用程序。在应用程序遵循常见设计模式的情况下,一个复杂的图表并不是必需的。在其他情况下,图表是必不可少的,以展示组件集合是如何集成和部署的。

  • 上下文视图为其他四个视图提供了一个统一的上下文。上下文视图通常描述使用(或与)将要构建的系统交互的参与者。这可能涉及人类参与者以及自动化接口:两者都在系统之外,系统必须对这些外部参与者做出响应。

通常从上下文视图开始,这样我们就有了一个关于其他视图描述的内容感。随着我们对用户和问题领域的理解不断演变,上下文也会随之演变。

认识到所有这些 4+1 视图是共同演化的非常重要。一个视图的变更通常会在其他视图中得到反映。一个常见的错误是认为一个视图在某种程度上是基础性的,而其他视图则在此基础上构建,形成一个设计步骤的级联,这些步骤总是导致软件的产生。

在我们开始尝试分析应用程序或设计软件之前,我们将对问题进行总结并提供一些背景信息。

引言和问题概述

如我们之前提到的,我们将从一个更简单的问题开始——分类花朵。我们希望实现一种流行的方法,称为k-最近邻,或简称k-NN。我们需要一个训练数据集,分类算法将使用它作为正确分类的鸢尾花示例。每个训练样本都有若干属性,这些属性被简化为数值分数,以及一个最终的、正确的分类(即鸢尾花种类)。在这个鸢尾花例子中,每个训练样本是一朵鸢尾花,其属性,如花瓣形状、大小等,被编码成一个数值向量,这是鸢尾花的整体表示,以及该鸢尾花的正确种类标签。

给定一个未知样本,一个我们想要知道种类的鸢尾花,我们可以在向量空间中测量未知样本与任何已知样本之间的距离。对于一些附近的邻居小群体,我们可以进行投票。未知样本可以被分类到由附近多数邻居选择的子群体中。

如果我们只有两个维度(或属性),我们可以这样绘制k-NN 分类图:

图,工程图纸,自动生成描述

图 1.11:k-最近邻

我们未知样本是一个标记为"???"的菱形。它被已知样本的正方形和圆形种类所包围。当我们找到三个最近的邻居,如虚线圆内所示,我们可以进行投票并决定未知样本最像圆形种类。

一个基本概念是对于各种属性要有具体的数值测量。将文字、地址和其他非序数数据转换为序数测量可能具有挑战性。好消息是,我们将开始使用的数据已经具有适当的序数测量和明确的测量单位。

另一个支持概念是参与投票的邻居数量。这是k-最近邻中的k因子。在我们的概念图中,我们展示了k=3 个邻居;其中两个最近的邻居是圆形,第三个是正方形。如果我们把k值改为 5,这将改变池子的组成,并将投票倾向于正方形。哪个是正确的?这通过使用已知正确答案的测试数据来检查,以确认分类算法工作得相当好。在前面的图中,很明显,菱形被巧妙地选择在两个簇之间,有意地创造了一个困难的分类问题。

学习如何工作的流行数据集是鸢尾花分类数据集。有关此数据的背景信息,请参阅archive.ics.uci.edu/ml/datasets/iris。此数据也可在www.kaggle.com/uciml/iris和其他许多地方找到。

更有经验的读者可能会注意到,在我们进行面向对象的分析和设计工作时,存在一些差距和可能的矛盾。这是故意的。对任何范围的问题的初步分析将涉及学习和重做。随着我们了解更多,这个案例研究将不断发展。如果你发现了差距或矛盾,制定你自己的设计,看看它是否与后续章节中学到的经验相一致。

在查看了一些问题的方面之后,我们可以通过参与者和描述参与者如何与要构建的系统交互的用例或场景来提供更具体的上下文。我们将从上下文视图开始。

上下文视图

我们的应用程序对鸢尾花物种进行分类的上下文涉及以下两类参与者:

  • 一个“植物学家”,他提供经过适当分类的训练数据和测试数据集。植物学家还运行测试用例以确定分类的正确参数。在简单的 k-NN 情况下,他们可以决定应该使用哪个 k 值。

  • 一个“用户”,他需要对未知数据进行分类。用户已经进行了仔细的测量,并使用测量数据向这个分类器系统提出请求以获得分类。名字“用户”听起来有些模糊,但我们不确定什么更好。我们暂时保留它,并推迟到我们预见问题再进行更改。

这个 UML 上下文图说明了我们将探索的两个参与者和三个场景:

图描述自动生成

图 1.12:UML 上下文图

整个系统被描绘成一个矩形。它包围着椭圆形来表示用户故事。在 UML 中,特定的形状具有意义,我们保留矩形用于对象。椭圆形(和圆形)用于用户故事,它们是系统的接口。

为了进行任何有用的处理,我们需要经过适当分类的训练数据。每一组数据包含两个部分:一个训练集和一个测试集。我们将整个集合称为“训练数据”,而不是更长的(但更精确的)“训练和测试数据”。

调节参数由植物学家设置,他必须检查测试结果以确保分类器工作。这些是可以调节的两个参数:

  • 要使用的距离计算方法

  • 考虑投票的邻居数量

我们将在本章后面的“处理视图”部分详细讨论这些参数。我们还将随后在案例研究章节中回顾这些想法。距离计算是一个有趣的问题。

我们可以将一系列实验定义为一个网格,其中包含每个备选方案和方法,然后系统地用测试集的测量结果填充网格。提供最佳拟合的组合将是植物学家推荐的参数集。在我们的案例中,有两种选择,网格是一个二维表格,如下所示。对于更复杂的算法,“网格”可能是一个多维空间:

各种 k 因子
k=3
距离计算算法 欧几里得 测试结果…
曼哈顿
切比雪夫
索伦森
其他?

测试完成后,用户可以提出请求。他们提供未知数据以从经过训练的分类器过程中接收分类结果。从长远来看,这个“用户”不会是一个人——他们将是来自某个网站的销售或目录引擎到我们聪明的基于分类器的推荐引擎的连接。

我们可以用用例用户故事语句来总结这些场景:

  • 作为一名植物学家,我想向这个系统提供正确分类的训练和测试数据,以便用户能够正确识别植物。

  • 作为一名植物学家,我想检查分类器的测试结果,以确保新样本很可能被正确分类。

  • 作为一名用户,我希望能够向分类器提供一些关键测量值,并使鸢尾花物种得到正确分类。

根据用户故事中的名词和动词,我们可以使用这些信息来创建应用程序将处理的数据的逻辑视图。

逻辑视图

观察上下文图,处理从训练数据和测试数据开始。这是用于测试我们分类算法的正确分类样本数据。以下图示展示了查看包含各种训练和测试数据集的类的一种方式:

图示描述自动生成

图 1.13:训练和测试的类图

这显示了具有每个实例属性的对象的Training Data类。TrainingData对象给我们的样本集合起了一个名字,以及上传和测试完成的一些日期。目前,似乎每个TrainingData对象都应该有一个用于k-NN 分类算法的单个调整参数k。实例还包括两个单独样本的列表:一个训练列表和一个测试列表。

每个对象类别都用一个矩形表示,其中包含多个单独的部分:

  • 最顶部的部分为对象类提供了一个名称。在两种情况下,我们使用了类型提示List[Sample];泛型类list的使用方式确保列表的内容只能是Sample对象。

  • 类矩形的下一部分显示了每个对象的属性;这些属性也被称为此类实例的实例变量。

  • 之后,我们将在类底部的部分添加“方法”用于类的实例。

Sample类的每个对象都有一些属性:四个浮点测量值和一个字符串值,这是植物学家为样本分配的分类。在这种情况下,我们使用了属性名class,因为在源数据中它就是这样称呼的。

UML 箭头显示了两种特定类型的关系,通过填充或空心的菱形突出显示。一个填充的菱形表示组合:一个TrainingData对象部分由两个集合组成。一个开放的菱形表示聚合:一个List[Sample]对象是Sample项的聚合。为了回顾我们之前学到的内容:

  • 组合是一种存在关系:没有两个List[Sample]对象,我们无法拥有TrainingData。相反,一个List[Sample]对象在我们的应用程序中不被用作TrainingData对象的一部分。

  • 另一方面,聚合是一种可以独立存在的关系。在这个图中,多个Sample对象可以是List[Sample]的一部分,也可以独立于列表存在。

显然,用开放的菱形来表示将Sample对象聚合到List对象中的聚合关系并不相关。这可能是一个无用的设计细节。在不确定的情况下,最好省略这些类型的细节,直到它们显然是必需的,以确保有一个满足用户期望的实现。

我们将List[Sample]显示为一个单独的对象类。这是 Python 的通用List,用特定的对象类Sample进行限定,这些对象将包含在列表中。通常避免这种级别的细节,并在以下类似的图中总结关系:

图描述自动生成

图 1.14:压缩后的类图

这种略微简化的形式有助于进行分析工作,其中底层的数据结构并不重要。对于设计工作来说,具体的 Python 类信息则更为重要。

给定一个初始草图,我们将把这个逻辑视图与上下文图中提到的三个场景之一进行比较,如前节中图 1.12所示。我们想要确保用户故事中的所有数据和处理都可以分配到图中分散在类、属性和方法中的责任。

在遍历用户故事时,我们发现了这两个问题:

  • 测试和参数调整如何与这个图相匹配并不清楚。我们知道需要一个k因子,但没有相关的测试结果来显示替代k因子及其选择的结果。

  • 用户请求根本未显示。对用户的响应也没有显示。没有类将这些项作为其责任的一部分。

第一点表明我们需要重新阅读用户故事,并再次尝试创建一个更好的逻辑视图。第二点是关于边界的问题。虽然缺少了网络请求和响应的细节,但首先描述基本的问题域——分类和k-NN——更为重要。处理用户请求的 Web 服务是(众多)解决方案技术之一,在开始时我们应该将其放在一边。

现在,我们将关注数据处理。我们遵循创建应用程序描述的有效顺序。数据必须首先描述;这是最持久的部分,也是每次处理细化过程中始终保留的东西。处理可以次要于数据,因为随着上下文的变化以及用户体验和偏好的变化,它会发生变化。

处理视图

有三个独立的故事。这并不一定迫使我们创建三个流程图。对于复杂的处理,流程图可能比用户故事多。在某些情况下,一个用户故事可能过于简单,不需要精心设计的图表。

对于我们的应用,似乎至少有三个独特的感兴趣的过程,具体如下:

  • 上传包含一些TrainingData的初始Samples集。

  • 使用给定的k值运行分类器的测试。

  • 使用新的Sample对象进行分类请求。

我们将为这些用例绘制活动图。活动图总结了一系列状态变化。处理从开始节点开始,直到达到结束节点。在基于事务的应用程序中,如 Web 服务,通常省略显示整体 Web 服务器引擎。这使我们免于描述 HTTP 的常见特性,包括标准头、cookie 和安全问题。相反,我们通常专注于为每种不同类型的请求创建响应的独特处理。

活动以圆角矩形表示。当特定类别的对象或软件组件相关时,它们可以链接到相关活动。

更重要的是确保在处理视图工作时,逻辑视图随着新想法的出现而更新。完全独立完成任何一种视图都是困难的。当新的解决方案想法出现时,在每个视图中进行增量更改更为重要。在某些情况下,需要额外的用户输入,这也将导致这些视图的演变。

我们可以绘制一个图表来展示当植物学家提供初始数据时系统如何响应。以下是第一个例子:

图描述自动生成

图 1.15:活动图

KnownSample值的集合将被划分为两个子集:一个训练子集和一个测试子集。在我们的问题摘要或用户故事中并没有规则来区分这一点;差距表明我们在原始用户故事中缺少细节。当用户故事中缺少细节时,逻辑视图也可能不完整。目前,我们可以假设大部分数据——比如说 75%——将用于训练,其余的 25%将用于测试。

为每个用户故事创建类似的图示通常很有帮助。这也有助于确保所有活动都有相关的类来实现步骤并表示由每个步骤引起的状态变化。

我们在这个图示中包含了一个动词Partition。这表明需要实现这个动词的方法。这可能会导致重新思考类模型,以确保可以实施处理。

我们将转向考虑需要构建的一些组件。由于这是一个初步分析,我们的想法将在我们进行更详细的设计并开始创建类定义时演变。

开发视图

在最终部署和要开发的组件之间往往有一个微妙的平衡。在罕见的情况下,部署约束很少,设计师可以自由地思考要开发的组件。物理视图将随着开发而演变。在更常见的情况下,必须使用特定的目标架构,物理视图的元素是固定的。

有几种方法可以将这个分类器作为更大应用程序的一部分进行部署。我们可能构建一个桌面应用程序、一个移动应用程序或一个网站。由于互联网计算机的普遍性,一种常见的方法是创建一个网站,并从桌面和移动应用程序连接到它。

例如,一个网络服务架构意味着可以向服务器发送请求;响应可以是用于在浏览器中展示的 HTML 页面,或者可以被移动应用程序显示的 JSON 文档。一些请求将提供全新的训练数据集。其他请求将试图对未知样本进行分类。我们将在下面的物理视图中详细说明架构。我们可能希望使用 Flask 框架来构建网络服务。有关 Flask 的更多信息,请参阅精通 Flask Web 开发www.packtpub.com/product/mastering-flask-web-development-second-edition/9781788995405,或学习 Flask 框架www.packtpub.com/product/learning-flask-framework/9781783983360

下面的图示显示了我们需要为基于 Flask 的应用程序构建的一些组件:

图示描述自动生成

图 1.16:需要构建的组件

此图示显示了一个包含多个模块的 Python 包Classifier。三个顶级模块是:

  • 数据模型:(由于这仍然是分析阶段,这里的命名并不完全符合 Python 风格;我们将在进入实现阶段时将其更改。)将定义问题域的类分离到模块中通常很有帮助。这使得我们能够在与使用这些类的任何特定应用程序隔离的情况下测试它们。我们将关注这部分,因为它构成了基础。

  • 视图函数:(也是一个分析名称,而不是 Python 实现名称。)此模块将创建Flask类的实例,即我们的应用程序。它将定义通过创建可以由移动应用程序或浏览器显示的响应来处理请求的函数。这些函数公开了模型的功能,并不涉及模型本身的深度和复杂性;在案例研究中,我们不会关注这个组件。

  • 测试: 这将为模型和视图函数提供单元测试。虽然测试对于确保软件可用性至关重要,但它们是第十三章的主题,即面向对象程序的测试

我们已经包括了依赖关系箭头,使用虚线。这些可以用 Python 特定的“导入”标签来注释,以帮助阐明各种包和模块之间的关系。

随着我们进入后面的章节进行设计,我们将扩展这个初始视图。在思考了需要构建的内容之后,我们现在可以通过绘制应用程序的物理视图来考虑它的部署。如上所述,开发和部署之间存在微妙的平衡。这两个视图通常一起构建。

物理视图

物理视图显示了软件将如何安装到物理硬件中。对于网络服务,我们经常谈论持续集成和持续部署CI/CD)管道。这意味着软件的更改作为单元进行测试,与现有应用程序集成,作为一个整体进行测试,然后部署给用户。

虽然通常假设是网站,但这也可以部署为命令行应用程序。它可能运行在本地计算机上。它也可能运行在云端的计算机上。另一个选择是围绕核心分类器构建一个网络应用程序。

下面的图显示了网络应用程序服务器的一个视图:

图描述自动生成

图 1.17:应用程序服务器图

此图显示了客户端和服务器节点作为安装了“组件”的三维“盒子”。我们已经确定了三个组件:

  • 运行客户端应用程序的应用程序客户端。此应用程序连接到分类器网络服务并发出 RESTful 请求。它可能是一个用 JavaScript 编写的网站,也可能是一个用 Kotlin 或 Swift 编写的移动应用程序。所有这些前端都有一个通用的HTTPS连接到我们的网络服务器。这个安全连接需要一些证书和加密密钥对的配置。

  • GUnicorn网络服务器。此服务器可以处理网络服务请求的许多细节,包括重要的 HTTPS 协议。有关详细信息,请参阅docs.gunicorn.org/en/stable/index.html

  • 我们的Classifier应用程序。从这个视角来看,复杂性已经被省略,整个Classifier包被简化为更大 Web 服务框架中的一个小型组件。这可以使用 Flask 框架来实现。

在这些组件中,客户的客户端应用不是开发分类器所做工作的组成部分。我们包含这部分内容是为了说明上下文,但我们实际上并不打算去构建它。

我们使用虚线依赖箭头来表示我们的Classifier应用程序是来自 Web 服务器的依赖。GUnicorn将导入我们的 Web 服务器对象并使用它来响应请求。

现在我们已经概述了应用程序,我们可以考虑编写一些代码。在编写过程中,保持图表更新是有帮助的。有时,它们可以作为代码荒野中的便捷路线图。

结论

在这个案例研究中有几个关键概念:

  1. 软件应用可能相当复杂。有五种视图来描述用户、数据、处理、要构建的组件以及目标物理实现。

  2. 错误将会发生。这个概述中存在一些空白。向前推进部分解决方案是很重要的。Python 的一个优点是能够快速构建软件,这意味着我们并没有深入投资于坏主意。我们可以(并且应该)快速移除和替换代码。

  3. 对扩展持开放态度。在我们实现这个之后,我们会看到设置k参数是一个繁琐的练习。一个重要的下一步是使用网格搜索调优算法来自动化调优。通常,先把这些事情放在一边,先实现一个能工作的东西,然后再扩展工作软件以添加这个有用的功能。

  4. 尽量为每个类分配清晰的职责。这已经相当成功,但有些职责是模糊的或完全省略了。随着我们将这个初步分析扩展到实现细节,我们将重新审视这个问题。

在后面的章节中,我们将更深入地探讨这些各种主题。因为我们的意图是展示现实的工作,这将会涉及重做。一些设计决策可能会随着读者接触到越来越多的 Python 面向对象编程技术而进行修订。此外,解决方案的一些部分将随着我们对设计选择和问题的理解而发展。基于所学知识的重做是敏捷开发方法的一个结果。

回忆

本章的一些关键点:

  • 在面向对象的环境中分析问题需求

  • 如何绘制统一建模语言UML)图来传达系统的工作方式

  • 使用正确的术语和行话讨论面向对象系统

  • 理解类、对象、属性和行为之间的区别

  • 一些面向对象的设计技术比其他技术使用得更多。在我们的案例研究示例中,我们关注了以下几个:

    • 将特性封装到类中

    • 继承以扩展类的新功能

    • 从组件对象构建类的组合

练习

这是一本实用的书。因此,我们不会分配一大堆虚假的面向对象分析问题来为你创建要分析和设计的设计。相反,我们希望给你一些你可以应用到自己的项目中的想法。如果你有面向对象的经验,你不需要在这个章节上投入太多精力。然而,如果你已经使用 Python 一段时间,但从未真正关心过所有这些类的东西,它们仍然是有用的心理练习。

首先,思考一下你最近完成的一个编程项目。确定设计中最突出的对象。尽量想出这个对象尽可能多的属性。它有下面的属性吗:颜色?重量?大小?利润?成本?名称?ID 号码?价格?风格?

思考属性类型。它们是原始类型还是类?其中一些属性实际上是伪装成行为的行为?有时,看起来像是数据的东西实际上是从对象上的其他数据计算出来的,你可以使用一个方法来进行这些计算。对象还有哪些其他方法或行为?哪些对象调用了这些方法?它们与这个对象有什么样的关系?

现在,考虑一个即将到来的项目。项目是什么并不重要;它可能是一个有趣的空闲时间项目,或者是一个价值数百万美元的合同。它不必是一个完整的应用程序;它可能只是一个子系统。进行基本的面向对象分析。确定需求以及相互作用的对象。绘制一个展示该系统最高抽象级别的类图。确定主要相互作用的对象。确定次要支持对象。对一些最有趣的对象的属性和方法进行详细描述。将不同的对象带到不同的抽象级别。寻找可以使用继承或组合的地方。寻找应该避免继承的地方。

目标不是设计一个系统(尽管如果你有意愿并且有足够的时间,当然可以这样做)。目标是思考面向对象的设计。专注于你曾经工作过的项目,或者你未来打算工作的项目,这样可以使它变得真实。

最后,访问你最喜欢的搜索引擎,查找一些关于 UML 的教程。有很多,所以找到适合你学习偏好的一个。为之前识别出的对象绘制一些类图或序列图。不要过于纠结于记忆语法(毕竟,如果它很重要,你总是可以再次查找);只需感受一下这种语言。一些东西会留在你的脑海中,如果你能快速绘制出你下一次面向对象讨论的图表,这可以使沟通变得容易一些。

摘要

在本章中,我们快速浏览了面向对象范式的术语,重点关注面向对象设计。我们可以将不同的对象分为不同类别的分类,并通过类接口描述这些对象的属性和行为。抽象、封装和信息隐藏是高度相关的概念。对象之间存在许多不同类型的关系,包括关联、组合和继承。UML 语法对于娱乐和沟通非常有用。

在下一章中,我们将探讨如何在 Python 中实现类和方法。

第二章:Python 中的对象

我们手头有一个设计方案,并准备好将其转化为一个可工作的程序!当然,事情通常不会这么顺利。本书中我们将看到关于良好软件设计的例子和提示,但我们的重点是面向对象编程。因此,让我们来看看允许我们创建面向对象软件的 Python 语法。

完成本章后,我们将了解以下内容:

  • Python 的类型提示

  • 在 Python 中创建类和实例化对象

  • 将课程组织成包和模块

  • 如何建议人们不要破坏对象的数据,从而破坏内部状态

  • 使用来自 Python 包索引 PyPI 的第三方包

本章将继续我们的案例研究,转向一些类的设计。

介绍类型提示

在我们深入探讨创建类之前,我们需要稍微谈谈什么是类以及我们如何确保正确地使用它。这里的中心思想是,Python 中的万物皆对象。

当我们编写像 "Hello, world!"42 这样的字面值时,我们实际上是在创建内置类的实例。我们可以启动交互式 Python,并使用定义这些对象属性的类上的内置 type() 函数:

>>> type("Hello, world!")
<class 'str'>
>>> type(42)
<class 'int'> 

面向对象编程的目的是通过对象的交互来解决一个问题。当我们编写 6*7 时,这两个对象的乘法操作是由内置的 int 类的方法处理的。对于更复杂的行为,我们通常会需要编写独特的新类。

这里是 Python 对象工作原理的前两条核心规则:

  • Python 中的一切都是对象

  • 每个对象都通过至少属于一个类来定义

这些规则有许多有趣的后果。我们使用class语句编写的类定义创建了一个新的type类对象。当我们创建一个类的实例时,类对象将用于创建和初始化实例对象。

类和类型之间的区别是什么?class语句让我们能够定义新的类型。因为class语句是我们所使用的,所以我们将它们在整个文本中称为类。参见 Eli Bendersky 的Python 对象、类型、类和实例 - 术语表eli.thegreenplace.net/2012/03/30/python-objects-types-classes-and-instances-a-glossary中的这段有用引言:

“类”和“类型”这两个术语是两个指代同一概念的名称的例子。

我们将遵循常规用法,并将这些注释称为类型提示

这里还有另一个重要的规则:

  • 变量是对一个对象的引用。想象一下一张黄色的便利贴,上面写着名字,贴在某个东西上。

这看起来似乎并不惊天动地,但实际上相当酷。这意味着类型信息——一个对象是什么——是由与该对象关联的类(们)定义的。这种类型信息以任何方式都不附加到变量上。这导致以下代码虽然有效但非常令人困惑的 Python 代码:

>>> a_string_variable = "Hello, world!"
>>> type(a_string_variable)
<class 'str'>
>>> a_string_variable = 42
>>> type(a_string_variable)
<class 'int'> 

我们使用内置类str创建了一个对象,并将一个长名称a_string_variable分配给该对象。然后,我们使用另一个内置类int创建了一个对象,并将相同的名称分配给这个对象。(之前的字符串对象不再有任何引用,因此不再存在。)

这里展示了两个步骤,并排显示,说明了变量是如何从一个对象移动到另一个对象的:

图表描述自动生成

图 2.1:变量名和对象

各种属性都是对象的一部分,而不是变量的。当我们使用 type() 函数检查变量的类型时,我们看到的是变量当前引用的对象的类型。变量本身并没有类型;它不过是一个名字。同样地,请求一个变量的 id() 会显示变量所引用的对象的 ID。因此,如果我们把名字分配给一个整数对象,那么名字 a_string_variable 就有点误导了。

类型检查

让我们将对象与类型之间的关系再向前推进一步,并看看这些规则的一些更多后果。下面是一个函数定义:

>>> def odd(n):
...     return n % 2 != 0
>>> odd(3)
True
>>> odd(4)
False 

此函数对一个参数变量n进行少量计算。它计算除法后的余数,即模数。如果我们用 2 除以一个奇数,我们会剩下 1。如果我们用 2 除以一个偶数,我们会剩下 0。此函数对所有奇数返回一个真值。

当我们未能提供一个数字时会发生什么?嗯,让我们试一试看看(这是学习 Python 的一种常见方法!)在交互式提示符中输入代码,我们会得到类似以下的内容:

>>> odd("Hello, world!")
Traceback (most recent call last):
  File "<doctestexamples.md[9]>", line 1, in <module>
odd("Hello, world!")
  File "<doctestexamples.md[6]>", line 2, in odd
    return n % 2 != 0
TypeError: not all arguments converted during string formatting 

这是由 Python 的超级灵活规则产生的一个重要后果:没有任何东西阻止我们做些愚蠢的事情,这可能会引发异常。这是一个重要的提示:

Python 并不允许我们尝试使用对象不存在的函数。

在我们的例子中,str 类提供的 % 操作符与 int 类提供的 % 操作符工作方式不同,会引发异常。对于字符串,% 操作符并不常用,但它可以进行插值:"a=%d" % 113 计算出的字符串是 'a=113';如果左侧没有像 %d 这样的格式指定符,则异常是 TypeError。对于整数,它是除法的余数:355 % 113 返回一个整数,16

这种灵活性反映了一种明确的权衡,即为了方便使用而牺牲了复杂地预防潜在问题的能力。这使得一个人在使用变量名时几乎无需动用太多心智资源。

Python 的内部运算符会检查操作数是否符合运算符的要求。然而,我们编写的函数定义中并不包括任何运行时类型检查。我们也不想添加运行时类型检查的代码。相反,我们使用工具作为测试的一部分来检查代码。我们可以提供称为 类型提示 的注释,并使用工具检查我们的代码在类型提示之间的一致性。

首先,我们将查看注释。在几种情况下,我们可以在变量名后面跟一个冒号 :, 然后是一个类型名。我们可以在函数(和方法)的参数中这样做。我们也可以在赋值语句中这样做。此外,我们还可以在函数(或类方法)定义中添加 -> 语法来解释预期的返回类型。

这就是类型提示的样式:

>>> def odd(n: int) -> bool:
...     return n % 2 != 0 

我们为我们的odd()小函数定义添加了两个类型提示。我们指定了n参数的值应该是整数。我们还指定了结果将是布尔类型两个值之一。

虽然这些提示会消耗一些存储空间,但它们对运行时没有影响。Python 会礼貌地忽略这些提示;这意味着它们是可选的。然而,阅读你代码的人却会非常高兴看到它们。它们是向读者传达你意图的绝佳方式。在你学习时可以省略它们,但当你回头扩展之前所写的内容时,你会非常喜爱它们。

mypy 工具通常用于检查提示的一致性。它不是 Python 的内置工具,需要单独下载和安装。我们将在本章的 第三方库 部分后面讨论虚拟环境和工具的安装。目前,如果您使用 conda 工具,可以使用 python -m pip install mypyconda install mypy

假设我们有一个文件,名为 bad_hints.py,位于 src 目录中,其中包含这两个函数和一些调用 main() 函数的代码行:

def odd(n: int) -> bool:
    return n % 2 != 0
def main():
    print(odd("Hello, world!"))
if __name__ == "__main__":
    main() 

当我们在操作系统的终端提示符下运行mypy命令时:

% mypy –strict src/bad_hints.py 

mypy 工具将发现一系列潜在问题,包括至少以下这些:

src/bad_hints.py:12: error: Function is missing a return type annotation
src/bad_hints.py:12: note: Use "-> None" if function does not return a value
src/bad_hints.py:13: error: Argument 1 to "odd" has incompatible type "str"; expected "int" 

def main():语句位于我们示例的第 12 行,因为我们的文件中有一堆未在上文显示的注释。对于你的版本,错误可能出现在第 1 行。以下是两个问题:

  • main() 函数没有返回类型;mypy 建议包含 -> None 以使返回值的缺失完全明确。

  • 更重要的是第 13 行:代码将尝试使用一个str值来评估odd()函数。这与odd()函数的类型提示不匹配,并表明了另一个可能存在的错误。

本书中的大多数示例都将包含类型提示。我们认为它们总是有帮助的,尤其是在教学环境中,即使它们是可选的。因为大多数 Python 代码在类型方面是通用的,所以有一些情况下,Python 的行为难以通过简洁、富有表现力的提示来描述。在这本书中,我们将避免这些边缘情况。

Python 增强提案(PEP)585 涵盖了某些新的语言特性,以使类型提示变得更加简单。我们使用mypy版本 0.812 来测试本书中的所有示例。任何较旧版本都将遇到一些较新语法和注解技术的兼容性问题。

现在我们已经讨论了如何使用类型提示来描述参数和属性,接下来让我们实际构建一些类。

创建 Python 类

我们不需要写很多 Python 代码就能意识到 Python 是一种非常简洁的语言。当我们想要做某事时,我们只需去做,无需设置一大堆先决代码。你可能已经看到的 Python 中无处不在的hello world,仅有一行代码。

同样地,Python 3 中最简单的类看起来是这样的:

class MyFirstClass: 
    pass 

这就是我们的第一个面向对象程序!类的定义从class关键字开始。之后是一个名称(由我们选择),用于标识这个类,并以冒号结束。

类名必须遵循标准的 Python 变量命名规则(它必须以字母或下划线开头,并且只能由字母、下划线或数字组成)。此外,Python 风格指南(在网络上搜索 PEP 8)建议使用 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> 

这段代码从新类中实例化了两个对象,将对象变量命名为ab。创建类的实例只需输入类名,然后跟上一对括号。这看起来很像函数调用;调用一个类将创建一个新的对象。当打印时,这两个对象会告诉我们它们属于哪个类以及它们居住在哪个内存地址。在 Python 代码中,内存地址并不常用,但在这里,它们展示了涉及的是两个不同的对象。

我们可以通过使用is运算符来看到它们是不同的对象:

>>> a is b
False 

这有助于在我们创建了一堆对象并为这些对象分配了不同的变量名时减少混淆。

添加属性

现在,我们有一个基本的类,但它相当无用。它不包含任何数据,也不做任何事情。我们该如何给一个特定的对象分配属性?

事实上,在类定义中我们不需要做任何特殊操作就能添加属性。我们可以使用点符号在实例化的对象上设置任意属性。以下是一个示例:

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类,没有数据或行为。然后,它创建了该类的两个实例,并将每个实例的xy坐标分配给它们,以标识二维空间中的一个点。要为一个对象的属性赋值,我们只需使用<object>.<attribute> = <value>语法。这有时被称为点符号。值可以是任何东西:Python 原语、内置数据类型或另一个对象。甚至可以是一个函数或另一个类!

创建这样的属性会让 mypy 工具感到困惑。在 Point 类定义中包含提示信息并没有简单的方法。我们可以在赋值语句中包含提示,例如:p1.x: float = 5。一般来说,在类型提示和属性方面有一个好得多的方法,我们将在本章后面的 初始化对象 部分进行探讨。不过,首先,我们将向我们的类定义中添加行为。

让它做些事情

现在,拥有具有属性的实体是很好的,但面向对象编程真正关注的是实体之间的交互。我们感兴趣的是调用那些导致属性发生变化的行为。我们有了数据;现在是为我们的类添加行为的时候了。

让我们在Point类上模拟几个动作。我们可以从一个名为reset的方法开始,该方法将点移动到原点(原点是xy都为零的位置)。这是一个很好的入门动作,因为它不需要任何参数:

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 代码,正如方法所适合的那样。

我们在reset()方法中省略了类型提示,因为这并不是最常用于提示的地方。我们将在初始化对象部分探讨最佳提示位置。首先,我们将更详细地看看这些实例变量,以及self变量是如何工作的。

与自己对话

在类外部的类方法和函数在语法上的一个区别是,方法有一个必需的参数。这个参数传统上被命名为 self;我从未见过有 Python 程序员为这个变量使用其他名称(惯例是非常强大的东西)。然而,技术上并没有阻止你将其命名为 this 或甚至 Martha,但最好还是承认 Python 社区在 PEP 8 中编码的社会压力,并坚持使用 self

方法中的self参数是对正在调用该方法的对象的引用。该对象是类的实例,这有时被称为实例变量。

我们可以通过这个变量访问那个对象的属性和方法。这正是我们在reset方法内部设置self对象的xy属性时所做的事情。

在这次讨论中请注意区分对象的区别。我们可以把方法看作是附加到类上的一个函数。self参数指的是类的特定实例。当你对两个不同的对象调用方法时,你实际上是两次调用相同的方法,但传递了两个不同的对象作为self参数。

注意到当我们调用p.reset()方法时,我们并没有显式地将self参数传递给它。Python 会自动为我们处理这部分。它知道我们正在调用p对象上的方法,因此它会自动将那个对象p传递给Point类的方法。

对于一些人来说,可以将一种方法想象成一个恰好是某个类一部分的函数。我们不必在对象上调用该方法,而可以像在类中定义的那样调用该函数,明确地将我们的对象作为self参数传递:

>>> p = Point() 
>>> Point.reset(p) 
>>> print(p.x, p.y) 

输出与上一个示例相同,因为内部发生的是完全相同的过程。这并不是一个好的编程实践,但它可以帮助你巩固对self参数的理解。

如果我们在类定义中忘记包含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: float, y: float) -> None:
        self.x = x
        self.y = y
    def reset(self) -> None:
        self.move(0, 0)
    def calculate_distance(self, other: "Point") -> float:
        return math.hypot(self.x - other.x, self.y - other.y) 

我们定义了一个具有两个属性xy以及三个独立方法move()reset()calculate_distance()的类。

move() 方法接受两个参数,xy,并将这些值设置在 self 对象上。reset() 方法调用 move() 方法,因为重置实际上是将对象移动到特定的已知位置。

calculate_distance() 方法计算两点之间的欧几里得距离。(还有许多其他看待距离的方法。在 第三章当物体相似时 的案例研究中,我们将探讨一些替代方案。)目前,我们希望你能理解数学原理。定义是 图片,即 math.hypot() 函数。在 Python 中我们使用 self.x,但数学家们通常更喜欢写成 图片

这里是一个使用此类定义的示例。这展示了如何带参数调用一个方法:将参数放在括号内,并使用相同的点符号来访问实例中的方法名。我们只是随机选取了一些位置来测试这些方法。测试代码会调用每个方法,并将结果打印到控制台:

>>> point1 = Point()
>>> point2 = Point()
>>> point1.reset()
>>> point2.move(5, 0)
>>> print(point2.calculate_distance(point1))
5.0
>>> assert point2.calculate_distance(point1) == point1.calculate_distance(
...    point2
... )
>>> point1.move(3, 4)
>>> print(point1.calculate_distance(point2))
4.47213595499958
>>> print(point1.calculate_distance(point1))
0.0 

assert语句是一个出色的测试工具;如果assert后面的表达式评估为False(或零、空或None),程序将退出。在这种情况下,我们使用它来确保无论哪个点调用另一个点的calculate_distance()方法,距离都是相同的。在第十三章面向对象程序的测试中,我们将看到更多assert的使用,我们将编写更严格的测试。

初始化对象

如果我们没有明确设置我们的Point对象的xy位置,无论是使用move方法还是直接访问它们,我们将得到一个没有实际位置的损坏的Point对象。当我们尝试访问它时会发生什么?

好吧,让我们试一试看看。试一试看看是 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(),或者如果我们能强制用户在创建对象时告诉我们那些位置应该是什么,那会更好。

有趣的是,mypy 无法确定 y 是否应该是 Point 对象的属性。属性按定义是动态的,因此不存在一个简单的列表是类定义的一部分。然而,Python 有一些广泛遵循的约定,可以帮助命名预期的属性集。

大多数面向对象的编程语言都有构造函数的概念,这是一种在对象创建时创建和初始化对象的特殊方法。Python 稍有不同;它有一个构造函数和一个初始化器。构造函数方法 __new__() 通常很少使用,除非你在做非常特殊的事情。因此,我们将从更常见的初始化方法 __init__() 开始我们的讨论。

Python 的初始化方法与任何其他方法相同,只是它有一个特殊的名称,__init__。前后的双下划线表示这是一个特殊的方法,Python 解释器会将其视为特殊情况。

永远不要用前后双下划线命名你自己的方法。这可能在今天的 Python 中没有任何意义,但总有这样的可能性,即 Python 的设计者将来会添加一个具有特殊目的的函数,其名称与你的方法相同。当他们这样做的时候,你的代码将会出错。

让我们在Point类中添加一个初始化函数,该函数要求用户在实例化Point对象时提供xy坐标:

class Point:
    def __init__(self, x: float, y: float) -> None:
        self.move(x, y)
    def move(self, x: float, y: float) -> None:
        self.x = x
        self.y = y
    def reset(self) -> None:
        self.move(0, 0)
    def calculate_distance(self, other: "Point") -> float:
        return math.hypot(self.x - other.x, self.y - other.y) 

构建一个 Point 实例现在看起来是这样的:

point = Point(3, 5) 
print(point.x, point.y) 

现在,我们的Point对象绝不能没有xy坐标!如果我们尝试构建一个没有包含适当初始化参数的Point实例,它将失败并显示一个类似于我们之前忘记在方法定义中包含self参数时收到的not enough arguments错误。

大多数情况下,我们将初始化语句放在一个__init__()函数中。确保所有属性在__init__()方法中初始化非常重要。这样做有助于mypy工具,因为它在一个明显的地方提供了所有属性。这也帮助了阅读你代码的人;它节省了他们阅读整个应用程序以找到在类定义外部设置的神秘属性的时间。

虽然它们是可选的,但通常在方法参数和结果值上包含类型注解是有帮助的。在每个参数名称之后,我们都包括了每个值的预期类型。在定义的末尾,我们包括了两个字符的->运算符和由方法返回的类型。

类型提示和默认值

如我们之前多次提到的,提示(hints)是可选的。它们在运行时不会做任何事情。然而,有一些工具可以检查提示以确保一致性。mypy工具被广泛用于检查类型提示。

如果我们不想提供所需的两个参数,可以使用 Python 函数提供默认参数时使用的相同语法。关键字参数语法在每个变量名后附加一个等号。如果调用对象没有提供此参数,则使用默认参数。这些变量仍然可用于函数,但它们将具有在参数列表中指定的值。以下是一个示例:

class Point:
    def __init__(self, x: float = 0, y: float = 0) -> None:
        self.move(x, y) 

单个参数的定义可能会变得很长,从而导致代码行数非常多。在一些示例中,你会看到这条单独的逻辑代码行被扩展成多个物理行。这依赖于 Python 将物理行组合起来匹配括号()的方式。当行变得很长时,我们可能会这样写:

class Point:
    def __init__(
        self, 
        x: float = 0, 
        y: float = 0
    ) -> None:
        self.move(x, y) 

这种样式并不常用,但它有效,并且可以使行距更短,更容易阅读。

类型提示和默认值非常方便,但当我们遇到新的需求时,我们还可以做更多来提供一个易于使用且易于扩展的类。我们将添加文档形式的 docstrings。

使用文档字符串进行自我解释

Python 可以是一种极其易于阅读的编程语言;有些人可能会说它是自文档化的。然而,在进行面向对象编程时,编写清晰总结每个对象和方法功能的 API 文档非常重要。保持文档更新是困难的;最好的方法是将文档直接写入我们的代码中。

Python 通过使用 docstrings 来支持这一点。每个类、函数或方法头都可以有一个标准的 Python 字符串作为定义内部的第一行缩进(以冒号结束的行)。

Docstrings 是 Python 中用单引号 (') 或双引号 (") 包围的字符串。通常,文档字符串相当长,跨越多行(风格指南建议行长度不应超过 80 个字符),可以格式化为多行字符串,用匹配的三重单引号 (''') 或三重双引号 (""") 包围。

一个文档字符串应该清晰地简洁地总结所描述的类或方法的目的。它应该解释任何使用不明显的参数,并且也是包含 API 使用短示例的好地方。还应注明 API 用户应该注意的任何警告或问题。

在文档字符串中包含一个具体的例子是其中最好的做法之一。像doctest这样的工具可以定位并确认这些例子是否正确。本书中的所有例子都经过 doctest 工具的检查。

为了说明文档字符串的使用,我们将以我们完全文档化的Point类结束本节:

class Point:
    """
    Represents a point in two-dimensional geometric coordinates
    >>> p_0 = Point()
    >>> p_1 = Point(3, 4)
    >>> p_0.calculate_distance(p_1)
    5.0
    """
    def __init__(self, x: float = 0, y: float = 0) -> None:
        """
        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.
        :param x: float x-coordinate
        :param y: float x-coordinate
        """
        self.move(x, y)
    def move(self, x: float, y: float) -> None:
        """
        Move the point to a new location in 2D space.
        :param x: float x-coordinate
        :param y: float x-coordinate
        """
        self.x = x
        self.y = y
    def reset(self) -> None:
        """
        Reset the point back to the geometric origin: 0, 0
        """
        self.move(0, 0)
    def calculate_distance(self, other: "Point") -> float:
        """
        Calculate the Euclidean distance from this point 
        to a second point passed as a parameter.
        :param other: Point instance
        :return: float distance
        """
        return math.hypot(self.x - other.x, self.y - other.y) 

尝试在交互式解释器中输入或加载(记住,是python -i point.py)此文件。然后,在 Python 提示符下输入help(Point)<enter>

你应该看到格式良好的类文档,如下所示输出:

Help on class Point in module point_2:
class Point(builtins.object)
 |  Point(x: float = 0, y: float = 0) -> None
 |  
 |  Represents a point in two-dimensional geometric coordinates
 |  
 |  >>> p_0 = Point()
 |  >>> p_1 = Point(3, 4)
 |  >>> p_0.calculate_distance(p_1)
 |  5.0
 |  
 |  Methods defined here:
 |  
 |  __init__(self, x: float = 0, y: float = 0) -> None
 |      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.
 |      
 |      :param x: float x-coordinate
 |      :param y: float x-coordinate
 |  
 |  calculate_distance(self, other: 'Point') -> float
 |      Calculate the Euclidean distance from this point
 |      to a second point passed as a parameter.
 |      
 |      :param other: Point instance
 |      :return: float distance
 |  
 |  move(self, x: float, y: float) -> None
 |      Move the point to a new location in 2D space.
 |      
 |      :param x: float x-coordinate
 |      :param y: float x-coordinate
 |  
 |  reset(self) -> None
 |      Reset the point back to the geometric origin: 0, 0
 |  
 |  ----------------------------------------------------------------
 |  Data descriptors defined here:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined) 

我们的文档不仅与内置函数的文档一样精致,而且我们可以通过运行python -m doctest point_2.py来确认文档字符串中显示的示例。

此外,我们还可以运行 mypy 来检查类型提示,同样。使用 mypy –-strict src/*.py 来检查 src 文件夹中的所有文件。如果没有问题,mypy 应用程序不会产生任何输出。(记住,mypy 不是标准安装的一部分,所以你需要将其添加。请参阅前言以获取有关需要安装的额外包的信息。)

模块和包

现在我们已经知道了如何创建类和实例化对象。在你开始失去对这些类的跟踪之前,你不需要编写太多的类(或者更不用说非面向对象的代码了)。对于小型程序,我们通常将所有类放入一个文件中,并在文件末尾添加一个小脚本以启动它们之间的交互。然而,随着我们的项目增长,在定义的众多类中找到需要编辑的那个类可能会变得困难。这就是模块发挥作用的地方。模块就是 Python 文件,没有更多。我们小型程序中的单个文件就是一个模块。两个 Python 文件就是两个模块。如果我们有同一文件夹中的两个文件,我们就可以从一个模块中加载一个类,用于另一个模块。

Python 模块的名称是文件的基础名;即不带.py后缀的名称。一个名为model.py的文件是一个名为model的模块。模块文件通过搜索包括本地目录和已安装包的路径来找到。

import 语句用于导入模块或从模块中导入特定的类或函数。我们已经在上一节中通过我们的 Point 类看到了这个例子。我们使用 import 语句来获取 Python 的内置 math 模块,并在 distance 计算中使用其 hypot() 函数。让我们从一个新的例子开始。

如果我们正在构建一个电子商务系统,我们很可能会在数据库中存储大量数据。我们可以将所有与数据库访问相关的类和函数放入一个单独的文件中(我们可以称它为有意义的名称:database.py)。然后,我们的其他模块(例如,客户模型、产品信息和库存)可以导入database模块中的类,以便访问数据库。

让我们从名为 database 的模块开始。它是一个文件,database.py,包含一个名为 Database 的类。另一个名为 products 的模块负责与产品相关的查询。products 模块中的类需要从 database 模块实例化 Database 类,这样它们才能在数据库中的产品表上执行查询。

import 语句的语法有多种变体,可以用来访问 Database 类。一种变体是将整个模块导入:

>>> import database
>>> db = database.Database("path/to/data") 

此版本导入database模块,创建一个database命名空间。database模块中的任何类或函数都可以使用database.<something>的表示法进行访问。

或者,我们可以使用from...import语法只导入所需的那个类:

>>> from database import Database
>>> db = Database("path/to/data") 

这个版本只从database模块导入了Database类。当我们从几个模块中导入少量项目时,这可以是一个有用的简化,以避免使用较长的完全限定名称,如database.Database。当我们从多个不同的模块中导入大量项目时,如果我们省略了限定词,这可能会成为混淆的潜在来源。

如果由于某种原因,products 已经有一个名为 Database 的类,而我们又不想这两个名称混淆,我们可以在 products 模块内部使用时重命名该类:

>>> from database import Database as DB
>>> db = DB("path/to/data") 

我们也可以在一个语句中导入多个项目。如果我们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 * 语法,找到那个类所在位置的时间会大大增加。代码维护变得如同噩梦一般。

  • 如果存在冲突的名称,我们就注定要失败了。假设我们有两个模块,它们都提供了一个名为Database的类。使用from module_1 import *from module_2 import *意味着第二个导入语句会覆盖第一个导入创建的Database名称。如果我们使用import module_1import module_2,我们会使用模块名称作为限定符来区分module_1.Databasemodule_2.Database

  • 此外,大多数代码编辑器都能够提供额外的功能,例如可靠的代码补全、跳转到类定义的能力,或者内联文档,如果使用正常的导入语句的话。import * 语法可能会妨碍它们可靠地执行这些功能。

  • 最后,使用 import * 语法可能会将意外的对象引入我们的局部命名空间。当然,它将导入从导入的模块中定义的所有类和函数,但除非模块中提供了特殊的 __all__ 列表,否则这个 import 也会导入任何自身被导入到该文件中的类或模块!

在模块中使用的每个名称都应该来自一个明确指定的位置,无论是定义在该模块中,还是明确从另一个模块导入。不应该存在看似凭空出现的魔法变量。我们应当始终能够立即识别出我们当前命名空间中名称的来源。我们承诺,如果你使用这种邪恶的语法,你总有一天会经历极其令人沮丧的时刻,比如“这个类究竟是从哪里冒出来的?”

为了娱乐,试着在你的交互式解释器中输入import this。它会打印一首优美的诗(包含一些内部玩笑),总结了一些 Python 程序员倾向于使用的惯用语。具体到这次讨论,请注意这句“明确优于隐晦。”明确地将名称导入你的命名空间,比隐式的from module import *语法使你的代码更容易导航。

组织模块

随着项目逐渐发展成为一个包含越来越多模块的集合,我们可能会发现我们想要添加另一个抽象层级,在我们的模块层级上某种嵌套的层次结构。然而,我们不能在模块中嵌套模块;毕竟,一个文件只能包含一个文件,而模块只是文件。

然而,文件可以放入文件夹中,模块也是如此。一个是一个文件夹中模块的集合。包的名称就是文件夹的名称。我们需要告诉 Python 一个文件夹是一个包,以便将其与其他目录中的文件夹区分开来。为此,在文件夹中放置一个名为__init__.py的(通常是空的)文件。如果我们忘记了这个文件,我们就无法从这个文件夹中导入模块。

让我们把我们的模块放在工作文件夹内的ecommerce包中,这个包也将包含一个main.py文件以启动程序。另外,我们还在ecommerce包内添加了一个包,用于各种支付选项。

在创建深度嵌套的包时,我们需要保持一定的谨慎。Python 社区的一般建议是“扁平优于嵌套”。在这个例子中,我们需要创建一个嵌套包,因为所有各种支付替代方案都有一些共同特性。

文件夹层次结构将如下所示,根目录位于项目文件夹中的一个目录下,通常命名为 src

src/
 +-- main.py
 +-- ecommerce/
     +-- __init__.py
     +-- database.py
     +-- products.py
     +-- payments/
     |   +-- __init__.py
     |   +-- common.py
     |   +-- square.py
     |   +-- stripe.py
     +-- contact/
         +-- __init__.py
         +-- email.py 

src 目录将作为整体项目目录的一部分。除了 src,项目通常还会有名为 docstests 等的目录。项目父目录通常还会包含为 mypy 等工具的配置文件。我们将在 第十三章面向对象程序的测试 中回到这一点。

当在包之间导入模块或类时,我们必须谨慎对待我们包的结构。在 Python 3 中,有两种导入模块的方式:绝对导入和相对导入。我们将分别探讨每一种。

绝对导入

绝对导入指定了我们想要导入的模块、函数或类的完整路径。如果我们需要访问products模块内的Product类,我们可以使用以下任何一种语法来进行绝对导入:

>>> import ecommerce.products
>>> product = ecommerce.products.Product("name1") 

或者,我们可以在包内从模块中具体导入一个类定义:

>>> from ecommerce.products import Product 
>>> product = Product("name2") 

或者,我们可以从包含的包中导入整个模块:

>>> from ecommerce import products 
>>> product = products.Product("name3") 

import语句使用点操作符来分隔包或模块。包是一个包含模块名称的命名空间,就像对象是一个包含属性名称的命名空间一样。

这些语句可以在任何模块中工作。我们可以在main.py文件中的database模块,或者两个支付模块中的任何一个中使用这种语法实例化一个Product类。实际上,假设这些包对 Python 可用,它将能够导入它们。例如,这些包也可以安装到 Python 的site-packages文件夹中,或者可以将PYTHONPATH环境变量设置为告诉 Python 搜索哪些文件夹以查找它将要导入的包和模块。

在这些选择中,我们该选择哪种语法?这取决于你的受众和当前的应用。如果我们想使用products模块中的几十个类和函数,我们通常会使用from ecommerce import products语法来导入模块名称,然后使用products.Product来访问单个类。如果我们只需要products模块中的一个或两个类,我们可以直接使用from ecommerce.products import Product语法来导入。重要的是要编写使代码对他人来说最容易阅读和扩展的内容。

相对导入

当在深度嵌套的包内部处理相关模块时,指定完整路径似乎有些多余;我们知道我们的父模块叫什么名字。这就是相对导入发挥作用的地方。相对导入根据相对于当前模块的位置来识别类、函数或模块。它们仅在模块文件中才有意义,而且更进一步,它们仅在存在复杂包结构的地方才有意义。

例如,如果我们正在products模块中工作,并且想要从相邻的database模块导入Database类,我们可以使用相对导入:

from .database import Database 

database前的时期表示使用当前包内的数据库模块。在这种情况下,当前包是我们正在编辑的products.py文件所在的包,即ecommerce包。

如果我们要编辑位于ecommerce.payments包内的stripe模块,例如,我们可能希望使用父包内的数据库包。这可以通过使用两个点轻松实现,如下所示:

from ..database import Database 

我们可以使用更多的点来进入更高级别的层次,但到了某个阶段,我们必须承认我们拥有太多的包。当然,我们也可以沿着一边向下,然后再从另一边向上。如果我们想将send_mail函数导入到我们的payments.stripe模块中,以下将是从ecommerce.contact包中导入的有效导入,其中包含一个email模块:

from ..contact.email import send_mail 

这个导入使用了两个点来表示payments.stripe包的父包,然后使用正常的package.module语法向下进入contact包,以命名email模块。

相对导入并不像看起来那么有用。如前所述,Python 之禅(当你运行import this时可以阅读)建议“扁平优于嵌套”。Python 的标准库相对扁平,包含的包很少,嵌套的包更少。如果你熟悉 Java,包会深层次嵌套,这是 Python 社区喜欢避免的。相对导入是为了解决模块名称在包之间重复使用时的特定问题。在某些情况下它们可能会有所帮助。需要超过两个点来定位共同的父父包表明设计应该被扁平化。

作为整体的包

我们可以导入看起来直接来自一个包的代码,而不是包内部的模块。正如我们将看到的,这里确实涉及一个模块,但它有一个特殊的名称,因此是隐藏的。在这个例子中,我们有一个名为ecommerce的包,包含两个模块文件,分别命名为database.pyproducts.pydatabase模块包含一个db变量,它被许多地方访问。如果我们可以将其导入为from ecommerce import db而不是from ecommerce.database import db,那岂不是更方便?

记得那个定义目录为包的__init__.py文件吗?这个文件可以包含我们喜欢的任何变量或类声明,它们将作为包的一部分可用。在我们的例子中,如果ecommerce/__init__.py文件包含以下行:

from .database import db 

我们可以随后从main.py或任何其他文件中通过以下导入访问db属性:

from ecommerce import db 

ecommerce/__init__.py文件想象成ecommerce.py文件可能会有所帮助。它让我们可以将ecommerce包视为具有模块协议和包协议。如果你将所有代码放在一个模块中,后来又决定将其拆分成模块包,这也会很有用。新包的__init__.py文件仍然可以是使用它的其他模块的主要接触点,但代码可以在内部组织成几个不同的模块或子包。

我们建议不要在__init__.py文件中放置太多代码。程序员不会期望在这个文件中发生实际逻辑,而且就像from x import *一样,如果他们在寻找特定代码的声明却找不到,这可能会让他们感到困惑,直到他们检查__init__.py文件。

在总体查看模块之后,让我们深入探讨一个模块内部应该包含的内容。这些规则是灵活的(与其他语言不同)。如果你熟悉 Java,你会看到 Python 给你一些自由,以有意义和富有信息性的方式打包事物。

将我们的代码组织成模块

Python 模块是一个重要的焦点。每个应用程序或网络服务至少有一个模块。即使是看似“简单”的 Python 脚本也是一个模块。在任何一个模块内部,我们都可以指定变量、类或函数。它们可以是一种方便的方式来存储全局状态,同时避免命名空间冲突。例如,我们一直在将Database类导入到各个模块中,然后实例化它,但可能更合理的是,从database模块全局地只提供一个database对象。database模块可能看起来像这样:

class Database:
    """The Database Implementation"""
    def __init__(self, connection: Optional[str] = None) -> None:
        """Create a connection to a database."""
        pass
database = Database("path/to/data") 

然后,我们可以使用我们讨论过的任何导入方法来访问数据库对象,例如:

from ecommerce.database import database 

前一个类的问题在于,当模块首次导入时就会立即创建数据库对象,这通常发生在程序启动时。这并不总是理想的,因为连接到数据库可能需要一段时间,从而减慢启动速度,或者数据库连接信息可能尚未可用,因为我们需要读取配置文件。我们可以通过调用一个initialize_database()函数来创建模块级别的变量,从而延迟创建数据库,直到实际需要时:

db: Optional[Database] = None
def initialize_database(connection: Optional[str] = None) -> None:
    global db
    db = Database(connection) 

Optional[Database] 类型提示向 mypy 工具表明这可能为 None,或者它可能包含 Database 类的实例。Optional 提示在 typing 模块中定义。这种提示在我们的应用程序的其他地方很有用,以确保我们确认 database 变量的值不是 None

global 关键字告诉 Python,initialize_database() 函数内部的数据库变量是模块级别的变量,位于函数外部。如果我们没有将变量指定为全局变量,Python 将会创建一个新的局部变量,该变量在函数退出时会被丢弃,从而不会改变模块级别的值。

我们需要做一项额外的修改。我们需要整体导入database模块。我们不能从模块内部导入db对象;它可能尚未初始化。我们需要确保在db具有有意义的值之前调用database.initialize_database()。如果我们想直接访问数据库对象,我们会使用database.db

常见的替代方案是一个返回当前数据库对象的函数。我们可以在需要访问数据库的任何地方导入这个函数:

def get_database(connection: Optional[str] = None) -> Database:
    global db
    if not db:
        db = Database(connection) 
    return db 

如这些示例所示,所有模块级别的代码都是在导入时立即执行的。classdef语句创建将在函数调用时执行的代码对象。这对于执行操作的脚本来说可能是一个棘手的问题,比如我们电子商务示例中的主脚本。有时,我们编写一个做些有用事情的程序,然后后来发现我们想要将那个模块中的函数或类导入到不同的程序中。然而,一旦我们导入它,模块级别的任何代码都会立即执行。如果我们不小心,我们可能会在实际上只想访问该模块内部的一些函数时运行第一个程序。

为了解决这个问题,我们应该始终将启动代码放在一个函数中(通常称为main())并且只有在我们知道我们正在以脚本方式运行该模块时才执行该函数,而不是当我们的代码被从不同的脚本导入时。我们可以通过在条件语句中保护main的调用来实现这一点,如下所示:

class Point:
    """
    Represents a point in two-dimensional geometric coordinates.
    """
    pass
def main() -> None:
    """
    Does the useful work.
    >>> main()
    p1.calculate_distance(p2)=5.0
    """
    p1 = Point()
    p2 = Point(3, 4)
    print(f"{p1.calculate_distance(p2)=}")
if __name__ == "__main__":
    main() 

Point 类(以及 main() 函数)可以放心地重复使用。我们可以导入此模块的内容,而无需担心任何意外的处理。然而,当我们将其作为主程序运行时,它将执行 main() 函数。

这之所以有效,是因为每个模块都有一个名为 __name__ 的特殊变量(记住,Python 使用双下划线表示特殊变量,例如类的 __init__ 方法),它在模块被导入时指定了模块的名称。当模块直接通过 python module.py 执行时,它永远不会被导入,因此 __name__ 被任意设置为字符串 "__main__"

将将所有脚本包裹在if __name__ == "__main__"测试中作为一个政策,以防万一你将来编写一个可能希望被其他代码导入的函数。

因此,方法属于类,类属于模块,模块属于包。这就是全部了吗?

实际上,不是这样的。这是在 Python 程序中事物典型的顺序,但并非唯一的布局。类可以在任何地方定义。它们通常在模块级别定义,但也可以在函数或方法内部定义,如下所示:

from typing import Optional
class Formatter:
    def format(self, string: str) -> str:
        pass
def format_string(string: str, formatter: Optional[Formatter] = None) -> str:
    """
    Format a string using the formatter object, which
    is expected to have a format() method that accepts
    a string.
    """
    **class DefaultFormatter(Formatter):**
        **"""Format a string in title case."""**
        **def format(self, string: str) -> str:**
            **return str(string).title()**
    if not formatter:
        formatter = DefaultFormatter()
    return formatter.format(string) 

我们定义了一个Formatter类作为抽象,来解释一个格式化类需要具备哪些功能。我们尚未使用抽象基类(abc)的定义(我们将在第六章抽象基类和运算符重载中详细探讨这些内容)。相反,我们为该方法提供了一个没有实际内容的空实现。它包含了一套完整的类型提示,以确保mypy有我们意图的正式定义。

format_string()函数中,我们创建了一个内部类,它是Formatter类的扩展。这正式化了我们的类在函数内部具有特定方法集的预期。这种在Formatter类的定义、formatter参数和DefaultFormatter类的具体定义之间的联系确保我们没有意外遗漏或添加任何内容。

我们可以像这样执行这个函数:

>>> hello_string = "hello world, how are you today?"
>>> print(f" input: {hello_string}")
 input: hello world, how are you today?
>>> print(f"output: {format_string(hello_string)}")
output: Hello World, How Are You Today? 

format_string 函数接受一个字符串和可选的 Formatter 对象,然后将格式化器应用于该字符串。如果没有提供 Formatter 实例,它将创建一个本地类作为自己的格式化器并实例化它。由于它是在函数的作用域内创建的,因此这个类不能从该函数外部访问。同样,函数也可以在其它函数内部定义;一般来说,任何 Python 语句都可以在任何时候执行。

这些内部类和函数偶尔用于一次性项目,这些项目在模块级别不需要或不应拥有自己的作用域,或者仅在单个方法内部有意义。然而,很少看到频繁使用这种技术的 Python 代码。

我们已经了解了如何创建类和模块。有了这些核心技术,我们可以开始思考编写有用的、有帮助的软件来解决问题。然而,当应用程序或服务变得庞大时,我们常常会遇到边界问题。我们需要确保对象尊重彼此的隐私,并避免造成复杂软件成为错综复杂的关系网,就像意大利面一样。我们更希望每个类都能成为一个封装良好的意大利面饼。让我们来看看组织我们的软件以创建良好设计的另一个方面。

谁可以访问我的数据?

大多数面向对象编程语言都有一个访问控制的概念。这与抽象有关。一个对象上的某些属性和方法被标记为私有,意味着只有那个对象可以访问它们。其他被标记为保护,意味着只有那个类及其任何子类可以访问。其余的是公共的,意味着任何其他对象都可以访问它们。

Python 不会这样做。Python 并不真正相信强制执行可能有一天会阻碍你的法律。相反,它提供了未强制执行的指南和最佳实践。技术上讲,类上的所有方法和属性都是公开可用的。如果我们想建议一个方法不应该公开使用,我们可以在文档字符串中注明该方法仅用于内部使用(最好附上对面向公众的 API 如何工作的解释!)

我们经常用“我们都是成年人”这样的话来提醒彼此。当我们都能看到源代码时,没有必要将变量声明为私有。

按照惯例,我们通常在内部属性或方法前加上一个下划线字符,_。Python 程序员会理解以下划线开头的名称意味着这是一个内部变量,在直接访问之前请三思。但是,如果他们认为这样做对他们最有利,解释器内部并没有什么可以阻止他们访问它。因为,如果他们这样认为,我们为什么要阻止他们呢?我们可能根本不知道我们的类将来可能被用于什么目的,而且它们可能在未来的版本中被移除。这是一个非常明显的警告标志,表明应避免使用它。

还有一个方法可以强烈建议外部对象不要访问一个属性或方法:在它前面加上双下划线__。这将对该属性执行名称混淆。本质上,名称混淆意味着如果外部对象真的想这样做,它们仍然可以调用该方法,但这需要额外的工作,并且是一个强烈的信号,表明你要求你的属性保持私有

当我们使用双下划线时,属性会以_<classname>作为前缀。当类内部的方法访问这个变量时,它们会自动进行解混淆。当外部类希望访问它时,它们必须自己进行名称混淆。因此,名称混淆并不能保证隐私;它只是强烈建议这样做。这种情况很少使用,并且当使用时常常会引起混淆。

不要在你的代码中创建新的双下划线命名,这只会带来痛苦和心碎。考虑将其保留给 Python 内部定义的特殊名称。

重要的是,封装——作为一种设计原则——确保了类的方法定义了属性的状态变化。属性(或方法)是否为私有并不会改变封装带来的基本良好设计。

封装原则适用于单个类以及包含多个类的模块。它同样适用于包含多个模块的包。作为面向对象的 Python 的设计者,我们正在隔离职责并清晰地封装特性。

当然,我们正在使用 Python 来解决问题。结果发现,有一个庞大的标准库可供我们使用,帮助我们创建有用的软件。庞大的标准库正是我们为什么将 Python 描述为“内置电池”语言的原因。直接从盒子里出来,你几乎拥有你需要的一切,无需跑到商店去购买电池。

在标准库之外,还有一个更大的第三方包的宇宙。在下一节中,我们将探讨如何通过更多现成的优点来扩展我们的 Python 安装。

第三方库

Python 自带一个可爱的标准库,这是一个包含各种包和模块的集合,可在运行 Python 的每台机器上使用。然而,你很快会发现它并不包含你需要的一切。当这种情况发生时,你有两个选择:

  • 自己编写一个支持包

  • 使用别人的代码

我们不会详细介绍如何将你的包转换为库,但如果你有需要解决的问题而你又不想编写代码(最好的程序员都非常懒惰,更愿意重用现有的、经过验证的代码,而不是自己编写),你可能在pypi.python.org/上的Python 包索引PyPI)上找到你想要的库。一旦你确定了一个想要安装的包,你可以使用名为pip的工具来安装它。

您可以使用以下操作系统的命令来安装软件包:

% python -m pip install mypy 

如果你尝试此操作而不做任何准备,你可能会直接将第三方库安装到你的系统 Python 目录中,或者更有可能的是,你会遇到一个错误,提示你没有权限更新系统 Python。

Python 社区的普遍共识是不要触碰操作系统中的任何 Python。较老的 Mac OS X 版本中预装了 Python 2.7。这实际上对最终用户来说并不可用。最好将其视为操作系统的一部分;忽略它,并且始终安装一个全新的 Python。

Python 自带一个名为venv的工具,这是一个实用程序,它会在你的工作目录中为你提供一个名为虚拟环境的 Python 安装。当你激活这个环境时,与 Python 相关的命令将使用你的虚拟环境的 Python 而不是系统 Python。因此,当你运行pippython时,它根本不会触及系统 Python。以下是使用它的方法:

cd project_directory
python -m venv env
source env/bin/activate    # on Linux or macOS
env/Scripts/activate.bat   # on Windows 

(对于其他操作系统,请参阅docs.python.org/3/library/venv.html,其中包含激活环境所需的所有变体。)

一旦激活虚拟环境,你可以确保 python -m pip 将新包安装到虚拟环境中,而不会影响到任何操作系统中的 Python。现在你可以使用 python -m pip install mypy 命令将 mypy 工具添加到当前的虚拟环境中。

在家用电脑上——您有权访问特权文件的地方——有时您可以安装并使用单个集中式的系统级 Python。在企业计算环境中,由于系统级目录需要特殊权限,因此需要使用虚拟环境。因为虚拟环境方法总是有效,而集中式系统级方法并不总是有效,所以通常最好的做法是创建并使用虚拟环境。

对于每个 Python 项目创建一个不同的虚拟环境是很常见的。你可以将虚拟环境存储在任何位置,但一个好的做法是将它们保存在与项目文件相同的目录中。当使用像Git这样的版本控制工具时,.gitignore文件可以确保你的虚拟环境不会被提交到 Git 仓库中。

当开始新事物时,我们通常会创建一个目录,然后使用cd命令进入该目录。接着,我们将运行python -m venv env命令来创建一个虚拟环境,通常使用简单的名称如env,有时也会使用更复杂的名称如CaseStudy39

最后,我们可以使用前面代码中的最后两行之一(根据注释中所示,取决于操作系统)来激活环境。

每次我们在项目上进行工作时,都可以使用cd命令进入目录,并执行source(或activate.bat)命令来使用特定的虚拟环境。当切换项目时,使用deactivate命令可以撤销环境设置。

虚拟环境对于将第三方依赖项与 Python 标准库分离至关重要。通常,不同的项目可能依赖于特定库的不同版本(例如,一个较老的网站可能运行在 Django 1.8 上,而新版本则运行在 Django 2.1 上)。将每个项目放在独立的虚拟环境中,可以轻松地在 Django 的任一版本中工作。此外,如果你尝试使用不同的工具安装相同的包,这还可以防止系统安装的包和pip安装的包之间的冲突。最后,它绕过了围绕操作系统 Python 的任何 OS 权限限制。

管理虚拟环境的有效工具有很多第三方工具。其中一些包括 virtualenvpyenvvirtualenvwrapperconda。如果你在一个数据科学环境中工作,你可能需要使用 conda 来安装更复杂的包。有许多特性导致了在解决管理庞大的第三方 Python 包生态系统问题时,出现了许多不同的方法。

案例研究

本节扩展了我们现实例子的面向对象设计。我们将从使用统一建模语言UML)创建的图表开始,以帮助描述和总结我们将要构建的软件。

我们将描述构成 Python 类定义实现的各个方面考虑因素。我们将从回顾描述将要定义的类的图开始。

逻辑视图

这里是我们需要构建的课程的概述。这(除了一个新方法外)是上一章的模型:

图表描述自动生成

图 2.2:逻辑视图图

定义我们核心数据模型的有三个类,以及一些通用列表类的用法。我们使用List的类型提示来展示它。以下是四个核心类:

  • TrainingData 类是一个包含两个数据样本列表的容器,一个列表用于训练我们的模型,另一个列表用于测试我们的模型。这两个列表都是由 KnownSample 实例组成的。此外,我们还将有一个包含替代 Hyperparameter 值的列表。一般来说,这些是调整值,它们会改变模型的行为。想法是通过使用不同的超参数来测试,以找到最高质量的模型。

    我们还为这个类分配了一点点元数据:我们正在处理的数据的名称,我们第一次上传数据的日期时间,以及我们对模型进行测试的日期时间。

  • Sample类的每个实例是工作数据的核心部分。在我们的例子中,这些是萼片长度和宽度以及花瓣长度和宽度的测量数据。手稳的植物学研究生们仔细测量了大量的花朵以收集这些数据。我们希望他们在工作时有时间停下来闻一闻玫瑰的芬芳。

  • KnownSample 对象是一个扩展的 Sample。这部分设计预示了第三章 当对象相似时 的重点。KnownSample 是一个带有额外属性(指定的物种)的 Sample。这些信息来自有经验的植物学家,他们已经对一些我们可以用于训练和测试的数据进行了分类。

  • Hyperparameter 类使用了 k 来定义需要考虑的最近邻的数量。它还包含使用这个 k 值的测试总结。质量指标告诉我们有多少测试样本被正确分类。我们预计较小的 k 值(如 1 或 3)分类效果不佳。我们预计中等大小的 k 值会有更好的表现,而非常大的 k 值则可能表现不佳。

图表上的KnownSample类可能不需要是一个独立的类定义。随着我们深入探讨细节,我们将查看这些类中每个的替代设计方案。

我们将从Sample(以及KnownSample)类开始。Python 提供了定义新类的三个基本路径:

  • 一个定义;我们将从这一点开始关注。

  • 一个 @dataclass 定义。这提供了一系列内置功能。虽然很方便,但对于刚开始接触 Python 的程序员来说并不理想,因为它可能会掩盖一些实现细节。我们将这个内容留到 第七章Python 数据结构 中讨论。

  • typing.NamedTuple 类的扩展。这个定义最显著的特点将是对象的状态是不可变的;属性值不能被更改。不变的属性对于确保应用程序中的错误不会干扰训练数据来说是一个有用的特性。我们也将这一点放在第七章中讨论。

我们的第一项设计决策是使用 Python 的 class 语句来编写 Sample 类及其子类 KnownSample 的类定义。这在未来(即,第七章)可能会被使用数据类以及 NamedTuple 的替代方案所取代。

样本及其状态

图 2.2 中的图表展示了Sample类及其扩展,即KnownSample类。这似乎并不是对各种样本类型的完整分解。当我们回顾用户故事和流程视图时,似乎存在一个差距:具体来说,用户提出的“进行分类请求”需要未知样本。这个未知样本与Sample具有相同的测量属性,但没有KnownSample所具有的指定物种属性。此外,没有状态变化来添加属性值。未知样本永远不会被植物学家正式分类;它将由我们的算法进行分类,但那只是一个 AI,而不是植物学家。

我们可以为Sample的两个不同子类进行论证:

  • UnknownSample: 这个类包含初始的四个Sample属性。用户提供这些对象以获取它们的分类。

  • 已知样本: 这个类具有样本属性以及分类结果,即物种名称。我们使用这些信息进行模型的训练和测试。

通常,我们认为类定义是一种封装状态和行为的方式。用户提供的UnknownSample实例最初没有任何物种。然后,在分类器算法计算出物种之后,Sample的状态会改变,由算法分配一个物种。

我们在定义类时必须始终提出的问题是:

状态变化时,行为是否会有所改变?

在这种情况下,似乎没有发生任何新或不同的事情。也许这可以作为一个具有一些可选属性的单一类来实现。

我们还有另一个可能的状态变更担忧。目前,没有哪个类负责将Sample对象划分到训练集或测试集。这也算是一种状态变更。

这引出了第二个重要问题:

哪个班级负责进行这种状态改变?

在这种情况下,看起来TrainingData类应该拥有测试数据和训练数据之间的区分权。

帮助我们仔细审视课程设计的一种方法就是列举所有个体样本的各种状态。这种技术有助于揭示在类中需要属性的需求。它还有助于确定对类对象进行状态变更的方法。

样本状态转换

让我们来看看Sample对象的生命周期。一个对象的生命周期始于对象的创建,然后是状态的变化,以及在没有任何引用指向它时(在某些情况下)其处理生命的结束。我们有三种场景:

  1. 初始加载:我们需要一个load()方法,从原始数据源填充TrainingData对象。我们可以预览一下第九章字符串、序列化和文件路径中的部分内容,通过说读取 CSV 文件通常会生成一系列字典。我们可以想象一个使用 CSV 读取器的load()方法,用来创建具有物种值的Sample对象,使它们成为KnownSample对象。load()方法将KnownSample对象分割成训练和测试列表,这对于TrainingData对象来说是一个重要的状态变化。

  2. 超参数测试:我们需要在Hyperparameter类中实现一个test()方法。test()方法的主体与关联的TrainingData对象中的测试样本一起工作。对于每个样本,它应用分类器并计算 Botanist 分配的物种与我们的 AI 算法的最佳猜测之间的匹配数。这指出了需要一个用于单个样本的classify()方法,该方法由test()方法用于一批样本。test()方法将通过设置质量分数来更新Hyperparameter对象的状态。

  3. 用户启动的分类:一个 RESTful 网络应用通常被分解为单独的视图函数来处理请求。当处理对分类未知样本的请求时,视图函数将有一个用于分类的Hyperparameter对象;这将由 Botanist 选择以产生最佳结果。用户输入将是一个UnknownSample实例。视图函数将应用Hyperparameter.classify()方法来向用户创建一个响应,说明鸢尾花被分类为哪种物种。AI 在分类UnknownSample时发生的状态变化真的重要吗?这里有两种视图:

    • 每个 UnknownSample 都可以有一个 classified 属性。设置这个属性是 Sample 状态的改变。不清楚这个状态改变是否与任何行为变化相关。

    • 分类结果根本不属于样本的一部分。它是在视图函数中的一个局部变量。这个函数中的状态变化用于响应用户,但在样本对象内部没有生命力。

这些替代方案的详细分解背后有一个关键概念:

没有“正确”的答案

一些设计决策基于非功能性和非技术性考虑。这些可能包括应用程序的寿命、未来的用例、可能被吸引的额外用户、当前的日程和预算、教育价值、技术风险、知识产权的创造,以及演示在电话会议中看起来有多酷。

第一章面向对象设计中,我们暗示了该应用程序是消费者产品推荐器的先驱。我们指出:“用户最终想要处理复杂的消费者产品,但认识到解决一个难题并不是学习如何构建这类应用的好方法。从某种可管理的复杂度开始,然后逐步改进和扩展,直到它满足他们的所有需求,这样做会更好。”

由于这个原因,我们将把从 UnknownSampleClassifiedSample 的状态变化视为非常重要。Sample 对象将存在于数据库中,用于额外的营销活动或在新产品可用和训练数据发生变化时进行可能的重新分类。

我们将决定将分类和物种数据保留在UnknownSample类中。

这项分析表明我们可以将所有各种样本细节合并为以下设计:

图表描述自动生成

图 2.3:更新后的 UML 图

这种视图使用开放箭头来展示Sample的多个子类。我们不会直接将这些作为子类实现。我们包括箭头是为了表明我们对这些对象有一些独特的使用场景。具体来说,KnownSample的框中有一个条件species is not None,用于总结这些Sample对象独特之处。同样,UnknownSample也有一个条件,species is None,用于阐明我们对具有None属性值的Sample对象的意图。

在这些 UML 图中,我们通常避免显示 Python 的“特殊”方法。这有助于最小化视觉杂乱。在某些情况下,一个特殊方法可能是绝对必要的,并且值得在图中展示。几乎任何实现都需要有一个 __init__() 方法。

另有一种特别的方法可以真正帮助到您:__repr__() 方法用于创建对象的表示形式。这种表示形式是一个字符串,通常具有 Python 表达式的语法,以便重建对象。对于简单的数字,它就是数字本身。对于简单的字符串,它将包括引号。对于更复杂的对象,它将包含所有必要的 Python 标点符号,包括对象的类和状态的所有细节。我们通常会使用 f-string 结合类名和属性值。

这里是一个名为Sample的类开始的示例,它似乎捕捉到了单个样本的所有特征:

class Sample:
    def __init__(
        self,
        sepal_length: float,
        sepal_width: float,
        petal_length: float,
        petal_width: float,
        species: Optional[str] = None,
    ) -> None:
        self.sepal_length = sepal_length
        self.sepal_width = sepal_width
        self.petal_length = petal_length
        self.petal_width = petal_width
        self.species = species
        self.classification: Optional[str] = None
    def __repr__(self) -> str:
        if self.species is None:
            known_unknown = "UnknownSample"
        else:
            known_unknown = "KnownSample"
        if self.classification is None:
            classification = ""
        else:
            classification = f", {self.classification}"
        return (
            f"{known_unknown}("
            f"sepal_length={self.sepal_length}, "
            f"sepal_width={self.sepal_width}, "
            f"petal_length={self.petal_length}, "
            f"petal_width={self.petal_width}, "
            f"species={self.species!r}"
            f"{classification}"
            f")"
        ) 

__repr__() 方法反映了这个 Sample 对象相当复杂的内部状态。物种的存在(或不存在)以及分类的存在(或不存在)所暗示的状态导致了微小的行为变化。到目前为止,任何对象行为的变化都仅限于用于显示对象当前状态的 __repr__() 方法。

重要的是,状态变化确实导致了(微小的)行为变化。

我们为Sample类提供了两种特定应用的方法。这些方法将在下一段代码片段中展示:

 def classify(self, classification: str) -> None:
        self.classification = classification
    def matches(self) -> bool:
        return self.species == self.classification 

classify() 方法定义了从未分类到已分类的状态变化。matches() 方法将分类结果与植物学家指定的物种进行比较。这用于测试。

这里是一个展示这些状态变化如何呈现的例子:

>>> from model import Sample
>>> s2 = Sample(
...     sepal_length=5.1, sepal_width=3.5, petal_length=1.4, petal_width=0.2, species="Iris-setosa")
>>> s2
KnownSample(sepal_length=5.1, sepal_width=3.5, petal_length=1.4, petal_width=0.2, species='Iris-setosa')
>>> s2.classification = "wrong"
>>> s2
KnownSample(sepal_length=5.1, sepal_width=3.5, petal_length=1.4, petal_width=0.2, species='Iris-setosa', classification='wrong') 

我们有一个可操作的Sample类定义。__repr__()方法相当复杂,这表明可能有一些改进的空间。

它可以帮助定义每个类的职责。这可以是一个对属性和方法的聚焦总结,并附带一些额外的理由来将它们联系起来。

班级责任

哪个类负责实际执行测试?Training类是否在测试集中对每个KnownSample调用分类器?或者,也许它将测试集提供给Hyperparameter类,将测试任务委托给Hyperparameter类?由于Hyperparameter类负责k值,以及定位k-最近邻的算法,因此让Hyperparameter类使用它自己的k值和提供给它的KnownSample实例列表来运行测试似乎是合理的。

似乎也很明显,TrainingData 类是一个记录各种 Hyperparameter 尝试的合适位置。这意味着 TrainingData 类可以识别哪个 Hyperparameter 实例的 k 值能够以最高的准确率对鸢尾花进行分类。

这里存在多个相关的状态变化。在这种情况下,Hyperparameter(超参数)和TrainingData(训练数据)类都将完成部分工作。整个系统——作为一个整体——会随着各个元素的单独状态变化而改变状态。这有时被描述为涌现行为。我们不是编写一个做很多事情的巨无霸类,而是编写了更小的类,它们协作以实现预期的目标。

TrainingData 类的 test() 方法是我们没有在 UML 图中展示的内容。我们将 test() 方法包含在了 Hyperparameter 类中,但在当时,似乎没有必要将其添加到 TrainingData 类中。

这里是类定义的开始:

class Hyperparameter:
    """A hyperparameter value and the overall quality of the classification."""
    def __init__(self, k: int, training: "TrainingData") -> None:
        self.k = k
        self.data: weakref.ReferenceType["TrainingData"] = weakref.ref(training)
        self.quality: float 

注意我们是如何为尚未定义的类编写类型提示的。当一个类在文件中稍后定义时,对尚未定义的类的任何引用都是一个前向引用。对尚未定义的TrainingData类的引用是以字符串形式提供的,而不是简单的类名。当mypy分析代码时,它会将字符串解析为正确的类名。

测试是通过以下方法定义的:

 def test(self) -> None:
        """Run the entire test suite."""
        training_data: Optional["TrainingData"] = self.data()
        if not training_data:
            raise RuntimeError("Broken Weak Reference")
        pass_count, fail_count = 0, 0
        for sample in training_data.testing:
            sample.classification = self.classify(sample)
            if sample.matches():
                pass_count += 1
            else:
                fail_count += 1
        self.quality = pass_count / (pass_count + fail_count) 

我们首先解析对训练数据的弱引用。如果存在问题,这将引发异常。对于每个测试样本,我们对其进行分类,设置样本的classification属性。matches方法告诉我们模型的分类是否与已知的物种匹配。最后,通过通过测试的比例来衡量整体质量。我们可以使用整数计数,或者通过测试总数中通过测试的比例来表示。

我们在本章中不会探讨分类方法;我们将把这部分内容留到第十章,迭代器模式。相反,我们将通过查看TrainingData类来完善这个模型,该类结合了迄今为止所看到的元素。

TrainingData 类

TrainingData 类包含两个 Sample 对象子类的列表。KnownSampleUnknownSample 可以作为对公共父类 Sample 的扩展来实现。

我们将在第七章从多个角度来探讨这个问题。TrainingData类还有一个包含Hyperparameter实例的列表。这个类可以简单地直接引用之前定义过的类。

这个类有两个启动处理的方法:

  • load()方法读取原始数据并将其划分为训练数据和测试数据。这两个本质上都是具有不同目的的KnownSample实例。训练子集用于评估k-NN 算法;测试子集用于确定k超参数的工作效果。

  • test() 方法使用一个 Hyperparameter 对象,执行测试,并保存结果。

回顾到第一章的上下文图,我们看到有三个故事:提供训练数据设置参数并测试分类器发起分类请求。添加一个使用给定的Hyperparameter实例进行分类的方法似乎是有帮助的。这将向TrainingData类添加一个classify()方法。再次强调,这在我们设计工作的开始时并不是一个明确的需求,但现在看起来是个不错的想法。

这里是类定义的开始:

class TrainingData:
    """A set of training data and testing data with methods to load and test the samples."""
    def __init__(self, name: str) -> None:
        self.name = name
        self.uploaded: datetime.datetime
        self.tested: datetime.datetime
        self.training: List[Sample] = []
        self.testing: List[Sample] = []
        self.tuning: List[Hyperparameter] = [] 

我们已经定义了一系列属性来追踪这个类变更的历史。例如,上传时间和测试时间提供了一些历史信息。trainingtestingtuning属性包含Sample对象和Hyperparameter对象。

我们不会编写设置所有这些的方法。这是 Python,直接访问属性对于复杂应用来说是一种巨大的简化。责任被封装在这个类中,但我们通常不会编写很多获取/设置方法。

第五章何时使用面向对象编程中,我们将探讨一些巧妙的技术,例如 Python 的属性定义,以及处理这些属性的其他方法。

load()方法被设计用来处理另一个对象提供的数据。我们本来可以设计load()方法来打开和读取文件,但那样的话,我们就将TrainingData绑定到了特定的文件格式和逻辑布局上。似乎将文件格式的细节与训练数据管理的细节隔离开来会更好。在第五章中,我们将仔细研究读取和验证输入。在第九章字符串、序列化和文件路径中,我们将重新审视文件格式的问题。

目前,我们将使用以下大纲来获取训练数据:

 def load(
            self, 
            raw_data_source: Iterable[dict[str, str]]
    ) -> None:
        """Load and partition the raw data"""
        for n, row in enumerate(raw_data_source):
            ... filter and extract subsets (See Chapter 6)
            ... Create self.training and self.testing subsets 
        self.uploaded = datetime.datetime.now(tz=datetime.timezone.utc) 

我们将依赖于一个数据源。我们使用类型提示描述了该数据源的特性,Iterable[dict[str, str]]Iterable 表示该方法的结果可以被 for 循环语句或 list 函数使用。这适用于列表和文件等集合。对于生成器函数,也就是第十章迭代器模式的主题,也是如此。

这个迭代器的结果需要是映射字符串到字符串的字典。这是一个非常通用的结构,它允许我们要求一个看起来像这样的字典:

{
    "sepal_length": 5.1, 
    "sepal_width": 3.5, 
    "petal_length": 1.4, 
    "petal_width": 0.2, 
    "species": "Iris-setosa"
} 

这种所需的结构似乎足够灵活,我们可以构建一些能够产生它的对象。我们将在第九章中查看细节。

剩余的方法将大部分工作委托给Hyperparameter类。这个类不是直接进行分类工作,而是依赖于另一个类来完成这项工作:

def test(
        self, 
        parameter: Hyperparameter) -> None:
    """Test this Hyperparameter value."""
    parameter.test()
    self.tuning.append(parameter)
    self.tested = datetime.datetime.now(tz=datetime.timezone.utc)
def classify(
        self, 
        parameter: Hyperparameter, 
        sample: Sample) -> Sample:
    """Classify this Sample."""
    classification = parameter.classify(sample)
    sample.classify(classification)
    return sample 

在这两种情况下,都提供了一个特定的超参数对象作为参数。对于测试来说,这样做是有意义的,因为每个测试都应该有一个独特的值。然而,对于分类来说,应该使用“最佳”的超参数对象来进行分类。

本案例研究部分为SampleKnownSampleTrainingDataHyperparameter构建了类定义。这些类捕获了整体应用的部分内容。当然,这并不完整;我们省略了一些重要的算法。从清晰的事物开始,识别行为和状态变化,并定义责任是很好的。接下来的设计阶段可以在此基础上填充细节。

回忆

本章的一些关键点:

  • Python 提供了可选的类型提示,以帮助描述数据对象之间的关系以及方法和函数的参数应该是什么。

  • 我们使用class语句创建 Python 类。我们应该在特殊的__init__()方法中初始化属性。

  • 模块和包被用作类的更高层次分组。

  • 我们需要规划模块内容的组织结构。虽然普遍的建议是“扁平结构优于嵌套结构”,但在某些情况下,拥有嵌套的包可能会有所帮助。

  • Python 没有“私有”数据的概念。我们常说“我们都是成年人”;我们可以看到源代码,私有声明并不很有帮助。这并不会改变我们的设计;它只是消除了需要一些关键字的需求。

  • 我们可以使用 PIP 工具安装第三方包。例如,我们可以使用 venv 创建一个虚拟环境。

练习

编写一些面向对象的代码。目标是使用你在本章中学到的原则和语法来确保你理解我们已经涵盖的主题。如果你一直在进行一个 Python 项目,回顾一下它,看看是否可以创建一些对象并为它们添加属性或方法。如果项目很大,尝试将其划分为几个模块甚至包,并尝试不同的语法。虽然一个“简单”的脚本在重构为类时可能会扩展,但通常会有灵活性和可扩展性的提升。

如果你没有这样的项目,尝试启动一个新的项目。它不一定要是你打算完成的;只需草拟一些基本的设计部分。你不需要完全实现所有内容;通常,只需要一个print("这个方法将执行某些操作")就足以将整体设计定位好。这被称为自顶向下设计,在这种设计中,你先确定不同的交互方式,并描述它们应该如何工作,然后再实际实现它们的功能。相反的,自底向上设计则是先实现细节,然后再将它们全部整合在一起。这两种模式在不同的时间都很实用,但为了理解面向对象的原则,自顶向下的工作流程更为合适。

如果你正苦于想不出点子,试着编写一个待办事项应用。它可以追踪你每天想要做的事情。项目可以从未完成状态变为完成状态。你可能还需要考虑那些处于开始但尚未完成的中途状态的项目。

现在尝试设计一个更大的项目。创建一个用于模拟玩牌的类集合可以是一个有趣的挑战。牌有几项特性,但规则有很多变体。当添加牌时,手牌类会有一些有趣的状态变化。找到你喜欢的游戏,并创建类来模拟牌、手牌和玩法。(不要尝试创建获胜策略;那可能很困难。)

类似于克里比奇(Cribbage)这样的游戏有一个有趣的状态变化,即每个玩家手中的两张牌被用来创建一种第三手牌,称为“crib”。确保你尝试使用包和模块导入语法。在各个模块中添加一些函数,并尝试从其他模块和包中导入它们。使用相对和绝对导入。看看区别,并尝试想象你想要使用每种导入方式的场景。

摘要

在本章中,我们学习了在 Python 中创建类以及分配属性和方法是多么简单。与许多语言不同,Python 区分了构造函数和初始化器。它在访问控制方面持宽松态度。存在许多不同的作用域级别,包括包、模块、类和函数。我们理解了相对导入和绝对导入之间的区别,以及如何管理不随 Python 一起提供的第三方包。

在下一章,我们将学习更多关于使用继承进行共享实现的内容。

第三章:当物体相似时

在编程领域,重复代码被视为邪恶。我们不应该在不同地方有相同或相似的代码的多个副本。当我们修复了一个副本中的错误,却未能修复另一个副本中的相同错误时,我们给自己带来了无穷无尽的麻烦。

合并具有相似功能的代码或对象有许多方法。在本章中,我们将介绍最著名的面向对象原则:继承。正如在第一章面向对象设计中讨论的那样,继承使我们能够在两个或更多类之间创建“是”的关系,将共同逻辑抽象到超类中,并在每个子类中用特定细节扩展超类。特别是,我们将介绍以下 Python 语法和原则:

  • 基本继承

  • 从内置类型继承

  • 多重继承

  • 多态和鸭子类型

本章的案例研究将扩展上一章的内容。我们将利用继承和抽象的概念来寻找管理k最近邻计算中公共代码的方法。

我们将首先详细探讨继承的工作原理,以便提取共同特性,从而避免复制粘贴编程。

基本继承

技术上讲,我们创建的每个类都使用了继承。所有 Python 类都是名为 object 的特殊内置类的子类。这个类提供了一点点元数据和一些内置行为,以便 Python 可以一致地处理所有对象。

如果我们没有显式地从不同的类继承,我们的类将自动继承自object。然而,我们可以使用以下语法冗余地声明我们的类继承自object

class MySubClass(object): 
    pass 

这就是继承!从技术上讲,这个例子与我们第二章中非常第一个例子没有区别,即Python 中的对象。在 Python 3 中,如果我们没有明确提供不同的超类,所有类都会自动从object继承。在这个例子中,超类或父类是继承的类,即object。一个子类——在这个例子中是MySubClass——从超类继承。子类也被说成是从其父类派生,或者子类扩展了父类。

如您从示例中可能已经推断出来,继承相对于基本类定义只需要额外的少量语法。只需在类名和冒号之间用括号包含父类的名称即可。这就是我们要做的全部,以告诉 Python 新类应该从给定的超类派生。

我们如何在实践中应用继承?继承最简单和最明显的用途是为现有类添加功能。让我们从一个联系人管理器开始,它跟踪几个人的姓名和电子邮件地址。Contact类负责维护一个全局列表,其中包含一个类变量中曾经见过的所有联系人,并为单个联系人初始化姓名和地址:

class Contact:
   all_contacts: List["Contact"] = []
   def __init__(self, name: str, email: str) -> None:
       self.name = name
       self.email = email
       Contact.all_contacts.append(self)
   def __repr__(self) -> str:
       return (
           f"{self.__class__.__name__}("
           f"{self.name!r}, {self.email!r}"
           f")"
      ) 

这个例子向我们介绍了类变量all_contacts列表,因为它属于类定义的一部分,被这个类的所有实例共享。这意味着只有一个Contact.all_contacts列表。我们也可以从Contact类的任何实例的方法中通过self.all_contacts来访问它。如果一个字段在对象上(通过self)找不到,那么它将在类上找到,因此将引用同一个单个列表。

小心使用基于self的引用。它只能提供访问现有基于类的变量的权限。如果你试图使用self.all_contacts设置变量,实际上你将会创建一个仅与该对象关联的实例变量。类变量将保持不变,并且可以通过Contact.all_contacts进行访问。

我们可以通过以下示例了解该类如何跟踪数据:

>>> c_1 = Contact("Dusty", "dusty@example.com")
>>> c_2 = Contact("Steve", "steve@itmaybeahack.com")
>>> Contact.all_contacts
[Contact('Dusty', 'dusty@example.com'), Contact('Steve', 'steve@itmaybeahack.com')] 

我们创建了两个Contact类的实例,并将它们分别赋值给变量c_1c_2。当我们查看Contact.all_contacts类变量时,我们发现列表已经更新,以跟踪这两个对象。

这是一个简单的类,它允许我们跟踪每个联系人的几项数据。但如果我们的一些联系人也是我们需要从他们那里订购物资的供应商怎么办呢?我们可以在Contact类中添加一个order方法,但这样可能会让人们在无意中从客户或家人朋友那里订购东西。相反,让我们创建一个新的Supplier类,它类似于我们的Contact类,但有一个额外的order方法,该方法接受一个尚未定义的Order对象:

class Supplier(Contact):
   def order(self, order: "Order") -> None:
       print(
           "If this were a real system we would send "
           f"'{order}' order to '{self.name}'"
       ) 

现在,如果我们在我们可靠的解释器中测试这个类,我们会看到所有联系,包括供应商,都在它们的__init__()方法中接受一个名称和电子邮件地址,但只有Supplier实例才有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
>>> from pprint import pprint
>>> pprint(c.all_contacts)
[Contact('Dusty', 'dusty@example.com'),
 Contact('Steve', 'steve@itmaybeahack.com'),
 Contact('Some Body', 'somebody@example.net'),
 Supplier('Sup Plier', 'supplier@example.net')]
>>> 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类可以做到一个联系人所能做的一切(包括将自己添加到Contact.all_contacts的列表中),以及作为供应商需要处理的特殊事务。这就是继承的美丽之处。

此外,请注意Contact.all_contacts已经收集了Contact类及其子类Supplier的每一个实例。如果我们使用self.all_contacts,那么这不会将所有对象收集到Contact类中,而是将Supplier实例放入Supplier.all_contacts中。

扩展内置功能

这种继承的一个有趣用途是向内置类添加功能。在前面看到的Contact类中,我们正在将联系人添加到所有联系人的列表中。如果我们还想按名称搜索这个列表怎么办?嗯,我们可以在Contact类上添加一个方法来搜索它,但感觉这个方法实际上属于列表本身。

以下示例展示了我们如何通过从内置类型继承来实现这一点。在这种情况下,我们使用的是list类型。我们将通过使用list["Contact"]来通知mypy我们的列表只包含Contact类的实例。为了使此语法在 Python 3.9 中工作,我们还需要从__future__包中导入annotations模块。定义看起来是这样的:

from __future__ import annotations
class ContactList(list["Contact"]):
    def search(self, name: str) -> list["Contact"]:
        matching_contacts: list["Contact"] = []
        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: str, email: str) -> None:
        self.name = name
        self.email = email
        Contact.all_contacts.append(self)
    def __repr__(self) -> str:
        return (
            f"{self.__class__.__name__}(" 
            f"{self.name!r}, {self.email!r}" f")"
        ) 

我们不是将一个通用列表作为我们的类变量,而是创建一个新的ContactList类,该类扩展了内置的list数据类型。然后,我们将这个子类实例化为我们自己的all_contacts列表。我们可以如下测试新的搜索功能:

>>> c1 = Contact("John A", "johna@example.net")
>>> c2 = Contact("John B", "johnb@sloop.net")
>>> c3 = Contact("Jenna C", "cutty@sark.io")
>>> [c.name for c in Contact.all_contacts.search('John')]
['John A', 'John B'] 

我们有两种创建通用列表对象的方法。使用类型提示,我们有了另一种谈论列表的方式,这不同于创建实际的列表实例。

首先,使用[]创建列表实际上是一个使用list()创建列表的快捷方式;这两种语法的行为相同:

>>> [] == list()
True 

[] 简短而甜美。我们可以称它为语法糖;它是对 list() 构造函数的调用,用两个字符代替了六个字符。list 名称指的是一种数据类型:它是一个我们可以扩展的类。

工具如 mypy 可以检查 ContactList.search() 方法的主体,以确保它确实会创建一个填充有 Contact 对象的 list 实例。请确保您已安装了 0.812 或更高版本的版本;mypy 的旧版本无法完全处理基于泛型的这些注解。

由于我们在ContactList类的定义之后提供了Contact类的定义,我们不得不将一个尚未定义的类的引用作为字符串提供,即list["Contact"]。通常情况下,先提供单个项目类的定义更为常见,然后集合可以无使用字符串通过名称引用已定义的类。

作为第二个例子,我们可以扩展dict类,它是一个键及其相关值的集合。我们可以使用{}语法糖来创建字典的实例。以下是一个扩展的字典,它跟踪它所看到的最大键长:

class LongNameDict(dict[str, int]):
    def longest_key(self) -> Optional[str]:
        """In effect, max(self, key=len), but less obscure"""
        longest = None
        for key in self:
            if longest is None or len(key) > len(longest):
                longest = key
        return longest 

课程提示将通用的 dict 窄化为更具体的 dict[str, int];键的类型为 str,值的类型为 int。这有助于 mypy 推理 longest_key() 方法。由于键应该是 str 类型的对象,因此 for key in self: 语句将遍历 str 对象。结果将是 str 类型,或者可能是 None。这就是为什么结果被描述为 Optional[str] 的原因。(None 是否合适?或许并不合适。或许抛出一个 ValueError 异常会更好;这将在 第四章,意料之外 中讨论。)

我们将处理字符串和整数值。也许这些字符串是用户名,而整数值是他们在一个网站上阅读的文章数量。除了核心的用户名和阅读历史之外,我们还需要知道最长的名字,以便我们可以格式化一个具有正确尺寸显示框的分数表。这可以在交互式解释器中轻松测试:

>>> articles_read = LongNameDict()
>>> articles_read['lucy'] = 42
>>> articles_read['c_c_phillips'] = 6
>>> articles_read['steve'] = 7
>>> articles_read.longest_key()
'c_c_phillips'
>>> max(articles_read, key=len)
'c_c_phillips' 

如果我们想要一个更通用的字典?比如说,其值可以是字符串整数?我们就需要一个稍微更广泛的类型提示。我们可能会使用dict[str, Union[str, int]]来描述一个将字符串映射到字符串或整数的并集的字典。

大多数内置类型都可以类似地扩展。这些内置类型分为几个有趣的家族,具有各自类型的提示:

  • 通用集合:setlistdict。这些使用类型提示如set[something]list[something]dict[key, value]来将提示从纯泛型缩小到应用实际使用的更具体类型。要使用通用类型作为注解,需要在代码的第一行使用from __future__ import annotations

  • typing.NamedTuple 定义允许我们定义新的不可变元组类型,并为成员提供有用的名称。这将在 第七章Python 数据结构,以及 第八章面向对象与函数式编程的交汇处 中进行介绍。

  • Python 为文件相关的 I/O 对象提供了类型提示。一种新的文件可以使用 typing.TextIOtyping.BinaryIO 类型提示来描述内置的文件操作。

  • 通过扩展 typing.Text,可以创建新的字符串类型。在大多数情况下,内置的 str 类就能满足我们的需求。

  • 新的数值类型通常以numbers模块作为内置数值功能的来源。

我们将在整本书中大量使用通用集合。正如所注,我们将在后面的章节中探讨命名元组。对于内置类型的其他扩展,本书内容过于高级。在下一节中,我们将更深入地探讨继承的好处以及我们如何在子类中选择性利用超类特性。

覆盖和超类

因此,继承非常适合于向现有类中添加新的行为,但关于改变行为又如何呢?我们的Contact类只允许添加姓名和电子邮件地址。这可能对大多数联系人来说已经足够了,但如果我们想为我们的亲密朋友添加电话号码怎么办呢?

如同我们在第二章Python 中的对象中看到的,我们可以在对象构建后通过设置一个phone属性来轻松实现这一点。但如果我们想在初始化时使这个第三个变量可用,我们必须重写__init__()方法。重写意味着用子类中具有相同名称的新方法(或替换)来更改或替换超类的方法。为此不需要特殊的语法;子类新创建的方法会自动被调用,而不是调用超类的方法,如下面的代码所示:

class Friend(Contact):
    def __init__(self, name: str, email: str, phone: str) -> None:
        self.name = name
        self.email = email
        self.phone = phone 

任何方法都可以被覆盖,不仅仅是__init__()。然而,在我们继续之前,我们需要解决这个例子中的一些问题。我们的ContactFriend类在设置nameemail属性方面有重复的代码;这可能会使代码维护变得复杂,因为我们不得不在两个或更多的地方更新代码。更令人担忧的是,我们的Friend类正在忽视将自身添加到我们在Contact类上创建的all_contacts列表中。最后,展望未来,如果我们向Contact类添加一个功能,我们希望它也成为Friend类的一部分。

我们真正需要的是一种方法,在新的类内部执行Contact类的原始__init__()方法。这正是super()函数的作用;它返回一个对象,仿佛它实际上是父类的一个实例,这样我们就可以直接调用父类的方法:

class Friend(Contact):
    def __init__(self, name: str, email: str, phone: str) -> None:
        super().__init__(name, email)
        self.phone = phone 

此示例首先使用super()将实例绑定到父类,并在该对象上调用__init__()方法,传入预期的参数。然后它执行自己的初始化,即设置phone属性,这是Friend类独有的。

Contact 类提供了一个定义,用于生成字符串表示形式的 __repr__() 方法。我们的类没有覆盖从超类继承来的 __repr__() 方法。以下是这种做法的后果:

>>> f = Friend("Dusty", "Dusty@private.com", "555-1212")
>>> Contact.all_contacts
[Friend('Dusty', 'Dusty@private.com')] 

对于Friend实例显示的详细信息不包括新属性。在考虑类设计时,很容易忽略特殊方法定义。

在任何方法内部都可以调用super()。因此,所有方法都可以通过重写和调用super()来修改。super()的调用也可以在任何方法点进行;我们不必将调用作为第一行执行。例如,我们可能需要在将参数转发给超类之前对其进行操作或验证。

多重继承

多重继承是一个敏感的话题。在原则上,它是简单的:一个从多个父类继承的子类可以访问它们的功能。在实践中,它需要一些小心,以确保任何方法覆盖都被完全理解。

作为一条幽默的经验法则,如果你认为你需要多重继承,你可能错了,但如果你确实需要它,你可能是对的。

最简单且最有用的多重继承形式遵循一种名为混入(mixin)的设计模式。混入类定义并不是为了独立存在,而是意味着它将被其他类继承以提供额外的功能。例如,假设我们想要为我们的Contact类添加功能,使其能够向self.email发送电子邮件。

发送电子邮件是一项常见的任务,我们可能在许多其他课程中也会用到。因此,我们可以编写一个简单的混合类来帮我们完成电子邮件发送:

class Emailable(Protocol):
    email: str
class MailSender(Emailable):
    def send_mail(self, message: str) -> None:
        print(f"Sending mail to {self.email=}")
        # Add e-mail logic here 

MailSender 类没有做任何特别的事情(实际上,它几乎不能作为一个独立的类使用,因为它假设了一个它没有设置的属性)。我们有两个类,因为我们描述了两件事情:混合类的主类方面以及混合类提供给主类的新方面。我们需要创建一个提示,Emailable,来描述我们的 MailSender 混合类期望与之一起工作的类类型。

这种类型提示被称为协议;协议通常有方法,也可以有带有类型提示的类级别属性名,但不能有完整的赋值语句。协议定义是一种不完整的类;可以将其想象为类特征的合同。协议告诉 mypy 任何 Emailable 对象的类(或子类)必须支持一个 email 属性,并且它必须是一个字符串。

注意,我们依赖于 Python 的名称解析规则。名称 self.email 可以解析为实例变量,或者类级别变量 Emailable.email,或者属性。mypy 工具将检查与 MailSender 混合的所有类中的实例或类级别定义。我们只需要在类级别提供属性的名称,并附上类型提示,以便向 mypy 表明混合类没有定义该属性——混合到其中的类将提供 email 属性。

由于 Python 的鸭子类型规则,我们可以将MailSender混入任何定义了email属性的类。与MailSender混入的类不必是Emailable的正式子类;它只需提供所需的属性即可。

为了简洁,我们没有在这里包含实际的电子邮件逻辑;如果您想了解它是如何实现的,请参阅 Python 标准库中的smtplib模块。

MailSender 类确实允许我们定义一个新的类,该类描述了ContactMailSender,通过多重继承实现:

class EmailableContact(Contact, MailSender):
    pass 

多重继承的语法看起来像类定义中的参数列表。我们不是在括号内包含一个基类,而是包含两个(或更多),用逗号分隔。当做得好的时候,结果类通常没有自己独特的特性。它是混入(mixins)的组合,类定义的主体通常只是pass占位符。

我们可以测试这个新的混合体,看看混合效果如何:

>>> e = EmailableContact("John B", "johnb@sloop.net")
>>> Contact.all_contacts
[EmailableContact('John B', 'johnb@sloop.net')]
>>> e.send_mail("Hello, test e-mail here")
Sending mail to self.email='johnb@sloop.net' 

Contact 初始化器仍在将新的联系人添加到 all_contacts 列表中,并且混入(mixin)能够向 self.email 发送邮件,因此我们知道一切都在正常工作。

这并不那么困难,你可能想知道我们关于多重继承的严重警告是为了什么。我们稍后会深入探讨复杂性,但让我们考虑一下这个例子中我们有的其他选项,而不是使用混入(mixin):

  • 我们本可以使用单继承,并将send_mail函数添加到Contact的子类中。这里的缺点是,电子邮件功能必须为任何需要电子邮件功能但与之无关的类重复实现。例如,如果我们应用中的支付部分包含电子邮件信息,这些信息与这些联系人无关,而我们想要一个send_mail()方法,我们就必须重复代码。

  • 我们可以创建一个独立的 Python 函数用于发送电子邮件,并在需要发送电子邮件时,只需调用该函数并传入正确的电子邮件地址作为参数(这是一个非常常见的做法)。因为这个函数不是类的一部分,所以更难确保使用了适当的封装。

  • 我们可以探索一些使用组合而非继承的方法。例如,EmailableContact 可以将 MailSender 对象作为属性,而不是从它继承。这导致 MailSender 类变得更加复杂,因为它现在必须独立存在。这也导致 EmailableContact 类变得更加复杂,因为它必须将每个 Contact 实例与一个 MailSender 实例关联起来。

  • 我们可以尝试对Contact类进行猴子补丁(我们将在第十三章面向对象程序的测试中简要介绍猴子补丁),在类创建后添加一个send_mail方法。这是通过定义一个接受self参数的函数,并将其设置为现有类的属性来实现的。这对于创建单元测试用例是可行的,但对于应用程序本身来说却非常糟糕。

多重继承在我们混合不同类的方法时工作得很好,但当我们需要在超类上调用方法时可能会变得混乱。当存在多个超类时,我们如何知道应该调用哪个类的的方法?选择合适的超类方法的规则是什么?

让我们通过向我们的Friend类添加家庭地址来探讨这些问题。我们可以采取几种方法:

  • 地址是一组表示联系人的街道、城市、国家和其他相关细节的字符串集合。我们可以将这些字符串中的每一个作为参数传递给Friend类的__init__()方法。我们还可以将这些字符串存储在一个通用的元组或字典中。当地址信息不需要新方法时,这些选项工作得很好。

  • 另一个选择是创建我们自己的Address类来将这些字符串组合在一起,然后将其实例传递到我们的Friend类的__init__()方法中。这种解决方案的优势在于,我们可以在数据上添加行为(比如,提供方向或打印地图的方法),而不仅仅是静态地存储它。这是我们之前在第一章面向对象设计中讨论的组合的一个例子。组合的“拥有”关系是解决这个问题的完美可行方案,并允许我们在其他实体(如建筑物、企业或组织)中重用Address类。(这是一个使用数据类的机会。我们将在第七章Python 数据结构中讨论数据类。)

  • 第三种行动方案是合作式多重继承设计。虽然这可以使其工作,但在mypy看来并不符合规范。原因,我们将看到,是一些难以用现有类型提示描述的潜在歧义。

此处的目标是添加一个新的类来存储地址。我们将把这个新类命名为AddressHolder而不是Address,因为继承定义了一个“是”的关系。说一个Friend类是一个Address类是不正确的,但是既然一个朋友可以有一个Address类,我们可以争论说一个Friend类是一个AddressHolder类。稍后,我们还可以创建其他也持有地址的实体(公司、建筑)。(复杂的命名和关于“是”的微妙问题可以作为我们应坚持组合而不是继承的合理指示。)

这里有一个简单的AddressHolder类。我们称之为简单,因为它在处理多重继承方面做得不好:

class AddressHolder:
    def __init__(self, street: str, city: str, state: str, code: str) -> None:
        self.street = street
        self.city = city
        self.state = state
        self.code = code 

我们在初始化时将所有数据以及参数值抛入实例变量中。我们将探讨这种做法的后果,然后展示一个更好的设计。

钻石问题

我们可以使用多重继承来将这个新类作为我们现有Friend类的父类。棘手的部分在于我们现在有两个父类__init__()方法,它们都需要被调用。而且它们需要用不同的参数来调用。我们该如何做呢?好吧,我们也可以从对Friend类的天真方法开始:

class Friend(Contact, AddressHolder):
    def __init__(
        self,
        name: str,
        email: str,
        phone: str,
        street: str,
        city: str,
        state: str,
        code: str,
    ) -> None:
        Contact.__init__(self, name, email)
        AddressHolder.__init__(self, street, city, state, code)
        self.phone = phone 

在这个例子中,我们直接在每个超类上调用__init__()函数,并显式传递self参数。这个例子在技术上是可以工作的;我们可以直接在类上访问不同的变量。但存在一些问题。

首先,如果忘记显式调用初始化器,一个超类可能保持未初始化状态。这不会破坏这个示例,但在常见场景中可能会导致难以调试的程序崩溃。我们会在那些显然有__init__()方法的类中遇到许多看起来奇怪的AttributeError异常。实际上并未使用__init__()方法这一点通常并不明显。

更为隐蔽的可能性是由于类层次结构的组织,一个超类被多次调用。看看这个继承图:

图表描述自动生成

图 3.1:我们多重继承实现的继承图

Friend 类的 __init__() 方法首先调用 Contact 类的 __init__(),这隐式地初始化了 object 超类(记住,所有类都从 object 继承)。然后 Friend 类再次调用 AddressHolder__init__(),这又隐式地初始化了 object 超类。这意味着父类被设置了两次。使用 object 类,这相对无害,但在某些情况下,可能会造成灾难。想象一下,每次请求都要尝试连接数据库两次!

基类应该只被调用一次。一次,是的,但何时调用呢?我们是先调用Friend,然后是Contact,接着是Object,最后是AddressHolder吗?还是先调用Friend,然后是Contact,接着是AddressHolder,最后是Object

让我们构造一个例子来更清晰地说明这个问题。在这里,我们有一个基类BaseClass,它有一个名为call_me()的方法。两个子类LeftSubclassRightSubclass扩展了BaseClass类,并且每个子类都使用不同的实现覆盖了call_me()方法。

然后,另一个子类通过多重继承同时扩展了这两个类,并使用第四个,独特的call_me()方法实现。这被称为菱形继承,因为类图呈现出菱形形状:

图表描述自动生成

图 3.2:钻石继承

让我们将这个图表转换为代码。这个示例展示了方法被调用的时机:

class BaseClass:
    num_base_calls = 0
    def call_me(self) -> None:
        print("Calling method on BaseClass")
        self.num_base_calls += 1
class LeftSubclass(BaseClass):
    num_left_calls = 0
    def call_me(self) -> None:
        BaseClass.call_me(self)
        print("Calling method on LeftSubclass")
        self.num_left_calls += 1
class RightSubclass(BaseClass):
    num_right_calls = 0
    def call_me(self) -> None:
        BaseClass.call_me(self)
        print("Calling method on RightSubclass")
        self.num_right_calls += 1
class Subclass(LeftSubclass, RightSubclass):
    num_sub_calls = 0
    def call_me(self) -> None:
        LeftSubclass.call_me(self)
        RightSubclass.call_me(self)
        print("Calling method on Subclass")
        self.num_sub_calls += 1 

此示例确保每个重写的call_me()方法都直接调用同名父方法。它通过将信息打印到屏幕上,让我们每次调用方法时都能得知。它还创建了一个独特的实例变量,以显示该方法被调用的次数。

self.num_base_calls += 1 这一行需要一点侧边栏的解释。

这实际上等同于 self.num_base_calls = self.num_base_calls + 1。当 Python 在等号右侧解析 self.num_base_calls 时,它首先会查找实例变量,然后查找类变量;我们提供了一个默认值为零的类变量。在执行 +1 计算之后,赋值语句将创建一个新的实例变量;它不会更新类级别的变量。

每次在第一次调用之后,实例变量将被找到。对于类来说,为实例变量提供默认值是非常酷的。

如果我们实例化一个Subclass对象,并对其调用一次call_me()方法,我们将得到以下输出:

>>> s = Subclass()
>>> s.call_me()
Calling method on BaseClass
Calling method on LeftSubclass
Calling method on BaseClass
Calling method on RightSubclass
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。

Python 的方法解析顺序MRO)算法将菱形转换为平面的线性元组。我们可以在类的__mro__属性中看到这个结果。这个菱形的线性版本是序列SubclassLeftSubclassRightSubClassBaseClassobject。这里重要的是SubclassLeftSubclass之前列出RightSubClass,对菱形中的类施加了一个顺序。

在多重继承中需要注意的事情是,我们通常想要调用 MRO(Method Resolution Order,方法解析顺序)序列中的下一个方法,而不一定是父类的方法。super()函数在 MRO 序列中定位名称。实际上,super()函数最初是为了使复杂的多重继承形式成为可能而开发的。

这里是使用 super() 编写的相同代码。我们重命名了一些类,添加了 _S 以使其明确这是使用 super() 的版本:

class BaseClass:
    num_base_calls = 0
    def call_me(self):
        print("Calling method on Base Class")
        self.num_base_calls += 1
class LeftSubclass_S(BaseClass):
    num_left_calls = 0
    def call_me(self) -> None:
        **super().call_me()**
        print("Calling method on LeftSubclass_S")
        self.num_left_calls += 1
class RightSubclass_S(BaseClass):
    num_right_calls = 0
    def call_me(self) -> None:
        **super().call_me()**
        print("Calling method on RightSubclass_S")
        self.num_right_calls += 1
class Subclass_S(LeftSubclass_S, RightSubclass_S):
    num_sub_calls = 0
    def call_me(self) -> None:
        **super().call_me()**
        print("Calling method on Subclass_S")
        self.num_sub_calls += 1 

变化相当微小;我们只是将原始的直接调用替换为对 super() 的调用。位于菱形底部的 Subclass_S 类只需调用一次 super(),而不是必须对左边的和右边的都进行调用。这个变化足够简单,但看看当我们执行它时的差异:

>>> ss = Subclass_S()
>>> ss.call_me()
Calling method on BaseClass
Calling method on RightSubclass_S
Calling method on LeftSubclass_S
Calling method on Subclass_S
>>> print(
... ss.num_sub_calls,
... ss.num_left_calls,
... ss.num_right_calls,
... ss.num_base_calls)
1 1 1 1 

这个输出看起来不错:我们的基础方法只被调用了一次。我们可以通过查看类的__mro__属性来了解它是如何工作的:

>>> from pprint import pprint
>>> pprint(Subclass_S.__mro__)
(<class 'commerce_naive.Subclass_S'>,
 <class 'commerce_naive.LeftSubclass_S'>,
 <class 'commerce_naive.RightSubclass_S'>,
 <class 'commerce_naive.BaseClass'>,
 <class 'object'>) 

类的顺序显示了super()将使用什么顺序。元组中的最后一个类通常是内置的object类。正如本章前面所提到的,它是所有类的隐式超类。

这显示了super()实际上在做什么。由于print语句是在super调用之后执行的,所以打印的输出顺序是每个方法实际执行的顺序。让我们从后往前查看输出,看看谁在调用什么:

  1. 我们从Subclass_S.call_me()方法开始。这会评估super().call_me()。MRO(方法解析顺序)显示下一个是LeftSubclass_S

  2. 我们开始评估 LeftSubclass_S.call_me() 方法。这会评估 super().call_me()。MRO(方法解析顺序)将 RightSubclass_S 作为下一个。这并不是一个超类;它在类菱形中是相邻的。

  3. RightSubclass_S.call_me()方法和super().call_me()方法的评估,这会导致BaseClass

  4. BaseClass.call_me() 方法完成了其处理过程:打印一条消息并将实例变量 self.num_base_calls 设置为 BaseClass.num_base_calls + 1

  5. 然后,RightSubclass_S.call_me() 方法可以完成,打印一条消息并设置一个实例变量,self.num_right_calls

  6. 然后,LeftSubclass_S.call_me() 方法将通过打印一条消息并设置一个实例变量,self.num_left_calls 来结束。

  7. 这是为了为Subclass_S完成其call_me()方法处理做好准备。它写了一条消息,设置了一个实例变量,然后休息,感到快乐且成功。

特别留意这一点super调用并不是在LeftSubclass_S(其父类是BaseClass)的父类上调用方法。相反,它是在RightSubclass_S上调用,即使它不是LeftSubclass_S的直接父类!这是 MRO(方法解析顺序)中的下一个类,而不是父类方法。然后RightSubclass_S调用BaseClass,而super()调用确保了类层次结构中的每个方法只执行一次。

不同的参数集

这将使得事情变得复杂,因为我们回到了我们的Friend合作式多重继承示例。在Friend类的__init__()方法中,我们最初是将初始化委托给两个父类的__init__()方法,使用不同的参数集

Contact.__init__(self, name, email)
AddressHolder.__init__(self, street, city, state, code) 

当使用super()时,我们如何管理不同的参数集?我们实际上只能访问 MRO(方法解析顺序)序列中的下一个类。正因为如此,我们需要一种方法,通过构造函数传递额外的参数,以便后续从其他混入类中对super()的调用能够接收到正确的参数。

它的工作原理是这样的。第一次调用super()时,将nameemail参数传递给 MRO(方法解析顺序)中的第一个类,即传递给Contact.__init__()。然后,当Contact.__init__()调用super()时,它需要能够将地址相关的参数传递给 MRO 中下一个类的__init__()方法,即AddressHolder.__init__()

这个问题通常在我们想要调用具有相同名称但参数集不同的超类方法时出现。在特殊方法名称周围经常发生冲突。在这些例子中,最常见的情况是具有不同参数集的各种__init__()方法,正如我们在这里所做的那样。

Python 没有魔法般的功能来处理具有不同 __init__() 参数的类之间的协作。因此,这需要我们在设计类参数列表时格外小心。协作的多继承方法是为任何不是每个子类实现所必需的参数接受关键字参数。一个方法必须将其意外的参数传递给其 super() 调用,以防它们对于类 MRO(方法解析顺序)序列中后续方法来说是必要的。

虽然这方法效果很好,但用类型提示来描述它却很困难。相反,我们不得不在几个关键位置关闭mypy

Python 的函数参数语法提供了一个我们可以用来做这件事的工具,但它使得整体代码看起来有些笨拙。看看 Friend 多重继承代码的一个版本:

class Contact:
    all_contacts = ContactList()
    def __init__(self, /, name: str = "", email: str = "", **kwargs: Any) -> None:
        super().__init__(**kwargs)  # type: ignore [call-arg]
        self.name = name
        self.email = email
        self.all_contacts.append(self)
    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(" f"{self.name!r}, {self.email!r}" f")"
class AddressHolder:
    def __init__(
        self,
        /,
        street: str = "",
        city: str = "",
        state: str = "",
        code: str = "",
        **kwargs: Any,
    ) -> None:
        super().__init__(**kwargs)  # type: ignore [call-arg]
        self.street = street
        self.city = city
        self.state = state
        self.code = code
class Friend(Contact, AddressHolder):
    def __init__(self, /, phone: str = "", **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self.phone = phone 

我们添加了**kwargs参数,该参数将所有额外的关键字参数值收集到一个字典中。当使用Contact(name="this", email="that", street="something")调用时,street参数被放入kwargs字典中;这些额外的参数通过super()调用传递到下一个类。特殊参数/将可以通过位置在调用中提供的参数与需要通过关键字与参数值关联的参数分开。我们还为所有字符串参数提供了一个空字符串作为默认值。

如果你不太熟悉 **kwargs 语法,它基本上收集了传递给方法的所有关键字参数,这些参数在参数列表中并未明确列出。这些参数存储在一个名为 kwargs 的字典中(我们可以将变量命名为任何我们喜欢的名字,但惯例建议使用 kwkwargs)。当我们调用一个方法时,例如,super().__init__(),并将 **kwargs 作为参数值,它会展开字典并将结果作为关键字参数传递给方法。我们将在 第八章面向对象与函数式编程的交汇处 中更深入地探讨这一点。

我们引入了两个针对 mypy(以及任何审查代码的人)的注释。# type: ignore 注释在特定行提供了一个特定的错误代码 call-arg,以忽略该行。在这种情况下,我们需要忽略 super().__init__(**kwargs) 调用,因为对 mypy 来说,运行时实际的 MRO(方法解析顺序)并不明显。作为阅读代码的人,我们可以查看 Friend 类并看到顺序:ContactAddressHolder。这个顺序意味着在 Contact 类内部,super() 函数将定位到下一个类,即 AddressHolder

然而,mypy 工具并不会这样深入地检查;它依据 class 语句中显式列出的父类列表。由于没有指定父类名称,mypy 相信 object 类将通过 super() 方法找到。由于 object.__init__() 无法接受任何参数,因此 ContactAddressHolder 中的 super().__init__(**kwargs)mypy 看来是错误的。实际上,MRO(方法解析顺序)中的类链将消耗所有各种参数,并且将没有剩余参数留给 AddressHolder 类的 __init__() 方法。

关于合作多继承的类型提示注解的更多信息,请参阅github.com/python/mypy/issues/8769。该问题持续存在的时间表明解决方案可能有多么困难。

之前的例子做了它应该做的事情。但是回答以下问题非常困难:我们需要传递哪些参数给 Friend.__init__()?这是任何计划使用该类的人首要的问题,因此应该在方法中添加一个文档字符串来解释所有父类中的参数列表。

在参数拼写错误或多余的参数情况下出现的错误信息也可能令人困惑。信息TypeError: object.__init__() takes exactly one argument (the instance to initialize)并没有提供太多关于如何将多余的参数提供给object.__init__()的信息。

我们已经讨论了在 Python 中涉及合作多继承的许多注意事项。当我们需要考虑所有可能的情况时,我们必须为此做出规划,我们的代码可能会变得混乱。

按照 mixin 模式实现的多个继承通常效果非常好。其思路是在 mixin 类中定义额外的方法,但将所有属性集中在一个宿主类层次结构中。这样可以避免合作初始化的复杂性。

使用组合进行设计通常也比复杂的多重继承效果更好。我们在第十一章“常见设计模式”和第十二章“高级设计模式”中将要讨论的许多设计模式都是基于组合的设计示例。

继承范式依赖于类之间清晰的“是一种”关系。多重继承会折叠其他不那么清晰的关系。例如,我们可以说“电子邮件是一种联系”,但似乎并不那么清晰地说“客户是电子邮件”。我们可能会说“客户有一个电子邮件地址”或“客户通过电子邮件被联系”,使用“有一个”或“被联系”来代替直接的“是一种”关系。

多态性

我们在第一章面向对象设计中接触到了多态性。这是一个描述简单概念的华丽名称:根据使用的是哪个子类,会发生不同的行为,而无需明确知道子类实际上是什么。它有时也被称为 Liskov 替换原则,以纪念芭芭拉·利斯科夫对面向对象编程的贡献。我们应该能够用任何子类替换其超类。

例如,想象一个播放音频文件的程序。一个媒体播放器可能需要加载一个AudioFile对象然后播放它。我们可以在对象上放置一个play()方法,该方法负责解压缩或提取音频并将其路由到声卡和扬声器。播放一个AudioFile的行为可能实际上非常简单,就像这样:

audio_file.play() 

然而,解压缩和提取音频文件的过程因文件类型的不同而大相径庭。当.wav文件以未压缩的形式存储时,.mp3.wma.ogg文件则全部采用了完全不同的压缩算法。

我们可以通过继承和多态来简化设计。每种类型的文件都可以由AudioFile的不同子类来表示,例如WavFileMP3File。这些子类中的每一个都会有一个play()方法,该方法会针对每个文件以不同的方式实现,以确保遵循正确的提取程序。媒体播放器对象永远不需要知道它引用的是AudioFile的哪个子类;它只需调用play(),并通过多态让对象处理播放的实际细节。让我们快速看一下一个示例框架,展示这可能是如何工作的:

from pathlib import Path
class AudioFile:
    ext: str
    def __init__(self, filepath: Path) -> None:
        if not filepath.suffix == self.ext:
            raise ValueError("Invalid file format")
        self.filepath = filepath
class MP3File(AudioFile):
    ext = ".mp3"
    def play(self) -> None:
        print(f"playing {self.filepath} as mp3")
class WavFile(AudioFile):
    ext = ".wav"
    def play(self) -> None:
        print(f"playing {self.filepath} as wav")
class OggFile(AudioFile):
    ext = ".ogg"
    def play(self) -> None:
        print(f"playing {self.filepath} as ogg") 

所有音频文件在初始化时都会检查是否给出了有效的扩展名。如果文件名不以正确的名称结尾,则会引发异常(异常将在第四章意料之外中详细说明)。

但你是否注意到父类中的 __init__() 方法是如何能够从不同的子类中访问 ext 类变量的?这就是多态性的体现。AudioFile 父类仅仅有一个类型提示,向 mypy 解释将会有一个名为 ext 的属性。它实际上并没有存储对 ext 属性的引用。当子类使用继承的方法时,就会使用子类对 ext 属性的定义。类型提示可以帮助 mypy 发现缺少属性赋值的类。

此外,AudioFile的每个子类都以不同的方式实现play()方法(这个示例实际上并没有播放音乐;音频压缩算法真的值得有一本单独的书来介绍!)。这同样是多态性的体现。媒体播放器可以使用完全相同的代码来播放文件,无论其类型如何;它不关心它正在查看的AudioFile的子类是什么。音频文件的解压缩细节被封装起来。如果我们测试这个示例,它将如我们所期望的那样工作:

>>> p_1 = MP3File(Path("Heart of the Sunrise.mp3"))
>>> p_1.play()
playing Heart of the Sunrise.mp3 as mp3
>>> p_2 = WavFile(Path("Roundabout.wav"))
>>> p_2.play()
playing Roundabout.wav as wav
>>> p_3 = OggFile(Path("Heart of the Sunrise.ogg"))
>>> p_3.play()
playing Heart of the Sunrise.ogg as ogg
>>> p_4 = MP3File(Path("The Fish.mov"))
Traceback (most recent call last):
...
ValueError: Invalid file format 

看看AudioFile.__init__()是如何在不实际知道它引用的是哪个子类的情况下检查文件类型的?

多态性实际上是面向对象编程中最酷的事情之一,它使得一些在早期范式下不可能的编程设计变得明显。然而,由于鸭子类型,Python 使得多态性看起来不那么酷。Python 中的鸭子类型允许我们使用任何提供所需行为的对象,而不必强制它成为子类。Python 的动态特性使得这一点变得微不足道。以下示例没有扩展 AudioFile,但在 Python 中可以使用完全相同的接口与之交互:

class FlacFile:
    def __init__(self, filepath: Path) -> None:
        if not filepath.suffix == ".flac":
            raise ValueError("Not a .flac file")
        self.filepath = filepath
    def play(self) -> None:
        print(f"playing {self.filepath} as flac") 

我们的媒体播放器可以像播放扩展自AudioFile类的对象一样轻松地播放FlacFile类的对象。

多态性是许多面向对象场景中使用继承最重要的原因之一。因为任何提供正确接口的对象都可以在 Python 中互换使用,这减少了需要多态公共超类的情况。继承仍然可以用于共享代码,但如果共享的仅仅是公共接口,那么鸭子类型就足够了。

这种减少对继承的需求也减少了多重继承的需求;通常,当多重继承看起来是一个有效的解决方案时,我们只需使用鸭子类型来模拟其中一个多重超类。

在某些情况下,我们可以使用typing.Protocol提示来形式化这种鸭子类型。为了使mypy了解期望,我们通常会定义多个函数或属性(或它们的组合)作为一个正式的Protocol类型。这有助于阐明类之间的关系。例如,我们可能会有以下这种定义来定义FlacFile类和AudioFile类层次结构之间的共同特征:

class Playable(Protocol):
    def play(self) -> None:
        ... 

当然,仅仅因为一个对象满足特定的协议(通过提供所需的方法或属性)并不意味着它在所有情况下都能简单地工作。它必须以一种在整体系统中有意义的方式实现该接口。仅仅因为一个对象提供了一个play()方法并不意味着它将自动与媒体播放器协同工作。这些方法除了具有相同的语法外,还必须具有相同的意义或语义。

鸭式类型的一个有用特性是,鸭式类型的对象只需提供那些实际被访问的方法和属性。例如,如果我们需要创建一个模拟文件对象来读取数据,我们可以创建一个新的对象,该对象具有read()方法;如果将要与模拟对象交互的代码不会调用它,我们就不必重写write()方法。更简洁地说,鸭式类型不需要提供对象可用的整个接口;它只需满足实际使用的协议。

案例研究

本节扩展了我们示例中的面向对象设计,即鸢尾花分类。我们已经在之前的章节中构建了这一部分,并在后续章节中将继续构建。在本章中,我们将回顾使用统一建模语言UML)创建的图表,以帮助描述和总结我们将要构建的软件。我们将从上一章继续前进,为计算k最近邻算法中“最近”的多种方式添加功能。对此有几种变体,这展示了类层次结构是如何工作的。

随着设计的不断完善,我们将探讨几个设计原则。其中一套流行的原则是SOLID原则,具体如下:

  • S. 单一职责原则。一个类应该只有一个职责。这可以意味着当应用程序的需求发生变化时,只有一个理由去修改。

  • O. 开放/封闭原则。一个类应该对扩展开放,但对修改封闭。

  • L. Liskov 替换原则。(以 Barbara Liskov 命名,她是第一个面向对象编程语言 CLU 的创造者之一。)任何子类都可以替换其超类。这往往使类层次结构集中在具有非常相似接口的类上,导致对象之间的 多态性。这是继承的本质。

  • I. 接口隔离。一个类应该拥有尽可能小的接口。这可能是这些原则中最重要的一个。类应该相对较小且独立。

  • D. 依赖倒置。这个名字听起来很特别。我们需要了解什么是糟糕的依赖关系,这样我们才知道如何将其反转以形成良好的关系。从实用主义的角度来看,我们希望类是独立的,这样 Liskov 替换就不会涉及到大量的代码更改。在 Python 中,这通常意味着在类型提示中引用超类,以确保我们有灵活性来做出更改。在某些情况下,这也意味着提供参数,这样我们就可以在不修改任何代码的情况下进行全局类更改。

我们在本章中不会探讨所有这些原则。因为我们关注的是继承,我们的设计将倾向于遵循 Liskov 替换设计原则。其他章节将涉及其他设计原则。

逻辑视图

这里是上一章案例研究中展示的一些类别的概述。那些定义中的一个重要遗漏是Hyperparameter类的classify算法:

图表描述自动生成

图 3.3:类概述

在上一章中,我们避免了深入探讨分类算法。这反映了一种常见的策略,有时被称为“先难后易”,也称为“先做简单部分”。这种策略鼓励尽可能遵循常见的模式设计,以隔离困难的部分。实际上,简单部分定义了一系列围栏,这些围栏包围并限制了新颖和未知的部分。

我们所进行的分类是基于k-最近邻算法,k-NN。给定一组已知样本和一个未知样本,我们希望找到靠近未知样本的邻居;大多数邻居告诉我们如何对新人进行分类。这意味着k通常是一个奇数,因此计算多数是容易的。我们一直在避免这个问题,“我们所说的‘最近’是什么意思?”

在传统的二维几何意义上,我们可以使用样本之间的“欧几里得”距离。给定一个位于  的未知样本和一个位于  的训练样本,这两个样本之间的欧几里得距离, ,是:

图片

我们可以将其可视化如下:

图表描述自动生成

图 3.4:欧几里得距离

我们将其称为 ED2,因为它只有二维。在我们的案例研究数据中,我们实际上有四个维度:花瓣长度、花瓣宽度、萼片长度和萼片宽度。这确实很难可视化,但数学并不太复杂。即使很难想象,我们仍然可以完整地写出来,如下所示:

图片

所有二维示例都扩展到四维,尽管想象起来很困难。在本节的图中,我们将坚持使用更容易可视化的x-y距离。但我们的真正意思是包括所有可用测量的完整四维计算。

我们可以将这个计算过程表示为一个类定义。ED 类的一个实例可以被 Hyperparameter 类使用:

class ED(Distance):
    def distance(self, s1: Sample, s2: Sample) -> float:
        return hypot(
            s1.sepal_length - s2.sepal_length,
            s1.sepal_width - s2.sepal_width,
            s1.petal_length - s2.petal_length,
            s1.petal_width - s2.petal_width,
        ) 

我们已经利用了 math.hypot() 函数来完成距离计算的平方和开方部分。我们使用了一个尚未定义的超类 Distance。我们相当确信它将会被需要,但我们暂时先不定义它。

欧几里得距离是已知样本和未知样本之间许多替代距离定义之一。有两种相对简单的方法来计算距离,它们相似,并且通常能产生一致的良好结果,而不需要平方根的复杂性:

  • 曼哈顿距离:这是你在具有方块状街区(类似于曼哈顿市的部分地区)的城市中行走的距离。

  • 切比雪夫距离:这种距离计算将对角线步长计为 1。曼哈顿计算会将这个距离计为 2。欧几里得距离将如图 3.4所示,为 

在有多种选择的情况下,我们需要创建不同的子类。这意味着我们需要一个基类来定义距离的一般概念。查看现有的定义,似乎基类可以是以下这样:

class Distance:
    """Definition of a distance computation"""
    def distance(self, s1: Sample, s2: Sample) -> float:
        pass 

这似乎捕捉到了我们所看到的距离计算的精髓。让我们再实现几个这个类的子类,以确保抽象确实有效。

曼哈顿距离是沿着x轴的总步数,加上沿着y轴的总步数。该公式使用距离的绝对值,表示为,其形式如下:

图片

这可能比直接欧几里得距离大 41%。然而,它仍然以一种可以产生良好的k-NN 结果的方式与直接距离平行,但计算速度更快,因为它避免了平方数字和计算平方根。

这里是曼哈顿距离的视图:

包含图表的图片 自动生成描述

图 3.5:曼哈顿距离

这里是一个计算这种变化的Distance子类的示例:

class MD(Distance):
    def distance(self, s1: Sample, s2: Sample) -> float:
        return sum(
            [
                abs(s1.sepal_length - s2.sepal_length),
                abs(s1.sepal_width - s2.sepal_width),
                abs(s1.petal_length - s2.petal_length),
                abs(s1.petal_width - s2.petal_width),
            ]
        ) 

切比雪夫距离是绝对 xy 距离中最大的。这倾向于最小化多个维度的影响:

图片

这里是切比雪夫距离的视图;它倾向于强调彼此更近的邻居:

图表描述自动生成

图 3.6:切比雪夫距离

这里是一个Distance子类的示例,它实现了距离计算的这种变体:

class CD(Distance())
    def distance(self, s1: Sample, s2: Sample) -> float:
        return sum(
            [
                abs(s1.sepal_length - s2.sepal_length),
                abs(s1.sepal_width - s2.sepal_width),
                abs(s1.petal_length - s2.petal_length),
                abs(s1.petal_width - s2.petal_width),
            ]
        ) 

查阅 距离度量选择对 KNN 分类器性能的影响 - 一篇综述 (arxiv.org/pdf/1708.04321.pdf)。该论文包含了 54 种不同的度量计算。我们所研究的例子被统称为“闵可夫斯基”度量,因为它们相似且对每个轴的度量相等。每种替代距离策略在给定一组训练数据的情况下,对模型分类未知样本的能力都会产生不同的结果。

这改变了超参数类的理念:我们现在有两个不同的超参数。一个是决定要检查多少个邻居的k值,另一个是距离计算,它告诉我们如何计算“最近”。这些都是算法的可变部分,我们需要测试各种组合,看看哪种最适合我们的数据。

我们如何能够拥有所有这些不同的距离计算方法呢?简短的回答是我们需要为公共距离类定义大量的子类。上述综述论文让我们将领域缩小到几个更有用的距离计算方法。为了确保我们有一个良好的设计,让我们再看看一种更多的距离计算方法。

另一种距离

只为了让你们更清楚地了解添加子类有多简单,我们将定义一个稍微复杂一些的距离度量。这就是 Sorensen 距离,也称为 Bray-Curtis。如果我们距离类能够处理这类更复杂的公式,我们就可以有信心它能够处理其他情况:

图片

我们通过除以可能值的范围,有效地标准化了曼哈顿距离的每个组成部分。

这里有一个图表来展示索伦森距离是如何工作的:

图表描述自动生成,置信度中等

图 3.7:曼哈顿距离与索伦森距离

简单的曼哈顿距离无论我们离原点有多远都适用。Sorensen 距离降低了远离原点的度量值的重要性,这样它们就不会因为是大数值的异常值而主导k-NN。

我们可以通过添加一个新的Distance子类来将此概念引入我们的设计中。虽然这在某些方面与曼哈顿距离相似,但它通常被单独分类:

class SD(Distance):
    def distance(self, s1: Sample, s2: Sample) -> float:
        return sum(
            [
                abs(s1.sepal_length - s2.sepal_length),
                abs(s1.sepal_width - s2.sepal_width),
                abs(s1.petal_length - s2.petal_length),
                abs(s1.petal_width - s2.petal_width),
            ]
        ) / sum(
            [
                s1.sepal_length + s2.sepal_length,
                s1.sepal_width + s2.sepal_width,
                s1.petal_length + s2.petal_length,
                s1.petal_width + s2.petal_width,
            ]
        ) 

这种设计方法使我们能够利用面向对象的继承来构建一个多态的距离计算函数族。我们可以在前几个函数的基础上创建一个广泛的函数族,并将这些函数用作超参数调整的一部分,以找到测量距离和执行所需分类的最佳方式。

我们需要将一个Distance对象集成到Hyperparameter类中。这意味着提供这些子类中的一个实例。因为它们都在实现相同的distance()方法,我们可以替换不同的替代距离计算方法,以找到在我们独特的数据和属性集合中表现最佳的方法。

目前,我们可以在我们的Hyperparameter类定义中参考一个特定的距离子类。在第十一章常见设计模式中,我们将探讨如何从Distance类定义的层次结构中灵活地插入任何可能的距离计算方法。

回忆

本章的一些关键点:

  • 面向对象设计的一个核心原则是继承:子类可以继承超类的一些特性,从而节省复制粘贴编程。子类可以扩展超类以添加功能或以其他方式对超类进行专门化。

  • 多继承是 Python 的一个特性。最常见的形式是主类与混合类定义的组合。我们可以利用方法解析顺序来组合多个类,以处理初始化等常见功能。

  • 多态性允许我们创建多个类,这些类提供满足合同的不同实现。由于 Python 的鸭子类型规则,任何具有正确方法的类都可以相互替代。

练习

环顾你的工作空间,看看你能否用继承层次结构来描述一些物理对象。人类已经用这种分类法来划分世界数百年了,所以这不应该很难。物体类别之间是否存在任何非显而易见的继承关系?如果你要在计算机应用程序中模拟这些对象,它们会共享哪些属性和方法?哪些需要被多态地覆盖?它们之间会有哪些完全不同的属性?

现在写一些代码。不,不是指物理层次结构;那很无聊。物理实体比方法有更多的属性。想想看,在过去一年里,你有没有想要尝试解决的项目,但一直没找到时间去实现。对于你想要解决的问题,试着想一些基本的继承关系,然后实现它们。确保你也注意到了那些实际上不需要使用继承的关系类型。有没有可能需要使用多重继承的地方?你确定吗?你能看到任何可能想要使用混入(mixin)的地方吗?试着快速搭建一个原型。它不必有用,甚至不必部分工作。你已经看到了如何使用python -i来测试代码;只需编写一些代码,并在交互式解释器中测试它。如果它工作正常,就继续写更多。如果它不工作,就修复它!

现在,让我们看看案例研究中的各种距离计算。我们需要能够处理测试数据以及用户提供的未知样本。这两种类型的样本有什么共同点?你能创建一个公共超类,并使用继承来处理这两个具有相似行为的类吗?(我们还没有仔细研究k-NN 分类,但你可以提供一个“模拟”分类器,它将提供虚假答案。)

当我们观察距离计算时,可以看到一个超参数是如何作为一个包含距离算法插件作为参数之一的组合。这是否是一个好的 mixin 候选者?为什么或为什么不?mixin 有哪些限制是插件所不具备的?

摘要

我们已经从简单的继承,这是面向对象程序员工具箱中最有用的工具之一,一路发展到多重继承——这是最复杂的一种。继承可以用来向现有类和内置泛型添加功能。将相似代码抽象到父类中可以帮助提高可维护性。父类上的方法可以使用super来调用,并且在使用多重继承时,参数列表必须格式化得安全,以确保这些调用能够正常工作。

在下一章,我们将探讨处理特殊情况的微妙艺术。

第四章:预期意外之事

使用软件构建的系统可能很脆弱。虽然软件本身具有高度的预测性,但运行时环境可能会提供意外的输入和情况。设备会故障,网络不可靠,纯粹的无政府状态被释放到我们的应用程序中。我们需要有一种方法来应对困扰计算机系统的各种故障范围。

处理意外情况有两种主要方法。一种方法是在函数中返回一个可识别的错误信号值。例如,可以使用None这样的值。应用程序可以使用其他库函数来检索错误条件的相关细节。对此主题的一种变体是将操作系统请求的返回值与成功或失败指示器配对。另一种方法是中断语句的正常、顺序执行,并转向处理异常的语句。第二种方法正是 Python 所采用的:它消除了检查返回值以确定错误的需求。

在本章中,我们将学习异常,这是在正常响应不可能时抛出的特殊错误对象。特别是,我们将涵盖以下内容:

  • 如何引发异常发生

  • 当异常发生时如何恢复

  • 如何以不同的方式处理不同的异常类型

  • 当发生异常时进行清理

  • 创建新的异常类型

  • 使用异常语法进行流程控制

本章的案例研究将探讨数据验证。我们将检查多种使用异常来确保我们的分类器输入有效的方法。

我们将首先探讨 Python 的 异常 概念,以及异常是如何被引发和处理的。

抛出异常

Python 的正常行为是按照它们被找到的顺序执行语句,无论是在文件中还是在>>>提示符的交互式模式下。一些语句,特别是ifwhilefor,会改变语句执行的简单自上而下的顺序。此外,异常可以打断执行流程。异常会被引发,这会中断语句的顺序执行。

在 Python 中,抛出的异常也是一个对象。有众多不同的异常类可供选择,我们也可以轻松地定义更多的自定义异常。它们共有的一个特点是都继承自一个内置类,称为BaseException

当抛出异常时,原本应该发生的一切都被打断。取而代之的是,异常处理取代了正常处理。这说得通吗?别担心,它会的!

引发异常的最简单方式就是做一些愚蠢的事情。很可能你已经这样做过了,并且看到了异常输出。例如,每当 Python 遇到它无法理解的程序中的某一行时,它会通过SyntaxError退出,这是一种异常类型。下面是一个常见的例子:

>>> print "hello world"
  File "<input>", line 1
    print "hello world"
          ^
SyntaxError: Missing parentheses in call to 'print'. Did you mean print("hello world")? 

print() 函数要求参数必须放在括号内。因此,如果我们把前面的命令输入到 Python 3 解释器中,就会引发一个 SyntaxError 异常。

除了SyntaxError,以下示例中展示了其他一些常见的异常:

>>> x = 5 / 0
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division 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 

我们可以将这些异常情况大致分为四个类别。有些情况比较模糊,但有些边缘有一条清晰的界限:

  • 有时,这些异常是我们程序中存在明显错误的指示。像SyntaxErrorNameError这样的异常意味着我们需要找到指示的行号并修复问题。

  • 有时,这些异常是 Python 运行时出现问题的指示。可能会引发一个 RuntimeError 异常。在许多情况下,这个问题可以通过下载并安装更新的 Python 版本来解决。(或者,如果你正在与“发布候选”版本搏斗,可以向维护者报告这个错误。)

  • 一些异常是设计问题。我们可能未能正确处理边缘情况,有时甚至尝试计算空列表的平均值。这会导致ZeroDivisionError错误。当我们再次发现这些问题时,我们还得回到指示的行号。但一旦我们找到了产生的异常,我们就需要从那里回溯以找出导致异常的问题原因。某个地方将会有一个处于意外或非设计状态的对象。

  • 大多数异常都出现在我们程序的接口附近。任何用户输入,或操作系统请求,包括文件操作,都可能遇到我们程序外部资源的问题,从而导致异常。我们可以将这些接口问题进一步细分为两个子组:

    • 在不寻常或未预料到的状态下出现的外部对象。这通常发生在由于路径拼写错误而找不到文件,或者由于我们的应用程序之前崩溃并重新启动而已经存在的目录。这些情况通常会导致某种OSError,其根本原因相对明确。当用户输入错误,甚至恶意尝试破坏应用程序时,这种情况也很常见。这些应该是特定于应用程序的异常,以防止愚蠢的错误或故意的滥用。

    • 此外,还有相对较小的简单混沌类别。最终分析,计算机系统是由许多相互连接的设备组成,任何一个组件都可能表现不佳。这些情况难以预测,制定恢复策略更是难上加难。当使用小型物联网计算机时,部件较少,但可能安装在具有挑战性的物理环境中。当与拥有数千个组件的企业服务器农场一起工作时,0.1%的故障率意味着总会有东西出问题。

你可能已经注意到 Python 的所有内置异常都以名称Error结尾。在 Python 中,errorexception这两个词几乎可以互换使用。有时人们认为错误(error)比异常(exception)更为严重,但它们处理的方式完全相同。确实,前面示例中的所有错误类都以Exception(它扩展了BaseException)作为它们的超类。

抛出异常

我们将在一分钟内了解如何处理此类异常,但首先,让我们探讨如果我们正在编写一个需要通知用户或调用函数输入无效的程序时,我们应该做什么。我们可以使用 Python 使用的完全相同的机制。以下是一个简单的类,它只将偶数整数的项添加到列表中:

from typing import List
class EvenOnly(List[int]):
    def append(self, value: int) -> None:
        if not isinstance(value, int):
            raise TypeError("Only integers can be added")
        if value % 2 != 0:
            raise ValueError("Only even numbers can be added")
        super().append(value) 

这个类扩展了内置的列表,正如我们在第二章Python 中的对象中讨论的那样。我们提供了一个类型提示,表明我们正在创建一个仅包含整数对象的列表。为此,我们重写了append方法来检查两个条件,确保项目是一个偶数整数。我们首先检查输入是否是int类型的实例,然后使用取模运算符来确保它能被 2 整除。如果这两个条件中的任何一个不满足,raise关键字将导致异常发生。

raise 关键字后面跟着要抛出的异常对象。在上一个例子中,从内置的 TypeErrorValueError 类中构造了两个对象。抛出的对象也可以是我们自己创建的新 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) 

虽然这个类在演示异常行为方面很有效,但它并不擅长自己的工作。仍然可以通过索引表示法或切片表示法将其他值放入列表中。可以通过覆盖其他适当的方法来避免这些额外的行为,其中一些是魔法双下划线方法。为了真正完整,我们需要覆盖像extend()insert()__setitem__()甚至__init__()这样的方法,以确保事情从正确开始。

异常的影响

当抛出异常时,它似乎会立即停止程序执行。在异常抛出后本应运行的任何代码行都不会被执行,除非异常被except子句处理,否则程序将带错误信息退出。我们将首先检查未处理的异常,然后详细探讨异常处理。

看看这个基本函数:

from typing import NoReturn
def never_returns() -> NoReturn:
    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" 

我们为这个函数添加了NoReturn类型提示。这有助于减轻mypy的担忧,即这个函数没有返回字符串值的途径。类型提示正式地说明,这个函数预期不会返回任何值。

(请注意,mypy知道最终的return无法执行。它不会反对返回类型为NoReturn,即使有一个包含字符串字面量的return语句。很明显,这无法执行。)

如果我们执行这个函数,我们会看到第一个print()调用被执行,然后抛出异常。第二个print()函数调用永远不会被执行,同样,return语句也不会执行。下面是它的样子:

>>> never_returns()
I am about to raise an exception
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 6, in never_returns
Exception: This is always raised 

此外,如果我们有一个函数调用了另一个抛出异常的函数,那么在第二个函数抛出异常的点之后,第一个函数中就没有任何代码被执行。抛出异常会立即停止所有执行,直到异常被处理或者迫使解释器退出。为了演示,让我们添加一个调用never_returns()函数的第二个函数:

def call_exceptor() -> None:
    print("call_exceptor starts here...")
    never_returns()
    print("an exception was raised...")
    print("...so these lines don't run") 

当我们调用这个函数时,我们会看到第一条print语句执行了,以及never_returns()函数中的第一条语句。但是一旦抛出异常,其他任何操作都不会执行:

>>> call_exceptor()
call_exceptor starts here...
I am about to raise an exception
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 3, in call_exceptor
  File "<input>", line 6, in never_returns
Exception: This is always raised 

注意到 mypy 没有识别出 never_returns()call_exceptor() 中的处理方式。根据之前的示例,似乎 call_exceptor() 更适合被描述为一个 NoReturn 函数。当我们尝试这样做时,我们收到了 mypy 的警告。结果证明,mypy 的关注点相当狭窄;它相对独立地检查函数和方法定义;它没有意识到 never_returns() 会引发一个异常。

我们可以控制异常从初始raise语句的传播方式。我们可以在调用栈中的任意一个方法内对异常做出反应并处理。

查看上面未处理的异常的输出,这被称为跟踪回溯。这显示了调用栈。命令行("<module>"是在没有输入文件时使用的名称)调用了call_exceptor(),而call_exceptor()又调用了never_returns()。在never_returns()内部,异常最初被抛出。

异常沿着调用栈向上传播。在call_exceptor()函数内部,那个讨厌的never_returns()函数被调用,异常被冒泡到调用方法。从那里,它又向上提升一级到达主解释器,主解释器不知道如何处理它,于是放弃并打印了跟踪对象。

处理异常

现在我们来看看异常硬币的背面。如果我们遇到异常情况,我们的代码应该如何反应或恢复?我们通过将可能抛出异常的任何代码(无论是异常代码本身,还是调用任何可能在其内部引发异常的函数或方法)包裹在try...except子句中来处理异常。最基础的语法看起来是这样的:

def handler() -> None:
    try:
        never_returns()
        print("Never executed")
    except Exception as ex:
        print(f"I caught an exception: {ex!r}")
    print("Executed after the exception") 

如果我们使用现有的never_returns()函数运行这个简单的脚本——正如我们所非常清楚的那样,这个函数总是会抛出异常——我们会得到以下输出:

I am about to raise an exception
I caught an exception: Exception('This is always raised')
Executed after the exception 

never_returns() 函数愉快地通知我们它即将抛出一个异常,并且确实抛出了异常。handler() 函数的 except 子句捕获了这个异常。一旦捕获到异常,我们就能够自行清理(在这个例子中,通过输出我们正在处理这种情况),然后继续前进。never_returns() 函数中剩余的代码保持未执行状态,但 try: 语句之后的 handler() 函数中的代码能够恢复并继续执行。

注意tryexcept周围的缩进。try子句包含可能抛出异常的任何代码。然后except子句回到与try行相同的缩进级别。处理异常的任何代码都缩进在except子句内部。然后正常代码在原始缩进级别上继续执行。

前面代码的问题在于它使用Exception类来匹配任何类型的异常。如果我们编写了一些可能引发TypeErrorZeroDivisionError的代码,会怎样呢?我们可能需要捕获ZeroDivisionError,因为它反映了已知的对象状态,但让其他任何异常传播到控制台,因为它们反映了我们需要捕获并解决的错误。你能猜到语法吗?

这里有一个相当愚蠢的功能,它就是做这件事:

from typing import Union
def funny_division(divisor: float) -> Union[str, float]:
    try:
        return 100 / divisor
    except ZeroDivisionError:
        return "Zero is not a good idea!" 

此函数执行简单的计算。我们为divisor参数提供了float类型的类型提示。我们可以提供一个整数,普通的 Python 类型转换将起作用。mypy工具了解整数如何转换为浮点数的方式,从而避免了对参数类型的过度关注。

我们必须非常清楚关于返回类型。如果我们不抛出异常,我们将计算并返回一个浮点结果。如果我们抛出ZeroDivisionError异常,它将被处理,我们将返回一个字符串结果。还有其他异常吗?让我们试一试看看:

>>> print(funny_division(0))
Zero is not a good idea!
>>> print(funny_division(50.0))
2.0
>>> print(funny_division("hello"))
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for /: 'int' and 'str' 

输出的第一行显示,如果我们输入0,我们会得到适当的模拟。如果我们使用一个有效的数字进行调用,它将正确运行。然而,如果我们输入一个字符串(你可能想知道如何得到一个TypeError,对吧?),它将因为未处理的异常而失败。如果我们没有指定匹配ZeroDivisionError异常类,我们的处理器也会看到TypeError,并指责我们在发送字符串时除以零,这根本不是一种合适的行为。

Python 还有一种“裸除”的异常处理语法。使用 except: 而不指定匹配的异常类通常是不被推荐的,因为这会阻止应用程序在应该崩溃时简单地崩溃。我们通常使用 except Exception: 来显式捕获一组合理的异常。

空的异常语法实际上与使用 except BaseException: 相同,它试图处理通常无法恢复的系统级异常。确实,这可以使应用程序在行为异常时无法崩溃。

我们甚至可以捕获两个或更多不同类型的异常,并使用相同的代码来处理它们。以下是一个引发三种不同类型异常的示例。它使用相同的异常处理程序来处理TypeErrorZeroDivisionError,但如果您提供数字13,它也可能引发一个ValueError错误:

def funnier_division(divisor: int) -> Union[str, float]:
    try:
        if divisor == 13:
            raise ValueError("13 is an unlucky number")
        return 100 / divisor
    except (ZeroDivisionError, TypeError):
        return "Enter a number other than zero" 

我们在except子句中包含了多个异常类。这使得我们可以用一个通用的处理程序来处理各种条件。以下是我们可以如何使用一系列不同的值来测试这一点:

>>> for val in (0, "hello", 50.0, 13):
...     print(f"Testing {val!r}:", end=" ")
...     print(funnier_division(val))
...     
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 "<input>", line 3, in <module>
  File "<input>", line 4, in funnier_division
ValueError: 13 is an unlucky number 

for语句遍历多个测试输入并打印结果。如果你对print函数中的那个end参数感到好奇,它只是将默认的尾随换行符转换为一个空格,这样就可以与下一行的输出连接起来。

数字 0 和字符串都被except子句捕获,并打印出合适的错误信息。来自数字13的异常没有被捕获,因为它是一个ValueError,这并没有包含在正在处理的异常类型中。这一切都很好,但如果我们想捕获不同的异常并对它们做不同的事情呢?或者,我们可能想在处理异常之后让它继续向上冒泡到父函数,就像它从未被捕获一样?

我们不需要任何新的语法来处理这些情况。我们可以堆叠except子句,并且只有第一个匹配的将会被执行。对于第二个问题,使用不带参数的raise关键字,如果我们已经在一个异常处理程序内部,它将会重新抛出最后一个异常。观察以下代码:

def funniest_division(divisor: int) -> Union[str, float]:
    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派生出来的)。如果我们有一个匹配Exceptionexcept子句在匹配TypeError之前,那么只有Exception处理程序会被执行,因为TypeError是通过继承成为Exception的。

在我们想要特别处理一些异常情况,然后以更通用的方式处理所有剩余异常的情况下,这会很有用。在捕获所有特定异常之后,我们可以在自己的子句中列出Exception,并在那里处理通用情况。

通常,当我们捕获异常时,我们需要引用到Exception对象本身。这种情况最常发生在我们使用自定义参数定义自己的异常时,但也可能与标准异常相关。大多数异常类在其构造函数中接受一组参数,我们可能希望在异常处理程序中访问这些属性。如果我们定义了自己的Exception类,那么在捕获它时甚至可以调用自定义方法。捕获异常作为变量的语法使用as关键字:

>>> try: 
...     raise ValueError("This is an argument") 
... except ValueError as e: 
...     print(f"The exception arguments were {e.args}") 
...
The exception arguments were ('This is an argument',) 
ValueError upon initialization.

我们已经看到了处理异常的语法变体,但我们仍然不知道如何在发生异常与否的情况下执行代码。我们也不能指定仅在未发生异常时才应执行的代码。另外两个关键字 finallyelse 提供了一些额外的执行路径。这两个关键字都不需要任何额外的参数。

我们将通过带有finally子句的示例来展示。大部分情况下,我们通常使用上下文管理器而不是异常块,作为一种更干净的方式来实现无论是否发生异常中断处理都会发生的最终化操作。其理念是将最终化责任封装在上下文管理器中。

以下示例遍历了多个异常类,为每个类抛出一个实例。然后运行了一些不太复杂的异常处理代码,展示了新引入的语法:

some_exceptions = [ValueError, TypeError, IndexError, None]
for choice in some_exceptions:
    try:
        print(f"\nRaising {choice}")
        if choice:
            raise choice("An error")
        else:
            print("no exception raised")
    except ValueError:
        print("Caught a ValueError")
    except TypeError:
        print("Caught a TypeError")
    except Exception as e:
        print(f"Caught some other error: {e.__class__.__name__}")
    else:
        print("This code called if there is no exception")
    finally:
        print("This cleanup code is always called") 

如果我们运行这个示例——它几乎涵盖了所有可想象的异常处理场景——我们将看到以下输出:

(CaseStudy39) % python ch_04/src/all_exceptions.py
Raising <class 'ValueError'>
Caught a ValueError
This cleanup code is always called
Raising <class 'TypeError'>
Caught a TypeError
This cleanup code is always called
Raising <class 'IndexError'>
Caught some other error: IndexError
This cleanup code is always called
Raising None
no exception raised
This code called if there is no exception
This cleanup code is always called 

注意到在finally子句中的print语句无论发生什么情况都会被执行。这是在我们代码运行完成后执行某些任务的一种方式(即使发生了异常)。以下是一些常见的例子:

  • 清理一个打开的数据库连接

  • 关闭一个打开的文件

  • 在网络上发送关闭握手

所有这些通常都通过上下文管理器来处理,这是第八章面向对象与函数式编程的交汇,的主题之一。

虽然晦涩,但finally子句会在try子句中的return语句之后执行。虽然这可以被用于return之后的处理,但对于阅读代码的人来说也可能造成困惑。

此外,注意在未抛出异常时的输出:elsefinally 子句都会被执行。else 子句可能看起来是多余的,因为当没有抛出异常时应该执行的代码可以直接放在整个 try...except 块之后。区别在于,如果捕获并处理了异常,else 块将不会被执行。当我们后面讨论使用异常作为流程控制时,我们会看到更多关于这一点的内容。

try块之后,可以省略任何exceptelsefinally子句(尽管单独的else是无效的)。如果你包含多个子句,except子句必须首先出现,然后是else子句,最后是finally子句。你必须确保except子句的顺序是从最具体的子类到最通用的超类。

异常层次结构

我们已经看到了几个最常见的内置异常,你可能在日常的 Python 开发过程中会遇到其余的。正如我们之前注意到的,大多数异常都是Exception类的子类。但并非所有异常都如此。实际上,Exception类扩展了一个名为BaseException的类。实际上,所有异常都必须扩展BaseException类或其子类之一。

有两个关键的内置异常类,SystemExitKeyboardInterrupt,它们直接从 BaseException 类派生,而不是从 Exception 类派生。SystemExit 异常在程序自然退出时被抛出,通常是因为我们在代码的某个地方调用了 sys.exit() 函数(例如,当用户选择退出菜单项、点击窗口上的“关闭”按钮、输入命令关闭服务器,或者操作系统向应用程序发送信号以终止)。这个异常的设计目的是为了让我们在程序最终退出之前清理代码。

如果我们处理了SystemExit异常,我们通常会重新抛出异常,因为捕获它可能会阻止程序退出。想象一下一个存在 bug 的 web 服务,它正在锁定数据库,并且不重启服务器就无法停止。

我们不希望SystemExit异常被意外地捕获在通用的except Exception:子句中。这就是为什么它直接从BaseException派生出来的原因。

KeyboardInterrupt 异常在命令行程序中很常见。当用户使用操作系统依赖的键组合(通常是 Ctrl + C)显式中断程序执行时,会抛出此异常。对于 Linux 和 macOS 用户,kill -2 <pid> 命令也会生效。这是用户故意中断正在运行程序的标准方式,并且,像 SystemExit 异常一样,它几乎总是通过终止程序来响应。此外,像 SystemExit 一样,它可以在 finally 块内处理任何清理任务。

这里是一个完全展示层次的类图:

图表描述自动生成

图 4.1:异常层次结构

当我们使用不带指定异常类型的 except: 子句时,它将捕获 BaseException 的所有子类;也就是说,它将捕获所有异常,包括两个特殊的异常。由于我们几乎总是希望对这些异常进行特殊处理,因此在不带参数的情况下使用 except: 语句是不明智的。如果你想要捕获所有异常(除了 SystemExitKeyboardInterrupt),请始终显式捕获 Exception。大多数 Python 开发者认为不带类型的 except: 是一个错误,并在代码审查中将其标记出来。

定义我们自己的异常

有时,当我们想要抛出一个异常时,我们会发现内置的任何异常都不合适。区别通常集中在应用程序必须如何处理异常;当我们引入一个新的异常时,必须是因为在异常处理程序中会有不同的处理方式。

没有充分的理由去定义一个与ValueError处理方式完全相同的异常;我们可以直接使用ValueError。幸运的是,定义我们自己的新异常非常简单。类的名称通常被设计用来传达出了什么问题,我们可以在初始化器中提供任意的参数来包含额外的信息。

我们只需要从Exception类或其中一个语义上相似的现有异常类继承。我们甚至不需要向类中添加任何内容!当然,我们也可以直接扩展BaseException,但这意味着我们正在发明新的停止运行程序的方法,这是一个非常不寻常的事情去创造。

这里是一个我们可能在银行应用程序中使用的简单异常:

>>> class InvalidWithdrawal(ValueError): 
...     pass 

>>> raise InvalidWithdrawal("You don't have $50 in your account")
Traceback (most recent call last):
  File "<input>", line 1, in <module>
InvalidWithdrawal: You don't have $50 in your account 

raise语句说明了如何引发新定义的异常。我们能够向异常传递任意数量的参数。通常使用字符串消息,但任何可能在后续异常处理中有用的对象都可以存储。Exception.__init__()方法被设计为接受任何参数并将它们存储为一个名为args的属性中的元组。这使得定义异常变得更加容易,而无需覆盖__init__()方法。

当然,如果我们确实想要自定义初始化器,我们完全可以这样做。以下是上述异常的修订版,其初始化器接受当前余额和用户想要取出的金额。此外,它还增加了一个方法来计算请求的超支程度:

>>> from decimal import Decimal
>>> class InvalidWithdrawal(ValueError): 
...     def __init__(self, balance: Decimal, amount: Decimal) -> None: 
...         super().__init__(f"account doesn't have ${amount}") 
...         self.amount = amount 
...         self.balance = balance 
...     def overage(self) -> Decimal: 
...         return self.amount - self.balance 

由于我们处理的是货币,我们已经导入了数字的Decimal类。对于有固定小数位数和复杂四舍五入规则的货币,我们不能使用 Python 的默认intfloat类型,因为这些类型假设了精确的小数运算。

(同时请注意,账号号码不属于例外范围。银行家们对在日志或跟踪消息中可能暴露账号号码的使用方式表示不满。)

这里是一个创建此异常实例的示例:

>>> raise InvalidWithdrawal(Decimal('25.00'), Decimal('50.00'))
Traceback (most recent call last):
...
InvalidWithdrawal: account doesn't have $50.00 

如有InvalidWithdrawal异常抛出,我们将这样处理:

>>> try: 
...     balance = Decimal('25.00')
...     raise InvalidWithdrawal(balance, Decimal('50.00')) 
... except InvalidWithdrawal as ex: 
...     print("I'm sorry, but your withdrawal is " 
...             "more than your balance by " 
...             f"${ex.overage()}") 

在这里,我们看到as关键字被有效用于将异常保存在局部变量ex中。按照惯例,大多数 Python 程序员会将异常赋值给变量如exexcexception;尽管如此,通常情况下,你可以自由地将其命名为the_exception_raised_above,或者如果你愿意,可以叫它aunt_sally

定义我们自己的异常有很多原因。通常情况下,向异常中添加信息或以某种方式记录它是很有用的。但是,当创建一个旨在供其他程序员访问的框架、库或 API 时,自定义异常的实用性才能真正显现出来。在这种情况下,务必确保你的代码抛出的异常对客户端程序员来说是合理的。以下是一些标准:

  • 他们应该清楚地描述发生了什么。例如,KeyError 异常提供了找不到的键。

  • 客户端程序员应该能够轻松地看到如何修复错误(如果它反映了他们代码中的错误)或处理异常(如果这是一个他们需要了解的情况)。

  • 处理方式应与其他异常区分开来。如果处理方式与现有异常相同,则重用现有异常为最佳选择。

现在我们已经了解了如何抛出异常和定义新的异常,我们可以看看围绕异常数据和应对问题的一些设计考虑。存在许多不同的设计选择,我们将从这样一个观点开始,即在 Python 中,异常可以用于许多并非严格意义上的错误情况。

异常并不特殊

新手程序员往往认为异常情况仅适用于特殊情况。然而,特殊情况的定义可能模糊且具有主观性。考虑以下两个函数:

def divide_with_exception(dividend: int, divisor: int) -> None:
    try:
        print(f"{dividend / divisor=}")
    except ZeroDivisionError:
        print("You can't divide by zero")
def divide_with_if(dividend: int, divisor: int) -> None:
    if divisor == 0:
        print("You can't divide by zero")
    else:
        print(f"{dividend / divisor=}") 

这两个函数的行为完全相同。如果divisor为零,则会打印错误信息;否则,会显示除法结果的打印信息。我们可以通过使用if语句来检查它,从而避免ZeroDivisionError异常被抛出。在这个例子中,对有效除法的检查看起来相对简单(divisor == 0)。在某些情况下,它可能相当复杂。在某些情况下,它可能涉及计算中间结果。在最坏的情况下,对“这会起作用吗?”的测试涉及到使用类中的多个其他方法——实际上——进行操作预演,以查看过程中是否会出现错误。

Python 程序员倾向于遵循一个被总结为“求原谅比求许可更容易”的模式,有时简称为 EAFP。其核心思想是先执行代码,然后处理可能出现的任何错误。另一种做法被描述为“三思而后行”,通常简称为 LBYL。这通常不太受欢迎。原因有几个,但最主要的原因是,在代码的正常执行路径中,通常没有必要浪费 CPU 周期去寻找那些不太可能发生的不寻常情况。

因此,在特殊情况下使用异常是明智的,即使这些情况只是稍微有些特殊。进一步来说,异常语法在流程控制中可以非常有效。就像一个if语句一样,异常可以被用来进行决策、分支和消息传递。

想象一下一家销售小工具和配件的公司库存应用。当顾客进行购买时,该商品可以是可用的,在这种情况下,商品将从库存中移除,并返回剩余商品的数量,或者它可能已经缺货。现在,在库存应用中缺货是完全正常的事情发生。这绝对不是一种异常情况。但如果缺货了,我们应该返回什么?一条说“缺货”的字符串?一个负数?在这两种情况下,调用方法都必须检查返回值是正整数还是其他东西,以确定是否缺货。这看起来有点混乱,尤其是如果我们忘记在代码的某个地方做这件事的话。

相反,我们可以抛出一个 OutOfStock 异常,并使用 try 语句来控制程序流程。这说得通吗?此外,我们还想确保不会将同一件商品卖给两个不同的客户,或者出售尚未到货的商品。实现这一目标的一种方法是将每种类型的商品锁定,以确保一次只能有一个人更新它。用户必须锁定商品,操作商品(购买、增加库存、计算剩余数量...),然后解锁商品。(这实际上是一个上下文管理器,是 第八章 的一个主题。)

这里是一个带有文档字符串的、不完整的库存示例,它描述了某些方法应该执行的操作:

class OutOfStock(Exception):
    pass
class InvalidItemType(Exception):
    pass
class Inventory:
    def __init__(self, stock: list[ItemType]) -> None:
        pass
    def lock(self, item_type: ItemType) -> None:
        """Context Entry.
        Lock the item type so nobody else can manipulate the
        inventory while we're working."""
        pass
    def unlock(self, item_type: ItemType) -> None:
        """Context Exit.
        Unlock the item type."""
        pass
    def purchase(self, item_type: ItemType) -> int:
        """If the item is not locked, raise a
        ValueError because something went wrong.
        If the item_type does not exist,
          raise InvalidItemType.
        If the item is currently out of stock,
          raise OutOfStock.
        If the item is available,
          subtract one item; return the number of items left.
        """
        # Mocked results.
        if item_type.name == "Widget":
            raise OutOfStock(item_type)
        elif item_type.name == "Gadget":
            return 42
        else:
            raise InvalidItemType(item_type) 

我们可以将这个对象原型交给开发者,并让他们实现所需的方法,确保它们按照所说的那样执行,同时我们专注于编写完成购买所需的代码。我们将利用 Python 强大的异常处理功能来考虑不同的分支,这取决于购买的方式。我们甚至可以编写一个测试用例,以确保对这个类应该如何工作没有任何疑问。

这里是ItemType的定义,只是为了完善这个例子:

class ItemType:
    def __init__(self, name: str) -> None:
        self.name = name
        self.on_hand = 0 

这里是一个使用这个Inventory类的交互会话:

>>> widget = ItemType("Widget")
>>> gadget = ItemType("Gadget")
>>> inv = Inventory([widget, gadget])
>>> item_to_buy = widget
>>> inv.lock(item_to_buy)
>>> try:
...     num_left = inv.purchase(item_to_buy)
... except InvalidItemType:
...     print(f"Sorry, we don't sell {item_to_buy.name}")
... except OutOfStock:
...     print("Sorry, that item is out of stock.")
... else:
...     print(f"Purchase complete. There are {num_left} {item_to_buy.name}s left")
... finally:
...     inv.unlock(item_to_buy)
...
Sorry, that item is out of stock. 

所有可能的异常处理子句都被用来确保在正确的时间发生正确的操作。即使OutOfStock并不是一个特别异常的情况,我们仍然能够使用异常来适当地处理它。同样的代码也可以用if...elif...else结构来编写,但这样阅读和维护起来就不会那么容易。

作为一个插曲,其中一条异常信息“There are {num_left} {item_to_buy.name}s left”存在一个蹩脚的英语语法问题。当只剩下一个物品时,它需要经过重大修改为“There is {num_left} {item_to_buy.name} left”。为了支持合理的翻译方法,最好避免在 f-string 内部篡改语法细节。最好在else:子句中处理它,使用类似以下的方法来选择合适的语法信息:

msg = (
    f"there is {num_left} {item_to_buy.name} left" 
    if num_left == 1 
    else f"there are {num_left} {item_to_buy.name}s left")
print(msg) 

我们还可以使用异常在方法之间传递消息。例如,如果我们想通知客户商品预计何时再次有库存,我们可以在构造OutOfStock对象时确保它需要一个back_in_stock参数。然后,当我们处理异常时,我们可以检查该值并向客户提供额外信息。附加到对象的信息可以轻松地在程序的两个不同部分之间传递。异常甚至可以提供一个方法,指示库存对象重新订购或补货商品。

使用异常进行流程控制可以设计出一些方便的程序。从这个讨论中,我们得到的重要一点是,异常并不是我们应该试图避免的坏事情。出现异常并不意味着你应该阻止这种特殊情况发生。相反,这只是在不同代码部分之间传递信息的一种强大方式,这些部分可能并没有直接相互调用。

案例研究

本章的案例研究将探讨一些我们可以找到并帮助用户修复数据或应用程序计算中潜在问题的方法。数据和处理都是异常行为可能的来源。然而,它们并不等价;我们可以如下比较这两个方面:

  • 异常数据是问题最常见的原因。数据可能不遵循语法规则,或者具有无效的物理格式。其他更小的错误可能源于数据没有公认的逻辑组织,例如列名拼写错误。异常也可能反映用户试图执行未经授权的操作。我们需要提醒用户和管理员注意无效数据或无效操作。

  • 异常处理通常被称为错误。应用程序不应该尝试从这些问题中恢复。虽然我们更喜欢在单元测试或集成测试中找到它们(参见第十三章面向对象程序的测试),但有可能一个问题逃过了我们的审查,最终在生产环境中出现,并暴露给了我们软件的用户。我们需要告诉用户有些东西出了问题,并且尽可能优雅地停止处理,或者“崩溃”。在有错误的情况下继续运行是一种严重的信任破坏。

在我们的案例研究中,我们需要检查三种类型的输入,以寻找潜在的问题:

  1. 由植物学家提供的已知样本实例,反映了专家判断。尽管这些数据在质量上应该是典范的,但无法保证没有人不小心重命名了一个文件,用无效数据或无法处理的数据替换了好的数据。

  2. 由研究人员提供的未知Sample实例,这些可能存在各种数据质量问题。我们将查看其中的一些。

  3. 研究员或植物学家采取的行动。我们将审查使用案例,以确定每个用户类别应允许采取哪些行动。在某些情况下,通过为每个用户类别提供他们可以采取的特定操作菜单,可以预防这些问题。

我们将首先回顾使用案例,以便确定这个应用程序所需的异常类型。

上下文视图

在第一章的上下文图中,“用户”这一角色的描述——目前来看——并不理想。作为对应用程序接口的初始描述,它是可以容忍的。随着我们逐步进行设计,我们可以看到,像“研究员”这样的更具体术语可能更适合描述那些研究样本并寻找分类的人。

这里是一个考虑了用户及其授权操作的新扩展上下文图:

图表描述自动生成

图 4.2:应用上下文图

植物学家负责一种类型的数据,并且有两个有效的操作。研究人员负责另一种类型的数据,并且只有一个有效的操作。

数据和处理用例紧密相连。当植物学家提供新的训练数据或设置参数并测试分类器时,应用程序软件必须确保他们的输入是有效的。

同样地,当研究员试图对样本进行分类时,软件必须确认数据是有效的并且可以使用的。无效数据必须报告给研究员,以便他们可以修复输入并再次尝试。

我们可以将处理不良数据分解为两部分,每部分分别处理:

  • 发现异常数据。正如我们在本章所看到的,当遇到无效数据时,这通过抛出一个异常来实现。

  • 应对异常数据。这通过一个try:/except:块实现,该块提供了关于问题性质和可能采取的解决措施的有用信息。

我们首先从发现异常数据开始。正确地抛出异常是处理不良数据的基础。

处理视图

尽管在这个应用程序中有许多数据对象,但我们将把我们的焦点缩小到KnownSampleUnknownSample类。这两个类与一个共同的超类Sample类相关。它们是由另外两个类创建的。以下图表显示了Sample对象是如何被创建的:

图表描述自动生成

图 4.3:对象创建

我们包含了两个类来创建这两种类型的样本。TrainingData类将加载已知样本。一个整体的ClassifierApp类将验证未知样本,并尝试对其进行分类。

一个KnownSample对象有五个属性,每个属性都有一个定义明确的有效值集合:

  • 测量值花瓣长度花瓣宽度花瓣长度花瓣宽度都是浮点数。这些值的下限为零。

  • 专家提供的species值是一个字符串,有三个有效值。

一个UnknownSample对象只有四个测量值。使用公共超类定义的想法可以帮助我们确保这种验证处理被重用。

上列的有效值规则仅定义了在单独考虑每个属性时的有效值。在某些应用中,可能存在属性之间的复杂关系,或者定义样本之间关系的规则。对于我们的案例研究,我们将重点关注五个属性验证规则。

会出什么问题?

考虑到加载一个Sample对象时可能出现的错误,以及用户可以对此做些什么,这会有所帮助。我们的样本验证规则建议我们可能需要引发特殊的ValueError异常来描述那些测量值不是有效的浮点值或物种名称不是已知字符串的情况。

我们可以使用以下类来定义无法处理的不良数据条件:

class InvalidSampleError(ValueError):
    """Source data file has invalid data representation""" 

这允许我们为这个应用程序无法处理的输入数据抛出一个InvalidSampleError异常。目的是提供一个包含需要修复详情的消息。

这可以帮助我们区分代码中的错误,这些错误可能会引发一个ValueError异常,以及存在不良数据时出现的正确行为,此时将引发InvalidSampleError异常。这意味着我们需要在except:块中具体指定,使用InvalidSampleError异常。

如果我们使用 except ValueError:,它将处理通用异常以及我们独特的异常。这意味着我们可能会将更严重的错误视为无效数据。技巧是要小心处理通用异常;我们可能是在绕过一个错误。

不良行为

之前我们建议用户可能会尝试执行一个无效的操作。例如,研究人员可能会尝试提供分类的已知样本对象。加载新的训练数据的行为保留给植物学家;这意味着研究人员的尝试应该引发某种类型的异常。

我们的应用程序在整体操作系统的环境中运行。对于命令行应用程序,我们可以将用户分为两组,并使用操作系统的文件所有权和访问权限来限制哪一组可以运行哪些应用程序。这是一个有效且全面的解决方案,且不需要任何 Python 代码。

对于基于 Web 的应用程序,然而,我们需要对每个用户进行 Web 应用程序的认证。所有 Python 的 Web 应用程序框架都提供了用户认证机制。许多框架都提供了方便的插件,用于系统如开放认证、OAuth。更多信息请参阅oauth.net/2/

对于 Web 应用,我们通常有两层处理级别:

  • 用户认证。这是用户进行身份识别的地方。这可能涉及单一因素,如密码,或者多个因素,如物理钥匙或与手机的交互。

  • 授权执行某些操作。我们通常会为用户定义角色,并根据用户的角色限制对各种资源的访问。这意味着当用户没有适当的角色来访问资源时,会引发一个异常。

许多 Web 框架会将异常用作一个内部信号,表示某些操作不被允许。然后,这个内部异常必须映射到外部的 HTTP 状态码,例如401 授权所需响应。

这是一个深入的话题,超出了本书的范围。例如,请参阅使用 Flask 构建 Web 应用程序(www.packtpub.com/product/building-web-applications-with-flask/9781784396152)以了解 Web 应用程序的介绍。

从 CSV 文件创建样本

不同文件格式下读取样本的详细选项,我们需要参考第九章字符串、序列化和文件路径,在那里我们详细讨论了序列化技术。目前,我们将跳过许多细节,并专注于一种非常适合 CSV 格式数据的处理方法。

CSV逗号分隔值 – 可以用来定义电子表格的行。在每一行中,单元格值以文本形式表示,并用逗号分隔。当这些数据被 Python 的 csv 模块解析时,每一行可以表示为一个字典,其中键是列名,值是特定行的单元格值。

例如,一行可能看起来像这样:

>>> row = {"sepal_length": "5.1", "sepal_width": "3.5", 
... "petal_length": "1.4", "petal_width": "0.2", 
... "species": "Iris-setosa"} 

csv模块的DictReader类提供了一系列的dict[str, str]类型的行实例。我们需要将这些原始行转换为Sample的一个子类的实例,如果所有特征都有有效的字符串值。如果原始数据无效,那么我们需要抛出一个异常。

给定如上例所示的行,这里有一个方法可以将字典翻译成更有用的对象。这是KnownSample类的一部分:

@classmethod
def from_dict(cls, row: dict[str, str]) -> "KnownSample":
    if row["species"] not in {
            "Iris-setosa", "Iris-versicolour", "Iris-virginica"}:
        raise InvalidSampleError(f"invalid species in {row!r}")
    try:
        return cls(
            species=row["species"],
            sepal_length=float(row["sepal_length"]),
            sepal_width=float(row["sepal_width"]),
            petal_length=float(row["petal_length"]),
            petal_width=float(row["petal_width"]),
        )
    except ValueError as ex:
        raise InvalidSampleError(f"invalid {row!r}") 

from_dict() 方法会检查物种值,如果无效则抛出异常。它尝试创建一行,应用 float() 函数将各种测量值从字符串值转换为浮点值。如果转换都成功,那么 cls 参数——要创建的类——将构建预期的对象。

如果float()函数的任何评估遇到问题并引发ValueError异常;这被用来创建我们应用程序的特定InvalidSampleError异常。

这种验证方式是先观察再行动LBYL)和求原谅比求许可更容易EAFP)两种风格的混合体。在 Python 中,最广泛使用的方法是 EAFP。然而,对于物种值的情况,没有类似于float()的转换函数来引发异常或处理错误数据。在这个例子中,我们选择使用 LBYL 来处理这个属性值。下面我们将探讨一个替代方案。

from_dict() 方法使用 @classmethod 装饰器定义。这意味着实际的类对象成为第一个参数,cls。当我们这样做时,意味着任何继承这个方法的子类都将拥有针对该子类定制的功能。我们可以创建一个新的子类,例如,TrainingKnownSample,使用如下代码:

class TrainingKnownSample(KnownSample): 
    pass 

TrainingKnownSample.from_dict() 方法将接受 TrainingKnownSample 类作为 cls 参数的值;在不添加任何其他代码的情况下,这个类的 from_dict() 方法将构建 TrainingKnownSample 类的实例。

虽然这样做效果不错,但对mypy来说并不明确它是否有效。我们可以使用以下定义来提供显式的类型映射:

class TrainingKnownSample(KnownSample):
    @classmethod
    def from_dict(cls, row: dict[str, str]) -> "TrainingKnownSample":
        return cast(TrainingKnownSample, super().from_dict(row)) 

另一种选择是使用更简单的类定义,并将cast()操作放在实际使用from_dict()的地方,例如,cast(TrainingKnownSample, TrainingKnownSample.from_dict(data))。由于这种方法在许多地方都没有使用,因此很难断言哪种变体更简单。

这里是来自上一章的KnownSample类的其余部分,重复如下:

class KnownSample(Sample):
    def __init__(
        self,
        species: str,
        sepal_length: float,
        sepal_width: float,
        petal_length: float,
        petal_width: float,
    ) -> None:
        super().__init__(
            sepal_length=sepal_length,
            sepal_width=sepal_width,
            petal_length=petal_length,
            petal_width=petal_width,
        )
        self.species = species
    def __repr__(self) -> str:
        return (
            f"{self.__class__.__name__}("
            f"sepal_length={self.sepal_length}, "
            f"sepal_width={self.sepal_width}, "
            f"petal_length={self.petal_length}, "
            f"petal_width={self.petal_width}, "
            f"species={self.species!r}, "
            f")"
        ) 

让我们看看这在实际中是如何工作的。以下是一个加载一些有效数据的示例:

>>> from model import TrainingKnownSample
>>> valid = {"sepal_length": "5.1", "sepal_width": "3.5",
...  "petal_length": "1.4", "petal_width": "0.2",
...  "species": "Iris-setosa"}
>>> rks = TrainingKnownSample.from_dict(valid)
>>> rks
TrainingKnownSample(sepal_length=5.1, sepal_width=3.5, petal_length=1.4, petal_width=0.2, species='Iris-setosa', ) 

我们创建了一个名为 valid 的字典,这是一个 csv.DictReader 从输入行创建的。然后,我们从该字典构建了一个 TrainingKnownSample 实例,命名为 rks。生成的对象具有适当的浮点数值,表明已按需执行了字符串到数值的转换。

这里展示了验证的行为。这是一个为不良数据引发的异常类型的示例:

>>> from model import TestingKnownSample, InvalidSampleError
>>> invalid_species = {"sepal_length": "5.1", "sepal_width": "3.5",
...  "petal_length": "1.4", "petal_width": "0.2",
...  "species": "nothing known by this app"}
>>> eks = TestingKnownSample.from_dict(invalid_species)
Traceback (most recent call last):
...
model.InvalidSampleError: invalid species in {'sepal_length': '5.1', 'sepal_width': '3.5', 'petal_length': '1.4', 'petal_width': '0.2', 'species': 'nothing known by this app'} 

当我们尝试创建一个TestingKnownSample实例时,无效的物种值引发了一个异常。

我们是否已经发现了所有潜在的问题?csv模块处理物理格式问题,因此提供 PDF 文件等,将导致csv模块抛出异常。在from_dict()方法中会检查无效的物种名称和浮点值。

有些事情我们没有检查。以下是额外的验证:

  • 缺少的键。如果一个键拼写错误,此代码将引发一个KeyError异常,它不会被重新表述为InvalidSampleError异常。这个变化留给读者作为练习。

  • 额外键。如果出现意外的列,数据是否无效,或者我们应该忽略它们?可能的情况是,我们得到了包含额外列的电子表格数据,这些列应该被忽略。虽然灵活性是有帮助的,但同时也非常重要,要揭示输入中可能存在的问题。

  • 超出范围的浮点值。测量范围很可能存在一些合理的上下限。下限为零是显然的;负数测量没有太多意义。然而,上限并不那么明确。有一些统计技术用于定位异常值,包括中值绝对偏差MAD)技术。

    查阅www.itl.nist.gov/div898/handbook/eda/section3/eda35h.htm获取更多关于如何识别似乎不符合正态分布的数据的信息。

这些额外检查中的第一个可以添加到from_dict()方法中。第二个则是一个必须与用户达成一致的决策,然后可能添加到from_dict()方法中。

异常值检测更为复杂。我们需要在所有测试和训练样本加载完毕后执行此检查。因为异常值检查不适用于单行,它需要一个不同的异常处理。我们可能定义另一个异常,如下所示:

class OutlierError(ValueError):
    """Value lies outside the expected range.""" 

这个异常可以使用简单的范围检查,或者更复杂的 MAD 方法来检测异常值。

验证枚举值

有效的物种列表并不十分明显。我们基本上将它隐藏在了from_dict()方法中,这可能会成为一个维护问题。当源数据发生变化时,我们还需要更新这个方法,这可能会很难记住,而且几乎和找到它一样困难。如果物种列表变得很长,代码行可能会变得难以阅读。

使用带有有效值列表的显式enum类是将此转换为纯 EAFP 处理的一种方法。考虑使用以下方式来验证物种。这样做意味着重新定义多个类:

>>> from enum import Enum
>>> class Species(Enum):
...    Setosa = "Iris-setosa"
...    Versicolour = "Iris-versicolour"
...    Viginica = "Iris-virginica"
>>> Species("Iris-setosa")
<Species.Setosa: 'Iris-setosa'>
>>> Species("Iris-pinniped")
Traceback (most recent call last):
...
ValueError: 'Iris-pinniped' is not a valid Species 

当我们将enum类名称Species应用于枚举字面值之一时,它将引发一个ValueError异常来显示物种的字符串表示形式无效。这类似于float()int()对于不是有效数字的字符串引发ValueError异常的方式。

将值切换为枚举值也将需要修改已知样本的类定义。类需要修改为使用枚举Species而不是str。对于这个案例研究,值列表很小,使用Enum似乎很实用。然而,对于其他问题域,值的枚举列表可能相当大,并且Enum类可能很长且信息量不足。

我们可能继续使用字符串对象而不是Enum类。我们可以将每个唯一的字符串值域定义为Set[str]类的扩展:

>>> from typing import Set
>>> class Domain(Set[str]):
...     def validate(self, value: str) -> str:
...         if value in self:
...             return value
...         raise ValueError(f"invalid {value!r}")
>>> species = Domain({"Iris-setosa", "Iris-versicolour", "Iris-virginica"})
>>> species.validate("Iris-versicolour")
'Iris-versicolour'
>>> species.validate("odobenidae")
Traceback (most recent call last):
...
ValueError: invalid 'odobenidae' 

我们可以使用 species.validate() 函数,类似于我们使用 float() 函数的方式。这将验证字符串,而不会将其强制转换为不同的值。相反,它返回字符串。对于无效值,它将引发一个 ValueError 异常。

这使我们能够将from_dict()方法的主体重写如下:

@classmethod
def from_dict(cls, row: dict[str, str]) -> "KnownSample":
    try:
        return cls(
            species=species.validate(row["species"]),
            sepal_length=float(row["sepal_length"]),
            sepal_width=float(row["sepal_width"]),
            petal_length=float(row["petal_length"]),
            petal_width=float(row["petal_width"]),
        )
    except ValueError as ex:
        raise InvalidSampleError(f"invalid {row!r}") 

这种变化依赖于全局的species是一个有效的物种集合。它还使用了一种令人愉悦的 EAFP 方法来构建所需的对象或引发异常。

如我们之前提到的,这个设计分为两部分。我们已经探讨了基础元素,即抛出一个合适的异常。现在我们可以看看我们使用这个from_dict()函数的上下文,以及错误是如何报告给用户的。

读取 CSV 文件

我们将提供一个通用的模板,用于从 CSV 源数据创建对象。其思路是利用各种类的from_dict()方法来创建应用程序所使用的对象:

class TrainingData:
    def __init__(self, name: str) -> None:
        self.name = name
        self.uploaded: datetime.datetime
        self.tested: datetime.datetime
        self.training: list[TrainingKnownSample] = []
        self.testing: list[TestingKnownSample] = []
        self.tuning: list[Hyperparameter] = []
    def load(self, raw_data_iter: Iterable[dict[str, str]]) -> None:
        for n, row in enumerate(raw_data_iter):
            try:
                if n % 5 == 0:
                    test = TestingKnownSample.from_dict(row)
                    self.testing.append(test)
                else:
                    train = TrainingKnownSample.from_dict(row)
                    self.training.append(train)
            except InvalidSampleError as ex:
                print(f"Row {n+1}: {ex}")
                return
        self.uploaded = datetime.datetime.now(tz=datetime.timezone.utc) 

load()方法正在将样本划分为测试集和训练集。它期望一个可迭代的dict[str, str]对象源,这些对象由csv.DictReader对象生成。

这里实现的用户体验是报告第一次失败并返回。这可能会导致如下错误信息:

text Row 2: invalid species in {'sepal_length': 7.9, 'sepal_width': 3.2, 'petal_length': 4.7, 'petal_width': 1.4, 'species': 'Buttercup'} 

这条消息包含了所有必要的信息,但可能不如期望的那样有帮助。例如,我们可能希望报告所有失败,而不仅仅是第一次失败。以下是我们可以如何重构load()方法的示例:

def load(self, raw_data_iter: Iterable[dict[str, str]]) -> None:
    bad_count = 0
    for n, row in enumerate(raw_data_iter):
        try:
            if n % 5 == 0:
                test = TestingKnownSample.from_dict(row)
                self.testing.append(test)
            else:
                train = TrainingKnownSample.from_dict(row)
                self.training.append(train)
        except InvalidSampleError as ex:
            print(f"Row {n+1}: {ex}")
            bad_count += 1
    if bad_count != 0:
        print(f"{bad_count} invalid rows")
        return
    self.uploaded = datetime.datetime.now(tz=datetime.timezone.utc) 

这种变化会捕获每个InvalidSampleError错误,显示一条消息并计算问题数量。这些信息可能更有帮助,因为用户可以纠正所有无效的行。

在处理一个非常、非常大的数据集的情况下,这可能会导致无用细节达到一个很高的水平。如果我们不小心使用了一个包含几十万行手写数字图像的 CSV 文件,而不是鸢尾花数据,那么我们会收到几十万条消息,告诉我们每一行都是错误的。

在这个加载操作周围需要一些额外的用户体验设计,以便使其在各种情况下都非常有用。然而,其基础是当出现问题时引发的 Python 异常。在本案例研究中,我们利用了 float() 函数的 ValueError 并将其重写为我们的应用程序特有的 InvalidSampleError 异常。我们还创建了针对意外字符串的我们自己的 ValueError 异常。

不要重复自己

TrainingDataload() 方法将创建两个不同的 KnownSample 子类。我们将大部分处理工作放在了 KnownSample 超类中;这样做可以避免在每个子类中重复进行验证处理。

对于一个UnknownSample,然而,我们遇到了一个小问题:在UnknownSample中没有物种数据。理想的情况是提取四个测量的验证,并将它们与验证物种分开。如果我们这样做,我们就不能简单地通过一个简单的 EAFP 方法来同时构建一个Sample和进行验证,这个方法要么创建所需的对象,要么抛出一个无法构建的异常。

当子类引入新字段时,我们有两种选择:

  • 放弃看似简单的 EAFP 验证。在这种情况下,我们需要将验证与对象构造分离。这将导致进行两次 float() 转换的成本:一次用于验证数据,另一次用于创建目标对象。多次 float() 转换意味着我们并没有真正遵循不要重复自己DRY)的原则。

  • 构建一个中间表示,该表示可以被子类使用。这意味着SampleKnownSample子类将涉及三个步骤。首先,构建一个Sample对象,验证四个测量值。然后,验证物种。最后,使用从Sample对象中获取的有效字段和有效的物种值来构建KnownSample。这创建了一个临时对象,但避免了代码的重复。

我们将把实现细节留给读者作为练习。

一旦定义了异常,我们还需要以引导用户采取正确补救措施的形式向用户展示结果。这是基于底层异常构建的独立用户体验设计考虑因素。

回忆

本章的一些关键点:

  • 抛出异常发生在出现错误时。我们以除以零为例进行了说明。也可以使用raise语句来抛出异常。

  • 异常的效果是中断语句的正常顺序执行。它使我们免于编写大量的if语句来检查事情是否可能工作,或者检查是否真的失败了。

  • 处理异常是通过try:语句完成的,该语句为每种我们想要处理的异常都有一个except:子句。

  • 异常层次结构遵循面向对象设计模式来定义我们可以与之工作的Exception类的多个子类。一些额外的异常,如SystemExitKeyboardInterrupt,并不是Exception类的子类;处理这些异常会引入风险,并且并不能解决很多问题,所以我们通常忽略它们。

  • 定义我们自己的异常是扩展Exception类的问题。这使得我们可以定义具有非常特定语义的异常。

练习

如果你之前从未处理过异常,首先你需要查看你之前写的任何 Python 代码,注意是否有应该处理异常的地方。你会如何处理它们?你是否需要处理它们?有时,让异常传播到控制台是向用户传达信息的最有效方式,尤其是如果用户也是脚本的编写者。有时,你可以从错误中恢复,并允许程序继续运行。有时,你只能将错误重新格式化为用户可以理解的形式,并将其显示给他们。

一些常见的检查点包括文件输入输出(你的代码是否尝试读取一个不存在的文件?),数学表达式(你正在除的值是否为零?),列表索引(列表是否为空?),以及字典(键是否存在?)。

问问自己是否应该忽略这个问题,先检查值来处理它,还是通过异常来处理它。特别注意那些你可能使用了finallyelse的地方,以确保在所有条件下都能执行正确的代码。

现在编写一些新的代码,将案例研究扩展到涵盖对输入数据的任何额外验证检查。例如,我们需要检查测量值以确保它们在合理的范围内。这可以是一个额外的ValueError子类。我们可以将这个概念应用到案例研究的其他部分。例如,我们可能想要验证Sample对象以确保所有值都是正数。

案例研究在from_dict()方法中没有进行任何范围检查。检查零的下界很容易,最好将其作为第一个练习添加。

为了对各种测量结果设定上限,了解数据非常重要。首先,对数据进行调查并找出实际的最小值、最大值、中位数以及与中位数的绝对偏差是有帮助的。有了这些汇总信息,就可以定义一个合理的范围,并添加范围检查。

我们尚未解决创建UnknownSample实例的问题,将from_dict()方法留作读者的练习。在上面的不要重复自己部分,我们描述了一个实现,其中在from_dict()处理中对四个测量值的验证被重构到 Sample 类中。这导致了两个设计变更:

  • KnownSample 中,使用 Sample.from_dict() 来验证测量结果、验证物种,并构建最终的 KnownSample 对象。

  • UnknownSample 中,使用 Sample.from_dict() 验证测量结果,然后构建最终的 UnknownSample 对象。

这些更改应导致一个相对灵活的数据验证,不需要复制和粘贴测量或物种的验证规则。

最后,尝试思考一下在你的代码中可以抛出异常的地方。这可能是在你编写或正在工作的代码中,或者你可以作为一个练习写一个新的项目。你可能最有可能成功设计一个旨在供他人使用的简单框架或 API;异常是你代码与他人的代码之间一个极好的沟通工具。记住,要将任何自抛出的异常的设计和文档作为 API 的一部分,否则他们可能不知道如何处理它们!

摘要

在本章中,我们深入探讨了异常的创建、处理、定义和操作等细节。异常是一种强大的方式,可以在不要求调用函数显式检查返回值的情况下,传达异常情况或错误条件。存在许多内置的异常,抛出它们非常简单。处理不同的异常事件有不同的语法。

在下一章中,我们将把迄今为止所学的所有内容结合起来,讨论如何在 Python 应用程序中最佳地应用面向对象编程的原则和结构。

第五章:何时使用面向对象编程

在前面的章节中,我们讨论了许多面向对象编程的标志性特征。我们现在已经了解了一些面向对象设计的原理和范式,并且已经覆盖了 Python 中面向对象编程的语法。

然而,我们并不确切知道如何在实践中具体运用这些原则和语法。在本章中,我们将讨论我们所获得知识的一些有用应用,并在过程中探讨一些新的主题:

  • 如何识别物体

  • 数据和行为,再次

  • 使用属性包装数据行为

  • 不要重复自己原则和避免重复

在本章中,我们还将讨论针对案例研究问题的一些替代设计方案。我们将探讨如何将样本数据划分为训练集和测试集。

我们将从这个章节开始,仔细研究对象的本质及其内部状态。在某些情况下,没有状态变化,定义一个类可能不是所希望的。

将对象视为对象

这可能看起来很明显;你通常应该在你代码中为问题域中的单独对象提供一个特殊的类。我们在前几章的案例研究中已经看到了这样的例子:首先,我们识别问题中的对象,然后对它们的数据和行为进行建模。

识别对象是面向对象分析和编程中的一个非常重要的任务。但事实并非总是像在简短的段落中数名词那样简单,坦白地说,作者们明确地为了这个目的构建了这些段落。记住,对象是既有数据又有行为的事物。如果我们只处理数据,我们通常更倾向于将其存储在列表、集合、字典或其他 Python 数据结构中(我们将在第七章“Python 数据结构”中全面介绍)。另一方面,如果我们只处理行为而没有存储数据,一个简单的函数就更为合适。

然而,一个对象既有数据也有行为。熟练的 Python 程序员会使用内置的数据结构,除非(或者直到)有明显的需要定义一个类。如果没有帮助组织我们的代码,就没有理由添加额外的复杂性。另一方面,这种需求并不总是显而易见的。

我们通常可以通过在几个变量中存储数据来开始我们的 Python 程序。随着程序的扩展,我们后来会发现我们正在将同一组相关的变量传递给一组函数。这时,我们应该考虑将变量和函数都组合成一个类。

例如,如果我们正在设计一个用于在二维空间中模拟多边形的程序,我们可能会从将每个多边形表示为点的列表开始。这些点将被模拟为两个元组(x,y),描述该点的位置。这全部都是数据,存储在一系列嵌套的数据结构中(具体来说,是一个元组列表)。我们可以(并且经常这样做)从命令提示符开始进行修改:

>>> square = [(1,1), (1,2), (2,2), (2,1)] 

现在,如果我们想要计算多边形周长的距离,我们需要求出每个点之间的距离之和。为了做到这一点,我们需要一个函数来计算两点之间的距离。这里有两个这样的函数:

>>> from math import hypot
>>> def distance(p_1, p_2):
...     return hypot(p_1[0]-p_2[0], p_1[1]-p_2[1])
>>> def perimeter(polygon):
...     pairs = zip(polygon, polygon[1:]+polygon[:1])
...     return sum(
...         distance(p1, p2) for p1, p2 in pairs
...     ) 

我们可以练习使用函数来检查我们的工作:

>>> perimeter(square)
4.0 

这是一个开始,但它并不能完全描述问题域。我们可以大致看出多边形可能是什么样子。但我们还需要阅读整个代码块,才能了解这两个函数是如何协同工作的。

我们可以添加类型提示来帮助明确每个函数背后的意图。结果看起来像这样:

from __future__ import annotations
from math import hypot
from typing import Tuple, List
Point = Tuple[float, float]
def distance(p_1: Point, p_2: Point) -> float:
    return hypot(p_1[0] - p_2[0], p_1[1] - p_2[1])
Polygon = List[Point]
def perimeter(polygon: Polygon) -> float:
    pairs = zip(polygon, polygon[1:] + polygon[:1])
    return sum(distance(p1, p2) for p1, p2 in pairs) 

我们添加了两个类型定义,PointPolygon,以帮助阐明我们的意图。Point 的定义展示了我们将如何使用内置的 tuple 类。Polygon 的定义展示了内置的 list 类是如何建立在 Point 类之上的。

在方法参数定义内编写注释时,我们通常可以直接使用类型名称,例如,def method(self, values: list[int]) -> None:。为了使其生效,我们需要使用from __future__ import annotations。然而,在定义新的类型提示时,我们需要使用typing模块中的名称。这就是为什么新的Point类型定义在表达式Tuple[float, float]中使用了typing.Tuple

现在,作为面向对象的程序员,我们清楚地认识到一个多边形类可以封装点(数据)列表和周长函数(行为)。进一步地,一个如我们在第二章中定义的类,可以封装xy坐标以及距离方法。问题是:这样做有价值吗?

对于之前的代码,可能对,也可能不对。凭借我们最近在面向对象原则方面的经验,我们可以用创纪录的时间编写出一个面向对象的版本。让我们如下进行比较:

from math import hypot
from typing import Tuple, List, Optional, Iterable
class Point:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y
    def distance(self, other: "Point") -> float:
        return hypot(self.x - other.x, self.y - other.y)
class Polygon:
    def __init__(self) -> None:
        self.vertices: List[Point] = []
    def add_point(self, point: Point) -> None:
        self.vertices.append((point))
    def perimeter(self) -> float:
        pairs = zip(
            self.vertices, self.vertices[1:] + self.vertices[:1])
        return sum(p1.distance(p2) for p1, p2 in pairs) 

这里似乎有比我们早期版本多近两倍的代码,尽管我们可以争论add_point方法并不是严格必要的。我们还可以试图坚持使用_vertices来阻止使用属性,但使用以_开头变量名似乎并不能真正解决问题。

现在,为了更好地理解这两个类之间的区别,让我们比较一下正在使用的两个 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对象列表的初始化器:

class Polygon_2:
    def __init__(self, vertices: Optional[Iterable[Point]] = None) -> None:
        self.vertices = list(vertices) if vertices else []
    def perimeter(self) -> float:
        pairs = zip(
            self.vertices, self.vertices[1:] + self.vertices[:1])
        return sum(p1.distance(p2) for p1, p2 in pairs) 

对于perimeter()方法,我们使用了zip()函数来创建顶点的配对,通过从两个列表中抽取项目来创建一对对的序列。提供给zip()的一个列表是顶点的完整序列。另一个顶点列表从顶点 1(而不是 0)开始,并以顶点 1 之前的顶点(即顶点 0)结束。对于一个三角形,这将产生三个配对:(v[0], v[1])(v[1], v[2])(v[2], v[0])。然后我们可以使用Point.distance()来计算配对之间的距离。最后,我们求和距离序列。这似乎显著提高了事情。现在我们可以像原始的插入函数定义一样使用这个类:

>>> square = Polygon_2(
... [Point(1,1), Point(1,2), Point(2,2), Point(2,1)]
... )
>>> square.perimeter()
4.0 

了解各个方法定义的细节非常方便。我们构建了一个接近原始、简洁定义集的 API。我们添加了足够的正式性,以确保在开始编写测试用例之前,代码很可能会正常工作。

让我们再迈出一步。让我们也允许它接受元组,如果需要的话,我们可以自己构造Point对象:

Pair = Tuple[float, float]
Point_or_Tuple = Union[Point, Pair]
class Polygon_3:
    def __init__(self, vertices: Optional[Iterable[Point_or_Tuple]] = None) -> None:
        self.vertices: List[Point] = []
        if vertices:
            for point_or_tuple in vertices:
                self.vertices.append(self.make_point(point_or_tuple))
    @staticmethod
    def make_point(item: Point_or_Tuple) -> Point:
        return item if isinstance(item, Point) else Point(*item) 

此初始化器遍历项目列表(无论是Point还是Tuple[float, float]),并确保任何非Point对象都被转换为Point实例。

如果你正在尝试上述代码,你应该通过创建Polygon的子类并重写__init__()方法来定义这些变体类设计。通过扩展具有显著不同方法签名的类可能会触发mypy的错误标志。

对于这样一个小例子,面向对象和更数据导向的代码版本之间并没有明显的胜者。它们都做了同样的事情。如果我们有接受多边形参数的新函数,例如 area(polygon)point_in_polygon(polygon, x, y),面向对象的代码的优势就越来越明显。同样,如果我们给多边形添加其他属性,比如 colortexture,将那些数据封装到一个单独的类中就越来越有意义了。

区分是一个设计决策,但一般来说,一组数据的重要性越高,它就越有可能拥有针对该数据的多个特定功能,并且使用具有属性和方法类的做法就越有用。

在做出这个决定时,考虑一下这个类将如何被使用也是有益的。如果我们只是在解决一个更大的问题的情况下尝试计算一个多边形的周长,使用一个函数可能编写起来最快,并且仅使用一次会更容易 仅限一次使用。另一方面,如果我们的程序需要以多种方式操作许多多边形(计算周长、面积、与其他多边形的交集、移动或缩放等),我们几乎肯定已经识别出一系列相关对象。随着实例数量的增加,类的定义变得越来越重要。

此外,注意对象之间的交互。寻找继承关系;没有类,继承关系就难以优雅地建模,所以请确保使用它们。寻找我们在第一章面向对象设计中讨论的其他类型的关系,包括关联和组合。

从技术上讲,可以使用数据结构来建模组合 - 例如,我们可以有一个包含元组值的字典列表 - 但有时创建几个对象类会更简单,尤其是如果数据关联有行为的话。

并非所有情况都适用单一尺寸。内置的通用集合和函数对于大量简单情况工作良好。对于大量更复杂的情况,类定义工作得很好。最佳情况下,这个边界是模糊的。

在类数据中添加具有属性的函数

在整本书中,我们一直关注行为和数据分离的概念。这在面向对象编程中非常重要,但我们很快就会看到,在 Python 中,这种区别显得异常模糊。Python 非常擅长模糊化区别;它并不真正帮助我们跳出思维定式。相反,它教导我们停止考虑思维定式。

在我们深入细节之前,让我们讨论一些不良的面向对象设计原则。许多面向对象开发者教导我们永远不要直接访问属性。他们坚持认为我们应该这样编写属性访问:

class Color:
    def __init__(self, rgb_value: int, name: str) -> None:
        self._rgb_value = rgb_value
        self._name = name
    def set_name(self, name: str) -> None:
        self._name = name
    def get_name(self) -> str:
        return self._name
    def set_rgb_value(self, rgb_value: int) -> None:
        self._rgb_value = rgb_value
    def get_rgb_value(self) -> int:
        return self._rgb_value 

实例变量以下划线开头,以表示它们是私有的(其他语言实际上会强制它们成为私有)。然后,getset 方法提供了对每个变量的访问。在实际应用中,此类将如下使用:

>>> c = Color(0xff0000, "bright red")
>>> c.get_name()
'bright red'
>>> c.set_name("red")
>>> c.get_name()
'red' 

上述示例的阅读性远不如 Python 所青睐的直接访问版本:

class Color_Py:
    def __init__(self, rgb_value: int, name: str) -> None:
        self.rgb_value = rgb_value
        self.name = name 

这节课的工作原理是这样的。它稍微简单一些:

>>> c = Color_Py(0xff0000, "bright red")
>>> c.name
'bright red'
>>> c.name = "red"
>>> c.name
'red' 

那么,为什么有人会坚持基于方法的语法呢?

设置器和获取器的概念似乎有助于封装类定义。一些基于 Java 的工具可以自动生成所有的获取器和设置器,使它们几乎不可见。自动化它们的创建并不使它们成为一个伟大的想法。拥有获取器和设置器最重要的历史原因是为了使二进制的独立编译以一种整洁的方式进行。在没有必要单独链接编译的二进制文件的情况下,这种技术并不总是适用于 Python。

使用获取器和设置器的一个持续的理由是,将来我们可能需要在设置或检索值时添加额外的代码。例如,我们可能会决定缓存一个值以避免复杂的计算,或者我们可能想要验证给定的值是否是一个合适的输入。

例如,我们可以决定将set_name()方法修改如下:

class Color_V:
    def __init__(self, rgb_value: int, name: str) -> None:
        self._rgb_value = rgb_value
        if not name:
            raise ValueError(f"Invalid name {name!r}")
        self._name = name
    def set_name(self, name: str) -> None:
        if not name:
            raise ValueError(f"Invalid name {name!r}")
        self._name = name 

如果我们最初为直接属性访问编写了代码,然后后来将其更改为前面提到的方法,我们就会遇到问题:任何编写了直接访问属性代码的人现在都必须更改他们的代码以访问方法。如果他们没有将访问方式从属性访问更改为函数调用,他们的代码就会出错。

我们应该将所有属性设置为私有,并通过方法访问这一信条在 Python 中并没有太多意义。Python 语言缺乏任何真正的私有成员概念!我们可以查看源代码;我们经常说“我们都是成年人。”我们能做什么呢?我们可以使属性和方法之间的语法区别不那么明显。

Python 为我们提供了property函数来创建看起来像属性的方法。因此,我们可以编写代码以使用直接成员访问,如果我们意外地需要修改实现,在获取或设置该属性的值时进行一些计算,我们可以这样做而不必改变接口。让我们看看它是如何表现的:

class Color_VP:
    def __init__(self, rgb_value: int, name: str) -> None:
        self._rgb_value = rgb_value
        if not name:
            raise ValueError(f"Invalid name {name!r}")
        self._name = name
    def _set_name(self, name: str) -> None:
        if not name:
            raise ValueError(f"Invalid name {name!r}")
        self._name = name
    def _get_name(self) -> str:
        return self._name
    **name = property(_get_name, _set_name)** 

与早期版本相比,我们首先将name属性改为(半)私有属性_name。然后,我们添加了两个(半)私有方法来获取和设置该变量,并在设置变量时执行我们的验证。

最后,我们在底部有property构造。这是 Python 的魔法。它为Color类创建了一个新的属性,名为name。它将此属性设置为property类型。在底层,property属性将实际工作委托给了我们刚才创建的两个方法。当在访问上下文中使用(=:=的右侧)时,第一个函数获取值。当在更新上下文中使用(=:=的左侧)时,第二个函数设置值。

这个Color类的新版本可以像早期版本一样使用,但现在在设置name属性时,它现在会执行验证:

>>> c = Color_VP(0xff0000, "bright red")
>>> c.name
'bright red'
>>> c.name = "red"
>>> 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 ValueError(f"Invalid name {name!r}")
ValueError: Invalid name '' 

因此,如果我们之前编写了用于访问name属性的代码,然后将其更改为使用基于property的对象,那么之前的代码仍然会工作。如果它尝试设置一个空的property值,这是我们想要禁止的行为。成功!

请记住,即使有name属性,之前的代码也不是 100%安全的。人们仍然可以直接访问_name属性并将其设置为空字符串,如果他们想这么做的话。但如果是访问我们明确用下划线标记的变量,即暗示它是私有的,那么处理后果的是他们,而不是我们。我们建立了一个正式的契约,如果他们选择违反契约,那么他们必须承担相应的后果。

详细属性

property函数视为返回一个对象,该对象通过我们指定的方法名代理对属性值的获取或设置请求。property内置函数就像这样一个对象的构造函数,而这个对象被设置为给定属性的公共成员。

这个property构造函数实际上可以接受两个额外的参数,一个delete函数和一个用于属性的文档字符串。在实际情况中,delete函数很少被提供,但它可以用来记录一个值已被删除的事实,或者如果我们有理由这么做,可能用来阻止删除。文档字符串只是一个描述属性功能的字符串,与我们在第二章Python 中的对象中讨论的文档字符串没有区别。如果我们不提供这个参数,文档字符串将默认从第一个参数的文档字符串中复制过来:即getter方法。

这里有一个愚蠢的例子,它声明了每次调用任何方法时:

class NorwegianBlue:
    def __init__(self, name: str) -> None:
        self._name = name
        self._state: str
    def _get_state(self) -> str:
        print(f"Getting {self._name}'s State")
        return self._state
    def _set_state(self, state: str) -> None:
        print(f"Setting {self._name}'s State to {state!r}")
        self._state = state
    def _del_state(self) -> None:
        print(f"{self._name} is pushing up daisies!")
        del self._state
    silly = property(
        _get_state, _set_state, _del_state, 
        "This is a silly property") 

注意,state 属性有一个类型提示,str,但没有初始值。它可以被删除,并且只存在于 NorwegianBlue 生命的一部分。我们需要提供一个提示来帮助 mypy 理解类型应该是什么。但我们不分配默认值,因为那是 setter 方法的职责。

如果我们实际使用这个类的实例,当我们要求它打印时,它确实会打印出正确的字符串:

>>> p = NorwegianBlue("Polly")
>>> p.silly = "Pining for the fjords"
Setting Polly's State to 'Pining for the fjords'
>>> p.silly
Getting Polly's State
'Pining for the fjords'
>>> del p.silly
Polly is pushing up daisies! 

此外,如果我们查看Silly类的帮助文本(通过在解释器提示符中输入help(Silly)),它会显示我们silly属性的定制文档字符串:

Help on class NorwegianBlue in module colors:
class NorwegianBlue(builtins.object)
 |  NorwegianBlue(name: str) -> None
 |  
 |  Methods defined here:
 |  
 |  __init__(self, name: str) -> None
 |      Initialize self.  See help(type(self)) for accurate signature.
 |  
 |  ----------------------------------------------------------------------
 |  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 

再次强调,一切正如我们计划的那样进行。在实践中,属性通常只使用前两个参数定义:gettersetter函数。如果我们想为属性提供一个文档字符串,我们可以在getter函数上定义它;属性代理会将其复制到自己的文档字符串中。delete函数通常留空,因为对象属性很少被删除。

装饰器 – 创建属性的另一种方法

我们可以使用装饰器来创建属性。这使得定义更容易阅读。装饰器是 Python 语法的一个普遍特性,具有多种用途。大部分情况下,装饰器会修改它们之前定义的函数。我们将在第十一章,常见设计模式中更广泛地探讨装饰器设计模式。

property 函数可以与装饰器语法一起使用,将一个 get 方法转换为一个 property 属性,如下所示:

class NorwegianBlue_P:
    def __init__(self, name: str) -> None:
        self._name = name
        self._state: str
    @property
    def silly(self) -> str:
        print(f"Getting {self._name}'s State")
        return self._state 

这将property函数作为装饰器应用于后面的函数。这与之前的silly = property(_get_state)语法等效。从可读性的角度来看,主要区别在于我们可以在方法顶部将silly方法标记为属性,而不是在定义之后,这样就不容易被忽略。这也意味着我们不需要创建带有下划线前缀的私有方法来定义属性。

再进一步,我们可以为新的属性指定一个setter函数,如下所示:

class NorwegianBlue_P:
    def __init__(self, name: str) -> None:
        self._name = name
        self._state: str
    @property
    def silly(self) -> str:
        """This is a silly property"""
        print(f"Getting {self._name}'s State")
        return self._state
    @silly.setter
    def silly(self, state: str) -> None:
        print(f"Setting {self._name}'s State to {state!r}")
        self._state = state 

@property相比,这种语法@silly.setter看起来有些奇怪,尽管意图应该是清晰的。首先,我们将silly方法装饰为一个获取器。然后,我们通过应用原始装饰的silly方法的setter属性,装饰了一个具有完全相同名称的第二个方法!这是因为property函数返回一个对象;这个对象也有自己的setter属性,然后可以将它作为装饰器应用于其他方法。使用相同的名称为获取和设置方法有助于将访问一个公共属性的多个方法组合在一起。

我们还可以使用@silly.deleter指定一个delete函数。下面是它的样子:

@silly.deleter
def silly(self) -> None:
    print(f"{self._name} is pushing up daisies!")
    del self._state 

我们不能使用property装饰器来指定文档字符串,因此我们需要依赖于装饰器从初始获取器方法复制文档字符串。这个类操作起来与我们的早期版本完全相同,包括帮助文本。您将看到广泛使用的装饰器语法。函数语法是它实际上在底层是如何工作的。

决定何时使用属性

由于内置的property模糊了行为和数据之间的界限,因此在选择属性、方法或属性时可能会感到困惑。在之前提到的Color_VP类示例中,我们在设置属性时添加了参数值验证。在NorwegianBlue类示例中,当设置和删除属性时,我们编写了详细的日志条目。在决定使用属性时,还有其他因素需要考虑。

在 Python 中,数据、属性和方法都是类的属性。一个方法是否可调用并不能将其与其他类型的属性区分开来;实际上,我们将在第八章“面向对象编程与函数式编程的交汇”中看到,可以创建出可以像函数一样调用的普通对象。我们还将发现函数和方法本身也是普通对象。

方法是可调用的属性,而属性也是属性,这一事实可以帮助我们做出这个决定。我们建议以下原则:

  • 使用方法来表示动作;可以作用于对象或由对象执行的事情。当你调用一个方法时,即使只有一个参数,它也应该一些事情。方法名通常是动词。

  • 使用属性或特性来表示对象的状态。这些是描述对象的名词、形容词和介词。

    • 默认为普通(非属性)属性,在__init__()方法中初始化。这些必须在急切模式下计算,这对于任何设计都是一个良好的起点。

    • 在设置或获取(或删除)属性时涉及计算的特殊情况下,请使用属性。例如,包括数据验证、日志记录和访问控制。我们稍后会看看缓存管理。我们还可以使用属性来处理延迟属性,因为我们希望延迟计算,因为这种计算成本高昂且很少需要。

让我们来看一个更现实的例子。自定义行为的一个常见需求是缓存一个难以计算或查找代价高昂的值(例如,需要网络请求或数据库查询)。目标是本地存储该值,以避免重复调用昂贵的计算。

我们可以通过在属性上自定义获取器来实现这一点。第一次检索值时,我们执行查找或计算。然后,我们可以在我们的对象上本地缓存该值作为私有属性(或在专门的缓存软件中),下次请求该值时,我们返回存储的数据。以下是我们可以如何缓存网页的示例:

from urllib.request import urlopen
from typing import Optional, cast
class WebPage:
    def __init__(self, url: str) -> None:
        self.url = url
        self._content: Optional[bytes] = None
    @property
    def content(self) -> bytes:
        if self._content is None:
            print("Retrieving New Page...")
            with urlopen(self.url) as response:
                self._content = response.read()
        return self._content 

我们只会读取网站内容一次,当self._content的初始值为None时。之后,我们将返回最近读取的网站值。我们可以测试这段代码以验证页面只被检索一次:

import time
webpage = WebPage("http://ccphillips.net/")
now = time.perf_counter()
content1 = webpage.content
first_fetch = time.perf_counter() - now
now = time.perf_counter()
content2 = webpage.content
second_fetch = time.perf_counter() - now
assert content2 == content1, "Problem: Pages were different"
print(f"Initial Request     {first_fetch:.5f}")
print(f"Subsequent Requests {second_fetch:.5f}") 

输出结果?

% python src/colors.py
Retrieving New Page...
Initial Request     1.38836
Subsequent Requests 0.00001 

ccphilips.net网站主机检索一页需要大约 1.388 秒。第二次获取——从笔记本电脑的 RAM 中——仅需 0.01 毫秒!这有时被写作 10 μs,即 10 微秒。由于这是最后一位数字,我们可以怀疑它可能受到四舍五入的影响,因此时间可能只有一半,也许少到 5 μs。

自定义获取器对于需要根据其他对象属性动态计算属性的属性也非常有用。例如,我们可能想要计算一个整数列表的平均值:

class AverageList(List[int]):
    @property
    def average(self) -> float:
        return sum(self) / len(self) 

这个小类继承自list,因此我们免费获得了类似列表的行为。我们给这个类添加了一个属性,然后——嘿,魔术般地!——我们的列表可以这样计算平均值:

>>> a = AverageList([10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5])
>>> a.average
9.0 

当然,我们也可以将其做成一个方法,但如果我们这样做,那么我们应该将其命名为 calculate_average(),因为方法代表动作。但是,一个名为 average 的属性更为合适,它既容易输入也容易阅读。

我们可以想象出许多类似的缩减,包括最小值、最大值、标准差、中位数和众数,这些都是数字集合的性质。通过将这些摘要封装到数据值集合中,这可以简化更复杂的分析。

自定义设置器对于验证很有用,正如我们之前所看到的,但它们也可以用来代理一个值到另一个位置。例如,我们可以在WebPage类中添加一个内容设置器,每当值被设置时,它会自动登录到我们的网络服务器并上传一个新的页面。

管理员对象

我们一直专注于对象及其属性和方法。现在,我们将探讨设计更高级的对象;这类对象管理其他对象——将一切联系在一起的对象。这些对象有时被称为外观对象,因为它们在底层复杂性之上提供了一个令人愉悦、易于使用的界面。请参阅第十二章高级设计模式,以了解外观设计模式的更多内容。

大多数之前的例子倾向于模拟具体的概念。管理对象更像是办公室经理;他们并不在地板上实际进行可见的工作,但如果没有他们,部门之间将没有沟通,没有人会知道他们应该做什么(尽管,如果组织管理不善,这也可能是真的!)类似地,管理类上的属性往往指的是那些进行可见工作的其他对象;这类对象的行为会在适当的时候委托给那些其他类,并在它们之间传递消息。

经理依赖于组合设计。我们通过将其他对象编织在一起来组装一个经理类。经理的整体行为来自于对象的交互。在一定程度上,经理在各种接口之间也是一个适配器。参见 第十二章高级设计模式,以了解适配器设计模式的更多内容。

例如,我们将编写一个程序,用于对存储在压缩归档文件中的文本文件执行查找和替换操作,无论是 ZIP 归档还是 TAR 归档。我们需要对象来代表整个归档文件以及每个单独的文本文件(幸运的是,我们不需要编写这些类,因为它们在 Python 标准库中已有提供)。

一个总体管理对象将负责确保以下三个步骤发生:

  1. 解压缩压缩文件以检查每个成员

  2. 在文本成员上执行查找和替换操作

  3. 将新文件压缩,包括未更改和已更改的成员

注意,我们必须在这三个步骤中选择一种急切或懒惰的方法。我们可以急切地解压(或解 tar)整个存档,处理所有文件,然后构建一个新的存档。这通常会占用大量磁盘空间。另一种方法是懒惰地从存档中逐个提取项目,执行查找和替换,然后在进行过程中构建一个新的压缩存档。懒惰的方法不需要那么多的存储空间。

此设计将整合pathlibzipfile以及正则表达式(re)模块的元素。初始设计将专注于当前任务。在本章的后续部分,随着新需求的浮现,我们将重新思考此设计。

类初始化时使用存档文件的名称。创建时我们不做其他任何事情。我们将定义一个方法,其名称中包含一个良好、清晰的动词,用于执行任何处理:

from __future__ import annotations
import fnmatch
from pathlib import Path
import re
import zipfile
class ZipReplace:
    def __init__(
            self,
            archive: Path,
            pattern: str,
            find: str,
            replace: str
    ) -> None:
        self.archive_path = archive
        self.pattern = pattern
        self.find = find
        self.replace = replace 

给定存档、要匹配的文件名模式和要处理的字符串,该对象将拥有它所需的一切。我们可能会提供类似 ZipReplace(Path("sample.zip"), "*.md", "xyzzy", "xyzzy") 的参数。

查找和替换操作的整体管理方法修订了一个给定的存档。这个ZipReplace类(如上所述)的方法使用了另外两种方法,并将大部分实际工作委托给其他对象:

 def find_and_replace(self) -> None:
        input_path, output_path = self.make_backup()
        with zipfile.ZipFile(output_path, "w") as output:
            with zipfile.ZipFile(input_path) as input:
                self.copy_and_transform(input, output) 

make_backup() 方法将使用 pathlib 模块重命名旧的 ZIP 文件,使其显然是备份副本,未被修改。这个备份副本将被输入到 copy_and_transform() 方法中。原始名称也将是最终的输出。这使得看起来文件是在原地更新的。实际上,创建了一个新文件,但旧名称将被分配给新的内容。

我们创建了两个上下文管理器(一种特殊的管理器)来控制打开的文件。打开的文件与操作系统资源相互纠缠。在 ZIP 文件或 TAR 存档的情况下,当文件关闭时,需要正确地写入摘要和校验和。使用上下文管理器可以确保即使抛出任何异常,这项额外的工作也会被正确地完成。所有文件操作都应该用with语句包装,以利用 Python 的上下文管理器并处理适当的清理。我们将在第九章字符串、序列化和文件路径中再次探讨这个问题。

copy_and_transform() 方法使用两个 ZipFile 实例的方法和 re 模块来转换原始文件中的成员。由于已经备份了原始文件,这将从备份文件构建输出文件。它检查存档中的每个成员,执行一系列步骤,包括展开压缩数据,使用 transform() 方法进行转换,并将压缩数据写入输出文件,然后清理临时文件(和目录)。

显然,我们可以在一个类的方法中完成所有这些步骤,或者实际上在一个复杂的脚本中完成所有这些步骤,而不需要创建任何对象。将步骤分开有几个优点:

  • 可读性:每个步骤的代码都包含在一个独立的单元中,易于阅读和理解。方法名称描述了该方法的功能,因此不需要额外的文档就能理解正在发生的事情。

  • 可扩展性:如果一个子类想要使用压缩的 TAR 文件而不是 ZIP 文件,它可以覆盖copy_and_transform()方法,重用所有支持的方法,因为它们适用于任何文件,无论其归档类型为何。

  • 分区:外部类可以创建此类的实例,并直接使用 make_backup()copy_and_transform() 方法,绕过 find_and_replace() 管理器。

ZipReplace 类(如上所述)的这两种方法通过从备份中读取并在修改后写入新项目来创建备份副本和创建新文件:

def make_backup(self) -> tuple[Path, Path]:
    input_path = self.archive_path.with_suffix(
        f"{self.archive_path.suffix}.old")
    output_path = self.archive_path
    self.archive_path.rename(input_path)
    return input_path, output_path
def copy_and_transform(
    self, input: zipfile.ZipFile, output: zipfile.ZipFile
) -> None:
    for item in input.infolist():
        extracted = Path(input.extract(item))
        if (not item.is_dir() 
                and fnmatch.fnmatch(item.filename, self.pattern)):
            print(f"Transform {item}")
            input_text = extracted.read_text()
            output_text = re.sub(self.find, self.replace, input_text)
            extracted.write_text(output_text)
        else:
            print(f"Ignore    {item}")
        output.write(extracted, item.filename)
        extracted.unlink()
        for parent in extracted.parents:
            if parent == Path.cwd():
                break
            parent.rmdir() 

make_backup() 方法应用了一种常见的策略来避免损坏文件。原始文件会被重命名以保留它,并且会创建一个新的文件,该文件将具有原始文件的名字。此方法旨在与文件类型或其他处理细节无关。

copy_and_transform() 函数方法从原始存档中提取成员来构建新的存档。它为存档的每个成员执行一系列步骤:

  • 从原始存档中提取此文件。

  • 如果该项目不是一个目录(这不太可能,但仍然有可能),并且名称与通配符模式匹配,我们希望对其进行转换。这是一个三步的过程。

    1. 读取文件的文本。

    2. 使用re模块的sub()函数转换文件。

    3. 将文本写入,替换提取的文件。这是我们创建内容副本的地方。

  • 将文件(无论是未修改的文件还是已转换的文件)压缩成新的存档。

  • 我们断开临时副本的链接。如果没有其他链接指向该文件,操作系统将会将其删除。

  • 我们清理提取过程创建的任何临时目录。

  • copy_and_transform() 方法所执行的操作涵盖了 pathlibzipfilere 模块。将这些操作封装到一个使用上下文管理器的管理器中,为我们提供了一个接口小巧的整洁包。

我们可以创建一个主脚本以使用ZipReplace类:

if __name__ == "__main__":
    sample_zip = Path("sample.zip")
    zr = ZipReplace(sample_zip, "*.md", "xyzzy", "plover's egg")
    zr.find_and_replace() 

我们提供了存档文件(sample.zip),文件匹配模式(*.md),要替换的字符串(xyzzy),以及最终的替换内容(plover's egg)。这将执行一系列复杂的文件操作。一个更实用的方法是使用argparse模块为这个应用程序定义命令行界面CLI)。

为了简洁,细节部分被简要地记录。我们目前的重点是面向对象设计;如果您对zipfile模块的内部细节感兴趣,请参考标准库中的文档,无论是在线还是通过在您的交互式解释器中输入import zipfilehelp(zipfile)

当然,ZipReplace 类的实例不必从命令行创建;我们的类可以被导入到另一个模块中(以执行批量 ZIP 文件处理),或者作为 GUI 界面的一部分,甚至是一个更高级的管理对象的一部分,该对象知道如何获取 ZIP 文件(例如,从 FTP 服务器检索或将其备份到外部磁盘)。

Façade 和 Adapter 设计模式的益处在于将复杂性封装到一个更有用的类设计中。这些组合对象往往不像物理对象那样,而是进入了概念对象的领域。当我们远离与真实世界有紧密对应关系的对象时,方法就是改变这些概念状态的行为;需要小心谨慎,因为简单的类比在思想的迷雾中开始消失。当基础是一组具体的数据值和定义良好的行为时,这会很有帮助。

一个值得记住的好例子是万维网。一个 web 服务器浏览器 提供内容。内容可以包括像桌面应用程序一样行为的 JavaScript,它连接到其他 web 服务器以展示内容。这些概念关系通过实际的字节传输来实现。它还包括一个浏览器来绘制文本、图像、视频或声音的页面。其基础是字节传输,这是一个实际的动作。在课堂环境中,开发者们可以通过传递粘性便签和橡胶球来代表请求和响应。

这个例子工作得很好。当我们面临额外需求时,我们需要找到一种方法来构建新的、相关的功能,而不重复代码。我们首先讨论这个工程必要性,然后查看修订后的设计。

移除重复代码

通常,管理风格类如ZipReplace中的代码相当通用,可以以多种方式应用。可以使用组合或继承来帮助将此代码集中在一个地方,从而消除重复代码。在我们查看任何此类示例之前,让我们讨论一些设计原则。特别是,为什么重复代码是一件坏事?

有几个原因,但它们都归结为可读性和可维护性。当我们编写一个与早期代码相似的新代码时,最简单的事情就是复制粘贴旧代码,并更改需要更改的内容(变量名、逻辑、注释),使其在新位置工作。或者,如果我们正在编写看起来与项目中的其他代码相似,但又不完全相同的新代码,那么编写具有相似行为的全新代码通常比找出如何提取重叠功能要容易得多。我们有时称这种编程为复制粘贴编程,因为结果是大量纠缠在一起的代码团块,就像一碗意大利面一样。

但是,当试图理解代码的人遇到重复(或几乎重复)的代码块时,他们现在又遇到了一个额外的理解障碍。由于一系列的辅助问题而产生了一种智力摩擦。它们真的是完全相同的吗?如果不是,一个部分与另一个部分有什么不同?哪些部分是相同的?在什么条件下调用一个部分?我们什么时候调用另一个?你可能认为只有你在阅读你的代码,但如果你八个月都没有接触过那段代码,它对你来说将和对于一个新手程序员一样难以理解。当我们试图阅读两段相似的代码时,我们必须理解它们为什么不同,以及它们是如何不同的。这浪费了读者的时间;代码应该首先被编写成易于阅读的。

[Dusty here, stepping out of formal author mode] 我曾经不得不尝试理解一段代码,这段代码有三个完全相同的 300 行糟糕的代码副本。我在这个代码上工作了整整一个月,才最终明白这三个相同的版本实际上执行的是略微不同的税务计算。其中一些细微的差异是有意为之,但也有明显的地方,有人在更新了一个函数中的计算时,没有更新其他两个函数。代码中细微、难以理解的错误数量无法计数。我最终用大约 20 行的易读函数替换了所有 900 行。

如前所述的故事所暗示的那样,保持两段相似代码的更新可能是一场噩梦。我们每次更新其中一段时,都必须记得更新两个部分,而且我们还得记得多个部分之间的差异,以便在编辑每个部分时修改我们的更改。如果我们忘记更新所有部分,最终可能会遇到极其烦人的错误,通常表现为:“我已经修复了那个问题,为什么它还在发生?

这里关键的因素是解决故障、维护和改进所花费的时间与最初创建代码所花费的时间相比。软件在使用几周以上的时间内,其受到的关注将远远超过创建它所花费的时间。当我们不得不维护它时,通过复制粘贴现有代码所节省的那一点点时间,实际上是被浪费的。

作者个人最佳成就之一是一款使用了近十七年的应用程序。如果其他开发者和用户每年浪费额外的一天试图整理代码中的某些令人困惑的部分,这意味着作者本应至少再花上两周时间改进代码,以避免未来的维护成本。

代码被读取和修改的次数远多于被编写的次数,可读的代码始终应该是首要考虑的。

这就是为什么程序员,尤其是 Python 程序员(他们往往比普通开发者更重视代码的优雅性),会遵循所谓的不要重复自己DRY)原则。我们给初学者的建议是永远不要使用编辑器的复制粘贴功能。对于中级程序员:在按下 Ctrl + C 之前三思。

但是,我们应该做什么来避免代码重复呢?最简单的解决方案通常是把代码移动到一个接受参数的函数中,以处理那些不同的部分。这并不是一个严格面向对象的解决方案,但它通常是最佳选择。

例如,如果我们有两个将 ZIP 文件解压到两个不同目录的代码片段,我们可以轻松地用一个接受参数的函数来替换它,该参数指定了应该解压到的目录。这可能会使函数本身稍微长一些。函数的大小——以代码行数来衡量——并不是衡量可读性的好指标。在代码高尔夫比赛中,没有人能获胜。

好的名称和文档字符串是必不可少的。每个类、方法、函数、变量、属性、属性、模块和包名都应该经过深思熟虑的选择。在编写文档字符串时,不要解释代码是如何工作的(代码应该做到这一点)。务必关注代码的目的、使用它的先决条件以及函数或方法使用后将会是什么情况。

这个故事的意义是:总是努力重构你的代码,使其更易于阅读,而不是编写看似更容易编写但质量较差的代码。现在我们可以查看ZipReplace类定义的修订版设计。

在实践中

让我们探索两种我们可以重用现有代码的方法。在编写代码以替换一个包含文本文件的 ZIP 文件中的字符串之后,我们后来被委托将 ZIP 文件中的所有图片缩放到适合移动设备的尺寸。虽然分辨率各不相同,但 640 x 960 大约是我们需要的最小尺寸。看起来我们可以使用与我们在ZipReplace中使用的非常相似的方法。

我们的第一反应可能是保存该模块的副本,并将副本中的find_replace方法更改为scale_image或类似的名称。

此处理将依赖于 Pillow 库来打开图像文件,调整其大小,并将其保存。可以使用以下命令安装 Pillow 图像处理工具:

% python -m pip install pillow 

这将提供一些优秀的图像处理工具。

如我们上文在本章的移除重复代码部分所述,这种复制粘贴的编程方法并不是最佳选择。如果我们有一天想要将unzipzip方法修改为也能打开 TAR 文件呢?或者我们可能希望为临时文件使用一个保证唯一的目录名。在任何一种情况下,我们都不得不在两个不同的地方进行修改!

我们将首先演示一个基于继承的解决方案来解决这个问题。首先,我们将修改我们的原始ZipReplace类,使其成为一个超类,用于以多种方式处理 ZIP 文件:

from abc import ABC, abstractmethod
class ZipProcessor(ABC):
    def __init__(self, archive: Path) -> None:
        self.archive_path = archive
        self._pattern: str
    def process_files(self, pattern: str) -> None:
        self._pattern = pattern
        input_path, output_path = self.make_backup()
        with zipfile.ZipFile(output_path, "w") as output:
            with zipfile.ZipFile(input_path) as input:
                self.copy_and_transform(input, output)
    def make_backup(self) -> tuple[Path, Path]:
        input_path = self.archive_path.with_suffix(
            f"{self.archive_path.suffix}.old")
        output_path = self.archive_path
        self.archive_path.rename(input_path)
        return input_path, output_path
    def copy_and_transform(
        self, input: zipfile.ZipFile, output: zipfile.ZipFile
    ) -> None:
        for item in input.infolist():
            extracted = Path(input.extract(item))
            if self.matches(item):
                print(f"Transform {item}")
                self.transform(extracted)
            else:
                print(f"Ignore    {item}")
            output.write(extracted, item.filename)
            self.remove_under_cwd(extracted)
    def matches(self, item: zipfile.ZipInfo) -> bool:
        return (
            not item.is_dir() 
            and fnmatch.fnmatch(item.filename, self._pattern))
    def remove_under_cwd(self, extracted: Path) -> None:
        extracted.unlink()
        for parent in extracted.parents:
            if parent == Path.cwd():
                break
            parent.rmdir()
    @abstractmethod
    def transform(self, extracted: Path) -> None:
        ... 

我们将__init__()patternfindreplace这四个特定于ZipReplace的参数移除,并将find_replace()方法重命名为process_files()。我们将复杂的copy_and_transform()方法分解,使其调用其他几个方法来完成实际工作。这包括一个用于transform()方法的占位符。这些名称更改有助于展示我们新类更通用的特性。

这个新的 ZipProcessor 类是 ABC 的子类,其中 ABC 是一个抽象基类,允许我们提供占位符而不是方法。(关于抽象基类的内容将在 第六章抽象基类和运算符重载 中详细介绍。)这个抽象类实际上并没有定义一个 transform() 方法。如果我们尝试创建 ZipProcessor 类的实例,缺失的 transform() 方法将引发异常。@abstractmethod 装饰的正式性使得缺失的部分一目了然,并且这部分必须具有预期的形状。

现在,在我们继续到图像处理应用之前,让我们创建一个原始ZipReplace类的版本。这个版本将基于ZipProcessor类,以便利用这个父类,如下所示:

class TextTweaker(ZipProcessor):
    def __init__(self, archive: Path) -> None:
        super().__init__(archive)
        self.find: str
        self.replace: str
    def find_and_replace(self, find: str, replace: str) -> "TextTweaker":
        self.find = find
        self.replace = replace
        return self
    def transform(self, extracted: Path) -> None:
        input_text = extracted.read_text()
        output_text = re.sub(self.find, self.replace, input_text)
        extracted.write_text(output_text) 

这段代码比原始版本更短,因为它从父类继承了 ZIP 处理能力。我们首先导入我们刚刚编写的基类,并让TextTweaker扩展这个类。然后,我们使用super()来初始化父类。

我们需要两个额外的参数,并且我们已经使用了一种称为流畅接口的技术来提供这两个参数。find_and_replace()方法更新对象的状态,然后返回self对象。这使得我们可以使用这个类,如下所示的一行代码:

TextTweaker(zip_data)\
.find_and_replace("xyzzy", "plover's egg")\
.process_files("*.md") 

我们创建了一个类的实例,使用了find_and_replace()方法来设置一些属性,然后使用了process_files()方法来开始处理。这被称为“流畅”接口,因为使用了多个方法来帮助明确参数及其关系。

我们已经做了大量工作来重新创建一个在功能上与我们最初开始时没有区别的程序!但是完成这项工作后,我们现在写其他操作 ZIP 存档中文件的类要容易得多,例如(假设请求的)照片缩放器。

此外,如果我们想要改进或修复 ZIP 功能中的错误,我们只需更改一个ZipProcessor基类,就可以一次性对所有子类进行操作。因此,维护工作将更加高效。

看看现在创建一个利用ZipProcessor功能进行图片缩放的类是多么简单:

from PIL import Image  # type: ignore [import]
class ImgTweaker(ZipProcessor):
    def transform(self, extracted: Path) -> None:
        image = Image.open(extracted)
        scaled = image.resize(size=(640, 960))
        scaled.save(extracted) 

看看这个类有多简单!我们之前所做的所有工作都得到了回报。我们只需打开每个文件,调整其大小,然后保存回去。ZipProcessor 类会处理压缩和解压缩,而不需要我们做任何额外的工作。这似乎是一个巨大的胜利。

创建可重用的代码并不容易。通常需要超过一个用例来明确哪些部分是通用的,哪些部分是特定的。因为我们需要具体的例子,所以避免过度设计,努力追求想象中的重用是值得的。这是 Python,事物可以非常灵活。根据需要重写,以覆盖场景中出现的各种情况。

案例研究

在本章中,我们将继续开发案例研究的元素。我们希望探索 Python 面向对象设计的一些附加功能。第一个有时被称为“语法糖”,这是一种方便的方式来编写一些提供更简单方式表达相对复杂内容的方法。第二个是管理者的概念,它为资源管理提供上下文。

第四章意料之外中,我们为识别无效输入数据建立了一个异常。当输入数据无法使用时,我们使用这个异常来报告。

在这里,我们将从一个类开始,通过读取经过适当分类的训练和测试数据文件来收集数据。在本章中,我们将忽略一些异常处理细节,以便我们能够专注于问题的另一个方面:将样本划分为测试集和训练集。

输入验证

TrainingData 对象是从名为 bezdekIris.data 的样本源文件中加载的。目前,我们并没有对文件内容进行大量验证。我们不是确认数据是否包含格式正确、具有数值测量和适当物种名称的样本,而是简单地创建 Sample 实例,并希望一切顺利。数据的一点点变化可能会导致我们应用程序中不为人知的部分出现意外问题。通过立即验证输入数据,我们可以专注于问题,并向用户提供专注的、可操作的报告。例如,“第 42 行有一个无效的 petal_length 值 '1b.25'”,并附带数据行、列和无效值。

在我们的应用程序中,通过TrainingDataload()方法处理带有训练数据的文件。目前,此方法需要一个字典的可迭代序列;每个单独的样本都被读取为一个包含测量和分类的字典。类型提示为Iterable[dict[str, str]]。这是csv模块工作的一种方式,使得与它一起工作变得非常容易。我们将在第八章“面向对象编程与函数式编程的交汇点”和第九章“字符串、序列化和文件路径”中返回到加载数据的更多细节。

考虑到可能存在其他格式,这表明TrainingData类不应该依赖于 CSV 文件处理所建议的dict[str, str]行定义。虽然期望每行的值是一个字典很简单,但它将一些细节推入了TrainingData类,这些细节可能并不属于那里。源文档表示的细节与管理和测试样本集合无关;这似乎是面向对象设计将帮助我们解开这两个想法的地方。

为了支持多个数据源,我们需要一些通用的规则来验证输入值。我们需要一个像这样的类:

class SampleReader:
    """
    See iris.names for attribute ordering in bezdekIris.data file
    """
    target_class = Sample
    header = [
        "sepal_length", "sepal_width", 
        "petal_length", "petal_width", "class"
    ]
    def __init__(self, source: Path) -> None:
        self.source = source
    def sample_iter(self) -> Iterator[Sample]:
        target_class = self.target_class
        with self.source.open() as source_file:
            reader = csv.DictReader(source_file, self.header)
            for row in reader:
                try:
                    sample = target_class(
                        sepal_length=float(row["sepal_length"]),
                        sepal_width=float(row["sepal_width"]),
                        petal_length=float(row["petal_length"]),
                        petal_width=float(row["petal_width"]),
                    )
                except ValueError as ex:
                    raise BadSampleRow(f"Invalid {row!r}") from ex
                yield sample 

这是从 CSV DictReader实例读取的输入字段构建Sample超类的一个实例。sample_iter()方法使用一系列转换表达式将每列的输入数据转换为有用的 Python 对象。在这个例子中,转换是简单的,实现是一系列float()函数,用于将 CSV 字符串数据转换为 Python 对象。我们可以想象,对于其他问题域可能存在更复杂的转换。

float() 函数在遇到不良数据时将引发一个 ValueError。虽然这很有帮助,但距离公式中的错误也可能引发一个 ValueError,从而导致可能的混淆。对于我们的应用程序来说,产生独特的异常要稍微好一些;这使得更容易识别问题的根本原因。

目标类型Sample作为一个类级别变量target_class提供,这使得我们可以通过一个相对明显的修改来引入Sample的新子类。这不是必需的,但像这样的可见依赖关系提供了一种将类彼此分离的方法。

我们将遵循第四章意料之外,并定义一个独特的异常定义。这是帮助我们将应用程序的错误与 Python 代码中的普通错误区分开来的更好方法:

class BadSampleRow(ValueError):
    pass 

为了利用这一点,我们将由 ValueError 异常信号的各种 float() 问题映射到我们应用程序的 BadSampleRow 异常。这有助于区分一个坏的 CSV 源文件和由于 k-NN 距离计算中的错误导致的坏计算。虽然两者都可能引发 ValueError 异常,但 CSV 处理异常被封装为特定于应用程序的异常,以消除歧义。

我们通过将目标类实例的创建包裹在try:语句中来执行异常转换。在这里引发的任何ValueError都将变为BadSampleRow异常。我们使用了raise...from...来保留原始异常,以便于调试。

一旦我们有了有效的输入,我们必须决定该对象是用于训练还是测试。我们将在下一节讨论这个问题。

输入分区

我们刚刚介绍的SampleReader类使用一个变量来标识要创建哪种类型的对象。target_class变量提供了一个要使用的类。请注意,我们在引用SampleReader.target_classself.target_class时需要稍微小心一些。

一个像 self.target_class(sepal_length=, ... etc.) 这样简单的表达式看起来像是一个方法调用。当然,self.target_class 不是一个方法;它是一个另一个类。为了确保 Python 不会假设 self.target_class() 指的是一个方法,我们将其分配给一个名为 target_class 的局部变量。现在我们可以使用 target_class(sepal_length=, … etc.) 而不会有歧义。

这非常符合 Python 的风格。我们可以创建这个读取器的子类,从而从原始数据中创建不同类型的样本。

这个SampleReader类定义暴露了一个问题。单个原始样本数据源需要被分割成两个独立的KnownSample子类;它要么是TrainingSample要么是TestingSample。这两个类之间有细微的行为差异。TestingSample用于确认k-NN 算法是否工作,并用于将算法分类与专家植物学家指定的物种进行比较。这不是TrainingSample需要做的事情。

理想情况下,单个读者会发出两种类型的混合。到目前为止的设计仅允许创建单个类别的实例。我们有两条前进路径来提供所需的功能:

  • 一个更复杂的算法用于决定创建哪个类。该算法可能包括一个if语句来创建一个对象实例或另一个对象实例。

  • KnownSample的简化定义。这个单一类可以分别处理不可变的训练样本和可变测试样本,后者可以被分类(并重新分类)任意多次。

简化似乎是一个好主意。更少的复杂性意味着更少的代码和更少的地方让错误隐藏。第二个选择建议我们可以将样本的三个不同方面分开:

  • "原始"数据。这是核心的测量集合。它们是不可变的。(我们将在第七章Python 数据结构中讨论这种设计变化。)

  • 由植物学家指定的物种。这适用于训练或测试数据,但不属于未知样本的一部分。指定的物种,就像测量值一样,是不可变的。

  • 算法分配的分类。这应用于测试样本和未知样本。这个值可以看作是可变的;每次我们分类一个样本(或重新分类一个测试样本),这个值都会改变。

这是对迄今为止设计的一个深刻改变。在项目早期,这种改变可能是必要的。早在第一章第二章,我们决定为各种样本创建一个相当复杂的类层次结构。是时候回顾那个设计了。这不会是我们最后一次思考这个问题。优秀设计的本质是首先创造和淘汰许多不良设计。

样本类层次结构

我们可以从几个不同的角度重新思考我们早期的设计。一个替代方案是将必要的Sample类与附加功能分离。看起来我们可以为每个Sample实例识别出四种附加行为,如下表所示。

已知 未知
未分类 训练数据 待分类的样本
分类 测试数据 分类样本

我们从分类行中省略了一个细节。每次我们进行分类时,都会有一个特定的超参数与生成的分类样本相关联。更准确地说,这是一个由特定的超参数对象进行分类的样本。但这可能会过于杂乱。

未知列中的两个单元格之间的区别非常微小。这种区别如此微小,以至于对大多数处理来说几乎无关紧要。一个未知样本将等待被分类,最多只需要几行代码。

如果我们重新思考这个问题,我们可能能够创建更少的类,同时仍然正确地反映对象状态和行为的变化。

Sample可以有两大子类,每个子类都有一个独立的Classification对象。下面是一个图示。

图表描述自动生成

图 5.1:示例类图

我们已经细化了类层次结构,以反映两种本质上不同的样本:

  • 一个KnownSample实例可用于测试或训练。与其他类别的区别在于实现分类的方法。我们可以使这取决于一个purpose属性,用一个小方块(或有时用一个"-"作为前缀)来表示。Python 没有私有变量,但这个标记可以作为设计笔记很有帮助。公共属性可以用一个小圆圈(或用一个"+"来节省空间)作为前缀来表示。

    • 当目的的值为Training时,classify()方法将引发异常。样本不能被重新分类;那样将使训练无效。

    • 当目的的值为Testing时,classify()方法将正常工作,应用给定的Hyperparameter来计算一个物种。

  • 一个UnknownSample实例可用于用户分类。这里的分类方法不依赖于purpose属性值,并且始终执行分类。

让我们看看如何使用本章所学的@property装饰器来实现这些行为。我们可以使用@property来获取计算值,就像它们是简单的属性一样。我们还可以使用@property来定义不能设置的属性。

目的列举

我们将首先列举一个目的值域:

class Purpose(enum.IntEnum):
    Classification = 0
    Testing = 1
    Training = 2 

这个定义创建了一个命名空间,其中包含三个我们可以在代码中使用的对象:Purpose.ClassificationPurpose.TestingPurpose.Training。例如,我们可以使用if sample.purpose == Purpose.Testing:来识别一个测试样本。

我们可以使用Purpose(x)从输入值转换为Purpose对象,其中x是一个整数值,0、1 或 2。任何其他值都会引发一个ValueError异常。我们也可以将其转换回数值。例如,Purpose.Training.value的值为1。这种使用数值代码的方式可以很好地与那些不擅长处理 Python 对象枚举的外部软件兼容。

我们将Sample类的KnownSample子类分解为两部分。以下是第一部分。我们使用Sample.__init__()方法所需的数据初始化一个样本,并额外添加两个值,即purpose数值代码和分配的物种:

class KnownSample(Sample):
    def __init__(
        self,
        sepal_length: float,
        sepal_width: float,
        petal_length: float,
        petal_width: float,
        purpose: int,
        species: str,
    ) -> None:
        purpose_enum = Purpose(purpose)
        if purpose_enum not in {Purpose.Training, Purpose.Testing}:
            raise ValueError(
                f"Invalid purpose: {purpose!r}: {purpose_enum}"
            )
        super().__init__(
            sepal_length=sepal_length,
            sepal_width=sepal_width,
            petal_length=petal_length,
            petal_width=petal_width,
        )
        self.purpose = purpose_enum
        self.species = species
        self._classification: Optional[str] = None
    def matches(self) -> bool:
        return self.species == self.classification 

我们验证purpose参数的值,以确保它解码为Purpose.TrainingPurpose.Testing之一。如果purpose的值不是这两个允许的值之一,我们将引发一个ValueError异常,因为数据不可用。

我们创建了一个实例变量,self._classification,其名称以_开头。这是一个约定,表明该名称不供此类客户端的通用使用。它不是“私有”的,因为在 Python 中没有隐私的概念。我们可以称之为“隐藏”或者“也许在这里留意惊喜”。

与某些语言中可用的大型、不透明的墙不同,Python 使用一个低矮的装饰性花卉边框来区分这个变量与其他变量。你可以直接穿过花卉边框的 _ 字符来近距离查看值,但你可能不应该这样做。

这里是第一个 @property 方法:

 @property
    def classification(self) -> Optional[str]:
        if self.purpose == Purpose.Testing:
            return self._classification
        else:
            raise AttributeError(f"Training samples have no classification") 

这定义了一个将作为属性名称可见的方法。以下是一个创建用于测试目的示例的例子:

>>> from model import KnownSample, Purpose
>>> s2 = KnownSample(
...     sepal_length=5.1, 
...     sepal_width=3.5, 
...     petal_length=1.4, 
...     petal_width=0.2, 
...     species="Iris-setosa", 
...     purpose=Purpose.Testing.value)
>>> s2
KnownSample(sepal_length=5.1, sepal_width=3.5, petal_length=1.4, petal_width=0.2, purpose=1, species='Iris-setosa')
>>> s2.classification is None
True 

当我们评估s2.classification时,这将调用该方法。此函数确保这是一个用于测试的样本,并返回“隐藏”的实例变量self._classification的值。

如果这是一个Purpose.Training样本,该属性将引发一个AttributeError异常,因为任何检查训练样本分类值的应用程序中都有一个需要修复的错误。

属性设置器

我们如何设置分类?我们真的执行了self._classification = h.classify(self)这个语句吗?答案是:不是——我们可以创建一个属性来更新“隐藏”的实例变量。这比上面的例子要复杂一些:

 @classification.setter
    def classification(self, value: str) -> None:
        if self.purpose == Purpose.Testing:
            self._classification = value
        else:
            raise AttributeError(
                f"Training samples cannot be classified") 

classification的初始@property定义被称为“获取器”。它获取一个属性值。(实现使用为我们创建的描述符对象的__get__()方法。)classification@property定义还创建了一个额外的装饰器@classification.setter。由设置器装饰的方法被赋值语句使用。

注意,这两个属性的名称都是分类。这是需要使用的属性名称。

现在像这样的语句s2.classification = h.classify(self)会将分类从特定的Hyperparameter对象中改变。这个赋值语句将使用该方法来检查这个样本的目的。如果目的是测试,则值将被保存。如果目的不是Purpose.Testing,那么尝试设置分类将引发AttributeError异常,并标识出我们应用程序中出错的地点。

重复的 if 语句

我们有多个检查特定Purpose值的if语句。这表明这种设计可能不是最优的。变体行为并没有封装在一个单独的类中;相反,多个行为被组合到一个类中。

存在一个目的枚举和用于检查枚举值的if语句,这表明我们可能有多个类。这里的“简化”并不令人满意。

在本案例研究的输入分区部分,我们建议有两条前进路径。一条是尝试通过将purpose属性设置为将测试数据与训练数据分开来简化类。这似乎增加了if语句,但实际上并没有简化设计。

这意味着我们将在后续章节的案例研究中寻找更好的分区算法。目前,我们有能力创建有效数据,但我们也有代码中充斥着if语句的情况。鼓励读者尝试不同的设计方案,以检查生成的代码,看看哪种看起来更简单、更容易阅读。

回忆

本章的一些关键点如下:

  • 当我们同时拥有数据和行为时,这就是面向对象设计的最佳状态。我们可以利用 Python 的泛型集合和普通函数来完成许多事情。当事情变得足够复杂,以至于我们需要确保所有部分都一起定义时,那么我们就需要开始使用类了。

  • 当属性值是另一个对象的引用时,Python 的一种方法是允许直接访问该属性;我们不需要编写复杂的设置器和获取器函数。当属性值是计算得出的,我们有两种选择:我们可以立即计算它,或者延迟计算。属性允许我们延迟计算,仅在需要时进行。

  • 我们经常会遇到协作对象;应用程序的行为是从协作中产生的。这通常会导致管理对象,它们将组件类定义中的行为组合起来,以创建一个集成、工作的整体。

练习

我们已经探讨了在面向对象的 Python 程序中,对象、数据和方法之间可以相互交互的各种方式。通常情况下,你的第一个想法应该是如何将这些原则应用到自己的工作中。你是否有任何杂乱的脚本,可以使用面向对象的管理器重写?浏览一下你的一些旧代码,寻找那些不是动作的方法。如果名称不是一个动词,尝试将其重写为一个属性。

想想你在任何语言中编写的代码。它是否违反了 DRY 原则?有没有任何重复的代码?你是否复制粘贴了代码?你是否因为不想理解原始代码而编写了两个相似的代码版本?现在回顾一下你最近编写的代码,看看你是否可以使用继承或组合来重构重复的代码。尽量选择一个你仍然感兴趣维护的项目,而不是那些你再也不想触碰的旧代码。这有助于你在进行改进时保持兴趣!

现在,回顾一下本章中我们讨论的一些示例。从一个使用属性来缓存检索数据的缓存网页示例开始。这个示例的一个明显问题是缓存永远不会刷新。给属性的 getter 方法添加一个超时,只有当页面在超时之前被请求时,才返回缓存的页面。你可以使用time模块(time.time() - an_old_time返回自an_old_time以来经过的秒数)来确定缓存是否已过期。

还要看看基于继承的ZipProcessor。在这里使用组合而不是继承可能是合理的。你不需要在ZipReplaceScaleZip类中扩展类,而是可以将这些类的实例传递给ZipProcessor构造函数,并调用它们来完成处理部分。实现这一点。

你觉得哪个版本更容易使用?哪个更优雅?哪个更容易阅读?这些问题都是主观的;每个人的答案都不同。然而,知道答案是很重要的。如果你发现你更喜欢继承而非组合,你需要注意在日常编码中不要过度使用继承。如果你更喜欢组合,确保不要错过创建基于优雅继承解决方案的机会。

最后,给我们在案例研究中创建的各种类添加一些错误处理器。如何处理一个不良样本?模型应该失效吗?还是应该跳过这一行?看似微小的技术实现选择实际上有着深刻的数据科学和统计后果。我们能否定义一个允许两种不同行为的类?

在你的日常编码中,请注意复制和粘贴命令。每次你在编辑器中使用它们时,考虑一下是否改善你程序的组织结构是个好主意,这样你就可以只保留你即将复制的代码的一个版本。

摘要

在本章中,我们专注于识别对象,尤其是那些不是立即显而易见的对象;那些管理和控制的对象。对象应具备数据和行为,但属性可以被用来模糊两者之间的区别。DRY 原则是代码质量的重要指标,继承和组合可以用来减少代码重复。

在下一章中,我们将探讨 Python 定义抽象基类的方法。这使我们能够定义一种模板式的类;它必须通过添加具有狭窄定义的实现特性来扩展为子类。这使我们能够构建一系列相关的类,并确信它们能够正确地协同工作。

第六章:抽象基类和运算符重载

我们经常需要在具有完整属性和方法集合的具体类和缺少一些细节的抽象类之间做出区分。这类似于抽象作为总结复杂性的哲学思想。我们可能会说,帆船和飞机有共同的、抽象的关系,即它们都是交通工具,但它们移动的细节是不同的。

在 Python 中,我们有两种定义类似事物的途径:

  • 鸭子类型: 当两个类定义具有相同的属性和方法时,这两个类的实例具有相同的协议并且可以互换使用。我们常说,“当我看到一只既像鸭子走路又像鸭子游泳还像鸭子嘎嘎叫的鸟时,我就称那只鸟为鸭子。”

  • 继承:当两个类定义有共同特性时,子类可以共享超类的共同特性。这两个类的实现细节可能不同,但在使用由超类定义的共同特性时,这些类应该是可互换的。

我们可以将继承进一步深化。我们可以拥有抽象的父类定义:这意味着它们自身不能直接使用,但可以通过继承来创建具体的类。

我们必须承认在术语“基类”和“超类”周围存在一个术语问题。这很令人困惑,因为它们是同义词。这里有两种平行的隐喻,我们在它们之间来回切换。有时,我们会使用“基类是基础”的隐喻,其中另一个类通过继承在其之上构建。其他时候,我们会使用“具体类扩展超类”的隐喻。超类优于具体类;它通常在 UML 类图中位于其上方,并且需要首先定义。例如:

图表描述自动生成

图 6.1:抽象基类

我们的基础类,在此命名为BaseClass,有一个特殊的类abc.ABC作为其父类。这提供了一些特殊的元类特性,有助于确保具体类已经替换了抽象。在这个图中,我们添加了一个大“A”圆圈来标记这个类为抽象类。这种装饰是可选的,并且通常并不有帮助,所以我们不会在其他图中使用它。斜体字体是另一个表明该类为抽象类的提示。

图表展示了一个抽象方法 a_method(),它没有定义方法体。子类必须提供这个方法。同样,使用斜体字来表示方法名,以提供这是一个抽象方法的提示。两个具体子类提供了这个缺失的方法。

在本章中,我们将涵盖以下主题:

  • 创建一个抽象基类

  • ABCs 和类型提示

  • collections.abc 模块

  • 创建自己的抽象基类

  • 揭秘魔法——探究 ABC 实现的内部机制

  • 运算符重载

  • 扩展内置函数

  • 元类

本章的案例研究将基于前几章的案例研究材料。我们将能够仔细研究在不同训练集和测试集之间划分数据的不同方法。

我们将首先探讨如何使用抽象类并从中创建一个具体类。

创建一个抽象基类

想象一下我们正在创建一个带有第三方插件的媒体播放器。在这种情况下,创建一个抽象基类(ABC)是明智的,以便记录第三方插件应提供的 API(文档是抽象基类(ABC)的强大用例之一)。

通用设计是拥有一个共同特性,例如play(),这个特性适用于多个类。我们不希望选择某个特定的媒体格式作为超类;声称某种格式是基础性的,而所有其他格式都由此派生出来,这似乎有些不妥。

我们更愿意将媒体播放器定义为一种抽象。每种独特的媒体文件格式都可以提供该抽象的具体实现。

abc 模块提供了完成此操作的工具。以下是一个抽象类,它要求子类提供一个具体方法和一个具体属性才能发挥作用:

class MediaLoader(abc.ABC):
    @abc.abstractmethod
    def play(self) -> None:
        ...
    @property
    @abc.abstractmethod
    def ext(self) -> str:
        ... 

abc.ABC 类引入了一个 元类 —— 用于构建具体类定义的类。Python 的默认元类名为 type。默认元类在尝试创建实例时不会检查抽象方法。abc.ABC 类扩展了 type 元类,以防止我们创建未完全定义的类的实例。

在抽象中描述占位符时使用了两个装饰器。示例展示了@abc.abstractmethod以及@property@abc.abstractmethod的组合。Python 广泛使用装饰器来修改方法或函数的一般性质。在这种情况下,它提供了由ABC类包含的元类使用的额外细节。因为我们标记了一个方法或属性为抽象的,所以这个类的任何子类都必须实现该方法或属性,才能成为一个有用且具体的实现。

这些方法的主体实际上是 ... 。这个省略号标记,实际上是一个有效的 Python 语法。它不仅仅是在这本书中用作占位符;它是 Python 代码,用来提醒大家,为了创建一个工作状态良好的具体子类,需要编写一个有用的主体。

我们也在ext()方法上使用了@property装饰器。对于ext属性,我们的意图是提供一个具有字符串字面值值的简单类级别变量。将其描述为@property有助于实现选择在简单变量和实现属性的方法之间进行选择。在具体类中的简单变量将在运行时满足抽象类的期望,并且也有助于mypy检查代码类型的一致使用。如果需要更复杂的计算,可以使用方法作为简单属性变量的替代。

标记这些属性的一个后果是,这个类现在有一个新的特殊属性,__abstractmethods__。这个属性列出了需要填写以创建具体类所需的所有名称:

>>> MediaLoader.__abstractmethods__
frozenset({'ext', 'play'}) 

看看如果你实现了一个子类会发生什么?我们将查看一个没有为抽象提供具体实现的示例。我们还将查看一个提供了所需属性的示例:

>>> 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子类的定义未能实现任何一个抽象属性。当我们尝试创建Wav类的实例时,会引发异常。因为这个MediaLoader子类仍然是抽象的,所以无法实例化该类。这个类仍然是一个可能很有用的抽象类,但您必须将其子类化并填充抽象占位符,它才能实际执行任何操作。

Ogg 子类提供了这两个属性,因此它至少可以干净地实例化。确实,play() 方法的主体并没有做很多。重要的是所有的占位符都被填充了,这使得 Ogg 成为抽象 MediaLoader 类的一个具体子类。

使用类级别的变量作为首选媒体文件扩展名存在一个微妙的问题。因为ext属性是一个变量,它可以被更新。使用o.ext = '.xyz'并没有被明确禁止。Python 没有简单直观的方式来创建只读属性。我们通常依赖文档来解释更改ext属性值所带来的后果。

这在创建复杂应用程序时具有明显优势。使用这种抽象使得mypy能够很容易地得出一个类是否具有所需的方法和属性的结论。

这也要求进行一定程度的繁琐导入,以确保模块能够访问应用程序所需的必要抽象基类。鸭子类型的一个优点是能够避免复杂的导入,同时仍然创建一个可以与同类类多态交互的有用类。这种优点往往被abc.ABC类定义支持通过mypy进行类型检查的能力所超越,并且还可以对子类定义的完整性进行运行时检查。当出现问题时,abc.ABC类还能提供更多有用的错误信息。

ABCs 的一个重要用例是collections模块。该模块使用一组复杂的基类和混入定义了内置的泛型集合。

集合的 ABC

在 Python 标准库中,对抽象基类的真正综合运用体现在 collections 模块中。我们使用的集合是 Collection 抽象类的扩展。Collection 是一个更基础抽象 Container 的扩展。

由于基础是Container类,让我们在 Python 解释器中检查它,看看这个类需要哪些方法:

>>> from collections.abc import Container
>>> Container.__abstractmethods__ 
frozenset({'__contains__'}) 

因此,Container 类恰好有一个需要实现的方法,即 __contains__()。您可以通过执行 help(Container.__contains__) 来查看函数签名应该是什么样的:

>>> help(Container.__contains__)
Help on function __contains__ in module collections.abc:
__contains__(self, x) 

我们可以看到__contains__()方法需要接受一个单一参数。不幸的是,帮助文件并没有告诉我们这个参数应该是什么,但从 ABC 的名称以及它实现的单一方法来看,这个参数就是用户用来检查容器是否包含该值的值。

这个 __contains__() 特殊方法实现了 Python 的 in 操作符。该方法由 setliststrtupledict 实现。然而,我们也可以定义一个愚蠢的容器,它告诉我们给定的值是否在奇数集合中:

from collections.abc import Container
class OddIntegers:
    def __contains__(self, x: int) -> bool:
        return x % 2 != 0 

我们使用了模数测试来判断奇偶性。如果x除以二的余数为零,那么x就是偶数,否则x就是奇数。

这里有一个有趣的部分:我们可以实例化一个OddContainer对象,并确定即使我们没有扩展Container类,该类仍然表现得像一个Container对象:

>>> odd = OddIntegers()
>>> isinstance(odd, Container)
True
>>> issubclass(OddIntegers, Container)
True 

正因如此,鸭子类型(duck typing)远比经典的多态性(polymorphism)更出色。我们可以在不编写设置继承(或者更糟糕的是多重继承)代码的开销下创建“是...类型”的关系。

Container ABC 的一个酷特点是,任何实现了它的类都可以免费使用in关键字。实际上,in只是一个语法糖,它委托给__contains__()方法。任何具有__contains__()方法的类都是Container,因此可以通过in关键字进行查询。例如:

>>> odd = OddIntegers()
>>> 1 in odd
True
>>> 2 in odd
False
>>> 3 in odd
True 

这里真正的价值在于能够创建与 Python 内置泛型集合完全兼容的新类型集合。例如,我们可以创建一个使用二叉树结构来保留键而不是哈希查找的字典。我们将从Mapping抽象基类定义开始,但会更改支持__getitem__()__setitem__()__delitem__()等方法的算法。

Python 的鸭子类型(duck typing)通过内置函数isinstance()issubclass()(部分)工作。这些函数用于确定类之间的关系。它们依赖于类可以提供的两个内部方法:__instancecheck__()__subclasscheck__()。一个抽象基类(ABC)可以提供一个__subclasshook__()方法,该方法被__subclasscheck__()方法用来断言给定的类是抽象基类的适当子类。这些细节超出了本书的范围;这可以被视为一个路标,指出在创建需要与内置类并存的创新类时需要遵循的路径。

抽象基类和类型提示

抽象基类的概念与泛型类的思想紧密相连。抽象基类通常在某个由具体实现提供的细节上是泛型的。

大多数 Python 的通用类——例如listdictset——可以用作类型提示,并且这些提示可以进行参数化以缩小范围。list[Any]list[int]之间有着天壤之别;值["a", 42, 3.14]对于第一种类型提示是有效的,但对于另一种则无效。这种将通用类型参数化以使其更具体的概念,通常也适用于抽象类。

为了使这可行,你通常需要将 from __future__ import annotations 作为代码的第一行。这修改了 Python 的行为,允许函数和变量注释来参数化这些标准集合。

通用类和抽象基类不是同一回事。这两个概念有重叠,但又是不同的:

  • 通用类与Any具有隐式关系。这通常需要使用类型参数来缩小范围,例如list[int]。列表类是具体的,当我们想要扩展它时,我们需要插入一个类名来替换Any类型。Python 解释器根本不使用通用类提示;它们只由静态分析工具如mypy进行检查。

  • 抽象类使用占位符代替一个或多个方法。这些占位符方法需要做出一个设计决策,以提供具体的实现。这些类并不是完全定义好的。当我们扩展它们时,我们需要提供具体的方法实现。这由mypy进行检查。但这还不是全部。如果我们没有提供缺失的方法,当尝试创建一个抽象类的实例时,解释器将引发运行时异常。

一些类可以是抽象的和泛型的。如上所述,类型参数帮助mypy理解我们的意图,但不是必需的。需要具体的实现。

与抽象类相邻的另一个概念是协议。这是鸭子类型工作原理的精髓:当两个类拥有相同的批方法时,它们都遵循一个共同的协议。每次我们看到具有相似方法的类时,都有一个共同的协议;这可以通过类型提示来形式化。

考虑可哈希的对象。不可变类实现了 __hash__() 方法,包括字符串、整数和元组。通常,可变类不实现 __hash__() 方法;这包括 listdictset 等类。这个方法就是 Hashable 协议。如果我们尝试编写像 dict[list[int], list[str]] 这样的类型提示,那么 mypy 将会反对 list[int] 不能用作键。它不能用作键,因为给定的类型 list[int] 没有实现 Hashable 协议。在运行时,尝试使用可变键创建字典项也会因为同样的原因失败:列表没有实现所需的方法。

创建 ABCs 的本质定义在abc模块中。我们稍后会探讨它是如何工作的。目前,我们想要使用抽象类,这意味着要使用collections模块中的定义。

The collections.abc module

抽象基类的一个显著用途是在collections.abc模块中。此模块提供了 Python 内置集合的抽象基类定义。这就是如何从单个组件定义中构建listsetdict(以及其他一些)的原因。

我们可以使用定义来构建我们自己的独特数据结构,这些结构的方式与内置结构重叠。我们还可以在想要为数据结构的一个特定特性编写类型提示时使用这些定义,而不必过于具体地说明可能也接受的替代实现。

collections.abc 中的定义并不——简单地——包括 listsetdict。相反,该模块提供了诸如 MutableSequenceMutableMappingMutableSet 这样的定义,这些定义——实际上——是抽象基类,而我们使用的 listdictset 类则是这些基类的具体实现。让我们回顾 Mapping 定义的各个方面及其起源。Python 的 dict 类是 MutableMapping 的具体实现。这种抽象来源于将键映射到值的思想。MutableMapping 类依赖于 Mapping 定义,一个不可变、冻结的字典,可能针对查找进行了优化。让我们追踪这些抽象之间的关系。

这是我们要遵循的路径:

图表描述自动生成

图 6.2:映射抽象

从中间开始,我们可以看到Mapping定义依赖于Collection类定义。反过来,Collection抽象类的定义又依赖于三个其他的抽象基类:SizedIterableContainer。每个这些抽象都要求特定的方法。

如果我们要创建一个只读的字典——一个具体的Mapping实现——我们需要实现至少以下方法:

  • Sized 抽象需要为 __len__() 方法提供一个实现。这使得我们的类实例能够对 len() 函数提供一个有用的回答。

  • Iterable 抽象需要实现 __iter__() 方法。这使得一个对象能够与 for 语句和 iter() 函数一起工作。在 第十章迭代器模式,我们将重新探讨这个主题。

  • 容器抽象需要实现__contains__()方法。这允许innot in运算符正常工作。

  • Collection 抽象将 SizedIterableContainer 结合在一起,而不引入额外的抽象方法。

  • 基于CollectionMapping抽象,需要__getitem__()__iter__()__len__()等方法,其中之一。它为__contains__()提供了一个默认定义,基于我们提供的__iter__()方法。Mapping定义还将提供一些其他方法。

这个方法列表直接来源于基类中的抽象关系。通过从这些抽象中构建我们的新字典式不可变类,我们可以确保我们的类将与其他 Python 泛型类无缝协作。

当我们查看docs.python.org/3.9/library/collections.abc.html中的文档时,我们看到页面主要由一个表格展示抽象类定义及其依赖的定义所主导。这里有一个依赖关系的网格,显示了类定义之间的重叠。正是这种重叠使得我们可以使用for语句遍历实现Iterable抽象基类的每一种集合。

让我们通过扩展抽象类来定义我们自己的不可变Mapping对象实现。目标是能够一次性加载我们的类似字典的映射,并使用它将键映射到它们的值。由于我们不会允许任何更新,我们可以应用各种算法使其既非常快速又非常紧凑。

目标是拥有如下类型的类:

BaseMapping = abc.Mapping[Comparable, Any] 

我们将创建一个类似于字典的映射,从一些键映射到——好吧——任何可能的类型对象。我们使用类型Comparable定义了键,因为我们希望能够比较键并将它们排序。在有序列表中进行搜索通常比在无序列表中进行搜索更有效。

我们首先将查看Lookup类定义的核心。在巩固了从键到值的新类型映射的基本要素之后,我们将回到Comparable类定义。

当我们考虑构建字典的方法时,我们会发现字典可以由两种不同的数据结构构建而成。我们新的映射必须具备这种相同的灵活性。以下两个结构是例证:

>>> x = dict({"a": 42, "b": 7, "c": 6})
>>> y = dict([("a", 42), ("b", 7), ("c", 6)])
>>> x == y
True 

我们可以从现有的映射中构建一个映射,或者我们可以从包含键和值的二元组序列中构建一个映射。这意味着__init__()有两个独立的定义:

  • def __init__(self, source: BaseMapping) -> None

  • def __init__(self, source: 可迭代的[tuple[可比较, 任意]]) -> None

这两个定义具有不同的类型提示。为了使 mypy 清楚,我们需要提供 重载 的方法定义。这通过 typing 模块中的一个特殊装饰器 @overload 来实现。我们将提供两个带有两种替代方案的方法定义;在这些定义之后,我们将提供实际执行有用工作的方法定义。因为这些是类型提示,所以它们不是必需的。它们可能有些冗长,但有助于我们确保有一个合理的实现。

这是Lookup类定义的第一部分。我们将将其拆分成几个部分,因为__init__()方法需要涵盖由替代重载定义的这两个情况:

BaseMapping = abc.Mapping[Comparable, Any]
class Lookup(BaseMapping):
    @overload
    def __init__(
          self, 
          source: Iterable[tuple[Comparable, Any]]
    ) -> None:
        ...
    @overload
    def __init__(self, source: BaseMapping) -> None:
        ...
    def __init__(
          self, 
          source: Union[Iterable[              tuple[Comparable, Any]]
              BaseMapping,
              None] = None,
    ) -> None:
        sorted_pairs: Sequence[tuple[Comparable, Any]]
        if isinstance(source, Sequence):
            sorted_pairs = sorted(source)
        elif isinstance(source, abc.Mapping):
            sorted_pairs = sorted(source.items())
        else:
            sorted_pairs = []
        self.key_list = [p[0] for p in sorted_pairs]
        self.value_list = [p[1] for p in sorted_pairs] 

__init__() 方法需要处理三种情况以加载映射。这意味着从一系列的键值对中构建值,或者从另一个映射对象中构建值,或者创建一个空的值序列。我们需要将键和值分开并将它们放入两个并行列表中。一个排序后的键列表可以快速搜索以找到匹配项。当我们从映射中获取键的值时,返回排序后的值列表。

这里是需要导入的依赖:

from __future__ import annotations
from collections import abc
from typing import Protocol, Any, overload, Union
import bisect
from typing import Iterator, Iterable, Sequence, Mapping 

这里是其他由@abstractmethod装饰器定义的抽象方法。我们提供了以下具体实现:

 def __len__(self) -> int:
        return len(self.key_list)
    def __iter__(self) -> Iterator[Comparable]:
        return iter(self.key_list)
    def __contains__(self, key: object) -> bool:
        index = bisect.bisect_left(self.key_list, key)
        return key == self.key_list[index]
    def __getitem__(self, key: Comparable) -> Any:
        index = bisect.bisect_left(self.key_list, key)
        if key == self.key_list[index]:
            return self.value_list[index]
        raise KeyError(key) 

__len__()__iter__()__contains__() 方法是 SizedIterableContainer 抽象类所要求的。Collection 抽象类结合了其他三个,但没有引入任何新的抽象方法。

__getitem__() 方法必须是一个 Mapping。没有它,我们无法根据给定的键检索单个值。

使用 bisect 模块是快速在排序键列表中查找特定值的一种方法。bisect.bisect_left() 函数用于找到键在列表中的位置。如果键存在,我们可以返回它所映射的值。如果键不存在,我们可以引发 KeyError 异常。

注意,__contains__() 方法定义中使用了 object 类作为类型提示,这与其他方法不同。这是必需的,因为 Python 的 in 操作需要支持任何类型的对象,甚至那些明显不支持 Comparable 协议的对象。

这是我们使用我们闪亮的新Lookup类时的样子:

>>> x = Lookup(
...     [
...         ["z", "Zillah"],
...         ["a", "Amy"],
...         ["c", "Clara"],
...         ["b", "Basil"],
...     ]
... )
>>> x["c"]
'Clara' 

这组集合通常表现得有点像字典。尽管如此,我们无法使用一些像字典那样的特性,因为我们选择了一个抽象基类,它没有描述dict类的全部方法集。

如果我们尝试类似这样做:

>>> x["m"] = "Maud" 

我们将遇到一个异常,它将说明我们构建的类的局限性:

TypeError: 'Lookup' object does not support item assignment 

这个异常与我们的设计其余部分保持一致。更新此对象意味着在正确的位置插入一个项目以保持排序顺序。在大列表周围进行洗牌变得昂贵;如果我们需要更新查找集合,我们应该考虑其他数据结构,如红黑树。但是,对于使用二分查找算法的纯搜索操作,这表现得很不错。

我们跳过了Comparable类的定义。这定义了键的最小特征集——即协议——用于键。这是一种形式化比较规则的方式,这些规则是保持映射中键的顺序所需的。这有助于mypy确认我们尝试用作键的对象确实可以进行比较:

from typing import Protocol, Any
class Comparable(Protocol):
    def __eq__(self, other: Any) -> bool: ...
    def __ne__(self, other: Any) -> bool: ...
    def __le__(self, other: Any) -> bool: ...
    def __lt__(self, other: Any) -> bool: ...
    def __ge__(self, other: Any) -> bool: ...
    def __gt__(self, other: Any) -> bool: ... 

没有实现。这个定义用于引入一个新的类型提示。因为这是一个提示,所以我们为方法提供...作为主体,因为主体将由现有的类定义,如strint提供。

注意,我们并不依赖于项目具有哈希码。这是对内置dict类的一个有趣扩展,它要求键必须是可哈希的。

使用抽象类的一般方法是这样的:

  1. 找到一个能满足你大部分需求的课程。

  2. 识别collections.abc定义中标记为抽象的方法。文档通常会提供很多信息,但你还需要查看源代码。

  3. 继承抽象类,填充缺失的方法。

  4. 虽然制作一个方法清单可能会有所帮助,但也有一些工具可以帮助你完成这项工作。创建一个单元测试(我们将在第十三章面向对象程序的测试中介绍测试)意味着你需要创建你新类的一个实例。如果你还没有定义所有抽象方法,这将引发一个异常。使用mypy也可以发现那些在具体子类中未正确定义的抽象方法。

这是一种在选择了合适的抽象时重用代码的强大方式;一个人可以在不知道所有细节的情况下形成对类的心理模型。这同样是一种创建紧密相关的类并能够轻松被mypy检查的强大方式。除了这两个优点之外,将方法标记为抽象的形式性还给我们提供了运行时保证,即具体的子类确实实现了所有必需的方法。

现在我们已经看到了如何使用抽象基类,接下来让我们看看如何定义一个新的抽象。

创建自己的抽象基类

我们有两条创建类似类的通用路径:我们可以利用鸭子类型,或者我们可以定义公共抽象。当我们利用鸭子类型时,我们可以通过创建一个使用协议定义来列举公共方法或使用Union[]来列举公共类型的类型提示来形式化相关类型。

影响因素几乎无限,它们暗示了某种或另一种方法。虽然鸭子类型提供了最大的灵活性,但我们可能要牺牲使用mypy的能力。抽象基类定义可能过于冗长且容易引起混淆。

我们将解决一个小问题。我们想要构建一个涉及多面骰子的游戏模拟。这些骰子包括四面、六面、八面、十二面和二十面。六面骰子是传统的立方体。有些骰子套装包括十面骰子,这很酷,但技术上并不是一个规则多面体;它们是由两组五个“风筝形”面组成的。

提出一个问题是如何最好地模拟这些不同形状骰子的滚动。在 Python 中,有三个现成的随机数据来源:random模块、os模块和secrets模块。如果我们转向第三方模块,我们可以添加加密库如pynacl,它提供了更多的随机数功能。

我们可以将随机数生成器的选择嵌入到类中,而是可以定义一个具有骰子一般特征的抽象类。一个具体的子类可以提供缺失的随机化能力。random模块有一个非常灵活的生成器。os模块的功能有限,但涉及使用一个熵收集器来增加随机性。灵活性和高熵通常通过加密生成器结合在一起。

要创建我们的掷骰子抽象,我们需要abc模块。这与collections.abc模块不同。abc模块包含抽象类的基础定义:

import abc
class Die(abc.ABC):
    def __init__(self) -> None:
        self.face: int
        self.roll()
    @abc.abstractmethod
    def roll(self) -> None:
        ...
    def __repr__(self) -> str:
        return f"{self.face}" 

我们定义了一个从 abc.ABC 类继承的类。使用 ABC 作为父类可以确保任何尝试直接创建 Die 类实例的操作都会引发一个 TypeError 异常。这是一个运行时异常;它也由 mypy 进行检查。

我们使用@abc.abstract装饰器将方法roll()标记为抽象。这不是一个非常复杂的方法,但任何子类都应该符合这个抽象定义。这仅由mypy进行检查。当然,如果我们把具体实现搞得一团糟,运行时很可能会出现问题。考虑以下一团糟的代码:

>>> class Bad(Die):
...     def roll(self, a: int, b: int) -> float:
...         return (a+b)/2 

这将在运行时引发一个 TypeError 异常。问题是由基类 __init__() 没有为这个看起来奇怪的 roll() 方法提供 ab 参数引起的。这是有效的 Python 代码,但在这种上下文中没有意义。该方法还会生成 mypy 错误,提供了足够的警告,表明方法定义与抽象不匹配。

下面是Die类两个适当扩展的示例:

class D4(Die):
    def roll(self) -> None:
        self.face = random.choice((1, 2, 3, 4))
class D6(Die):
    def roll(self) -> None:
        self.face = random.randint(1, 6) 

我们已经提供了为Die类中的抽象占位符提供合适定义的方法。它们采用了截然不同的方法来选择一个随机值。四面骰子使用random.choice()。六面骰子——即大多数人所熟知的普通立方骰子——使用random.randint()

让我们更进一步,创建另一个抽象类。这个类将代表一把骰子。同样,我们有许多候选解决方案,并且可以使用抽象类来推迟最终的设计选择。

这个设计的有趣之处在于掷骰子游戏规则的不同。在一些游戏中,规则要求玩家掷出所有骰子。许多两骰子游戏的规则要求玩家掷出两个骰子。在其他游戏中,规则允许玩家保留骰子,并重新掷选定的骰子。在一些游戏中,比如“帆船”游戏,玩家最多允许重新掷骰子两次。在其他游戏中,比如“零分”游戏,他们可以一直重新掷骰子,直到他们选择保留得分或掷出无效的骰子,从而失去所有分数,得到零分(因此得名)。

这些是应用于简单Die实例列表的截然不同的规则。下面是一个将掷骰子实现作为抽象的类:

class Dice(abc.ABC):
    def __init__(self, n: int, die_class: Type[Die]) -> None:
        self.dice = [die_class() for _ in range(n)]
    @abc.abstractmethod
    def roll(self) -> None:
        ...
    @property
    def total(self) -> int:
        return sum(d.face for d in self.dice) 

__init__() 方法期望一个整数,n,以及用于创建 Die 实例的类,命名为 die_class。类型提示为 Type[Die],告诉 mypy 密切关注任何抽象基类 Die 的子类。我们并不期望任何 Die 子类的实例;我们期望的是类对象本身。我们期望看到 SomeDice(6, D6) 来创建一个包含六个 D6 类实例的列表。

我们将Die实例的集合定义为列表,因为这看起来很简单。有些游戏在保存一些骰子并重新掷剩余骰子时,会通过位置来识别骰子,整数列表索引对于这一点来说似乎很有用。

这个子类实现了“掷所有骰子”的规则:

class SimpleDice(Dice):
    def roll(self) -> None:
        for d in self.dice:
            d.roll() 

每次应用程序评估 roll() 时,所有骰子都会更新。它看起来是这样的:

>>> sd = SimpleDice(6, D6)
>>> sd.roll()
>>> sd.total
23 

对象sd是抽象类Dice派生出的具体类SimpleDice的一个实例。这个SimpleDice实例包含了六个D6类的实例。同样,D6类也是一个从抽象类Die派生出的具体类。

这里是另一个提供一组截然不同方法的子类。其中一些方法填补了抽象方法留下的空白。然而,还有一些方法是子类独有的:

class YachtDice(Dice):
    def __init__(self) -> None:
        super().__init__(5, D6)
        self.saved: Set[int] = set()
    def saving(self, positions: Iterable[int]) -> "YachtDice":
        if not all(0 <= n < 6 for n in positions):
            raise ValueError("Invalid position")
        self.saved = set(positions)
        return self
    def roll(self) -> None:
        for n, d in enumerate(self.dice):
            if n not in self.saved:
                d.roll()
        self.saved = set() 

我们创建了一套保存位置。最初它是空的。我们可以使用saving()方法来提供一个整数位置的迭代集合以进行保存。它的工作方式如下:

>>> sd = YachtDice()
>>> sd.roll()
>>> sd.dice
[2, 2, 2, 6, 1]
>>> sd.saving([0, 1, 2]).roll()
>>> sd.dice
[2, 2, 2, 6, 6] 

我们将手牌从三张同花顺提升到了满堂红。

在这两种情况下,无论是Die类还是Dice类,并不明显地看出abc.ABC基类以及存在@abc.abstractmethod装饰比提供一个具有一组常见默认定义的具体基类有显著优势。

在某些语言中,需要基于抽象的定义。在 Python 中,由于鸭子类型,抽象是可选的。在它有助于阐明设计意图的情况下,使用它。在它显得过于繁琐且几乎无足轻重的情况下,则将其置之不理。

因为它用于定义集合,所以我们经常在类型提示中使用collection.abc名称来描述协议对象必须遵循的规则。在不常见的场合,我们将利用collections.abc抽象来创建我们自己的独特集合。

揭秘魔法

我们已经使用了抽象基类,很明显它们为我们做了很多工作。让我们看看类内部,看看一些正在发生的事情:

>>> from dice import Die
>>> Die.__abstractmethods__
frozenset({'roll'})
>>> Die.roll.__isabstractmethod__
True 

抽象方法 roll() 在类的特别命名的属性 __abstractmethods__ 中进行跟踪。这表明了 @abc.abstractmethod 装饰器的作用。此装饰器将 __isabstractmethod__ 设置为标记方法。当 Python 最终从各种方法和属性构建类时,抽象方法的列表也会被收集,以创建一个必须实现的方法的类级别集合。

任何扩展 Die 的子类也将继承这个 __abstractmethods__ 集合。当在子类内部定义方法时,随着 Python 从定义构建类,名称将从集合中移除。我们只能创建那些类中抽象方法集合为空的类的实例。

这其中的核心是类的创建方式:类构建对象。这是大多数面向对象编程的本质。但什么是类呢?

  1. 类是另一种具有两个非常有限职责的对象:它拥有用于创建和管理该类实例的特殊方法,并且它还充当该类对象方法定义的容器。我们认为使用class语句构建类对象,这留下了class语句如何构建class对象的问题。

  2. type 类是构建我们应用程序类的内部对象。当我们输入类的代码时,构建的细节实际上是 type 类方法的职责。在 type 创建了我们的应用程序类之后,我们的类随后创建了解决我们问题的应用程序对象。

type 对象被称为元类,是用于构建类的类。这意味着每个类对象都是 type 的一个实例。大多数情况下,我们很乐意让 class 语句由 type 类处理,以便我们的应用程序代码可以运行。然而,有一个地方,我们可能想要改变 type 的工作方式。

因为 type 本身就是一个类,所以它可以被扩展。类 abc.ABCMeta 扩展了 type 类以检查带有 @abstractmethod 装饰的方法。当我们扩展 abc.ABC 时,我们正在创建一个新的类,该类使用 ABCMeta 元类。我们可以在 ABCMeta 类的特殊 __mro__ 属性的值中看到这一点;该属性列出了用于解析方法名称的类(MRO方法解析顺序)。这个特殊属性列出了要搜索给定属性的以下类:abc.ABCMeta 类、type 类,最后是 object 类。

我们在创建新类时,如果想显式使用ABCMeta元类,可以这样操作:

class DieM(metaclass=abc.ABCMeta):
    def __init__(self) -> None:
        self.face: int
        self.roll()
    @abc.abstractmethod
    def roll(self) -> None:
        ... 

我们在定义组成类的组件时使用了metaclass作为关键字参数。这意味着将使用abc.ABCMeta扩展来创建最终的类对象。

现在我们已经了解了如何构建类,我们就可以考虑在创建和扩展类时可以做的事情。Python 揭示了语法运算符(如 / 运算符)与实现类的方法之间的绑定。这使得 floatint 类可以使用 / 运算符做不同的事情,但它也可以用于相当不同的目的。例如,我们将在第九章“字符串、序列化和文件路径”中讨论的 pathlib.Path 类,也使用了 / 运算符。

运算符重载

Python 的运算符,如 +, /, -, * 等,是通过类上的特殊方法实现的。我们可以比内置的数字和集合类型更广泛地应用 Python 运算符。这样做可以称为“重载”运算符:让它们能够与更多内置类型一起工作。

回顾本章早先的 The collections.abc module 部分,我们曾暗示了 Python 如何将一些内置特性与我们的类关联起来。当我们查看 collections.abc.Collection 类时,它是所有 SizedIterableContainers 的抽象基类;它需要三个方法来启用两个内置函数和一个内置运算符:

  • __len__() 方法被内置的 len() 函数所使用。

  • __iter__() 方法被内置的 iter() 函数使用,这意味着它被 for 语句使用。

  • __contains__() 方法由内置的 in 操作符使用。此操作符由内置类的方法实现。

想象内置的 len() 函数具有如下定义是没有错的:

def len(object: Sized) -> int:
    return object.__len__() 

当我们请求len(x)时,它执行的操作与x.__len__()相同,但更短,更易于阅读,也更易于记忆。同样,iter(y)实际上等同于y.__iter__()。而像z in S这样的表达式,其评估过程就像它是S.__contains__(z)一样。

当然,除了少数例外,Python 都是按照这种方式工作的。我们编写愉快、易于阅读的表达式,这些表达式会被转换成特殊方法。唯一的例外是逻辑运算符:andornotif-else。这些并不直接映射到特殊方法定义。

因为几乎所有的 Python 都依赖于特殊方法,这意味着我们可以改变它们的行为来添加功能。我们可以用新的数据类型重载运算符。一个突出的例子是在 pathlib 模块中:

>>> from pathlib import Path
>>> home = Path.home()
>>> home / "miniconda3" / "envs"
PosixPath('/Users/slott/miniconda3/envs') 

注意:您的结果将因操作系统和用户名而异。

不变的是,/运算符用于将Path对象与字符串对象连接,以创建一个新的Path对象。

/ 运算符是通过 __truediv__()__rtruediv__() 方法实现的。为了使操作具有交换性,Python 在查找实现时会查看两个地方。给定一个表达式 A *op* B,其中 op 是 Python 中的任何运算符,如 __add__ 对应于 +,Python 会进行以下检查以实现特殊方法来执行运算符:

  1. BA 的真子类时,存在一个特殊情况。在这些罕见的情况下,顺序会被颠倒,因此 B.__rop__(A) 可以在尝试其他任何操作之前被尝试。这使得子类 B 可以覆盖超类 A 中的操作。

  2. 尝试 A.__op**(B). 如果这返回的不是特殊的 NotImplemented 值,这就是结果。对于一个 Path 对象表达式,例如 home / "miniconda3",这实际上等同于 home.__truediv__("miniconda3")。一个新的 Path 对象将从旧的 Path 对象和字符串中构建。

  3. 尝试 B.__rop__(A). 这可能是反向加法实现的 __radd__() 方法。如果此方法返回的值不是 NotImplemented 值,则这是结果。请注意,操作数顺序被反转。对于交换律操作,如加法和乘法,这并不重要。对于非交换律操作,如减法和除法,顺序的改变需要在实现中反映出来。

让我们回到我们的一把骰子的例子。我们可以实现一个+运算符来将一个Die实例添加到一个Dice集合中。我们将从一个包含不同种类骰子的异构一把骰子的基础类定义开始。查看之前的Dice类,它假设了同质骰子。这不是一个抽象类;它有一个roll方法的定义,该定义会重新掷所有骰子。我们将从一些基础知识开始,然后引入__add__()特殊方法:

class DDice:
    def __init__(self, *die_class: Type[Die]) -> None:
        self.dice = [dc() for dc in die_class]
        self.adjust: int = 0
    def plus(self, adjust: int = 0) -> "DDice":
        self.adjust = adjust
        return self
    def roll(self) -> None:
        for d in self.dice:
            d.roll()
    @property
    def total(self) -> int:
        return sum(d.face for d in self.dice) + self.adjust 

这点并不令人惊讶。它看起来与上面定义的Dice类非常相似。我们添加了一个由plus()方法设置的adjust属性,这样我们就可以使用DDice(D6, D6, D6).plus(2)。它与一些桌面角色扮演游戏(TTRPGs)更契合。

此外,请记住我们向 DDice 类提供骰子的类型,而不是骰子的实例。我们使用类对象 D6,而不是通过表达式 D6() 创建的 Die 实例。类的实例是通过 DDice 类的 __init__() 方法创建的。

这里有个酷炫的功能:我们可以使用加号运算符与 DDice 对象、Die 类以及整数一起定义一个复杂的骰子投掷:

def __add__(self, die_class: Any) -> "DDice":
    if isinstance(die_class, type) and issubclass(die_class, Die):
        new_classes = [type(d) for d in self.dice] + [die_class]
        new = DDice(*new_classes).plus(self.adjust)
        return new
    elif isinstance(die_class, int):
        new_classes = [type(d) for d in self.dice]
        new = DDice(*new_classes).plus(die_class)
        return new
    else:
        return NotImplemented
def __radd__(self, die_class: Any) -> "DDice":
    if isinstance(die_class, type) and issubclass(die_class, Die):
        new_classes = [die_class] + [type(d) for d in self.dice]
        new = DDice(*new_classes).plus(self.adjust)
        return new
    elif isinstance(die_class, int):
        new_classes = [type(d) for d in self.dice]
        new = DDice(*new_classes).plus(die_class)
        return new
    else:
        return NotImplemented 

这两种方法在许多方面都很相似。我们检查三种不同的+运算:

  • 如果参数值 die_class 是一个类型,并且它是 Die 类的子类,那么我们就在 DDice 集合中添加另一个 Die 对象。这类似于 DDice(D6) + D6 + D6 这样的表达式。大多数操作符实现的意义是从前面的对象创建一个新的对象。

  • 如果参数值是一个整数,那么我们正在对一个骰子集合进行调整。这就像 DDice(D6, D6, D6) + 2

  • 如果参数值既不是Die的子类也不是整数,那么可能存在其他情况,并且这个类没有实现。这可能是某种错误,或者可能是参与操作的另一个类可以提供实现;返回NotImplemented给其他对象一个执行操作的机会。

因为我们已经提供了 __radd__() 以及 __add__(),这些操作是交换的。我们可以使用类似 D6 + DDice(D6) + D62 + DDice(D6, D6) 的表达式。

我们需要执行特定的 isinstance() 检查,因为 Python 操作符是完全通用的,预期的类型提示必须是 Any。我们只能通过运行时检查来缩小适用类型。mypy 程序在遵循分支逻辑以确认整数对象在整数上下文中正确使用方面非常聪明。

"但是等等,”你说,“我喜欢的游戏有需要 3d6+2 的规则。”这表示掷三个六面的骰子并将结果加二。在许多桌上角色扮演游戏(TTRPGs)中,这种缩写用于总结骰子的使用。

我们能否添加乘法来完成这个操作?完全没有理由不行。对于乘法,我们只需要担心整数。D6 * D6 在任何规则中都没有使用,但 3*D6 与大多数桌面角色扮演游戏(TTRPG)规则的文本非常吻合:

def __mul__(self, n: Any) -> "DDice":
    if isinstance(n, int):
        new_classes = [type(d) for d in self.dice for _ in range(n)]
        return DDice(*new_classes).plus(self.adjust)
    else:
        return NotImplemented
def __rmul__(self, n: Any) -> "DDice":
    if isinstance(n, int):
        new_classes = [type(d) for d in self.dice for _ in range(n)]
        return DDice(*new_classes).plus(self.adjust)
    else:
        return NotImplemented 

这两种方法遵循与 __add__()__radd__() 方法相似的设计模式。对于每个现有的 Die 子类,我们将创建几个类的实例。这使得我们可以使用 3 * DDice(D6) + 2 这样的表达式来定义掷骰子的规则。Python 的运算符优先级规则仍然适用,因此 3 * DDice(D6) 这一部分会先被评估。

Python 对各种 __op__()__rop__() 方法的使用,对于将各种运算符应用于不可变对象(如字符串、数字和元组)来说非常有效。我们的一把骰子可能会让人有些摸不着头脑,因为单个骰子的状态可能会改变。重要的是,我们将手牌的组合视为不可变的。对 DDice 对象的每一次操作都会创建一个新的 DDice 实例。

那么,可变对象又是怎样的呢?当我们编写一个赋值语句,例如 some_list += [some_item],我们实际上是在修改 some_list 对象的值。+= 语句与更复杂的表达式 some_list.extend([some_item]) 做的是同样的事情。Python 通过像 __iadd__()__imul__() 这样的操作符名称支持这种操作。这些是“原地”操作,旨在修改对象。

例如,考虑:

>>> y = DDice(D6, D6)
>>> y += D6 

这可以通过两种方式之一进行处理:

  • 如果 DDice 实现了 __iadd__(),这将变为 y.__iadd__(D6)。该对象可以就地修改自身。

  • 如果 DDice 没有实现 __iadd__(),则这是 y = y.__add__(D6)。该对象创建了一个新的、不可变的对象,并赋予它旧对象的变量名。这使得我们可以执行类似 string_variable += "." 的操作。在底层,string_variable 并未被修改;它被替换了。

如果对一个对象进行可变操作是有意义的,我们可以通过这个方法支持对DDice对象的就地修改:

def __iadd__(self, die_class: Any) -> "DDice":
    if isinstance(die_class, type) and issubclass(die_class, Die):
        self.dice += [die_class()]
        return self
    elif isinstance(die_class, int):
        self.adjust += die_class
        return self
    else:
        return NotImplemented 

__iadd__() 方法向骰子的内部集合中添加元素。它遵循与 __add__() 方法类似的规则:当提供一个类时,会创建一个实例,并将其添加到 self.dice 列表中;如果提供一个整数,则将其添加到 self.adjust 值中。

我们现在可以对单个掷骰子规则进行增量更改。我们可以使用赋值语句来改变单个DDice对象的状态。由于对象发生了变化,我们并没有创建很多对象的副本。复杂骰子的创建方式如下:

>>> y = DDice(D6, D6)
>>> y += D6
>>> y += 2 

这逐步构建了 3d6+2 骰子投掷器。

使用内部特殊方法名称允许与其他 Python 功能无缝集成。我们可以使用 collections.abc 构建与现有集合相匹配的类。我们可以重写实现 Python 操作符的方法,以创建易于使用的语法。

我们可以利用特殊的方法名称来为 Python 的内置泛型集合添加功能。我们将在下一节中讨论这个话题。

扩展内置功能

Python 有两个内置集合,我们可能想要扩展。我们可以将这些大致分为以下几类:

  • 不可变对象,包括数字、字符串、字节和元组。这些对象通常会定义扩展运算符。在本章的“运算符重载”部分,我们探讨了如何为Dice类的对象提供算术运算。

  • 可变集合,包括集合、列表和字典。当我们查看collections.abc中的定义时,这些是带大小、可迭代的容器,三个我们可能想要关注的独立方面。在本章的collections.abc模块部分,我们探讨了如何创建对Mapping抽象基类的扩展。

除了其他内置类型外,这两组分类通常适用于各种问题。例如,我们可以创建一个拒绝重复值的字典。

内置字典总是更新与键关联的值。这可能导致看起来奇怪但能正常工作的代码。例如:

>>> d = {"a": 42, "a": 3.14}
>>> d
{'a': 3.14} 

然后:

>>> {1: "one", True: "true"}
{1: 'true'} 

这些是定义良好的行为。在表达式中提供两个键但在结果中只有一个键看起来可能有些奇怪,但构建字典的规则使得这种情况不可避免且结果正确。

我们可能不喜欢默默忽略一个键的行为。这可能会让我们的应用程序变得不必要地复杂,去担心重复的可能性。让我们创建一种新的字典,一旦加载了项目,它就不会更新这些项目。

学习 collections.abc 时,我们需要扩展一个映射,通过改变 __setitem__() 的定义来防止更新现有的键。在交互式 Python 提示符下工作,我们可以尝试这样做:

>>> from typing import Dict, Hashable, Any, Mapping, Iterable
>>> class NoDupDict(Dict[Hashable, Any]):
...     def __setitem__(self, key, value) -> None:
...         if key in self:
...             raise ValueError(f"duplicate {key!r}")
...         super().__setitem__(key, value) 

当我们将它付诸实践时,我们看到了以下情况:

>>> nd = NoDupDict()
>>> nd["a"] = 1
>>> nd["a"] = 2
Traceback (most recent call last):
  ...
  File "<doctest examples.md[10]>", line 1, in <module>
    nd["a"] = 2
  File "<doctest examples.md[7]>", line 4, in __setitem__
    raise ValueError(f"duplicate {key!r}")
ValueError: duplicate 'a' 

我们还没有完成,但已经有一个良好的开端。在某些情况下,这个字典会拒绝重复项。

然而,当我们尝试从一个字典构造另一个字典时,它并没有阻止重复的键。我们不希望它这样做:

>>> NoDupDict({"a": 42, "a": 3.14})
{'a': 3.14} 

所以我们还有一些工作要做。一些表达式会正确地引发异常,而其他表达式仍然默默地忽略重复的键。

基本问题是并非所有设置项的方法都使用了 __setitem__()。为了缓解上述问题,我们还需要重写 __init__() 方法。

我们还需要在我们的初稿中添加类型提示。这将使我们能够利用mypy来确认我们的实现将普遍适用。下面是添加了__init__()的方法版本:

from __future__ import annotations
from typing import cast, Any, Union, Tuple, Dict, Iterable, Mapping
from collections import Hashable
DictInit = Union[
    Iterable[Tuple[Hashable, Any]],     Mapping[Hashable, Any], 
    None]
class NoDupDict(Dict[Hashable, Any]):
    def __setitem__(self, key: Hashable, value: Any) -> None:
        if key in self:
            raise ValueError(f"duplicate {key!r}")
        super().__setitem__(key, value)
    def __init__(self, init: DictInit = None, **kwargs: Any) -> None:
        if isinstance(init, Mapping):
            super().__init__(init, **kwargs)
        elif isinstance(init, Iterable):
            for k, v in cast(Iterable[Tuple[Hashable, Any]], init):
                self[k] = v
        elif init is None:
            super().__init__(**kwargs)
        else:
            super().__init__(init, **kwargs) 

这个版本的 NoDupDict 类实现了一个 __init__() 方法,它可以与多种数据类型一起工作。我们使用 DictInit 类型提示列举了各种类型。这包括一系列 键值对,以及另一个映射。在键值对序列的情况下,我们可以使用之前定义的 __setitem__() 方法来在键值重复时抛出异常。

这涵盖了初始化用例,但——仍然——没有涵盖所有可以更新映射的方法。我们仍然需要实现update()setdefault()__or__()__ior__()来扩展所有可以修改字典的方法。虽然创建这些方法需要大量工作,但这些工作被封装在一个字典子类中,我们可以在我们的应用程序中使用它。这个子类与内置类完全兼容;它实现了我们没有编写的方法,并且还有一个我们编写的额外功能。

我们构建了一个更复杂的字典,它扩展了 Python dict类的核心功能。我们的版本增加了一个拒绝重复的功能。我们还涉及了使用abc.ABC(以及abc.ABCMeta)来创建抽象基类。有时我们可能希望更直接地控制创建新类的机制。接下来,我们将转向元类。

元类

如我们之前所述,创建一个新的类涉及到由type类执行的工作。type类的任务是创建一个空的类对象,这样各种定义和属性赋值语句就可以构建出我们应用所需的最终、可用的类。

这就是它的工作原理:

图表描述自动生成

图 6.3:如何创建 MyClass 类型

class语句用于定位适当的元类;如果没有提供特殊的metaclass=,则使用type类。type类将准备一个新的、空的字典,称为命名空间,然后类中的各种语句将填充这个容器,添加属性和方法定义。最后,“new”步骤完成类的创建;这通常是我们可以进行更改的地方。

这里有一个图表展示我们如何使用一个新的类,SpecialMeta,来利用type为我们构建新类的方式:

图表描述自动生成

图 6.4:扩展类型类

如果我们在创建类时使用 metaclass= 选项,我们将改变所使用的元类。在先前的图中,SpecialMetatype 类的子类,并且它可以为我们的类定义执行一些特殊处理。

虽然我们可以用这个技术做一些巧妙的事情,但保持对元类的正确认识是很重要的。它们改变了类对象构建的方式,有可能重新定义了“类”的含义。这可能会极大地改变 Python 面向对象编程的基础。当阅读和维护代码的人无法理解为什么某些事情会起作用时,这可能会导致挫败感;不应轻率地采取这种做法。

让我们看看一个元类,它为我们把一些小特性构建到类定义中。让我们继续扩展本章前面提到的骰子模拟示例。我们可能有一系列骰子类,每个都是抽象基类 Die 的一个实例。我们希望它们都拥有围绕实现提供的 roll() 方法的审计日志。我们希望分别跟踪每次投掷,也许这样有人可以审查它们的统计有效性。

因为我们不想强迫各种骰子的程序员包含任何额外的或新的代码,所以我们更倾向于为所有 Die 类的抽象基类添加日志记录功能,并且调整 roll() 方法的具体实现以创建日志输出。

这是一个很高的要求。由于我们正在处理抽象类,这使得任务变得更加具有挑战性。这需要我们小心地区分抽象类构造和具体类构造。我们不希望强迫程序员更改他们的具体Die类定义。

要使用元类解决这个问题,我们需要对每个构建的与Die相关的具体类执行以下三件事:

  1. 扩展 ABCMeta 元类。我们需要支持 @abc.abstractmethod 装饰,因此我们希望从内置的 type 元类中获取所有现有的元类功能。

  2. logger属性注入到每个类中。通常,日志记录器的名称与类名相匹配;在元类中这样做很容易。我们可以在创建类的任何实例之前,将日志记录器作为类的一部分创建。

  3. 将混凝土的 roll() 方法封装成一个函数,该函数使用程序员提供的 roll() 方法,同时向记录器写入消息。这与方法装饰器的工作方式类似。

元类定义需要使用 __new__() 方法对最终类的构建方式做轻微调整。我们不需要扩展 __prepare__() 方法。我们的 __new__() 方法将使用 abc.ABCMeta.__new__() 来构建最终的类对象。这个 ABCMeta 类将决定对象是具体的还是保持抽象状态,因为 roll() 方法尚未定义:

import logging
from functools import wraps
from typing import Type, Any
class DieMeta(abc.ABCMeta):
    def __new__(
        metaclass: Type[type],
        name: str,
        bases: tuple[type, ...],
        namespace: dict[str, Any],
        **kwargs: Any,
    ) -> "DieMeta":
        if "roll" in namespace and not getattr(
            namespace["roll"], "__isabstractmethod__", False
        ):
            namespace.setdefault("logger", logging.getLogger(name))
            original_method = namespace["roll"]
            @wraps(original_method)
            def logged_roll(self: "DieLog") -> None:
                original_method(self)
                self.logger.info(f"Rolled {self.face}")
            namespace["roll"] = logged_roll
        new_object = cast(
            "DieMeta", abc.ABCMeta.__new__(
                 metaclass, name, bases, namespace)
        )
        return new_object 

__new__() 方法接收一大堆令人困惑的参数值:

  • 元类参数是对执行工作的元类的引用。Python 通常不会创建和使用元类的实例。相反,元类本身被作为参数传递给每个方法。这有点像提供给对象的self值,但它是类,而不是类的实例。

  • name参数是目标类的名称,取自原始的class语句。

  • bases参数是基类列表。这些是混入类,按照方法解析顺序排序。在这个例子中,它将是我们将定义的、使用此元类的超类,即下面将要展示的DieLog

  • namespace参数是一个由内置type类的__prepare__()方法启动的字典。当类体执行时,该字典会被更新;def语句和赋值语句将在该字典中创建条目。当我们到达__new__()方法时,类的(方法和变量)将在这里进行编排,等待构建最终的类对象。

  • kwargs 参数将包含作为类定义一部分提供的任何关键字参数。如果我们使用类似 class D6L(DieLog, otherparam="something") 的语句来创建一个新类,那么 otherparam 将会是传递给 __new__()kwargs 之一。

__new__() 方法必须返回新的类定义。通常,这是通过使用超类 __new__() 方法来构建类对象的结果。在我们的例子中,超类方法是 abc.ABCMeta.__new__()

在这个方法中,if 语句检查正在构建的类是否定义了所需的 roll() 方法。如果该方法被标记为 @abc.abstractmethod 装饰器,那么该方法将有一个属性 __isabstractmethod__,并且该属性的值将是 True。对于一个具体的方法——没有装饰器——将不会有 __isabstractmethod__ 属性值。这个条件确认存在一个 roll() 方法,并且如果该 roll() 方法是具体的。

对于具有具体 roll() 方法的班级,我们将在构建的命名空间中添加 "logger",提供一个名为适当的默认日志记录器。如果已经存在日志记录器,我们将保持其位置不变。

接下来,namespace["roll"] 从具体类中挑选出定义的函数,即 roll 方法。我们将定义一个替换方法,logged_roll。为了确保新的 logged_roll() 方法看起来像原始方法,我们使用了 @wraps 装饰器。这将复制原始方法名称和文档字符串到新方法中,使其看起来像原本在类中存在的定义。然后,这个定义被放回命名空间中,以便它可以被新类所包含。

最后,我们使用元类、类名、基类以及如果存在具体的roll()方法实现,我们修改的命名空间来评估abc.ABCMeta.__new__()__new__()操作最终确定类,执行所有原始的 Python 家务工作。

使用元类可能会有些尴尬;因此,通常提供一个使用元类的超类是很常见的。这意味着我们的应用程序可以扩展超类,而无需在类定义中额外处理metaclass=参数:

class DieLog(metaclass=DieMeta):
    logger: logging.Logger
    def __init__(self) -> None:
        self.face: int
        self.roll()
    @abc.abstractmethod
    def roll(self) -> None:
        ...
    def __repr__(self) -> str:
        return f"{self.face}" 

这个超类DieLog是由元类构建的。这个类的任何子类也将由元类构建。

现在,我们的应用程序可以创建DieLog的子类,无需担心元类的细节:我们不需要记得在定义中包含metaclass=。我们的最终应用程序类相当简洁:

class D6L(DieLog):
    def roll(self) -> None:
        """Some documentation on D6L"""
        self.face = random.randrange(1, 7) 

我们在这里创建了一个掷骰子工具,每次掷骰的结果都会记录在一个以类命名的记录器中。以下是它向控制台记录的样子:

>>> import sys
>>> logging.basicConfig(stream=sys.stdout, level=logging.INFO)
>>> d2 = D6L()
INFO:D6L:Rolled 1
>>> d2.face
1 

这个 D6L 类的日志记录方面的细节与该类特定的应用处理完全分离。我们可以更改元类来更改日志记录的细节,知道当元类更改时,所有相关的应用类都将被更改。

由于元类改变了类的构建方式,因此元类可以做的事情没有界限。常见的建议是保持元类特性非常小,因为它们很晦涩。按照目前的写法,元类的logged_roll()方法将丢弃子类中具体roll()方法的任何返回值。

案例研究

我们将在本章中完善我们的案例研究。之前,在第二章Python 中的对象中,我们以模糊的方式讨论了加载数据训练集并将其分为两个部分——训练集和测试集。在第五章何时使用面向对象编程中,我们探讨了将源文件反序列化为Sample实例的方法。

在本章中,我们希望进一步探讨使用原始数据创建多个TrainingKnownSample实例的操作,这些实例与多个TestingKnownSample实例分开。在前一章中,我们确定了样本对象的四种情况,如下表所示:

已知 未知
未分类 训练数据 待分类的样本
分类 测试数据 分类样本

当查看由植物学家分类的已知样本时,我们需要将数据分成两个独立的类别。我们将采用多种方法来完成这项工作,包括一系列重载的比较操作。

我们的训练数据排序可以从两个不同的方向来处理:

  • 我们可以摄入所有原始数据,然后将它们分配到两个集合中以便后续使用

  • 在摄入过程中,我们可以在集合之间进行选择

净效果是相同的。处理整个集合可能相对简单,但会消耗大量内存。逐个处理项目可能更复杂,但所需的内存较少。

我们将首先构建一些复杂的集合。第一个将是一个跟踪两个子列表的列表。

使用两个子列表扩展列表类

我们可以扩展内置的list类以添加一些功能。需要注意的是,扩展内置类型可能会很棘手,因为这些类型的类型提示有时会出人意料地复杂。

Python 的内置结构如 list 有多种初始化选项:

  • 我们可以使用list()来创建一个空列表

  • 我们可以使用list(x)从可迭代的源数据创建一个列表

为了让mypy清楚这一点,我们需要使用@overload装饰器;这将展示list__init__()方法被使用的两种不同方式:

class SamplePartition(List[SampleDict], abc.ABC):
    @overload
    def __init__(self, *, training_subset: float = 0.80) -> None:
        ...
    @overload
    def __init__(
        self,
        iterable: Optional[Iterable[SampleDict]] = None,
        *,
        training_subset: float = 0.80,
    ) -> None:
        ...
    def __init__(
        self,
        iterable: Optional[Iterable[SampleDict]] = None,
        *,
        training_subset: float = 0.80,
    ) -> None:
        self.training_subset = training_subset
        if iterable:
            super().__init__(iterable)
        else:
            super().__init__()
    @abc.abstractproperty
    @property
    def training(self) -> List[TrainingKnownSample]:
        ...
    @abc.abstractproperty
    @property
    def testing(self) -> List[TestingKnownSample]:
        ... 

我们为__init__()方法定义了两种重载;这些是告诉mypy我们意图的形式。第一种重载是没有任何位置参数的__init__()。这应该创建一个空的SampleDict对象列表。第二种重载是__init__(),它只有一个位置参数,即SampleDict对象的可迭代来源。孤独的*将参数分为两种:一种是可以按位置提供参数值的参数,另一种必须作为关键字提供参数值的参数。training_subset参数将与普通的列表初始化器明显不同。

第三个定义是实际实现。这个__init__()方法的定义缺少@overload装饰器。实现使用超类的__init__()方法来构建一个List[SampleDict]对象。子类可能想要扩展这个方法,以便在创建SamplePartition对象时对数据进行分区。

目的是能够使用一个类似 SomeSamplePartition 的类来子类化,并通过 data = SomeSamplePartition(data, training_subset=0.67) 创建一个对象 data,这个对象是一个包含一些额外功能的列表。

由于这是一个超类,我们没有为trainingtesting属性提供定义。每个算法都可以有不同的方法实现,以提供这些属性的值。

这取决于以下SampleDict定义:

class SampleDict(TypedDict):
    sepal_length: float
    sepal_width: float
    petal_length: float
    petal_width: float
    species: str 

这告诉 mypy 我们正在使用一个只包含五个提供的键而没有其他键的字典。这可以支持一些验证来检查字面量键值是否与这个集合匹配。

让我们来看看一些提供不同分区策略的子类。我们将从一个像一副扑克牌那样洗牌和切割的子类开始。

分区的一种洗牌策略

另一个选择是对列表进行洗牌和切割——这正是游戏开始前一副牌被洗牌和切割的方式。我们可以使用random.shuffle()来处理随机洗牌。切割——从某种意义上说——是一个超参数。训练集应该比测试集大多少?对于知识渊博的数据科学家的一些建议包括 80%到 20%,67%到 33%,以及 50%到 50%的均等分割。由于专家意见不一,我们需要提供一个方法让科学家调整分割比例。

我们将使拆分成为类的一个特性。我们可以创建独立的子类来实现不同的拆分。下面是一个洗牌实现的示例:

class ShufflingSamplePartition(SamplePartition):
    def __init__(
        self,
        iterable: Optional[Iterable[SampleDict]] = None,
        *,
        training_subset: float = 0.80,
    ) -> None:
        super().__init__(iterable, training_subset=training_subset)
        self.split: Optional[int] = None
    def shuffle(self) -> None:
        if not self.split:
            random.shuffle(self)
            self.split = int(len(self) * self.training_subset)
    @property
    def training(self) -> List[TrainingKnownSample]:
        self.shuffle()
        return [TrainingKnownSample(**sd) for sd in self[: self.split]]
    @property
    def testing(self) -> List[TestingKnownSample]:
        self.shuffle()
        return [TestingKnownSample(**sd) for sd in self[self.split :]] 

由于我们正在扩展SamplePartition超类,我们可以利用重载的__init__()方法定义。对于这个子类,我们需要提供一个与超类兼容的具体实现。

这两个属性,trainingtesting,都使用了内部的shuffle()方法。该方法使用 split 属性来确保它将样本恰好打乱一次。除了跟踪数据是否被打乱之外,self.split属性还显示了如何将样本分割成训练集和测试集子集。

训练测试属性也使用 Python 列表切片来细分原始的SampleDict对象,并从原始数据中构建有用的TrainingKnownSampleTestingKnownSample对象。这些操作依赖于列表推导式来应用类构造函数,例如TrainingKnownSample,到列表子集的行值字典中,self[: self.split]]。列表推导式使我们免于使用for语句和一系列的append()操作来构建列表。我们将在第十章,迭代器模式中看到更多这种操作的变体。

因为这依赖于random模块,结果难以预测,使得测试变得没有必要复杂。许多数据科学家希望数据被打乱,但他们也希望得到可重复的结果。通过将random.seed()设置为固定值,我们可以创建随机但可重复的样本集合。

这是这样工作的:

>>> import random
>>> from model import ShufflingSamplePartition
>>> from pprint import pprint
>>> data = [
...     {
...         "sepal_length": i + 0.1,
...         "sepal_width": i + 0.2,
...         "petal_length": i + 0.3,
...         "petal_width": i + 0.4,
...         "species": f"sample {i}",
...     }
...     for i in range(10)
... ]
>>> random.seed(42)
>>> ssp = ShufflingSamplePartition(data)
>>> pprint(ssp.testing)
[TestingKnownSample(sepal_length=0.1, sepal_width=0.2, petal_length=0.3, petal_width=0.4, species='sample 0', classification=None, ),
 TestingKnownSample(sepal_length=1.1, sepal_width=1.2, petal_length=1.3, petal_width=1.4, species='sample 1', classification=None, )] 

使用随机种子42,我们在测试集中总是得到相同的两个样本。

这使我们能够以多种方式构建初始列表。例如,我们可以将数据项追加到一个空列表中,如下所示:

ssp = ShufflingSamplePartition(training_subset=0.67)
for row in data:
    ssp.append(row) 

SamplePartition 子类继承自 list 类的所有方法。这使得我们能够在提取训练集和测试集之前对列表的内部状态进行修改。我们添加了大小参数作为关键字参数,以确保它与用于初始化列表的列表对象明确区分开来。

分区的一个增量策略

我们在构建单个列表之后有一个替代方案。而不是扩展list类以提供两个子列表,我们可以稍微重新定义问题。让我们定义一个SamplePartition的子类,该子类在通过初始化、append()extend()方法呈现的每个SampleDict对象上,在测试和训练之间做出随机选择。

这里有一个抽象,总结了我们对这个问题的思考。我们将有三种构建列表的方法,以及两个属性将提供训练和测试集,如下所示。我们不继承自List,因为我们没有提供任何其他类似列表的功能,甚至不包括__len__()。这个类只有五个方法,如下所示:

class DealingPartition(abc.ABC):
    @abc.abstractmethod
    def __init__(
        self,
        items: Optional[Iterable[SampleDict]],
        *,
        training_subset: Tuple[int, int] = (8, 10),
    ) -> None:
        ...
    @abc.abstractmethod
    def extend(self, items: Iterable[SampleDict]) -> None:
        ...
    @abc.abstractmethod
    def append(self, item: SampleDict) -> None:
        ...
    @property
    @abc.abstractmethod
    def training(self) -> List[TrainingKnownSample]:
        ...
    @property
    @abc.abstractmethod
    def testing(self) -> List[TestingKnownSample]:
        ... 

这个定义没有具体的实现。它提供了五个占位符,可以在其中定义方法以实现必要的处理算法。我们略微修改了training_subset参数的定义,与之前的示例相比。在这里,我们将其定义为两个整数。这使得我们可以逐个计数和处理。

下面是如何扩展这个方法来创建一个具体的子类,该子类封装了两个内部集合。我们将将其分为两个部分——首先,构建集合,然后构建属性以暴露集合的值:

class CountingDealingPartition(DealingPartition):
    def __init__(
        self,
        items: Optional[Iterable[SampleDict]],
        *,
        training_subset: Tuple[int, int] = (8, 10),
    ) -> None:
        self.training_subset = training_subset
        self.counter = 0
        self._training: List[TrainingKnownSample] = []
        self._testing: List[TestingKnownSample] = []
        if items:
            self.extend(items)
    def extend(self, items: Iterable[SampleDict]) -> None:
        for item in items:
            self.append(item)
    def append(self, item: SampleDict) -> None:
        n, d = self.training_subset
        if self.counter % d < n:
            self._training.append(TrainingKnownSample(**item))
        else:
            self._testing.append(TestingKnownSample(**item))
        self.counter += 1 

我们定义了一个初始化器,用于设置两个空集合的初始状态。然后,如果提供了源可迭代对象,它将使用extend()方法从该对象构建集合。

extend() 方法依赖于 append() 方法来为测试集或训练集分配一个 SampleDict 实例。实际上,append() 方法完成了所有的工作。它会计算项目数量,并根据一些模运算做出决策。

训练子集被定义为分数;我们已将其定义为元组(8,10),并在注释中说明这表示 8/10 或 80%用于训练,剩余部分用于测试。对于给定的计数器值c,如果c < 8 (mod 10),我们将其称为训练,而如果c 8 (mod 10),我们将其称为测试。

这里是用于揭示两个内部列表对象值的剩余两种方法:

 @property
    def training(self) -> List[TrainingKnownSample]:
        return self._training
    @property
    def testing(self) -> List[TestingKnownSample]:
        return self._testing 

在一定程度上,这些可能被视为无用。在 Python 中,通常简单地命名两个内部集合为self.trainingself.testing是很常见的。如果我们使用属性,实际上并不需要这些属性方法。

我们已经看到了两种将源数据划分为测试集和训练集的类设计。一种版本依赖于随机数进行洗牌,而另一种则不依赖于随机数生成器。当然,还有其他基于随机选择的组合和项目增量分布的组合,我们将这些留作读者的练习。

回忆

本章的一些关键点如下:

  • 使用抽象基类定义是一种创建带有占位符的类定义的方法。这是一个实用的技巧,并且在未实现的方法中使用raise NotImplementedError时,它可能更加清晰。

  • ABCs 和类型提示提供了创建类定义的方法。ABC 是一种类型提示,可以帮助我们明确从对象中需要的核心特性。例如,使用Iterable[X]来强调我们需要类实现的一个方面是很常见的。

  • The collections.abc 模块定义了 Python 内置集合的抽象基类。当我们想要创建一个可以无缝集成到 Python 中的独特收集类时,我们需要从这个模块的定义开始。

  • 创建自己的抽象基类可以利用 abc 模块。abc.ABC 类定义通常是创建抽象基类的完美起点。

  • 大部分工作是由type类完成的。回顾这个类有助于理解type类是如何通过方法创建类的。

  • Python 运算符通过类中的特殊方法实现。我们可以在某种程度上通过定义适当特殊方法来“重载”运算符,使得运算符可以与我们的独特类对象一起工作。

  • 扩展内置类型是通过一个修改内置类型行为的子类来完成的。我们通常会使用 super() 来利用内置行为。

  • 我们可以自定义元类来以根本的方式改变 Python 类对象的构建方式。

练习

我们已经探讨了定义抽象类来定义两个对象的一些——但不是所有——共同特性的概念。快速环顾四周,看看你如何将这些原则应用到自己的工作中。一个脚本通常可以被重新表述为一个类;工作的每个主要步骤对应一个独立的方法。你是否有看起来相似的脚本——可能——共享一个共同的抽象定义?另一个可以找到部分相关内容的地方是在描述数据文件的类中。电子表格文件在布局上通常有一些小的变化;这表明它们有一个共同的抽象关系,但需要一个方法作为扩展的一部分来处理布局上的变化。

当我们思考DDice类时,还有一个增强功能会很好。目前,所有操作符都只针对DDice实例定义。为了创建一副骰子,我们需要在某个地方使用DDice构造函数。这导致了3*DDice(D6)+2,这似乎是多余的冗长。

能够编写 3*d6+1 会更好。这暗示了一些设计上的改动:

  1. 由于我们无法(轻易地)将运算符应用于类,我们必须处理类的实例。我们假设d6 = D6()被用来创建一个Die实例,它可以作为操作数。

  2. Die 类需要一个 __mul__() 方法和一个 __rmul__() 方法。当我们用一个整数乘以一个 Die 实例时,这将创建一个包含骰子类型的 DDice 实例,即 DDice(type(self))。这是因为 DDice 预期一个类型,并从该类型创建自己的实例。

这在DieDDice之间建立了一个循环关系。由于这两个定义都在同一个模块中,所以这不会带来任何真正的问题。我们可以在类型提示中使用字符串,因此让Die方法使用类型提示-> "DDice"效果很好。mypy程序可以使用字符串作为对尚未定义的类型的前向引用。

现在,回顾一下我们在前几章中讨论的一些示例。我们能否利用抽象类定义来简化Sample实例需要表现的各种行为方式?

查看DieMeta示例。按照目前的写法,元类的logged_roll()方法会丢弃子类中roll()具体方法的任何返回值。这可能在所有情况下都不合适。需要如何重写才能使元类方法包装器从包装的方法返回一个值?这会改变DieLog超类定义吗?

我们可以使用超类来提供一个日志记录器吗?(答案似乎是一个响亮的“是的。”)

更重要的是,我们能否使用装饰器为具体的roll()方法提供日志记录功能?编写这个装饰器。然后考虑我们是否可以信任开发者包含这个装饰器。我们应该信任其他开发者正确使用框架吗?虽然我们可以想象开发者会忘记包含装饰器,但我们也可以想象单元测试来确认日志条目的写入。哪种方式更好:一个可见的装饰器加上单元测试,还是一种无形地调整代码的元类?

在案例研究中,我们将测试和训练属性定义为 Iterable[SampleDict] 而不是 List[SampleDict]。当我们查看 collections.abc 时,我们看到 List 是一个 Sequence,它是 Iterable 基类的一个子类。你能看到区分这三个抽象层次的优势吗?如果 Iterable 在一般情况下都适用,我们是否应该总是使用可迭代对象?SequenceIterable 有哪些区别?不同的特征集合对案例研究中的类是否有影响?

摘要

在本章中,我们专注于识别对象,尤其是那些不是立即显而易见的对象;那些管理和控制的对象。对象应具备数据和行为,但属性可以被用来模糊两者之间的区别。DRY 原则是代码质量的重要指标,继承和组合可以用来减少代码重复。

在接下来的两章中,我们将介绍几个内置的 Python 数据结构和对象,重点关注它们的面向对象特性以及它们如何被扩展或适应。

第七章:Python 数据结构

在我们之前的例子中,我们已经看到了许多内置的 Python 数据结构在实际中的应用。你可能也在入门书籍或教程中接触过它们中的许多。在本章中,我们将讨论这些数据结构的面向对象特性,以及它们应该在什么情况下替代常规类使用,以及在什么情况下不应该使用。特别是,我们将涵盖以下主题:

  • 元组与命名元组

  • 数据类

  • 字典

  • 列表和集合

  • 三种类型的队列

本章的案例研究将重新审视k最近邻分类器的数据模型。在查看 Python 的复杂内置数据结构和类定义之后,我们可以简化一些应用程序类定义。

我们将首先探讨一些基础构造,特别是object类。

空对象

让我们从最基本的 Python 内置函数开始,这是我们已经在很多次使用中隐含地使用过的,也是(结果证明)我们在创建的每一个类中都进行了扩展的:object

技术上,我们可以不编写子类就实例化一个对象,如下所示:

>>> o = object()
>>> o.x = 5
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    AttributeError: 'object' object has no attribute 'x' 

很不幸,正如您所看到的,无法在直接实例化的对象上设置任何属性。这并不是因为 Python 开发者想要强迫我们编写自己的类,或者任何如此邪恶的事情。他们这样做是为了节省内存——大量的内存。当 Python 允许一个对象具有任意属性时,它需要一定量的系统内存来跟踪每个对象具有哪些属性,用于存储属性名称及其值。即使没有存储任何属性,也会分配内存以使其能够添加属性。考虑到典型的 Python 程序中有数十、数百或数千个对象(每个类都扩展了object类),这样一小块内存很快就会变成大量内存。因此,Python 默认禁用了object以及几个其他内置对象的任意属性。

使用 __slots__ 可以在自定义类上限制任意属性。槽位是 第十二章高级设计模式 的一部分。我们将通过它们作为一种为频繁出现的对象节省内存的方法来探讨。

然而,创建我们自己的空对象类是非常简单的;我们在最早的例子中看到了这一点:

>>> class MyObject: 
...     pass 

实际上,class MyObject 等同于 class MyObject(object)。正如我们之前所看到的,我们可以在这样的类上设置属性,如下所示:

>>> m = MyObject()
>>> m.x = "hello"
>>> m.x
'hello' 

如果我们想要将未知数量的属性值分组,我们可以将它们存储在一个空对象中,如下所示。这种方法的缺点是缺乏一个明显的模式,我们可以用它来理解应该有哪些属性以及它们将具有哪些类型的值。

本书的一个重点是,只有在你想要指定数据行为时,才应该使用类和对象。因此,从一开始就决定数据是否仅仅是数据,或者它是否是伪装成对象的实体,这一点非常重要。一旦做出这个设计决策,其余的设计就可以从种子概念中发展出来。

元组与命名元组

元组是能够按顺序存储特定数量其他对象的实体。它们是不可变的,这意味着我们无法在运行时添加、删除或替换对象。这看起来可能是一个巨大的限制,但事实是,如果你需要修改一个元组,你使用的数据类型可能不正确(通常,使用列表会更合适)。元组不可变性的主要好处是,不可变对象的元组(如字符串、数字和其他元组)有一个哈希值,允许我们将其用作字典的键和集合的成员。(包含可变结构(如列表、集合或字典)的元组不是由不可变项组成的,并且没有哈希值。我们将在下一节中仔细研究这个区别。)

Python 内置的泛型 tuple 类的实例用于存储数据;无法将行为关联到内置的元组。如果我们需要行为来操作元组,我们必须将元组传递给一个执行该操作的函数(或另一个对象上的方法)。这是 第八章面向对象与函数式编程的交汇点 的主题。

元组与坐标或维度的概念重叠。数学上的(x, y)对或(r, g, b)颜色都是元组的例子;顺序很重要:颜色(255, 0, 0)看起来与(0, 255, 0)完全不同。元组的主要目的是将不同的数据片段聚集到一个容器中。

我们通过逗号分隔值来创建一个元组。通常,元组会被括号包围以使其易于阅读,并与其他表达式的部分区分开来,但这并非总是必须的。以下两个赋值是相同的(它们记录了一家相当盈利公司的股票、当前价格、52 周最高价和 52 周最低价):

>>> stock = "AAPL", 123.52, 53.15, 137.98
>>> stock2 = ("AAPL", 123.52, 53.15, 137.98) 

(当这本书的第一版印刷时,该股票的交易价格约为每股 8 美元;随着这本书每一版的出版,股票价值几乎翻倍!)

如果我们在某个其他对象内部对元组进行分组,例如函数调用、列表推导或生成器,则需要使用括号。否则,解释器将无法知道它是一个元组还是下一个函数参数。例如,以下函数接受一个元组和日期,并返回一个包含日期和股票最高价与最低价之间中间值的元组:

>>> import datetime
>>> def middle(stock, date):
...     symbol, current, high, low = stock
...     return (((high + low) / 2), date)
>>> middle(("AAPL", 123.52, 53.15, 137.98), datetime.date(2020, 12, 4))
(95.565, datetime.date(2020, 12, 4)) 

在这个例子中,一个新的四元组直接在函数调用内部创建。这些项由逗号分隔,整个元组被括号包围。然后,通过逗号将其与第二个参数,一个datetime.date对象分开。当 Python 显示一个元组时,它使用所谓的规范表示法;这总是会包括括号(),即使在严格意义上不是必需的,括号的使用也已成为一种常见做法。特别是return语句,它在其创建的元组周围有冗余的括号。

退化情况包括只有一个元素的元组,写作这样 (2.718,)。这里需要额外的逗号。空元组是 ()

我们有时可能会得出这样的陈述:

>>> a = 42,
>>> a
(42,) 

有时候会让人惊讶,变量 a 会被赋值为一个单元素元组。尾随的逗号是用来创建一个包含单个元素的列表表达式;这就是元组的值。括号 () 有两个用途:(1) 创建一个空元组,或者(2) 将元组与其他表达式区分开来。例如,以下代码创建了嵌套元组:

>>> b = (42, 3.14), (2.718, 2.618), 
>>> b
((42, 3.14), (2.718, 2.618)) 

Python 中尾随的逗号被礼貌地忽略。

middle() 函数也展示了元组解包。函数内部的第 一行将stock参数解包成四个不同的变量。元组的长度必须与变量的数量完全相同,否则会引发异常。

解包是 Python 中一个非常实用的功能。元组将相关值组合在一起,使得存储和传递它们变得更加简单;当我们需要访问这些片段时,我们可以将它们解包到单独的变量中。当然,有时我们只需要访问元组中的一个变量。我们可以使用与其他序列类型(例如列表和字符串)相同的语法来访问单个值:

>>> s = "AAPL", 132.76, 134.80, 130.53
>>> high = s[2]
>>> high
134.8 

我们甚至可以使用切片符号来提取更大的元组片段,如下所示:

>>> s[1:3]
(132.76, 134.8) 

这些例子虽然说明了元组可以有多大的灵活性,但也展示了它们的一个主要缺点:可读性。阅读这段代码的人如何知道特定元组的第 2 个位置是什么内容呢?他们可以猜测,通过我们分配给它的变量名称,它可能是某种“高”值,但如果我们只是在不分配的情况下访问元组值进行计算,就没有这样的提示。他们必须翻阅代码,找到元组被打包或解包的位置,才能发现它所做的工作。

在某些情况下,直接访问元组成员是可以的,但不要养成这种习惯。索引值变成了我们可能称之为魔法数字的东西:看起来像是凭空出现,在代码中没有明显的意义。这种不透明性是许多编码错误的源头,导致长时间的挫败感调试。尽量只在你知道所有值都将同时有用,并且通常在访问时会被解包的情况下使用元组。想想(x, y)坐标对和(r, g, b)颜色,其中项目数量固定,顺序很重要,意义也很明确。

提供一些有用文档的一种方法就是定义许多小辅助函数。这有助于阐明元组的使用方式。以下是一个示例。

>>> def high(stock):
...     symbol, current, high, low = stock
...     return high
>>> high(s)
134.8 

我们需要将这些辅助函数收集到一个单独的命名空间中。这样做让我们怀疑,一个类比带有许多辅助函数的元组更好。还有其他方法可以澄清元组的内容,其中最重要的是typing.NamedTuple类。

通过 typing.NamedTuple 命名的元组

那么,当我们想要将值分组在一起,但又知道我们经常会需要单独访问它们时,我们该怎么办呢?实际上有几种选择,包括以下这些:

  • 我们可以使用一个空的对象实例,正如之前所讨论的。我们可以给这个对象分配任意的属性。但是如果没有一个良好的定义来规定允许什么以及期望什么类型,我们将难以理解这一点。而且我们会遇到很多mypy错误。

  • 我们可以使用字典。这样可能会很好用,并且我们可以使用typing.TypedDict提示来正式化字典可接受的键列表。我们将在第九章的案例研究中涉及到这些内容,即字符串、序列化和文件路径

  • 我们可以使用一个@dataclass,这是本章下一节的主题。

  • 我们还可以为元组的各个位置提供名称。在此过程中,我们还可以为这些命名元组定义方法,使它们变得非常有帮助。

这里有一个例子:

>>> from typing import NamedTuple
>>> class Stock(NamedTuple):
...     symbol: str
...     current: float
...     high: float
...     low: float 

这个新类将包含多个方法,包括 __init__()__repr__()__hash__()__eq__()。这些方法将基于通用的 tuple 处理,并增加了为各种项目命名的好处。还有更多方法,包括比较操作。以下是创建此类元组的示例。它看起来几乎就像创建一个通用元组:

>>> Stock("AAPL", 123.52, 137.98, 53.15) 

我们可以使用关键字参数来使事情更加清晰:

>>> s2 = Stock("AAPL", 123.52, high=137.98, low=53.15) 

构造函数必须恰好有正确数量的参数来创建元组。值可以作为位置参数或关键字参数传递。

重要的是要认识到,名称是在类级别提供的,但我们实际上并没有创建类级别的属性。类级别的名称用于构建__init__()方法;每个实例都将拥有在元组内部位置的预期名称。从我们编写的内容到结果类的更复杂定义(具有命名和位置项)之间存在一种巧妙的元类级别转换。有关元类的更多信息,请参阅第六章抽象基类和运算符重载

我们NamedTuple子类的实例,即Stock,可以被打包、解包、索引、切片,以及其他像普通元组一样的处理方式,但我们也可以通过名称访问单个属性,就像它是一个对象一样:

>>> s.high
137.98
>>> s[2]
137.98
>>> symbol, current, high, low = s
>>> current
123.52 

命名元组非常适合许多用例。与字符串一样,元组和命名元组都是不可变的,因此一旦设置了属性,我们就不能修改它。例如,自从我们开始这次讨论以来,这家公司的股票当前价值已经下降,但我们不能设置新的值,如下所示:

>>> s.current = 122.25
Traceback (most recent call last):
  ...
  File "<doctest examples.md[27]>", line 1, in <module>
    s2.current = 122.25
AttributeError: can't set attribute 

不可变性仅指元组本身的属性。这可能会显得有些奇怪,但这是由不可变元组的定义所导致的后果。元组可以包含可变元素。

>>> t = ("Relayer", ["Gates of Delirium", "Sound Chaser"])
>>> t[1].append("To Be Over")
>>> t
('Relayer', ['Gates of Delirium', 'Sound Chaser', 'To Be Over']) 

对象 t 是一个元组,这意味着它是不可变的。元组对象包含两个元素。t[0] 的值是一个字符串,它也是不可变的。然而,t[1] 的值是一个可变列表。与它关联的对象 t 的不可变性并不会改变列表的可变性。列表是可变的,无论其上下文如何。即使元组 t 内部的元素是可变的,t 本身也是不可变的。

因为示例元组t包含一个可变列表,所以它没有哈希值。这也不应该令人惊讶。hash()计算需要集合中每个项目的哈希值。由于t[1]的列表值无法生成哈希,因此整个元组t也无法生成哈希。

这是我们尝试时会发生的情况:

>>> hash(t)
Traceback (most recent call last):
  ...
  File "<doctest examples.md[31]>", line 1, in <module>
    hash(t)
TypeError: unhashable type: 'list' 

存在不可哈希的列表对象意味着整个元组也是不可哈希的。

我们可以创建方法来计算命名元组的属性派生值。例如,我们可以重新定义我们的Stock元组,将其中间计算作为一个方法(或一个@property)包含进去:

>>> class Stock(NamedTuple):
...     symbol: str
...     current: float
...     high: float
...     low: float
...     @property
...     def middle(self) -> float:
...         return (self.high + self.low)/2 

我们无法改变状态,但我们可以计算从当前状态派生出的值。这使得我们可以直接将计算与持有源数据的元组耦合。以下是一个使用这种Stock类定义创建的对象:

>>> s = Stock("AAPL", 123.52, 137.98, 53.15)
>>> s.middle
95.565 

middle() 方法现在已成为类定义的一部分。最好的部分?mypy 工具可以站在我们身后,确保我们的应用程序中所有的类型提示都正确匹配。

当创建元组时,命名元组的状态是固定的。如果我们需要能够更改存储的数据,那么可能需要一个dataclass,我们将在下一节中探讨这些内容。

数据类

自从 Python 3.7 版本开始,dataclasses 允许我们使用简洁的语法来定义具有属性的标准对象。它们——表面上——看起来非常类似于命名元组。这是一种令人愉悦的方法,使得理解它们的工作原理变得容易。

这里是我们的Stock示例的dataclass版本:

>>> from dataclasses import dataclass
>>> @dataclass
... class Stock:
...     symbol: str
...     current: float
...     high: float
...     low: float 

对于这个案例,定义几乎与NamedTuple定义相同。

dataclass 函数作为类装饰器使用,通过 @ 操作符应用。我们在 第六章抽象基类和运算符重载 中遇到了装饰器。我们将在 第十一章常见设计模式 中深入探讨它们。这种类定义语法与带有 __init__() 的普通类相比,并没有少多少冗余,但它为我们提供了访问几个额外的 dataclass 功能。

重要的是要认识到,这些名称是在类级别提供的,但实际上并没有创建类级别的属性。类级别的名称用于构建多个方法,包括__init__()方法;每个实例都将具有预期的属性。装饰器将我们编写的内容转换成具有预期属性和__init__()参数的更复杂的类定义。

因为数据类对象可以是具有状态的、可变的对象,所以有众多额外功能可供使用。我们将从一些基础知识开始。以下是一个创建Stock数据类实例的示例。

>>> s = Stock("AAPL", 123.52, 137.98, 53.15) 

一旦实例化,Stock 对象就可以像任何普通类一样使用。你可以按照以下方式访问和更新属性:

>>> s
Stock(symbol='AAPL', current=123.52, high=137.98, low=53.15)
>>> s.current
123.52
>>> s.current = 122.25
>>> s
Stock(symbol='AAPL', current=122.25, high=137.98, low=53.15) 

与其他对象一样,我们可以在正式声明为数据类一部分的属性之外添加属性。这并不总是最好的主意,但这是被支持的,因为这是一个普通的可变对象:

>>> s.unexpected_attribute = 'allowed'
>>> s.unexpected_attribute
'allowed' 

为冻结的数据类添加属性是不可用的,我们将在本节稍后讨论这一点。乍一看,似乎数据类与具有适当构造函数的普通类定义相比并没有带来很多好处。这里有一个与数据类相似的普通类:

>>> class StockOrdinary:
...     def __init__(self, name: str, current: float, high: float, low: ... float) -> None:
...         self.name = name
...         self.current = current
...         self.high = high
...         self.low = low
>>> s_ord = StockOrdinary("AAPL", 123.52, 137.98, 53.15) 

数据类的一个明显好处是我们只需要声明一次属性名称,这样就节省了在__init__()参数和主体中的重复。但是等等,这还不是全部!数据类还提供了一个比从隐式超类object获得的字符串表示更实用的功能。默认情况下,数据类还包括一个等价比较。在不适用的情况下,可以将其关闭。以下示例比较了手动构建的类与这些数据类功能:

>>> s_ord
<__main__.StockOrdinary object at 0x7fb833c63f10>
>>> s_ord_2 = StockOrdinary("AAPL", 123.52, 137.98, 53.15)
>>> s_ord == s_ord_2
False 

手动构建的类有一个糟糕的默认表示,而且缺少等式测试可能会让生活变得困难。我们更倾向于将Stock类的行为定义为数据类。

>>> stock2 = Stock(symbol='AAPL', current=122.25, high=137.98, low=53.15)
>>> s == stock2
True 

使用 @dataclass 装饰的类定义也具有许多其他有用的功能。例如,您可以指定数据类的属性默认值。也许市场目前是关闭的,您不知道当天的值是什么:

@dataclass
class StockDefaults:
    name: str
    current: float = 0.0
    high: float = 0.0
    low: float = 0.0 

您可以使用股票名称来构建这个类;其余的值将采用默认值。但您仍然可以指定值,如下所示:

>>> StockDefaults("GOOG")
StockDefaults(name='GOOG', current=0.0, high=0.0, low=0.0)
>>> StockDefaults("GOOG", 1826.77, 1847.20, 1013.54)
StockDefaults(name='GOOG', current=1826.77, high=1847.2, low=1013.54) 

我们之前看到,数据类默认支持相等比较。如果所有属性都相等,那么整个数据类对象也会被视为相等。默认情况下,数据类不支持其他比较,如小于或大于,并且不能进行排序。然而,如果您愿意,可以轻松地添加比较,如下所示:

@dataclass(order=True)
class StockOrdered:
    name: str
    current: float = 0.0
    high: float = 0.0
    low: float = 0.0 

提问“这需要的就是这些吗?”是可以的。答案是肯定的。装饰器中的order=True参数会导致所有比较特殊方法的创建。这种变化为我们提供了对这类实例进行排序和比较的机会。它的工作原理是这样的:

>>> stock_ordered1 = StockOrdered("GOOG", 1826.77, 1847.20, 1013.54)
>>> stock_ordered2 = StockOrdered("GOOG")
>>> stock_ordered3 = StockOrdered("GOOG", 1728.28, high=1733.18, low=1666.33)
>>> stock_ordered1 < stock_ordered2
False
>>> stock_ordered1 > stock_ordered2
True
>>> from pprint import pprint
>>> pprint(sorted([stock_ordered1, stock_ordered2, stock_ordered3]))
[StockOrdered(name='GOOG', current=0.0, high=0.0, low=0.0),
 StockOrdered(name='GOOG', current=1728.28, high=1733.18, low=1666.33),
 StockOrdered(name='GOOG', current=1826.77, high=1847.2, low=1013.54)] 

当数据类装饰器接收到order=True参数时,它将默认根据每个属性定义的顺序来比较它们的值。因此,在这种情况下,它首先比较两个对象的name属性值。如果这些值相同,它将比较current属性值。如果这些值也相同,它将继续比较high属性,如果所有其他属性都相等,甚至包括low。这些规则遵循元组的定义:定义的顺序是比较的顺序。

数据类的一个有趣特性是 frozen=True。这会创建一个类似于 typing.NamedTuple 的类。我们在获得的功能上存在一些差异。我们需要使用 @dataclass(frozen=True, ordered=True) 来创建结构。这引发了一个问题:“哪个更好?”,当然,这取决于特定用例的细节。我们还没有探索数据类的所有可选功能,比如仅初始化的字段和 __post_init__() 方法。某些应用可能不需要所有这些功能,一个简单的 NamedTuple 可能就足够了。

有几种其他的方法。在标准库之外,像 attrspydanticmarshmallow 这样的包提供了类似于数据类的属性定义功能。标准库之外的其他包还提供了额外的功能。有关比较,请参阅 jackmckew.dev/dataclasses-vs-attrs-vs-pydantic.html

我们已经探讨了两种创建具有特定属性值的独特类的方法,即命名元组和数据类。通常,从数据类开始并添加专用方法会更简单。这可以节省我们一些编程工作,因为一些基本操作,如初始化、比较和字符串表示,都为我们优雅地处理了。

是时候看看 Python 的内置泛型集合了,dictlistset。我们将从探索字典开始。

词典

词典是极其有用的容器,它允许我们将对象直接映射到其他对象。在给定一个特定的对象,该对象映射到该值的情况下,词典在查找方面非常高效。速度的秘密在于使用键的哈希来定位值。每个不可变的 Python 对象都有一个数值哈希码;使用一个相对简单的表将数值哈希直接映射到值。这个技巧意味着字典永远不会在整个集合中搜索键;键被转换成哈希,这可以立即定位相关的值(几乎)。

字典可以通过使用dict()构造函数或{}语法快捷方式来创建。在实践中,后者格式几乎总是被使用。我们可以通过使用冒号分隔键和值,以及使用逗号分隔键值对来预先填充一个字典。

我们还可以使用关键字参数来创建字典。我们可以使用 dict(current=1235.20, high=1242.54, low=1231.06) 来创建值 {'current': 1235.2, 'high': 1242.54, 'low': 1231.06}。这种 dict() 语法与其他构造函数(如 dataclasses 和命名元组)重叠。

例如,在我们的股票应用中,我们通常会想要通过股票符号来查找价格。我们可以创建一个字典,使用股票符号作为键,而将当前价、最高价和最低价作为值的元组(当然,你也可以使用命名元组或数据类作为值),如下所示:

>>> stocks = {
...     "GOOG": (1235.20, 1242.54, 1231.06),
...     "MSFT": (110.41, 110.45, 109.84),
... } 

如前例所示,我们可以通过在方括号内请求字典中的键来查找值。如果键不在字典中,它将引发一个KeyError异常,如下所示:

>>> stocks["GOOG"]
(1235.2, 1242.54, 1231.06)
>>> stocks["RIMM"]
Traceback (most recent call last):
  ...
  File "<doctest examples.md[56]>", line 1, in <module>
    stocks.get("RIMM", "NOT FOUND")
KeyError: 'RIMM' 

当然,我们可以捕获KeyError并处理它。但还有其他选择。记住,字典是对象,即使它们的主要目的是持有其他对象。因此,它们具有与它们相关的几种行为。其中最有用的方法之一是get方法;它接受一个键作为第一个参数,如果键不存在,则可以接受一个可选的默认值:

>>> print(stocks.get("RIMM"))
None
>>> stocks.get("RIMM", "NOT FOUND")
'NOT FOUND' 

为了获得更多的控制,我们可以使用setdefault()方法。如果键存在于字典中,这个方法的行为就像get()方法一样;它返回该键的值。否则,如果键不在字典中,它不仅会返回我们在方法调用中提供的默认值(就像get()方法所做的那样);它还会将该键设置为那个相同的值。另一种思考方式是,setdefault()方法仅在之前没有设置该值的情况下,才在字典中设置一个值。然后,它返回字典中的值;要么是已经存在的那个值,要么是新提供的默认值,如下所示:

>>> stocks.setdefault("GOOG", "INVALID")
(1235.2, 1242.54, 1231.06)
>>> stocks.setdefault("BB", (10.87, 10.76, 10.90))
(10.87, 10.76, 10.9)
>>> stocks["BB"]
(10.87, 10.76, 10.9) 

"GOOG" 股票已经在字典中,所以当我们尝试使用 setdefault() 函数将其更改为无效值时,它只是返回了字典中已有的值。键 "BB" 不在字典中,因此 setdefault() 方法返回了默认值,并为我们设置了字典中的新值。然后我们检查新的股票确实在字典中。

字典的类型提示必须包括键的类型和值的类型。从 Python 3.9 版本开始,以及 mypy 版本 0.812,我们使用类型提示 dict[str, tuple[float, float, float]] 来描述这种结构;这样可以避免导入 typing 模块。根据您的 Python 版本,您通常需要在模块的第一行代码中使用 from __future__ import annotations;这包括将内置类作为适当泛型类型提示所需的语言支持。

三种其他有用的字典方法是 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
BB last value is 10.87 

每个键/值元组被解包成两个名为 stockvalues 的变量(我们可以使用任何我们想要的变量名,但这两个似乎都很合适),然后以格式化的字符串形式打印出来。

注意,股票的显示顺序与它们被插入的顺序相同。这种情况在 Python 3.6 之前并不成立,直到 Python 3.7 才成为语言定义的正式部分。在此之前,dict 实现使用了一个不同的底层数据结构,其顺序难以预测。根据 PEP 478,Python 3.5 的最终发布是在 2020 年 9 月,这使得这种较老且难以预测的顺序完全过时。为了保留键的顺序,我们曾经被迫在 collections 模块中使用 OrderedDict 类,但现在不再需要这样做了。

一旦实例化字典,就有许多种方法可以检索数据:我们可以使用方括号作为索引语法,get()方法,setdefault()方法,或者遍历items()方法,等等。

最后,正如你可能已经知道的,我们可以使用与检索值相同的索引语法在字典中设置一个值:

>>> stocks["GOOG"] = (1245.21, 1252.64, 1245.18)
>>> stocks['GOOG']
(1245.21, 1252.64, 1245.18) 

要反映 GOOG 股票的变化,我们可以在字典中更新元组值。我们可以使用这种索引语法为任何键设置值,无论该键是否在字典中。如果它在字典中,旧值将被新值替换;否则,将创建一个新的键值对。

我们到目前为止一直使用字符串作为字典键,但我们并不局限于字符串键。通常情况下,使用字符串作为键是很常见的,尤其是当我们需要在字典中存储数据以将其聚集在一起时(而不是使用具有命名属性的类或数据类)。但我们也可以使用元组、数字,甚至是我们自己定义的对象作为字典键。关键因素是一个__hash__()方法,这是不可变类型提供的。虽然我们甚至可以在单个字典中使用不同类型的对象作为键,但这很难向mypy描述。

这里是一个包含多种键和值的字典示例:

>>> 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
>>> random_keys[[1,2,3]] = "we can't use lists as keys" 
Traceback (most recent call last):
  ...
  File "<doctest examples.md[72]>", line 1, in <module>
    random_keys[[1,2,3]] = "we can't use lists as keys"
TypeError: unhashable type: 'list' 

这段代码展示了我们可以提供给字典的几种不同类型的键。该数据结构具有类型提示dict[Union[str, int, float, Tuple[str, int], AnObject], str]。这显然非常复杂。为这种类型编写类型提示可能会让人感到困惑,暗示这不是最佳方法。

这个例子也展示了一种不能用作键的对象类型。我们已经广泛使用了列表,在下一节中我们将看到更多关于它们的细节。因为列表是可变的——它们可以在任何时间改变(例如通过添加或删除项目)——所以它们不能哈希到一个单一值。

我们可以使用以下代码来检查字典中的值。这是因为映射的默认行为是遍历键。

>>> for key in random_keys: 
...     print(f"{key!r} has value {random_keys[key]!r}") 
'astring' has value 'somestring'
5 has value 'aninteger'
25.2 has value 'floats work too'
('abc', 123) has value 'so do tuples'
<__main__.AnObject object at ...> has value 'We can even store objects' 

要用作字典键,一个对象必须是可哈希的,也就是说,必须有一个__hash__()方法来将对象的状态转换为唯一整数值,以便在字典或集中快速查找。内置的hash()函数使用对象的类的__hash__()方法。这个哈希值用于在字典中查找值。例如,字符串根据字符串中字符的数值代码映射到整数,而元组则结合元组内项的哈希值。任何被认为相等的两个对象(例如具有相同字符的字符串或具有相同值的元组)必须也有相同的哈希值。请注意,相等性和匹配的哈希值之间存在不对称性。如果两个字符串具有相同的哈希值,它们仍然可能不相等。将哈希相等性视为相等性测试的近似:如果哈希值不相等,就无需查看细节。如果哈希值相等,则投入时间检查每个属性值或元组的每个项,或字符串的每个单独字符。

这里是一个示例,展示了两个具有相同哈希值但实际并不相等的整数:

>>> x = 2020
>>> y = 2305843009213695971
>>> hash(x) == hash(y)
True
>>> x == y
False 

当我们将这些值用作字典中的键时,哈希冲突算法将保持它们的分离。这种情况会导致在哈希冲突的罕见情况下出现微小的速度减慢。这就是为什么字典查找并不总是立即发生:哈希冲突可能会减慢访问速度。

内置的可变对象——包括列表、字典和集合——不能用作字典键。这些可变集合不提供哈希值。然而,我们可以创建自己的对象类,这些对象既是可变的又提供哈希值;这不太安全,因为对象状态的变化可能会使得在字典中查找键变得困难。

当然,我们可能会走得太远。确实有可能创建一个具有可变和不可变属性的类,并将定制的哈希计算仅限于可变属性。由于可变和不可变特性之间的行为差异,这看起来像是两个协作的对象,而不是具有可变和不可变特性的单个对象。我们可以使用不可变部分作为字典键,并将可变部分保留在字典值中。

与此相反,没有限制可以使用哪些类型的对象作为字典值。例如,我们可以使用字符串键映射到列表值,或者我们可以在另一个字典中有一个嵌套字典作为值。

字典使用案例

词典极其多功能,用途广泛。以下是两个主要例子:

  • 我们可以拥有所有值都是相同类型对象不同实例的字典。例如,我们的股票字典会有一个类型提示为 dict[str, tuple[float, float, float]]。字符串键映射到一个包含三个值的元组。我们使用股票符号作为价格详情的索引。如果我们有一个更复杂的 Stock 类,我们可能有一个类型提示为 dict[str, Stock] 的字典,作为这些对象的索引。

  • 第二种设计是让每个键代表单个对象的一些方面或属性;值通常具有不同的类型。例如,我们可以用{'name': 'GOOG', 'current': 1245.21, 'range': (1252.64, 1245.18)}来表示一支股票。这种情况下,它与命名元组、数据类以及一般对象明显重叠。实际上,对于这种类型的字典,有一个特殊的类型提示,称为TypedDict,其外观类似于NamedTuple类型提示。

这个第二个例子可能会让人困惑;我们如何决定如何表示一个对象的属性值呢?我们可以这样对技术进行排序。

  1. 对于许多情况,数据类提供了一系列有助于减少代码编写的功能。它们可以是不可变的,也可以是可变的,为我们提供了广泛的选择。

  2. 对于数据不可变的情况,NamedTuple 比冻结的数据类大约高效 5% —— 并不是很多。这里的平衡点在于昂贵的属性计算。虽然 NamedTuple 可以有属性,但如果计算非常昂贵且结果被频繁使用,提前计算它可能会有所帮助,这是 NamedTuple 不擅长的。在极少数需要提前计算属性值的情况下,请查看数据类的文档及其 __post_init__() 方法,作为更好的选择。

  3. 字典在事先不知道完整键集时非常理想。当我们开始设计时,可能会使用字典来制作可丢弃的原型或概念验证。当我们尝试编写单元测试和类型提示时,可能需要提高正式性。在某些情况下,可能的键域是已知的,使用TypedDict类型提示作为描述有效键和值类型的手段是有意义的。

由于语法相似,尝试不同的设计以查看哪种更适合问题、哪种更快、哪种更容易测试以及哪种使用的内存更少相对容易。有时,这三个方面都会趋于一致,从而有一个最佳选择。更常见的情况是,这是一个权衡的过程。

技术上,大多数类都是在底层使用字典实现的。你可以通过将一个对象加载到交互式解释器中并查看是否存在特殊属性__dict__来看到这一点。当你使用类似obj.attr_name的语法在对象上访问属性时,这实际上在底层是obj.__dict__['attr_name']。实际上要复杂一些,涉及到__getattr__()__getattribute__(),但你能理解其精髓。即使是数据类也有__dict__属性,这仅仅表明字典的实际应用范围有多么广泛。它们不是万能的,但它们很常见。

使用 defaultdict

我们已经看到了如何使用setdefault方法在键不存在时设置默认值,但如果每次查找值时都需要设置默认值,这可能会变得有点单调乏味。例如,如果我们正在编写代码来计算一个句子中某个字母出现的次数,我们可以这样做:

from __future__ import annotations
def letter_frequency(sentence: str) -> dict[str, int]:
    frequencies: dict[str, int] = {}
    for letter in sentence:
        frequency = frequencies.setdefault(letter, 0)
        frequencies[letter] = frequency + 1
    return frequencies 

每次我们访问字典时,都需要检查它是否已经有值,如果没有,则将其设置为零。当每次请求空键时都需要执行此类操作时,我们可以创建字典的不同版本。在collections模块中定义的defaultdict优雅地处理了缺失键的情况:

from collections import defaultdict
def letter_frequency_2(sentence: str) -> defaultdict[str, int]:
    frequencies: defaultdict[str, int] = defaultdict(int)
    for letter in sentence:
        frequencies[letter] += 1
    return frequencies 

这段代码看起来很奇怪:defaultdict() 函数的构造函数接受一个函数,int,作为参数。我们并不是在评估 int() 函数;我们是在向 defaultdict() 提供对这个函数的引用。每当访问一个不在字典中的键时,它会调用这个函数(无参数),以创建一个默认值。

注意,defaultdict[str, int] 类型提示比 defaultdict() 评估本身要冗长一些。defaultdict() 类只需要一个函数来创建默认值。键的类型在运行时实际上并不重要;任何具有 __hash__() 方法的对象都可以工作。然而,当将 defaultdict 用作类型提示时,我们需要一些额外的细节才能确保这会工作。我们需要提供键的类型——在这个例子中是 str ——以及将与键关联的对象的类型——在这个例子中是 int

在这个例子中,frequencies 对象使用 int() 函数来创建默认值。这是整数对象的构造函数。通常,整数是通过将整数数字输入到我们的代码中作为字面量来创建的。如果我们使用 int() 构造函数创建整数,它通常是一个转换的一部分;例如,将数字字符串转换为整数,如 int("42")。但是,如果我们不带任何参数调用 int(),它方便地返回数字零。在这段代码中,如果 defaultdict 中不存在字母,则通过工厂函数创建数字零,并在我们访问它时返回。然后,我们将这个数字加一,以表示我们找到了该字母的一个实例,并将更新后的值保存回字典。下次我们找到相同的字符时,将返回新的数字,我们可以增加这个值并将其保存回字典。

defaultdict() 函数在创建容器字典时非常有用。如果我们想创建一个包含过去 30 天收盘价的字典,我们可以使用股票代码作为键,并将价格存储在一个list中;当我们第一次访问股票价格时,我们希望创建一个空列表。只需将list函数传递给defaultdict,如下所示:defaultdict(list)。每次访问一个之前未知的键时,都会调用list()函数。如果我们想使用子字典作为键的值,我们可以用集合或空字典来做类似的事情。

当然,我们也可以编写自己的函数并将它们传递给defaultdict。假设我们想要创建一个defaultdict,其中每个键映射到一个包含该键信息的数据类。如果我们使用默认值定义我们的数据类,那么我们的类名将作为一个不带参数的函数使用。

考虑这个数据类,Prices,其中包含所有默认值:

>>> from dataclasses import dataclass
>>> @dataclass
... class Prices:
...     current: float = 0.0
...     high: float = 0.0
...     low: float = 0.0
...
>>> Prices() 
Prices(current=0.0, high=0.0, low=0.0) 

由于该类为所有属性提供了默认值,我们可以不提供参数值直接使用类名,从而获得一个有用的对象。这意味着我们的类名将作为defaultdict()函数的参数:

>>> portfolio = collections.defaultdict(Prices)
>>> portfolio["GOOG"]
Prices(current=0.0, high=0.0, low=0.0)
>>> portfolio["AAPL"] = Prices(current=122.25, high=137.98, low=53.15) 

当我们打印portfolio时,我们看到默认对象是如何在字典中保存的:

>>> from pprint import pprint
>>> pprint(portfolio)
defaultdict(<class 'dc_stocks.Prices'>,
            {'AAPL': Prices(current=122.25, high=137.98, low=53.15),
             'GOOG': Prices(current=0.0, high=0.0, low=0.0)}) 

这个portfolio字典为未知键创建了一个默认的Prices对象。这是因为Prices类为所有属性都设置了默认值。

我们甚至可以更进一步扩展。如果我们想要按月分组的价格呢?我们想要一个以股票名称为键的字典。在这个字典中,我们想要以月份为键的子字典。而在那个内部字典中,我们想要价格。这可能会有些棘手,因为我们想要一个接受零个参数并为我们创建defaultdict(Prices)的默认函数。我们可以定义一个单行函数:

>>> def make_defaultdict():
...     return collections.defaultdict(Prices) 

我们还可以使用 Python 的 lambda 表达式——一个无名称的、单表达式函数来做到这一点。lambda 可以有参数,但我们不需要任何参数。这个单表达式就是我们要创建的对象,作为默认值。

>>> by_month = collections.defaultdict(
...     lambda: collections.defaultdict(Prices)
... ) 

现在我们可以拥有嵌套的 defaultdict 字典。当缺失键时,会构建一个合适的默认值。

>>> by_month["APPL"]["Jan"] = Prices(current=122.25, high=137.98, low=53.15) 

by_month 集合的最高级键指向一个内部字典。该内部字典包含每个月的价格。

计数器

你可能会想,算法的简化程度不可能超过使用 defaultdict(int)我想在可迭代对象中计数特定实例 的用例足够常见,以至于 Python 开发者为这个特定目的创建了一个特定的类,进一步简化了事情。之前用于计算字符串中字符数量的代码可以轻松地用一行计算出来:

from collections import Counter
def letter_frequency_3(sentence: str) -> Counter[str]:
    return Counter(sentence) 

Counter 对象的行为类似于增强版的字典,其中键是正在计数的项,值是此类项的数量。其中最有用的函数之一是 most_common() 方法。它按计数降序返回 (key,count) 元组的列表。你可以选择性地向 most_common() 方法传递一个整数参数,以请求仅包含最常见元素的列表。例如,你可以编写一个简单的投票应用程序如下:

>>> import collections
>>> responses = [
...     "vanilla", 
...     "chocolate", 
...     "vanilla", 
...     "vanilla", 
...     "caramel", 
...     "strawberry", 
...     "vanilla" 
... ]
>>> favorites = collections.Counter(responses).most_common(1)
>>> name, frequency = favorites[0]
>>> name
'vanilla' 

据推测,您可能通过数据库或使用计算机视觉算法来统计举手的孩子。在这里,我们硬编码了responses对象,并使用字面值,以便测试most_common()方法。此方法始终返回一个列表,即使我们只请求一个元素。提示实际上是list[tuple[T, int]],其中T是我们正在计数的类型。在我们的例子中,我们正在计数字符串,因此most_common()方法的提示是list[tuple[str, int]]。我们只想从一个只有一个元素的列表中获取第一个项目,所以需要[0]。然后我们可以将这个二元组分解为被计数的值和整数计数。

谈到列表,是时候更深入地了解一下 Python 的列表集合了。

列表

Python 的通用列表结构集成到许多语言特性中。我们不需要导入它们,也很少需要使用方法语法来访问它们的功能。我们可以访问列表中的所有项目,而无需显式请求迭代器对象,并且我们可以用非常简单的语法构造一个列表(就像字典一样)。此外,列表推导式和生成器表达式使它们成为计算功能的瑞士军刀。

如果你不知道如何创建或向列表中添加内容,如何从列表中检索项目,或者什么是切片表示法,我们立即将你指引到官方的 Python 教程。它可以在网上找到,地址是docs.python.org/3/tutorial/。在本节中,我们将超越基础知识,讨论何时应该使用列表,以及它们作为对象的本性。

在 Python 中,当我们想要存储同一类型对象的多个实例时,通常应该使用列表;例如字符串列表或数字列表。我们通常会使用类型提示list[T]来指定列表中保存的对象类型T,例如list[int]list[str]

(请记住,要使此功能正常工作,需要使用from __future__ import annotations。)当我们想要以某种顺序存储项目时,必须使用列表。通常,这是它们被插入的顺序,但也可以根据其他标准进行排序。

列表是可变的,因此可以从列表中添加、替换和删除项目。这有助于反映某些更复杂对象的状态。

就像字典一样,Python 列表使用了一种极其高效和调优良好的内部数据结构,因此我们可以关注我们存储的内容,而不是存储的方式。Python 在列表的基础上扩展了一些专门的数据结构,用于队列和栈。Python 并不区分基于数组或使用链接的列表。通常,内置的列表数据结构可以满足多种用途。

不要使用列表来收集单个项目的不同属性。元组、命名元组、字典和对象都更适合收集不同种类的属性值。本章开头我们存储的Stock数据示例中存储了当前价格、最低价格和最高价格,每个都是单序列中具有不同含义的不同属性。这并不是真正理想的,命名元组或数据类显然更优越。

这里有一个相当复杂的反例,展示了我们如何使用列表来执行频率示例。它比字典示例复杂得多,并说明了选择正确(或错误)的数据结构对我们代码的可读性(以及性能)可能产生的影响。这如下所示:

from __future__ import annotations
import string
CHARACTERS = list(string.ascii_letters) + [" "]
def letter_frequency(sentence: str) -> list[tuple[str, int]]:
    frequencies = [(c, 0) for c in CHARACTERS]
    for letter in sentence:
        index = CHARACTERS.index(letter)
        frequencies[index] = (letter, frequencies[index][1] + 1)
    non_zero = [
        (letter, count) 
        for letter, count in frequencies if count > 0
    ]
    return non_zero 

这段代码从一个可能的字符列表开始。string.ascii_letters 属性提供了一个包含所有字母(大小写)的字符串,并按顺序排列。我们将这个字符串转换为列表,然后使用列表连接(+ 运算符将两个列表连接成一个)添加一个额外的字符,即一个空格。这些就是我们在频率列表中可用的字符(如果我们尝试添加不在列表中的字母,代码将会出错)。

函数内的第一行使用列表推导将CHARACTERS列表转换为一个元组列表。然后,我们遍历句子中的每个字符。我们首先在CHARACTERS列表中查找字符的索引,由于我们知道这个索引与我们的频率列表中的索引相同,因为我们是从第一个列表创建第二个列表的。然后,我们通过创建一个新的元组来更新频率列表中的该索引,丢弃原始的元组。除了垃圾回收和内存浪费的担忧之外,这相当难以阅读!

最后,我们通过检查每个元组来过滤列表,只保留计数大于零的成对元素。这样就去掉了我们分配了空间但从未见过的字母。

除了更长之外,CHARACTERS.index(letter) 操作可能非常慢。最坏的情况是检查列表中的每个字符以找到匹配项。平均来说,它会搜索列表的一半。与进行哈希计算并检查一个项以找到匹配项的字典相比。(除非发生哈希冲突,在这种情况下,检查多个项的概率极小,并且它必须通过第二次查找来处理哈希冲突。)

类型提示描述了列表中对象的数据类型。我们将其总结为list[tuple[str, int]]。结果列表中的每个项目都将是一个包含两个元素的元组。这使得mypy可以确认操作尊重列表的整体结构和列表中每个元组的结构。

就像字典一样,列表也是对象。它们有几个可以在其上调用的方法。以下是一些常见的:

  • append(element)方法将元素添加到列表的末尾

  • count(element)方法告诉我们一个元素在列表中出现的次数

  • index() 方法告诉我们列表中一个项目的索引,如果找不到它将抛出一个异常

  • find() 方法执行相同的操作,但针对缺失的项目返回 -1 而不是抛出异常

  • reverse() 方法确实如其名所示——将列表反转

  • sort() 方法有一些相当复杂的面向对象行为,我们现在就来探讨一下

有一些不太常用的。完整的方法列表可以在 Python 标准库文档的序列类型部分找到:docs.python.org/3.9/library/stdtypes.html#sequence-types-list-tuple-range.

排序列表

在没有任何参数的情况下,list 对象的 sort() 方法通常会按预期工作。如果我们有一个 list[str] 对象,sort() 方法将按字母顺序排列项目。这个操作是区分大小写的,所以所有大写字母都将排在小写字母之前;也就是说,Za 之前。如果是一个数字列表,它们将按数值顺序排序。如果提供了一个包含不可排序项的混合列表,排序将引发一个 TypeError 异常。

如果我们想要将我们自己定义的类的对象放入列表中,并使这些对象可排序,我们就需要做更多的工作。必须定义一个特殊的__lt__()方法,它代表“小于”,以便使该类的实例可比较。列表上的sort方法将访问每个对象的此方法,以确定它在列表中的位置。如果我们的类在某种程度上小于传递的参数,则此方法应返回True,否则返回False

通常,当我们需要这样的比较时,我们会使用数据类。正如在数据类部分所讨论的,@dataclass(order=True)装饰器将确保所有比较方法都为我们构建。命名元组也默认定义了排序操作。

在排序过程中可能会遇到的一个棘手情况是处理一种有时被称为标记联合的数据结构。联合是对一个对象的描述,其中属性并非总是相关。如果一个属性的相关性取决于另一个属性值,这可以看作是具有标记以区分两种类型的不同子类型的联合。

这里有一些示例数据,其中标签值、数据源列是必须的,以便决定如何最好地处理剩余的列。数据源的一些值告诉我们使用时间戳,而其他值则告诉我们使用创建日期。

数据来源 时间戳 创建日期 名称、所有者等
本地 1607280522.68012 "某些文件",等等
远程 "2020-12-06T13:47:52.849153" "另一个文件",等等
本地 1579373292.452993 "此文件",等等
远程 "2020-01-18T13:48:12.452993" "那个文件",等等

我们如何将这些内容排序成一个单一、连贯的顺序呢?我们希望在列表中有一个单一、一致的数据类型,但源数据有两个子类型并带有标签。

看起来简单的 if row.data_source == "Local": 可以用来区分值,但对于 mypy 来说,这可能是一种令人困惑的逻辑。一两个 ad hocif 语句问题不大,但将 if 语句抛向问题的设计原则并不十分可扩展。

在这个例子中,我们可以将时间戳视为首选的表示方式。这意味着我们只需要从创建日期字符串中计算数据源为"远程"的项目的时间戳。在这个例子中,无论是浮点值还是字符串都能正确排序。这恰好工作得很好,因为字符串是精心设计的 ISO 格式。如果它采用美国月份-日期-年份格式,就需要转换成时间戳才能使用。

将所有各种输入格式转换为 Python 的原生 datetime.datetime 对象是另一种选择。这具有与任何输入格式都不同的优势。虽然这需要做更多的工作,但它给了我们更多的灵活性,因为我们不会绑定到可能在未来发生变化的源数据格式。其概念是将每个变体输入格式转换为单个、通用的 datetime.datetime 实例。

核心在于将两种亚型视为一个单一的对象类别。这并不总是能取得良好的效果。通常,当我们有更多的客户或额外的数据来源时,这会变成一种设计约束,悄悄地出现在我们面前。

我们将从一个支持数据子类型的数据类型开始实施。这并不是理想的做法,但它与源数据相匹配,并且通常是我们开始处理这类数据的方式。下面是基本的类定义:

from typing import Optional, cast, Any
from dataclasses import dataclass
import datetime
@dataclass(frozen=True)
class MultiItem:
    data_source: str
    timestamp: Optional[float]
    creation_date: Optional[str]
    name: str
    owner_etc: str
    def __lt__(self, other: Any) -> bool:
        if self.data_source == "Local":
            self_datetime = datetime.datetime.fromtimestamp(
                cast(float, self.timestamp)
            )
        else:
            self_datetime = datetime.datetime.fromisoformat(
                cast(str, self.creation_date)
            )
        if other.data_source == "Local":
            other_datetime = datetime.datetime.fromtimestamp(
                cast(float, other.timestamp)
            )
        else:
            other_datetime = datetime.datetime.fromisoformat(
                cast(str, other.creation_date)
            )
        return self_datetime < other_datetime 

__lt__() 方法比较 MultiItem 类的对象与同一类的另一个实例。由于存在两个隐式子类,我们必须检查标签属性,self.data_sourceother.data_source,以确定我们正在处理各种字段组合中的哪一个。然后我们将时间戳或字符串转换为通用表示。之后,我们可以比较这两个通用表示。

转换处理几乎是重复的代码。在本节稍后,我们将探讨如何重构以消除冗余。cast()操作是必需的,以便向mypy明确指出该项不会是None。虽然我们知道匹配标签(数据源列)和两种值的规则,但这些规则需要以一种mypy可以利用的方式表述。cast()是我们告诉mypy运行时数据将是什么的方式;实际上并没有发生任何处理。

注意,我们的应用程序可能存在不完整的类型提示,并且我们可能会在存在错误的情况下运行,此时一个不是MultiItem实例的对象可能会与一个MultiItem实例进行比较。这很可能会导致运行时错误。cast()是对意图和设计的声明,没有运行时影响。由于 Python 的鸭子类型,一些具有正确属性的意外类型也可以使用并且会正常工作。即使有仔细的类型提示,单元测试也是必不可少的。

以下输出展示了该类在排序方面的实际应用:

>>> mi_0 = MultiItem("Local", 1607280522.68012, None, "Some File", "etc. 0")
>>> mi_1 = MultiItem("Remote", None, "2020-12-06T13:47:52.849153", "Another File", "etc. 1")
>>> mi_2 = MultiItem("Local", 1579373292.452993, None, "This File", "etc. 2")
>>> mi_3 = MultiItem("Remote", None, "2020-01-18T13:48:12.452993", "That File", "etc. 3")
>>> file_list = [mi_0, mi_1, mi_2, mi_3]
>>> file_list.sort()
>>> from pprint import pprint
>>> pprint(file_list)
[MultiItem(data_source='Local', timestamp=1579373292.452993, creation_date=None, name='This File', owner_etc='etc. 2'),
 MultiItem(data_source='Remote', timestamp=None, creation_date='2020-01-18T13:48:12.452993', name='That File', owner_etc='etc. 3'),
 MultiItem(data_source='Remote', timestamp=None, creation_date='2020-12-06T13:47:52.849153', name='Another File', owner_etc='etc. 1'),
 MultiItem(data_source='Local', timestamp=1607280522.68012, creation_date=None, name='Some File', owner_etc='etc. 0')] 

比较规则被应用于合并成一个单一类定义的各种子类型之间。然而,如果规则更加复杂,这可能会变得难以操控。

仅需要实现__lt__()方法即可启用排序功能。为了完整,该类还可以实现类似的__gt__()__eq__()__ne__()__ge__()__le__()方法。这确保了所有<>==!=>=<=运算符也能正常工作。通过实现__lt__()__eq__(),然后应用@total_ordering类装饰器来提供其余部分,你可以免费获得这些功能:

from functools import total_ordering
from dataclasses import dataclass
from typing import Optional, cast
import datetime
@total_ordering
@dataclass(frozen=True)
class MultiItem:
    data_source: str
    timestamp: Optional[float]
    creation_date: Optional[str]
    name: str
    owner_etc: str
    def __lt__(self, other: "MultiItem") -> bool:
        Exercise: rewrite this to follow the example of __eq__.
    def __eq__(self, other: object) -> bool:
        return self.datetime == cast(MultiItem, other).datetime
    @property
    def datetime(self) -> datetime.datetime:
        if self.data_source == "Local":
            return datetime.datetime.fromtimestamp(
                cast(float, self.timestamp))
        else:
            return datetime.datetime.fromisoformat(
                cast(str, self.creation_date)) 

我们没有重复__lt__()方法的主体;我们鼓励读者将其重写,使其更像__eq__()方法。当我们提供一些组合的<(或>)和=时,@total_order装饰器可以推断出剩余的逻辑运算符实现。例如,图片__ge__(self, other)的实现是not self < other

注意,我们的类方法定义(非常)专注于比较这些对象之间的 timestampcreation_date 属性。这些方法的定义——或许——并不理想,因为它们正好反映了比较的一个用例。我们通常有两种可能的设计:

  • 狭义地定义比较操作,专注于特定的用例。在这个例子中,我们只比较时间戳,忽略所有其他属性。这种方法不够灵活,但可以非常高效。

  • 广泛定义比较操作,通常只支持__eq__()__ne__(),因为可能存在太多可用的替代排序比较。我们将单个属性比较规则提取到类外,并使其成为排序操作的一部分。

第二种设计策略要求我们将比较操作作为评估sort()方法的一部分进行本地化,而不是将比较作为类的一般部分。sort()方法可以接受一个可选的key参数。我们利用这个参数向sort()方法提供一个“键提取”函数。这个参数传递给sort()的是一个函数,它可以将列表中的每个对象转换成可以进行比较的对象。在我们的例子中,我们希望有一个函数能够提取用于比较的timestampcreation_date。它看起来是这样的:

@dataclass(frozen=True)
class SimpleMultiItem:
    data_source: str
    timestamp: Optional[float]
    creation_date: Optional[str]
    name: str
    owner_etc: str
def by_timestamp(item: SimpleMultiItem) -> datetime.datetime:
    if item.data_source == "Local":
        return datetime.datetime.fromtimestamp(
            cast(float, item.timestamp))
    elif item.data_source == "Remote":
        return datetime.datetime.fromisoformat(
            cast(str, item.creation_date))
    else:
        raise ValueError(f"Unknown data_source in {item!r}") 

这就是如何使用这个by_timestamp()函数,通过每个SimpleMultiItem对象中的datetime对象来比较对象:

>>> file_list.sort(key=by_timestamp) 

我们已经将排序规则从类中分离出来,这导致了一种令人愉悦的简化。我们可以利用这种设计来提供其他类型的排序。例如,我们可能只按名称排序。这稍微简单一些,因为不需要转换:

>>> file_list.sort(key=lambda item: item.name) 

我们创建了一个 lambda 对象,这是一个微不足道的无名函数,它接受一个项目作为参数并返回item.name的值。Lambda 是一个函数,但它没有名字,并且不能有任何语句。它只有一个表达式。如果你需要语句(例如 try/except 子句),你需要在sort()方法参数外部使用传统的函数定义。

有一些排序键操作非常常见,Python 团队已经提供了它们,这样你就不必自己编写它们。例如,通常情况下,我们会根据列表中的除第一个元素之外的其他元素来对元组列表进行排序。可以使用 operator.attrgetter 方法作为键来完成这个操作:

>>> import operator
>>> file_list.sort(key=operator.attrgetter("name")) 

attrgetter() 函数用于从对象中获取特定属性。当处理元组或字典时,可以使用 itemgetter() 通过名称或位置提取特定项。甚至还有一个 methodcaller(),它返回对排序对象进行方法调用的结果。有关更多信息,请参阅 operator 模块文档。

数据对象很少只有一个排序方式。将键函数作为sort()方法的一部分,让我们能够定义多种多样的排序规则,而无需创建复杂的类定义。

在查阅了词典和列表之后,我们可以将注意力转向集合。

集合

列表是极其多功能的工具,适用于许多容器对象应用。但是,当我们想要确保列表中的对象唯一时,它们就不再有用。例如,一个音乐库可能包含同一艺术家的许多歌曲。如果我们想要在库中筛选并创建所有艺术家的列表,我们必须在再次添加之前检查列表,看看我们是否已经添加了该艺术家。

这就是集合发挥作用的地方。集合来源于数学,它们代表了一组无序且唯一的物品。我们可以尝试将一个物品添加到集合中五次,但“是集合的成员”这一属性在我们第一次添加后并不会改变。

在 Python 中,集合可以包含任何可哈希的对象,而不仅仅是字符串或数字。可哈希的对象实现了__hash__()方法;这些对象与可以作为字典键使用的对象相同;因此,可变列表、集合和字典都不适用。像数学集合一样,它们只能存储每个对象的单个副本。如果我们试图创建一个歌曲艺术家的列表,我们可以创建一个字符串名称的集合,并将它们简单地添加到集合中。这个例子从一个(song, artist)元组的列表开始,并创建一个艺术家集合:

>>> 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) 

对于空集合,没有内置的语法与列表和字典相同;我们使用set()构造函数来创建一个集合。然而,只要集合包含值,我们就可以使用花括号(从字典语法中借用)来创建一个集合。如果我们使用冒号来分隔值对,它就是一个字典,例如{'key': 'value', 'key2': 'value2'}。如果我们只是用逗号分隔值,它就是一个集合,例如{'value', 'value2'}

可以使用add()方法逐个将项目添加到集合中,并使用update()方法批量更新。如果我们运行上面显示的脚本,我们会看到集合按预期工作:

{'Sarah Brightman', "Guns N' Roses", 'Vixy and Tony', 'Opeth'} 

如果你关注输出结果,你会注意到项目不是按照它们被添加到集合中的顺序打印的。实际上,每次运行这个程序,你可能会看到不同的顺序。

集合由于使用了基于哈希的数据结构以实现成员的高效访问,因此本质上是无序的。正因为这种缺乏顺序性,集合不能通过索引来查找项目。集合的主要目的是将世界划分为两组:集合中的事物不在集合中的事物。检查一个项目是否在集合中或者遍历集合中的项目都很简单,但如果我们要对它们进行排序或排序,我们必须将集合转换为列表。此输出显示了所有这三个活动:

>>> "Opeth" in artists
True
>>> alphabetical = list(artists)
>>> alphabetical.sort()
>>> alphabetical
["Guns N' Roses", 'Opeth', 'Sarah Brightman', 'Vixy and Tony'] 

这个输出非常多变;根据所使用的哈希随机化,任何可能的排序都可以使用。

>>> for artist in artists:
...     print(f"{artist} plays good music")
...
Sarah Brightman plays good music
Guns N' Roses plays good music
Vixy and Tony play good music
Opeth plays good music 

集合的主要特征是唯一性。集合通常用于去重数据。集合还用于创建组合,包括集合之间的并集和差集。大多数集合类型上的方法都作用于其他集合,使我们能够高效地组合或比较两个或更多集合中的项目。

union方法是最常见且最容易理解的。它接受一个作为参数的第二个集合,并返回一个包含两个集合中所有元素的新的集合;如果一个元素在原始的两个集合中,它只会在新的集合中显示一次。并集操作就像逻辑or操作。实际上,如果你不喜欢调用方法,可以使用|运算符对两个集合执行并集操作。

相反,交集方法接受第二个集合,并返回一个新集合,该集合仅包含在两个集合中都存在的元素。这就像一个逻辑操作,也可以使用&运算符来引用。

最后,symmetric_difference 方法告诉我们剩下的是什么;它是那些只在一个集合或另一个集合中,但不在两个集合中的对象的集合。它使用 ^ 操作符。以下示例通过比较两位不同的人喜欢的艺术家来说明这些方法:

>>> dusty_artists = {
...     "Sarah Brightman",
...     "Guns N' Roses",
...     "Opeth",
...     "Vixy and Tony",
... }
>>> steve_artists = {"Yes", "Guns N' Roses", "Genesis"} 

这里是并集、交集和对称差集的三个示例:

>>> print(f"All: {dusty_artists | steve_artists}")
All: {'Genesis', "Guns N' Roses", 'Yes', 'Sarah Brightman', 'Opeth', 'Vixy and Tony'}
>>> print(f"Both: {dusty_artists.intersection(steve_artists)}")
Both: {"Guns N' Roses"}
>>> print(
...    f"Either but not both: {dusty_artists ^ steve_artists}"
... )
Either but not both: {'Genesis', 'Sarah Brightman', 'Opeth', 'Yes', 'Vixy and Tony'} 

联集、交集和对称差集方法都是交换律的。我们可以说 dusty_artists.union(steve_artists) 或者 steve_artists.union(dusty_artists) 并得到相同的一般结果。由于哈希随机化,值的顺序可能会有所不同,但相同的项将存在于两个集合中。

也有一些方法根据调用者和参数的不同返回不同的结果。这些方法包括issubsetissuperset,它们是彼此的逆操作。两者都返回一个bool

  • issubset 方法返回 True 如果调用集合中的所有项目也都在作为参数传递的集合中。我们也可以使用 <= 操作符来做到这一点。

  • issuperset 方法返回 True 如果调用集中所有项目也在参数中。因此,s.issubset(t)s <= tt.issuperset(s)t >= s 都是相同的。

  • 如果t包含s中的所有元素,它们都将返回True。(<>运算符用于真子集和真超集;这些操作没有命名的方法。)

最后,difference 方法返回调用集中存在的所有元素,但不包括作为参数传递的集合中的元素。difference 方法也可以用 - 运算符表示。以下代码展示了这些方法在实际中的应用:

>>> artists = {"Guns N' Roses", 'Vixy and Tony', 'Sarah Brightman', 'Opeth'}
>>> bands = {"Opeth", "Guns N' Roses"}
>>> artists.issuperset(bands)
True
>>> artists.issubset(bands)
False
>>> artists - bands
{'Sarah Brightman', 'Vixy and Tony'}
>>> bands.issuperset(artists)
False
>>> bands.issubset(artists)
True
>>> bands.difference(artists)
set() 

差分法在最终表达式中返回一个空集,因为bands中没有不在artists中的项目。从另一个角度来看,我们以bands中的值为起点,然后移除artists中的所有项目。可以将其视为表达式bands - artists

并集交集差集方法都可以接受多个集合作为参数;正如我们可能预期的,它们将返回在所有参数上调用操作时创建的集合。

因此,集合上的方法明显表明集合是用来在其他集合上操作的,并且它们不仅仅是容器。如果我们从两个不同的来源接收数据,并且需要以某种方式快速地将它们组合起来,以便确定数据重叠或不同之处,我们可以使用集合操作来高效地比较它们。或者,如果我们接收到的数据可能包含已经处理过的数据的重复项,我们可以使用集合来比较这两个数据集,并且只处理新数据。

最后,了解集合在用 in 关键字检查成员资格时比列表更高效是有价值的。如果你在集合或列表上使用 value in container 语法,如果 container 中的任何一个元素等于 value,它将返回 True,否则返回 False。然而,在列表中,它会检查容器中的每一个对象,直到找到该值,而在集合中,它只是对值进行哈希并检查成员资格。这意味着无论容器有多大,集合都会在相同的时间内找到该值,但列表随着包含的值的增加,搜索值所需的时间会越来越长。

三种类型的队列

我们将探讨如何使用列表结构来创建一个队列。队列是一种特殊的缓冲区,简称为先进先出FIFO)。其理念是作为一个临时的存储空间,以便应用程序的一部分可以向队列写入数据,而另一部分则从队列中消费项目。

数据库可能有一个数据队列等待写入磁盘。当我们的应用程序执行更新时,数据的本地缓存版本会被更新,这样所有其他应用程序都能看到这个变化。然而,写入磁盘的操作可能会被放入队列中,由写入器在几毫秒后处理。

当我们查看文件和目录时,队列可以是一个方便的地方来存放目录的详细信息,以便稍后处理。我们通常将目录表示为从文件系统根目录到感兴趣文件的路径。我们将在第九章字符串、序列化和文件路径中详细探讨Path对象。算法的工作原理如下:

queue starts empty
Add the base directory to the queue
While the queue is not empty:
    Pop the first item from the queue
    If the item is a file:
        Process the item
    Else if the item is a directory:
        For each sub-item in the directory:
           Add this sub-item to the queue 

我们可以将这个类似列表的结构想象为通过append()方法增长,通过pop(0)方法缩小。它看起来会是这样:

图表描述自动生成

图 7.1:队列概念

这个想法是让队列增长和缩小:每个目录增长队列,每个文件缩小队列。最终,所有文件和目录都已被处理,队列变为空。原始顺序通过先进先出(FIFO)规则得到保留。

我们有几种方法在 Python 中实现队列:

  1. 使用列表的 pop()append() 方法创建列表。

  2. collections.deque 结构,支持 popleft()append() 方法。一个 "deque" 是一个双端队列。这是一个比简单列表在追加和弹出特定操作上更快的优雅队列实现。

  3. queue模块提供了一个常用于多线程的队列,但它也可以用于我们的单线程来检查目录树。这使用了get()put()方法。由于这个结构是为并发设计的,它会锁定数据结构以确保每个更改都是原子的,并且不会被其他线程中断。对于非并发应用程序,锁定开销是一个我们可以避免的性能惩罚。这是第十四章并发的主题。

heapq模块也提供了一个队列,但它进行了一些与这个特定示例无关的额外处理。它按照优先级顺序保存项目,而不是它们被放入队列的顺序,打破了先进先出(FIFO)的预期。我们将在第八章函数也是对象部分使用这个功能。

每种实现都有细微的差别。这表明我们希望围绕它们创建便捷的包装类,以提供统一的接口。我们可以创建如下所示的类定义:

class ListQueue(List[Path]):
    def put(self, item: Path) -> None:
        self.append(item)
    def get(self) -> Path:
        return self.pop(0)
    def empty(self) -> bool:
        return len(self) == 0 

这展示了队列的三个基本操作。我们可以将某物放入队列,将其附加到末尾。我们也可以从队列中取出某物,移除队列头部的项目。最后,我们可以询问队列是否为空。我们通过扩展列表类并添加三个新方法:put()get()empty()来实现这一功能。

接下来是稍微不同的实现方式。typing.Deque 类型提示是 collections.deque 类的包装器。Python 的一次最近更改改变了底层的 collections.deque 类,从而消除了对特殊提示的需求。

from typing import Deque
class DeQueue(Deque[Path]):
    def put(self, item: Path) -> None:
        self.append(item)
    def get(self) -> Path:
        return self.popleft()
    def empty(self) -> bool:
        return len(self) == 0 

很难看出这种实现和通用列表实现之间的区别。结果是popleft()方法是一种比传统列表中的pop(0)更高的速度版本。否则,这看起来与基于列表的实现非常相似。

这里是一个使用queue模块的最终版本。这个queue模块的实现使用锁来防止多个线程并发访问时损坏数据结构。对于我们来说,它通常是透明的,除了带来微小的性能成本。

import queue
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    BaseQueue = queue.Queue[Path]  # for mypy.
else:
    BaseQueue = queue.Queue  # used at runtime.
class ThreadQueue(BaseQueue):
    pass 

这种实现方式之所以有效,是因为我们决定将Queue类接口作为其他两个类的模板。这意味着我们实际上不需要做任何真正的开发工作来实现这个类;这种设计是其他类设计的目标。

然而,类型提示看起来相当复杂。queue.Queue 类的定义也是一个泛型类型提示。当代码正在被 mypy 检查时,TYPE_CHECKING 变量是 True,我们需要为泛型类型提供一个参数。当 TYPE_CHECKING 变量是 False 时,我们未使用 mypy,此时只需要类名(不带任何额外参数)就可以在运行时定义一个队列。

这三个类在三个定义的方法方面是相似的。我们可以为它们定义一个抽象基类。或者,我们可以提供以下类型提示:

PathQueue = Union[ListQueue, DeQueue, ThreadQueue] 

这个 PathQueue 类型提示总结了所有三种类型,使我们能够定义一个对象,用于最终实现选择,可以是这三个类中的任何一个。

“哪个更好”的问题,标准回答是“这取决于你需要做什么。”

  • 对于单线程应用程序,collections.deque 是理想的;它为此目的而设计。

  • 对于多线程应用程序,需要使用queue.Queue来提供一个可以被多个并发线程读写的数据结构。我们将在第十四章,并发中回到这个话题。

虽然我们通常可以利用内置结构,如通用的 list 类,来满足各种用途,但这可能并不理想。其他两种实现方式在内置列表之上提供了优势。Python 的标准库以及通过 Python 包索引(PYPI)提供的更广泛的外部包生态系统,可以在通用结构之上提供改进。重要的是在四处寻找“完美”的包之前,先有一个具体的改进方案。在我们的例子中,dequelist 之间的性能差异很小。时间主要被用于收集原始数据的操作系统工作所占据。对于大型文件系统,可能跨越多个主机,这种差异将会累积。

Python 的面向对象特性为我们提供了探索设计替代方案的自由度。我们应该感到自由地尝试多种解决方案来更好地理解问题,并找到可接受的解决方案。

案例研究

在本章的案例研究中,我们将回顾我们的设计,利用 Python 的 @dataclass 定义。这为简化我们的设计提供了一些潜力。我们将探讨一些选择和限制;这将引导我们探索一些困难的工程权衡,在这些权衡中,没有一种明显最佳的方法。

我们还将探讨不可变的NamedTuple类定义。这些对象没有内部状态变化,这可能导致某些设计简化。这也会改变我们的设计,使其减少对继承的使用,更多地使用组合。

逻辑模型

让我们回顾一下到目前为止我们为model.py模块所做的设计。这显示了Sample类定义的层次结构,用于反映样本被使用的各种方式:

图表描述自动生成

图 7.2:到目前为止的类图

各种Sample类与数据类定义非常契合。这些对象具有许多属性,自动构建的方法似乎符合我们想要的行为。以下是经过修订的Sample类,它被实现为@dataclass而不是完全手动构建:

from dataclasses import dataclass, asdict
from typing import Optional
@dataclass
class Sample:
    sepal_length: float
    sepal_width: float
    petal_length: float
    petal_width: float 

我们使用了@dataclass装饰器从提供的属性类型提示中创建一个类。我们可以像这样使用生成的Sample类:

>>> from model import Sample
>>> x = Sample(1, 2, 3, 4)
>>> x
Sample(sepal_length=1, sepal_width=2, petal_length=3, petal_width=4) 

这个例子展示了我们如何使用@dataclass装饰器定义类的实例。注意,一个表示函数__repr__()已经自动为我们创建;它显示了有用的详细程度,如上例所示。这非常令人愉快。几乎感觉像是在作弊!

这里是Sample类层次结构中一些更多定义的说明。

@dataclass
class KnownSample(Sample):
    species: str
@dataclass
class TestingKnownSample(KnownSample):
    classification: Optional[str] = None
@dataclass
class TrainingKnownSample(KnownSample):
    """Note: no classification instance variable available."""
    pass 

这似乎涵盖了第一章中描述的用户故事,即面向对象设计,并在第四章预料之外中进行了扩展。我们可以提供训练数据,测试分类器,并处理未知样本的分类。我们不需要编写很多代码,就能获得许多有用的特征。

我们确实存在一个潜在问题。虽然我们被允许在TrainingKnownSample实例上设置一个分类属性,但这似乎不是一个好主意。以下是一个例子,我们创建一个用于训练的样本,然后也设置了分类属性。

>>> from model import TrainingKnownSample
>>> s1 = TrainingKnownSample(
...     sepal_length=5.1, sepal_width=3.5, petal_length=1.4, 
...     petal_width=0.2, species="Iris-setosa")
>>> s1
TrainingKnownSample(sepal_length=5.1, sepal_width=3.5, petal_length=1.4, petal_width=0.2, species='Iris-setosa')
# This is undesirable...
>>> s1.classification = "wrong"
>>> s1
TrainingKnownSample(sepal_length=5.1, sepal_width=3.5, petal_length=1.4, petal_width=0.2, species='Iris-setosa')
>>> s1.classification
'wrong' 

通常情况下,Python 并不会阻止我们在对象中创建新的属性,例如 classification。这种行为可能是隐藏性错误的来源。(一个好的单元测试通常会暴露这些错误。)请注意,这个类中新增的属性不会反映在 __repr__() 方法处理或 __eq__() 方法比较中。这不是一个严重的问题。在后面的章节中,我们将使用冻结的数据类以及 typing.NamedTuple 类来解决这个问题。

我们模型中剩余的类不像Sample类那样,从实现为数据类(dataclasses)中获得巨大的好处。当一个类有很多属性而方法很少时,使用@dataclass定义就是一个很大的帮助。

@dataclass处理中受益最多的另一个类是Hyperparameter类。以下是定义的第一部分,省略了方法体:

@dataclass
class Hyperparameter:
    """A specific tuning parameter set with k and a distance algorithm"""
    k: int
    algorithm: Distance
    data: weakref.ReferenceType["TrainingData"]
    def classify(self, sample: Sample) -> str:
        """The k-NN algorithm"""
        ... 

这揭示了当我们使用 from __future__ import annotations 时出现的一个有趣特性。具体来说,weakref.ReferenceType["TrainingData"] 的值有两个不同的目标:

  • mypy 工具使用此方法来检查类型引用。我们必须提供一个限定符,weakref.ReferenceType["TrainingData"]。这使用一个字符串作为对尚未定义的 TrainingData 类的前向引用。

  • 当通过@dataclass装饰器在运行时构建类定义时,不会使用额外的类型限定符。

我们省略了classify()方法的细节。我们将在第十章,迭代器模式中考察一些替代实现。

我们还没有看到数据类的所有功能。在下一节中,我们将冻结它们以帮助发现那种使用训练数据用于测试目的的 bug 类型。

冻结的数据类

数据类的通用情况是创建可变对象。可以通过为新属性赋值来改变对象的状态。这并不总是我们希望拥有的特性,我们可以使数据类不可变。

我们可以通过添加«Frozen»的类符来描述设计的 UML 图。这种表示法可以帮助我们记住将对象设置为不可变性的实现选择。我们还得遵守冻结数据类的一个重要规则:通过继承的扩展也必须被冻结。

冻结的Sample对象的定义必须与处理未知样本或测试样本的不可变对象分开。这把我们的设计分为两个类家族:

  • 一个不可变类的小层次,具体为SampleKnownSample

  • 一些利用这些冻结类别的相关类别

测试样本、训练样本和未知样本的相关类形成了一个具有几乎相同方法和属性的松散类集合。我们可以称这为“相关类的泛化”。这源于鸭子类型规则:“当我看到一只既像鸭子走路又像鸭子嘎嘎叫的鸟时,我就称那只鸟为鸭子。”从具有相同属性和方法的类中创建的对象可以互换,即使它们缺少一个共同的抽象超类。

我们可以用这样的图表来描述这个修改后的设计:

图表描述自动生成

图 7.3:带有冻结类的修订版类图

这里是Sample类层次结构的变更。这个变更相对较小,很容易在几个地方忽略frozen=True

@dataclass(frozen=True)
class Sample:
    sepal_length: float
    sepal_width: float
    petal_length: float
    petal_width: float
@dataclass(frozen=True)
class KnownSample(Sample):
    species: str
@dataclass
class TestingKnownSample:
    sample: KnownSample
    classification: Optional[str] = None
@dataclass(frozen=True)
class TrainingKnownSample:
    """Cannot be classified."""
    sample: KnownSample 

当我们创建一个TrainingKnownSampleTestingKnownSample的实例时,我们必须尊重这些对象的组成:每个类中都有一个冻结的KnownSample对象。以下示例展示了创建复合对象的一种方法。

>>> from model_f import TrainingKnownSample, KnownSample
>>> s1 = TrainingKnownSample(
...     sample=KnownSample(
...         sepal_length=5.1, sepal_width=3.5, 
...         petal_length=1.4, petal_width=0.2, species="Iris-setosa"
...     )
... )
>>> s1
TrainingKnownSample(sample=KnownSample(sepal_length=5.1, sepal_width=3.5, petal_length=1.4, petal_width=0.2, species='Iris-setosa')) 

这种嵌套构造一个包含KnownSample对象的TrainingKnownSample实例是明确的。它暴露了不可变的KnownSample对象。

冻结设计在检测细微错误方面有一个非常令人愉悦的结果。以下示例展示了由于不当使用TrainingKnownSample而引发的异常:

>>> s1.classification = "wrong"
Traceback (most recent call last):
... details omitted
dataclasses.FrozenInstanceError: cannot assign to field 'classification' 

我们不能意外地引入一个会改变训练实例的 bug。

我们获得了一个额外的功能,这使得在分配实例到训练集时更容易发现重复项。Sample(以及KnownSample)类的冻结版本产生一致的hash()值。这使得通过检查具有共同哈希值的物品子集来定位重复值变得更加容易。

适当使用 @dataclass@dataclass(frozen=True) 可以在实现面向对象的 Python 中提供很大帮助。这些定义提供了丰富的功能,同时代码量最小化。

我们还有另一种可用的技术,类似于冻结的数据类,即typing.NamedTuple。我们将在下一节中探讨这个话题。

命名元组类

使用 typing.NamedTuple 与使用 @dataclass(frozen=True) 有一定的相似性。然而,在实现细节上存在一些显著差异。特别是,typing.NamedTuple 类不支持最直观的继承方式。这导致我们在 Sample 类层次结构中采用基于对象组合的设计。使用继承时,我们通常扩展基类以添加功能。而使用组合时,我们通常构建由几个不同类组成的多个部分的对象。

这里是Sample作为NamedTuple的定义。它看起来与@dataclass的定义相似。然而,KnownSample的定义必须发生显著变化:

class Sample(NamedTuple):
    sepal_length: float
    sepal_width: float
    petal_length: float
    petal_width: float
class KnownSample(NamedTuple):
    sample: Sample
    species: str 

KnownSample 类是一个复合类,由一个 Sample 实例以及数据最初加载时分配的物种组成。由于这两个都是 typing.NamedTuple 的子类,因此其值是不可变的。

我们在设计上已经从继承转向了组合。以下是这两个概念,并列展示:

图表描述自动生成

图 7.4:基于继承与基于组合的类设计

在图中,这些差异可能容易被忽视:

  • 采用以继承为中心的设计,KnownSample实例是一个Sample实例。它具有五个属性:从Sample类继承的所有四个属性,加上一个仅属于KnownSample子类的独特属性。

  • 使用以组合为重点的设计,一个KnownSample_C实例由一个Sample实例和物种分类组成。它有两个属性。

正如我们所见,这两种设计都能工作。选择是困难的,通常围绕着从超类继承的方法的数量和复杂性来决定。在这个例子中,Sample类中没有定义对应用程序重要的方法。

继承与组合的设计决策代表了一个没有单一、正确答案的艰难选择。这个决策通常需要通过细微地理解一个子类是否真正是超类的一个成员来帮助。比喻地说,我们经常问一个苹果是否是水果来帮助理解更窄的子类和通用的超类。我们面临的问题是,苹果也可以是甜点,这使原本看似简单的决策因为额外的细节而变得复杂。

不要忘记,苹果(作为苹果酱)也可能是主菜的一部分。这种复杂性可能会使得“是”的问题更难回答。在我们的情况下,样本、已知样本、未知样本、测试样本和训练样本之间的“是”的关系可能不是最佳的前进路径。我们似乎有多个角色(即,测试、训练、待分类)与每个样本相关联,并且可能只有两个Sample的子类,即已知和未知。

TestingKnownSampleTrainingKnownSample 类定义遵循鸭子类型规则。它们具有相似的属性,在许多情况下可以互换使用。

class TestingKnownSample:
    def __init__(
        self, sample: KnownSample, classification: Optional[str] = None
    ) -> None:
        self.sample = sample
        self.classification = classification
    def __repr__(self) -> str:
        return (
            f"{self.__class__.__name__}(sample={self.sample!r}," 
            f"classification={self.classification!r})"
        )
class TrainingKnownSample(NamedTuple):
    sample: KnownSample 

在这种情况下,TestingKnownSampleTrainingKnownSample 都是包含一个 KnownSample 对象的复合对象。主要区别在于是否存在(或不存在)一个额外的属性,即 classification 值。

这里是一个创建TrainingKnownSample并尝试(错误地)设置分类的示例:

>>> from model_t import TrainingKnownSample, KnownSample, Sample
>>> s1 = TrainingKnownSample(
...     sample=KnownSample(
...         sample=Sample(sepal_length=5.1, sepal_width=3.5, 
...         petal_length=1.4, petal_width=0.2),
...         species="Iris-setosa"
...     ),
... )
>>> s1
TrainingKnownSample(sample=KnownSample(sample=Sample(sepal_length=5.1, sepal_width=3.5, petal_length=1.4, petal_width=0.2), species='Iris-setosa'))
>>> s1.classification = "wrong"
Traceback (most recent call last):
...
AttributeError: 'TrainingKnownSample' object has no attribute 'classification' 

代码反映了复合的复合设计。一个TrainingKnownSample实例包含一个KnownSample对象,该对象又包含一个Sample对象。示例显示我们无法向TrainingKnownSample实例添加新的属性。

结论

到目前为止,我们已经看到了总共四种处理面向对象设计和实现的方法。

  • 在前面的章节中,我们探讨了从头开始创建对象,自己编写所有方法定义。我们强调了Sample类层次结构中类的继承。

  • 在本章中,我们看到了使用@dataclass定义的状态类。这支持Sample类层次结构中类之间的继承。

  • 我们还看到了使用 @dataclass(frozen=True) 的无状态(或不可变)定义。这往往抑制了一些继承方面的特性,并倾向于支持组合。

  • 最后,我们探讨了使用NamedTuple进行无状态(或不可变)定义。这必须通过组合设计。对这些类的前期概述使得设计看起来相当简单。我们将在第八章面向对象与函数式编程的交汇处中回到这一点。

在 Python 中,我们拥有很大的灵活性。从我们未来想要添加或修改特性的自我视角来看,考虑选择是很重要的。遵循 SOLID 设计原则并专注于单一职责和接口分离,有助于隔离和封装我们的类定义。

回忆

我们在本章中探讨了多种内置的 Python 数据结构。Python 允许我们在不承受大量可能令人困惑的类定义开销的情况下进行大量的面向对象编程。当它们适合我们的问题时,我们可以依赖许多内置的类。

在本章中,我们探讨了以下内容:

  • 元组和命名元组允许我们利用一组简单的属性。当需要时,我们可以扩展NamedTuple的定义来添加方法。

  • Dataclasses 提供了复杂的属性集合。可以为我们提供各种方法,简化我们需要编写的代码。

  • 字典是一个基本特性,在 Python 中被广泛使用。有许多地方键与值相关联。使用内置字典类的语法使其易于使用。

  • 列表和集合也是 Python 的一等组成部分;我们的应用程序可以利用这些功能。

  • 我们还研究了三种类型的队列。这些结构比通用列表对象具有更专业化的结构和更集中的访问模式。专业化和缩小特征域的概念可以导致性能提升,同时,也使得这一概念具有更广泛的应用性。

此外,在案例研究中,我们探讨了如何使用这些内置类来定义用于测试和训练的数据样本。

练习

学习如何选择正确的数据结构最好的方法是通过几次做错(有意或无意地!)来实践。拿一些你最近写的代码,或者编写一些使用列表的新代码。尝试用一些不同的数据结构重写它。哪些更合理?哪些不合理?哪些拥有最优雅的代码?

尝试使用几对不同类型的数据结构进行这个练习。你可以查看之前章节练习中做过的例子。有没有对象使用了方法,而你本可以使用数据类(dataclasses)、namedtupledict 的?尝试两种方法并看看结果。有没有字典本可以成为集合,因为你实际上并没有访问其值?你有没有检查重复项的列表?一个集合是否足够?或者可能需要几个集合?队列实现中哪一个可能更高效?限制 API 只允许对栈顶的访问而不是允许对列表的随机访问是否有用?

你最近有没有编写过任何可以通过继承内置类并重写一些特殊的双下划线方法来改进的容器对象?你可能需要进行一些研究(使用dirhelp,或者查阅 Python 库参考)来找出哪些方法需要重写。

你确定继承是正确的工具吗;基于组合的解决方案可能更有效吗?在做出决定之前,尝试两种方法(如果可能的话)。尝试找到不同的情况下,每种方法都比另一种方法更好的情况。

如果你在这章开始之前就已经熟悉了各种 Python 数据结构和它们的用途,你可能感到无聊。但如果是这种情况,你很可能过度使用了数据结构!看看你的一些旧代码,并重写它们以使用更多自定义类。仔细考虑替代方案,并尝试所有这些方案;哪一个能让你构建出最易于阅读和维护的系统?

本节中的MultiItem示例最初使用了一个看起来笨拙的__lt__()方法。第二个版本有一个稍微更好的__eq__()方法。将__lt__()重写为遵循__eq__()的设计模式。

原始类设计的更大问题是试图处理各种子类型及其可选字段。存在一个可选属性是一种暗示——也许——有不同类正在努力相互区分。如果我们区分两个密切相关但不同的类:LocalItem(使用timestamp)和RemoteItem(使用created_date),会发生什么?我们可以定义一个公共类型提示为Union[LocalItem, RemoteItem]。如果每个类都有一个像creation_datetime这样的属性,它会计算一个datetime.datetime对象,处理会变得更简单吗?构建这两个类;创建一些测试数据。如何分离这两个子类型看起来会是什么样子?

总是批判性地评估你的代码和设计决策。养成回顾旧代码的习惯,并注意自你编写以来你对良好设计的理解是否有所改变。软件设计具有很大的美学成分,就像在画布上用油画的艺术家一样,我们都需要找到最适合自己的风格。

摘要

我们已经介绍了几个内置的数据结构,并尝试理解如何为特定的应用选择一个。有时,我们能做的最好的事情就是创建一个新的对象类,但通常,内置的其中一个就能提供我们所需的一切。当它不能满足需求时,我们总是可以使用继承或组合来适应我们的使用场景。我们甚至可以覆盖特殊方法来完全改变内置语法的行为。

在下一章中,我们将讨论如何整合 Python 中面向对象和非面向对象方面的内容。在这个过程中,我们会发现它比第一眼看上去更加面向对象!

第八章:面向对象与函数式编程的交汇

Python 的许多方面看起来更像是结构化或函数式编程,而不是面向对象编程。尽管面向对象编程在过去二十年里是最明显的编程范式,但旧的模式最近又有所复兴。与 Python 的数据结构一样,这些工具中的大多数都是在底层面向对象实现之上的语法糖;我们可以把它们看作是在(已经抽象化的)面向对象范式之上构建的进一步抽象层。在本章中,我们将介绍一些不是严格面向对象的 Python 特性:

  • 内置函数,一次调用即可处理常见任务

  • 方法重载的替代方案

  • 函数作为对象

  • 文件输入输出和上下文管理器

本章的案例研究将回顾一些关键的 k 近邻分类算法。我们将探讨如何使用函数而不是类的方法。对于应用的部分,将算法与类定义分离可以提供一些灵活性。

我们将从这个章节开始,通过查看一些 Python 的内置函数。其中一些与类定义紧密相关,使我们能够使用函数式编程风格来处理底层复杂对象。

Python 内置函数

Python 中有许多函数,它们在底层类上不是方法,但可以在某些类型的对象上执行任务或计算结果。它们通常抽象出适用于多种类型类的通用计算。这就是鸭子类型最完美的体现;这些函数接受具有某些属性或方法的对象,并能够使用这些方法执行通用操作。我们已经使用了许多内置函数,但让我们快速浏览一下重要的函数,并在过程中学习一些巧妙的技巧。

len() 函数

函数与对象方法相关的一个简单例子是len()函数,它返回某种容器对象中元素的数量,例如字典或列表。您之前已经见过它,如下所示:

>>> len([1, 2, 3, 4])
4 

你可能会 wonder 为什么这些对象没有长度属性,而需要调用它们上的一个函数。Technically,它们确实有。大多数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(myobj),被一些人认为比替代方法风格,myobj.len(),更易读。有些人争论这种语法的非一致性,但其他人更喜欢它,因为它适用于大量集合类型中的那些常见操作。

另一个有时被忽视的原因,len() 是一个外部函数,是因为向后兼容性。这在文章中常被引用为 出于历史原因,这可以是一种轻微的轻视方式,意味着很久以前犯了一个错误,我们现在只能忍受它。严格来说,len() 并不是一个错误;它是一个经得起时间考验的设计决策,并且带来了一些好处。

reversed() 函数

reversed() 函数接受任何序列作为输入,并返回该序列的逆序副本。它通常在for循环语句中使用,当我们想要从后向前遍历项目时。

len() 函数类似,reversed() 函数会在参数的类上调用 __reversed__() 方法。如果该方法不存在,reversed 会通过调用 __len__()__getitem__() 方法来构建反转的序列,这些方法用于定义序列。我们只需要覆盖 __reversed__() 方法,如果我们想以某种方式自定义或优化这个过程,如下面的代码所示:

>>> class CustomSequence:
...     def __init__(self, args):
...         self._list = args
...     def __len__(self):
...         return 5
...     def __getitem__(self, index):
...         return f"x{index}"
>>> class FunkyBackwards(list):
...     def __reversed__(self):
...         return "BACKWARDS!" 

让我们在三种不同的列表上练习这个函数:

>>> generic = [1, 2, 3, 4, 5]
>>> custom = CustomSequence([6, 7, 8, 9, 10])
>>> funkadelic = FunkyBackwards([11, 12, 13, 14, 15])
>>> for sequence in generic, custom, funkadelic:
...     print(f"{sequence.__class__.__name__}: ", end="")
...     for item in reversed(sequence):
...         print(f"{item}, ", end="")
...     print()
list: 5, 4, 3, 2, 1, 
CustomSequence: x4, x3, x2, x1, x0, 
FunkyBackwards: B, A, C, K, W, A, R, D, S, !, 

结尾处的for语句打印出通用列表对象的反转版本,以及CustomSequence类和FunkyBackwards类的实例。输出显示reversed在这三个对象上都有效,但结果却非常不同。

当我们反转CustomSequence时,__getitem__()方法会对每个项目进行调用,这只是在索引前插入一个x。对于FunkyBackwards__reversed__()方法返回一个字符串,其中的每个字符都在for循环中单独输出。

CustomSequence 类不完整。它没有定义一个合适的 __iter__() 版本,因此对它们的正向 for 循环永远不会结束。这是 第十章迭代器模式的主题。

enumerate() 函数

有时候,当我们使用for语句检查容器中的项目时,我们希望访问当前正在处理的项目在容器中的索引(即当前位置)。for语句并没有为我们提供索引,但enumerate()函数提供了更好的东西:它创建了一个元组的序列,其中每个元组的第一个对象是索引,第二个是原始项目。

这很有用,因为它分配了一个索引号。它适用于没有固有索引顺序的值集合或字典。它也适用于文本文件,因为文本文件隐含了行号。考虑一些简单的代码,它输出文件中的每一行及其关联的行号:

>>> from pathlib import Path
>>> with Path("docs/sample_data.md").open() as source:
...     for index, line in enumerate(source, start=1):
...         print(f"{index:3d}: {line.rstrip()}") 

运行此代码将显示以下内容:

1: # Python 3 Object-Oriented Programming
2: 
3: Chapter 8\. The Intersection of Object-Oriented and Functional Programming
4: 
5: Some sample data to show how the `enumerate()` function works. 

enumerate 函数是一个可迭代对象:它返回一系列元组。我们的 for 语句将每个元组拆分为两个值,并且 print() 函数将它们格式化在一起。我们在 enumerate 函数上使用了可选的 start=1 来提供一个基于 1 的行号序列约定。

我们只简要介绍了几个比较重要的 Python 内置函数。正如您所看到的,其中许多都涉及面向对象的概念,而另一些则遵循纯粹的功能性或过程性范式。标准库中还有许多其他函数;其中一些比较有趣的包括以下内容:

  • abs(), str(), repr(), pow(), 和 divmod() 直接映射到特殊方法 __abs__(), __str__(), __repr__(), __pow__(), 和 __divmod__()

  • bytes(), format(), hash(), 和 bool() 也直接映射到特殊方法 __bytes__(), __format__(), __hash__()__bool__()

还有几个更多。《Python 语言参考》中的第 3.3 节,特殊方法名称提供了这些映射的详细信息。其他有趣的内置函数包括以下内容:

  • all()any()函数,它们接受一个可迭代对象,如果所有或任何项目评估为真(例如非空字符串或列表、非零数字、非None的对象或字面量True),则返回True

  • eval(), exec()compile(),这些函数在解释器内部执行字符串作为代码。对这些函数要小心;它们不安全,因此不要执行未知用户提供给您的代码(通常,假设所有未知用户都是恶意的、愚蠢的,或者两者都是)。

  • hasattr(), getattr(), setattr(), 和 delattr(),这些函数允许通过对象的字符串名称来操作其属性。

  • zip() 函数接受两个或更多序列,并返回一个新的元组序列,其中每个元组包含来自每个序列的单个值。

  • 还有更多!请参阅每个函数的解析器帮助文档,这些函数列在help("builtins")中。

核心在于避免一个狭隘的观点,即面向对象编程语言必须始终使用 object.method() 语法来处理所有事情。Python 追求可读性,简单的 len(collection) 似乎比略微更一致的潜在替代方案 collection.len() 更清晰。

方法重载的替代方案

许多面向对象编程语言的一个显著特点是称为方法重载的工具。方法重载指的是拥有多个具有相同名称但接受不同参数集的方法。在静态类型语言中,如果我们想要一个可以接受整数或字符串的方法,例如,这非常有用。在非面向对象的语言中,我们可能需要两个函数,分别称为add_sadd_i,以适应这种情况。在静态类型面向对象语言中,我们需要两个方法,都称为add,一个接受字符串,另一个接受整数。

在 Python 中,我们已经看到我们只需要一个方法,该方法接受任何类型的对象。可能需要对对象类型进行一些测试(例如,如果它是一个字符串,则将其转换为整数),但只需要一个方法即可。

一个可以接受多种类型的参数的类型提示可能会变得相当复杂。我们通常会使用 typing.Union 提示来表明一个参数可以具有来自 Union[int, str] 的值。这个定义明确了备选方案,以便 mypy 可以确认我们正确地使用了重载函数。

我们在这里必须区分两种过载的类型:

  • 使用 Union[...] 指示符来过载参数以允许使用替代类型

  • 通过使用更复杂的参数模式来过度加载该方法

例如,电子邮件消息方法可能有两种版本,其中一种接受用于发件人电子邮件地址的参数。另一种方法可能查找默认的发件人电子邮件地址。某些语言强制我们编写具有相同名称但不同参数模式的多个方法。Python 不允许对具有相同名称的方法进行多次定义,但它提供了一种不同但同样灵活的方式来指定变体参数。

我们在之前的示例中已经看到了一些向方法和函数发送参数值的方法,但现在我们将涵盖所有细节。最简单的函数不接受任何参数。我们可能不需要示例,但为了完整性,这里有一个例子:

>>> def no_params():
...     return "Hello, world!" 

这就是它的称呼方式:

>>> no_params()
'Hello, world!' 

在这个情况下,因为我们是在交互式工作,所以我们省略了类型提示。一个接受参数的函数将通过逗号分隔的列表提供那些参数名称。只需要提供每个参数的名称。然而,类型提示总是有帮助的。提示跟随名称,由冒号分隔,:

当调用函数时,必须按照顺序指定位置参数的值,且不能遗漏或跳过任何一个。这是我们之前示例中最常见的指定参数的方式:

>>> def mandatory_params(x, y, z): 
...     return f"{x=}, {y=}, {z=}" 

要调用它,请输入以下内容:

>>> a_variable = 42
>>> mandatory_params("a string", a_variable, True) 

Python 代码在类型方面是通用的。这意味着任何类型的对象都可以作为参数值传递:一个对象、一个容器、一个原始数据类型,甚至是函数和类。前面的调用显示了硬编码的字符串、变量的值以及传递给函数的布尔值。

通常,我们的应用程序并非完全通用。这就是我们经常提供类型提示来缩小可能值的域的原因。在极少数情况下,当我们真正编写通用代码时,我们可以使用typing.Any提示来告诉mypy我们确实意味着任何对象都是可用的:

>>> from typing import Any
>>> def mandatory_params(x: Any, y: Any, z: Any) -> str: 
...     return f"{x=}, {y=}, {z=}" 

我们可以使用mypy通过--disallow-any-expr选项定位此类代码。这可以标记出可能需要一些清晰说明哪些类型真正重要的行。

参数的默认值

如果我们想让一个参数的值是可选的,我们可以指定一个默认值。一些其他语言(例如 Java)要求有一个带有不同参数集的第二种方法。在 Python 中,我们定义一个单一的方法;我们可以使用等号为一个参数提供默认值。如果调用代码没有为该参数提供参数值,它将被分配给定的默认值。这意味着调用代码仍然可以选择通过传递不同的值来覆盖默认值。如果一个None值被用作可选参数值的默认值,typing模块允许我们使用Optional类型提示来描述这种情况。

这里是一个带有默认参数定义的函数定义:

def latitude_dms(
    deg: float, min: float, sec: float = 0.0, dir: Optional[str] = None
) -> str:
    if dir is None:
        dir = "N"
    return f"{deg:02.0f}° {min+sec/60:05.3f}{dir}" 

前两个参数是必填的,必须提供。最后两个参数有默认参数值,可以省略。

我们可以通过几种方式调用这个函数。我们可以按顺序提供所有参数值,就像所有参数都是位置参数一样,如下所示:

>>> latitude_dms(36, 51, 2.9, "N")
'36° 51.048N' 

或者,我们可以按顺序提供必要的参数值,允许其中一个关键字参数(sec)使用默认值,并为dir参数提供一个关键字参数:

>>> latitude_dms(38, 58, dir="N")
'38° 58.000N' 

我们在调用函数时使用了等号语法来跳过我们不感兴趣的默认值。

令人惊讶的是,我们甚至可以使用等号语法来打乱位置参数的顺序,只要所有参数都给出了一个参数值:

>>> latitude_dms(38, 19, dir="N", sec=7)
'38° 19.117N' 

你可能会偶尔发现创建一个仅关键字参数很有用。要使用这个,必须以关键字参数的形式提供参数值。你可以通过在所有仅关键字参数前放置一个*来实现这一点:

def kw_only(
    x: Any, y: str = "defaultkw", *, a: bool, b: str = "only"
) -> str:
    return f"{x=}, {y=}, {a=}, {b=}" 

此函数有一个位置参数x,以及三个关键字参数yabxy参数都是必需的,但a只能作为关键字参数传递。yb都是可选的,并具有默认值,但如果提供了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 

但你可以将ab作为关键字参数传递:

>>> kw_only('x', a='a', b='b')
"x='x', y='defaultkw', a='a', b='b'" 

我们还可以将参数标记为仅通过位置提供。我们通过在分隔仅位置参数和随后更灵活参数的单个 / 之前提供这些名称来实现这一点。

def pos_only(x: Any, y: str, /, z: Optional[Any] = None) -> str:
    return f"{x=}, {y=}, {z=}" 

此函数要求xy参数的值作为前两个参数,并且不允许对xy使用命名参数。以下是尝试时的结果:

>>> pos_only(x=2, y="three")
Traceback (most recent call last):
  ...
  File "<doctest hint_examples.__test__.test_pos_only[0]>", line 1, in <module>
    pos_only(x=2, y="three")
TypeError: pos_only() got some positional-only arguments passed as keyword arguments: 'x, y'
>>> pos_only(2, "three")
"x=2, y='three', z=None"
>>> pos_only(2, "three", 3.14159) 
"x=2, y='three', z=3.14159" 

我们必须按位置提供前两个参数xy的值。第三个参数z可以按位置提供,也可以使用关键字提供。

我们有三种不同的参数可能性:

  • 仅位置相关:在某些情况下这些很有用;请参阅 PEP 570 中的示例:www.python.org/dev/peps/pep-0570.

  • 位置或关键字:大多数参数都是这种情况。参数的顺序设计得有助于理解,并且可以使用关键字进行说明。超过三个位置参数可能会引起混淆,因此长列表的位置参数不是一个好主意。

  • 关键词仅限: 在 * 之后,参数值必须提供关键词。这有助于使不常用的选项更加明显。可以将关键词视为字典的键。

选择如何调用方法通常可以自行处理,这取决于需要提供哪些值,哪些可以保留为默认值。对于只有几个参数值的基本方法,使用位置参数几乎是预期的。对于具有许多参数值的复杂方法,使用关键字可以帮助阐明其工作原理。

默认值的附加细节

在关键字参数中需要注意的一点是,我们提供的任何默认参数都是在函数首次创建时进行评估的,而不是在评估时。这意味着我们不能有动态生成的默认值。例如,以下代码的行为可能不会完全符合预期:

number = 5
def funky_function(x: int = number) -> str:
    return f"{x=}, {number=}" 

x 参数的默认值是函数定义时的当前值 当函数被定义时。我们可以通过尝试用不同的 number 变量值来评估这个行为,从而看到这种行为:

>>> funky_function(42)
'x=42, number=5'
>>> number = 7
>>> funky_function()
'x=5, number=5' 

第一次评估看起来符合我们的预期;默认值是原始值。这是一个巧合。第二次评估,在改变全局变量number之后,显示函数定义对于默认值有一个固定值——变量不会被重新评估。

为了使这个功能正常工作,我们通常会使用 None 作为默认值,并在函数体内分配全局变量的当前值:

def better_function(x: Optional[int] = None) -> str:
    if x is None:
        x = number
    return f"better: {x=}, {number=}" 

这个better_function()函数在函数定义中没有绑定number变量的值。它使用全局number变量的当前值。是的,这个函数隐式地依赖于一个全局变量,并且文档字符串应该解释这一点,理想情况下用火焰表情符号包围,以便让任何阅读它的人都能清楚地了解函数的结果可能不是显然的幂等的。

将参数值设置为一个参数或默认值的一个稍微紧凑的方式如下:

def better_function_2(x: Optional[int] = None) -> str:
    x = number if x is None else x
    return f"better: {x=}, {number=}" 

number if x is None else x 表达式似乎表明 x 将具有全局变量 number 的值,或者为 x 提供的参数值。

“定义时评估”可能会在处理如列表、集合和字典等可变容器时让我们陷入困境。将空列表(或集合或字典)作为参数的默认值似乎是一个良好的设计决策。我们不应该这样做,因为当代码首次构建时,它将只创建一个可变对象的实例。这个对象将被重复使用,如下所示:

from typing import List
def bad_default(tag: str, history: list[str] = []) -> list[str]:
    """ A Very Bad Design (VBD™)."""
    history.append(tag)
    return history 

这是非常糟糕的设计。我们可以尝试创建一个历史列表,h,并将事物添加到其中。这似乎是可行的。剧透警告:默认对象是一个特定的可变对象,list,它是共享的:

>>> h = bad_default("tag1")
>>> h = bad_default("tag2", h)
>>> h
['tag1', 'tag2']
>>> h2 = bad_default("tag21")
>>> h2 = bad_default("tag22", h2)
>>> h2
['tag1', 'tag2', 'tag21', 'tag22'] 

哎呀,这并不是我们预期的结果!当我们尝试创建第二个历史列表,h2时,它是基于唯一默认值:

>>> h
['tag1', 'tag2', 'tag21', 'tag22']
>>> h is h2
True 

解决这个问题的通常方法是设置默认值为None。我们已经在之前的例子中见过这种情况,这是一种常见的做法:

def good_default(
        tag: str, history: Optional[list[str]] = None
) -> list[str]:
    history = [] if history is None else history
    history.append(tag)
    return history 

如果没有提供参数,这将创建一个全新的空list[str]对象。这是处理默认值且这些默认值也是可变对象的最佳方式。

可变参数列表

仅使用默认值并不能提供我们可能需要的所有灵活性。Python 真正出色的一点是能够编写接受任意数量位置参数或关键字参数的方法,而无需明确命名它们。我们还可以将这些任意列表和字典传递给此类函数。在其他语言中,这些有时被称为可变参数,变参

例如,我们可以编写一个函数来接受一个链接或 URL 列表并下载网页。这个想法是为了避免当我们只想下载一个页面时,出现看起来令人困惑的单例列表。而不是用一个 URL 列表接受单个值,我们可以接受任意数量的参数,其中每个参数都是一个 URL。我们通过定义一个位置参数来接收所有参数值来实现这一点。这个参数必须在最后(在位置参数中),我们将在函数定义中使用一个*来装饰它,如下所示:

from urllib.parse import urlparse
from pathlib import Path
def get_pages(*links: str) -> None:
    for link in links:
        url = urlparse(link)
        name = "index.html" if url.path in ("", "/") else url.path
        target = Path(url.netloc.replace(".", "_")) / name
        print(f"Create {target} from {link!r}")
        # etc. 

*links参数中的*表示,“我会接受任意数量的参数并将它们全部放入一个名为links的元组中”。如果我们只提供一个参数,它将是一个只有一个元素的列表;如果我们不提供任何参数,它将是一个空列表。因此,所有这些函数调用都是有效的:

>>> get_pages()

>>> get_pages('https://www.archlinux.org') 
Create www_archlinux_org/index.html from 'https://www.archlinux.org'
>>> get_pages('https://www.archlinux.org', 
...        'https://dusty.phillips.codes',
...        'https://itmaybeahack.com'
... ) 
Create www_archlinux_org/index.html from 'https://www.archlinux.org'
Create dusty_phillips_codes/index.html from 'https://dusty.phillips.codes'
Create itmaybeahack_com/index.html from 'https://itmaybeahack.com' 

注意,我们的类型提示表明在这个例子中所有位置参数的值都是同一类型,即str。这是一个普遍的期望:变量参数功能不过是语法糖,可以让我们避免编写看起来愚蠢的列表。对于变量参数元组使用单一类型而非其他类型的替代方案可能会造成混淆:为什么编写一个期望复杂且不同类型集合的函数,但——不知为何——在参数定义中又不明确指出这一点?不要编写那样的函数。

我们还可以接受任意的关键字参数。这些参数以字典的形式传递给函数。在函数声明中,它们通过两个星号(如**kwargs)指定。这个工具在配置设置中常用。以下类允许我们指定一组具有默认值的选项:

from __future__ import annotations
from typing import Dict, Any
class Options(Dict[str, Any]):
    default_options: dict[str, Any] = {
        "port": 21,
        "host": "localhost",
        "username": None,
        "password": None,
        "debug": False,
    }
    def __init__(self, **kwargs: Any) -> None:
        super().__init__(self.default_options)
        self.update(kwargs) 

这个类利用了__init__()方法的特性。我们有一个默认选项的字典,名称为default_options,它是类的一部分定义的。__init__()方法开始使用类级别的默认字典中的值初始化这个实例。我们这样做而不是直接修改字典,以防我们实例化两个不同的选项集。(记住,类级别的变量在类的所有实例之间是共享的。)

在从类级别的源数据中初始化实例之后,__init__() 方法使用从超类继承的 update() 方法将任何非默认值更改为作为关键字参数提供的值。因为 kwargs 的值也是一个字典,所以 update() 方法处理默认值与覆盖值的合并。

这里是一个展示该类在实际应用中的会话:

>>> options = Options(username="dusty", password="Hunter2",
...     debug=True)
>>> options['debug']
True
>>> options['port']
21
>>> options['username']
'dusty' 

我们可以使用字典索引语法访问我们的options实例。Options字典包括默认值以及我们使用关键字参数设置的值。

注意,父类是 typing.Dict[str, Any],这是一个限制键为字符串的泛型字典类。当我们初始化 default_options 对象时,我们可以依赖 from __future__ import annotations 语句,并使用 dict[str, Any] 来告诉 mypy 工具对这个变量有什么期望。这种区别很重要:该类依赖于 typing.Dict 作为其超类。

变量需要一个类型提示,我们可以使用typing.Dict类,或者我们可以使用内置的dict类。我们建议仅在绝对必要时使用typing模块,尽可能多地使用内置类。

在前面的示例中,可以向Options初始化器传递任意的关键字参数来表示默认字典中不存在的选项。这在向应用程序添加新功能时可能很有用。但在调试拼写错误时可能会造成麻烦。使用“Port”选项而不是“port”选项会导致出现两个看起来相似但实际上只应存在一个的选项。

限制拼写错误风险的一种方法是通过编写一个只替换现有键的update()方法。这可以防止拼写错误造成问题。这个解决方案很有趣,我们将把它留作读者的练习题。

关键字参数在需要接受任意参数传递给第二个函数,但不知道这些参数具体是什么时也非常有用。我们在第三章当对象相似时中看到了这一点,当时我们在构建多重继承的支持。

当然,我们可以在一个函数调用中结合变量参数和变量关键字参数的语法,同时也可以使用正常的位置参数和默认参数。以下示例有些牵强,但它展示了四种参数的实际应用:

from __future__ import annotations
import contextlib
import os
import subprocess
import sys
from typing import TextIO
from pathlib import Path
def doctest_everything(
        output: TextIO,
        *directories: Path,
        verbose: bool = False,
        **stems: str
) -> None:
    def log(*args: Any, **kwargs: Any) -> None:
        if verbose:
            print(*args, **kwargs)
    with contextlib.redirect_stdout(output):
        for directory in directories:
            log(f"Searching {directory}")
            for path in directory.glob("**/*.md"):
                if any(
                        parent.stem == ".tox"
                        for parent in path.parents
                ):
                    continue
                log(
                    f"File {path.relative_to(directory)}, "
                    f"{path.stem=}"
                )
                if stems.get(path.stem, "").upper() == "SKIP":
                    log("Skipped")
                    continue
                options = []
                if stems.get(path.stem, "").upper() == "ELLIPSIS":
                    options += ["ELLIPSIS"]
                search_path = directory / "src"
                print(
                    f"cd '{Path.cwd()}'; "
                    f"PYTHONPATH='{search_path}' doctest '{path}' -v"
                )
                option_args = (
                    ["-o", ",".join(options)] if options else []
                )
                subprocess.run(
                    ["python3", "-m", "doctest", "-v"] 
                        + option_args + [str(path)],
                    cwd=directory,
                    env={"PYTHONPATH": str(search_path)},
                ) 

此示例处理一个任意目录路径列表,在这些目录中运行doctest工具对 Markdown 文件进行处理。让我们详细查看每个参数的定义:

  • 第一个参数,output,是一个用于写入输出的打开文件。

  • directories 参数将接受所有非关键字参数。这些都应该都是 Path() 对象。

  • 仅关键字参数verbose告诉我们是否要在处理每个文件时打印信息。

  • 最后,我们可以将任何其他关键字作为处理特殊文件的名称。四个名称——output、directories、verbose 和 stems——实际上是特殊文件名,不能进行特殊处理。任何其他关键字参数都将收集到stems字典中,并且这些名称将被单独选出进行特殊处理。具体来说,如果一个文件基名列出的值为"SKIP",则该文件将不会被测试。如果值为"ellipsis",则将提供一个特殊的选项标志给 doctest。

我们创建了一个内部辅助函数,log(),它只会在verbose参数被设置时打印消息。这个函数通过将此功能封装在单个位置来保持代码的可读性。

最外层的 with 语句将所有通常发送到 sys.stdout 的输出重定向到所需的文件。这使得我们可以从 print() 函数中收集单个日志。for 语句检查收集到 directories 参数中的所有位置参数值。每个目录都使用 glob() 方法进行检查,以定位任何子目录中的所有 *.md 文件。

文件的主名是指不带路径或后缀的名称。所以 ch_03/docs/examples.md 的主名为 examples。如果将主名用作关键字参数,该参数的值提供了关于具有该特定主名文件要执行的操作的额外细节。例如,如果我们提供关键字参数 examples='SKIP',这将填充 **stems** 字典,并且任何主名为 examples 的文件都将被跳过。

我们使用 subprocess.run() 是因为 doctest 处理本地目录的方式。当我们想在多个不同的目录中运行 doctest 时,似乎最简单的方法是首先设置当前工作目录 (cwd),然后再运行 doctest。

在常见情况下,此函数可以如下调用:

doctest_everything(
    sys.stdout,
    Path.cwd() / "ch_02",
    Path.cwd() / "ch_03",
) 

此命令会在这两个目录中查找所有*.md文件并运行 doctest。输出将显示在控制台上,因为我们已将sys.stdout重定向回sys.stdout。由于verbose参数默认值为False,因此产生的输出会非常少。

如果我们想要收集详细的输出,我们可以通过以下命令来实现:

doctest_log = Path("doctest.log")
with doctest_log.open('w') as log:
    doctest_everything(
        log,
        Path.cwd() / "ch_04",
        Path.cwd() / "ch_05",
        verbose=True
    ) 

这个测试在两个目录中的文件,并告诉我们它在做什么。注意,在这个例子中不能将verbose指定为一个位置参数;我们必须将其作为关键字参数传递。否则,Python 会认为它是在*directories列表中的另一个Path

如果我们想更改列表中选定文件的处理方式,我们可以传递额外的关键字参数,如下所示:

doctest_everything(
    sys.stdout,
    Path.cwd() / "ch_02",
    Path.cwd() / "ch_03",
    examples="ELLIPSIS",
    examples_38="SKIP",
    case_study_2="SKIP",
    case_study_3="SKIP",
) 

这将测试两个目录,但由于我们没有指定verbose,所以不会显示任何输出。这将对任何具有examples步骤的文件应用doctest --ellipsis选项。同样,任何以examples_38case_study_2case_study_3为根名的文件都将被跳过。

由于我们可以选择任何名称,并且它们都将被收集到stems参数的值中,我们可以利用这种灵活性来匹配目录结构中文件的名称。当然,Python 标识符有一些限制,它们与操作系统文件名不匹配,这使得这并不完美。然而,这确实展示了 Python 函数参数的惊人灵活性。

解包参数

还有一个涉及位置参数和关键字参数的巧妙技巧。我们在之前的例子中使用过它,但解释永远不会太晚。给定一个值列表或字典,我们可以将一系列值传递给函数,就像它们是正常的位置参数或关键字参数一样。看看下面的代码:

>>> def show_args(arg1, arg2, arg3="THREE"): 
...     return f"{arg1=}, {arg2=}, {arg3=}" 

该函数接受三个参数,其中一个参数有默认值。但是当我们有一个包含三个参数值的列表时,我们可以在函数调用内部使用*运算符来将其解包为三个参数。

这是我们使用*some_args来提供一个包含三个元素的迭代器运行时的样子:

>>> some_args = range(3) 
>>> show_args(*some_args)
'arg1=0, arg2=1, arg3=2' 

*some_args的值必须与位置参数定义相匹配。因为arg3有一个默认值,使其成为可选参数,所以我们可以提供两个或三个值。

如果我们有一个参数字典,我们可以使用**语法来解包字典,为关键字参数提供参数值。它看起来是这样的:

>>> more_args = { 
...        "arg1": "ONE", 
...        "arg2": "TWO"}
>>> show_args(**more_args)
"arg1='ONE', arg2='TWO', arg3='THREE'" 

这通常在将用户输入或外部来源(例如,网页或文本文件)收集的信息映射到函数或方法调用时很有用。我们不是将外部数据源分解成单个关键字参数,而是直接从字典键提供关键字参数。像show_args(arg1=more_args['arg1'], arg2=more_args['arg2'])这样的表达式似乎是一种容易出错的将参数名与字典键匹配的方法。

这种解包语法也可以用在函数调用之外的一些区域。本章前面“变量参数列表”部分展示的Options类,其__init__()方法看起来是这样的:

def __init__(self, **kwargs: Any) -> None:
    super().__init__(self.default_options)
    self.update(kwargs) 

做这件事的一个更加简洁的方法是将这两个字典这样展开:

def __init__(self, **kwargs: Any) -> None:
    super().__init__({**self.default_options, **kwargs}) 

表达式 {**self.default_options, **kwargs} 通过将每个字典解包成关键字参数,然后将它们组装成一个最终的字典来合并字典。由于字典是从左到右按顺序解包的,因此生成的字典将包含所有默认选项,其中任何 kwarg 选项将替换一些键。以下是一个示例:

>>> x = {'a': 1, 'b': 2}
>>> y = {'b': 11, 'c': 3}
>>> z = {**x, **y}
>>> z
{'a': 1, 'b': 11, 'c': 3} 

这个字典解包是**操作符将字典转换为函数调用命名参数的方式的一个便捷结果。

在探讨了我们可以如何为函数提供参数值的各种复杂方法之后,我们需要对函数的概念进行更广泛的思考。Python 将函数视为一种“可调用”的对象。这意味着函数是对象,高阶函数可以接受函数作为参数值,并返回函数作为结果。

函数也是对象

在许多情况下,我们都希望传递一个简单的对象来执行某个动作。本质上,我们希望的是一个可调用的函数对象。这通常在事件驱动编程中最为常见,例如图形工具包或异步服务器;我们将在第十一章“常见设计模式”和第十二章“高级设计模式”中看到一些使用它的设计模式。

在 Python 中,我们不需要在类定义中包裹这样的方法,因为函数本身就是对象!我们可以在函数上设置属性(尽管这不是一个常见的活动),并且我们可以传递它们以便在稍后调用。它们甚至还有一些可以直接访问的特殊属性。

这里还有一个人为设计的例子,有时会被用作面试问题:

>>> def fizz(x: int) -> bool:
...     return x % 3 == 0
>>> def buzz(x: int) -> bool:
...     return x % 5 == 0
>>> def name_or_number(
...         number: int, *tests: Callable[[int], bool]) -> None:
...     for t in tests:
...         if t(number):
...             return t.__name__
...     return str(number)
>>> for i in range(1, 11):
...     print(name_or_number(i, fizz, buzz)) 

fizz()buzz() 函数检查它们的参数 x 是否是另一个数的精确倍数。这依赖于模运算符的定义:如果 x 是 3 的倍数,那么 3 除以 x 没有余数。有时数学书中会说 图片。在 Python 中,我们说 x % 3 == 0

name_or_number() 函数使用任意数量的测试函数,这些函数作为 tests 参数的值提供。for 语句将 tests 集合中的每个函数分配给变量 t,然后使用数字参数的值评估该变量。如果函数的值为真,则结果为该函数的名称。

这就是当我们将此函数应用于一个数字和另一个函数时,它的样子:

>>> name_or_number(1, fizz)
'1'
>>> name_or_number(3, fizz)
'fizz'
>>> name_or_number(5, fizz)
'5' 

在每种情况下,tests参数的值是(fizz,),一个只包含fizz函数的元组。name_or_number()函数评估t(number),其中tfizz()函数。当fizz(number)为真时,返回的值是函数的__name__属性值——即'fizz'字符串。函数名在运行时作为函数的一个属性可用。

如果我们提供多个函数呢?每个函数都会应用到这个数字上,直到其中一个函数返回为真:

>>> name_or_number(5, fizz, buzz)
'buzz' 

顺便说一下,这并不完全正确。对于像 15 这样的数字,会发生什么?它是fizz还是buzz,或者两者都是?因为两者都是,所以在name_or_number()函数中需要做一些工作来收集所有真正函数的所有名称。这听起来像是一个很好的练习。

我们可以增加我们特殊函数的列表。我们可以定义bazz()对于七的倍数返回 true。这同样听起来像是一个不错的练习。

如果我们运行这段代码,我们可以看到我们能够将两个不同的函数传递给我们的name_or_number()函数,并且为每个函数得到不同的输出:

>>> for i in range(1, 11):
...     print(name_or_number(i, fizz, buzz))
1
2
fizz
4
buzz
fizz
7
8
fizz
buzz 

我们可以使用 t(number) 将我们的函数应用于一个参数值。我们能够通过 t.__name__ 获取函数的 __name__ 属性的值。

函数对象和回调函数

函数作为顶级对象的事实通常被用来在稍后执行时传递它们,例如,当满足某个条件时。回调作为构建用户界面的一部分很常见:当用户点击某个东西时,框架可以调用一个函数,以便应用程序代码可以创建一个视觉响应。对于像文件传输这样长时间运行的任务,传输库通常会在已传输的字节数上回调应用程序的状态,这有助于显示状态温度计来显示状态。

让我们使用回调函数构建一个事件驱动的定时器,以便事物能够在预定的时间间隔内发生。这对于基于小型 CircuitPython 或 MicroPython 设备的物联网(IoT)应用来说可能很有用。我们将将其分为两部分:一个任务和一个执行存储在任务中的函数对象的调度器:

from __future__ import annotations
import heapq
import time
from typing import Callable, Any, List, Optional
from dataclasses import dataclass, field
Callback = Callable[[int], None]
@dataclass(frozen=True, order=True)
class Task:
    scheduled: int
    callback: Callback = field(compare=False)
    delay: int = field(default=0, compare=False)
    limit: int = field(default=1, compare=False)
    def repeat(self, current_time: int) -> Optional["Task"]:
        if self.delay > 0 and self.limit > 2:
            return Task(
                current_time + self.delay,
                cast(Callback, self.callback),  # type: ignore [misc]
                self.delay,
                self.limit - 1,
            )
        elif self.delay > 0 and self.limit == 2:
            return Task(
                current_time + self.delay,
                cast(Callback, self.callback),  # type: ignore [misc]
            )
        else:
            return None 

Task 类的定义包含两个必填字段和两个可选字段。必填字段 scheduledcallback 提供了一个预定的时间去做某事以及一个回调函数,即在预定时间要执行的操作。预定时间有一个 int 类型的提示;时间模块可以使用浮点时间,以实现超精确的操作。我们将忽略这些细节。此外,mypy 工具非常清楚整数可以被强制转换为浮点数,所以我们不需要对数字类型过于挑剔精确。

回调函数具有Callable[[int], None]的提示。这总结了函数定义应该看起来像什么。一个回调函数的定义应该看起来像def some_name(an_arg: int) -> None:。如果它不匹配,mypy将提醒我们回调函数的定义与类型提示指定的合约之间可能存在不匹配。

repeat() 方法可以返回可能重复的任务。它计算任务的新时间,提供对原始函数对象的引用,并可能提供后续的延迟和改变的限制。改变的限制将计算重复次数,使计数达到零,为我们提供处理的上限定义;确保迭代将终止总是令人愉快的。

# type: ignore [misc] 注释存在是因为这里有一个让 mypy 感到困惑的功能。当我们使用 self.callbacksomeTask.callback() 这样的代码时,它看起来像是一个普通的方法。Scheduler 类中的代码不会将其作为普通方法使用;它将用作对完全定义在类外部的单独函数的引用。Python 中内置的假设是:一个 Callable 属性必须是一个方法,这意味着该方法必须有一个 "self" 变量。在这种情况下,可调用对象是一个单独的函数。反驳这个假设的最简单方法是通过静默 mypy 对此行代码的检查。另一种方法是将其赋值给另一个非 self 变量,使其看起来像是一个外部函数。

这里是使用这些Task对象及其相关回调函数的Scheduler类的整体结构:

class Scheduler:
    def __init__(self) -> None:
        self.tasks: List[Task] = []
    def enter(
        self,
        after: int,
        task: Callback,
        delay: int = 0,
        limit: int = 1,
    ) -> None:
        new_task = Task(after, task, delay, limit)
        heapq.heappush(self.tasks, new_task)
    def run(self) -> None:
        current_time = 0
        while self.tasks:
            next_task = heapq.heappop(self.tasks)
            if (delay := next_task.scheduled - current_time) > 0:
               time.sleep(next_task.scheduled - current_time)
            current_time = next_task.scheduled
            next_task.callback(current_time)  # type: ignore [misc]
            if again := next_task.repeat(current_time):
                heapq.heappush(self.tasks, again) 

Scheduler类的核心特性是一个堆队列,这是一个按照特定顺序排列的Task对象列表。我们在第七章三种队列类型部分提到了堆队列,指出由于优先级排序,它不适合那个用例。然而,在这里,堆数据结构利用了列表的灵活性来保持项目顺序,而不需要整个列表的完整排序开销。在这种情况下,我们希望按照项目需要执行的时间顺序来保持项目顺序:“先来先服务”的顺序。当我们向堆队列中推入某物时,它会以保持时间顺序的方式插入。当我们从队列中弹出下一个项目时,堆可能会进行调整,以保持队列前面的项目是优先的。

Scheduler 类提供了一个 enter() 方法来将新任务添加到队列中。此方法接受一个表示在执行回调任务之前等待间隔的 delay 参数,以及 task 函数本身,这是一个将在正确时间执行的函数。此 task 函数应适合上面定义的 Callback 类型的提示。

没有运行时检查来确保回调函数确实符合类型提示。这仅由 mypy 进行检查。更重要的是,afterdelaylimit 参数应该有一些验证检查。例如,afterdelay 的负值应该引发一个 ValueError 异常。有一个特殊的方法名,__post_init__(),数据类可以使用它来进行验证。这个方法在 __init__() 之后被调用,可以用于其他初始化、预计算派生值或验证值的组合是否合理。

run()方法按照项目应该执行的时间顺序从队列中移除项目。如果我们已经到达(或超过)了所需时间,那么计算出的delay值将是零或负数,我们不需要等待;我们可以立即执行回调。如果我们还在所需时间之前,那么我们需要睡眠直到时间到来。

在指定时间,我们将更新current_time变量中的当前时间。我们将调用Task对象中提供的回调函数。然后我们将查看Task对象的repeat()方法是否会将另一个重复任务添加到队列中。

这里需要注意的重要事项是那些接触回调函数的行。该函数就像任何其他对象一样被传递,而SchedulerTask类永远不会知道或关心该函数的原始名称或定义位置。当需要调用该函数时,Scheduler只需使用new_task.callback(current_time)来评估该函数。

这里是一组用于测试Scheduler类的回调函数:

import datetime
def format_time(message: str) -> None:
    now = datetime.datetime.now()
    print(f"{now:%I:%M:%S}: {message}")
def one(timer: float) -> None:
    format_time("Called One")
def two(timer: float) -> None:
    format_time("Called Two")
def three(timer: float) -> None:
    format_time("Called Three")
class Repeater:
    def __init__(self) -> None:
        self.count = 0
    def four(self, timer: float) -> None:
        self.count += 1
        format_time(f"Called Four: {self.count}") 

这些函数都符合Callback类型提示的定义,所以它们将很好地工作。Repeater类定义中有一个名为four()的方法,它符合该定义。这意味着Repeater的一个实例也可以被使用。

我们定义了一个方便的实用函数,format_time(),用于编写常用信息。它使用格式字符串语法将当前时间添加到信息中。三个小的回调函数输出当前时间,并显示哪个回调已被触发。

这里是一个创建调度器并将回调函数加载到其中的示例:

s = Scheduler()
s.enter(1, one)
s.enter(2, one)
s.enter(2, two)
s.enter(4, two)
s.enter(3, three)
s.enter(6, three)
repeater = Repeater()
s.enter(5, repeater.four, delay=1, limit=5)
s.run() 

这个例子让我们看到多个回调如何与计时器交互。

Repeater 类演示了方法也可以用作回调,因为它们实际上是绑定到对象上的函数。使用 Repeater 类实例的方法就像使用任何其他函数一样。

输出显示事件按照预期的顺序执行:

01:44:35: Called One
01:44:36: Called Two
01:44:36: Called One
01:44:37: Called Three
01:44:38: Called Two
01:44:39: Called Four: 1
01:44:40: Called Three
01:44:40: Called Four: 2
01:44:41: Called Four: 3
01:44:42: Called Four: 4
01:44:43: Called Four: 5 

注意,某些事件具有相同的预定运行时间。例如,在 2 秒后预定,回调函数one()two()都被定义。它们都在 01:44:36 运行。没有规则来决定如何解决这两个函数之间的平局。调度器的算法是从堆队列中弹出一个项目,执行回调函数,然后从堆队列中弹出一个另一个项目;如果它们具有相同的执行时间,那么评估下一个回调函数。这两个回调函数哪个先执行,哪个后执行是堆队列的实现细节。如果你的应用程序中顺序很重要,你需要一个额外的属性来区分同时预定的事件;通常使用优先级数字来完成这个任务。

因为 Python 是一种动态语言,类的内含内容不是固定的。我们有更多高级的编程技术可以使用。在下一节中,我们将探讨如何更改类的属性。

使用函数来修补类

在前一个示例中,我们注意到mypy假设了Callable属性callbackTask类的一个方法。这可能导致一个可能令人困惑的mypy错误信息,“无效的自变量Task到属性函数callback的类型“Callable[[int], None]””。在前一个示例中,可调用属性明确不是一个方法。

存在混淆意味着一个可调用的属性可以被当作一个类的成员方法。由于我们通常可以向类提供额外的成员方法,这意味着我们可以在运行时添加额外的成员方法。

这是否意味着我们应该这样做?或许这并不是一个好主意,除非在非常特殊的情况下。

可以向一个实例化的对象添加或更改一个函数,如下所示。首先,我们将定义一个类,A,并包含一个方法,show_something()

>>> class A:
...     def show_something(self):
...         print("My class is A")
>>> a_object = A()
>>> a_object.show_something()
My class is A 

这看起来是我们预期的样子。我们在类的实例上调用该方法,并查看print()函数的结果。现在,让我们修复这个对象,替换show_something()方法:

>>> def patched_show_something():
...     print("My class is NOT A")
>>> a_object.show_something = patched_show_something
>>> a_object.show_something()
My class is NOT A 

我们对该对象进行了修复,引入了一个可调用的函数属性。当我们使用a_object.show_something()时,规则是首先查找本地属性,然后查找类属性。正因为如此,我们使用可调用属性为A类的这个实例创建了一个本地化的修复补丁。

我们可以创建该类的另一个实例,未修补的,并查看它仍然使用的是类级别的方法

>>> b_object = A()
>>> b_object.show_something()
My class is A 

如果我们可以修补一个对象,你可能会想我们也可以修补类。我们可以。在类上替换方法而不是对象是可能的。如果我们更改类,我们必须考虑到将隐式提供给类中定义的方法的self参数。

需要注意的是,修补一个类将会改变该对象所有实例的方法,即使这些实例已经实例化。显然,以这种方式替换方法可能会既危险又难以维护。阅读代码的人会看到调用了某个方法,并会在原始类中查找该方法。但原始类中的方法并不是被调用的那个方法。弄清楚究竟发生了什么可能会变成一个棘手、令人沮丧的调试过程。

有一个基本的假设需要支撑我们写下的每一件事。这是一种对于理解软件工作原理至关重要的契约:

模块文件中人们看到的代码必须是正在运行的代码。

打破这个假设会真正让人感到困惑。我们之前的例子展示了一个名为A的类,它有一个名为show_something()的方法,其行为与类A的定义明显不同。这可能会导致人们不信任你的应用程序软件。

这种技术虽然有其用途。通常,在运行时替换或添加方法(称为猴子补丁)被用于自动化测试。如果在测试客户端-服务器应用程序时,我们可能不想在测试客户端时实际连接到服务器;这可能会导致意外转账或尴尬的测试邮件发送给真实的人。

相反,我们可以设置我们的测试代码来替换对象发送请求到服务器的一些关键方法,以便它只记录这些方法已被调用。我们将在第十三章面向对象程序的测试中详细讲解这一点。在测试的狭隘领域之外,猴子补丁通常被视为设计不佳的标志。

这有时被正当化为修复导入组件中的错误的一部分。如果这样做,补丁需要明确标记,以便任何查看代码的人都知道正在解决哪个错误以及何时可以移除修复。我们称这种代码为技术债务,因为使用猴子补丁的复杂性是一种负债。

在本例中我们班级的情况下,一个具有独特show_something()实现方式的A的子类,会比修补方法使事情更加清晰。

我们可以使用类定义来创建可以像函数一样使用的对象。这为我们使用小型、独立的函数构建应用程序提供了另一条途径。

可调用对象

正如函数是可以设置属性的实体一样,可以创建一个可以像函数一样调用的对象。任何对象都可以通过给它一个接受所需参数的__call__()方法来使其可调用。让我们通过使其可调用,使从计时器示例中的Repeater类更容易使用,如下所示:

class Repeater_2:
    def __init__(self) -> None:
        self.count = 0
    def __call__(self, timer: float) -> None:
        self.count += 1
        format_time(f"Called Four: {self.count}") 

这个例子与之前的类并没有太大的不同;我们只是将repeater函数的名称改为了__call__,并将对象本身作为可调用对象传递。这是怎么工作的呢?我们可以通过以下交互式操作来查看一个示例:

class Repeater_2:
    def __init__(self) -> None:
        self.count = 0
    def __call__(self, timer: float) -> None:
        self.count += 1
        format_time(f"Called Four: {self.count}")
rpt = Repeater_2() 

到目前为止,我们已经创建了一个可调用的对象,rpt()。当我们评估类似 rpt(1) 的内容时,Python 会为我们评估 rpt.__call__(1),因为定义了 __call__() 方法。它看起来是这样的:

>>> rpt(1)
04:50:32: Called Four: 1
>>> rpt(2)
04:50:35: Called Four: 2
>>> rpt(3)
04:50:39: Called Four: 3 

这里是一个使用Repeater_2类定义的这种变体以及Scheduler对象的示例:

s2 = Scheduler()
s2.enter(5, Repeater_2(), delay=1, limit=5)
s2.run() 

注意,当我们调用enter()方法时,我们将Repeater_2()的值作为参数传递。这两个括号正在创建类的新实例。创建的实例具有__call__()方法,该方法可以被Scheduler使用。当与可调用对象一起工作时,创建类的实例是至关重要的;是对象可调用,而不是类。

到目前为止,我们已经看到了两种不同类型的可调用对象:

  1. Python 的函数,通过 def 语句构建。

  2. 可调用对象。这些是定义了__call__()方法的类的实例。

通常情况下,简单的def语句就足够了。然而,可调用对象可以做一些普通函数做不到的事情。我们的Repeater_2类会计算它被使用的次数。一个普通函数是无状态的。可调用对象可以是状态的。这需要谨慎使用,但某些算法可以通过在缓存中保存结果来显著提高性能,而可调用对象是保存函数结果以避免重新计算的一个很好的方法。

文件输入输出

我们迄今为止的例子中,涉及文件系统的操作都是完全在文本文件上进行的,并没有太多考虑底层发生了什么。操作系统将文件表示为一系列字节,而不是文本。我们将在第九章“字符串、序列化和文件路径”中深入探讨字节和文本之间的关系。现在,请注意,从文件中读取文本数据是一个相当复杂的过程,但 Python 在幕后为我们处理了大部分工作。

文件的概念早在有人提出面向对象编程这一术语之前就已经存在了。然而,Python 将操作系统提供的接口封装在一个甜美的抽象层中,这使得我们可以与文件(或类似文件的对象,即鸭子类型)对象一起工作。

产生混淆的原因是因为操作系统文件和 Python 文件对象通常都被称为“文件”。很难做到极度谨慎,并且在每个对术语文件的引用周围都加上适当的环境来区分磁盘上的字节和从 Python 文件对象访问这些字节的操作系统库。

Python 的 open() 内置函数用于打开操作系统文件并返回一个 Python 文件对象。对于从文件中读取文本,我们只需将文件名传递给函数即可。操作系统文件将以读取模式打开,并且使用平台默认编码将字节转换为文本。

文件名 "name" 可以是相对于当前工作目录的相对路径。它也可以是一个绝对路径,从目录树的根开始。文件名是从文件系统根到文件路径的末尾。基于 Linux 的文件系统中,根是 "/"。在 Windows 中,每个设备上都有一个文件系统,所以我们使用更复杂的名称,如 "C:\"。虽然 Windows 使用 \ 来分隔文件路径的元素,但 Python 的 pathlib 一致地使用 "/",在需要时将字符串转换为特定于操作系统的名称。

当然,我们并不总是想读取文件;通常我们希望向其中写入数据!为了打开文件进行写入,我们需要将mode参数作为open()函数的第二个位置参数传递,其值为"w"

>>> contents = "Some file contents\n"
>>> file = open("filename.txt", "w")
>>> file.write(contents)
>>> file.close() 

我们也可以将值 "a" 作为模式参数提供,以 追加 的方式添加到文件末尾,而不是完全覆盖现有文件内容。

这些内置将字节转换为文本的包装器的文件很棒,但如果我们要打开的文件是一个图片、可执行文件或其他二进制文件,那就非常不方便了,不是吗?

要打开一个二进制文件,我们需要修改模式字符串以追加 "b"。因此,"wb" 将打开一个用于写入字节的文件,而 "rb" 允许我们读取它们。它们的行为类似于文本文件,但不会自动将文本编码为字节。当我们读取这样的文件时,它将返回 bytes 对象而不是 str,当我们向其写入时,如果我们尝试传递一个文本对象,它将失败。

这些用于控制文件打开方式的模式字符串相当晦涩,既不符合 Python 风格,也不是面向对象的。然而,由于它们基于备受尊敬的标准 I/O 库,因此与几乎所有其他编程语言都保持一致。文件 I/O 是操作系统必须处理的基本任务之一,所有编程语言都必须使用相同的系统调用来与操作系统进行通信。

由于所有文件实际上都是字节,因此重要的是要意识到读取文本意味着字节被转换为文本字符。大多数操作系统使用一种称为 UTF-8 的编码来表示 Python 作为字节使用的 Unicode 字符。在某些情况下,可能会使用其他编码,并且当我们打开使用不常见编码的文本文件时,可能需要提供一个encoding='cp1252'的参数值。

一旦文件被打开用于读取,我们可以调用read()readline()readlines()中的任何一种方法来获取文件的内容。read()方法返回整个文件的内容作为一个strbytes对象,这取决于模式中是否有"b"。请注意,不要在没有参数的情况下对大文件使用此方法。你不想知道如果尝试将如此多的数据加载到内存中会发生什么!

从文件中读取固定数量的字节也是可能的;我们通过传递一个整数参数给read()方法,来描述我们想要读取的字节数。下一次调用read()将加载下一个字节序列,依此类推。我们可以在while语句中这样做,以分块读取整个文件。

一些文件格式为我们定义了整齐划分的块。日志模块可以将日志对象作为字节传输。读取这些字节的过程必须首先读取四个字节以确定日志消息的大小。大小值定义了还需要读取多少字节才能收集到一个完整的信息。

readline() 方法从文件中返回单行(每行以换行符、回车符或两者兼而有之结束,具体取决于创建文件时使用的操作系统)。我们可以反复调用它来获取额外的行。复数形式的 readlines() 方法返回文件中所有行的列表。与 read() 方法类似,在处理非常大的文件时使用它并不安全。这两个方法在文件以 bytes 模式打开时也能工作,但这只有在我们要解析具有合理位置换行符的类似文本数据时才有意义。例如,图像或音频文件中不会有换行符(除非换行字节恰好代表某个像素或声音),因此应用 readline() 就没有意义。

为了提高可读性,并且避免一次性将大文件读入内存,通常最好使用for语句从文件对象中逐行读取。对于文本文件,它会逐行读取,每次一行,我们可以在for语句内部进行处理。对于二进制文件,这同样适用,但通常不太可能二进制文件遵循文本文件的规则。对于二进制文件,最好使用read()方法读取固定大小的数据块,并传递一个参数来指定要读取的最大字节数。

读取文件可能看起来像这样:

with open("big_number.txt") as input:
    for line in input:
        print(line) 

向文件写入同样简单;文件对象的write()方法会将一个字符串(或字节,对于二进制数据)对象写入文件。它可以被反复调用以写入多个字符串,一个接一个。writelines()方法接受一个字符串序列,并将迭代值中的每个值写入文件。writelines()方法在序列中的每个项目后不会添加一个新行。它基本上是一个命名不佳的便利函数,用于在不显式使用for语句迭代的情况下写入字符串序列的内容。

将内容写入文件可能看起来像这样:

results = str(2**2048)
with open("big_number.txt", "w") as output:
    output.write("# A big number\n")
    output.writelines(
        [
            f"{len(results)}\n",
            f"{results}\n"
        ]
    ) 

明确的换行符 \n 是在文件中创建行断点的必需品。只有 print() 函数会自动添加换行符。因为 open() 函数是内置的,所以进行简单的文件输入输出操作不需要导入。

最后,我们真的指的是最后,我们来到了close()方法。当我们在完成读取或写入文件后,应该调用此方法,以确保任何缓冲的写入都被写入磁盘,文件已经被适当清理,以及所有与文件关联的资源都被释放回操作系统。在像网络服务器这样的长时间运行过程中,明确地清理并处理好自己的事务非常重要。

每个打开的文件都是一个上下文管理器,可以通过with语句使用。如果我们这样使用文件,close()方法将在上下文结束时自动执行。我们将在下一节中详细探讨如何使用上下文管理器来控制操作系统资源。

将其置于上下文中

完成文件操作后关闭文件的需求可能会让我们的代码变得相当丑陋。因为在文件输入输出过程中,任何时刻都可能发生异常,所以我们应当将所有对文件的调用都包裹在try...finally语句中。文件应在finally语句中关闭,无论输入输出操作是否成功。这并不太符合 Python 的风格。当然,还有更优雅的方式来处理这个问题。

Python 的文件对象也是 上下文管理器。通过使用 with 语句,上下文管理方法确保即使在抛出异常的情况下文件也会被关闭。使用 with 语句后,我们不再需要显式地管理文件的关闭。

下面是文件导向的with语句在实际中的样子:

>>> source_path = Path("requirements.txt")
>>> with source_path.open() as source_file:
...     for line in source_file:
...         print(line, end='') 

Path对象的open方法返回一个文件对象,该对象具有__enter__()__exit__()方法。通过as子句,返回的对象被分配给名为source_file的变量。我们知道当代码返回到外部缩进级别时,文件将被关闭,即使发生异常也是如此。(我们将在第九章字符串序列化和文件路径中更详细地了解Path对象。现在,我们将使用它们来打开我们的文件。)

with 语句被广泛使用,通常在需要将启动和清理代码连接起来,尽管可能会出现任何错误的情况下。例如,urlopen 调用返回一个上下文对象,该对象可以在 with 语句中使用来清理套接字,当我们完成时。threading 模块中的锁可以在 with 语句的主体执行完毕后自动释放锁。

最有趣的是,由于任何具有适当特殊方法的对象都可以成为上下文管理器,可以被with语句使用,我们可以在自己的框架中使用它。例如,记住字符串是不可变的,但有时你需要从多个部分构建一个字符串。为了效率,这通常是通过将组件字符串存储在列表中并在最后将它们连接起来来完成的。让我们扩展列表类来创建一个简单的上下文管理器,它允许我们构建一个字符序列,并在退出时自动将其转换为字符串:

>>> class StringJoiner(list): 
...     def __enter__(self): 
...         return self 
...     def __exit__(self, exc_type, exc_val, exc_tb): 
...         self.result = "".join(self) 

此代码将上下文管理器所需的两个特殊方法添加到它继承的list类中。__enter__()方法执行任何所需的设置代码(在这种情况下,没有)然后返回将在with语句中的as之后分配给变量的对象。通常,就像我们在这里所做的那样,这将是上下文管理器对象本身。__exit__()方法接受三个参数。在正常情况下,这些参数都被赋予None的值。然而,如果在with块内部发生异常,它们将被设置为与异常的类型、值和回溯相关的值。这允许__exit__()方法执行可能需要的任何清理代码,即使发生了异常。在我们的例子中,我们通过连接字符串中的字符来创建一个结果字符串,无论是否抛出异常。在某些情况下,可能需要进行更复杂的清理来响应异常情况。

正式来说,类型提示看起来是这样的:

from typing import List, Optional, Type, Literal
from types import TracebackType
class StringJoiner(List[str]):
    def __enter__(self) -> "StringJoiner":
        return self
    def __exit__(
        self,
        exc_type: Optional[Type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> Literal[False]:
        self.result = "".join(self)
        return False 

注意,我们已经定义了__exit__()方法始终返回False。返回值False确保在上下文中抛出的任何异常都会被看到。这是典型行为。然而,我们可以通过返回True来静默这些异常。这意味着将类型提示从Literal[False]更改为bool,并且当然——检查异常细节以确定是否应该静默。例如,我们可以检查exc_type以确定它是否为StopIteration,如下所示:

return exc_type == StopIteration 

这将仅静默StopIteration异常,并允许所有其他异常在上下文外部传播。关于异常的复习,请参阅第四章意料之外

虽然这是我们能够编写的最简单的上下文管理器之一,并且其有用性值得怀疑,但它确实可以与with语句一起工作。看看它是如何运作的吧:

>>> with StringJoiner("Hello") as sj:
...     sj.append(", ")
...     sj.extend("world")
...     sj.append("!")
>>> sj.result
'Hello, world!' 

此代码通过追加和扩展初始字符列表来构建一个字符串。当with语句完成上下文缩进语句后,会调用__exit__()方法,此时result属性在StringJoiner对象sj上变得可用。然后我们打印这个值以查看生成的字符串。请注意,__exit__()总是会被执行,即使有异常发生。以下示例在上下文中引发异常,但最终结果仍然会被构建:

>>> with StringJoiner("Partial") as sj:
...     sj.append(" ")
...     sj.extend("Results")
...     sj.append(str(2 / 0))
...     sj.extend("Even If There's an Exception")
Traceback (most recent call last):
  ...
  File "<doctest examples.md[60]>", line 3, in <module>
    sj.append(str(2 / 0))
ZeroDivisionError: division by zero
>>> sj.result
'Partial Results' 

零除引发了异常。将此异常附加到sj变量上的语句失败,并且上下文中的剩余语句没有执行。上下文的__exit__()方法被执行,并带有异常的详细信息。__exit__()方法计算了result属性,并允许异常传播。sj变量具有部分结果。

我们也可以从一个简单的函数构建上下文管理器。这依赖于迭代器的一个特性,我们将在第十章“迭代器模式”中深入探讨。目前,只需知道yield语句会产生一系列结果中的第一个结果。由于 Python 中迭代器的工作方式,我们可以编写一个函数,通过单个yield语句将__enter__()处理和__exit__()处理分开。

字符串连接器的例子是一个有状态的上下文管理器,使用函数可以干净地分离改变状态的对象和执行状态改变的上下文管理器。

这里是一个实现了部分功能的修改后的"字符串连接器"对象。它包含字符串以及最终的result属性:

class StringJoiner2(List[str]):
    def __init__(self, *args: str) -> None:
        super().__init__(*args)
        self.result = "".join(self) 

与此分开的是,有一个上下文管理器包含了一些进入和退出上下文的步骤:

from contextlib import contextmanager
from typing import List, Any, Iterator
@contextmanager
def joiner(*args: Any) -> Iterator[StringJoiner2]:
    string_list = StringJoiner2(*args)
    try:
        yield string_list
    finally:
        string_list.result = "".join(string_list) 

在进入上下文之前执行yield之前的步骤。yield语句中的表达式被分配给with语句中的as变量。当上下文正常结束时,处理yield之后的代码。try:语句的finally:子句将确保最终结果属性始终被设置,无论是否存在异常。由于try:语句没有显式匹配任何异常,它不会静默任何内容,异常将在包含的with语句外部可见。这和上面的StringJoiner示例行为相同;唯一的改变是将StringJoiner——一个上下文管理器类——替换为joiner

@contextmanager 装饰器用于在函数周围添加一些功能,使其工作起来像是一个上下文管理器类的定义。这使我们免去了定义同时包含 __enter__()__exit__() 方法的类的开销。在这种情况下,上下文管理涉及到的代码行数如此之少,以至于装饰过的函数似乎比一个更长且看起来更复杂的类更合适。

上下文管理器可以执行许多操作。我们之所以将它们与简单的文件操作放在一起介绍,是因为我们可以使用上下文管理器的一个重要场景就是在打开文件、数据库或网络连接时。任何涉及到外部、操作系统管理的资源的地方,我们都需要上下文管理器来确保无论我们的应用程序编程中发生什么错误,外部资源都能得到适当的释放。

每次我们处理文件时,总是将处理过程包裹在with语句中。

案例研究

虽然面向对象编程有助于封装特性,但这并非创建灵活、表达性强和简洁的应用程序的唯一途径。函数式编程强调功能设计和函数组合,而非面向对象设计。

在 Python 中,函数式设计通常涉及使用一些面向对象的技术。这是 Python 的一个优点:能够选择一组适当的设计工具来有效地解决问题。

我们通常用类及其各种关联来描述面向对象的设计。对于函数式设计,我们关注的是用于转换对象的函数。函数式设计可以紧密遵循数学实践。

在本案例研究的这一部分,我们将回顾分类器作为与类定义混合的功能的多个特性。我们将从纯面向对象的观点中抽身,采用一种混合视图。特别是,我们将仔细研究将数据分割成训练集和测试集的过程。

处理概述

第一章面向对象设计的初步分析中,确定了三个不同的过程用于收集训练数据、测试分类器以及实际进行分类。上下文图看起来是这样的:

图表描述自动生成

图 8.1:上下文图

我们可以将这些视为独立的函数来构建一些样本数据集合:

  1. 基于的“提供训练数据”用例的函数会将源数据转换成两个样本集合,一个是训练集,另一个是测试集。我们希望避免将测试集中的项目与训练集中的项目完全匹配,这对此过程产生了一些约束。我们可以将此视为从已知样本测试已知样本训练已知样本的映射。

  2. 基于的“设置参数并测试分类器”用例的函数会将一个超参数(即k值和距离算法)以及样本测试集转换成一个质量分数。我们可以将这视为从TestingKnownSample到正确或错误分类的映射,以及将结果缩减为一个单一值,表示测试中正确分类的数量。

  3. 基于使用案例“创建分类请求”的函数会将一个超参数(即k值和距离算法)以及单个样本转换为一个分类结果。

我们将分别查看这些函数。我们可以使用这些处理步骤来定义一种功能方法,为我们的应用程序构建一个替代模型。

分割数据

实际上,将数据分为两个子集可以通过一些过滤函数来定义。我们暂时避开 Python,专注于概念性的数学,以确保在深入代码之前逻辑完全正确。从概念上讲,我们有一对函数,,它们决定一个样本,,是用于测试,e,还是用于训练,r。这些函数用于将样本划分为两个子集。(如果测试和训练都不以 t 开头,我们可能会更容易找到名字。考虑用于评估和测试,以及用于运行真正的分类可能会有所帮助。)

如果这两个函数是互斥的,那就更简单了,。(我们将使用 ¬ 而不是较长的 not。)如果它们是彼此的适当逆函数,这意味着我们只需要定义这两个函数中的任意一个:

图片 1图片 2

如果上述语法不熟悉,它仅仅意味着训练集是来自源数据 S 中的所有项目,,其中 为真。测试集是来自源数据中 为假的所有项目。这种数学形式化可以帮助确保所有情况都得到适当的覆盖。

这个概念是一组样本的“理解”或“构建者”。我们可以相当直接地将数学理解转化为 Python 列表理解。我们将我们的概念函数 实现为 Python 函数,training()。我们还将索引值,i,作为单独的参数暴露给这个函数:

def training(s: Sample, i: int) -> bool:
    pass
training_samples = [
    TrainingKnownSample(s) 
    for i, s in enumerate(samples) 
    if training(s, i)]
test_samples = [
    TestingKnownSample(s) 
    for i, s in enumerate(samples) 
    if not training(s, i)] 

第十章迭代器模式中,我们将深入探讨这一点。目前,只需了解理解有三个部分:一个表达式,一个for子句和一个if条件。for子句提供值,实际上就是形式语句中的部分。if条件过滤值,实际上就是子句。最后的表达式s决定了什么被积累到结果列表对象中。

我们已经创建了一个TrainingKnownSample对象,作为源KnownSample实例的包装器。这利用了来自第七章Python 数据结构中的基于组合的设计。

我们可以使用索引值来划分数据。除法后的余数,即模数,可以用来将数据分成子集。例如,i % 5的值是一个从 0 到 4 的值。如果我们使用i % 5 == 0作为测试数据,将有 20%的值被选中。当i % 5 != 0时,这是剩余的 80%数据,将用于训练。

以下是一个不带[]包装器的列表推导。我们使用了list()函数来从生成器中消费项目并构建列表:

test_samples = list(
    TestingKnownSample(s) 
    for i, s in enumerate(samples) 
    if not training(s, i)) 

使用 []list() 的处理方式相同。有些人喜欢 list() 的清晰度,尽管它比 [] 更啰嗦。如果我们为列表类创建自己的扩展,那么找到 list(...) 比找到所有使用 [...] 的地方并区分列表构建器和其他 [] 的用法要简单一些。

重新思考分类

第二章Python 中的对象 中,我们探讨了处理与分类相关的状态变化的各种方法。这里有两个类似的过程,一个用于测试的 KnownSample 对象,另一个由用户进行分类的 UnknownSample 对象。流程图看起来很简单,但隐藏着一个重要的问题。

这是用户对未知样本的分类:

图表,示意图 描述自动生成

图 8.2:未知样本分类流程图

我们可以借鉴这个(进行一些微小的类变化)并用于测试。以下是一个处理测试目的分类的方法,它与未知样本处理过程相类似:

图表,示意图 描述自动生成

图 8.3:测试已知样本分类过程图

理想情况下,相同的代码可以在两种情况下使用,从而降低应用程序的整体复杂性。

当我们考虑对流程视图的不同替代方案时,这会导致逻辑视图的变化。这里是一个修订后的视图,将这些类视为不可变组合。我们添加了注释来建议在应用程序处理过程中何时创建这些对象。我们特别强调了两个需要仔细考虑的类:

图表描述自动生成

图 8.4:修订的逻辑视图

TestingKnownSampleTrainingKnownSample 类之间只有非常小的差异。它们没有引入新的属性或方法。以下是它们的差异:

  • TrainingKnownSample实例永远不会用于分类。

  • TestingKnownSampleUnknownSample实例用于分类和测试。我们将通过重新包装KnownSample实例到一个新的容器中,从TestingKnownSample对象创建一个ClassifiedKnownSample对象。这创建了一个更一致的定义集。

这个想法是,Hyperparameter 类的 classifier() 方法应该与两种类的对象一起工作,通过类型提示 Union[TestingKnownSample, UnknownSample] 来总结。这种提示可以帮助我们找出使用这些类不正确的应用代码。

这张图似乎捕捉了这些对象的使用方式。拥有这些细节信息可以导致更详细的类型提示,这些提示可以用来阐明我们的意图。

partition() 函数

我们可以定义多个版本的training()函数,将我们的数据分为 80/20、75/25 或 67/33 的分割:

def training_80(s: KnownSample, i: int) -> bool:
    return i % 5 != 0
def training_75(s: KnownSample, i: int) -> bool:
    return i % 4 != 0
def training_67(s: KnownSample, i: int) -> bool:
    return i % 3 != 0 

这里有一个函数,partition(),它接受一个training_xx()函数作为参数。training_xx()函数被应用于一个样本,以决定它是否是训练数据:

TrainingList = List[TrainingKnownSample]
TestingList = List[TestingKnownSample]
def partition(
    samples: Iterable[KnownSample], 
    rule: Callable[[KnownSample, int], bool]
) -> Tuple[TrainingList, TestingList]:
    training_samples = [
        TrainingKnownSample(s) 
        for i, s in enumerate(samples) if rule(s, i)
    ]
    test_samples = [
        TestingKnownSample(s) 
        for i, s in enumerate(samples) if not rule(s, i)
    ]
    return training_samples, test_samples 

我们构建了一个“高阶”函数,它接受另一个函数作为参数值。这是函数式编程的一个非常酷的特性,也是 Python 的一个核心组成部分。

这个partition()函数从数据源和一个函数中构建两个列表。这涵盖了简单的情况,我们并不关心将training列表中的值重复引入到testing列表中。

虽然这很简洁且表达清晰,但它有一个隐藏的成本。我们希望避免对数据进行两次检查。对于这个特定问题中已知的一小部分样本,处理并不特别昂贵。但我们在最初可能有一个生成器表达式来创建原始数据。由于我们只能消费一次生成器,我们希望避免创建大量数据的多份副本。

此外,我们希望避免分配与训练值恰好匹配的测试值。这变成了一个更复杂的问题。我们将在第十章迭代器模式中推迟讨论这个问题。

单次分区

我们可以在一次数据遍历中创建多个样本池。有几种方法;我们将展示一种具有更简单类型提示的方法。再次强调,这是一个函数,而不是完整的类定义。单个样本实例具有不同的类,但这个过程产生的是不同类的对象,更适合函数式风格。

想法是创建两个空的列表对象,一个用于训练,另一个用于测试。然后我们可以为每个列表分配特定的类型提示,并利用mypy来确保我们适当地使用这些列表:

def partition_1(
        samples: Iterable[KnownSample], 
        rule: Callable[[KnownSample, int], bool]
) -> Tuple[TrainingList, TestingList]:

    training: TrainingList = []
    testing: TestingList = []
    for i, s in enumerate(samples):
        training_use = rule(s, i)
        if training_use:
            training.append(TrainingKnownSample(s))
        else:
            testing.append(TestingKnownSample(s))
    return training, testing 

在这个 partition_1() 函数中,我们使用了 rule 函数来判断数据是否用于训练。我们期望在当前案例研究中定义的某个 training_xx() 函数作为 rule 参数的参数提供。

基于此输出,我们可以为每个样本实例创建一个合适的类,然后将样本分配到相应的列表中。

这个例子没有检查测试样本和训练样本之间的重复项。一些数据科学家建议我们不想有任何与训练样本完全匹配的测试样本;这会偏颇测试结果。我们可以在training_use变量分配和最终对列表进行追加操作之间插入所需的决策。如果training_useFalse且项目已存在于训练集中,这个项目也必须用于训练。

我们可以通过在处理过程中稍后执行类型转换来稍微重构这个算法。这使我们能够根据预期的使用情况创建一个基于KnownSample对象的“池”的字典。到目前为止,我们只有两个池——训练池,其中training_xx()规则为True,以及测试:

from collections import defaultdict, Counter
def partition_1p(
    samples: Iterable[KnownSample], 
    rule: Callable[[KnownSample, int], bool]
) -> tuple[TrainingList, TestingList]:
    pools: defaultdict[bool, list[KnownSample]] = defaultdict(list)
    partition = ((rule(s, i), s) for i, s in enumerate(samples))
    for usage_pool, sample in partition:
        pools[usage_pool].append(sample)
    training = [TrainingKnownSample(s) for s in pools[True]]
    testing = [TestingKnownSample(s) for s in pools[False]]
    return training, testing 

defaultdict 对象 pools 将布尔值映射到 List[KnownSample] 对象。我们提供了 list 函数来设置当访问一个之前不存在的键时的默认值。我们只预期两个键,这也可以写成 pools: dict[bool, list[KnownSample]] = {True: [], False: []}

分区操作首先通过创建一个生成器函数来将给定的rule函数应用于每个样本。结果是两个元素的元组;我们可以写出显式的类型提示tuple[bool, KnownSample]。这个分配给分区变量的生成器表达式是惰性的,并且直到for语句消耗了值之前不会进行任何计算。

for语句从生成器中消耗值,将每个样本追加到适当的池中。当值被消耗时,生成器函数将被评估,生成包含池、布尔值和KnownSample实例的两个元组的流。

一旦KnownSample对象被分区,我们可以将它们封装为TrainingKnownSample类或TestingKnownSample类的实例。在这个例子中,类型提示似乎比上一个版本更简单。

这实际上并不会创建数据的两个副本。对KnownSample对象的引用被收集到一个字典中。从这些引用中,创建了两个TrainingKnownSampleTestingKnownSample对象的列表。每个派生对象都包含对原始KnownSample对象的引用。临时字典的结构代表了一些内存开销,但总体来说,我们已经避免了数据的重复,从而减少了该应用程序所需的内存。

这个例子存在一个复杂问题。并不完全清楚如何防止创建与训练样本完全匹配的测试样本。在for循环内部添加一个额外的if语句可以检查是否存在usage_poolFalse(换句话说,一个测试项)且同时存在于pools[True](换句话说,训练项)中的项。这增加了很多额外的复杂性。

我们不会在这里添加额外的步骤,而是等待到第十章,即《迭代器模式》,然后修改算法以处理避免过多特殊情况或额外if语句的重复项删除。

在第五章的案例研究中,即何时使用面向对象编程,我们使用了with语句和csv模块来加载原始样本数据。在该章中,我们定义了一个SampleReader类。重要的是要回顾旧的定义,并使用这些新的分区函数来创建一个可以正确读取和分区样本数据源的完整整体。

回忆

我们已经讨论了多种方式,说明面向对象和函数式编程技术在 Python 中的应用:

  • Python 内置函数提供了访问特殊方法的能力,这些方法可以被各种类实现。几乎所有的类,其中大多数完全无关,都提供了对 __str__( )__repr__() 方法的实现,这些方法可以被内置的 str()repr() 函数使用。有许多这样的函数,其中提供了一个函数来访问跨越类边界的实现。

  • 一些面向对象的语言依赖于“方法重载”——一个单一的名字可以有多个实现,这些实现通过不同的参数组合来区分。Python 提供了一种替代方案,其中一个方法名可以包含可选的、必填的、仅位置参数和仅关键字参数。这提供了极大的灵活性。

  • 函数是对象,可以像其他对象一样使用。我们可以将它们作为参数值提供;我们也可以从函数中返回它们。函数也有属性。

  • 文件输入输出让我们仔细思考我们如何与外部对象交互。文件始终由字节组成。Python 会为我们将字节转换为文本。最常见的编码,UTF-8,是默认的,但我们也可以指定其他编码。

  • 上下文管理器是一种确保即使在发生异常时,操作系统纠缠也能正确清理的方法。然而,其用途并不仅限于简单地处理文件和网络连接。在任何我们有明确上下文,希望在进入或退出时进行一致处理的地方,都是一个上下文管理器可以发挥作用的地方。

练习

如果你之前没有遇到过with语句和上下文管理器,我鼓励你,像往常一样,回顾一下你的旧代码,找到所有你打开文件的地方,并确保它们使用with语句安全关闭。同时,也要寻找可以编写你自己的上下文管理器的地方。丑陋或重复的try...finally语句是一个很好的起点,但你会发现它们在任何需要执行上下文中的前后任务时都很有用。

你可能之前已经使用过许多基本的内置函数。我们介绍了几种,但并没有深入细节。多尝试使用enumeratezipreversedanyall,直到你确信自己会在需要时记得使用它们。enumerate函数尤其重要,因为不使用它会导致一些相当丑陋的while循环。

还可以探索一些将函数作为可调用对象传递的应用,以及使用__call__()方法使自己的对象可调用。你可以通过给函数附加属性或在一个对象上创建__call__()方法来达到相同的效果。在什么情况下你会使用一种语法,而何时又更合适使用另一种语法呢?

参数、关键字参数、可变参数和可变关键字参数之间的关系可能会有些令人困惑。当我们讨论多重继承时,我们看到了它们是如何痛苦地相互作用的。设计一些其他例子来看看它们如何协同工作,以及了解它们何时不能协同工作。

使用 **kwargsOptions 示例存在一个潜在问题。从 dict 类继承的 update() 方法会添加或替换键。如果我们只想替换键值呢?我们就必须编写自己的 update() 版本,该版本将更新现有键,并在提供新键时引发 ValueError 异常。

name_or_number() 函数示例存在一个明显的错误。它并不完全正确。对于数字 15,它不会同时报告“fizz”和“buzz”。修复 name_or_number() 函数以收集所有真实函数的所有名称。这是一个很好的练习。

name_or_number() 函数示例有两个测试函数,fizz()buzz()。我们需要一个额外的函数 bazz() 来处理七的倍数。编写这个函数,并确保它与 name_or_number() 函数一起正常工作。确保数字 105 被正确处理。

复习之前的案例研究并将它们结合成一个更完整的应用是有帮助的。章节案例研究往往关注细节,避免对更完整应用的总体整合。我们将整合工作留给读者,以便他们能更深入地研究设计。

摘要

我们在本章中涵盖了各种主题。每个主题都代表了一个在 Python 中流行的、重要的非面向对象特性。仅仅因为我们可以使用面向对象的原则,并不意味着我们总是应该这样做!

然而,我们也看到 Python 通常通过提供传统面向对象语法的语法捷径来实现这些功能。了解这些工具背后的面向对象原则使我们能够更有效地在我们自己的类中使用它们。

我们讨论了一系列内置函数和文件输入输出操作。当我们使用参数、关键字参数和可变参数列表调用函数时,我们有大量的不同语法可供选择。上下文管理器对于在两个方法调用之间嵌入一段代码的常见模式非常有用。即使函数也是对象,反之,任何普通对象也可以被设置为可调用的。

在下一章中,我们将学习更多关于字符串和文件操作的知识,甚至还会花一些时间探讨标准库中最不面向对象的主题之一:正则表达式。

第九章:字符串、序列化和文件路径

在我们涉足高级设计模式之前,让我们深入探讨 Python 中最常见的对象之一:字符串。我们会发现字符串远比表面看起来要复杂得多,我们还将涵盖在字符串中搜索模式以及序列化数据以进行存储或传输的内容。

所有这些主题都是使对象持久化的元素。我们的应用程序可以在文件中创建对象,以便在以后使用。我们通常将持久性——将数据写入文件并在任意日期检索的能力——视为理所当然。因为持久性是通过文件、在字节级别、通过操作系统写入和读取来实现的,这导致了两个转换:我们存储的数据必须解码成内存中一个漂亮、有用的对象集合;内存中的对象需要编码成某种笨拙的文本或字节格式以供存储、通过网络传输或在远程服务器上进行远程调用。

在本章中,我们将探讨以下主题:

  • 字符串、字节和字节数组的复杂性

  • 字符串格式化的方方面面

  • 神秘的正则表达式

  • 如何使用pathlib模块来管理文件系统

  • 几种数据序列化的方法,包括 Pickle 和 JSON

本章将扩展案例研究,探讨如何最佳地处理数据文件集合。在案例研究中,我们将探讨另一种序列化格式,CSV。这将帮助我们探索训练和测试数据的替代表示形式。

我们将从了解 Python 字符串开始。它们功能强大,很容易忽视其丰富的特性。

字符串

字符串是 Python 中的基本原始类型;到目前为止,我们在讨论的几乎所有例子中都使用了它们。它们所做的只是表示一个不可变的字符序列。然而,尽管你可能之前没有考虑过,字符这个词有点模糊;Python 字符串能否表示带重音的字符序列?中文字符?又或者是希腊文、西里尔文或波斯文?

在 Python 3 中,答案是肯定的。Python 字符串都是用 Unicode 表示的,这是一种字符定义标准,可以代表地球上任何语言(以及一些虚构语言和随机字符)中的几乎所有字符。这是无缝完成的。因此,让我们将 Python 3 字符串视为不可变的 Unicode 字符序列。我们在之前的例子中已经触及了字符串可以操作的各种方法,但让我们在这里快速总结一下:字符串理论的快速入门!

非常重要的是要摆脱我们曾经熟悉和喜爱的旧编码方式。例如,ASCII 编码每个字符限制为一个字节。Unicode 有几种方法将字符编码成字节。最流行的一种,称为 UTF-8,对于一些标点和字母来说,与旧的 ASCII 编码相似。它大约每个字符一个字节。但是,如果你需要成千上万的其它 Unicode 字符之一,可能涉及多个字节。

重要的规则是这样的:我们将字符编码成字节;我们将字节解码以恢复字符。这两者之间由一个高高的栅栏隔开,栅栏上有一个标有“编码”的侧门和标有“解码”的另一侧门。我们可以这样可视化它:

图表描述自动生成

图 9.1:字符串和字节

从字节值的规范显示中可能会产生一种混淆源。Python 会将字节值显示为 b'Flamb\xc3\xa9'。在字节值中,字母是数字的缩写,并使用较旧的 ASCII 编码方案。

对于大多数字母,UTF-8 和 ASCII 编码是相同的。b' 前缀告诉我们这些是字节,而这些字母实际上只是 ASCII 码,而不是真正的 Unicode 字符。我们可以通过这一点看出,Unicode 中的 é – 使用 UTF-8 编码 – 占用两个字节,而且这两个字节在 ASCII 中都没有简写。

字符串操作

如你所知,在 Python 中,可以通过将字符序列用单引号或双引号括起来来创建字符串。使用三个引号字符可以轻松创建多行字符串,并且可以通过将它们并排放置来将多个硬编码的字符串连接在一起。以下是一些示例:

>>> a = "hello"
>>> b = 'world' 
>>> c = '''a multiple 
... line string''' 
>>> d = """More 
... multiple""" 
>>> e = ("Three " "Strings " 
...        "Together") 

最后那个字符串会被解释器自动组合成一个单一的字符串。也可以使用+运算符来连接字符串(例如"hello " + "world")。当然,字符串不必硬编码。它们也可以来自各种外部来源,例如文本文件和用户输入,或者可以在网络上传输。

注意缺失的操作符

自动连接相邻的字符串可能会在遗漏逗号时引发一些令人捧腹的 bug。然而,当需要将长字符串放入函数调用中而不超过由 Python 风格指南 PEP-8 建议的 79 个字符的行长度限制时,这却非常有用。

与其他序列一样,字符串可以逐个字符迭代(按字符迭代),索引,切片或连接。语法与列表和元组相同。

str 类提供了许多方法来简化字符串操作。dir()help() 函数可以告诉我们如何使用它们的所有方法;我们将直接考虑一些更常见的方法。

几个布尔便利方法帮助我们识别字符串中的字符是否匹配某种模式。其中大多数,如 isalpha()isupper()islower()startswith()endswith(),都有相对容易理解的解释。isspace() 方法也很明显,但请记住,所有空白字符(包括制表符和换行符)都被考虑在内,而不仅仅是空格字符。如有疑问,help() 函数很有用:

>>> help(str.isalpha)
Help on method_descriptor:
isalpha(...)
    S.isalpha() -> bool
    Return True if all characters in S are alphabetic and there is at     least one character in S, False otherwise.
    A string is alphabetic if all characters in the string are     alphabetic and there is at least one character in the string. 

istitle() 方法返回 True 如果每个单词的首字母都大写且所有其他字母都小写。请注意,它并不严格遵循英语语法对标题格式的定义。例如,利·亨特的诗作《手套与狮子》遵循常见的标题风格指南,但不符合 Python 方法狭窄的规则。同样,罗伯特·塞尔的《萨姆·麦基的火化》遵循通常的英语规则,即使最后一个单词中间有一个大写字母;Python 的 istitle() 方法将返回 False,因为它不了解像 McGee 这样的名字或标题中像 andthe 这样的单词需要大写的规则。

请小心使用 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 

所有这些不一致的结果是,布尔数值检查必须谨慎使用,了解规则的细节。我们通常会需要编写一个正则表达式(本章后面将讨论)来确认字符串是否匹配特定的数值模式。我们称这种编程风格为 LBYL("在跳之前先看")。一个非常常见的方法是使用一个包裹在int()float()转换尝试周围的try/except块。我们称这种编程风格为 EAFP("请求原谅比请求许可更容易")。EAFP 风格与 Python 非常自然地契合。

其他用于模式匹配的方法不会返回布尔值。count() 方法告诉我们给定子字符串在字符串中出现的次数,而 find()index()rfind()rindex() 告诉我们在原始字符串中给定子字符串的位置。大多数操作从零索引开始,从左到右进行。两个带有 r(表示 rightreverse)的方法从字符串的最高索引端开始搜索,并从右到左进行。如果找不到子字符串,find() 方法返回 -1,而在此情况下 index() 会引发一个 ValueError 异常。看看这些方法在实际中的应用:

>>> s = "hello world"
>>> s.count('l')
3
>>> s.find('l')
2
>>> s.rindex('m')
Traceback (most recent call last):
...
File "<doctest examples.md[11]>", line 1, in <module>
s.rindex('m')
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-string)在开引号f处有一个前缀,例如f"hello world"。如果这样的字符串包含特殊字符{}以及表达式,包括周围作用域中的变量,这些表达式将被评估,然后插入到字符串中。以下是一个示例:

>>> name = "Dusty"
>>> activity = "reviewing"
>>> message = f"Hello {name}, you are currently {activity}."
>>> print(message) 

如果我们运行这些语句,它会按照以下顺序用变量替换大括号:

Hello Dusty, you are currently reviewing. 

逃离花括号

括号字符在字符串中除了用于格式化外,通常很有用。我们需要一种方法来转义它们,以便在想要它们显示为自身而不是被替换的情况下使用。这可以通过重复括号来实现。例如,我们可以使用 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}");
...     }}
... }}
... """ 

在模板中我们看到{{}}序列——也就是说,包围 Java 类和方法定义的花括号——我们知道 f-string 将会用单个花括号替换它们,而不是周围方法中的某个参数。以下是输出结果:

>>> print(template)
public class MyClass {
    public static void main(String[] args) {
        System.out.println("print('hello world')");
    }
} 

类名和输出内容已被两个参数所替换,同时双大括号已被单大括号所取代,从而生成一个有效的 Java 文件。结果证明,这可能是打印出最简单的 Java 程序,该程序又能打印出最简单的 Python 程序的最简单 Python 程序。

f-字符串可以包含 Python 代码

我们不仅限于将简单字符串变量的值插入到 f-string 模板中。任何原始数据类型,如整数或浮点数,都可以进行格式化。更有趣的是,包括列表、元组、字典和任意对象在内的复杂对象也可以使用,并且我们可以在 format 字符串内部访问这些对象的索引和变量或调用这些对象上的函数。

例如,如果我们的电子邮件消息将FromTo电子邮件地址组合成一个元组,并将主题和消息放入一个字典中,由于某种原因(可能是因为我们需要使用的一个现有send_mail函数需要这样的输入),我们可以这样格式化它:

>>> emails = ("steve@example.com", "dusty@example.com")
>>> message = {
...     "subject": "Next Chapter",
...     "message": "Here's the next chapter to review!",
... }
>>> formatted = f"""
... From: <{emails[0]}>
... To: <{emails[1]}>
... Subject: {message['subject']}
... 
... {message['message']}
... """ 

模板字符串中花括号内的变量看起来有点奇怪,让我们看看它们在做什么。两个电子邮件地址是通过表达式 emails[x] 查找的,其中 x 要么是 0 要么是 1。这是一个普通的元组索引操作,所以 emails[0] 指的是 emails 元组中的第一个元素。同样,表达式 message['subject'] 从字典中获取一个项。

当我们需要展示一个更复杂的目标时,这种方法尤其有效。我们可以提取目标属性和特性,甚至可以在 f-string 内调用方法。让我们再次更改我们的电子邮件消息数据,这次改为一个类:

>>> class Notification:
...     def __init__(
...             self, 
...             from_addr: str, 
...             to_addr: str, 
...             subject: str, 
...             message: str
...     ) -> None:
...         self.from_addr = from_addr
...         self.to_addr = to_addr
...         self.subject = subject
...         self._message = message
...     def message(self):
...         return self._message 

这里是Notification类的一个实例:

>>> email = Notification(
...     "dusty@example.com",
...     "steve@example.com",
...     "Comments on the Chapter",
...     "Can we emphasize Python 3.9 type hints?",
... ) 

我们可以使用这个电子邮件实例来填充一个 f-string,如下所示:

>>> formatted = f"""
... From: <{email.from_addr}>
... To: <{email.to_addr}>
... Subject: {email.subject}
... 
... {email.message()}
... """ 

几乎任何你期望返回字符串(或可以由 str() 函数转换为字符串的值)的 Python 代码都可以在 f-string 中执行。作为一个如何强大到何种程度的例子,你甚至可以在格式字符串参数中使用列表推导式或三元运算符:

>>> f"{[2*a+1 for a in range(5)]}"
'[1, 3, 5, 7, 9]'
>>> for n in range(1, 5):
...     print(f"{'fizz' if n % 3 == 0 else n}")
1
2
fizz
4 

在某些情况下,我们可能需要在值上包含一个标签。这对于调试来说非常好;我们可以在表达式中添加一个=后缀。它看起来是这样的:

>>> a = 5
>>> b = 7
>>> f"{a=}, {b=}, {31*a//42*b + b=}"
'a=5, b=7, 31*a//42*b + b=28' 

这种技术为我们创建了一个标签和值。这可以非常有帮助。当然,还有许多更复杂的格式化选项可供我们选择。

使其看起来正确

能够在模板字符串中包含变量是一件很方便的事情,但有时变量需要一点强制转换才能在输出中呈现出我们想要的样子。我们计划在切萨皮克湾周围进行一次航海之旅。从安纳波利斯出发,我们想去圣迈克尔、牛津和剑桥。为了做到这一点,我们需要知道这些航海港口之间的距离。这里有一个用于相对较短距离的有用距离计算方法。首先,是正式的数学公式,因为那可以帮助解释代码:

图片

这遵循与三角形斜边计算相同的模式。

图片

存在一些重要的差异:

  • 我们将南北纬度的差异写为 ,从度转换为弧度。这看起来比 更简单。

  • 我们为东西经度的差异编写了 ,将其从度转换为弧度。这比 更简单。在世界的某些地区,经度将是一个正负数的混合,我们需要找出最小正值距离,而不是计算绕地球一周的行程。

  • R 值将弧度转换为海里(大约 1.85 公里,1.15 英里,正好是纬度 1/60 度)。

  • 余弦计算反映了经度距离在极点处向零压缩的方式。在北极,我们可以沿着一个微小的圆圈行走,覆盖所有 360°。而在赤道,我们必须行走(或行走并航行)40,000 公里才能覆盖同样的 360°。

否则,这与我们在第三章案例研究中使用的math.hypot()函数类似,这意味着它涉及到平方根以及过于精确的浮点数。

这里是代码:

def distance(
        lat1: float, lon1: float, lat2: float, lon2: float
) -> float:
    d_lat = radians(lat2) - radians(lat1)
    d_lon = min(
        (radians(lon2) - radians(lon1)) % (2 * pi),
        (radians(lon1) - radians(lon2)) % (2 * pi),
    )
    R = 60 * 180 / pi
    d = hypot(R * d_lat, R * cos(radians(lat1)) * d_lon)
    return d 

这里是我们的测试用例:

>>> annapolis = (38.9784, 76.4922)
>>> saint_michaels = (38.7854, 76.2233)
>>> round(distance(*annapolis, *saint_michaels), 9)
17.070608794 

这听起来很有趣。一艘帆船以大约 6 节的速度航行,17.070608794 海里的旅程将花费 2.845101465666667 小时穿越海湾。如果风更小,我们可能只能达到 5 节的速度,这次旅行将需要 3.4141217588000004 小时。

这位数太多,实际上并没有太大用处。船的长度是 42 英尺(12.8 米);那相当于 0.007 海里;所以,小数点后第三位及以后的数字都是噪音,不是有用的结果。我们需要调整这些距离以提供有用的信息。此外,我们有多段航程,我们不希望将每一段航程视为特殊情况。我们需要提供更好的组织和数据展示。

这是我们计划这次旅行的方案。首先,我们将定义我们想要去的地方的四个航点。然后,我们将这些航点组合成路段。

>>> annapolis = (38.9784, 76.4922)
>>> saint_michaels = (38.7854, 76.2233)
>>> oxford = (38.6865, 76.1716)
>>> cambridge = (38.5632, 76.0788)
>>> legs = [
...     ("to st.michaels", annapolis, saint_michaels),
...     ("to oxford", saint_michaels, oxford),
...     ("to cambridge", oxford, cambridge),
...     ("return", cambridge, annapolis),
... ] 

我们可以使用距离计算来确定到达每个目的地有多远。我们可以计算出覆盖这段距离所需的速度,如果我们不能航行而必须使用引擎,我们甚至可以计算出所需的燃油量:

>>> speed = 5
>>> fuel_per_hr = 2.2
>>> for name, start, end in legs:
...     d = distance(*start, *end)
...     print(name, d, d/speed, d/speed*fuel_per_hr)
    to st.michaels 17.070608794397305 3.4141217588794612     7.511067869534815
    to oxford 6.407736547720565 1.281547309544113 2.8194040809970486
    to cambridge 8.580230239760064 1.716046047952013 3.7753013054944287
    return 31.571582240989173 6.314316448197834 13.891496186035237 

虽然我们已经规划了整个旅程,但我们仍然有太多的数字。距离最多只需要两位小数。十分之一小时是六分钟;那里不需要太多数字。同样,燃料也可以计算到最近的十分之一加仑。(十分之一加仑是 0.4 升。)

f-string 替换规则包括一些可以帮助我们的格式化功能。在表达式(一个变量是一个非常简单的表达式)之后,我们可以使用:后跟对数字布局的详细描述。我们将在示例之后返回细节。以下是一个包含更多有用打印格式的改进计划:

>>> speed = 5
>>> fuel_per_hr = 2.2
>>> print(f"{'leg':16s} {'dist':5s} {'time':4s} {'fuel':4s}")
leg              dist  time fuel
>>> for name, start, end in legs:
...     d = distance(*start, *end)
...     print(
...         f"{name:16s} {d:5.2f} {d/speed:4.1f} "
...         f"{d/speed*fuel_per_hr:4.0f}"
...     )
to st.michaels   17.07  3.4    8
to oxford             6.41  1.3    3
to cambridge      8.58  1.7    4
return                 31.57  6.3   14 

例如,:5.2f格式说明符的含义如下,从左到右:

  • 5: 占用最多五个空格 – 这在使用固定宽度字体时保证了列对齐

  • .: 显示小数点

  • 2: 显示小数点后两位

  • f: 将输入值格式化为浮点数值

真棒!位置格式化为16s。这遵循了与浮点格式相同的模式:

  • 16 表示它应该占用 16 个字符。默认情况下,对于字符串,如果字符串的长度小于指定的字符数,它会在字符串的右侧添加空格,使其足够长(但是请注意:如果原始字符串太长,它不会被截断!)。

  • s 表示它是一个字符串值。

当我们编写标题时,我们使用了看起来很奇怪的 f-string:

f"{'leg':16s} {'dist':5s} {'time':4s} {'fuel':4s}") 

这里有字符串字面量如'leg',其格式为16s,以及'dist',其格式为5s。大小是从详细行复制的,以确保标题能够覆盖各自的列。确保大小匹配使得确认标题和细节对齐变得容易。

所有这些格式说明符都有相同的模式;细节是可选的:

  • 一个填充字符(如果没有提供则为空格)用于填充数字以达到指定的长度。

  • 对齐规则。默认情况下,数字右对齐,字符串左对齐。字符如 <^> 可以强制左对齐、居中对齐或右对齐。

  • 如何处理符号(默认负数为,正数为无符号。)您可以使用+来显示所有符号。此外,空格(" ")为正数留出空间,而-为负数留出空间,以确保正确的对齐。

  • 如果你想在数字前面填充前导零,则为0

  • 该字段的整体大小。这应包括符号、小数点、逗号以及浮点数本身的句点。

  • 如果您想要以逗号","分隔的 1,000 组,请使用下划线"_"来分隔带有下划线的组。如果您所在的地区使用"."进行分组,并且小数分隔符是逗号",",您将希望使用n格式来使用所有地区设置。f格式倾向于使用逗号","进行分组的地区。

  • 如果是浮点数(f)或一般数(g),后面跟着小数点右边的数字位数。

  • 类型。常见的类型有 s 用于字符串,d 用于十进制整数,以及 f 用于浮点数。默认类型为 s,即字符串。大多数其他格式说明符是这些类型的变体;例如,o 表示八进制格式,而 X 表示整数的十六进制格式。n 类型说明符对于按当前区域设置格式化任何类型的数字可能很有用。对于浮点数,% 类型会将数值乘以 100 并将浮点数格式化为百分比。

这是一种非常复杂的显示数字的方式。它可以通过减少杂乱并使数据按列对齐,来简化其他情况下可能令人困惑的输出。

错误的导航建议

这些航路点有点误导。如果你是只鸟,从圣迈克尔到牛津的路程只有 6.41 英里。中间有一个大半岛挡路,实际上,绕过波普勒和蒂尔曼群岛,沿着切帕恩克河的旅程会愉快地更长。对距离的表面分析需要通过实际查看海图并插入多个额外的航路点来支持。我们的算法允许这样做,更新航段列表也很容易。

自定义格式化工具

虽然这些标准格式化程序适用于大多数内置对象,但其他对象也可以定义非标准指定符。例如,如果我们将一个datetime对象传递给format函数,我们可以使用datetime.strftime()函数中使用的指定符,如下所示:

>>> import datetime 
>>> important = datetime.datetime(2019, 10, 26, 13, 14)
>>> f"{important:%Y-%m-%d %I:%M%p}"
'2019-10-26 01:14PM' 

甚至可以为我们自己创建的对象编写自定义格式化程序,但这超出了本书的范围。如果你需要在代码中这样做,请查看如何重写__format__()特殊方法。

Python 的格式化语法非常灵活,但它是一种难以记忆的小型语言。将 Python 标准库中的页面添加到书签以帮助查找细节是很有帮助的。虽然这种格式化功能在很多方面都很不错,但对于更大规模的模板需求,如生成网页,它的功能还不够强大。如果你需要做的不仅仅是格式化几个字符串,你可以考虑使用几个第三方模板库。

format() 方法

F 字符串是在 Python 3.6 中引入的。由于 Python 3.5 的支持在 2020 年结束(详情请见 PEP-478),我们不再需要担心没有 f-strings 的旧 Python 运行时。有一个稍微更通用的工具可以将值插入到字符串模板中:字符串的 format() 方法。它使用与 f-strings 相同的格式说明符。值来自 format() 方法的参数值。以下是一个示例:

>>> from decimal import Decimal
>>> subtotal = Decimal('2.95') * Decimal('1.0625')
>>> template = "{label}: {number:*^{size}.2f}" 
>>> template.format(label="Amount", size=10, number=subtotal)
'Amount: ***3.13***'
>>> grand_total = subtotal + Decimal('12.34')
>>> template.format(label="Total", size=12, number=grand_total)
'Total: ***15.47****' 

format() 方法的行为与 f-string 类似,但有一个重要的区别:你只能访问作为 format() 方法参数提供的值。这使得我们能够在复杂应用程序中将消息模板作为配置项提供。

我们有三种方式来引用将要插入到模板字符串中的参数:

  • 按名称: 示例在模板中包含 {label}{number},并为 format() 方法提供了名为 label=number= 的参数。

  • 按位置:我们可以在模板中使用 {0},这将使用 format() 的第一个位置参数,如下所示:"Hello {0}!".format("world")

  • 通过隐式位置:我们可以在模板中使用 {},这将按照模板中的顺序使用位置参数,例如:"{} {}!".format("Hello", "world")

在 f 字符串和模板的 format() 方法之间,我们可以通过将表达式或值插入到模板中来创建复杂的字符串值。在大多数情况下,f 字符串就是我们需要的。在极少数情况下,如果格式字符串可能是一个复杂应用的配置参数,那么 format() 方法是有帮助的。

字符串是 Unicode

在本节的开头,我们将字符串定义为不可变的 Unicode 字符集合。这实际上在某些时候会使事情变得非常复杂,因为 Unicode 不是一个存储格式。例如,如果你从一个文件或套接字中获取一个字节字符串,它们将不会是 Unicode。实际上,它们将是内置的bytes类型。字节是...嗯,字节的不可变序列。字节是计算中的基本存储格式。它们代表 8 位,通常描述为介于 0 到 255 之间的整数,或者介于0x000xFF之间的十六进制等效值。字节不表示任何特定内容;字节序列可能存储编码字符串的字符,或图像中的像素,或表示一个整数,或浮点值的一部分。

如果我们打印一个bytes对象,Python 会使用一种合理的紧凑的规范显示方式。任何映射到 ASCII 字符的单独字节值都会以字符形式显示,而非字符 ASCII 字节则以转义序列的形式打印,要么是一个字符的转义序列,如\n,要么是一个十六进制代码,如\x1b。你可能觉得奇怪,一个表示为整数的字节可以映射到一个 ASCII 字符。但旧的 ASCII 代码为许多不同的字节值定义了拉丁字母。在 ASCII 中,字符a由与整数 97 相同的字节表示,这是十六进制数0x61。所有这些都是对二进制模式0b1100001的解释。

>>> list(map(hex, b'abc'))
['0x61', '0x62', '0x63']
>>> list(map(bin, b'abc'))
['0b1100001', '0b1100010', '0b1100011'] 

下面是当这些标准显示字节包含既有 ASCII 字符表示又有无简单字符表示的值的混合时,它们可能看起来是怎样的:

>>> bytes([137, 80, 78, 71, 13, 10, 26, 10])
b'\x89PNG\r\n\x1a\n' 

第一个字节使用了十六进制转义,\x89。接下来的三个字节是 ASCII 字符,PNG。接下来的两个字符使用了一个字符的转义,\r\n。第七个字节也使用了十六进制转义,\x1a,因为没有任何其他编码。最后一个字节是另一个一个字符的转义,\n。这八个字节扩展成了 17 个可打印字符,不包括前缀b'和最后的'

许多 I/O 操作只知道如何处理bytes,即使bytes对象是文本数据的编码。因此,了解如何在不同bytes值和 Unicode str值之间进行转换是至关重要的。

问题在于存在许多编码方式,它们将bytes映射到 Unicode 文本。其中一些是真正的国际标准,但许多其他的是商业产品的一部分,使得它们非常流行,但并不——确切地说——是标准化的。Python 的codecs模块提供了许多这些编码解码规则,用于将字节解码为字符串以及将字符串编码为字节。

多种编码的重要后果是,当使用不同的编码进行映射时,相同的字节序列会表示完全不同的文本字符!因此,bytes 必须使用与它们编码时相同的字符集进行解码。如果不了解字节应该如何解码,就无法从字节中获取文本。如果我们收到未指定编码的未知字节,我们最好的做法是猜测它们编码的格式,但我们很可能会出错。

解码字节到文本

如果我们从某处获得了一个bytes数组,我们可以使用bytes类的.decode()方法将其转换为 Unicode。此方法接受一个字符串作为字符编码的名称。存在许多这样的编码;常见的包括 ASCII、UTF-8、latin-1 和 cp-1252。在这些中,UTF-8 是最常用的之一。

字节序列(十六进制表示),63 6c 69 63 68 c3 a9,实际上代表了单词 cliche 在 UTF-8 编码下的字符:

>>> characters = b'\x63\x6c\x69\x63\x68\xc3\xa9' 
>>> characters 
b'clich\xc3\xa9' 

第一行创建了一个bytes字面量,作为一个b''字符串。字符串前面的b字符告诉我们我们正在定义一个bytes对象,而不是一个普通的 Unicode 文本字符串。在字符串内部,每个字节使用十六进制数指定,在这种情况下,使用的是十六进制数。\x字符在字节字符串中用于转义,并且每个\x表示接下来的两个字符使用十六进制数字表示一个字节

最后的行是输出结果,展示了 Python 对 bytes 对象的规范表示。在这七个字节中的前五个字节中,有一个可用的 ASCII 字符。然而,最后的两个字节却没有 ASCII 字符,因此必须使用 \xc3\xa9

假设我们使用的是一个支持 UTF-8 编码的 shell,我们可以将字节解码为 Unicode,并看到以下内容:

>>> characters.decode("utf-8") 
'cliché' 

decode 方法返回一个包含正确字符的文本(Unicode)str对象。注意,字节序列\xc3\xa9映射为一个单一的 Unicode 字符。

在某些情况下,Python 终端可能没有定义正确的编码,因此操作系统无法从操作系统字体中正确选择字符。是的,从字节到文本再到显示字符的映射非常复杂,其中一部分是 Python 的问题,另一部分是操作系统的问题。理想情况下,您的计算机使用 UTF-8 编码并且具有包含完整 Unicode 字符集的字体。如果不是这样,您可能需要研究 PYTHONIOENCODING 环境变量。请参阅 docs.python.org/3.9/using/cmdline.html#envvar-PYTHONIOENCODING

然而,如果我们使用西里尔文iso8859-5编码来解码这个相同的字符串,我们最终会得到这个:

>>> characters.decode("iso8859-5")
'clichУЉ' 

这是因为 \xc3\xa9 字节在其他编码中映射到不同的字符。多年来,发明了大量的不同编码,但并非所有编码都得到广泛使用。

>>> characters.decode("cp037")
'Ä%ÑÄÇZ' 

这就是为什么我们需要知道所使用的编码。通常,UTF-8 应该是首选的编码。这是一个常见的默认设置,但并非普遍适用。

将文本编码为字节

将字节转换为 Unicode 的另一方面是将输出的 Unicode 转换为字节序列的情况。这通过str类的encode()方法完成,与decode()方法一样,需要指定一个编码名称。以下代码创建了一个 Unicode 字符串,并将其编码为不同的字符集:

>>> characters = "cliché" 
>>> characters.encode("UTF-8")
b'clich\xc3\xa9'
>>> characters.encode("latin-1")
b'clich\xe9'
>>> characters.encode("cp1252")
b'clich\xe9'
>>> characters.encode("CP437")
b'clich\x82'
>>> characters.encode("ascii") 
Traceback (most recent call last):
...
File "<doctest examples.md[73]>", line 1, in <module>
characters.encode("ascii")
UnicodeEncodeError: 'ascii' codec can't encode character '\xe9' in position 5: ordinal not in range(128) 

现在你应该明白编码的重要性了!在这些编码中,带重音的字符通常被表示为不同的字节;如果我们解码字节为文本时使用了错误的编码,我们就会得到错误的字符。

在最后一种情况下的异常并不总是期望的行为;可能存在我们希望未知字符以不同方式处理的情况。encode方法接受一个名为errors的可选字符串参数,该参数可以定义如何处理此类字符。此字符串可以是以下之一:

  • "严格"

  • "替换"

  • "忽略"

  • "xmlcharrefreplace"

严格替换策略是我们刚刚看到的默认设置。当遇到一个在请求的编码中没有有效表示的字节序列时,会引发一个异常。当使用replace策略时,字符会被替换成另一个字符。在 ASCII 编码中,它是一个问号;其他编码可能使用不同的符号,例如一个空框。

ignore策略简单地丢弃它不理解的所有字节,而xmlcharrefreplace策略则创建一个表示 Unicode 字符的xml实体。这在将未知字符串转换为用于 XML 文档时可能很有用。

下面是每种策略如何影响我们的样本单词:

>>> characters = "cliché" 
>>> characters.encode("ascii", "replace")
b'clich?'
>>> characters.encode("ascii", "ignore")
b'clich'
>>> characters.encode("ascii", "xmlcharrefreplace")
b'clich&#233;' 

可以调用 str.encode()bytes.decode() 方法而不传递编码名称。编码将设置为当前平台的默认编码。这取决于当前的操作系统和区域设置;您可以使用 sys.getdefaultencoding() 函数来查找它。尽管如此,通常最好明确指定编码,因为平台的默认编码可能会更改,或者程序将来可能会扩展以处理来自更广泛来源的文本。

如果你正在编码文本且不知道使用哪种编码,最好使用 UTF-8 编码。UTF-8 能够表示任何 Unicode 字符。在现代软件中,它是一种广泛使用的标准编码,以确保任何语言——甚至多种语言的文档——可以交换。其他各种可能的编码对于旧文档或在默认使用不同字符编码的软件中是有用的。

UTF-8 编码使用一个字节来表示 ASCII 和其他常见字符,而对于其他字符则最多使用四个字节。UTF-8 的特别之处在于它(大部分情况下)与 ASCII 兼容;使用 UTF-8 编码的 ASCII 文档几乎与原始的 ASCII 文档相同。

编码与解码

记忆起来是否使用 encodedecode 来将二进制字节转换为 Unicode 文本是很困难的。问题在于 "code" 这个词在 Unicode 中可能会造成混淆。我建议忽略它们。如果我们把字节看作是代码,我们就将纯文本编码成字节,再将字节解码回纯文本。

可变字节字符串

bytes 类型,与 str 类似,是不可变的。我们可以在 bytes 对象上使用索引和切片表示法来搜索特定的字节序列,但我们不能扩展或修改它们。在处理 I/O 时,这可能会不方便,因为通常需要缓冲输入或输出的字节,直到它们准备好发送。例如,如果我们从套接字接收数据,我们可能需要在接收到整个消息之前,累积几个 recv 调用的结果。

这就是内置的bytearray类型发挥作用的地方。这种类型的行为类似于列表,但它只存储字节。该类的构造函数可以接受一个bytes对象来初始化它。可以使用extend方法将另一个bytes对象追加到现有数组中(例如,当从套接字或其他 I/O 通道接收更多数据时)。

切片符号可用于bytearray以就地修改项目,无需创建新对象的额外开销。例如,此代码从一个bytes对象构建一个bytearray,然后替换两个字节:

>>> ba = bytearray(b"abcdefgh") 
>>> ba[4:6] = b"\x15\xa3"
>>> ba
bytearray(b'abcd\x15\xa3gh') 

我们使用切片符号将 [4:6] 切片中的字节替换为两个替换字节,b"\x15\xa3"

如果我们要在bytearray中操作单个元素,其值必须是一个介于 0 到 255(包含)之间的整数,这是一个特定的bytes模式。如果我们尝试传递一个字符或bytes对象,将会引发异常。

单个字节字符可以使用ord()(简称序数)函数转换为整数。此函数返回单个字符的整数表示:

>>> ba = bytearray(b"abcdefgh") 
>>> ba[3] = ord(b'g')
>>> ba[4] = 68
>>> ba
bytearray(b'abcgDfgh') 

在构建数组后,我们将索引3(第四个字符,因为索引从0开始,与列表相同)处的字符替换为字节103。这个整数是由ord()函数返回的,并且是小写字母g的 ASCII 字符。

为了说明,我们还用字节编号68替换了下一个字符,它对应于大写字母D的 ASCII 字符。

bytearray 类型具有允许其像列表一样操作的方法(例如,我们可以向其中追加整数字节)。它也可以像 bytes 对象一样操作(我们可以使用 count()find() 等方法)。不同之处在于 bytearray 是一个可变类型,这对于从特定的输入源构建复杂的字节序列非常有用。例如,我们可能需要在读取有效载荷字节之前读取一个包含长度信息的四个字节的头部。能够直接将读取操作执行到可变的 bytearray 中,以节省在内存中创建大量小对象,是非常方便的。

正则表达式

你知道使用面向对象原则真正难以做到的是什么吗?解析字符串以匹配任意模式,就是这样。已经有许多学术论文被撰写,其中使用面向对象设计来设置字符串解析,但结果看起来过于冗长且难以阅读,而且在实践中并不广泛使用。

在现实世界中,大多数编程语言中的字符串解析都是由正则表达式处理的。这些表达式并不冗长,但哇,它们确实很难阅读,至少在你学会语法之前是这样的。尽管正则表达式不是面向对象的,但 Python 的正则表达式库提供了一些类和对象,你可以使用它们来构建和运行正则表达式。

当我们使用正则表达式来“匹配”一个字符串时,这仅仅是对正则表达式真正含义的部分描述。我们可以将正则表达式想象成一个数学规则,它可以生成(可能无限多的)字符串集合。当我们“匹配”一个正则表达式时,这类似于询问给定的字符串是否在由表达式生成的集合中。棘手的是,使用原始 ASCII 字符集中可用的一小部分标点符号来重写一些复杂的数学表达式。为了帮助解释正则表达式的语法,我们将稍微偏离一下,通过一些使正则表达式阅读变得具有挑战性的排版问题进行一次小型的侧游。

这是一个针对一小组字符串的理想化数学正则表达式:world。我们希望匹配这五个字符。这个集合中有一个字符串 "world" 与之匹配。这似乎并不复杂;表达式相当于 w AND o AND r AND l AND d,其中 "AND" 是隐含的。这与 表示 d = r times t 的方式相类似;乘法是隐含的。

这是一个具有重复模式的正则表达式的示例:。我们希望匹配五个字符,但其中之一必须重复出现。这个集合中有一个字符串 "hello" 与之匹配。这强调了正则表达式、乘法和指数之间的平行关系。它还指出了使用指数来区分匹配 2 个字符和匹配前一个正则表达式两次的情况。

有时候,我们希望有一些灵活性,并且想要匹配任何数字。数学排版允许我们使用一种新的字体来实现这一点:我们可以说 。这个看起来很花哨的 D 代表任何数字,或者 ,而上面的 4 代表四份。这描述了一个包含从 "0000" 到 "9999" 共 10,000 个可能匹配字符串的集合。为什么使用这种花哨的数学排版?我们可以使用不同的字体和字母排列来区分“任何数字”和“四份”的概念,与字母 D 和数字 4 区分开来。代码——正如我们将看到的——缺乏这些花哨的字体,迫使设计师在区分代表自身意义的字母,如 D,和具有其他有用意义的字母,如 之间进行工作。

当然,正则表达式看起来很像一个长乘法。它与“必须包含这些”和乘法之间有着非常强的平行关系。与加法有平行关系吗?是的,这是可选或替代结构的概念;实际上是一个“或”代替了默认的“与”。

如果我们想在日期中描述可能有两个或四个数字的年份,从数学上讲,我们可能会说 图片。如果我们不确定有多少位数字呢?我们有一个特殊的“任何次方”,即 Kleene 星号。我们可以说 图片 来表示在 图片 集合中字符的任意重复次数。

所有这些数学排版都必须在正则表达式语言中实现。这可能会使得精确地理解正则表达式的含义变得困难。

正则表达式用于解决一个常见问题:给定一个字符串,确定该字符串是否与给定的模式匹配,并且可选地收集包含相关信息子串。它们可以用来回答以下问题:

  • 这串字符是否是一个有效的 URL?

  • 日志文件中所有警告信息的日期和时间是什么?

  • /etc/passwd 文件中哪些用户属于指定的组?

  • 访客输入的 URL 请求了什么用户名和文档?

在许多类似场景中,正则表达式是正确的答案。在本节中,我们将获得足够关于正则表达式的知识,以便将字符串与相对常见的模式进行比较。

这里有一些重要的限制。正则表达式不能描述具有递归结构的语言。当我们查看 XML 或 HTML 时,例如,一个<p>标签可以包含内联的<span>标签,如下所示:<p><span>hello</span><span>world</span></p>。这种标签嵌套标签的递归结构通常不是用正则表达式尝试处理的好方法。我们可以识别 XML 语言的各个元素,但像包含其他标签的段落标签这样的高级结构需要比正则表达式更强大的工具。Python 标准库中的 XML 解析器可以处理这些更复杂的结构。

匹配模式

正则表达式是一种复杂的迷你语言。我们需要能够描述单个字符以及字符类,以及用于分组和组合字符的运算符,所有这些都可以使用几个与 ASCII 兼容的字符来完成。让我们从字面字符开始,例如字母、数字和空格字符,它们总是匹配自身。让我们看一个基本示例:

>>> import re 
>>> search_string = "hello world"
>>> pattern = r"hello world"
>>> if match := re.match(pattern, search_string): 
...     print("regex matches") 
...     print(match)
regex matches
<re.Match object; span=(0, 11), match='hello world'> 

Python 标准库中的正则表达式模块被称为 re。我们导入它并设置一个搜索字符串和搜索模式;在这种情况下,它们是相同的字符串。由于搜索字符串与给定的模式匹配,条件成立并且执行了 print 语句。

一个成功的匹配会返回一个re.Match对象,描述了精确匹配的内容。一个失败的匹配返回None,这在if语句的布尔上下文中等同于False

我们使用了“海象”操作符(:=)来计算re.match()的结果,并将这些结果保存到变量中,所有这些都是在if语句的一部分。这是使用海象操作符计算结果然后测试结果是否为真的一种最常见方式。这是一种小小的优化,可以帮助阐明如果匹配操作的结果不是None,它们将如何被使用。

我们几乎总是使用带有 r 前缀的“原始”字符串来表示正则表达式。原始字符串不会将反斜杠转义符转换为 Python 中的其他字符。例如,在一个普通字符串中,\b 被转换为一个单独的退格字符。在原始字符串中,它由两个字符组成,\b。在这个例子中,r-字符串实际上并不是必需的,因为模式没有涉及任何特殊的 \d\w 类型的正则表达式符号。使用 r-字符串是一个好习惯,我们将努力做到一致。

请记住,match 函数匹配的是字符串开头锚定的模式。因此,如果模式是 r"ello world",则不会找到匹配,因为 search_string 的值以 "h" 开头而不是 "e"。带有令人困惑的不对称性,解析器一旦找到匹配就会停止搜索,所以模式 r"hello wo" 成功匹配了 search_string 的值,并且留下了一些多余的字符。让我们构建一个小型示例程序来展示这些差异,并帮助我们学习其他正则表达式语法:

import re
from typing import Pattern, Match
def matchy(pattern: Pattern[str], text: str) -> None:
    if match := re.match(pattern, text):
        print(f"{pattern=!r} matches at {match=!r}")
    else:
        print(f"{pattern=!r} not found in {text=!r}") 

matchy() 函数在先前的例子基础上进行了扩展;它接受模式和搜索字符串作为参数。我们可以看到模式的开头必须匹配,但一旦找到匹配项,就会立即返回一个值。

这里是使用此函数的一些示例:

>>> matchy(pattern=r"hello wo", text="hello world")
pattern='hello wo' matches at match=<re.Match object; span=(0, 8), match='hello wo'>
>>> matchy(pattern=r"ello world", text="hello world")
pattern='ello world' not found in text='hello world' 

我们将在接下来的几节中使用这个函数。一系列测试用例是开发正则表达式的一种常见方法——从我们想要匹配和不想匹配的一堆文本示例中,我们测试以确保我们的表达式按预期工作。

如果你需要控制项目是否出现在行首或行尾(或者字符串中没有换行符,或者在字符串的开始和结束处),你可以使用^$字符分别表示字符串的开始和结束。

如果你想要一个模式匹配整个字符串,包含这两个都很好主意:

>>> matchy(pattern=r"^hello world$", text="hello world")
pattern='^hello world$' matches at match=<re.Match object; span=(0, 11), match='hello world'>
>>> matchy(pattern=r"^hello world$", text="hello worl")
pattern='^hello world$' not found in text='hello worl' 

我们称 ^$ 字符为“锚点”。它们将匹配定位到字符串的开始或结束位置。重要的是,它们并不字面地匹配自身;它们也被称为元字符。如果我们进行复杂的数学排版,我们会使用不同的字体来区分表示“锚定在开始”的 ^ 和表示实际字符 "^"^。由于在 Python 代码中我们没有复杂的数学排版,我们使用 \ 来区分元字符和普通字符。在这种情况下,^ 是一个元字符,而 \^ 是一个普通字符。

>>> matchy(pattern=r"\^hello world\$", text="hello worl")
pattern='\\^hello world\\$' not found in text='hello worl'
>>> matchy(pattern=r"\^hello world\$", text="^hello world$")
pattern='\\^hello world\\$' matches at match=<re.Match object; span=(0, 13), match='^hello world$'> 

因为使用了 \^,所以我们需要在字符串中匹配 ^ 字符;这并不是作为锚点的元字符。注意,我们使用了 r"\^hello…" 来创建一个原始字符串。Python 的规范显示结果为 '\\^hello…'。带有双反斜杠的规范版本可能难以输入。虽然原始字符串更容易处理,但它们的显示方式与我们输入的方式不同。

匹配一组字符

让我们从匹配任意字符开始。在正则表达式模式中,点号字符是一个元字符,代表包含所有字符的集合。这将匹配任何单个字符。在字符串中使用点号意味着你不在乎字符是什么,只要那里有一个字符即可。以下是matchy()函数的一些示例输出:

pattern='hel.o world' matches at match=<re.Match object; span=(0, 11), match='hello world'>
pattern='hel.o world' matches at match=<re.Match object; span=(0, 11), match='helpo world'>
pattern='hel.o world' matches at match=<re.Match object; span=(0, 11), match='hel o world'>
pattern='hel.o world' not found in text='helo world' 

注意到最后一个例子不匹配,因为模式中没有字符位于句号的位置。没有一些额外的特征,我们无法匹配“无”。我们将在本节稍后讨论可选字符的概念。

这听起来不错,但如果我们只想匹配一组较小的字符集怎么办?我们可以在方括号内放置一组字符来匹配这些字符中的任何一个。所以,如果我们在一个正则表达式模式中遇到字符串[abc],这定义了一组备选方案来匹配正在搜索的字符串中的一个字符;这个字符将位于字符集中。请注意,包围集合的[]是元字符;它们包含集合,并且不匹配自身。让我们看几个例子:

pattern='hel[lp]o world' matches at match=<re.Match object; span=(0, 11), match='hello world'>
pattern='hel[lp]o world' matches at match=<re.Match object; span=(0, 11), match='helpo world'>
pattern='hel[lp]o world' not found in text='helPo 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' 

有些字符类非常常见,以至于它们有自己的缩写。\d代表数字,\s代表空白字符,\w代表“单词”字符。与其使用[0-9],不如使用\d。与其尝试枚举所有 Unicode 空白字符,不如使用\s。与其使用[a-z0-9_],不如使用\w。以下是一个示例:

>>> matchy(r'\d\d\s\w\w\w\s\d\d\d\d', '26 Oct 2019')
pattern='\\d\\d\\s\\w\\w\\w\\s\\d\\d\\d\\d' matches at match=<re.Match object; span=(0, 11), match='26 Oct 2019'> 

没有定义的集合,这个模式将开始为 [0-9][0-9][ \t\n\r\f\v][A-Za-z0-9_][A-Za-z0-9_][A-Za-z0-9_]。随着我们重复 [ \t\n\r\f\v] 类和 [0-9] 类四次,它变得相当长。

当使用[]定义一个类时,-变成了一个元字符。如果我们还想匹配[A-Z]-,怎么办?我们可以通过将-放在非常开头或非常末尾来实现这一点;[A-Z-]表示在AZ之间的任何字符,也包括-

转义字符

如我们上面所提到的,许多字符具有特殊含义。例如,在一个模式中放置一个句号字符可以匹配任何任意字符。我们如何只匹配字符串中的一个句号呢?我们将使用反斜杠来转义特殊含义,并将字符从元字符(如类定义、锚点或类的开始)转变为一个普通字符来理解。这意味着我们通常会在正则表达式中有一系列的反斜杠字符,这使得 r 字符串变得非常有用。

这是一个用于匹配 0.00 到 0.99 之间两位小数数字的正则表达式:

pattern='0\\.[0-9][0-9]' matches at match=<re.Match object; span=(0, 4), match='0.05'>
pattern='0\\.[0-9][0-9]' not found in text='005'
pattern='0\\.[0-9][0-9]' not found in text='0,05' 

对于这个模式,两个字符\.匹配单个.字符。如果点号字符缺失或不是相同的字符,则不会匹配。

这个反斜杠转义序列用于正则表达式中的各种特殊字符。你可以使用\[来插入一个方括号而不开始一个字符类,并且使用\(来插入一个括号,我们稍后将会看到它也是一个元字符。

更有趣的是,我们还可以使用转义符号后跟一个字符来表示特殊字符,例如换行符(\n)和制表符(\t)。正如我们之前所看到的,一些字符类可以使用转义字符串更简洁地表示。

为了使原始字符串和反斜杠更加清晰,我们将再次包含函数调用,以展示我们编写的代码与 Python 对原始字符串的规范显示是分开的。

>>> matchy(r'\(abc\]', "(abc]")
pattern='\\(abc\\]' matches at match=<re.Match object; span=(0, 5), match='(abc]'>
>>> matchy(r'\s\d\w', " 1a")
pattern='\\s\\d\\w' matches at match=<re.Match object; span=(0, 3), match=' 1a'>
>>> matchy(r'\s\d\w', "\t5n")
pattern='\\s\\d\\w' matches at match=<re.Match object; span=(0, 3), match='\t5n'>
>>> matchy(r'\s\d\w', " 5n")
pattern='\\s\\d\\w' matches at match=<re.Match object; span=(0, 3), match=' 5n'> 

总结来说,这种反斜杠的使用有两种不同的含义:

  • 对于元字符,反斜杠\可以使其摆脱元字符的意义。例如,.代表一类字符,而\.则代表单个字符;同样,^是字符串开头的锚点,但\^则是帽子字符。

  • 对于一些普通字符,反斜杠用于命名字符类。这种用法并不常见;最常用的例子有 \s\d\w\S\D\W。大写变体 \S\D\W 是小写字母的逆。例如,\d 表示任何数字,而 \D 表示任何非数字。

这种奇怪的区别一开始可能会让人感到困惑。通常有帮助的是记住,字母前有一个反斜杠\会创建一个特殊情况,而标点符号前的\则移除了元字符的含义。

字符重复的模式

有了这些信息,我们能够匹配大多数已知长度的字符串,但大多数情况下,我们并不知道在模式内部需要匹配多少个字符。正则表达式也能处理这种情况。我们可以通过添加后缀字符来修改模式。当我们把正则表达式看作一个乘积时,一个重复的序列就像是对某个数进行幂运算。这遵循了a*a*a*a == a**4的模式。

星号(*)字符表示前面的模式可以匹配零次或多次。这听起来可能有些荒谬,但它是最有用的重复字符之一。在我们探讨为什么之前,考虑一些荒谬的例子以确保我们理解它的作用:

>>> matchy(r'hel*o', 'hello')
pattern='hel*o' matches at match=<re.Match object; span=(0, 5), match='hello'>
>>> matchy(r'hel*o', 'heo')
pattern='hel*o' matches at match=<re.Match object; span=(0, 3), match='heo'>
>>> matchy(r'hel*o', 'helllllo')
pattern='hel*o' matches at match=<re.Match object; span=(0, 8), match='helllllo'> 

因此,模式中的 * 字符表示前面的模式(即 l 字符)是可选的,如果存在,可以尽可能多地重复以匹配模式。其余的字符(heo)必须恰好出现一次。

如果我们将星号与匹配多个字符的模式结合起来,这会变得更加有趣。例如,.* 将匹配任何字符串,而 [a-z]* 则匹配任何由小写字母组成的集合,包括空字符串。以下是一些示例:

>>> matchy(r'[A-Z][a-z]* [a-z]*\.', "A string.")
pattern='[A-Z][a-z]* [a-z]*\\.' matches at match=<re.Match object; span=(0, 9), match='A string.'>
>>> matchy(r'[A-Z][a-z]* [a-z]*\.', "No .")
pattern='[A-Z][a-z]* [a-z]*\\.' matches at match=<re.Match object; span=(0, 4), match='No .'>
>>> matchy(r'[a-z]*.*', "")
pattern='[a-z]*.*' matches at match=<re.Match object; span=(0, 0), match=''> 

模式中加号(+)的行为类似于星号(*);它表示前面的模式可以重复一次或多次;这意味着表达式不是可选的。问号(?)确保模式恰好出现零次或一次,但不能更多。让我们通过玩数字来探索这些模式(记住\d[0-9]匹配相同的字符类):

>>> matchy(r'\d+\.\d+', "0.4")
pattern='\\d+\\.\\d+' matches at match=<re.Match object; span=(0, 3), match='0.4'>
>>> matchy(r'\d+\.\d+', "1.002")
pattern='\\d+\\.\\d+' matches at match=<re.Match object; span=(0, 5), match='1.002'>
>>> matchy(r'\d+\.\d+', "1.")
pattern='\\d+\\.\\d+' not found in text='1.'
>>> matchy(r'\d?\d%', "1%")
pattern='\\d?\\d%' matches at match=<re.Match object; span=(0, 2), match='1%'>
>>> matchy(r'\d?\d%', "99%")
pattern='\\d?\\d%' matches at match=<re.Match object; span=(0, 3), match='99%'>
>>> matchy(r'\d?\d%', "100%")
pattern='\\d?\\d%' not found in text='100%' 

这些例子也说明了反斜杠 \ 的两种不同用法。对于点号 . 字符,\. 将其从匹配任何内容的元字符转换为字面意义上的点号。对于字母 d\d 将其从字面意义上的 d 转换为字符类 [0-9]。别忘了 *+? 都是元字符,而字面匹配它们意味着使用 \*\+\?

将模式分组

到目前为止,我们已经看到了如何多次重复一个模式,但我们受到可以重复的模式类型的限制。如果我们想重复单个字符,我们没问题,但如果我们想重复字符序列呢?将任何一组模式用括号括起来,在应用重复操作时,它们可以被当作一个单独的模式来处理。比较以下模式:

pattern='abc{3}' matches at match=<re.Match object; span=(0, 5), match='abccc'>
pattern='(abc){3}' not found in text='abccc'
pattern='(abc){3}' matches at match=<re.Match object; span=(0, 9), match='abcabcabc'> 

这源于正则表达式背后的核心数学原理。公式 具有截然不同的含义。

结合复杂模式,此分组功能极大地扩展了我们的模式匹配库。以下是一个匹配简单英文句子的正则表达式:

>>> matchy(r'[A-Z][a-z]*( [a-z]+)*\.$', "Eat.")
pattern='[A-Z][a-z]*( [a-z]+)*\\.$' matches at match=<re.Match object; span=(0, 4), match='Eat.'>
>>> matchy(r'[A-Z][a-z]*( [a-z]+)*\.$', "Eat more good food.")
pattern='[A-Z][a-z]*( [a-z]+)*\\.$' matches at match=<re.Match object; span=(0, 19), match='Eat more good food.'>
>>> matchy(r'[A-Z][a-z]*( [a-z]+)*\.$', "A good meal.")
pattern='[A-Z][a-z]*( [a-z]+)*\\.$' matches at match=<re.Match object; span=(0, 12), match='A good meal.'> 

第一词以大写字母开头,后跟零个或多个小写字母,[A-Z][a-z]*。然后,我们进入一个括号表达式,匹配一个空格后跟一个由一个或多个小写字母组成的单词,[a-z]+。这个整个括号表达式可以重复零次或多次,( [a-z]+)*。模式以句号结束。句号之后不能有其他字符,如模式末尾的$锚点所示。

我们已经看到了许多最基本的模式,但正则表达式语言支持更多。值得将 Python 的 re 模块文档加入书签并经常查阅。正则表达式几乎可以匹配任何东西,因此在解析不涉及复杂递归定义的字符串时,它们应该是你首先考虑的工具。

使用正则表达式解析信息

让我们现在关注 Python 方面的事情。正则表达式语法与面向对象编程相去甚远。然而,Python 的re模块提供了一个面向对象的接口来进入正则表达式引擎。

我们一直在检查re.match()函数是否返回一个有效的对象。如果模式不匹配,该函数返回None。然而,如果它匹配,它将返回一个有用的对象,我们可以检查它以获取有关模式的信息。

到目前为止,我们的正则表达式已经回答了诸如这个字符串是否匹配这个模式?的问题。匹配模式很有用,但在许多情况下,一个更有趣的问题可能是如果这个字符串匹配这个模式,相关子串的值是多少?如果你使用分组来标识你稍后想要引用的模式部分,你可以从匹配返回值中提取它们,如下一个示例所示:

def email_domain(text: str) -> Optional[str]:
    email_pattern = r"[a-z0-9._%+-]+@([a-z0-9.-]+\.[a-z]{2,})"
    if match := re.match(email_pattern, text, re.IGNORECASE):
        return match.group(1)
    else:
        return None 

描述所有有效电子邮件地址的完整规范极其复杂,准确匹配所有可能性的正则表达式异常地长。因此,我们采取了欺骗手段,创建了一个更小的正则表达式,它可以匹配许多常见的电子邮件地址;关键是我们想访问域名(在@符号之后)以便我们可以连接到该地址。这可以通过将模式的一部分用括号括起来,并在match()方法返回的对象上调用group()方法轻松实现。

我们使用了额外的参数值,re.IGNORECASE,来标记这个模式为不区分大小写。这使我们不必在模式中三个地方都使用 [a-zA-Z…]。当大小写不重要时,这是一个方便的简化方法。

收集匹配的组有三种方法。我们使用了group()方法,它提供一组匹配的组。由于只有一个()对,这似乎是谨慎的做法。更通用的groups()方法返回一个元组,包含模式内所有匹配的()组,我们可以通过索引来访问特定的值。组是从左到右排序的。然而,请注意,组可以是嵌套的,这意味着你可以在一个组内有一个或多个组。在这种情况下,组是按照它们最左边的(的顺序返回的,所以最外层的组会在其内部匹配的组之前返回。

我们还可以为组提供名称。语法看起来非常复杂。我们必须使用 (?P<name>…) 而不是 (…) 来收集匹配的文本作为一个组。?P<name> 是我们在括号内提供名为 name 的组名的方式。这使得我们可以使用 groupdict() 方法来提取名称及其内容。

这里是电子邮件域名解析器的另一种选择;这个使用命名组:

def email_domain_2(text: str) -> Optional[str]:
    email_pattern = r"(?P<name>[a-z0-9._%+-]+)@(?P<domain>[a-z0-9.-]+\.[a-z]{2,})"
    if match := re.match(email_pattern, text, re.IGNORECASE):
        return match.groupdict()["domain"]
    else:
        return None 

我们已经将模式修改为在括号()内添加?P<name>?<domain>,以提供这些捕获组的名称。这部分正则表达式不会改变匹配的内容,它只是为捕获组提供名称。

re 模块的其他特性

除了match()函数外,re模块还提供了一些其他有用的函数,例如search()findall()search()函数用于查找匹配模式的第一个实例,放宽了模式应该隐式锚定到字符串第一个字母的限制。请注意,您可以通过使用match()并在模式前添加一个.*字符来达到类似的效果,以匹配字符串开始和您要查找的模式之间的任何字符。

findall() 函数的行为与 search() 类似,但它找到的是匹配模式的全部非重叠实例,而不仅仅是第一个。可以将其想象为首先搜索第一个匹配项,然后在第一个匹配项结束后继续搜索以找到下一个匹配项。

与您预期的返回re.Match对象列表不同,它返回的是匹配的字符串列表或元组。有时是字符串,有时是元组。这根本不是一个好的 API!和所有糟糕的 API 一样,您必须记住这些差异,而不能依赖直觉。返回值的类型取决于正则表达式内部括号组的数量:

  • 如果模式中没有分组,re.findall()将返回一个字符串列表,其中每个值都是从源字符串中匹配到模式的完整子串

  • 如果模式中恰好有一个分组,re.findall() 将返回一个字符串列表,其中每个值都是该分组的内 容

  • 如果模式中有多个组,re.findall()将返回一个包含元组的列表,其中每个元组包含一个匹配组的值,顺序如下

一致性有助于

当你在设计自己的 Python 库中的函数调用时,尽量让函数总是返回一致的数据结构。设计能够接受任意输入并处理它们的函数通常是好的,但返回值不应该根据输入从单个值切换到列表,或者从值列表切换到元组列表。让 re.findall() 成为我们的教训!

以下交互会话中的示例有望阐明差异:

>>> import re
>>> re.findall(r"\d+[hms]", "3h 2m   45s")
['3h', '2m', '45s']
>>> re.findall(r"(\d+)[hms]", "3h:2m:45s")
['3', '2', '45']
>>> re.findall(r"(\d+)([hms])", "3h, 2m, 45s")
[('3', 'h'), ('2', 'm'), ('45', 's')]
>>> re.findall(r"((\d+)([hms]))", "3h - 2m - 45s")
[('3h', '3', 'h'), ('2m', '2', 'm'), ('45s', '45', 's')] 

看起来尽可能分解数据元素总是一个好的实践。在这种情况下,我们将数值与单位、小时、分钟或秒分离开来,这使得将一个复杂的字符串转换成时间间隔变得更加容易。

使正则表达式高效

每次调用正则表达式方法时,re 模块 都必须将模式字符串转换为一种内部结构,以便快速搜索字符串。这种转换需要相当多的时间。如果正则表达式模式将被多次使用(例如,在 forwhile 语句中),那么最好只进行一次这种转换步骤。

这可以通过re.compile()方法实现。它返回一个经过编译的面向对象的正则表达式对象,该对象具有我们已探索的方法(如match()search()findall())等。对我们所看到的内容的改变是微小的。以下是我们的使用方法:

>>> re.findall(r"\d+[hms]", "3h 2m   45s") 

我们可以创建一个两步操作,其中单个模式被重复用于多个字符串。

>>> duration_pattern = re.compile(r"\d+[hms]")
>>> duration_pattern.findall("3h 2m   45s")
['3h', '2m', '45s']
>>> duration_pattern.findall("3h:2m:45s")
['3h', '2m', '45s'] 

在使用之前预先编译模式是一种方便的优化。这使得应用程序稍微简单一些,并且更有效率。

这绝对是对正则表达式的简要介绍。到目前为止,我们对基础知识有了很好的感觉,并且会知道何时需要进一步研究。如果我们有一个字符串模式匹配问题,正则表达式几乎肯定能够为我们解决它们。然而,我们可能需要在一个更全面的正则表达式主题覆盖中查找新的语法。但现在我们知道该寻找什么了!一些工具,如 Pythex 在 pythex.org,可以帮助开发和调试正则表达式。让我们继续到一个完全不同的主题:文件系统路径。

文件系统路径

大多数操作系统都提供了一个文件系统,这是一种将逻辑抽象的目录(通常表示为文件夹)和文件映射到硬盘或另一个存储设备上存储的位和字节的方法。作为人类,我们通常通过带有文件夹和不同类型文件图像的拖放界面与文件系统进行交互。或者,我们可以使用如cpmvmkdir之类的命令行程序。

作为程序员,我们必须通过一系列系统调用来与文件系统进行交互。你可以把它们看作是操作系统提供的库函数,以便程序可以调用它们。它们有一个笨拙的接口,包括整数文件句柄和缓冲的读写操作,而且这个接口根据你使用的操作系统而有所不同。Python 的 os 模块暴露了其中一些底层调用。

os模块中包含os.path模块。虽然它能够工作,但并不十分直观。它需要大量的字符串连接操作,并且你必须注意操作系统之间的差异。例如,有一个os.sep属性表示路径分隔符;在符合 POSIX 规范的操作系统上它是"/",而在 Windows 上是"\"。使用它可能会导致如下所示的代码:

>>> import os.path
>>> path = os.path.abspath(
...     os.sep.join(
...         ["", "Users", "dusty", "subdir", "subsubdir", "file.ext"]))
>>> print(path)
/Users/dusty/subdir/subsubdir/file.ext 

os.path 模块隐藏了一些平台特定的细节。但这一点仍然迫使我们以字符串的形式处理路径。

使用字符串形式的文件系统路径通常令人烦恼。在命令行上容易输入的路径在 Python 代码中变得难以辨认。当处理多个路径时(例如,在处理机器学习计算机视觉问题中的数据管道中的图像时),仅仅管理这些目录就变成了一件有点麻烦的事情。

因此,Python 语言设计者将一个名为pathlib的模块包含在了标准库中。它是对路径和文件的对象化表示,与使用起来更加愉快。使用pathlib的前一个路径看起来会是这样:

>>> from pathlib import Path
>>> path = Path("/Users") / "dusty" / "subdir" / "subsubdir" / "file.ext"
>>> print(path)
/Users/dusty/subdir/subsubdir/file.ext 

正如你所见,观察正在发生的事情要容易得多。注意除法运算符的独特用法,作为路径分隔符,这样你就不必对os.sep做任何事情。这是对 Python 的__truediv__()方法进行重载的优雅应用,为Path对象提供这一功能。

在一个更贴近现实世界的例子中,考虑一些代码,这些代码可以计算给定目录及其子目录中所有 Python 文件中代码行的数量——不包括空白和注释:

from pathlib import Path
from typing import Callable
def scan_python_1(path: Path) -> int:
    sloc = 0
    with path.open() as source:
        for line in source:
            line = line.strip()
            if line and not line.startswith("#"):
                sloc += 1
    return sloc
def count_sloc(path: Path, scanner: Callable[[Path], int]) -> int:
    if path.name.startswith("."):
        return 0
    elif path.is_file():
        if path.suffix != ".py":
            return 0
        with path.open() as source:
            return scanner(path)
    elif path.is_dir():
        count = sum(
            count_sloc(name, scanner) for name in path.iterdir())
        return count
    else:
        return 0 

在典型的pathlib使用中,我们很少需要构造许多Path对象。在这个例子中,基本的Path作为参数提供。大部分的Path操作是定位相对于给定Path的其他文件或目录。其余的与Path相关的处理是请求特定Path的属性。

count_sloc() 函数会查看路径名称,跳过以 "." 开头的名称。这避免了 "." 和 ".." 目录,但它也会跳过由我们的工具创建的类似 .tox.coverage.git 的目录。

有三种一般情况:

  • 实际文件可能包含 Python 源代码。我们确保文件名的后缀是 .py 以确保我们想要打开该文件。我们将调用给定的 scanner() 函数来打开并读取每个 Python 文件。在计算源代码方面有几种方法;我们在这里展示了其中一种,即在 scan_python_1() 函数中,该函数应作为参数值提供。

  • 目录。在这种情况下,我们遍历目录的内容,对我们在这个目录内找到的项调用count_sloc()函数。

  • 其他文件系统对象,如设备挂载名称、符号链接、设备、FIFO 队列和套接字。我们忽略这些。

Path.open 方法与内置的 open 函数具有类似的参数,但它使用更面向对象的语法。我们可以使用 Path('./README.md').open() 来打开文件进行读取,如果路径已存在。

scan_python_1() 函数遍历文件中的每一行并将其添加到计数中。我们跳过空白行和注释行,因为这些并不代表实际的源代码。总计数被返回到调用函数。

这就是如何调用此函数来统计一个目录中的代码行数。

>>> base = Path.cwd().parent
>>> chapter =  base / "ch_02"
>>> count = count_sloc(chapter, scan_python_1)
>>> print(
...     f"{chapter.relative_to(base)}: {count} lines of code"
... )
ch_02: 542 lines of code 

这展示了这个相当复杂的例子中唯一的 Path() 构造函数。我们从 当前工作目录CWD)跳转到父目录。从那里我们可以进入 ch_02 子目录并四处翻找,查看目录和 Python 文件。

这也展示了我们如何将scan_python_1()函数作为扫描参数的参数值。要深入了解将函数作为其他函数的参数的使用,请参阅第八章面向对象与函数式编程的交汇点

pathlib 模块中的 Path 类拥有一个方法或属性,几乎可以覆盖你想要对路径进行的所有操作。除了我们在示例中提到的那些之外,这里还有一些 Path 对象的额外方法和属性:

  • .absolute() 返回从文件系统根目录开始的完整路径。这有助于显示相对路径的来源。

  • .parent 返回父目录的路径。

  • .exists() 检查文件或目录是否存在。

  • .mkdir() 在当前路径下创建一个目录。它接受 parentsexist_ok 参数来指示如果需要应递归创建目录,并且如果目录已存在则不应抛出异常。

请参考标准库文档docs.python.org/3/library/pathlib.html以获取更多用法。作者们自豪地为这个库做出了贡献。

几乎所有接受字符串路径的标准库模块也可以接受一个pathlib.Path对象。使用os.PathLike类型提示来描述接受Path的参数。例如,你可以通过传递一个路径来打开一个 ZIP 文件:

>>> zipfile.ZipFile(Path('nothing.zip'), 'w').writestr('filename', 'contents') 

一些外部包可能无法与Path对象一起使用。在这种情况下,您需要使用str(pathname)将路径转换为字符串。

语句与代码行

scan_python_1() 函数将三引号的多行字符串中的每一行都当作代码行来计数。如果我们确定每一行物理上的内容都很重要,那么即使它实际上不是代码,一个长的文档字符串也可能是有意义的。另一方面,我们可能决定我们想要计数有意义的语句而不是物理行;在这种情况下,我们需要一个更智能的函数,该函数使用 ast 模块。与尝试处理源文本相比,与抽象语法树ASTs)一起工作要好得多。使用 ast 模块不会改变 Path 处理。这比阅读文本要复杂一些,并且超出了本书的范围。如果我们计数语句(而不是可能是语句或可能是三引号注释的行),那么有 257 个语句,分布在 542 行代码中。

我们已经探讨了工作字符串、字节和文件系统路径。接下来,我们需要介绍如何将应用程序的对象保存到文件中,以及如何从文件的字节中恢复对象。我们称这个过程为序列化。

对象序列化

我们一直使用字节和文件路径作为支持处理持久化对象的基础。为了使一个对象持久化,我们需要创建一系列代表对象状态的字节,并将这些字节写入文件。因此,持久化过程中缺失的部分就是将对象编码为一系列字节的过程。我们还想从一系列字节中解码对象及其关系。这种编码和解码过程也被称为序列化反序列化

当我们查看网络服务时,经常会看到一个服务被描述为 RESTful。这里的“REST”概念代表的是表现层状态转移;服务器和客户端将交换对象状态的表示。这里的区别可能很有帮助:这两块软件并不交换对象。应用程序有自己的内部对象;它们交换的是对象状态的表示。

对象序列化的方法有多种。我们将从使用pickle模块的一个简单且通用的方法开始。稍后,我们将探讨json包作为替代方案。

Python 的 pickle 模块是一种面向对象的方式,可以直接将对象状态存储在一种特殊的存储格式中。它本质上将对象的状态(以及它作为属性持有的所有对象的状态)转换成一系列字节,这些字节可以按我们的需求进行存储或传输。

对于基本任务,pickle模块提供了一个极其简单的接口。它包含四个基本函数用于存储和加载数据:两个用于操作类似文件的对象,另外两个用于操作bytes对象,这样我们就可以在不一定需要打开文件的情况下处理序列化对象。

dump() 方法接受一个要写入的对象和一个用于写入序列化字节的类似文件对象。类似文件对象必须有一个 write() 方法,并且该方法必须知道如何处理 bytes 参数。这意味着以文本输出打开的文件将不起作用;我们需要以 wb 模式打开文件。

load()方法正好相反;它从一个类似文件的对象中读取序列化对象的状态。此对象必须具有适当的类似文件read()readline()方法,每个方法当然都必须返回bytespickle模块将读取这些字节,而load()方法将返回完全重建的对象。以下是一个示例,它在一个列表对象中存储一些数据,然后加载这些数据:

>>> import pickle
>>> some_data = [
... "a list", "containing", 5, "items",
... {"including": ["str", "int", "dict"]}
... ]
>>> 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)
['a list', 'containing', 5, 'items', {'including': ['str', 'int', 'dict']}]
>>> assert loaded_data == some_data 

此代码序列化由 some_list 指向的对象。这包括相关的字符串、字典,甚至整数。这些数据被存储在文件中,然后从同一文件中加载。在每种情况下,我们使用 with 语句打开文件,以确保它自动关闭。我们已小心使用 wbrb 模式,以确保文件处于字节模式而不是文本模式。

文件末尾的assert语句会在新加载的对象与原始对象不相等时引发错误。相等性并不意味着它们是同一个对象。实际上,如果我们打印出两个对象的id(),我们会发现它们是具有不同内部标识符的不同对象。然而,由于它们都是内容相等的列表,这两个列表也被认为是相等的。

dumps()loads() 函数的行为与它们的文件类对应函数非常相似,只是它们返回或接受的是 bytes 而不是文件类对象。dumps 函数只需要一个参数,即要存储的对象,并返回一个序列化的 bytes 对象。loads() 函数需要一个 bytes 对象,并返回恢复的对象。方法名中的 's' 字符代表字符串;它是 Python 早期版本中的一个遗留名称,在那个版本中,使用 str 对象而不是 bytes

在单个已打开文件上可以多次调用dump()load()。每次对dump的调用将存储一个单独的对象(以及它所组成的或包含的任何对象),而load()的调用将加载并返回一个对象。因此,对于单个文件,在存储对象时对dump()的每次单独调用都应该在稍后恢复时有一个相关的load()调用。

重要的是要意识到,对象的表示高度集中在 Python 的一个特定主要版本上。例如,在 Python 3.7 中创建的 pickle 文件可能无法被 Python 3.8 使用。这表明 pickle 文件适合临时持久化,但不适合长期存储或在不同可能不具有相同版本的 Python 应用程序之间共享。

从序列化表示恢复对象状态的过程在某些情况下可能会导致评估 pickle 文件中埋藏的任意代码。这意味着 pickle 文件可能成为恶意代码的载体。这导致在 pickle 模块的文档中有一个显著的警告:

警告

pickle模块不安全。仅解包你信任的数据。

这些建议通常引导我们避免在没有信任发送者并确保中间没有人篡改文件的情况下接受 pickle 格式的文件。使用 pickle 作为临时缓存的应用程序无需担忧。

定制泡菜

对于大多数常见的 Python 对象,序列化(pickling)总是有效。基本原始类型,如整数、浮点数和字符串,可以被序列化,同样,任何容器对象,如列表或字典,也可以被序列化,前提是这些容器的内容也必须是可序列化的。此外,并且非常重要的一点是,任何对象都可以被序列化,只要它的所有属性也都是可序列化的。

那么,什么使得一个属性不可序列化?通常,这与可能发生变化的动态属性值有关。例如,如果我们有一个打开的网络套接字、打开的文件、正在运行的线程、子进程、处理池或作为对象属性存储的数据库连接,那么对这些对象进行序列化就没有意义了。当我们尝试稍后重新加载对象时,设备和操作系统状态将变得毫无意义。我们不能假装原始的线程或套接字连接在重新加载时仍然存在!不,我们需要以某种方式自定义如何转储和加载这种短暂和动态的数据。

这里有一个每小时加载网页内容的类,以确保内容保持最新。它使用threading.Timer类来安排下一次更新:

from threading import Timer
import datetime
from urllib.request import urlopen
class URLPolling:
    def __init__(self, url: str) -> None:
        self.url = url
        self.contents = ""
        self.last_updated: datetime.datetime
        self.timer: Timer
        self.update()
    def update(self) -> None:
        self.contents = urlopen(self.url).read()
        self.last_updated = datetime.datetime.now()
        self.schedule()
    def schedule(self) -> None:
        self.timer = Timer(3600, self.update)
        self.timer.setDaemon(True)
        self.timer.start() 

类似于 urlcontentslast_updated 这样的对象都是可序列化的,但如果尝试序列化这个类的实例,self.timer 实例就会变得有点疯狂:

>>> import pickle
>>> poll = URLPolling("http://dusty.phillips.codes")
>>> pickle.dumps(poll)
Traceback (most recent call last):
  ...
  File "<doctest url_poll.__test__.test_broken[2]>", line 1, in <module>
pickle.dumps(poll)
TypeError: cannot pickle '_thread.lock' object 

这不是一个很有用的错误,但看起来我们正在尝试对不应该 pickle 的东西进行 pickle 操作。那将是Timer实例;我们在schedule()方法中存储了对self.timer的引用,而这个属性不能被序列化。

pickle尝试序列化一个对象时,它只是尝试存储状态,即对象的__dict__属性值;__dict__是一个字典,将对象上的所有属性名映射到它们的值。幸运的是,在检查__dict__之前,pickle会检查是否存在一个__getstate__()方法。如果存在,它将存储该方法的返回值而不是__dict__对象。

让我们在URLPolling类中添加一个__getstate__()方法,该方法简单地返回一个不包含不可序列化的计时器对象的__dict__的副本:

def __getstate__(self) -> dict[str, Any]:
pickleable_state = self.__dict__.copy()
    if "timer" in pickleable_state:
        del pickleable_state["timer"]
    return pickleable_state 

如果我们将这个URLPolling扩展版本的实例进行序列化,它将不再失败。我们甚至可以使用loads()方法成功恢复该对象。然而,恢复的对象没有self.timer属性,因此它不会像设计的那样刷新内容。我们需要在对象反序列化时以某种方式创建一个新的计时器(以替换缺失的那个)。

正如我们可能预料的,存在一个可以实现的互补的 __setstate__() 方法来定制反序列化过程。此方法接受一个单一参数,即由 __getstate__ 返回的对象。如果我们实现了这两个方法,__getstate__() 就不需要返回一个字典,因为 __setstate__() 会知道如何处理 __getstate__() 选择返回的任何对象。在我们的情况下,我们只是想恢复 __dict__,然后创建一个新的计时器:

def __setstate__(self, pickleable_state: dict[str, Any]) -> None:
    self.__dict__ = pickleable_state
    self.schedule() 

__init__()__setstate__() 之间的相似之处很重要。两者都涉及到调用 self.schedule() 来创建(或重新创建)内部计时器对象。这是处理具有动态状态且必须恢复的序列化对象时的常见模式。

pickle 模块非常灵活,并提供其他工具以供进一步自定义序列化过程,如果您需要的话。然而,这些内容超出了本书的范围。我们已涵盖的工具对于许多基本的序列化任务已经足够。要序列化的对象通常是相对简单的数据对象。一些流行的机器学习框架,如 scikit-learn,使用 pickle 来保存创建的模型。这使得数据科学家可以使用该模型进行预测或进一步测试。

由于安全限制,我们需要一个用于交换数据的替代格式。基于文本的格式可能很有帮助,因为通常检查一个文本文件以确保它不是恶意的要容易得多。我们将探讨 JSON 作为一种流行的基于文本的序列化格式。

使用 JSON 序列化对象

这些年,用于文本数据交换的格式有很多。可扩展标记语言XML)很受欢迎,但文件往往很大。另一种标记语言YAML)是另一种偶尔会提到的格式。表格数据通常以逗号分隔值CSV)格式交换。其中许多正在逐渐消失,你将在未来遇到更多。Python 为它们都提供了稳固的标准库或第三方库。

在使用这些库处理不可信数据之前,务必调查每个库的安全问题。例如,XML 和 YAML 都有一些不为人知的特性,如果被恶意使用,可能会允许在宿主机器上执行任意命令。这些特性可能默认并未关闭。做好你的研究。即使是看似简单的 ZIP 文件或 JPEG 图像也可能被黑客攻击,创建出可能导致网页服务器崩溃的数据结构。

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 无法表示 Python 特有的对象,如类或函数定义。

通常,json模块的函数尝试使用对象的__dict__属性值来序列化对象的状态。一个更好的方法是提供自定义代码来将对象的状态序列化为一个 JSON 友好的字典。我们还想走另一条路:将 JSON 字典反序列化以恢复 Python 对象的状态。

json模块中,对象编码和解码函数都接受可选参数以自定义行为。dump()dumps()函数接受一个命名不佳的cls关键字参数。(它是class的缩写,我们不得不以奇怪的方式拼写,因为class是一个保留关键字。)如果这个参数值被提供给函数,它应该是一个JSONEncoder类的子类,并且重写了default()方法。这个重写的default()方法接受一个任意的 Python 对象,并将其转换为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__属性:

>>> import json
>>> c = Contact("Noriko", "Hannah")
>>> json.dumps(c.__dict__)
'{"first": "Noriko", "last": "Hannah"}' 

但以这种方式访问特殊的 __dict__ 属性有点粗糙。当属性具有 json 模块尚未序列化的值时,这可能会导致问题;datetime 对象就是一个常见的问题。此外,如果接收代码(可能是一些网页上的 JavaScript)希望提供 full_name 属性怎么办?当然,我们可以手动构造字典,但让我们创建一个自定义编码器代替:

import json
class ContactEncoder(json.JSONEncoder):
    def default(self, obj: Any) -> Any:
        if isinstance(obj, Contact):
            return {
                "__class__": "Contact",
                "first": obj.first,
                "last": obj.last,
                "full_name": obj.full_name,
            }
        return super().default(obj) 

默认方法需要检查我们试图序列化的对象类型。如果是Contact类型,我们手动将其转换为字典。否则,我们让父类处理序列化(假设它是一个基本类型,json知道如何处理)。请注意,我们传递了一个额外的属性来识别这个对象是一个联系人,因为在加载时无法区分。

在某些情况下,我们可能需要提供一个更完整、完全限定的名称,包括包和模块。记住,字典的格式取决于接收端的代码;必须就如何指定数据达成一致。

我们可以通过将类(而不是实例化对象)传递给dumpdumps函数来使用这个类来编码一个联系人:

>>> c = Contact("Noriko", "Hannah")
>>> text = json.dumps(c, cls=ContactEncoder)
>>> text
'{"__class__": "Contact", "first": "Noriko", "last": "Hannah", "full_name": "Noriko Hannah"}' 

对于解码,我们可以编写一个函数,该函数接受一个字典并检查是否存在__class__属性,以决定是否将其转换为Contact实例或保持为默认字典:

def decode_contact(json_object: Any) -> Any:
    if json_object.get("__class__") == "Contact":
        return Contact(json_object["first"], json_object["last"])
    else:
        return json_object 

我们可以使用object_hook关键字参数将此函数传递给load()loads()函数:

>>> some_text = (
...     '{"__class__": "Contact", "first": "Milli", "last": "Dale", '
...     '"full_name": "Milli Dale"}'
... )
>>> c2 = json.loads(some_text, object_hook=decode_contact)
>>> c2.full_name
'Milli Dale' 

这些示例展示了我们如何使用 JSON 来交换编码了多个常见 Python 对象的对象。对于不常见的 Python 对象,有直接的方法添加编码器或解码器来处理更复杂的情况。在更大的应用程序中,我们可能包括一个特殊的 to_json() 方法来生成一个有用的对象序列化。

案例研究

在案例研究的上一章节中,我们一直在回避一个在处理复杂数据时经常出现的问题。文件既有逻辑布局也有物理格式。我们一直隐含地假设我们的文件是 CSV 格式,布局由文件的第一行定义。在第二章中,我们提到了文件加载。在第六章中,我们重新讨论了数据的加载以及将其划分为训练集和测试集。

在前两章中,我们相信数据将以 CSV 格式存在。这不是一个很好的假设。我们需要考虑替代方案,并将我们的假设提升为设计选择。同时,我们也需要构建灵活性,以便随着我们应用使用环境的演变而进行更改。

将复杂对象映射到字典中是很常见的,因为字典有整洁的 JSON 表示形式。因此,Classifier 网络应用程序利用了字典。我们还可以将 CSV 数据解析成字典。与字典一起工作的想法为 CSV、Python 和 JSON 提供了一种某种意义上的大统一。我们将首先查看 CSV 格式,然后再探讨一些序列化的替代方案,如 JSON。

CSV 格式设计

我们可以利用csv模块来读取和写入文件。CSV代表逗号分隔值,最初是为了从电子表格中导出和导入数据而设计的。

CSV 格式描述了一组行。每一行是一组字符串。这就是全部,但也可能存在一定的局限性。

CSV 中的“逗号”是一个角色,而不是一个特定的字符。这个字符的作用是分隔数据列。在大多数情况下,逗号的角色由字面上的“,”扮演。但其他字符也可以承担这个角色。常见的做法是使用制表符字符,写作“\t”或“\x09”,来扮演逗号的角色。

行尾通常是 CRLF 序列,写作 "\r\n" 或 \x0d\x0a。在 macOS X 和 Linux 上,每行末尾也可以使用单个换行符,\n。同样,这只是一个角色,还可以使用其他字符。

为了在列的数据中包含逗号字符,可以将数据引用起来。这通常是通过在列的值周围使用 " 字符来实现的。在描述 CSV 方言时,可以指定不同的引用字符。

因为 CSV 数据仅仅是字符串的序列,对数据的任何其他解释都需要通过我们的应用程序进行处理。例如,在 TrainingSample 类中,load() 方法包括如下处理:

test = TestingKnownSample(
    species=row["species"],
    sepal_length=float(row["sepal_length"]),
    sepal_width=float(row["sepal_width"]),
    petal_length=float(row["petal_length"]),
    petal_width=float(row["petal_width"]),
) 

这个 load() 方法从每一行中提取特定的列值,将转换函数应用于文本以构建一个 Python 对象,并使用所有属性值来构建结果对象。

消费(和生成)CSV 格式数据的两种方式。我们可以将每一行作为一个字典来处理,或者将每一行处理为一个简单的字符串列表。我们将探讨这两种方法,看看它们在我们的案例研究中适用得如何。

CSV 字典读取器

我们可以将 CSV 文件读取为字符串序列,或者作为字典。当我们以字符串序列读取文件时,没有为列标题做特殊处理。我们被迫管理哪些列具有特定属性的细节。这很复杂,但有时是必要的。

我们还可以读取 CSV 文件,使每一行成为一个字典。我们可以提供一个键的序列,或者文件的第一行可以提供键。这是相对常见的做法,当列标题是数据的一部分时,它可以减少一些混淆。

我们一直在研究 Bezdek Iris 数据集作为我们的案例研究。该数据集的副本可在 Kaggle 仓库中找到,www.kaggle.com/uciml/iris。数据同样可在 archive.ics.uci.edu/ml/datasets/iris 获取。UCI 机器学习仓库中的文件 bezdekIris.data 没有列标题;这些标题在名为 iris.names 的单独文件中提供。

iris.names 文件中包含大量信息,包括文档第七部分中的以下内容:

7\. Attribute Information:
   1\. sepal length in cm
   2\. sepal width in cm
   3\. petal length in cm
   4\. petal width in cm
   5\. class: 
      -- Iris Setosa
      -- Iris Versicolour
      -- Iris Virginica 

这定义了五列数据。元数据与样本数据之间的这种分离并不理想,但我们可以将此信息复制粘贴到代码中,从而使其变得有用。

我们将用它来定义一个 Iris 读取器类,如下所示:

class CSVIrisReader:
    """
    Attribute Information:
       1\. sepal length in cm
       2\. sepal width in cm
       3\. petal length in cm
       4\. petal width in cm
       5\. class:
          -- Iris Setosa
          -- Iris Versicolour
          -- Iris Virginica
    """
  header = [
        "sepal_length",  # in cm
        "sepal_width",  # in cm
        "petal_length",  # in cm
        "petal_width",  # in cm
        "species",  # Iris-setosa, Iris-versicolour, Iris-virginica
    ]
    def __init__(self, source: Path) -> None:
        self.source = source
    def data_iter(self) -> Iterator[dict[str, str]]:
        with self.source.open() as source_file:
            reader = csv.DictReader(source_file, self.header)
            yield from reader 

我们将文档转换成一系列列名。这种转换并非随意为之。我们匹配了结果KnownSample类的属性名称。

在相对简单的应用中,数据来源单一,因此类和 CSV 文件列的属性名称容易保持一致。但这并不总是如此。在某些问题领域,数据可能有多个变体名称和格式。我们可能会选择看似合适的属性名称,但可能并不简单地匹配任何输入文件。

data_iter() 方法有一个表明它是多个数据项迭代器的名字。类型提示 (Iterator[Dict[str, str]]) 确认了这一点。该函数使用 yield from 来按需提供来自 CSV DictReader 对象的行。

这是一种“懒惰”的方式来读取 CSV 文件中的行,因为它们被另一个对象所需要。迭代器就像是一个使用看板技术的工厂——它根据需求准备数据。这种方式不会一次性将整个文件吸入,从而创建一个庞大的字典列表。相反,迭代器每次只产生一个字典,正如它们被请求的那样。

从迭代器请求数据的一种方法是通过使用内置的list()函数。我们可以这样使用这个类:

>>> from model import CSVIrisReader
>>> from pathlib import Path
>>> test_data = Path.cwd().parent/"bezdekIris.data"
>>> rdr = CSVIrisReader(test_data)
>>> samples = list(rdr.data_iter())
>>> len(samples)
150
>>> samples[0]
{'sepal_length': '5.1', 'sepal_width': '3.5', 'petal_length': '1.4', 'petal_width': '0.2', 'species': 'Iris-setosa'} 

CSV 的 DictReader 生成一个字典。我们使用 self.header 的值提供了这个字典的键;另一种选择是使用文件的第一个行作为键。在这种情况下,文件的第一行没有列标题,因此我们提供了列标题。

data_iter() 方法为消费类或函数生成行。在这个例子中,list() 函数消费了可用的行。正如预期的那样,数据集有 150 行。我们已经展示了第一行。

注意属性值是字符串。在读取 CSV 文件时这一点始终成立:所有输入值都是字符串。我们的应用程序必须将字符串转换为float类型的值,以便能够创建KnownSample对象。

另一种消费值的方式是使用for循环语句。这就是TrainingData类的load()方法是如何工作的。它使用如下所示的代码:

def load(self, raw_data_iter: Iterator[Dict[str, str]]) -> None:
    for n, row in enumerate(raw_data_iter):
        ... more processing here 

我们将一个IrisReader对象与这个对象结合来加载样本。它看起来是这样的:

>>> training_data = TrainingData("besdekIris")
>>> rdr = CSVIrisReader(test_data)
>>> training_data.load(rdr.data_iter()) 

load()方法将消耗由data_iter()方法产生的值。数据的加载是两个对象之间的协作过程。

将 CSV 数据作为字典处理似乎非常方便。为了展示一种替代方法,我们将转向使用非字典 CSV 读取器来读取数据。

CSV 列表读取器

非字典 CSV 读取器从每一行生成一个字符串列表。然而,这并不是我们的TrainingData集合的load()方法所期望的。

我们有两个选择来满足load()方法的接口要求:

  1. 将列值列表转换为字典。

  2. load()方法改为使用固定顺序的值列表。这会带来一个不幸的后果,即迫使TrainingData类的load()方法与特定的文件布局相匹配。或者,我们不得不重新排序输入值以匹配load()的要求;这样做大约与构建字典一样复杂。

构建一个字典似乎相对简单;这使得load()方法能够处理列布局与我们的初始预期不同的数据。

这里有一个名为 CSVIrisReader_2 的类,它使用 csv.reader() 来读取文件,并根据在 iris.names 文件中发布的属性信息构建字典。

class CSVIrisReader_2:
    """
    Attribute Information:
       1\. sepal length in cm
       2\. sepal width in cm
       3\. petal length in cm
       4\. petal width in cm
       5\. class:
          -- Iris Setosa
          -- Iris Versicolour
          -- Iris Virginica
    """
    def __init__(self, source: Path) -> None:
        self.source = source
    def data_iter(self) -> Iterator[dict[str, str]]:
        with self.source.open() as source_file:
            reader = csv.reader(source_file)
            for row in reader:
                yield dict(
                    sepal_length=row[0],  # in cm
                    sepal_width=row[1],  # in cm
                    petal_length=row[2],  # in cm
                    petal_width=row[3],  # in cm
                    species=row[4]  # class string
                ) 

data_iter() 方法产生单个字典对象。这个 for-with-yield 概括了 yield from 的作用。当我们写 yield from X 时,这实际上等同于更长的

for item in X:
    yield item 

对于这个应用,非字典处理是通过从输入行创建一个字典来工作的。这似乎并没有比csv.DictReader类有任何优势。

另一个重要的选择是 JSON 序列化。我们将探讨如何将本章中展示的技术应用到我们的案例研究数据中。

JSON 序列化

JSON 格式可以序列化多种常用的 Python 对象类,包括:

  • 无(None)

  • 布尔值

  • 浮点数和整数

  • 字符串

  • 兼容对象列表

  • 字符串键和兼容对象值的字典

"兼容对象"可以包括嵌套结构。这种列表内的字典和字典内的字典递归可以允许 JSON 表示非常复杂的事物。

我们可以考虑以下一个理论上的(但无效的)类型提示:

JSON = Union[
    None, bool, int, float, str, List['JSON'], Dict[str, 'JSON']
] 

这个提示在mypy中并不直接支持,因为它涉及显式递归:JSON 类型是基于 JSON 类型定义的。这个提示可以作为一个有助于理解我们在 JSON 表示法中可以表示什么的概念框架。从实际的角度来看,我们通常使用Dict[str, Any]来描述 JSON 对象,忽略可能存在的其他结构的细节。然而,当我们知道字典的预期键时,我们可以更加具体一些;我们将在下面展开讨论。

在 JSON 表示法中,我们的数据将看起来像这样:

[
  {
    "sepal_length": 5.1,
    "sepal_width": 3.5,
    "petal_length": 1.4,
    "petal_width": 0.2,
    "species": "Iris-setosa"
  },
  {
    "sepal_length": 4.9,
    "sepal_width": 3.0,
    "petal_length": 1.4,
    "petal_width": 0.2,
    "species": "Iris-setosa"
  }, 

注意,数值没有引号,如果它们包含.字符,则会被转换为float值,如果它们缺少.字符,则会被转换为整数。

json.org 标准要求文件中包含一个单独的 JSON 对象。这促使我们创建一个“字典列表”结构。从实用主义的角度来看,文件的结构可以通过以下类型提示来概括:

JSON_Samples = List[Dict[str, Union[float, str]]] 

该文档——作为一个整体——是一个列表。它包含多个将字符串键映射到浮点数或字符串值的字典。

如上所述,我们可以对预期的键更加具体。在这种情况下,我们希望将我们的应用程序限制为只处理特定的字典键。我们可以通过使用typing.TypedDict提示来更加精确:

class SampleDict(TypedDict):
    sepal_length: float
    sepal_width: float
    petal_length: float
    petal_width: float
    species: str 

这对于mypy(以及其他阅读我们代码的人)来说可能很有帮助,因为它展示了预期的结构应该是怎样的。我们甚至可以添加total=True来断言定义显示了所有有效键的整个域。

这个TypedDict提示并不能真正确认 JSON 文档的内容是否有效或合理。记住,mypy只对代码进行静态检查,并且没有运行时影响。要检查 JSON 文档的结构,我们需要比 Python 类型提示更复杂的东西。

这里是我们的 JSON 读取器类定义:

class JSONIrisReader:
    def __init__(self, source: Path) -> None:
        self.source = source
    def data_iter(self) -> Iterator[SampleDict]:
        with self.source.open() as source_file:
            sample_list = json.load(source_file)
        yield from iter(sample_list) 

我们已经打开了源文件并加载了字典列表对象。然后我们可以通过遍历列表来逐个生成样本字典。

这存在一个隐藏的成本。我们将探讨如何通过修改标准,使用换行符分隔的 JSON 来帮助减少使用的内存。

换行符分隔的 JSON

对于大量对象的集合,首先将单个庞大的列表读入内存并不是理想的做法。由ndjson.org描述的“按行分隔”的 JSON 格式提供了一种将大量单独的 JSON 文档放入单个文件的方法。

文件将看起来像这样:

{"sepal_length": 5.0, "sepal_width": 3.3, "petal_length": 1.4, "petal_width": 0.2, "species": "Iris-setosa"}
{"sepal_length": 7.0, "sepal_width": 3.2, "petal_length": 4.7, "petal_width": 1.4, "species": "Iris-versicolor"} 

没有整体的[]来创建列表。每个单独的样本必须在文件的单独一行中完整。

这导致我们在处理文档序列的方式上存在细微的差异:

class NDJSONIrisReader:
    def __init__(self, source: Path) -> None:
        self.source = source
    def data_iter(self) -> Iterator[SampleDict]:
        with self.source.open() as source_file:
            for line in source_file:
                sample = json.loads(line)
                yield sample 

我们已经读取了文件中的每一行,并使用json.loads()将单个字符串解析成样本字典。接口保持一致:一个Iterator[SampleDict]。生成该迭代器的技术是针对以换行符分隔的 JSON 的独特方法。

JSON 验证

我们注意到我们的mypy类型提示并不能真正保证 JSON 文档确实是我们所期望的。Python 包索引中有一个包可以用于此目的。jsonschema包允许我们为 JSON 文档提供一个规范,然后确认文档是否符合该规范。

我们需要安装一个额外的库来进行验证:

python -m pip install jsonschema 

JSON Schema 验证是一个运行时检查,与 mypy 类型提示不同。这意味着使用验证会使我们的程序变慢。它还可以帮助诊断微小的 JSON 文档错误。有关详细信息,请参阅 json-schema.org。这正朝着标准化方向发展,并且有多个符合性检查版本可用。

我们将专注于换行符分隔的 JSON。这意味着在更大的文档集合中,我们需要为每个样本文档定义一个模式。当接收一批未知样本进行分类时,这种额外的验证可能相关。在采取任何行动之前,我们希望确保样本文档具有正确的属性。

JSON Schema 文档也使用 JSON 编写。它包含一些元数据,有助于阐明文档的目的和意义。通常,使用 JSON Schema 定义创建 Python 字典会稍微容易一些。

这里是一个针对单个样本的 Iris 架构的候选定义:

IRIS_SCHEMA = {
    "$schema": "https://json-schema.org/draft/2019-09/hyper-schema",
    "title": "Iris Data Schema",
    "description": "Schema of Bezdek Iris data",
    "type": "object",
    "properties": {
        "sepal_length": {
            "type": "number", "description": "Sepal Length in cm"},
        "sepal_width": {
            "type": "number", "description": "Sepal Width in cm"},
        "petal_length": {
            "type": "number", "description": "Petal Length in cm"},
        "petal_width": {
            "type": "number", "description": "Petal Width in cm"},
        "species": {
            "type": "string",
            "description": "class",
            "enum": [
                "Iris-setosa", "Iris-versicolor", "Iris-virginica"],
        },
    },
    "required": [
"sepal_length", "sepal_width", "petal_length", "petal_width"],
} 

每个样本都是一个 object,这是 JSON Schema 中用于表示具有键和值的字典的术语。对象的 properties 是字典的键。这些键中的每一个都用数据类型进行描述,在这种情况下是 number。我们可以提供额外的细节,例如值的范围。我们提供了一个描述,取自 iris.names 文件。

species属性的情况下,我们提供了一系列有效的字符串值。这有助于确认数据是否符合我们的整体预期。

我们通过创建一个jsonschema验证器并应用该验证器来检查我们读取的每个样本来使用这个模式信息。一个扩展的类可能看起来像这样:

class ValidatingNDJSONIrisReader:
    def __init__(self, source: Path, schema: dict[str, Any]) -> None:
        self.source = source
        self.validator = jsonschema.Draft7Validator(schema)
    def data_iter(self) -> Iterator[SampleDict]:
        with self.source.open() as source_file:
            for line in source_file:
                sample = json.loads(line)
                if self.validator.is_valid(sample):
                    yield sample
                else:
                    print(f"Invalid: {sample}") 

我们在__init__()方法中接受了一个额外的参数,该参数包含模式定义。我们使用这个参数来创建将应用于每个文档的Validator实例。

data_iter() 方法使用 validatoris_valid() 方法来处理仅通过 JSON Schema 验证的样本。其他样本将被报告并忽略。我们使用 print() 函数打印了输出。使用 file=sys.stderr 关键字参数将输出定向到错误输出会更聪明。使用 logging 包将错误消息写入日志会更好。

注意,我们现在有两个独立的、但类似的定义用于构建Sample实例的原始数据:

  1. 用于描述预期 Python 中间数据结构的类型提示 SampleDict。这同样适用于 CSV 以及 JSON 数据,并有助于总结 TrainingData 类的 load() 方法与各种读取器之间的关系。

  2. 一个同时描述预期外部数据结构的 JSON Schema。这并不描述一个 Python 对象,而是描述了 Python 对象的 JSON 序列化。

对于非常简单的情况,这两种数据描述似乎有些冗余。然而,在更复杂的情况下,这两种描述将会出现分歧,并且在外部模式、中间结果和最终类定义之间的相当复杂的转换是 Python 应用程序的常见特征。这是因为序列化 Python 对象有多种方式。我们需要足够灵活,以便与各种有用的表示形式一起工作。

回忆

在本章中,我们探讨了以下主题:

  • 将字符串编码为字节以及将字节解码为字符串的方法。虽然一些较老的字符编码(如 ASCII)将字节和字符同等对待,但这会导致混淆。Python 文本可以是任何 Unicode 字符,而 Python 字节是范围在 0 到 255 之间的数字。

  • 字符串格式化允许我们准备包含模板片段和动态片段的字符串对象。这在 Python 中的许多情况下都适用。其中之一是为人们创建可读的输出,但我们可以在从各个部分创建复杂字符串的任何地方使用 f 字符串和字符串format()方法。

  • 我们使用正则表达式来分解复杂的字符串。实际上,正则表达式与花哨的字符串格式化器正好相反。正则表达式在区分我们要匹配的字符和提供额外匹配规则(如重复或选择)的“元字符”方面存在困难。

  • 我们已经探讨了几种数据序列化的方法,包括 Pickle、CSV 和 JSON。还有一些其他格式,如 YAML,与 JSON 和 Pickle 足够相似,所以我们没有详细涵盖它们。其他序列化方式如 XML 和 HTML 则要复杂得多,我们避开了它们。

练习

我们在本章中涵盖了广泛的主题,从字符串到正则表达式,再到对象序列化,最后又回到起点。现在,是时候考虑如何将这些想法应用到您自己的代码中了。

Python 字符串非常灵活,Python 也是处理字符串的强大工具。如果你在日常工作中不经常进行字符串处理,试着设计一个专门用于字符串操作的工具。尽量想出一些创新的东西,但如果遇到瓶颈,可以考虑编写一个网站日志分析器(每小时有多少请求?有多少人访问了五个以上的页面?)或者是一个模板工具,用其他文件的內容替换特定的变量名。

花大量时间玩转字符串格式化运算符,直到你记住了它们的语法。编写一些模板字符串和对象,将它们传递给格式化函数,看看能得到什么样的输出。尝试一些异国风情的格式化运算符,比如百分比或十六进制表示法。尝试使用填充和对齐运算符,看看它们在整数、字符串和浮点数上的表现有何不同。考虑编写一个包含__format__方法的类;我们之前没有详细讨论这一点,但探索一下你可以定制格式化的程度。

确保你理解bytesstr对象之间的区别。Python 对字节显示的方式看起来像字符串可能会让人困惑。唯一棘手的部分是知道何时以及如何在这两者之间进行转换。为了练习,尝试将文本数据写入一个以bytes模式打开的文件(你需要自己编码文本),然后从同一个文件中读取。

bytearray上进行一些实验。看看它如何同时像bytes对象和列表或容器对象一样工作。尝试向一个缓冲区写入数据,该缓冲区在返回之前将数据存储在字节数组中,直到达到一定的长度。你可以通过使用time.sleep调用模拟将数据放入缓冲区的代码,以确保数据不会太快到达。

在线学习正则表达式。再深入学习一些。特别是了解命名组、贪婪匹配与懒惰匹配,以及正则表达式标志,这三个我们在本章中没有涉及到的特性。有意识地决定何时不使用它们。很多人对正则表达式有非常强烈的看法,要么过度使用,要么完全拒绝使用。尽量让自己相信只在适当的时候使用它们,并找出何时是适当的时候。

如果你曾经编写过适配器来从文件或数据库中加载少量数据并将其转换为对象,考虑使用 pickle。pickle 不适合存储大量数据,但它们在加载配置或其他简单对象时可能很有用。尝试用多种方式编码:使用 pickle、文本文件或小型数据库。你发现哪种方式最容易使用?

尝试对数据进行腌制实验,然后修改持有数据的类,并将腌制的数据加载到新的类中。哪些方法有效?哪些方法无效?有没有一种方法可以对类进行重大更改,例如重命名属性或将它拆分为两个新的属性,同时还能从旧的腌制文件中提取数据?(提示:尝试在每个对象上放置一个私有的腌制版本号,并在更改类时更新它;你可以在__setstate__中放置一个迁移路径。)

如果你从事任何形式的网页开发,JSON 序列化器将是核心。它可以通过坚持使用标准的 JSON 可序列化对象来简化事情,而不是编写自定义编码器或object_hooks,但设计取决于对象的复杂性和传输的状态表示。

在案例研究中,我们将 JSON Schema 验证应用于一个 JSON 文件。它同样适用于从 CSV 格式的文件中读取的行。这是两种常见数据格式中处理数据的强大工具组合;它有助于应用严格的验证规则,以确保行符合应用程序的期望。要了解这是如何工作的,请修改 CSVIrisReader 类以包含对数据行的 JSON Schema 验证。

摘要

我们在本章中介绍了字符串操作、正则表达式和对象序列化。可以使用强大的字符串格式化系统将硬编码的字符串和程序变量组合成可输出的字符串。区分二进制和文本数据非常重要,bytesstr具有特定的用途,这些用途必须理解。两者都是不可变的,但在处理字节时可以使用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 中的 迭代器 协议。此定义也被 typing 模块引用,以提供合适的类型提示。在基础层面,任何 Collection 类定义都必须是 Iterable。要成为 Iterable,意味着实现一个 __iter__() 方法;此方法创建一个 Iterator 对象。

图表描述自动生成

图 10.1:Iterable 的抽象

如前所述,一个Iterator类必须定义一个__next__()方法,该方法是for语句(以及其他支持迭代的特性)可以调用来从序列中获取新元素的方法。此外,每个Iterator类还必须实现Iterable接口。这意味着Iterator也将提供一个__iter__()方法。

这可能听起来有些令人困惑,所以请看一下下面的例子。请注意,这是一种非常冗长的解决问题的方式。它解释了迭代和所涉及的两个协议,但我们在本章后面将探讨几种更易读的方式来实现这一效果:

from typing import Iterable, Iterator
class CapitalIterable(Iterable[str]):
    def __init__(self, string: str) -> None:
        self.string = string
    def __iter__(self) -> Iterator[str]:
        return CapitalIterator(self.string)
class CapitalIterator(Iterator[str]):
    def __init__(self, string: str) -> None:
        self.words = [w.capitalize() for w in string.split()]
        self.index = 0
    def __next__(self) -> str:
        if self.index == len(self.words):
            raise StopIteration()
        word = self.words[self.index]
        self.index += 1
        return word 

此示例定义了一个CapitalIterable类,其任务是遍历字符串中的每个单词,并将它们以首字母大写的方式输出。我们通过使用Iterable[str]类型提示作为超类来形式化这一点,以明确我们的意图。这个可迭代类的大部分工作都委托给了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 

这个例子首先构建了一个可迭代对象,并将其赋值给一个名字平淡无奇的变量iterable。然后从iterable对象中检索出一个CapitalIterator实例。这种区别可能需要解释;可迭代对象是一个具有可迭代元素的对象。通常,这些元素可以被多次循环遍历,甚至可能同时或重叠地进行。另一方面,迭代器代表可迭代对象中的特定位置;一些项目已经被消费,而一些还没有。两个不同的迭代器可能在单词列表的不同位置,但任何一个迭代器只能标记一个位置。

每次在迭代器上调用next()时,它都会按顺序返回可迭代对象中的另一个标记,并更新其内部状态以指向下一个项目。最终,迭代器将耗尽(没有更多元素可以返回),在这种情况下,将引发StopIteration异常,然后我们跳出while语句。

Python 从可迭代对象构造迭代器的语法更简单:

>>> for i in iterable:
...     print(i)
...     
The
Quick
Brown
Fox
Jumps
Over
The
Lazy
Dog 

正如你所见,尽管for语句看起来与面向对象毫无关联,但实际上它是通往一些基本面向对象设计原则的捷径。在我们讨论生成器表达式时,请记住这一点,因为它们似乎也是面向对象工具的对立面。然而,它们与for语句使用相同的迭代协议,并且是另一种快捷方式。

Python 中可迭代的类数量很大。当字符串、元组和列表是可迭代的时,我们并不感到惊讶。显然,集合也必须是可迭代的,即使元素的顺序可能难以预测。映射默认会遍历键;其他迭代器也是可用的。文件会遍历可用的行。正则表达式有一个finditer()方法,它是一个迭代器,遍历它可以找到的每个匹配子串的实例。Path.glob()方法会遍历目录中的匹配项。range()对象也是一个迭代器。你明白了:任何稍微有点集合样式的对象都将支持某种类型的迭代器。

理解

理解是简单但强大的语法,它允许我们用一行代码将可迭代对象进行转换或过滤。结果对象可以是一个完全正常的列表、集合或字典,也可以是一个生成器表达式,在保持每次只存储一个元素的同时,可以高效地消费。

列推导式

列表推导是 Python 中最强大的工具之一,因此人们往往认为它们是高级的。其实并非如此。实际上,我们在之前的例子中已经自由地使用了推导,假设你们能够理解它们。虽然高级程序员确实大量使用推导,但这并不是因为它们高级。而是因为推导在 Python 中非常基础,它可以处理应用软件中许多最常见的操作。

让我们看看这些常见操作之一;即,将一个项目列表转换为相关项目列表。具体来说,假设我们刚刚从一个文件中读取了一个字符串列表,现在我们想要将其转换为整数列表。我们知道列表中的每个项目都是一个整数,并且我们想要对这些数字进行一些活动(比如,计算平均值)。这里有实现这一目标的一种简单方法:

>>> input_strings = ["1", "5", "28", "131", "3"]

>>> output_integers = [] 
>>> for num in input_strings: 
...    output_integers.append(int(num)) 

这工作得很好,而且只有三行代码。如果你不习惯使用列表推导式,你可能甚至觉得它看起来不丑!现在,看看使用列表推导式的相同代码:

>>> output_integers = [int(num) for num in input_strings] 

我们只剩下一行代码,并且对于性能来说,我们为列表中的每个项目去掉了append方法的调用。总体来说,即使你不习惯理解这种语法,也很容易看出发生了什么。

方括号始终表示我们正在创建一个列表。在这个列表内部有一个for循环,它会遍历输入序列中的每个项目。可能让人感到困惑的是列表开括号和for语句开始之间的内容。这里提供的任何表达式都会应用于输入列表中的每个项目。所讨论的项目通过for循环中的num变量来引用。因此,这个表达式将int函数应用于每个元素,并将结果整数存储在新的列表中。

从术语上讲,我们称这为映射。在这个例子中,我们应用结果表达式int(num),将源可迭代对象中的值映射到创建的结果可迭代列表中。

基本列表推导式的全部内容就是这些。推导式经过高度优化,在处理大量项目时比for语句要快得多。当明智地使用时,它们也更易于阅读。这就是广泛使用它们的两个强有力的理由。

将一个项目列表转换为相关列表并不是列表推导所能做的唯一事情。我们还可以选择通过在推导中添加一个if语句来排除某些值。我们称这为过滤器。看看这个例子:

>>> output_integers = [int(num) for num in input_strings if len(num) < 3]
>>> output_integers
[1, 5, 28, 3] 

与前一个示例相比,本质区别在于 if len(num) < 3 这条语句。这段额外的代码排除了任何超过两个字符的字符串。if 语句应用于每个元素最终 int() 函数之前,因此它是在测试字符串的长度。由于我们的输入字符串本质上都是整数,它排除了任何大于 99 的数字。

列推导式可以将输入值映射到输出值,同时在过程中应用过滤器以包含或排除满足特定条件的任何值。许多算法都涉及映射和过滤操作。

任何可迭代的对象都可以作为列表推导式的输入。换句话说,我们可以用for循环包裹的任何东西也可以用作推导式的来源。

例如,文本文件是可迭代的;对文件迭代器调用__next__()将返回文件的一行。我们可以通过在列表推导式的for子句中指定打开的文件来检查文本文件的行。然后,我们可以使用if子句来提取有趣的文本行。以下示例在测试文件中找到行的一个子集:

>>> from pathlib import Path
>>> source_path = Path('src') / 'iterator_protocol.py'
>>> with source_path.open() as source:
...     examples = [line.rstrip() 
...         for line in source 
...         if ">>>" in line] 

在这个例子中,我们添加了一些空白来使理解更加易读(列表推导式不必适应一行物理空间,即使它们是逻辑上的一行)。此示例创建了一个包含">>>"提示符的行列表。">>>"的存在表明这个文件中可能有一个 doctest 示例。对行列表应用了rstrip()方法,以移除尾随空白,例如迭代器返回的每行文本末尾的\n。结果列表对象examples暗示了一些可以在代码中找到的测试用例。(这并不像 doctest 自己的解析器那样聪明。)

让我们将这个例子扩展一下,以便捕获包含">>>"提示符的每个示例的行号。这是一个常见的需求,内置的enumerate()函数帮助我们将一个数字与迭代器提供的每个项目配对:

>>> with source_path.open() as source:
...     examples = [(number, line.rstrip()) 
...         for number, line in enumerate(source, start=1) 
...         if ">>>" in line] 

enumerate() 函数消耗一个可迭代对象,提供一个由数字和原始项组成的元组的可迭代序列。如果该行通过了我们的 ">>>" 测试,我们将创建一个包含数字和清理后的文本的两个元组。我们在实际上只使用了一行代码就完成了一些复杂的处理。本质上,它是一个过滤和映射的过程。首先,它从源中提取元组,然后过滤掉与给定 if 子句匹配的行,接着评估 (number, line.rstrip()) 表达式以创建结果元组,最后将所有内容收集到一个列表对象中。这种迭代-过滤-映射-收集模式的普遍性推动了列表推导背后的思想。

集合和字典推导式

理解并不局限于列表。我们还可以使用类似的语法,用大括号创建集合和字典。让我们从集合开始。创建集合的一种方法是将列表推导式包裹在set()构造函数中,将其转换为集合。但为什么要在中间列表上浪费内存,而这个列表最终会被丢弃,当我们可以直接创建一个集合时?

这里有一个使用命名元组来建模作者/标题/类型三元组的示例,然后检索所有在特定类型中写作的作者集合:

>>> from typing import NamedTuple
>>> class Book(NamedTuple):
...     author: str
...     title: str
...     genre: str
>>> 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("Jemisin", "The Broken Earth", "fantasy"),
...     Book("Turner", "The Thief", "fantasy"),
...     Book("Phillips", "Preston Diamond", "western"),
...     Book("Phillips", "Twice Upon A Time", "scifi"),
... ] 

我们已经定义了一个Book类实例的小型库。我们可以通过使用集合推导来从这些对象中创建一个集合。它看起来很像列表推导,但使用{}而不是[]

>>> fantasy_authors = {b.author for b in books if b.genre == "fantasy"} 

与演示数据设置相比,高亮显示的集合理解确实要短得多!如果我们使用列表理解,当然,特里·普拉切特会被列出两次。实际上,集合的性质消除了重复项,我们最终得到以下结果:

>>> fantasy_authors
{'Pratchett', 'Le Guin', 'Turner', 'Jemisin'} 

注意,集合没有定义的顺序,因此您的输出可能与这个示例不同。出于测试目的,我们有时会将PYTHONHASHSEED环境变量设置为强制一个顺序。这引入了一个微小的安全漏洞,因此仅适用于测试。

仍然使用花括号,我们可以引入一个冒号来创建必需的 key:value 对来构建字典推导式。例如,如果我们知道书名,快速查找作者或类型在字典中可能很有用。我们可以使用字典推导式将书名映射到 books 对象:

fantasy_titles = {b.title: b for b in books if b.genre == "fantasy"} 

现在,我们有一个字典,可以使用正常语法通过书名查找书籍,例如fantasy_titles['Nightwatch']。我们已经从一个低性能序列创建了一个高性能索引。

总结来说,列表推导不是高级 Python,也不是颠覆面向对象编程的特性。它们是从现有的可迭代数据源创建列表、集合或字典的更简洁语法。

生成器表达式

有时候我们希望在不对系统内存中拉取新的列表、集合或字典的情况下处理一个新的序列。如果我们逐个迭代项目,并且实际上并不关心创建一个完整的容器(例如列表或字典),那么容器就是内存的浪费。当我们逐个处理项目时,我们只需要在任何时刻内存中可用的当前对象。但是,当我们创建一个容器时,所有对象都必须在开始处理之前存储在该容器中。

例如,考虑一个处理日志文件的程序。一个非常简单的日志可能包含以下格式的信息:

Apr 05, 2021 20:03:29 DEBUG This is a debugging message.
Apr 05, 2021 20:03:41 INFO This is an information method.
Apr 05, 2021 20:03:53 WARNING This is a warning. It could be serious.
Apr 05, 2021 20:03:59 WARNING Another warning sent.
Apr 05, 2021 20:04:05 INFO Here's some information.
Apr 05, 2021 20:04:17 DEBUG Debug messages are only useful if you want to figure something out.
Apr 05, 2021 20:04:29 INFO Information is usually harmless, but helpful.
Apr 05, 2021 20:04:35 WARNING Warnings should be heeded.
Apr 05, 2021 20:04:41 WARNING Watch for warnings. 

流行网络服务器、数据库或电子邮件服务器的日志文件可能包含数 GB 的数据(其中一位作者曾经不得不从一个行为异常的系统上清理近两 TB 的日志)。如果我们想处理日志中的每一行,我们不能使用列表推导;它会创建一个包含文件中每一行的列表。这很可能无法适应 RAM,并且可能会根据操作系统使计算机瘫痪。

如果我们在日志文件上使用for语句,我们就可以逐行处理,在读取下一行到内存之前。如果我们可以使用理解语法来达到同样的效果,那岂不是很好?

这就是生成器表达式发挥作用的地方。它们使用与列表推导式相同的语法,但不会创建一个最终的容器对象。我们称它们为懒加载;它们会根据需求不情愿地产生值。要创建一个生成器表达式,将推导式用括号()而不是方括号[]或大括号{}括起来。

以下代码解析了之前展示格式的日志文件,并输出一个只包含WARNING行的新的日志文件:

>>> from pathlib import Path
>>> full_log_path = Path.cwd() / "data" / "sample.log"
>>> warning_log_path = Path.cwd() / "data" / "warnings.log"
>>> with full_log_path.open() as source:
...     warning_lines = (line for line in source if "WARN" in line)
...     with warning_log_path.open('w') as target:
...         for line in warning_lines:
...             target.write(line) 

我们已经打开了sample.log文件,这个文件可能太大而无法全部装入内存。一个生成器表达式将过滤掉警告(在这种情况下,它使用if语法并保留该行未修改)。这是懒加载的,实际上直到我们消费其输出之前并不会做任何事情。我们可以打开另一个文件作为子集。最后的for语句消费warning_lines生成器中的每一行。在任何时候,完整的日志文件都不会被读入内存;处理是逐行进行的。

如果我们在我们的样本文件上运行它,生成的warnings.log文件看起来是这样的:

Apr 05, 2021 20:03:53 WARNING This is a warning. It could be serious.
Apr 05, 2021 20:03:59 WARNING Another warning sent.
Apr 05, 2021 20:04:35 WARNING Warnings should be heeded.
Apr 05, 2021 20:04:41 WARNING Watch for warnings. 

当然,对于一个小型的输入文件,我们可以安全地使用列表推导式,在内存中完成所有处理。当文件有数百万行长时,生成器表达式将对内存和速度产生巨大影响。

理解的核心是生成器表达式。将生成器用[]括起来创建一个列表。将生成器用{}括起来创建一个集合。使用{}:分隔键和值创建一个字典。将生成器用()括起来仍然是生成器表达式,而不是元组。

生成器表达式通常在函数调用中最为有用。例如,我们可以对生成器表达式调用summinmax,而不是列表,因为这些函数一次处理一个对象。我们只对汇总结果感兴趣,而不是任何中间容器。

通常情况下,在四个选项中,应尽可能使用生成器表达式。如果我们实际上不需要列表、集合或字典,而只是需要过滤或对序列中的项目应用映射,生成器表达式将是最有效的。如果我们需要知道列表的长度,或者对结果进行排序、移除重复项或创建字典,我们就必须使用推导式语法并创建一个结果集合。

生成器函数

生成器函数体现了生成器表达式的本质特征,它是理解的一种推广。生成器函数的语法看起来甚至比我们见过的任何东西都更非面向对象,但我们将再次发现,它是一种创建迭代对象类型的语法快捷方式。它帮助我们按照标准的迭代器-过滤-映射模式构建处理过程。

让我们进一步探讨日志文件示例。如果我们想要将日志分解成列,我们将在映射步骤中进行更显著的转换。这需要使用正则表达式来查找时间戳、严重性词以及整个消息。我们将探讨几个解决这个问题的方案,以展示如何应用生成器和生成函数来创建我们想要的对象。

这里是一个完全避免使用生成器表达式的版本:

import csv
import re
from pathlib import Path
from typing import Match, cast
def extract_and_parse_1(
        full_log_path: Path, warning_log_path: Path
)-> None:
    with warning_log_path.open("w") as target:
        writer = csv.writer(target, delimiter="\t")
        pattern = re.compile(
            r"(\w\w\w \d\d, \d\d\d\d \d\d:\d\d:\d\d) (\w+) (.*)")
        with full_log_path.open() as source:
            for line in source:
                if "WARN" in line:
                    line_groups = cast(
                        Match[str], pattern.match(line)).groups()
                    writer.writerow(line_groups) 

我们定义了一个正则表达式来匹配三个组:

  • 复杂的日期字符串,(\w\w\w \d\d, \d\d\d\d \d\d:\d\d:\d\d), 是像 "Apr 05, 2021 20:04:41" 这样的字符串的泛化。

  • 严重程度级别,(\w+),匹配一串字母、数字或下划线。这将匹配像 INFO 和 DEBUG 这样的单词。

  • 一个可选的消息,(.*),它将收集到行尾的所有字符。

此模式被分配给pattern变量。作为替代,我们也可以使用split(' ')将行分割成空格分隔的单词;前四个单词是日期,下一个单词是严重性,所有剩余的单词是消息。这不如定义正则表达式灵活。

将行分解成组涉及两个步骤。首先,我们对文本行应用 pattern.match() 来创建一个 Match 对象。然后我们查询 Match 对象以获取匹配的组序列。我们使用 cast(Match[str], pattern.match(line)) 来告诉 mypy 每一行都会创建一个 Match 对象。re.match() 的类型提示是 Optional[Match],因为它在没有匹配时返回 None。我们使用 cast() 来声明每一行都会匹配,如果它没有匹配,我们希望这个函数抛出一个异常。

这个深度嵌套的函数看起来是可维护的,但在如此少的行数中却有着如此多的缩进级别,看起来有点丑陋。更令人担忧的是,如果文件中存在一些不规则性,而我们又想处理pattern.match(line)返回None的情况,我们就必须包含另一个if语句,从而导致更深层次的嵌套。深度嵌套的条件处理会导致执行它们的条件变得模糊不清。

读者必须心理上整合所有前面的if语句来计算出条件。这种解决方案可能会出现这样的问题。

现在让我们考虑一个真正面向对象的解决方案,没有任何捷径:

import csv
import re
from pathlib import Path
from typing import Match, cast, Iterator, Tuple, TextIO
class WarningReformat(Iterator[Tuple[str, ...]]):
    pattern = re.compile(
        r"(\w\w\w \d\d, \d\d\d\d \d\d:\d\d:\d\d) (\w+) (.*)")
    def __init__(self, source: TextIO) -> None:
        self.insequence = source
    def __iter__(self) -> Iterator[tuple[str, ...]]:
        return self
    def __next__(self) -> tuple[str, ...]:
        line = self.insequence.readline()
        while line and "WARN" not in line:
            line = self.insequence.readline()
        if not line:
            raise StopIteration
        else:
            return tuple(
                cast(Match[str], 
                     self.pattern.match(line)
                ).groups()
            )
def extract_and_parse_2(
        full_log_path: Path, warning_log_path: Path
) -> None:
    with warning_log_path.open("w") as target:
        writer = csv.writer(target, delimiter="\t")
        with full_log_path.open() as source:
            filter_reformat = WarningReformat(source)
            for line_groups in filter_reformat:
                writer.writerow(line_groups) 

我们定义了一个正式的 WarningReformat 迭代器,它输出日期、警告和消息的三元组。我们使用了类型提示 tuple[str, ...],因为它与 self.pattern.match(line).groups() 表达式的输出相匹配:它是一个字符串序列,没有对存在数量的限制。迭代器使用一个 TextIO 对象初始化,这是一个类似文件的对象,具有 readline() 方法。

这个 __next__() 方法从文件中读取行,丢弃任何不是 WARNING 行的行。当我们遇到一个 WARNING 行时,我们解析它并返回一个包含三个字符串的三元组。

extract_and_parse_2() 函数在 for 循环中使用 WarningReformat 类的一个实例;这将反复评估 __next__() 方法以处理后续的 WARNING 行。当我们用完行时,WarningReformat 类会引发一个 StopIteration 异常来告诉函数语句我们已经完成了迭代。与其它示例相比,这看起来相当丑陋,但它也很强大;现在我们手中有了这个类,我们可以用它来做任何我们想做的事情。

在具备这些背景知识之后,我们终于能够看到真正的生成器在实际中的应用。接下来的这个例子与上一个例子完全相同:它创建了一个具有__next__()方法的对象,当输入耗尽时,该方法会引发StopIteration异常:

from __future__ import annotations
import csv
import re
from pathlib import Path
from typing import Match, cast, Iterator, Iterable
def warnings_filter(
        source: Iterable[str]
) -> Iterator[tuple[str, ...]]:
    pattern = re.compile(
        r"(\w\w\w \d\d, \d\d\d\d \d\d:\d\d:\d\d) (\w+) (.*)")
    for line in source:
        if "WARN" in line:
            yield tuple(
                cast(Match[str], pattern.match(line)).groups())
def extract_and_parse_3(
        full_log_path: Path, warning_log_path: Path
) -> None:
    with warning_log_path.open("w") as target:
        writer = csv.writer(target, delimiter="\t")
        with full_log_path.open() as infile:
            filter = warnings_filter(infile)
            for line_groups in filter:
                writer.writerow(line_groups) 

warning_filters()函数中的yield语句是生成器的关键。当 Python 在函数中看到yield时,它会将该函数包装在一个遵循Iterator协议的对象中,这与我们之前示例中定义的类类似。将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__()方法时,它会恢复状态,并从上次离开的地方继续执行。

这个生成器函数几乎与这个生成器表达式相同:

warnings_filter = (
    tuple(cast(Match[str], pattern.match(line)).groups())
    for line in source
    if "WARN" in line
) 

我们可以看到这些各种模式是如何对齐的。生成器表达式包含了语句的所有元素,略微压缩,并且顺序不同:

图表描述自动生成

图 10.2:生成器函数与生成器表达式的比较

因此,一个理解(comprehension)是一个被[]{}包裹的生成器,用于创建一个具体对象。在某些情况下,使用list()set()dict()作为生成器的外包装是有意义的。当我们考虑用我们自己的定制集合替换通用集合时,这很有帮助。将list()改为MySpecialContainer()似乎使这种变化更加明显。

生成器表达式具有简洁且直接出现在所需位置的优势。生成器函数有一个名称和参数,这意味着它可以被重用。更重要的是,在需要语句的情况下,生成器函数可以包含多个语句和更复杂的处理逻辑。从生成器表达式切换到函数的一个常见原因是为了添加异常处理。

从另一个可迭代对象中产生项目

通常,当我们构建一个生成器函数时,我们可能会遇到想要从另一个可迭代对象中产生数据的情况,这可能是一个我们在生成器内部构建的列表推导式或生成器表达式,或者可能是传递给函数的一些外部项目。我们将探讨如何使用yield from语句来实现这一点。

让我们对生成器示例进行一点修改,使其不再接受输入文件,而是接受一个目录的名称。想法是保持我们现有的警告过滤器生成器不变,但调整使用它的函数的结构。我们将对迭代器进行操作,作为输入和结果;这样,无论日志行来自文件、内存、网络还是另一个迭代器,都可以使用相同的函数。

代码的这个版本演示了一个新的file_extract()生成器。在从warnings_filter()生成器提供信息之前,它进行了一些基本的设置:

def file_extract(
        path_iter: Iterable[Path]
) -> Iterator[tuple[str, ...]]:
    for path in path_iter:
        with path.open() as infile:
            yield from warnings_filter(infile)
def extract_and_parse_d(
        directory: Path, warning_log_path: Path) -> None:
    with warning_log_path.open("w") as target:
        writer = csv.writer(target, delimiter="\t")
        log_files = list(directory.glob("sample*.log"))
        for line_groups in file_extract(log_files):
            writer.writerow(line_groups) 

我们的高级函数 extract_and_parse_d() 进行了一点点修改,现在使用 file_extract() 函数而不是打开一个文件并对一个文件应用 warnings_filter()file_extract() 生成器将产生所有在参数值中提供的 所有 文件中的 WARNING 行。

yield from 语法在编写链式生成器时是一个有用的快捷方式。

在这个例子中,关键的是每个生成器的懒惰性。考虑当extract_and_parse_d()函数,即客户端,提出需求时会发生什么:

  1. 客户端评估 file_extract(log_files)。由于这在一个 for 循环语句中,因此会进行 __iter__() 方法评估。

  2. file_extract() 生成器从 path_iter 可迭代对象中获取一个迭代器,并使用它来获取下一个 Path 实例。Path 对象用于创建一个提供给 warnings_filter() 生成器的文件对象。

  3. warnings_filter() 生成器使用文件的行迭代器读取,直到找到 WARNING 行,然后解析该行,并生成一个元组。找到此行所需读取的行数最少。

  4. file_extract() 生成器从 warnings_filter() 生成器中产生,因此单个元组被提供给最终客户端,即 extract_and_parse_d() 函数。

  5. extract_and_parse_d() 函数将单个元组写入打开的 CSV 文件,然后要求另一个元组。这个请求发送到 file_extract(),它将请求传递给 warnings_filter()warnings_filter() 将请求传递到打开的文件,以提供行,直到找到 WARNING 行。

每个生成器都是懒惰的,只提供一个响应,尽可能少做工作以产生结果。这意味着一个包含大量巨型日志文件的目录通过只打开一个日志文件,解析并处理当前行来处理。无论文件有多大,它都不会占用内存。

我们已经看到生成器函数如何向其他生成器函数提供数据。我们也可以用普通的生成器表达式做到这一点。我们将对warnings_filter()函数做一些小的修改,以展示我们如何创建一个生成器表达式的堆栈。

生成器堆栈

生成器函数(以及生成器表达式)对于warnings_filter做出了一个令人不快的假设。使用cast()mypy提出了一种——或许——不恰当的主张。以下是一个示例:

warnings_filter = (
    tuple(cast(Match[str], pattern.match(line)).groups())
    for line in source
    if "WARN" in line
) 

使用 cast() 是一种断言 pattern.match() 总是会返回一个 Match[str] 对象的方式。这不是一个很好的假设。有人可能会更改日志文件的格式,以包含多行消息,而我们的 WARNING 过滤器每次遇到多行消息时都会崩溃。

这里是一条可能引起问题的信息,随后是一条易于处理的信息:

Jan 26, 2015 11:26:01 INFO This is a multi-line information
message, with misleading content including WARNING
and it spans lines of the log file WARNING used in a confusing way
Jan 26, 2015 11:26:13 DEBUG Debug messages are only useful if you want to figure something out. 

第一行在一个多行消息中包含单词WARN,这将打破我们对包含单词WARN的行的假设。我们需要更加小心地处理这个问题。

我们可以将这个生成器表达式重写为一个生成器函数,并添加一个赋值语句(用于保存Match对象)以及一个if语句来进一步分解过滤过程。我们还可以使用 Walrus 操作符 := 来保存Match对象。

我们可以将生成器表达式重构成以下生成器函数:

def warnings_filter(source: Iterable[str]
) -> Iterator[Sequence[str]]:
    pattern = re.compile
        (r"(\w\w\w \d\d, \d\d\d\d \d\d:\d\d:\d\d) (\w+) (.*)")
    for line in source:
        if match := pattern.match(line):
            if "WARN" in match.group(2):
                yield match.groups() 

如我们上面所提到的,这种复杂的过滤往往趋向于深层嵌套的if语句,这可能会创建难以总结的逻辑。在这种情况下,这两个条件并不特别复杂。一个替代方案是将这转换为一系列的映射和过滤阶段,每个阶段都对输入进行单独的、小的转换。我们可以将匹配和过滤分解为以下步骤:

  • 使用 pattern.match() 方法将源行映射到 Optional[Match[str]] 对象。

  • 过滤掉任何None对象,只传递好的Match对象,并应用groups()方法创建一个List[str]

  • 过滤字符串以拒绝非WARN行,并传递WARN行。

每个这些阶段都是一个遵循标准模式的生成器表达式。我们可以将warnings_filter表达式展开成三个表达式的堆栈:

possible_match_iter = (pattern.match(line) for line in source)
group_iter = (
    match.groups() for match in possible_match_iter if match)
warnings_filter = (
    group for group in group_iter if "WARN" in group[1]) 

这些表达式当然是极其懒惰的。最后的warnings_filter使用了可迭代对象group_iter。这个可迭代对象从另一个生成器possible_match_iter获取匹配项,而possible_match_iter则从source对象获取源文本行,source对象是一个行可迭代的来源。由于这些生成器中的每一个都在从另一个懒惰迭代器获取项目,因此在处理过程的每个阶段,只有一行数据通过if子句和最终表达式子句进行处理。

注意,我们可以利用周围的()将每个表达式拆分成多行。这有助于展示每个表达式中体现的映射或过滤操作。

只要符合这个基本的映射-过滤设计模式,我们就可以注入额外的处理。在继续之前,我们将切换到一个稍微更友好的正则表达式,用于定位日志文件中的行:

pattern = re.compile(
    r"(?P<dt>\w\w\w \d\d, \d\d\d\d \d\d:\d\d:\d\d)"
    r"\s+(?P<level>\w+)"
    r"\s+(?P<msg>.*)"
) 

这个正则表达式被拆分为三个相邻的字符串;Python 会自动连接字符串字面量。该表达式使用了三个命名组。例如,日期时间戳是第一组,这是一些难以记住的琐事。() 内的 ?P<dt> 表示 Match 对象的 groupdict() 方法将在结果字典中有一个键 dt。随着我们引入更多的处理步骤,我们需要对中间结果有更清晰的了解。

这里展示了一个有时有帮助的正则表达式图像:

图片

图 10.3:日志行正则表达式图

让我们将这个例子扩展到将日期时间戳转换为另一种格式。这涉及到从输入格式到所需输出格式的转换。我们可以一次性完成,也可以分多次小步骤进行。

这一系列步骤使得在不破坏整个处理流程的情况下添加或更改单个步骤变得更加容易:

possible_match_iter = (
    pattern.match(line) for line in source)
group_iter = (
    match.groupdict() for match in possible_match_iter if match)
warnings_iter = (
    group for group in group_iter if "WARN" in group["level"])
dt_iter = (
    (
        datetime.datetime.strptime(g["dt"], "%b %d, %Y %H:%M:%S"),
        g["level"],
        g["msg"],
    )
    for g in warnings_iter
)
warnings_filter = (
    (g[0].isoformat(), g[1], g[2]) for g in dt_iter) 

我们创建了两个额外的阶段。第一个阶段将输入时间解析为 Python datetime 对象;第二个阶段将 datetime 对象格式化为 ISO 格式。将转换过程分解成小步骤,使我们能够将每个映射操作和每个过滤操作视为独立的、分开的步骤。在创建这些更小、更容易理解的步骤时,我们可以有更多的灵活性来添加、更改和删除。想法是将每个转换隔离成单独的对象,由生成器表达式描述。

dt_iter 表达式的结果是匿名元组的可迭代对象。这是一个NamedTuple可以增加清晰度的位置。有关NamedTuple的更多信息,请参阅第七章Python 数据结构

我们有另一种方式来观察这些转换步骤,即使用内置的 map()filter() 函数。这些函数提供了与生成器表达式类似的功能,但使用了一种稍微不同的语法:

possible_match_iter = map(pattern.match, source)
good_match_iter = filter(None, possible_match_iter)
group_iter = map(lambda m: m.groupdict(), good_match_iter)
warnings_iter = filter(lambda g: "WARN" in g["level"], group_iter)
dt_iter = map(
    lambda g: (
        datetime.datetime.strptime(g["dt"], "%b %d, %Y %H:%M:%S"),
        g["level"],
        g["msg"],
    ),
    warnings_iter,
)
warnings_filter = map(
    lambda g: (g[0].isoformat(), g[1], g[2]), dt_iter) 

Lambda 对象是无名函数。每个 lambda 都是一个具有参数的单个表达式,该表达式被评估并返回的可调用对象。Lambda 的主体中没有名称和语句。这个管道中的每个阶段都是一个离散的映射或过滤操作。虽然我们可以将映射和过滤组合成一个单一的 map(lambda ..., filter(lambda ..., source)),但这可能会过于混乱,反而没有帮助。

possible_match_iter 对每一行应用 pattern.match()good_match_iter 使用特殊的 filter(None, source) 过滤器,只传递非 None 对象,并拒绝 None 对象。group_iter 使用 lambda 表达式评估 good_match_iter 中每个对象 mm.groups()warnings_iter 将过滤 group_iter 的结果,只保留 WARN 行,并拒绝所有其他行。dt_iter 和最终的 warnings_filter 表达式将源日期时间格式转换为通用的 datetime 对象,然后以不同的字符串格式格式化 datetime 对象。

我们已经看到了处理复杂映射-过滤器问题的多种方法。我们可以编写嵌套的forif语句。我们可以创建显式的Iterator子类定义。我们还可以使用包含yield语句的函数定义来创建基于迭代器的对象。这为我们提供了Iterator类的正式接口,而不需要定义__iter__()__next__()方法所需的冗长样板代码。此外,我们还可以使用生成器表达式和列表推导式,将迭代器设计模式应用于多种常见场景。

迭代模式是 Python 编程的基础要素。每次我们处理一个集合时,我们都会遍历其中的项目,并且会使用迭代器。因为迭代如此核心,所以有各种方法来解决这个问题。我们可以使用 for 语句、生成器函数、生成器表达式,并且我们可以构建自己的迭代器类。

案例研究

Python 广泛使用迭代器和可迭代集合。这一基本特性在许多地方都有体现。每个for语句都隐式地使用了这一点。当我们使用函数式编程技术,如生成器表达式,以及map()filter()reduce()函数时,我们正在利用迭代器。

Python 拥有一个充满额外基于迭代器设计模式的 itertools 模块。这值得研究,因为它提供了许多使用内置构造函数即可轻松使用的常见操作的示例。

我们可以在我们的案例研究中将这些建议应用于多个地方:

  • 将所有原始样本划分为测试集和训练集。

  • 通过对所有测试用例进行分类来测试特定的k和距离超参数集。

  • k-最近邻(k-NN)算法本身以及它是如何从所有训练样本中定位到最近的k个邻居的。

这三个处理示例的共同点是每个示例的“对所有”方面。我们将稍微偏离一下,探讨理解列表推导和生成器函数背后的数学原理。这个数学并不特别复杂,但接下来的部分可以被视为深入背景。在这次离题之后,我们将深入探讨使用迭代器概念将数据划分为训练集和测试集。

集合构造背景

从严格意义上讲,我们可以用逻辑表达式来总结诸如分区、测试甚至定位最近邻等操作。一些开发者喜欢它的正式性,因为它可以在不强制特定 Python 实现的情况下帮助描述处理过程。

这里是分区的基本规则,例如。这涉及到一个“对所有”条件,它描述了样本集合 S 的元素:

图片

换句话说,对于所有在可用样本宇宙中的样本 s,即样本集 S,样本 s 的值要么在训练集 R 中,要么在测试集 E 中。这总结了数据成功划分的结果。这并不是直接描述一个算法,但拥有这个规则可以帮助我们确信我们没有遗漏任何重要的东西。

我们还可以为测试总结一个性能指标。召回率指标隐含了由 构造的“对所有”的含义:

图片

质量分数,q,是测试集E中所有e的求和,其中knn()分类器应用于e时匹配到e的物种s(e)为 1,否则为 0。这可以很好地映射到一个 Python 生成器表达式。

k-NN 算法在其定义上涉及更多的复杂性。我们可以将其视为一个划分问题。我们需要从一个有序对的集合开始。每一对表示未知点u到训练样本r的距离,总结为图片。正如我们在第三章中看到的,有几种方法可以计算这个距离。这必须对所有训练样本r在训练样本集R的宇宙中完成:

图片

然后我们需要将这些距离分为两个子集,NF(近和远),使得 N 中的所有距离都小于或等于 F 中的所有距离:

图片

我们还需要确保近邻集中的元素数量,即N,等于期望的邻居数量,即k

这种最终形式揭示了计算中的一个有趣细微差别。如果有超过 k 个邻居具有相同的距离度量呢?是否应该将所有等距的训练样本都包含在投票中?或者我们应该任意地选择恰好 k 个等距样本?如果我们“任意”地切割,选择等距训练样本时使用的确切规则是什么?选择规则甚至重要吗?这些问题可能是重要的问题,但它们超出了本书的范围。

本章后面的例子使用了sorted()函数,该函数倾向于保留原始顺序。这会不会导致我们在面对等距选择时对分类器产生偏差?这同样可能是一个重要问题,而且它也不在本书的讨论范围之内。

给定一点集合论知识,我们可以处理数据划分和计算k最近邻的想法,利用常见的迭代器特性。我们将从 Python 中划分算法的实现开始。

多分区

我们的目标是将测试数据和训练数据分开。然而,在道路上有一个小小的障碍,称为去重。整体质量的统计指标依赖于训练集和测试集的独立性;这意味着我们需要避免重复样本在测试集和训练集之间被分割。在我们能够创建测试和训练分区之前,我们需要找到任何重复的样本。

我们无法轻松地将每个样本与其他所有样本进行比较。对于大量样本集,这可能需要非常长的时间。一万样本的集合会导致一亿次的重复检查。这并不实用。相反,我们可以将我们的数据划分为子组,其中所有测量特征的值很可能是相等的。然后,从这些子组中,我们可以选择测试和训练样本。这使我们能够避免将每个样本与其他所有样本进行比较以寻找重复项。

如果我们使用 Python 的内部哈希值,我们可以创建包含可能具有相等值的样本的桶。在 Python 中,如果项目相等,它们必须具有相同的整数哈希值。反之则不成立:项目可能偶然具有相同的哈希值,但实际上并不相等。

正式来说,我们可以这样说:

图片

也就是说,如果 Python 中的两个对象 a 和 b 相等,它们也必须具有相同的哈希值 。反之则不成立,因为相等不仅仅是简单的哈希值检查;可能存在 ;哈希值可能相同,但底层对象实际上并不相等。我们称这种情况为“两个不相等值的哈希冲突”。

继续这个思路,以下是对模运算定义的问题:

图片

如果两个值相等,它们也等于这些值的任何模数。当我们想知道 a == b 是否成立时,我们可以询问 a % 2 == b % 2;如果两个数都是奇数或都是偶数,那么 ab 可能相等。如果一个是偶数而另一个是奇数,它们就不可能相等。

对于复杂对象,我们可以使用 hash(a) % m == hash(b) % m。如果两个哈希值,模 m 后相同,那么这两个哈希值可能是相同的,并且两个对象 ab 也可能是相等的。我们知道多个对象可能具有相同的哈希值,甚至更多对象在模 m 后具有相同的哈希值。

虽然这并不能告诉我们两个项目是否相等,但这项技术将精确比较所需对象的域限制在非常小的几个项目池中,而不是所有样本的整个集合。如果我们避免拆分这些子组之一,我们就可以避免重复。

这里是七个样本的视图,根据它们的哈希码模 3 分为三个子组。大多数子组中的项目在潜在上是相等的,但实际上并不相等。其中一个组实际上有一个重复的样本:

图表描述自动生成

图 10.4:将样本数据分区以定位重复项

要找到重复的样本,我们不需要将每个样本与其他六个样本进行比较。我们可以在每个子组内部查找,并比较几个样本,看看它们是否恰好是重复的。

这种去重方法的理念是将整个样本集分成六十个桶,其中样本的哈希值相等,模六十。同一桶中的样本可能相等,作为一个简单的权宜之计,我们可以将它们视为相等。不同桶中的样本具有不同的哈希值,不可能相等。

我们可以通过一起使用整个桶的样本集来避免在测试和训练中存在重复样本。这样,重复的样本要么全部用于测试,要么全部用于训练,但绝不会分开。

这是一个分区函数,它首先为样本创建 60 个独立的桶。然后,其中一部分桶被分配用于测试,其余的用于训练。具体来说,60 个桶中的 12、15 或 20 个桶大约占人口的 20%、25%或 33%。以下是一个在将数据集划分为测试集和训练集时进行去重的实现示例:

import itertools
from typing import DefaultDict, Iterator
ModuloDict = DefaultDict[int, List[KnownSample]]
def partition_2(
    samples: Iterable[KnownSample], 
    training_rule: Callable[[int], bool]
) -> tuple[TrainingList, TestingList]:
    rule_multiple = 60
    partitions: ModuloDict = collections.defaultdict(list)
    for s in samples:
        partitions[hash(s) % rule_multiple].append(s)
    training_partitions: list[Iterator[TrainingKnownSample]] = []
    testing_partitions: list[Iterator[TestingKnownSample]] = []
    for i, p in enumerate(partitions.values()):
        if training_rule(i):
            training_partitions.append(
                TrainingKnownSample(s) for s in p)
        else:
            testing_partitions.append(
                TestingKnownSample(s) for s in p)
    training = list(itertools.chain(*training_partitions))
    testing = list(itertools.chain(*testing_partitions))
    return training, testing 

在这个划分过程中有三个步骤:

  1. 我们创建了六十个独立的样本列表,由于哈希值相同,这些列表中可能存在重复。我们将这些批次放在一起,以避免将重复样本分割到测试集和训练集两个子集中。

  2. 我们构建了两个迭代器列表。每个列表都有一个对桶子子集的迭代器。training_rule() 函数用于确保我们在测试中获取到 12/60、15/60 或 20/60 的桶子,其余的用于训练。由于这些迭代器都是懒加载的,因此这些迭代器列表可以用来累积样本。

  3. 最后,我们使用itertools.chain来从生成器的序列中消费值。一系列迭代器将消费来自各个单独的桶级别迭代器的项目,以创建两个最终的样本分区。

注意到ModuloDict的类型提示定义了一个泛型DefaultDict的子类型。它提供了一个int类型的键,值将是一个list[KnownSample]实例的列表。我们提供了这个命名类型,以避免重复我们将会使用的字典的冗长定义。

itertools.chain() 是一种相当聪明的迭代器。它从其他迭代器中消耗数据。以下是一个示例:

>>> p1 = range(1, 10, 2)
>>> p2 = range(2, 10, 2)
>>> itertools.chain(p1, p2)
<itertools.chain object at ...>
>>> list(itertools.chain(p1, p2))
[1, 3, 5, 7, 9, 2, 4, 6, 8] 

我们创建了两个 range() 对象,p1p2。链对象将是一个迭代器,我们使用了 list() 函数来消费所有值。

上面的步骤可以创建一个大型映射作为中间数据结构。它还创建了六十个生成器,但这些不需要太多的内存。最后的两个列表包含了对分区字典中相同的Sample对象的引用。好消息是,这个映射是临时的,并且只存在于这个函数期间。

此函数还依赖于一个training_rule()函数。此函数的类型提示为Callable[[int], bool]。给定一个分区(从 0 到 59 的值,包括 0 和 59),我们可以将其分配给测试或训练分区。

我们可以使用不同的实现方法来获取 80%、75%或 66%的测试数据。例如:

lambda i: i % 4 != 0 

上述 lambda 对象将执行 75% 的训练和 25% 的测试分割。

一旦我们对数据进行分区,我们就可以使用迭代器来对样本进行分类,以及测试我们分类过程的质量。

测试

测试过程也可以被定义为一个高阶函数,即一个接受函数作为参数值的函数。我们可以将测试工作量总结为一个映射-归约问题。给定一个具有k值的超参数和距离算法,我们需要使用迭代器来完成以下两个步骤:

  • 函数将所有测试样本分类,如果分类正确则将每个测试样本映射到 1,如果分类错误则映射到 0。这是 map-reduce 中的映射部分。

  • 函数通过计算实际分类样本长序列中正确值的数量来生成一个摘要。这是 map-reduce 中的 reduce 部分。

Python 为这些映射和归约操作提供了高级函数。这使得我们能够专注于映射的细节,而忽略遍历数据项的样板代码部分。

期待下一节内容,我们将想要重构Hyperparameter类,将其分类器算法拆分为一个独立的、单独的函数。我们将使分类器函数成为一个策略,在我们创建Hyperparameter类的实例时提供。这样做意味着我们可以更容易地尝试一些替代方案。我们将探讨三种不同的方法来重构一个类。

这里有一个依赖于外部分类器函数的定义:

Classifier = Callable[
    [int, DistanceFunc, TrainingList, AnySample], str]
class Hyperparameter(NamedTuple):
    k: int
    distance_function: DistanceFunc
    training_data: TrainingList
    classifier: Classifier
    def classify(self, unknown: AnySample) -> str:
        classifier = self.classifier
        return classifier(
            self.k, self.distance_function,             self.training_data, 
            unknown
        )
    def test(self, testing: TestingList) -> int:
        classifier = self.classifier
        test_results = (
            ClassifiedKnownSample(
                t.sample,
                classifier(
                    self.k, self.distance_function, 
                    self.training_data, t.sample
                ),
            )
            for t in testing
        )
        pass_fail = map(
            lambda t: (
                1 if t.sample.species == t.classification else 0),
            test_results
        )
        return sum(pass_fail) 

test() 方法使用了两个映射操作和一个归约操作。首先,我们定义了一个生成器,它将每个测试样本映射到一个 ClassifiedKnownSample 对象。这个对象包含原始样本和分类的结果。

其次,我们定义了一个生成器,它将每个ClassifiedKnownSample对象映射到 1(对于匹配预期物种的测试)或 0(对于失败的测试)。这个生成器依赖于第一个生成器来提供值。

实际工作是对这些值的求和:这会从第二个生成器中获取值。第二个生成器从第一个生成器中获取对象。这种技术可以最小化在任何时刻内存中的数据量。它还将一个复杂的算法分解为两个独立的步骤,使我们能够根据需要做出更改。

这里也有一个优化方法。第二个生成器中t.classification的值是self.classify(t.sample.sample)。可以将它简化为一个生成器,并消除创建中间ClassifiedKnownSample对象的过程。

这里是测试操作的外观。我们可以使用距离函数manhattan()和分类器函数k_nn_1()来构建一个Hyperparameter实例:

h = Hyperparameter(1, manhattan, training_data, k_nn_1)
h.test(testing_data) 

我们将在接下来的两节中探讨各种分类器的实现。我们将从基础定义k_nn_1()开始,然后接着查看基于bisect模块的一个实现。

基本的 k-NN 算法

我们可以将k-近邻算法总结为以下步骤:

  1. 创建所有(距离,训练样本)对的列表。

  2. 按升序排列这些。

  3. 选择第一个k,它将是最近的k个邻居。

  4. 选择距离最近的邻居(最高频率)的模式标签。

实现方式如下:

class Measured(NamedTuple):
    distance: float
    sample: TrainingKnownSample
def k_nn_1(
    k: int, dist: DistanceFunc, training_data: TrainingList, 
    unknown: AnySample
) -> str:
    distances = sorted(
        map(
           lambda t: Measured(dist(t, unknown), t), training_data
        )
    )
    k_nearest = distances[:k]
    k_frequencies: Counter[str] = collections.Counter(
        s.sample.sample.species for s in k_nearest
    )
    mode, fq = k_frequencies.most_common(1)[0]
    return mode 

虽然清晰,但这确实会在distances列表对象中累积大量的距离值,而实际上只需要k个。sorted()函数消耗了源生成器,并创建了一个(可能很大的)中间值列表。

这个特定的 k-NN 算法中成本较高的部分是在计算距离之后对整个训练数据集进行排序。我们用描述性的方式将其复杂度总结为 O(n log n) 操作。避免成本的一种方法是不对整个距离计算集进行排序。

步骤 1步骤 3 可以优化,只保留 k 个最小的距离值。我们可以通过使用 bisect 模块来定位在有序列表中插入新值的位置。如果我们只保留小于列表中 k 个值的那些值,就可以避免进行冗长的排序。

使用二分模块的 k-NN

这里提供了一个避免对所有距离计算进行排序的 k-NN 的替代实现:

  1. 对于每个训练样本:

    1. 计算此训练样本到未知样本的距离。

    2. 如果它大于迄今为止看到的最近的 k 个邻居中的最后一个,则丢弃新的距离。

    3. 否则,在 k 值中找到一个位置;插入新项目;截断列表至长度 k

  2. 在最近的k个邻居中找到结果值的频率。

  3. 在最近的k个邻居中选择模式(最高频率)。

如果我们将k个最近邻的列表初始化为浮点无穷大值,对于数学家来说,Python 中的float("inf"),那么前几个计算出的距离d将被保留,因为。在计算了前k个距离之后,剩余的距离必须小于k个邻居中任意一个的距离才能被认为是相关的:

def k_nn_b(
    k: int, dist: DistanceFunc, training_data: TrainingList, 
    unknown: AnySample
) -> str:
    k_nearest = [
        Measured(float("inf"), cast(TrainingKnownSample, None)) 
        for _ in range(k)
    ]
    for t in training_data:
        t_dist = dist(t, unknown)
        if t_dist > k_nearest[-1].distance:
            continue
        new = Measured(t_dist, t)
        k_nearest.insert(bisect.bisect_left(k_nearest, new), new)
        k_nearest.pop(-1)
    k_frequencies: Counter[str] = collections.Counter(
        s.sample.sample.species for s in k_nearest
    )
    mode, fq = k_frequencies.most_common(1)[0]
    return mode 

我们不是将所有距离排序到一个大列表中,而是从一个远小得多的列表中插入(和删除)一个距离。在计算了前 k 个距离之后,此算法涉及两种状态变化:一个新的项目被插入到最近的 k 个邻居中,并且最远的 k+1 个邻居之一被移除。虽然这并没有以戏剧性的方式改变整体复杂度,但当在只有 k 个项目的非常小的列表上执行时,这些操作相对来说是低成本的。

使用 heapq 模块实现的 k-NN

我们还有另一个锦囊妙计。我们可以使用heapq模块来维护一个排序后的项目列表。这使得我们能够在每个项目被放入整体列表时执行排序操作。这并不会降低处理的一般复杂性,但它将两个低成本的插入和弹出操作替换为可能更经济的插入操作。

我们可以随后从堆中弹出k个元素以检索最近的邻居。

def k_nn_q(
    k: int, dist: DistanceFunc, training_data: TrainingList, 
    unknown: AnySample
) -> str:
    measured_iter = (
        Measured(dist(t, unknown), t) for t in training_data)
    k_nearest = heapq.nsmallest(k, measured_iter)
    k_frequencies: Counter[str] = collections.Counter(
        s.sample.sample.species for s in k_nearest
    )
    mode, fq = k_frequencies.most_common(1)[0]
    return mode 

这非常简洁优雅。然而,它并不特别快速。结果证明,计算距离的成本超过了使用更高级的堆队列来减少待排序项目数量的成本节约。

结论

我们可以通过提供一组一致的训练和测试数据来比较这些不同的k-NN 算法。我们将使用如下函数:

def test_classifier(
        training_data: List[TrainingKnownSample],
        testing_data: List[TestingKnownSample],
        classifier: Classifier) -> None:
    h = Hyperparameter(
        k=5,
        distance_function=manhattan,
        training_data=training_data,
        classifier=classifier)
    start = time.perf_counter()
    q = h.test(testing_data)
    end = time.perf_counter()
    print(
        f'| {classifier.__name__:10s} '
        f'| q={q:5}/{len(testing_data):5} '
        f'| {end-start:6.3f}s |') 

我们创建了一个一致的超参数实例。每个实例都有一个共同的值k和一个共同的距离函数;它们有一个不同的分类算法。我们可以执行test()方法并显示所需的时间。

一个 main() 函数可以使用此功能来检查各种分类器:

def main() -> None:
    test, train = a_lot_of_data(5_000)
    print("| algorithm  | test quality  | time    |")
    print("|------------|---------------|---------|")
    test_classifier(test, train, k_nn_1)
    test_classifier(test, train, k_nn_b)
    test_classifier(test, train, k_nn_q) 

我们已经将每个分类器应用于一组一致的数据。我们没有展示a_lot_of_data()函数。这创建了两个TrainingKnownSampleTestingKnownSample实例的列表。我们将这留作读者的练习。

这里是这些替代 k-NN 算法的性能比较结果:

算法 测试质量 时间
k_nn_1 q= 241/ 1000 6.553s
k_nn_b q= 241/ 1000 3.992s
k_nn_q q= 241/ 1000 5.294s

测试质量是正确测试用例的数量。这个数字很低,因为数据是完全随机的,如果我们的随机数据使用四种不同的物种名称,预期的正确分类率大约是 25%。

原始算法 k_nn_1 是最慢的,这是我们怀疑的。这提供了必要的证据,表明对此进行优化可能是必要的。基于 bisect 的处理,表格中的行 k_nn_b,表明使用小列表的工作成本超过了多次执行二分查找操作的成本。heapq 的处理时间,行 k_nn_h,比原始算法要好,但仅好约 20%。

在对算法的复杂度进行理论分析的同时,使用实际数据进行基准测试也是非常重要的。在投入时间和精力进行性能改进之前,我们需要从基准分析开始,以确定我们可能在哪些方面做得更加高效。在尝试优化性能之前,确认处理过程正确也同样重要。

在某些情况下,我们需要对特定的函数或甚至 Python 运算符进行详细分析。在这里,timeit模块可能会有所帮助。我们可能需要做如下操作:

>>> import timeit
>>> m = timeit.timeit(
...     "manhattan(d1, d2)",
...     """
... from model import Sample, KnownSample, TrainingKnownSample, TestingKnownSample
... from model import manhattan, euclidean
... d1 = TrainingKnownSample(KnownSample(Sample(1, 2, 3, 4), "x"))
... d2 = KnownSample(Sample(2, 3, 4, 5), "y")
... """) 

计算出的 m 值可以帮助我们具体比较距离计算。timeit 模块将在执行一次性的导入设置和创建样本数据之后,执行给定的语句 manhattan(d1, d2)

迭代器既是一种性能提升,也是一种阐明整体设计的潜在方法。在我们的案例研究中,迭代器可能很有帮助,因为大部分的处理都是在大数据集上迭代进行的。

回忆

本章探讨了在 Python 中似乎无处不在的设计模式——迭代器。Python 的迭代器概念是语言的基础,并且被广泛使用。在本章中,我们考察了多个方面:

  • 设计模式是我们看到在软件实现、设计和架构中反复出现的好想法。一个好的设计模式有一个名称,以及一个它可用的上下文。因为它只是一个模式,而不是可重用的代码,所以每次遵循该模式时,实现细节都会有所不同。

  • 迭代器协议是其中最强大的设计模式之一,因为它提供了一种一致的方式来处理数据集合。我们可以将字符串、元组、列表、集合,甚至文件视为可迭代的集合。映射包含多个可迭代的集合,包括键、值以及项(键值对)。

  • 列表、集合和字典推导式是创建新集合的简洁总结。它们涉及一个源可迭代对象、一个可选的过滤器,以及一个最终表达式来定义新集合中的对象。

  • 生成器函数建立在其他模式之上。它们让我们能够定义具有映射和过滤功能的可迭代对象。

练习

如果你平时编码中很少使用列表推导,你应该首先搜索一些现有的代码,找到一些for循环。看看它们是否可以轻易地转换成生成器表达式或列表、集合或字典推导。

测试列表推导式是否比for循环更快的说法。这可以通过内置的timeit模块来完成。使用timeit.timeit函数的帮助文档来了解如何使用它。基本上,编写两个执行相同功能的函数,一个使用列表推导式,另一个使用for循环遍历数千个项目。将每个函数传递给timeit.timeit,并比较结果。如果你喜欢冒险,也可以比较生成器和生成器表达式。使用timeit测试代码可能会变得上瘾,所以请记住,除非代码被大量执行,例如在巨大的输入列表或文件上,否则代码不需要非常快。

尝试使用生成器函数。从需要多个值的简单迭代器开始(数学序列是典型的例子;如果你想不到更好的例子,斐波那契数列可能会被过度使用)。尝试一些更高级的生成器,它们可以执行诸如接受多个输入列表并以某种方式合并它们的值等操作。生成器也可以用于文件;你能编写一个简单的生成器,显示两个文件中相同的行吗?

将日志处理练习扩展到用时间范围过滤器替换WARNING过滤器;例如,所有在 2015 年 1 月 26 日 11:25:46 和 2015 年 1 月 26 日 11:26:15 之间的消息。

一旦你能找到WARNING行或特定时间内的行,将这两个过滤器结合起来,以只选择给定时间内的警告。你可以在单个生成器内使用and条件,或者组合多个生成器,实际上构建一个and条件。哪种方式似乎更能适应不断变化的需求?

当我们展示了类 WarningReformat(Iterator[Tuple[str, ...]]): 的迭代器示例时,我们做出了一些值得商榷的设计决策。__init__() 方法接受一个打开的文件作为参数值,而 __next__() 方法则在该文件上使用 readline()。如果我们稍作改变,创建一个显式的迭代器对象,并在另一个迭代器内部使用它,会怎样呢?

def __init__(self, source: TextIO) -> None:
    self.insequence = iter(source) 

如果我们进行这个更改,那么 __next__() 可以使用 line = next(self.insequence) 而不是 line = self.insequence.readline()。从 object.readline() 切换到 next(object) 是一个有趣的推广。这会改变 extract_and_parse_2() 函数的任何内容吗?这让我们能够与 WarningReformat 迭代器一起使用生成器表达式吗?

再进一步。将WarningReformat类重构为两个独立的类,一个用于过滤WARN,另一个用于解析和重新格式化输入日志的每一行。使用这两个类的实例重写extract_and_parse_2()函数。哪个更好?你使用了哪些指标来评估“更好”?

案例研究将k-NN 算法总结为一种计算距离值、排序并选择最近的k个的方法。案例研究并没有过多地讨论用于将训练数据与测试数据分开的分区算法。这也可能像一对列表推导式那样工作。然而,这里有一个有趣的问题。我们希望创建两个列表,并且只读取源数据一次。这并不容易通过列表推导式完成。然而,查看itertools模块可能会有一些可能的设计。具体来说,itertools.tee()函数将从一个单一源提供多个可迭代对象。

查看itertools模块的食谱部分。如何使用itertools.partition()函数来分割数据?

摘要

在本章中,我们了解到设计模式是有用的抽象,为常见的编程问题提供了最佳实践解决方案。我们介绍了我们的第一个设计模式——迭代器,以及 Python 如何以各种方式使用和滥用这个模式来实现其自身的邪恶目的。原始的迭代器模式非常面向对象,但编写起来既丑陋又啰嗦。然而,Python 的内建语法抽象掉了这种丑陋,为我们提供了面向对象构造的整洁接口。

理解和生成器表达式可以将容器构建与迭代结合在一行中。可以使用yield语法来构建生成器函数。

我们将在接下来的两章中介绍更多设计模式。

第十一章:常见设计模式

在上一章中,我们简要介绍了设计模式,并涵盖了迭代器模式,这是一个如此有用且常见的模式,以至于它被抽象成了编程语言的核心。在本章中,我们将回顾其他常见模式以及它们在 Python 中的实现方式。与迭代一样,Python 经常提供一种替代语法来简化这类问题的处理。我们将涵盖这些模式的传统设计和 Python 版本。

在本章中,我们将看到:

  • 装饰者模式

  • 观察者模式

  • 策略模式

  • 命令模式

  • 状态模式

  • 单例模式

本章的案例研究将强调距离计算是如何成为策略设计模式的一个例子,以及我们如何利用抽象基类来设计各种距离计算方法,这些方法可以进行比较,以确定哪种方法产生的结果最有用。

与《设计模式:可复用面向对象软件元素》中的实践一致,我们将首字母大写模式名称。这有助于它们在普通英语用法中脱颖而出。

我们将从装饰者模式开始。这种模式用于将不同种类的功能组合成一个单一的结果对象。

装饰者模式

装饰器模式允许我们用其他对象来包装一个提供核心功能的对象,从而改变这个功能。任何使用装饰对象的对象都将与它以完全相同的方式交互,就像它没有被装饰一样(也就是说,装饰对象的外界接口与核心对象相同)。

装饰者模式主要有两种用途:

  • 提高组件在向第二个组件发送数据时的响应能力

  • 支持多种可选行为

第二种选择通常是多重继承的一个合适替代方案。我们可以构建一个核心对象,然后创建一个装饰器来包装这个核心。由于装饰器对象与核心对象具有相同的接口,我们甚至可以在新对象上再包装其他装饰器。下面是它在 UML 图中的样子:

图表描述自动生成

图 11.1:UML 中的装饰器模式

在这里,核心及其所有装饰器实现了一个特定的接口。虚线表示“实现”或“实现”。装饰器通过组合维护对该接口核心实例的引用。当被调用时,装饰器在其包装的接口调用前后执行一些额外的处理。包装的对象可能是另一个装饰器,或者是核心功能。虽然多个装饰器可以相互包装,但所有这些装饰器链末尾的对象提供了核心功能。

每个这些都必须提供一个公共功能的实现。目的是提供从各种装饰器中组合的处理步骤,应用于核心。通常装饰器很小,通常是一个没有状态的函数定义。

在 Python 中,由于鸭子类型,我们不需要通过官方的抽象接口定义来正式化这些关系。确保类有匹配的方法就足够了。在某些情况下,我们可能定义一个typing.Protocol作为类型提示,以帮助mypy推理这些关系。

装饰器示例

让我们来看一个网络编程的例子。我们想要构建一个小型服务器,它提供一些数据,并且有一个客户端与之交互。服务器将模拟掷出复杂的一把把骰子。客户端将请求一把骰子并等待包含一些随机数的回答。

这个例子中有两个进程通过 TCP 套接字进行交互,这是一种在计算机系统之间传输字节的方式。套接字是由一个监听连接的服务器创建的。当客户端尝试连接到套接字时,服务器必须接受新的连接,然后两个进程就可以相互传递字节;在这个例子中,将会有客户端向服务器发送请求和服务器向客户端发送响应。TCP 套接字是 HTTP 的基础部分,整个万维网都是围绕它构建的。

客户端和服务器进程将使用 socket.send() 方法通过套接字传输一串字节。他们还将使用 socket.recv() 来接收字节。我们将从一个交互式服务器开始,该服务器等待来自客户端的连接,然后响应用户请求。我们将把这个模块命名为 socket_server.py。以下是总体概述:

import contextlib
import socket
def main_1() -> None:
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(("localhost", 2401))
    server.listen(1)
    with contextlib.closing(server):
        while True:
            client, addr = server.accept()
            dice_response(client)
            client.close() 

服务器绑定到“公共”套接字,使用大约随机的端口号2401。这就是服务器监听连接请求的地方。当客户端尝试连接到这个套接字时,会创建一个子套接字,以便客户端和服务器可以进行通信,同时保持公共套接字为更多连接做好准备。一个网络服务器通常会使用多个线程来允许大量并发会话。我们并没有使用线程,第二个客户端必须等待服务器完成与第一个客户端的通信。这就像是一家只有一个咖啡师制作浓缩咖啡的咖啡馆队列。

(请注意,TCP/IP 套接字既有主机地址也有端口号。端口号必须大于1023。端口号1023以下是被保留的,并且需要特殊的操作系统权限。我们选择端口号2401,因为它似乎没有被用于其他任何事情。)

dice_response() 函数执行了我们服务的大部分实际工作。它接受一个 socket 参数,以便能够响应用户。它读取客户端请求的字节,创建响应,然后发送。为了优雅地处理异常,dice_response() 函数看起来是这样的:

def dice_response(client: socket.socket) -> None:
    request = client.recv(1024)
    try:
        response = dice.dice_roller(request)
    except (ValueError, KeyError) as ex:
        response = repr(ex).encode("utf-8")
    client.send(response) 

我们将另一个函数dice_roller()封装在异常处理程序中。这是一种常见的模式,用于将错误处理和其他开销与计算骰子滚动并响应客户端以提供他们角色扮演游戏的有用数字的实际工作分离:

import random
def dice_roller(request: bytes) -> bytes:
    request_text = request.decode("utf-8")
    numbers = [random.randint(1, 6) for _ in range(6)]
    response = f"{request_text} = {numbers}"
    return response.encode("utf-8") 

这并不复杂。我们将在本章后面的命令模式部分对此进行扩展。然而,目前它将提供一个随机数的序列。

注意,我们实际上并没有对来自客户端的request对象做任何操作。在最初的几个例子中,我们将读取这些字节并忽略它们。request是一个占位符,用于描述一个更复杂的请求,包括需要掷多少个骰子和掷多少次。

我们可以利用装饰器设计模式来添加功能。装饰器将包装核心的dice_response()函数,该函数接收一个socket对象,它可以读取和写入。为了利用设计模式,重要的是要利用这个函数在添加功能时依赖的socket.send()socket.recv()方法。在添加装饰时,我们需要保留接口定义。

为了测试服务器,我们可以编写一个非常简单的客户端,该客户端连接到相同的端口并在退出前输出响应:

import socket
def main() -> None:
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.connect(("localhost", 2401))
    count = input("How many rolls: ") or "1"
    pattern = input("Dice pattern nd6[dk+-]a: ") or "d6"
    command = f"Dice {count} {pattern}"
    server.send(command.encode("utf8"))
    response = server.recv(1024)
    print(response.decode("utf-8"))
    server.close()
if __name__ == "__main__":
    main() 

这个客户端提出了两个问题并创建了一个看起来相当复杂的字符串,command,其中包含一个计数和掷骰子模式。目前,服务器还没有使用这个命令。这是一个更高级掷骰子器的预告。

要使用这两个独立的应用程序,请按照以下步骤操作:

  1. 打开两个并排的终端窗口。(将窗口标题更改为“客户端”和“服务器”可能会有帮助。macOS 终端用户可以在shell菜单中使用更改标题项。Windows 用户可以使用title命令。)

  2. 在服务器窗口中,启动服务器应用程序:

    python src/socket_server.py 
    
  3. 在客户端窗口中,启动客户端应用程序:

    python src/socket_client.py 
    
  4. 在客户端窗口中输入您的响应。例如:

    How many rolls: 2
    Dice pattern nd6[dk+-]a: d6 
    
  5. 客户端将发送命令,读取响应,将其打印到控制台,然后退出。你可以多次运行客户端以获取一系列骰子滚动结果。

结果看起来可能像这样:

文本描述自动生成

图 11.2:服务器和客户端

在左侧是服务器。我们启动了应用程序,它开始监听端口2401以接收客户端连接。在右侧是客户端。每次运行客户端时,它都会连接到公共套接字;连接操作会创建一个子套接字,该套接字可以用于后续的交互。客户端发送一个命令,服务器响应该命令,然后客户端将其打印出来。

现在,回顾我们的服务器代码,我们看到有两个部分。dice_response() 函数读取数据并通过一个 socket 对象将数据发送回客户端。剩余的脚本负责创建那个 socket 对象。我们将创建一对装饰器,以自定义 socket 的行为,而无需扩展或修改 socket 本身。

让我们从日志装饰器开始。这个对象在将数据发送到客户端之前,将其输出到服务器的控制台:

class LogSocket:
    def __init__(self, socket: socket.socket) -> None:
        self.socket = socket
    def recv(self, count: int = 0) -> bytes:
        data = self.socket.recv(count)
        print(
            f"Receiving {data!r} from {self.socket.getpeername()[0]}"
        )
        return data
    def send(self, data: bytes) -> None:
        print(f"Sending {data!r} to {self.socket.getpeername()[0]}")
        self.socket.send(data)
    def close(self) -> None:
        self.socket.close() 

这个类装饰了一个socket对象,并为使用它的客户端提供了send()recv()close()接口。一个更好的装饰器可以正确实现send函数的所有参数(实际上它接受一个可选的标志参数),但让我们保持示例简单。每当在LogSocket类的实例上调用send()时,它会在使用原始套接字向客户端发送数据之前将输出记录到屏幕上。同样,对于recv(),它读取并记录接收到的数据。

我们只需在我们的原始代码中更改一行即可使用这个装饰器。而不是用原始客户端套接字调用dice_response()函数,我们用装饰过的套接字来调用它:

def main_2() -> None:
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(("localhost", 2401))
    server.listen(1)
    with contextlib.closing(server):
        while True:
            client, addr = server.accept()
            logging_socket = cast(socket.socket, LogSocket(client))
            dice_response(logging_socket)
            client.close() 

我们已经用LogSocket装饰了核心socketLogSocket不仅会将信息打印到控制台,还会调用它所装饰的socket对象的方法。dice_response()函数中的基本处理没有改变,因为LogSocket实例的行为就像底层的socket对象一样。

注意,我们需要使用显式的 cast() 来告诉 mypyLogSocket 实例将提供一个类似于普通 socket 的接口。对于这样一个简单的例子,我们不得不问自己,为什么我们不去扩展 socket 类并重写 send 方法。子类可以在记录之后调用 super().send()super().recv() 来执行实际的发送操作。装饰器比继承提供了一种优势:装饰器可以在不同的类层次结构中的各种类之间重用。在这个具体的例子中,类似 socket 的对象并不多,因此重用的可能性有限。

如果我们将焦点转向比socket更通用的东西,我们可以创建潜在的可重用装饰器。处理字符串或字节似乎比处理socket更常见。改变结构可以给我们带来一些期望的灵活性,同时也有重用的潜力。最初,我们将处理分解为一个dice_response()函数,该函数处理 socket 的读写操作,与一个dice_roller()函数分开,后者与字节一起工作。因为dice_roller()函数消耗请求字节并生成响应字节,所以它可能更容易扩展并添加功能。

我们可以有一个相关的装饰器家族。我们可以装饰已经装饰过的对象。这个想法是通过组合来赋予我们灵活性。让我们重新设计日志装饰器,使其专注于字节请求和响应,而不是socket对象。以下应该与之前的示例类似,但部分代码已移动到单个__call__()方法中:

Address = Tuple[str, int]
class LogRoller:
    def __init__(
            self, 
            dice: Callable[[bytes], bytes], 
            remote_addr: Address
    ) -> None:
        self.dice_roller = dice
        self.remote_addr = remote_addr
    def __call__(self, request: bytes) -> bytes:
        print(f"Receiving {request!r} from {self.remote_addr}")
        dice_roller = self.dice_roller
        response = dice_roller(request)
        print(f"Sending {response!r} to {self.remote_addr}")
        return response 

这里是一个使用gzip压缩对结果字节进行压缩的第二个装饰器:

import gzip
import io
class ZipRoller:
    def __init__(self, dice: Callable[[bytes], bytes]) -> None:
        self.dice_roller = dice
    def __call__(self, request: bytes) -> bytes:
        dice_roller = self.dice_roller
        response = dice_roller(request)
        buffer = io.BytesIO()
        with gzip.GzipFile(fileobj=buffer, mode="w") as zipfile:
            zipfile.write(response)
        return buffer.getvalue() 

此装饰器在将数据发送到客户端之前对其进行压缩。它装饰了一个底层的dice_roller对象,该对象用于计算对请求的响应。

现在我们有了这两个装饰器,我们可以编写代码,将一个装饰器堆叠在另一个装饰器之上:

def dice_response(client: socket.socket) -> None:
    request = client.recv(1024)
    try:
        remote_addr = client.getpeername()
        roller_1 = ZipRoller(dice.dice_roller)
        roller_2 = LogRoller(roller_1, remote_addr=remote_addr)
        response = roller_2(request)
    except (ValueError, KeyError) as ex:
        response = repr(ex).encode("utf-8")
    client.send(response) 

此处的目的是将此应用的三个方面分开:

  • 压缩生成的文档

  • 编写日志

  • 进行底层计算

我们可以将压缩或日志记录应用于任何处理接收和发送字节的类似应用程序。如果我们愿意,还可以将压缩操作作为一个动态选择。我们可能有一个单独的配置文件来启用或禁用 GZip 功能。这意味着类似于以下内容:

if config.zip_feature:
    roller_1 = ZipRoller(dice.dice_roller)
else:
    roller_1 = dice.dice_roller 

我们有一套动态的装饰。试着用多重继承混入(mixin)来实现这个功能,看看它会变得多么混乱!

Python 中的装饰器

装饰器模式在 Python 中很有用,但还有其他选择。例如,我们可以使用猴子补丁(在运行时更改类定义)来达到类似的效果。例如,socket.socket.send = log_send 将改变内置 socket 的工作方式。有时会有一些令人惊讶的实现细节,这可能会使事情变得不愉快地复杂。单继承,其中可选的计算在一个大方法中通过一系列if语句完成,可能是一个选择。多重继承不应该因为之前看到的特定示例不适合而被放弃。

在 Python 中,在函数上使用这种模式非常常见。正如我们在前一章中看到的,函数也是对象。实际上,函数装饰如此普遍,以至于 Python 提供了一种特殊的语法,以便于将这样的装饰器应用于函数。

例如,我们可以更一般性地看待日志记录的例子。除了只在套接字上记录发送调用之外,我们可能会发现记录对某些函数或方法的全部调用是有帮助的。以下示例实现了一个装饰器,它正是这样做的:

from functools import wraps
def log_args(function: Callable[..., Any]) -> Callable[..., Any]:
    @wraps(function)
    def wrapped_function(*args: Any, **kwargs: Any) -> Any:
        print(f"Calling {function.__name__}(*{args}, **{kwargs})")
        result = function(*args, **kwargs)
        return result
    return wrapped_function 

这个装饰器函数与我们之前探讨的例子非常相似;在之前的例子中,装饰器接受一个类似于套接字的对象并创建一个类似的套接字对象。这次,我们的装饰器接受一个函数对象并返回一个新的函数对象。我们提供了类型提示Callable[..., Any]来表明任何函数都可以在这里使用。这段代码包含三个独立任务:

  • 一个接受另一个函数function作为参数值的函数,log_args()

  • 此函数(内部)定义了一个新函数,命名为wrapped_function,在调用原始函数并返回原始函数的结果之前,它会做一些额外的工作。

  • 新的内联函数,wrapped_function(),由装饰器函数返回。

因为我们在使用 @wraps(function),新的函数将拥有原始函数的名称和原始函数的文档字符串。这避免了所有我们装饰的函数最终都命名为 wrapped_function

这里有一个示例函数来展示装饰器的使用:

def test1(a: int, b: int, c: int) -> float:
    return sum(range(a, b + 1)) / c
test1 = log_args(test1) 

此函数可以被装饰并这样使用:

>>> test1(1, 9, 2)
Calling test1(*(1, 9, 2), **{})
22.5 

这种语法使我们能够动态地构建装饰过的函数对象,就像我们在套接字示例中所做的那样。如果我们不使用赋值来将新对象分配给旧名称,我们甚至可以保留装饰过的和非装饰过的版本以适应不同的情况。我们可以使用类似 test1_log = log_args(test1) 的语句来创建 test1() 函数的第二个装饰版本,命名为 test1_log()

通常,这些装饰器是对不同函数进行永久性修改的通用修改。在这种情况下,Python 支持一种特殊的语法,可以在定义函数时应用装饰器。我们已经在几个地方看到了这种语法;现在,让我们了解它是如何工作的。

我们可以在方法定义之后应用装饰器函数,也可以使用@decorator语法一次性完成:

@log_args
def test1(a: int, b: int, c: int) -> float:
    return sum(range(a, b + 1)) / c 

这种语法的首要好处是,每次我们阅读函数定义时,都能轻松地看到函数已经被装饰了。如果装饰器是在之后应用的,阅读代码的人可能会错过函数已经被修改的事实。回答像“为什么我的程序会将函数调用记录到控制台?”这样的问题可能会变得困难得多!然而,这种语法只能应用于我们定义的函数,因为我们没有访问其他模块源代码的权限。如果我们需要装饰属于他人第三方库中的函数,我们必须使用早期的语法。

Python 的装饰器也允许参数。标准库中最有用的装饰器之一是functools.lru_cache。缓存的想法是将函数的计算结果保存下来,以避免重新计算。我们不必保存所有参数和结果,可以通过丢弃最近最少使用LRU)的值来保持缓存的大小。例如,以下是一个涉及可能昂贵的计算的功能:

>>> from math import factorial
>>> def binom(n: int, k: int) -> int:
...     return factorial(n) // (factorial(k) * factorial(n-k))
>>> f"6-card deals: {binom(52, 6):,d}"
'6-card deals: 20,358,520' 

我们可以使用lru_cache装饰器来避免在已知答案后重复进行此计算。这里需要做的微小改动是:

>>> from math import factorial
>>> from functools import lru_cache
>>> @lru_cache(64)
... def binom(n: int, k: int) -> int:
...     return factorial(n) // (factorial(k) * factorial(n-k)) 

参数化装饰器 @lru_cache(64) 用于创建 binom() 函数的第二个版本,意味着它会保存最近的 64 个结果以避免在值已经被计算过一次时重新计算。在应用程序的其他地方不需要做任何更改。有时,这种小改动带来的加速效果可能是显著的。当然,我们可以根据数据和正在进行的计算数量来微调缓存的大小。

像这样的参数化装饰器涉及两步舞。首先,我们使用参数自定义装饰器,然后我们将这个自定义装饰器应用到函数定义上。这两个独立的步骤与通过__init__()方法初始化可调用对象的方式相平行,并且可以通过它们的__call__()方法像函数一样被调用。

这里是一个可配置的日志装饰器的示例,NamedLogger

class NamedLogger:
    def __init__(self, logger_name: str) -> None:
        self.logger = logging.getLogger(logger_name)
    def __call__(
           self, 
           function: Callable[..., Any]
    ) -> Callable[..., Any]:
        @wraps(function)
        def wrapped_function(*args: Any, **kwargs: Any) -> Any:
            start = time.perf_counter()
            try:
                result = function(*args, **kwargs)
                μs = (time.perf_counter() - start) * 1_000_000
                self.logger.info(
                    f"{function.__name__}, { μs:.1f}μs")
                return result
            except Exception as ex:
                μs = (time.perf_counter() - start) * 1_000_000
                self.logger.error(
                    f"{ex}, {function.__name__}, { μs:.1f}μs")
                raise
        return wrapped_function 

__init__() 方法确保我们可以使用类似 NamedLogger("log4") 的代码来创建一个装饰器;这个装饰器将确保随后的函数使用特定的记录器。

__call__() 方法遵循上述模式。我们定义一个新的函数,wrapped_function(),来完成这项工作,并返回这个新创建的函数。我们可以这样使用它:

>>> @NamedLogger("log4")
... def test4(median: float, sample: float) -> float:
...     return abs(sample-median) 

我们已经创建了一个NamedLogger类的实例。然后我们将这个实例应用于test4()函数定义。调用__call__()方法,将创建一个新的函数,即test4()函数的装饰版本。

装饰器语法还有一些其他的使用场景。例如,当一个装饰器是类的一个方法时,它还可以保存关于被装饰函数的信息,创建一个被装饰函数的注册表。此外,类也可以被装饰;在这种情况下,装饰器返回一个新的类而不是一个新的函数。在所有这些更高级的案例中,我们使用的是普通的面向对象设计,但语法看起来更简单,即使用@decorator

观察者模式

观察者模式适用于状态监控和事件处理场景。此模式允许一个特定对象被一个未知且动态的观察者对象组监控。被观察的核心对象需要实现一个接口,使其成为可观察的

当核心对象上的值发生变化时,它会通过调用一个宣布状态发生改变的方法,让所有观察者对象知道已发生改变。这在 GUI 中得到了广泛应用,以确保底层模型中的任何状态变化都能反映在模型的视图中。通常会有详细视图和摘要视图;对详细信息的更改也必须更新显示详细信息的控件,并更新显示的任何摘要。有时模式的大幅变化可能导致多个项目被更改。例如,点击一个“锁定”图标可能会改变多个显示项目,以反映它们被锁定的状态。这可以通过将多个观察者附加到可观察的显示控件来实现。

在 Python 中,观察者可以通过__call__()方法被通知,使得每个观察者表现得像一个函数或其他可调用对象。每当核心对象发生变化时,每个观察者可能负责不同的任务;核心对象不知道或关心那些任务是什么,观察者通常也不知道或关心其他观察者在做什么。

这通过将状态变化对响应的影响与变化本身解耦,提供了极大的灵活性。

这里是 UML 中观察者设计模式的表示:

图表描述自动生成

图 11.3:UML 中的观察者模式

我们已经展示了Core对象包含一系列观察者对象。为了可观察,Core类必须遵循对可观察性的共同理解;具体来说,它必须提供一个观察者列表以及一种附加新观察者的方法。

我们已经展示了Observer子类具有__call__()方法。这个方法将由可观察对象用来通知每个观察者状态的变化。与装饰器模式一样,我们不需要通过正式定义的抽象超类来正式化这些关系。在大多数情况下,我们可以依赖鸭子类型规则;只要观察者具有正确的接口,它们就可以在这个模式中定义的角色中使用。如果它们缺少适当的接口,mypy可能会捕捉到冲突,并且单元测试应该能够捕捉到这个问题。

一个观察者示例

在图形用户界面之外,观察者模式对于保存对象的中继状态很有用。在需要严格审计变更的系统中使用观察者对象可能很方便。在混乱盛行且组件不可靠的系统中也同样方便。

复杂的基于云的应用可能因为不可靠的连接而出现混乱问题。我们可以使用观察者来记录状态变化,从而使恢复和重启更加容易。

对于这个例子,我们将定义一个核心对象来维护一组重要值,然后让一个或多个观察者创建该对象的序列化副本。这些副本可能存储在数据库中、远程主机上或本地文件中,例如。由于我们可以有多个观察者,因此很容易修改设计以使用不同的数据缓存。在这个例子中,我们想到了一个名为 Zonk 或 Zilch 或 Ten Thousand 的掷骰子游戏,玩家将掷六个骰子,为三倍和连跑得分,并可能再次掷骰,从而产生一系列骰子。(规则比这个简单的总结要复杂一些。)

我们将首先简要介绍一些内容,以便使我们的意图更加明确:

from __future__ import annotations
from typing import Protocol
class Observer(Protocol):
    def __call__(self) -> None:
        ...
class Observable:
    def __init__(self) -> None:
        self._observers: list[Observer] = []
    def attach(self, observer: Observer) -> None:
        self._observers.append(observer)
    def detach(self, observer: Observer) -> None:
        self._observers.remove(observer)
    def _notify_observers(self) -> None:
        for observer in self._observers:
            observer() 

Observer类是一个协议,是我们观察者的抽象超类。我们没有将其正式化为abc.ABC抽象类;我们不依赖于abc模块提供的运行时错误。在定义Protocol时,我们依赖mypy来确认所有观察者实际上实现了所需的方法。

Observable 类定义了 _observers 实例变量和三个纯粹属于此协议定义的方法。一个可观察对象可以添加观察者、移除观察者,以及——最重要的是——通知所有观察者状态变化。核心类需要做的唯一特殊或不同的事情是在状态变化时调用 _notify_observers() 方法。适当的通知是可观察对象设计中的重要组成部分。

这是我们在意的 Zonk 游戏的一部分。这个类保存玩家的手:

from typing import List
Hand = List[int]
class ZonkHandHistory(Observable):
    def __init__(self, player: str, dice_set: Dice) -> None:
        super().__init__()
        self.player = player
        self.dice_set = dice_set
        self.rolls: list[Hand]
    def start(self) -> Hand:
        self.dice_set.roll()
        self.rolls = [self.dice_set.dice]
        self._notify_observers()  # State change
        return self.dice_set.dice
    def roll(self) -> Hand:
        self.dice_set.roll()
        self.rolls.append(self.dice_set.dice)
        self._notify_observers()  # State change
        return self.dice_set.dice 

这个类在重要的状态变化时调用 self._notify_observers()。这将通知所有观察者实例。观察者可能会缓存手部的副本,通过网络发送详细信息,更新 GUI 上的小部件——任何数量的事情。从 Observable 继承的 _notify_observers() 方法会遍历任何已注册的观察者,并让每个观察者知道手部的状态已发生变化。

现在我们来实现一个简单的观察者对象;这个对象将会将一些状态打印到控制台:

class SaveZonkHand(Observer):
    def __init__(self, hand: ZonkHandHistory) -> None:
        self.hand = hand
        self.count = 0
    def __call__(self) -> None:
        self.count += 1
        message = {
            "player": self.hand.player,
            "sequence": self.count,
            "hands": json.dumps(self.hand.rolls),
            "time": time.time(),
        }
        print(f"SaveZonkHand {message}") 

这里没有什么特别激动人心的东西;观察到的对象是在初始化器中设置的,当调用观察者时,我们做了一些事情,在这个例子中,是打印一行。请注意,这里的超类Observer实际上并不需要。这个类被使用的上下文足以让mypy确认这个类符合所需的Observer协议。虽然我们不需要声明它是一个Observer,但这可以帮助读者看到这个类实现了Observer协议。

我们可以在交互式控制台中测试SaveZonkHand观察者:

>>> d = Dice.from_text("6d6")
>>> player = ZonkHandHistory("Bo", d)
>>> save_history = SaveZonkHand(player)
>>> player.attach(save_history)
>>> r1 = player.start()
SaveZonkHand {'player': 'Bo', 'sequence': 1, 'hands': '[[1, 1, 2, 3, 6, 6]]', 'time': 1609619907.52109}
>>> r1
[1, 1, 2, 3, 6, 6]
>>> r2 = player.roll()
SaveZonkHand {'player': 'Bo', 'sequence': 2, 'hands': '[[1, 1, 2, 3, 6, 6], [1, 2, 2, 6, 6, 6]]', 'time': ...} 

将观察者附加到Inventory对象后,每当改变两个被观察属性中的任何一个时,都会调用观察者并执行其动作。请注意,我们的观察者跟踪一个序列号并包含一个时间戳。这些是在游戏定义之外,并且通过成为SaveZonkHand观察者类的一部分,与核心游戏处理保持分离。

我们可以添加多个不同类别的观察者。让我们添加一个第二个观察者,它有一个有限的任务,即检查三对并宣布它:

class ThreePairZonkHand:
    """Observer of ZonkHandHistory"""
    def __init__(self, hand: ZonkHandHistory) -> None:
        self.hand = hand
        self.zonked = False
    def __call__(self) -> None:
        last_roll = self.hand.rolls[-1]
        distinct_values = set(last_roll)
        self.zonked = len(distinct_values) == 3 and all(
            last_roll.count(v) == 2 for v in distinct_values
        )
        if self.zonked:
            print("3 Pair Zonk!") 

对于这个例子,我们省略了将Observer命名为超类。我们可以信任mypy工具来记录这个类是如何被使用以及它必须实现哪些协议。引入这个新的ThreePairZonkHand观察者意味着,当我们改变手的状态时,可能会有两组输出,每组对应一个观察者。这里的关键思想是,我们可以轻松地添加完全不同类型的观察者来完成不同种类的事情,在这种情况下,就是复制数据以及检查数据中的特殊情况。

观察者模式将正在被观察的代码与执行观察的代码分离。如果我们没有使用这种模式,我们就不得不在ZonkHandHistory类中放置代码来处理可能出现的不同情况:记录到控制台、更新数据库或文件、检查特殊情况等等。每个这些任务的代码都会与核心类定义混合在一起。维护它将是一场噩梦,并且在以后日期添加新的监控功能将会很痛苦。

策略模式

策略模式是面向对象编程中抽象的常见示例。该模式实现了对单个问题的不同解决方案,每个解决方案都在不同的对象中。核心类可以在运行时动态地选择最合适的实现。

通常,不同的算法有不同的权衡;一个可能比另一个更快,但会使用更多的内存,而第三个算法可能在存在多个 CPU 或提供了分布式系统时最为合适。

这里是 UML 中的策略模式:

图表描述自动生成

图 11.4:UML 中的策略模式

连接到策略抽象的核心代码只需知道它正在处理某种符合特定操作策略接口的类。每个实现都应该执行相同的任务,但以不同的方式。实现接口需要完全相同,并且利用抽象基类来确保实现的一致性通常是有帮助的。

这种插件策略的想法也是观察者模式的一个方面。实际上,策略对象的想法是本章涵盖的许多模式的一个重要方面。常见的想法是使用一个单独的对象来隔离条件性或可替换的处理,并将工作委托给这个单独的对象。这对于可观察对象、装饰以及——正如我们将看到的——命令和状态也是适用的。

策略示例

策略模式的一个常见例子是排序程序;多年来,已经发明了多种算法来对一组对象进行排序。快速排序、归并排序和堆排序都是具有不同特性的算法,每个算法在其自身适用的范围内都是有用的,这取决于输入的大小和类型、它们的顺序如何以及系统的要求。

如果我们的客户端代码需要对一个集合进行排序,我们可以将其传递给一个具有sort()方法的对象。这个对象可能是一个QuickSorterMergeSorter对象,但无论哪种情况,结果都将相同:一个排序后的列表。用于排序的策略从调用代码中抽象出来,使其模块化且可替换。

当然,在 Python 中,我们通常只是调用sorted()函数或list.sort()方法,并相信它将足够快地完成排序,以至于 TimSort 算法的细节并不真正重要。有关 TimSort 如何惊人的快速的信息,请参阅bugs.python.org/file4451/timsort.txt。虽然排序是一个有用的概念,但它并不是最实用的例子,所以让我们看看其他的东西。

作为策略设计模式的简单示例,可以考虑桌面壁纸管理器。当图像显示在桌面背景上时,它可以以不同的方式调整到屏幕大小。例如,假设图像小于屏幕,它可以平铺在整个屏幕上,居中显示,或者缩放到适合。还可以使用其他更复杂的策略,例如缩放到最大高度或宽度,将其与纯色、半透明或渐变背景颜色结合,或者进行其他操作。虽然我们可能希望在以后添加这些策略,但让我们先从几个基本策略开始。

您需要安装pillow模块。如果您使用conda来管理您的虚拟环境,请使用conda install pillow来安装 Pillow 项目的PIL实现。如果您不使用conda,请使用python -m pip install pillow

我们的策略对象需要接受两个输入:要显示的图像,以及屏幕宽度和高度的元组。它们各自返回一个与屏幕大小相同的新图像,并根据给定的策略对图像进行操作以适应。

这里是一些初步的定义,包括所有策略变体的抽象超类:

import abc
from pathlib import Path
from PIL import Image  # type: ignore [import]
from typing import Tuple
Size = Tuple[int, int]
class FillAlgorithm(abc.ABC):
    @abc.abstractmethod
    def make_background(
            self, 
            img_file: Path, 
            desktop_size: Size
    ) -> Image:
        pass 

这个抽象是否必要?这正处在过于简单以至于不需要抽象和足够复杂以至于超类有帮助之间的边缘。函数签名相当复杂,有一个特殊的类型提示来描述大小元组。因此,抽象可以帮助检查每个实现,以确保所有类型匹配。

注意,我们需要包含特殊的# type: ignore [import]注释,以确保mypy不会因为 PIL 模块中缺少注释而感到困惑。

这是我们的第一个具体策略;这是一个填充算法,用于铺贴图像:

class TiledStrategy(FillAlgorithm):
    def make_background(
            self, 
            img_file: Path, 
            desktop_size: Size
    ) -> Image:
        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 

这是通过将输出高度和宽度除以输入图像的高度和宽度来实现的。num_tiles序列是一种对宽度和高度进行相同计算的方法。它是一个通过列表推导计算的两个元组,以确保宽度和高度以相同的方式进行处理。

这里有一个填充算法,可以在不重新缩放图像的情况下将其居中:

class CenteredStrategy(FillAlgorithm):
    def make_background(
            self, 
            img_file: Path, 
            desktop_size: Size
    ) -> Image:
        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(FillAlgorithm):
    def make_background(
            self, 
            img_file: Path, 
            desktop_size: Size
    ) -> Image:
        in_img = Image.open(img_file)
        out_img = in_img.resize(desktop_size)
        return out_img 

在这里,我们有三个策略子类,每个子类都使用PIL.Image来执行其任务。所有策略实现都有一个make_background()方法,它接受相同的参数集。一旦选择,适当的策略对象可以被调用以创建桌面图像的正确尺寸版本。TiledStrategy计算输入图像块的数量,这些块可以适合显示屏幕的宽度和高度,并将图像复制到每个块位置,重复进行,而不进行缩放,因此可能无法填满整个空间。CenteredStrategy确定需要在图像的四个边缘留下多少空间以使其居中。ScaledStrategy强制将图像调整到输出大小,而不保留原始的宽高比。

这里有一个用于调整大小的整体对象,使用这些策略类之一。当创建一个Resizer实例时,algorithm实例变量会被填充:

class Resizer:
    def __init__(self, algorithm: FillAlgorithm) -> None:
        self.algorithm = algorithm
    def resize(self, image_file: Path, size: Size) -> Image:
        result = self.algorithm.make_background(image_file, size)
        return result 

以下是构建Resizer类实例并应用可用策略类之一的main函数:

def main() -> None:
    image_file = Path.cwd() / "boat.png"
    tiled_desktop = Resizer(TiledStrategy())
    tiled_image = tiled_desktop.resize(image_file, (1920, 1080))
    tiled_image.show() 

重要的是策略实例的绑定尽可能在处理过程中晚些时候发生。决策可以在处理的任何时刻做出(和撤销),因为任何可用的策略对象都可以在任何时候插入到Resizer对象中。

考虑一下在没有策略模式的情况下如何实现这些选项之间的切换。我们需要将所有代码放入一个巨大的方法中,并使用一个尴尬的if语句来选择期望的那个。每次我们想要添加一个新的策略时,我们都需要让这个方法变得更加笨拙。

Python 中的策略

Strategy 模式的先前标准实现,虽然在大多数面向对象的库中非常常见,但在 Python 中并不理想。它涉及一些并非真正必要的开销。

这些策略类每个都定义了只提供单个方法的对象。我们完全可以将其函数命名为 __call__ 并直接使对象可调用。由于与对象关联的数据没有其他,我们只需创建一组顶级函数并将它们作为我们的策略传递即可。

与抽象类带来的开销相比,我们可以用以下类型提示来总结这些策略:

FillAlgorithm = Callable[[Image, Size], Image] 

当我们这样做时,我们可以在类定义中消除所有对FillAlgorithm的引用;我们将class CenteredStrategy(FillAlgorithm):更改为class CenteredStrategy``:

由于我们可以在抽象类和类型提示之间进行选择,策略设计模式似乎显得多余。这导致了一场奇怪的对话,开始于 "因为 Python 有第一类函数,策略模式是不必要的。" 事实上,Python 的第一类函数使我们能够以更直接的方式实现策略模式,而不需要类定义的开销。模式不仅仅是实现细节。了解模式可以帮助我们为我们的程序选择一个好的设计,并使用最易读的语法来实现它。当我们需要允许客户端代码或最终用户在运行时从同一接口的多个实现中选择时,无论是类还是顶级函数,都应该使用策略模式。

在混入类定义和插件策略对象之间存在一条清晰的界限。正如我们在第六章抽象基类和运算符重载中看到的,混入类定义是在源代码中创建的,并且不能在运行时轻易修改。然而,插件策略对象是在运行时填充的,允许策略的后期绑定。它们的代码通常非常相似,为每个类提供清晰的文档字符串来解释各种类如何相互配合是很有帮助的。

命令模式

当我们思考类职责时,有时可以区分出“被动”类,这些类持有对象并维护内部状态,但很少主动发起操作,以及“主动”类,这些类会扩展到其他对象以采取行动和执行任务。这种区分并不是非常清晰,但它可以帮助区分相对被动的观察者(Observer)和更活跃的命令(Command)设计模式。观察者会在有变化时被通知。另一方面,指挥者(Commander)将是主动的,在其他对象中做出状态改变。我们可以结合这两个方面,这就是通过描述适用于类或类之间关系的各种模式来讨论软件架构之美的一部分。

命令模式通常涉及一个类层次结构,每个类都执行某些操作。一个核心类可以创建一个命令(或一系列命令)来执行动作。

从某种意义上说,它是一种元编程:通过创建包含大量语句的命令对象,设计具有命令对象的更高层次“语言”。

这里是一个展示核心对象和一组命令的 UML 图:

图表描述自动生成

图 11.5:UML 中的命令模式

这看起来与策略模式和观察者模式的图示相似,因为所有这些模式都依赖于将工作从核心对象委托给插件对象。在这种情况下,一系列代表执行一系列命令的单独插件对象。

命令示例

例如,我们将查看本章前面提到的装饰器模式示例中省略的复杂骰子滚动。在先前的示例中,我们有一个函数,dice_roller(),它计算了一系列随机数:

def dice_roller(request: bytes) -> bytes:
    request_text = request.decode("utf-8")
    numbers = [random.randint(1, 6) for _ in range(6)]
    response = f"{request_text} = {numbers}"
    return response.encode("utf-8") 

这并不是很聪明;我们更愿意处理一些更复杂的东西。我们希望能够写出像 3d6 这样的字符串来表示三个六面骰子,3d6+2 来表示三个六面骰子加上两个额外的奖励,以及像 4d6d1 这样稍微有点晦涩的字符串来表示“掷四个六面骰子并丢弃一个最低的骰子。”我们可能还想将这两者结合起来,写出 4d6d1+2,以便同时丢弃最低的骰子并给结果加二。

结尾处的这些d1+2选项可以被视为一系列命令。常见的有四种类型:“删除”、“保留”、“添加”和“减去”。当然,还可以有更多,以反映广泛的游戏机制和所需的统计分布,但我们将探讨四种可以修改一批骰子的命令。

这里是我们将要实现的正则表达式:

dice_pattern = re.compile(r"(?P<n>\d*)d(?P<d>\d+)(?P<a>[dk+-]\d+)*") 

这个正则表达式可能有点令人望而生畏。有些人发现www.debuggex.com上的铁路图示很有帮助。这里有一个作为 UML 状态图的表示:

图表描述自动生成

图 11.6:骰子解析正则表达式

此模式包含四个部分:

  1. 第一组,(?P<n>\d*),捕获了一组数字,用于表示骰子的数量,并将其保存为名为 n 的组。这是可选的,因此我们可以写作 d6 而不是 1d6

  2. 必须存在的字母 "d",但并未被捕捉到。

  3. 下一个分组 (?P<d>\d+) 捕获每个骰子上的数字,将其保存为名为 d 的分组。如果我们非常挑剔,可能会尝试将其限制为 (4|6|8|10|12|20|100) 以定义一个可接受的规则多面骰子列表(以及两个常见的非规则多面体)。我们没有提供这个简短的列表;相反,我们将接受任何数字序列。

  4. 最后的分组 (?P<a>[dk+-]\d+)* 定义了一系列重复的调整。每一个调整都有一个前缀和一系列数字,例如,d1k3+1-2。我们将捕获整个调整序列作为分组 a,并分别分解各个部分。这些部分中的每一个都将变成一个命令,遵循命令设计模式。

我们可以将掷骰子的每一部分视为一个独立的命令。一个命令用于掷骰子,随后的一系列命令则调整骰子的数值。例如,3d6+2 表示掷三个骰子(例如,)并将 2 加到总数上,得到 13。整个类看起来是这样的:

class Dice:
    def __init__(self, n: int, d: int, *adj: Adjustment) -> None:
        self.adjustments = [cast(Adjustment, Roll(n, d))] + list(adj)
        self.dice: list[int]
        self.modifier: int
    def roll(self) -> int:
        for a in self.adjustments:
            a.apply(self)
        return sum(self.dice) + self.modifier 

当我们想要掷一个新的骰子时,一个Dice对象会应用单个Adjustment对象来创建一个新的掷骰结果。我们可以在__init__()方法中看到一种Adjustment对象的例子:一个Roll对象。这个对象首先被放入一系列调整中;之后,任何额外的调整都会按顺序处理。每个调整都是另一种命令。

这里是各种调整命令,它们可以改变Dice对象的状态:

class Adjustment(abc.ABC):
    def __init__(self, amount: int) -> None:
        self.amount = amount
    @abc.abstractmethod
    def apply(self, dice: "Dice") -> None:
        ...
class Roll(Adjustment):
    def __init__(self, n: int, d: int) -> None:
        self.n = n
        self.d = d
    def apply(self, dice: "Dice") -> None:
        dice.dice = sorted(
            random.randint(1, self.d) for _ in range(self.n))
        dice.modifier = 0
class Drop(Adjustment):
    def apply(self, dice: "Dice") -> None:
        dice.dice = dice.dice[self.amount :]
class Keep(Adjustment):
    def apply(self, dice: "Dice") -> None:
        dice.dice = dice.dice[: self.amount]
class Plus(Adjustment):
    def apply(self, dice: "Dice") -> None:
        dice.modifier += self.amount
class Minus(Adjustment):
    def apply(self, dice: "Dice") -> None:
        dice.modifier -= self.amount 

Roll() 类的一个实例设置了骰子的值和 Dice 实例的修饰符属性。其他 Adjustment 对象要么移除一些骰子,要么改变修饰符。这些操作依赖于骰子的排序。这使得通过切片操作丢弃最差的或保留最好的变得容易。因为每个调整都相当于一种命令,它们对掷出的骰子的整体状态进行了调整。

缺少的部分是将字符串骰子表达式转换为一系列Adjustment对象。我们将其作为Dice类的@classmethod实现。这使得我们可以使用Dice.from_text()来创建一个新的Dice实例。同时,它还提供了子类作为第一个参数值cls,确保每个子类都创建适当的自身实例,而不是这个父类实例。下面是这个方法的定义:

@classmethod
def from_text(cls, dice_text: str) -> "Dice":
    dice_pattern = re.compile(
        r"(?P<n>\d*)d(?P<d>\d+)(?P<a>[dk+-]\d+)*")
    adjustment_pattern = re.compile(r"([dk+-])(\d+)")
    adj_class: dict[str, Type[Adjustment]] = {
        "d": Drop,
        "k": Keep,
        "+": Plus,
        "-": Minus,
    }
    if (dice_match := dice_pattern.match(dice_text)) is None:
        raise ValueError(f"Error in {dice_text!r}")
    n = int(dice_match.group("n")) if dice_match.group("n") else 1
    d = int(dice_match.group("d"))
    adjustment_matches = adjustment_pattern.finditer(
        dice_match.group("a") or "")
    adjustments = 
        adj_class[a.group(1))) 
        for a in adjustment_matches
    ]
    return cls(n, d, *adjustments) 

首先应用整体dice_pattern,并将结果赋值给dice_match变量。如果结果是None对象,则表示模式不匹配,我们无法做更多的事情,只能抛出一个ValueError异常并放弃。adjustment_pattern用于分解掷骰表达式后缀中的调整字符串。使用列表推导式从Adjustment类定义创建一个对象列表。

每个调整类都是一个独立的命令。Dice 类将注入一个特殊的命令,Roll,它通过模拟掷骰子的动作来启动处理过程。然后,调整命令可以对其初始掷骰结果应用各自的更改。

此设计使我们能够手动创建一个类似实例:

 dice.Dice(4, dice.D6, dice.Keep(3)) 

前两个参数定义了特殊的Roll命令。剩余的参数可以包括任意数量的进一步调整。在这种情况下,只有一个,即Keep(3)命令。另一种方法是解析文本,如下所示:dice.Dice.from_text("4d6k3")。这将构建Roll命令和其他Adjustment命令。每次我们想要一个新的骰子投掷时,都会执行命令序列,先投掷骰子,然后将该投掷调整以给出最终结果。

状态模式

状态模式在结构上与策略模式相似,但其意图和目的是非常不同的。状态模式的目标是表示状态转换系统:在这种系统中,一个对象的行为受到其所在状态的约束,并且存在明确定义的转换到其他状态的过程。

要使这可行,我们需要一个管理器或上下文类,它提供了一个用于切换状态的接口。内部,这个类包含了一个指向当前状态的指针。每个状态都知道它可以处于哪些其他状态,并且会根据对其调用的动作转换到那些状态。

这就是它在 UML 中的样子:

图表描述自动生成

图 11.7:UML 中的状态模式

状态模式将问题分解为两种类型的类:核心类和多个状态类。核心类维护当前状态,并将动作转发给当前状态对象。状态对象通常对调用核心对象的任何其他对象都是隐藏的;它像一个内部执行状态管理的黑盒。

国家示例

最令人信服的特定状态处理示例之一是解析文本。当我们编写一个正则表达式时,我们正在详细描述一系列用于匹配样本文本中模式的替代状态变化。在更高层次上,解析编程语言或标记语言的文本也是高度状态化的工作。像 XML、HTML、YAML、TOML 这样的标记语言,甚至 reStructuredText 和 Markdown 都有关于接下来允许什么和不允许什么的状态化规则。

我们将探讨在解决物联网IoT)问题时出现的一种相对简单的语言。GPS 接收器的数据流是一个有趣的问题。在这种语言中解析语句是状态设计模式的一个例子。这种语言是美国国家海洋电子协会的 NMEA 0183 语言。

GPS 天线的输出是一串字节流,形成了一系列“句子”。每个句子以 $ 开头,包含 ASCII 编码的可打印字符,并以回车符和换行符结束。GPS 设备的输出包括多种不同类型的句子,包括以下几种:

  • GPRMC – 推荐的最小数据

  • GPGGA – 全球定位

  • GPGLL – 纬度和经度

  • GPGSV – 可见卫星

  • GPGSA – 活跃卫星

可用的消息非常多,而且它们从天线设备中以令人困惑的速度输出。然而,它们都遵循一个共同的格式,这使得我们能够轻松地验证和筛选,以便使用对我们特定应用有用的消息,并忽略那些不提供有用信息的消息。

一条典型的消息看起来像这样:

$GPGLL,3723.2475,N,12158.3416,W,161229.487,A,A*41 

这句话具有以下结构:

$ 开始句子
GPGLL “说话者”,GP,以及消息类型,GLL
3723.2475 纬度,37°23.2475
N 赤道以北
12158.3416 经度,121°58.3416
W 0°子午线以西
161229.487 UTC 时区的时间戳:16:12:29.487
A 状态,A=有效,V=无效
A 模式,A=自主,D=差分 GPS,E=DR
* 结束句子,开始校验和
41 文本的十六进制校验和(不包括 `
--- ---
GPGLL “说话者”,GP,以及消息类型,GLL
3723.2475 纬度,37°23.2475
N 赤道以北
12158.3416 经度,121°58.3416
W 0°子午线以西
161229.487 UTC 时区的时间戳:16:12:29.487
A 状态,A=有效,V=无效
A 模式,A=自主,D=差分 GPS,E=DR
* 结束句子,开始校验和
* 字符)

除了少数例外,GPS 发送的所有信息都将具有相似的格式。异常信息将以!开头,我们的设计将安全地忽略它们。

当构建物联网设备时,我们需要注意两个复杂因素:

  1. 事物并不十分可靠,这意味着我们的软件必须为处理损坏或不完整的消息做好准备。

  2. 这些设备非常小巧,一些在大型通用笔记本电脑上运行良好的常见 Python 技术,在只有 32K 内存的微型 Circuit Playground Express 芯片上可能不会很好地工作。

那么,我们需要做的是在字节到达时读取和验证消息。这有助于在处理数据时节省时间(和内存)。因为这些 GPS 消息有一个定义的上限,即 82 字节,因此我们可以使用 Python 的bytearray结构来处理消息的字节。

阅读消息的过程包含多个不同的状态。以下状态转换图显示了可用的状态变化:

图表描述自动生成

图 11.8:解析 NMEA 语句的状态转换

我们开始处于等待下一个$的状态。我们假设物联网设备存在松散的电线和电源问题。(有些人焊接技术非常好,因此对他们来说,不可靠性可能不像对作者那样普遍。)

一旦我们收到$,我们将过渡到读取五个字符头部的状态。如果在任何时候我们再次收到$,这意味着我们在某处丢失了一些字节,我们需要重新开始。一旦我们有了包含消息名称的五个字符,我们就可以过渡到读取消息主体。这将有多达 73 个字节。当我们收到*时,它告诉我们我们已经到达了主体的末尾。同样,如果在过程中我们看到$,这意味着出了些问题,我们应该重新启动。

最后两个字节(*之后)代表一个十六进制值,该值应等于前一条消息(头部和主体)计算出的校验和。如果校验和正确,则消息可以被应用程序使用。消息的末尾将有一个或多个“空白”字符——通常是回车和换行符。

我们可以将这些状态想象为以下类的一个扩展:

class NMEA_State:
    def __init__(self, message: "Message") -> None:
        self.message = message
    def feed_byte(self, input: int) -> "NMEA_State":
        return self
    def valid(self) -> bool:
        return False
    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({self.message})" 

我们已经定义了每个状态都与一个Message对象协同工作。某个读取对象会将一个字节喂给当前状态,该状态会对这个字节进行一些操作(通常是将它保存)并返回下一个状态。确切的行为取决于接收到的字节;例如,大多数状态在接收到$时会将消息缓冲区重置为空并转换到Header状态。大多数状态在valid()函数中会返回False。然而,有一个状态会验证一条完整的消息,如果校验和正确,它可能会为valid()函数返回True

对于纯粹主义者来说,类名并不完全遵循 PEP-8 规范。在包含缩写或首字母缩略词的同时保持正确的驼峰命名格式是一项挑战。看起来NmeaState并不那么清晰。虽然妥协的类名可能是NMEAState,但缩写和类名之间的冲突似乎很令人困惑。在这种情况下,我们更喜欢引用“愚蠢的一致性是小智者的恶魔……”。保持类层次结构内部的一致性比完全遵循 PEP-8 级别的一致性更重要。

Message 对象是围绕两个 bytearray 结构的包装,其中我们累积消息的内容:

class Message:
    def __init__(self) -> None:
        self.body = bytearray(80)
        self.checksum_source = bytearray(2)
        self.body_len = 0
        self.checksum_len = 0
        self.checksum_computed = 0
    def reset(self) -> None:
        self.body_len = 0
        self.checksum_len = 0
        self.checksum_computed = 0
    def body_append(self, input: int) -> int:
        self.body[self.body_len] = input
        self.body_len += 1
        self.checksum_computed ^= input
        return self.body_len
    def checksum_append(self, input: int) -> int:
        self.checksum_source[self.checksum_len] = input
        self.checksum_len += 1
        return self.checksum_len
    @property
    def valid(self) -> bool:
        return (
            self.checksum_len == 2
            and int(self.checksum_source, 16) == self.checksum_computed
        ) 

这个Message类的定义封装了来自 GPS 设备每个句子的重要信息。我们定义了一个方法body_append(),用于在主体中累积字节,并累积这些字节的校验和。在这种情况下,使用^运算符来计算校验和。这是一个真正的 Python 运算符;它是位异或。异或意味着“一个或另一个,但不是两者”。您可以通过类似bin(ord(b'a') ^ ord(b'z'))的表达式看到它的作用。b'a'中的位是0b1100001b'z'中的位是0b1111010。将“一个或另一个,但不是两者”应用于这些位,异或的结果是0b0011011

这里是这样一个读者,它通过接收字节时经历多个状态变化来构建有效的Message对象:

class Reader:
    def __init__(self) -> None:
        self.buffer = Message()
        self.state: NMEA_State = Waiting(self.buffer)
    def read(self, source: Iterable[bytes]) -> Iterator[Message]:
        for byte in source:
            self.state = self.state.feed_byte(cast(int, byte))
            if self.buffer.valid:
                yield self.buffer
                self.buffer = Message()
                self.state = Waiting(self.buffer) 

初始状态是Waiting类的一个实例,它是NMEA_State的子类。read()方法从输入中消耗一个字节,并将其传递给当前的NMEA_State对象进行处理。状态对象可能会保存该字节或丢弃它,状态对象可能会转换到另一个状态,或者它可能会返回当前状态。如果状态对象的valid()方法返回True,则消息完整,我们可以将其提供给应用程序进行进一步处理。

注意,我们会在对象Message的 byte arrays 完整且有效之前重复使用它们。这样做可以避免在嘈杂的线路上忽略不完整消息的同时,分配和释放大量对象。这在大型计算机上的 Python 程序中并不典型。在某些应用中,我们不需要保存原始消息,而只需要保存几个字段的值,从而进一步减少使用的内存量。

为了重用Message对象中的缓冲区,我们需要确保它不是任何特定State对象的一部分。我们已经将当前的Message对象纳入整体的Reader中,并将工作的Message对象作为参数值提供给每个State

现在我们已经了解了上下文,以下是实现不完整消息各种状态的类。我们将从等待初始$以开始消息的状态开始。当看到$时,解析器转换到新的状态,Header

class Waiting(NMEA_State):
    def feed_byte(self, input: int) -> NMEA_State:
        if input == ord(b"$"):
            return Header(self.message)
        return self 

当我们处于Header状态时,我们已经看到了$符号,并且正在等待识别说话者("GP")和句子类型(例如,"GLL")的五个字符。我们将累积字节,直到我们得到这五个字符,然后过渡到Body状态:

class Header(NMEA_State):
    def __init__(self, message: "Message") -> None:
        self.message = message
        self.message.reset()
    def feed_byte(self, input: int) -> NMEA_State:
        if input == ord(b"$"):
            return Header(self.message)
        size = self.message.body_append(input)
        if size == 5:
            return Body(self.message)
        return self 

Body 状态是我们累积大部分消息的地方。对于某些应用,当我们收到不想要的报文类型时,我们可能希望在头部应用额外的处理,并转回到等待头部状态。这可以在处理产生大量数据的设备时节省一点处理时间。

*到达时,主体部分已经完整,接下来的两个字节必须是校验和的一部分。这意味着需要转换到校验和状态:

class Body(NMEA_State):
    def feed_byte(self, input: int) -> NMEA_State:
        if input == ord(b"$"):
            return Header(self.message)
        if input == ord(b"*"):
            return Checksum(self.message)
        self.message.body_append(input)
        return self 

校验和状态类似于在头部状态中累积字节:我们正在等待特定数量的输入字节。在计算校验和之后,大多数消息后面都跟着 ASCII 字符\r\n。如果我们收到这两个字符中的任何一个,我们就过渡到结束状态,在那里我们可以优雅地忽略这些多余的字符:

class Checksum(NMEA_State):
    def feed_byte(self, input: int) -> NMEA_State:
        if input == ord(b"$"):
            return Header(self.message)
        if input in {ord(b"\n"), ord(b"\r")}:
            # Incomplete checksum... Will be invalid.
            return End(self.message)
        size = self.message.checksum_append(input)
        if size == 2:
            return End(self.message)
        return self 

End 状态具有一个额外特性:它覆盖了默认的 valid() 方法。对于所有其他状态,valid() 方法返回 False。一旦我们收到一条完整的消息,此状态的类定义会改变有效性规则:我们现在依赖于 Message 类来比较计算出的校验和与最终的校验和字节,以告诉我们消息是否有效:

class End(NMEA_State):
    def feed_byte(self, input: int) -> NMEA_State:
        if input == ord(b"$"):
            return Header(self.message)
        elif input not in {ord(b"\n"), ord(b"\r")}:
            return Waiting(self.message)
        return self
    def valid(self) -> bool:
        return self.message.valid 

这种以状态为导向的行为改变是使用此设计模式的最主要原因之一。我们不再使用一组复杂的if条件来判断我们是否收到了一个完整的消息,并且它是否包含所有正确的部分和标点符号,而是将这种复杂性重构为多个单独的状态以及状态之间转换的规则。这使我们只有在收到$符号、五个字符、一个正文、*符号、再两个字符,并确认校验和正确时,才检查其有效性。

这是一个测试案例,用于展示其工作原理:

>>> message = b'''
... $GPGGA,161229.487,3723.2475,N,12158.3416,W,1,07,1.0,9.0,M,,,,0000*18
... $GPGLL,3723.2475,N,12158.3416,W,161229.487,A,A*41
... '''
>>> rdr = Reader()
>>> result = list(rdr.read(message))
[Message(bytearray(b'GPGGA,161229.487,3723.2475,N,12158.3416,W,1,07,1.0,9.0,M,,,,0000'), bytearray(b'18'), computed=18), Message(bytearray(b'GPGLL,3723.2475,N,12158.3416,W,161229.487,A,A'), bytearray(b'41'), computed=41)] 

我们已从 SiRF NMEA 参考手册,修订版 1.3 中复制了两个示例消息,以确保我们的解析是正确的。有关 GPS IoT 设备的更多信息,请参阅 www.sparkfun.com/products/13750。有关更多示例和详细信息,请参阅 aprs.gids.nl/nmea/

在解析复杂消息时,使用状态转换通常很有帮助,因为我们可以将验证重构为单独的状态定义和状态转换规则。

国家与战略

状态模式看起来与策略模式非常相似;实际上,这两个模式的 UML 图是相同的。实现方式也完全相同。我们甚至可以将我们的状态写成一等函数,而不是像本章前面关于策略模式部分所建议的那样将它们封装在对象中。

这两种模式相似,因为它们都将工作委托给其他对象。这把复杂问题分解为几个密切相关但更简单的问题。

策略模式用于在运行时选择一个算法;通常情况下,对于特定的用例,只会选择这些算法中的一个。这里的想法是在设计过程尽可能晚的时候提供实现选择。策略类定义很少意识到其他实现;每个策略通常都是独立的。

与此相反,状态模式旨在允许在某个过程演变时动态地在不同状态之间切换。在我们的例子中,状态随着字节的消耗和满足的逐渐变化的验证条件而改变。状态定义通常被定义为具有在各个状态对象之间切换能力的一组。

在一定程度上,用于解析 NMEA 消息的End状态既具有状态模式(State pattern)特征,也具有策略模式(Strategy pattern)特征。因为valid()方法的实现与其他状态不同,这反映了确定句子有效性的不同策略。

单例模式

单例模式引起了一些争议;许多人指责它是一种反模式,一种应该避免而不是推广的模式。在 Python 中,如果有人使用单例模式,他们几乎肯定是在做错事,可能是因为他们来自一种更加限制性的编程语言。

那么,为什么要讨论它呢?单例模式在过度面向对象的编程语言中很有用,并且是传统面向对象编程的重要组成部分。更有意义的是,单例模式背后的思想是有用的,即使我们在 Python 中以完全不同的方式实现这个概念。

单例模式背后的基本思想是确保某个特定对象恰好只有一个实例存在。通常,这类对象是一种管理类,就像我们在第五章何时使用面向对象编程中讨论过的那些。这类管理对象通常需要被各种其他对象引用;将管理对象的引用传递给需要它们的各个方法和构造函数,可能会使代码难以阅读。

相反,当使用单例时,单独的对象会从类中请求管理对象的单个实例。UML 图并没有完全描述它,但为了完整性,这里提供了它:

表格描述自动生成

图 11.9:UML 中的单例模式

在大多数编程环境中,单例模式是通过将构造函数设为私有(这样就没有人可以创建它的额外实例)来实现的,然后提供一个静态方法来获取这个单例实例。该方法在第一次被调用时创建一个新的实例,之后的所有调用都返回同一个实例。

单例实现

Python 没有私有构造函数,但为了这个目的,我们可以使用__new__()类方法来确保始终只创建一个实例:

>>> class OneOnly: 
...     _singleton = None 
...     def __new__(cls, *args, **kwargs): 
...         if not cls._singleton: 
...             cls._singleton = super().__new__(cls, *args, **kwargs) 
...         return cls._singleton 

当调用 __new__() 方法时,它通常构建请求的类的新实例。当我们重写它时,我们首先检查我们的单例实例是否已经被创建;如果没有,我们使用 super 调用来创建它。因此,无论何时我们在 OneOnly 上调用构造函数,我们总是得到完全相同的实例:

>>> o1 = OneOnly()
>>> o2 = OneOnly()
>>> o1 == o2
True
>>> id(o1) == id(o2)
True
>>> o1
<__main__.OneOnly object at 0x7fd9c49ef2b0>
>>> o2
<__main__.OneOnly object at 0x7fd9c49ef2b0> 

这两个对象相等且位于相同的地址;因此,它们是同一个对象。这种特定的实现并不十分透明,因为使用特殊方法创建单例对象并不明显。

我们实际上并不需要这个。Python 提供了两个内置的单例模式供我们利用。与其发明一些难以阅读的东西,有两个选择:

  • Python 模块单例的。一次import操作将创建一个模块。所有后续尝试导入该模块的操作都将返回该模块的唯一单例实例。在需要全局配置文件或缓存的地方,将这部分作为独立模块的一部分。像loggingrandom甚至re这样的库模块都有模块级别的单例缓存。我们将在下面看看如何使用模块级别的变量。

  • Python 类定义也可以被用作单例模式。在给定的命名空间中,一个类只能被创建一次。考虑使用具有类级别属性的类作为单例对象。这意味着需要使用 @staticmethod 装饰器来定义方法,因为永远不会创建实例,也没有 self 变量。

要使用模块级变量而不是复杂的单例模式,我们会在定义它之后实例化该类。我们可以改进之前的状态模式实现,为每个状态使用单例对象。而不是每次改变状态时都创建一个新的对象,我们可以创建一个包含模块级变量的集合,这些变量始终可访问。

我们还将进行一个小但非常重要的设计变更。在上面的示例中,每个状态都有一个对正在累积的Message对象的引用。这要求我们在构建一个新的NMEA_State对象时提供Message对象;我们使用了类似return Body(self.message)的代码来切换到新的状态Body,同时处理同一个Message实例。

如果我们不想创建(并重新创建)状态对象,我们需要将Message作为参数传递给相关方法。

这里是修改后的 NMEA_State 类:

class NMEA_State:
    def enter(self, message: "Message") -> "NMEA_State":
        return self
    def feed_byte(
            self, 
            message: "Message", 
            input: int
    ) -> "NMEA_State":
        return self
    def valid(self, message: "Message") -> bool:
        return False
    def __repr__(self) -> str:
        return f"{self.__class__.__name__}()" 

这个 NMEA_State 类的变体没有任何实例变量。所有的方法都使用客户端传入的参数值。以下是各个状态的定义:

class Waiting(NMEA_State):
    def feed_byte(
            self, 
            message: "Message", 
            input: int
    ) -> "NMEA_State":
        return self
        if input == ord(b"$"):
            return HEADER
        return self
class Header(NMEA_State):
    def enter(self, message: "Message") -> "NMEA_State":
        message.reset()
        return self
    def feed_byte(
            self, 
            message: "Message", 
            input: int
    ) -> "NMEA_State":
        return self
        if input == ord(b"$"):
            return HEADER
        size = message.body_append(input)
        if size == 5:
            return BODY
        return self
class Body(NMEA_State):
    def feed_byte(
            self, 
            message: "Message", 
            input: int
    ) -> "NMEA_State":
        return self
        if input == ord(b"$"):
            return HEADER
        if input == ord(b"*"):
            return CHECKSUM
        size = message.body_append(input)
        return self
class Checksum(NMEA_State):
    def feed_byte(
            self, 
            message: "Message", 
            input: int
    ) -> "NMEA_State":
        return self
        if input == ord(b"$"):
            return HEADER
        if input in {ord(b"\n"), ord(b"\r")}:
            # Incomplete checksum... Will be invalid.
            return END
        size = message.checksum_append(input)
        if size == 2:
            return END
        return self
class End(NMEA_State):
    def feed_byte(
            self, 
            message: "Message", 
            input: int
    ) -> "NMEA_State":
        return self
        if input == ord(b"$"):
            return HEADER
        elif input not in {ord(b"\n"), ord(b"\r")}:
            return WAITING
        return self
    def valid(self, message: "Message") -> bool:
        return message.valid 

这里是从每个NMEA_State类实例创建的模块级变量。

WAITING = Waiting()
HEADER = Header()
BODY = Body()
CHECKSUM = Checksum()
END = End() 

在这些类中的每一个,我们可以引用这五个全局变量来改变解析状态。一开始,引用在类之后定义的全局变量的能力可能显得有些神秘。这是因为 Python 变量名直到运行时才会解析为对象。当每个类正在构建时,像CHECKSUM这样的名字不过是一串字母。当评估Body.feed_byte()方法并且需要返回CHECKSUM的值时,这个名称就会被解析为Checksum()类的单例实例:

注意到Header类是如何重构的。在版本中,每个状态都有一个__init__(),我们可以在进入Header状态时明确评估Message.reset()。由于在这个设计中我们不创建新的状态对象,我们需要一种方法来处理进入新状态的特殊情况,并且只执行一次enter()方法来进行初始化或设置。这个需求导致了对Reader类的一小处改动:

class Reader:
    def __init__(self) -> None:
        self.buffer = Message()
        self.state: NMEA_State = WAITING
    def read(self, source: Iterable[bytes]) -> Iterator[Message]:
        for byte in source:
            new_state = self.state.feed_byte(
            self.buffer, cast(int, byte)
            )
            if self.buffer.valid:
                yield self.buffer
                self.buffer = Message()
                new_state = WAITING
            if new_state != self.state:
                new_state.enter(self.buffer)
                self.state = new_state 

我们不会简单地用self.state.feed_byte()评估的结果替换self.state实例变量的值。相反,我们比较self.state的前一个值与下一个值new_state,以查看是否发生了状态变化。如果发生了变化,那么我们需要在新状态下评估enter(),以便状态变化能够执行所需的任何一次性初始化。

在这个例子中,我们并没有浪费内存去创建每个状态对象的新实例,这些实例最终必须被垃圾回收。相反,我们为每条进入的数据流重用单个状态对象。即使同时运行多个解析器,也只需要使用这些状态对象。状态消息数据在每个状态对象中与状态处理规则分开保存。

我们结合了两种模式,每种模式都有不同的目的。状态模式涵盖了处理过程是如何完成的。单例模式涵盖了对象实例是如何管理的。许多软件设计都涉及了众多重叠和互补的模式。

案例研究

我们将回顾我们在第三章当物体相似时中留出的一部分案例研究。我们讨论了计算距离的各种方法,但将部分设计留待以后完成。现在我们已经看到了一些基本的设计模式,我们可以将其中的一些应用到我们不断发展的案例研究中。

具体来说,我们需要将各种距离计算方法放入超参数类的定义中。在第三章中,我们介绍了距离计算并非单一定义的想法。有超过 50 种常用的距离计算方法,有些简单,有些则相当复杂。在第三章中,我们展示了一些常见的方法,包括欧几里得距离、曼哈顿距离、切比雪夫距离,甚至看起来复杂的索伦森距离。每种方法对邻居的“邻近度”赋予的权重略有不同。

这使我们把超参数类看作包含三个重要组成部分:

  • 对基础TrainingData的引用。这用于找到所有邻居,从中选择最近的。

  • 用于确定将检查多少个邻居的k值。

  • 距离算法。我们希望能够在这里插入任何算法。我们的研究揭示了大量的竞争性选择。这表明实现一个或两个算法不太能适应现实世界的需求。

将距离算法嵌入是一个对策略设计模式的良好应用。对于一个给定的Hyperparameter对象hh.distance对象有一个distance()方法,用于执行计算距离的工作。我们可以嵌入Distance的任何子类来完成这项工作。

这意味着Hyperparameter类的classify()方法将使用策略的self.distance.distance()来计算距离。我们可以利用这一点来提供替代的distance对象以及替代的k值,以找到提供最佳质量未知样本分类的组合。

我们可以使用如下类似的 UML 图来总结这些关系:

图表描述自动生成

图 11.10:带有超参数和距离类的 UML 图

此图重点展示了几个类:

  • Hyperparameter类的实例将引用一个Distance类。这种使用策略设计模式的方法使我们能够根据文献中找到的任何算法创建任意数量的Distance子类。

  • Distance类的实例将计算两个样本之间的距离。研究人员已经设计了 54 种实现方式。我们将坚持使用第三章中展示的几个简单示例:

    • 切比雪夫使用 max() 函数将每个维度上的四个距离缩减为单个最大值。

    • Euclidean 使用 math.hypot() 函数。

    • 曼哈顿是四个维度上每个距离的总和。

  • Hyperparameter类的实例也将引用一个k最近邻Classifier函数。这种使用策略设计模式的方法使我们能够使用任意数量的优化分类算法。

  • 一个TrainingData对象包含原始的Sample对象,这些对象被Hyperparameter对象所共享。

这里是Distance类定义的示例,定义了距离计算的总体协议和Euclidean实现:

from typing import Protocol
from math import hypot
class Distance(Protocol):
    def distance(
            self, 
            s1: TrainingKnownSample, 
            s2: AnySample
    ) -> float:
        ...
class Euclidean(Distance):
    def distance(self, s1: TrainingKnownSample, s2: AnySample) -> float:
      return hypot(
        (s1.sample.sample.sepal_length - s2.sample.sepal_length)**2,
        (s1.sample.sample.sepal_width - s2.sample.sepal_width)**2,
        (s1.sample.sample.petal_length - s2.sample.petal_length)**2,
        (s1.sample.sample.petal_width - s2.sample.petal_width)**2,
      ) 

我们定义了一个Distance协议,以便像mypy这样的工具能够识别执行距离计算的类。distance()函数的主体是 Python 令牌...。这确实是三个点;在这里,这不是书中的占位符,而是我们像在第六章中学到的那样,用于抽象方法主体的令牌。

曼哈顿距离和切比雪夫距离彼此相似。曼哈顿距离是特征之间变化的总和,而切比雪夫距离是特征之间的最大变化:

class Manhattan(Distance):
    def distance(self, s1: TrainingKnownSample, s2: AnySample) -> float:
        return sum(
            [
                abs(s1.sample.sample.sepal_length - s2.sample.sepal_length),
                abs(s1.sample.sample.sepal_width - s2.sample.sepal_width),
                abs(s1.sample.sample.petal_length - s2.sample.petal_length),
                abs(s1.sample.sample.petal_width - s2.sample.petal_width),
            ]
        )
class Chebyshev(Distance):
    def distance(self, s1: TrainingKnownSample, s2: AnySample) -> float:
        return max(
            [
                abs(s1.sample.sample.sepal_length - s2.sample.sepal_length),
                abs(s1.sample.sample.sepal_width - s2.sample.sepal_width),
                abs(s1.sample.sample.petal_length - s2.sample.petal_length),
                abs(s1.sample.sample.petal_width - s2.sample.petal_width),
            ]
        ) 

类似地,k-最近邻分类也可以定义为一个具有替代实现策略的层次结构。正如我们在第十章迭代器模式中看到的,执行此算法也有多种方式。我们可以使用一个简单的排序列表方法,或者使用一个更复杂的方法,其中我们使用堆队列,或者使用bisect模块作为减少大量邻居开销的一种方式。我们在这里不会重复所有第十章的定义。这些都是定义为函数的,这是最简单的一种版本,它累积并排序所有距离计算,寻找最近的k个样本:

From collections import Counter
def k_nn_1(
        k: int, 
        dist: DistanceFunc, 
        training_data: TrainingList, 
        unknown: AnySample
) -> str:
    distances = sorted(
        map(lambda t: Measured(dist(t, unknown), t), training_data))
    k_nearest = distances[:k]
    k_frequencies: Counter[str] = Counter(
        s.sample.sample.species for s in k_nearest
    )
    mode, fq = k_frequencies.most_common(1)[0]
    return mode 

给定这两类距离函数和整体分类器算法,我们可以定义一个超参数类,该类依赖于两个插件策略对象。由于细节已经被分解到可以按需扩展的单独类层次结构中,因此类定义变得相当小:

class Hyperparameter(NamedTuple):
    k: int
    distance: Distance
    training_data: TrainingList
    classifier: Classifier
    def classify(self, unknown: AnySample) -> str:
        classifier = self.classifier
        distance = self.distance
        return classifier(
            self.k, distance.distance, self.training_data, unknown) 

这就是如何创建和使用一个超参数实例。这展示了策略对象是如何提供给超参数对象的:

>>> data = [
...     KnownSample(sample=Sample(1, 2, 3, 4), species="a"),
...     KnownSample(sample=Sample(2, 3, 4, 5), species="b"),
...     KnownSample(sample=Sample(3, 4, 5, 6), species="c"),
...     KnownSample(sample=Sample(4, 5, 6, 7), species="d"),
... ]
>>> manhattan = Manhattan().distance
>>> training_data = [TrainingKnownSample(s) for s in data]
>>> h = Hyperparameter(1, manhattan, training_data, k_nn_1)
>>> h.classify(UnknownSample(Sample(2, 3, 4, 5)))
'b' 

我们创建了一个Manhattan类的实例,并将该对象的distance()方法(方法对象,而非计算出的距离值)提供给Hyperparameter实例。我们为最近邻分类提供了k_nn_1()函数。训练数据是一系列四个KnownSample对象。

我们在距离函数和分类器算法之间有一个细微的区分,距离函数对分类效果有直接影响,而分类器算法则是一种微小的性能优化。我们可以争论说,这些并不是真正的同级,也许我们已经在某一类中堆积了过多的特征。我们实际上并不需要测试分类器算法的质量;相反,我们只需要测试其性能。

这个微小的例子确实正确地定位了给定未知样本的最近邻。作为一个实际问题,我们需要一个更复杂的测试能力来检查测试数据集的所有样本。

我们可以将以下方法添加到上面定义的Hyperparameter类中:

def test(self, testing: TestingList) -> float:
    classifier = self.classifier
    distance = self.distance
    test_results = (
        ClassifiedKnownSample(
            t.sample,
            classifier(
                self.k, distance.distance, 
                self.training_data, t.sample),
        )
        for t in testing
    )
    pass_fail = map(
        lambda t: (1 if t.sample.species == t.classification else 0), 
        test_results
    )
    return sum(pass_fail) / len(testing) 

对于给定的Hyperparameter,这个test()方法可以将classify()方法应用于测试集中所有的样本。正确分类的测试样本数与总测试数的比例是衡量这种特定参数组合整体质量的一种方式。

存在许多超参数的组合,可以使用命令模式(Command design pattern)来创建多个测试命令。每个这些命令实例都会包含创建和测试一个独特的Hyperparameter对象所需的所有值。我们可以创建一个包含这些命令的大集合,以执行全面的超参数调整。

当执行基本命令时,会创建一个Timing对象。Timing对象是对测试结果的总结,其外观如下:

class Timing(NamedTuple):
    k: int
    distance_name: str
    classifier_name: str
    quality: float
    time: float  # Milliseconds 

测试命令接受一个超参数和测试数据的引用。这可以在之后实际收集调整结果时使用。使用命令设计模式使得将创建命令与执行命令分离成为可能。这种分离有助于理解正在发生的事情。当存在一次性设置处理,而我们不想在比较各种算法的性能时测量它时,这也可能是必要的。

这里是我们的TestCommand类定义:

import time
class TestCommand:
    def __init__(
        self,
        hyper_param: Hyperparameter,
        testing: TestingList,
    ) -> None:
        self.hyperparameter = hyper_param
        self.testing_samples = testing
    def test(self) -> Timing:
        start = time.perf_counter()
        recall_score = self.hyperparameter.test(self.testing_samples)
        end = time.perf_counter()
        timing = Timing(
            k=self.hyperparameter.k,
            distance_name=
                self.hyperparameter.distance.__class__.__name__,
            classifier_name=
                self.hyperparameter.classifier.__name__,
            quality=recall_score,
            time=round((end - start) * 1000.0, 3),
        )
        return timing 

构造函数保存了超参数和测试样本列表。当test()方法被评估时,会运行测试,并创建一个Timing对象。对于这个非常小的数据集,测试运行得非常快。对于更大和更复杂的数据集,超参数调整可能需要数小时。

这里有一个函数用于构建并执行一系列TestCommand实例。

def tuning(source: Path) -> None:
    train, test = load(source)
    scenarios = [
        TestCommand(Hyperparameter(k, df, train, cl), test)
        for k in range(3, 33, 2)
        for df in (euclidean, manhattan, chebyshev)
        for cl in (k_nn_1, k_nn_b, k_nn_q)
    ]
    timings = [s.test() for s in scenarios]
    for t in timings:
        if t.quality >= 1.0:
            print(t) 

此函数加载原始数据并将数据分区。此代码基本上是第九章字符串、序列化和文件路径的主题。它为许多组合的k、距离和分类函数创建多个TestCommand对象,并将这些对象保存在scenarios列表中。

在所有命令实例创建完毕后,它将执行所有对象,并将结果保存在timings列表中。这些结果会被显示出来,以帮助我们找到最优的超参数集。

我们在构建调谐函数时使用了策略模式和命令模式。三个距离计算类是 Singleton 类似类设计的良好候选者:我们只需要每个这些对象的一个实例。通过设计模式来描述设计,可以使得向其他开发者描述设计变得更加容易。

回忆

软件设计的领域充满了好主意。真正的好主意会被重复并形成可重复的模式。了解并使用这些软件设计模式可以帮助开发者避免在试图重新发明已经被开发出来的东西时消耗大量的脑力。在本章中,我们探讨了几个最常见的模式:

  • 装饰器模式在 Python 语言中被用于向函数或类添加功能。我们可以定义装饰器函数并直接应用它们,或者使用@语法将装饰器应用于另一个函数。

  • 观察者模式可以简化编写 GUI 应用程序。它也可以用于非 GUI 应用程序,以形式化状态改变的对象与显示或总结或以其他方式使用状态信息的对象之间的关系。

  • 策略模式在许多面向对象编程中起着核心作用。我们可以将大问题分解为包含数据和策略对象的容器,这些对象有助于处理数据。策略对象是另一种对象的“插件”。这为我们提供了适应、扩展和改进处理方式的方法,而无需在我们进行更改时破坏我们编写的所有代码。

  • 命令模式是一种方便的方式来总结应用于其他对象的一系列更改。在来自网络客户端的外部命令到达的 Web 服务环境中,它非常有帮助。

  • 状态模式是一种定义处理方式,其中存在状态变化和行为变化。我们通常可以将独特或特殊情况的处理推入特定状态的对象中,利用策略模式来插入特定状态的行为。

  • 单例模式用于那些需要确保只有一个特定类型的对象存在的情况。例如,通常会将应用程序限制为仅与中央数据库建立一个连接。

这些设计模式帮助我们组织复杂的对象集合。了解多种模式可以帮助开发者可视化一组协作的类,并分配它们的职责。这也有助于开发者讨论设计:当他们都阅读过关于设计模式的同一本书时,他们可以通过名称引用模式并跳过冗长的描述。

练习

在编写本章的示例时,作者们发现提出应该使用特定设计模式的优秀示例可能非常困难,但同时也极具教育意义。与其像我们在前几章建议的那样,通过查看现有或旧项目来确定可以应用这些模式的地方,不如思考这些模式和可能出现的不同情况。尝试跳出自己的经验思考。如果你的当前项目在银行业务领域,考虑一下你如何在零售或销售点应用这些设计模式。如果你通常编写 Web 应用程序,那么在编写编译器时考虑使用设计模式吧。

研究装饰器模式,并想出一些何时应用它的好例子。关注模式本身,而不是我们讨论过的 Python 语法。这比实际模式要通用一些。然而,装饰器的特殊语法也是你可能希望在现有项目中寻找应用的地方。

使用观察者模式有哪些好的应用领域?为什么?不仅要考虑如何应用这个模式,还要思考在不使用观察者模式的情况下如何实现同样的任务。选择使用观察者模式,你得到了什么,又失去了什么?

考虑策略模式与状态模式之间的区别。在实现上,它们看起来非常相似,但它们有不同的目的。你能想到哪些情况下这些模式可以互换使用吗?将基于状态的系统重新设计为使用策略模式,或者相反,是否合理?实际上,设计会有多大的不同呢?

在掷骰子示例中,我们解析了一个简单的表达式来创建几个命令。还有更多可选的选项。请参阅help.roll20.net/hc/en-us/articles/360037773133-Dice-Reference#DiceReference-Roll20DiceSpecif以获取一些用于描述骰子和骰子游戏的复杂语法。为了实现这一点,需要进行两项更改。首先,设计所有这些选项的命令层次结构。之后,编写一个正则表达式来解析更复杂的掷骰子表达式并执行所有现有的命令。

我们已经注意到可以使用 Python 模块变量来构建单例对象。有时比较两种不同的 NMEA 消息处理器的性能是有帮助的。如果你没有带 USB 接口的 GPS 芯片,你可以在互联网上搜索解析 NMEA 示例消息。aprs.gids.nl/nmea/ 是一个很好的示例来源。模块变量可能引起的混淆与应用性能之间有一个权衡问题。拥有支持你所学课程的数据是有帮助的。

摘要

本章详细讨论了几种常见的设计模式,包括示例、UML 图和 Python 与静态类型面向对象语言之间差异的讨论。装饰者模式通常使用 Python 更通用的装饰器语法来实现。观察者模式是一种将事件与其在事件上采取的操作解耦的有用方法。策略模式允许选择不同的算法来完成相同的任务。命令模式帮助我们设计具有公共接口但执行不同操作的主动类。状态模式看起来与策略模式相似,但用于表示可以使用定义良好的操作在不同状态之间移动的系统。在有些静态类型语言中流行的单例模式,在 Python 中几乎总是反模式。

在下一章,我们将结束对设计模式的讨论。

第十二章:高级设计模式

在本章中,我们将介绍更多设计模式。我们将再次涵盖标准示例,以及任何在 Python 中常见的替代实现。我们将讨论以下内容:

  • 适配器模式

  • 外观模式

  • 懒加载和享元模式

  • 抽象工厂模式

  • 组合模式

  • 模板模式

本章的案例研究将展示如何将这些模式中的几个应用到鸢尾花样本问题中。特别是,我们将展示设计中有多少是基于这些模式——隐含地——构建的。

与《设计模式:可复用面向对象软件元素》中的实践一致,我们将首字母大写模式名称。

我们将从适配器模式开始。这种模式通常用于在对象的设计不完全符合我们的需求时,提供一个所需的接口。

适配器模式

与我们在上一章中回顾的大多数模式不同,适配器模式的设计目的是与现有代码进行交互。我们不会设计一套全新的对象来实现适配器模式。适配器用于允许两个预先存在的对象协同工作,即使它们的接口不兼容。就像允许您将 Micro USB 充电线插入 USB-C 手机的显示适配器一样,适配器对象位于两个不同接口之间,在运行时进行转换。适配器对象的唯一目的是执行这种转换。适配可能涉及各种任务,例如将参数转换为不同的格式、重新排列参数的顺序、调用不同名称的方法,或者提供默认参数。

在结构上,适配器模式类似于简化的装饰器模式。装饰器通常提供与它们所替代的相同接口,而适配器则在不同接口之间进行映射。这在上面的 UML 图中以图形形式表示:

图片

图 12.1:适配器模式

在这里,一个客户端对象,即Client的一个实例,需要与其他类协作以完成一些有用的操作。在这个例子中,我们使用load_data()作为一个需要适配器的具体方法示例。

我们已经有一个完美的类,命名为Implementation,它能够完成我们想要的所有操作(为了避免重复,我们不想重新编写它!)。这个完美的类有一个问题:它需要使用名为read_raw_data()parse_raw_data()create_useful_object()的方法进行一系列复杂的操作。Adapter类实现了一个易于使用的load_data()接口,它隐藏了由Implementation提供的现有接口的复杂性。

这种设计的优势在于,将希望接口映射到实际接口的代码都集中在一个地方,即适配器类。另一种选择则需要将代码放入客户端,可能会使客户端充斥着可能无关的实现细节。如果我们有多个客户端,那么每当这些客户端需要访问Implementation类时,我们都必须在多个地方执行复杂的load_data()处理。

适配器示例

想象我们有一个以下预定义的类,它接受格式为HHMMSS的字符串时间戳,并从这些字符串中计算出有用的浮点时间间隔:

class TimeSince:
    """Expects time as six digits, no punctuation."""
    def parse_time(self, time: str) -> tuple[float, float, float]:
        return (
            float(time[0:2]),
            float(time[2:4]),
            float(time[4:]),
        )
    def __init__(self, starting_time: str) -> None:
        self.hr, self.min, self.sec = self.parse_time(starting_time)
        self.start_seconds = ((self.hr * 60) + self.min) * 60 + self.sec
    def interval(self, log_time: str) -> float:
        log_hr, log_min, log_sec = self.parse_time(log_time)
        log_seconds = ((log_hr * 60) + log_min) * 60 + log_sec
        return log_seconds - self.start_seconds 

这个类处理字符串到时间间隔的转换。由于我们在应用程序中已经有了这个类,它有单元测试用例并且运行良好。如果你忘记了from __future__ import annotations,尝试使用tuple[float, float, float]作为类型提示时会出现错误。确保将annotations模块作为代码的第一行包含进来。

这里有一个示例,展示了这个类是如何工作的:

>>> ts = TimeSince("000123")  # Log started at 00:01:23
>>> ts.interval("020304")
7301.0
>>> ts.interval("030405")
10962.0 

与这些未格式化的时间打交道有点尴尬,但许多物联网IoT)设备提供这类时间字符串,与日期的其他部分分开。例如,看看来自 GPS 设备的 NMEA 0183 格式消息,其中日期和时间是未格式化的数字字符串。

我们有一份这些设备之一的旧日志,显然是几年前创建的。我们希望分析这份日志,找出每个 ERROR 消息之后发生的消息序列。我们希望得到相对于 ERROR 的精确时间,作为我们根本原因问题分析的一部分。

这里是我们用于测试的一些日志数据:

>>> data = [
...     ("000123", "INFO", "Gila Flats 1959-08-20"),
...     ("000142", "INFO", "test block 15"),
...     ("004201", "ERROR", "intrinsic field chamber door locked"),
...     ("004210.11", "INFO", "generator power active"),
...     ("004232.33", "WARNING", "extra mass detected")
... ] 

计算 ERROR 信息和 WARNING 信息之间的时间间隔很困难。这并非不可能;我们大多数人都有足够的指头来进行计算。但最好是用相对时间而不是绝对时间来显示日志。以下是我们要使用的日志格式化程序的概要。然而,这段代码有一个问题,我们用???标记了它:

class LogProcessor:
    def __init__(self, log_entries: list[tuple[str, str, str]]) -> None:
        self.log_entries = log_entries
    def report(self) -> None:
        first_time, first_sev, first_msg = self.log_entries[0]
        for log_time, severity, message in self.log_entries:
            if severity == "ERROR":
                first_time = log_time
            **interval = ??? Need to compute an interval ???**
            print(f"{interval:8.2f} | {severity:7s} {message}") 

这个LogProcessor类看起来是正确的做法。它遍历日志条目,每次遇到 ERROR 行时都会重置first_time变量。这确保了日志显示从错误开始的偏移量,从而让我们免于进行大量的数学计算来确定确切发生了什么。

但是,我们遇到了一个问题。我们非常希望重用TimeSince类。然而,它并不简单地计算两个值之间的间隔。我们有几种选择来应对这种情况:

  • 我们可以将TimeSince类重写为与一对时间字符串一起工作。这可能会使我们的应用中其他部分出现问题的风险增加。我们有时将这种变化称为溅射半径——当我们把一块大石头扔进游泳池时,会有多少其他东西被弄湿?开放/封闭设计原则(SOLID 原则之一,我们在第四章案例研究中讨论过;更多背景信息请参阅subscription.packtpub.com/book/application_development/9781788835831/4)建议一个类应该易于扩展但不易于这种修改。如果这个类是从 PyPI 下载的,我们可能不想改变其内部结构,因为那样我们就无法使用任何后续版本。我们需要一个替代方案来避免在另一个类内部进行篡改。

  • 我们可以使用当前的类,并且每次我们需要计算一个错误和随后的日志行之间的间隔时,我们都会创建一个新的TimeSince对象。这会导致大量的对象创建。想象一下,如果我们有多个日志分析应用程序,每个应用程序都在查看日志消息的不同方面。进行更改意味着我们必须返回并修复所有创建这些TimeSince对象的地方。在LogProcessor类中添加TimeSince类工作细节会使类变得杂乱无章,这违反了单一职责设计原则。另一个原则,不要重复自己DRY),在这个情况下似乎也适用。

  • 相反,我们可以添加一个适配器,将LogProcessor类的需求与TimeSince类提供的方法相连接。

适配器解决方案引入了一个类,该类提供了LogProcessor类所需的接口。它消费了TimeSince类提供的接口。它允许两个类独立演化,使它们对修改封闭但对扩展开放。它看起来是这样的:

class IntervalAdapter:
    def __init__(self) -> None:
        self.ts: Optional[TimeSince] = None
    def time_offset(self, start: str, now: str) -> float:
        if self.ts is None:
            self.ts = TimeSince(start)
        else:
            h_m_s = self.ts.parse_time(start)
            if h_m_s != (self.ts.hr, self.ts.min, self.ts.sec):
                self.ts = TimeSince(start)
        return self.ts.interval(now) 

此适配器在需要时创建一个TimeSince对象。如果没有TimeSince,它必须创建一个。如果已经存在一个TimeSince对象,并且它使用已经建立的开端时间,那么TimeSince实例可以被重用。然而,如果LogProcessor类已经将分析的焦点转移到新的错误消息上,那么就需要创建一个新的TimeSince

这是LogProcessor类的最终设计,使用了IntervalAdapter类:

class LogProcessor:
    def __init__(
        self,
        log_entries: list[tuple[str, str, str]]
    ) -> None:
        self.log_entries = log_entries
        self.time_convert = IntervalAdapter()
    def report(self) -> None:
        first_time, first_sev, first_msg = self.log_entries[0]
        for log_time, severity, message in self.log_entries:
            if severity == "ERROR":
                first_time = log_time
            **interval = self.time_convert.time_offset(first_time, log_time)**
            print(f"{interval:8.2f} | {severity:7s} {message}") 

在初始化过程中,我们创建了一个IntervalAdapter()实例。然后我们使用这个对象来计算每个时间偏移。这使得我们可以在不修改原始类的情况下重用现有的TimeSince类,并且它使LogProcessor不会因为TimeSince的工作细节而变得杂乱。

我们也可以通过继承来处理这种设计。我们可以扩展TimeSince类来添加所需的方法。这种继承方案并不是一个坏主意,它说明了存在没有单一“正确”答案的常见情况。在某些情况下,我们需要编写出继承解决方案,并将其与适配器解决方案进行比较,以看哪一个更容易解释。

与继承不同,有时我们也可以使用猴子补丁(monkey patching)来向现有类中添加方法。Python 允许我们添加一个新方法,它提供了调用代码所需的适配接口。这意味着,当然,class 语句内部的易于找到的类定义并不是运行时使用的整个类。我们迫使其他开发者搜索代码库以找出新特性被猴子补丁添加到类的位置。在单元测试之外,猴子补丁并不是一个好主意。

经常可以将一个函数用作适配器。虽然这并不明显符合适配器类设计模式的传统设计,但这种区别对实际影响很小:具有__call__()方法的类是一个可调用对象,与函数无法区分。一个函数可以是一个完美的适配器;Python 不要求所有内容都必须在类中定义。

适配器(Adapter)和装饰器(Decorator)之间的区别虽小但很重要。适配器通常扩展、修改或组合被适配的类(们)的多个方法。然而,装饰器一般避免进行深刻的变化,保持给定方法的相似接口,并逐步添加功能。正如我们在第十一章“常见设计模式”中看到的,装饰器应被视为一种特殊的适配器。

使用适配器类与使用策略类非常相似;其理念是我们可能需要进行更改,并在某一天需要不同的适配器。主要区别在于策略通常在运行时选择,而适配器则是在设计时做出的选择,并且变化非常缓慢。

我们接下来要探讨的下一个模式类似于适配器,因为它同样是在一个新容器中封装功能。区别在于被封装内容的复杂性;外观(Façade)通常包含更为复杂的结构。

外观模式

外观模式旨在为复杂组件系统提供一个简单的接口。它允许我们定义一个新的类,该类封装了系统的典型用法,从而避免暴露隐藏在多个对象交互中的许多实现细节的设计。任何我们需要访问常见或典型功能的时候,我们都可以使用单个对象的简化接口。如果项目的另一部分需要访问更完整的功能,它仍然可以直接与组件和单个方法进行交互。

Façade 模式的 UML 图实际上依赖于子系统,显示为一个包,Big System,但以模糊的方式看起来是这样的:

图片

图 12.2:外观模式

外观模式在很多方面类似于适配器模式。主要区别在于外观模式试图从一个复杂的接口中抽象出一个更简单的接口,而适配器模式只尝试将一个现有的接口映射到另一个接口。

外墙示例

这本书的图像是用 PlantUML 制作的(plantuml.com). 每个图表最初都是一个文本文件,需要转换成作为文本一部分的 PNG 文件。这是一个两步过程,我们使用外观模式(Façade pattern)来合并这两个过程。

第一部分是定位所有的 UML 文件。这是通过遍历目录树来查找所有以 .uml 结尾的文件。我们还会查看文件内部,看看是否有多个在文件内部命名的图表。

from __future__ import annotations
import re
from pathlib import Path
from typing import Iterator, Tuple
class FindUML:
    def __init__(self, base: Path) -> None:
        self.base = base
        self.start_pattern = re.compile(r"@startuml *(.*)")
    def uml_file_iter(self) -> Iterator[tuple[Path, Path]]:
        for source in self.base.glob("**/*.uml"):
            if any(n.startswith(".") for n in source.parts):
                continue
            body = source.read_text()
            for output_name in self.start_pattern.findall(body):
                if output_name:
                    target = source.parent / output_name
                else:
                    target = source.with_suffix(".png")
                yield (
                    source.relative_to(self.base),
                    target.relative_to(self.base)
                ) 

FindUML 类需要一个基本目录。uml_file_iter() 方法遍历整个目录树,使用 Path.glob() 方法。它会跳过任何以 . 开头的目录;这些目录通常被像 toxmypygit 这样的工具使用,我们不希望查看这些目录内部。剩余的文件将包含 @startuml 行。其中一些将包含指定多个输出文件的行。大多数 UML 文件不会创建多个文件。如果提供了名称,self.start_pattern 正则表达式将捕获该名称。迭代器产生包含两个路径的元组。

分别地,我们有一个类,它作为子进程运行 PlantUML 应用程序。当 Python 运行时,它是一个操作系统进程。我们可以使用 subprocess 模块启动子进程,运行其他二进制应用程序或 shell 脚本。它看起来是这样的:

import subprocess
class PlantUML:
    conda_env_name = "CaseStudy"
    base_env = Path.home() / "miniconda3" / "envs" / conda_env_name
    def __init__(
        self,
        graphviz: Path = Path("bin") / "dot",
        plantjar: Path = Path("share") / "plantuml.jar",
    ) -> None:
        self.graphviz = self.base_env / graphviz
        self.plantjar = self.base_env / plantjar
    def process(self, source: Path) -> None:
        env = {
            "GRAPHVIZ_DOT": str(self.graphviz),
        }
        command = [
          "java", "-jar",         str(self.plantjar), "-progress",         str(source)
        ]
        subprocess.run(command, env=env, check=True)
        print() 

这个 PlantUML 类依赖于使用 conda 创建一个名为 CaseStudy 的虚拟环境。如果使用其他虚拟环境管理器,子类可以提供所需的路径修改。我们需要将 Graphviz 软件包安装到指定的虚拟环境中;这将把图表渲染为图像文件。我们还需要在某处下载 plantuml.jar 文件。我们选择将其放入虚拟环境内的 share 目录中。command 变量的值假设 Java 运行时环境JRE)已正确安装且可见。

subprocess.run() 函数接受命令行参数以及需要设置的任何特殊环境变量。它将运行给定的命令,在给定的环境中执行,并且会检查返回的代码以确保程序正确运行。

分别地,我们可以使用这些步骤来查找所有的 UML 文件并创建图表。因为界面有点不灵活,遵循外观模式的类有助于创建一个有用的命令行应用程序。

class GenerateImages:
    def __init__(self, base: Path) -> None:
        self.finder = FindUML(base)
        self.painter = PlantUML()
    def make_all_images(self) -> None:
        for source, target in self.finder.uml_file_iter():
            if (
               not target.exists() 
               or source.stat().st_mtime > target.stat().st_mtime
            ):
                print(f"Processing {source} -> {target}")
                self.painter.process(source)
            else:
                print(f"Skipping {source} -> {target}") 

GenerateImages 类是一个方便的界面,它结合了 FindUMLPlantUML 类的功能。它使用 FindUML.uml_file_iter() 方法定位源文件并输出图像文件。它会检查这些文件的修改时间,以避免在图像文件比源文件新时处理它们。(stat().st_mtime 非常晦涩;结果发现 Pathstat() 方法提供了大量的文件状态信息,而修改时间只是我们能从文件中找到的许多信息之一。)

如果.uml文件较新,这意味着作者之一对其进行了修改,因此需要重新生成图像。执行此操作的主要脚本现在变得非常简单:

if __name__ == "__main__":
    g = GenerateImages(Path.cwd())
    g.make_all_images() 

这个例子展示了 Python 可以用来自动化事物的一个重要方法。我们将这个过程分解成几个步骤,这些步骤可以用几行代码实现。然后我们把这些步骤组合起来,用门面模式(Façade)封装它们。另一个更复杂的应用可以使用门面模式,而不必深入关心其实现细节。

尽管在 Python 社区中很少被提及,但外观模式(Façade pattern)是 Python 生态系统的一个基本组成部分。因为 Python 强调语言的易读性,所以其语言及其库都倾向于为复杂任务提供易于理解的接口。例如,for 循环、list 推导和生成器都是对更复杂的迭代协议的外观封装。defaultdict 的实现是一个外观封装,它抽象掉了当字典中不存在键时的烦人边缘情况。

第三方requestshttpx库都是对 HTTP 处理中不太易读的urllib库的强大封装。urllib包本身是对使用底层socket包管理基于文本的 HTTP 协议的封装。

外观隐藏了复杂性。有时,我们希望避免数据重复。下一个设计模式可以帮助在处理大量数据时优化存储。它在非常小的计算机上特别有用,这些计算机是物联网应用的典型代表。

轻量级模式

Flyweight 模式是一种内存优化模式。新手 Python 程序员往往忽略内存优化,认为内置的垃圾回收器会处理它。依赖内置的内存管理是开始的最佳方式。在某些情况下,例如,非常大的数据科学应用,内存限制可能成为障碍,需要采取更积极的措施。在非常小的物联网设备中,内存管理也可能很有帮助。

Flyweight 模式确保共享相同状态的对象可以使用相同的内存来存储它们的共享状态。它通常只在程序已经显示出内存问题时才会被实现。在某些情况下,从一开始就设计一个最优配置可能是有意义的,但请记住,过早优化是创建一个过于复杂而难以维护的程序的最有效方式。

在某些语言中,Flyweight 设计需要仔细共享对象引用,避免意外复制对象,并仔细跟踪对象所有权以确保对象不会被提前删除。在 Python 中,一切皆对象,所有对象都通过一致的引用进行工作。Python 中的 Flyweight 设计通常比其他语言要简单一些。

让我们看一下以下 Flyweight 模式的 UML 图:

图片

图 12.3:享元模式

每个 Flyweight 对象都没有自己的特定状态。每次它需要在对 SpecificState 执行操作时,该状态都需要由调用代码作为参数值传递给 Flyweight。传统上,返回 Flyweight 类实例的工厂是一个单独的对象;其目的是返回单个 Flyweight 对象,可能通过某种键或索引进行组织。它的工作方式类似于我们在 第十一章常见设计模式 中讨论的单例模式;如果 Flyweight 存在,我们就返回它;否则,我们创建一个新的。在许多语言中,工厂不是作为一个单独的对象实现,而是作为 Flyweight 类本身的静态方法实现。

我们可以将这比作万维网取代了装满数据的计算机的方式。在古代,我们被迫收集和索引文档和文件,将我们的本地计算机填满源材料的副本。这曾经涉及到像软盘和 CD 这样的物理媒体的传输。现在,我们可以通过一个网站——来获取原始数据的引用,而不需要制作一个庞大、占用空间的副本。因为我们是在使用源数据的引用进行工作,所以我们可以在移动设备上轻松地阅读它。使用数据引用的 Flyweight 原则对我们获取信息的方式产生了深远的影响。

与仅需要返回一个类实例的 Singleton 设计模式不同,Flyweight 设计模式可能包含多个 Flyweight 类的实例。一种方法是将项目存储在字典中,并根据字典键为 Flyweight 对象提供值。在有些物联网应用中,另一种常见的方法是利用项目缓冲区。在大型计算机上,分配和释放对象相对成本较低。在小型物联网计算机上,我们需要最小化对象创建,这意味着利用共享缓冲区的 Flyweight 设计。

Python 中的轻量级示例

我们将从一个与 GPS 消息协同工作的物联网设备的具体类开始。我们不希望创建大量具有从源缓冲区复制值的单个Message对象;相反,我们希望使用 Flyweight 对象来帮助节省内存。这利用了两个重要的特性:

  • 轻量级对象在单个缓冲区中复用字节。这避免了在小型计算机中的数据重复。

  • Flyweight 类可以对各种消息类型进行独特的处理。特别是 GPGGA、GPGLL 和 GPRMC 消息都包含经纬度信息。尽管消息的细节各不相同,但我们不想创建不同的 Python 对象。当唯一的实际处理区别是相关字节在缓冲区中的位置时,这会产生相当大的开销。

这里是 UML 图:

图 12.4:GPS 消息 UML 图

给定一个从 GPS 读取字节的Buffer对象,我们可以应用MessageFactory来创建各种Message子类的 Flyweight 实例。每个子类都可以访问共享的Buffer对象,并可以生成一个Point对象,但它们具有独特的实现,反映了每个消息的独特结构。

Python 中存在一个独特的附加复杂性。当我们对 Buffer 对象的多个实例进行引用时,可能会遇到麻烦。在处理了若干条消息之后,我们会在每个 Message 子类中拥有局部、临时数据,包括对 Buffer 实例的引用。

情况可能看起来如下所示,其中包含具体对象及其引用:

图片

图 12.5:参考图

一些客户端应用程序,以Client对象的形式展示,拥有对Buffer实例的引用。它将大量 GPS 交通数据读入这个缓冲区。此外,一个特定的GPGGA实例也拥有对Buffer对象的引用,因为缓冲区中的偏移量 0 处有一个 GPGGA 消息。偏移量 68 和 98 处有其他消息;这些也将有对Buffer实例的引用。

因为Buffer对象有一个指向 GPGGA Message对象的引用,而Message对象也回指Buffer对象,所以我们存在一对循环引用。当客户端停止使用Buffer时,引用计数从四个引用变为三个。我们无法轻易移除Buffer及其Message对象。

我们可以通过利用 Python 的 weakref 模块来解决此问题。与普通("强")引用不同,弱引用不计入内存管理的范畴。我们可以对一个对象拥有许多弱引用,但一旦最后一个普通引用被移除,该对象就可以从内存中移除。这允许客户端开始使用一个新的 Buffer 对象,而无需担心旧的 Buffer 会占用内存。强引用的数量从一变为零,从而允许其被移除。同样,每个 Message 对象可能从 Buffer 有一个强引用,因此移除 Buffer 也会移除每个 Message

弱引用是 Python 运行时基础的一部分。因此,它们是一些特殊情况下出现的重要优化。其中一种优化是我们不能创建对 bytes 对象的弱引用。这种开销会非常痛苦。

在少数情况下(如这种情况)我们需要为底层的 bytes 对象创建一个适配器,以便将其转换为一个可以拥有弱引用的对象。

class Buffer(Sequence[int]):
    def __init__(self, content: bytes) -> None:
        self.content = content
    def __len__(self) -> int:
        return len(self.content)
    def __iter__(self) -> Iterator[int]:
        return iter(self.content)
    @overload
    def __getitem__(self, index: int) -> int:
        ...
    @overload
    def __getitem__(self, index: slice) -> bytes:
        ...
    def __getitem__(self, index: Union[int, slice]) -> Union[int, bytes]:
        return self.content[index] 

这个Buffer类的定义实际上并不包含很多新的代码。我们提供了三个特殊方法,而这三个方法都将工作委托给了底层的bytes对象。Sequence抽象基类为我们提供了一些方法,例如index()count()

重载的 __getitem__() 方法的三个定义是我们如何向 mypy 表明 buffer[i]buffer[start: end] 这样的表达式之间的重要区别。第一个表达式从缓冲区获取单个 int 类型的项,第二个使用切片并返回一个 bytes 对象。__getitem__() 的最终非重载定义通过将工作委托给 self.contents 对象来实现这两个重载,该对象很好地处理了这一点。

第十一章常见设计模式中,我们探讨了使用基于状态的设计来获取和计算校验和。本章采用了一种不同的方法来处理大量快速到达的 GPS 消息。

这里是一个典型的 GPS 消息:

>>> raw = Buffer(b"$GPGLL,3751.65,S,14507.36,E*77") 

$符号开始消息。*符号结束消息。*符号之后的字符是校验值。在这个例子中,我们将忽略两个校验字节,并相信它是正确的。以下是具有一些常用方法的Message抽象类,这些方法有助于解析这些 GPS 消息:

class Message(abc.ABC):
    def __init__(self) -> None:
        self.buffer: weakref.ReferenceType[Buffer]
        self.offset: int
        self.end: Optional[int]
        self.commas: list[int]
    def from_buffer(self, buffer: Buffer, offset: int) -> "Message":
        self.buffer = weakref.ref(buffer)
        self.offset = offset
        self.commas = [offset]
        self.end = None
        for index in range(offset, offset + 82):
            if buffer[index] == ord(b","):
                self.commas.append(index)
            elif buffer[index] == ord(b"*"):
                self.commas.append(index)
                self.end = index + 3
                break
        if self.end is None:
            raise GPSError("Incomplete")
        # TODO: confirm checksum.
        return self
    def __getitem__(self, field: int) -> bytes:
        if (not hasattr(self, "buffer") 
            or (buffer := self.buffer()) is None):
        raise RuntimeError("Broken reference")
    start, end = self.commas[field] + 1, self.commas[field + 1]
    return buffer[start:end] 

__init__() 方法实际上并没有做任何事情。我们提供了一组实例变量及其类型列表,但在这里并没有实际设置它们。这是一种通知 mypy 在类的其他地方将要设置哪些实例变量的方式。

from_buffer()方法中,我们使用weakref.ref()函数创建对Buffer实例的弱引用。如上所述,这种特殊引用不用于跟踪Buffer对象被使用的位置数量,即使Message对象仍然持有对它们的旧、过时的引用,也允许移除Buffer对象。

from_buffer() 方法扫描缓冲区以查找 "," 字符,这使得定位每个字段的位置变得更容易。如果我们需要多个字段,这可以节省一些时间。如果我们只需要一个或两个字段,这可能会造成过度的开销。

__getitem__() 方法中,我们取消对弱引用的引用以追踪 Buffer 对象。通常情况下,当处理 Buffer 时,它会在内存中与一些 Message 对象一起存在。评估 self.buffer() – 就像调用函数一样调用引用 – 获取我们可以在方法主体中使用的普通引用。在 __getitem__() 方法的末尾,缓冲区变量不再使用,临时引用也随之消失。

客户端应用程序可能包含如下代码:

while True:
    buffer = Buffer(gps_device.read(1024))
    # process the messages in the buffer. 

buffer 变量对 Buffer 对象有一个普通引用。理想情况下,这应该是唯一的引用。每次我们执行这个赋值语句时,旧的 Buffer 对象将没有引用并且可以从内存中移除。在这条赋值语句之后,在我们评估 Messagefrom_buffer() 方法之前,尝试使用 Message 对象的 __getitem__() 方法将引发 RuntimeError 异常。

如果我们的应用程序试图在没有先执行 set_fields() 的情况下使用 Message 对象的 __getitem__() 方法,那将是一个严重且致命的错误。我们通过使应用程序崩溃来试图让它变得明显。当我们到达第 13 章面向对象程序的测试 时,我们可以使用单元测试来确认方法是否按照正确的顺序使用。在此之前,我们必须确保我们正确地使用了 __getitem__()

这里是Message抽象基类的其余部分,展示了从消息中提取修复所需的方法:

def get_fix(self) -> Point:
    return Point.from_bytes(
        self.latitude(), 
        self.lat_n_s(), 
        self.longitude(), 
        self.lon_e_w()
    )
@abc.abstractmethod
def latitude(self) -> bytes:
    ...
@abc.abstractmethod
def lat_n_s(self) -> bytes:
    ...
@abc.abstractmethod
def longitude(self) -> bytes:
    ...
@abc.abstractmethod
def lon_e_w(self) -> bytes:
    ... 

get_fix() 方法将工作委托给四个独立的方法,每个方法从 GPS 消息中提取多个字段中的一个。我们可以提供如下子类:

class GPGLL(Message):
    def latitude(self) -> bytes:
        return self[1]
    def lat_n_s(self) -> bytes:
        return self[2]
    def longitude(self) -> bytes:
        return self[3]
    def lon_e_w(self) -> bytes:
        return self[4] 

本节课将使用从Message类继承而来的get_field()方法,从整个字节序列中挑选出四个特定字段的字节。因为get_field()方法使用了一个指向Buffer对象的引用,所以我们不需要复制整个消息的字节序列。相反,我们回溯到Buffer对象以获取数据,从而避免内存的杂乱。

我们还没有展示Point对象。它被留作练习的一部分。它需要将字节字符串转换为有用的浮点数。

这是我们如何根据缓冲区中的消息类型创建一个合适的享元对象:

def message_factory(header: bytes) -> Optional[Message]:
    # TODO: Add functools.lru_cache to save storage and time
    if header == b"GPGGA":
        return GPGGA()
    elif header == b"GPGLL":
        return GPGLL()
    elif header == b"GPRMC":
        return GPRMC()
    else:
        return None 

如果我们在查看一个已识别的消息,我们会创建我们 Flyweight 类中的一个实例。我们留下了一条注释建议另一个练习:使用functools.lru_cache来避免创建已经可用的Message对象。让我们看看message_factory()在实际中是如何工作的:

>>> buffer = Buffer(
...     b"$GPGLL,3751.65,S,14507.36,E*77"
... )
>>> flyweight = message_factory(buffer[1 : 6])
>>> flyweight.from_buffer(buffer, 0)
<gps_messages.GPGLL object at 0x7fc357a2b6d0>
>>> flyweight.get_fix()
Point(latitude=-37.86083333333333, longitude=145.12266666666667)
>>> print(flyweight.get_fix())
(37°51.6500S, 145°07.3600E) 

我们已经将一些字节加载到了一个Buffer对象中。消息名称是缓冲区中位置 1 到 6 的字节切片。切片操作将在这里创建一个小的bytes对象。message_factory()函数将定位我们 Flyweight 类定义中的一个,即GPGLL类。然后我们可以使用from_buffer()方法,这样 Flyweight 就可以从偏移量零开始扫描Buffer,寻找","字节以确定各个字段的起始点和结束点。

当我们评估 get_fix() 时,GPGLL 飞行轻量级将提取四个字段,将值转换为有用的度数,并返回一个包含两个浮点值的 Point 对象。如果我们想将其与其他设备关联起来,我们可能希望显示一个度数和分钟分开的值。看到 37°51.6500S 比看到 37.86083333333333 更有帮助。

缓冲区中的多条消息

让我们稍微展开一下,看看一个包含消息序列的缓冲区。我们将把两个 GPGLL 消息放入字节数列中。我们将包括一些 GPS 设备在数据流中包含的显式行尾空白字符。

>>> buffer_2 = Buffer(
...     b"$GPGLL,3751.65,S,14507.36,E*77\\r\\n"
...     b"$GPGLL,3723.2475,N,12158.3416,W,161229.487,A,A*41\\r\\n"
... )
>>> start = 0
>>> flyweight = message_factory(buffer_2[start+1 : start+6])
>>> p_1 = flyweight.from_buffer(buffer_2, start).get_fix()
>>> p_1
Point(latitude=-37.86083333333333, longitude=145.12266666666667)
>>> print(p_1)
(37°51.6500S, 145°07.3600E) 

我们找到了第一条 GPGLL 消息,创建了一个GPGLL对象,并从消息中提取了定位信息。下一条消息从上一条消息结束的地方开始。这使得我们可以在缓冲区的新偏移量处开始,检查不同的字节区域。

>>> flyweight.end
30
>>> next_start = buffer_2.index(ord(b"$"), flyweight.end)
>>> next_start
32
>>> 
>>> flyweight = message_factory(buffer_2[next_start+1 : next_start+6])
>>> p_2 = flyweight.from_buffer(buffer_2, next_start).get_fix()
>>> p_2
Point(latitude=37.387458333333335, longitude=-121.97236)
>>> print(p_2)
(37°23.2475N, 121°58.3416W) 

我们使用了message_factory()函数来创建一个新的 GPGLL 对象。由于消息中的数据不在对象中,我们可以重用之前的 GPGLL 对象。我们可以移除flyweight =这一行代码,结果仍然相同。当我们使用from_buffer()方法时,我们会定位到新的“,”字符批次。当我们使用get_fix()方法时,我们会从整体字节数据集合中的新位置获取值。

此实现创建了一些短的字节字符串来创建一个用于message_factory()的缓存对象。当它创建一个Point时,它会创建新的浮点值。然而,它通过使消息处理对象重用单个Buffer实例来避免传递大块字节。

通常,在 Python 中使用享元模式是一个确保我们有原始数据引用的问题。通常,Python 避免对对象进行隐式复制;几乎所有的对象创建都是显而易见的,使用类名或者可能是理解语法。一个对象创建不明显的情况是从序列中取切片,比如字节数组缓冲区:当我们使用bytes[start: end]时,这会创建字节数组的副本。如果这些副本太多,我们的物联网设备就会耗尽可用内存。享元设计避免了创建新对象,并且特别避免通过切片字符串和字节来创建数据的副本。

我们的例子还介绍了weakref。这对于 Flyweight 设计来说不是必需的,但它可以帮助识别可以从内存中移除的对象。虽然这两个经常一起出现,但它们之间并没有紧密的联系。

Flyweight 模式可以对内存消耗产生巨大的影响。对于优化 CPU、内存或磁盘空间的编程解决方案来说,它们通常会产生比未优化的版本更复杂的代码。因此,在决定代码的可维护性和优化之间权衡时,非常重要。在选择优化时,尽量使用如 Flyweight 这样的模式,以确保优化引入的复杂性仅限于代码的一个(良好文档化的)部分。

在我们探讨抽象工厂模式之前,我们将稍微偏离一下主题,来了解一下 Python 特有的另一种内存优化技术。这就是__slots__魔法属性名。

通过 Python 的 __slots__ 进行内存优化

如果你在一个程序中有很多 Python 对象,另一种节省内存的方法是通过使用__slots__。这是一个旁注,因为它不是 Python 语言之外常见的模式。这是一个有用的 Python 设计模式,因为它可以从广泛使用的对象中节省几个字节。与共享存储的 Flyweight 设计不同——其中存储是故意共享的——slots 设计创建了具有自己私有数据的对象,但避免了 Python 的内置字典。相反,存在从属性名到值序列的直接映射,避免了每个 Python dict对象都包含的相当大的哈希表。

回顾本章之前的示例,我们避免了描述作为Message每个子类的get_fix()方法创建的Point对象。下面是Point类的一个可能定义:

class Point:
    __slots__ = ("latitude", "longitude")
    def __init__(self, latitude: float, longitude: float) -> None:
        self.latitude = latitude
        self.longitude = longitude
    def __repr__(self) -> str:
        return (
            f"Point(latitude={self.latitude}, "
            f"longitude={self.longitude})"
        ) 

每个 Point 实例恰好可以有两个属性,名称分别为 latitudelongitude__init__() 方法设置这些值,并为像 mypy 这样的工具提供了有用的类型提示。

在大多数其他方面,这个类与没有 __slots__ 的类相同。最显著的区别是我们不能添加属性。以下是一个示例,展示了会抛出什么异常:

>>> p2 = Point(latitude=49.274, longitude=-123.185)
>>> p2.extra_attribute = 42
Traceback (most recent call last):
...
AttributeError: 'Point' object has no attribute 'extra_attribute' 

定义槽位名称的额外维护工作,在我们应用程序创建大量此类对象时可能有所帮助。然而,在许多情况下,我们的应用程序建立在类的一个或非常少数的实例之上,引入__slots__所带来的内存节省是微不足道的。

在某些情况下,使用NamedTuple可以像使用__slots__一样有效地节省内存。我们曾在第七章Python 数据结构中讨论过这些内容。

我们已经看到了如何通过封装对象在门面(Façade)中管理复杂性。我们也看到了如何通过使用具有少量(或没有)内部状态的享元(Flyweight)对象来管理内存使用。接下来,我们将探讨如何使用工厂创建各种不同类型的对象。

抽象工厂模式

抽象工厂模式适用于我们有一个或多个系统实现的可能,这些实现依赖于某些配置或平台细节。调用代码从抽象工厂请求一个对象,并不知道将返回哪种类的对象。底层返回的实现可能依赖于各种因素,例如当前区域设置、操作系统或本地配置。

抽象工厂模式的常见示例包括操作系统无关的工具包代码、数据库后端、以及特定国家的格式化器或计算器。一个操作系统无关的 GUI 工具包可能会使用抽象工厂模式,在 Windows 下返回一组 WinForm 小部件,在 Mac 下返回一组 Cocoa 小部件,在 Gnome 下返回一组 GTK 小部件,在 KDE 下返回一组 QT 小部件。Django 提供了一个抽象工厂,根据当前站点的配置设置返回一组用于与特定数据库后端(MySQL、PostgreSQL、SQLite 等)交互的对象关系类。如果应用程序需要部署在多个地方,每个地方只需更改一个配置变量就可以使用不同的数据库后端。不同的国家有不同的系统来计算零售商品的税费、小计和总计;抽象工厂可以返回特定的税费计算对象。

抽象工厂有两个核心特性:

  • 我们需要有多重实现选择。每个实现都有一个工厂类来创建对象。一个单独的抽象工厂定义了实现工厂的接口。

  • 我们拥有许多紧密相关的对象,并且这些关系是通过每个工厂的多种方法实现的。

以下 UML 类图看起来像是一团关系的混乱:

图片

图 12.6:抽象工厂模式

这里有一个非常重要的基本对称性。客户端需要 A 类和 B 类的实例。对于客户端来说,这些都是抽象类定义。Factory类是一个抽象基类,它需要一个实现。每个实现包,implementation_1implementation_2,都提供了具体的Factory子类,这些子类将为客户端构建必要的 A 和 B 实例。

抽象工厂示例

没有具体示例,抽象工厂模式的 UML 类图很难理解,所以让我们先从创建一个具体示例开始。让我们看看两种纸牌游戏,扑克和克里比奇。别慌,你不需要知道所有规则,只需知道它们在几个基本方面相似但在细节上不同。这如下面的图中所示:

图片

图 12.7:Cribbage 和 Poker 的抽象工厂模式

Game 类需要 Card 对象和 Hand 对象(以及其他几个对象)。我们已经展示了抽象的 Card 对象包含在抽象的 Hand 集合中。每个实现都提供了一些独特的功能。大部分情况下,PokerCard 与通用的 Card 定义相匹配。然而,PokerHand 类却扩展了 Hand 抽象基类,包含了定义手牌等级的所有独特规则。扑克玩家知道扑克游戏变体非常多。我们展示了包含五张牌的手牌,因为这似乎是许多游戏的一个共同特征。

Cribbage(克里比奇)的实现引入了多种CribbageCard子类,每个子类都有一个额外的属性,即点数。CribbageFace牌都值 10 点,而其他种类的CribbageCard类中点数与牌面等级相匹配。CribbageHand类通过具有在手中找到所有计分组合的独特规则的抽象基类Hand进行扩展。我们可以使用抽象工厂来构建CardHand对象。

这里是的核心定义。我们没有创建这些官方的抽象基类。Python 不需要这些,额外的复杂性看起来也没有什么帮助。

from enum import Enum, auto
from typing import NamedTuple, List
class Suit(str, Enum):
    Clubs = "\N{Black Club Suit}"
    Diamonds = "\N{Black Diamond Suit}"
    Hearts = "\N{Black Heart Suit}"
    Spades = "\N{Black Spade Suit}"
class Card(NamedTuple):
    rank: int
    suit: Suit
    def __str__(self) -> str:
        return f"{self.rank}{self.suit}"
class Trick(int, Enum):
    pass
class Hand(List[Card]):
    def __init__(self, *cards: Card) -> None:
        super().__init__(cards)
    def scoring(self) -> List[Trick]:
        pass 

这些似乎捕捉了“牌”和“牌手”的本质。我们需要通过子类扩展这些,以适应每个游戏。我们还需要一个抽象工厂来为我们创建牌和牌手:

import abc
class CardGameFactory(abc.ABC):
    @abc.abstractmethod
    def make_card(self, rank: int, suit: Suit) -> "Card":
        ...
    @abc.abstractmethod
    def make_hand(self, *cards: Card) -> "Hand":
        ... 

我们已经将工厂类设计成了一个实际的抽象基类。每个单独的游戏都需要为游戏的独特特性“手牌”和“牌”提供扩展。游戏还将提供一个CardGameFactory类的实现,该实现可以构建预期的类。

我们可以这样定义克里比奇牌戏的牌:

class CribbageCard(Card):
    @property
    def points(self) -> int:
        return self.rank
class CribbageAce(Card):
    @property
    def points(self) -> int:
        return 1
class CribbageFace(Card):
    @property
    def points(self) -> int:
        return 10 

这些对基本 Card 类的扩展都包含一个额外的点数属性。在克里比奇牌戏中,一种技巧类型是任何点数为 15 的牌的组合。大多数牌的点数等于其花色等级,但杰克、王后和国王都值 10 点。这也意味着对 Hand 的克里比奇扩展有一个相当复杂的计分方法,我们现在将省略。

class CribbageHand(Hand):
    starter: Card
    def upcard(self, starter: Card) -> "Hand":
        self.starter = starter
        return self
    def scoring(self) -> list[Trick]:
        """15's. Pairs. Runs. Right Jack."""
        ... details omitted ...
        return tricks 

为了在游戏之间提供一些统一性,我们将 Cribbage 中的计分组合和 Poker 中的手牌等级定义为“回合”的子类。在 Cribbage 中,有相当多的计分回合。而在 Poker 中,则有一个代表整个手牌的单一回合。回合似乎不是一个抽象工厂能发挥作用的地方。

克里比奇牌计算各种得分组合是一个相当复杂的问题。它涉及到查看所有可能的牌的组合,这些组合的总分为 15 点,以及其他方面。这些细节与抽象工厂设计模式无关。

德州扑克变体有其独特的复杂性:Aces(王牌)的等级高于 King(国王):

class PokerCard(Card):
    def __str__(self) -> str:
        if self.rank == 14:
            return f"A{self.suit}"
        return f"{self.rank}{self.suit}"
class PokerHand(Hand):
    def scoring(self) -> list[Trick]:
        """Return a single 'Trick'"""
     ... details omitted ...
        return [rank] 

对扑克牌的各种手牌进行排名也是一个相当复杂的问题,但这个问题并不属于抽象工厂的范畴。下面是具体工厂构建扑克牌手牌和牌的示例:

class PokerFactory(CardGameFactory):
    def make_card(self, rank: int, suit: Suit) -> "Card":
        if rank == 1:
            # Aces above kings
            rank = 14
        return PokerCard(rank, suit)
    def make_hand(self, *cards: Card) -> "Hand":
        return PokerHand(*cards) 

注意make_card()方法如何反映了扑克牌中 A 牌的工作方式。A 牌高于 K 牌反映了众多纸牌游戏中常见的复杂情况;我们需要反映 A 牌的各种工作方式。

这里是一个关于克里比奇游戏如何进行的测试案例:

>>> factory = CribbageFactory()
>>> cards = [
...     factory.make_card(6, Suit.Clubs),
...     factory.make_card(7, Suit.Diamonds),
...     factory.make_card(8, Suit.Hearts),
...     factory.make_card(9, Suit.Spades),
... ]
>>> starter = factory.make_card(5, Suit.Spades)
>>> hand = factory.make_hand(*cards)
>>> score = sorted(hand.upcard(starter).scoring())
>>> [t.name for t in score]
['Fifteen', 'Fifteen', 'Run_5'] 

我们已经创建了一个CribbageFactory类的实例,这是抽象类CardGameFactory的一个具体实现。我们可以使用这个工厂来创建一些牌,同时也可以用它来创建一副牌。在玩克里比奇牌戏时,会额外翻出一张牌,称为“起始牌”。在这种情况下,我们的手牌是按顺序的四张牌,而起始牌恰好与这个顺序相匹配。我们可以计算手牌的分数,并看到有三个得分组合:有两种方式可以凑出 15 分,再加上一个五张牌的顺子。

此设计提供了一些提示,关于当我们想要添加更多游戏支持时需要做什么。引入新规则意味着需要创建新的HandCard子类,同时也需要扩展抽象工厂类的定义。当然,继承带来了复用的机会,这是我们能够利用来创建具有相似规则的游戏家族的方法。

Python 中的抽象工厂

之前的例子突出了 Python 鸭式类型检查方式的一个有趣后果。我们真的需要这个抽象基类 CardGameFactory 吗?它提供了一个用于类型检查的框架,但除此之外并没有任何有用的功能。由于我们实际上并不需要它,我们可以将这种设计视为有三个并行模块:

图片

图 12.8:无抽象基类的抽象工厂

两个定义的游戏都实现了一个名为 CardGameFactory 的类,该类定义了游戏独特的功能。因为这些功能在独立的模块中,我们可以为每个类使用相同的名称。这使得我们能够编写一个使用 from cribbage import CardGameFactory 的克瑞比奇应用程序。这跳过了公共抽象基类的开销,并允许我们通过共享一些公共基类定义的模块来提供扩展。每个替代实现还提供了一个公共模块级接口:它们公开了一个标准类名,用于处理创建独特对象的剩余细节。

在这种情况下,抽象工厂只是一个概念,并不是作为一个实际的抽象基类来实现的。我们需要为所有声称是 CardGameFactory 实现的类提供充分的文档说明。我们可以通过定义一个使用 typing.Protocol 的协议来明确我们的意图。它可能看起来像这样:

class CardGameFactoryProtocol(Protocol):
    def make_card(self, rank: int, suit: Suit) -> "Card":
        ...
    def make_hand(self, *cards: Card) -> "Hand":
        ... 

这个定义允许 mypy 确认 Game 类可以引用 poker.CardGameFactorycribbage.CardGameFactory,因为两者都实现了相同的协议。与抽象基类定义不同,这并不是一个运行时检查。协议定义仅由 mypy 用于确认代码可能通过其单元测试套件。

抽象工厂模式帮助我们定义相关对象家族,例如,扑克牌和手牌。单个工厂可以生产两个紧密相关的独立类对象。在某些情况下,这些关系不仅仅是集合和项目。有时除了项目外,还有子集合。这类结构可以使用组合设计模式来处理。

组合模式

组合模式允许从简单的组件(通常称为节点)构建复杂的树结构。带有子节点的节点将表现得像一个容器;没有子节点的节点将表现得像一个单一的对象。组合对象通常是一个容器对象,其中内容可能又是另一个组合对象。

传统上,复合对象中的每个节点必须是叶节点(不能包含其他对象)或复合节点。关键在于复合节点和叶节点可以拥有相同的接口。下面的 UML 图展示了这种优雅的并行性,通过some_action()方法表示:

图片

图 12.9:组合模式

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

图片

图 12.10:一个大型组合模式

组合模式适用于语言处理。自然语言和人工语言(如 Python)都倾向于遵循层次化的规则,并且很好地与组合设计模式相匹配。标记语言,如 HTML、XML、RST 和 Markdown,往往反映了某些常见的组合概念,例如列表中的列表和带有子标题的标题。

编程语言涉及递归树结构。Python 标准库中包含了ast模块,该模块提供了定义 Python 代码结构的类。我们可以使用这个模块来检查 Python 代码,而无需求助于正则表达式或其他难以正确处理的文本处理方法。

一个综合示例

组合模式需要应用于像文件系统中的文件和文件夹这样的树结构。无论树中的节点是普通数据文件还是文件夹,它都仍然要受到移动、复制或删除节点等操作的影响。我们可以创建一个支持这些操作的组件接口,然后使用组合对象来表示文件夹,以及使用叶节点来表示数据文件。

当然,在 Python 中,我们再次可以利用鸭子类型来隐式提供接口,因此我们只需要编写两个类。让我们首先在以下代码中定义这些接口:

class Folder:
    def __init__(
            self, 
            name: str, 
            children: Optional[dict[str, "Node"]] = None
    ) -> None:
        self.name = name
        self.children = children or {}
        self.parent: Optional["Folder"] = None
    def __repr__(self) -> str:
        return f"Folder({self.name!r}, {self.children!r})"
    def add_child(self, node: "Node") -> "Node":
        node.parent = self
        return self.children.setdefault(node.name, node)
    def move(self, new_folder: "Folder") -> None:
        pass
    def copy(self, new_folder: "Folder") -> None:
        pass
    def remove(self) -> None:
        pass
class File:
    def __init__(self, name: str) -> None:
        self.name = name
        self.parent: Optional[Folder] = None
    def __repr__(self) -> str:
        return f"File({self.name!r})"

    def move(self, new_path): 
        pass 

    def copy(self, new_path): 
        pass 

    def remove(self): 
        pass 

对于每个复合对象Folder,我们维护一个子对象的字典。子对象可能包括FolderFile实例的混合。对于许多复合实现,列表就足够了,但在这个情况下,使用字典通过名称查找子对象将会很有用。

考虑到涉及的方法,存在几种模式:

  • 进行移动操作时,移动文件夹会将其所有子项一同移动。移动文件的结果将与上述代码完全相同,因为我们不需要考虑子项。

  • 为了进行复制,我们需要复制所有的子节点。由于复合对象的外部没有File节点之外的数据,我们不需要做更多的事情。

  • 对于删除操作,我们应该遵循 Linux 模式,在尝试移除父节点之前先清除子节点。

此设计使我们能够创建具有不同操作实现的子类。每个子类的实现可能进行外部请求,或者也许在本地机器上执行操作系统请求。

为了利用类似的操作,我们可以将公共方法提取到一个父类中。让我们重构一下,创建一个基类,Node,以下代码所示:

class Node(abc.ABC):
    def __init__(
        self,
        name: str,
    ) -> None:
        self.name = name
        self.parent: Optional["Folder"] = None
    def move(self, new_place: "Folder") -> None:
        previous = self.parent
        new_place.add_child(self)
        if previous:
            del previous.children[self.name]
    @abc.abstractmethod
    def copy(self, new_folder: "Folder") -> None:
        ...
    @abc.abstractmethod
    def remove(self) -> None:
        ... 

这个名为 Node 的抽象类定义了每个节点都有一个字符串,用于引用其父节点。保留父节点信息使我们能够沿着树向上查看根节点。这使得通过更改父节点的子节点集合来移动和删除文件成为可能。

我们在Node类中创建了move()方法。这个方法通过将FolderFile对象重新分配到新位置来实现。随后,它会从其原始位置删除该对象。对于move()方法,目标应该是一个已存在的文件夹,否则我们会得到一个错误,因为File实例没有add_child()方法。正如技术书籍中的许多例子一样,错误处理严重缺失,以帮助集中关注正在考虑的原则。一种常见的做法是通过引发一个新的TypeError异常来处理AttributeError异常。参见第四章预料之外

我们可以将这个类扩展以提供具有子项的文件夹的独特特性和作为树中叶节点的文件,它没有子项:

class Folder(Node):
    def __init__(
            self, 
            name: str, 
            children: Optional[dict[str, "Node"]] = None
    ) -> None:
        super().__init__(name)
        self.children = children or {}
    def __repr__(self) -> str:
        return f"Folder({self.name!r}, {self.children!r})"
    def add_child(self, node: "Node") -> "Node":
        node.parent = self
        return self.children.setdefault(node.name, node)
    def copy(self, new_folder: "Folder") -> None:
        target = new_folder.add_child(Folder(self.name))
        for c in self.children:
            self.children[c].copy(target)
    def remove(self) -> None:
        names = list(self.children)
        for c in names:
            self.children[c].remove()
        if self.parent:
            del self.parent.children[self.name]
class File(Node):
    def __repr__(self) -> str:
        return f"File({self.name!r})"
    def copy(self, new_folder: "Folder") -> None:
        new_folder.add_child(File(self.name))
    def remove(self) -> None:
        if self.parent:
            del self.parent.children[self.name] 

当我们将一个孩子添加到文件夹中时,我们会做两件事。首先,我们告诉孩子他们的新父母是谁。这确保了每个节点(除了根文件夹实例)都有一个父节点。其次,如果新的节点尚未存在于文件夹的子节点集合中,我们将将其放入文件夹的子节点集合中。

当我们在复制 Folder 对象时,需要确保所有子对象都被复制。每个子对象可能本身又是一个带有子对象的 Folder。这种递归遍历涉及到将 copy() 操作委托给 Folder 实例内的每个子 Folder。另一方面,对于 File 对象的实现则更为简单。

删除的递归设计类似于递归复制。一个Folder实例必须首先删除所有子项;这可能包括删除子Folder实例。另一方面,File对象可以直接删除。

好吧,这很简单。让我们看看以下代码片段是否能够正确地工作我们的组合文件层次结构:

>>> tree = Folder("Tree")
>>> tree.add_child(Folder("src"))
Folder('src', {})
>>> tree.children["src"].add_child(File("ex1.py"))
File('ex1.py')
>>> tree.add_child(Folder("src"))
Folder('src', {'ex1.py': File('ex1.py')})
>>> tree.children["src"].add_child(File("test1.py"))
File('test1.py')
>>> tree
Folder('Tree', {'src': Folder('src', {'ex1.py': File('ex1.py'), 'test1.py': File('test1.py')})}) 

tree的值可能有点难以可视化。这里有一个显示方式的变体,可以帮助理解。

+-- Tree
     +-- src
          +-- ex1.py
          +-- test1.py 

我们没有涵盖生成这种嵌套可视化的算法。将其添加到类定义中并不太难。我们可以看到父文件夹Tree下有一个子文件夹src,里面包含两个文件。我们可以这样描述文件系统操作:

>>> test1 = tree.children["src"].children["test1.py"]
>>> test1
File('test1.py')
>>> tree.add_child(Folder("tests"))
Folder('tests', {})
>>> test1.move(tree.children["tests"])
>>> tree
Folder('Tree', 
    {'src': Folder('src', 
        {'ex1.py': File('ex1.py')}), 
     'tests': Folder('tests', 
        {'test1.py': File('test1.py')})}) 

我们创建了一个新的文件夹,tests,并将文件移动了。以下是结果组合对象的另一种视图:

+-- Tree
     +-- src
          +-- ex1.py
     +-- tests
          +-- test1.py 

组合模式对于各种树形结构极为有用,包括 GUI 小部件层次结构、文件层次结构、树集、图和 HTML DOM。有时,如果只创建一个浅层树,我们可以用列表的列表或字典的字典来应付,而不需要实现自定义的组件、叶子和组合类。实际上,JSON、YAML 和 TOML 文档通常遵循字典的字典模式。虽然我们通常使用抽象基类来处理这种情况,但这并不是必需的;Python 的鸭子类型可以使得向组合层次结构添加其他对象变得容易,只要它们具有正确的接口。

组合模式的其中一个重要方面是节点各种子类型的一个公共接口。我们需要为FolderFile类提供两种实现变体。在某些情况下,这些操作是相似的,提供一个复杂方法的模板实现可能会有所帮助。

模板模式

模板模式(有时称为模板方法)对于去除重复代码非常有用;它的目的是支持我们在第五章“何时使用面向对象编程”中讨论的“不要重复自己”原则。它适用于我们有许多不同的任务需要完成,这些任务有一些但不是所有步骤是共同的情况。共同的步骤在基类中实现,而不同的步骤在子类中被覆盖以提供自定义行为。在某种程度上,它类似于策略模式,除了算法的相似部分是通过基类共享的。以下是它的 UML 格式表示:

图片

图 12.11:模板模式

模板示例

让我们以创建一个汽车销售报告员为例。我们可以在 SQLite 数据库表中存储销售记录。SQLite 是一个内置的数据库引擎,它允许我们使用 SQL 语法来存储记录。Python 将 SQLite 包含在其标准库中,因此无需安装额外的模块。

我们有两个常见的任务需要执行:

  • 选择所有新车销售记录并以逗号分隔的格式输出到屏幕上

  • 输出所有销售人员的销售额列表,以逗号分隔,并将其保存到可以导入电子表格的文件中

这些任务看起来相当不同,但它们有一些共同特征。在两种情况下,我们需要执行以下步骤:

  1. 连接到数据库

  2. 构建针对新车或总销售额的查询

  3. 发出查询

  4. 将结果格式化为逗号分隔的字符串

  5. 将数据输出到文件或电子邮件

两个任务的查询构建和输出步骤不同,但剩余步骤是相同的。我们可以使用模板模式将公共步骤放在一个基类中,而将不同的步骤放在两个子类中。

在我们开始之前,让我们使用几行 SQL 语句创建一个数据库并将一些样本数据放入其中:

import sqlite3
def test_setup(db_name: str = "sales.db") -> sqlite3.Connection:
    conn = sqlite3.connect(db_name)
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS Sales (
            salesperson text,
            amt currency,
            year integer,
            model text,
            new boolean
        )
        """
    )
    conn.execute(
        """
        DELETE FROM Sales
        """
    )
    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('Hannah', 8000, 2004, 'Dodge Neon', 'false')
        """
    )
    conn.execute(
        """
        INSERT INTO Sales 
        VALUES('Hannah', 28000, 2009, 'Ford Mustang', 'true')
        """
    )
    conn.execute(
        """
        INSERT INTO Sales 
        VALUES('Hannah', 50000, 2010, 'Lincoln Navigator', 'true')
        """
    )
    conn.execute(
        """
        INSERT INTO Sales 
        VALUES('Jason', 20000, 2008, 'Toyota Prius', 'false')
        """
    )
    conn.commit()
    return conn 

希望即使你不了解 SQL,你也能看懂这里的情况;我们创建了一个名为 Sales 的表来存储数据,并使用了六个 insert 语句来添加销售记录。数据存储在一个名为 sales.db 的文件中。现在我们有一个包含可以用于开发我们的模板模式的表的示例数据库。

既然我们已经概述了模板必须执行的步骤,我们可以从定义包含这些步骤的基本类开始。每个步骤都拥有自己的方法(以便可以单独覆盖任何一步),我们还有一个管理方法,它会依次调用这些步骤。在没有方法内容的情况下,这个类在完成的第一步可能看起来是这样的:

class QueryTemplate:
    def __init__(self, db_name: str = "sales.db") -> None:
    def connect(self) -> None:
        pass
    def construct_query(self) -> None:
        pass
    def do_query(self) -> None:
        pass
    def output_context(self) -> ContextManager[TextIO]:
        pass
    def output_results(self) -> None:
        pass
    def process_format(self) -> None:
        self.connect()
        self.construct_query()
        self.do_query()
        self.format_results()
        self.output_results() 

process_format() 方法是外部客户端需要调用的主要方法。它确保每个步骤按顺序执行,但它并不关心该步骤是在此类中实现还是在子类中实现。对于我们的示例,我们预计 construct_query()output_context() 方法可能会发生变化。

在 Python 中,我们可以通过使用抽象基类来形式化我们的期望。另一种选择是在模板中为缺失的方法抛出NotImplementedError异常。如果我们从QueryTemplate派生子类,并且——也许——拼写错误地尝试覆盖construct_query()方法,这将提供一个运行时检查。

剩余的方法将在我们两个班级之间保持一致:

class QueryTemplate:
    def __init__(self, db_name: str = "sales.db") -> None:
        self.db_name = db_name
        self.conn: sqlite3.Connection
        self.results: list[tuple[str, ...]]
        self.query: str
        self.header: list[str]
    def connect(self) -> None:
        self.conn = sqlite3.connect(self.db_name)
    def construct_query(self) -> None:
        raise NotImplementedError("construct_query not implemented")
    def do_query(self) -> None:
        results = self.conn.execute(self.query)
        self.results = results.fetchall()
    def output_context(self) -> ContextManager[TextIO]:
        self.target_file = sys.stdout
        return cast(ContextManager[TextIO], contextlib.nullcontext())
    def output_results(self) -> None:
        writer = csv.writer(self.target_file)
        writer.writerow(self.header)
        writer.writerows(self.results)
    def process_format(self) -> None:
        self.connect()
        self.construct_query()
        self.do_query()
        with self.output_context():
            self.output_results() 

这是一种抽象类。它不使用正式的抽象基类;相反,我们期望更新的两种方法展示了提供抽象定义的两种不同的方法:

  • construct_query() 方法必须被重写。基类中的方法定义会引发 NotImplementedError 异常。这在 Python 中是创建抽象接口的一种替代方法。引发 NotImplementedError 有助于程序员理解该类旨在被继承并重写这些方法。这可以描述为在 class 定义中“隐性地引入抽象基类”,而不使用 @abc.abstractmethod 装饰器。

  • output_context() 方法可以被重写。提供了一个默认实现,该实现设置了 self.target_file 实例变量,并返回一个上下文值。默认情况下使用 sys.stdout 作为输出文件,并使用一个空上下文管理器。

现在我们有一个模板类,它负责处理那些繁琐的细节,同时足够灵活,允许执行和格式化各种查询。最好的部分是,如果我们将来想要将我们的数据库引擎从 SQLite 更改为其他数据库引擎(例如py-postgresql),我们只需在这里,在这个模板类中操作,而无需触及我们可能编写的两个(或两百个)子类。

让我们来看看具体的类:

import datetime
class NewVehiclesQuery(QueryTemplate):
    def construct_query(self) -> None:
        self.query = "select * from Sales where new='true'"
        self.header = ["salesperson", "amt", "year", "model", "new"]
class SalesGrossQuery(QueryTemplate):
    def construct_query(self) -> None:
        self.query = (
            "select salesperson, sum(amt) "
            " from Sales group by salesperson"
        )
        self.header = ["salesperson", "total sales"]
    def output_context(self) -> ContextManager[TextIO]:
        today = datetime.date.today()
        filepath = Path(f"gross_sales_{today:%Y%m%d}.csv")
        self.target_file = filepath.open("w")
        return self.target_file 

这两个类实际上相当简短,考虑到它们所做的事情:连接数据库、执行查询、格式化结果以及输出它们。超类负责处理重复性工作,但允许我们轻松指定不同任务之间有所不同的步骤。此外,我们还可以轻松更改基类中提供的一些步骤。例如,如果我们想输出除了逗号分隔的字符串之外的内容(例如,要上传到网站的 HTML 报告),我们仍然可以覆盖output_results()方法。

案例研究

案例研究的前几章包含了许多设计模式。我们将选择模型的一个变体,并介绍本章中的一些模式及其应用方式。

这里是应用类几个部分的概述。这来自第七章的案例研究,《Python 数据结构》

图片

图 12.12:案例研究逻辑视图

这涉及到本章中我们看到的许多模式。我们将从Hyperparameter类开始,它是一个包含两个独立复杂组件的 Façade,即分类算法和训练数据。

首先,我们将探讨分类器算法。在第十章迭代器模式中,我们了解到分类器本身就是一个复杂的结构。我们考察了三种替代方案:k_nn_1(),它采用了简单的排序,k_nn_b(),它使用了二分查找,以及k_nn_q(),它使用了堆队列。这次探索依赖于本章中提到的几个设计模式:

  • 分类器依赖于策略设计模式来整合众多距离计算方法之一。我们定义了一个名为Distance的类,并确保每个距离计算都是一个子类。分类器算法被赋予了距离计算作为参数。

  • 分类器是一个门面,它提供了一个统一的接口用于测试和评估样本。分类器使用的每种变体都采用了稍微不同的数据结构来管理最近邻集合。我们不想对大量训练集进行排序;我们只想跟踪最近邻的子集。

在前几章中,我们确保训练数据利用了享元设计模式来避免保留多个训练数据副本。将每个Sample对象包裹在一个单独的冻结数据类中,以包含有关样本的已知信息,这也是一种享元设计。更根本的是,它是一个组合模式的例子。可用的样本是一个组合对象,避免了在内存中保留多个底层的KnownSample对象副本。

查看TrainingData类,我们可以看到这种设计也遵循了外观设计模式。许多不同的操作具有统一的接口。这里有两个重要的部分:

  • 将原始Sample实例加载以将它们划分为训练集和测试集。第九章中描述的各种数据格式,如字符串、序列化和文件路径,可以被视为通过统一的外观(Façade)简化的复杂算法。将初始样本集划分为训练集和测试集的算法选择,同样也是策略设计模式的应用。这使得我们可以通过从策略类层次结构的不同实现中更改用于训练和测试的样本比例。

  • 将用于超参数调整的测试集和训练集保持分离,是通过将原始数据划分为两个互斥的列表来实现的。

创建TrainingKnownSampleTestingKnownSample实例的想法是抽象工厂模式的一个例子。分区算法可以通过一个抽象工厂类的定义来描述。每个分区算法都成为一个具体的工厂,它创建不同的训练和测试对象的混合体。

第十一章常见设计模式中,我们详细探讨了超参数调整过程。k最近邻算法依赖于两个参数,称为超参数:

  • 用于计算样本之间距离的算法。

  • 使用的样本数量,k。最常见的是将k个最近邻中的最接近的一个作为分配给未知样本的标签。如果k的值是奇数,我们可以避免两个选择之间的平分,确保总有一个赢家。

第十一章中,展示的调谐算法并不特别快速,但非常耐心且全面:网格搜索算法。在第十一章中,我们使用了命令设计模式来列举各种k和距离计算的组合。每个组合都是一个命令,当执行时,提供质量和时间信息。

在整个应用过程中涉及了三个主要工作阶段。这些阶段在第一章面向对象设计中作为各种用例进行了阐述:

  1. 植物学家提供训练数据

  2. 植物学家使用超参数调优来定位一个最优模型

  3. 用户利用此方法对他们的未知样本进行分类

这种工作模式表明,可能需要模板设计模式来确保像TrainingData类这样的类以及整个应用程序能够一致地工作。目前,似乎不需要精心设计的类层次结构。然而,当我们回顾第一章时,最初的意图是利用这个例子来了解更多关于分类器的知识,并最终将这个例子从简单的鸢尾花物种分类扩展到更复杂的现实世界问题。这遵循了所谓的“望远镜规则”:

汤姆森给初学望远镜制作者的规则:“制作一个四英寸的镜子比制作一个六英寸的镜子要快。”

-- 编程珠玑,ACM 通讯,1985 年 9 月

题目背后的意图是构建一个可工作的系统,使用各种设计模式。然后,各种组件可以被替换、修订和扩展,以应对更大和更复杂的问题。望远镜制造商在制作他们的第一个镜片时,将从望远镜的制作中学到很多知识,而这些经验可以应用于制作下一个更有用的望远镜。类似的 学习模式也适用于软件和面向对象的设计。如果各个组件设计良好并遵循既定模式,那么对改进和扩展所做的更改就不会造成损害或破坏。

回忆

通常,我们会发现一些真正优秀的想法被反复提及;这种重复可以形成一种可识别的模式。利用基于模式的软件设计方法可以帮助开发者避免浪费时间尝试重新发明已经非常清楚的东西。在本章中,我们探讨了几个更高级的设计模式:

  • 适配器类是一种插入中介的方式,使得客户端即使现有类不是完美匹配,也能使用该类。软件适配器与各种具有不同 USB 接口连接器的设备之间的 USB 硬件适配器的理念相类似。

  • 外观模式是一种在多个对象上创建统一接口的方法。这种想法与建筑外观相似,它将独立的楼层、房间和走廊统一成一个单一的空间。

  • 我们可以利用享元模式来实现一种懒加载初始化。而不是复制对象,我们可以设计享元类来共享一个公共的数据池,从而最小化或避免完全初始化。

  • 当我们拥有紧密相关的对象类时,可以使用抽象工厂模式来构建一个能够发出可以协同工作的实例的类。

  • 组合模式在复杂文档类型中被广泛使用。它涵盖了编程语言、自然语言和标记语言,包括 XML 和 HTML。甚至像具有目录和文件层次结构的文件系统也符合这种设计模式。

  • 当我们拥有许多相似且复杂的类时,似乎创建一个遵循模板模式的类是合适的。我们可以在模板中留下空隙或开口,以便我们可以注入任何独特的特性。

这些模式可以帮助设计师专注于被接受的良好设计实践。当然,每个问题都是独特的,因此模式必须进行适应。通常,对已知模式进行改编比尝试发明完全新的东西要好。

练习

在深入到每个设计模式的练习之前,花一点时间将 ospathlib 调用添加到 组合模式 部分中实现 FileFolder 对象的方法。File 上的 copy() 方法需要读取和写入文件的字节。Folder 上的 copy() 方法要复杂得多,因为你首先需要复制文件夹,然后将每个子项递归地复制到新位置。我们提供的示例更新了内部数据结构,但不会应用到操作系统。在隔离目录中测试时要小心,你不想不小心破坏重要文件。

现在,就像上一章一样,看看我们讨论过的模式,并考虑你可能实施它们的地方。你可能想要将适配器模式应用于现有代码,因为它通常适用于与现有库接口,而不是新代码。你如何使用适配器来强制两个接口正确地相互交互?

你能否想到一个足够复杂的系统来证明使用外观模式(Façade pattern)的合理性?考虑一下外观在实际生活中的应用,例如汽车的驾驶员界面,或者工厂的控制面板。在软件中,情况类似,只不过外观接口的使用者是其他程序员,而不是受过培训来使用它的人。在你的最新项目中,是否有复杂系统可以从外观模式中受益?

可能你没有任何庞大的、消耗内存的代码能够从享元模式中受益,但你能否想到一些可能有用的情况?任何需要处理大量重叠数据的地方,享元模式都在等待被使用。在银行业务中会有用吗?在 Web 应用中呢?在什么情况下采用享元模式是有意义的?什么时候又会过度使用呢?

抽象工厂模式,或者我们讨论过的稍微更 Pythonic 的衍生模式,在创建一键可配置的系统时可以非常实用。你能想到哪些地方这样的系统是有用的吗?

组合模式适用于许多场景。在编程中,我们周围到处都是树状结构。其中一些,比如我们的文件层次结构示例,非常明显;而另一些则相当微妙。在什么情况下组合模式可能会很有用?你能想到在你的代码中可以应用它的地方吗?如果你稍微调整一下模式;例如,为不同类型的对象包含不同类型的叶节点或组合节点,会怎样呢?

ast 模块为 Python 代码提供了复合树结构。特别有用的一点是,可以使用 ast 模块来定位某些代码中的所有导入语句。这有助于确认一个项目的所需模块列表,通常在 requirements.txt 文件中,是否完整且一致。

模板方法在分解复杂操作时非常有用,因为它允许进行扩展。看起来k最近邻算法可能是一个很好的模板方法候选者。在第十章迭代器模式中,我们将k最近邻算法重写为三个完全独立的函数。这是必要的吗?我们能否将其重写为一个将问题分解为三个步骤的方法:计算距离、找到k最近邻以及然后找到众数?将这种设计与作为独立函数实现的方法进行比较;你发现哪种方法更具有表现力?

摘要

在本章中,我们详细介绍了几个更多的设计模式,包括它们的规范描述以及如何在 Python 中实现它们的替代方案,Python 通常比传统的面向对象语言更加灵活和多功能。适配器模式适用于匹配接口,而外观模式则适合简化它们。享元模式是一个复杂的设计模式,只有在需要内存优化时才有用。抽象工厂允许根据配置或系统信息在运行时分离实现。组合模式被普遍用于树形结构。模板方法可以帮助将复杂操作分解成步骤,以避免重复常见功能。

这是本书中真正面向对象设计章节的最后一章。在接下来的两章中,我们将讨论测试 Python 程序的重要性以及如何进行测试,重点关注面向对象原则。然后我们将探讨 Python 的并发特性以及如何利用这些特性更快地完成任务。

第十三章:测试面向对象程序

熟练的 Python 程序员都认为测试是软件开发最重要的方面之一。尽管这一章节被放置在书的末尾附近,但这并非是事后想起的;我们迄今为止所学的所有内容都将有助于我们编写测试。在本章中,我们将探讨以下主题:

  • 单元测试和测试驱动开发的重要性

  • 标准库 unittest 模块

  • pytest 工具

  • mock 模块

  • 代码覆盖率

在本章的案例研究中,我们将聚焦——不出所料——为案例研究示例编写一些测试。

我们将从一些基本原因开始,解释为什么自动化软件测试如此重要。

为什么进行测试?

许多程序员已经知道测试代码的重要性。如果你是其中之一,请随意浏览这一节。你会发现下一节——我们将实际看到如何在 Python 中创建测试——要有趣得多。

如果你还没有确信测试的重要性,我们提醒你,如果没有任何测试,代码将会出错,而且没有人有任何方法知道这一点。继续阅读!

有些人认为在 Python 代码中测试更为重要,因为其动态特性;人们有时会认为像 Java 和 C++这样的编译语言在某些方面更安全,因为它们在编译时强制进行类型检查。然而,Python 测试很少检查类型。它们检查值。它们确保在正确的时间设置了正确的属性,或者序列具有正确的长度、顺序和值。这些高级概念在任何语言中都需要进行测试。Python 程序员比其他语言的程序员测试得更多,真正的原因是 Python 进行测试非常容易!

但为什么要测试?我们真的需要测试吗?如果我们不测试会怎样?为了回答这些问题,回想一下你上次编写代码的时候。它第一次运行正确了吗?没有语法错误?没有逻辑问题?原则上来说,偶尔输入一次完美的代码是可能的。但从实际的角度来看,需要纠正的明显语法错误数量可能是一个指标,表明可能还有更多需要纠正的微妙逻辑错误。

我们不需要一个正式的、独立的测试来确保我们的代码能够正常工作。像我们通常做的那样运行程序,并修复错误,这是一种粗略的测试形式。Python 的交互式解释器和几乎为零的编译时间使得编写几行代码并运行程序来确保这些代码按预期工作变得非常容易。虽然这在项目开始时是可以接受的,但随着时间的推移,这会变成一个不断增长的负担。试图更改几行代码可能会影响到我们没有意识到会受到这些更改影响的程序部分,而没有测试,我们就不知道我们破坏了什么。试图进行重新设计或甚至小的优化重写可能会遇到问题。此外,随着程序的增长,解释器可以通过该代码的路径数量也会增长,很快就会变得不可能或手动测试变得非常粗糙,以至于无法测试所有这些路径。

为了确保我们自己和他人我们的软件能够正常工作,我们编写了自动化测试。这些是自动将某些输入通过其他程序或程序的部分运行的程序。我们可以在几秒钟内运行这些测试程序,覆盖比一个程序员每次更改时想要测试的更多潜在输入情况。

无法通过自动化测试演示的软件特性实际上是不存在的。

  • 极限编程详解,肯特·贝克

编写测试的四个主要原因:

  • 确保代码按照开发者的预期工作

  • 确保在做出更改后代码仍然可以继续工作

  • 确保开发者理解了需求

  • 确保我们编写的代码具有可维护的接口

当我们有自动化测试时,我们可以在每次更改代码时运行它们,无论是初始开发阶段还是维护版本发布。测试可以确认我们在添加或扩展功能时没有无意中破坏任何东西。

前述最后两点具有有趣的后果。当我们编写测试时,它有助于我们设计代码所采用的 API、接口或模式。因此,如果我们对需求理解有误,编写测试可以帮助突出显示这种误解。从另一方面来看,如果我们不确定我们想要如何设计一个类,我们可以编写一个与该类交互的测试,这样我们就有了一个关于最自然地确认接口工作的想法。实际上,在我们编写要测试的代码之前编写测试通常是很有益的。

专注于软件测试还有一些其他有趣的后果。我们将探讨这三个后果:

  • 使用测试驱动开发

  • 管理测试的不同目标

  • 为测试场景制定一个一致的模板

让我们从使用测试来驱动开发工作开始。

测试驱动开发

先写测试是测试驱动开发的箴言。测试驱动开发将未经测试的代码是错误的代码这一概念进一步深化,并建议只有未编写的代码才应该是未经测试的。我们不会编写任何代码,直到我们写出了能够证明其工作的测试。第一次运行测试时,它应该失败,因为代码还没有编写。然后,我们编写确保测试通过的代码,接着为下一段代码编写另一个测试。

测试驱动开发可以很有趣;它允许我们构建小谜题来解决。然后,我们编写代码来解决这些谜题。之后,我们制作一个更复杂的谜题,并编写代码来解决新谜题,同时不解决之前的谜题。

测试驱动方法有两个目标。第一个是确保真正编写了测试。

其次,先编写测试迫使我们必须仔细考虑代码将如何被使用。它告诉我们对象需要有哪些方法以及属性将如何被访问。它帮助我们将初始问题分解成更小、可测试的问题,然后将经过测试的解决方案重新组合成更大、同样经过测试的解决方案。因此,编写测试可以成为设计过程的一部分。通常,当我们为新的对象编写测试时,我们会发现设计中的异常,这迫使我们考虑软件的新方面。

测试让软件变得更好。在我们发布软件之前编写测试,可以让它在最终代码编写之前就变得更好。

书中所有检查的代码都已通过自动化测试套件运行。这是确保示例是坚如磐石、可正常工作的唯一方法。

测试目标

我们在运行测试时有许多不同的目标。这些通常被称为测试类型,但“类型”这个词在软件行业中过度使用。在本章中,我们将探讨这些测试目标中的两个:

  • 单元测试确认软件组件在独立状态下能够正常工作。我们将首先关注这一点,因为福勒的测试金字塔似乎表明单元测试能够创造最大的价值。如果各个类和函数都遵循它们的接口并产生预期的结果,那么将它们集成起来也将运行良好,并且惊喜相对较少。通常使用覆盖率工具来确保所有代码行都是作为单元测试套件的一部分被测试的。

  • 集成测试 - 令人意外的是 - 确认软件组件在集成后能够正常工作。集成测试有时被称为系统测试、功能测试和验收测试等。当集成测试失败时,通常意味着接口定义不正确,或者单元测试没有包含一些通过与其他组件集成而暴露的边缘情况。集成测试似乎依赖于良好的单元测试,因此在重要性上似乎处于次要地位。

我们注意到,“单位”在 Python 语言中并没有正式定义。这是一个有意的选择。一个代码单位通常是一个单独的函数或一个单独的类。它也可以是一个单独的模块。这个定义给我们提供了一定的灵活性,以便识别独立的、单个的代码单位。

尽管测试有许多不同的目标,但所使用的技巧往往相似。有关更多资料,请参阅www.softwaretestinghelp.com/types-of-software-testing/,其中列出了 40 多种不同的测试目标;这可能会让人感到压倒性,这就是为什么我们将只关注单元测试和集成测试。所有测试都有一种共同的模式,我们将在下一节中探讨测试的一般模式。

测试模式

编写代码通常具有挑战性。我们需要弄清楚对象的内部状态是什么,它经历了哪些状态变化,以及确定它与其他哪些对象协作。在整个书中,我们提供了一系列设计类的常见模式。

测试,从某种意义上说,比类定义简单,并且所有测试都具有基本相同的模式:

GIVEN some precondition(s) for a scenario
WHEN we exercise some method of a class
THEN some state change(s) or side effect(s) will occur that we can confirm 

在某些情况下,先决条件可能很复杂,或者状态变化或副作用可能很复杂。它们可能复杂到需要分解成多个步骤。这个三部分模式的重要之处在于它如何将设置、执行和预期结果相互解开。这个模型适用于各种测试。如果我们想确保水足够热,可以再泡一杯茶,我们将遵循一系列类似的步骤:

  • 给定一个放在炉子上的水壶

  • AND 燃烧器已关闭

  • WHEN我们打开壶盖

  • THEN 我们看到蒸汽正在逸出

这种模式对于确保我们有清晰的设置和可观察的结果非常有用。

假设我们需要编写一个函数来计算一个数字列表的平均值,同时排除序列中可能存在的None值。我们可能会这样开始:

def average(data: list[Optional[int]]) -> float:
    """
    GIVEN a list, data = [1, 2, None, 3, 4]
    WHEN we compute m = average(data)
    THEN the result, m, is 2.5
    """
    pass 

我们已经草拟了函数的定义,并总结了我们认为它应该如何表现。GIVEN 步骤定义了测试用例的一些数据。WHEN 步骤精确地定义了我们将要执行的操作。最后,THEN 步骤描述了预期的结果。自动化测试工具可以将实际结果与声明的期望进行比较,并在测试失败时报告。然后,我们可以使用我们偏好的测试框架将此精炼为一个单独的测试类或函数。unittest 和 pytest 实现这一概念的方式略有不同,但核心概念在两个框架中都保持一致。一旦完成,测试应该失败,然后我们可以开始实现真正的代码,因为这个测试作为一个清晰的终点线,是我们想要跨越的。

一些有助于设计测试用例的技术包括等价类划分边界值分析。这些技术帮助我们将一个方法或函数所有可能的输入域分解成多个部分。一个常见的例子是定位两个部分,“有效数据”和“无效数据”。给定这些部分,部分边界上的值成为在测试用例中使用的有兴趣的值。更多信息请参阅www.softwaretestinghelp.com/what-is-boundary-value-analysis-and-equivalence-partitioning/

我们将首先了解内置的测试框架unittest。它有一个缺点,就是看起来有点冗长且复杂。但它也有一个优点,那就是它是内置的,可以立即使用;不需要进一步安装。

使用 unittest 进行单元测试

让我们从 Python 内置的测试库开始我们的探索。这个库提供了一个用于 单元测试 的通用面向对象接口。这个 Python 库被称为,不出所料,unittest。它提供了一些用于创建和运行单元测试的工具,其中最重要的是 TestCase 类。(命名遵循 Java 命名风格,因此许多方法名看起来不太像 Python 风格。)TestCase 类提供了一组方法,允许我们比较值、设置测试,并在它们完成后进行清理。

当我们想要为特定任务编写一组单元测试时,我们创建一个TestCase的子类,并编写单独的方法来进行实际测试。这些方法都必须以test开头命名。遵循此约定时,测试会自动作为测试过程的一部分运行。对于简单的例子,我们可以将GIVENWHENTHEN概念打包到测试方法中。以下是一个非常简单的例子:

import unittest
class CheckNumbers(unittest.TestCase):
    def test_int_float(self) -> None:
        self.assertEqual(1, 1.0)
if __name__ == "__main__":
    unittest.main() 

此代码继承自TestCase类并添加了一个调用TestCase.assertEqual()方法的方法。GIVEN步骤是一对值,1 和 1.0。WHEN步骤是一种退化示例,因为没有创建新对象且没有状态变化发生。THEN步骤是对两个值将测试为相等的断言。

当我们运行测试用例时,这个方法要么会静默成功,要么会抛出一个异常,这取决于两个参数是否相等。如果我们运行这段代码,unittest模块中的main函数会给出以下输出:

.
--------------------------------------------------------------
Ran 1 test in 0.000s
OK 

你知道浮点数和整数可以被视为相等进行比较吗?

让我们添加一个失败的测试,如下所示:

 def test_str_float(self) -> None: 
        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表示第二次测试失败。然后,在最后,它给我们提供了一些信息性的总结,告诉我们测试失败的原因和位置,以及失败次数的统计。

即使是操作系统级别的返回码也提供了一个有用的总结。如果所有测试都通过,返回码为零;如果有任何测试失败,返回码则不为零。这有助于构建持续集成工具:如果unittest运行失败,则不应允许提出的更改。

我们可以在一个TestCase类中拥有尽可能多的测试方法。只要方法名以test开头,测试运行器就会将每个方法作为一个独立的、隔离的测试来执行。

每个测试都应该完全独立于其他测试。

测试的结果或计算不应影响任何其他测试。

为了使测试彼此隔离,我们可能需要几个具有共同 GIVEN 的测试,这些测试通过一个共同的 setUp() 方法实现。这表明我们通常会拥有相似的课程,我们需要使用继承来设计测试,以便它们可以共享功能同时仍然保持完全独立。

编写良好单元测试的关键是使每个测试方法尽可能简短,每个测试案例测试一小块代码。如果我们的代码看起来不能自然地分解成小块、可测试的单位,这可能是一个迹象,表明代码需要重新设计。本章后面的“使用模拟对象进行模仿”部分提供了一种用于测试目的隔离对象的方法。

unittest 模块要求将测试结构化为类定义。这在某种程度上——有点儿——增加了开销。pytest 包在测试发现方面稍微聪明一些,并且以函数而不是类的方法构建测试的方式更加灵活。我们将在下一节中探讨 pytest

使用 pytest 进行单元测试

我们可以使用一个提供测试场景通用框架的库来创建单元测试,同时还包括一个测试运行器来执行测试并记录结果。单元测试专注于在任何一个测试中尽可能测试最少的代码。标准库中包含了unittest包。虽然这个包被广泛使用,但它往往迫使我们为每个测试案例编写相当多的样板代码。

标准库中的 unittest 之一更受欢迎的替代方案是 pytest。它具有让我们编写更小、更清晰的测试用例的优势。没有额外开销使得这成为一个理想的替代方案。

由于 pytest 不是标准库的一部分,您需要自行下载并安装它。您可以从 docs.pytest.org/en/stable/pytest 主页获取它。您可以使用任何安装程序进行安装。

在终端窗口中,激活你正在工作的虚拟环境。(例如,如果你使用的是 venv,你可能需要使用python -m venv c:\path\to\myenv。)然后,使用以下类似的操作系统命令:

% python -m  pip install pytest 

Windows 命令应与 macOS 和 Linux 上的命令相同。

pytest 工具可以使用与 unittest 模块显著不同的测试布局。它不需要测试用例是 unittest.TestCase 的子类。相反,它利用了 Python 函数是一等对象的事实,并允许任何正确命名的函数表现得像测试。而不是提供大量用于断言相等的自定义方法,它使用 assert 语句来验证结果。这使得测试更加简单、易读,从而更容易维护。

当我们运行pytest时,它将在当前文件夹中启动并搜索以字符test_开头的任何模块或子包。(包括_字符。)如果此模块中的任何函数也以test开头(不需要_),它们将被作为单独的测试执行。此外,如果模块中存在以Test开头的类,那么该类上以test_开头的任何方法也将被在测试环境中执行。

它还会在名为 – 令人惊讶的是 – tests 的文件夹中进行搜索。正因为如此,常见的做法是将代码拆分到两个文件夹中:src/ 目录包含工作模块、库或应用程序,而 tests/ 目录包含所有测试用例。

使用以下代码,让我们将之前编写的简单 unittest 示例移植到 pytest

def test_int_float() -> None: 
    assert 1 == 1.0 

对于相同的测试,我们编写了两行更易读的代码,相比之下,在我们的第一个unittest示例中需要六行代码。

然而,我们并没有被禁止编写基于类的测试。类可以用于将相关的测试分组在一起,或者用于需要访问类上相关属性或方法的测试。以下示例展示了一个包含通过和失败测试的扩展类;我们将看到错误输出比unittest模块提供的更为全面:

class TestNumbers:
    def test_int_float(self) -> None:
        assert 1 == 1.0
    def test_int_str(self) -> None:
        assert 1 == "1" 

注意,类不需要扩展任何特殊对象就能被识别为测试用例(尽管pytest可以很好地运行标准的unittest TestCases)。如果我们运行python -m pytest tests/<filename>,输出将如下所示:

% python -m pytest tests/test_with_pytest.py
======================== test session starts ========================
platform darwin -- Python 3.9.0, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /path/to/ch_13
collected 2 items                                                   
tests/test_with_pytest.py .F                                  [100%]
============================= FAILURES ==============================
_____________________ TestNumbers.test_int_str ______________________
self = <test_with_pytest.TestNumbers object at 0x7fb557f1a370>
    def test_int_str(self) -> None:
>       assert 1 == "1"
E       AssertionError: assert 1 == "1"
tests/test_with_pytest.py:15: AssertionError
====================== short test summary info ======================
FAILED tests/test_with_pytest.py::TestNumbers::test_int_str - Asse...
==================== 1 failed, 1 passed in 0.07s ==================== 

输出开始于一些关于平台和解释器的有用信息。这可以用于在不同系统间共享或讨论错误。第三行告诉我们正在测试的文件名称(如果有多个测试模块被选中,它们都将被显示),接着是我们在unittest模块中看到的熟悉的.F.字符表示通过测试,而字母F表示失败。

所有测试运行完毕后,每个测试的错误输出都会显示出来。它展示了局部变量的摘要(在这个例子中只有一个:传递给函数的self参数),错误发生的位置的源代码,以及错误信息的摘要。此外,如果抛出的异常不是AssertionErrorpytest将为我们提供一个完整的回溯信息,包括源代码引用。

默认情况下,pytest 在测试成功时抑制 print() 输出。这对于测试调试很有用;当测试失败时,我们可以在测试中添加 print() 语句来检查特定变量和属性在测试运行过程中的值。如果测试失败,这些值会被输出以帮助诊断。然而,一旦测试成功,print() 输出就不会显示,并且很容易被忽略。我们不需要通过移除 print() 来清理测试输出。如果由于未来的更改,测试再次失败,调试输出将立即可用。

有趣的是,这种使用 assert 语句的方式向 mypy 暴露了一个潜在问题。当我们使用 assert 语句时,mypy 可以检查类型,并将提醒我们 assert 1 == "1" 可能存在的问题。这段代码很可能是不正确的,它不仅会在单元测试中失败,而且还会在 mypy 检查中失败。

我们已经探讨了pytest如何通过函数和assert语句支持测试的WHENTHEN步骤。现在,我们需要更仔细地看看如何处理GIVEN步骤。为测试建立GIVEN前提条件有两种方法;我们将从适用于简单情况的一种开始。

pytest 的设置和清理函数

pytest 支持设置和清理功能,类似于 unittest 中使用的方法,但它提供了更大的灵活性。我们将简要讨论这些通用功能;pytest 为我们提供了一种强大的固定功能,我们将在下一节中进行讨论。

如果我们正在编写基于类的测试,我们可以使用两种方法,称为 setup_method()teardown_method()。它们分别在类中的每个测试方法之前和之后被调用,以执行设置和清理任务。

此外,pytest 提供了其他设置和清理函数,以便我们能够更好地控制准备和清理代码的执行时机。setup_class()teardown_class() 方法预期是类方法;它们接受一个表示相关类的单个参数(因为没有 self 参数,因为没有实例;相反,提供了类)。这些方法是在类初始化时由 pytest 运行的,而不是在每次测试运行时。

最后,我们有setup_module()teardown_module()函数,这些函数会在pytest运行该模块中所有测试(在函数或类中)之前和之后立即执行。这些函数对于进行一次性设置很有用,例如创建一个将被模块中所有测试使用的套接字或数据库连接。在使用时请小心,因为如果某些对象状态在测试之间没有被正确清理,可能会意外地引入测试之间的依赖关系。

那个简短的描述并没有很好地解释这些方法究竟在何时被调用,所以让我们来看一个例子,以具体说明它发生的情况:

from __future__ import annotations
from typing import Any, Callable
def setup_module(module: Any) -> None:
    print(f"setting up MODULE {module.__name__}")
def teardown_module(module: Any) -> None:
    print(f"tearing down MODULE {module.__name__}")
def test_a_function() -> None:
    print("RUNNING TEST FUNCTION")
class BaseTest:
    @classmethod
    def setup_class(cls: type["BaseTest"]) -> None:
        print(f"setting up CLASS {cls.__name__}")
    @classmethod
    def teardown_class(cls: type["BaseTest"]) -> None:
        print(f"tearing down CLASS {cls.__name__}\n")
    def setup_method(self, method: Callable[[], None]) -> None:
        print(f"setting up METHOD {method.__name__}")
    def teardown_method(self, method: Callable[[], None]) -> None:
        print(f"tearing down METHOD {method.__name__}")
class TestClass1(BaseTest):
    def test_method_1(self) -> None:
        print("RUNNING METHOD 1-1")
    def test_method_2(self) -> None:
        print("RUNNING METHOD 1-2")
class TestClass2(BaseTest):
    def test_method_1(self) -> None:
        print("RUNNING METHOD 2-1")
    def test_method_2(self) -> None:
        print("RUNNING METHOD 2-2") 

BaseTest 类的唯一目的是提取四个方法,这些方法在其他方面与两个测试类完全相同,并使用继承来减少重复代码的数量。因此,从 pytest 的角度来看,这两个子类不仅各自有两个测试方法,还有两个设置方法和两个销毁方法(一个在类级别,一个在方法级别)。

如果我们使用带有print()函数输出抑制(通过传递-s--capture=no标志)的pytest运行这些测试,它们会显示各种函数在测试本身中的调用关系:

% python -m pytest --capture=no tests/test_setup_teardown.py
========================= test session starts ==========================
platform darwin -- Python 3.9.0, pytest-6.2.2, py-1.10.0, pluggy-0.13.1
rootdir: /…/ch_13
collected 5 items                                                      
tests/test_setup_teardown.py setting up MODULE test_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 test_setup_teardown
========================== 5 passed in 0.01s =========================== 

模块的整体设置和销毁方法在会话的开始和结束时执行。然后,单独的模块级测试函数被运行。接下来,执行第一个类的设置方法,然后是针对该类的两个测试。这些测试每个都分别被包裹在单独的 setup_method()teardown_method() 调用中。在测试执行完毕后,调用类上的销毁方法。对于第二个类,发生相同的序列,最后最终只调用一次 teardown_module() 方法。

虽然这些函数名提供了很多测试选项,但我们通常会面临多个测试场景中共享的设置条件。这些可以通过基于组合的设计进行复用;pytest将这些设计称为“固定装置”。我们将在下一节中探讨固定装置。

pytest 的设置和清理 fixtures

各种设置函数最常见的一个用途是确保测试的给定步骤已准备好。这通常涉及创建对象并确保在运行测试方法之前,某些类或模块变量具有已知的值。

除了为测试类提供一组特殊的方法名称外,pytest 还提供了一种完全不同的方法来实现这一点,即使用所谓的 ** fixtures**。Fixtures 是用于构建测试的 GIVEN 条件的函数,在测试的 WHEN 步骤之前执行。

pytest 工具提供了一系列内置的 fixtures,我们可以在配置文件中定义 fixtures 并重复使用它们,同时我们还可以将独特的 fixtures 作为测试的一部分进行定义。这使我们能够将配置与测试执行分离,使得 fixtures 可以在多个类和模块之间使用。

让我们看看一个执行了一些我们需要测试的计算的类:

from typing import List, Optional
class StatsList(List[Optional[float]]):
    """Stats with None objects rejected"""
    def mean(self) -> float:
        clean = list(filter(None, self))
        return sum(clean) / len(clean)
    def median(self) -> float:
        clean = list(filter(None, self))
        if len(clean) % 2:
            return clean[len(clean) // 2]
        else:
            idx = len(clean) // 2
            return (clean[idx] + clean[idx - 1]) / 2
    def mode(self) -> list[float]:
        freqs: DefaultDict[float, int] = collections.defaultdict(int)
        for item in filter(None, self):
            freqs[item] += 1
        mode_freq = max(freqs.values())
        modes = [item 
            for item, value in freqs.items() 
            if value == mode_freq]
        return modes 

这个类扩展了内置的 list 类,增加了三个统计摘要方法,mean()median()mode()。对于每个方法,我们需要有一组可以使用的数据;这种包含已知数据的 StatsList 配置是我们将要测试的基准配置。

要使用夹具创建GIVEN预条件,我们将夹具名称作为参数添加到我们的测试函数中。当测试运行时,测试函数参数的名称将在夹具集合中定位,那些创建夹具的函数将自动为我们执行。

例如,为了测试StatsList类,我们希望反复提供一个有效的整数列表。我们可以这样编写我们的测试:

import pytest
from stats import StatsList
@pytest.fixture
def valid_stats() -> StatsList:
    return StatsList([1, 2, 2, 3, 3, 4])
def test_mean(valid_stats: StatsList) -> None:
    assert valid_stats.mean() == 2.5
def test_median(valid_stats: StatsList) -> None:
    assert valid_stats.median() == 2.5
    valid_stats.append(4)
    assert valid_stats.median() == 3
def test_mode(valid_stats: StatsList) -> None:
    assert valid_stats.mode() == [2, 3]
    valid_stats.remove(2)
    assert valid_stats.mode() == [3] 

三个测试函数中的每一个都接受一个名为valid_stats的参数;这个参数是由pytest自动调用valid_stats函数为我们创建的。该函数被装饰为@pytest.fixture,因此可以通过pytest以这种方式使用。

并且是的,名称必须匹配。pytest 运行时会寻找与参数名称匹配的带有 @fixture 装饰器的函数。

固定装置的功能远不止返回简单的对象。可以将一个request对象传递给固定装置工厂,以提供修改固定装置行为的极其有用的方法和属性。request对象的moduleclsfunction属性使我们能够确切地看到哪个测试正在请求固定装置。request对象的config属性允许我们检查命令行参数以及大量的其他配置数据。

如果我们将夹具实现为一个生成器,它也可以在每次测试运行后执行清理代码。这相当于在每个夹具级别上提供了一个拆卸方法。我们可以用它来清理文件、关闭连接、清空列表或重置队列。对于单元测试,其中项目是隔离的,使用模拟对象比在具有状态的对象上执行拆卸更好。请参阅本章后面的使用模拟模仿对象部分,了解适用于单元测试的更简单的方法。

对于集成测试,我们可能需要测试一些创建、删除或更新文件的代码。我们通常会使用pytesttmp_path固定装置将这些代码写入可以稍后删除的目录中,这样我们就不需要在测试中进行拆卸操作了。虽然对于单元测试很少需要,但拆卸操作对于停止子进程或移除集成测试中的一部分数据库更改是有帮助的。我们将在本节稍后看到这一点。首先,让我们来看一个小型的具有设置和拆卸功能的固定装置的例子。

要开始了解既包含设置又包含拆卸的夹具概念,这里有一小段代码,它会对文件创建备份副本,并写入一个包含现有文件校验和的新文件:

import tarfile
from pathlib import Path
import hashlib
def checksum(source: Path, checksum_path: Path) -> None:
    if checksum_path.exists():
        backup = checksum_path.with_stem(f"(old) {checksum_path.stem}")
        backup.write_text(checksum_path.read_text())
    checksum = hashlib.sha256(source.read_bytes())
    checksum_path.write_text(f"{source.name} {checksum.hexdigest()}\n") 

有两种情况:

  • 源文件存在;已将新的校验和添加到目录中

  • 源文件和校验和文件都存在;在这种情况下,旧的校验和被复制到备份位置,并写入新的校验和

我们不会测试两种场景,但我们将展示一个夹具如何创建——然后删除——测试序列所需的文件。我们将重点关注第二种场景,因为它更复杂。我们将把测试分为两部分,从夹具开始:

from __future__ import annotations
import checksum_writer
import pytest
from pathlib import Path
from typing import Iterator
import sys
@pytest.fixture
def working_directory(tmp_path: Path) -> Iterator[tuple[Path, Path]]:
    working = tmp_path / "some_directory"
    working.mkdir()
    source = working / "data.txt"
    source.write_bytes(b"Hello, world!\n")
    checksum = working / "checksum.txt"
    checksum.write_text("data.txt Old_Checksum")
    **yield source, checksum**
    checksum.unlink()
    source.unlink() 

yield语句是使这工作起来的秘密。我们的工具实际上是生成器,它产生一个结果然后等待对值的下一个请求。第一个创建的结果遵循一系列步骤:创建一个工作目录,在工作目录中创建一个源文件,然后创建一个旧的校验和文件。yield语句为测试提供了两条路径并等待下一个请求。这项工作完成了测试的GIVEN条件设置。

当测试函数执行完毕时,pytest 将尝试从这个 fixture 中获取一个最后的项。这允许函数解除文件的链接,将其删除。没有返回值,这表示迭代的结束。除了利用生成器协议外,working_directory fixture 还依赖于 pytesttmp_path fixture 来为这个测试创建一个临时的工作位置。

这里是使用这个working_directory测试用例的测试:

@pytest.mark.skipif(
    sys.version_info < (3, 9), reason="requires python3.9 feature")
def test_checksum(working_directory: tuple[Path, Path]) -> None:
    source_path, old_checksum_path = working_directory
    checksum_writer.checksum(source_path, old_checksum_path)
    backup = old_checksum_path.with_stem(
        f"(old) {old_checksum_path.stem}")
    assert backup.exists()
    assert old_checksum_path.exists()
    name, checksum = old_checksum_path.read_text().rstrip().split()
    assert name == source_path.name
    assert (
        checksum == "d9014c4624844aa5bac314773d6b689a"
        "d467fa4e1d1a50a1b8a99d5a95f72ff5"
    ) 

测试被标记为skipif条件,因为在这个测试中 Python 3.8 将无法运行;Path对象的with_stem()方法不是旧版pathlib实现的一部分。这确保了测试会被计算,但会被标记为不适合特定 Python 版本。我们将在本章后面的使用 pytest 跳过测试部分回到这个问题。

working_directory 配置项的引用强制 pytest 执行配置项函数,在测试之前为测试场景提供两个路径作为 GIVEN 条件的一部分。WHEN 步骤使用这两个路径评估 checksum_writer.checksum() 函数。THEN 步骤是一系列 assert 语句,以确保文件以预期的值创建。测试运行后,pytest 将使用 next() 从配置项中获取另一个项目;这个动作执行 yield 之后的代码,导致测试之后进行清理。

当单独测试组件时,我们通常不需要使用夹具的拆卸功能。然而,对于集成测试,其中多个组件协同使用,可能需要停止进程或删除文件。在下一节中,我们将探讨一个更复杂的夹具。这种夹具可以用于多个测试场景。

更复杂的夹具

我们可以将一个scope参数传递给创建一个比单个测试更持久的夹具。这在设置一个可以被多个测试复用的昂贵操作时很有用,只要资源复用不会破坏测试的原子性或单元性质:一个单元测试不应该依赖于,也不应该受到任何其他单元测试的影响。

例如,我们将定义一个作为客户端-服务器应用程序一部分的服务器。我们希望多个网络服务器将它们的日志消息发送到单个集中式日志。除了独立的单元测试之外,我们还需要一个集成测试。这个测试确保网络服务器和日志收集器能够正确地相互集成。集成测试需要启动和停止这个日志收集服务器。

测试金字塔至少有三个层级。单元测试是基础,它独立地锻炼每个组件。集成测试位于金字塔的中间,确保组件之间能够正确集成。系统测试或验收测试位于金字塔的顶端,确保整个软件套件能够实现其宣称的功能。

我们将探讨一个日志收集服务器,它接受消息并将它们写入一个单一的、中央的文件。这些消息由logging模块的SocketHandler定义。我们可以将每个消息描绘为一个带有头部和有效负载的字节块。在下面的表格中,我们使用字节块的切片展示了其结构。

这就是消息的定义方式:

切片开始 切片结束 含义 解析的 Python 模块和函数
0 4 负载大小 struct.unpack(">L", bytes)
4 负载大小+4 负载 pickle.loads(bytes)

标头的尺寸以四个字节的切片形式显示,但此处显示的尺寸可能会造成误导。标头正式且官方的定义是由struct模块使用的格式字符串,即">L"struct模块有一个名为calcsize()的函数,用于从格式字符串计算实际长度。我们的代码不会直接使用从">L"格式派生出的字面量 4,而是会从尺寸格式字符串size_format中派生出尺寸,即size_bytes。使用一个合适的来源size_format来为这两部分信息提供数据,遵循了“不要重复自己”(Don't Repeat Yourself)的设计原则。

这里是一个包含logging模块消息的示例缓冲区。第一行是带有有效载荷大小的标题,一个四字节值。接下来的行是日志消息的序列化数据:

b'\x00\x00\x02d' b'}q\x00(X\x04\x00\x00\x00nameq\x01X\x03\x00\x00\x00appq\x02X\x03\x00\x00\x00msgq\x03X\x0b\x00\x00\x00Factorial 
…
\x19X\n\x00\x00\x00MainThreadq\x1aX\x0b\x00\x00\x00processNameq\x1bX\x0b\x00\x00\x00MainProcessq\x1cX\x07\x00\x00\x00processq\x1dMcQu.' 

要读取这些消息,我们首先需要收集负载大小字节。然后,我们可以消费随后的负载。以下是读取头部和负载并将它们写入文件的套接字服务器:

from __future__ import annotations
import json
from pathlib import Path
import socketserver
from typing import TextIO
import pickle
import struct
class LogDataCatcher(socketserver.BaseRequestHandler):
    log_file: TextIO
    count: int = 0
    size_format = ">L"
    size_bytes = struct.calcsize(size_format)
    def handle(self) -> None:
        size_header_bytes = self.request.recv(LogDataCatcher.size_bytes)
        while size_header_bytes:
            payload_size = struct.unpack(
                LogDataCatcher.size_format, size_header_bytes)
            payload_bytes = self.request.recv(payload_size[0])
            payload = pickle.loads(payload_bytes)
            LogDataCatcher.count += 1
            self.log_file.write(json.dumps(payload) + "\n")
            try:
                size_header = self.request.recv(
                    LogDataCatcher.size_bytes)
            except (ConnectionResetError, BrokenPipeError):
                break
def main(host: str, port: int, target: Path) -> None:
    with target.open("w") as unified_log:
        LogDataCatcher.log_file = unified_log
        with socketserver.TCPServer(
                (host, port), LogDataCatcher) as server:
            server.serve_forever() 

socketserver.TCPServer 对象将监听来自客户端的连接请求。当客户端连接时,它将创建 LogDataCatcher 类的一个实例,并评估该对象的 handle() 方法以从该客户端收集数据。handle() 方法通过两步舞来解码大小和有效载荷。首先,它读取几个字节以找到有效载荷的大小。它使用 struct.unpack() 将这些字节解码成一个有用的数字,即 payload_size,然后读取指定数量的字节以获取有效载荷。pickle.loads() 将从有效载荷字节中加载一个 Python 对象。这个对象被序列化为 JSON 表示法,使用 json.dumps() 并写入打开的文件。一旦处理了一条消息,我们就可以尝试读取下几个字节以查看是否有更多数据等待。这个服务器将吸收来自客户端的消息,直到连接断开,导致读取错误并退出 while 语句。

此日志收集服务器可以从网络中任何位置的应用程序吸收日志消息。此示例实现是单线程的,意味着它一次只能处理一个客户端。我们可以使用额外的混合器来创建一个多线程服务器,该服务器将接受来自多个来源的消息。在这个例子中,我们希望专注于测试一个依赖于此服务器的单个应用程序。

为了完整性,以下是启动服务器运行的主要脚本:

if __name__ == "__main__":
    HOST, PORT = "localhost", 18842
    main(HOST, PORT, Path("one.log")) 

我们提供一个主机 IP 地址、端口号以及我们希望所有消息写入的文件。作为一个实际操作,我们可能会考虑使用argparse模块和os.environ字典来将这些值提供给应用程序。目前,我们已将它们硬编码。

这是remote_logging_app.py应用程序,它将日志记录传输到日志捕获服务器:

from __future__ import annotations
import logging
import logging.handlers
import time
import sys
from math import factorial
logger = logging.getLogger("app")
def work(i: int) -> int:
    logger.info("Factorial %d", i)
    f = factorial(i)
    logger.info("Factorial(%d) = %d", i, f)
    return f
if __name__ == "__main__":
    HOST, PORT = "localhost", 18842
    socket_handler = logging.handlers.SocketHandler(HOST, PORT)
    stream_handler = logging.StreamHandler(sys.stderr)
    logging.basicConfig(
        handlers=[socket_handler, stream_handler], 
        level=logging.INFO)
    for i in range(10):
        work(i)
    logging.shutdown() 

此应用程序创建了两个日志处理器。SocketHandler 实例将在指定的服务器和端口号上打开一个套接字,并开始写入字节。这些字节将包括头部和有效负载。StreamHandler 实例将写入终端窗口;这是如果没有创建任何特殊处理器,我们会得到的默认日志处理器。我们使用这两个处理器配置我们的日志记录器,以便每个日志消息都发送到我们的控制台和收集消息的流服务器。实际的工作?一点数学计算,计算一个数的阶乘。每次运行此应用程序时,它应该发出 20 条日志消息。

为了测试集成客户端和服务器,我们需要在一个单独的进程中启动服务器。我们不希望频繁地启动和停止它(这需要一些时间),因此我们将只启动一次并在多个测试中使用它。我们将将其分为两个部分,首先从以下两个固定装置开始:

from __future__ import annotations
import subprocess
import signal
import time
import pytest
import logging
import sys
import remote_logging_app
from typing import Iterator, Any
@pytest.fixture(scope="session")
def log_catcher() -> Iterator[None]:
    print("loading server")
    p = subprocess.Popen(
        ["python3", "src/log_catcher.py"],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
    )
    time.sleep(0.25)
    **yield**
    p.terminate()
    p.wait()
    if p.stdout:
        print(p.stdout.read())
    assert (
        p.returncode == -signal.SIGTERM.value
    ), f"Error in watcher, returncode={p.returncode}"
@pytest.fixture
def logging_config() -> Iterator[None]:
    HOST, PORT = "localhost", 18842
    socket_handler = logging.handlers.SocketHandler(HOST, PORT)
    remote_logging_app.logger.addHandler(socket_handler)
    yield
    socket_handler.close()
    remote_logging_app.logger.removeHandler(socket_handler) 

log_catcher 配置将启动 log_catcher.py 服务器作为一个子进程。在 @fixture 装饰器中,其作用域被设置为 "session",这意味着在整个测试会话中只执行一次。作用域可以是字符串 "function""class""module""package""session" 之一,提供不同的位置来创建和重用配置。启动过程中会有一个短暂的暂停(250 毫秒),以确保其他进程已正确启动。当此配置达到 yield 语句时,GIVEN 测试设置的这部分就完成了。

logging_config 配置项将调整正在测试的 remote_logging_app 模块的日志配置。当我们查看 remote_logging_app.py 模块中的 work() 函数时,我们可以看到它期望一个模块级别的 logger 对象。这个测试配置项创建一个 SocketHandler 对象,将其添加到 logger 中,然后执行 yield 语句。

一旦这两个固定装置都对给定条件做出了贡献,我们就可以定义包含步骤的测试用例。以下是两个类似场景的两个示例:

def test_1(log_catcher: None, logging_config: None) -> None:
    for i in range(10):
        r = remote_logging_app.work(i)
def test_2(log_catcher: None, logging_config: None) -> None:
    for i in range(1, 10):
        r = remote_logging_app.work(52 * i) 

这两种场景都需要这两个固定装置。log_catcher装置具有会话作用域,只需准备一次,即可用于两个测试。然而,logging_config装置具有默认作用域,这意味着它为每个测试函数都进行了准备。

None的类型提示遵循了Iterator[None]的固定定义。在yield语句中不返回任何值。对于这些测试,设置操作通过启动一个进程来准备整体运行时环境。

当测试函数完成后,logging_config 配置项在 yield 语句之后恢复。(这个配置项是一个迭代器,使用 next() 函数尝试从中获取第二个值。)这会关闭并移除处理程序,干净利落地断开与日志捕获进程的网络连接。

当整体测试完成后,log_catcher 测试夹具可以终止子进程。为了帮助调试,我们打印任何输出。为了确保测试成功,我们检查操作系统的返回码。因为进程是被终止的(通过 p.terminate()),返回码应该是 signal.SIGTERM 的值。其他返回码值,尤其是返回码为一,意味着日志捕获器崩溃,测试失败。

我们省略了详细的THEN检查,但它也应该是log_catcher测试套件的一部分。现有的assert语句确保日志捕获器以预期的返回代码终止。一旦天上的捕获器完成吸收日志消息,这个测试套件也应该读取日志文件,以确保它包含两个场景预期的条目。

固件也可以进行参数化。我们可以使用一个装饰器如 @pytest.fixture(params=[some, list, of, values]) 来创建固件的多个副本,这将导致每个参数值都有多个测试。

pytest 配置的复杂性使得它们在满足各种测试设置和清理需求时非常方便。在本节前面,我们提到了如何标记测试不适用于 Python 的特定版本。在下一节中,我们将探讨如何标记要跳过的测试。

跳过 pytest 中的测试

有时在 pytest 中跳过测试是必要的,原因多种多样:被测试的代码尚未编写,测试仅在特定的解释器或操作系统上运行,或者测试耗时较长,只在特定情况下运行。在上一节中,我们的一些测试在 Python 3.8 中无法运行,需要被跳过。

跳过测试的一种方法是使用 pytest.skip() 函数。它接受一个参数:一个字符串,描述为什么它被跳过。这个函数可以在任何地方调用。如果我们在一个测试函数内部调用它,测试将被跳过。如果我们它在模块级别调用,该模块中的所有测试都将被跳过。如果我们它在 fixture 内部调用,所有引用该 fixture 的测试都将被跳过。

当然,在这些所有位置,通常只有当满足或未满足某些条件时,才希望跳过测试。由于我们可以在 Python 代码的任何地方执行skip()函数,我们可以在if语句中执行它。我们可以编写一个看起来如下所示的测试:

import sys
import pytest
def test_simple_skip() -> None:
    if sys.platform != "ios":
        pytest.skip("Test works only on Pythonista for ios")
    import location  # type: ignore [import]
    img = location.render_map_snapshot(36.8508, -76.2859)
    assert img is not None 

这个测试在大多数操作系统上都会跳过。它应该在 iOS 的 Pythonista 端口上运行。它展示了我们如何有条件地跳过某个场景,并且由于if语句可以检查任何有效的条件,我们在决定测试是否跳过时拥有很大的权力。通常,我们会检查sys.version_info来确认 Python 解释器的版本,sys.platform来确认操作系统,或者some_library.__version__来检查我们是否有给定模块的最新版本。

由于基于条件跳过单个测试方法或函数是测试跳过的最常见用途之一,pytest 提供了一个方便的装饰器,允许我们一行内完成此操作。该装饰器接受一个字符串,该字符串可以包含任何评估为布尔值的可执行 Python 代码。例如,以下测试仅在 Python 3.9 或更高版本上运行:

import pytest
import sys
@pytest.mark.skipif(
    sys.version_info < (3, 9), 
    reason="requires 3.9, Path.removeprefix()"
)
def test_feature_python39() -> None:
    file_name = "(old) myfile.dat"
    assert file_name.removeprefix("(old) ") == "myfile.dat" 

pytest.mark.xfail 装饰器将测试标记为预期会失败。如果测试成功,它将被记录为失败(它未能失败!)。如果测试失败,它将被报告为预期的行为。在xfail的情况下,条件参数是可选的。如果没有提供,测试将被标记为在所有条件下预期会失败。

pytest 框架除了这里描述的功能之外,还有许多其他特性,并且开发人员持续在添加创新的新方法来提升您的测试体验。他们在其网站上提供了详尽的文档,网址为 docs.pytest.org/

pytest 工具可以找到并运行使用标准 unittest 库定义的测试,以及它自己的测试基础设施。这意味着,如果您想从 unittest 迁移到 pytest,您不需要重写所有旧测试。

我们已经探讨了使用夹具来设置和拆除用于测试的复杂环境。这对某些集成测试很有帮助,但可能更好的方法是模拟一个昂贵的对象或一个有风险的操作。此外,任何拆除操作对于单元测试都是不合适的。单元测试将每个软件组件隔离成单独的单元进行测试。这意味着我们通常会替换所有接口对象为模拟对象,称为“模拟”,以隔离正在测试的单元。接下来,我们将转向创建模拟对象以隔离单元并模仿昂贵的资源。

使用 Mocks 模拟对象

独立的问题更容易诊断和解决。弄清楚为什么汽油车无法启动可能很棘手,因为有很多相互关联的部件。如果测试失败,揭示所有这些相互关系会使问题诊断变得困难。我们通常希望通过提供简化的模拟来隔离项目。结果发现,用模拟(或“模拟”)对象替换完美的代码有两个原因:

  • 最常见的案例是隔离一个待测试的单元。我们希望创建协作的类和函数,这样我们就可以在已知、可信的测试固定装置环境中测试一个未知组件。

  • 有时候,我们想要测试需要使用昂贵或存在风险的对象的代码。例如,共享数据库、文件系统和云基础设施的设置和拆除对于测试来说可能非常昂贵。

在某些情况下,这可能会导致设计一个具有可测试接口的 API。为了可测试性而进行设计通常也意味着设计一个更易用的接口。特别是,我们必须暴露关于协作类的假设,这样我们就可以注入一个模拟对象,而不是实际应用程序类的实例。

例如,假设我们有一些代码用于跟踪外部键值存储(例如redismemcache)中的航班状态,以便我们可以存储时间戳和最新的状态。该实现将需要redis客户端;编写单元测试时不需要它。可以使用以下命令安装客户端:python -m pip install redis

% python -m pip install redis
Collecting redis
  Downloading redis-3.5.3-py2.py3-none-any.whl (72 kB)
     |████████████████████████████████| 72 kB 1.1 MB/s 
Installing collected packages: redis
Successfully installed redis-3.5.3 

如果你想要使用真实的 redis 服务器运行此程序,你还需要下载并安装 redis。这可以通过以下步骤完成:

  1. 下载 Docker 桌面版以帮助管理此应用程序。请参阅 www.docker.com/products/docker-desktop

  2. 使用终端窗口中的 docker pull redis 命令下载一个 redis 服务器镜像。这个镜像可以用来构建一个正在运行的 Docker 容器。

  3. 您可以使用 docker run -p 6379:6379 redis 命令启动服务器。这将启动一个运行 redis 镜像的容器。然后您可以用它来进行集成测试。

避免使用docker的另一种方案涉及一系列平台特定的步骤。请参阅redislabs.com/ebook/appendix-a/以了解多种安装场景。以下示例将假设使用docker;将redis切换到原生安装所需的微小更改留作读者的练习。

这里有一些代码,用于在redis缓存服务器中保存状态:

from __future__ import annotations
import datetime
from enum import Enum
import redis
class Status(str, Enum):
    CANCELLED = "CANCELLED"
    DELAYED = "DELAYED"
    ON_TIME = "ON TIME"
class FlightStatusTracker:
    def __init__(self) -> None:
        self.redis = redis.Redis(host="127.0.0.1", port=6379, db=0)
    def change_status(self, flight: str, status: Status) -> None:
        if not isinstance(status, Status):
            raise ValueError(f"{status!r} is not a valid Status")
        key = f"flightno:{flight}"
        now = datetime.datetime.now(tz=datetime.timezone.utc)
        value = f"{now.isoformat()}|{status.value}"
        self.redis.set(key, value)
    def get_status(self, flight: str) -> tuple[datetime.datetime, Status]:
        key = f"flightno:{flight}"
        value = self.redis.get(key).decode("utf-8")
        text_timestamp, text_status = value.split("|")
        timestamp = datetime.datetime.fromisoformat(text_timestamp)
        status = Status(text_status)
        return timestamp, status 

Status 类定义了一个包含四个字符串值的枚举。我们提供了如 Status.CANCELLED 这样的符号名称,以便我们能够拥有一个有限、有界的有效状态值域。实际存储在数据库中的值将是类似于 "CANCELLED" 的字符串,目前它们恰好与我们将在应用程序中使用的符号相匹配。在未来,值域可能会扩展或改变,但我们希望将应用程序的符号名称与数据库中出现的字符串保持分离。使用 Enum 与数字代码一起是很常见的,但它们可能难以记忆。

change_status()方法中,我们应该测试很多东西。我们检查以确保status参数的值确实是一个有效的Status枚举实例,但我们还可以做更多。我们应该检查如果flight参数的值不合理,它是否会引发适当的错误。更重要的是,我们需要一个测试来证明当在redis对象上调用set()方法时,键和值具有正确的格式。

然而,在我们的单元测试中,我们不需要检查的是redis对象是否正确存储数据。这是在集成测试或应用测试中绝对应该测试的事情,但在单元测试层面,我们可以假设py-redis的开发者已经测试了他们的代码,并且这个方法会按照我们的期望执行。一般来说,单元测试应该是自包含的;被测试的单元应该与外部资源,例如正在运行的 Redis 实例,隔离。

我们不需要与 Redis 服务器集成,只需测试 set() 方法被正确次数和正确参数调用即可。我们可以在测试中使用 Mock() 对象来替换那个麻烦的方法,用一个我们可以内省的对象来代替。以下示例说明了 Mock 的使用:

import datetime
import flight_status_redis
from unittest.mock import Mock, patch, call
import pytest
@pytest.fixture
def mock_redis() -> Mock:
    mock_redis_instance = Mock(set=Mock(return_value=True))
    return mock_redis_instance
@pytest.fixture
def tracker(
    monkeypatch: pytest.MonkeyPatch, mock_redis: Mock
) -> flight_status_redis.FlightStatusTracker:
    fst = flight_status_redis.FlightStatusTracker()
    monkeypatch.setattr(fst, "redis", mock_redis)
    return fst
def test_monkeypatch_class(
    tracker: flight_status_redis.FlightStatusTracker, mock_redis: Mock
) -> None:
    with pytest.raises(ValueError) as ex:
        tracker.change_status("AC101", "lost")
    assert ex.value.args[0] == "'lost' is not a valid Status"
    assert mock_redis.set.call_count == 0 

此测试使用raises()上下文管理器来确保在传入不适当的参数时,能够抛出正确的异常。此外,它为FlightStatusTracker将要使用的redis实例创建了一个Mock对象。

模拟对象包含一个属性,set,这是一个总是返回True的模拟方法。然而,测试确保redis.set()方法永远不会被调用。如果它被调用了,那就意味着我们的异常处理代码中存在一个 bug。

注意对模拟对象的导航。我们使用mock_redis.set来检查由mock_redis测试用例创建的Mock对象的模拟set()方法。call_count是所有Mock对象都维护的一个属性。

虽然我们可以在测试期间使用类似 flt.redis = mock_redis 的代码来用一个 Mock 对象替换真实对象,但这可能会存在潜在问题。仅仅替换一个值或者替换一个类方法,只能对每个测试函数中销毁和创建的对象有效。如果我们需要在模块级别修补项目,模块不会被重新导入。一个更通用的解决方案是使用修补器临时注入一个 Mock 对象。在这个例子中,我们使用了 pytestmonkeypatch 修复件来临时更改 FlightStatusTracker 对象。monkeypatch 在测试结束时具有自己的自动清理功能,这使得我们可以在不破坏其他测试的情况下使用修补过的模块和类。

此测试用例将被mypy标记。mypy工具将反对为change_status()函数的状态参数使用字符串参数值;这显然必须是一个Status枚举的实例。可以添加一个特殊注释来静默mypy的参数类型检查,# type: ignore [arg-type]

额外的打补丁技术

在某些情况下,我们可能只需要在单个测试期间注入一个特殊函数或方法。我们可能并不真的需要创建一个在多个测试中使用的复杂Mock对象。我们可能只需要为单个测试准备一个小型的Mock。在这种情况下,我们可能也不需要使用monkeypatch固定装置的所有功能。例如,如果我们想测试Mock方法中的时间戳格式化,我们需要确切知道datetime.datetime.now()将要返回什么值。然而,这个值每次运行都会变化。我们需要某种方法将其固定到特定的日期时间值,这样我们就可以进行确定性测试。

将库函数临时设置为特定值是修补工作至关重要的地方。除了monkeypatch测试夹具外,unittest.mock库还提供了一个patch上下文管理器。这个上下文管理器允许我们用模拟对象替换现有库中的属性。当上下文管理器退出时,原始属性会自动恢复,以免影响其他测试用例。以下是一个示例:

def test_patch_class(
    tracker: flight_status_redis.FlightStatusTracker, mock_redis: Mock
) -> None:
    fake_now = datetime.datetime(2020, 10, 26, 23, 24, 25)
    utc = datetime.timezone.utc
    with patch("flight_status_redis.datetime") as mock_datetime:
        mock_datetime.datetime = Mock(now=Mock(return_value=fake_now))
        mock_datetime.timezone = Mock(utc=utc)
        tracker.change_status(
        "AC101", flight_status_redis.Status.ON_TIME)
    mock_datetime.datetime.now.assert_called_once_with(tz=utc)
    expected = f"2020-10-26T23:24:25|ON TIME"
    mock_redis.set.assert_called_once_with("flightno:AC101", expected) 

我们不希望测试结果依赖于计算机的时钟,因此我们构建了一个fake_now对象,它包含我们预期在测试结果中看到的特定日期和时间。这种替换在单元测试中非常常见。

patch()上下文管理器返回一个用于替换其他对象的Mock对象。在这种情况下,被替换的对象是flight_status_redis模块内部的整个datetime模块。当我们分配mock_datetime.datetime时,我们用我们自己的Mock对象替换了模拟的datetime模块中的datetime类;这个新的Mock定义了一个属性,now。因为utcnow属性是一个返回值的Mock,它表现得像是一个方法,并返回一个固定、已知的值,fake_now。当解释器退出patch上下文管理器时,原始的datetime功能将得到恢复。

在使用已知的值调用我们的 change_status() 方法后,我们使用 Mock 对象的 assert_called_once_with() 方法来确保 now() 函数确实恰好一次以预期的参数(在这种情况下,没有参数)被调用。我们还对 Mockredis.set 方法使用 assert_called_once_with() 方法,以确保它以我们预期的格式调用参数。除了“恰好一次调用”之外,我们还可以检查所进行的模拟调用的确切列表。这个序列可以在 Mock 对象的 mock_calls 属性中找到。

模拟日期以获得确定性的测试结果是常见的修补场景。这项技术适用于任何有状态的对象,但对于存在于我们应用程序之外的外部资源(如时钟)尤其重要。

对于datetimetime的特殊情况,像freezegun这样的包可以简化所需的 monkeypatching,以便提供一个已知且固定的日期。

在本例中我们故意进行了广泛的修改。我们将整个 datetime 模块替换为一个 Mock 对象。这往往会暴露出 datetime 特性的意外用法;如果使用了任何未特别模拟的方法(比如 now() 方法被模拟了),它将返回可能导致被测试代码崩溃的 Mock 对象。

之前的例子也展示了测试性如何需要指导我们的 API 设计。tracker固定器有一个有趣的问题:它创建了一个FlightStatusTracker对象,该对象构建了一个 Redis 连接。在 Redis 连接建立之后,我们将其替换。然而,当我们为这段代码运行测试时,我们会发现每个测试都会创建一个未使用的 Redis 连接。如果没有运行 Redis 服务器,一些测试可能会失败。因为这个测试需要外部资源,所以它不是一个合适的单元测试。可能存在两层失败:代码本身不工作,或者单元测试因为某些隐藏的外部依赖而无法工作。这可能会变成一个难以整理的噩梦。

我们可以通过模拟 redis.Redis 类来解决此问题。这个类的模拟可以在 setUp 方法中返回一个模拟实例。然而,更好的想法可能是从根本上重新思考我们的实现。而不是在 __init__ 方法内部构建 redis 实例,我们应该允许用户传入一个,如下例所示:

def __init__(
        self, 
        redis_instance: Optional[redis.Connection] = None
) -> None:
    self.redis = (
        redis_instance
        if redis_instance
        else redis.Redis(host="127.0.0.1", port=6379, db=0)
    ) 

这允许我们在测试时传递一个连接,这样Redis方法就不会被构造。此外,它还允许任何与FlightStatusTracker通信的客户端代码传递他们自己的redis实例。他们可能出于各种原因想要这样做:他们可能已经为代码的其他部分构造了一个实例;他们可能已经创建了一个优化的redis API 实现;也许他们有一个将指标记录到他们内部监控系统中的实例。通过编写单元测试,我们揭示了一个使用案例,这使得我们的 API 从一开始就更加灵活,而不是等待客户端要求我们支持他们的特殊需求。

这是对模拟代码奇妙的简要介绍。自 Python 3.3 以来,模拟对象一直是标准 unittest 库的一部分。正如您从这些示例中看到的,它们也可以与 pytest 和其他测试框架一起使用。随着代码变得更加复杂,模拟对象还有其他更高级的功能,您可能需要利用这些功能。例如,您可以使用 spec 参数邀请模拟对象模仿一个现有的类,这样如果代码尝试访问被模仿类上不存在的属性时,就会引发错误。您还可以通过传递一个列表作为 side_effect 参数来构建每次被调用时返回不同参数的模拟方法。side_effect 参数非常灵活;您还可以在模拟对象被调用时执行任意函数或引发异常。

单元测试的目的是确保每个“单元”在独立的情况下都能正常工作。通常,一个单元是一个单独的类,我们需要对这些协作者进行模拟。在某些情况下,有多个类组成或者有一个门面(Façade),这些应用类可以一起作为一个“单元”进行测试。然而,当不适当地使用模拟时,会存在一个明确的边界。如果我们需要查看某些外部模块或类(我们未编写的)以了解如何模拟其依赖项,那么我们就走得太远了。

不要检查你应用外部类的实现细节来了解如何模拟它们的协作者;相反,模拟你依赖的整个类。

这通常会导致为整个数据库或外部 API 提供一个模拟。

我们可以将模仿对象的这个想法再进一步扩展。当我们想要确保数据未被修改时,我们会使用一个专门的夹具。我们将在下一部分探讨这个话题。

哨兵对象

在许多设计中,我们会有一个带有属性值的类,这些属性值可以作为参数提供给其他对象,而实际上并不对这些对象进行任何处理。例如,我们可能将一个Path对象提供给一个类,然后该类将这个Path对象传递给操作系统的一个函数;我们设计的这个类所做的不仅仅是保存对象。从单元测试的角度来看,该对象对我们正在测试的类来说是“不透明的”——我们正在编写的类不会查看对象的状态或方法。

unittest.mock模块提供了一个便捷的对象,即sentinel,它可以用来创建不透明的对象,我们可以在测试用例中使用这些对象来确保应用程序存储和转发对象时未对其进行修改。

这里有一个名为 FileChecksum 的类,它使用 hashlib 模块的 sha256() 函数计算并保存一个对象:

class FileChecksum:
    def __init__(self, source: Path) -> None:
        self.source = source
        self.checksum = hashlib.sha256(source.read_bytes()) 

我们可以将这段代码从其他模块中分离出来,以便进行单元测试。我们将为hashlib模块创建一个Mock,并使用一个sentinel来表示结果:

from unittest.mock import Mock, sentinel
@pytest.fixture
def mock_hashlib(monkeypatch) -> Mock:
    mocked_hashlib = Mock(sha256=Mock(return_value=sentinel.checksum))
    monkeypatch.setattr(checksum_writer, "hashlib", mocked_hashlib)
    return mocked_hashlib
def test_file_checksum(mock_hashlib, tmp_path) -> None:
    source_file = tmp_path / "some_file"
    source_file.write_text("")
    cw = checksum_writer.FileChecksum(source_file)
    assert cw.source == source_file
    assert cw.checksum == sentinel.checksum 

我们的 mocked_hashlib 对象提供了一个名为 sha256 的方法,该方法返回唯一的 sentinel.checksum 对象。这是一个由 sentinel 对象创建的对象,具有非常少的方法或属性。任何属性名都可以创建为一个唯一对象;我们在这里选择了“checksum”。生成的对象旨在进行相等性检查,别无其他用途。测试用例中的 sentinel 是确保 FileChecksum 类不会对其提供的对象执行任何错误或意外的操作的一种方式。

测试用例创建了一个FileChecksum对象。测试确认文件是提供的参数值,即source_file。测试还确认校验和与原始的sentinel对象匹配。这确认了FileChecksum实例正确存储了校验和结果,并以checksum属性值的形式呈现了结果。

如果我们将FileChecksum类的实现改为——例如——使用属性而不是直接访问属性,测试将确认校验和被当作一个来自hashlib.sha256()函数的不可见对象处理,并且没有以任何其他方式进行处理。

我们已经探讨了两个单元测试框架:内置的 unittest 包和外部 pytest 包。它们都为我们提供了编写清晰、简单测试的方法,以确认我们的应用程序能够正常工作。明确界定所需测试量的目标是至关重要的。Python 有一个易于使用的覆盖率包,它为我们提供了一个衡量测试质量的客观指标。

测试多少才算足够?

我们已经确定未经测试的代码就是有缺陷的代码。但如何判断我们的代码测试得有多好呢?我们如何知道我们的代码中有多少部分实际上被测试了,有多少部分是存在问题的?第一个问题是更重要的问题,但很难回答。即使我们知道我们已经测试了我们应用程序中的每一行代码,我们也不知道我们是否正确地进行了测试。例如,如果我们编写一个stats测试,它只检查当我们提供一个整数列表时会发生什么,那么如果用于浮点数列表、字符串或自定义对象,它可能仍然会以惊人的方式失败。设计完整的测试套件的责任仍然在于程序员。

第二个问题——我们的代码中有多少实际上被测试了——是容易验证的。代码覆盖率是程序执行代码行数的计数。从程序整体中的行数,我们知道代码被真正测试或覆盖的百分比。如果我们还有一个指示器告诉我们哪些行没有被测试,我们就可以更容易地编写新的测试来确保这些行不太可能隐藏问题。

测试代码覆盖率最流行的工具被称为,足够令人难忘的,coverage.py。它可以通过使用python -m pip install coverage命令,像大多数其他第三方库一样进行安装。

我们没有足够的空间来涵盖覆盖 API 的所有细节,所以我们只看看几个典型的例子。如果我们有一个 Python 脚本可以为我们运行所有单元测试(这可能使用unittest.mainunittest discoverpytest),我们可以使用以下命令为特定的单元测试文件执行覆盖率分析:

% export PYTHONPATH=$(pwd)/src:$PYTHONPATH
% coverage run -m pytest tests/test_coverage.py 

此命令将创建一个名为 .coverage 的文件,该文件包含运行的数据。

Windows Powershell 用户可以进行以下操作:

> $ENV:PYTHONPATH = "$pwd\src" + ";" + $PYTHONPATH
> coverage run -m pytest tests/test_coverage.py 

我们现在可以使用覆盖率报告命令来获取代码覆盖率的分析:

% coverage report 

最终输出应该是以下这样:

Name                     Stmts   Miss  Cover
--------------------------------------------
src/stats.py                19     11    42%
tests/test_coverage.py       7      0   100%
--------------------------------------------
TOTAL                       26     11    58% 

本报告列出了被执行文件(我们的单元测试及其导入的模块)、每个文件中的代码行数,以及测试执行的代码行数。这两个数字合并后,显示了代码覆盖率。不出所料,整个测试都被执行了,但只有stats模块的一小部分被测试到。

如果我们将-m选项传递给report命令,它将添加一个列,用于标识测试执行中缺失的行。输出如下:

Name                     Stmts   Miss  Cover   Missing
------------------------------------------------------
src/stats.py                19     11    42%   18-23, 26-31
tests/test_coverage.py       7      0   100%
------------------------------------------------------
TOTAL                       26     11    58% 

这里列出的行范围标识了在测试运行期间未执行的stats模块中的行。

示例代码使用了我们在本章早期创建的相同 stats 模块。然而,它故意使用一个失败的测试来测试文件中的大量代码。以下是这个测试:

import pytest
from stats import StatsList
@pytest.fixture
def valid_stats() -> StatsList:
    return StatsList([1, 2, 2, 3, 3, 4])
def test_mean(valid_stats: StatsList) -> None:
    assert valid_stats.mean() == 2.5 

这个测试并不测试中位数或众数函数,这些函数对应于覆盖率输出告诉我们缺失的行号。

文本报告提供了足够的信息,但如果我们使用coverage html命令,我们可以获得一个更加有用的交互式 HTML 报告,我们可以在网页浏览器中查看。交互式报告有许多我们可以启用的实用过滤器。网页甚至还会突出显示哪些源代码行被测试过,哪些没有被测试过。

这就是它的样子:

包含图形用户界面的图片 自动生成描述

图 13.1:交互式 HTML 覆盖率报告

我们使用pytestcoverage模块创建了 HTML 报告。为此,我们之前安装了用于代码覆盖的pytest插件,使用命令python -m pip install pytest-cov。该插件为pytest添加了几个命令行选项,其中最有用的是--cover-report,它可以设置为htmlreportannotate(后者实际上修改了原始源代码,以突出显示任何未覆盖的行)。

在覆盖率分析中包含src目录树以外的内容可能会有所帮助。大型项目可能有一个复杂的测试目录,包括额外的工具和支持库。随着项目的演变,可能会有一些过时的测试或支持代码,但尚未清理。

不幸的是,如果我们能以某种方式对这个章节的这一部分运行覆盖率报告,我们会发现我们没有涵盖关于代码覆盖率的大部分知识!我们可以在自己的程序(或测试套件)内部使用覆盖率 API 来管理代码覆盖率,而coverage.py接受了许多我们尚未涉及的配置选项。我们也没有讨论语句覆盖和分支覆盖之间的区别(后者更有用,并且在coverage.py的最近版本中是默认的),或者其他代码覆盖率风格。

请记住,虽然达到 100%的代码覆盖率是我们都应该努力追求的目标,但 100%的覆盖率并不足够!仅仅因为一个语句被测试了,并不意味着它对所有可能的输入都进行了适当的测试。边界值分析技术包括观察五个值来界定边缘情况:一个低于最小值的值,最小值,中间某个值,最大值,以及一个高于最大值的值。对于非数值类型,可能没有整洁的范围,但这个建议可以适应其他数据结构。例如,对于列表和映射,这个建议通常建议使用空列表或使用意外的键进行测试。Hypothesis 包(pypi.org/project/hypothesis/)可以帮助进行更复杂的测试用例。

很难强调测试的重要性。测试驱动开发方法鼓励我们通过可见的、可测试的目标来描述我们的软件。我们必须将复杂问题分解成离散的、可测试的解决方案。拥有比实际应用代码更多的测试代码行数并不罕见。有时,一个简短但令人困惑的算法最好通过例子来解释,而且每个例子都应该是一个测试用例。

测试与开发

这些单元测试能够帮助的许多方式之一是在调试应用程序问题时。当每个单元似乎独立工作的时候,任何剩余的问题通常都是由于组件之间使用不当的接口造成的。在寻找问题根本原因时,一系列通过测试的测试用例就像一组路标,引导开发者进入组件之间的边界地带那些未经测试的功能的荒野。

当发现问题,原因通常如下:

  • 有人在编写新类时未能理解现有类的一个接口,并错误地使用了它。这表明需要一个新的单元测试来反映正确使用接口的方法。这个新测试应该导致新代码在其扩展的测试套件中失败。集成测试也有帮助,但不如专注于接口细节的新单元测试重要。

  • 接口描述不够详细,使用该接口的双方需要就接口的使用方式达成一致。在这种情况下,接口的双方都需要进行额外的单元测试来展示接口应该如何使用。这两个类都应该在新的单元测试中失败;然后它们可以被修复。此外,可以使用集成测试来确认这两个类是否达成一致。

这里提出的想法是利用测试用例来驱动开发过程。一个“错误”或“事件”需要被转换为一个失败的测试用例。一旦我们将问题以测试用例的形式具体表达出来,我们就可以创建或修改软件,直到所有测试通过。

如果出现错误,我们通常会遵循以下测试驱动计划:

  1. 编写一个测试(或多个测试)来复制或证明所讨论的 bug 正在发生。这个测试当然会失败。在更复杂的应用中,可能很难找到在独立的代码单元中重现 bug 的确切步骤;找到这一点是很有价值的工作,因为它需要了解软件知识,并将这些知识作为测试场景捕获。

  2. 然后,编写代码以停止测试失败。如果测试是全面的,那么错误将被修复,我们将知道在尝试修复某事的过程中没有破坏新的东西。

测试驱动开发另一个好处是测试用例对于进一步改进的价值。一旦测试用例编写完成,我们可以随心所欲地改进我们的代码,并确信我们的更改没有破坏我们一直在测试的内容。此外,我们知道何时我们的重构完成:当所有测试都通过时。

当然,我们的测试可能无法全面测试我们需要测试的所有内容;维护或代码重构仍然可能导致未诊断的 bug,这些 bug 在测试中不会显现出来。自动化测试并非万无一失。正如 E. W. Dijkstra 所说,“程序测试可以用来显示 bug 的存在,但永远不能显示它们的缺失!”我们需要有充分的理由来证明我们的算法是正确的,以及测试用例来证明它没有任何问题。

案例研究

我们将回到前面章节的一些内容,并应用一些仔细的测试来确保我们有一个良好、可行的实现。在第三章当对象相似时,我们探讨了k最近邻分类器的一部分距离计算。在那个章节中,我们查看了几种产生略微不同结果的计算:

  • 欧几里得距离:这是从一个样本到另一个样本的直接连线。

  • 曼哈顿距离:这是沿着网格(如曼哈顿市)的街道和大道计算(如曼哈顿市),累加沿一系列直线路径所需的步数。

  • 切比雪夫距离:这是街道和林荫大道距离中最大的。

  • 索尔森距离:这是曼哈顿距离的一种变体,它对近距离步骤的权重比对远距离步骤的权重更大。它倾向于放大小距离,使得更细微的区分更加明显。

这些算法从相同的输入中产生不同的结果;它们都涉及看起来复杂的数学,并且它们都需要单独测试以确保我们正确实现了它们。我们将从距离的单元测试开始。

单元测试距离类

我们需要为每个距离计算算法创建一些测试用例。当我们查看各种方程时,我们可以看到从两个样本中有四对相关的值:花瓣的长度和宽度,以及花瓣的长度和宽度。为了极其彻底,我们可以为每个算法创建至少 16 个不同的测试用例:

  • 案例 0:所有四个值都相同;距离应为零。

  • 案例 1-4:两个样本中的四个值中有一个不同。例如,一个测试样本可能有以下测量值 ("sepal_length": 5.1, "sepal_width": 3.5, "petal_length": 1.4, "petal_width": 0.2),而一个训练样本可能有以下测量值 ("sepal_length": 5.2, "sepal_width": 3.5, "petal_length": 1.4, "petal_width": 0.2);这些值中只有一个不同。

  • 案例 5-10:一对值不同。

  • 案例 11-14:两组样本之间有三个值不同。

  • 案例 15:所有四个值都不同。

此外,等价类划分和边界值分析的概念表明,我们还需要定位那些存在深刻状态变化的位置。例如,无效值会引发异常,这也是应该进行测试的情况。这可以在上述每个案例中创建出多个子案例。

在本案例研究的这部分,我们不会为每个算法创建全部 16 种情况。相反,我们将仔细研究是否真的需要所有 16 种情况。为了开始,我们将限制自己只针对每个距离算法使用一个案例。这将是一个案例 15 的例子,其中两个样本的所有四个值都不同。

使用数学结果时,我们需要在我们正在构建的软件之外计算预期的答案。我们当然可以用铅笔和纸或者电子表格来尝试计算预期的答案。

在处理更高级的数学问题时,一个有用的技巧是使用sympy包来更仔细地检查数学计算。

例如,已知样本 k 和未知样本 u 之间的欧几里得距离具有以下形式定义:

图片

这计算了所有四个测量值之间的距离。例如,已知的萼片长度是k[sl]。其他属性有类似的名字。

虽然 sympy 可以做很多事情,但我们想用它来达到两个特定的目的:

  1. 为了确认我们的 Python 版本的公式确实正确

  2. 使用特定的变量替换来计算预期结果

我们通过使用 sympy 来执行符号运算来完成这项工作。而不是插入特定的浮点数值,我们希望将 Python 表达式转换为传统的数学符号表示。

这是一个应用于设计而非实现的测试案例。它证实了代码的设计很可能与原始意图相符。我们将像 k[sl] 这样的精美排版名称翻译成了 Python 风格的(但不是那么容易阅读的)k_sl。以下是我们的与 sympy 的交互:

>>> from sympy import *
>>> ED, k_sl, k_pl, k_sw, k_pw, u_sl, u_pl, u_sw, u_pw = symbols(
...     "ED, k_sl, k_pl, k_sw, k_pw, u_sl, u_pl, u_sw, u_pw")
>>> ED = sqrt( (k_sl-u_sl)**2 + (k_pl-u_pl)**2 + (k_sw-u_sw)**2 + (k_pw-u_pw)**2 )
>>> ED
sqrt((k_pl - u_pl)**2 + (k_pw - u_pw)**2 + (k_sl - u_sl)**2 + (k_sw - u_sw)**2)
>>> print(pretty(ED, use_unicode=False))
   ___________________________________________________________________
  /              2                2                2                2 
\/  (k_pl - u_pl)  + (k_pw - u_pw)  + (k_sl - u_sl)  + (k_sw - u_sw) 

我们导入了sympy并定义了一组与原始公式匹配的符号。我们需要定义这些对象,以便sympy能够将它们作为数学符号而不是普通 Python 对象来处理。然后,我们尽力将欧几里得距离公式从数学翻译成 Python。看起来是正确的,但我们还是想确保一下。

注意,当我们请求ED的值时,我们没有看到 Python 计算的结果。因为我们已经将变量定义为符号,sympy构建了一个我们可以操作的方程表示。

当我们使用 sympy 中的 pretty() 函数时,它显示了我们的表达式的 ASCII 艺术版本,看起来非常像原始版本。我们使用了 use_unicode=False 选项,因为在这个书中这样看起来最好。当使用合适的字体打印时,use_unicode=True 版本可能更容易阅读。

这个公式是我们可以与专家分享的,以确保我们的测试用例确实正确地描述了这一特定类的行为。因为公式看起来是正确的,我们可以用具体的数值来评估它:

>>> e = ED.subs(dict(
...     k_sl=5.1, k_sw=3.5, k_pl=1.4, k_pw=0.2,
...     u_sl=7.9, u_sw=3.2, u_pl=4.7, u_pw=1.4,
... ))
>>> e.evalf(9)
4.50111097 

subs()方法用于替换公式中的符号值。然后我们使用evalf()方法将结果评估为浮点数。我们可以用这个来为类创建一个单元测试用例。

在我们查看测试用例之前,这里有一个欧几里得距离类的实现。作为一个优化,它使用了math.hypot()函数:

class ED(Distance):
    def distance(self, s1: Sample, s2: Sample) -> float:
        return hypot(
            s1.sepal_length - s2.sepal_length,
            s1.sepal_width - s2.sepal_width,
            s1.petal_length - s2.petal_length,
            s1.petal_width - s2.petal_width,
        ) 

这种实现似乎与数学相符。最好的检查方式是创建一个自动化的测试。回想一下,测试通常有一个GIVEN-WHEN-THEN的框架。我们可以将这个框架扩展到以下概念场景:

Scenario: Euclidean Distance Computation
  Given an unknown sample, U, and a known sample, K
   When we compute the Euclidean Distance between them
   Then we get the distance, ED. 

我们可以提供用于符号计算的UK和预期距离的值。我们将从一个支持GIVEN步骤的测试夹具开始:

@pytest.fixture
def known_unknown_example_15() -> Known_Unknown:
    known_row: Row = {
        "species": "Iris-setosa",
        "sepal_length": 5.1,
        "sepal_width": 3.5,
        "petal_length": 1.4,
        "petal_width": 0.2,
    }
    k = TrainingKnownSample(**known_row)
    unknown_row = {
        "sepal_length": 7.9,
        "sepal_width": 3.2,
        "petal_length": 4.7,
        "petal_width": 1.4,
    }
    u = UnknownSample(**unknown_row)
    return k, u 

我们创建了一个TrainingKnownSample和一个UnknownSample对象,我们可以在后续的测试中使用它们。这个固定装置定义依赖于许多重要的类型提示和定义:

From __future__ import annotations
import pytest
from model import TrainingKnownSample, UnknownSample
from model import CD, ED, MD, SD
from typing import Tuple, TypedDict
Known_Unknown = Tuple[TrainingKnownSample, UnknownSample]
class Row(TypedDict):
    species: str
    sepal_length: float
    sepal_width: float
    petal_length: float
    petal_width: float 

我们可以将距离计算作为一个WHEN步骤,并在assert语句中进行最终的THEN比较。由于我们处理的是浮点数,我们需要使用一个approx对象来进行比较,因为精确比较很少能得出好的结果。

对于这个应用,测试用例中的小数位数似乎过多。我们保留了所有数字,以便值与approx默认使用的值相匹配,approx的相对误差为 1 x 10^(-6),或者用 Python 表示法为1e-6。以下是测试用例的其余部分:

def test_ed(known_unknown_example_15: Known_Unknown) -> None:
    k, u = known_unknown_example_15
    assert ED().distance(k, u) == pytest.approx(4.50111097) 

这份文档简洁明了。给定两个样本,距离结果应该与我们手工计算或使用sympy计算的结果相匹配。

每个距离类别都需要一个测试用例。这里提供另外两种距离计算方法。预期结果来自于验证公式并提供具体值,就像我们之前所做的那样:

def test_cd(known_unknown_example_15: Known_Unknown) -> None:
    k, u = known_unknown_example_15
    assert CD().distance(k, u) == pytest.approx(3.3)
def test_md(known_unknown_example_15: Known_Unknown) -> None:
    k, u = known_unknown_example_15
    assert MD().distance(k, u) == pytest.approx(7.6) 

对于切比雪夫距离和曼哈顿距离,我们正在为四个属性中的每一个添加单独的步骤,并计算总和或找到最大的单个距离。我们可以手动计算出这些值,并确信我们的预期答案是正确的。

然而,Sorensen 距离稍微复杂一些,并且可以从与符号结果的比较中受益。以下是其正式定义:

图片

这里是我们用来比较我们的实现与定义的符号定义。显示的方程看起来非常像正式的定义,这让我们有信心用它来计算期望值。以下是从代码中提取的我们想要检查的定义:

>>> SD = sum(
...     [abs(k_sl - u_sl), abs(k_sw - u_sw), abs(k_pl - u_pl), abs(k_pw - u_pw)]
...  ) / sum( 
...     [k_sl + u_sl, k_sw + u_sw, k_pl + u_pl, k_pw + u_pw])
>>> print(pretty(SD, use_unicode=False))
|k_pl - u_pl| + |k_pw - u_pw| + |k_sl - u_sl| + |k_sw - u_sw|
-------------------------------------------------------------
    k_pl + k_pw + k_sl + k_sw + u_pl + u_pw + u_sl + u_sw 

ASCII 艺术的公式版本看起来与正式定义非常相似,这让我们有足够的信心可以使用sympy来计算预期的答案。我们将用具体的示例值来替换,看看预期的结果应该是什么:

>>> e = SD.subs(dict(
...     k_sl=5.1, k_sw=3.5, k_pl=1.4, k_pw=0.2,
...     u_sl=7.9, u_sw=3.2, u_pl=4.7, u_pw=1.4,
... ))
>>> e.evalf(9)
0.277372263 

现在我们确信我们得到了有效的预期结果,我们就可以把这个预期结果放入一个单元测试用例中。下面是这个测试用例的样貌:

def test_sd(known_unknown_example_15: Known_Unknown) -> None:
    k, u = known_unknown_example_15
    assert SD().distance(k, u) == pytest.approx(0.277372263) 

我们将 sympy 作为设计辅助工具来帮助我们创建单元测试用例。它不是测试流程的常规部分。我们只想在那些我们不确定能否依靠纸笔计算出预期答案的晦涩难懂的情况下使用它。

如我们在本章案例研究开头所提到的,存在 16 种不同的值组合,其中已知样本属性和未知样本属性是不同的。我们只提供了这 16 种组合中的一种。

使用覆盖率工具,我们可以看到所有相关代码都通过这个单一案例进行了测试。我们真的需要其他 15 个案例吗?有两种观点:

  • 从“黑盒”的角度来看,我们不知道代码中有什么,我们需要测试所有可能的组合。这种黑盒测试依赖于假设这些值可能存在一些复杂的相互依赖关系,这只能通过耐心检查所有案例来发现。

  • 从“白盒”的角度来看,我们可以查看各种距离函数的实现,并发现所有四个属性都被统一处理。代码的检查告诉我们一个单一的情况就足够了。

对于 Python 应用程序,我们建议遵循白盒测试,除非有充分的理由避免查看代码。我们可以使用覆盖率报告来确认确实有一个案例测试了相关的代码。

我们不必为各种距离算法创建 16 个不同的测试用例,我们可以集中精力确保应用程序的可靠性并使用最少的计算资源。我们还可以专注于测试应用程序的其他部分。接下来,我们将查看Hyperparameter类,因为它依赖于Distance计算类层次结构。

单元测试 Hyperparameter 类

超参数类依赖于距离计算。对于这种复杂的类,我们有两种测试策略:

  • 使用已经测试过的距离计算进行集成测试

  • 一个单元测试,将Hyperparameter类与任何距离计算隔离开来,以确保该类能够正常工作

作为一般原则,每一行代码都需要至少通过一个单元测试进行检验。之后,集成测试也可以用来确保所有模块、类和函数都遵守了接口定义。"测试一切"的精神比"确保数字正确"更重要;计算行数是我们确保已测试所有内容的其中一种方法。

我们将探讨使用 Mock 对象来测试 Hyperparameter 类的 classify() 方法,以将 Hyperparameter 类与任何距离计算隔离开来。我们还将模拟 TrainingData 对象,以进一步隔离此类的一个实例。

这里是我们将要测试的相关代码:

class Hyperparameter:
    def __init__(
            self, 
            k: int, 
            algorithm: "Distance", 
            training: "TrainingData"
    ) -> None:
        self.k = k
        self.algorithm = algorithm
        self.data: weakref.ReferenceType["TrainingData"] = \
            weakref.ref(training)
        self.quality: float
    def classify(
            self, 
            sample: Union[UnknownSample, TestingKnownSample]) -> str:
        """The k-NN algorithm"""
        training_data = self.data()
        if not training_data:
            raise RuntimeError("No TrainingData object")
        distances: list[tuple[float, TrainingKnownSample]] = sorted(
            (self.algorithm.distance(sample, known), known)
            for known in training_data.training
        )
        k_nearest = (known.species for d, known in distances[: self.k])
        frequency: Counter[str] = collections.Counter(k_nearest)
        best_fit, *others = frequency.most_common()
        species, votes = best_fit
        return species 

Hyperparameter类的algorithm属性是对距离计算对象实例的引用。当我们替换这个属性时,Mock对象必须是可调用的,并且必须返回一个适当的可排序数字。

data 属性是对一个 TrainingData 对象的引用。用于替换 data 对象的 Mock 必须提供一个 training 属性,该属性是一个模拟样本的列表。由于这些值是直接提供给另一个模拟对象,而没有经过任何中间处理,因此我们可以使用一个 sentinel 对象来确认训练数据已被提供给模拟的距离函数。

这个想法可以概括为观察classify()方法“走流程”。我们提供模拟和哨兵来确认请求已被发送,并且捕获了这些请求的结果。

对于更复杂的测试,我们需要一些模拟样本数据。这将会依赖于sentinel对象。这些对象将被传递到一个模拟的距离计算中。以下是我们将使用的某些模拟样本对象的定义:

from __future__ import annotations
from model import Hyperparameter
from unittest.mock import Mock, sentinel, call
@pytest.fixture
def sample_data() -> list[Mock]:
    return [
        Mock(name="Sample1", species=sentinel.Species3),
        Mock(name="Sample2", species=sentinel.Species1),
        Mock(name="Sample3", species=sentinel.Species1),
        Mock(name="Sample4", species=sentinel.Species1),
        Mock(name="Sample5", species=sentinel.Species3),
    ] 

此配置文件是KnownSamples的模拟列表。我们为每个样本提供了一个独特的名称,以帮助调试。我们提供了一个species属性,因为这是classify()方法使用的属性。我们没有提供其他属性,因为它们不是被测试单元所使用的。我们将使用这个sample_data配置文件来创建一个具有模拟距离计算和此模拟数据集合的Hyperparameter实例。以下是我们将使用的测试配置文件:

@pytest.fixture
def hyperparameter(sample_data: list[Mock]) -> Hyperparameter:
    mocked_distance = Mock(distance=Mock(side_effect=[11, 1, 2, 3, 13]))
    mocked_training_data = Mock(training=sample_data)
    mocked_weakref = Mock(
        return_value=mocked_training_data)
    fixture = Hyperparameter(
        k=3, algorithm=mocked_distance, training=sentinel.Unused)
    fixture.data = mocked_weakref
    return fixture 

mocked_distance 对象将提供一系列看起来像距离计算结果的结果。距离计算是单独测试的,我们通过这个 Mockclassify() 方法从特定的距离计算中隔离出来。我们通过一个将表现得像弱引用的 Mock 对象提供了模拟的 KnownSample 实例列表;这个模拟对象的学习属性将是给定的样本数据。

为了确保Hyperparameter实例发出正确的请求,我们评估了classify()方法。以下是整个场景,包括这两个最后的 THEN 步骤:

  • 给定一个包含五个实例的数据固定样本,反映两种物种

  • 当我们应用k-近邻算法时

  • THEN 结果是距离最近的三个物种

  • AND 使用所有训练数据调用了模拟距离计算

这里是最终的测试,使用上述工具:

def test_hyperparameter(sample_data: list[Mock], hyperparameter: Mock) -> None:
    s = hyperparameter.classify(sentinel.Unknown)
    assert s == sentinel.Species1
    assert hyperparameter.algorithm.distance.mock_calls == [
        call(sentinel.Unknown, sample_data[0]),
        call(sentinel.Unknown, sample_data[1]),
        call(sentinel.Unknown, sample_data[2]),
        call(sentinel.Unknown, sample_data[3]),
        call(sentinel.Unknown, sample_data[4]),
    ] 

此测试用例检查距离算法以确保整个训练数据集都被使用。它还确认了使用最近邻来定位未知样本的结果物种。

由于我们已单独测试了距离计算,我们对将这些各种类组合成一个单一、可工作的应用程序的集成测试非常有信心。为了调试目的,将每个组件隔离成单独测试的单位非常有帮助。

回忆

在本章中,我们探讨了与用 Python 编写的应用程序测试相关的多个主题。这些主题包括以下内容:

  • 我们描述了单元测试和测试驱动开发的重要性,作为确保我们的软件按预期工作的方法。

  • 我们最初使用的是unittest模块,因为它属于标准库,并且易于获取。它看起来有点啰嗦,但除此之外,它对于确认我们的软件是否正常工作非常有效。

  • pytest 工具需要单独安装,但它似乎生成的测试用例比使用 unittest 模块编写的测试用例要简单一些。更重要的是,固定(fixture)概念的复杂性让我们能够为各种场景创建测试用例。

  • mock 模块是 unittest 包的一部分,它允许我们创建模拟对象,以便更好地隔离正在测试的代码单元。通过隔离每一块代码,我们可以将注意力集中在确保其正常工作并具有正确的接口上。这使得组件的组合变得更加容易。

  • 代码覆盖率是一个有助于确保我们的测试充分的指标。仅仅遵循一个数字目标并不能替代思考,但它可以帮助确认在创建测试场景时已经尽力做到全面和细致。

我们已经使用各种工具查看了几种测试:

  • 使用unittest包或pytest包进行单元测试,通常使用Mock对象来隔离被测试的固定或单元。

  • 集成测试,也使用 unittestpytest,对更完整的组件集成集合进行测试。

  • 静态分析可以使用 mypy 来检查数据类型,以确保它们被正确使用。这是一种测试,用以确保软件是可接受的。还有其他类型的静态测试,并且可以使用像 flake8pylintpyflakes 这样的工具来进行这些额外的分析。

一些研究将揭示大量额外的测试类型。每种不同的测试类型都有其特定的目标或确认软件工作原理的方法。例如,性能测试旨在确定软件是否足够快,并且使用了可接受数量的资源。

我们无法强调测试的重要性。没有自动化测试,软件不能被认为是完整的,甚至不能使用。从测试用例开始,让我们能够以具体、可衡量、可实现、基于结果和可追踪的方式定义预期的行为:SMART。

练习

练习测试驱动开发。这是你的第一个练习。如果你开始一个新的项目,这样做会更简单,但如果你需要处理现有的代码,你可以从为每个新实现的功能编写测试开始。随着你对自动化测试越来越着迷,这可能会变得令人沮丧。旧的未经测试的代码将开始感觉僵化且紧密耦合,维护起来会变得不舒服;你可能会开始觉得所做的更改正在破坏代码,而你由于缺乏测试而无法得知。但如果你从小处着手,逐渐向代码库中添加测试,随着时间的推移,这会改进代码。测试代码多于应用代码的情况并不罕见!

因此,为了开始接触测试驱动开发,请启动一个新项目。一旦你开始欣赏其好处(你会的)并意识到编写测试所花费的时间很快就能通过更易于维护的代码得到回报,你就会想要开始为现有代码编写测试。这就是你应该开始这样做的时候,而不是在此之前。为已知工作的代码编写测试是无聊的。直到我们意识到我们以为正在工作的代码实际上有多糟糕,我们才可能对项目产生兴趣。

尝试使用内置的unittest模块和pytest编写相同的测试集。你更喜欢哪一个?unittest与其他语言的测试框架更为相似,而pytest则可以说是更符合 Python 风格。两者都允许我们轻松编写面向对象的测试,并使用面向对象的方式测试程序。

在我们的案例研究中,我们使用了pytest,但并未涉及任何使用unittest难以轻松测试的特性。尝试调整测试以使用跳过测试或固定装置。尝试各种设置和拆卸方法。哪种方式对你来说更自然?

尝试运行你编写的测试的覆盖率报告。你是否遗漏了测试任何代码行?即使你有 100%的覆盖率,你是否测试了所有可能的输入?如果你在做测试驱动开发,100%的覆盖率应该会相当自然地跟随,因为你会在满足测试的代码之前编写测试。然而,如果你是在为现有代码编写测试,更有可能存在未被测试的边缘情况。

将案例研究的代码覆盖率提高到 100%可能有些棘手,因为我们一直在跳来跳去,以几种不同的方式实现案例研究的某些方面。可能需要为案例研究类的不同实现编写几个类似的测试。创建可重用的测试设置可能会有所帮助,这样我们就可以在替代实现之间提供一致的测试。

在创建测试用例时,仔细思考一些有区别的值可能会有所帮助,例如以下这些,例如:

  • 当你期待满列表时却得到空列表

  • 负数、零、一或无穷大与正整数相比

  • 无法四舍五入到精确小数位的浮点数

  • 当你期望数字时却出现了字符串

  • 当你期望 ASCII 字符串时却得到了 Unicode 字符串

  • 当你期望有意义的东西时,无处不在的None

如果你的测试涵盖了这样的边缘情况,你的代码将会处于良好的状态。

距离计算的数值方法可能更适合使用假设项目进行测试。请在此处查看文档:hypothesis.readthedocs.io/en/latest/。我们可以使用假设项目来轻松确认在距离计算中操作数的顺序并不重要;也就是说,对于任何两个样本,distance(s1, s2) == distance(s2, s1)。通常包括假设测试来确认基本的k最近邻分类器算法在随机打乱的数据上也能正常工作;这将确保训练集中第一个或最后一个项目没有偏差。

摘要

我们终于涵盖了 Python 编程中最重要的话题:自动化测试。测试驱动开发被认为是最佳实践。标准库中的unittest模块提供了一个出色的开箱即用的测试解决方案,而pytest框架则提供了一些更 Pythonic 的语法。在测试中,我们可以使用 Mock 来模拟复杂的类。代码覆盖率可以给我们提供一个估计,即我们的代码中有多少部分被测试执行了,但它并不能告诉我们我们已经测试了正确的事情。

在下一章,我们将跳入一个完全不同的主题:并发。

第十四章:并发

并发是让计算机同时(或看似同时)做多项工作的艺术。从历史上看,这意味着邀请处理器每秒在多个任务之间切换多次。在现代系统中,这也可以字面意义上理解为在单独的处理器核心上同时做两件或多件事。

并发本身并不是一个面向对象的课题,但 Python 的并发系统提供了面向对象的接口,正如我们在整本书中所述。本章将向您介绍以下主题:

  • 线程

  • Multiprocessing

  • Futures

  • AsyncIO

  • 餐饮哲学家基准

本章的案例研究将探讨我们如何加快模型测试和超参数调整的方法。我们无法消除计算,但我们可以利用现代的多核计算机来缩短完成时间。

并发进程可能会变得复杂。基本概念相当简单,但当状态变化序列不可预测时,可能出现的错误却难以追踪。然而,对于许多项目来说,并发是获得所需性能的唯一途径。想象一下,如果网络服务器不能在另一个用户的请求完成之前响应用户的请求会怎样!我们将探讨如何在 Python 中实现并发,以及一些需要避免的常见陷阱。

Python 语言明确地按顺序执行语句。为了考虑语句的并发执行,我们需要暂时离开 Python。

并发处理背景

从概念上讲,可以通过想象一群彼此看不见的人正在尝试协作完成一项任务来理解并发处理。或许他们的视力受损或被屏幕阻挡,或者他们的工作空间有难以看穿的尴尬门道。然而,这些人可以互相传递代币、笔记和正在进行中的工作。

想象一下在美国大西洋沿岸的一个古老海滨度假城市里的一家小熟食店,其柜台布局有些尴尬。两位三明治厨师彼此看不见也听不见对方。虽然店主可以支付得起两位优秀的厨师,但他却负担不起超过一个服务托盘的费用。由于古老建筑的尴尬复杂性,厨师们实际上也看不到托盘。他们被迫低头到柜台下面去确认服务托盘是否放置妥当。然后,确认托盘在位后,他们小心翼翼地将他们的艺术品——包括泡菜和一些薯片——放置到托盘上。(他们看不到托盘,但他们是非常出色的厨师,能够完美地放置三明治、泡菜和薯片。)

然而,店主可以看见厨师们。确实,过路人可以观看厨师们的工作。这是一场精彩的表演。店主通常严格按照交替的方式将订单票分发到每位厨师手中。而且通常,唯一的服务托盘可以放置得恰到好处,使得三明治能够以优雅的姿态送达餐桌。正如我们所说,厨师们必须等待,直到他们的下一件作品温暖了某人的味蕾。

然后,有一天,其中一位厨师(我们暂且称他为迈克尔,但他的朋友们叫他莫)几乎完成了订单,但不得不跑到冷却器那里去拿更多大家喜欢的酸黄瓜。这延误了莫的准备时间,店主看到另一位厨师康斯坦丁似乎会比莫早完成几秒钟。尽管莫已经拿回了酸黄瓜,并且准备好了三明治,但店主却做了件令人尴尬的事。规则很明确:先检查,然后放置三明治。店里每个人都清楚这一点。当店主把托盘从莫的工作站下面的开口移到康斯坦丁工作站下面的开口时,莫就把他们的作品——本应是一个加了额外泡菜的令人愉悦的鲁本三明治——放入本应放置托盘的空位,结果它溅到了熟食店的地上,让所有人都感到尴尬。

那个检查托盘、然后放置三明治的万无一失的方法怎么会失灵呢?它已经经受住了许多忙碌午餐时间的考验,然而,在常规事件顺序中的一次小小的干扰,就引发了一团糟。在检查托盘和放置三明治之间的时间间隔,是主人进行状态改变的机会。

老板和厨师之间有一场竞争。防止意外状态变化是并发编程的基本设计问题。

一种解决方案可能是使用一个信号量——一个标志——来防止对托盘的意外更改。这是一种共享锁。每位厨师在装盘前都必须抓住这个标志;一旦他们拿到标志,他们就可以确信主人不会移动托盘,直到他们把标志放回厨师站之间的那个小标志架上。

并发工作需要一种方法来同步对共享资源的访问。大型现代计算机的一个基本功能是通过操作系统特性来管理并发,这些特性统称为内核。

较老且体积较小的计算机,在单个 CPU 中只有一个核心,必须交错处理所有任务。巧妙的协调使得它们看起来像是在同一时间工作。较新的多核计算机(以及大型多处理器计算机)实际上可以并发执行操作,这使得工作调度变得更加复杂。

我们有几种方法来实现并发处理:

  • 操作系统允许我们同时运行多个程序。Python 的 subprocess 模块为我们提供了对这些功能的便捷访问。multiprocessing 模块提供了一系列方便的工作方式。这相对容易启动,但每个程序都会被仔细隔离,与其他所有程序分开。它们如何共享数据呢?

  • 一些巧妙的软件库允许一个程序拥有多个并发操作线程。Python 的 threading 模块为我们提供了多线程的访问权限。这需要更复杂的入门步骤,并且每个线程都可以完全访问所有其他线程中的数据。我们如何协调对共享数据结构的更新呢?

此外,concurrent.futuresasyncio 提供了对底层库更易于使用的包装器。我们将从本章开始,通过查看 Python 使用 threading 库来允许在单个操作系统进程中并发执行许多事情。这很简单,但在处理共享数据结构时有一些挑战。

线程

线程是一系列可能被中断和恢复的 Python 字节码指令。其理念是创建独立的、并发的线程,以便在程序等待 I/O 操作完成时,计算可以继续进行。

例如,服务器可以在等待前一个请求的数据到达的同时开始处理一个新的网络请求。或者,一个交互式程序可能在等待用户按下键时渲染动画或执行计算。记住,虽然一个人每分钟可以输入超过 500 个字符,但计算机每秒可以执行数十亿条指令。因此,在快速输入时,即使在单个按键之间,也可能发生大量的处理。

从理论上讲,在程序内部管理所有这些活动之间的切换是可能的,但实际上要完全正确地做到这一点几乎是不可能的。相反,我们可以依赖 Python 和操作系统来处理那些棘手的切换部分,而我们在创建看起来可以独立但同时又同时运行的对象。这些对象被称为线程。让我们来看一个基本的例子。我们将从线程处理的本质定义开始,如下面的类所示:

class Chef(Thread):
    def __init__(self, name: str) -> None:
        super().__init__(name=name)
        self.total = 0
    def get_order(self) -> None:
        self.order = THE_ORDERS.pop(0)
    def prepare(self) -> None:
        """Simulate doing a lot of work with a BIG computation"""
        start = time.monotonic()
        target = start + 1 + random.random()
        for i in range(1_000_000_000):
            self.total += math.factorial(i)
            if time.monotonic() >= target:
                break
        print(
            f"{time.monotonic():.3f} {self.name} made {self.order}")
    def run(self) -> None:
        while True:
            try:
                self.get_order()
                self.prepare()
            except IndexError:
                break  # No more orders 

我们运行中的应用程序中的线程必须扩展Thread类并实现run方法。由run方法执行的任何代码都是一个独立的处理线程,独立调度。我们的线程依赖于一个全局变量THE_ORDERS,它是一个共享对象:

import math
import random
from threading import Thread, Lock
import time
THE_ORDERS = [
    "Reuben",
    "Ham and Cheese",
    "Monte Cristo",
    "Tuna Melt",
    "Cuban",
    "Grilled Cheese",
    "French Dip",
    "BLT",
] 

在这个例子中,我们定义了订单为一个简单的、固定的值列表。在一个更大的应用中,我们可能会从套接字或队列对象中读取这些值。下面是启动程序运行的顶层程序:

Mo = Chef("Michael")
Constantine = Chef("Constantine")
if __name__ == "__main__":
    random.seed(42)
    Mo.start()
    Constantine.start() 

这将创建两个线程。新的线程不会开始运行,直到我们在对象上调用start()方法。当两个线程开始运行后,它们都会从订单列表中弹出一个值,然后开始执行大量计算,并最终报告它们的状态。

输出看起来像这样:

1.076 Constantine made Ham and Cheese
1.676 Michael made Reuben
2.351 Constantine made Monte Cristo
2.899 Michael made Tuna Melt
4.094 Constantine made Cuban
4.576 Michael made Grilled Cheese
5.664 Michael made BLT
5.987 Constantine made French Dip 

注意,三明治并不是按照THE_ORDERS列表中呈现的顺序完成的。每位厨师都以自己的(随机化)速度工作。改变种子值将改变时间,并可能略微调整顺序。

这个例子中重要的是线程正在共享数据结构,而并发性是由线程的巧妙调度所创造的,这种调度使得两个厨师线程的工作得以交错进行,从而产生并发的错觉。

在这个小例子中,对共享数据结构的唯一更新是从列表中弹出。如果我们创建自己的类并实现更复杂的状态变化,我们可能会发现使用线程时存在许多有趣且令人困惑的问题。

线程的许多问题

如果适当注意管理共享内存,线程可能是有用的,但现代的 Python 程序员由于几个原因往往避免使用它们。正如我们将看到的,还有其他方法可以编写并发编程,这些方法正在获得 Python 社区的更多关注。在转向多线程应用程序的替代方案之前,让我们先讨论一些潜在的问题。

共享内存

线程的主要问题也是它们的最大优势。线程可以访问所有进程内存以及所有变量。对共享状态的忽视也容易导致不一致性。

你是否遇到过一间房里只有一个开关,但有两个不同的人同时去打开它的情景?每个人(线程)都期望他们的行为能够将灯(一个变量)打开,但最终的结果(灯的状态)是关闭的,这与他们的期望不符。现在想象一下,如果这两个线程正在处理银行账户之间的资金转移或者管理车辆的巡航控制。

在线程编程中,解决这个问题的方法是同步对任何读取或(尤其是)写入共享变量的代码的访问。Python 的threading库提供了Lock类,可以通过with语句来创建一个上下文,在这个上下文中,单个线程可以访问更新共享对象。

同步解决方案在一般情况下是有效的,但很容易忘记将其应用于特定应用程序中的共享数据。更糟糕的是,由于不当使用同步而导致的错误很难追踪,因为线程执行操作的顺序是不一致的。我们无法轻易地重现错误。通常,最安全的方法是强制线程之间通过使用已经适当使用锁的轻量级数据结构来进行通信。Python 提供了 queue.Queue 类来实现这一点;多个线程可以写入队列,而单个线程则消费结果。这为我们提供了一个整洁、可重用、经过验证的技术,用于多个线程共享数据结构。multiprocessing.Queue 类几乎相同;我们将在本章的 多进程 部分讨论这一点。

在某些情况下,这些缺点可能被允许共享内存的一个优点所抵消:它速度快。如果多个线程需要访问一个巨大的数据结构,共享内存可以快速提供这种访问。然而,在 Python 中,两个在不同的 CPU 核心上运行的线程同时进行计算是不可能的,这一点通常抵消了这一优势。这把我们带到了我们关于线程的第二个问题。

全局解释器锁

为了高效管理内存、垃圾回收以及在本地库中对机器代码的调用,Python 有一个全局解释器锁,或称为GIL。这个锁无法关闭,并且意味着线程调度受到 GIL 的限制,防止任何两个线程同时进行计算;工作被人为地交错。当一个线程发起操作系统请求——例如,访问磁盘或网络——一旦线程开始等待操作系统请求完成,GIL 就会被释放。

GIL(全局解释器锁)常受到批评,主要是一些不了解其是什么或其对 Python 带来的好处的人。虽然它可能会干扰多线程计算密集型编程,但对于其他类型的工作负载的影响通常很小。当面对计算密集型算法时,切换到使用dask包来管理处理可能会有所帮助。有关此替代方案的更多信息,请参阅dask.org。这本书《使用 Dask 在 Python 中进行可扩展数据分析》也可能很有参考价值。

虽然 GIL 可能是大多数人使用的 Python 参考实现中的一个问题,但在 IronPython 中可以选择性禁用。有关如何在 IronPython 中释放 GIL 以进行计算密集型处理的详细信息,请参阅 《IronPython 烹饪书》

线程开销

与我们稍后将要讨论的其他异步方法相比,线程的一个额外限制是维护每个线程的成本。每个线程都需要占用一定量的内存(在 Python 进程和操作系统内核中)来记录该线程的状态。在各个线程之间切换也会消耗(少量)CPU 时间。这项工作在没有额外编码的情况下无缝进行(我们只需调用start(),其余的都会被处理),但这项工作仍然需要在某个地方发生。

这些成本可以通过重用线程来执行多个任务,从而在更大的工作量中摊销。Python 提供了 ThreadPool 功能来处理这个问题。它的行为与我们将很快讨论的 ProcessPool 完全相同,所以让我们将这次讨论推迟到本章的后面部分。

在下一节中,我们将探讨多线程的主要替代方案。multiprocessing模块使我们能够与操作系统级别的子进程进行工作。

多进程

在单个操作系统进程中存在线程;这就是它们可以共享访问公共对象的原因。我们也可以在进程级别进行并发计算。与线程不同,单独的进程不能直接访问其他进程设置的变量。这种独立性是有帮助的,因为每个进程都有自己的全局解释器锁(GIL)和自己的私有资源池。在现代的多核处理器上,一个进程可能有它自己的核心,允许与其他核心进行并发工作。

multiprocessing API 最初是为了模仿threading API 而设计的。然而,multiprocessing接口已经发展演变,在 Python 的最近版本中,它更稳健地支持更多功能。multiprocessing库是为了在需要并行执行 CPU 密集型任务且可用多个核心时设计的。当进程的大部分时间都在等待 I/O(例如,网络、磁盘、数据库或键盘)时,multiprocessing并不那么有用,但对于并行计算来说,这是可行的方法。

multiprocessing模块会启动新的操作系统进程来完成工作。这意味着每个进程都会运行一个独立的 Python 解释器副本。让我们尝试使用与threading API 提供的类似构造来并行化一个计算密集型操作,如下所示:

from multiprocessing import Process, cpu_count
import time
import os
class MuchCPU(Process):
    def run(self) -> None:
        print(f"OS PID {os.getpid()}")
        s = sum(
            2*i+1 for i in range(100_000_000)
        )
if __name__ == "__main__":
    workers = [MuchCPU() for f in range(cpu_count())]
    t = time.perf_counter()
    for p in workers:
        p.start()
    for p in workers:
        p.join()
    print(f"work took {time.perf_counter() - t:.3f} seconds") 

这个例子只是让 CPU 计算一亿个奇数的总和。你可能不会认为这是一项有用的工作,但它可以在寒冷的日子里为你的笔记本电脑预热!

API 应该是熟悉的;我们实现了一个 Process 的子类(而不是 Thread),并实现了一个 run 方法。这个方法在执行一些激烈(尽管可能是错误的)工作之前,会打印出操作系统的 进程 IDPID),这是分配给机器上每个进程的唯一数字。

请特别注意模块级代码周围的 if __name__ == "__main__": 保护措施,这可以防止在模块被导入时运行,而不是作为程序运行。这通常是一种良好的实践,但在使用 multiprocessing 模块时,这一点至关重要。在幕后,multiprocessing 模块可能需要在每个新进程中重新导入我们的应用程序模块,以便创建类并执行 run() 方法。如果我们允许整个模块在那个时刻执行,它将开始递归地创建新进程,直到操作系统耗尽资源,导致您的计算机崩溃。

这个演示为我们的机器上的每个处理器核心构建一个进程,然后启动并加入每个进程。在 2020 年的 2 GHz 四核英特尔酷睿 i5 的 MacBook Pro 上,输出如下:

% python src/processes_1.py
OS PID 15492
OS PID 15493
OS PID 15494
OS PID 15495
OS PID 15497
OS PID 15496
OS PID 15498
OS PID 15499
work took 20.711 seconds 

前八行是打印在每个 MuchCPU 实例内部的进程 ID。最后一行显示,一亿次的求和运算大约需要 20 秒。在这 20 秒内,所有八个核心都运行在 100% 的负载,风扇嗡嗡作响,试图散发热量。

如果在MuchCPU中我们使用threading.Thread而不是multiprocessing.Process,输出将如下所示:

% python src/processes_1.py
OS PID 15772
OS PID 15772
OS PID 15772
OS PID 15772
OS PID 15772
OS PID 15772
OS PID 15772
OS PID 15772
work took 69.316 seconds 

这次,线程是在同一个操作系统进程中运行的,运行时间长达三倍。显示结果显示没有哪个核心特别繁忙,这表明工作正在各个核心之间被转移。总体上的减速是 GIL(全局解释器锁)在处理密集型计算时的开销。

我们可能预计单进程版本至少是八进程版本的八倍长。缺乏一个简单的乘数表明,在 Python 处理低级指令、操作系统调度程序以及硬件本身的过程中涉及了多个因素。这表明预测是困难的,最好计划运行多个性能测试,使用多种软件架构。

启动和停止单个进程实例涉及很多开销。最常见的情况是拥有一个工作者池并将任务分配给它们。我们将在下一部分探讨这个问题。

多进程池

因为操作系统会细致地将每个进程分开,所以进程间通信变得非常重要。我们需要在这些分开的进程之间传递数据。一个真正常见的例子是有一个进程写入一个文件,另一个进程可以读取这个文件。当两个进程同时读写文件时,我们必须确保读取者正在等待写入者生成数据。操作系统的管道结构可以完成这个任务。在 shell 中,我们可以写ps -ef | grep python并将ps命令的输出传递给grep命令。这两个命令是并发运行的。对于 Windows PowerShell 用户,有类似类型的管道处理,使用不同的命令名称。(有关示例,请参阅docs.microsoft.com/en-us/powershell/scripting/learn/ps101/04-pipelines?view=powershell-7.1

multiprocessing 包提供了一些实现进程间通信的额外方法。池可以无缝地隐藏数据在进程间移动的方式。使用池看起来就像是一个函数调用:你将数据传递给一个函数,它在另一个或多个进程中执行,当工作完成时,返回一个值。理解支持这一功能的工作量是很重要的:一个进程中的对象被序列化并通过操作系统进程管道传递。然后,另一个进程从管道中检索数据并反序列化它。所需的工作在子进程中完成,并产生一个结果。结果被序列化并通过管道传递回来。最终,原始进程反序列化并返回它。总的来说,我们将这种序列化、传输和反序列化称为数据的序列化。有关更多信息,请参阅第九章字符串、序列化和文件路径

进程间通信的序列化需要时间和内存。我们希望以最小的序列化成本完成尽可能多的有用计算。理想的混合比例取决于交换的对象的大小和复杂性,这意味着不同的数据结构设计将具有不同的性能水平。

性能预测很难进行。确保并发设计有效,对分析应用程序至关重要。

拥有这些知识,让所有这些机器运作的代码竟然出奇地简单。让我们来看一下计算一组随机数的所有质因数的问题。这是各种密码学算法的常见部分(更不用说对这些算法的攻击了!)。

因数分解某些加密算法使用的 232 位数字需要数月甚至数年的处理能力。以下实现虽然可读,但效率极低;即使因数分解一个 100 位的数字也需要数年。这没关系,因为我们想看到它在因数分解 9 位数字时消耗大量的 CPU 时间:

from __future__ import annotations
from math import sqrt, ceil
import random
from multiprocessing.pool import Pool
def prime_factors(value: int) -> list[int]:
    if value in {2, 3}:
        return [value]
    factors: list[int] = []
    for divisor in range(2, ceil(sqrt(value)) + 1):
        quotient, remainder = divmod(value, divisor)
        if not remainder:
            factors.extend(prime_factors(divisor))
            factors.extend(prime_factors(quotient))
            break
    else:
        factors = [value]
    return factors
if __name__ == "__main__":
    to_factor = [
        random.randint(100_000_000, 1_000_000_000)
        for i in range(40_960)
    ]
    with Pool() as pool:
        results = pool.map(prime_factors, to_factor)
    primes = [
        value
        for value, factor_list in zip(to_factor, results)
            if len(factor_list) == 1
    ]
    print(f"9-digit primes {primes}") 

让我们专注于并行处理方面,因为计算因子的暴力递归算法已经很清晰了。我们创建了一个包含 40,960 个单独数字的to_factor列表。然后我们构建了一个多进程pool实例。

默认情况下,此池为运行在其上的机器中的每个 CPU 核心创建一个单独的进程。

pool 的 map() 方法接受一个函数和一个可迭代对象。池将可迭代对象中的每个值序列化,并将其传递给池中可用的工作进程,该进程在该值上执行函数。当该进程完成其工作后,它将结果的因子列表序列化,并将其传递回池。然后,如果池中还有更多工作可用,工作进程将承担下一项工作。

一旦池中的所有工作者完成处理(这可能需要一些时间),results 列表将被返回到原始进程,该进程一直在耐心地等待所有这些工作完成。map() 的结果将与请求的顺序相同。这使得使用 zip() 来匹配原始值与计算出的质因数变得合理。

通常使用类似的 map_async() 方法更为有用,即使进程仍在运行,该方法也会立即返回。在这种情况下,results 变量不会是一个值的列表,而是一个在未来客户端调用 results.get() 时返回值列表的合约(或协议或义务)。这个未来对象还具有如 ready()wait() 等方法,允许我们检查是否所有结果都已就绪。这适用于完成时间高度可变的过程处理。

或者,如果我们事先不知道所有想要得到结果的值,我们可以使用apply_async()方法来排队一个单独的任务。如果池中有一个进程尚未工作,它将立即启动;否则,它将保留该任务,直到有可用的空闲工作进程。

池也可以被关闭;它们拒绝接受任何进一步的任务,但会继续处理队列中当前的所有任务。它们还可以被终止,这比关闭更进一步,拒绝启动队列中仍然存在的任何任务,尽管当前正在运行的任务仍然被允许完成。

对工人数量的限制有很多,包括以下内容:

  • 只有cpu_count()个进程可以同时进行计算;任何数量的进程都可以等待。如果工作负载是 CPU 密集型的,那么增加工作者池的大小并不会使计算更快。然而,如果工作负载涉及大量的输入/输出,那么一个较大的工作者池可能会提高完成工作的速度。

  • 对于非常大的数据结构,可能需要减少池中的工作者数量,以确保内存得到有效利用。

  • 进程间的通信代价高昂;易于序列化的数据是最好的策略。

  • 创建新的流程需要一定的时间;一个固定大小的池子有助于最小化这种成本的影响。

多进程池为我们提供了巨大的计算能力,而我们只需做相对较少的工作。我们需要定义一个可以执行并行计算的函数,并且需要使用multiprocessing.Pool类的实例将参数映射到该函数上。

在许多应用中,我们需要做的不仅仅是将参数值映射到复杂的结果。对于这些应用,简单的 poll.map() 可能就不够用了。对于更复杂的数据流,我们可以利用显式的待处理工作和计算结果队列。接下来,我们将探讨如何创建队列网络。

队列

如果我们需要对进程间的通信有更多控制,queue.Queue 数据结构非常有用。它提供了从一个进程向一个或多个其他进程发送消息的几种变体。任何可序列化的对象都可以发送到Queue中,但请记住,序列化可能是一个昂贵的操作,因此请保持这些对象小巧。为了说明队列,让我们构建一个小型的文本内容搜索引擎,该搜索引擎将所有相关条目存储在内存中。

这个特定的搜索引擎会并行扫描当前目录下的所有文件。为 CPU 上的每个核心构建一个进程。每个进程被指示将一些文件加载到内存中。让我们看看执行加载和搜索功能的函数:

from __future__ import annotations
from pathlib import Path
from typing import List, Iterator, Optional, Union, TYPE_CHECKING
if TYPE_CHECKING:
    Query_Q = Queue[Union[str, None]]
    Result_Q = Queue[List[str]]
def search(
        paths: list[Path], 
        query_q: Query_Q, 
        results_q: Result_Q
) -> None:
    print(f"PID: {os.getpid()}, paths {len(paths)}")
    lines: List[str] = []
    for path in paths:
        lines.extend(
            l.rstrip() for l in path.read_text().splitlines())
    while True:
        if (query_text := query_q.get()) is None:
            break
        results = [l for l in lines if query_text in l]
        results_q.put(results) 

记住,search() 函数是在一个单独的进程中运行的(实际上,它是在 cpu_count() 个单独的进程中运行的),与创建队列的主进程是分开的。每个这些进程都是以一个 pathlib.Path 对象列表和一个 multiprocessing.Queue 对象列表启动的,一个用于接收查询,另一个用于发送输出结果。这些队列会自动将队列中的数据序列化,并通过管道传递给子进程。这两个队列在主进程中设置,并通过管道传递到子进程中的搜索函数内部。

类型提示反映了mypy希望了解每个队列中数据结构的方式。当TYPE_CHECKINGTrue时,意味着mypy正在运行,并且需要足够的细节来确保应用程序中的对象与每个队列中对象的描述相匹配。当TYPE_CHECKINGFalse时,这是应用程序的普通运行时,无法提供队列消息的结构细节。

search() 函数执行两个不同的操作:

  1. 当它启动时,它会打开并读取列表中的所有Path对象所提供的文件。这些文件中的每一行文本都会累积到lines列表中。这种准备相对昂贵,但它只执行一次。

  2. while循环语句是搜索的主要事件处理循环。它使用query_q.get()从其队列中获取一个请求。它搜索行。它使用results_q.put()将响应放入结果队列。

while循环语句具有基于队列处理的典型设计模式。该过程将从待执行工作的队列中获取一个值,执行工作,然后将结果放入另一个队列。我们可以将非常庞大和复杂的问题分解为处理步骤和队列,以便工作可以并行执行,从而在更短的时间内产生更多结果。这种技术还允许我们调整处理步骤和工人的数量,以最大限度地利用处理器。

应用程序的主要部分构建了这个工作者和他们的队列的池。我们将遵循外观设计模式(更多信息请参阅第十二章高级设计模式)。这里的想法是定义一个类,DirectorySearch,将队列和工作进程池封装成一个单一的对象。

此对象可以设置队列和工作者,然后应用程序可以通过发布查询和消费回复与它们交互。

from __future__ import annotations
from fnmatch import fnmatch
import os
class DirectorySearch:
    def __init__(self) -> None:
        self.query_queues: List[Query_Q]
        self.results_queue: Result_Q
        self.search_workers: List[Process]
    def setup_search(
        self, paths: List[Path], cpus: Optional[int] = None) -> None:
        if cpus is None:
            cpus = cpu_count()
        worker_paths = [paths[i::cpus] for i in range(cpus)]
        self.query_queues = [Queue() for p in range(cpus)]
        self.results_queue = Queue()
        self.search_workers = [
            Process(
                target=search, args=(paths, q, self.results_queue))
            for paths, q in zip(worker_paths, self.query_queues)
        ]
        for proc in self.search_workers:
            proc.start()
    def teardown_search(self) -> None:
        # Signal process termination
        for q in self.query_queues:
            q.put(None)
        for proc in self.search_workers:
            proc.join()
    def search(self, target: str) -> Iterator[str]:
        for q in self.query_queues:
            q.put(target)
        for i in range(len(self.query_queues)):
            for match in self.results_queue.get():
                yield match 

setup_search() 方法准备工作子进程。[i::cpus] 切片操作使我们能够将这个列表分成若干个大小相等的部分。如果 CPU 的数量是 8,步长将是 8,我们将使用从 0 到 7 的 8 个不同的偏移值。我们还构建了一个 Queue 对象列表,用于将数据发送到每个工作进程。最后,我们构建了一个 单个 的结果队列。这个队列被传递给所有的工作子进程。每个子进程都可以将数据放入队列,它将在主进程中汇总。

一旦创建了队列并启动了工作者,search() 方法会一次性将目标提供给所有工作者。然后他们可以开始检查各自的数据集合以发出答案。

由于我们需要搜索相当多的目录,我们使用生成器函数all_source()来定位给定base目录下所有的*.py Path对象。下面是查找所有源文件的函数:

def all_source(path: Path, pattern: str) -> Iterator[Path]:
    for root, dirs, files in os.walk(path):
        for skip in {".tox", ".mypy_cache", "__pycache__", ".idea"}:
            if skip in dirs:
                dirs.remove(skip)
        yield from (
            Path(root) / f for f in files if fnmatch(f, pattern)) 

all_source() 函数使用 os.walk() 函数来检查目录树,拒绝包含我们不希望查看的文件的目录。此函数使用 fnmatch 模块将文件名与 Linux shell 使用的通配符模式进行匹配。例如,我们可以使用模式参数 '*.py' 来查找所有以 .py 结尾的文件。这为 DirectorySearch 类的 setup_search() 方法提供了种子。

DirectorySearch 类的 teardown_search() 方法将一个特殊的终止值放入每个队列中。记住,每个工作进程是一个独立的过程,它在 search() 函数内部执行 while 语句并从请求队列中读取。当它读取到一个 None 对象时,它将退出 while 语句并退出函数。然后我们可以使用 join() 来收集所有子进程,礼貌地清理。(如果我们不执行 join(),一些 Linux 发行版可能会留下“僵尸进程”——因为父进程崩溃而没有正确重新连接到父进程的子进程;这些进程消耗系统资源,通常需要重启。)

现在让我们看看实现搜索功能的具体代码:

if __name__ == "__main__":
    ds = DirectorySearch()
    base = Path.cwd().parent
    all_paths = list(all_source(base, "*.py"))
    ds.setup_search(all_paths)
    for target in ("import", "class", "def"):
        start = time.perf_counter()
        count = 0
        for line in ds.search(target):
            **# print(line)**
            count += 1
        milliseconds = 1000*(time.perf_counter()-start)
        print(
            f"Found {count} {target!r} in {len(all_paths)} files "
            f"in {milliseconds:.3f}ms"
        )
    ds.teardown_search() 

此代码创建了一个DirectorySearch对象,命名为ds,并通过base = Path.cwd().parent从当前工作目录的父目录开始提供所有源路径。一旦工作者准备就绪,ds对象将执行对几个常见字符串的搜索,包括"import""class""def"。请注意,我们已注释掉显示有用结果的print(line)语句。目前,我们关注的是性能。初始文件读取只需几分之一秒即可开始。然而,一旦所有文件都读取完毕,搜索所需的时间将显著增加。在一台装有 134 个源代码文件的 MacBook Pro 上,输出看起来如下:

python src/directory_search.py
PID: 36566, paths 17
PID: 36567, paths 17
PID: 36570, paths 17
PID: 36571, paths 17
PID: 36569, paths 17
PID: 36568, paths 17
PID: 36572, paths 16
PID: 36573, paths 16
Found 579 'import' in 134 files in 111.561ms
Found 838 'class' in 134 files in 1.010ms
Found 1138 'def' in 134 files in 1.224ms 

搜索 "import" 花了大约 111 毫秒(0.111 秒)。为什么这个搜索速度比其他两个搜索慢这么多?这是因为当第一个请求被放入队列时,search() 函数仍在读取文件。第一个请求的性能反映了将文件内容加载到内存中的一次性启动成本。接下来的两个请求每个大约运行 1 毫秒。这太令人惊讶了!在只有几行 Python 代码的笔记本电脑上,几乎可以达到每秒 1,000 次搜索。

这个在工作者之间传递数据的队列示例是一个单主机版本的分布式系统可能成为的样子。想象一下,搜索请求被发送到多个主机计算机,然后重新组合。现在,假设你能够访问谷歌数据中心中的计算机集群,你可能就会明白为什么它们能够如此快速地返回搜索结果了!

我们在这里不会讨论它,但multiprocessing模块包含一个管理类,可以移除前面代码中的许多样板代码。甚至有一个版本的multiprocessing.Manager可以管理远程系统上的子进程,以构建一个基本的分布式应用程序。如果你对此感兴趣并想进一步了解,请查看 Python 的multiprocessing文档。

多进程的问题

与线程一样,多进程也有问题,其中一些我们已经讨论过了。进程间共享数据是昂贵的。正如我们讨论过的,所有进程间的通信,无论是通过队列、操作系统管道,甚至是共享内存,都需要序列化对象。过度的序列化可能会主导处理时间。通过限制序列化到共享内存的初始设置,共享内存对象可以有所帮助。当需要在进程间传递相对较小的对象,并且每个对象都需要完成大量工作时,多进程工作得最好。

使用共享内存可以避免重复序列化和反序列化的成本。可以共享的 Python 对象类型存在许多限制。共享内存有助于提高性能,但也可能导致 Python 对象看起来更加复杂。

多进程的另一个主要问题是,就像线程一样,很难确定变量或方法是在哪个进程中访问的。在多进程中,工作进程会从父进程继承大量数据。这不是共享的,而是一次性复制。子进程可以被赋予一个映射或列表的副本,并修改对象。父进程将看不到子进程修改的效果。

多进程的一个大优点是进程之间的绝对独立性。我们不需要仔细管理锁,因为数据不共享。此外,内部操作系统对打开文件数量的限制是在进程级别分配的;我们可以拥有大量资源密集型进程。

当设计并发应用程序时,重点是最大化 CPU 的使用,尽可能在尽可能短的时间内完成更多工作。在如此多的选择面前,我们总是需要检查问题,以确定众多可用解决方案中哪一个最适合该问题。

并行处理的概念过于宽泛,以至于没有一种正确的方式来执行它。每个独特的问题都有一个最佳解决方案。编写代码时,重要的是要使其能够调整、微调和优化。

我们已经探讨了 Python 中并行的两种主要工具:线程和进程。线程存在于单个操作系统进程中,共享内存和其他资源。进程之间是独立的,这使得进程间通信成为必要的开销。这两种方法都适用于概念上有一组并发的工作者等待工作并在未来的某个不可预测的时间提供结果。这种结果在未来可用的抽象正是塑造了 concurrent.futures 模块的基础。我们接下来将探讨这一点。

期货

让我们开始探讨一种更异步的实现并发的方式。一个“未来”或“承诺”的概念是描述并发工作的一个便捷的抽象。一个未来对象封装了一个函数调用。这个函数调用在后台,在一个线程或一个单独的进程中运行。future对象有方法来检查计算是否完成以及获取结果。我们可以将其视为一个结果将在未来到达的计算,同时我们可以等待结果的过程中做其他事情。

查看更多背景信息,请访问hub.packtpub.com/asynchronous-programming-futures-and-promises/

在 Python 中,concurrent.futures模块根据我们需要哪种并发性来封装multiprocessingthreading。一个未来(future)并不能完全解决意外改变共享状态的问题,但使用未来(future)允许我们以这样的方式结构化我们的代码,使得在出现问题时更容易追踪问题的原因。

期货可以帮助管理不同线程或进程之间的边界。类似于多进程池,它们对于调用和响应类型的交互非常有用,在这种交互中,处理可以在另一个线程(或进程)中进行,然后在未来的某个时刻(毕竟,它们的名字很贴切),你可以请求结果。它是对多进程池和线程池的封装,但它提供了一个更干净的 API,并鼓励编写更好的代码。

让我们看看另一个更复杂的文件搜索和分析示例。在上一个部分,我们实现了一个 Linux grep命令的版本。这次,我们将创建一个简单的find命令版本,其中包含对 Python 源代码的巧妙分析。我们将从分析部分开始,因为它是我们需要同时完成的工作的核心:

class ImportResult(NamedTuple):
    path: Path
    imports: Set[str]
    @property
    def focus(self) -> bool:
        return "typing" in self.imports
class ImportVisitor(ast.NodeVisitor):
    def __init__(self) -> None:
        self.imports: Set[str] = set()
    def visit_Import(self, node: ast.Import) -> None:
        for alias in node.names:
            self.imports.add(alias.name)
    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        if node.module:
            self.imports.add(node.module)
def find_imports(path: Path) -> ImportResult:
    tree = ast.parse(path.read_text())
    iv = ImportVisitor()
    iv.visit(tree)
    return ImportResult(path, iv.imports) 

我们在这里定义了一些东西。我们从一个命名元组开始,ImportResult,它将一个Path对象和一组字符串绑定在一起。它有一个属性,focus,用于在字符串集中查找特定的字符串,"typing"。我们很快就会看到这个字符串为什么如此重要。

ImportVisitor 类是使用标准库中的 ast 模块构建的。抽象语法树AST)是解析后的源代码,通常来自一种正式的编程语言。毕竟,Python 代码只是一堆字符;Python 代码的 AST 将文本分组为有意义的语句和表达式、变量名和运算符,这些都是语言的语法组成部分。访问者有一个方法来检查解析后的代码。我们为 NodeVisitor 类的两个方法提供了覆盖,因此我们只会访问两种导入语句:import xfrom x import y。每个 node 数据结构的工作细节略超出了这个示例的范围,但标准库中的 ast 模块文档描述了每个 Python 语言结构的独特结构。

find_imports() 函数读取一些源代码,解析 Python 代码,遍历 import 语句,然后返回一个包含原始 Path 和由访问者找到的名称集合的 ImportResult。这在许多方面——都比简单的 "import" 模式匹配要好得多。例如,使用 ast.NodeVisitor 将会跳过注释并忽略字符字符串字面量内的文本,这两项任务用正则表达式来做是比较困难的。

find_imports() 函数并没有什么特别之处,但请注意它并没有访问任何全局变量。所有与外部环境的交互都是传递给函数或从函数返回的。这并不是一个技术要求,但这是在用 futures 编程时保持你的大脑在头骨内的最佳方式。

我们虽然想要处理成百上千个文件,分布在几十个目录中。最佳的方法是同时运行大量这样的任务,让我们的 CPU 核心被大量的计算所堵塞。

def main() -> None:
    start = time.perf_counter()
    base = Path.cwd().parent
    with futures.ThreadPoolExecutor(24) as pool:
        analyzers = [
           pool.submit(find_imports, path) 
           for path in all_source(base, "*.py")
        ]
        analyzed = (
            worker.result() 
            for worker in futures.as_completed(analyzers)
        )
    for example in sorted(analyzed):
        print(
            f"{'->' if example.focus else '':2s} " 
            f"{example.path.relative_to(base)} {example.imports}"
        )
    end = time.perf_counter()
    rate = 1000 * (end - start) / len(analyzers)
    print(f"Searched {len(analyzers)} files at {rate:.3f}ms/file") 

我们正在利用本章前面“队列”部分展示的相同all_source()函数;这需要一个起始搜索的基础目录,以及一个模式,例如"*.py",以找到所有具有.py扩展名的文件。我们创建了一个ThreadPoolExecutor,分配给pool变量,包含二十多个工作线程,都在等待任务。我们在analyzers对象中创建了一个Future对象列表。这个列表是通过列表推导式应用pool.submit()方法到我们的搜索函数find_imports()以及all_source()的输出中的Path创建的。

池中的线程将立即开始处理提交的任务列表。随着每个线程完成工作,它会在Future对象中保存结果,并继续取一些工作来做。

同时,在前景中,应用程序使用生成器表达式来评估每个 Future 对象的 result() 方法。请注意,这些 Future 对象是通过 futures.as_completed() 生成器进行访问的。该函数开始提供完整的 Future 对象,一旦它们可用。这意味着结果可能不会按照最初提交的顺序出现。还有其他访问 Future 对象的方法;例如,我们可以等待所有对象都完成,然后再按照提交的顺序访问它们,如果这是重要的。

我们从每个 Future 中提取结果。从类型提示中,我们可以看到这将是一个带有 Path 和一系列字符串的 ImportResult 对象;这些是导入模块的名称。我们可以对结果进行排序,这样文件就会以某种合理的顺序显示。

在 MacBook Pro 上,处理每个文件大约需要 1.689 毫秒(0.001689 秒)。24 个独立的线程可以轻松地在一个进程中运行,而不会对操作系统造成压力。增加线程数量对运行时间的影响并不显著,这表明任何剩余的瓶颈不是并发计算,而是对目录树进行初始扫描和创建线程池的过程。

ImportResult 类的 focus 功能是什么?为什么 typing 模块是特殊的?在本书的开发过程中,每当 mypy 发布新版本时,我们需要回顾每一章的类型提示。将模块分为那些需要仔细检查的和那些不需要修订的是很有帮助的。

这就是开发基于未来的 I/O 密集型应用所需的所有内容。在底层,它使用的是我们之前已经讨论过的相同线程或进程 API,但它提供了一个更易于理解的接口,并使得查看并发运行函数之间的边界变得更加容易(只是不要尝试在 future 内部访问全局变量!)。

在没有适当同步的情况下访问外部变量可能会导致一个称为竞态条件的问题。例如,想象有两个并发写入尝试增加一个整数计数器。它们同时开始,并且两个线程都读取共享变量的当前值为 5。一个线程在竞争中先到达;它增加值并写入 6。另一个线程随后到达;它增加变量原来的值,也写入 6。但如果两个进程都在尝试增加一个变量,预期的结果应该是它增加 2,所以结果应该是 7。

现代智慧认为,避免这样做最简单的方法是尽可能将状态保持为私有,并通过已知安全的结构,如队列或未来对象,来共享它们。

对于许多应用,concurrent.futures模块是设计 Python 代码时的起点。对于非常复杂的情况,较低级别的threadingmultiprocessing模块提供了一些额外的结构。

使用 run_in_executor() 允许应用程序利用 concurrent.futures 模块的 ProcessPoolExecutorThreadPoolExecutor 类将工作分配给多个进程或多个线程。这为整洁、人性化的 API 提供了很大的灵活性。

在某些情况下,我们并不真的需要并发进程。在某些情况下,我们只需要能够来回切换,在等待数据时进行等待,当数据可用时进行计算。Python 的 async 特性,包括 asyncio 模块,可以在单个线程内交错处理。我们将在下一节中探讨并发主题的这种变体。

异步 IO

AsyncIO 是 Python 并发编程的当前最佳实践。它结合了未来(futures)和事件循环(event loop)的概念与协程(coroutines)。结果是尽可能优雅且易于理解,尤其是在编写响应式应用程序时,这些应用程序似乎不会浪费时间等待输入。

为了使用 Python 的 async 功能,一个 协程 是一个等待事件发生的函数,同时也可以为其他协程提供事件。在 Python 中,我们使用 async def 来实现协程。带有 async 的函数必须在 事件循环 的上下文中工作,该循环在等待事件的协程之间切换控制。我们将看到一些使用 await 表达式的 Python 构造,以展示事件循环可以切换到另一个 async 函数的情况。

认识到异步操作是交错进行的,而不是通常意义上的并行,这一点至关重要。最多只有一个协程处于控制状态并进行处理,而其他所有协程都在等待事件发生。交错的概念被描述为协作多任务处理:一个应用程序可以在处理数据的同时等待下一个请求消息的到来。当数据可用时,事件循环可以将控制权传递给其中一个等待的协程。

AsyncIO 偏向于网络 I/O。大多数网络应用程序,尤其是在服务器端,花费大量时间等待从网络中接收数据。AsyncIO 可以比单独为每个客户端处理一个线程更高效;这样一些线程可以工作,而其他线程则等待。问题是这些线程会消耗内存和其他资源。当数据可用时,AsyncIO 使用协程来交错处理周期。

线程调度依赖于操作系统请求的线程(以及在某种程度上,全局解释器锁(GIL)对线程的交织)。进程调度依赖于操作系统的整体调度器。线程和进程调度都是抢占式的——线程(或进程)可以被中断,以便允许不同优先级的线程或进程控制 CPU。这意味着线程调度是不可预测的,如果多个线程将要更新共享资源,那么锁就很重要。在操作系统层面,如果两个进程想要更新共享的操作系统资源,如文件,则需要共享锁。与线程和进程不同,AsyncIO 协程是非抢占式的;它们在处理过程中的特定点明确地将控制权交给对方,从而消除了对共享资源显式锁定的需要。

asyncio 库提供了一个内置的 事件循环:这是处理运行协程之间交错控制的循环。然而,事件循环也有其代价。当我们在一个事件循环上的 async 任务中运行代码时,该代码 必须 立即返回,既不能阻塞 I/O,也不能阻塞长时间的计算。在编写我们自己的代码时,这算是一件小事,但这意味着任何阻塞 I/O 的标准库或第三方函数都必须用可以礼貌等待的 async def 函数包装。

当使用 asyncio 时,我们将以一组协程的形式编写我们的应用程序,这些协程使用 asyncawait 语法通过事件循环来交错控制。因此,顶层“main”程序的任务简化为运行事件循环,这样协程就可以来回传递控制权,交错等待和工作。

AsyncIO 实战

阻塞函数的一个典型例子是time.sleep()调用。我们不能直接调用time模块的sleep()函数,因为它会夺取控制权,使事件循环停滞。我们将使用asyncio模块中的sleep()版本。在await表达式中使用时,事件循环可以在等待sleep()完成的同时,交错执行另一个协程。以下我们将使用这个调用的异步版本来展示 AsyncIO 事件循环的基本原理,具体如下:

import asyncio
import random
async def random_sleep(counter: float) -> None:
    delay = random.random() * 5
    print(f"{counter} sleeps for {delay:.2f} seconds")
    await asyncio.sleep(delay)
    print(f"{counter} awakens, refreshed")
async def sleepers(how_many: int = 5) -> None:
    print(f"Creating {how_many} tasks")
    tasks = [
        asyncio.create_task(random_sleep(i)) 
        for i in range(how_many)]
    print(f"Waiting for {how_many} tasks")
    await asyncio.gather(*tasks)
if __name__ == "__main__":
    asyncio.run(sleepers(5))
    print("Done with the sleepers") 

本例涵盖了 AsyncIO 编程的几个特性。整体处理过程由 asyncio.run() 函数启动。这启动了事件循环,执行 sleepers() 协程。在 sleepers() 协程中,我们创建了一些单独的任务;这些是带有给定参数值的 random_sleep() 协程的实例。random_sleep() 使用 asyncio.sleep() 来模拟长时间运行请求。

因为这是使用 async def 函数和围绕 asyncio.sleep()await 表达式构建的,所以 random_sleep() 函数的执行和整个 sleepers() 函数的执行是交织在一起的。虽然 random_sleep() 请求是按照它们的 counter 参数值顺序启动的,但它们完成时的顺序却完全不同。以下是一个例子:

python src/async_1.py 
Creating 5 tasks
Waiting for 5 tasks
0 sleeps for 4.69 seconds
1 sleeps for 1.59 seconds
2 sleeps for 4.57 seconds
3 sleeps for 3.45 seconds
4 sleeps for 0.77 seconds
4 awakens, refreshed
1 awakens, refreshed
3 awakens, refreshed
2 awakens, refreshed
0 awakens, refreshed
Done with the sleepers 

我们可以看到,random_sleep() 函数在 counter 值为 4 时具有最短的睡眠时间,并且在完成 await asyncio.sleep() 表达式后首先获得控制权。唤醒的顺序严格基于随机睡眠间隔,以及事件循环从协程到协程传递控制的能力。

作为异步程序员,我们不需要过多了解run()函数内部发生的事情,但要注意,有很多操作正在进行,以追踪哪些协程正在等待,以及哪些协程在当前时刻应该拥有控制权。

在这个语境中,一个任务是一个asyncio知道如何在事件循环中安排的对象。这包括以下内容:

  • 使用 async def 语句定义的协程。

  • asyncio.Future 对象。这些与上一节中你看到的 concurrent.futures 几乎相同,但用于 asyncio

  • 任何可等待的对象,即具有__await__()函数的对象。

在这个例子中,所有任务都是协程;我们将在后续的例子中看到其他一些。

仔细观察一下那个 sleepers() 协程。它首先构建了 random_sleep() 协程的实例。这些实例每个都被 asyncio.create_task() 调用所包装,这会将它们作为未来任务添加到循环的任务队列中,以便它们可以在控制权返回到循环时立即执行并启动。

每当我们调用 await 时,控制权都会返回到事件循环。在这种情况下,我们调用 await asyncio.gather() 以将控制权交予其他协程,直到所有任务完成。

每个 random_sleep() 协程都会打印一条启动信息,然后通过自己的 await 调用将控制权返回给事件循环一段时间。当睡眠完成时,事件循环将控制权返回给相应的 random_sleep() 任务,该任务在返回之前会打印一条唤醒信息。

async 关键字充当文档说明,通知 Python 解释器(和程序员)协程包含 await 调用。它还做一些工作来准备协程在事件循环上运行。它的行为很像一个装饰器;实际上,在 Python 3.4 之前,它曾经被实现为一个 @asyncio.coroutine 装饰器。

阅读 AsyncIO 未来

AsyncIO 协程按顺序执行每行代码,直到遇到 await 表达式,此时它将控制权交还给事件循环。事件循环随后执行任何其他准备就绪的任务,包括原始协程所等待的任务。每当那个子任务完成时,事件循环将结果发送回协程,以便它可以继续执行,直到遇到另一个 await 表达式或返回。

这使得我们能够编写同步执行的代码,直到我们明确需要等待某事发生。因此,线程没有非确定性行为,所以我们不必如此担心共享状态。

限制共享状态是个好主意:一种无共享的哲学可以防止许多由有时难以想象的交错操作时间线引发的困难 bug。

将操作系统调度器想象成故意且邪恶的;它们会恶意地(以某种方式)在进程、线程或协程中找到最糟糕的操作序列。

AsyncIO 的真正价值在于它允许我们将代码的逻辑部分集合在一个单独的协程中,即使我们在等待其他地方的工作。作为一个具体的例子,尽管在 random_sleep() 协程中调用的 await asyncio.sleep 允许在事件循环中发生大量的事情,但协程本身看起来像是在有序地进行所有操作。这种无需担心等待任务完成的机制就能阅读相关异步代码的能力,是 AsyncIO 模块的主要优势。

异步 IO 网络编程

我们将重写那个示例,创建一个基于 asyncio 的服务器,能够处理来自(大量)客户端的请求。它可以通过拥有许多协程来实现,所有协程都在等待日志记录的到来。当记录到达时,一个协程可以保存记录,进行一些计算,而其他协程则等待。

第十三章中,我们感兴趣的是编写一个测试,用于将日志捕获过程与独立的日志编写客户端应用程序过程进行集成。以下是涉及关系的说明:

图表描述自动生成

图 14.1:天空中日志捕捉器

日志捕获进程创建一个套接字服务器以等待来自所有客户端应用程序的连接。每个客户端应用程序都使用logging.SocketHandler将日志消息直接发送到等待的服务器。服务器收集这些消息并将它们写入一个单独的、集中的日志文件。

这次测试基于第十二章中的一个示例,该示例的实现较弱。为了使该章节内容简单,日志服务器当时只能同时与一个应用程序客户端协同工作。我们希望重新审视收集日志消息的服务器这一想法。这种改进的实现将能够处理非常大量的并发客户端,因为它使用了 AsyncIO 技术。

本设计的核心部分是一个协程,它从套接字中读取日志条目。这涉及到等待构成头部的字节,然后解码头部以计算有效负载的大小。协程可以读取日志消息有效负载所需的正确数量的字节,然后使用另一个协程来处理有效负载。下面是log_catcher()函数:

SIZE_FORMAT = ">L"
SIZE_BYTES = struct.calcsize(SIZE_FORMAT)
async def log_catcher(
    reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
    count = 0
    client_socket = writer.get_extra_info("socket")
    size_header = await reader.read(SIZE_BYTES)
    while size_header:
        payload_size = struct.unpack(SIZE_FORMAT, size_header)
        bytes_payload = await reader.read(payload_size[0])
        await log_writer(bytes_payload)
        count += 1
        size_header = await reader.read(SIZE_BYTES)
    print(f"From {client_socket.getpeername()}: {count} lines") 

这个 log_catcher() 函数实现了 logging 模块的 SocketHandler 类所使用的协议。每个日志条目都是一个我们可以分解为头部和有效负载的字节块。我们需要读取存储在 size_header 中的前几个字节,以获取随后消息的大小。一旦我们有了大小,我们就可以等待有效负载字节到达。由于这两个读取操作都是 await 表达式,因此当这个函数等待头部和有效负载字节到达时,其他协程可以工作。

log_catcher() 函数由一个提供 StreamReaderStreamWriter 的服务器调用。这两个对象封装了由 TCP/IP 协议创建的套接字对。流读取器(以及写入器)是适当的异步感知对象,我们可以在等待从客户端读取字节时使用 await

这个 log_catcher() 函数等待套接字数据,然后将数据提供给另一个协程 log_writer() 进行转换和写入。log_catcher() 函数的工作是进行大量的等待,然后将数据从读取器传输到写入器;它还进行内部计算以统计来自客户端的消息。增加计数器并不是什么大事,但这是在等待数据到达时可以完成的工作。

这里有一个函数serialize()和一个协程log_writer(),用于将日志条目转换为 JSON 表示法并将其写入文件:

TARGET: TextIO
LINE_COUNT = 0
def serialize(bytes_payload: bytes) -> str:
    object_payload = pickle.loads(bytes_payload)
    text_message = json.dumps(object_payload)
    TARGET.write(text_message)
    TARGET.write("\n")
    return text_message
async def log_writer(bytes_payload: bytes) -> None:
    global LINE_COUNT
    LINE_COUNT += 1
    text_message = await asyncio.to_thread(serialize, bytes_payload) 

serialize() 函数需要一个已打开的文件,名为 TARGET,日志消息将被写入该文件。文件的打开(和关闭)需要在应用程序的其他地方处理;我们将在下面查看这些操作。serialize() 函数被 log_writer() 协程使用。因为 log_writer() 是一个 async 协程,其他协程将等待读取和解码输入消息,而在此协程写入它们时。

serialize() 函数实际上执行了相当多的计算。它还隐藏着一个深刻的问题。文件写入操作可能会被阻塞,也就是说,它会卡在等待操作系统完成工作的状态。向磁盘写入意味着将工作交给磁盘设备,并等待设备响应写入操作已完成。虽然写入包含 1,000 个字符的行数据可能只需要微秒级的时间,但对于 CPU 来说却是永恒的。这意味着所有文件操作都会阻塞它们的线程,等待操作完成。为了与主线程中的其他协程礼貌地协作,我们将这项阻塞工作分配给一个单独的线程。这就是为什么log_writer()协程使用asyncio.to_thread()将这项工作分配给一个单独的线程的原因。

因为log_writer()协程在这个单独的线程上使用了await,所以在等待写入完成时,它将控制权交回事件循环。这种礼貌的await允许其他协程在log_writer()协程等待serialize()完成时继续工作。

我们已经将两种工作传递给了单独的线程:

  • 一个计算密集型操作。这些是pickle.loads()json.dumps()操作。

  • 一个阻塞的操作系统操作。这是 TARGET.write()。这些阻塞操作包括大多数操作系统请求,包括文件操作。它们不包括已经是 asyncio 模块一部分的各种网络流。正如我们在上面的 log_catcher() 函数中看到的,流已经是事件循环的礼貌用户。

将工作传递给线程的这种技术是我们确保事件循环尽可能多地花费时间等待的方法。如果所有协程都在等待事件,那么接下来发生的事情将会尽可能快地得到响应。众多等待者的这一原则是响应式服务的秘密。

全局变量 LINE_COUNT 可能会让人感到惊讶。回想一下前面的章节,我们曾对多个线程同时更新共享变量的后果发出过严重警告。在 asyncio 中,线程之间没有抢占。因为每个协程都通过事件循环使用显式的 await 请求将控制权交给其他协程,所以我们可以在 log_writer() 协程中更新这个变量,知道状态变化将有效地对所有协程是原子的——不可分割的更新。

为了使这个示例完整,以下是所需的导入:

from __future__ import annotations
import asyncio
import asyncio.exceptions
import json
from pathlib import Path
from typing import TextIO
import pickle
import signal
import struct
import sys 

这是启动此服务的顶层调度器:

server: asyncio.AbstractServer
async def main(host: str, port: int) -> None:
    global server
    server = await asyncio.start_server(
        log_catcher,
        host=host,
        port=port,
    )
    if sys.platform != "win32":
        loop = asyncio.get_running_loop()
        loop.add_signal_handler(signal.SIGTERM, server.close)
    if server.sockets:
        addr = server.sockets[0].getsockname()
        print(f"Serving on {addr}")
    else:
        raise ValueError("Failed to create server")
    async with server:
        await server.serve_forever() 

main() 函数包含了一种优雅的方法来自动为每个网络连接创建新的 asyncio.Task 对象。asyncio.start_server() 函数在指定的主机地址和端口号上监听传入的套接字连接。对于每个连接,它使用 log_catcher() 协程创建一个新的 Task 实例;这个实例被添加到事件循环的协程集合中。一旦服务器启动,main() 函数就让它通过服务器的 serve_forever() 方法永久提供服务。

循环中的 add_signal_handler() 方法值得一些解释。对于非 Windows 操作系统,进程是通过操作系统发出的信号来终止的。信号有小的数字标识符和符号名称。例如,终止信号有一个数字代码 15,名称为 signal.SIGTERM。当一个父进程终止子进程时,会发送这个信号。如果我们不做任何特殊处理,这个信号将简单地停止 Python 解释器。当我们使用键盘上的 Ctrl + C 序列时,这会变成一个 SIGINT 信号,导致 Python 抛出 KeyboardInterrupt 异常。

loop 中的add_signal_handler()方法允许我们检查传入的信号并将它们作为我们 AsyncIO 处理循环的一部分来处理。我们不想仅仅因为未处理的异常而停止。我们希望完成各种协程,并允许任何执行serialize()函数的写线程正常完成。为了实现这一点,我们将信号连接到server.close()方法。这干净地结束了serve_forever()进程,让所有协程完成。

对于 Windows 系统,我们不得不在 AsyncIO 处理循环之外工作。这段额外的代码是必需的,以便将低级信号连接到将干净关闭服务器的函数。

if sys.platform == "win32":
    from types import FrameType
    def close_server(signum: int, frame: FrameType) -> None:
        # print(f"Signal {signum}")
        server.close()
    signal.signal(signal.SIGINT, close_server)
    signal.signal(signal.SIGTERM, close_server)
    signal.signal(signal.SIGABRT, close_server)
    signal.signal(signal.SIGBREAK, close_server) 

我们已定义了三个标准信号,SIGINTSIGTERMSIGABRT,以及一个针对 Windows 的特定信号 SIGBREAK。这些信号都将关闭服务器,结束请求的处理并关闭处理循环,当所有挂起的协程都完成后。

正如我们在之前的 AsyncIO 示例中看到的,主程序也是一种简洁启动事件循环的方式:

if __name__ == "__main__":
    # These often have command-line or environment overrides
    HOST, PORT = "localhost", 18842
    with Path("one.log").open("w") as TARGET:
        try:
            if sys.platform == "win32":
                # https://github.com/encode/httpx/issues/914
                loop = asyncio.get_event_loop()
                loop.run_until_complete(main(HOST, PORT))
                loop.run_until_complete(asyncio.sleep(1))
                loop.close()
            else:
                asyncio.run(main(HOST, PORT))
        except (
                asyncio.exceptions.CancelledError, 
                KeyboardInterrupt):
            ending = {"lines_collected": LINE_COUNT}
            print(ending)
            TARGET.write(json.dumps(ending) + "\n") 

这将打开一个文件,设置由 serialize() 函数使用的全局 TARGET 变量。它使用 main() 函数创建等待连接的服务器。当 serve_forever() 任务因 CancelledErrorKeyboardInterrupt 异常被取消时,我们可以在日志文件中添加一条最终总结行。这一行确认了事情正常完成,使我们能够验证没有丢失任何行。

对于 Windows 系统,我们需要使用run_until_complete()方法,而不是更全面的run()方法。同时,我们还需要在事件循环中添加一个额外的协程asyncio.sleep(),以便等待其他任何协程的最终处理。

从实用主义的角度来看,我们可能希望使用argparse模块来解析命令行参数。我们可能希望在log_writer()函数中使用更复杂的文件处理机制,以便我们可以限制日志文件的大小。

设计考虑因素

让我们来看看这个设计的一些特性。首先,log_writer() 协程将字节传递到运行 serialize() 函数的外部线程中,并从中传出。这比在主线程中的协程中解码 JSON 更好,因为(相对昂贵的)解码可以在不停止主线程的事件循环的情况下进行。

调用 serialize() 的行为实际上是一个未来。在本章前面的 Futures 部分,我们看到了使用 concurrent.futures 时有一些样板代码。然而,当我们使用与 AsyncIO 一起的 futures 时,几乎没有任何样板代码!当我们使用 await asyncio.to_thread() 时,log_writer() 协程将函数调用包装在一个 future 中,并将其提交到内部线程池执行器。然后我们的代码可以返回到事件循环,直到 future 完成,允许主线程处理其他连接、任务或 futures。将阻塞 I/O 请求放入单独的线程尤为重要。当 future 完成,log_writer() 协程可以结束等待并进行任何后续处理。

main() 协程使用了 start_server();服务器监听连接请求。它将为每个创建的任务提供客户端特定的 AsyncIO 读写流,以处理不同的连接;任务将包装 log_catcher() 协程。使用 AsyncIO 流,从流中读取是一个可能阻塞的调用,因此我们可以使用 await 来调用它。这意味着礼貌地返回到事件循环,直到字节开始到达。

考虑一下在这个服务器内部工作负载是如何增长的。最初,main() 函数是唯一的协程。它创建了 server,现在 main()server 都在事件循环等待协程的集合中。当一个连接建立时,服务器创建一个新的任务,事件循环现在包含 main()server 和一个 log_catcher() 协程的实例。大多数时候,所有这些协程都在等待做某事:要么是服务器的新连接,要么是 log_catcher() 的消息。当消息到达时,它会被解码并交给 log_writer(),这时又有一个协程可用。无论接下来发生什么,应用程序都准备好做出响应。等待协程的数量受可用内存的限制,所以很多单独的协程可以耐心地等待工作来做。

接下来,我们将快速浏览一个使用此日志捕获器的日志编写应用程序。该应用程序并没有什么实际用途,但它可以在很长的一段时间内占用大量的核心。这将展示异步 IO 应用程序的响应能力。

日志编写演示

为了展示这种日志捕获的工作原理,这个客户端应用程序写入了一大批消息,并进行了大量的计算。为了查看日志捕获器的响应速度,我们可以启动这个应用程序的多个副本以对日志捕获器进行压力测试。

这个客户端没有使用 asyncio;这是一个计算密集型工作与少量围绕其的 I/O 请求的虚构示例。在这个例子中,使用协程来与计算同时执行 I/O 请求——按设计——是无用的。

我们编写了一个应用程序,它将 bogosort 算法的变体应用于一些随机数据。以下是关于这个排序算法的一些信息:rosettacode.org/wiki/Sorting_algorithms/Bogosort。这不是一个实用的算法,但它很简单:它枚举所有可能的排序方式,寻找一个符合期望的升序排列。以下是导入和抽象超类Sorter,用于排序算法:

from __future__ import annotations
import abc
from itertools import permutations
import logging
import logging.handlers
import os
import random
import time
import sys
from typing import Iterable
logger = logging.getLogger(f"app_{os.getpid()}")
class Sorter(abc.ABC):
    def __init__(self) -> None:
        id = os.getpid()
        self.logger = logging.getLogger(            f"app_{id}.{self.__class__.__name__}")
    @abc.abstractmethod
    def sort(self, data: list[float]) -> list[float]:
        ... 

接下来,我们将定义一个抽象Sorter类的具体实现:

class BogoSort(Sorter):
    @staticmethod
    def is_ordered(data: tuple[float, ...]) -> bool:
        pairs: Iterable[Tuple[float, float]] = zip(data, data[1:])
        return all(a <= b for a, b in pairs)
    def sort(self, data: list[float]) -> list[float]:
        self.logger.info("Sorting %d", len(data))
        start = time.perf_counter()
        ordering: Tuple[float, ...] = tuple(data[:])
        permute_iter = permutations(data)
        steps = 0
        while not BogoSort.is_ordered(ordering):
            ordering = next(permute_iter)
            steps += 1
        duration = 1000 * (time.perf_counter() - start)
        self.logger.info(
            "Sorted %d items in %d steps, %.3f ms", 
            len(data), steps, duration)
        return list(ordering) 

BogoSort 类的 is_ordered() 方法用于检查对象列表是否已正确排序。sort() 方法生成数据的所有排列,寻找一个满足由 is_sorted() 定义的约束条件的排列。

注意,一组 n 个值的排列组合有 n! 种,因此这是一个效率极低的排序算法。13 个值的排列组合超过六十亿种;在大多数计算机上,这个算法可能需要数年才能将 13 个元素排序。

一个 main() 函数处理排序并写入一些日志消息。它进行大量的计算,占用 CPU 资源却做些特别无用的工作。以下是我们可以在效率低下的排序消耗处理时间时使用的 main 程序来发起日志请求:

def main(workload: int, sorter: Sorter = BogoSort()) -> int:
    total = 0
    for i in range(workload):
        samples = random.randint(3, 10)
        data = [random.random() for _ in range(samples)]
        ordered = sorter.sort(data)
        total += samples
    return total
if __name__ == "__main__":
    LOG_HOST, LOG_PORT = "localhost", 18842
    socket_handler = logging.handlers.SocketHandler(
        LOG_HOST, LOG_PORT)
    stream_handler = logging.StreamHandler(sys.stderr)
    logging.basicConfig(
        handlers=[socket_handler, stream_handler], 
        level=logging.INFO)
    start = time.perf_counter()
    workload = random.randint(10, 20)
    logger.info("sorting %d collections", workload)
    samples = main(workload, BogoSort())
    end = time.perf_counter()
    logger.info(
        "sorted %d collections, taking %f s", workload, end - start)
    logging.shutdown() 

顶层脚本首先创建一个 SocketHandler 实例;这会将日志消息写入上面显示的日志捕获服务。一个 StreamHandler 实例将消息写入控制台。这两个都作为处理程序提供给所有定义的日志记录器。一旦配置了日志记录,就会以随机的工作负载调用 main() 函数。

在一台 8 核心的 MacBook Pro 上,这次测试使用了 128 个工作者,他们都在低效地对随机数字进行排序。内部操作系统time命令描述的工作负载使用了核心的 700%;也就是说,八个核心中有七个完全被占用。然而,仍然有足够的时间来处理日志消息、编辑这份文档以及在后台播放音乐。使用更快的排序算法后,我们启动了 256 个工作者,在大约 4.4 秒内生成了 5,632 条日志消息。这是每秒 1,280 次交易,而我们只使用了可用的 800%中的 628%。您的性能可能会有所不同。对于网络密集型工作负载,AsyncIO 似乎在为有工作要做的事件循环分配宝贵的 CPU 时间方面做得非常出色,并且最小化了线程因等待要做的事情而被阻塞的时间。

重要的是要注意,AsyncIO 在网络上资源方面有很强的倾向性,包括套接字、队列和操作系统管道。文件系统不是asyncio模块的一级部分,因此我们需要使用相关的线程池来处理那些将被操作系统阻塞直到完成的过程。

我们将偏离主题,来探讨如何使用 AsyncIO 编写客户端应用程序。在这种情况下,我们不会创建服务器,而是利用事件循环来确保客户端能够非常快速地处理数据。

异步 IO 客户端

由于它能够处理数千个同时连接,AsyncIO 在实现服务器方面非常常见。然而,它是一个通用的网络库,同时也为客户端进程提供全面支持。这一点非常重要,因为许多微服务充当其他服务器的客户端。

客户端可以比服务器简单得多,因为它们不需要设置成等待传入的连接。我们可以利用awaitasyncio.gather()函数来分配大量工作,并在它们完成时等待处理结果。这可以很好地与asyncio.to_thread()一起工作,该函数将阻塞请求分配到单独的线程,允许主线程在协程之间交错工作。

我们还可以创建可以由事件循环交错处理的单个任务。这允许实现任务的协程协同安排读取数据,同时计算读取到的数据。

对于这个例子,我们将使用httpx库来提供一个适用于 AsyncIO 的 HTTP 请求。这个附加包需要使用conda install https(如果你使用conda作为虚拟环境管理器)或python -m pip install httpx来安装。

这是一个用于向美国气象服务发送请求的应用程序,使用asyncio实现。我们将重点关注对切萨皮克湾地区的航海者有用的预报区域。我们将从一些定义开始:

import asyncio
import httpx
import re
import time
from urllib.request import urlopen
from typing import Optional, NamedTuple
class Zone(NamedTuple):
    zone_name: str
    zone_code: str
    same_code: str  # Special Area Messaging Encoder
    @property
    def forecast_url(self) -> str:
        return (
            f"https://tgftp.nws.noaa.gov/data/forecasts"
            f"/marine/coastal/an/{self.zone_code.lower()}.txt"
        ) 

给定名为 Zone 的元组,我们可以分析海洋预报产品的目录,并创建一个以如下方式开始的 Zone 实例列表:

ZONES = [
    Zone("Chesapeake Bay from Pooles Island to Sandy Point, MD", 
        "ANZ531", "073531"),
    Zone("Chesapeake Bay from Sandy Point to North Beach, MD",      
       "ANZ532", "073532"),
. . . 
] 

根据你打算去哪里航行,你可能需要额外的或不同的区域。

我们需要一个MarineWX类来描述需要完成的工作。这是一个命令模式的例子,其中每个实例都是我们希望执行的其他事情。这个类有一个run()方法,用于从气象服务中收集数据:

class MarineWX:
    advisory_pat = re.compile(r"\n\.\.\.(.*?)\.\.\.\n", re.M | re.S)
    def __init__(self, zone: Zone) -> None:
        super().__init__()
        self.zone = zone
        self.doc = ""
    async def run(self) -> None:
        async with httpx.AsyncClient() as client:
            response = await client.get(self.zone.forecast_url)
        self.doc = response.text
    @property
    def advisory(self) -> str:
        if (match := self.advisory_pat.search(self.doc)):
            return match.group(1).replace("\n", " ")
        return ""
    def __repr__(self) -> str:
        return f"{self.zone.zone_name} {self.advisory}" 

在这个例子中,run() 方法通过 httpx 模块的 AsyncClient 类的实例从气象服务下载文本文档。一个单独的属性 advisory() 解析文本,寻找标记海洋气象警告的模式。气象服务文档的各部分确实是通过三个句点、一段文本和三个句点来标记的。海洋预报系统旨在提供一种易于处理的格式,同时文档大小非常小。

到目前为止,这并不独特或引人注目。我们已经定义了一个区域信息的存储库,以及一个用于收集区域数据的类。这里的关键部分是一个main()函数,它使用 AsyncIO 任务尽可能快地收集尽可能多的数据。

async def task_main() -> None:
    start = time.perf_counter()
    forecasts = [MarineWX(z) for z in ZONES]
    await asyncio.gather(
        *(asyncio.create_task(f.run()) for f in forecasts))
    for f in forecasts:
        print(f)
    print(
        f"Got {len(forecasts)} forecasts "
        f"in {time.perf_counter() - start:.3f} seconds"
    )
if __name__ == "__main__":
    asyncio.run(main()) 

main() 函数在 asyncio 事件循环中运行时,将启动多个任务,每个任务都在执行不同区域的 MarineWX.run() 方法。gather() 函数会等待所有任务完成,然后返回未来对象的列表。

在这种情况下,我们并不真正想要从创建的线程中获取未来的结果;我们想要的是对所有的MarineWX实例所做的状态变更。这将是一个包含Zone对象和预报详情的集合。这个客户端运行得相当快——我们大约在 300 毫秒内获取了所有十三项预报。

httpx 项目支持将获取原始数据和将数据处理成单独的协程进行分解。这允许等待数据与处理相互交织。

我们在本节中已经涵盖了 AsyncIO 的多数要点,并且本章还涉及了许多其他的并发原语。并发是一个难以解决的问题,没有一种解决方案适用于所有用例。设计一个并发系统最重要的部分是决定在可用的工具中选择哪一个是解决该问题的正确工具。我们已经看到了几个并发系统的优缺点,并且现在对哪些是满足不同类型需求更好的选择有了一些见解。

下一个主题涉及到如何衡量一个并发框架或包的“表达能力”。我们将看到asyncio如何通过一个简洁、外观干净的应用程序来解决经典的计算机科学问题。

餐饮哲学家基准

在一个古老的滨海度假城市(位于美国大西洋沿岸)的哲学学院,教师们有一个长期的传统,那就是每周日晚上一起聚餐。食物由 Mo's Deli 提供,但总是——总是——一大碗意大利面。没有人记得为什么,但 Mo 的厨艺一流,每周的意大利面都是一次独特的体验。

哲学系规模较小,仅有五位终身教职员工。他们经济拮据,只能负担得起五把叉子。因为就餐的哲学家们每人需要两把叉子来享用他们的意大利面,所以他们围坐在一张圆形餐桌旁,这样每位哲学家都能接触到附近的两把叉子。

两个叉子吃饭的需求导致了一个有趣的资源竞争问题,如下图中所示:

图表描述自动生成

图 14.2:就餐的哲学家

理想情况下,一位哲学家,比如说哲学家 4,作为系主任,以及一位本体论者,将获得所需的两个最接近的叉子,即叉子 4 和叉子 0,以便用餐。一旦他们用餐完毕,他们就会释放叉子,以便有时间从事哲学研究。

有一个问题亟待解决。如果每位哲学家都是右撇子,他们会伸手去拿右边的叉子,然后——由于无法再拿另一个叉子——他们就被阻止了。这个系统处于僵局状态,因为没有任何一位哲学家能够获得进食所需的资源。

一种可能的解决方案是通过使用超时来打破僵局:如果哲学家在几秒钟内无法获得第二个叉子,他们就会放下第一个叉子,等待几秒钟,然后再次尝试。如果他们都以相同的节奏进行,这将导致每个哲学家都获得一个叉子,等待几秒钟,放下他们的叉子,然后再次尝试。有趣,但并不令人满意。

一个更好的解决方案是每次只允许四位哲学家坐在桌旁。这确保至少有一位哲学家能够拿到两个叉子并开始用餐。当这位哲学家在思考哲学问题时,叉子现在就可供其两个邻居使用。此外,第一个完成哲学思考的人可以离开桌子,这样第五位哲学家就可以坐下并加入对话。

这在代码中看起来如何?这里定义了哲学家,作为一个协程:

FORKS: List[asyncio.Lock]
async def philosopher(
        id: int,
        footman: asyncio.Semaphore
) -> tuple[int, float, float]:
    async with footman:
        async with FORKS[id], FORKS[(id + 1) % len(FORKS)]:
            eat_time = 1 + random.random()
            print(f"{id} eating")
            await asyncio.sleep(eat_time)
        think_time = 1 + random.random()
        print(f"{id} philosophizing")
        await asyncio.sleep(think_time)
    return id, eat_time, think_time 

每位哲学家都需要了解一些事情:

  • 他们的唯一标识符。这指引他们到他们被允许使用的两个相邻分支。

  • 一个信号员——也就是仆人——负责为他们安排座位。仆人的职责是限制可以坐下的客人数,从而避免死锁。

  • 由一系列 Lock 实例表示的全球集合,这些实例将被哲学家们共享。

哲学家的用餐时间是通过获取和使用资源来描述的。这是通过使用async with语句来实现的。事件的顺序看起来是这样的:

  1. 哲学家从仆人那里获得一个座位,这个仆人被称为“信号员”。我们可以把仆人想象成手持一个银色托盘,上面有四个“你可以吃”的标记。哲学家必须拥有一个标记才能坐下。离开餐桌时,哲学家会将他们的标记扔到托盘上。第五位哲学家正焦急地等待着第一个吃完的哲学家扔下标记。

  2. 哲学家用他们的 ID 号码和下一个更高编号的叉子来领取。模运算符确保“下一个”的计数会绕回到零;(4+1)%5 等于 0。

  3. 在餐桌旁坐下,拿起两把叉子,哲学家就可以享用他们的意大利面了。莫经常使用卡拉马塔橄榄和腌制洋蓟心;这非常美味。每个月可能有一次会有一些沙丁鱼或羊乳酪。

  4. 饭后,一位哲学家释放了两个叉子资源。然而,他们并没有结束晚餐。一旦他们放下叉子,他们便开始花时间对生活、宇宙以及一切进行哲学思考。

  5. 最后,他们放弃在餐桌上的座位,将他们的“你可以吃”的令牌归还给仆人,以防另一位哲学家正在等待。

查看一下philosopher()函数,我们可以看到叉子是一个全局资源,但信号量是一个参数。没有令人信服的技术理由来区分用全局的Lock对象集合来表示叉子和将Semaphore作为参数。我们展示了这两种方法来阐述为协程提供数据的两种常见选择。

这里是该代码的导入语句:

from __future__ import annotations
import asyncio
import collections
import random
from typing import List, Tuple, DefaultDict, Iterator 

整个餐厅的布局如下:

async def main(faculty: int = 5, servings: int = 5) -> None:
    global FORKS
    FORKS = [asyncio.Lock() for i in range(faculty)]
    footman = asyncio.BoundedSemaphore(faculty - 1)
    for serving in range(servings):
        department = (
            philosopher(p, footman) for p in range(faculty))
        results = await asyncio.gather(*department)
        print(results)
if __name__ == "__main__":
    asyncio.run(main()) 

main() 协程创建了分叉集合;这些分叉被建模为哲学家可以获取的 Lock 对象。仆人是一个 BoundedSemaphore 对象,其限制比学院规模少一个;这避免了死锁。对于每一次服务,部门由一组 philosopher() 协程来代表。asyncio.gather() 等待部门的所有协程完成它们的工作——进食和思考。

这个基准问题的美在于展示了在给定的编程语言和库中,处理过程可以表述得多么出色。使用asyncio包,代码极其优雅,看起来是对该问题解决方案的一个简洁且富有表现力的表达。

concurrent.futures 库可以利用显式的 ThreadPool。它可以达到这种清晰度,但涉及一点更多的技术开销。

threadingmultiprocessing 库也可以直接用来提供类似的实现。使用这两个库中的任何一个都比 concurrent.futures 库涉及更多的技术开销。如果吃饭或哲学思考涉及真正的计算工作——而不仅仅是睡眠——我们会看到 multiprocessing 版本会最快完成,因为计算可以分散到几个核心上。如果吃饭或哲学思考主要是等待 I/O 完成,那么它会更像这里展示的实现,使用 asyncio 或使用线程池的 concurrent.futures 会工作得很好。

案例研究

经常困扰在机器学习应用中工作的数据科学家的问题之一是“训练”模型所需的时间。在我们具体的k最近邻实现示例中,训练意味着执行超参数调整以找到k的最佳值和正确的距离算法。在我们案例研究的上一章中,我们默认假设将存在一个最佳的超参数集。在这一章中,我们将探讨一种定位最佳参数的方法。

在更复杂且定义不明确的问题中,训练模型所需的时间可能会相当长。如果数据量巨大,那么构建和训练模型需要非常昂贵的计算和存储资源。

作为更复杂模型的一个例子,看看 MNIST 数据集。请参阅yann.lecun.com/exdb/mnist/获取该数据集的源数据以及已进行的某些分析。与我们的小型 Iris 分类问题相比,这个问题需要更多的时间来定位最优超参数。

在我们的案例研究中,超参数调整是一个计算密集型应用的例子。这里的 I/O 非常少;如果我们使用共享内存,就没有 I/O。这意味着需要一个进程池来允许并行计算是必不可少的。我们可以将进程池包裹在 AsyncIO 协程中,但对于这种计算密集型的例子,额外的asyncawait语法似乎并不有帮助。相反,我们将使用concurrent.futures模块来构建我们的超参数调整函数。concurrent.futures的设计模式是利用处理池将各种测试计算分配给多个工作者,并收集结果以确定哪种组合是最优的。进程池意味着每个工作者可以占用一个单独的核心,最大化计算时间。我们将尽可能同时运行尽可能多的Hyperparameter实例的测试。

在前面的章节中,我们探讨了定义训练数据和超参数调整值的好几种方法。在本案例研究中,我们将使用来自第七章Python 数据结构的一些模型类。从这一章开始,我们将使用TrainingKnownSampleTestingKnownSample类定义。我们需要将这些保存在一个TrainingData实例中。而且,最重要的是,我们需要Hyperparameter实例。

我们可以这样总结模型:

图表描述自动生成

图 14.3:超参数模型

我们想强调KnownTestingSampleKnownTrainingSample类。我们正在关注测试,不会对UnknownSample实例做任何事情。

我们的调优策略可以描述为网格搜索。我们可以想象一个网格,其顶部是k的备选值,而侧面是不同的距离算法。我们将填充网格的每个单元格以得到一个结果:

for k in range(1, 41, 2):
    for algo in ED(), MD(), CD(), SD():
        h = Hyperparameter(k, algo, td)
        print(h.test()) 

这使我们能够比较一系列的k值和距离算法,以查看哪种组合最佳。然而,我们并不真的想打印出结果。我们希望将它们保存在一个列表中,对它们进行排序以找到最佳质量的结果,并将其用作分类未知样本的首选超参数配置。

(剧透警告:对于这个 Iris 数据集,它们都相当不错。)

每次测试运行都是完全独立的。因此,我们可以同时进行所有测试。

为了展示我们将要并行运行的内容,以下是Hyperparameter类的测试方法:

def test(self) -> "Hyperparameter":
    """Run the entire test suite."""
    pass_count, fail_count = 0, 0
    for sample in self.data.testing:
        sample.classification = self.classify(sample)
        if sample.matches():
            pass_count += 1
        else:
            fail_count += 1
    self.quality = pass_count / (pass_count + fail_count)
    return self 

我们将使用每个测试样本,执行分类算法。如果已知结果与classify()算法分配的物种匹配,我们将将其计为通过。如果分类算法与已知结果不匹配,我们将将其计为失败。正确匹配的百分比是衡量分类质量的一种方法。

这是一个整体的测试函数,load_and_tune()。该函数将从bezdekiris.data文件中将原始数据加载到内存中,该文件可在本书的代码仓库中找到。该函数包括使用ProcessPoolExecutor来并发运行多个工作者的功能:

def grid_search_1() -> None:
    td = TrainingData("Iris")
    source_path = Path.cwd().parent / "bezdekiris.data"
    reader = CSVIrisReader(source_path)
    td.load(reader.data_iter())
    tuning_results: List[Hyperparameter] = []
    with futures.ProcessPoolExecutor(8) as workers:
        test_runs: List[futures.Future[Hyperparameter]] = []
        for k in range(1, 41, 2):
            for algo in ED(), MD(), CD(), SD():
                h = Hyperparameter(k, algo, td)
                test_runs.append(workers.submit(h.test))
        for f in futures.as_completed(test_runs):
            tuning_results.append(f.result())
    for result in tuning_results:
        print(
            f"{result.k:2d} {result.algorithm.__class__.__name__:2s}"
            f" {result.quality:.3f}"
        ) 

我们使用了 workers.submit() 来提供一个函数,即 Hyperparameter 实例 htest() 方法,提交给工作池。结果是具有 Hyperparameter 作为结果的 Future[Hyperparameter],最终将拥有一个 Hyperparameter。每个提交的未来,由 ProcessPoolExecutor 管理,将评估这个函数,并将产生的 Hyperparameter 对象作为未来的结果保存。

使用ProcessPoolExecutor这种方式是否最优?因为我们数据池很小,看起来效果不错。每次提交时序列化训练数据的开销很小。对于更大的一组训练和测试样本,我们在序列化所有数据时将会遇到性能问题。由于样本是字符串和浮点对象,我们可以更改数据结构以使用共享内存。这是一个需要利用第十二章,高级设计模式中的 Flyweight 设计模式的彻底重构。

我们使用了Future[Hyperparameter]类型提示来提醒mypy工具,我们期望test()方法返回一个Hyperparameter结果。确保期望的结果类型与实际提供给submit()函数的函数返回的结果类型相匹配是很重要的。

当我们检查Future[超参数]对象时,result函数将提供在工作线程中处理过的超参数。我们可以收集这些信息以定位最优的超参数集。

有趣的是,它们都相当不错,准确率在 97%到 100%之间。下面是输出结果的简要片段:

 5 ED 0.967
 5 MD 0.967
 5 CD 0.967
 5 SD 0.967
 7 ED 0.967
 7 MD 0.967
 7 CD 1.000
 7 SD 0.967
 9 ED 0.967
 9 MD 0.967
 9 CD 1.000
 9 SD 0.967 

为什么质量始终如一地保持高水平?原因有很多:

  • 原始数据是由原始研究论文的作者精心整理和准备的。

  • 每个样本只有四个特征。分类并不复杂,而且近似的分类机会也不多。

  • 在这四个特征中,有两个与产生的物种非常强烈相关。另外两个特征与物种之间的相关性较弱。

选择这个例子其中一个原因是因为数据使我们能够享受成功,而不必应对设计糟糕的问题的复杂性,难以处理的数据,或者高水平的噪声,这些噪声会淹没数据中隐藏的重要信号。

查看iris.names文件,第八部分,我们看到以下摘要统计信息:

Summary Statistics:
                 Min  Max   Mean    SD    Class Correlation
   sepal length: 4.3  7.9   5.84  0.83    0.7826   
    sepal width: 2.0  4.4   3.05  0.43   -0.4194
   petal length: 1.0  6.9   3.76  1.76    0.9490  (high!)
    petal width: 0.1  2.5   1.20  0.76    0.9565  (high!) 

这些统计数据表明,仅使用两个特征可能比使用所有四个特征更好。实际上,忽略花瓣宽度可能会提供更好的结果。

进入更复杂的问题将带来新的挑战。基本的 Python 编程不应再成为问题的一部分。它应该有助于制定可行的解决方案。

回忆

我们已经仔细研究了与 Python 并发处理相关的各种主题:

  • 线程在许多情况下具有简单性的优势。这必须与全局解释锁(GIL)对计算密集型多线程的干扰相平衡。

  • 多进程的优势在于充分利用处理器的所有核心。这必须与进程间通信的成本相平衡。如果使用共享内存,则存在对共享对象进行编码和访问的复杂性。

  • concurrent.futures 模块定义了一个抽象——未来(future),它可以最小化用于访问线程或进程的应用程序编程中的差异。这使得切换起来变得容易,并可以看到哪种方法最快。

  • Python 语言的async/await特性由 AsyncIO 包支持。因为这些是协程,所以没有真正的并行处理;协程之间的控制切换允许单个线程在等待 I/O 和计算之间交错。

  • 餐厅哲学家基准测试可以用来比较不同类型的并发语言特性和库。这是一个相对简单的问题,但也有一些有趣的复杂性。

  • 可能最重要的观察是缺乏一个简单的、适用于所有情况的并发处理解决方案。创建并衡量各种解决方案以确定一个能够最大限度地利用计算硬件的设计是至关重要的。

练习

我们在本章中介绍了几个不同的并发范式,但仍未对何时使用每个范式有一个清晰的认识。在案例研究中,我们暗示通常最好在确定一个比其他方案明显更好的方案之前,先开发几个不同的策略。最终的选择必须基于对多线程和多处理解决方案性能的测量。

并发是一个非常大的主题。作为你的第一个练习,我们鼓励你上网搜索,了解被认为是最新 Python 并发最佳实践的内容。研究非 Python 特定的材料,以了解操作系统原语,如信号量、锁和队列,可能会有所帮助。

如果你最近的应用程序中使用了线程,请查看代码,看看如何通过使用未来(futures)来使其更易于阅读且更少出现错误。比较线程和进程池中的未来(multiprocessing futures),看看通过使用多个 CPU 是否可以获得任何优势。

尝试实现一个用于基本 HTTP 请求的 AsyncIO 服务。如果你能将其做到让网页浏览器能够渲染一个简单的 GET 请求,那么你对 AsyncIO 网络传输和协议就会有很好的理解。

确保你理解在访问共享数据时线程中发生的竞态条件。尝试编写一个使用多个线程以使数据故意变得损坏或无效的方式来设置共享值的程序。

第八章面向对象与函数式编程的交汇处中,我们查看了一个使用subprocess.run()在目录内执行多个python -m doctest命令的示例。回顾那个示例,并重写代码以使用futures.ProcessPoolExecutor并行运行每个子进程。

回顾到第十二章,高级设计模式,有一个示例是运行外部命令来为每一章创建图表。这依赖于外部应用程序java,它在运行时往往会消耗大量的 CPU 资源。并发处理对这个示例有帮助吗?运行多个并发的 Java 程序似乎是一个巨大的负担。这是否意味着进程池大小的默认值设置得过大?

在查看案例研究时,一个重要的替代方案是使用共享内存,以便多个并发进程可以共享一组公共的原始数据。使用共享内存意味着共享字节或共享一个简单对象的列表。对于 NumPy 这样的包,共享字节工作得很好,但对我们 Python 类定义来说效果不佳。这表明我们可以创建一个包含所有样本值的SharedList对象。我们将需要应用 Flyweight 设计模式,从共享内存中的列表中提取具有有用名称的属性。然后,一个单独的FlyweightSample将提取四个测量值和一个物种分配。一旦数据准备就绪,并发进程和进程内的线程之间的性能差异是什么?为了在需要时才加载测试和训练样本,需要对TrainingData类进行哪些更改?

摘要

本章以一个不太面向对象的课题结束了我们对面向对象编程的探索。并发是一个难题,我们只是触及了表面。虽然底层操作系统的进程和线程抽象并没有提供一个接近面向对象的 API,但 Python 围绕它们提供了一些真正优秀的面向对象抽象。线程和进程池包都提供了对底层机制的面向对象接口。未来对象能够将许多杂乱的细节封装成一个单一的对象。AsyncIO 使用协程对象使我们的代码看起来像是在同步运行,同时在非常简单的循环抽象背后隐藏了丑陋和复杂的实现细节。

感谢您阅读《Python 面向对象编程》,第四版。我们希望您已经享受了这次旅程,并且渴望开始在您未来的所有项目中实施面向对象的软件!

图片

packt.com

订阅我们的在线数字图书馆,即可全面访问超过 7,000 本书籍和视频,以及行业领先的工具,助您规划个人发展并推进职业生涯。如需更多信息,请访问我们的网站。

为什么订阅?

  • 使用来自超过 4,000 位行业专业人士的实用电子书和视频,花更少的时间学习,更多的时间编码

  • 使用专为您定制的技能计划学习更佳

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于轻松获取关键信息

  • 复制粘贴、打印和收藏内容

你知道吗,Packt 提供每本书的电子书版本,包括 PDF 和 ePub 文件可供下载?您可以在 www.Packt.com 升级到电子书版本,并且作为印刷版书籍的顾客,您有权获得电子书副本的折扣。如需了解更多详情,请联系我们 customercare@packtpub.com

www.Packt.com,您还可以阅读一系列免费的技术文章,注册多种免费通讯,并接收 Packt 书籍和电子书的独家折扣和优惠。

posted @ 2025-09-20 21:34  绝不原创的飞龙  阅读(17)  评论(0)    收藏  举报