Python-应用开发学习指南-全-

Python 应用开发学习指南(全)

原文:zh.annas-archive.org/md5/bc1d7b25f7d20f8af5ed00e75a04e3e5

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Python 是最广泛使用的动态编程语言之一。它支持丰富的库和框架,这些库和框架能够实现快速开发。这种快速的开发节奏往往伴随着自己的负担,可能会降低应用程序的整体质量、性能和可扩展性。本书将帮助你通过教授你如何构建和部署有趣的应用程序来提升你的 Python 技能水平。

从一个简单的程序开始,本书带你一步步设计和发展健壮且高效的应用程序。它以易于理解和有趣的方式触及了几个重要主题。

使用幻想主题作为解释各种概念的载体。在本书的整个过程中,你将遇到许多虚构的游戏角色。在你学习不同主题的同时,这些虚构的角色会与你交谈、提问并请求新功能。

每一章都针对应用程序开发的某个不同方面。最初的一些章节侧重于软件的健壮性、打包和发布应用程序代码。接下来的几章是关于通过使代码可扩展、可重用和可读来提高应用程序的寿命。你将学习到重构、单元测试、设计模式、文档和最佳实践。

在性能专题的三个章节中,涵盖了识别瓶颈和改进性能的技术。最后一章介绍了 GUI 开发。

需要注意的重要事项

  • 本书使用有趣、基于文本的游戏主题作为解释各种应用程序开发方面的载体。然而,本书本身并不是关于开发游戏应用程序的!

  • 每一章都将包含其自己的 Python 源代码文件集。尽管我们将讨论大部分代码,但你应该手头保留相关文件。有关更多详细信息,请参阅“下载示例代码”部分。

  • 以下内容适用于阅读本书的电子版。本书中展示的大部分代码都是以图像形式创建的。为了获得更好的阅读体验,请尝试使用 100%的缩放比例,因为这些代码快照在缩放级别上应该看起来清晰。

  • 练习的解答(如果有)通常不会提供。

  • 本书提供了几个外部链接(URL)供进一步阅读。随着时间的推移,其中一些链接可能会失效。如果发生这种情况,请尝试使用适当的搜索词在网络上进行搜索。

  • 一些经验丰富的读者可能会觉得代码解释有点冗长。在这种情况下,你可以查看本书支持材料中提供的代码。

非常重要提示,针对电子书阅读器

你在本书中看到的代码示例实际上是图像文件或代码快照。

这些图像的渲染质量将取决于你的 PDF 阅读器的页面显示分辨率和缩放级别。

如果你发现阅读此代码有困难,你可以在你的 PDF 或电子书阅读器中尝试以下操作:

  • 将缩放级别设置为 100%

  • 使用每英寸 96 像素或类似的页面显示分辨率

如果问题仍然存在,您可以尝试使用不同的分辨率。

如何设置这个分辨率?这取决于您的电子书阅读器。例如,如果您正在使用 Adobe Reader,请转到编辑 | 首选项,然后从左侧面板中选择页面显示。您将在右侧面板中看到分辨率作为选项。选择96 像素/英寸或类似选项,看看是否有助于更好地渲染图像。

本书涵盖的内容

第一章,开发简单应用程序,从安装必备条件和本书的主题开始。第一个程序是一个以脚本形式呈现的幻想文本游戏。然后使用函数开发这个程序的一个带有新功能的增量版本。随着更多功能的添加,代码变得难以管理。为了解决这个问题,游戏应用程序使用面向对象的概念进行了重新设计。这个应用程序现在成为接下来几章的参考版本。

第二章,处理异常,将教会您如何修复前面章节中编写的代码的明显问题。您将学习如何添加异常处理代码以使应用程序健壮。您还将了解try…except…finally子句、抛出和重新抛出异常、创建和使用自定义异常类等内容。

第三章,模块化、打包、部署!,将教会您如何模块化和打包前面章节中编写的代码。在准备好包之后,它将向您展示如何部署源分布、进行增量发布、设置私有 Python 包仓库以及将代码置于版本控制之下。

第四章,文档和最佳实践,深入探讨了编码标准,这是一套在开发代码时应遵循的指南。遵守这些标准可以对代码的可读性和代码的生命周期产生重大影响。在本章中,您将了解软件开发的重要方面之一,即代码文档和最佳实践。它从 reStructuredText 格式的介绍开始,并使用它来编写文档字符串。您将使用 Sphinx 文档生成器为代码创建 HTML 文档。本章还讨论了编写 Python 代码和使用 PyLint 检查代码质量的一些重要编码标准。

第五章, 单元测试和重构,从介绍 Python 中的单元测试框架开始。你将为迄今为止开发的游戏应用程序编写一些单元测试。它涵盖了其他许多主题,例如在单元测试中使用 Mock 库以及使用代码覆盖率来衡量单元测试的有效性。本章的后半部分讨论了许多代码重构技术。这是利用前面章节中开发的代码的最后一章。接下来的章节将会有与相同高奇幻主题相关的简化示例。

第六章, 设计模式,告诉你,在开发过程中,你经常会遇到一个反复出现的问题。很多时候,存在一个通用的解决方案或配方,它正好适用于这个问题。这通常被称为设计模式。本章向你介绍了一些常用的设计模式。它涵盖了策略、简单和抽象工厂、适配器模式。对于每种模式,一个简单的游戏场景将演示一个实际问题。你将学习设计模式如何帮助解决这个问题。每个模式都将使用 Python 方法实现。

第七章, 性能 – 识别瓶颈,是关于性能提升的三章系列中的第一章。你将编写一个名为黄金狩猎的简单程序,它在调整一些参数之前看起来是无害的。参数调整揭示了性能问题。在本章中,你将识别代码中的耗时块。它涵盖了测量应用程序运行时间的基本方法,对代码进行性能分析以识别性能瓶颈,内存分析的基本知识,以及使用大 O 符号来表示计算复杂度。

第八章, 提高性能 – 第一部分,教你如何修复上一章中识别的一些性能瓶颈。此外,你还将了解一些技术,例如算法更改、列表推导、生成器表达式、选择合适的数据结构等,以提高应用程序的性能。

第九章, 提高性能 – 第二部分,NumPy 和并行化,是关于性能提升的最后一章,其中你将大幅提升黄金狩猎应用程序的性能。本章将向你介绍NumPy包。它还将介绍使用 Python 进行并行处理。

第十章,简单 GUI 应用程序,是最后一章,它将向你介绍简单 GUI 应用程序的开发。到目前为止,这些章节已经涵盖了使用命令行程序进行应用程序开发的几个关键方面。然而,在这一章中,你将学习关于 Tkinter 模块、MVC 架构,以及开发 第一章 中开发的第一个应用程序的 GUI 版本,开发简单应用程序

你需要这本书的内容

本书中的代码与 Python 3.5 版本兼容。支持的代码包还提供了与版本 2.7.9 兼容的文件;然而,本书中假设使用的是 Python 3.5。有关需要安装的基本软件包的详细信息,请参阅 第一章 的 安装先决条件 部分,开发简单应用程序。此外,还有一些需要安装的 Python 软件包依赖项。大多数这些软件包可以使用 pip(Python 软件包管理器)安装。这些依赖项在需要它们的章节中提到。

这本书面向谁

你是否了解 Python 和面向对象编程的基础?

你是否愿意走得更远,学习使你的 Python 应用程序健壮、可扩展和高效的技巧?

如果你对这些问题的回答是肯定的,这本书就是为你准备的。它也适合那些有不同编程背景(例如 C++ 或 Java)并希望掌握 Python 应用程序开发的人。

如果以下任何一个陈述适用于你,这本书就不适合你:

  • 你对 Python 完全陌生,或者没有 OOP 的背景。第一章涵盖了基础知识,但需要进一步理解。

  • 你正在寻找关于特定应用领域的参考,例如 Web、GUI、数据库或游戏应用程序。除了 GUI 之外,本书不涵盖此类特定领域的话题。尽管如此,你在这本书中学到的技术应该为所有这些领域提供一个坚实的基础。

习惯用法

在这本书中,你会找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号显示如下:“GoldHunt.find_coins 方法有一些更改。”

代码块设置如下:

results = pool.starmap_async(self.find_coins, 
                               zip(itertools.repeat(x_list), 
                                   itertools.repeat(y_list), 
                                   x_centers, 

任何命令行输入或输出都按以下方式编写:

export PATH=$PATH:/usr/bin/

新术语重要词汇 以粗体显示。你会在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“如前所述,在安装时,你应该选择将 Python 3.5 添加到 PATH 选项。”

注意

警告或重要提示会出现在这样的框中。

提示

小技巧和窍门看起来像这样。

读者反馈

我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,请简单地发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍标题。

如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 图书的骄傲拥有者,我们有许多事情可以帮助你从购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com的账户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击顶部的支持标签。

  4. 搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

你还可以通过点击 Packt Publishing 网站上书籍网页上的代码文件按钮下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录您的 Packt 账户。

文件下载完成后,请确保使用最新版本解压或提取文件夹,具体版本如下:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learning-Python-Application-Development。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,网址为github.com/PacktPublishing/。去看看吧!

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/LearningPythonApplicationDevelopment_ColorImages.pdf下载此文件。

错误列表

尽管我们已经尽一切努力确保内容的准确性,但错误仍然会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章:开发简单应用

Python 是最广泛使用的动态编程语言之一。它支持丰富的包、GUI 库和 Web 框架,使您能够构建高效的跨平台应用。它是快速应用开发的理想语言。这种快速的开发往往伴随着自己的负担,可能会降低代码的整体质量、性能和可扩展性。本书将向您展示如何处理这种情况,并帮助您开发更好的 Python 应用。关键概念将通过命令行应用进行解释,这些应用将在后续章节中逐步改进。

本章将是一个入门章节。它将作为 Python 编程的复习。话虽如此,我们假设您对 Python 语言以及面向对象编程OOP)概念有所了解。

本章节的组织结构如下:

  • 我们将从安装必备条件开始,并为 Python 开发设置一个合适的环境。

  • 为了为本书的其余部分定下基调,下一节将简要介绍本书的高幻想主题

  • 接下来是第一个程序。它是一个简单的基于文本的幻想游戏,以 Python 脚本的形式呈现。

  • 我们将向这个游戏添加一些复杂性,并使用简单的函数开发游戏的增量版本。

  • 在继续前进之前,我们将添加更多功能到游戏中,并通过应用 OOP 概念重新设计代码。

  • 最后一个主题将简要介绍 Python 中的抽象基类ABCs)。

代码解释将稍微详细一些。有经验的读者可以快速浏览示例并进入下一章,但请确保理解本书的主题并回顾ch01_ex03.py文件中的代码。在接下来的几章中,您将学习逐步改进此代码的技术。

重要准备工作笔记

在深入本章的其余部分之前,让我们先做一些准备工作。如果您还没有做,您应该阅读前言,它记录了以下大部分内容:

  • 每个章节都将有自己的 Python 源文件集。虽然我们将讨论大部分代码,但您应该手头保留相关文件。

  • 源代码可以从 Packt Publishing 网站下载。遵循前言中提到的说明。

  • 本书中的代码与 Python 版本 3.5.1 兼容。支持代码包还提供了与版本 2.7.9 兼容的文件。

  • 如前所述,我们假设您熟悉 Python 语言的基础知识,并且了解 OOP 概念。

  • 本书采用有趣、基于文本的游戏主题作为解释各种应用开发方面的载体。然而,本书本身并不是关于开发游戏应用的!

  • 练习的解答(如果有)通常不会提供。

  • 本书提供了几个外部链接(URL)供进一步阅读。随着时间的推移,其中一些链接可能会损坏。如果发生这种情况,请尝试使用适当的搜索词在网络上搜索。

安装先决条件

确保我们已经安装了先决条件。以下是一个表格,总结了本章及以后所需的基本工具;更详细的安装说明将在下一节中提供:

工具 备注
Python 3.5 本书中的代码与版本 3.5 兼容。请参阅下一表以获取可用的 Python 发行版。支持代码捆绑还提供与 2.7.9 兼容的文件。
pip(Python 的包管理器) pip 已经在 3.5 和 2.7.9 版本的官方发行版中提供。
IPython 可选安装。IPython 是一个增强型 Python 解释器。
集成开发环境(IDE 使用 Python 编辑器或您选择的任何 IDE。本章后面将列出一些好的 IDE。

在后续章节中,我们需要安装一些额外的依赖项。Python 包管理器(pip)将使这项任务变得简单。

小贴士

您已经设置了所需的 Python 环境,或者知道如何设置吗?只需跳过下面的设置说明,继续到 本书主题 部分,那里才是真正的行动开始的地方!

安装 Python

安装 Python 有两种选择。您可以使用官方 Python 版本或免费提供的捆绑发行版之一。

选项 1 – 官方发行版

对于 Linux 或 Mac 用户,Python 可能已经安装在您的系统上。如果没有,您可以使用操作系统的包管理器进行安装。Windows 操作系统用户可以从官方 Python 网站下载 Python 安装程序来安装 Python 3.5:

选项 1 – 官方发行版

在安装过程中,只需确保选择将 Python 3.5 添加到系统环境变量 PATH 的选项,如前面的截图所示。您还可以访问官方 Python 网站 www.python.org/downloads,以获取特定平台的发行版。

选项 2 – 捆绑发行版

或者,还有几个免费提供的 Python 捆绑发行版,它们将一些有用的 Python 软件包捆绑在一起,包括 pip 和 IPython。以下表格总结了最流行的几个 Python 发行版,包括官方发行版:

发行版 支持的平台 备注
官方 Python 发行版www.python.org Windows, Linux, Mac
  • 免费提供

  • 2.7.9 和 3.5 版本默认包含 pip

|

Anacondacontinuum.io Windows, Linux, Mac
  • 免费提供

  • 包含 pip、IPython 和 Spyder IDE

  • 主要捆绑科学、数学、工程和数据分析的软件包

|

Enthought Canopy Expresswww.enthought.com/canopy-express/ Windows, Linux, Mac
  • 免费提供

  • 包含 pip 和 IPython

  • 集成 Python 代码编辑器和应用程序开发平台

|

Python(x, y)python-xy.github.io/ Windows
  • 免费提供

  • 包含 pip、IPython 和 Spyder IDE

|

Python 安装位置

让我们简要谈谈 Python 的安装路径,以及如何确保python在你的终端窗口中作为命令可用。当然,具体取决于你安装的位置和选择的 Python 发行版,情况会有很大差异。

小贴士

官方 Python 文档页面提供了在不同平台上设置 Python 环境的详细信息。以下是一个链接,以防你需要进一步的帮助,超出我们已覆盖的内容:docs.python.org/3/using/index.html

类 Unix 操作系统

在类似于 Linux 的 Unix 操作系统上,默认位置通常是/usr/bin/python/usr/local/bin/python

如果你使用操作系统的包管理器安装 Python,pythonpython3命令应该在终端窗口中可用。如果不是,你需要更新PATH系统环境变量,包括 Python 可执行文件的目录路径。例如,如果你有一个Bash shell,将以下内容添加到你的用户主目录中的.bashrc文件:

export PATH=$PATH:/usr/bin/

用你的 Python 安装的实际路径替换/usr/bin

Windows 操作系统

在 Windows 操作系统上,默认的 Python 安装路径通常是以下目录:C:\Users\name\AppData\Local\Programs\Python\Python35-32\python.exe。将name替换为你的 Windows 用户名。根据你的安装程序和系统,Python 目录也可以是Python35-64。如前所述,在安装时,你应该选择将 Python 3.5 添加到 PATH选项,以确保pythonpython.exe自动被识别为命令。或者,你可以仅选择此选项重新运行安装程序。

验证 Python 安装

在 Windows 操作系统的终端窗口(或命令提示符)中输入以下命令以验证 Python 版本。如果 Python 已安装并且作为终端窗口中的命令可用,则此命令将生效。否则,指定 Python 可执行文件的完整路径。例如,在 Linux 上,如果 Python 安装在/usr/bin,你可以指定为/usr/bin/python

$ python -V 

小贴士

注意,上一条命令行中的$符号属于终端窗口,不是命令本身的一部分!换句话说,实际命令只是python -V。在终端窗口中,$%符号是 Linux 上普通用户的提示符。对于 root(管理员)用户,符号是#。同样,在 Windows 操作系统上,相应的符号是>。你将在该符号之后输入实际命令。

以下只是示例输出,如果我们运行前面的命令:

[user@hostname ~]$ python -V
Python 3.5.1 :: Anaconda 4.0.0 (64-bit)

安装 pip

pip 是一个软件包管理器,它使得从官方第三方软件仓库 PyPI 安装 Python 软件包变得非常简单。对于 Python-2 版本 2.7.9 或更高版本和 Python-3 版本 3.4 或更高版本,pip 已经安装。如果你使用的是不同的 Python 版本,请查看pip.pypa.io/en/stable/installing 以获取安装说明。

在 Linux 操作系统上,pip 的默认位置与 Python 可执行文件的默认位置相同。例如,如果你有 /usr/bin/python,那么 pip 应该可用为 /usr/bin/pip。在 Windows 操作系统上,默认的 pip.exe 通常如下所示:C:\Users\name\AppData\Local\Programs\Python\Python35-32\Scripts\pip.exe。如前所述,将 name 替换为你的 Windows 用户名。根据你的安装程序和系统,Python 目录也可以是 Python35-64

安装 IPython

这是一个可选安装。IPython 是 Python 解释器的增强版本。如果它还没有包含在你的 Python 发行版中,你可以使用以下命令安装它:

$ pip install ipython 

安装完成后,只需在终端中键入 ipython 即可启动 IPython 交互式外壳。以下是使用 Anaconda Python 3.5 发行版的 IPython 外壳的截图:

安装 IPython

小贴士

使用 Jupyter Notebook 编写和分享交互式程序通常非常方便。它是一个网络应用程序,它允许在丰富的文本、图像、图表等旁边编写 Python 代码的交互式环境。有关更多详细信息,请查看项目主页jupyter.org/。Jupyter Notebook 可以使用以下命令安装:

$ pip install "ipython[notebook]"

选择 IDE

使用 IDE 进行开发是个人偏好的问题。简单来说,IDE 是一个旨在加速应用程序开发的工具。它通过集成他们最常用的工具,使开发者能够快速编写高效的代码。Python 安装附带了一个名为 IDLE 的程序。这是一个基本的 Python IDE,应该能帮助你入门。对于高级开发,你可以从许多免费或商业工具中选择。任何好的 Python IDE 都有以下基本功能:

  • 具有代码补全和语法高亮功能的源代码编辑器

  • 用于浏览文件、项目、函数和类的代码浏览器

  • 用于交互式识别问题的调试器

  • 集成版本控制系统,如 Git

你可以通过尝试使用一个免费可用的 IDE 来开始。以下是一些流行的 IDE 的部分列表。如果你只对简单的源代码编辑器感兴趣,你可以查看wiki.python.org/moin/PythonEditors,以获取可用的选择列表。

Python IDE 备注
PyCharm Community Editionwww.jetbrains.com/pycharm 提供免费社区版。是开始 Python 开发的优秀工具!
Wing IDE 101wingware.com/downloads/wingide-101 仅限非商业用途免费。提供额外功能的商业版本。另一个优秀的 Python IDE。
Spyder pythonhosted.org/spyder 免费且开源。也包含在 Python(x,y)和 Anaconda 等捆绑 Python 发行版中。
Eclipse PyDevwww.pydev.org 免费且开源。
Sublime Text 2 或 Sublime Text 3(beta)www.sublimetext.com/2 仅限评估目的免费。高度可配置的 IDE。

本书主题

你读过 J.R.R. Tolkien 的高奇幻小说,如《魔戒》或《霍比特人》吗?或者看过基于这些小说的电影?好吧,这里有一本关于 Python 应用开发的“托尔金式”主题的高奇幻书籍。

小贴士

要了解更多关于 J.R.R. Tolkien 的作品,请参阅en.wikipedia.org/wiki/J._R._R._Tolkien。术语高奇幻常用来表示设定在另一个虚构世界的奇幻主题。更多信息请查看en.wikipedia.org/wiki/High_fantasy

本书带你进入一个虚构的世界,在那里你将基于上述主题开发一个文本游戏。是的,你甚至在这个虚构世界中也可以继续作为开发者!在本书的过程中,你将伴随着许多虚构角色。当你学习 Python 开发的各个方面时,这些角色会与你交谈,提问,要求新功能,甚至与敌人战斗。

应注意,本书并非关于开发游戏应用。它使用简单的基于文本的游戏作为学习各种开发方面的媒介。

小贴士

闲话少说,如果你对玩高奇幻主题游戏感兴趣,有很多可供选择。在开源游戏中,Battle for Wesnoth 是评分最高的免费、回合制策略游戏之一,具有高奇幻主题。更多信息请查看www.wesnoth.org

遇见角色

让我们遇见将在各个章节中陪伴你的虚构角色:

遇见角色 Sir Foo一位被描绘为守护南部平原的伟大骑士的人类骑士。他是我们的主角,将在整本书中与我们交谈。
遇见角色 Orc Rider奥克是一种类似人类的人造生物。在这里,它被描绘为敌军士兵。奥克被看到骑在类似野猪的生物上。你将在本章中看到这种生物。
遇见角色 精灵骑士精灵是一种超自然的神话生物。精灵骑在一匹精灵马上。他被描绘为友好的。你将在第六章,设计模式中遇到 Mr. Elf。
遇见角色 仙女一位具有内在魔法能力的聪明仙女。当她找到她在第七章,性能识别瓶颈中找到的魔法护身符时,她会使用她的魔法一次。(见 O(log n))。你将在第六章,设计模式中第一次遇见她。
遇见角色 矮人矮人是一种类似人类的小型神话生物。他被描绘为 Foo 山脉的“伟大的矮人”。他问了很多问题。你将在本书的第二部分看到他,从第六章开始,设计模式

以这个有趣的主题为载体,让我们从简单的命令行应用程序开始我们的旅程。这将是一个基于文本的游戏。后续章节中添加的复杂性将挑战你面对有趣的问题。本书将向你展示如何优雅地处理这种情况。

简单脚本 – 兽人攻击 v0.0.1

我们已经准备好了所需的工具和环境。现在是时候编写我们的第一个 Python 程序了。它将是一个简单的机会游戏,作为一个命令行应用程序开发。随着我们进一步深入,我们将为游戏添加更多复杂性,并学习新的技术来开发高效的应用程序。所以,准备好行动吧!

游戏 – 兽人攻击 v0.0.1

人类与其宿敌,兽人之间的战争即将爆发。一支庞大的兽人军队正向人类定居点进发。他们几乎摧毁了他们所经过的一切。人类种族的伟大国王们携手合作,为了他们时代的伟大战役,击败他们最凶恶的敌人。人们被召集加入军队。Foo 爵士,一位勇敢的骑士,负责守卫南部的平原,开始了一段漫长的东行之旅,穿过一片未知的茂密森林。在两天两夜的谨慎行进中,他穿过茂密的树林。在路上,他发现了一个小型的孤立定居点。疲惫不堪,希望补充他的食物储备,他决定绕道而行。当他接近村庄时,他看到了五座小屋。周围看不到任何人。犹豫不决,他决定进入一座小屋...

游戏 – 兽人攻击 v0.0.1

问题陈述

你正在设计一个简单的游戏,玩家需要为 Foo 爵士选择一座小屋。小屋可能被朋友或敌人随机占据,也可能有些小屋是空的。如果选中的是敌人的小屋,玩家就会失败。在其他两种情况下,玩家获胜。

模拟代码 – 版本 0.0.1

既然目标已经明确,打开您最喜欢的编辑器并记下主要步骤。这有时被称为伪代码。

当用户希望继续玩游戏时:

  • 打印游戏任务

  • 创建一个huts列表

  • 随机放置'enemy''friend''unoccupied'在 5 个小屋中

  • 提示玩家选择小屋编号

  • if enemy: print "you lose"

  • else: print "you win"

如您所注意到的,代码的关键部分是随机占用五个小屋,要么是敌人,要么是朋友,其余的保持空置。我们如何做到这一点?让我们快速使用 Python 解释器来解决这个问题。如果您已安装 IPython,请启动 IPython 解释器。否则,只需在终端窗口中键入命令python即可使用默认的 Python 解释器。首先,我们需要一个 Python 列表来存储所有占用者类型。接下来,我们将使用内置的random模块并调用random.choice从该列表中随机选择一个元素。此代码在以下屏幕截图中显示:

伪代码 – 版本 0.0.1

现在,我们只需要编写周围的代码。让我们接下来回顾它。

检查代码

从本章提供的补充代码包中下载源代码,ch01_ex01.py。文件扩展名,.py,表示这是一个 Python 文件。在您选择的 Python 编辑器或 IDE 中打开它。建议您在阅读以下讨论时保留此文件。通常,浏览完整代码更容易理解。观察以下代码片段。这只是上述文件中if __name__ == '__main__'条件块内的一小部分代码。

小贴士

如果您已安装 Python 2.7.9,支持代码包中提供了一个单独的 Python 2.7.9 兼容源。

检查代码

让我们回顾一下前面截图中的代码片段:

  • 前两行导入两个内置模块以访问这些模块内提供的功能。textwrap模块本质上提供了格式化在命令行上打印的消息的功能。

  • if条件块if __name__ == '__main__'仅在文件作为独立脚本运行时调用。换句话说,如果您在其他文件中导入此文件,则此条件块内的代码不会执行。

  • 现在,让我们看看这个条件块中的代码。首先,我们将初始化一些变量。如前所述,列表occupants存储小屋的潜在占用者类型。

  • 最后几行只是为了格式化在终端窗口中打印的文本。dotted_line是一个字符串,将显示一个由连字符符号组成的 72 个字符长的行。

  • 使用 ASCII 转义序列来打印粗体文本。序列"\0331m"用于使文本加粗,而"\033[0m"用于返回正常打印样式。

接下来的几行代码在控制台中打印有关游戏的更多信息:

![审查代码让我们看看前面截图中的代码:+ 变量 msg 是一个非常长的字符串。这就是使用 textwrap 模块的地方。+ textwrap.fill 函数将消息包装成每行 72 个字符,正如我们在代码中指定的 width。现在,让我们回顾以下 while 循环。### 小贴士对于 Python 2.7.9,在第一个例子中需要做的唯一更改是将所有对内置函数 input 的调用替换为 raw_inputpy# For Python 2.7 user_choice = raw_input(msg)审查代码

  • 这个顶层循环给玩家提供了再次玩游戏的选择。

  • 使用 random.choice,我们从 occupants 列表中的居住者中随机选择一个并添加到 huts 列表中。这已经在前面说明了。

  • 内置的 input 函数接受用户选择的棚屋编号作为整数。idx 变量存储一个数字。

接下来,它通过打印相关信息来揭示居住者。最后,通过检查与棚屋编号相对应的列表项来确定获胜者。请注意,huts 列表索引从 0 开始。因此,为了检索给定棚屋编号的列表元素 idx,我们需要检查 idx-1 的列表索引。

运行奥克之攻 v0.0.1

假设你已经在系统环境变量中安装了 Python,PATH(可用作 pythonpython3),从命令行运行程序如下:

$ python ch01_ex01.py

就这些!只需玩游戏,并尝试通过选择正确的棚屋来救出 Sir Foo!以下是一个 Linux 终端窗口的快照,显示了我们的游戏正在运行:

运行奥克之攻 v0.0.1

使用函数 – 奥克之攻 v0.0.5

在最后一节中,你编写了一套快速指令来创建一个不错的命令行游戏。你让朋友们试玩,他们似乎很喜欢(也许他们只是想表现得友好!)。你收到了第一个关于游戏的特性请求。

"我认为这个游戏有很大的发展潜力。关于在游戏的下一个版本中加入战斗怎么样?当 Sir Foo 遇到敌人时,他不应该那么轻易放弃。与敌人战斗!让战斗决定胜负。"-你的朋友

使用函数 – 奥克之攻 v0.0.5

你喜欢这个想法,并决定在下一个版本中添加这个功能。此外,你还想让它更具交互性。

你为第一个程序编写的脚本很小。然而,随着我们继续添加新功能,它很快就会变成一个维护难题。作为进一步的操作,我们将现有代码封装成小的函数,以便更容易管理。在函数式编程中,通常关注的是函数的排列和它们的组合。例如,你可以使用一组可重用的简单函数构建复杂的逻辑。

回顾上一个版本

在添加任何新功能之前,让我们回顾一下您在上一版本(版本 0.0.1)中编写的脚本。我们将识别可以封装成函数的代码块。以下两个代码片段中标记了这样的代码块:

回顾上一个版本

我们将把大部分高亮显示的代码封装成单独的函数,如下所示:

1:  show_theme_message 
2:  show_game_mission 
3:  occupy_huts 
4:  process_user_choice 
5:  reveal_occupants
6:  enter_hut

回顾上一个版本

除了这六个代码块之外,我们还可以创建一些顶级函数来处理所有这些逻辑。在 Python 中,使用 def 关键字创建函数,后跟括号内的函数名和参数。例如,reveal_occupants 函数需要 huts 列表的信息。我们还需要可选地传递 dotted_line 字符串,如果我们不想在函数中重新创建它。因此,我们将小屋编号 idxhuts 列表和 dotted_line 字符串作为函数参数传递。这个函数可以写成如下所示:

回顾上一个版本

在这项初步工作之后,原始脚本可以被重写为:

回顾上一个版本

现在阅读起来容易多了。我们刚才所做的也被称为重构;关于各种重构技术的更多内容将在后面的章节中介绍。这使得对单个方法的更改变得更加容易。例如,如果您想自定义任务声明或场景描述,您不需要打开主函数 run_application。同样,occupy_huts 可以进一步扩展,而不会在主代码中造成混乱。

小贴士

代码的初始重构版本并不完美。还有很多改进的空间。你能减少传递 dotted_line 参数的负担,或者想出其他处理打印粗体文本的方法吗?

带有攻击功能的伪代码 – 版本 0.0.5

在上一节中,我们将游戏逻辑封装到单独的函数中。这不仅提高了代码的可读性,还使得维护变得更加容易。让我们继续前进,并将新的 attack() 函数包含到游戏中。以下步骤展示了包含攻击功能的游戏逻辑。

当用户希望继续玩游戏时:

  • 打印游戏任务

  • 创建一个 huts 列表

  • 在 5 个小屋中随机放置 'enemy''friend''unoccupied'

  • 提示玩家选择小屋编号

  • if 小屋里有敌人,执行以下操作:

    • while 用户希望继续攻击,使用 attack() 方法对敌人进行攻击

      每次攻击后,更新并显示 Sir Foo 和敌人的健康状态;if enemy health <= 0: 打印 "You Win".

      但是,if Sir Foo health <= 0: 打印 "You Lose".

  • else(小屋里有朋友或为空)打印 "you win"

初始时,弗鲁爵士和兽人将拥有满血量。为了量化血量,让我们给这些角色(或游戏单位)分配生命值。所以当我们说角色拥有满血量时,意味着它拥有最大可能的生命值。根据角色不同,默认的生命值数量会有所不同。以下图片显示了弗鲁爵士和兽人默认的生命值,由生命值标签指示:

带有攻击功能的伪代码 – 版本 0.0.5

图像中生命值标签上方的条形图代表生命值。本质上,它跟踪的是生命值。在接下来的讨论中,我们将交替使用生命值和生命值表这两个术语。在战斗中,玩家或敌人可能会受伤。目前,忽略双方都毫发无损逃走的第三种可能性。伤害将减少受伤单位可用的生命值数量。在游戏中,我们将假设在单个攻击回合中只有一个角色被击中。以下图片将帮助您想象这样的攻击回合:

带有攻击功能的伪代码 – 版本 0.0.5

在这里,弗鲁爵士的生命值显示为最大值,而兽人则受到了伤害!

带有攻击功能的伪代码 – 版本 0.0.5

嗯,兽人认为他能打败弗鲁爵士!这很有趣。我们先开发游戏,然后再看看谁有更大的胜算!

在理解了这个问题之后,让我们回顾实现这个功能的代码。

审查代码

从章节代码包中下载源文件ch01_ex02.py,并浏览代码。关键逻辑将在attack()函数中。我们还需要一个数据结构来保存弗鲁爵士和敌人的生命记录。让我们先介绍以下一些处理打印业务的实用函数:

审查代码

现在,看看主函数run_application和辅助函数reset_health_meter。除了引入health_meter字典外,我们还将游戏逻辑封装在play_game中:

审查代码

在新游戏开始时,通过调用reset_health_meterhealth_meter字典的值重置为初始值:

审查代码

接下来,让我们回顾一下play_game函数。如果小屋里有敌人,玩家将被询问是否继续攻击(while循环的开始)。根据用户输入,代码将调用attack函数或退出当前游戏:

审查代码

敌人通过交互式的while循环反复受到攻击,该循环接受用户输入。执行attack函数可能会导致 Sir Foo 或敌人受伤,或者两者都受伤。也有可能没有人受伤。为了简单起见,我们只考虑两种可能性:一次攻击可能会伤害敌人或 Sir Foo。在前一节中,我们使用了内置的随机数生成器来随机确定棚屋的居住者。我们可以使用同样的技术来确定谁会受伤:

injured_unit = random.choice(['player', 'enemy'])
但是等等。Sir Foo 有话要说:

审查代码

我们应该考虑玩家和敌人受伤的可能性。在下面的attack函数中,我们将假设大约60%的时间敌人会被击中,而剩下的40%,Sir Foo 将成为受害者。

最简单的方法是创建一个包含 10 个元素的列表。这个列表应该有六个条目为'enemy'和四个条目为'player'。然后,让random.choice从这个列表中选择一个元素。你总是可以在游戏中引入一个难度级别并改变这种分布:

审查代码

一旦随机选择injured_unit,伤害将通过在1015之间选择一个随机数字来确定,包括1015。在这里,我们使用random.randint函数。最后重要的一步是更新受伤单位的health_meter字典,减少其生命值。

魔兽攻击 v0.0.5

我们已经讨论了这款游戏中最重要的功能。请回顾从下载的文件中获取的其他辅助功能。以下截图显示了游戏的实际运行情况:

魔兽攻击 v0.0.5

使用面向对象编程 – 魔兽攻击 v1.0.0

你在上一款游戏中添加的攻击功能使游戏变得更加有趣。你可以看到一些朋友一次又一次地回来玩游戏。新的功能请求已经开始涌入。

这里是请求的功能的部分列表:

  1. 新任务:占领所有棚屋并击败所有敌人。这也意味着游戏开始时就应该揭示棚屋的居住者。

  2. 在友好的或未被占用的棚屋中恢复健康的能力。

  3. 放弃战斗(或从敌人那里逃跑)的能力。这是一个逃跑、在友好的棚屋中恢复健康并继续战斗的战略举措。

  4. 引入一个或多个骑手来协助 Sir Foo。他们可以轮流获得棚屋。理想情况下,这是一个用户可配置的选项。

  5. 可配置每个敌人单位和每个骑手的最大生命值。

  6. 可配置的总棚屋数量;例如,增加到 10 个。

  7. 每个棚屋都可以有一些金币或武器,Sir Foo 和他的朋友们可以捡起。

  8. 让一个精灵骑手加入 Sir Foo。他的能力使他有很高的几率在更少的攻击中获胜。

这是一个相当长的列表。你正在制定一个计划。以下是你需要添加到现有代码中以实现一些这些功能的部分列表:

  • 跟踪占据各个小屋的多个敌方单位的生命值

  • 维护爵士福及其所有伴随骑手的健康记录

  • 监控爵士福的军队占领了多少个小屋

  • 另一个字典或列表用于跟踪每个小屋中的金币,还有一个用于武器;此外,如果有人想在小屋中放置盔甲怎么办?

  • 不要忘记,还有另一个列表,为每个单位接受这些好东西的字典

  • 啊!他们想要一个具有自己特性和能力的精灵骑手...不错...感谢你带来的额外麻烦!

这已经是一个很长的列表了。虽然你仍然可以使用函数式编程方法,但随着游戏的演变和新功能的添加,在这种情况下将会变得更加困难。

幸运的是,面向对象编程来拯救我们。我们是否可以让爵士福成为一个Knight类的实例?有了这个,应该很容易管理与爵士福相关的参数。例如,一个属性hitpoints可以用来跟踪爵士福的健康状况,而不是使用早期示例中的health_meter字典。同样,类中的其他属性可以跟踪在占领小屋时收集的金币或武器数量(另一个请求的功能)。

这本书籍之外还有很多内容。这个类中的各种方法可以实现对行为的具体实现,例如攻击、奔跑、治疗等等。伴随爵士福的骑手也可以是这个类Knight的实例。或者,你可以创建一个新的类HorseRider,用于所有这些接受爵士福命令的单位。

优先处理功能请求

对于这个新版本,让我们从早期的列表中挑选一些请求的功能。实际上,爵士福应该是做出这个决定的人:

优先处理功能请求

如您所愿,爵士福...我们只会在这个版本中添加新的治疗功能。

问题陈述

现在是明确定义这次发布目标的时候了。你不仅是在你的应用程序中添加新功能,还在对代码进行一些基本的更改,以适应未来的请求。

在这个版本中,任务是占领所有五个小屋。在这里,你将实现一个新的治疗功能,以恢复爵士福的全部生命值。你还将实现一些战略控制,例如逃离战斗、在友好的小屋中治疗,然后焕然一新地返回击败敌人。

重新设计代码

我们已经讨论过创建Knight类将如何帮助简化与爵士福相关的数据处理和其他所有事情,无论是生命值还是他攻击敌人的方式。

还可以划分出哪些其他类?如何将敌人作为一个对象?敌人可以占据多个小屋。记住,我们需要击败所有敌人。想象以下场景:Sir Foo 在编号 2 的小屋中伤害了一个敌人,从而减少了其生命值。然后,他移动到另一个由另一个敌人占据的小屋。现在,我们需要为每个这些敌人单位维护两个独立的生命值计数器。

在未来的版本中,你可以期待用户请求不同的敌人类型,具有攻击或治愈的能力,就像我们对 Sir Foo 所做的那样。因此,在这个阶段,有一个单独的类,其实例代表敌人单位是有意义的。我们将把这个类命名为OrcRider。它将具有与Knight类相似的属性。然而,为了简单起见,我们不会给敌人提供诸如治愈、改变小屋等能力。

Sir Foo 说他很高兴看到敌人被剥夺了一些重要能力。(但你无法看到他头盔后面的笑脸。)

我们还应该考虑其他事情。到目前为止,huts只是一个简单的 Python list对象,包含有关占用类型的信息作为字符串。

看到请求的功能列表,我们还需要记录小屋中金币和盔甲的数量,并根据战斗结果更新其居住者。在未来的版本中,你可能还希望显示一些统计数据,例如居住者的历史记录、金币数量的变化等。为此以及更多,我们将创建一个类,Hut

描绘整体画面

拿起笔和纸,写下到目前为止讨论的每个类所需的重要属性。在这个阶段,不要担心将它们分类为实例变量或封装执行特定任务指令的类方法。只需写下你认为属于每个类的属性即可。

下面的示意图显示了KnightHutOrcRider类可能具有的属性列表。斜体字中的属性名称表示在本示例中不会实现的潜在属性。但是,在设计阶段始终考虑未来并牢记于心总是好的:

描绘整体画面

这不是一个完整的规范,但现在我们有一个良好的起点。当 Sir Foo 进入敌人的小屋时,我们可以选择调用Knight类的attack方法。和以前一样,attack方法将随机选择谁会受到伤害,并扣除该角色的生命值。在Knight类中,有一个新的属性enemy将代表活跃的对手。在这个例子中,enemy将是一个OrcRider类的实例。

让我们进一步开发这个设计。你注意到KnightOrcRider类有几个共同点吗?我们将使用继承原则为这些类创建一个超类,并将其命名为GameUnit。我们将把共同代码移动到超类中,并让子类覆盖它们想要不同实现的部分。在下一节中,我们将用类似统一建模语言UML)的图来表示这些类。

伪 UML 表示

下面的图将有助于了解各种组件之间是如何相互通信的:

伪 UML 表示

前面的图类似于 UML 表示。它有助于创建软件设计的视觉表示。在这本书中,我们将松散地遵循 UML 表示。让我们称这里使用的图为伪 UML 图(或类似 UML 图)。

理解伪 UML 图

对于这里使用的类似 UML 的约定,需要加以解释。我们将用圆角矩形表示图中的每个类。它显示了类名后跟其属性。属性前的加号(+)表示它是公共的。受保护的或私有方法通常用负号(-)表示。此图中显示的所有属性都是公共属性。因此,你可以选择在每个属性旁边添加一个加号。在后面的章节中,我们将遵循此约定。为了便于说明,只列出了几个相关的公共属性。请注意,我们在此图中使用了不同类型的连接器:

  • 带有空三角符号的箭头表示继承;例如,Knight类继承自GameUnit

  • 带有实心菱形符号的箭头表示对象组合,例如,一个Hut实例拥有GameUnit类(或其子类)的对象

  • 带有空菱形符号的箭头表示对象聚合

现在,让我们来讨论之前展示的图中的各个组成部分。

KnightOrcRider类继承自GameUnit。在这种情况下,Knight类将覆盖默认方法,如attackhealrun_awayOrcRider类将不会有这样的覆盖方法,因为我们不会赋予敌人这些能力。

Hut类将有一个居住者。居住者可以是KnightOrcRider的实例,或者如果小屋未被占用,则是None类型。图中的实心菱形连接器表示组合。

小贴士

对象组合

这是一个重要的面向对象编程原则。它意味着一种“拥有”关系。在这种情况下,Hut包含,或由,一些其他对象组成,这些对象将被用来执行特定任务。大声说出来;一个Hut有一个Knight,一个Hut有一个OrcRider,等等。

除了前面讨论的四个类,我们还将引入另一个类来封装顶层代码。让我们称它为 AttackOfTheOrcs。由于有五个小屋,AttackOfTheOrcs 类中的一个类方法创建了相应数量的 Hut 实例。这是对象聚合,如前图中空菱形箭头所示。

你是否注意到了 AttackOfTheOrcs 中的另一个“有”关系?这个类中的 player 属性是 Knight 类的一个实例,但将来这可能会改变。这种关系由连接 KnightAttackOfTheOrcs 方框的实心菱形头连接器表示。

检查代码

在有了这个高级理解之后,让我们开始编写代码。下载 Python 源文件 ch01_ex03.py。我们将在代码中仅审查几个重要方法。请参考此源文件以获取完整代码。

小贴士

此示例的代码 ch01_ex03.py 全部压缩在一个文件中。这是否是良好的实践?当然不是!随着我们的进行,你将了解最佳实践。本书的后面部分,我们将讨论应用开发的一些重要构建块,即重构、编码标准和设计模式。作为练习,尝试将代码拆分成更小的模块,并添加代码文档。

主要执行代码在此处展示,以及 AttackOfTheOrcs 类的一些细节。在 __init__ 方法中,我们将初始化一些实例变量,稍后更新它们所持有的值。例如,self.player 代表游戏开始时 Knight 类的实例:

检查代码

小贴士

作为复习,__init__ 方法在类似于 C++ 这样的语言中类似于构造函数;然而,请注意一些差异。例如,你不能像在这些语言中那样重载 __init__。相反,你可以通过使用可选参数或 classmethod 装饰器轻松地完成这个任务。我们将在本书的后面部分介绍一些方面。

让我们快速回顾 play_occupy_huts 方法:

检查代码

self.playerKnight 类的一个实例。我们将调用此实例的 acquire_hut 方法,其中大部分高级动作发生。之后,程序简单地查找玩家的健康参数和敌人的健康参数。它还会查询 Hut 实例以查看是否已获取。

_occupy_hut 方法中,Hut 对象被创建并附加到 self.huts 列表中。此方法在以下图中展示:

检查代码

注意

Python 中的公共、受保护和私有

你会注意到AttackOfTheOrcs类的一些方法以下划线开头,例如_process_user_choice()。这是一种表示该方法不打算公开使用的方式。它打算在类内部使用。像 C++这样的语言定义了类访问说明符,即privateprotectedpublic。这些用于对类属性的访问进行限制。

在 Python 中不存在这样的东西。它允许通过单个下划线作为game._process_user_choice()从外部访问属性。如果属性名以下划线开头,则不能直接调用。例如,你不能直接调用game.__process_user_choice()。话虽如此,还有另一种从外部访问此类属性的方法。但让我们不要谈论它。尽管 Python 允许你访问这样的属性,但这并不是一个好的做法!

观察骑士类中的acquire_hut方法:

查看代码

让我们接下来讨论这个方法:

  • 首先,我们需要检查小屋的居住者是朋友还是敌人。这由前面的图中的变量is_enemy确定。

  • 小屋的居住者可以是以下类型之一:Knight类的实例、OrcRider类的实例,或者设置为None

  • GameUnit类及其子类KnightOrcRider定义了一个unit_type属性。这只是一个设置为'friend''enemy'的字符串。

  • 因此,为了确定小屋中是否隐藏着敌人,我们首先检查hut.occupant是否是超类GameUnit的实例。如果是真的,我们将知道它有一个unit_type参数。因此,我们将检查hut.occupant.unit_type是否等于'enemy'。对于OrcRider类,unit_type默认设置为'enemy'

  • 其余的逻辑很简单。如果居住者是敌人,它会询问用户下一步做什么:攻击或逃跑。

  • Knight.attack方法与之前讨论的方法类似。这里的一个变化是我们可以访问受伤单位的health_meter属性并更新它。

  • 如果hut.occupant恰好是'friend'None,它将调用hut.acquire()

当调用Hut.acquire()方法时会发生什么?以下是Hut类的代码片段:

查看代码

acquire方法只是简单地使用传递给此方法的参数更新occupant属性。

运行兽人攻击 v1.0.0

玩耍时间到了!我们已经回顾了新类中最重要的一些方法。你可以从ch01_ex03.py文件中查看其余的代码,或者更好的方法是尝试自己编写这些方法。像之前一样,从命令行运行应用程序。以下截图显示了游戏运行情况:

运行兽人攻击 v1.0.0

Python 中的抽象基类

在上一节中,我们使用面向对象的方法重新设计了代码。我们还通过定义超类 GameUnit 并从它派生 KnightOrcRider 子类来演示了继承的使用。作为本章的最后一个话题,让我们来谈谈在 Python 中使用抽象基类。

小贴士

本节旨在提供 Python 中 ABC 的基本理解。这里的讨论远非全面,但足以在我们的应用程序代码中实现 ABC。欲了解更多信息,请参阅 docs.python.org/3/library/abc.html 的 Python 文档。

如果你熟悉像 Java 或 C++ 这样的面向对象语言,你可能已经知道 ABC 的概念。

基类是一个父类,其他类可以从它派生。类似地,你可以有一个抽象基类,并创建继承这个类的其他类。那么,区别在哪里呢?其中一个主要区别是 ABC 不能被实例化。但这并不是唯一的区别。ABC 强制派生类实现该类内部定义的特定方法。关于 ABC 的这些知识应该足够你处理这本书中的示例。更多细节,请参阅上述 Python 文档链接。让我们回顾一个简单的例子,展示如何在 Python 中实现抽象基类以及它与普通基类的区别。abc 模块提供了必要的框架。以下代码片段比较了 ABC 的实现与普通基类的实现:

Python 中的抽象基类

左侧的类 AbstractGameUnit 是抽象基类,而右侧的 GameUnit 类是一个普通基类。ABC 实现中的三个区别用数字标记,如前面的截图所示。

  • 使用 metaclass=ABCMeta 参数定义 AbstractGameUnit 为一个 ABC。

  • ABCMeta 是一个用于定义抽象基类的 元类。这是一个广泛讨论的话题,但元类的简化意义如下:要创建一个对象,我们使用一个类。同样,想象一下元类是用于创建类的一种类。

  • Python 的 装饰器 提供了一种简单的方法来动态改变方法、类或函数的功能。这是一种特殊的 Python 语法,以一个 @ 符号开始,后跟装饰器名称。装饰器直接放在方法定义之上。

  • @abstractmethod 是一个装饰器,它使得下一行定义的方法成为一个抽象方法。

  • 抽象方法是 ABC 要求所有子类必须实现的方法。在这种情况下,AbstractGameUnit要求其Knight子类实现info()方法。如果子类没有实现此方法,Python 将简单地不会实例化该子类,并抛出TypeError。您可以尝试删除Knight.info方法并运行代码来测试这一点。

  • 如果Knight类继承自一个普通基类,例如GameUnit,则没有这样的限制。

小贴士

这里展示的代码是为 Python 3.5 版本准备的。对于 2.7 版本,语法有所不同。请参阅支持材料中 Python2 目录下的ch01_ex03_AbstractBaseClass.py文件以获取等效示例。

练习

ch01_ex03.py文件中,您将看到一些注释。这些注释是故意保留的,以给您一个改进代码部分的机会。此代码中有许多改进的空间。看看您是否可以重写代码的部分,使其更加健壮。如果您更喜欢一个定义明确的问题,这里有一个:

KnightOrcRider类继承自GameUnit超类。这个练习是将GameUnit转换为抽象基类AbstractGameUnit。以下是一个为您准备的速查表;以下图中显示的代码骨架是 Python 3.5 的语法。

请参阅ch01_ex03_AbstractBaseClass.py文件:

练习

小贴士

注意,对于 Python 2.7,此代码有一个单独的版本。请参阅支持代码包中的src_ch1_Python2目录。

摘要

在本章中,我们简要介绍了 Python 的一些基本概念,以开发一个简单的命令行应用程序。我们首先通过设置 Python 开发环境来装备自己。

我们编写的第一个程序是一个简单的 Python 脚本。我们很快意识到,如果添加更多功能,简单的脚本将很难维护。作为下一步,我们进行了一些重构,并将代码封装在函数中。这提高了代码的可读性,也使其更容易管理。向应用程序引入更多功能使我们重新思考了设计。我们学习了如何将代码转换为面向对象的设计,并实现了这些新功能中的几个。

我们怎能忘记 Sir Foo!他将陪伴我们走过整本书。

代码是否开发得没有错误?您在玩游戏时可能已经注意到了一些问题!在下一章中,我们将看到如何通过处理异常来使应用程序更加健壮。

对于电子书阅读器的重要提示

您在这本书中看到的代码示例实际上是图像文件或代码快照。

这些图像的渲染质量将根据您的 PDF 阅读器的页面显示分辨率和缩放级别而有所不同。

如果您在阅读此代码时遇到困难,您可以在您的 PDF 或电子书阅读器中尝试以下操作:

  • 将缩放级别设置为 100%

  • 使用 96 像素/英寸或类似的页面显示分辨率

如果问题仍然存在,你可以尝试使用不同的分辨率。

你如何设置这个分辨率?这取决于你的电子书阅读器。例如,如果你正在使用 Adobe Reader,请转到编辑 | 首选项,然后从左侧面板中选择页面显示。你将在右侧面板中看到分辨率作为选项。选择96 像素/英寸或类似的设置,看看是否有助于更好地渲染图像。

第二章 处理异常

在上一章中,我们从一个简单的命令行脚本开始,逐渐将其转换为面向对象的代码。在这个过程中添加了几个新功能。到目前为止,我们很少关注应用程序质量。我们忽略了在程序执行过程中遇到的任何明显错误。在应用程序运行时检测到的这些错误被称为异常。在本章中,你将学习通过处理异常来使应用程序更加健壮的技术。

具体来说,我们将涵盖以下主题:

  • Python 中有哪些异常?

  • 使用 try…except 子句控制程序流程

  • 通过处理异常处理常见问题

  • 创建和使用自定义异常类

让我们从回顾用户反馈开始。

重新审视《兽人攻击》v1.0.0

在 v1.0.0 中添加的恢复功能在核心用户中非常受欢迎。面向对象的方法使你能够更好地实现新功能(或者你认为如此!)。随着功能请求的涌入,报告的 bug 也越来越多。

游戏还可以,但有几个令人烦恼的地方。例如,当被提示选择小屋时,有时我会输入大于 5 的数字或误输入字符。之后,它只打印一些奇怪的错误信息,应用程序就终止了。你能修复这个问题吗?

重新审视《兽人攻击》v1.0.0

调试问题

让我们尝试重现报告的问题。运行来自 第一章 的示例,开发简单应用程序

$ python ch01_ex03.py

当提示输入小屋编号时,输入任何字符,如下面的截图所示:

调试问题

应用程序在控制台中出现错误跟踪回溯后被终止。跟踪回溯是在异常(错误)发生时的调用栈快照。在这个特定的例子中,_process_user_choice 方法是由 play 方法调用的,而 play 方法直接从模块中调用。行号显示了这些调用发生的位置。这对于调试很有用。在这种情况下报告的错误是 ValueError。它发生是因为我们假设用户选择的是一个整数。另一个报告的问题是当小屋编号不在 1 到 5 的范围内时。收到的跟踪回溯错误是 IndexError。它发生在访问用户输入对应的 huts 列表条目时:

调试问题

如果你仔细查看两个跟踪回溯,这两个错误都发生在 AttackOfTheOrcs 类的 _process_user_choice 方法中。让我们回顾一下原始方法:

调试问题

好的!我们已经确定了问题的所在。现在,下一个任务是修复这些错误。

修复错误…

Sir Foo 对修复错误有一些想法…

修复错误…

当然。修复报告问题的方法之一是添加条件块,以确保用户输入是介于 1 和 5 之间的数字。

但像许多其他语言一样,Python 提供了一种优雅的方式来处理这种情况,即使用try…except子句。它基于更容易请求原谅而不是请求许可EAFP)原则。

注意

EAFP 原则

在编码时,你假设某些事物存在,并相应地编写代码。但如果这证明是一个错误的假设,你将通过捕获那个例外来请求原谅。这是在 Python 开发中非常常见的一种方法。你可以查看 Python 3 文档(docs.python.org/3/glossary.html),其中定义了这个习语。在某些情况下,与使用if条件块相比,异常处理可能会影响性能;然而,当你使用try…except子句时,你很可能会发现更多的好处而不是坏处。

例外

在直接跳入代码并修复这些问题之前,让我们首先了解什么是例外,以及我们所说的处理例外是什么意思。

什么是例外?

在 Python 中,例外是一个对象。它为我们提供了关于程序执行过程中检测到的错误的信息。在调试应用程序时注意到的错误是未处理的例外,因为我们没有预料到这些错误。在接下来的章节中,你将学习处理这些例外情况的技术。

在早期的跟踪信息中看到的ValueErrorIndexError例外是 Python 中内置例外类型的例子。在下一节中,你将了解 Python 支持的一些其他内置例外。

最常见的例外

让我们快速回顾一些最常遇到的例外情况。最简单的方法是尝试运行一些有问题的代码,让它报告问题作为错误跟踪信息!启动你的 Python 解释器,并编写以下代码:

最常见的例外

这里有一些额外的例外情况:

最常见的例外

如你所见,代码的每一行都会抛出一个带有例外类型的错误跟踪信息(显示为高亮)。这些是 Python 中的一些内置例外。可以在docs.python.org/3/library/exceptions.html#bltin-exceptions找到内置例外的完整列表。

Python 为所有内置例外提供了BaseException作为基类。然而,大多数内置例外并不直接继承自BaseException。相反,它们从一个称为Exception的类中派生,该类反过来又继承自BaseException。处理程序退出的内置例外(例如,SystemExit)直接从BaseException派生。你还可以创建自己的例外类作为Exception的子类。你将在本章后面了解这一点。

异常处理

到目前为止,我们已经看到了异常的发生。现在,是时候学习如何使用try…except子句来处理这些异常了。以下伪代码展示了try…except子句的一个非常简单的例子:

异常处理

让我们回顾一下前面的代码片段:

  • 首先,程序尝试执行try子句中的代码。

  • 在这个执行过程中,如果出现问题(如果发生异常),它将跳出这个try子句。try块中剩余的代码将不会执行。

  • 然后它将在except子句中寻找合适的异常处理器并执行它。

这里使用的except子句是一个通用的。它将捕获try子句中发生的所有类型的异常。与其使用这个“捕获所有”的处理程序,不如更好地实践是捕获你预期的错误,并为这些错误编写特定的异常处理代码。例如,try子句中的代码可能会抛出AssertionError。而不是使用通用的except子句,你可以编写一个特定的异常处理器,如下所示:

异常处理

这里,我们有一个专门处理AssertionErrorexcept子句。这也意味着除了AssertionError之外的其他错误将作为一个未处理的异常滑过。为此,我们需要定义多个具有不同异常处理器的except子句。然而,在任何时候,只有一个异常处理器会被调用。这可以通过一个例子更好地解释。让我们看看以下代码片段:

异常处理

try块调用solve_something()。这个函数接受一个用户输入的数字,并断言这个数字大于零。如果断言失败,它将直接跳转到处理器,except AssertionError

在另一种场景中,如果a > 0solve_something()中的其余代码将被执行。你会注意到变量x未定义,这导致NameError。这个异常由另一个异常子句except NameError处理。同样,你可以为预期的错误定义特定的异常处理器。

抛出和重新抛出异常

Python 中的raise关键字用于强制抛出一个异常。换句话说,它引发了一个异常。语法很简单;只需打开 Python 解释器并输入:

>>> raise AssertionError("some error message")

这会产生以下错误跟踪信息:

Traceback (most recent call last): 
 File "<stdin>", line 1, in <module> 
AssertionError :  some error message

在某些情况下,我们需要重新抛出一个异常。为了更好地理解这个概念,这里有一个简单的场景。假设,在try子句中,你有一个除以零的表达式。在普通算术中,这个表达式没有意义。这是一个错误!这会导致程序抛出一个名为ZeroDivisionError的异常。如果没有异常处理代码,程序将只打印错误消息并终止。

如果您希望将此错误写入某个日志文件然后终止程序呢?在这里,您可以使用 except 子句首先记录错误。然后,使用不带任何参数的 raise 关键字重新抛出异常。异常将向上传播到堆栈中。在这个例子中,它将终止程序。可以使用不带任何参数的 raise 关键字重新抛出异常。

这里有一个示例,展示了如何重新抛出异常:

抛出和重新抛出异常

如所示,在解决 a/b 表达式时抛出了除以零的异常。这是因为变量 b 的值被设置为 0。为了说明目的,我们假设没有为这个错误指定特定的异常处理器。因此,我们将使用通用的 except 子句,在记录错误后重新抛出异常。如果您想亲自尝试,只需将前面展示的代码写入一个新的 Python 文件,并在终端窗口中运行它。以下截图显示了前面代码的输出:

抛出和重新抛出异常

try…except 的 else 块

try…except 子句中可以指定一个可选的 else 块。只有当 try…except 子句中没有发生异常时,else 块才会执行。其语法如下:

try…except 的 else 块

else 块在 finally 子句之前执行,我们将在下一节学习。

finally...清理它!

try…except…else 的故事中还有其他要补充的内容:一个可选的 finally 子句。正如其名所示,此子句中的代码在相关的 try…except 块结束时执行。无论是否抛出异常,如果指定了 finally 子句,它将肯定在 try…except 子句结束时执行。想象一下,这是 Python 给出的一个全方位的保证!以下代码片段显示了 finally 块的实际操作:

finally...清理它!

运行此简单代码将产生以下输出:

$ python finally_example1.py 
Enter a number: -1
Uh oh..Assertion Error. 
Do some special cleanup 

输出的最后一行是 finally 子句中的 print 语句。

finally...清理它!

这是一个很好的问题!让我们给这个故事加一个转折。如果 except 子句中的新代码强制函数返回,会发生什么?在这种情况下,您的解决方案会执行前面截图中的最后一行代码吗?

以下截图显示了带有和没有 finally 子句的代码片段。finally 子句中的代码确保在 except 子句指示从函数返回之前执行。

finally...清理它!

finally 子句通常用于在离开函数之前执行清理任务。一个示例用法是关闭数据库连接或文件。然而,请注意,为此目的,您也可以在 Python 中使用 with 语句。

回到游戏 – 奥克之攻 v1.1.0

在了解了异常处理的知识后,让我们开始工作,制作应用程序的下一个增量版本。

准备工作

在编写任何代码之前,让我们首先了解本节的其他部分是如何组织的。简而言之,我们将从 第一章,开发简单应用程序 的代码 v1.0.0 版本开始,逐步添加异常处理代码,并调用新的版本 v1.1.0。

注意

支持代码包中的 Python 文件已经包含了本节以及本章后续部分“定义自定义异常”中将要讨论的异常处理代码。

以下要点进一步阐述了详细内容:

  • 我们将首先从 第一章,开发简单应用程序 下载游戏的 v1.0.0 版本。文件名为 ch01_ex03_AbstractBaseClass.py(回想一下,这是在 第一章,开发简单应用程序 中提供的练习题的解决方案)。你可以在这个章节的代码包中找到此文件。

  • 将上述文件与 ch01_ex03.py 进行比较。这里唯一的区别是使用了抽象基类 AbstractGameUnit 而不是普通基类 GameUnit。其余代码完全相同。

  • 让我们复制 ch01_ex03_AbstractBaseClass.py 并将其保存为 attackoftheorcs_v1_1.py,或者你可以给它起任何你喜欢的名字。在接下来的讨论中,我们将以此新名字来引用该文件,并逐步向其中添加异常处理代码。

  • 如前所述,支持代码包中包含了我们将要审查的所有异常处理代码。你将在代码包中找到一个同名文件(attackoftheorcs_v1_1.py),其中包含了所有更改。

添加异常处理代码

这将基本上是一个修复错误的版本,不会添加任何新功能。之前所做的调试已经帮助我们找到了问题所在。打开 Python 文件(attackoftheorcs_v1_1.py),并更新 AbstractGameUnit 类的 _process_user_choice 方法。带有新 try…except 子句的此方法更新版本如下所示:

添加异常处理代码

小贴士

如果你之前错过了阅读这部分内容,你应该复制 ch01_ex03_AbstractBaseClass.py 文件,并将其命名为 attackoftheorcs_v1_1.py。然后使用这个新文件添加前面的异常处理代码。或者,你也可以简单地查看代码包中提供的同名文件。它包含了我们将要讨论的所有更改。Python 2.7.9 兼容的源文件也包含在代码包中。

让我们回顾一下前面的代码:

  • try 子句中,如果 user_choice 变量不是一个数字,将发生 ValueError 异常,该异常由 except ValueError as e 处理。

  • 使用as关键字将异常分配给e对象

  • 或者,你也可以直接使用语法except ValueError

  • 第二个try…except子句处理输入数字超出huts列表范围的情况

  • 当发生IndexError异常时,except子句中的continue语句会使用户重新输入输入值

这就是我们需要的所有内容。现在,让我们运行应用程序。

运行《奥克之攻》v1.1.0

是时候运行应用程序,看看这些更改是否解决了报告的问题。在终端窗口中运行程序,如下代码片段所示:

$ python attackoftheorcs_v1_1.py

当提示输入时,输入一个不可接受的棚屋编号值:

运行《奥克之攻》v1.1.0

看起来不错!至少报告的问题已经解决。很容易找到更多这样的错误。例如,用户在选择棚屋时还可以输入 0 或负数,或者当程序请求允许攻击敌人时,任何除yn之外的输入都没有得到优雅的处理。作为练习,尝试自己修复这些问题!

定义自定义异常

你可以通过从Exception基类或任何其他异常类继承来自定义异常类。为什么我们需要这样的定制?首先,你可以创建一个具有描述性名称的异常类。这允许我们仅通过查看描述性名称就能识别异常的目的。例如,而不是ValueError,一个名为ValueGreaterThanFiveError的自定义异常将立即帮助我们识别问题。还有其他优点。你可以使用这样的类根据错误子类别添加自定义消息、编写错误日志等。让我们学习如何定义自定义异常。

准备工作

这里是我们将使用的文件列表:

  • attackoftheorcs_v1_1.py: 这是上一节中我们将使用的文件。如前所述,支持代码包已经有一个同名文件。它包括我们将讨论的所有修改。

  • gameuniterror.py: 这是一个新的模块,用于存放自定义异常类。

  • heal_exception_example.py: 这里将编写顶层控制代码。这是一个简化版的游戏,我们不需要玩完整游戏就能重现问题。

你需要将所有上述文件放在同一个目录下。

自定义异常 – 问题

为了演示自定义异常的使用,让我们识别一个简单的问题。观察下面显示的heal方法(回想一下,它定义在Knight的父类AbstractGameUnit中)。你可以在attackoftheorcs_v1_1.py文件中找到它。

自定义异常 – 问题

该方法有两个可选参数。如果 full_healing 设置为 True,游戏单位将恢复所有失去的生命值。另一个选项 heal_by 通过一小部分来 heal 游戏单位。在这个版本中,我们没有使用 heal_by 选项。但在未来的版本中,你可能想在游戏中引入回合制功能,其中受伤的单位在每个回合中通过一小部分来恢复健康*。

为了演示如何创建和使用自定义异常,让我们在 heal_by 功能中引入一个人工错误!将以下代码保存为 heal_exception_example.py 并将此文件放置在 attackoftheorcs_v1_1.py 相同的目录中。

自定义异常 – 问题

这是一个简化版的游戏,我们不需要玩完整游戏就能创建这个人工错误!这是一个顶层控制代码,它创建一个 Knight 实例,强制减少生命值(查看 knight.health_meter),就像骑士已经战斗并受伤一样。最后,它使用 heal_by 参数调用 heal 函数。

你注意到这里有什么问题吗?回想一下,knight 实例的最大生命值是 40(查看实例属性 Knight.max_hp)。前面的代码试图使用 heal_by 参数通过 100 点来 heal 骑士。显然,这将超过限制。防止这种情况的一种方法是在 heal 方法中添加一个断言语句,如下面的代码片段所示:

assert (self.health_meter + heal_by  <= self.max_hp)

这将引发一个 AssertionError。这是一个可接受的解决方案。另一种实现方式是使用自定义异常类。接下来将演示。

编写新的异常类

Exception 类派生一个新的异常类是非常简单的。打开你的 Python 解释器,创建以下类:

>>> class GameUnitError(Exception):
...     pass
... 
>>>

那就全部了!我们有一个新的异常类 GameUnitError,准备部署。如何测试这个异常?只需 raise 它。在你的 Python 解释器中输入以下代码行:

>>> raise GameUnitError("ERROR: some problem with game unit")

抛出新创建的异常将打印以下跟踪回溯:

>>> raise GameUnitError("ERROR: some problem with game unit")
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
__main__.GameUnitError: ERROR: some problem with game unit

GameUnitError 类复制到其自己的模块 gameuniterror.py 中,并将其保存到与 attackoftheorcs_v1_1.py 相同的目录中。

接下来,更新 attackoftheorcs_v1_1.py 文件以包含以下更改:

  • 首先,在文件开头添加以下导入语句:

    from gameuniterror import GameUnitError
    
  • 第二个更改是在 AbstractGameUnit.heal 方法中。更新的代码如下所示。观察高亮显示的代码,当 self.health_meter 的值超过 self.max_hp 时,会抛出自定义异常。编写新的异常类

通过这两个更改,运行之前创建的 heal_exception_example.py。你将看到新异常被抛出,如下面的截图所示:

编写新的异常类

扩展异常类

我们能否对GameUnitError类做更多的事情?当然可以!就像任何其他类一样,我们可以定义属性并使用它们。让我们进一步扩展这个类。在修改后的版本中,它将接受一个额外的参数和一些预定义的错误代码。更新后的GameUnitError类如下所示:

扩展异常类

让我们看一下前面截图中的代码:

  • 首先,它调用Exception超类的__init__方法,然后定义一些额外的实例变量。

  • 一个新的字典对象self.error_dict持有错误整数代码和错误信息作为键值对。

  • self.error_message根据提供的错误代码存储有关当前错误的信息。

  • try…except子句确保error_dict实际上具有由code参数指定的键。在except子句中并不存在;我们只是使用默认错误代码000来检索值。

现在,让我们看看这个类的消费者。观察修改后的heal方法。这里唯一的改变是向GameUnitError实例添加了一个额外的参数。在这里,我们将错误代码作为第二个参数传递:

扩展异常类

到目前为止,我们已经对GameUnitError类和AbstractGameUnit.heal方法进行了修改。我们还没有完成。最后一部分是要修改heal_exception_example.py文件中的main程序。代码如下所示:

扩展异常类

让我们回顾一下代码:

  • 由于heal_by值太大,try子句中的heal方法抛出了GameUnitError异常。

  • 新的except子句像处理任何其他内置异常一样处理GameUnitError异常。

  • except子句中,我们有两条print语句。第一条打印health_meter > max_hp!(回想一下,当这个异常在heal方法中被抛出时,这个字符串被作为GameUnitError实例的第一个参数)。第二条print语句检索并打印GameUnitError实例的error_message属性。

我们已经完成了所有的更改。我们可以从终端窗口运行这个示例:

$ python heal_exception_example.py

程序的输出如下所示:

扩展异常类

在这个简单的例子中,我们只是将错误信息打印到控制台。你可以进一步将详细的错误日志写入文件,并跟踪应用程序运行期间生成的所有错误消息。

从异常类继承

Sir Foo 有 一些关于之前在GameUnitError.error_dict中维护的错误代码的看法…

从异常类继承

你是正确的。在抛出异常时,你需要记住每个错误号对应的内容。让我们讨论一些替代方案。

一种选择是使用唯一的字符串作为error_dict的键,而不是错误号,例如:

self.error_dict = { 
    'health_meter_problem':"ERROR: Health meter problem!"}

这减轻了记住错误代码的问题。然而,如果你想要做的不仅仅是打印消息,这种方法就不适用了。例如,根据错误类型,你可能想要进行一些额外的处理。

一个更好的方法是使用GameUnitError作为基异常类,并派生出针对特定错误的新的类。这些异常类的描述性名称应该有助于传达相同的信息。以下代码片段展示了如何实现它的一个示例。你可以用以下截图中的代码替换gameuniterror.py中现有的代码:

从异常类继承

现在,在heal方法中,不要抛出GameUnitError异常,而是直接raise``HealthMeterException。确保按照以下代码片段中的指示导入HealthMeterException模块。

从异常类继承

使用上述更改运行代码会产生类似的输出。只是我们修订了HealthMeterException类的error_message。输出如下所示:

$ python heal_exception_example.py 
Creating a Knight..
Health: Sir Bar: 10
health_meter > max_hp!
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
ERROR: Health Meter Problem
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Health: Sir Bar: 110

同样,你也可以创建其他子类来处理特定问题。

练习

识别任何可以从异常处理中受益的代码。例如,创建一个新的HutError异常,并使用它来抛出与Hut类相关的错误。以下是一个速查表:

练习

你也可以不使用error_dict,而是创建子类,例如:

class HutNumberGreaterThanFiveError(HutError): pass
class NegativeHutNumberError(HutError): pass 

摘要

本章介绍了 Python 中异常处理的基础。我们看到了异常是如何发生的,了解了一些常见的内置异常类,并编写了简单的代码来使用try…except子句处理这些异常。通过处理异常,我们修复了“奥克之攻”游戏中的某些明显错误。

本章还演示了技术,例如抛出和重新抛出异常,使用finally子句等。本章的后期部分专注于实现自定义异常类。我们定义了一个新的异常类,并使用它为我们的应用程序抛出自定义异常。

通过异常处理,代码的形态变得更好。然而,我们仍然有大部分代码挤在一个单独的文件(attackoftheorcs_v1_1.py)中。在下一章中,你将学习如何打包应用程序代码并将其发布给更广泛的受众。

第三章。模块化、打包、部署!

在过去几章中,你编写了一个简单的应用程序,为其添加了新功能,并确保修复了一些常见错误。现在,是时候让它面向更广泛的受众了。在本章中,你将学习以下主题:

  • 将前几章编写的代码模块化和打包

  • 准备和部署源代码分发

  • 设置私有 Python 包仓库

  • 制作增量发布

  • 将代码纳入版本控制

多亏了口碑宣传,高奇幻游戏应用正在获得更多的关注。越来越多的人要求获取代码,要么是为了在自己的应用程序中使用功能,要么只是为了简单地玩游戏。到目前为止,你已经向请求的用户发送了完整的源代码。但是,继续这样做是愚蠢的,因为你已经进行了相当频繁的升级。

有几种处理方式。最基本的选择是将代码托管在某个服务器上,并要求用户从该位置下载。另一个选择是使用版本控制系统,如Git来管理代码,并允许其他人克隆它。另一种选择,我们将在下一节中看到,是将它作为 Python 包部署。

模块化、打包、部署!

*别急,先生 Foo!我们首先得做一些准备工作。现在先抑制一下你的热情。顺便说一句,你的军队还远着呢。你将在第六章“设计模式”中与你的战友们重逢。Chapter 6。

选择版本控制约定

我们如何命名代码的新版本?目前有几种版本控制方案在使用中。让我们快速回顾几个流行的方案。

序列递增

在这个方案中,你只需以序列方式逐次增加版本号进行升级,例如,v1、v2、v3,依此类推。然而,这并没有提供关于特定版本内容的任何信息。仅通过查看版本号,很难判断特定版本是引入了革命性功能还是仅仅修复了一个小错误。它也没有提供关于 API 兼容性的信息。如果你有一个小应用,用户基础小,范围有限,可以选择这种简单的版本控制方案。

序列递增

注意

API 兼容性

简单来说,应用程序编程接口API)允许程序的一部分,比如库或应用程序,通过一组标准的函数、方法或对象与另一部分进行通信。

想象一个名为car的软件库,它存储了一些关于豪华汽车的数据。你的应用程序希望获取一些关于汽车颜色的信息。库会说:“只需调用我的color()方法即可获取所需信息。”在这里,color()方法是car库的 API 方法。有了这个信息,你开始在应用程序中使用car.color()

car库的最新版本中,color()已被重命名为get_color()。如果您切换到这个新版本,由于您仍然使用car.color()从库中检索颜色信息,您的应用程序代码将会中断。在这种情况下,新的 API 被认为与库的旧版本不兼容。相反,向后兼容的 API 是指使用库旧版本的应用程序即使在新的版本中也能正常运行。这是看待 API 兼容性的一个方法。

使用日期格式

在这个约定中,版本名称通过嵌入发布时间的信息来标记。例如,它可能遵循 YYYY-MM 约定来包含发布年份和月份。这样的约定有助于确定特定发布的年龄。然而,正如之前一样,除非您遵循某种混合命名约定,否则发布名称本身不会提供有关 API 兼容性的任何信息。这个方案通常在您遵循常规发布计划或发布中包含一些时间敏感功能时很有用。

语义版本控制方案

这是一个推荐的版本控制约定。在我们迄今为止开发的应用中,我们大致遵循了语义版本控制方案。在这个方案中,版本通过三个数字(MAJOR.MINOR.PATCH)来表示。例如,当我们说版本 1.2.4 时,这意味着主版本号是 1,次要版本是 2,补丁或维护版本号是 4。当你向 API 引入不兼容的更改以访问你的包的功能时,主版本号会增加。当向包中添加一些新的次要功能,同时保持代码向后兼容时,次要版本会增加。例如,你向下一个版本添加一个新内部功能,但这不会破坏上一个版本的任何代码。访问包功能的 API 与之前保持相同。最后一个数字代表补丁。当修复了一些错误时,它会增加。

小贴士

Python PEP 440 规范深入讨论了 Python 发行版的语义版本控制方案。这是 Python 社区推荐的。您可以在www.python.org/dev/peps/pep-0440/找到这个规范。选择最适合您应用的版本控制约定。

本书中所展示的版本控制方案仅大致遵循语义版本控制。例如,在早期的示例中,在修复了一些重要错误之后,我们更新了次要版本号而不是补丁版本号。

在理解了各种版本控制约定之后,让我们回到奥克之攻代码,并将其拆分为独立的模块。这将是我们创建包的第一步。

代码模块化

我们在前面几章中提到了模块。现在需要对此进行解释。具有 .py 扩展名的单个 Python 文件是一个 模块。您可以使用 import 语句在源代码中使用此模块。模块名称与文件名相同,只是没有 .py 扩展名。例如,如果文件名是 knight.py,则 import knight 将将模块导入到您的源文件中。

在本节中,我们将 attackoftheorcs_v1_1.py 文件中的代码拆分为单独的模块。您可以在上一章的支持代码包中找到此文件。

《奥克之战》v2.0.0 版

我们将这个版本命名为 2.0.0。由于我们即将进行一些 API 级别的更改,主版本号增加到 2。引入新模块后,我们从代码中访问功能的方式将发生变化。让我们回顾一下来自 第二章,处理异常 的源文件 attackoftheorcs_v1_1.py。第一步是为每个类创建一个模块(一个新文件)。模块名称最好是全部小写。

奥克之战 v2.0.0

让我们看一下前面的截图中的代码:

  • 创建一个名为 gameutils.py 的新模块,并将 weighted_random_selectionprint_bold 这两个实用函数复制到该模块中。

  • attackoftheorcs.py 文件包含 AttackOfTheOrcs 类。在同一文件中,复制运行游戏的主体执行代码。可选地,为主要的代码创建一个新的模块。

  • 参考前面的截图中的代码,并将其他类放入它们自己的模块中。

我们还没有完成。将代码拆分为多个模块会导致未解决引用。我们现在需要修复这些新错误。之前这并不是一个问题,因为整个代码都在一个文件中。例如,在 AttackOfTheOrcs 类中创建 Hut 实例时,Python 可以在同一个文件中找到 Hut 类的定义。现在,我们需要从各自的模块中导入这些类。

小贴士

如果您正在使用 PyCharm 等集成开发环境 (IDE),则可以使用 代码检查 功能轻松检测此类未解决引用。IDE 将为所有问题引用显示视觉指示(例如,红色下划线)。此外,Inspect Code 功能允许您一次性找到所有问题代码。

attackoftheorcs.py 文件的开始处添加以下 import 语句:

奥克之战 v2.0.0

在这里,我们从新模块 hut 中导入 Hut 类,等等。以下代码截图显示了 knight.py 文件中的 import 语句:

奥克之战 v2.0.0

以下代码截图显示了 abstractgameunit.py 文件中的 import 语句:

奥克之战 v2.0.0

同样,您必须更新所有剩余的文件并包含必要的 import 语句。这些更改在此不讨论。有关更多详细信息,您可以参考本章支持代码包中的相应文件。

将所有新模块放在一个目录中,命名为 wargame 或您喜欢的任何名称。回想一下,在 第二章 中,我们曾在 gameuniterror.py 文件中创建了一个名为 GameUnitError 的类。请确保将此文件复制到新目录中。复制 gameuniterror.py 后的目录结构如下截图所示:

Orcs 攻击 v2.0.0

作为最后一步,让我们通过执行以下命令来验证应用程序是否运行顺畅:

$ python attackoftheorcs.py

其中 python 版本可以是 3.5 或 2.7.9(或更高版本),具体取决于您的环境。

创建一个包

现在我们已经模块化了代码,让我们创建一个 Python 包。什么是包?它是一种对 Python 模块所在目录的别称。然而,它不仅仅如此。为了使这样的目录被称为包,它还必须包含一个 __init__.py 文件。这个文件可以保持为空,或者你可以在这个文件中放入一些初始化代码。为了将 wargame 目录转换为 Python 包,我们将在该目录中创建一个空的 __init__ .py 文件。新的目录结构如下截图所示:

创建包

从包中导入

让我们看看如何使用这个新创建的包的功能。为了测试这一点,在 wargame 包的同一目录级别创建一个新的文件,名为 run_game.py。目录结构将如下所示。在这里,mydir 是顶级目录(可以是任何名称):

从包中导入

将以下代码添加到 run_game.py 文件中:

从包中导入

第一行是新的 import 语句。在这里,我们是从 attackoftheorcs.py 文件中导入 AttackOfTheOrcs 类。如果您在终端窗口中执行此文件,程序可能会突然结束,并显示以下代码中的错误跟踪:

$ python run_game.py
Traceback (most recent call last): 
 File "run_game.py", line 2, in <module> 
 from wargame.attackoftheorcs import AttackOfTheOrcs 
 File "/mydir/wargame/attackoftheorcs.py", line 29, in <module> 
 from hut import Hut 
ImportError: No module named 'hut'

如果 wargame 目录路径未包含在 Python 环境中,将会出现此类错误。在错误跟踪中,无法找到 hut.py 文件。该文件位于 /mydir/wargame/hut.py。然而,位置 /mydir/wargame 不在 Python 的搜索路径中。因此,它无法找到该目录中的模块。有几种方法可以解决这个问题。最简单的方法是在终端中指定一个 PYTHONPATH 环境变量。在 Linux 操作系统的 Bash shell 中,可以这样指定:

$ export PYTHONPATH=$PYTHONPATH:/mydir/wargame

在 Windows 操作系统上,您可以从命令提示符设置它,如下所示:

> set PYTHONPATH=%PYTHONPATH%;C:\mydir\wargame

只需将 /mydir/wargame 替换为您系统上的适当路径。另一种修复问题的方法是,在 run_game.py 代码中的 import 语句之前,添加一个 sys.path.append ("/mydir/wargame") 语句,如下面的代码所示:

import sys 
sys.path.append("/mydir/wargame") 
from wargame.attackoftheorcs import AttackOfTheOrcs

然而,使用这两种选项,您都必须指定完整路径。另一种处理问题的方法是在 wargame/__init__.py 文件中添加以下代码:

import sys 
import os 
current_path = os.path.dirname(os.path.abspath(__file__)) 
sys.path.append(current_path) 
# optionally print the sys.path for debugging)
#print("in __init__.py sys.path:\n ",sys.path)

当前路径给出了 __init__.py 文件所在目录的绝对路径。通过这次更新,您应该已经准备好运行游戏了。

在 PyPI 上发布包

Python 包索引 (PyPI) (pypi.python.org/pypi) 是 Python 社区的包分发机制。它是第三方包的官方仓库。默认情况下,Python 包管理器 pip 会搜索这个仓库来安装包。

这是我们上传源分发的地方,使其对 Python 社区普遍可用。PyPI 仓库有一个专门的 测试服务器 (testpypi.python.org/pypi),供刚开始学习打包代码的开发者使用。由于这是一个学习活动,我们将首先在测试服务器上部署我们的包。

准备分发

让我们从为发布奠定基础开始。我们首先需要准备要发布的分发。以下步骤提供了一组最小指令来准备分发。

步骤 1 – 设置包目录

创建一个新的目录,命名为 testgamepkg 或您喜欢的任何名称。在这个目录中,复制我们之前创建的 wargame 包。现在,在这个目录中创建以下四个空文件,READMELICENSE.txtMANIFEST.in 和一个 setup.py 文件。目录树如图所示:

步骤 1 – 设置包目录

小贴士

不需要创建新的目录 testgamepkg。相反,您可以在包含 wargame 包的同一目录中创建这四个文件。所有这些文件也可以在本章的支持材料中找到。

接下来,我们将向每个新文件添加内容。

步骤 2 – 编写 setup.py 文件

setup.py 文件是一个必需的文件,其中包含您要发布的包的元数据。让我们在这个文件中写下以下代码:

步骤 2 – 编写 setup.py 文件

第一行的 import 语句导入内置的 setup 函数。在接下来的几行中,README 文件的内容被存储在一个名为 readme 的字符串中。最后,我们使用前面代码片段中所示的各种参数调用 setup 函数。

在这些参数中,只有nameversionpackages是必需字段。您可以为setup函数添加几个其他可选元数据参数。在前面的代码中,我们指定了最常见的参数。

小贴士

setup函数接受多个可选参数。有关详细信息,请参阅 API 参考(docs.python.org/3/distutils/apiref.html)。

在代码中,使用一个唯一的字符串更新name字段。确保该名称尚未被用作 PyPI 软件包的名称。version字段表示软件包的当前版本。在本章的早期部分,我们给模块化代码指定了版本号 2.0.0。你可以采用这个方案,或者使用自己的版本控制约定。第三个必需的字段packages是一个包含在分发中的源软件包列表。在这种情况下,它只是包含所有代码的wargame软件包。存储在long_description字段中的字符串用于在 PyPI 网站上显示软件包的主页。在代码中,我们将README文件的内容作为long_description

第 3 步 – 更新 README 和 LICENSE.txt 文件

LICENSE.txt文件中,只需复制你想要发布软件包的许可描述即可。例如,如果你正在根据MIT 许可协议opensource.org/licenses/MIT)分发此软件包,请将 MIT 许可描述复制并粘贴到该文件中。

README文件是您可以添加项目详细描述的文件。PyPI 期望此文件以reStructuredTextRST)或.rst格式存在。有关此格式的更多信息,请参阅docutils.sourceforge.net/rst.html。以下是README文件的示例。请注意,每个标题之前和.. code:: python关键字之后的新行都很重要:

Attack of the Orcs 
================== 

Introduction 
------------- 
This is a command line fantasy war game! 

Documentation 
-------------- 
Documentation can be found at... 

Example Usage 
------------- 
Here is an example to import the modules from this package. 

.. code:: python 

    from wargame.attackoftheorcs import AttackOfTheOrcs 
    game = AttackOfTheOrcs() 
    game.play() 

LICENSE 
------- 
See LICENSE.txt file.

第 4 步 – 更新MANIFEST.in文件

默认情况下,distutils在创建分发时包含以下文件:

  • READMEREADME.txtsetup.pysetup.cfg文件位于顶级分发目录中

  • setup.py*.py文件列表中隐含的所有文件

  • 所有test/test*.py文件

  • setup.py中的librariesext_modules指示的C源文件

但如果你想在项目中包含一些额外的文件怎么办?例如,我们希望将LICENSE.txt与分发一起打包。由于默认情况下没有提供添加它的方法,它将不会被包含。为此,distutils会查找一个名为MANIFEST.in的模板文件,其中可以指定自定义规则以包含额外的文件。

让我们编辑MANIFEST.in文件,并为LICENSE.txt的包含制定一个规则。将以下行添加到该文件并保存:

include *.txt 

此模板中的每一行代表一个命令。前面的行告诉 Python 包含顶级分发目录中的所有 .txt 文件。因此,LICENSE.txt 现在将被包含在分发中。

所有文件现在都已更新。现在是时候构建分发啦!

第 5 步 – 构建部署就绪的分发

让我们创建一个源分发。在终端窗口中,运行以下命令:

$ cd testgamepkg
$ python setup.py sdist 

sdist 命令创建一个包含源文件的分发。运行第二个命令创建一个包含存档文件的新 dist 目录。例如,在 setup.py 中,如果 name 字段是 testgamepkg 且版本是 2.0.0,则存档在 Linux 上将是 testgamepkg-2.0.0.tar.gz,在 Windows OS 上将是 testgamepkg-2.0.0.zip

此外,它创建一个包含包中所有包含文件的 MANIFEST 文件。以下截图显示了运行 python setup.py sdist 命令后的命令行输出:

第 5 步 – 构建部署就绪的分发

注意

创建 bdist

sdist 命令创建一个源分发。本章中的示例将仅使用 sdist。然而,你也可以创建一个构建分发。创建构建分发的最简单方法是 $ python setup.py bdist。这将在你的平台上创建一个默认的构建分发,例如在 Linux OS 上的 dist/testgamepkg-2.0.0.linux-x86_64.tar.gz。作为一个练习,创建这个分发并查看存档的内容。创建 bdist 的另一种方法是使用一个名为 wheel 的 Python 包(pypi.python.org/pypi/wheel)。它是一种构建包格式,尽管使用 wheel 需要一些工作。你可以尝试这个作为另一个练习。你可能需要执行以下操作:

$ pip install pip --upgrade 
$ pip install wheel 
$ pip install setuptools –upgrade

然后,将以下 import 语句添加到 setup.py 文件中:import setuptools。最后,运行命令 $ python setup.py bdist_wheel。这将创建一个在 dist 目录下具有 .whl 扩展名的分发存档。

上传分发

分发已准备好部署。现在让我们部署它!

第 1 步 – 在 PyPI 测试网站上创建账户

如果你没有 PyPI 测试网站的账户,请在 testpypi.python.org/pypi?:action=register_form 上创建一个。按照该网站上的步骤创建新账户。

第 2 步 – 创建 .pypirc 文件

这是一个重要的步骤。Python 假设上传分发的默认仓库是 pypi.python.org/pypi。然而,PyPI 测试服务器有一个不同的地址,需要在 .pypirc 文件中指定(注意名称开头的点)。此文件具有特殊格式。将以下内容添加到 .pypirc 文件中:

[distutils] 
index-servers= 
pypitest 

[pypitest] 
repository = https://testpypi.python.org/pypi 
username=<add username>
password=<add password>

该文件在 [pypitest] 标题下包含 PyPI 测试仓库的详细信息。在此文件中,你可以存储不同的配置文件。在这里,[pypitest] 是一个存储仓库 URL 和 PyPI 测试仓库用户凭据的配置文件。这提供了一个方便的方法来指定注册或上传发行版时的账户凭据和仓库 URL。配置文件名可以更改为任何其他字符串,只要更新index-servers变量中的对应条目。例如,你可以将其命名为 [test]。如果你在 PyPI 或 PyPI 测试网站上拥有多个账户,你也可以创建多个这样的配置文件。

在此文件中,使用你的实际凭据更新usernamepassword字段,并保存文件。在 Linux 操作系统上,将此文件放在用户主目录中:~/.pypirc。在 Windows 操作系统上,在C:\Users\user_name\.pypirc处创建它。将user_name替换为实际的用户名。

步骤 3 – 注册你的项目

注册你的项目的一个简单方法是登录到测试 PyPI 网站,然后使用包提交表单:testpypi.python.org/pypi?:action=register_form

或者,项目注册也可以通过命令行完成。打开一个终端窗口,并输入以下命令。将/path/to/testgamepkg替换为包含setup.py的实际路径:

$ cd /path/to/testgamepkg
$ python setup.py register -r pypitest

register命令的-r选项用于指定 PyPI 测试仓库的 URL。请注意,我们在这里没有直接写 URL,而是简单地写了配置文件名,pypitest。或者,你也可以指定完整的 URL,如下面的命令所示:

$ python setup.py register -r  https://testpypi.python.org/pypi

以下截图显示了命令执行后的输出:

步骤 3 – 注册你的项目

如果你登录到测试 PyPI 网站,你将看到一个名为你选择的唯一名称的新项目(在这个例子中,它是testgamepkg)。

步骤 4 – 上传包

最后,是时候上传包了。这可以通过以下命令完成:

$ python setup.py sdist upload -r pypitest

此命令执行了两件事。首先,使用sdist命令创建源分布,然后使用upload命令将源分布上传到 PyPI 测试仓库。

步骤 4 – 上传包

这是一个很好的观点,Sir Foo!在准备发行版部分(见步骤 4 – 更新 MANIFEST.in 文件,我们确实使用了* python setup.py sdist *命令来创建发行版。

在本书编写时,setuptools 没有提供上传现有发行版的选项——发行版的创建和上传需要在单个命令中完成。好消息是,有一个名为 twine 的第三方 Python 包,它允许上传已创建的发行版。

此包可以使用 pip 安装:

$ pip install twine

这将在与你的 Python 可执行文件相同的目录下安装 twine。例如,如果 Python 3 通过 /usr/bin/python 访问,那么 twine 可以通过 /usr/bin/twine 访问。现在,按照以下方式上传现有的源发行版:

$ twine upload -r pypitest dist/* 
Uploading distributions to https://testpypi.python.org/pypi 
Uploading testgamepkg-2.0.0.tar.gz 

现在这个发行版可供任何人下载和安装到 PyPI 测试仓库!要验证这一点,请访问 PyPI 测试站点上的包主页,https://testpypi.python.org/pypi/your_package_name。以下截图显示了 testgamepkg2.0.2 版本的主页:

步骤 4 – 上传包

小贴士

安全提示

对于旧版本的 Python(在 v2.7.9 或 v3.2 之前),当你使用 python seup.py sdist upload 时,会使用 HTTP 连接上传文件。这意味着如果发生网络攻击,你的用户名和密码将面临安全风险!在这种情况下,强烈建议使用 twine 包。它通过验证的连接安全地使用 HTTPS 上传发行版。

对于 Python 2.7.9+ 和 3.2+,HTTPS 是上传发行版的默认选择。但你仍然可以使用 twine 来获得之前讨论的其他优势。有关更多信息,请访问 pypi.python.org/pypi/twine

一个命令就能完成所有操作

现在我们已经知道了所有步骤,让我们将这些三个步骤结合起来,即注册项目、创建发行版,以及将发行版上传到单个命令中。

为了使这生效,我们将在 setup.py 中进行两个小的修改,如下所示:

  1. name 字段更改为另一个唯一的名称。这应该与你在之前步骤中选择的名称不同。

  2. 更新 url 字段以反映这个新名称。

在终端窗口中运行以下命令后,这些更改将生效:

$ python setup.py register -r pypitest sdist upload -r pypitest

这是一个三个命令的序列组合。第一个命令,register -r pypitest,注册一个新的项目;第二个命令,sdist,创建源发行版;最后,第三个命令,upload -r pypitest,将发行版提交到 PyPI 测试仓库!

安装自己的发行版

现在可以使用 pip 安装这个发行版。让我们自己安装它以确保没有问题。运行以下代码片段中的 pip 命令。将 testgamepkg 替换为你上传的发行版名称:

$ pip install -i https://testpypi.python.org/pypi testgamepkg

-i(或者 --index-url)选项指定 PyPI 的基本 URL。如果你不指定此选项,它将默认为 pypi.python.org/simple。以下是执行 install 命令时的一个示例响应:

Collecting testgamepkg 
Downloading https://testpypi.python.org/packages/source/t/testgamepkg/testgame pkg-2.0.0.tar.gz 
Installing collected packages: testgamepkg 
  Running setup.py install for testgamepkg 
Successfully installed testgamepkg-2.0.0

一旦包成功安装,通过调用该包的功能来测试它。例如,启动你的 Python 解释器并编写以下代码:

>>> from wargame.attackoftheorcs import AttackOfTheOrcs 
>>> game = AttackOfTheOrcs() 
>>> game.play()

如果你没有看到任何错误,那么一切按预期工作!现在,我们的用户可以在 PyPI 测试网站上一般性地获取这个发行版。

安装自己的分发

你说得对。我们只讨论了使用 Python 社区仓库的开放分发!如果你想创建私有分发,你应该设置并维护自己的 PyPI 仓库。让我们接下来讨论这个话题。

使用私有 PyPI 仓库

本节将简要介绍如何设置私有 PyPI 仓库。讨论将限于创建一个简单的基于 HTTP 的本地服务器。有几个包可以帮助你完成这项工作。让我们使用一个流行的包,称为 pypiserver (pypi.python.org/pypi/pypiserver)。让我们打开一个终端窗口并准备行动。

步骤 1 – 安装 pypiserver

首先,安装所需的包:

$ pip install pypiserver 

pypi-server 可执行文件位于与 Python 可执行文件相同的目录。例如,如果你有 /usr/bin/python,则 pypi-server 将作为 /usr/bin/pypi-server 可用。

步骤 2 – 构建新的源分发

前往包含 setup.py 和所有其他文件的目录。在前面讨论中,我们将其命名为 testgamepkg

$ cd /path/to/testgamepkg

我们已经在前面部分安装了 testgamepkg。为了简化,在 setup.py 中,让我们将 name 字段更改为其他内容。在此过程中,也请更改 urlversion 字段。带有这些更改的 setup.py 如下截图所示。更改已突出显示:

步骤 2 – 构建新的源分发

现在,让我们创建一个名为 testpkg_private 的新源分发。和以前一样,存档将创建在 dist 目录中:

$ python setup.py sdist 

步骤 3 – 启动本地服务器

接下来,让我们在你的计算机上启动一个本地服务器:

$ pypi-server -p 8081 ./dist

-p 选项用于指定端口号。你可以选择除 8081 以外的数字。命令还接受一个目录作为参数。我们将其指定为 dist 目录。这就是它将搜索你的私有分发包的地方。

步骤 3 – 启动本地服务器

服务器将监听 http://localhost:8081。就这样!在浏览器中打开此 URL。它将显示一个带有说明的简单网页,如前一个截图所示:

步骤 4 – 安装私有分发

http://localhost:8081 的安装说明是自我解释的。你可以点击 简单 链接查看所有可用的包。它基本上显示了我们在启动服务器时指定的 dist 目录的内容。如果你想包含任何额外的包,你可以简单地将其复制到这个目录。以下命令安装此私有分发:

$ pip install -i http://localhost:8081 testpkg_private

小贴士

这只是一个快速介绍如何设置私有 PyPI 仓库。为了说明,我们只是基于 HTTP 创建了一个本地服务器。在实践中,你应该设置一个使用 HTTPS 协议的安全服务器并验证用户,类似于 PyPI 网站所做的那样。此外,我们有一个基本的机制,其中包被复制到仓库目录。在现实世界的情况下,你需要支持远程上传。有关进一步阅读,请访问pypiserver的 GitHub 页面github.com/pypiserver/pypiserver。其他一些有助于设置私有仓库的包包括位于pypi.python.org/pypi/pyshoppyshop和位于pypi.python.org/pypi/djangopypidjangopypi

制作增量发布

包已经发布,但这并不是故事的结束。很快,你将需要对代码进行更改并再次使新版本可用。在本节中,我们将学习如何向已部署的分布提交增量补丁。

打包和上传新版本

准备新版本发布相当简单。只需在setup.py文件中将版本号更新为,例如2.0.1。在做出这个更改后,运行之前创建源分布并一次性上传包的命令:

$ python setup.py sdist upload -r pypitest

v2.0.1 的增量发布现在将在 PyPI 测试仓库中可用。

升级已安装的版本

如果包的先前版本已经安装在你的计算机上,请使用--upgrade选项更新到最新发布版本。这一步是可选的,但始终是一个好习惯来验证发布版本是否按预期工作:

$ pip install -i https://testpypi.python.org/pypi testgamepkg --upgrade

如我们之前所做的那样,将名称testgamepkg替换为你选择的包名。

代码版本控制

让我们回顾一下到目前为止我们所做的工作。我们从一个简单的脚本开始应用程序开发。逐渐地,我们对应用程序进行了重新设计,添加了新功能并修复了错误,使其转变为现在的状态。如果你想要回到代码的早期状态,比如说两天前你写的代码,你会怎么做?你可能出于各种原因想要这样做。例如,最新的代码可能有一些你在两天前没有看到的错误。想象另一种场景,你正在与你的同事合作一个项目,你们所有人都需要工作在同一组文件上。我们如何完成这个任务?

在这种情况下,一个版本控制系统VCS)会帮助我们。它记录了你所做的代码更改。现在文件和目录都有了与之关联的版本。VCS 使你能够拉取任何文件的特定版本。

目前有几种版本控制系统在使用中。Git、SVN、CVS 和 Mercurial 是最受欢迎的开源 VCS 之一。在这本书中,我们将介绍一些关于使用 Git(一个分布式版本控制系统)的初步操作指令。

Git 资源

Git 是协作开发的一个非常强大的工具。这是一个相当大的主题。本章仅简要概述了一些常见用例。这里的目的是提供一组最小的指令,以便将我们的 Python 应用程序代码纳入版本控制。

注意

以下是一些涵盖 Git 的资源的链接,其深度远超我们的范围:

如果您已经熟悉 Git,或者使用过 SVN 等其他版本控制系统,请直接跳转到最后的话题以解决练习。此外,即将进行的讨论将主要关注从命令行使用 Git。如果您更喜欢 GUI 客户端,使用 GUI 客户端进行 Git这一节将提供一些提示。

安装 Git

Git 软件可以从git-scm.com/downloads下载。该网站为各种操作系统提供了详细的安装说明。

对于大多数 Linux 发行版,可以使用操作系统的包管理器简单地安装。例如,在 Ubuntu 上,可以从终端安装,如下所示:

$ sudo apt-get install git

对于 Windows 操作系统,可以使用 Git 网站上的安装程序进行安装。安装完成后,您应该能够从命令行访问 Git 可执行文件。如果不可用,请在您的环境变量中将PATH添加到其可执行文件中。

配置您的身份

在创建 Git 仓库并提交任何代码之前,您应该告诉 Git 您的身份:

$ git config --global user.name  "YOUR NAME HERE" 
$ git config --global user.email YOUR_EMAIL_HERE

使用此命令,您所做的任何提交都将自动与您的用户名和电子邮件地址关联。

基本 Git 术语

让我们了解 Git 中一些常用的命令。这个列表远非详尽无遗。目的是仅学习最常用的 Git 命令:

  • add:这是一个用于将任何文件或目录纳入版本控制的关键字。使用add命令,Git 索引将被更新,新文件将被暂存以供下一次提交,同时还包括目录中的其他更改。

  • commit:在更改任何受版本控制文件后,可以使用此关键字将文件提交到仓库以注册更改。换句话说,Git 为文件记录了一个新版本,其中还包含有关谁进行了这些更改的信息。在提交文件时,您还可以添加有关所做更改的说明性消息。

  • clone:在 Git 术语中,这个关键字意味着将原始仓库复制到一个新的仓库中。您计算机上的这个克隆仓库可以用作源代码的本地或工作仓库。这样的仓库会跟踪您对包含代码的所有本地更改。

  • push:假设你有一个与你的团队共享的中央仓库。它可能位于远程服务器上。你已经在你的电脑上克隆了这个仓库,并对这个仓库进行了几个更改。现在你想要将这些更改提供给其他人。push 命令用于将这些更改发送到中央仓库。

  • pull:你已经使用 push 命令更新了中央仓库。现在,如果其他人想要使用这个代码,他们的克隆仓库需要与中央仓库同步。pull 命令可以用来使用中央仓库中可用的新更改更新克隆仓库。如果使用此命令更新的任何文件都有本地修改,Git 将尝试将中央仓库的更改合并到本地仓库中。

创建和使用 Git 仓库

让我们为我们的应用程序设置一个 Git 仓库。我们即将遵循的步骤在以下简化的示意图中表示。

创建和使用 Git 仓库

小贴士

有许多方法可以将代码纳入版本控制。这里展示的只是其中一种选项。例如,你可以在现有的 wargame 软件包目录中直接使用 git init 而不是创建裸仓库然后克隆它。

创建裸远程仓库

首先,我们将创建一个 Git 仓库。它只是一个存储你的项目修订历史记录的目录。请注意,它没有任何提交或分支。我们将使用这个裸仓库作为我们的中央或远程仓库。

小贴士

Git 使用远程仓库的概念。在这本书中,我们不会真正设置一个真正的远程仓库。远程仓库将只是你电脑上的另一个本地目录。为了避免混淆,在接下来的讨论中,我们将把远程仓库称为中央仓库。远程仓库和 Git 分支的详细信息存储在 .git/config 文件中。

习惯上是在名称后添加 .git 扩展名。在命令行中,执行以下命令以初始化一个裸仓库:

$ mkdir wargameRepo.git
$ cd wargameRepo.git
$ git --bare init

首先,创建一个名为 wargameRepo.git 的目录。在这个目录内部,git --bare init 命令初始化一个新的仓库。这个命令为你的项目创建一个 .git 目录。点前缀表示这是一个隐藏目录。--bare 选项表示这是一个裸仓库。

克隆仓库

如前所述,clone 命令可以用来创建中央仓库的副本。以下是执行此操作的命令:

$ git clone ~/wargameRepo.git wargameRepo
Cloning into 'wargameRepo'... 
warning: You appear to have cloned an empty repository. 
done.

在这里,它将 wargameRepo.git 克隆为 wargameRepo(一个新目录)。这假设你没有任何同名目录。现在你可以使用克隆的仓库,wargameRepo,作为你的工作副本。这个仓库包含完整的工作树。然而,在这种情况下,里面除了 .git 文件夹外没有其他内容。接下来,我们将向这个工作树添加文件和目录。

将代码复制到克隆的仓库中

在克隆后,将之前创建的 wargame 包复制到克隆的仓库中。此操作后的目录结构如下所示:

将代码复制到克隆的仓库中

预处理代码并提交

只是将代码复制到仓库并不意味着它被版本控制。为了做到这一点,打开命令提示符,并使用 cd 命令转到 wargameRepo 目录。

$ cd wargameRepo

现在,运行以下命令。注意命令中的点。这假设 git 在你的终端窗口中被识别为一个命令。如果不是这样,你需要更新 PATH 环境变量,或者直接指定此可执行文件的完整路径。

$ git add  .

这告诉 Git 将当前目录中的所有内容进行暂存以进行提交。在这种情况下,它将添加 wargame 目录及其内部的所有文件。如果你运行 git status 命令,它将显示所有为初始提交准备的新文件(无论何时发生)。下一步是实际上提交我们工作仓库中的文件:

预处理代码并提交

git commit 命令中的 -m 参数用于指定关于提交内容的描述性信息。在此命令之后的消息将显示在执行此命令后从 Git 收到的响应中。

将更改推送到中央仓库

这一步非常有用,尤其是在你与其他开发者共同开发代码时。在这种情况下,将会有一个中央仓库,我们之前使用 --bare 选项创建的。为了使你的更改对他人可用,你需要将这些更改推送到中央仓库。如前所述的旁注中提到,这个中央仓库只是你电脑上另一个 Git 目录。

我们从一个空的仓库开始。对于初始推送到中央仓库,请执行以下命令:

$ git push origin master

这里的 origin 是什么意思?回想一下,我们克隆的仓库 wargameRepo 是从中央仓库 wargameRepo.git 出发的。origin 简单来说就是指向你的中央仓库的 URL。第二个参数 master 是 Git 中的分支名称,更改将被推送到这个分支。默认分支被称为 master。你也可以创建不同的分支。我们将限制这次讨论到默认分支。.git/config 文件存储了关于本地仓库中 origin 和分支的详细信息。

总结一下,前面提到的命令是将你工作仓库中的 master 分支推送到中央仓库中的新 master 分支(origin/master)。

在初次推送后,如果你对代码进行了任何修改,你首先需要在工作仓库中提交这些修改:

$ git commit -m "some changes to files" foo.py

假设你继续在同一个分支(master)上工作,对于任何后续推送到中央仓库的操作,只需执行以下命令:

$ git push

这将更新中央仓库的 master 分支以包含你的更改。有了这个,你就可以准备好与其他开发者共享你的代码了。如果你想获取其他开发者所做的更改,可以使用 $ git pull 来获取这些更改并将它们合并到你的工作副本中。我们没有讨论其他 Git 功能,如对代码打标签、创建分支、解决冲突等。建议你阅读 Git 文档,git-scm.com/doc,以更好地理解这些概念。

使用 Git 的 GUI 客户端

早期章节专门讨论了如何从命令行使用 Git。这些命令也可以通过图形用户界面(GUI)访问。有许多 Git 的 GUI 客户端可用,例如 Linux 上的 gitk (gitk.sourceforge.net/) 或适用于 Mac 和 Windows 7 或更高版本的 Github Desktop (desktop.github.com/)。免费的 Python IDE,如 PyCharm 的社区版,为 Git 和其他版本控制系统提供了易于使用的 GUI 集成。PyCharm 为 Git 命令提供了上下文菜单集成。例如,在 IDE 中右键单击文件将提供一个上下文菜单选项,用于将文件添加或提交到仓库。

练习

由于这只是一个小问题,我们将分发版发布到了 PyPI 测试仓库。对于更严肃的内容,你应该将包部署到 PyPI 主仓库,pypi.python.org/pypi。作为一个练习,将包部署到主 PyPI 服务器。这个过程与我们之前讨论的类似。

  • 在 PyPI 网站上创建一个新账户。请注意,你需要创建一个单独的账户;测试 PyPI 账户在这里不起作用。

  • .pypirc 文件中,创建一个新的配置文件来存储主服务器的凭证。以下插图可以提供灵感:

    [distutils] 
    index-servers= 
    pypitest 
    pypimain
    
    [pypimain]
    repository = https://pypi.python.org/pypi
    username=<add PyPI main username>
    password=<add PyPI main password>
    
    [pypitest] 
    repository = https://testpypi.python.org/pypi 
    username=<add username>
    password=<add password>
    
  • 适当地更新 setup.py 中的 url 字段。

  • 按照包创建和发布的其他步骤进行。请记住,在所有地方指定主仓库,而不是测试仓库。例如:

    $ python setup.py register -r pypimain
    $ python setup.py sdist upload -r pypimain
    
    
  • 看看如果不指定 -r 选项会发生什么?它会默认到哪个仓库?

摘要

本章介绍了应用开发的一些关键方面,特别是 Python 应用开发。本章从介绍不同的版本控制约定开始。它演示了如何创建 Python 模块和包。

通过逐步说明,本章演示了如何准备一个分发版(也称为包),将其部署到 PyPI 测试服务器,并使用 pip 安装已部署的包。此外,它还展示了如何进行增量发布和设置私有 Python 分发。最后,本章概述了使用 Git 进行版本控制。

编码规范是一套你在编写代码时应该遵循的指南。遵守这些规范可以对代码的可读性和代码的寿命产生重大影响。在下一章中,你将学习软件开发的重要方面之一,即代码文档和最佳实践。

第四章:文档和最佳实践

到目前为止,我们的重点是开发代码和推出第一个版本。我们还没有谈到应用开发的另一个重要方面,即文档和编码标准。尽管代码库仍然相当易于管理,但在为时已晚之前,我们应该学习提高代码可读性的技术。在本章中,我们将涵盖以下主题:

  • 理解 reStructuredTextRST) 格式的基础以及如何使用它来编写文档字符串

  • 学习如何使用 Sphinx 文档生成器为代码创建 HTML 文档

  • 涵盖一些重要的编码标准以编写 Python 代码

  • 使用 Pylint 来评估我们遵循这些指南的情况

如您从前面的话题中可以猜到的,我们暂时从编码中抽身,学习这些非常重要的概念。

文档和最佳实践

如果你对代码非常熟悉,你可能会觉得文档是不必要的。但想象一下,你被分配了一个拥有大量代码库但文档很少的不同项目。你会怎么想?当然,你无论如何都要审查代码以熟悉它。但如果代码没有很好地记录,你的生产力将会受到打击。你花费在理解这种代码上的时间也取决于它编写得有多好。这就是编码标准方面发挥作用的地方。

总结来说,永远不要忽视编码标准和文档。确保在代码开发过程中遵循这些指南。维护文档并不过度文档化也很重要。让我们从学习为 Python 项目创建良好文档的技术开始。

记录代码

广义上,有三种级别的文档。在顶部,你有项目或 分发级别文档。它的目的是提供关于项目的高级信息,例如安装说明、许可条款等。在 第三章 中,模块化、打包、部署!,你已经感受到了这种文档的滋味。我们创建了 READMELICENSE 文件来配合分发。此外,你可以添加更多文件以使文档更全面,例如 INSTALLTODORELEASENOTESCREDITS 等。

第二级是 API 级文档。它总结了如何使用函数、方法、类或模块。我们将在下一节学习的 Python 文档字符串用于生成 API 级文档。

第三级文档是以 代码注释 的形式存在的。这样的注释有助于解释代码是如何工作的。

Sphinx 是一个用于 Python 的文档生成工具,用于创建项目和 API 级别的文档。在本章中,我们将使用 Sphinx 从文档字符串创建 API 级别的文档。但在深入这个主题之前,让我们首先了解 Python 中的文档字符串是什么。

注意

Python 增强提案 (PEPs) 提供了一种提出和记录 Python 语言各种设计标准的方法。有几个 PEPs,每个都有一个永久编号。例如,PEP 8PEP 257PEP 287 等等。

PEP 257 记录了编写文档字符串的指南,而 PEP 287 提供了关于 reStructuredText 文档字符串格式的信息(关于 reStructuredText 格式的更多内容将在本章后面介绍)。

本章的目的不是重复这些 PEPs 已经记录的内容。在接下来的章节中,我们会适当参考这些指南。为了全面理解这些和其他 PEPs,请查看 www.python.org/dev/peps

文档字符串

文档字符串或文档字符串是一个用于描述类、方法、函数或模块的字符串字面量。文档字符串的目的是简要描述代码的功能。它与详细阐述代码内部工作原理的注释不同。它可以通过内置属性 __doc__ 访问。让我们写一个例子来说明这个概念。打开 Python 解释器并编写以下简单的函数:

>>> def get_number(): 
...     return 10
... 
>>>

让我们看看这个函数的 __doc__ 属性存储了什么:

>>> get_number.__doc__ 
>>> 

函数的 __doc__ 属性是一个空字符串,因为我们还没有为这个函数编写任何文档。现在让我们为这个函数编写一个文档字符串,并再次打印这个属性:

>>> def get_number(): 
...     """Return a special number""" 
...     return 10 
... 
>>> get_number.__doc__ 
'Return a special number'

现在的 __doc__ 属性显示了函数的文档字符串。如所见,文档字符串的表示方式与注释不同。它被三重双引号(推荐风格)"""Return a special number""" 或三重单引号 '''Return a special number''' 所包围,并作为该类、方法、函数或模块的第一个语句编写。

小贴士

PEP 257

之前代码中显示的简单示例是单行文档字符串。同样,你也可以有多行文档字符串。查看 PEP 257 规范 (www.python.org/dev/peps/pep-0257) 以获取更多详细信息。

要使用 Sphinx 生成有效的文档,文档字符串应该用一种称为 reStructuredText 的标记语言编写。让我们了解这种格式的基础知识。

reStructuredText 简介

reStructuredTextRST)定义了一种简单的标记语法,主要用于 Python 文档。它是 Python 文档处理系统的一部分,称为 docutils (docutils.sourceforge.net/index.html)。

小贴士

RST

这听起来熟悉吗?在 第三章 中,没有太多解释,我们创建了一个 RST 格式的 README 文件。在该章中,参考 准备分发 部分以获取更多信息。本节将向您提供一个关于 RST 语法的最低限度介绍。有关进一步阅读,完整的文档可在 docutils.sourceforge.net/rst.html 获取。

让我们回顾一下 RST 最常用的功能。

部分标题

为了区分部分标题和其余文本,它被使用任何非字母数字字符(如 ~~~~====–---####)创建的下划线装饰。装饰的下划线应与标题文本的长度相同(或更长),如下例标题所示:

1\. Introduction
----------------

在这里,破折号 (---) 用于装饰标题。假设这被认为是文档中的 标题 1 样式;任何后续使用此装饰器都将产生相同的样式。在下面的屏幕截图中,RST 语法显示在左侧列;右侧列显示了在浏览器中的显示方式:

部分标题

小贴士

试试看!

您可以使用在线 RST 编辑器,如 rst.ninjs.org,快速测试您的 RST 文件将如何被处理。

段落

要创建一个段落,只需写一个。完成后,在段落末尾至少留一个空白行。此外,如果在 RST 文件中缩进一个段落,它将在浏览器中显示为缩进的块。以下是写两个段落的 RST 语法:

para1\. Just write the sentences in the para 
and end it by adding one or more blank line.

    para2 . blah blah blah. 
    ...more stuff in paragraph 2 See how it gets appended.. 

作为练习,使用任何在线 RST 编辑器,看看它将在网页浏览器中如何显示。

文本样式

你可以在段落内部或正文文本中应用不同的文本样式。使用双星号装饰文本以使其显示为粗体,例如,**粗体文本**。同样,单个星号装饰 *斜体文本* 用于 斜体 样式。

代码片段

RST 提供了各种指令来处理格式化的文档块。code-block 指令通过语法指定,例如,.. code-block::。请注意,在单词 code-block 和前面的两个点之间有一个空格。code-block 指令可以与代码语言一起指定来构建一个文本块。在下面的 RST 示例中,我们指定了 Python 作为代码语言:

.. code-block:: python 

    from wargame.attackoftheorcs import AttackOfTheOrcs 
    game = AttackOfTheOrcs() 
    game.play() 

code-block 指令的参数指定为 python。它告诉文档生成器这是 Python 语法。此外,请注意,在编写实际代码之前,指令之后应该有一个空白行。你也可以使用 code 指令,.. code::,来表示一段代码。对于语法高亮,需要一个名为 Pygments 的 Python 包。我们将在学习 Sphinx 文档生成器时进一步讨论这个问题。

数学公式

math指令用于编写数学方程。请注意,您需要在数学方程块前后留出空白空间。以下语法(左侧列)是表示数学公式的一种方式。右侧列显示了它在网页浏览器中的显示方式:

数学方程

列表和编号

可以使用以下任何字符添加项目符号:*+-。要求在第一个项目符号之前和最后一个项目符号之后至少有一个空白行:

Text before the bullet points. A blank line follows...

* First bullet item
  Some continuation text for first bullet, 
  Note that its alignment should match the bullet it is part of.  
* second bullet item
* last bullet item

Text after the bullets. Again needs a blank line after the last bullet. 

类似地,您可以指定编号列表,如下所示:

Text before the enumerated list. A blank line follows...

1\. item 1
2\. item 2
   some continuation stuff in item 2
3\. item 3

Text after the enumerated lust. Again needs a blank line after the last item. 

小贴士

需要记住的关键事项

RST 语法要求您在不同样式块之间留出空白行。例如,当您编写代码片段、数学方程或段落时,您需要在这些文档块前后各留一个空白行。RST 对缩进敏感。

使用 RST 的文档字符串

为了生成我们应用程序的漂亮文档,我们首先需要用 RST 格式编写文档字符串。PEP 287提出了使用 RST 格式编写文档字符串的指南。有关全面描述,请查看www.python.org/dev/peps/pep-0287。在这里,我们将讨论您编写文档字符串时需要记住的一些最重要的要点。为了说明这个概念,让我们为wargame/hut.py模块编写一个文档字符串。文档也提供了该章节的补充代码。

以下代码截图显示了Hut类的示例类级别文档字符串:

使用 RST 的文档

让我们现在回顾一下这个语法:

  • 文档标准建议在下一描述块之前留有一行总结,并留有空白行。

  • :arg字段描述了此类输入参数,如__init__方法中给出的。您也可以使用:param字段。

  • :ivar字段用于描述类的实例变量。您可以选择在相同行上指定实例变量的类型,例如:

    :ivar int number: A number assigned to this hut.
    :ivar AbstractGameUnit occupant: The occupant of...
    

    当 Sphinx 生成 HTML 文档时,实例变量类型将显示在其名称旁边。它还会尝试创建对该类型的链接。

  • .. seealso::字段指令用于引用与该类相关的任何重要内容。

  • :py:meth:字段用于交叉引用方法。请注意,方法名称应由反引号(符号`)括起来。

  • 注意我们没有为__init__方法编写任何文档字符串。指南建议您为类或其__init__方法编写文档字符串。为了简单起见,让我们遵循刚才展示的风格,即在类级别编写文档字符串。

小贴士

当 Sphinx 生成文档时,默认情况下,它会忽略 __init__ 方法的文档字符串。您可以使用 conf.py 中的 autodoc-skip-member 事件来更改此默认行为。有关更多信息,请参阅 sphinx-doc.org/ext/autodoc.html#skipping-members

Sphinx 生成的 Hut 类的 HTML 文档将如图所示。您将很快学会如何创建这样的文档!

使用 RST 的文档字符串

刚才展示的内容应作为一个基本示例。您可以使用 RST 和 Sphinx 做很多事情。下表列出了编写文档字符串时最常用的功能(指令、信息字段和语法)。请像前例中所示那样使用这些字段。要获取全面文档,请访问 Sphinx 网站 (sphinx-doc.org)。

信息字段或指令 描述
:param 参数描述。
:arg 用于描述输入参数。
:key 关键字描述。

| :type | 参数或参数的类型,例如,intstring 等。您也可以使用替代语法,例如:

:param type param_name: description 

|

:ivar:var 任何变量描述。通常用于实例变量。
:vartype 变量类型描述。

|

  • :py:meth:

  • :py:func:

  • :py:class:

  • :py:attr:

用于交叉引用 Python 方法、函数、类或属性的语法。例如,:py:meth:`MyClassA.method_a` 将显示为 MyClassA.method_a()
.. code::
.. todo::
.. note::
.. warning::
.. seealso::

文档字符串格式化样式

在本章中,我们将仅使用默认的 RST 格式来编写文档字符串。各种项目遵循自己的约定来编写文档字符串。许多这些样式与 Sphinx 文档生成器兼容。

这里将简要讨论 Google Python 风格指南 (google.github.io/styleguide/pyguide.html)。这种风格因其简洁性而被广泛使用。您将在下面的代码截图中看到这一点。这是我们为 Hut 类编写的相同文档字符串,使用 Google Python 风格指南 重新编写:

文档字符串格式化样式

为了与 Sphinx 一起使用,您需要安装napoleon,这是 Sphinx 的一个扩展。它本质上是一个预处理器,可以将 Google 风格的文档字符串解析并转换为 RST 格式。有关 napoleon 的安装说明,请查看pypi.python.org/pypi/sphinxcontrib-napoleon/。有关 Google Python 文档风格的示例,可以在 napoleon 文档页面上找到,sphinxcontrib-napoleon.readthedocs.org

小贴士

Numpy风格的文档是 Python 社区中另一种流行的风格。它也由 napoleon 扩展支持。有关更多详细信息,请查看sphinxcontrib-napoleon.readthedocs.org

自动创建文档字符串占位符

这是一个相对高级的话题,主要是因为它需要一些使用命令行工具(如patch)的背景知识。

在许多情况下,您甚至没有为函数、方法和类编写基本的文档字符串。或者,您可能正在遵循 Google 文档字符串风格,但现在您想切换到不同的风格,比如基本的 RST 风格。开源工具pyment就是为了这样的场景而设计的。它可以用来创建或更新文档字符串,也可以在 RST、Google 文档字符串和numpydoc等一些常见格式化风格之间进行转换。

小贴士

再读一遍……工具的名称是"pyment",而不是"payment"(不要与 Python 包Pygment混淆)。此工具可在 GitHub 上找到(github.com/dadadel/pyment)。在撰写本章时,它不在 PyPi 网站上提供。因此,您可能无法使用pip install pyment命令安装它。

由于 pyment 无法使用 pip 安装,安装说明不同。请遵循 GitHub 项目主页上的安装说明(github.com/dadadel/pyment)。这里提供了不使用 Git 的替代安装说明:

  1. 从项目主页下载 pyment 的 ZIP 存档。

  2. 将此 ZIP 文件解压到某个文件夹中,例如,pyment-master

  3. 打开命令提示符并执行以下命令:

    $ cd  pyment-master
    $ python setup.py install
    
    

最后一条命令应该在包含 Python 可执行文件的同一目录下安装 pyment。根据 Python 的安装位置,您可能需要以管理员身份执行前面的命令。安装完成后,按照以下方式从命令行运行此工具:

$ pyment hut.py

这将生成一个名为hut.py.patch的补丁文件,其中包含基本的文档字符串占位符。

小贴士

这里,重要的是要注意,pyment 只会创建基本的文档字符串占位符。填写空白是我们的责任。换句话说,我们应该通过编写适当的函数或方法的总结来进一步改进这些文档字符串——关于每个输入参数(如果有的话)做了什么等的简短一行。

接下来,你预计要将这个补丁与主文件 hut.py 合并。在 Linux 上,使用以下 patch 命令(查看 en.wikipedia.org/wiki/Patch_(Unix) 获取更多详细信息) 将生成的文档字符串与主文件合并:

$ patch hut.py hut.py.patch 
patching file hut.py 

小贴士

Windows 用户

这里描述的 patch 命令是一个 Unix 命令。在 Windows 上,修补文件可能不是那么直接。以下是一些可以用来应用补丁的选项:

这样,hut.py 模块应该会显示基本的文档字符串占位符。我们已经对创建文档字符串有了基本理解。让我们使用 Sphinx 将文档提升到下一个层次。

使用 Sphinx 生成文档

Sphinx 是 Python 的事实标准文档生成工具。不要将其与文档字符串混淆。文档字符串是你用来总结对象行为的内容。例如,类的文档字符串通常根据你的项目文档指南列出实例变量和公共方法。

Sphinx 使用这样的文档字符串或任何 RST 文件来创建看起来很棒的文档。它可以生成各种输出格式的文档,如 HTML、PDF 等。让我们一步一步地使用 Sphinx 生成 HTML 格式的 API 文档。

第 1 步 – 使用 pip 安装 Sphinx

Sphinx 可以使用 pip 安装,如下所示命令行:

$ pip install Sphinx

小贴士

pip 是用于安装 Python 包的包管理器。有关 pip 的更多信息,请参阅 第一章,开发简单应用程序

这会创建四个可执行脚本,sphinx-autogensphinx-apidocsphinx-buildsphinx-quickstart

小贴士

在 Linux 上,这些可执行文件放置在与你的 Python 可执行文件相同的位置。例如,如果 Python 可用为 /usr/bin/python,则 Sphinx 可执行文件可以从相同的位置访问。在 Windows OS 上,Sphinx 可执行文件放在 Scripts 目录中。这是你放置 pip.exe 的同一目录。有关更多详细信息,请参阅第一章开发简单应用程序

为了代码的语法高亮显示,Sphinx 使用一个名为 Pygments 的工具(pygments.org)。如果它尚未包含在你的 Python 发行版中,请使用 pip 安装此包:

$ pip install pygments

步骤 2 – 切换到源目录

在第三章模块化、打包、部署中,我们创建了一个名为 wargame 的 Python 包,其中包含所有模块。打开一个终端窗口并 cd 到此目录。目录内容如下所示:

步骤 2 – 切换到源目录

步骤 3 – 运行 sphinx-quickstart

如其名所示,此脚本将帮助你开始使用 Sphinx。它设置一个目录,文档文件将放置于此,并创建一个默认配置文件 conf.py。运行以下命令:

$ sphinx-quickstart

当你运行此工具时,它将询问你几个问题以完成基本设置。在 Mac 上按 return 键或在其他系统上按 Enter 键选择大多数问题的默认答案。我们将为几个问题定制答案,如下所示。第一个提示要求输入放置文档的目录。我们将为此目的创建一个名为 docs 的新目录:

> Root path for the documentation [.]: docs 

> Separate source and build directories (y/n) [n]: y 

> Project name: wargame

> Author name(s): Your_Name 

> Project version: 2.0.0 

Please indicate if you want to use one of the following Sphinx extensions: 
> autodoc: automatically insert docstrings from modules (y/n) [n]: y

最后一个答案启用了 Sphinx 的 autodoc 扩展。此扩展将帮助我们创建从之前创建的 docstrings 生成的文档。将其他问题保留为默认答案。最后,sphinx-quickstart 打印以下摘要信息:

步骤 3 – 运行 sphinx-quickstart

此脚本创建的目录结构如下所示:

步骤 3 – 运行 sphinx-quickstart

生成的 Makefile(Linux/Mac)和 make.bat(Windows OS)将在本主题的最后一部分使用,即步骤 6 – 构建文档docs/source 目录是我们需要放置所有 RST 文件(或文档源文件)的地方。默认情况下,它会创建一个空的 index.rst 文件。它还包含一个名为 conf.py 的文件,接下来将对其进行讨论。

步骤 4 – 更新 conf.py

sphinx-quickstart 脚本创建了一个构建配置文件,conf.py。这里,它位于 docs/source/conf.py。这是定义所有 Sphinx 定制的文件。例如,你可以在生成文档时指定要使用的 Sphinx 扩展。在上一个步骤中,我们启用了 autodoc 扩展以包含来自 docstrings 的文档。它在 conf.py 中的表示如下:

extensions = [   'sphinx.ext.autodoc', ]

为了处理与 .. todo:: 指令相关的某些警告,将以下内容添加到 extensions 列表中(你也可以在 sphinx-quickstart 中指定此内容):

extensions = [   'sphinx.ext.autodoc', 'sphinx.ext.todo', ]

我们只需要对这个文件进行一点小的修改。由于我们的源代码不在 docs 目录下,我们需要添加一个适当的路径,以避免在生成文档时出现导入错误。取消以下代码行的注释。你应该在这个 import 语句之后立即找到这一行:

#sys.path.insert(0, os.path.abspath('.'))

你还需要指定包含系统上 wargame 包的目录的完整路径。以下代码是一个示例:

sys.path.insert(0,   
  os.path.abspath('/home/book/wargame_distribution')
)

步骤 5 – 运行 sphinx-apidoc

现在,是时候使用 sphinx-apidoc 工具创建文档源文件(RST 文件)了。此工具使用 autodoc 扩展从 docstrings 中提取文档。其语法如下:

$ sphinx-apidoc [options] -o <outputdir> <sourcedir> [pathnames …]

在终端窗口中,运行以下命令(在运行以下命令之前,请确保你位于 docs 目录中,使用 cd 命令进入该目录):

$ sphinx-apidoc  -o source/ ../

-o 参数指定了生成的 RST 文件将被放置的输出目录。在这种情况下,输出目录是以 source 命名的目录。这个名字可能有点令人费解,但请记住,source 目录是我们存放文档源文件的目录。在下一步中,这些文件将被用来创建最终的输出(例如 HTML 文件)。第二个参数代表包含 Python 代码的目录路径。在这种情况下,目录路径是相对于当前工作目录指定的。你也可以指定完整的路径,例如:

$ sphinx-apidoc  -o source/  /home/book/wargame_distribution

运行此工具后的命令行输出如下所示:

步骤 5 – 运行 sphinx-apidoc

小贴士

作为一项练习,检查自动生成的文件 source/wargame.rst。它包含 autodoc 扩展的 automodule 指令。有关更多详细信息,请参阅 Sphinx 文档 (sphinx-doc.org/ext/autodoc.html)。

步骤 6 – 构建文档

上一步已经创建了所有我们需要创建美观文档的原始材料!创建 HTML 文档有两种方式。第一种方式是使用 sphinx-build 工具,另一种方式是使用我们之前创建的 Makefile。接下来让我们讨论这些选项。

使用 sphinx-build

使用 sphinx-build 工具可以轻松地生成最终的文档。在仍然位于 docs 目录下时,运行以下命令:

$ sphinx-build source build

第一个参数是我们拥有所有 RST 文件的源目录,第二个参数是最终 HTML 文档将被创建的目录。在网页浏览器中打开 docs/build/index.html 文件,通过链接导航以查看文档!

使用 sphinx-build

使用 Makefile

sphinx-build 的一个替代方案是使用在 步骤 3 – 运行 sphinx-quickstart 中创建的 Makefile(或 make.bat)。在 Linux 上,输入以下命令(首先使用 cd 命令移动到 docs/source 目录):

$ cd /home/book/wargame_distribution/wargame/docs/source
$ make html 

最后一个命令在 docs/build 目录中创建 HTML 文档。如果你使用 Windows 操作系统,使用 make.bat,例如:

> make.bat  html

现在你已经学会了如何编写良好的文档,让我们进一步了解在编写 Python 代码时应遵循哪些指南。

Python 编码标准

编码标准作为编写高质量代码的指南。遵守这些标准可以对代码的可读性产生重大影响,并且在一般情况下对代码的生命周期产生影响。

小贴士

Python 代码的 PEP 8 风格指南

PEP 8 规范为编写 Python 代码提供了一个风格指南。如果你在一个遵循自己编码规范的项目上工作,而不是强制执行 PEP 8 标准,你应该遵守项目特定的规范。最重要的是一致性。对于任何新的项目,强烈建议使用 PEP 8 风格指南。在本节中,我们将介绍你应该了解的最基本的指南集合。对于全面的概述,请查看 www.python.org/dev/peps/pep-0008

以下表格列出了在 PEP 8 中记录的一些编写 Python 代码的重要指南:

Python 代码的 PEP 8 风格指南 详情
每个缩进级别使用四个空格 这可以在大多数 Python 编辑器中设置为偏好设置。
使用空格而不是制表符进行缩进 Python 3 中不允许混合使用制表符和空格。大多数编辑器都有一个选项将制表符转换为空格。
限制最大行长度为 79 个字符 这可能因项目而异。一些项目遵循 80 个字符的限制。本书中的插图使用 80 列限制。大多数编辑器都会提供一个选项,在指定的列处绘制一条线,作为视觉指示。
将所有 import 语句放在文件顶部 不要在类或函数体内放置 import 语句。将它们移出来并放在顶部。

| 每行一个 import 语句 | 此指南的一个例外是,如果你从一个模块中导入多个对象,可以使用单个导入。以下导入是可以接受的:

import os
import sys
from numpy import trapz, polyfit

|

模块名称 尽量保持简短。它们应该是全部小写。例如:attackoftheorcs.py

| 类名 | 使用 UpperCamelCase,每个单词的首字母大写。例如:

class AttackOfTheOrcs:

|

| 函数和方法名称 | 应该全部小写;如果可以提高可读性,则使用下划线。例如:

def show_game_mission(self):

避免以下风格:showGameMission小驼峰式)。仅在您正在从事使用此约定的项目时使用此类名称。如果您来自不同的编程背景,如 C++,这可能会让您感到惊讶。在方法和函数名称中使用下划线是 Python 的方式。 |

| 与None比较 | 总是像这样将变量与None进行比较:

if my_var is None:
    # do something

或者像这样:

if my_var is not None: 
    # do something else. 

永远不要像这样比较:if my_var == Nonemy_var != None |

| 异常:

  • 在捕获异常时,指定异常类型而不是仅使用裸except子句。

  • 使用Exception类来派生异常,而不是使用BaseException类。

  • 避免在单个try子句中编写大量代码;这样做会使隔离错误变得困难。

参考第二章处理异常,讨论了这些指南的一些内容。

| 公共和非公共属性:

  • 非公共属性应该以一个前导下划线开头。

  • 当不确定时,将属性设置为非公共的。

如第一章开发简单应用程序中所述,Python 不会强制执行任何规则来使非公共属性对外部世界不可访问。然而,一个好的做法是在作用域之外避免使用非公共属性。如果你不确定它应该被定义为公共还是非公共,作为一个起点,让它成为非公共的。以后,如果需要,可以将其更改为公共属性。请参考第五章单元测试和重构,其中我们讨论了非公共方法_occupy_huts的测试策略。

如前所述,这只是一个全面PEP 8指南的代表性样本。请阅读PEP 8文档以获取更多详细信息。

代码分析 – 我们做得怎么样?

在本节中,我们将讨论帮助检测编码标准违规的工具。

代码分析 – 我们做得怎么样?

欢迎回来,Sir Foo!你一直很安静,希望你在认真听讲。你* 提出了一个有效的担忧。开发者们在试图遵守这么多指南时可能会感到不知所措。最初,这可能看起来像是一个挑战,但练习应该会使你变得完美。话虽如此,你仍然有可能忘记一个指南。幸运的是,有一些工具不仅可以检测编码标准违规,还可以检查代码中的错误。一些工具还试图重新格式化代码以符合编码标准。让我们接下来学习这一点。

使用 IDE 进行代码分析

在第一章 开发简单应用程序中列出了一些流行的 Python 集成开发环境IDE)。在查看以下讨论的任何检查工具之前,请先从您的 IDE 开始。许多 IDE 都配备了代码检查和重排工具。例如,PyCharm Community Edition 对代码检查提供了出色的支持。以下截图显示了代码菜单下提供的一些选项:

使用 IDE 进行代码分析

使用具有良好代码分析工具的 IDE 具有主要优势。它可以帮助您在编写代码时检测问题。该工具可以持续监控代码以查找常见的编码违规,并在代码旁边显示错误或警告的视觉指示。通常,这种指示会像拼写检查器在文字处理器中显示拼写错误一样出现。这些及时的指示在解决常见编码错误时非常有帮助。

Pylint

Pylint是一个检查代码错误并警告您关于编码标准违规的工具。它与几个 IDE 集成(有关 Pylint 可用或可作为插件安装的 IDE 和编辑器的列表,请参阅docs.pylint.org/ide-integration)。我们将看到如何将 Pylint 用作命令行工具。首先,使用 pip 安装它——根据您的 Python 安装,您可能需要管理员权限才能安装它:

$ pip install pylint 

这将在您有 Python 可执行文件的位置安装pylint(或在 Windows 上的pylint.exe)。现在,您应该能够从命令行使用此工具。在 Linux 上,语法如下:

$ pylint module_name.py

其中module_name.py是您想要检查错误和编码风格问题的文件。当您从命令行运行pylint时,它会打印出详细的分析报告。此报告包含有关编码风格、警告、错误和重构需求的信息。最后,它根据 10 分制对您的代码进行评分。

您还可以自定义默认设置以适应项目需求。这是通过配置文件完成的。在 Linux 上,请在终端中运行以下命令:

$ pylint --generate-rcfile > ~/.pylintrc

这将在您的$HOME目录(~/.pylintrc)中创建一个默认的 Pylint 配置模板并保存。即使在 Windows 操作系统上,此文件也可以在您的用户主目录中创建。或者,您也可以指定PYLINTRC环境变量,它包含文件的完整路径。

Pylint 的实际应用

是时候采取一些行动了。对wargame/hut.py文件运行 Pylint 代码分析。回想一下,在早期部分使用 RST 的文档字符串中,我们添加了一个类级别的文档字符串。这就是我们对这个文件的文档。Pylint 可能不会喜欢这样,所以请准备好接受“打击”!

$ cd wargame
$ pylint hut.py

最后一个命令会在命令行上打印详细的报告。让我们看看我们得到了什么。以下截图显示了最终的报告——代码被评为 5.00 分,满分 10 分:

Pylint 在行动

这相当糟糕!让我们通过审查 Pylint 生成的报告来找出我们可以改进的地方。在报告中,它抱怨一个 import 错误。嗯,导入没有问题。显然,缺少 Python 目录 PATH。这可以通过编辑 .pythonrc 文件来修复。查找一个注释行,内容为 init-hook(它应该出现在文件的开头附近)。取消注释它并写入以下代码:

init-hook='import sys; sys.path.append("/path/to/wargame/")' 

/path/to/wargame 替换为系统上 wargame 目录的实际路径。进行此更改后,重新运行 Pylint 对此文件进行检查。新的评估结果如下:

Pylint 在行动

还不错!仅仅修复导入错误就已经使分数提高了 2.50 分。让我们再次审查生成的报告。在报告的开头,Pylint 列出了文件中存在的所有问题。在这种情况下,它抱怨模块和类方法的文档字符串缺失。它不高兴的另一点是模块的第一行 import 语句,即 from __future__ import print_function

小贴士

PEP 236 规范

虽然 __future__ import 语句必须作为第一行出现,但此规则的例外是模块文档字符串。模块文档字符串可以在写入 __future__ import 语句之前编写。有关更多信息,请参阅 PEP 236 规范 (www.python.org/dev/peps/pep-0236)。

我们可以轻松修复这两个问题。以下代码截图显示了重新工作的模块文档字符串以及重新排列的 __future__ import 语句:

Pylint 在行动

让我们通过再次运行 Pylint 来看看我们做得怎么样:

Pylint 在行动

哈哈!我们在这个模块上得到了满分!按照类似的过程改进其余的代码。作为练习,为类方法添加文档字符串。你也可以从本章的补充材料中下载wargame/hut.py,其中已经写好了所有的文档字符串。

PEP8 和 AutoPEP8

PEP 8 是另一个检查代码是否遵循 PEP 8 编码风格指南的工具。可以使用以下方式使用 pip 安装:

$ pip install pep8

要了解如何使用 pep8,请访问项目页面 (pypi.python.org/pypi/pep8)。还有一个叫做 autopep8 的实用工具,它会自动重新格式化代码,以符合 PEP 8 指南推荐的风格。此工具也可以使用 pip 安装:

$ pip install autopep8

注意,此工具需要安装 pep8。有关更多信息和使用示例,请参阅 pypi.python.org/pypi/autopep8

练习

在本章中,你学习了如何编写代码文档,使用 Sphinx 生成文档,以及使用 Pylint 等工具分析代码。以下是一个涵盖这三个方面的练习:

  • 下载 第三章 中展示的代码,模块化、打包、部署(你也可以使用你自己的 Python 代码代替)。

  • 为此代码编写文档字符串(确保在模块、类和方法/函数级别编写文档字符串)。你可以使用默认的 RST 格式来编写文档字符串,或者选择 Google Python 风格指南

  • 使用 Sphinx 生成 HTML 文档。

  • 运行代码分析,使用 Pylint 或任何其他工具,以修复编码错误和风格问题。

本章的支持代码已经达到一定程度的文档化。你可以使用此代码作为参考,并尝试进一步改进现有的文档。

摘要

你学习了如何使用 RST 格式编写代码文档。本章介绍了用于为我们的应用程序代码创建 HTML 文档的 Sphinx 文档生成器。你还了解了一些重要的 Python 编码标准,这些标准有助于提高可读性。最后,我们看到了如何使用代码分析来检查我们的应用程序代码中的错误和风格违规。

在理想的世界里,你希望你的代码完全符合编码规范。然而,由于各种原因,从新团队成员到紧张的项目截止日期,这通常并不成立。有时,为了使其符合编码标准,你需要在稍后的阶段重构它。在此过程中,你还需要确保没有功能被破坏。这是通过编写单元测试来实现的。我们将在下一章研究这些相互关联的方面。

第五章。单元测试和重构

这里是对你迄今为止所学内容的快速回顾。你使用面向对象的方法开发了一个命令行应用程序,然后学习了通过处理异常来使代码健壮的技术。你将代码模块化,准备分发,并将其发布给更广泛的受众。最后,你学习了编码标准和文档。

到目前为止,我们并没有过多关注应用程序的测试。我们完全依赖手动测试,其中一些功能是通过玩游戏来测试的。随着应用程序的复杂性增加,手动测试的任务变得越来越困难。很快你就会感到不堪重负,错误就会开始出现。虽然可能无法完全避免手动测试,但我们需要一个自动化的方式来确保功能按预期工作。在本章中,你将执行以下操作:

  • 了解 Python 中的单元测试框架 unittest

  • 为我们的应用程序编写一些单元测试

  • 查看如何在单元测试中使用模拟库

  • 学习如何衡量单元测试的有效性(代码覆盖率)

  • 理解代码重构是什么,为什么,何时以及如何进行

  • 在进行一些代码重构后,回到单元测试的讨论

这是本章的组织方式

本章从一个游戏场景开始,其中有一个错误通过了生产环境并一直隐藏,直到用户发现它。这个场景强调了自动化测试的需要,然后引出了对 Python 中单元测试框架的讨论。你将介绍 Python 中的 unittest 框架和 mock 库。本章将通过为我们的项目编写一些单元测试来演示这些库的使用。

接下来,它展示了在没有先重构代码的情况下难以编写单元测试的例子(参见重构前言)。这就是我们绕道而行,学习重构的基本知识,重构代码,然后开发最后一个单元测试的地方。

重要的注意事项

如果你还没有阅读前面的章节,这些笔记将很有用。否则,请继续阅读下一部分。像其他每个章节一样,这一章也有它自己的 Python 源代码文件。源代码可以从Packt Publishing网站下载。只需遵循本书前言中提到的说明。

这是最后一章,它依赖于前面章节中开发的代码。从第六章设计模式开始,我们将有独立、简化的例子来展示各种概念。话虽如此,所有这些都会与同一个高幻想主题联系起来。

为什么进行测试?

你玩过到目前为止开发的游戏吗?如果没有,就试玩一次。在与敌人的战斗中,可以观察到以下情况。对于每次攻击,Sir Foo 或敌人都会受到伤害。这通过减少生命值来表示。例如,在下面的示例游戏输出中,Sir Foo 在第一次攻击回合中被击中,而敌人则在接下来的两个攻击回合中受伤。

为什么要测试?

请求了一个新功能

用户请求增强战斗场景:"在战斗中,程序会询问你是否想继续攻击敌人。在每次攻击移动中,战士、玩家或敌人中的一位会受到伤害。你能让它更有趣吗?有时两位战士都能毫发无损地逃脱呢?"

请求了一个新功能

这对 Sir Foo 也有好处!我们将继续实施这个小的增强功能。尽管 Sir Foo 强烈反对,你还是匆忙实现了这个新功能。

你实现了这个功能

请记住,gameutils.weighted_random_selection 函数会从 weighted_list 中随机选择一个元素。列表被填充得使得大约 30%的时间,obj1 的唯一标识符被选中,而在其余时间,代表 obj2 的唯一标识符被选择。换句话说,Sir Foo (obj1) 受伤的概率大约是 30%,而敌人的 (obj2) 概率接近 70%。

为了添加没有人受伤的可能性,你通过添加一个新元素 None 来更改 weighted_list 的组成。战士受伤的新概率如下:

  • 敌人 (obj2) 受伤的概率约为 60%

  • Sir Foo (obj1) 受伤的概率约为 30%

  • 两者都毫发无损(None)的概率约为 10%

以下是上述更改前后的 weighted_random_selection 函数:

你实现了这个功能

这很简单,不是吗?你玩了一次游戏以确保没有出错。看起来一切正常。没有延迟,你发布了新版本。

但有些不对劲...

然而,发布后不久,用户投诉开始涌入。这是意料之外的。你的提交引入了一个新的错误!

但有些不对劲...

冷静下来,Sir Foo!你还在战争模式中!放松并深呼吸。我们很快就会解决这个问题。

那么出了什么问题呢?你写的函数没有问题。它正按预期运行。然而,你忘记对调用 weighted_random_selection 的代码做一些更改。结果,出现了以下未捕获的异常:

但有些不对劲...

错误跟踪信息指向 AbstractGameUnit.attack 方法。该方法调用 weighted_random_selection 函数随机选择一个受伤的单位。当 injured_unitNone 时,问题就出现了。导致问题的代码行在下面的代码片段中显示:

但似乎有些不对劲...

这需要彻底的测试

你已经通过运行游戏一次进行了基本的测试。但为什么你没有注意到这个问题呢?函数返回 None 的可能性很小。例如,对于 weighted_random_selection 函数的每次调用,平均只有一次会返回值 None。在这种情况下,你所进行的测试不足以重现问题。

这只是需要彻底测试的场景之一。同时,由于输出的随机性,它也容易受到人为错误的影响。如果你有一些自动化的方式来测试这个功能,那么这个错误就可以轻松避免。

因此,让我们学习如何使用 unittest 框架在 Python 中创建自动化测试。在你学会编写单元测试之后,我们将回来编写一个针对这里讨论的 weighted_random_selection 函数的单元测试。

单元测试

在单元测试中,你会在应用程序中调整一小段代码。主要任务是验证这段代码在整个应用程序的生命周期中是否继续按预期工作。这是通过编写针对该功能的测试来实现的。

单元测试可以通过一个例子更好地解释。考虑一个简单的函数,它返回两个数字的和。在单元测试中,你通过传递两个数字作为参数来调用这个函数,然后验证函数返回的值确实是给定数字的和。

有许多框架可用于编写单元测试。本章中的示例将基于内置的单元测试框架,称为 unittest。请参阅标题 其他单元测试工具,它提供了一个关于替代单元测试工具和框架的简要概述。

Python unittest 框架

unittest 模块提供了自动化测试的功能。在我们为应用程序实现任何测试之前,让我们首先从术语开始。

基本术语

  • 测试用例:当你编写单元测试时,它被称为测试用例。TestCase 是创建不同测试用例的基类。

  • 测试套件:当你将各种测试用例组合在一起时,它就变成了一个测试套件。一个测试套件也可能代表其他测试套件的集合。unittest.TestSuite 提供了一个创建套件的基类。TestSuite 并没有定义任何单元测试,它只是累积测试或其他测试套件。这是 TestSuiteTestCase 之间的一个主要区别。

  • 测试固定装置:这些是为单元测试顺利运行而做的准备工作。例如,TestCase.setUp在执行测试用例之前被调用。它可以用来向测试用例提供所需的数据。同样,TestCase.tearDown方法在测试执行后立即被调用。这些方法可以组合使用,例如启动和停止单元测试所消耗的服务。

  • 测试运行器:运行器帮助执行测试用例或测试套件。它还提供了一种表示测试结果的方式。例如,结果可以在命令行或某种图形形式中显示。基本实现由unittest.TextTestRunner类提供。

使用 unittest.TestCase 创建测试

为了理解构建和运行测试的基本知识,让我们编写一个简单的程序。观察以下代码:

使用 unittest.TestCase 创建测试

如前所述,setUptearDown方法被称为固定装置。MyUnitTests.setUp()在执行每个测试之前被调用。这允许在测试执行之前初始化一些公共变量。MyUnitTests.tearDown()方法在每次测试之后被调用。

当调用unittest.main()程序时,MyUnitTests类中定义的测试将依次运行。此程序还可以接受一个测试运行器作为可选参数(在此示例中未使用)。默认情况下,程序仅加载和运行名称以test开头的所有方法。在MyUnitTests类中,test_1test_2方法中定义的测试将按以下命令行输出所示执行:

使用 unittest.TestCase 创建测试

既然我们已经知道了测试用例是如何执行的,让我们回顾一下其中一个方法,如下所示:

def test_2(self):
    print("in test_2")
    self.assertEqual(1+1, 2)

assertEqual方法是TestCase类的一个内置方法。它本质上检查两个输入参数是否相等,如果不相等,则抛出断言错误。前面代码片段中展示的测试将通过。让我们回顾一个将失败的测试:

def test_2(self):
    print("in test_2")
    self.assertEqual(1+1, 3)

显然,1+1 != 3,所以我们预计测试将失败,如下所示命令行输出。对于失败的测试,它还会在输出中打印字母F

使用 unittest.TestCase 创建测试

同样,unittest.TestCase类定义了一系列方便的方法。例如,assertTrueassertFalse方法用于验证一个条件。另一个方法assertRaises用于检查代码是否抛出了特定的异常。

控制测试执行

有没有只运行选定测试用例的方法?一种方法是为要忽略的测试使用 Python 装饰器。让我们将此装饰器添加到上一个示例中的两个测试用例中:

控制测试执行

实际上,没有任何测试用例会被运行。代码运行后的输出表明这些测试已被跳过。对于每个跳过的测试,它会在输出中打印s

控制测试执行

小贴士

这里还有两个没有涵盖的装饰器,即skipIfskipUnless。这些装饰器用于基于条件的跳过测试。有关详细信息,请参阅以下文档页面:docs.python.org/3/library/unittest.html

有时候,你确实期望一些测试用例失败。例如,一个测试可能因为开发环境和生产环境之间的差异而失败,或者因为期望的数据库内容的缺失或存在而失败。这种预期的失败可以用另一个装饰器标记。我们知道test_2失败了,所以让我们为这个测试添加装饰器:

控制测试执行

对于每个预期的失败,它会在输出中打印x。最后,它会总结出预期失败的测试数量:

控制测试执行

使用 unittest.TestSuite

请参考本章支持代码包中的testsuitedemo.py文件。该模块包含两个类,即MyUnitTestAMyUnitTestB。这些类都继承自unittest.TestCase,并定义了一些简单的作为单元测试的方法。

小贴士

在第三章中,模块化、打包、部署,我们为每个类创建了一个独立的模块。在这里,testsuitedemo.py模块包含两个类。作为一个练习,你可以将这些类放入单独的模块中。

以下代码片段显示了这些类。为了简洁,这里省略了代码注释:

使用 unittest.TestSuite

unittest模块的makeSuite函数可以用来创建TestSuite的实例:

suite_a = unittest.makeSuite(MyUnitTestA)

上一行代码将构建一个测试套件,使用在MyUnitTestA类中定义的所有单元测试。只有以test*开头的方法名会被添加到测试套件中。在这个例子中,这些方法是test_a2test_a1。第三个方法not_called_by_default将不会自动被视为单元测试。

小贴士

非测试方法(例如本例中的not_called_by_default),通常在测试之间共享代码时很有用。

让我们看看如何将这些方法包含在测试套件中。下面的代码片段显示了在这个模块中定义的函数suite()

使用 unittest.TestSuite

让我们回顾一下前面的代码片段:

  • 这个函数创建了两个TestSuite实例,即suite_asuite_b

  • 使用addTest方法,将MyUnitTest.not_called_by_default方法添加到测试套件中作为测试用例。

  • 这个函数返回一个新的TestSuite对象。它接受一个 Python 元组作为参数。在这个例子中,元组包括之前创建的两个TestSuite实例。

本模块的最后部分是执行代码:

使用 unittest.TestSuite

运行 testsuitedemo.py 模块会产生以下输出。注意,它还执行了在 MyUnitTestB.not_called_by_default 中定义的测试:

使用 unittest.TestSuite

小贴士

测试套件也非常方便地根据它们的运行时间对测试用例进行分组。例如,您可以一起分组快速运行的测试和慢速运行的测试,并为测试运行脚本提供一个命令行选项来选择运行哪一个。

编写应用程序的单元测试

是时候为应用程序编写一些单元测试了。我们将创建一个新的 unittest.TestCase 子类来存放所有的单元测试。

设置测试包

作为第一步,让我们创建一个新的包来存放测试用例。在您其余代码所在的同一级别创建一个名为 test 的新目录。接下来,在这个 test 目录内创建两个新文件,如下所示:

设置测试包

test_wargame.py 模块是创建新单元测试的地方。为了将目录识别为 Python 包,添加一个空的 __init__.py 文件。

小贴士

如果您还没有阅读,请阅读 第三章,模块化、打包、部署! 了解创建 Python 包的详细信息。

创建用于单元测试的新类

test_wargame.py 文件也可以在支持代码中找到。它包含了接下来要讨论的所有代码。在接下来的讨论中,假设您将从空文件从头开始编写代码。

创建一个新的 unittest.TestCase 子类,并将其命名为 TestWarGame 或您喜欢的任何名称。类定义如下所示:

创建用于单元测试的新类

我们首先进行必要的导入。回想一下,setUp() 修复程序在运行单元测试之前立即被调用。在 setUp 中,创建了 KnightOrcRider 类的实例,然后它们被用于我们即将编写的单元测试 test_injured_unit_selection 中:如之前所见,对 unittest.main() 的调用将自动执行以 test 开头的方法名。在这个例子中,它将运行 test_injured_unit_selection()

小贴士

您也可以不使用修复程序编写相同的代码。只需在您编写的测试中创建所需的实例。如您接下来将看到的,test_injured_unit_selection() 单元测试使用了在 setUp() 中创建的对象。或者,您可以在测试中本地创建这些实例,如下所示:

def test_injured_unit_selection(self): 
    knight = Knight() 
    enemy = OrcRider() 
    # rest of the test code...

第一个单元测试 – 受伤单位选择

让我们回到在 为什么测试? 部分讨论的场景。记得您修改了 weighted_random_selection 函数的行为,使其也可以返回 None(没有人受伤)。这个新功能破坏了程序,并且由于未捕获的异常,应用程序终止。

我们即将编写的测试将验证这个函数的原始行为。原始行为是选择 Sir Foo(Knight实例)或敌人(OrcRider实例)作为受伤的单位。我们即将编写的单元测试将验证这一点。观察以下代码:

第一次单元测试 – 受伤单位选择

使用这个先前的函数,self.enemy受伤的概率大约是 70%,而self.knight(Sir Foo)的概率接近 30%。顶层的for循环只是确保它被调用100次,以考虑到函数返回值的随机性。如果injured_unit不是KnightOrcRider的实例,TestCase.assertInstance()将引发断言错误。现在让我们运行这个测试。

运行第一个单元测试

在终端窗口中,从顶层wargame目录运行此测试:

$ cd wargame
$ python -m unittest test.test_wargame

-m是 Python 的一个内置命令行选项。它允许你将一个库模块作为脚本运行。在这种情况下,它将运行unittest模块作为脚本。参数test.test_wargame代表文件test/test_wargame.pyunittest脚本将运行该模块中定义的测试。

如果weighted_random_selection的旧行为保持不变,测试将通过。但是,如果你实现了新行为,其中函数也可以返回None,它将通过引发AssertionError而失败,如下所示:

运行第一个单元测试

提示

没有必要运行for循环100次。只需确保至少调用该函数10次。作为一个练习,更新测试以验证更多细节。例如,验证函数大约 30%的时间返回Knight实例,等等。

第二次单元测试 – 获得小屋

让我们选择另一个功能进行测试。这次,它来自Hut类的一个方法:

第二次单元测试 – 获得小屋

在这个方法中,你认为我们可以测试什么?该方法有以下用途:(a) 更新占用信息,(b) 将is_acquired标志设置为True

提示

重新设计练习

在这个应用中,我们假设所有内容都是从玩家的上下文出发的。例如,Hut实例的is_acquired标志是从玩家的角度出发的。如果它被设置为True,这意味着小屋被玩家获得,而不是敌人。这已经容易产生错误。想象一下一个OrcRider实例调用这个方法!你可以添加断言来确保它只接受Knight实例。作为一个练习,从代码中移除对is_acquired标志的依赖。

在编写测试时,我们将确保新占用者与作为方法参数传递的对象是相同的。

第二次单元测试 – 获得小屋

*Sir Foo,这是个好问题。为什么还要写这个测试,如果方法已经运行得很好了?记住我们之前讨论的场景。功能上的有意改变给我们带来了很多麻烦。为什么要等到这样的错误出现呢?今天,这段代码的表现符合预期。单元测试是为了明天准备的。想象一下,多个开发者共同贡献这个应用程序。结果,代码会越来越多,有人可能会无意中引入会破坏此方法预期功能的代码。在这种情况下,你如何确保基本行为保持不变?单元测试会注意到这样的变化。未来的需求甚至可能会改变方法的基本行为。这在为什么测试?标题下的场景中得到了说明。当这种情况发生时,你编写的单元测试显然会失败。你确实期望它失败,这将迫使你更新测试以匹配新的要求。简而言之,单元测试将确保代码的意外更改立即被捕获,而不会成为你的噩梦,比如当有人报告了一个错误,而你艰难地发现它是由你几个月前写的代码中的一个小错误引起的。

让我们在同一个类TestWarGame中编写一个新的方法:

第二个单元测试 – 占领小屋

在前面的代码中,我们首先创建了一个Hut的实例。在第二行,这个小屋被self.knight获得。TestCase.assertIs检查代表小屋占用者的对象是否与self.knight相同,否则会引发AssertionError

仅运行第二个测试

如果我们执行以下命令,它将运行test_wargame.py模块中定义的所有测试:

$ cd wargame
$ python -m unittest test.test_wargame

如果你只想运行test_acquire_hut怎么办?假设你已经处于wargame目录中,以下是一个完成此任务的命令:

$ python -m unittest test.test_wargame.TestWarGame.test_acquire_hut

这个命令行参数可以读作package_name.module_name.class_name.method_name

运行此测试后的输出如下所示:

仅运行第二个测试

创建单独的测试模块

我们编写的最后一个单元测试是为了测试Hut类中的功能。我们在test_wargame.py模块中将这个作为TestWarGame类的一个方法创建。

我们是否必须将应用程序的所有测试都放在一个模块中?不!你可以选择为每个类创建单独的测试模块。

小贴士

对于大型应用程序,在类级别或包级别拥有单独的测试模块通常很方便。选择最适合你项目的策略。如果合理,你还可以创建一个测试类,将应用程序中的一些常用功能组合在一起。

让我们重新整理之前的例子。我们将创建一个新的模块test_hut.py,作为新类TestHut的家园。源代码也包含在本章的补充材料中——见wargame/test/test_hut.py。接下来,我们将TestWarGame.test_acquire_hut方法移动到这个类中。如下所示:

创建单个测试模块

执行单元测试的语法与之前使用的类似:

$ cd wargame
$ python -m unittest test.test_hut

批量执行单元测试

如果你的测试目录包含多个测试模块,你如何在目录内一次性运行所有测试?一个选项是编写一个脚本,列出执行单元测试的命令。然而,unittest模块提供了一个发现选项,可以在命令行上批量执行测试:

$ python -m unittest discover

以下命令行输出显示了在test目录内批量执行的两个测试模块:

批量执行单元测试

使用模拟库的单元测试

我们之前编写的两个测试相对简单易行。通常,编写一个用于验证功能的测试并非易事。原因可能多种多样。在某些情况下,代码可能需要重构,以便访问你想要测试的功能。在另一种情况下,代码可能存在依赖关系,这要求你编写比必要更多的代码。此外,要测试的功能可能需要进行耗时的准备工作,例如处理一些数字。这会增加总的测试执行时间。现在,我们将学习如何使用模拟库在这种情况下编写单元测试。在实际编写代码之前,让我们了解这个库提供了哪些功能。

模拟快速入门

模拟库提供了一种灵活的方式来创建虚拟对象,这些对象可以用来替换你正在测试的程序中的一些部分。

小贴士

模拟库从 Python 标准库(v3.3 及以后版本)中可用,作为unittest.mock。如果你使用的是早期版本,请使用以下命令安装:

$ pip install mock

访问 pypi.python.org/pypi/mock 获取更多信息。

使用模拟对象,你可以专注于要测试的主要功能,而不必过多担心这个功能所依赖的事物。它提供了一种方法,将支持代码块与正在测试的功能解耦。这可以通过一个例子更好地解释。参考以下卡通:

模拟快速入门

假设你正在编写一个名为compute()的函数的单元测试,该函数执行大量的科学计算。在这个函数内部,你调用其他支持函数来处理一些数据。这是一个耗时的操作。如果你知道支持函数提供的信息,你可以使用模拟对象来定义它们的行为。

让我们进行模拟!

是时候采取一些行动了。打开你的 Python 解释器,并开始编写以下代码。假设 mock 模块已经可用。如果它不可用,请使用前面建议的方式使用 pip 安装。首先,按照以下方式导入 Mock 类:

>>> from unittest.mock import Mock

接下来,创建一个 Mock 对象:

>>> mockObj = Mock()

对象类型及其唯一 ID 可以通过以下方式找到:

>>> mockObj 
<Mock id='140524045365320'> 

继续前进,在 Python 解释器中输入以下代码:

>>> mockObj.foo

让我们模拟!

良好的观察!对于在这里使用你的名字表示歉意……这是无意的。在开发者世界中,人们非常喜欢你的名字!所以问题是,它真的会引发属性错误吗?自己试试看!

执行最后一行代码将打印出类似于以下内容的输出:

>>> mockObj.foo
<Mock name='mockObj.foo' id='140524032172664'>

这是有趣的部分!它没有抱怨缺少属性;相反,它创建了一个新的模拟对象。你可以像它已经定义一样访问这个对象的任何任意属性。它将创建并返回一个代表该属性的新 Mock 对象。在这里,foo 也被称为 mockObj 的子模拟。

让我们看看如何利用这个功能。Mock.mock_calls 可以用来跟踪模拟对象及其子模拟的所有调用。结果以 Python 列表的形式返回。在 Python 解释器中编写以下代码行:

>>> mockObj.mock_calls
[]

这里,它返回一个空 Python 列表,因为我们还没有调用 mockObj 或其子模拟。

接下来,让我们看看这个列表是如何被填充的。Mock 对象是可调用的。编写以下代码来调用 mockObj.foo

>>> mockObj.foo()
<Mock name='mockObj.foo()' id='140524032173280'> 

我们将创建并调用另一个新的子模拟,如下所示:

>>> mockObj.foo2(return_value = 20) 
<Mock name='mock.foo2()' id='140271893632056'> 

现在,让我们再次调用 mockObj.mock_calls

>>> test_call_list = mockObj.mock_calls 
>>> test_call_list
[call.foo(), call.foo2(return_value=20)]

返回的列表现在包含两个对象,即 call.foo()call.foo2()。这些都是 unittest.mock.call 提供的辅助对象。

我们如何使用这些信息?当你编写单元测试时,你可以使用这个列表来断言哪些对象被调用以及它们的调用顺序。为了更好地理解这个概念,我们将在下一节编写一个简单的单元测试。

在单元测试中使用 Mock 对象

让我们为类 MyClassAcompute 方法编写一个单元测试。类定义如下。你还可以从支持代码包中下载 wargame/test/mockdemo.py 文件:

在单元测试中使用 Mock 对象

这是一个简单的例子。compute 方法依赖于两个方法返回的值,即 foofoo2。它使用这些值来计算并返回结果。在这个例子中,foofoo2 方法很简单。

想象一个场景,上述方法执行的任务需要非常长的时间。现在,要编写一个验证compute方法功能的单元测试,你需要检查result的最终值。由于在foofoo2中花费的时间,这自然会花费很长时间。如果你知道这些方法的预期结果,你可以在测试中简单地用Mock对象替换它们。我们可以这样做,因为假设foofoo2是辅助函数,而要测试的主要功能是结果值。

Mock对象将表现得像它们是原始方法一样,并返回所需的输出。但在现实中,我们绕过了耗时的计算。在此说明中,我们已经知道foo预期返回值为100foo2方法的返回值取决于输入参数x

查看 compute 方法,我们可以轻松推断出foo2的返回值将是100 + 200 = 300。因此,让我们编写一个单元测试来模拟这些方法调用。代码如下:

在单元测试中使用 Mock 对象

让我们回顾前面代码片段中的方法

  • a.fooa.foo2方法现在分别由新的Mock对象mockObj.foomockObj.foo2表示。在a.compute()内部,self.foo()self.foo2()调用现在被这些新对象模拟。

  • 测试通过调用TestCase.assertEqual验证参数 result 的值。

  • 测试还验证了被调用的对象以及它们的调用顺序。如前所述,test_call_list用于跟踪对mockObj及其子模拟的所有调用。此列表与存储对象预期调用顺序的参考列表进行比较。在此示例中,reference_call_list存储此信息。它期望foofoo2方法按此顺序调用。在未来,如果有人更改MyClassA.compute中的此顺序,这个测试将有助于捕捉到变化。

    注意

    The MagicMock class:

    MagicMockMock的子类。它本质上提供了你从Mock类期望的所有功能。此外,它为 Python 中的许多魔术方法提供了默认实现。魔术方法是一种具有双下划线作为前缀和后缀名称的特殊方法。魔术方法的示例包括__init____iter____len__等。在说明中,你可以使用MagicMock而不是Mock类。有关更多详细信息,请参阅以下页面:docs.python.org/3/library/unittest.mock.html

使用补丁

在上一个标题下,我们介绍了Mock类的一些基础知识。mock 库以补丁装饰器形式提供了另一个重要的功能。补丁是一种机制,允许您在测试中临时更改对象的行为。这是一个广泛的话题。在这本书中,我们将限制我们的讨论范围,仅限于使用unittest.mock.patch创建补丁。

小贴士

补丁可以通过四种不同的方式调用,即patchpatch.objectpatch.dictpatch.multiple。有关更多信息,请参阅docs.python.org/3/library/unittest.mock.html上的文档。

patch装饰器函数将target作为必需参数,后面跟着一系列可选参数。这里只显示了其中一个可选参数(new)。有关其他可选参数的信息,请参阅 unittest 文档:

patch(target, new=DEFAULT)
  • 在前面的函数中,target参数是你想要补丁的东西。它可以是一个函数、类方法或一个对象。

  • target被导入,应该用一个字符串表示,类似于典型的import语句(没有import关键字)。

  • 例如,如果您想在测试用例中补丁一个方法,则target应表示为:pkg.module.myclass.mymethod

  • 如果此方法与您创建补丁的文件相同(例如,方法和其测试在同一 Python 文件中),则target应写作:__main__.myclass.mymethod

在可选参数中,我们只将讨论和使用newnew参数告诉哪个对象将替换target。它可以是一个类或一个Mock对象。这可以通过一个例子更好地理解。请参见以下代码行:

patch('__main__.MyClassA.foo', new=Mock(return_value=500))

第一个参数是target。它是MyClassA类的一个方法foo,其行为需要在测试中临时更改。换句话说,这就是需要补丁的方法(或target)。new参数指定了将替换此方法的对象。换句话说,target通过new对象进行补丁。如果您不指定new参数,则target将自动使用MagicMock对象进行补丁。

在单元测试中使用补丁

为了演示patch装饰器的使用,我们将使用在标题在单元测试中使用 Mock 对象下讨论的例子。在阅读以下讨论之前,请回顾MyClassA.compute方法。它在上文标题中已说明,代码也可以在文件wargame/test/mockdemo.py中找到。以下是为MyClassA.compute编写的使用补丁的单元测试:

在单元测试中使用补丁

在前面的单元测试中:

  • patch是一个使用 with 语句调用的上下文处理器。

  • with关键字在代码执行后清理使用的资源。

  • MyClassA.foo方法被用可选参数new创建的Mock对象替换。

  • 换句话说,MyClassA.compute中的self.foo()调用被替换为这个Mock对象的return_value。在运行时,表达式x = self.foo()变成了x = 500,而不实际调用 foo 方法。

    小贴士

    之前示例中的测试会通过吗?为此,请回顾MyClassA.compute方法中的代码。由new参数创建的Mock对象返回值为500。在单元测试中,如果结果不是400,则会引发断言错误。因此,这个测试预期会失败。

如果没有指定new参数会发生什么?如前所述,target会自动被替换为一个新的MagicMock对象。这里还有另一种编写相同测试的方法。作为一个练习,运行这个测试,并打印foo_patch.__class__以找出它属于哪个类:

使用单元测试中的 patch

在对模拟库的介绍之后,让我们使用patch装饰器为我们的应用程序中的某个方法编写一个单元测试。

第三个单元测试 – 游戏方法

在本节中,我们将使用模拟库为AttackOfTheOrcs.play编写一个单元测试。让我们首先回顾一下这个方法。你还可以在wargame/attackoftheorcs.py文件中找到源代码:

第三个单元测试 – 游戏方法

这个先前的游戏方法做了很多事情。它首先创建一些必要的对象,例如玩家和小屋。然后程序运行,直到玩家占领所有小屋或玩家在战斗中失败。仔细观察代码。它依赖于用户输入来选择小屋。这并不是它需要的唯一用户输入。对Knight.acquire_hut方法的调用会再次询问用户是否继续攻击。

在自动化测试中,你不能期望有人输入小屋编号和其他输入以继续执行。那么我们如何为这个方法编写单元测试呢?这就是我们可以使用patch装饰器来模拟用户输入的地方:

第三个单元测试 – 游戏方法

我们在这里应该测试什么?我们应该测试该方法的整体功能。这里有几个需要测试的事项:

  • 胜利或失败的标准。当所有小屋都被占领时,玩家被宣布为胜利者。

  • 为了实现这一点,玩家也必须处于良好的健康状况,这意味着player.health_meter的值应该大于零。

因此,只有在这两个条件都为真时才会宣布胜利者。同样,也将会有一个容易确定的失败标准。为了精确控制,你还应该为play方法中调用的单个方法编写单独的单元测试。例如,应该有一个单独的测试来验证Knight.acquire_huts的工作情况。

让我们编写一个测试来验证整体功能。这个测试将使用patch来处理用户输入。和之前一样,你可以在这个模块wargame/test/test_wargame.py中找到这个测试。以下代码片段显示了该模块中的TestWarGame.test_play方法。在模块的开始部分,像这样导入 mock 库:

from unittest import mock

本模块中剩余的代码将在此不讨论。请查看上述文件以获取更多详细信息:

第三单元测试 – 演练方法

上述代码中的重要部分是mock.patch。我们的第一个目标是确保用户输入得到适当的处理。回想一下,在 Python 3 中,用户输入由内置函数input()处理。因此,我们需要用模拟用户输入的东西来修补这个函数。换句话说,用new参数表示的处理函数替换builtins.input函数。

self.hut_selection_counter属性被用作一个简单的计数器来模拟用户输入。其余的代码实现了验证获胜和失败标准的逻辑。acquired_hut_list使用列表推导生成。关于列表推导的更多内容,我们将在讨论性能改进时再谈。all函数在所有列表元素都是True时返回True

小贴士

如果你在使用 Python 2.7.9,尝试将builtins.input替换为__builtin__.raw_input。然而,这种方法似乎效果不佳,因为在运行测试时仍然会提示你!在 Python 3.5 中,这不是问题。正如之前所说,在 Python 3.3 之前,mock 不是一个内置模块(unittest.mock)。因此,对于 Python 2.7.9,你可能需要安装 mock 作为pip install mock,并对import语句进行适当的修改。

接下来,我们将回顾user_input_processor,它用于修补内置的input函数:

第三单元测试 – 演练方法

它将用户prompt作为参数,并返回对该提示的答案(用户输入)。例如,当提示输入小屋编号时,它会将self.hut_selection_counter增加1,并返回更新后的值。这个属性在test_play方法中被初始化为0。为了更好地理解这段代码,向这两个方法添加一些print语句,并按照以下方式执行测试:

$ cd wargame
$ python -m unittest test.test_wargame.TestWarGame.test_play

执行测试时的输出如下所示。注意,它不会在命令行输出中打印用户提示,如继续攻击?(y/n):

第三单元测试 – 演练方法

你的代码是否被覆盖了?

有没有一种方法可以检查你在测试方面的表现如何?单元测试覆盖了多少代码?为此,你需要一个名为coverage的 Python 包。它可以按照以下方式使用 pip 安装:

$ pip install coverage

此命令在 Python 安装的同一位置创建了一个名为coverage的可执行文件。在 Linux 中,如果 Python 3 安装在/usr/bin/,则coverage将在与/use/bin/coverage相同的路径下可用。在 Windows 操作系统上,它将在Scripts目录中可用,与pip.exe在同一位置。按照以下方式运行coverage命令:

$ cd wargame
$ coverage run -m test.test_wargame && coverage report

此命令是两个命令的组合,由&&分隔,依次执行。第一个命令运行测试:coverage run -m test.test_wargame。这与我们运行单元测试的方式相似。run选项运行 Python 程序,并测量代码执行。如前所述,-m选项指示coverage将下一个参数视为可导入的 Python 模块,而不是将其视为脚本。这就是为什么我们指定下一个参数为test.test_wargame(就像一个import语句)而不是编写test/test_wargame.py

第二个命令,coverage report,会生成显示测试覆盖率的报告。以下是运行此命令后覆盖报告的呈现方式。为了便于说明,以下截图未显示与测试用例执行(第一个命令)相关的输出:

你的代码是否被覆盖?

要查看不同的覆盖率报告,尝试在test_wargame.py中禁用一些测试,然后重新运行前面提到的coverage命令。

解决导入错误(如果有)

只有在执行覆盖率时遇到任何导入错误时,才阅读本节。如果您按照说明运行coverage,不太可能遇到没有名为knight的模块之类的导入错误。换句话说,从顶级目录wargame运行测试,并确保以模块(-m选项)而不是脚本的方式运行。如果您以以下方式运行coverage,可能会看到导入错误:

$ cd wargame/test
$ coverage run test_wargame.py && coverage report

在前一个例子中,它无法从wargame目录中找到模块的正确PATH。请确保wargametest目录都在您的sys.path中。一个快速且简单的解决方案是将以下代码添加到test_wargame.py中。假设您在test目录内运行覆盖率,请在from knight import Knight等导入语句之前添加以下代码:

import sys 
# Append the directory one level up to the sys.path . 
# Alternatively specify the full path to that dir.
sys.path.append('../')

其他单元测试工具

在本章中,我们专门使用了内置的unittest框架来编写测试。还有其他一些单元测试工具未被讨论。本节的目的仅在于向您介绍除了内置的unittest模块之外的其他单元测试工具。例如,有如 nose 或 pytest 之类的工具,在很大程度上简化了单元测试的编写。让我们简要回顾一些这些单元测试工具。

Doctest

这是一个内置模块,它寻找类似于在解释器中编写的 Python 代码的文本。以下是一个简单的示例,展示了带有函数示例用法的文档字符串:

def add_nums(a, b): 
    """Return sum of two numbers 

    Example usage: 
    .. doctest:: 

    >>> add_nums(10, 20) 
    30 
    """ 
    return (a + b)

Doctest识别这样的代码,并运行它以检查它是否真的做了它所说的。这是一种非常有效的方式来验证你在文档和/或文档字符串中编写的代码示例的正确性。虽然这非常有用,但在这里值得注意,文档字符串中的大量代码示例可能会分散注意力。有关更多详细信息,请参阅docs.python.org/3/library/doctest.html#module-doctest

Nose

Nose 是一个流行的第三方工具,它简化了单元测试的编写和运行。使用以下方式安装:

$ pip install nose

Nose 扩展了unittest。使用这个工具的一个优点是它不需要你将测试编写为unittest.TestCase的继承类方法。你甚至可以将测试编写为单独的函数。让我们编写一个简单的测试,并用nosetests运行它。在名为test_nose.py的文件中创建以下函数:

def test_a(): 
   assert( 1 == 1) 

按如下命令行运行此测试:

$ nosetests test_nose.py

那就结束了。它将运行测试。显然,这个测试将会通过。正如所见,我们不需要将测试放在unittest.TestCase的子类中。函数名需要包含testTest,因为我们正在使用默认的 nose 配置。尝试重命名函数,使其不包含单词test。例如,命名为foo_a。如果你再次运行nosetests,它将排除这个函数。要考虑不包含单词test的函数名,使用命令行选项--tests,如下所示:

$ nosetests --tests foo_a test_nose.py 

查看nose.readthedocs.org了解如何有效地使用 nose。

Pytest

Pytest 是另一个流行的工具,它简化了单元测试的编写。可以使用以下方式使用pip安装:

$ pip install pytest

你可以运行我们为 nose 编写的相同测试。让我们将以下代码保存到文件中,命名为test_pytest.py

def test_a(): 
   assert( 1 == 1) 

按如下命令行运行前面的测试:

$ py.test test_pytest.py

查看pytest.org/了解更多关于这个工具的信息。

重构前缀

让我们为游戏编写另一个单元测试。这次我们将关注主类AttackOfTheOrcs。当调用play方法时,它首先随机占领五个小屋。我们将编写一个测试来验证恰好有五个小屋。另一件要测试的事情是,小屋的居住者必须是AbstractGameUnit类的实例,或者应该是None类型。

_occupy_hut方法有相关的代码。但这需要编写一个针对非公共方法的测试(或者称之为受保护的或私有的)。

重构前缀

你说得对!虽然 Python 没有限制你调用以下划线开头的函数,但我们应该对他人友好,并尽量避免调用这样的函数。

那么,我们如何处理这种情况?以下是一个可用的选项列表:

  1. 在测试中,创建一个AttackOfThOrcs的实例,并直接调用受保护的方法。

  2. 将此方法转换为public方法(从名称中移除下划线前缀)。

  3. 调用play方法,然后它将调用_occupy_huts

  4. 重构play方法,并将_occupy_huts包装成一个可测试的public方法。

我们已经与第一个选项产生了道德冲突,因为_occupy_huts是一个非公共方法。第二个选项建议将其转换为public方法。这是可能的,但如果出于任何原因这个方法不应该从外部调用,我们应该避免这种改变。我们将记住这个选项,并寻找其他替代方案。

第三个选项需要调用play方法。我们已经在上一个例子中使用 patch 装饰器做到了这一点。虽然可行,但为了测试一个小功能而运行一大块代码是不高效的。让我们暂时不考虑这个选项。第四个选项建议重构代码。让我们进一步讨论。

小贴士

在我们开发的简单应用程序中,将_occupy_huts更改为public方法没有坏处!我们可以简单地将其重命名为occupy_huts(没有下划线前缀),然后更新调用代码,并愉快地编写测试!实际上,重命名也是一种将要介绍的重构形式。然而,在现实世界中,你可能没有将受保护的方法转换为公共方法的奢侈。考虑到这种情况,我们将重构代码以展示一种使代码易于测试的方法。

转弯——重构以提高可测试性

上一节中的第 4 步需要我们在编写测试之前重构play方法。这种重构将提高我们编写更干净测试的能力。那么,什么是重构?它是如何执行的?好消息是,你在第一章 开发简单应用程序 中已经进行了一种形式的重构,将初始的命令行脚本转换成一系列函数。让我们暂时偏离一下,学习一些重构技术。然后我们将带着重构后的代码回来,为我们的应用程序开发最终的单元测试。

重构

你已经在前面的章节中遇到了“重构”这个词,可能想知道它的意思。现在需要对此进行解释。

只需四处看看。窥视你的衣柜或打开你的办公桌抽屉。第一天,一切看起来都很整洁且易于管理。抽屉原本是用来存放所有重要的财务文件的。随着时间的推移,东西开始积累,抽屉里现在不仅装满了财务文件,还有各种各样的事物,从潦草的笔记、办公文件到贺卡。很快,你就找不到你现在需要的那个重要文件。你花了大量时间去挖掘你需要的东西。

金色时刻终于到来。你开始清理操作!发现了一些无用的东西,被扔掉了。还有一些其他的东西仍然有用,比如下周的足球比赛门票。你把这些东西移到另一个抽屉里,那里才是它们真正属于的地方。你还发现了几张属于同一类别的纸张:房屋维护账单。你把这些纸张整理在一起,放在一个单独的文件夹里。最后,通过所有的重新排列和清理,你的抽屉迎来了新的一天!

什么是重构?

重构与你的桌面抽屉非常相似。应用程序代码就像是抽屉里装满了文件。随着代码的演变,好的和坏的东西都会慢慢渗入。从外面看,抽屉的行为保持不变。你仍然可以往里面放文件(代码),业务照常进行。如果没有重构,总有一天会达到一个临界点,变得无法容纳新的文件。

备注

通过重构,你可以在不影响代码外部行为的情况下对代码进行内部更改。

为什么重构?

简短的回答是,如果你希望你的代码拥有长久和健康的生活!及时的重构对于保持代码的可维护性和可扩展性非常重要。你宁愿花更多的时间开发一个酷炫的新功能,也不愿熬夜去修复一个微不足道的问题——一个本可以在几分钟内修复的 bug,如果代码得到了适当的维护。

备注

重构应该更多的是一种习惯,而不是一种义务。

何时进行重构?

所以我们何时重构代码?你必须寻求最佳平衡。如果你在开发周期中意识到太晚,它会影响生产力,因为你需要花费相当多的时间进行代码清理。很多时候,项目截止日期会让你放弃重构。不幸的是,软件的用户可见部分胜过了内部的清理。你只考虑了立即的可交付成果,而忽略了重构只会帮助你更快地交付产品的这一事实。

一种策略是定期审查代码,并留出一些时间进行重构。如果你遵循Scrum方法论,你可以将一个冲刺时间用于一些较小的重构项目。这种维护冲刺从长远来看会带来回报。如果你面对的是需要立即重构以生存的大规模遗留代码,所需的变化可能会造成破坏。在这种情况下,考虑将其分解成更小的问题,并使用下一段中讨论的另一种策略。

备注

敏捷开发方法论

这通常与一套非传统的软件开发方法相关联,用于管理项目。在这个方法中,你定义在短时间内可实现的短期目标。有定期的检查点,称为冲刺或迭代。冲刺的结束应该产生一个增量且可发布的版本。这在复杂项目中很有用,因为很难规划整个项目,或者由于项目的动态性质,很难预测接下来会发生什么。该方法采用增量迭代的方法来处理这项任务。有关进一步阅读,请参阅以下维基页面:en.wikipedia.org/wiki/Agile_software_development

Scrum

这是一个产品开发方法。它是一个基于敏捷开发方法的框架,用于管理复杂系统。它实现了产品开发的增量迭代(冲刺)策略。以下链接是有关详细信息的维基页面:en.wikipedia.org/wiki/Scrum_(software_development)

另一种策略是在主要发布后立即进行重构任务。客户刚刚得到了他们所要求的东西。在没有任何阻止性错误的情况下,你通常会在这个期间找到一些空闲的工作周期。这是进行下一版发布规划和代码重构任务的好时机。这会因项目而异。它取决于应用程序的开发活跃度、大小、架构等因素。

如何进行重构?

现在我们已经了解了重构的含义,让我们看看如何进行它。首先的任务是识别那些制造麻烦的代码片段,然后对其进行重构。重构不应该影响代码的外部行为。同时,它应该通过简化内部机制(代码)来帮助开发者更容易地工作。我们将讨论一些最常见的重构操作。为了帮助理解这些操作,我们将在适当的地方使用类似 UML 的代表性块。

小贴士

统一建模语言UML)表示。请参阅www.uml.org

重命名

假设一位开发者在一个名为 Orcs 的攻击 的游戏中引入了一个新功能。每个小屋都有一个秘密的盒子。每当一个单位获得小屋时,盒子的内容就会以打印语句的形式向新主人展示。这位开发者已经在 Hut 类中引入了一个名为 showStuff() 的新方法。然而,这里使用的名称并不直观。不清楚它是显示盒子里的东西,还是提供了关于小屋中其他东西的一些信息。重命名这样的方法是代码重构最简单的形式之一。你可以将其重命名为更详细的名称,例如 show_box_contentsreveal_box_contents。但是,确保你彻底执行重命名任务,包括重命名所有方法调用。

小贴士

编码规范

这个例子提出了一个有趣的话题,Python 编码规范。如果你直接跳到了这一章,请阅读第四章 文档和最佳实践,它讨论了编码规范!这些规范基本上为 Python 程序员提供了一个编码风格指南。遵循这些规范并为项目定义自己的指南将有助于减少这样的重命名任务。

提取

在第一章 开发简单应用程序 中,我们有一个代表游戏的单个脚本。我们识别出可以写成单独函数的代码片段。每个函数的名称都是根据函数体应该执行的操作来选择的。这在上面的代码片段中显示:

提取提取

这个重构操作被称为函数提取。同样,你可以将相关的代码片段组合在一起,在类中提取一个方法或提取一个新的类。

移动

在第三章 模块化、打包、部署! 中,我们又进行了一种类型重构操作。你能猜到是什么吗?应用程序代码包含在一个单独的文件中。我们通过将每个类移动到自己的文件中,并更新引用的代码来对其进行模块化。

假设你有一个类 A 的方法,这个方法主要被类 B 的各种功能使用。根据问题的性质,看看这个方法是否更适合在类 B 中而不是现有的类 A 中。如果是这样,将这个方法移动到类 B 中可能是一个选择。

向下推

有一个新的功能请求。这次它来自 Sir Foo!

向下推

KnightOrcRider 分别是骑马和类似野猪的单位的骑乘单位。你在超类 AbstractGameUnit 中引入了一个新的方法 unmount,它赋予它们从骑乘的动物上下来的能力:

向下推

然而,你现在已经在游戏中引入了几个其他的虚构角色。对于大多数角色来说,这种方法已经变得不相关了。现在将unmount方法推到继承层次结构中的子类中,使其相关,是有意义的。这在下图中有所展示。unmount方法被移动到子类KnightOrcRider中:

向下移动

小贴士

当向上拉(见下一标题)或向下推类型重构简化了事情,但它可能并不总是达到其目的。unmount方法只是作为一个示例。马与运动相关联。一个选项是在这里定义一个移动行为。例如,用马移动,用野猪移动,等等。另一个替代方案是定义单元类型为骑乘或未骑乘。参考第六章,《设计模式》,它展示了处理类似情况的一种优雅方式。

向上拉

这是与向下推相反的操作,我们使用继承原则。一个子类定义了一些功能。完全相同的方法在其他子类中定义。这个方法可以被拉上来并在超类中定义,使其对所有子类可用。

Python 的重构工具

有一些工具可以自动化某些类型重构。例如,如果你想重命名一个方法,该工具将重命名它,并自动更新代码中对该方法的全部引用。以下是一些此类工具的部分列表:

  • 使用 Python IDE:假设你正在使用 IDE 进行 Python 应用程序开发,最方便的选项是使用 IDE 内置的功能来重构代码。例如,PyCharm 提供了重构的菜单项,并支持最常执行的重构操作,如前几节中讨论的。

  • 绳索:Rope 是一个开源库,用于重构 Python 代码。如果你是 vim 或 emacs 等编辑器的粉丝,可以安装插件以在编辑器中集成重构功能。可以使用 pip 安装此库。有关更多信息,请参阅 GitHub 页面github.com/python-rope

  • 自行车修理工:这是 Python 可用的另一个重构工具。可以使用 pip 安装此库。有关更多信息,请访问pypi.python.org/pypi/bicyclerepair

单元测试回顾

这里是对我们之前关于单元测试讨论的快速回顾。我们的意图是为在非公共方法AttackOfTheOrcs._occupy_huts中找到的功能编写单元测试。一个直接的选择是从单元测试中直接调用此方法。然而,调用非公共方法不被认为是最佳实践,所以我们开始寻找替代方案。另一个选择是重构AttackOfTheOrcs.play,并在单元测试中使用提取的public方法。在这个时候,我们偏离了单元测试,学习了重构的基础。现在是我们使用刚刚学到的技术重构AttackOfTheOrcs.play的时候了。

重构以提高可测试性

游戏源代码《奥克之攻》提供了足够的重构机会。下面的代码展示了play方法。为了说明,省略了代码注释:

重构以提高可测试性

上述代码的前一部分做一些准备工作以创建所需的对象。它创建了KnightHut实例,以及代表小屋居住者的对象。此外,它还在游戏中打印了一些信息。作为一个初步的重构,我们将提取一个新的public方法,如下所示:

重构以提高可测试性

新方法主要提高了代码的可读性,并简化了编写测试的难度。

小贴士

如在重构前言章节所述,这是一个玩具问题。这里使用的重构策略是提取一个新的方法以提高可读性和可测试性。你也可以用其他方式重构。例如,设置代码创建了诸如玩家和小屋之类的对象。也许你应该也将_occupy_huts重命名为create_huts?选择可能各不相同,重构策略也是如此。本节不仅回答了在这里重构的最佳策略是什么的问题,而且主要作为一个示例,说明重构如何帮助简化编写单元测试的任务。

play方法的基本重构将使得编写setup_game_scenario方法的单元测试成为可能,这反过来又有助于测试_occupy_huts中的功能。

第四个单元测试 – setup_game_scenario

如在重构前言章节所述,此测试将验证以下事项:(a)恰好有五个小屋,(b)小屋居住者是一个AbstractGameUnit的实例,或者是一个None类型的实例。

下面的测试展示了这一点。你还可以在支持代码中找到这个测试以及其他测试。请参阅wargame/test/test_wargame.py文件。代码注释应该能够使代码自解释:

第四个单元测试 – setup_game_scenario

按以下方式运行前面的单元测试:

$ cd wargame
$ python -m unittest test.test_wargame.TestWarGame.test_occupy_huts

练习

本章的各个部分已经提出了一些练习。尝试这些练习。例如,将单元测试拆分,以便你有单独的模块来测试不同类别的功能。添加更多单元测试以提高代码覆盖率。此外,尝试运行nosetests来测试我们已编写的测试。

重构和重新设计练习

在重构方面有几个低垂的果实!请审查AttackOfTheOrcs._occupy_huts方法。它创建了小屋对象,并将一个居住者在每个小屋中。作为第一步,你可以将其重命名为create_huts。这个方法中的代码可以写得更好。它使用if...else条件来决定创建哪个居住者。尽管在这个简单的应用中它可行,但如果添加其他类型的居住者(精灵、矮人、巫师等等),它将变成一个维护的头疼问题。

我们在这里能做什么?一种策略是让Hut类管理occupant对象的创建。小屋可以要求工厂随机创建一个居住者。你将在第六章设计模式中学习关于工厂模式的内容。由于我们将这个问题视为一个重构问题,你可以尝试以下方法:

  • 修改Hut.__init__的签名,以便你可以选择指定occupant

  • Hut类内部,通过调用一个新的实用函数create_unit来创建一个occupant(如果尚未创建)。你需要编写这个新的实用函数(解决方案未提供)。这个函数不应是Hut类的一个方法。

摘要

这一章开始强调了测试的需要。它介绍了 Python 中的单元测试框架。你学习了如何编写和执行单元测试。下一个主题是 Python 模拟库的介绍。章节展示了在单元测试中使用Mock对象。接下来,它展示了在没有先重构代码的情况下难以编写单元测试的例子。在这个时候,你学习了重构的基本知识,重构了代码,然后为这个例子开发了一个单元测试。

在开发过程中,你经常会遇到一个反复出现的问题。通常,存在一个通用的解决方案(或配方)适用于这个问题。这通常被称为设计模式。在下一章中,我们将回顾 Python 中一些常用的设计模式。

第六章:设计模式

本章将向你介绍一些常用的设计模式。以下是本章的组织结构:

  • 我们将从设计模式的一个快速介绍开始,然后讨论一些有助于简化它们实现的 Python 语言特性。

  • 接下来,借助幻想游戏主题,我们将讨论以下设计模式:

    • 策略模式

    • 简单和抽象工厂模式

    • 适配器模式

  • 对于每个模式,一个简单的游戏场景将演示一个实际的问题。我们将看到设计模式如何帮助解决这个问题。

  • 我们还将使用 Python 方法实现这些模式中的每一个。

已知有几种设计模式。如前所述,我们只讨论其中的一些。我们的目的是不提供一本关于模式的全新食谱,而是仅仅展示设计模式如何帮助解决一些常见的问题,以及如何在 Python 中实现它们。除了这本书之外,你还可以探索其他传统的设计模式,并尝试为它们添加 Python 风格。

顺便说一句,你即将被介绍到一些新的游戏角色。所以准备好和 Sir Foo 和他的朋友们一起学习设计模式吧!

设计模式简介

假设在进行应用程序开发过程中,你遇到了一个反复出现的问题。沮丧之余,你向你的共同开发者或社区寻求帮助。猜猜看,你并不孤单。很多人在他们的代码中遇到过类似的问题。幸运的是,你得到了一个找到解决方案的人的回应。这个解决方案似乎在类似的问题上工作得很可靠。你修改了有问题的代码,使其符合建议的设计,哇!你的问题解决了!

我们刚才讨论的是软件设计模式。软件设计模式是一种经过验证的解决方案或策略,帮助我们解决代码中常见的难题。让我们从设计模式的广泛类别开始,然后讨论一些重要的设计原则。

注意

四人帮书籍

在开始任何关于 Python 中设计模式的讨论之前,值得注意的是,有一本非常好的书你可能想在你的书架上,那就是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 合著的《设计模式:可复用面向对象软件元素》(Design Patterns: Elements of Reusable Object-Oriented Software)。这四位作者通常被称为四人帮GoF)。他们的书使用 C++和 Smalltalk 示例来说明设计模式。如果你有 C++或 Java 等编程语言背景,这本书可能对你更有兴趣。正如你将在本章中看到的,Python 的一些高级语言特性使得许多设计模式更容易实现。GoF 的书仍然是一本很好的参考书,将帮助你理解设计模式背后的核心概念。

模式的分类

软件设计模式可以大致分为四类,即行为模式、创建模式、结构模式和并发模式。在本书中,我们将仅讨论三种设计模式。我们将分别看到一个行为模式、一个创建模式和结构模式的例子。并发模式在此不涉及,因为它是一个高级主题,超出了本书的范围。为了深入了解其他设计模式,你可以阅读关于设计模式的书籍,例如前面提到的 GoF 书籍。考虑到这一点,接下来我们将简要讨论这些类别。

行为模式

行为设计模式试图简化不同对象之间如何相互通信。在这样做的同时,这些模式有助于保持这些对象松散耦合或减少彼此的依赖。以下是一些行为设计模式的列表:责任链模式、命令模式、策略模式、观察者模式、迭代器模式、访问者模式,等等。在本章中,我们将看到如何在 Python 中实现策略模式。

创建模式

这些模式都是关于实例创建机制的。这些设计模式展示了根据你所处理的情况创建对象的更好方式。

下面是主要创建设计模式列表:抽象工厂模式、工厂方法模式、建造者模式、原型模式和单例模式。我们将在本章中讨论抽象工厂模式。

结构模式

结构设计模式通常处理组件之间的关系,例如对象或类,以便更容易使这些实体在一个更大、更复杂的系统中协同工作。结构设计模式的例子包括适配器模式、组合模式、装饰器模式、外观模式、享元模式、代理模式等等。在本章中,我们将看到适配器模式的 Python 实现。

并发模式

简而言之,并发意味着同时执行多项任务。并发使你的应用程序能够在执行一项任务(例如,更新数据库)的同时,也在处理其他事情(例如,响应用户查询)。一般来说,并发设计模式处理多线程编程范式。以下是一些并发模式的列表:主动对象模式、拒绝模式、监控对象模式、双重检查锁定,等等。如前所述,本书将不讨论任何并发模式。尽管如此,第九章 性能提升 – 第二部分,NumPy 和并行化 将介绍 Python 中多线程编程的一些方面。有关更多信息,请访问en.wikipedia.org/wiki/Concurrency_pattern

Python 语言和设计模式

由于 Python 中内置的高级语言特性,许多正式的设计模式都很容易实现。在某些情况下,这些模式对语言来说如此自然,以至于很难将其视为正式的设计模式。例如,迭代器模式可以通过使用任何可迭代对象来实现,例如列表、字典等。让我们快速回顾本节中的这些语言特性或范式。这不是一个详尽的列表,但我们将涵盖一些重要方面。

提示

你即将阅读的习语(一等函数、闭包等)可能听起来有些复杂。但不要被这些术语所压倒!如果你是 Python 程序员,那么你很可能已经有意或无意地使用了这些特性。如果这些习语对你来说现在毫无意义,请跳到下一节,在那里我们将快速跳到一个假想的游戏场景。在接下来的讨论中,我们将使用这些语言特性。当你需要方便的参考时,可以随时回到这一节。

一等函数

存在一个名为一等公民的编程习语。在 Python 中,任何函数、类、类方法或对象,都符合一等公民的资格。在这些实体上,你可以自由执行通常在其他实体上支持的操作。

例如,你可以将一个函数赋值给一个变量,就像你将一个值赋给该变量一样。同样,你可以将这个函数作为参数传递,或者从另一个函数的返回值中获取它。任何支持在函数上执行此类操作的编程语言都被说成具有一等函数。以下是一段简单的代码,展示了我们如何在 Python 中使用一等函数所能实现的事情。在这个例子中,一个函数test被赋值给一个变量x。赋值之后,该函数可以以x()test()的形式调用:

>>> def test(): 
...     print("inside test function") 
... 
>>> x = test 
>>> x() 
inside test function 
>>> x 
<function test at 0x7fca460efbf8> 

这里还有一个例子,说明了第一等函数特性。它展示了我们如何将相同的函数test作为参数传递给另一个名为some_function的函数:

>>> def some_function(y): 
...     y() 
... 
>>> some_function(test) 
inside test function 

让我们看看我们可以用其他一等实体,Python 类,做什么。

作为一等对象的类

就像函数一样,Python 类也是一等公民。它们可以作为参数传递,赋值给变量,或从函数中返回。以下是一个例子,其中类Foo被赋值给变量bar。在这个赋值之后,你可以使用bar来创建Foo的一个实例:

>>> class Foo:
...     def say_hi(self):
...         print("hi!")
... 
>>> bar = Foo
>>> z = bar()
>>> z.say_hi()
hi!

提示

我们在本章中不会使用闭包。这是一个相对高级的话题,包含在这里是为了完整性。你可以选择性地跳过下一节。

闭包

考虑任何在 Python 中定义了一些局部变量的函数。你可以在函数内部使用这些变量,但它们不能被外部世界访问(除非你从函数中返回它)。从某种意义上说,函数可以被认为是封闭的。当函数执行时,它使用这些局部变量;当函数完成后,局部变量超出作用域。它们的任务完成了,这就是故事的结尾。现在,如果你想要一个在创建时保持其局部环境的函数呢?

我们希望有一种方法可以将这个函数及其局部环境一起封装起来。以下示例可以更好地解释这一点:

闭包

在前面的示例中:

  • modified_numberinitial_number函数内的嵌套函数。

  • 这个嵌套函数使用一个局部变量x,它在顶层函数的作用域内。

  • 在主程序中,我们创建了foo,它是initial_number的返回值。但看看initial_number函数的返回值。它返回嵌套函数modified_number

  • 这意味着foo变量变成了嵌套函数modified_number

我们在这里实现了什么?我们实现了两个目标——首先,它使得从主程序中访问嵌套函数成为可能,其次,嵌套函数仍然保留了我们在实例化initial_number时使用的原始工作环境。在这个例子中,工作环境指的是传递给这个函数的具有100值的x参数。以下是该程序的输出:

1\. Initial number (orig environment during function creation): 100 
2\. Now calling this function with its original environment loaded: 
 x: 100, y: 1 , x+y: 101 
 x: 100, y: 5 , x+y: 105

注意到x的值保持不变。任何后续对foo的调用都保留了这个原始的局部环境,这是嵌套函数modified_number使用的。同样,你也可以使用不同的x值创建initial_number的另一个实例。这被称为 Python 中的闭包。闭包可以用来实现如观察者模式等设计模式。

其他特性

让我们回顾一些在实现某些设计模式时非常有用的内置函数和装饰器。再次强调,这并不是一个完整的列表,但足以帮助我们进行即将到来的设计模式讨论。

类方法

类方法(@classmethod)是一种可以在不创建该类实例的情况下调用的方法。与需要将类的实例(self)作为其第一个参数的常规实例方法不同,类方法将类作为其第一个参数。装饰器@classmethod只是创建类方法的一种方便方式。我们将在简单工厂的讨论中看到如何使用类方法。

抽象方法

使用 @abstractmethod 装饰器来指示给定方法是抽象的,必须在子类中重新实现。回想一下,我们已经使用此装饰器将 AbstractGameUnit.info() 实现为抽象方法。有关更多详细信息,请参阅第一章中的Python 中的抽象基类部分,开发简单应用程序。在本章中,我们不会使用此装饰器。

__getattr__ 方法

当你尝试访问在类中尚未定义的实例属性时,Python 会自动调用 __getattr__ 方法。你可以在类中实现 __getattr__,并使用它为所有此类未定义属性添加特殊处理代码。此方法的使用将在适配器模式中稍后进行说明。

鸭子类型

“鸭子类型”这个术语通常被举例说明为:“如果它像鸭子游泳和嘎嘎叫,那么我们就把它当作鸭子对待。”让我们通过一个简单的例子来看看这意味着什么。我们有一个名为 Knight 的类,具有 moveattack 方法,如下所示:

class Knight: 
    def move(self): 
        pass 
    def attack(self): 
        pass 

一个函数接受 Knight 实例作为参数,并按如下方式调用这些方法:

def do_something(a_thing): 
    a_thing.move() 
    a_thing.attack() 

然而,该函数不会检查输入参数是否真的是 Knight 类的实例。只要对象具有 moveattack 方法,它就不会抱怨。因此,在鸭子类型中,语言不会对对象进行任何验证。它唯一关心的是是否可以使用该对象调用某些属性。鸭子类型的一个优点是代码可重用性。你可以通过传递不同类的对象来在其他代码中重用 do_something 函数。

例如,想象一个实现了 moveattack 方法的 Lion 类。你希望在某个其他项目中重用上述 do_something 函数,该项目已经使用了这个类:

 class Lion: 
    def move(self): 
        pass 
    def jump(self): 
        pass 
    def roar(self): 
        pass

只要输入对象定义了 moveattack 方法,do_something 函数就会正常工作。这一切如何转化为设计模式讨论?其他编程语言,如 Java,在代码中定义正式接口以实现某些设计模式,例如抽象工厂模式。在 C++ 中,使用纯虚函数定义抽象基类。在 Python 中,我们有选择使用鸭子类型而不是实现接口或抽象基类的选项。为了对设计模式本身有更清晰的理解,你可能仍然需要记录这样的抽象基类或接口。

小贴士

在 Python 中,我们仍然可以使用 Java 风格的接口。Zope 网络框架(本书未涉及)是一个很好的例子。有关更多信息,请访问以下链接:docs.zope.org/zope.interface/README.html。另外,请参阅稍后关于抽象工厂讨论中的注释,说明如何在 Python 中强制执行接口。

Duck typing 为程序员提供了很多自由,但这种自由有可能引入仅通过查看代码就难以察觉的 bug。但通过广泛的单元测试可以检测到这样的错误。减少此类问题的另一种方法是强制执行严格的编码标准和文档。例如,你可以创建一些自定义编码标准,以避免由于 Duck typing 引起的混淆。

通过对一些关键语言特性的基本介绍,让我们继续讨论如何实现一些设计模式,以及它们解决什么问题。

本章剩余部分的结构

在深入讨论设计模式和它们的实现之前,让我们首先为剩余的讨论制定一个策略。如前所述,我们将回顾策略模式、简单工厂模式和抽象工厂模式,以及适配器模式。关于设计模式的讨论将大致按照以下结构进行:

  • 从模式的正式定义开始

  • 提出一个新的功能请求的假设场景

  • 讨论引入这个新功能时遇到的问题

  • 尝试快速解决这个问题,很快意识到我们需要重新思考设计

  • 使用设计模式解决问题的方案

对于一些设计模式,我们将讨论两种解决问题的方法。一种类似于在 C++等语言中遵循的传统方法,另一种是 Pythonic 方法。

小贴士

如果你只对 Pythonic 方法感兴趣,可以跳过传统解决方案。

以下是从支持代码包中将要审查的文件列表:

本章剩余部分的结构

值得注意的是,我们不会开发一个完整的功能游戏应用。这个想法是利用这个游戏主题作为理解一些设计模式的辅助。本章使用的代码相当简单。虽然大部分代码将在接下来的讨论中展示,但你也可以从本章的补充代码包中下载并审查源代码。

快进 – 魔兽攻击 v6.0.0

让我们快速跳到游戏的未来假设版本!

这个假设版本是最受欢迎的开源 Python 应用程序之一。现在你有一支开发团队帮助你进行应用开发。游戏已经发展得相当多。它不再是一个简单的应用,通过击败敌人来控制小屋。现在它是一个回合制的幻想游戏,玩家和敌人轮流攻击对方,或者利用这个回合向对手靠近或远离。你引入了几个新的游戏任务,并重新设计和重构了代码以适应新的要求。在最新版本中,你有以下游戏角色:骑士、兽人骑手和精灵骑手。

小贴士

精灵是一种想象中的超自然神话生物。请参阅第一章中的书籍主题部分,开发简单应用程序,以获取有关精灵的一些参考资料。

在这个版本中,每个游戏角色都有攻击敌人、向敌人靠近或远离敌人,或在棚屋内恢复的能力。我们不必担心实现这些功能的应用程序的实际逻辑。我们更应关注应用程序的高级设计。以下伪 UML 图显示了各种类及其一些公共方法:

快进 – 魔兽攻击 v6.0.0

注意

如第一章中所述,开发简单应用程序,我们将松散地遵循 UML 表示。我们将其称为伪 UML 图。这里使用的惯例的解释是必要的。图中的每个类都由一个圆角矩形表示。它显示了类名后跟其属性。属性前的加号(+)表示它是公共的。受保护的或私有方法通常用负号(-)表示。为了便于说明,只列出了几个相关的公共属性。

如类图所示,所有游戏角色都继承自一个共同的超类AbstractGameUnit。每个子类都有自己的info()attack()实现。换句话说,每个子类都有自己攻击敌人的方式。进一步假设在上述版本中,所有子类都使用在超类中定义的公共move()方法。如果你看到游戏说明的实际操作,这可能会更容易想象。

请参阅以下截图,展示了玩家将被提示进行移动的方式:

快进 – 魔兽攻击 v6.0.0

注意

太棒了!我想玩这个新游戏场景。源代码在哪里?

这里的意图不是开发完整的游戏逻辑。这是一个想象场景,仅用于突出应用程序开发中常见的一些问题。通过这个场景,我们将看到设计模式如何帮助解决这些问题。没有提供代码来实际“玩”这个新游戏。支持代码说明了如何实现这里讨论的各种设计模式。

如从命令行输出中可见,它为玩家提供了选择向四个方向之一移动的选项。它还指示了每个方向前方的情况。在这个特定的情况下,玩家决定向南移动,但这种移动受到栅栏的限制。

策略模式

策略设计模式是一种行为模式,用于表示一组算法。该家族中的一个算法表示为一个策略对象。该模式使得在特定家族的不同策略(算法)之间轻松切换。这通常在你想要在运行时切换到不同的策略时非常有用。我们将在讨论策略模式的末尾重新审视这个定义。

策略场景 – 跳跃功能

有一个高优先级的特性请求。更确切地说,这是一个投诉。用户们只是讨厌栅栏强加的运动限制。现在连 Foo 爵士也加入了抗议...

策略场景 – 跳跃功能

与其从场景中移除栅栏,不如添加一个新功能,允许单位跳过栅栏或任何类似的障碍物?

你在超类 AbstractGameUnit 中引入了一个新的方法,jump()。所有类都继承了这个方法,如下面的类图所示:

策略场景 – 跳跃功能

栅栏不再阻止玩家移动。新的跳跃选项使得跨越栅栏没有任何问题。这很简单,不是吗?每个人都感到高兴(尤其是 Foo 爵士)!

策略 – 问题

让我们快速前进到应用的一些更多主要版本。

你向游戏中引入了两个新的虚构角色,一个矮人和一个精灵,提供了独特的技能。例如,精灵拥有治愈你军队中附近受伤单位的能力,而矮人单位则提供对敌人攻击的坚固防线。因此,每周的应用程序下载量现在已经达到了新的高峰。然而,用户们报告了一个新的问题。让我们听听伟大的矮人的意见:

策略 – 问题

你在这里看到了问题吗?跳跃功能有一个不希望出现的副作用。它甚至允许精灵或矮人跳过栅栏。骑士、精灵骑手和兽人骑手都是骑乘单位。想象这些单位跳过栅栏更容易。然而,对于一个像矮人这样的游戏角色来说,这样思考并不直观。我们遇到这个问题是因为所有类都使用了 AbstractGameUnit.jump 方法的默认实现。如下面的类图所示:

策略 – 问题

策略 – 尝试的解决方案

精灵和矮人游戏单位不应该有跳跃功能。那么我们在这里能做什么呢?精灵有话要说:

策略 – 尝试的解决方案

使用继承当然是一种方法。你可以在新类中覆盖jump方法,使其成为无操作。然而,在下一个版本中,你计划引入一些不应该跳跃或需要以不同方式跳跃的新角色。以下是一些在以下类图中表示的新类。所有这些都需要覆盖并实现它们自己的逻辑。

策略 – 尝试的解决方案

跳跃功能只是你将看到这个问题的地方之一。我们甚至不需要超出我们已有的内容。在上面的图中,看看moveattack方法。你是否看到了同样的问题在酝酿?

游戏角色在进化。它们有自己的移动规则。例如,骑马的Knight可能需要两回合就能穿越河流,而DwarfFighter则需要 10 回合才能完成同样的任务。

同样,每个单位都有其独特的攻击敌人的风格。你军队中的老Wizard可以对敌人施展魔法咒语。ElfRider角色在一回合内使用弓箭攻击两次。DwarfFighter角色则使用锤子攻击,等等。

如果我们继续在这里使用继承原则,很快就会变成一个维护噩梦。为什么?这是因为你写的每个类都负责实现和维护其移动、跳跃和攻击能力的逻辑。最初,你可能认为这是一个微不足道的问题,在子类中覆盖功能即可解决问题。但随着角色类型和它们不断增长的能力(移动、跳跃、游泳、防御、隐藏、再生等)的增加,这将会变成一项艰巨的任务。代码也可能在各个类之间重复。

逻辑的任何小改动都需要你更新所有类中的相应方法。如果在更新过程中遗漏了一些方法,也可能引入新的错误。我们需要重新思考设计,以便更容易地适应未来的需求。让我们接下来这么做。

策略 – 重新思考设计

在这种情况下我们能做什么?观察发现,定义这些能力的实现代码在子类之间是不同的。在这个例子中,DwarfFighter不能跳跃,而Knight则骑马跳跃。

首先要问的问题是为什么这些类要承担定义能力的负担?这能否外包给不同的类或函数?我们将重新设计AbstractGameUnit类(及其子类),使得各种能力现在由专门处理这些任务的对象来处理。换句话说,我们将使用对象组合来减轻AbstractGameUnit及其子类的工作负担。

注意

回想一下,在第一章《开发简单应用程序》中,我们使用了Hut类中的对象组合,其中其occupant由不同的对象表示。对象组合允许你通过组合简单对象来表示复杂对象。大声说出来,骑士有移动的能力,骑士有跳跃的能力,等等。每种能力都将由单独的对象表示。

我们如何实现这个新设计?我们将讨论两种解决问题的方法。第一种方法更像是经典方法,类似于在其他语言(如 C++)中通常遵循的方法。如果你有这样的开发背景,这种方法看起来会更熟悉。第二种方法更符合 Python 风格。它使用 Python 中的第一类函数,这是一种语言特性。第二种方法将使整个问题看起来微不足道。如果你对传统方法不感兴趣,可以跳到策略模式的 Python 解决方案。

策略解决方案 1 – 传统方法

在上一节中,我们决定创建专用对象来表示如跳跃等能力。让我们绘制一个类图来更好地解释这一点:

策略解决方案 1 – 传统方法

这里是对前面图表所表示内容的更详细描述:

  • 现在有三个新类,AttackStrategyMoveStrategyJumpStrategy,分别处理attackmovejump方法的逻辑。

  • 现在的AbstractGameUnit类由这些类的实例组成,即attack_strategymove_strategyjump_strategy

  • AbstractGameUnit.jump方法只是调用jump_strategy.jump()。对于moveattack方法,采用类似的实现方式。

由于游戏角色需要它们自己的跳跃实现,我们将创建JumpStrategy的子类。例如,一个子类CanNotJump可以用于无法跳跃的游戏单位。以下类图展示了这一点:

策略解决方案 1 – 传统方法

注意

重新审视策略模式定义

我们从策略模式的定义开始讨论。这个设计模式代表了一组算法。仔细看看前面的类图。JumpStrategy 及其子类代表了一组算法。每个这些类中定义的功能相当于一个算法或策略。这些类属于同一个家族,因为任何算法的执行都与跳跃有关。例如,PowerJump 类定义了一个与 HorseJump 类不同的算法。同样,MoveStrategy 定义了一组移动算法,AttackStrategy 定义了攻击敌人的算法。为了完成策略模式,我们还需要一种动态在算法家族之间切换的方法。让我们看看如何实现这一点。

让我们回顾一下新的类 JumpStrategy。它现在定义了之前在 AbstractGameUnit 中定义的跳跃行为。整体逻辑由以下示意图和代码片段表示。为了便于理解,我们只讨论与跳跃能力相关的函数:

策略解决方案 1 – 传统方法

因此,我们有一个由 JumpStrategy 及其子类表示的算法系列。以下是相关的代码片段,展示了 AbstractGameUnitDwarfFighter 类。支持文件 strategypattern_traditional.py 包含此代码:

策略解决方案 1 – 传统方法

实例变量 self.jump_strategy 用于表示跳跃行为的一种策略或算法。AbstractGameUnit 的子类可以选择由 JumpStrategy 类及其子类定义的算法系列中的任何跳跃策略。例如,DwarfFighter 子类可以使用 CanNotJump 类中定义的算法作为其跳跃策略,等等。AbstractGameUnit.jump 方法现在是调用代码的 API 方法。此方法依赖于策略对象来实现实际的跳跃。它只是调用该策略对象的相应方法,如前一个类图所示。

在这个简单的例子中,子类 DwarfFighter 只覆盖了抽象方法 info。你可以为这个类添加一些额外的定制。现在让我们看看跳跃功能的算法系列:

策略解决方案 1 – 传统方法

如前所述,目的不是开发一个完整的游戏应用程序,而是仅仅理解应用开发中的重要概念。在这个微不足道的例子中,我们只是打印一条信息性消息来阐述这个概念。在实际实现中,这些是定义你的算法的类。最后,让我们回顾一下实例化游戏角色并动态设置不同跳跃策略的调用代码:

策略解决方案 1 – 传统方法

我们首先创建 jump_strategy,这是一个定义单位如何跳跃的对象。在这种情况下,它被作为参数传递给 DwarfFighter__init__ 方法。或者,你也可以在这个类中定义一个默认策略对象,因为我们知道这个类的默认行为(单位不能跳跃)。然后你可以调用 set_jump_strategy 来切换到不同的跳跃算法,如代码片段所示。以下是这个程序的输出:

策略解决方案 1 – 传统方法

策略解决方案 2 – Pythonic 方法

我们在上一个章节中讨论的更多的是一种传统方法,通常在像 C++ 这样的编程语言中遵循。鉴于 Python 语言提供的灵活性,实际上没有必要像前一个解决方案中那样定义各种策略类。我们将利用前面讨论的一等函数语言特性。让我们看看修改后的代码。你还可以在代码包中的 strategypattern_pythonic.py 文件中找到这段代码:

策略解决方案 2 – Pythonic 方法

在前面的代码中,我们使用了 Python 语言支持将函数(jump_strategy)赋值给变量(self.jump)的功能。我们为什么要这样做?当我们回顾下一个代码片段时,答案就会变得清晰。在那之前,让我们快速讨论一下前面的代码片段。

注意

以下断言语句我们到底实现了什么?

assert(isinstance(jump_strategy, Callable))

这段话来自前面代码片段中的 AbstractGameUnit.__init__ 方法。在将函数赋值给变量之前,我们需要确保它确实是一个函数。这个断言语句会在条件不满足的情况下阻止代码的进一步执行。这个想法很简单。你想要确保 jump_strategy 是一个可调用的对象。任何可调用的对象都定义了一个内置的 __call__() 方法。collections.abc.Callable 类是所有提供内置 __call__() 方法的类的抽象基类。在 assert 语句中,我们检查 jump_strategy 是否是这个 Callable 类的实例。对于 Python 2.7.9,这个类应该直接导入为 collections.Callable

如前所述,让我们回顾一下实例化游戏角色(矮人)并动态设置不同跳跃策略的代码片段:

策略解决方案 2 – Pythonic 方法

将此代码与之前讨论的第一个方法进行比较。这里有一个区别。在先前的解决方案中,在实例化DwarfFighter时,我们传递了一个处理跳跃行为的CanNotJump类的实例。在这里,我们传递了can_not_jump函数作为参数,就像任何简单变量一样。为了动态更改jump算法,我们只需将dwarf.jump赋值给power_jump,如图所示。现在当我们调用dwarf.jump()时,它实际上执行了power_jump()函数中的代码。

小贴士

关于 Python 风格的方法:

我们刚才看到的是一个酷炫的 Python 风格,让事情变得非常简单。如果你来自 C++或 Java 编程背景,一开始你可能不太适应 Python 提供的自由度。例如,可能会出现程序员错误地将函数参数视为简单变量的情况,从而导致潜在的 bug。但这不应该阻止你使用这个出色的语言特性。为了避免这样的问题,你应该很好地记录代码,以便每个输入参数的目的清晰。

简单工厂

简单工厂通常不被视为正式的设计模式,但你会在日常编程中发现它非常有用。在本节末尾获得的理解将有助于讨论一个更正式的模式,即抽象工厂设计模式。让我们从简单工厂的定义开始。

工厂封装了实例创建的部分。客户端代码不需要知道实例创建的逻辑。它只知道,每当它需要特定类型的对象时,工厂就是首选的地方。任何用于构建此类对象的类、函数或类方法通常被称为工厂。简单工厂是你经常会用到的东西。它通常被认为比正式的设计模式更优秀的面向对象技术。

简单工厂场景 – 招募功能

回想一下,我们已将游戏快进到一个名为“Orcs 的攻击 v6.0.0”的虚构未来版本。这个版本引入了另一个备受期待的功能,允许招募新单位与敌人作战。

这里是新类Kingdomrecruit方法的初始版本,其他方法未展示。让我们假设它们存在。进一步假设玩家或敌人可以招募以下游戏角色中的任何一个:ElfRiderKnightDwarfFighterOrcRiderOrcKnight

简单工厂场景 – 招募功能

recruit方法包含基于用户输入(if..else块)创建游戏单位的逻辑。一旦角色创建完成,Kingdom就会支付雇佣费用(pay_gold),并且中央数据库会更新以反映军队中新成员的加入(update_records)。

简单工厂 – 问题

如预期,用户喜欢这个功能,现在希望有招募更多单位类型的能力。让我们看看 Sir Foo 有什么要说的:

简单工厂 – 问题

让我们添加新的招募类型,为了避免 Sir Foo 的愤怒,移除OrcKnight

简单工厂 – 问题

如前述代码片段所示,这已经变得难以维护。明天,你可能会决定支持更多的单位,或者移除一些现有的单位。我们如何处理这个问题?让我们看看下一个。

简单工厂 – 重新思考设计

我们能说些什么关于recruit方法中的那个大if..else块呢?它是可以改变的。方法中的其余代码只是记账(例如,更新记录)并且保持不变。如果我们把那块变量代码移除并给它一个新的家呢?这将减轻recruit方法的负担,这样你就不需要在需求有变化时每次都打开它进行编辑。接下来要问的问题是,我们将把这段代码放在哪里?

简单工厂 – 重新思考设计

是的,Fairy,这是一个选项。你可以在Kingdom类中创建一个新的方法,并将所有这些对象创建代码放在那里。

但想象一下一个游戏场景,其中有一个庞大的银河系军队,由GalacticArmy类表示。这个类需要一种招募或获取各种游戏角色的方法。它与Kingdom类毫不相干。因此,我们无法在Kingdom.recruit中重用对象创建代码。

让我们让Kingdom类摆脱创建新单位的责任。再一次,我们将使用对象组合原则。将有一个新的类(甚至是一个函数),它封装了实例创建的部分。我们将称之为简单工厂。客户端代码(在这个讨论中的KingdomGalacticArmy类)现在可以使用这个工厂来获取特定类型的对象。

简单工厂解决方案 1 – 传统方法

现在是时候实现简单工厂了。让我们首先回顾一下传统方法。

小贴士

这是最低限度的代码,没有任何异常处理。目的是仅仅为了说明使用类似于 C++实现风格的简单工厂。你可以作为一个练习使它更健壮。代码可以在simplefactory_traditional.py文件中找到。这个例子被写成单个模块以方便理解。理想情况下,你应该重构它,并将类放在它们自己的模块中。

看看下面的重构代码。我们从一个新的类开始,UnitFactory,它封装了对象创建的部分:

简单工厂解决方案 1 – 传统方法

在前面的代码中,我们将之前讨论的 Kingdom.recruit 方法中的大 if..else 子句重构出来,并将其放入 UnitFactory 类的 create_unit 方法中。create_unit 方法只有一个职责,即根据给定的输入参数(unit_type)创建并返回一个游戏角色的实例。以下是在此重构后的 Kingdom 类:

简单工厂解决方案 1 – 传统方法

self.factory 实例代表 UnitFactory。在 recruit 方法中,创建游戏角色的责任委托给了这个工厂对象。pay_goldupdate_records 方法只是为了完整性而展示。让我们不要担心这两个方法内部的逻辑。它们保持不变。最后,以下是一种使用工厂的方法。代码是自解释的:

简单工厂解决方案 1 – 传统方法

在这个例子中,我们没有展示的是我们工厂实际使用的具体产品类(如 ElfRiderKnight 等)的实现。这些类将与我们之前讨论过的类相似。例如,所有这些具体类都可以是 AbstractGameUnit 的子类。这些细节在我们刚刚覆盖的例子中没有展示。然而,实现简单工厂的方法不止一种。在 Python 中,我们还可以用其他方式处理这个问题。接下来将讨论其中一种方法。

简单工厂解决方案 2 – Pythonic 方法

上一节中提出的解决方案有一个问题。你仍然需要在 create_unit 中维护 if..else 块。另一个问题是,我们真的需要实例化 UnitFactory 吗?根据你的应用程序,答案可能是是或否。在这个例子中,create_unit 代码对于你创建的每个工厂实例都是相同的。所以,我们实际上不需要 UnitFactory 的实例。让我们讨论如何在不实际实例化的情况下实现简单工厂。

小贴士

这里展示的并不是实现简单工厂的唯一方法。源代码在支持材料中作为 simplefactory_pythonic.py 提供。根据你处理的问题类型,你可以进一步调整这种方法,并提出不同的解决方案。例如,你可以选择一个工厂实例,并像访问普通实例方法一样访问其方法。这种方法在 simplefactory_pythonic_alternatesolution.py 文件中有展示。

这里是来自文件 simplefactory_pythonic.py 的重新工作的 UnitFactory 类:

简单工厂解决方案 2 – Pythonic 方法

在本章早期,我们回顾了一些对设计模式有帮助的 Python 语言特性。让我们看看如何使用一等类和类方法在简单工厂中应用:

  • units_dict 是一个作为类变量声明的 Python 字典对象(对于 UnitFactory 类)。

  • Python 类是一等对象。因此,我们可以简单地将其作为字典 units_dict 的值。字典键可以是您选择的唯一字符串。只需确保调用代码知道哪个键对应哪个类。

  • create_unit 方法被定义为使用装饰器 @classmethod 的类方法。这意味着传递给此方法的第一个参数是类本身(表示为 cls),而不是 self(类的实例)。

  • 现在看看 create_unit 方法的 return 语句:

    return cls.units_dict.get(key)()
    

    在这里,我们通过 cls.units_dict 访问类变量 units_dict,并根据输入参数提供的特定键获取其值。这可以通过一个例子更好地解释。假设给定的键是 elfrider。字典中对应的值是 ElfRider 类。因此,create_unit 方法将返回 ElfRider(),它是 ElfRider 类的一个实例。

将此代码与我们在上一标题中看到的代码进行比较,简单的工厂解决方案 1 – 传统方法。如可注意到,代码行数并没有显著减少。但这里的代码清晰度要好得多。您仍然需要维护字典对象(units_dict)以供所有未来的需求,这相对于维护 if..else 子句来说相对容易。

现在观察 Kingdom 类。它只有几个变化:

简单的工厂解决方案 2 – Pythonic 方法

让我们回顾一下前面的代码片段

  • 首先,我们将 UnitFactory 类赋值给类变量 factory。我们之所以能够这样做,是因为 Python 类是一等对象。

  • recruit 方法是 Kingdom 的一个普通实例方法。类变量 factory 通过 type(self).factory 访问。

  • 在这个例子中,type(self).factory.create_unit 等同于 UnitFactory.create_unit。我们本可以直接那样写,但如果 Kingdom 的子类将其 factory 定义为不同的类,例如 DwarfUnitFactory,那么它将需要您编写一些额外的代码,例如重写 recruit 方法。

最后,这里是调用代码。请注意,我们没有创建任何 factory 实例:

简单的工厂解决方案 2 – Pythonic 方法

对简单工厂的讨论为正式设计模式——抽象工厂模式奠定了基础。让我们接下来回顾一下。

抽象工厂模式

我们刚刚学习了如何在程序中创建和使用简单的工厂。让我们更进一步,研究一个称为抽象工厂模式的形式化模式。

想象一下,我们有一个主工厂和一些跟随工厂。进一步假设每个跟随工厂负责生产其自己的品牌产品(对象)。跟随工厂在某种程度上是相关的。它们创建具有共同主题的产品。例如,每个跟随工厂都生产其自己的番茄酱版本。这些工厂有自己的产品订购表格。

客户在保持这么多订购番茄酱的表格方面感到困难。例如,一个工厂说你应该称之为 MyRedTomatoKetchup,否则它不会理解。因此,主工厂说:

我们制造的产品就像一个大家庭的一部分。如果我们可以简化并标准化从我们工厂集团订购这些产品的程序,我们的客户将受益。从现在起,每个跟随工厂都必须实现一个共同的订购表格。

客户受益,因为他们只需要知道高级名称番茄酱和可以提供这种产品的工厂。让我们用编程术语来表达这一点:

  • 主工厂是一个抽象工厂,而跟随工厂是具体工厂。

  • 番茄酱是一个抽象类。每个具体工厂都创建其定制的番茄酱版本;我们将称之为具体对象(具体类的实例)。

  • 之前提到的标准化程序被称为接口。抽象工厂声明了这样一个接口(或者用 Python 术语来说,一组抽象方法),具体工厂必须实现它以创建具体对象系列。

  • 客户是客户端代码。它不需要知道从具体工厂接收到的具体对象的详细信息。它只需要了解抽象类。

注意

Java 编程语言有一个创建抽象类型接口的条款。如果一个类实现了接口,它必须实现该接口描述的所有方法。有关 Java 语言中接口的更多信息,请访问维基页面:en.wikipedia.org/wiki/Interface_(Java)。在 Python 中,没有这样的正式条款来创建和实现接口。相反,我们可以使用继承,具体工厂从抽象工厂继承。或者,我们可以使用之前讨论过的一等特性。让我们看看这些。

太多了?让我们通过一个游戏场景更深入地了解抽象工厂模式。

抽象工厂场景 – 配件商店

想象一下,你已经实现了一个新功能,允许为你的军队购买配件。目前,你可以购买装甲夹克或金质护身符,如下面的代码片段所示:

抽象工厂场景 – 配件商店

添加了更多关于装甲和护身符的选择如下:

抽象工厂场景 – 配件商店

你重构了前面的代码,并实现了一个简单工厂。这个工厂将生产游戏角色的所有配件。在这个例子中,它将返回盔甲和护身符对象。

下一个展示的是实现简单工厂的重构代码:

抽象工厂场景 – 配件商店

下一个展示的是Kingdom类和主要执行代码。Kingdom类有一个实例变量self.factory,它代表我们的简单工厂:

抽象工厂场景 – 配件商店

self.factory变量用于创建armorlocket实例,如buy_accessories方法中所示。

小贴士

如简单工厂部分所示,工厂也可以指定为一个类属性,通过Kingdom.factory访问,而不是创建一个实例,self.factory

抽象工厂场景 – 配件商店

你所做的更改简化了实现。看起来 Foo 爵士对他的新铁甲非常满意,然而,其他人似乎并不这么认为!有一个 新问题...

抽象工厂 – 问题

一种尺寸 并不适合所有人!矮人王国现在使用这个 AccessoryFactory,并报告了产品的问题:

抽象工厂 – 问题

伟大的矮人现在对工厂不支持其创建的产品进行定制感到烦恼。我们如何解决这个问题?

这是我们可以使用抽象工厂模式的一个场景。

抽象工厂 – 重新思考设计

观察以下类图。它代表了一个典型的抽象工厂模式,我们解决问题的方法:

抽象工厂 – 重新思考设计

让我们回顾一下这里展示的类似 UML 的图的组成部分,并将它们与之前抽象工厂定义中使用的术语相对应:

  • DwarfAccessoryFactoryElfAccessoryFactory:跟随者或具体工厂。回想一下,每个具体工厂都创建具有共同主题的产品。在这里,它们为游戏角色创建配件。如前所述,它们需要实现由主工厂设定的标准程序。

  • AbstractAccessoryFactory:这是抽象工厂类,或者我们之前所说的主工厂。它定义了一个接口(一组抽象方法),具体工厂必须实现。在这种情况下,每个具体工厂都需要实现创建盔甲和护身符的方法。

  • 在这个例子中,每个具体工厂都实现了create_armorcreate_locket方法。这些方法返回具体产品类的实例。因此,每个工厂都创建了自己风格的产品。例如,DwarfAccessoryFactorycreate_locket方法返回DwarfGoldLocket的实例,而ElfAccessoryFactory中的相同方法返回ElfGoldLocket的实例。

  • AbstractArmor, AbstractLocket:这些是抽象产品类。可能存在几个具体产品类型从这些抽象类继承。例如,具体产品类DwarfGoldLocketSuperLocketAbstractLocket继承,等等。

  • 客户端代码:这没有在类图中展示。客户端代码不需要知道哪个具体产品类能提供它所需的产品。它只知道产品的高层名称(例如,create_locket)。本质上,它选择工厂,并调用标准 API 方法,如create_locket,以获取所需的对象。请参见下一节中的示例。

进一步简化设计

上述类图展示了实现抽象工厂模式的一种经典方式。为了便于理解,让我们进一步简化问题。我们将假设所有具体工厂都定义了所需的方法,而无需抽象工厂强制执行(接口)规则。

基于这个假设,我们甚至可以完全从设计中移除AbstractAccessoryFactory类,只保留具体工厂。回想一下,我们在本章开头讨论了鸭子类型。因此,只要具体工厂实现了所需的方法,客户端代码(参见下一例中的Knight.buy_accessories)就不会抱怨。

为了概念上的理解,我们将在接下来的讨论中保留继承层次结构。我们将把这个类简单地称为AccessoryFactory,并且不会将create_armorcreate_locket定义为抽象方法。强制执行接口需要在代码中进行一些小的调整。我们将在下一节的末尾简要讨论这一点,作为一个可选或高级主题,在那里我们将查看实际的实现。

抽象工厂解决方案 – Pythonic 方法

在上一节中,我们看到了一个展示抽象工厂模式实现细节的代表类图。我们将只讨论实现此模式的 Pythonic 解决方案。由于我们已经深入探讨了简单工厂,抽象工厂模式只是几步之遥。我们将讨论一些重要的类。请查看支持代码中的abstractfactory_pythonic.py文件以获取完整的源代码。

下一个示例中展示了KingdomDwarfKingdom类。代码是自解释的,并且之前已经讨论过:

抽象工厂解决方案 – Pythonic 方法

让我们看看AccessoryFactory类(参见上一标题下关于设计简化的注释,进一步简化设计):

抽象工厂解决方案 – Pythonic 方法

这与我们在简单工厂实现部分中审查的UnitFactory类非常相似。唯一的区别是工厂生产两个独立的产品,armorlocket。因此,我们为每个具体产品定义了两个不同的类方法(工厂方法)。armor_dict字典以与盔甲相关的具体类作为其值,而locket_dict用于与挂锁相关的类。这两个都是定义为类变量。

以下代码片段是为DwarfAccessoryFactory这个具体工厂之一。在这里,我们只重新定义了armor_dictlocket_dict字典。其他什么都没有改变。同样,你也可以定义其他具体工厂,如ElfAccesoryFactory。如果你想实现严格的抽象工厂模式,你也应该在具体工厂中强制执行一个接口。这一点在本节的末尾简要讨论:

抽象工厂解决方案 – Pythonic 方法

最后一个拼图碎片是主执行代码。它创建了两个王国,第一个是默认的Kingdom,第二个是伟大的矮人王国——DwarfKingdom。这是通过以下方式完成的:

抽象工厂解决方案 – Pythonic 方法

注意到buy_accessories在两个王国中用相同的参数ironjacketgoldlocket被调用。但是每个王国得到的具体产品取决于选择的工厂。例如,由于DwarfKingdom选择了DwarfAccessoryFactory作为其工厂,对于名为ironjacket的抽象产品,它会得到一个DwarfIronJacket的实例。以下是在abstractfactory_pythonic.py文件中的代码的示例输出:

抽象工厂解决方案 – Pythonic 方法

高级主题 – 强制执行接口

本节说明了在 Python 中强制执行接口的一种方法。如果你现在对此不感兴趣,请忽略它并继续下一个主题。

回想一下,为了简化 Pythonic 解决方案的说明,我们简化了问题。AccessoryFactory不强制要求子类实现create_armorcreate_locket方法。实际上,这样做很简单。如果你使用 Python 3.3 或更高版本,你可以简单地定义这些方法作为类方法,并使用两个装饰器@classmethod@abstractmethod,如下所示:

@classmethod 
@abstractmethod 
def create_armor(cls, armor_type): 
    return cls.armor_dict.get(armor_type)()

在像 DwarfAccessoryFactory 这样的子类中,你只需要实现这些类方法。为了完整性,通过从 ABCMeta 继承来使 AccessoryFactory 成为抽象类。技术上,这将确认抽象工厂的正式设计。但如果你看看这个方法(create_armor)内部的代码,它一点都没变。因此,在这个例子中,声明一个抽象方法只会帮助强制执行子类必须实现某些方法的规则。

适配器模式

适配器设计模式允许两个不兼容的接口之间进行握手。在这里,一个类或库的不兼容接口被转换成客户端代码期望的接口。这种转换是通过适配器类完成的。通常,具有与客户端期望不同的接口的另一个类被称为适配者。

适配器模式有两种主要类别,即类适配器模式和对象适配器模式。在前者中,适配器从适配者继承。在 Python 中可以实现类适配器,因为该语言支持多重继承。然而,选择对象组合(具有关系)而不是继承会更好。在对象适配器模式中,适配器对象有一个适配者对象,而不是从适配者类继承。对象适配器模式有助于保持适配者和客户端代码之间的松散耦合,其中客户端不需要了解适配者接口。与类适配器模式相比,这提供了更多的灵活性。

在接下来的讨论中,我们只会讨论对象适配器模式。

适配器场景 - 侏儒的远亲

让我们再次快进到一个 虚构的未来。一群开发者来找你。他们一直在开发一个类似的幻想游戏应用。鉴于你的游戏受欢迎程度,他们希望进行合作。这对双方都是双赢的局面。你欣然接受这个提议,因为它将让你能够访问他们收藏中的几个游戏角色。

适配器 - 问题

你开始进行集成工作,并注意到一个问题。让我们听听我们的朋友,精灵的看法:

适配器 - 问题

下面的代码进一步突出了这个问题。这里展示的是新 WoodElf 类的简化版本,只显示了 leap() 方法。假设它的其他方法与我们的现有接口相匹配。

小贴士

策略模式 部分讨论的 jump 方法(而不是跳跃策略)与这里展示的不相关。为了更容易理解这个模式,只展示了最基本代码。例如,这里没有使用 AbstractGameUnit 类。作为一个练习,尝试使用策略模式中的代码,并实现一个适配器,以便我们可以与 WoodElf 通信(解决方案未提供)!

适配器 - 问题

适配器 – 尝试的解决方案

新类 没有 leap() 方法。我们如何解决这个问题?有什么想法,仙女?

适配器 – 尝试的解决方案

我们可能可以这样做,但这个代码属于第三方。如果他们分享了源代码,那么你可以更新它。但这将给你带来维护开销。如果你没有源代码,那么你必须依赖他们来支持这个方法。出于所有这些原因,仙女提出的解决方案可能不是最佳前进方式。话虽如此,仙女的方向是正确的!她有一个 jump() 方法,它将这个调用委托给 leap() 方法。让我们看看适配器模式如何在这里帮助。

我们是否可以添加一个新的类,它能够在这两个接口之间实现握手?看看以下代码片段:

适配器 – 尝试的解决方案

这个最后的代码片段似乎解决了一个问题。我们不需要对第三方类 WoodElf 进行任何修改。我们将 WoodElf 的一个实例传递给适配器 WoodElfAdapter。这个适配器类有一个 jump 方法,它调用 WoodElfleap 方法。客户端代码只需要使用这个适配器实例而不是 WoodElf 实例。然而,这个解决方案有两个主要问题:

  • 适配器类似乎与 WoodElf 类相关联。如果我们有一个新的类 MountainElf,它将 spring 方法作为 jump 方法的等效方法呢?

  • 想象一下 WoodElf 类有其他方法,如 attackinfoclimb 等。其中一些可能已经与现有接口兼容,而对于其他一些,则没有等效方法。所有这些方法都可以直接调用,而无需像 leap() 那样进行任何特殊处理。如果我们遵循前面代码片段中讨论的方法,你将不得不在适配器类 WoodElfAdapter 中定义这些方法中的每一个。如果不实现它们,你将无法在客户端代码中无缝使用适配器类。这是一项相当多的工作。

解决这两个问题非常简单。让我们接下来写一个通用的解决方案。

适配器解决方案 – Pythonic 方法

总结一下问题,第三方开发者提供的 WoodElf 新类有一个 leap() 方法而不是 jump()。换句话说,它有一个不兼容的接口。我们正在寻找一个不需要我们修改 WoodElf 类的解决方案。我们创建了一个适配器 WoodElfAdapter,但正如前一小节所讨论的,它有其自身的不足之处,适配器 – 尝试的解决方案

让我们将适配器类进一步泛化以解决这些问题。参见补充的 adapterpattern.py 文件以获取源代码。这将在下面说明。首先看看以下代码片段,然后我们将讨论它:

适配器解决方案 – Pythonic 方法适配器解决方案 – Pythonic 方法

在前面的代码截图中有以下几点需要注意:

  • 适配器类被重命名为ForeignUnitAdapter

  • 第一个输入参数adaptee代表我们需要适配的类的实例。第二个参数adaptee_method是需要适配的实例方法(例如,wood_elf.leap需要被解释为jump方法)。

  • 接下来,我们利用 Python 的一等函数将adaptee_method分配给self.jump。例如,调用self.jump()现在等同于调用wood_elf.leap()。这消除了在适配器类内部创建单独的jump方法的需求。

  • 在本章的早期部分,我们学习了__getattr__方法。在这里,我们在适配器类ForeignUnitAdapter中实现了它。客户端代码假设适配器对象(代表第三方游戏角色),已经定义了info()attack()climb()等方法。客户端通过适配器对象调用这些方法。实际上,适配器类并没有定义这些方法。它依赖于self.foreign_unit来提供这些方法。

  • 这段处理代码是在__getattr__方法中编写的。在这里,getattr(self.foreign_unit, item)将简单地返回self.foreign_unit.item

  • 你可以通过传入不同的游戏单位实例和需要适配的方法来创建多个适配器对象。前述代码片段中展示了这样一个例子。

适配器 – 多个适配器方法

在早期的示例中,我们假设self.jump将是处理适配器的方法。如果我们有多个需要适配以符合现有 API 的方法,怎么办?你可以进一步泛化这个实现。这里有一种处理多个方法的方式。这个源代码可以在支持代码包中找到。查找adapterpattern_multiple_methods.py文件:

适配器 – 多个适配器方法

以下是最主要的执行代码:

适配器 – 多个适配器方法

再次,我们利用 Python 的一等函数。set_adapter方法使用内置方法setattr()ForeignUnitAdapter类设置新的属性。这些属性充当适配器方法。或者,你也可以按以下方式设置属性:

foo_elf_adapter.jump = foo_elf.leap 
foo_elf_adapter.attack = foo_elf.hit

摘要

本章介绍了 Python 中的设计模式,这是应用开发的一个重要方面。我们以一个介绍开始本章,并了解了设计模式的分类。接下来,我们回顾了 Python 语言提供的几个关键特性,这些特性有助于简化几个设计模式。通过实际示例,你学习了如何实现设计模式以解决应用开发中反复出现的问题。更具体地说,你学习了策略模式、抽象工厂模式和适配器模式。对于这些模式中的每一个,我们首先用一个有趣的游戏场景来描述问题。然后,我们讨论了设计模式如何解决这个问题,并进一步使用 Python 风格实现了设计模式。对于某些模式,我们还回顾了实现设计模式的传统方法。最后但同样重要的是,我们遇到了 Sir Foo 的一些新朋友。

到目前为止,我们已经讨论了应用开发中的几个重要方面。这次讨论帮助我们编写更好的代码,使应用更加健壮,并延长了应用的使用寿命。在接下来的三章中,我们将学习各种提高应用性能的方法。

第七章。性能 – 识别瓶颈

到目前为止,您已经学习了各种使应用程序健壮并适应新功能的方法。现在,让我们讨论提高应用程序性能的技术。这个广泛的话题被分为三个章节系列——这是本系列中的第一个。它将涵盖以下主题:

  • 计时应用程序运行时间的基本方法

  • 如何通过代码分析来识别运行时性能瓶颈

  • 使用memory_profiler包进行基本内存分析

  • 计算复杂度的大 O表示法

为了更好地理解这些概念,我们将开发一个有趣的场景游戏,称为黄金狩猎。您很快就会意识到,当您增加输入数据大小时,应用程序运行得非常慢。本章将详细阐述定位此类问题的技术。

三个性能章节概述

在我们深入主要讨论之前,让我们首先了解性能提升章节是如何组织的。如前所述,这个讨论被分为一系列三个相互关联的章节。

更多的关注运行时性能

性能提升这个术语可以意味着几件事情。可能是在谈论提高运行时间(CPU 使用率),使应用程序内存高效,减少网络消耗,或者这些的组合。在这本书中,我们将主要关注运行时性能提升。我们还将讨论内存消耗方面,但讨论将限于内存分析技术和生成器表达式的使用。

第一个性能章节

您正在阅读本系列的第一个章节。它进行了一些准备工作以提高应用程序的性能。这些准备工作包括测量运行时间,识别导致性能瓶颈的代码片段,理解大 O 表示法,等等。

第一个性能章节

当然!我们将开发之前提到的黄金狩猎场景,然后识别代码中的性能瓶颈。接下来的两个章节将利用这个基础逐步提高应用程序的性能。

第二个性能章节

下一章将全部关于学习各种性能提升技术。前半部分旨在提高黄金狩猎应用程序的应用程序运行时间。后半部分教授一些优化代码的技巧。本章涵盖了为高性能和内存效率设计的内置模块。它还讨论了列表推导式、生成器表达式、数据结构的选择、算法变更等等。

第三个性能章节

本系列中的最后一章将简要介绍NumPy包和 Python 中multiprocessing模块的并行化。我们将使用这些技术来显著提高应用程序的运行时性能。

即将到来的应用程序加速预览

下面是金矿寻宝程序从乌龟进化到兔子的预览。以下图表显示了性能改进每个主要步骤后的近似运行时间。当我们完成第九章,性能改进 – 第二部分,NumPy 和并行化时,应用程序的运行时间将从大约 106 秒减少到大约 14 秒。

即将到来的应用程序加速预览

没有必要花费时间去理解图表中展示的元素;一旦你阅读完关于性能的三个章节,一切都会变得清晰。现在,你需要知道的是,在接下来的章节中,我们将学习一些技术来显著提高应用程序的运行时间。

小贴士

注意

性能章节将展示一些低效代码的例子。运行这些例子可能会消耗大量的计算资源。你不需要使用这些章节中展示的问题规模,而应根据你的机器能处理的数据量选择合适的数据规模。

场景 – 金矿寻宝

你最近在游戏中引入了一个新的场景——为了支付军队的开销,Sir Foo 正在执行一项任务,从最近获得的领土中收集金子。这个场景从 Sir Foo 到达一个充满金币、珠宝等等的地方开始。然而有几个问题。首先,金子散落在整个矿场上。其次,Sir Foo 没有时间收集矿场上所有的金子。

场景 – 金矿寻宝

Sir Foo 身后你所看到的是一个虚构的金矿。Sir Foo 将从左侧进入并穿越这片矿场。他只会收集沿着他路径躺着的硬币,忽略矿场上散落的其余金子

让我们将这个金矿表示为一个半径约为 10 英里(直径 20 英里)的圆,中心位于坐标x = 0y = 0,如下截图所示:

场景 – 金矿寻宝

观察以下截图。虚线(矿场的直径)显示了 Sir Foo 在离开时走过的路径。在这 20 英里的旅程中,他在 10 个等距的点上停下来。换句话说,这些点相隔 2 英里,由小“搜索圆”的中心表示。对于每个停留点,他收集搜索圆内的金子。收集到的总金子是这 10 个小圆内硬币的总和。让我们不考虑这些搜索圆外的金子。

假设矿场上剩余的金子对我们正在解决的问题无关紧要。

场景 – 金矿寻宝

高级算法

以前面的截图为参考,让我们编写高级算法。我们将保持其简单。任务是收集图中每个小圆内的金币(记住,这些圆被称为搜索圆)。我们将这些圆的半径称为搜索半径。在当前场景中,搜索半径是 1 英里,或者我们可以简单地称之为 1 个单位:

  1. 在一个金矿区域内随机创建代表金币的点。金矿区域用一个半径为 10 个单位的圆表示,圆心在(x = 0, y = 0)。每枚金币用一个(x,y)位置表示。

  2. 从最左边的搜索圆开始,其中心代表 Sir Foo 的当前位置。金币搜索被限制在这个搜索圆内。

  3. 对于每个搜索圆:

    • 获取 Sir Foo 的当前位置坐标。

    • 计算场上每枚金币与 Sir Foo 位置(搜索圆的中心)之间的距离。

    • 收集所有距离小于搜索半径的金币。这些是位于当前搜索圆周内的金币。

    • 将 Sir Foo 移动到下一个搜索圆的中心。

    • 重复前面的步骤,直到达到最右边的圆。

  4. 报告收集到的金币总数。

检查初始代码

让我们接下来回顾代码(它也可以在支持代码包中找到,只需查找goldhunt_inefficient.py文件)。下面是一个新的GoldHunt类:

检查初始代码

这个类的play方法包含主要逻辑,如下面的截图所示:

检查初始代码

让我们回顾前面截图中的代码:

  • play方法的输入参数field_coinsfield_radius分别设置金币的数量和圆形金矿的半径。这些是可选参数,具有默认值,如__init__方法中所示。第三个可选参数search_radius帮助定义较小搜索圆的半径。

  • x_refy_ref变量代表当前搜索圆的中心。我们通过假设一个恒定的y_ref值为0.0来简化了问题。

  • play方法首先生成代表散布在金矿上的金币的随机点。generate_random_points函数返回两个 Python 列表,包含场上所有金币的xy坐标。

  • while循环中,total_collected_coins列表存储了位于搜索圆内的金币坐标,从最左边的一个开始。

  • 实际的搜索操作是通过find_coins方法完成的。

接下来,让我们回顾GoldHunt.find_coins方法:

检查初始代码

此方法遍历场上的所有点(金币),并对每个点,计算其与搜索圆心的距离。有了这个距离,我们可以确定给定的金币是否位于搜索圆的周界内。这在下图中以示意图的形式展示。(x_ref, y_ref) 坐标代表搜索圆心的位置。(x, y) 参数是场上任何金币的坐标。

查看初始代码

在此图中,点与中心之间的距离用 dist 表示。它显示了两个代表性的点(或金币)。旁边带有 勾号 的第一个点位于圆内,而带有 叉号 的另一个点位于圆外。只有位于圆内的点被收集。该方法返回一个 collected_coins 列表,其中包含所有此类点的位置元组 (x,y)

让我们回顾一下在场上创建随机点的函数:

查看初始代码

如果你具备基本的数学背景,你应该能够比较容易地理解这段代码。以下是它是如何工作的:

  • 考虑一个半径为 r、角度为 theta 的点。

  • 这个点的笛卡尔坐标是 x = rcos(theta)* 和 y = rsin(theta)*。

  • 内置函数 random.uniform 用于在 0.0(场中心)和 ref_radius(场半径)之间随机变化 r。注意,没有显示 import 语句。有关这些,请参阅 goldhunt_inefficient.py

  • 同样,theta 角度在 0.02*math.pi(360 度)之间随机变化。

小贴士

绘制点

您可以使用 matplotlib,一个 Python 绘图库,可视化生成的随机分布的金币。我们在这里不会讨论绘图技术。请查看他们的网站 (matplotlib.org),那里提供了一些教程和安装说明。Python 发行版,如 Anaconda,预装了 matplotlib。您还可以使用 goldhunt_inefficient.py 文件中提供的绘图函数 plot_points

运行代码

主要执行代码如下:

if __name__ == '__main__': 
    game = GoldHunt() 
    game.play()

此代码使用默认参数来实例化 GoldHunt。使用默认参数,代码应该可以顺利运行并在几秒钟内完成。实际时间将取决于您的机器配置、可用 RAM 等因素。您可以通过添加一些信息性的 print 语句来查看游戏进度。以下是使用默认参数的示例输出:

[user@hostname ch7]$ python goldhunt_inefficient.py 
Circle# 1, center:(-9.0, 0.0), coins: 55 
Circle# 2, center:(-7.0, 0.0), coins: 37 
Circle# 3, center:(-5.0, 0.0), coins: 54 
Circle# 4, center:(-3.0, 0.0), coins: 47 
Circle# 5, center:(-1.0, 0.0), coins: 53 
Circle# 6, center:(1.0, 0.0), coins: 60 
Circle# 7, center:(3.0, 0.0), coins: 44 
Circle# 8, center:(5.0, 0.0), coins: 50 
Circle# 9, center:(7.0, 0.0), coins: 51 
Circle# 10, center:(9.0, 0.0), coins: 51 
Total_collected_coins = 502

问题

在游戏场景中,你允许用户调整某些参数。例如,用户可以控制场上的总硬币数或修改搜索圆的半径。无意中,你打开了一个新的问题。对于大量输入,程序运行非常慢。例如,游戏的一个变体,Foo 山的巨魔正在执行黄金狩猎。让我们听听他有什么要说的:

问题

如果你将field_coins5000改为1000000,并将search_radius设置为0.1,应用程序完成这个过程将需要相当长的时间。以下是带有这些新参数的更新后的主要执行代码:

if __name__ == '__main__': 
    game = GoldHunt(field_coins=1000000, search_radius=0.1) 
    game.play() 

如果你进一步增加硬币数量或使搜索半径变得更小,这将严重影响应用程序的运行时间。

小贴士

警告!

如果你运行以下代码,根据你的机器配置,它可能会减慢你的机器速度,延长完成时间,在某些情况下(配置平均的机器)计算机可能停止响应。如果你不确定,最好不要运行它!这里只是作为一个例子展示。如果你真的想尝试,那么请自行承担风险!

例如,完成此操作可能需要几秒钟或几分钟。我们在这里能做些什么来提高性能?在跳到那之前,让我们首先回顾一些识别瓶颈的技术。

识别瓶颈

在上一节中,我们看到了不同的输入参数选择如何降低应用程序的运行时间。现在,我们需要一种方法来准确测量执行时间,并找出性能瓶颈或代码中耗时的部分。

测量执行时间

让我们从监控应用程序的执行时间开始。为此,我们将使用 Python 的内置time模块。time.perf_counter函数是一个性能计数器,它返回一个具有最高可用分辨率的时钟。此函数可用于确定函数连续两次调用之间的时间间隔或系统范围内的时间差。

小贴士

time.perf_counter函数从 Python 3.3 版本开始可用。如果你有较旧的 Python 版本(例如,2.7 版本),请使用time.clock()代替。在 Unix 上,time.clock()返回一个表示处理器时间的浮点数(以秒为单位)。在 Windows 上,它返回自函数第一次调用以来经过的墙钟时间(以秒为单位)。

原始文件goldhunt_inefficient.py已经包含了以下代码:

import time

if __name__ == '__main__': 
    start = time.perf_counter() 
    game = GoldHunt() 
    game.play() 
    end = time.perf_counter() 
    print("Total time interval:", end - start)

在文件开头,我们导入 time 模块。start 变量标记性能计数器的开始,而 end 变量代表其第二次连续调用。在这之间,我们将运行主要执行代码。计数器两个值之间的差异可以用作应用程序运行时间的指标。同样,你可以在代码的其他地方插入这些调用以监控单个代码片段。

测量小代码片段的运行时间

内置的 timeit 模块是一个用于快速检查小代码片段执行时间的有用工具。它可以从命令行使用,也可以在代码内部导入和调用。以下是使用命令行界面使用此功能的一种方法:

$ python -m timeit "x = 100*100" 
100000000 loops, best of 3: 0.0155 usec per loop

-m 选项允许从命令行运行 timeit 模块。在上面的例子中,它测量了 x = 100*100 语句的执行时间。

让我们回顾一下这个执行的输出。输出中的 100000000 loops 表示 timeit 执行代码的次数。它报告了三次测量的最佳时间。在这个例子中,单次执行的最好时间是 0.0155 微秒。你也可以通过使用 --number 参数来调整代码运行的次数,如下面的代码片段所示。在这里,代码只运行了 10 次:

$ python -m timeit --number=10  "x = 100*100" 
10 loops, best of 3: 0.0838 usec per loop 

在内部,timeit 使用 time.perf_counter 来测量时间。这是自 Python 3.3 版本以来的默认实现。有关更多详细信息,请参阅文档(docs.python.org/3/library/timeit.html)。

代码分析

我们迄今为止看到的性能测量技术工作得相当好,尤其是当你想要为应用程序运行基准测试时。然而,在整个项目中实现这些计时器以获取完整的执行配置文件通常很繁琐。这就是代码分析发挥作用的地方。这是一种在程序运行时分析程序并收集一些重要统计数据的技术。例如,它报告了该程序中各种函数调用的持续时间和频率。这些信息可用于识别代码中的性能瓶颈。

cProfile 模块

让我们看看如何使用 cProfile,Python 的内置代码分析模块。为了说明目的,我们将使用支持代码包中的 profile_ex.py 文件。它包含三个执行一些简单任务的功能,如下面的截图所示:

cProfile 模块

cProfile 命令可以从命令提示符运行,也可以通过导入要测试的模块内部来运行。以下是使用命令提示符运行时的输出:

cProfile 模块

注意

IPython交互式外壳还提供了一个方便的魔法命令,称为%prun。使用它,你可以快速分析 Python 语句。更多信息,请查看ipython.org/ipython-doc/3/interactive/magics.html

让我们来理解这次运行的输出:

  • 输出的第一行显示了监控到的函数调用总数。其中大部分是由于test_2内部的for循环引起的。对于每次迭代,它都会调用 Python list数据类型的append函数。

  • 在同一输出行上,它还报告了原始调用的数量。这些是不涉及递归的函数调用。test_3函数展示了递归的一个例子。为了更好地理解这一点,通过打印输入参数condition的值来运行代码。在这种情况下,只有一个递归函数调用。

  • ncalls列表示函数调用的次数。如果你将它们加起来,总的调用次数变为10007,与输出第一行报告的相同。注意,对于test_3,它报告的函数调用为2/1。这意味着该函数被调用两次,但其中一次是递归调用。

  • tottime列表示在给定函数中花费的总时间。

  • percall列记录了totcall/ncalls除法的商。

  • 在特定函数内部(包括其子函数)花费的时间由cumtime(累积时间)报告。

  • percall列报告了cumtime/原始调用的商。

  • 最后一列基本上是与函数相关的数据。它包括内置函数调用,例如 Python listappend方法等。

默认情况下,输出按标准名称排序。为了理解瓶颈,这种排序顺序并不十分有用。相反,你可以按累积时间、函数调用次数等排序。这可以通过命令行选项-s实现。有关可用排序选项的完整列表,请参阅docs.python.org/3/library/profile.html

以下截图显示了按tottime排序的输出。观察发现,它花费了最多时间在test_2函数中。

cProfile 模块

现在我们已经知道了如何使用cProfile,让我们用它来分析黄金寻宝问题。按照以下方式运行原始的goldhunt_inefficient.py文件,使用所有默认选项:

$ python -m cProfile goldhunt_inefficient.py 

由于涉及多个内部函数调用,它会在终端窗口中打印大量信息。可选地,你可以将stdout重定向到文本文件。为了有效地分析这些数据,Python 提供了一个内置模块,称为pstats。让我们在下一节中看看如何使用它。

pstats 模块

pstats模块可以用于进一步处理由cProfile生成的分析数据。与cProfile提供的有限选项相比,它为您创建报告提供了更大的控制权。cProfile生成数据的分析是通过pstats.Stats类完成的。为了使cProfile的输出可供pstats使用,我们需要使用命令行选项-o将其写入文件,如下所示:

$ python -m cProfile -o profile_output goldhunt_inefficient.py 

因此生成的profile_output文件不可读。虽然我们可以继续将此文件提供给pstats.Stats,但最好通过组合这两个实用程序来自动化整个过程。以下是一个简化的代码示例,实现了这一点:

pstats 模块

提示

警告

这是一个没有错误检查的简化示例!例如,代码没有检查输出文件是否已存在。为了使代码健壮,应在适当的地方添加此类检查和try…except语句。

此代码也作为profiling_goldhunt.py包含在本章的支持代码包中。让我们快速回顾一下这段代码的功能:

  • 主要执行代码展示了如何使用cProfilerun方法运行。run的第一个参数是要监控的函数(或语句),而第二个参数是存储分析输出的文件名。

  • view_stats函数是我们使用pstats功能的地方。该函数将生成的分析输出(filname)作为第一个参数。在创建pstats.Stats实例时使用。

  • Stats类的strip_dirs方法用于从文件名中移除所有前导路径信息字符串。这通过仅显示文件名来减少最终输出的杂乱。

  • 使用print_stats方法,我们可以在最终输出中施加一些限制。在这个例子中,它查找最右侧列中的goldhunt字符串,并显示匹配的行,忽略所有其他行。换句话说,它限制了与goldhunt_inefficient.py内部函数调用相关的信息。

注意

pstats.Stats类提供了其他一些有用的功能。例如,print_callees方法打印出被监控函数调用的所有函数的列表。有关更多详细信息,请参阅 Python 文档(docs.python.org/3/library/profile.html#pstats.Stats)。

此代码可以从命令提示符运行,如下所示(它依赖于goldhunt_inefficient.py,所以请将其放在与该文件相同的目录中):

$ python profiling_goldhunt.py

这是此运行过程的示例输出(仅显示与统计相关的输出):

pstats 模块

这将显著减少输出,并且仅限于我们希望监控的程序中的函数调用。如输出所示,只有19个函数调用中的5个被列出。列表按执行函数所花费的总内部时间排序。两个函数find_coinsgenerate_random_points位于榜首!它们的顺序可能取决于我们为field_coinssearch_radius变量选择的值。但本质上,代码分析帮助我们识别了应用程序中最耗时的代码。

pstats 模块

好问题!如果我们能查看函数内部并看到逐行分析输出,那当然会很有帮助。幸运的是,有一个工具可以实现这一点。让我们接下来回顾一下。

line_profiler

line_profiler包是一个第三方 Python 包,可以使用pip安装:

$ pip install line_profiler

这个包可以用来逐行监控函数的性能。当你安装这个包时,它也会创建一个可执行的kernprof

在 Linux 上,这个可执行文件与您的 Python 可执行文件位于同一位置。例如,在 Linux 上,如果 Python 可用作/usr/bin/python,则此可执行文件创建为/usr/bin/kernprof(或查找kernprof.py脚本)。在 Windows 操作系统上,它应该与pip.exe位于同一位置。有关pip.exe路径,请参阅第一章,开发简单应用程序开发简单应用程序

小贴士

在 Windows 操作系统上,如果您遇到任何错误,例如错误:无法找到 vcvarsall.bat,您可能需要使用 Visual C++ Express。有关更多信息,请参阅www.visualstudio.com/en-US/products/visual-studio-express-vs

使用这个工具需要对代码进行微小的修改。您需要做的只是在上面的函数或方法名上方添加一个@profile装饰器,如下面的截图所示:

line_profiler 包

然后,使用以下命令运行工具:

$ kernprof -v -l goldhunt_inefficient.py

-v--view选项在终端窗口中显示配置文件输出的结果。分析器还会创建一个输出文件,goldhunt_inefficient.py.lprof-l--line-by-line选项使用来自line_profiler模块的逐行分析器。

小贴士

当您不使用line_profiler分析应用程序时,请务必删除装饰器@profile。换句话说,在运行应用程序时删除它,如下所示:

$ python goldhunt_inefficient.py

否则,它将引发NameError异常。

下面显示了find_coins方法的line_profiler输出。

如您所见,相当多的时间被花费在计算点(金币)与搜索圆心的距离上。

line_profiler 包

同样,如果您看到generate_random_point函数的输出,大部分时间都花在创建一个随机的theta角度和r半径的组合上,这些组合用于定义一个点(一个金币)。

内存分析

我们到目前为止所涵盖的剖析技术旨在找到运行时瓶颈。让我们简要讨论内存分析,这是剖析的另一个重要方面。

内存分析器包

对于内存分析,我们将使用一个流行的 Python 包,称为memory_profiler。它可以通过pip安装。以下是如何在 Linux 命令行中安装它的方法:

$ pip install memory_profiler

文档强烈建议安装psutils模块。它还建议,为了使memory_profiler在 Windows 操作系统上工作,您将需要psutil模块。可以使用pip安装psutil模块,如下所示:

$ pip install psutil 

小贴士

关于memory_profiler的更多信息,请查看以下页面:pypi.python.org/pypi/memory_profiler

就像line_profiler一样,memory_profiler包在函数名上方使用@profile装饰器。让我们在generate_random_points函数上方添加装饰器@profile,然后对goldhunt_inefficient.py文件运行内存分析器。运行此命令的命令如下:

$ python -m memory_profiler goldhunt_inefficient.py

这里是内存分析器的输出。它按行报告内存消耗。请注意,分析器打印了整个函数,包括文档字符串。为了便于说明,部分文档字符串没有显示。

内存分析器包

代码中的行号显示在第一列。第二列Mem Usage告诉我们 Python 解释器执行该行号后消耗了多少内存。内存的单位是梅比字节MiB)。第三列Increment给出了当前行与上一行之间的内存差异。如果当前行代码释放了内存,那么Increment列将显示负数。最后一列显示了实际的代码行。从Increment列可以看出,内存主要消耗在for循环中。我们将在下一章中使用内存分析器来比较生成器表达式和列表解析的内存效率。

算法效率和复杂度

算法是一组解决特定问题的指令。在这个上下文中,算法可以是一个函数,甚至是一个简单的加法操作,用于将两个数字相加。让我们了解两个相关的术语:算法效率和算法复杂度。

算法效率

算法效率表示算法消耗的计算资源。通常,资源消耗越低,效率越好。计算资源可以指很多种东西。一种可能是谈论运行时间(CPU 使用率)、内存消耗(RAM 或硬盘)或网络消耗,或者这些的组合。

应用需求决定了哪种资源比其他资源更重要。例如,在 Web 应用中,网络使用可能比磁盘空间更重要。对于科学应用,你可能需要所有的内存,但运行时间可能会很痛苦,等等。在这本书中,我们将只讨论运行效率。

算法复杂度

假设你有一个程序(一个算法)在五分钟内处理一些数据。如果你增加数据的大小,程序需要多少时间?答案在于算法复杂度。它告诉我们,如果你增加问题的大小,算法将如何扩展。换句话说,计算复杂度影响了算法的性能。在下一节中,你将学习如何表示计算复杂度。

大 O 记号

简单来说,大 O 或大 O 记号是一种表示算法计算复杂度的方法。在这里,O 是字母O,表示顺序,而不是数字零。大 O 表示算法复杂度的上界或最坏情况(详细内容将在下一节中介绍)。这个概念可以通过一个例子来更好地解释。让我们看看以下代码:

num = 100 
x = []
for i in range(num): 
    x.append(i)

让我们把这段简单的代码片段称为算法。它是一个简单的操作,在for循环中将一个数字添加到list中。在这里,num代表算法使用的输入大小。如果你增加num,算法将不得不在for循环中做更多的工作。进一步增加,这个可怜的算法将不得不做更多的工作。因此,算法所需的时间取决于num的值,可以表示为一个增长函数,f(n)。在这里,n代表输入的大小,对应于这个例子中的num

小贴士

到目前为止是否理解了?你也可以通过测量执行时间来测试这一点。为了看到真正的差异,请选择一个较大的num值。

在这个算法中,最耗时的部分是for循环,它将决定算法的整体运行时间。在for循环内部,每次调用x.append(i)都需要常数时间,t,来完成。对于较大的num值,循环的总时间将大约是num(t)。因此,整个算法相对于num的运行效率是线性的。从大 O 记号的角度来看,这个特定的算法被认为是O(n)*复杂度。

大 O 复杂度类别

让我们回顾一下大 O 复杂度类。以下图表注释了各种复杂度类,并显示了f(n)如何影响算法的运行时间:

大 O 复杂度类

Y轴上,我们有f(n)函数,而x轴代表输入大小,n(前一次讨论中的num变量)。这个图比较了一些表示算法时间复杂度的常见函数。

应该注意的是,大 O 表示法不包括常数。因此,即使两个算法具有相同的大 O 复杂度,它们的运行性能也可能非常不同。图中的圆点标记显示了两个复杂度函数之间的典型交叉点。在这个例子中,这是在O(n)O(n log n)之间。如前所述,代表这些复杂度函数的个别算法将具有不同的常数乘数(在大 O 表示法中未反映)。调整这些乘数可以改变这个交叉点发生的位置。

让我们简要回顾一下这些符号。

O(1) – 常数时间

无论输入大小如何,算法所需的时间都保持不变。获取 Python 列表的长度(len(x),其中x是列表)或我们之前看到的append列表操作,都是O(1)复杂度的几个例子。

O(log n) – 对数复杂度

算法所需的时间与输入大小的对数成正比。对数复杂度的一个例子是二分查找算法。它从检查排序数组的中间元素开始。如果被搜索的值小于中间元素,则包括这个中间元素在内的整个上半部分将从搜索中排除。我们可以这样做,因为这是一个排序数组。这个过程会重复进行剩余的一半,直到我们找到所需的值。

感到困惑?让我们看看仙女最近在忙些什么……

小仙子在一间满是宝箱的房间里丢失了她的魔法钥匙。这些箱子编号从 1 到 100,并按顺序排列。换句话说,箱子已经排序,钥匙放在其中一个箱子里。她正试图在魔杖的帮助下找到它。魔杖知道钥匙在,例如,编号为 82 的箱子里,但它不会给出直接的答案!它期望她提出正确的问题。她正站在房间的中间,面对着编号为 50 的箱子。向她的左边,她看到数字 1 到 49;向她的右边,数字 51 到 100,按此顺序排列。她问魔杖,钥匙在编号为 50 的箱子里吗?魔杖说“不在”。她进一步问,数字是大于 50 还是小于 50?魔杖回答“大于 50”。**有了这个回答,她忽略了左侧的箱子(1-49),包括编号为 50 的箱子,然后站在她右边的中间位置(51-100)。现在,她面前是编号为 75 的箱子。她以编号为 75 的箱子为参考,重复提出问题。每次,剩余的箱子数量减半。搜索操作一直进行到她在编号为 82 的箱子里找到她的钥匙。

这就是二分查找的精髓。你可以在维基百科上找到更多信息(en.wikipedia.org/wiki/Binary_search_algorithm)。在最坏的情况下,这种搜索的时间复杂度为O(log n)。另一种看待对数复杂度的方法是:对于问题规模n的指数增长,算法所需的时间线性增加。如前图表所示,O(log n)的时间复杂度比O(n)(线性时间)复杂度要好,但不如O(1)

O(n) – 线性时间

我们已经看到了一个例子,其中for循环使得算法的复杂度为O(n)。在 Python 列表中查找最小或最大元素以及复制列表或字典都是这种复杂度的其他例子。

O(n log n) – 对数线性

一个对数线性时间复杂度的例子是快速排序算法。让我们再次请出小仙子,以便更好地了解这个算法的工作原理。

仙女进入另一个宝藏室,发现它极其杂乱。宝箱在房间里到处随意散落。不喜欢这种状况,她决定按照宝箱的价值(或价格)递增的顺序对它们进行排序。最初,宝箱是随机放置的,如下所示:[5 3 2 4 9 7 8 8]。在这里,数字代表每个宝箱的价值。仙女开始挑选一个枢轴宝箱,比如说价值标签为 5 的宝箱。然后她将宝箱重新排列成三个部分:(i)价值低于 5 的宝箱位于枢轴的左侧,(ii)枢轴宝箱 5,(iii)价值高于 5 的宝箱位于右侧。如下所示:[3 2 4 5 9 7 8 8]。将 5 固定在其位置后,她重复上述步骤对 5 左右两侧的物品进行操作。例如,只考虑 5 的左侧:[3 2 4]。仙女选择数字 3 作为新的枢轴,并将 3 左右两侧的价值按照之前所示进行排列。这种重新排列的结果如下:[2 3 4]。这个过程一直持续到所有宝箱按照价值递增的顺序排序,如下所示:**[2 3 4 5 7 8 8 9]

这是基本的快速排序操作,其复杂度为 O(n log n)。如图所示,对于较大的 n 值,与 O(n) 相比,O(n log n) 的复杂度较为昂贵,但它比二次复杂度要好得多。

小贴士

应该注意的是,O(n log n) 是快速排序算法的 平均情况 复杂度。请参考本章的 复杂度的上界(最坏情况) 部分,了解平均情况和最坏情况复杂度。

O(n²) – 二次方

这表示二次运行时复杂度。程序运行所需的时间随着算法输入大小的平方增长。让我们扩展之前的例子来进一步理解这一点:

num = 100 
x = [] 
for i in range(num): 
    for j in range(num): 
        x.append(i) 

这是一个嵌套的 for 循环。设 t 为将一个元素添加到列表中所需的时间。如前所述,单个添加操作的时间复杂度为 O(1)。内层 for 循环将大约需要 nt(或 numt)的时间来执行。由于我们有一个外层 for 循环,总的时间复杂度变为 n(nt)。这种复杂性的一个经典例子是 冒泡排序算法 (en.wikipedia.org/wiki/Bubble_sort)。该算法以迭代方式对列表进行排序,并且如果列表中的相邻元素放置错误,则反复交换它们。

O(n³) – 三次方

这是一个三次方复杂度,比二次复杂度更差。问题规模的小幅增加将导致运行时间的显著增加。在二次复杂度的示意图中添加另一个外层 for 循环将使其变为 O(n3)

小贴士

这只是一个复杂度类别的部分列表。还有很多其他的。如需更多信息,请查看en.wikipedia.org/wiki/Big_O_notation

复杂度的上界

让我们回顾一下我们之前做出的陈述:“大 O 符号表示算法复杂性的上界或最坏情况”。听起来很复杂?需要解释一下。我们将重用之前讨论O(n²)复杂度时使用的插图:

num = 100 
x = [] 
for i in range(num): 
    for j in range(num): 
        x.append(i) 

我们已经看到,单个x.append(i)操作是O(1),内部循环是O(N),完整的嵌套for循环的时间复杂度是O(n²)。那么,为什么我们说整个算法的复杂度是O(n²)呢?

如果你看看之前比较各种复杂度的图表,O(n²)在这三种复杂度中成本最高,因此也是最重要的部分。换句话说,算法的复杂度不能比O(n²)更差。现在,再读一遍之前关于上界的陈述。大 O 符号代表算法复杂性的最坏情况。这就是为什么这个算法的大 O 复杂度类被表示为O(n²)

注意

平均情况时间复杂度:

大多数情况下,算法是通过测量其最坏情况复杂度来分析的。然而,有些问题测量平均情况时间复杂度是有实际意义的。在这里,运行算法所需的时间是所有可能输入的平均值。我们之前看到的快速排序算法就是一个平均情况复杂度有用的经典例子。它决定了算法的真实(或实际)效率。这个算法的平均情况时间复杂度是O(n log n),而最坏情况复杂度是O(n²)。更多信息,请参阅en.wikipedia.org/wiki/Average-case_complexity

常见数据结构和算法的复杂度

下表总结了在 Python 数据结构上执行的一些常见操作的复杂度。这不是一个详尽的列表,更多内容请参阅 Python 维基百科(wiki.python.org/moin/TimeComplexity)。它记录了这些数据结构上其他几个操作的复杂度。

常见数据结构和算法的复杂度

下表总结了某些常见算法的复杂度以及实现它们的 Python 函数。请注意,列出的函数来自 NumPy 库。尽管下一章将介绍 NumPy,但我们不会在这本书中专门讨论这些函数。

常见数据结构和算法的复杂度

前面表格中列出的第一个算法是二分查找算法。当我们讨论O(log n)或对数复杂度时,这已经被说明了。numpy.searchsorted函数使用二分查找来找到需要插入以保持顺序的数组索引。表中剩余的算法是一些常见的排序算法,它们将元素按特定顺序放入列表中。我们已经讨论了快速排序。要了解更多关于其他算法的信息,请参阅en.wikipedia.org/wiki/Sorting_algorithm

结束大 O 讨论

让我们总结一下到目前为止你学到的关于大 O 符号的知识:

  • 大 O 符号使我们能够从时间(或空间)复杂度的角度比较不同的算法。这有助于我们选择正确的算法(如果可能的话)或确定加快速度的实现策略。

  • 它给我们提供了算法的增长率,但不会给出运行时间的绝对值。例如,某个算法 A 需要 10 分钟来执行。在相同的机器上,算法 B 需要 200 分钟来执行,猜猜看——这两个算法具有相同的复杂度,比如说O(n)。尽管它们的执行时间不同,但它们有一个共同点,即所需时间与问题规模线性增长。

结束大 O 讨论的图

很高兴你提到了这一点!大 O 符号表示算法的最坏情况,它决定了该算法中存在的其他(成本较低)复杂度类。换句话说,最坏情况复杂度决定了该算法的性能。

当问题规模很大时,了解复杂度是很好的。对于非常小的问题,它可能或可能不会产生巨大差异。一个好的做法是分析现有算法的性能瓶颈,然后看看是否值得为了加速而重写算法。权衡因素,比如你花费在更改算法上的时间和它对质量(错误和测试)的影响,以及加速带来的长期利益。简而言之,选择最适合你需求的策略。

值得注意的是,有时你必须接受具有特定复杂度类的算法。但这并不是终点。你仍然可以实施技术来加快代码速度,而不改变其复杂度等级。性能提升将取决于具体问题。例如,你可以并行化代码或提前计算一些参数以实现加速。本书后面将介绍 Python 中并行化的基础知识。

概述

本章是该系列三个基于性能的章节中的第一个。它为提高应用程序性能奠定了基础。我们学习了如何使用time模块记录运行时间。我们还看到了如何使用timeit模块来测量小段代码的性能。我们解决了一个实际问题,即当处理小输入时,应用程序运行良好,但随着输入的增长,速度显著减慢。通过这个例子,我们学习了如何使用cProfile来识别瓶颈,并使用pstats来显示结果。

我们看到了line_profiler模块如何帮助定位函数内部耗时语句。虽然大部分讨论都集中在运行时性能上,但我们简要介绍了memory_profiler模块。该模块允许对给定函数的内存消耗进行逐行分析。最后,我们学习了表示算法计算复杂度的大 O 表示法。

既然我们已经确定了性能瓶颈,那么让我们继续到下一章,以提高应用程序的性能。

第八章。提高性能 – 第一部分

让我们回顾一下上一章学到的内容。我们从一个看似无害的程序开始,直到一些参数被调整。这种变化揭示了性能问题。因此,我们执行了搜索操作(性能分析)来捕捉罪魁祸首(瓶颈)。现在,让我们看看我们可以做些什么来加快应用程序代码。具体来说,我们将涵盖以下主题:

  • 减少黄金狩猎应用程序的运行时间

  • 学习以下方法来提高应用程序性能:

    • 修改算法

    • 避免函数重新评估

    • 使用列表和字典推导式

    • 使用生成器表达式

    • 使用技巧提高涉及循环的代码的性能

    • 选择合适的数据结构

    • 简要讨论collectionsitertools模块

总结来说,本章将介绍几种(但不是全部)加快应用程序速度的技术。其中一些可以直接应用于缓解上一章中黄金狩猎场景的性能问题。对于其余的,我们将使用通用示例来说明这些技术的有效性。

本章的先决条件

您已经阅读了第七章性能 – 识别瓶颈了吗?它教您如何识别性能瓶颈。本章的一部分使用了上一章讨论的相同问题,并逐步提高其性能。此外,在本章中,我们假设您已经知道如何对代码进行性能分析。

这是本章的组织方式

我们将首先介绍黄金狩猎场景的性能改进的第一部分。目标是提供一个实际示例,说明如何解决问题,并逐步减少运行时间。以下图表显示了本章结束时将要完成的内容的预览——这是与上一章相同的图表。应用程序的运行时间将减少超过 50%!

这是本章的组织方式

本书的后半部分将向您展示许多提高应用速度的方法。为了这次讨论,我们将使用通用示例,因为并非所有技术都可以直接应用于黄金狩猎场景。后半部分将作为性能改进的便捷参考。

小贴士

Python 维基百科记录了几个性能改进技巧。其中一些将在本章中介绍。有关更多详细信息,请参阅wiki.python.org/moin/PythonSpeed/PerformanceTips

回顾黄金狩猎场景

在这一点上,你应该回到第七章,性能 – 识别瓶颈,并回顾一下黄金狩猎场景。为了总结问题,一个圆形区域散布着金币,你需要穿越整个区域尽可能多地捡起金币。然而,你只能捡起位于小搜索圆圈内的金币。我们编写了一个应用程序代码,并讨论了调整search_radiusfield_coins(总散布金币)参数如何影响性能。在接下来的讨论中,我们将逐步提高这段代码的性能。

选择问题规模

为了 在优化代码后看到时间上的真正差异,让我们进一步增加问题规模。在上一章中,大矮人要我们在场地上放置一百万枚金币。让我们翻倍。现在,有两百万金币可以争夺!简而言之,search_radiusfield_coins将分别设置为0.12000000*。

小贴士

注意!在运行任何示例之前阅读此内容

本章中的示例可能会消耗大量计算资源(本章将展示示例输出,因此您不必运行这些示例)。例如,goldhunt_0.py文件在一个 64 位 Linux 机器上完成需要近两分钟,该机器有 8GB RAM,处理器性能良好且只有少量运行任务。在执行过程中,它也消耗了相当多的内存。对于这种系统配置,性能并不算太差。一般来说,它将取决于您机器的规格。所以,要小心!一种策略是将field_coins设置为5000search_radius设置为1,看看应用程序运行得如何。然后,逐步调整这些参数到一个可接受的配置。

分析初始代码

我们将从源goldhunt_0.py文件开始(参见本章的支持代码)。这与goldhunt_inefficient.py相同,除了以下方面:

  • 它使用cProfile分析游戏执行并打印统计信息。因此,它还包括profiling_goldhunt.py 模块中的函数。虽然将这两个模块结合起来不是最佳实践,但它将有助于简化即将到来的说明。

  • 下一个展示的是更新后的play_game()函数。它使用新的参数值,如下所示:分析初始代码

代码可以按以下方式运行——如果需要,调整GoldHunt()的输入参数:

$ python goldhunt_0.py

以下截图显示了这次运行的性能分析统计:

分析初始代码

注意到find_coins消耗了相当多的时间。接下来是generate_random_points。让我们看看我们能做些什么来提高性能。

优化黄金狩猎——第一部分

是时候采取一些行动了。本节按以下方式组织——你将学习一些优化代码和加速应用程序的技术。这些技术将直接应用于提高 黄金狩猎 游戏的性能。

这是优化任务的第一部分。在这里,性能将通过三个步骤得到提升。我们将称之为 优化第一步第二步第三步。在实施每个策略之后,代码将重新进行性能分析,以了解所实现的加速。让我们从 优化第一步 开始。

调整算法——平方根

性能分析输出(参考 性能分析初始代码 部分)显示 find_distance 方法是瓶颈。作为一个起点,让我们对这个算法做一些修改,使其运行得更快。以下是 第七章 中 审查初始代码 部分提出的原始方法,性能——识别瓶颈

调整算法——平方根

该方法计算从搜索圆心到每个金币的距离,并确定给定的金币是否位于搜索圆内。计算出的距离,表示为 dist,是一个平方根。

我们真的需要计算平方根吗?平方根的计算很耗时,在这种情况下是不必要的。我们只是比较两个数。我们能否通过比较两个数的平方来避免这种情况?困惑了吗?看看下面的比较:

调整算法——平方根

我们有两个正数,a=4b=9。显然,a 小于 b。因此,比较 a < b 总是会返回 true。即使比较它们的平方根也适用。同样的逻辑可以应用到我们的问题上。distself.search_radius 变量可以被视为两个数的平方根。我们得到了以下代码:

dist = math.sqrt(delta_x*delta_x + delta_y*delta_y)

或者,我们可以这样说,dist 是某个数,dist_square 的平方根,如下所示:

dist_square = delta_x*delta_x + delta_y*delta_y

接下来,我们已经知道了 self.search_radius 的值。现在,想象它作为另一个数,search_radius_square 的平方根。这个数目前还没有,需要按照以下方式计算:

search_radius_square = self.search_radius*self.search_radius

作为最后一步,我们需要比较这两个数,而不是它们的平方根:

if dist_square <= search_radius_square: 
    # more code follows...

调整算法——平方根

这是一个很好的观察!它需要我们进行额外的计算来找出 self.search_radius* 的平方。但是,我们不需要在 for 循环的每次迭代中都进行这个计算。self.search_radius 在循环中不会改变。因此,这个计算可以在 for 循环之前只做一次。*

黄金狩猎优化——第一步

将所有这些放在一起,更新的 find_coins 方法如下所示:

金矿优化 – 第一次通过

现在是时候再次配置此代码,看看我们是否能获得性能上的任何改进。支持源文件goldhunt_pass1.py已包含这些更改。可以按照以下方式运行:

$ python goldhunt_pass1.py 

以下截图显示了本次运行的配置文件统计信息:

金矿优化 – 第一次通过

将计时与原始代码的计时进行比较。应用程序的运行时间有显著提高。之前,总运行时间超过 100 秒,但这次优化将其降低到 60 秒以下!你还可以将输出中的第一行(find_coins)与原始计时进行比较。分析器的计时将取决于机器规格和选择的输入值。

注意

即使再次运行相同的程序,计时也会略有变化。这背后有两个原因;首先,我们在战场上随机分配金币。因此,对于每次运行,列表中附加的总金币数都会有所变化。第二个影响因素是系统上的其他运行进程。理想情况下,你应该在相同的环境中运行它以减少这些变化(或噪声)。例如,关闭其他正在运行的应用程序,以免它们干扰计时。在性能基准测试过程中,经常多次运行相同的应用程序,并记录平均时间以减少这些变化的影响。

跳过点

Python 中的表示法允许访问给定对象的属性。看看以下代码,这是从上一个例子中的find_coins方法的for循环中取出的。这个例子是:

for x, y in zip(x_list, y_list):
    # Some code follows...
    # ...
    if dist_square <= search_radius_square: 
        collected_coins.append((x, y))

在这个循环中,对于每一次迭代,collected_coins.append函数都会被重新评估。回想一下,在第六章中,你学习了关于一等函数的内容。让我们用一个局部函数来表示collected_coins.append。这样可以避免函数的重新评估(跳过点),并有助于加快循环速度。

金矿优化 – 第二次通过

在第二次通过中,我们将改进之前通过(优化第一次通过)的代码。支持代码包中的goldhunt_pass2.py文件包含了接下来要讨论的所有更改。以下是修改后的find_coins方法:

金矿优化 – 第二次通过

在这里,一个名为append_coins_function的局部函数被分配给 Python list的内置append函数。这样可以避免append函数的重新评估。同样,self.xrefself.yref也被表示为局部变量。让我们配置这个新代码,看看我们是否能获得任何改进。命令如下:

$ python goldhunt_pass2.py 

金矿优化 – 第二次通过

性能有所提升,但结果并不像优化的第一个步骤那样令人印象深刻。这仍然是一个合理的提升,大约 10 秒或超过 15%。

你可以在代码的其他地方进行类似的更改,但在你急于行动之前,Sir Foo 有一个重要的信息要告诉你。

金矿优化 – 第二个步骤

这是一个非常好的观点!在采用此类技术时应谨慎行事。你应该记录代码或定义一个项目特定的编码规范,以便局部函数可以清楚地突出。这将帮助其他开发者理解此类赋值的目的。更普遍地说,不要过度使用,看看是否有真正的益处。

使用局部作用域

当寻找变量或函数定义时,Python 会按照以下顺序搜索以下命名空间局部全局内置。用更简单的话说,它首先寻找局部变量或函数,然后在模块级别进行搜索,如果找不到任何东西,它会寻找内置函数或变量名称。因此,查找局部变量或函数是最快的。用局部函数替换全局或内置函数可能有助于提高性能。你获得的速度提升将取决于问题。

让我们回顾一下 generate_random_points 函数。原始代码如下所示。请参阅第七章中的回顾初始代码部分,性能 – 识别瓶颈,其中进行了解释。

使用局部作用域

在原始函数中,我们调用内置模块 randommath 的各种函数。让我们在下一个优化步骤中更新 generate_random_points

金矿优化 – 第三个步骤

让我们进一步深入优化过程。我们将用局部函数替换 generate_random_points 函数中的内置函数调用。修改后的代码如下所示。在这里,l_uniform 变量代表 random.uniform 函数。同样,你可以在这段代码片段中看到其他赋值。

金矿优化 – 第三个步骤

小贴士

此步骤之后的优化是将使用局部作用域和跳过点结合起来。作为一个练习,你可以尝试将这些组件分开。例如,为了避免使用点,在模块顶部导入picos和其他符号,并在函数中直接使用它们。然后比较使用和不使用局部函数的性能。

此外,在实现此类代码之前,问自己几个问题:通过使用局部作用域,代码质量是否受到影响(是否更难阅读和维护)?最终的性能提升是否超过了所有其他因素?

您也可以在goldhunt_pass3.py中找到此代码。以下是该文件的cProfile输出。整体时间上只有轻微的改进。如果您将列表的第二行(generate_random_points)与优化通过二的对应输出进行比较,真正的差异将变得明显:

黄金狩猎优化 – 第三个通过

总运行时间已从最初的约 3.2 秒减少到约 2.6 秒。增加问题规模(硬币数量)可以使这种差异更加明显。

但看起来有人对这种加速并不太满意...

黄金狩猎优化 – 第三个通过

绝对可以!提高黄金狩猎游戏性能的任务还远未结束!在我们这么做之前,让我们讨论一些其他有助于加快应用程序速度的技术。我们将使用通用示例,因为许多这些技术在前文提到的游戏场景中并不相关。在下一章中,我们将重新审视黄金狩猎**问题,并使用 NumPy 和并行化进一步加快应用程序的速度。这将带来显著的性能提升。如果您不想打断连贯性,请先阅读下一章,然后再回来继续讨论。

性能提升技巧

让我们花些时间讨论一些有助于提高代码运行时性能的杂项技巧和窍门。您仍然可以将其中的一些技术应用到黄金狩猎问题上,但让我们只使用通用示例来解释这些概念。

提示

本节中所有的插图都可以在辅助文件misc_performance.py中找到。为了比较性能,我们将使用在第七章中讨论的timeit模块,性能 – 识别瓶颈(请参阅测量小代码片段的运行时间部分)。另请参阅timeit文档,docs.python.org/3/library/timeit.html

列表推导

列表推导是创建 Python 列表的一种紧凑方式。它常被用来替换嵌套的for循环或mapfilter功能。除了紧凑之外,与例如等效的for循环相比,它也更为高效。其基本语法如下:

a = [i*i for i in range(5)] 

这将创建一个包含以下元素的列表:[0, 1, 4, 9, 16]

前面的语法等同于以下:

mylist = []
for i in range(5):
    mylist.append(i*i)

让我们将这些代码块封装在两个函数中。我们将使用timeit模块来衡量每个函数的性能。之前提到的文件misc_performance.py也有这些函数。为了更好地了解性能提升,我们将选择更大的问题规模。正如本书中多次提到的,根据您的机器能够舒适处理的问题规模来选择。

下面的代码片段显示了这些函数:

列表推导式

sample_size_1变量被选择得足够大,以便可以看到差异。使用timeit.timeit方法捕获运行时间,其第一个参数是一个表示函数名称的字符串。第二个参数是一个setup参数,它告诉我们在哪里查找此函数。可以通过执行脚本进行比较运行性能,如下所示:

$ python misc_preformance.py

如以下输出所示,列表推导式与等效的for循环相比更快:

Without list comprehension : 1.218718248004734 
With list comprehension    : 0.8486306999984663 

提示

作为一个练习,尝试比较嵌套for循环与等效列表推导式语法的计时。请参考misc_performance.py文件中的list_comprehension_ex2函数。

黄金狩猎问题中,也可以在generate_random_points函数中使用列表推导式。例如,你可以选择性地将theta编写如下:

theta = [random.uniform(0.0, 2*math.pi) 
         for i in range(total_points)] 

但在做出这样的更改之前,请阅读下一章,其中展示了 NumPy 包如何极大地提高此函数的性能。

记录执行时间

在前节中,我们使用timeit.timeit函数记录并比较列表推导式与经典for循环的性能。让我们将timeit代码封装成一个实用函数,以便我们可以将其用于后续的讨论。run_timeit函数如下所示:

记录执行时间

在这里,func_1func_2是需要记录执行时间的函数名称(字符串)。timeit.timeit函数中的number参数表示给定函数执行的次数。run_timeit的调用者可以通过使用可选的num参数来调整这个数字。请参阅文档以获取更多详细信息。

提示

此函数不执行任何错误检查。作为一个练习,你可以添加这个功能。例如,添加try…except子句以捕获如果函数未找到的错误。

在接下来的讨论中,我们将使用run_timeit来比较两个功能等效的代码块的性能。

字典推导式

就像列表推导式一样,字典推导式是创建 Python 字典对象的语法结构。以下函数展示了两种创建字典的方法。第一个(no_dict_comprehension)使用for循环创建字典,而第二个函数展示了字典推导式语法。

字典推导式

如前节所述,从现在开始,我们将使用run_timeit实用函数来记录时间。执行此代码后的timeit输出如下:

Function: no_dict_comprehension, time: 0.14393422298599035 
Function: dict_comprehension, time: 0.13295511799515225

交换条件块和 for 循环

考虑以下简单的代码。有一个顶层的for循环和一个if…else条件块。根据num变量的值(假设它发生变化),将执行ifelse条件。和之前一样,应该为sample_size_1变量选择一个合适的整数:

交换条件块和 for 循环

我们可以通过交换for循环和if…else块来编写相同的代码。新函数有一个顶层的if…else块。在每一个条件语句中,我们都有相同的for循环。下面的if_condition_loop_opt函数展示了这一点(其输出保持不变):

交换条件块和 for 循环

让我们来找出这两个函数之间的胜者:

Function: no_if_condition_loop_opt, time: 0.1894498920009937 
Function: if_condition_loop_opt, time   : 0.15955313100130297 

总结来说,具有顶层if…else块的函数比具有顶层for循环的函数运行得更快。

小贴士

这是一个简单的例子,其中交换for循环和条件块很容易。然而,在现实世界中,权衡进行此类修改的优势与引入错误的风险。性能分析真的显示这个代码块是一个主要的瓶颈吗?如果你最终决定继续进行,请添加足够的自动化测试以确保函数输出保持不变!参见第五章,单元测试和重构,了解如何编写单元测试。

在循环中尝试

记住求饶比求许可更容易EAFP)原则,它鼓励使用try…except子句?这在第二章,处理异常中简要讨论过。让我们看看try…except子句如何节省一些执行时间。考虑以下函数,它根据i的值在for循环中填充一个列表。只有对于for循环的第一个迭代(i=0),执行if语句。对于所有其他i的值,它执行else块,val /=i

在循环中尝试

让我们用try…except子句替换if…else块。try子句将始终尝试执行val /= i语句。当我们有i=0时,它引发ZeroDivisionError异常,该异常在except子句中处理。

在循环中尝试

在这里,我们只需要捕获初始值i=0的错误。对于循环的其余部分,代码应该运行顺畅。try…except子句有效地消除了if…else条件块强加的额外检查。换句话说,我们不再需要为每个i的值检查if i==0。因此,代码运行得更快。下面显示了这些函数的执行时间——显然,using_try函数表现更好:

Function: not_using_try, time: 0.1821241550205741 
Function: using_try, time    : 0.09502803898067214

选择合适的数据结构

这是一个相当广泛的话题。数据结构的选择很大程度上取决于你试图解决的问题。在本节中,我们将仅讨论一个示例,以展示正确选择数据结构如何提高运行时性能。观察data_struct_choice_list函数;它首先创建一个列表对象mylist。接下来,在for循环内部,代码检查j是否是mylist的元素之一,并相应地更新val参数。

选择合适的数据结构

现在看看下面的data_struct_choice_set函数。它不是创建一个list对象,而是创建一个由myset变量表示的set对象。语法与我们之前看到的list或字典推导式语法类似(其余代码保持不变,并且这两个函数返回相同的值)。

选择合适的数据结构

当涉及到检查元素是否属于集合时,Python 的setlist更快。换句话说,"if (j in myset)"操作比"if (j in mylist)"操作更快。如第七章中的表格总结所示,性能 – 识别瓶颈,这个操作的平均时间复杂度对于setO(1),对于listO(n)

下文展示了这两个函数的timeit输出。显然,实现set的函数比实现list的函数要快得多:

Function: data_struct_choice_list, time: 1.7527358299994376 
Function: data_struct_choice_set, time: 0.015494994004257023

小贴士

你有没有注意到这个例子中的问题?timeit报告的运行时间包括了创建listset对象所需的时间。为了进行准确的比较,你应该只比较这些函数中的for循环。换句话说,将listset的创建部分移出函数定义,然后进行时间比较。

让我们继续讨论数据结构,并接下来回顾 Python 的collections模块。

集合模块

collections模块提供了一些特殊用途的容器数据类型。让我们回顾其中的一些常见类型。如果你想知道这个模块中的其他数据结构,请参阅 Python 文档(docs.python.org/3/library/collections.html)。

deque

deque类允许从deque数据结构的任一侧添加或删除元素。deque类中的appendpop操作内存高效且线程安全,复杂度为O(1)。以下代码展示了创建deque并移除最右侧元素的一种简单方法:

>>> dq = deque(range(10)) 
>>> dq 
deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 
>>> dq.pop() 
9 
>>> dq 
deque([0, 1, 2, 3, 4, 5, 6, 7, 8])

让我们比较 deque 与等效 list 的性能。观察以下两个函数,其中我们调用 listdeque 类的 pop() 方法——请注意,我们在这两个函数外部创建 listdeque 对象,以确保报告的计时不受对象创建的影响:

deque 类

以下 timeit 输出显示了在 deque 上执行 pop() 操作比在 list 上更快:

Function: list_example, time: 0.1243858500092756 
Function: deque_example, time: 0.0937135319982189

那么,我们应该在什么情况下使用 deque?一般来说,如果你的代码涉及大量需要在两端进行数据追加或弹出操作的操作,deque 比列表更受欢迎。但是,如果代码需要快速随机访问元素,list 是更好的数据结构选择。

defaultdict 类

defaultdict 类是从内置的 dict 类派生出来的。如果你尝试访问一个不存在的键,一个简单的 Python 字典会抛出 KeyError 异常。但是,defaultdict 类会创建一个新的键。这可以通过以下示例更好地解释:

>>> d1 = {} 
>>> d1['a'] 
Traceback (most recent call last): 
  File "<stdin>", line 1, in <module> 
KeyError: 'a' 

标准字典对象 d1 没有 'a' 键,因此会抛出错误。如果你尝试使用 defaultdict 类访问这个键,它将简单地创建它,如下例所示:

>>> from collections import defaultdict 
>>> d2 = defaultdict(int)
>>> d2['a'] 
0 
>>> d32
defaultdict(<class 'int'>, {'a': 0})

小贴士

标准字典的内置 setdefault() 方法执行类似的功能。如果你试图访问的键不存在,它会在字典中插入一个新的键,并给它分配一个默认值,然后返回这个默认值。然而,与 setdefault 方法相比,使用 defaultdict 更快。有关更多信息,请参阅文档(docs.python.org/3/library/stdtypes.html#dict)。

这只是 defaultdict 提供的功能之一。它还提供了一种有效的方法来计算容器中元素出现的次数。让我们通过一个例子来看看。以下 dict_counter 函数定义了一个名为 game_characters 的列表。这个列表中有许多重复的元素。该函数使用标准字典来计算每个元素出现的次数,然后返回这个字典。

defaultdict 类

例如,这个函数的输出将是一个字典:

{'orc': 2000000, 'knight': 3000000, 'elf': 1000000} 

sample_size_1 只是一个乘数,使得这个列表足够大,以便可以看到执行时间上的差异。在这个例子中,它被选为 100000。现在,让我们编写一个使用 defaultdict 类来完成相同工作的函数。看看结果代码是多么紧凑:

defaultdict 类

让我们比较这两个函数的性能。以下 timeit 输出确认实现 defaultdict 的函数运行得更快:

Function: dict_counter, time: 0.6270602609729394 
Function: defaultdict_counter, time: 0.4926446119789034

计数操作也可以使用collections.Counter类来完成。与defaultdict类相比,其语法简单且高效(我们将在本书中不讨论Counter类)。作为练习,阅读文档并编写一个使用Counter类的函数,用于前面的示例。

生成器和生成器表达式

生成器基本上是一个迭代器。它是一个强大的工具,用于处理非常大的数据集或无限数据集。生成器函数的编写方式与常规函数相同,但其特点是使用yield语句。在返回值方面,它与return语句类似。然而,生成器函数在yield之后“冻结”了当前环境。因此,下次您想要一个值时,生成器函数将从上次离开的地方继续,并返回下一个值。

换句话说,生成器一次返回一个值(例如从列表中),跟踪迭代当前状态(记住它在之前的调用中返回的所有值),并在再次被调用时,从上次离开的位置继续。当您向函数中添加yield语句时,它自动成为生成器函数。让我们写一个简单的例子来更好地理解这个概念:

>>> def get_data(): 
...     for i in range(3): 
...         yield i*i 
... 
>>> g = get_data() 
>>> g 
<generator object get_data at 0x7f704c55fb40>

get_data()函数返回一个生成器对象gnext()函数只是从生成器中获取值的一种方法:

>>> next(g) 
0 

get_data()函数的第一个迭代中,我们有i=0。因此,生成器返回的值是i*i=0。现在到了有趣的部分。让我们再次调用next()函数:

>>> next(g) 
1 

它返回了1这个值。这对应于get_data()函数中迭代器的下一个值,即i=1,这使得i*i=1。如果我们再次调用next(),它将返回i=2的结果,如下所示:

>>> next(g) 
4 

这将一直持续到生成器耗尽所有值。如果我们再次调用next(),它将引发一个StopIteration异常,如下所示:

>>> next(g) 
Traceback (most recent call last): 
  File "<stdin>", line 1, in <module> 
StopIteration 

使用yield语句是创建生成器函数,从而创建生成器对象的一种方法。让我们来了解生成器表达式,它提供了创建生成器对象的另一种方式。

生成器表达式

生成器表达式被提议为PEP 289,并总结为列表推导式和生成器的高性能内存高效泛化。

小贴士

有关PEP 289的更多详细信息,请参阅www.python.org/dev/peps/pep-0289

生成器表达式的语法与列表推导式类似。它使用圆括号()而不是方括号[]来创建生成器对象:

>>> g = (i*i for i in range(3)) 
>>> g 
<generator object <genexpr> at 0x7f0b71b0c8b8>

我们已经看到了如何使用next()函数从生成器对象中获取值。您也可以使用for循环从生成器中获取数据,如下所示:

>>> g = (i*i for i in range(3)) 
>>> for data in g: 
...     print(data) 
... 
0 
1 
4 

让我们看看一个简单的例子,其中可以使用生成器表达式。内置的 sum 函数接受一个可迭代对象作为输入。它将可迭代对象的所有元素相加,并返回一个单一的总和值:

>>> g = (i*i for i in range(3)) 
>>> sum(g) 
5

注意,你甚至可以将一个 list 传递给 sum() 方法以获得相同的结果。接下来,我们将比较生成器表达式与列表推导式的内存效率。

比较内存效率

对于中等规模的问题,列表推导式的运行时性能通常比等效的生成器表达式更好。我们在这里不会进行这种比较。相反,我们将看看生成器表达式和列表推导式在内存消耗方面的比较。

在上一章中,我们看到了如何使用 memory_profiler 包。让我们在这里使用它来分析内存使用情况。创建一个 compare_memory.py 文件或从本章的支持代码包中下载它。代码如下:

比较内存效率

list_comp_memory 函数使用列表推导式语法创建一个 listgenerator_expr_memory 函数使用生成器表达式语法创建一个生成器对象。@profile 装饰器标记该函数以供内存分析器进行性能分析。让我们在这个文件上运行 memory_profiler 函数:

$ python -m memory_profiler compare_memory.py

这是这次运行的输出:

比较内存效率

让我们回顾一下对 compare_memory.py 文件进行的性能分析所得到的输出:

  • Increment 列表明列表推导式创建一个 list 并将其放入内存中。在本例中,它消耗了大约 0.37 MiB。

  • 内存分析器报告的使用量为 MiB。对于生成器表达式,它报告 0.0 MiB 或在本例中将其解释为只有几个字节。

  • 如果你进一步增加 sample_size 变量,列表推导式消耗的内存将相应增加。

  • 对于非常大的 sample_size,你的计算机在创建列表推导式中的 list 时甚至可能会崩溃。

  • 使用生成器表达式时,无论数据大小如何,消耗的内存将保持不变。当操作非常大的或无限的数据集时,这是一个极其有用的特性。

生成器表达式或列表推导式?

生成器表达式或列表推导式?

好问题。如何在生成器表达式和列表推导式之间做出选择?选择取决于你处理的问题类型。以下要点将帮助你做出决定:
  • 当您处理一个非常大的(或无限的)数据集,并且只迭代一次时,请使用生成器表达式。列表推导式会将整个列表放入内存中,这对于小型或中型数据集来说工作良好。然而,随着数据集大小的增加,您会发现问题。另一方面,生成器表达式使用的是恒定内存。它即时返回数据。一旦数据生成,内存就会被释放。

  • 这实际上是将第一个观点的另一种说法。如果您想多次遍历整个数据集,请不要使用生成器表达式。在这种情况下,请使用列表推导式。

  • 生成器表达式不支持列表操作,如切片。因此,如果您想执行此类操作,请使用列表推导式。

itertools 模块

既然我们已经了解了生成器表达式的工作原理,让我们简要回顾一下itertools,这是 Python 中另一个重要的内置模块。它提供了创建迭代器的功能,以实现高效的循环。itertools模块为迭代器提供了几个构建块。一些常用的迭代器包括count()repeat()chain()groupBy()tee()product()permutation()combination()等等。这只是支持的功能的部分列表。在本章中,我们只将回顾chain()迭代器。

注意

有关itertools模块提供的其他迭代器的信息,请参阅docs.python.org/3/library/itertools.html

itertools.chain 迭代器

这个迭代器用于将多个迭代器连接在一起。它可以接受列表、元组、生成器,甚至这些迭代器的组合作为输入。让我们回顾一个简单的例子,说明如何创建一个chain对象:

>>> from itertools import chain
>>> mylist_1 = [1, 2, 3] 
>>> mytuple = ('x', 'y') 
>>> mylist_2 = [10, 20] 
>>> mychain = chain(mylist_1, mytuple, mylist_2) 
>>> mychain 
<itertools.chain object at 0x7fc6fcc1c2e8> 

查看这个chain对象内容的最简单方法是将它打印为一个新的list对象:

>>> print(list(mychain)) 
[1, 2, 3, 'x', 'y', 10, 20]

如所示,chain迭代器将两个输入列表和一个元组(或迭代器)组合在一起。有时,您可能想要在多个列表或任何其他可迭代数据结构上执行相同的操作。chain迭代器通过组合或连接这些数据结构来实现这一点。更重要的是,它不会消耗任何显著的内存。就像生成器一样,chain对象消耗的内存保持恒定,即使数据的大小增加。同样重要的是要注意,就像生成器一样,chain对象只能用于迭代给定的数据集一次。以下代码说明了这一点:

>>> mychain = chain(mylist_1, mytuple, mylist_2) 
>>> for item in mychain: 
...     print(item) 
... 
1 
2 
3 
x 
y 
10 
20 
>>> next(chain) 
Traceback (most recent call last): 
  File "<stdin>", line 1, in <module> 
TypeError: 'type' object is not an iterator

您可以将chain对象的内存效率与合并输入列表的等效代码进行比较。代码如下所示。这些函数中的for循环只是为了说明chain对象如何在循环中使用。

The itertools.chain iterator

你也可以在 compare_memory.py 文件中找到这段代码。在这个文件中,只需添加 @profile 装饰器。通过这个更改,将内存分析器作为练习运行。以下是从内存分析器输出中可以观察到的(此处未显示):

  • chain 对象消耗大约 0.004 MiB 的内存,并且即使你增加输入列表 data_1data_2data_3 的大小,其消耗也保持不变。

  • list_memory 函数在创建 mylist 对象时消耗了近 0.383 MiB 的内存。该函数消耗的内存随着输入数据大小的增加而增加。

练习

已经提出了一些练习。让我们列出其中的一些。(注意,这些练习没有提供解决方案):

  • 为嵌套的 for 循环编写一个列表推导式语法。比较嵌套 for 循环和列表推导式的执行时间。以下是一个示例:

    x = [ i*j for i in range(4) for j in range(4)]
    
  • 为前面的列表推导式编写一个生成器表达式。你只需要将外层的方括号 [] 改为圆括号 ()

摘要

在本章中,你学习了多种有助于减少应用程序运行时间的技巧。我们首先通过提高 《黄金狩猎》 应用程序的速度开始。运行此应用程序的总时间提高了超过 50%——我们通过更改算法,使其不需要计算距离比较的平方根来实现这一点。另外两个更改又从总执行时间中节省了几秒钟。我们避免了函数重新评估(跳过了“点”)并优先考虑局部作用域的变量而不是全局作用域。这是 《黄金狩猎》 程序性能改进的第一部分的结束。

接下来,本章教你多种加快代码速度的方法。它说明了列表推导式与等效的 for 循环相比做得更好。我们还看到了数据结构的选择如何影响性能。本章进一步介绍了提供比列表推导式内存优势的生成器表达式。此外,我们还简要回顾了 itertoolscollections 模块提供的功能。

我们承诺对应用程序进行进一步的改进,《伟大的矮人》。在下一章中,让我们学习那些能帮助我们履行承诺的东西!

第九章。提高性能 – 第二部分,NumPy 和并行化

这是关于性能改进的三章中的最后一章。它将介绍两个重要的库,NumPy,一个第三方包,以及内置的 multiprocessing 模块。在本章中,我们将涵盖以下主题:

  • NumPy 包的简要介绍

  • 使用 NumPy 加速 Gold Hunt 应用

  • 使用 multiprocessing 模块介绍并行处理

  • 使用 multiprocessing 模块进一步改善应用运行时间

本章的先决条件

你应该阅读最后两个章节,第七章,性能 – 识别瓶颈,和第八章,提高性能 – 第一部分,这些章节介绍了如何识别性能瓶颈并使用内置功能来提高运行时间。本章通过大幅提高性能将应用提升到下一个层次。

这是本章的组织结构

本章将是性能改进的 第二部分。就像上一章一样,Gold Hunt 程序的性能将通过逐步改进。我们将从对 NumPy 的快速介绍开始,仅足够使用其功能进行 优化过程四,接下来将介绍。继续前进,将对 multiprocessing 模块进行浅显的介绍。在 优化过程六 中,我们将使用此模块来并行化应用代码的一部分。让我们拉起上一章中相同的柱状图。最后两个柱子表示到本章结束时实现的加速。

这是本章的组织结构

但图表并没有讲述完整的故事。优化过程四将显著加快 Gold Hunt 程序的 generate_random_points 函数。这种加速在图表中没有体现,因为在这个场景中该函数对运行时间没有显著贡献。在结尾部分,本章将提供关于 PyPy 的初步信息,供进一步阅读。PyPy 是一个提供 即时 (JIT) 编译器的 Python 解释器。

注意

运行 Gold Hunt 优化示例

如果你仔细查看即将讨论的性能分析输出,你会注意到一个文件名,goldhunt_run_master.py。使用此文件是可选的,但它提供了一种方便的方式来运行任何优化过程。你可以在这个章节的支持代码包中找到此文件。

NumPy 简介

NumPy 是一个强大的 Python 科学计算包。它提供了一个多维array对象,使得在 Python 中高效实现数值计算成为可能。与列表相比,它的内存占用相对较小。array对象只是 NumPy 的许多重要特性之一。除此之外,它还提供了线性代数和随机数生成功能。它还提供了访问用其他语言编写的代码的工具,例如 C/C++和 Fortran。让我们从一个简短的介绍开始,以了解其功能。本书中我们将讨论的更多像是 NumPy 表面的冰山一角!本章涵盖了以后用于加速黄金狩猎应用程序的一些特性。

提示

查阅官方 NumPy 文档(docs.scipy.org),了解这里未涵盖的几个其他特性。

如果你已经熟悉 NumPy,你可以选择性地跳过这个介绍,直接进入优化黄金狩猎 – 第二部分部分。

安装 NumPy

一些 Python 发行版,例如 Anaconda (www.continuum.io/downloads),默认提供 NumPy。如果不可用,请使用pip安装它。以下是在 Linux 上安装的方法,假设pip可以在终端作为命令使用:

$ pip install numpy 

这应该会安装 NumPy。如果你遇到问题,请参阅www.scipy.org/install.html上的平台特定安装说明。或者,你可以使用前面提到的 Anaconda Python 发行版。

安装完成后,打开 Python 解释器并输入以下命令:

>>> import numpy as np 

假设安装成功,它应该导入 NumPy。在接下来的讨论中,我们将使用np作为numpy的别名。保持解释器窗口打开。在接下来的介绍中,我们将运行一些简单的 NumPy 操作。

创建数组对象

如前所述,多维(N 维)数组对象是 NumPy 的核心功能之一。这个数组由内置类numpy.ndarray提供。它表示同一类型的元素集合。换句话说,它是一个同质数组。有几种方法可以创建 NumPy 数组。在你的 Python 解释器中输入以下代码:

>>> import numpy as np 
>>> x = np.array([2, 4]) 
>>> x 
array([2, 4])

这将创建一个由x变量表示的数组实例,包含两个元素。这是一个numpy.ndarray类型的数组。它是一个一维数组。你可以访问任何元素或更改其值,就像 Python 的list一样:

>>> x[0] 
2 
>>> x[0]=8 
>>> x 
array([8, 4]) 

在这个简单的例子中,数组的大小是2。这也被称为数组的形状。NumPy 将数组形状表示为整数元组。它给出了每个维度的数组大小。这如下面的代码行所示:

>>> x.shape 
(2,)

继续深入,这里还有一个创建二维数组的例子:

>>> p = np.array([[4, 8], [10, 20]]) 
>>> p 
array([[ 4,  8], 
 [10, 20]]) 
>>> p.ndim
2
>>> p.shape 
(2, 2)

这里,ndim代表数组的维度数。数组形状表示每个维度的大小。

让我们回顾一下numpy.arange函数。这与 Python 的range函数类似。但是,arange返回一个array对象而不是list。以下是用numpy.arange创建数组的另一种方法:

>>> a = np.arange(10) 
>>> a 
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 

NumPy 中创建数组的方法有很多。有关更多详细信息,请参阅文档([docs.scipy.org/doc/numpy/reference/](http://docs.scipy.org/doc/numpy/reference/))。特别是,查找数组创建例程。

简单数组操作

我们将回顾一些可以在 NumPy 数组上执行的基本数学操作。让我们创建两个数组,xy(这些是一维数组或向量):

>>> import numpy as np 
>>> x = np.array([2, 4]) 
>>> y = np.array([2, 4]) 

使用这些数组,您可以执行数学运算,如加法、减法、乘法等。NumPy 按元素逐个执行所有这些操作:

>>> x - y 
array([0, 0]) 
>>> x + y 
array([4, 8]) 
>>> x*y 
array([ 4, 16]) 

在这里需要注意的是,x*y不是内积。它只是xy数组中相应元素的乘法。这些向量的内积可以通过dot函数实现,如下所示:

>>> x.dot(y) 
20 

以下代码使用二维数组说明了这个概念。在这里,x2.dot(y2)是一个矩阵乘法操作:

>>> x2 = np.array([[2, 4], [6, 8]]) 
>>> y2 = np.array([[2, 4], [1, 2]]) 
>>> x2*y2 
array([[ 4, 16], 
 [ 6, 16]]) 
>>> x2.dot(y2) 
array([[ 8, 16], 
 [20, 40]])

数组切片和索引

对于一维数组,索引切片操作与 Python list类似。如果您不熟悉list切片操作,请参阅docs.python.org/3/tutorial/introduction.html#lists。这是一个重要的概念。在本章中,我们只需要执行几个基本的索引操作。

索引

数组索引本质上是一种操作,使我们能够访问数组中的特定元素。这里有一个简单的一维数组,大小为五个:

>>> b = np.arange(5) 
>>> b 
array([0, 1, 2, 3, 4]) 

下面展示了最简单的索引操作,它访问了这个数组的一个元素。这个操作与 Python list中的操作类似:

>>> b[2] 
2 

以下是您如何从二维数组中检索元素的方法:

>>> p = np.array([[2,2], [4,4]]) 
>>> p 
array([[2, 2], 
 [4, 4]]) 
>>> p[0] 
array([2, 2]) 

完成后,它返回一个只包含第一行的数组。

小贴士

重要的是要注意,基本的数组索引不会返回原始数组的副本。它只是指向与原始数组相同的内存位置。请参阅以下链接,其中详细记录了基本和高级索引:[docs.scipy.org/doc/numpy/reference/arrays.indexing.html](http://docs.scipy.org/doc/numpy/reference/arrays.indexing.html)

以下代码将从二维数组中检索一个值:

>>> p[0][1] 
2

在对数组索引的基本介绍之后,让我们学习一些常见的切片操作。

切片

假设你想要获取只包含前两个元素的数组。就像一个list一样,你需要指定一个起始点和结束点。例如,b[start:stop]意味着结果(切片)数组将从start索引开始,并在stop-1索引结束:

>>> b[0:2] 
array([0, 1]) 

同样,要获取只包含位置12的元素的任何数组,你可以这样做:

>>> b[1:3] 
array([1, 2]) 

对于 N 维数组,你必须在每个方向上给出切片指令。考虑以下具有四行四列的数组:

>>> z2 = np.array([[2, 4, 6, 8], [1, 5, 7, 9], [3, 3, 3, 3], [4, 4, 9, 4]]) 
>>> z2 
array([[2, 4, 6, 8], 
 [1, 5, 7, 9], 
 [3, 3, 3, 3], 
 [4, 4, 9, 4]]) 
>>> z2.shape 
(4, 4) 

让我们切片这个数组,使其只返回第一行。这里是实现这一点的语法:

>>> z2[0:1, :] 
array([[2, 4, 6, 8]]) 

如果你只想获取z2的第一个列,那么可以这样指定切片:

>>> z2[:, 0:1] 
array([[2], 
 [1], 
 [3], 
 [4]]) 

以下切片操作将使用前两行和列的元素创建一个新的数组:

>>> z2[0:2, 0:2] 
array([[2, 4], 
 [1, 5]])

为了更好地理解数组切片操作,请在 Python 解释器中尝试更多示例。有关详细信息,请参阅文档(在网络上搜索 NumPy 数组切片)。

广播

广播是 NumPy 的另一个重要特性。让我们通过一个简单的例子来理解这个概念。我们有两个数组,p0p1,如下例所示:

>>> p0 = np.array([10]) 
>>> p1 = np.array([[1, 2], [3,4]]) 

这些数组的形状如下:

>>> p0.shape 
(1,) 
>>> p1.shape 
(2,2) 

尽管数组具有不同的形状,NumPy 可以在这些数组上执行算术运算。下面展示了一个基本的乘法操作:

>>> p0*p1 
array([[10, 20], 
 [30, 40]]) 

这被称为广播。p0数组相对于p1具有较小的形状。广播使得这个数组可以与p1一起工作。在这个例子中,它使得乘法操作成为可能。当然,这两个数组需要满足某些要求才能利用这个特性。请参考 NumPy 文档以了解更多关于广播的信息。

其他函数

让我们看看你可以使用 NumPy 数组执行的一些高级数学运算。

提示

这里展示的大部分操作将在后续关于使用 NumPy 进行性能改进的讨论中使用。所以,请密切关注这一部分。

numpy.ndarray.tolist

这是一个实用的函数,它将 NumPy 数组作为 Python list对象返回。根据数组维度,它可以是嵌套列表。以下是一个展示此函数如何工作的示例:

>>> x = np.array([2, 4]) 
>>> x_list = x.tolist() 
>>> x_list 
[2, 4]

numpy.reshape

如其名所示,它在不实际更改数据的情况下改变数组的形状。看看以下代码;x数组是一维的,大小(形状)为9

>>> x = np.arange(9) 
>>> x 
array([0, 1, 2, 3, 4, 5, 6, 7, 8]) 
>>> x.shape 
(9,) 

让我们看看如何将其重塑为具有三行三列的矩阵。换句话说,以下代码返回一个形状为(3,3)的新数组:

>>> np.reshape(x, (3,3)) 
array([[0, 1, 2], 
 [3, 4, 5], 
 [6, 7, 8]]) 

选择的新形状应该与数组的原始形状兼容;否则,它将抛出一个错误。对于前面的例子,如果你将其重塑为np.reshape(x, (3,2)),它将抛出一个值错误,抱怨大小已更改。

numpy.random

此模块提供了几个随机抽样的函数。有关详细列表,请参阅 docs.scipy.org/doc/numpy/reference/routines.random.html

让我们回顾一下 np.random.uniform,它从均匀分布中抽取样本:

>>> np.random.uniform(0.0, 2.0, size=3) 
array([ 0.24061728,  0.66123504,  1.86137435]) 
>>> np.random.uniform(0.0, 2.0, size=4) 
array([ 1.81382452,  1.20355728,  1.07085075,  0.9653697 ]) 

该函数的前两个参数代表输出区间的下限(0.0)和上限(2.0)。你可以指定任何浮点值作为限制。函数生成的所有随机值或样本都位于这两个限制之间。默认的下限和上限分别是 0.01.0size 参数代表输出数组的形状。在先前的例子中,它指定为一个单个整数值。如果你不指定 size 参数,它默认为 None。在这种情况下,函数将简单地返回一个单一的浮点数。以下是一个 size(或形状)参数为元组 (2,2) 的稍微复杂一点的例子:

>>> np.random.uniform(0.0, 2.0, size=(2,2)) 
array([[  1.02970767e+00,   4.48798719e-02], 
 [  5.20609066e-04,   6.10167655e-01]]) 

你是否已经注意到 Python 内置的 random.uniform 函数和 NumPy 等效的 np.random.uniform 函数之间的区别?NumPy 的 np.random.uniform 函数可以选择性地给我们一个包含从均匀分布中抽取的样本的 array 对象,而内置的 random.uniform 只能给我们一个单一的数字。我们将在 优化过程四 中使用这个 NumPy 函数。

numpy.dstack

这提供了一种简单的方法来沿第三个轴堆叠或连接一系列数组。考虑两个 NumPy 数组,xy,它们代表空间中一些点的 x 和 y 坐标。这些数组如下所示:

>>> x = np.array((1, 2, 3, 4)) 
>>> y = np.array((10, 20, 30, 40)) 

因此,x[0]=1y[0]=10 代表一个点 (1, 10)。同样,我们可以为剩余的元素表示其他点。有时,使用一个单一的数组来表示这些点的坐标是很方便的,如下所示:

points = [ [1,10], [2,20], [3, 30], [4, 40]] 

我们如何使用前面显示的 xy 数组创建这样的数组?有多种方法可以做到这一点。一个选项是使用 numpy.dstack。此函数允许沿第三个轴堆叠数组以创建单个数组。以下代码显示了如何使用输入的 xy 数组创建前面讨论的 points 数组:

>>> points = np.dstack((x,y)) 
>>> points 
array([[[ 1, 10], 
 [ 2, 20], 
 [ 3, 30], 
 [ 4, 40]]]) 

注意,结果数组是三维的:

>>> points.ndim 
3 

沿每个轴(或维度)的数组大小由其形状给出:

>>> points.shape 
(1, 4, 2) 

我们将在 优化过程五 中使用此函数。同样,还有其他堆叠数组的方法,例如 numpy.hstacknumpy.vstack。这些在本章中未讨论。有关更多详细信息,请参阅 NumPy 文档。

numpy.einsum

此函数提供了一种在输入数组上计算 爱因斯坦符号(或 爱因斯坦求和约定)的方法,用于操作(称为 操作数)。在性能方面,此函数提供了极大的效率。在章节的后面,我们将利用它来找到两点之间的距离的平方。

注意

理解 einsum 背后的数学概念可能有点挑战性,尤其是如果你没有数学背景的话。在这种情况下,只需记住关于 numpy.einsum 的一个关键点——它是一个允许你执行一些涉及数组的非常高效操作的函数。例如,两个 NumPy 数组之间的矩阵乘法操作或点积可以使用 numpy.einsum 更高效地完成。

请参阅 NumPy 文档以获取有关此函数的更多信息。有关爱因斯坦符号的信息,请参阅 en.wikipedia.org/wiki/Einstein_notation

这可以通过一个例子来更好地解释。考虑以下表示两个向量 AB 的方程:

numpy.einsum

这些是空间中的两个点,具有一些 xyz 坐标。这些向量的点积表示如下:

numpy.einsum

小贴士

要了解更多关于点积的信息,请参阅 en.wikipedia.org/wiki/Dot_product

它是一个标量积,可以表示为以下方程中的求和,如下所示:

numpy.einsum

前述方程的爱因斯坦求和约定如下所示:

numpy.einsum

在这里,隐含的是 AiBi 是对 i 的求和,下限为 1,上限为 3。这就是爱因斯坦求和约定的精髓。

numpy.einsum 对给定的输入数组评估爱因斯坦求和约定。以下显示了基本语法——还有其他可选参数,但在此处未显示:

numpy.einsum(subscripts, *operands)

第一个参数,subscripts,是一个表示一系列下标标签的字符串。这些标签通过逗号分隔,每个标签代表特定操作数的维度。在我们刚才看到的例子中,只有一个下标标签,i。第二个参数,operands,代表输入数组(例如,例子中的 AB)。

假设 AB 向量是一维的。它们的内积可以用下标字符串 'i,i' 来表示。以下是一个更好的例子来解释这一点:

>>> import numpy as np 
>>> a = np.array([2, 2]) 
>>> b = np.array([4, 4]) 
>>> np.einsum('i,i', a, b) 
16 

数组 ab 是一维的。您也可以使用 numpy.inner 函数来交叉检查答案,该函数返回相同的答案:

>>> np.inner(a,b) 
16 

numpy.einsum 函数更快,而且内存效率更高。现在,看看以下代码——它表示两个向量 a2b2 的点积(或矩阵乘法):

>>> a2 = np.array([[1,1], [2, 2]]) 
>>> b2 = np.array([[4,4], [6, 6]]) 
>>> np.einsum('ij,jk', a2, b2) 
array([[10, 10], 
 [20, 20]]) 

numpy.einsum 的下标字符串为 'ij,jk',其中 ij 是数组 a2 的两个维度的下标,而 jk 是数组 b2 的下标。点积也可以通过以下示例获得:

>>> np.dot(a2, b2) 
array([[10, 10], 
 [20, 20]])

使用 einsum 计算距离平方

到目前为止展示的示例应该只是给你一个关于einsum函数的味觉。让我们只讨论如何使用这个函数来计算两点之间的距离的平方。再次提醒,为了全面的参考,请参阅 NumPy 文档。

考虑任意一个坐标为(0, 2)的点p1。此外,假设中心位于(0, 0)。由于p1点的 x 坐标是0,你可以很容易地确定p1和中心之间的距离是 2 个单位。距离的平方可以使用einsum函数找到,如下所示:

>>> p1 = np.array([0,2]) 
>>> center = np.array([0, 0]) 
>>> d = p1 - center 
>>> d 
array([0, 2]) 
>>> np.einsum('i,i', d, d) 
4 

现在,假设有多个这样的点,并且你想找到每个点与中心的距离的平方。这是使用einsum计算的一种方法:

>>> points = np.array([[0,2], [0,4], [2, 2], [4, 4]]) 
>>> center = np.array([0,0]) 

points数组代表一系列点。对于这些点中的每一个,我们将找到一个向量,以center作为起点,以points数组中的给定点作为终点。让我们将这样的向量数组表示为diff,如下面的示例所示:

>>> diffs = points - center 
>>> diffs.shape 
(4, 2) 
>>> diffs 
array([[0, 2], 
 [0, 4], 
 [2, 2], 
 [4, 4]]) 

由于中心是(0,0),diff数组本质上与points数组相同。以下代码行显示了einsum语法——它使用省略号符号()在子脚本的每个项的左侧:

>>> np.einsum('...i,...i', diffs, diffs) 
array([ 4, 16,  8, 32]) 

它返回一个包含points数组中每个点距离平方的数组。这就是我们需要的全部!

这个省略号符号表示什么?为什么我们没有使用早期的语法?

>>>  np.einsum('i,i', d, d) 

早期的语法涉及单维数组(d),它只有一个下标标签。在这里我们不能使用它作为操作数(或diffs数组),因为爱因斯坦求和的操作数是一个二维数组。为了理解这一点,让我们再次看看diffs数组:

>>> diffs 
array([[0, 2], 
 [0, 4], 
 [2, 2], 
 [4, 4]]) 

考虑这个数组的任意一行。它本质上是一个点和中心之间的向量。例如,[0, 2]代表一个中心[0,0]和一个点[0,2]之间的向量。数组的另一个维度是用来存放许多这样的向量。省略号符号""是一个方便地广播第二个维度的方式。得到相同结果的另一种语法如下:

>>> np.einsum('ij,ij->i', diffs, diffs) 
array([ 4, 16,  8, 32]) 

然而,如果数组形状进一步改变,你将需要再次工作于为einsum函数构建一个合适的下标字符串。NumPy 文档中有几个示例展示了如何使用einsum。以下是一个 NumPy 1.10 版本的文档:docs.scipy.org/doc/numpy-1.10.0/reference/generated/numpy.einsum.html

哪里可以找到更多关于 NumPy 的信息?

在 NumPy 的介绍中,你被提供了几个指向文档的链接。为了完整性,让我们总结一下在哪里可以找到更多关于 NumPy 的信息。你可以从访问他们的网站(www.numpy.org/)或在网上搜索 NumPy 以到达其主页开始。

SciPy 是另一个值得提及的项目。它是一个集成了数学、科学和工程学科多个开源工具的库。NumPy、matplotlib 和 pandas 是其核心包之一。更多信息请参阅项目网站(www.scipy.org/)。

在之前的讨论中,提供了一些指向 NumPy 文档的链接。查看这些链接,你一定已经注意到它们都指向 SciPy 网站。NumPy 和 SciPy 的文档都位于docs.scipy.org/doc/

用于 Python 数据分析的开源 pandas 库。它提供了高性能的数据结构和工具来分析数据。更多信息请参阅pandas.pydata.org/

优化黄金狩猎 – 第二部分

前一节简要介绍了 NumPy。回想一下,在早期章节中,我们逐步提高了游戏的运行性能。最后一次记录的时间是使用优化迭代三次获得的时间。我们成功将总运行时间从大约 106 秒减少到近 44 秒。NumPy 支持向量化计算例程,如元素级乘法。它内部使用高效的 C 循环,有助于加快此类操作的运行速度。让我们利用 NumPy 的功能,进一步加快黄金狩猎游戏的运行速度。

黄金狩猎优化 – 第四次迭代

现在是时候继续对黄金狩猎问题进行优化操作了。让我们从优化迭代四次开始。我们将再次关注函数generate_random_numbers。作为一个复习,上次优化运行的cProfiler输出报告了总时间为约 2.6 秒,累计时间(包括子函数花费的时间)为约 5.2 秒**。

黄金狩猎优化 – 第四次迭代

你说得对。对于这个例子,优化这段代码不值得。5.2 秒的时间看起来并不那么糟糕。目前,该函数只被调用一次,正如cProfile输出的ncalls列所示。但任何未来的需求都可能使这个函数成为新的瓶颈。例如,想象一个新游戏场景,其中有成百上千这样的黄金区域或充满废弃武器的场所。我们可能需要多次调用这样的函数。这将增加生成点所需的总时间。考虑到这一点,让我们努力提高其性能。

我们将重新编写上次优化运行中的代码(goldhunt_pass3.py)。支持源代码位于goldhunt_pass4.py文件中。我们将首先在文件开头添加 NumPy 的import语句:

import numpy as np

以下代码片段展示了重新工作的generate_random_points函数:

黄金狩猎优化 – 第四次迭代

使用局部变量,如 l_uniform,是可选的。这些变量在这里用于跳过函数重新评估。这已经在上一章的 跳过点 部分中讨论过了。让我们接下来回顾这个函数:

  • 将新函数与之前的实现进行比较。这里需要注意的关键点是使用 NumPy 函数,例如 np.random.uniformnp.sqrt 等,来替代内置函数。

  • 另一个主要区别是我们不再需要 for 循环。np.random.uniform 函数返回一个 NumPy 数组。最后一个参数指定其大小。有关 random.uniform 功能的更多信息,请参阅之前关于 NumPy 的介绍部分。

  • 使用 radiustheta 数组计算 xy 坐标。请注意,变量 xy 是作为 NumPy 数组创建的。出于效率考虑,我们将以 Python 列表的形式返回这些坐标。这是通过使用 numpy.ndarray.tolist() 方法实现的,该方法对 NumPy array 对象可用。

让我们来分析这段代码,并将其性能与之前的优化过程进行比较。以下是执行此代码的命令:

$ python goldhunt_pass4.py

分析器的输出如下所示:

黄金狩猎优化 – 第四次迭代

观察到 generate_random_points 函数的累积时间列。原始函数的累积时间约为 5.2 秒,现在已减少到 0.346 秒。这已经是一个显著的改进。

小贴士

有可能进一步改进 generate_random_points 函数的性能。例如,在函数的开始处,你可以计算乘积 2*l_pi,例如:

two_pi = 2*np.pi 

然后在计算 theta 时使用这个变量。然而,这只会对运行时间产生微小的改进。

黄金狩猎优化 – 第五次迭代

在这次优化过程中,我们将进一步改进 GoldHunt.find_coins 方法的运行时性能。为了方便起见,原始方法在以下代码片段中显示。你还可以在之前的 goldhunt_pass4.py 文件中找到它。有关更多详细信息,请参阅上一章的 黄金狩猎优化 – 第二次迭代 部分。

黄金狩猎优化 – 第五次迭代

回想一下,此方法最后记录的运行时间约为 38 秒。我们的任务是进一步改进它。我们将通过修改 generate_random_points 函数来开始优化工作。回想一下,这个函数返回场地上 金币xy 坐标作为 Python 列表。相反,让我们将这些作为 NumPy 数组返回。

小贴士

如果你跳过了之前关于 NumPy 的介绍部分,现在应该是返回去阅读它的时候了!优化第五次迭代 使用了该部分讨论的 NumPy 函数。更具体地说,下面的代码片段使用了 einsumdpstack 函数。你可能觉得 einsum 语法很复杂。因此,建议你在深入研究代码之前先阅读介绍。

find_coins 方法中,我们将使用与这些 NumPy 数组高效工作的 NumPy 函数。下面的代码片段显示了更新的函数:

黄金狩猎优化 – 第五次迭代

通过这个修改,让我们快速回顾一下重新工作的 find_coins 方法:

黄金狩猎优化 – 第五次迭代

让我们回顾一下前面的代码片段:

  • 回想一下,我们的任务是找到场地上任何金币与搜索圆心的距离平方,然后使用这个值来检查金币是否位于搜索圆内。

  • 输入参数 x_listy_list 是表示金币在场地上 x 和 y 位置的 NumPy 数组。

  • 使用这些坐标,我们将创建一个包含 (x, y) 坐标对的 points 数组,作为其元素。这是通过使用 numpy.dstack 实现的。请参阅之前关于 NumPy 的介绍部分以获取示例用法。

  • 接下来,我们将找到 points 数组中每个点与搜索圆的 center 数组之间的向量。这些向量作为 diff 数组的元素存储。

  • 使用这个 diff 数组,我们将使用 einsum 计算所有金币与中心的距离平方。请参阅前面的,使用 einsum 计算距离平方 部分,其中对此进行了详细讨论。

  • 最后,我们将通过比较距离平方来检查金币是否位于圆内。enumerate() 函数是一个内置函数,它以更简洁的方式提供获取循环当前索引(i)和对应值(d)的方法。

代码已经准备好了。现在,是时候分析它了:

$ python goldhunt_pass5.py

以下是分析器的输出:

黄金狩猎优化 – 第五次迭代

观察到 find_coins 函数的累积时间已经从之前的 ~38 秒下降到 ~19.5 秒。仅对于这个函数来说,这几乎是 50% 的改进。此外,总运行时间现在是 ~21.5 秒,而之前的计时是 ~38 秒。

小贴士

可以通过使用列表推导而不是 for 循环来提高 find_coins 的性能。然而,这种改进将是微不足道的。你可以将其作为练习尝试(不提供解决方案)。以下是一个使用列表推导的示例代码:

collected_coins = [(x_list[i], y_list[i]) 
                    for i, d in enumerate(dist_sq_list) 
                    if d <= search_radius_square]

使用 multiprocessing 模块进行并行化

在讨论multiprocessing模块之前,让我们首先了解我们所说的并行化是什么意思。这将是一个非常简短的并行化介绍,仅足够理解如何使用multiprocessing模块的一些功能。

并行化简介

想象你站在一家杂货店收银台的长队中,等待你的轮次。现在,又开了三个收银台来服务顾客,现有的队列被分割。结果,你可以快速付款并离开商店。

在某种意义上,并行化实现了类似的结果。在这个例子中,每个计数器可以想象成一个独立的过程,执行独立的接受支付任务。顾客的初始队列可以想象成你的程序。然后,这个长队列被分成独立的队列(或任务),在各自的计数器(进程)上并行处理。

我们迄今为止编写的黄金狩猎程序是顺序执行的。程序在一个单一处理器上依次执行一系列任务。这类似于之前提到的杂货店例子中的单个计数器。很多时候,可以将程序分成更小的任务,并使用多个进程或线程独立运行。

让我们快速回顾两种处理并行进程通信的广泛编程模型。这些是共享内存分布式内存并行化。

共享内存并行化

在共享内存编程模型中,并行进程访问相同的内存段。因此,数据交换和进程间的通信通过这个公共内存进行。这种编程模型通常被称为线程编程。共享内存模型的缺点是所谓的竞态条件。在这里,多个线程竞争访问或修改,例如,内存位置的数据。可以通过使用来控制对关键信息的访问来避免竞态条件。然而,这会增加编程开销。有关更多信息,请参阅en.wikipedia.org/wiki/Shared_memory

分布式内存并行化

在这里,每个进程都有自己的内存空间。进程之间不共享任何内存资源,并且它们独立运行。进程间的通信通过进程间通信通道进行。这被称为消息传递。要了解更多关于消息传递的信息,请参阅en.wikipedia.org/wiki/Message_passing。由于进程不共享相同的内存空间,与分布式内存机制相关的通信开销也额外增加。

全局解释器锁

在 Python 中,threading模块提供了一个基于线程的并行化的高级接口。为了避免前面讨论的竞争条件,Python 采用了一种称为全局解释器锁GIL)的机制。当一个线程正在执行代码块时,它会获取一个全局锁。这个锁确保在 Python 解释器环境中一次只有一个线程被执行。GIL 的缺点是,你无法充分利用多核处理器。

多进程模块

multiprocessing模块解决了 GIL 问题,并为并行化 Python 程序提供了一种简单的方法。它不是使用线程,而是使用子进程,并避免了 GIL。在这个模块中,进程间数据交换是通过两个通信通道实现的,一个Queue类和一个Pipe函数。此模块还提供了其他一些有用的功能,如管理器代理对象Manager对象是通过multiprocessing.Manager()创建的。它控制一个服务器进程,该进程管理 Python 对象。管理器还允许其他进程通过代理来操作这些 Python 对象。讨论这些功能超出了本书的范围。Python 文档中有关于这些功能如何工作的优秀示例。有关更多信息,请参阅docs.python.org/3/library/multiprocessing.html

在本章中,我们将仅介绍Pool类的几个功能。

Pool

multiprocessing.Pool类提供了一种简单的方法来并行化程序。它用于管理一组工作进程,并定义了允许以各种方式并行运行给定任务的方法。

提示

另一种基本的方法是使用Process类,本书没有讨论这个类。有关详细信息,请参阅前面的文档链接。

Pool.mapPool.apply方法是经常使用的方法之一。这些是 Python 内置的mapapply函数的并行等效方法。这两种方法都会阻塞主程序,直到工作进程完成并且结果准备好。如果你对从并行进程中获取顺序输出感兴趣,这种阻塞特性是有用的。它们也有它们的异步变体,即map_asyncapply_async。异步变体更适合运行你不在乎结果返回顺序的并行作业。

提示

apply函数在 Python 3 中不再是内置函数。然而,它在 Python 2.7 中得到了支持。你可以参考 Python 2 文档来了解这个函数的功能。

让我们通过一个简单的例子来展示如何使用Pool类及其方法mapapply。观察以下代码:

The Pool class

让我们回顾一下前面的代码片段:

  • 我们首先导入multiprocessing模块。

  • 使用两个工作进程创建pool实例。您可以指定工作进程的数量作为可选输入参数。

  • 在创建工作进程池之后,调用pool.map方法。如前所述,这是内置map函数的并行等效。第一个参数是一个简单的函数get_result。此函数应用于作为第二个参数指定的iterable

  • 在这种情况下,get_result函数应用于numbers列表的每个元素。在这个函数内部,我们还打印出执行任务的当前工作进程的名称。

  • pool.close()方法在执行后停止工作进程,而pool.join()方法阻塞,直到工作进程终止。这模仿了threading模块提供的 API。

前面的代码也可以在pool_example.py文件中找到。在这个文件中,您只需要启用相关代码并禁用其他函数调用。该文件可以从命令提示符运行,如下所示:

$ python  pool_example.py 

这是执行后的一个示例命令行输出:

Current process: ForkPoolWorker-1 , Input Number: 2 
Current process: ForkPoolWorker-2 , Input Number: 4 
Current process: ForkPoolWorker-2 , Input Number: 6 
Current process: ForkPoolWorker-1 , Input Number: 8 
Output: [20, 40, 60, 80] 

注意,输出列表(mylist)的元素排列顺序与输入列表(numbers)相同。换句话说,我们有输入[2, 4, 6, 8],输出是每个元素的 10 倍,给出[20, 40, 60, 80]。对于异步变体,情况可能如此也可能不如此。这取决于进程完成并返回结果的顺序。

只需一行代码的更改,我们就可以使用Pool.apply运行相同的示例。下面的代码片段显示了如何做到这一点。get_result函数没有显示,因为它与之前相同,如下所示:

Pool 类

在这里,我们使用列表推导式创建了mylist。对于numbers列表中的每个元素,它调用Pool.apply方法。该方法的第一参数是函数的名称,而第二个参数args用于指定此函数的其他参数。此方法提供了方便的语法来指定要发送给工作进程的函数的任意数量的参数。其余的代码和编程输出保持不变,如Pool.map方法示例中所示。让我们回顾一下异步变体之一,Pool.apply_async。代码如下:

Pool 类

让我们分析一下这段代码:

  • 这涉及两个更改。第一个是微不足道的。将apply方法简单地替换为apply_async(如高亮显示所示)。方法语法没有变化。

  • 然而,apply_async调用的输出并不直接给出我们需要的最终值。相反,它返回Pool.ApplyResult类的对象。

  • 在这个例子中,apply_async被用在列表推导式中。因此,results列表的元素是ApplyResult类的对象。

  • 最终值可以通过使用ApplyResult.get()方法获得。我们使用列表推导,如图中所示。或者,你也可以使用前一章中讨论的生成器表达式语法。

在对并行化进行简短介绍之后,让我们看看如何将黄金狩猎应用程序的一些功能并行化。

并行化黄金狩猎程序

从之前的分析器输出中可以看出,find_coins函数仍然是主要的瓶颈,累计耗时约 19.5 秒。让我们看看并行化如何帮助进一步加快速度。

重新审视金矿场

这里是第七章中性能 – 识别瓶颈金矿场图像:

重新审视金矿场

让我们快速总结一下我们在第七章中已经看到的内容,性能 – 识别瓶颈

  • find_coins方法被调用以显示图中的每个小搜索圆。因此,如果有 10 个搜索圆,find_coins将被连续调用 10 次。

  • find_coins方法返回给定搜索圆内金币的坐标。

  • 所有这些收集到的金币信息都保存在一个列表对象中。

这里有一个需要注意的重要事项。这是一个串行执行。你从第一个圆开始,收集金币,然后移动到下一个,重复此过程,直到到达田野的另一端。

那么我们如何进一步优化搜索操作呢?有什么想法吗,伟大的矮人先生?

重新审视金矿场

太棒了!**由于每个圆内的搜索操作与其他操作是独立的,因此find_coins函数可以独立于每个搜索圆执行。这是一个并行化的理想候选者。

重新审视金矿场

这甚至更好!**由于结果返回的顺序(由工作进程返回)并不重要,我们可以使用 Pool.apply_async 来并行化这个任务。

黄金狩猎优化 – 第六次流程,并行化

作为第一步,你应该浏览一下上一次优化流程五play方法。我们即将进行的多数更改都将在这个方法中进行。此外,我们还将向find_coins方法传递一些额外的参数。

因此,我们决定使用一个由Pool对象表示的工作进程池。这个Pool对象的工作队列由之前显示的金矿内的所有搜索圆组成。每个工作进程将并行运行搜索操作(find_coins),并且它不依赖于其他搜索圆。通常,Pool对象内的工作进程在处理完完整的工作队列之前不会终止。当一个工作进程完成特定搜索圆中硬币的查找后,它可能会被分配去为另一个搜索圆执行此操作。

那么需要对play方法进行哪些更改?代码将与之前看到的apply_async基本示例非常相似。在现有方法中还需要更改其他内容吗?我们的朋友精灵有一个问题...

金矿优化 – 第六步,并行化

你说得对!现有的 play 方法串行运行搜索操作。它从最左边的圆开始,找到硬币,然后通过更新 x_ref 移动到下一个圆。 注意,在这个例子中,我们选择了 y_ref 0.0 。**当我们并行运行这个搜索操作时,每个圆都将有其独特的中心坐标。我们需要为每个并行过程提供这些坐标的适当值。为此,让我们移除对 x_ref y_ref 的依赖。在并行化搜索操作之前,将确定并存储所有圆的中心坐标。

带有前面更改的play方法如下所示:

金矿优化 – 第六步,并行化

让我们讨论一下这个方法中的重要变化:

  • 在一个while循环中,我们首先确定所有搜索圆的中心,并将坐标存储在一个名为x_centers的列表中。y 坐标(y_ref)没有更新,因为我们已经选择将其作为所有圆的常量(0.0)。

  • 在相同的while循环中,另一个circle_number列表被填充以表示圆的 ID。这只是为了打印目的,这样我们就会知道正在执行哪个搜索操作。

  • 准备好列表后,创建了一个工作线程池,然后在列表推导式中调用apply_async

  • 回想一下,Pool.apply_async方法的第一个参数是函数的名称(self.find_coins),而第二个参数args用于指定此函数的所有参数。

  • 代码的其余部分与我们在multiprocessing模块介绍中看到的内容类似。apply_async调用返回一个包含ApplyResult类对象的列表。然后,使用这个类的方法get()来获取最终值。

小贴士

如果您使用的是 Python 2.7.9,您可能需要创建并使用一个全局函数作为apply_async的第一个参数。然后这个全局函数可以返回GoldHunt.find_coins方法。这是在测试代码时发现PicklingError异常的解决方案。对于 Python 3.x,没有这个问题。此代码包含在补充代码包中。有关goldhunt_pass6_parallel.py文件的 Python 2 等价物的详细信息,请参阅。

最后,对GoldHunt.find_coins方法进行了一些修改。现在它接受process_x_refcircle_number函数作为两个新的参数。process_x_ref函数表示给定搜索圆的 x 坐标。添加process_前缀是为了区分它和self.x_ref,并表明其值对于每个工作进程将是不同的。

使用apply_async,我们将在这个单独的并行进程中运行这个方法。每个进程都有自己的圆心坐标和要提供给find_coins方法的数字。该方法在下面的代码片段中显示。高亮显示的代码表示与之前的优化迭代相比的变化。

黄金狩猎优化 – 第六次迭代,并行化

代码的其余部分与之前的优化迭代保持相同。源代码在goldhunt_pass6_parallel.py文件中提供。让我们运行这段代码并查看分析器的输出:

$ python goldhunt_pass6_parallel.py

这将打印出与之前相同的信息,以下是分析器的输出:

黄金狩猎优化 – 第六次迭代,并行化

注意,find_coins调用在分析器输出中没有显示。它隐藏在play方法报告的时间中。比较play方法的累积时间(cumtime)应该可以给出并行化带来的性能提升的合理估计。

总结来说,并行化帮助将总时间从之前的约 21.5 秒减少到约 13.5 秒。

小贴士

根据您的机器配置,您可以尝试通过更新Pool类的参数来增加工作进程的数量。例如,您可以用四个进程而不是三个进程来运行程序。然而,这是一个简单的情况,运行时间非常短,您几乎看不到任何进一步的改进。实际上,子进程的开销甚至可能导致性能略有下降。此外,根据问题,超过一定数量的进程后,由于并行化带来的性能提升可能会逐渐消失。

其他并行化方法

apply_async方法是并行化这个问题的唯一方法吗?当然不是。multiprocessing模块中有其他方法可以有效地完成这项工作。Pool.starmap_async是 Python 3.3 及以上版本中可用的一种方法。我们不会在这里讨论它,但以下代码展示了如何使用itertools.repeat函数调用它:

results = pool.starmap_async(self.find_coins, 
                               zip(itertools.repeat(x_list), 
                                   itertools.repeat(y_list), 
                                   x_centers, 
                                   circle_numbers))

要了解更多关于此类方法的信息,请参阅 multiprocessing 模块文档。

进一步阅读

在关于性能的三个章节系列中,我们涵盖了几个重要的方面。在这里学到的知识将帮助你完成大多数常见的应用性能提升任务。接下来我们该做什么呢?还有一些其他重要的主题你可以探索,其中包括即时编译器(JIT compilers)和图形处理单元GPU)编程。本节旨在提供这两个主题的一些基本信息。你可以通过这里提供的链接进一步了解。

JIT 编译器

Python 是一种解释语言。简单来说,这意味着代码是直接解析和执行的,而不涉及任何代码编译。虽然这提供了很大的灵活性,但程序通常运行得较慢。

在像 C++ 这样的高级编程语言中,代码是在编译之前或执行之前编译的。一般来说,编译程序(C++)比等效的解释程序(Python)运行得更快。

因此,我们在一边有一个提供灵活性的解释代码,在另一边有一个运行速度更快的编译代码。即时编译器取两者之长。它编译代码,但不是在执行之前编译,而是在程序执行期间即时编译。

PyPy 是这样一个项目,它提供了一个带有 JIT 编译器的 Python 语言的替代实现。Python 程序在 PyPy 上通常运行得更快。它还内存高效,并与现有的 Python 代码具有高度的兼容性。要了解更多关于 PyPy 的信息,请查看 pypy.org

Numba 是另一个旨在加速应用的计划。它提供了一个即时编译器和一个非常简单的语法来标记一个函数,以便使用即时编译器进行优化。你只需要使用 numba.git() 装饰器。换句话说,在函数名上方添加 @jit 来标记该函数进行优化。如果你正在使用第一章中讨论的 Anaconda Python 发行版,开发简单应用,它默认已经提供了 numba 模块。要了解更多信息,请访问项目主页 (numba.pydata.org)。

GPU 加速计算

GPU 一直被用于涉及大量渲染的应用程序,如游戏应用程序。现在它被广泛用于涉及科学模拟、神经网络、金融建模等应用程序。GPU 的海量并行架构在性能上比基于 CPU 的并行化有巨大的提升(达到 100 倍或更多)。一个典型的策略是确定应用程序中最计算密集的部分,然后将它发送到 GPU。其余的代码可以继续使用 CPU。然而,这并不像听起来那么简单,尤其是如果你正在处理遗留代码。在这种情况下,挑战可能在于使其与 GPU 加速完全兼容。

PyCUDA (pypi.python.org/pypi/pycuda) 是一个流行的 Python 包,它提供了一个包装器来访问 Nvidia 的 CUDA 并行 API。CUDA 是由 NVIDIA 开发的一个并行计算平台。更多信息可以在 www.nvidia.com/object/cuda_home_new.html 找到。

PyOpenCL (pypi.python.org/pypi/pyopencl) 是另一个 Python 包。它提供了一个简单的接口来访问 Open Computing Language (OpenCL) API。OpenCL 是一个并行计算的框架。有关更多信息,请参阅 en.wikipedia.org/wiki/OpenCL

摘要

在本章中,我们结束了关于性能提升的一系列章节。让我们首先总结一下本章学到的内容。我们从 NumPy 库的基本介绍开始,看到了如何利用它来进一步加速 Gold Hunt 应用程序。特别是,我们使用了数组 (numpy.ndarray) 数据结构以及其他功能,如 numpy.random.uniformnumpy.einsum 来实现加速。最后的优化步骤涉及并行化代码。本章简要介绍了并行处理的基础知识。我们使用了 Python 的 multiprocessing.Pool 类的功能来进一步缩短应用程序的运行时间。

最后,让我们将三个性能章节的内容一起总结一下。我们首先对代码进行性能分析以确定性能瓶颈,并了解了大 O 表示法。我们逐步解决这些瓶颈以提高应用程序的性能。这是通过多种方式实现的,包括改变算法、实现高效的数据结构以及使用 Python 标准库的功能。我们还通过使用 NumPy 和并行化代码进一步提高了运行时间。

小贴士

分析器报告的时间可能会有很大的差异。它取决于你的机器规格,也取决于当前运行的任务。因此,你观察到的时间可能与本书中报告的数字不同。

对于这些章节中讨论的黄金狩猎示例,总运行时间几乎减少了一个数量级,从最初的约 106 秒减少到最终的近 13.5 秒。

到目前为止,在这本书中,你已经学习了使用命令行程序进行应用开发的几个关键方面。在最后一章,我们将看到如何使用 Python 开发简单的图形用户界面(GUI)应用程序。

第十章:简单的 GUI 应用程序

到目前为止的所有章节都是关于学习如何用 Python 编写更好的应用程序代码。从一个简单的程序开始,我们看到了如何开发健壮和高效的应用程序。我们触及了软件开发的重要领域。更具体地说,我们涵盖了异常处理、部署应用程序、文档、采用最佳实践、单元测试、重构、设计模式和性能改进。关键概念是通过各种逐步改进的命令行应用程序进行解释的。

我们接下来要做什么?除了命令行之外,还有提供交互式用户界面的应用程序。桌面、移动 GUI 应用程序或 Web 应用程序都属于这一类别。此外,还有针对特定领域(如网络和数据库编程)的应用程序。这些都是广泛的话题,每个领域都有其独特的优点,有助于使应用程序更加健壮。尽管如此,本书中学到的技术为所有这些领域提供了一个坚实的基础。

这最后一章旨在让您对这样一个领域有一个基本的了解。它将是对使用 Python 进行桌面 GUI 应用程序开发的浅显介绍。

注意

GUI 编程太大,无法在一个章节中涵盖。尽管如此,我们仍然会这样做,同时考虑到有大量的机会可以在这里讨论之外学习。本章不会向您展示如何创建完整的、复杂的 GUI 应用程序。相反,我们只是用 Python 的 Tkinter 库浅尝辄止地涉足 GUI 应用程序开发。

下面是如何组织本章剩余内容的:

  • 本章将从对可用的 GUI 框架的概述开始。

  • 接下来,我们将看到什么是事件驱动编程,然后是 Tkinter 库的入门介绍。

  • 接下来的是我们的第一个项目,一个使用 Tkinter 的简单 GUI 应用程序。它本质上是我们第一章中开发的第一个应用程序的 GUI 版本,即开发简单应用程序

  • 下一节将作为对模型-视图-控制器MVC)架构的介绍。这将随后是我们的第二个项目,其中之前的应用程序被重写以实现 MVC 架构。

  • 本章还将讨论测试 GUI 应用程序。这将是一个高级讨论,不会涉及编写任何代码。

  • 由于这是最后一章,因此我们将通过简要讨论各种应用程序前沿来结束本章,并因此结束本书。

GUI 框架概述

用户界面通常是用户可以看到并用来与应用程序进行通信的东西。到目前为止,我们已经介绍了一种基于文本的用户界面。例如,在奥克之攻应用程序中,用户被提示指定小屋编号,并根据输入的编号采取进一步的操作。

另一方面,图形用户界面GUI)向用户提供了一个可能包含按钮、图标、文本字段、图形等的界面。有几种 Python 图形用户界面框架可供选择。其中许多基于跨平台技术,如 Tk、Qt、wxWidgets 等。让我们简要讨论一些最受欢迎的框架。目的是让您了解可用的 GUI 技术。

Tkinter

Tkinter 提供了 Python 绑定或接口到开源的 Tk 图形用户界面小部件工具包。有关 Tk 的更多信息,请参阅其官方网站,www.tcl.tk/。它作为 Python 的标准模块提供。这意味着只要安装了 Python,我们就不需要任何额外的安装来使用它。在本书中,我们将使用 Tkinter 库演示基本的 GUI 概念。

PyQt

PyQt (wiki.python.org/moin/PyQt) 是一个广泛使用的 Python 图形用户界面库。它是目前最成熟的框架之一。它本质上提供了对流行的 Qt 图形用户界面应用程序开发框架的 Python 绑定。为了使用这个框架,您首先需要安装 Qt。

值得注意的是,Qt 根据项目不同有不同的许可方案。例如,如果您的项目是开源发行版,根据 LGPL 或 GPL 条款许可,您可以自由使用 Qt。如果您将其用于商业目的,您必须购买许可证。有关详细信息,请访问 Qt 网站,www.qt.io/

PySide

PySide 是 Qt 图形用户界面框架的另一个 Python 绑定。它是一个免费软件,在 LGPL 许可下发布。PySide 支持 Windows、Mac 和 Linux 操作系统。有关更多信息,请参阅 wiki.qt.io/PySide

Kivy

这是创建跨平台交互式用户界面的最有前途的开源框架之一。使用 kivy,您可以快速为移动或桌面开发原生多点触控应用程序。它提供了一个名为 Kv 的设计语言用于 GUI 设计。kivy 网站列出了许多支持的操作系统,包括 Windows、Mac OS X、Ubuntu 和 Android,为这些操作系统提供了安装程序。

提示

如果您使用的是不在 kivy 网站上列出的操作系统,安装可能会是一个挑战。例如,在撰写本书时,没有为 Red Hat Enterprise LinuxRHEL)版本 6.x 提供安装程序。另一个选择是从源代码构建它。但如果您不熟悉代码编译和构建过程,这可能是一个挑战。如果您真的想使用它,您也可以在运行支持操作系统的虚拟机中安装它。

wxPython

此软件包为wxWidgets提供了一个包装器,它是一个跨平台的 GUI 库。这是一个开源工具包,根据项目网站(www.wxpython.org),支持的平台包括 32 位 Windows、许多类 Unix 操作系统和 Mac OS X。

虽然我们有多种选择可供选择,但在这个章节中,我们将使用前面提到的内置 Tkinter 模块。范围将限于开发一个演示基于 GUI 应用程序开发主要组件的简单应用程序。

GUI 编程设计考虑因素

虽然本章的重点是开发简单的 GUI 应用程序,但花一点时间简要讨论一些重要的实际设计考虑因素或指南,对于开发用户界面来说是有价值的。这些指南对于基于 Web 或移动应用程序也很有用。我们即将讨论的一些方面实际上应该是您 GUI 应用程序开发生命周期的一部分。

理解用户需求

第一和最重要的任务是站在最终用户的立场上。你正在为最终用户开发 GUI 应用程序。了解他们希望看到的功能的反馈是非常重要的。这通常是需求收集的一部分。

开发用户故事

好的,你知道了所需的功能,并准备了一个将在即将推出的版本中支持的功能列表。准备一个模拟用户界面,说明如何访问各种功能以及它们如何交互,通常是有用的。这个模拟用户界面可以是一个简单的演示。然后你可以从开发团队以及产品的关键用户那里获取反馈。这将使你能够立即识别问题(如果有的话),或者在编写代码之前就完善你的设计策略。与关键利益相关者的此类讨论也可能揭示出你尚未考虑的未来需求。这反过来又可以帮助你完善软件架构,为这些需求做出规定。接下来,让我们了解一些设计原则。

简洁性和易用性

GUI 应该足够简单,以便最常用的任务易于访问。开发者认为的“简单”可能并不总是与最终用户相匹配。获取用户反馈和进行设计迭代起着重要作用。在设计简洁性时,通常需要记住以下几点:

  • 如何在应用程序窗口中布局各种组件很重要。这是直观的吗?是否易于访问?

  • 在用户界面(UI)中突出显示常用和重要的功能。

  • 尽量隐藏高级或不太常用的功能。如果可能的话,你可以在你的图形用户界面(GUI)中创建一个专家模式,让这些功能突出显示。

  • 在适用的情况下设置默认值。

  • 常见用户操作应该容易执行。例如,如果更改背景颜色是一个常见任务,允许用户通过点击按钮或使用键盘快捷键来访问此选项。

  • 尽量不要在默认显示中放入太多东西。减少杂乱。

当然,这不是一个完整的列表,具体内容会根据应用程序和领域的变化而变化。

一致性

用户界面应该是统一的。如果你有类似的功能,它们应该有类似的外观和感觉,类似的执行步骤,等等。标准功能或功能的位置不应改变。例如,在文本编辑器中,打开按钮通常放置在应用程序窗口的右上角。这个默认位置应该保持不变。

可预测性和熟悉性

当按钮被点击时,用户应该能够预测下一步的操作。一个简单的例子是另存为...按钮——当点击时,用户期待一个对话框,可以选择指定位置和文件格式。为什么?因为他或她熟悉在其他应用程序中使用类似的功能。此外,用户会期待一些默认目录位置来保存文件。UI 不应该通过改变这种行为来让用户感到惊讶。

同样,当你设计图标时,它应该能够自我表达。例如,齿轮图标通常表示某种可配置的设置。UI 设计应该是用户可以轻松猜测在特定情况下下一步要执行的操作,无论是退出当前模式还是返回上一步,等等。

其他设计考虑因素

我们已经介绍了一些在设计 GUI 之前你应该知道的重要因素。还有很多其他的设计原则。其中一些原则与我们之前讨论的方面相关。以下是一些可以列举的原则:

  • GUI 应该具有视觉吸引力和清晰度。

  • 它应该是可理解的。换句话说,新用户应该能够快速上手。

  • 它应该预见常见问题,并优雅地处理用户错误。

事件驱动编程

在一个由算法驱动的程序中,程序的流程是由该程序中预定义的步骤决定的。程序可能会提示用户输入这些指令。一个例子是命令行应用程序按照预定义的顺序请求用户输入。

相比之下,具有图形用户界面的应用程序允许用户决定程序流程。应用程序等待用户操作,然后对这些操作做出响应。例如,如果你正在阅读一本书的 PDF 副本,你可以执行诸如跳转到下一页、放大、滚动或通过点击适当的按钮关闭窗口等操作。在这里,你实际上是在告诉应用程序接下来要做什么。这被称为事件驱动编程。在这里,程序的流程控制由触发的事件控制。应用程序在事件发生时对这些事件做出响应。响应可能是改变图形元素的状态或运行一些后台任务等。例如,如果用户点击代表下一页的按钮,应用程序将显示书的下一页。接下来,让我们简要谈谈事件驱动编程中的一些重要概念。

事件

简而言之,事件代表在 GUI 窗口内发生的一个动作。事件可能由各种来源触发。例如,当用户点击鼠标按钮时,它生成一个点击事件;按下键盘上的键被识别为另一个事件,等等。事件也可能在没有直接用户输入的情况下生成。例如,应用程序可能在后台完成了一些计算,现在想要更新 GUI 显示的内容。这可能会自动触发一些更新事件,从而重新绘制视图。

事件处理

当一个事件被触发时,应用程序会对该事件做出响应。例如,当你点击浏览器的关闭按钮时,你期望浏览器窗口关闭。在这个例子中,关闭窗口是应用程序对由于用户操作而生成点击事件的响应。换句话说,应用程序有一个监听器对象来处理这个点击事件。每个 GUI 框架都提供了一种将事件绑定(或连接)到处理函数的方法。

事件循环

事件循环是 GUI 程序的主要控制循环。当你启动应用程序时,主循环开始,并等待事件发生。它监视事件源,并在事件发生时调度事件。

通过这个简短的介绍,让我们总结一下关于事件驱动编程我们已经学到了什么:

  • 程序执行的整体流程由事件控制

  • 应用程序运行(主循环开始)并等待事件发生

  • 当一个事件被触发时,监听事件的程序代码会通过运行一个特定的处理函数来做出响应

  • 因此,程序的流程取决于触发的事件

使用 Tkinter 进行 GUI 编程

如前所述,GUI 提供了一种与应用程序交互的方式。用户不是通过基于文本的输入,而是通过文本框、单选按钮、工具栏等元素进行交互。本节将介绍使用 Tkinter 进行 GUI 编程的基础。这个库是 Python 的标准模块之一。

Tkinter 文档链接

让我们为方便参考记录一些链接。官方 Tkinter 文档页面可在docs.python.org/3/library/tkinter.html#module-tkinter找到。此页面列出了多个外部参考。一个很好的介绍可在effbot.org/tkinterbook找到。当然,你可以始终使用如 python 和 Tkinter 之类的搜索词进行网络搜索以找到更多资源。

或者,你可以快速使用 Python 解释器查找支持的功能和文档!

>>> import tkinter
>>> dir(tkinter)

前面的命令列出了支持的类、函数等。要提取文档字符串,可以在给定的属性上调用__doc__。以下是一个 Tkinter 中mainloop()的文档字符串示例:

>>> tkinter.mainloop.__doc__
'Run the main loop of Tcl.'

Tkinter 中的 mainloop()

在关于事件驱动编程的讨论中,我们学习了主控制循环。在 Tkinter 中编写事件循环或主循环非常简单。以下代码片段展示了主循环的作用。这是你可以用 Tkinter 编写的最简单的 GUI 应用程序:

from tkinter import Tk

if __name__ == '__main__':
    mainwin = Tk()
    mainwin.mainloop()

让我们逐行分析这段代码:

  • 第一条语句从tkinter模块中导入Tk类。

  • 接下来,我们通过实例化Tk类创建一个主应用程序窗口。它由变量mainwin表示。在 Tkinter 术语中,它通常被称为 root 或 master。在本章中,我们将称之为mainwin

  • 通过调用mainloop()方法启动主事件循环。

下文展示了这个简单程序的结果。你可以像运行其他 Python 程序一样运行它。代码也可以在本书的支持材料中找到(请参阅文件mainloop_example.py)。根据你的操作系统和环境,这个窗口的外观和感觉可能会有所不同。

$ python mainloop_example.py

Tkinter 中的 mainloop()

小贴士

在 Python 2.x 中,import语句有细微的变化。对于 Python 版本 2,模块tkinter作为 Tkinter(首字母大写)可用。支持代码已经通过以下条件import处理了这个问题。其余代码保持不变。

if sys.version_info < (3, 0):
    from Tkinter import Tk
else:
    from tkinter import Tk

简单 GUI 应用程序 – 第一次尝试

我们刚刚看到了如何启动mainloop()方法。让我们更进一步,向这个应用程序添加一些小部件。观察以下代码。你还可以在本章的代码包中看到文件simple_application_1.py

简单的 GUI 应用程序 – 第一次尝试

代码注释基本上解释了代码的功能。以下是总结:

  • 我们首先从tkinter模块导入必要的类和选项。注意,你也可以这样做:from tkinter import *。然而,我们在本书中之前看到的最佳实践并不推荐这样做。

  • 接下来,使用geometry()方法指定主窗口的大小。这是可选的。

  • 接下来的几行代码创建了两个小部件,一个将显示文本Hello World!Label小部件,以及一个Button,当点击时会终止应用程序。

  • 我们需要某种方式来安排这些小部件在应用程序窗口内的布局。这被称为几何或布局管理。有三种方法可以做到这一点。这里展示的是pack()方法。关于几何管理的更多内容将在后面讨论。

  • 当点击退出按钮时,我们需要某种方式来处理这个事件。这是通过将命令选项分配给回调函数来完成的。在这个例子中,我们简单地终止应用程序窗口,并通过调用mainwin.destroy()来终止mainloop()

    小贴士

    回想一下,Python 函数是一等对象。参见第六章,设计模式,我们在那里讨论了这一点。回调函数mainwin.destroy被分配给命令变量。

从命令行运行此应用程序将显示一个简单的 GUI 窗口,如下所示:

$ python simple_application_1.py

简单的 GUI 应用程序 – 第 1 次

如果你点击退出按钮,它将终止主应用程序窗口。

看起来福爵士对这个简单的脚本并不太满意...

简单的 GUI 应用程序 – 第 1 次

对于更大和更复杂的应用程序,遵循面向对象编程方法会更好。**让我们重写这个应用程序,并将其封装在一个类中。然而,请记住,这只是一个朝着创建更好的应用程序迈出的小小一步。在本章的后面部分,你将学习关于 MVC 架构,以及如何在 GUI 应用程序中实现它的基本示例。

简单 GUI 应用程序 – 第 2 次

是时候给这个混合物添加一些面向对象的风味了。上一节中的应用程序可以重写如下:

简单的 GUI 应用程序 – 第 2 次

让我们简要地讨论一下前面的代码:

  • 将此代码与之前的代码进行比较。

  • MyGame类是我们创建小部件和定义主要逻辑的地方。

  • 注意,按钮的命令回调函数被设置为exit_btn_callback

  • 这意味着当按下退出按钮时,它将调用exit_btn_callback()而不是直接调用mainwin.destroy()

  • 这只是为了向你展示如何指定不同的回调函数。你总是可以将它设置回command=mainwin.destroy()

其余的代码是自我解释的。你可以执行它以获得与第一个程序相同的Hello world!窗口。命令如下所示:

$ python simple_application_2.py

支持代码包中的 simple_application_2.py 文件基本上包含了我们刚才审查的程序。

提示

在所有示例中,我们将使用 Tk 实例 mainwin 作为创建的小部件的主对象或父对象。在实践中,创建一个容器来在 GUI 中包含其他小部件通常很有用。该容器可以是 Frame 类的实例或任何其他小部件,具体取决于应用程序。例如,您可以编写以下内容:

mainwin = Tk()
container = Frame(mainwin)
some_label = Label(container, text="blah blah")

现在我们已经知道了如何创建一个具有图形用户界面的简单应用程序,让我们继续讨论 Tkinter 库中可用的各种小部件。

Tkinter 中的 GUI 小部件

在本节中,我们将简要介绍一些常用的小部件。请注意,我们即将介绍的小部件并不特定于任何 GUI 库。然而,以下讨论是针对 Tkinter 库量身定制的。例如,您将在许多 GUI 库中找到一个 Menu 小部件。Tkinter 通过 Menu 类提供它,PyQt 库将其称为 QMenu 等。

提示

我们即将看到的内容远非详尽无遗。我们鼓励您探索以下维基页面,该页面列出了其他几个 GUI 元素:en.wikipedia.org/wiki/List_of_graphical_user_interface_elements

小部件是图形用户界面中的一个元素,它允许用户交互。换句话说,用户可以执行某些操作,如按下按钮并与 GUI 交互。

我们已经看到了如何创建 LabelButton 小部件。以下表格总结了 Tkinter 中一些重要的小部件类。

小部件类 基本语法 描述
Menu
menubar = Menu(parent)
此小部件表示一个菜单,例如菜单栏或弹出菜单。它包含菜单项。
Frame
container = Frame(parent, 
        width=100,          
        height=100,
        bg='white')
这通常用作容器来包含其他小部件。框架小部件也有自己的网格布局,并且像许多其他小部件一样,您可以指定背景颜色、边框和其他属性。
Canvas
my_canvas = Canvas(parent,
              width=100,
              height=100)
这是一个图形小部件。这是您可以绘制或写入内容的地方。例如,您可以渲染形状、图表、图像,或者使用此小部件来写入文本。
Label
 lbl = Label(parent, 
       text= "some text",
       bg = 'blue')
在标签中,您可以添加文本或图像。当您点击标签时,不会触发事件。相反,您可以在其他地方生成某些事件响应时更新标签。
Button
ok_button = 
 Button(parent, 
        text="OK",   
        command=parent.quit)

可选的 command 参数也可以分配给任何用户定义的函数。| 一个简单的按钮小部件。当按下或释放时,它触发一个事件。|

Radiobutton
rbutton_1 = 
   Radiobutton(parent,
           text="Option 1", 
           variable=var, 
           value=1)
rbutton_2 = 
   Radiobutton(parent,
           text="Option 2", 
           variable=var, 
           value=0)

一组单选按钮与一个公共变量 var 相关联。当您点击单选按钮时,该变量的值会更改为一个预定义的值,该值由值指定。| 单选按钮小部件允许用户从给定的一组值中选择单个值。它可以包含文本或图像。|

Checkbutton
c_button =  
    Checkbutton(parent,  
       text="Enable Audio",  
       variable=var)

当复选框被选中时,变量var的值为 1,否则值设置为 0。这是默认行为。| 此小部件允许将两个不同的值设置到变量中。典型用法是切换变量的状态(开启或关闭选择)。|

|

Listbox

|

lstbox = Listbox(parent)

然后,您可以使用以下insert()方法向此列表框添加元素:

lstbox.insert(END, "item1")
这个小部件用于显示一系列选项。用户可以从Listbox小部件中选择一个或多个元素。

|

Entry

|

text_edit= Entry(parent)
这是一个文本输入小部件,允许您显示或输入文本。在其他一些 GUI 框架中,它被称为行编辑小部件。

上表中显示的基本语法仅用于说明目的。您可以指定许多其他选项。传递给小部件的parent参数表示父小部件或基本小部件。

小贴士

在本书中,我们将在创建小部件时仅使用最基本选项。您可以通过指定适当的可选参数或调用相关方法来进一步配置这些小部件。为了进一步学习,请参考以下链接中官方 Tkinter 文档页面上列出的各种参考资料:docs.python.org/3/library/tkinter.html#module-tkinter

几何管理

布局或几何管理涉及在 GUI 中组织各种小部件。在 Tkinter 中,这种布局管理是通过称为几何管理器的机制实现的。有三种不同的几何管理器来组织小部件,即网格包装放置。在这些中,网格管理器是推荐的选择。在本章的后续内容中,我们将演示网格管理器的使用。

网格几何管理器

网格管理器在安排各种小部件方面提供了灵活性,并且也非常容易使用。

  • 网格管理器的父小部件(例如,一个框架或对话框)被视为一个具有行和列的表格。

  • 此表格的最小元素是一个单元格,它具有高度和宽度。

  • 您可以在这样的单元格中放置其他小部件。也可以有一个跨越多个单元格的小部件。

  • 表格中每行的高度由该行中最高的单元格(或小部件)的高度决定。同样,表格中每列的宽度由该列中最宽的单元格控制。

  • 网格几何管理器中的每一行和每一列都可以使用权重选项进行配置。权重决定了如果主小部件有可用空间时,特定行或列可以扩展多少。可以使用grid_rowconfiguregrid_columnconfigure方法分别指定行和列的权重。默认权重值为 0。

以下截图显示了一个代表性的网格布局:

网格几何管理器

在前面的图像中,一些 Label 小部件被排列在网格布局中。标签文本 Cell[0,0] 表示我们将此标签放置在网格的第 0 行和第 0 列。观察 Cell[3,0],它显示一个宽度占用四个列的标签。同样,Cell [1,3] 是一个高度跨越两行的标签。

包布局管理器

在我们的第一个 Tkinter 应用程序中,我们已经使用了包布局管理器来排列小部件。作为一个复习,这里是有关的代码片段(pack方法):

lbl = Label(mainwin, text="Hello World!",  bg='yellow')
lbl.pack(side=LEFT)

包布局管理器提供了如扩展、填充和侧边等选项来控制小部件的位置。当您想要排列多个小部件,无论是并排还是重叠时,它非常有用。另一种用例是当您希望小部件占据包含它的整个容器时。

小贴士

在同一个主窗口中使用网格和包布局管理器可能会导致不理想的结果。不要一起使用这些布局管理器。

位置布局管理器

位置布局管理器允许您指定小部件及其大小的绝对或相对位置。它在某些特殊场景中有用。我们不会进一步讨论这个布局管理器。在大多数情况下,您可以使用网格布局管理器代替。

Tkinter 中的事件

让我们简要地谈谈 Tkinter 支持的各种事件及其描述的语法。

事件类型

下表显示了最常用的几种事件类型。阅读文档以了解这里未列出的其他事件类型。下一节 事件描述符 将详细说明如何使用事件类型来描述事件。

事件名称 描述
Button(或ButtonPress 鼠标按钮之一被按下。是哪一个?这由事件描述符的detail字段确定(见下一节)。
ButtonRelease (之前按下的)鼠标按钮之一被释放。
Enter 鼠标指针进入了一个小部件。这与键盘上的 Enterreturn 键无关。
Leave 鼠标指针离开了一个小部件。
KeyPress 一个键盘键被按下。是哪一个?这由事件描述符的detail字段确定。
KeyRelease 一个键盘键被释放。
FocusIn 一个小部件获得输入焦点。
FocusOut 小部件不再具有输入焦点。

事件描述符

Tkinter 有一个特殊的语法来描述事件。它是一个具有以下一般形式的字符串:

<[modifier-]type[-detail]>
  • 指定的事件被括号<>包围。

  • 类型指定了事件的类型,例如鼠标点击。

  • 修饰符和详细指定符是可选的。

  • 修饰符是事件修饰符。想象一下,当按下鼠标按钮时,同时按下了控制按钮。在这里,控制按钮是事件修饰符,而鼠标按钮的按下是事件的类型。

  • 详细指定符提供了关于事件类型的更多信息。如果类型是鼠标点击,则详细信息将描述它是左鼠标按钮、右按钮还是中间按钮。

以下表格总结了某些常见的事件指定符。

事件语法 描述
<Button-1> 按下鼠标按钮 1(左鼠标按钮)。
<Button-2> 鼠标按钮 2 被按下(如果有中间按钮)。
<Button-3> 按下鼠标按钮 3(最右边的按钮)。
<KeyPress-B> 按下 B 键。同样,你也可以为其他键编写,例如 <KeyPress-G>
<Return> 按下回车键。
<Configure> 小部件的大小已更改(例如,窗口大小调整)。新大小存储为事件对象的宽度和高度属性。
<Shift-Button-1> 按下 Shift 键的同时按下左鼠标按钮。

事件对象属性

Event 类的实例持有描述事件的详细信息。以下表格列出了 Event 类的一些重要属性。

事件属性 描述
widget 触发此事件的部件对象。
x, y 当前鼠标位置的像素。
x_root, y_root 以像素为单位,相对于左上角的鼠标位置。
width, height <Configure> 类型事件的更改大小(宽度和高度)。

Tkinter 中的事件处理

在本章的早期,我们学习了事件和事件处理(参见 事件驱动编程 部分)。在本节中,我们将了解如何将用户交互引起的各种事件与适当的处理函数绑定。

命令回调(按钮小部件)

回想一下,当我们编写第一个 Tkinter 应用程序时,我们将回调函数绑定到 Button 小部件的 command 参数。下面是相关代码行的复制,以便于参考:

exit_button = Button(mainwin,text='Exit',command=mainwin.destroy)

当你点击退出按钮时,它会调用 mainwin.destroy(),这由 command 参数表示。需要注意的是,虽然 Button 小部件支持命令回调,但此功能并非所有支持的部件都可用。为此,Tkinter 提供了 bind() 方法,该方法定义在所有小部件上。bind() 方法只是 Tkinter 事件绑定级别中的一级。接下来,让我们谈谈几个事件绑定级别。

bind() 方法

此方法提供实例级别绑定。它将事件绑定到特定的小部件实例。另一种思考方式是,这可以指定对特定事件敏感的确切 GUI 元素。基本语法如下:

widget.bind(sequence=None, func=None, add=None)

应该注意的是,你也可以使用此方法为 toplevel 窗口。

为了便于理解,让我们将可选参数序列表示为even_descriptor,将func表示为event_handler。第三个可选参数add可以指定为字符串+。它允许你向现有绑定添加一个新函数。我们在此不讨论add参数。请参阅文档以获取更多详细信息。

widget.bind(event_descriptor, event_handler)

在前面的语句中,widget是生成一个或多个事件的任何小部件。例如,该小部件可以是ButtonEntry小部件等。event_descriptor是实际触发的事件,例如按键或点击等。event_handler是当事件被触发时被调用的函数。

让我们看看如何使用此方法来处理Button小部件,而不是命令回调。除了语法之外,我们还需要定义一个处理生成的事件的回调函数。让我们重写简单 GUI 应用程序部分中展示的代码。

bind()方法

注意,我们已经定义了一个新的事件处理函数exit_btn_clicked(),它接受事件对象(evt)作为参数。绑定到bind的第一个参数表示事件类型或事件格式。在这个例子中,<Button-1>表示在控件上按下左鼠标按钮。在本章中,我们只会使用bind()方法。但在我们进一步讨论之前,让我们简要地谈谈其他绑定级别。

bind_class()方法

此方法提供类级别的绑定。它将一个事件绑定到特定的小部件类。基本语法如下所示:

bind_class(className, event_descriptor, event_handler)

在前面的语法中,className是一个表示小部件类名的字符串。其他参数与上一节中讨论的相同。

假想你应用程序中的所有Button小部件代表一些数字。你可以配置它们以响应右键点击事件,使得每个都返回该数字的平方。在这个例子中,你可以像这样使用bind_class方法:

bind_class('Button', '<Button-3>', compute_square)

在这里,假设你已经定义了一个函数,compute_square

bind_all()方法

此方法提供应用级别的绑定。正如其名所示,此方法将事件绑定到应用级别的所有小部件。例如,在某些游戏应用中,你可能想要配置一个键来暂停游戏,无论当前焦点的小部件是什么。在这种情况下,你可以使用此方法。基本语法如下:

bind_all(event_descriptor, event_handler)

提示

Tkinter 支持称为绑定标签的功能。每个小部件都有自己的绑定标签列表。这些标签决定了与控件相关的事件处理的顺序。内置方法bindtags()可以用来设置或获取与控件关联的标签。请参阅文档以获取更多详细信息。

项目-1 – 兽人攻击 V10.0.0

你已经开发了一个强大且流行的命令行应用程序,兽人攻击。虽然用户对当前版本很满意,但现在有一个新的和不断增长的需求。用户现在希望应用程序有一个图形用户界面!

是时候开始另一个简单的程序了。还记得我们在第一章中编写的第一个命令行应用程序吗,开发简单应用程序?让我们使用相同的主题,开发一个等效的 GUI 程序。

背景场景

作为复习,以下是我们在第一章中看到的游戏主题,开发简单应用程序

在穿越茂密森林的路上,福爵士发现了一个小型的孤立定居点。又累又希望补充食物储备,他决定绕道而行。当他接近村庄时,他看到了五座小屋。周围看不到任何人。犹豫了一下,他决定进入一座小屋...

问题说明

任务是设计一个 简单的 GUI 程序。玩家可以选择福爵士可以休息的五座小屋中的一座。小屋可能被朋友或敌人占据,也可能空着。如果玩家选择的小屋是空的或有友军单位在里面,则玩家获胜。

以下截图显示了即将发生的事情。但不要太过兴奋!这是一个相当简单的游戏,将帮助你学习一些重要的 GUI 编程方面。

问题说明

当你点击一个小屋时,它会检查居住者是谁,然后弹出一个消息框宣布获胜者。就是这样!

编写代码

我们将使用 hutgame.py 文件中提供的代码。请从本章的代码包中下载此文件以及两个图像,Hut_small.gifJungle_small.gif

提示

建议你在阅读以下讨论时打开文件 hutgame.py 作为便捷的参考。在源代码编辑器中快速浏览完整代码通常很有帮助!

我们将从主执行代码开始:

编写代码

让我们分析一下这段代码:

  • 将它与“简单 GUI 应用程序 - 第二次尝试”部分中的主执行块进行比较。注意,两者之间没有太大的区别。

  • 我们使用 geometrytitle 方法设置应用程序窗口的大小和标题。mainwin.resizable 调用冻结窗口大小。这是可选的,但可以确保背景图像完美地适应窗口。

  • HutGame 类是我们创建小部件和定义主要逻辑的地方。

  • 通过调用 mainloop() 启动主事件循环。

HutGame 类概述

在审查 HutGame 类中的任何代码之前,让我们先了解整体情况。这个类的关键方法如下图中所示:

HutGame 类概述

如上图所示,这些方法可以根据功能大致分为三组。我们将在讨论 MVC 架构时再讨论这种分组。接下来,让我们回顾一下这个类中的各种方法。

小贴士

给更有经验的读者的一则笔记!

在接下来的几节中,我们将讨论 HutGame 类的方法。你可能会觉得这个讨论有点冗长!你可以选择只查看 hutgame.py 文件中的代码。代码有很好的文档说明。如果有什么不清楚的地方,请返回并阅读相关部分!

The init method

看看当 HutGame 被实例化时调用的代码:

The __init__ method

以下是对 __init__ 方法的描述:

  • PhotoImage 类用于在标签、按钮等控件中显示背景图像。它支持 GIF 图像格式。还有使用 Python Imaging LibraryPIL)加载图像的方法。我们在这里不会讨论这些细节。

  • 我们将在 RadioButton 上使用 hut_image,并将 village_image 设置为应用程序的背景。

  • self.setup() 调用确保控件被创建并适当地放置在应用程序窗口中。

The occupy_huts method

以下方法与 第一章 中的第一个示例相同,即 开发简单应用程序

The occupy_huts method

确切有五个小屋。这段代码基本上是从给定的 occupants 列表中随机选择居民来填充 self.huts 列表。

The create_widgets method

如其名所示,这个方法涉及创建我们应用程序的控件。实际上,控件并不多。我们只有一个标签来显示一些信息,以及一些代表小屋的单选按钮。方法如下所示:

The create_widgets method

上述方法可以这样解释:

  • self.var 是一个 Tkinter 变量。它是 Tkinter 支持的变量类的一个实例。在这里,它代表一个整型变量(IntVar 类)。同样,还有其他类,如 StringVar,用于处理字符串变量等。

  • 简而言之,Tkinter 变量使跟踪更改成为可能。我们有五个与单个 Tkinter 变量 self.var 相关联的单选按钮。可以为每个单选按钮指定一个值选项。当单选按钮被选中时,这个值会被分配给 self.var

  • 字典 r_btn_config 用于设置所有单选按钮的共同配置选项。它作为参数传递给 Radiobutton

  • 一个例子有助于理解单选按钮的工作原理。按钮 self.r4 有一个关联的值 4,代表小屋编号。当你选择按钮时,这个值会被分配给 self.var。这会调用 self.radio_btn_pressed(),即按钮的命令回调函数。

  • self.background_label用于为我们的应用程序窗口设置村庄背景。还有其他方法可以实现这一点。在这本书中,我们不会讨论此类自定义细节。

创建小部件方法

当然!请查看以下应用程序窗口,其中一些小部件或配置选项已标注。

创建小部件方法

设置布局方法

以下代码片段显示了setup_layout()方法以及它在顶级setup()方法中的调用方式:

设置布局方法

小贴士

网格布局在安排小部件方面提供了很多灵活性。在这幅插图中,我们只是触及了 Tkinter 的表面!要获得专业知识,您应该创建自己的 GUI 小部件,并尝试不同的布局配置选项。请参阅文档了解其他可用选项。

现在我们来分析这段代码:

  • 记住,我们可以在网格布局中为特定的行或列分配一个相对weight。这是通过使用grid_rowconfiguregrid_columnconfigure方法实现的。weight决定了行或列相对于其他行或列将占用多少空闲空间。默认值 0 表示即使有可用空间,它也不会增长。

  • 在这个例子中,容器中的第 1 行被赋予一个相对权重或1,允许它扩展并占用更多空闲空间。同样,第 0 列第 4 列被分配了相对权重1。尝试这个选项,看看它如何影响布局。另一个要尝试的选项是pad,它为小部件添加填充。

  • 对于background_label,我们使用place()几何管理器。标签锚定在(0, 0)relwidthrelheight参数表示父窗口的高度和宽度的分数。1.0 的值意味着标签的大小将与它的父窗口(主应用程序窗口)相同。

  • info_labelsticky选项确保小部件沿单元格的四个边缘对齐。值nsew分别沿北、南、东、西单元格边缘对齐小部件。您还可以指定几个值,例如,sticky='ew'将小部件沿左右边缘对齐。

radio_btn_pressed 和 enter_hut 方法

让我们一起回顾这些方法。在create_widgets()方法中,我们指定了命令选项,如下代码片段所示:

radio_btn_pressed 和 enter_hut 方法

radio_btn_pressed是所有单选按钮的命令回调。它如下所示:

radio_btn_pressed 和 enter_hut 方法

该方法只是调用 self.enter_hut。当单选按钮被选中时,它更新存储在 Tkinter 变量 self.var 中的值。这个值不过是分配给所选小屋的小屋编号,可以通过调用 Tkinter 的 IntVar 类的 get() 方法来获取。

让我们看看 enter_hut 方法:

radio_btn_pressed 和 enter_hut 方法

上述代码是自我解释的。它检查居住者,并宣布结果。获胜者公告是通过 messagebox 小部件完成的。

announce_winner 方法

这是我们将要审查的最后一个方法:

announce_winner 方法

在上述方法中,我们使用 Tkinter 的 messagebox 模块来显示信息框。此模块提供了几种其他类型的对话框。有关更多详细信息,请参阅文档。

运行应用程序

是时候采取一些行动了!按照以下方式运行此应用程序:

$ python hutgame.py

最后一条命令应该显示之前显示的 GUI 窗口。以下截图显示了游戏的实际运行情况。首先,你选择一个小屋:

运行应用程序

当你点击单选按钮时,它会显示通知获胜者的信息框。

运行应用程序

MVC 架构

MVC 是基于 GUI 的应用程序中广泛使用的软件架构模式。它有三个组件,即处理业务逻辑的 模型、用于用户界面的 视图 和处理用户输入、操作数据并更新视图的 控制器。以下是一个简化的示意图,展示了各个组件之间的基本交互:

MVC 架构

让我们进一步讨论这些组件的每个部分。

模型

MVC 架构中的模型组件代表应用程序的数据。它还代表作用于这些数据的核心业务逻辑。模型对视图或控制器没有任何了解。当模型中的数据发生变化时,它只是通知其监听器这一变化。在这种情况下,控制器对象是其监听器。

视图

视图组件是用户界面。它负责向用户显示模型的当前状态,并为用户提供与应用程序交互的手段。如果用户操作(如按钮点击)改变了这一状态,视图将刷新以显示这一变化。

控制器

在某种意义上,控制器使得模型和视图之间能够进行握手。它监控模型的变化。当用户与视图中的某个元素交互时,控制器在后台工作并处理由用户操作触发的事件,例如鼠标点击。处理函数可以进一步更新模型。当模型的状态发生变化时,控制器更新视图以反映这些变化。

控制器

*你说得对。通过插图可以更好地理解 MVC 的各个组件及其工作原理。让我们使用本节中先前给出的简单示例事件驱动编程

假设你打开了 PDF 文件进行阅读。在这种情况下,MVC 及其组件可以这样解释:

  • PDF 阅读器是运行中的应用程序。

  • 它将显示你打开的文件的内容,并且还将有按钮用于在文件中导航。这是处理用户界面的视图组件。

  • 要跳转到下一页,你需要与视图交互并点击下一页按钮。这是一个用户输入,它生成一个事件。

  • 这样的事件由控制器内部处理,然后更新模型,或者在这个上下文中,检索请求页面的相关数据。

  • 模型的状态已更改。控制器进一步与视图通信,以更新其新内容。

  • 视图被刷新,最后,你看到所需的页面。

MVC 的优势

MVC 架构传统上用于桌面 GUI 应用程序,并且在网络应用程序开发中也得到了广泛的应用。由于这是一个三组件架构,它提供的一个主要优势是跨多个应用程序的代码重用。例如,假设你有多款具有不同用户界面的应用程序,它们都需要相同的企业逻辑。使用 MVC 架构,你可以简单地重用模型对象在这些应用程序中表示的企业逻辑。

此外,MVC 允许用户界面开发者专注于 UI 代码,不必过多担心处理企业逻辑的代码。同样,专注于企业逻辑的开发者可以专注于那部分代码,而无需为 UI 小部件和相关的代码选择而烦恼。这被称为关注点分离。模型关注企业逻辑或数据,视图关注用户界面,控制器代码关注诸如启用视图操作和处理输入等问题。

项目 2 – Attack of the Orcs v10.1.0

让我们再做一个小型项目。实际上,这正是我们在 Project-1 – Attack of the Orcs V10.0.0 中开发的相同的小屋游戏。区别在于底层架构。我们将重写程序以实现 MVC 架构。

重新审视 HutGame 类

在第一个项目中,我们编写了 HutGame 类。让我们拉出表示这个类高级结构的图:

重新审视 HutGame 类

根据功能,这个类的函数可以大致分为三类,即模型、视图和控制器。前面的图示显示了这种划分。我们还需要进一步更新其中的一些方法。

创建 MVC 类

在上一节中,我们将旧类HutGame的方法分为三大类。现在是时候告别这个类了。我们将将其分解,并将它的方法分配给三个新的类,即ModelViewController。当然,你可以给这些类起更描述性的名字,但让我们继续使用上述名称。

观察以下类似于 UML 的表示,它显示了这些方法所在的类。这里只列出了重要的属性。

创建 MVC 类

提示

在第一章,开发简单应用程序中,我们简要地讨论了类似于 UML 的表示。创建此类图的一种方法是通过使用www.draw.io。这是一个免费、在线的图表软件,用于制作流程图、UML 图等。

现在我们知道了类的布局,让我们了解这些类是如何交换信息的。

MVC 对象之间的通信

在深入探讨 MVC 对象如何通信的细节之前,让我们首先列出关于 MVC 架构的一些重要观点:

  • 控制器不仅了解模型,还了解视图

  • 模型对其他两个(即控制器和视图)一无所知

  • 视图(就像模型一样)对控制器和模型一无所知

MVC 架构可能有其他一些变体。在这本书中,我们将坚持上述观点,并设计一个解决方案。

控制器到模型或视图通信

让我们从学习控制器如何将信息发送到模型或视图开始讨论。

Controller对象可以直接通过self.modelself.view分别与ModelView实例进行通信。例如,它可以这样调用一个View方法:

self.view.announce_winner(data)

这很简单。现在让我们看看它是如何从模型或视图接收数据的。

模型到控制器通信

控制器是如何从模型接收信息的?例如,在棚屋游戏场景中,胜利者是根据谁在所选的棚屋内来确定的。一旦确定了胜利者,Model类就需要将其传达给Controller类。这是通过Controller类的model_change_handler()方法实现的。每当Model类的状态发生变化时,该方法就会被调用。

模型到控制器通信

好问题! Model 类对 Controller View 一无所知。那么 Controller 是如何知道 Model 发生了变化的?让我们看看下一个问题。

Controller类可以通过多种方式从Model类接收信息。让我们简要地讨论两种这样的方法。

使用方法分配

记住,在 Python 中,你可以将一个方法赋值给一个变量。设计模式章节详细讨论了第一类对象。以下行代码可以添加到Controller.__init__中。

self.model.changed = self.model_change_handler

然后,在Model类中,你可以调用self.changed(),如下所示:

def enter_hut(self, hut_number):
    # Some code goes here (not shown)
    self.changed()

这会自动通知Controller模型已更改。虽然这非常方便,但我们将改用发布-订阅 API,这使得事情更加简单。

使用发布-订阅模式

发布-订阅是一种消息模式。发布者可以是任何向一个主题广播一些数据的程序。可能有一个或多个应用程序正在监听这个主题。这些被称为订阅者,他们接收发布的数据。发布者不知道(或不需要知道)关于订阅者的任何信息。同样,订阅者对发布者也没有任何了解。以下示意图给出了发布-订阅系统的高级概述:

使用发布-订阅模式

通过现实世界的类比可以更好地理解发布-订阅的概念。想象一个在线零售商每周进行闪购。你已经选择接收以短信或电子邮件警报形式的通知。还有几位其他客户也希望得到关于销售的通知。

在发布-订阅的世界里,在线零售商是一个发布者,将销售信息(数据)广播到主题,比如闪购。你和几位其他客户是这个主题的订阅者。同样,在线零售商可以以不同的主题发布其他信息,例如,周五特卖半价特卖等等。每个主题可能有多个订阅者。如果你没有订阅周五特卖,你将不会收到发送到该主题的任何通知。

PyPubSub 包

我们如何在 Python 中实现一个发布-订阅框架?一个选项是从头开始编写代码。相反,我们将只使用一个名为pypubsub的 Python 包。它提供了一个发布-订阅 API,简化了设计并提高了代码的可读性和可测试性。该包可以按照以下方式安装:

$ pip install pypubsub 

这里有一个简单的例子,展示了典型的用法。实际上,这个语法就是本章所需的所有内容。

PyPubSub 包

当你运行这个脚本时,它会产生以下输出:

$ "In model_change handler function, data= Player Won"

pub.subscribe()的第一个参数是你想要订阅的特定主题的函数。这里的主题名是 WINNER ANNOUNCMENT。代码的最后一行显示了如何使用pub.sendMessage()向特定主题广播消息。pub.sendMessage()的第一个参数是主题名。你可以指定任意数量的可选参数,只需确保订阅者函数接受所有这些参数!在这个例子中,它只发送数据作为唯一的可选参数。

小贴士

更多关于 PyPubSub 包的信息,请参阅项目主页:pubsub.sourceforge.net/

PyDispatcher 是 PyPubSub 包的替代品。虽然我们不会使用它,但这里有一个项目的链接:pypi.python.org/pypi/PyDispatcher

ViewController的通信

就像Model一样,View对象到Controller对象之间没有直接的通信链接。当用户按下单选按钮时,控制器需要被通知。我们可以采用类似前一个章节中讨论的方法。例如,你可以将Controller的一个方法分配给View方法。或者,你可以使用发布-订阅 API 与Controller对象通信。

ViewModel之间的通信

让我们讨论一下ViewModel是如何相互交流的:

  • 当用户按下单选按钮时,View使用之前讨论过的一种方法与Controller通信

  • 然后Controller对象与Model通信,指示其更新

  • Model的状态被更新,并将结果反馈给Controller

  • Controller要求View更新显示

注意

使用发布-订阅 API 进行ViewModel通信

你可以使用发布-订阅框架在ModelView对象之间建立通信通道。请注意,这仍然保持了基本规则。ModelView对象一无所知。它只是将数据发布到指定的主题。ViewModel对象也没有任何了解。它只是注册为订阅者,订阅Model广播数据的同一主题。因此,每当Model的状态发生变化时,View可以通过发布-订阅 API 获得通知。同样,对于从ViewModel的通信也是如此。潜在的负面影响是,这些发布-订阅信号本质上是全球变量,可能会带来与之相关的痛苦问题。所以请谨慎使用!

然而,对于这个项目,我们将坚持使用经典方法,其中通信是通过Controller发生的。

查看代码

到目前为止,你已经得到了关于新类及其相互通信方式的高级概述。在第一个项目中,我们已经审查了每个新类下列出的大多数方法。话虽如此,为了实现 MVC 架构,我们需要做一些修改。让我们仅从文件hutgame_mvc.py中回顾几个重要的方法。请注意,所有类都放在了同一个文件中。作为一个练习,将单独的类放入它们自己的模块中!

小贴士

由于我们不会逐行审查代码,您应该下载本章代码包中的文件 hutgame_mvc.py 以及两张图片,Hut_small.gifJungle_small.gif。在阅读即将到来的讨论时,请保留源文件。通常,快速浏览完整代码有助于更好地理解!

主要执行代码如下。它与我们在第一个项目中看到的是几乎相同的。唯一的区别是 game_app(已突出显示)。现在它是一个 Controller 类的实例,而不是 HutGame。实际上,本项目没有 HutGame 类!回想一下,我们将其分解,创建了三个新的类。

Reviewing the code

控制器类

如下所示,Controller 类相当小:

The Controller class

让我们分析一下代码。如果您已经理解了它,可以跳过阅读这些要点!

  • Controller 类由 ModelView 实例组成。这使得它可以直接调用这些类的功能。

  • self.view.set_callbacks() 函数本质上是将 radio_btn_pressed 方法分配给 View 的一个适当属性。简单来说,这意味着每当用户按下单选按钮时,就会调用此方法。请参阅 视图到控制器通信 部分,以获取更多详细信息。

  • Controller 类通过订阅主题 "WINNER ANNOUNCEMENT"Model 实例接收数据。我们已经看到了 pub.subscribe() 函数的一个例子。简单来说,每当宣布赢家时,就会调用 model_change_handler 方法。

  • model_change_handler 方法调用适当的 View 方法来显示宣布赢家信息的消息。

模型类

Model 类中没有太多变化。唯一的重大变化是给定代码中突出显示的行(enter_hut 方法中的 pub.sendMessage 调用)。其他方法的细节没有显示。这些方法在编辑器中以代码折叠的形式显示。

The Model class

将此方法与我们第一个项目中编写的代码进行比较。请注意,它没有直接调用 View.announce_winner。相反,它使用 pub.sendMessage() 通知 Controller 实例。其余代码保持不变,您可以查看 hutgame_mvc.py 文件以获取更多详细信息。

提示

视图与模型之间的通信 部分所述,您可以使用相同的发布-订阅框架来通知 Model 的状态变化给 View,反之亦然。

注意

对象关系映射器 (ORM):

简而言之,这是一个库,它使您能够使用像 Python 这样的面向对象语言来访问和更新数据库中的数据。在 Python 中,Django ORM 和 SQLAlchemy 是流行的 ORM 库之一。您可以在网上搜索这些库以找到有用的资源。

模型类和 ORM:本书不涵盖与 Web 或数据库应用程序编程相关的任何内容,但值得提及的是,Model类通常继承自 ORM,并代表数据库表,其中每个对象都是表中的一行。为这样的系统编写单元测试可能是一个挑战,因为你通常不希望在每次运行这些测试时实际击中数据库。在第五章中,我们看到了如何使用 Python 的 mock 库。mock 通常对单元测试这样的系统很有用(本书未涵盖)。

视图类

下图展示了View类。唯一显著的变化是名为set_callbacks的方法。其他方法以代码折叠的形式展示。

The View class

记住,在Controller.__init__方法中,我们有以下代码:

self.view.set_callbacks(self.radio_btn_pressed)

上述代码表明,View类的radio_btn_pressed属性代表Controller类的radio_btn_pressed()方法。其余的代码与第一个项目中的代码相同。

运行应用程序

在这个项目中,我们没有向 GUI 添加任何新功能。我们的想法只是展示一个实现 MVC 架构的初步示例。你可以按照以下步骤运行此应用程序:

$ python hutgame_mvc.py

这应该会显示与第一个项目相同的 GUI 窗口和功能。

测试图形用户界面应用程序

在一个复杂且功能丰富的图形用户界面应用程序中,用户会看到许多小部件、菜单、键盘快捷键等选择。如本章前面所述,GUI 程序的事件驱动特性让用户可以决定程序流程。这通常为用户提供了许多执行特定操作以获得所需输出的可能方式。

小贴士

应该注意的是,我们在这里不会编写任何代码。这只是一个高级讨论,涉及到一些重要的测试考虑因素。关于这个主题的进一步学习,请从以下维基页面开始:en.wikipedia.org/wiki/Graphical_user_interface_testing

想象一个图形用户界面应用程序,它允许在应用程序窗口中选择某个对象,例如,桌面上的文件夹图标。用户可以将鼠标悬停在图标上以突出显示该对象,然后点击它以选择它。或者,他可以进行窗口选择,在对象周围绘制选择窗口以选择它。另一种选择可能是使用键盘上的组合键。虽然用户很高兴能够以不同的方式完成任务,但这对于开发者来说编写无错误的代码却是一个挑战。

事件驱动编程的本质使得编写健壮的代码和全面的测试来应对大多数用户输入场景变得困难。错误可能会以某种方式悄悄出现。当然,这取决于应用程序和测试策略,但通常是大型和复杂 GUI 应用程序的一个问题。

测试考虑因素

有多种测试策略可以使 GUI 应用程序代码更加健壮。让我们探讨一些重要的测试考虑因素。

单元测试和 MVC

单元测试可以帮助你测试代码的某个部分。集成测试是指将多个单元测试组合在一起以测试更大的功能。在回归测试中,你通常会有单元和集成测试的组合。在这里,测试会重新运行以确保没有东西被破坏。一个好的回归测试框架对于作为防止错误的防线至关重要。单元测试通常有助于解决一些常见问题。在早期章节中,我们已经通过命令行应用程序的示例介绍了这个主题。

GUI 程序的 MVC 架构进一步有助于使代码更加健壮。关注点的分离或代码分解为模型、视图和控制组件,使我们能够为特定类型的错误编写单元测试。例如,在某些应用程序中,你可能会在Model类而不是View类中预测到ZeroDivisionError。因此,你可以为Model类编写专注于这种情况的单元测试,以便优雅地处理此类情况。

手动测试

虽然一个好的回归测试套件有助于解决常见问题,但程序的事件驱动特性经常提出未被考虑的场景。在手动测试中,软件测试人员通过操作 GUI 中提供的不同功能来手动检查应用程序的工作情况。如果某些功能不符合预期,测试人员会创建一个错误报告来记录重现问题的说明。许多隐藏的错误在手动测试阶段出现。

随着程序复杂性的增加,重复的手动测试工作对测试人员来说变得令人难以承受。这就是自动化 GUI 测试发挥作用的地方。

自动化 GUI 测试

在这里,测试工具记录用户操作以创建测试。如果你运行此类测试,用户操作会自动以相同的顺序重复。这允许快速识别损坏的功能。

小贴士

自动化测试不应取代手动测试。除非工具中集成了人工智能,否则你仍然需要有人来测试新功能,并以之前未尝试过的方式使用现有功能。通常,自动化测试应补充手动 GUI 测试活动。

在 Python 中,有几种开源和商业工具可用于自动 GUI 测试。以下表格总结了几个突出的、免费可用的 GUI 自动化测试工具。要获取完整列表,请参阅 Python 维基页面 wiki.python.org/moin/PythonTestingToolsTaxonomy

工具名称和链接 备注
Sikuli (SikuliX) www.sikuli.org/ 支持 Windows、Mac 和一些 Linux 操作系统。访问网站以检查您的操作系统是否受支持。
StoryText pypi.python.org/pypi/StoryText 支持的 GUI 框架包括 Tkinter、PyGTK、wxPython 等。请访问网站以获取完整列表。
Dogtail fedorahosted.org/dogtail/ 旨在用于类似 Fedora 的 Linux 操作系统。检查它是否与您的操作系统兼容。

这种自动化测试系统的弱点之一是,看似无辜的 GUI 变化可能需要您更改大量测试,并且根据您的 GUI 应用程序的复杂性,这可能会很麻烦。

练习

这里有一些您可以进一步改进 GUI 应用程序的方法。除了一个例外,这些练习的解决方案没有提供。

  1. ModelViewController 类放入它们自己的模块中!

  2. 使用发布-订阅 API 从 ViewController 进行通信。您可以参考文件 hutgame_mvc_pubsub.py 以获取解决方案。

  3. 添加更多小部件,如菜单栏和按钮。实现重新启动游戏按钮。当点击时,游戏应该重新启动。当此按钮被点击时,执行以下操作:

    • 通过调用 occupy_huts() 再次随机分配居民。

    • 清除单选按钮的状态。所有按钮都应该被取消选中。

  4. 向应用程序添加异常处理。

  5. 尝试将 View.add_callbacks 方法泛化,使其能够用于设置更多的回调函数。

进一步阅读

本书已涉及应用程序开发的几个重要方面。关键概念主要通过开发命令行应用程序来教授。如本章前面所述,有许多应用程序需要您学习特定领域的技巧。例如,在本章中,我们学习了在 GUI 应用程序中常见实现的 MVC 架构。让我们通过简要讨论一些重要的应用程序领域来结束本章,以及本书。这将为您提供一些有用的指针(附带大量链接!)到相关的库或应用程序框架。为了避免混乱,有关更多信息的相关网址将单独提供在本节末尾。以下是一个其他重要应用程序领域的列表;然而,这远非一个详尽的列表:

  1. 网页和移动应用程序开发:

    这些是重要的应用开发领域。要学习 Python 网络应用程序开发,你可以从探索 Python 中的 Flask 或 Django 框架开始。了解 MVC 也会有所帮助。对于移动应用程序开发,kivy 库可能是一个好的起点。

  2. 涉及数据库的应用程序:

    数据库管理系统DBMS)是另一个重要的应用领域。简而言之,DBMS 提供了一种创建、访问和管理数据的方式。Python 有几个库可以让你与数据库进行通信。

    SQLite3 是一个简单、轻量级的数据库系统。sqlite3 模块是 Python 的内置模块,它提供了一个符合 DB-API 2.0 的 SQL 接口。有几个用 Python 编写的客户端库提供了与数据库通信的方式。例如,PyMongo 模块提供了与 MongoDB 一起工作的工具,等等。

  3. 机器学习和深度学习:

    在数据科学领域,机器学习和深度学习库的使用正在迅速增长。了解 GPU 编程知识在这里会有所帮助。

    小贴士

    数据科学应用程序几乎总是涉及数据的可视化。使用 IPython 或 Jupyter 笔记本编写和共享交互式数据科学应用程序非常方便。有关更多详细信息,请参阅jupyter.org/

    对于机器学习,你可以探索 Apache Spark。这是一个通用集群计算系统,它为 Python 和其他语言提供了高级 API。MLlib 是 Apache Spark 的可扩展机器学习库。对于深度学习应用,Caffe 和 Tensorflow 是流行的深度学习框架之一。

  4. 物联网:

    这是一个快速发展的领域,Python 是开发应用程序时最受欢迎的语言之一。在这里,你可以使用 Python 不仅在服务器端处理数据(分析应用程序),还可以在终端设备上运行 Python 客户端。在这些应用程序中,你可以找到使用发布-订阅消息模式的例子,其中设备将数据发布到主题,而服务器端应用程序是一个接收这些数据的订阅者。

  5. 多媒体和游戏应用程序:

    这是一个广泛的话题,有几个框架和库可用于开发多媒体应用程序:

    • Python 维基文档了许多处理音频和视频的工具。GStreamerMoviePyMLT 是流行的框架之一。还可以查看 PyMedia 模块。

    • 图像处理有很多选项。查看 scikit-image、Opencv 和 pillow(Python Imaging Library 或 PIL 的分支)。

    • 有很多库对开发游戏和动画相关应用程序很有用。查看 PyGame 和 Pyglet。再次提醒,你可以在 Python 维基页面上找到完整的列表。

下表列出了一些有用的网络链接,提供了有关先前讨论的各种工具或领域的更多信息。

小贴士

在撰写本书时,书中所有提供的网页链接(URL)都是可访问的。正如 第一章 中所述,开发简单应用程序,这些链接可能会随着时间的推移而失效。如果发生这种情况,请使用适当的搜索词进行网络搜索。例如,如果你发现 PyMongo 模块的链接已损坏,你可以通过 Google 搜索 PyMongo Python MongoDB 来找到一些有用的资源!

工具或应用程序领域 进一步信息的网页链接
Flask flask.pocoo.org
Django www.djangoproject.com
Kivy kivy.org
sqlite3 docs.python.org/3/library/sqlite3.html
PyMongo api.mongodb.com/python/current/
Jupyter 笔记本 jupyter.org/
Apache Spark spark.apache.org
Caffe 框架 caffe.berkeleyvision.org
Tensorflow www.tensorflow.org/
物联网 (IoT) en.wikipedia.org/wiki/Internet_of_things
音频、视频处理 wiki.python.org/moin/AudioVideo
游戏和动画 wiki.python.org/moin/PythonGameLibraries

摘要

本章作为 Python GUI 编程的入门介绍。从不同 GUI 框架的概述开始,讨论了开发用户界面的一些重要实际设计考虑因素。你了解了事件驱动编程是什么,以及事件和事件处理。通过快速介绍 Tkinter 库,我们开发了简单的 Hut 游戏,这是 第一章 中第一个应用程序的 GUI 版本,开发简单应用程序

本章的后半部分介绍了 MVC 架构,并将 Hut 游戏转换为实现此架构。本章以对 GUI 应用程序测试的高级讨论结束。

摘要

posted @ 2025-09-22 13:20  绝不原创的飞龙  阅读(39)  评论(0)    收藏  举报