精通-Python-第二版-全-

精通 Python 第二版(全)

原文:zh.annas-archive.org/md5/170141883fc75e829195c599b2a0040d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Python 是一种易于学习的语言,任何人都可以在几分钟内通过“Hello, World!”脚本开始学习。然而,掌握 Python 却是一个完全不同的问题。

每个编程问题都有多种可能的解决方案,选择 Pythonic(惯用 Python)的解决方案并不总是显而易见的;它也可能随着时间的推移而变化。这本书不仅将展示一系列不同且新的技术,还将解释何时何地应该应用某种方法。引用 Tim Peters 的Python 之禅

“应该只有一个——最好是唯一一个——明显的方法来做这件事。尽管一开始可能不明显,除非你是荷兰人。”

尽管这并不总是有帮助,但本书的作者实际上是荷兰人。

这本书不是 Python 入门指南。它是一本可以教你 Python 中可能的高级技术的书,例如asyncio。它甚至详细介绍了 Python 3.10 的新特性,如结构化模式匹配(Python 的switch语句)。

作为一名拥有多年经验的 Python 程序员,我将尝试用相关的背景信息来合理化本书中做出的选择。然而,这些合理化并不是严格的指南,因为这些情况最终往往归结为个人风格。只需知道,它们源于经验,在许多情况下,是 Python 社区推荐的解决方案。

如果你不是蒙提·派森的粉丝,这本书中的一些参考文献可能对你来说并不明显。这本书在代码示例中通常使用spameggs代替foobar,因为 Python 编程语言是以蒙提·派森命名的。为了提供一些关于spameggs的背景信息,我建议你观看蒙提·派森的Spam片段。它非常滑稽。

这本书适合谁阅读

这本书是为已经熟悉 Python 并希望了解 Python 提供的更多高级特性的程序员而编写的。凭借本书的深度,我可以保证,如果他们愿意,几乎每个人都可以在这里学到新的东西。

如果你只知道 Python 的基础知识,请不要担心。本书从相对简单的部分开始,逐步过渡到更高级的主题,所以你应该没问题。

这本书涵盖了什么内容

第一章入门——每个项目一个环境,展示了管理 Python 版本、虚拟环境和包依赖的几种选项。

第二章交互式 Python 解释器,探讨了 Python 解释器的选项。Python 的默认解释器功能齐全,但还有更好的替代方案。通过一些修改或替换,你可以获得自动完成、语法高亮和图形输出。

第三章Pythonic 语法和常见陷阱,讨论了 Pythonic 编码,这是编写美观且易于阅读的 Python 代码的艺术。这章不是圣杯,但它充满了实现类似效果的技巧和最佳实践。

第四章Pythonic 设计模式,继续探讨了第三章的主题。编写 Pythonic 代码不仅仅是关于代码风格,还涉及到使用正确的设计模式和数据结构。本章将介绍可用的数据结构及其性能特征。

第五章函数式编程 – 可读性与简洁性的权衡,涵盖了函数式编程。有些人认为函数式编程是一种有点神秘的技艺,但正确应用时,它可以是一个非常强大的工具,使代码重用变得简单。它可能是你在编程中能接触到的基础数学的极致。

第六章装饰器 – 通过装饰实现代码重用,讨论了装饰器,这是一个用于重用方法的神奇工具。使用装饰器,你可以用其他函数包装函数和类,以修改它们的参数和返回值——这是一个极其有用的工具。

第七章生成器和协程 – 一次一步的无限,讨论了生成器。如果你已经知道将要使用每个元素,列表和元组是极好的,但更快的替代方案是只计算你实际需要的元素。这正是生成器为你做的事情:按需生成项目。

第八章元类 – 使类(而非实例)更智能,探讨了元类,即创建其他类的类。这是一种你很少需要但确实有实际用例的魔法,例如插件系统。

第九章文档 – 如何使用 Sphinx 和 reStructuredText,提供了有关文档的一些提示。编写文档可能不是大多数程序员的喜好活动,但它是有用的。本章展示了如何通过使用 Sphinx 和 reStructuredText 自动生成大量内容来简化这个过程。

第十章测试和日志记录 – 为虫子做准备,涵盖了如何实现测试和日志记录以防止和检测虫子。虫子是不可避免的,通过使用日志,我们可以追踪原因。通常,可以通过使用测试来防止虫子。

第十一章调试 – 解决虫子,基于第十章。上一章帮助我们找到虫子;现在我们需要解决它们。调试器在追踪困难虫子时可以提供巨大的帮助,本章展示了几个调试选项。

第十二章性能 – 跟踪和减少你的内存和 CPU 使用,讨论了代码的性能。程序员常见的常见问题是尝试优化不需要优化的代码,这是一个有趣但通常徒劳的练习。本章帮助你找到需要优化的代码。

第十三章asyncio – 无线程的多线程,涵盖了asyncio。等待外部资源,如网络资源,是应用程序最常见的瓶颈。使用asyncio,我们可以停止等待这些瓶颈,转而执行其他任务。

第十四章多进程 – 单个 CPU 核心不够用时,从不同的角度讨论了性能。使用多进程,我们可以并行使用多个处理器(甚至远程)。当你的处理器是瓶颈时,这可以大有帮助。

第十五章科学 Python 和绘图,涵盖了科学计算最重要的库。Python 已经成为科学目的的首选语言。

第十六章人工智能,展示了众多人工智能算法及其实现所需的库。除了是科学目的的首选语言外,大多数人工智能库目前也正在使用 Python 构建。

第十七章C/C++扩展、系统调用和 C/C++库,展示了如何从 Python 中使用现有的 C/C++库,这不仅允许重用,还可以极大地加快执行速度。Python 是一种很棒的语言,但通常并不是最快的解决方案。

第十八章打包 – 创建你自己的库或应用程序,将帮助你将代码打包成一个完全功能的 Python 包,供他人使用。在你构建了美妙的新库之后,你可能希望与世界分享它。

为了最大限度地利用这本书

根据你的经验水平,你应该从开头开始阅读,或者快速浏览章节,跳到对你有吸引力的部分。这本书适合中级到高级的 Python 程序员,但并非所有章节对每个人来说都同样有用。

例如,前两章是关于设置你的环境和 Python 解释器,看起来像是你可以完全跳过的章节,尤其是对于高级或专家级别的 Python 程序员来说,但我建议不要完全跳过,因为其中涉及了一些你可能不熟悉的实用工具和库。

本书各章节在一定程度上相互关联,但并没有严格的阅读顺序,你可以轻松挑选你想要阅读的部分。如果引用了早期章节,会有明确的指示。

代码示例的最新版本始终可以在github.com/mastering-python/code_2找到。

本存储库中的代码会自动进行测试,如果你有任何建议,欢迎提交 pull requests。

本书的大部分章节也包含结尾的练习,这将允许您测试您所学到的知识。由于问题通常有多个解决方案,您和其他本书的读者都可以在 GitHub 上提交并比较您的解决方案:github.com/mastering-python/exercises

鼓励您提交一个包含您解决方案的 pull request。当然,您也可以从这里学习他人的经验。

下载示例代码文件

本书代码包托管在 GitHub 上,网址为github.com/mastering-python/code_2,欢迎提交改进的 pull request。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

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

使用的约定

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

虽然本书主要遵循 PEP8 样式约定,但由于书籍格式的空间限制,做了一些妥协。简单来说,跨越多页的代码示例难以阅读,因此某些部分使用的空白比您通常期望的要少。完整的代码版本可在 GitHub 上找到,并且已自动测试以确保符合 PEP8 规范。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“itertools.chain()生成器是 Python 库中最简单但也是最实用的生成器之一。”

代码块设置如下:

from . import base

class A(base.Plugin):
    pass 

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

 :show-inheritance:
 `:private-members:`
 `:special-members:`
 `:inherited-members:` 

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

$ pip3 install -U mypy 

粗体:表示新术语、重要单词或您在屏幕上看到的单词,例如在菜单或对话框中。例如:“有时交互式解释器被称为REPL。这代表读取-评估-打印-循环。”

警告或重要提示看起来像这样。

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

联系我们

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

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

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

盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上材料的链接。

如果你有兴趣成为作者: 如果你有一个你擅长的主题,并且你对撰写或为书籍做出贡献感兴趣,请访问authors.packtpub.com

分享你的想法

一旦你阅读了《精通 Python 第二版》,我们很乐意听听你的想法!请点击此处直接进入亚马逊评论页面并分享你的反馈。

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

第一章:入门指南 – 每个项目一个环境

在本章中,你将了解为项目设置不同 Python 环境的方法,以及如何在单个系统上使用除包管理器提供的之外的多版本 Python。

环境设置完成后,我们将继续使用Python 包索引PyPI)和conda-forge(与 Anaconda 耦合的包索引)来安装包。

最后,我们将探讨几种跟踪项目依赖的方法。

总结来说,以下主题将被涵盖:

  • 使用venvpipenvpoetrypyenvanaconda创建环境

  • 通过pippoetrypipenvconda安装包

  • 使用requirements.txtpoetrypipenv管理依赖项

虚拟环境

Python 生态系统提供了许多安装和管理包的方法。你可以简单地下载并解压代码到你的项目目录,使用操作系统的包管理器,或者使用像pip这样的工具来安装一个包。为了确保你的包不会冲突,建议你使用虚拟环境。虚拟环境是一个轻量级的 Python 安装,它有自己的包目录和从创建环境时使用的二进制文件复制(或链接)的 Python 二进制文件。

为什么虚拟环境是一个好主意

为每个 Python 项目创建虚拟环境可能看起来有些麻烦,但它提供了足够的优势来这样做。更重要的是,有多个原因说明使用pip全局安装包是一个非常糟糕的主意:

  • 全局安装包通常需要提升权限(如sudorootadministrator),这是一个巨大的安全风险。当执行pip install <package>时,该包的setup.py将以执行pip install命令的用户身份执行。这意味着如果包包含恶意软件,它现在拥有超级用户权限去做任何它想做的事情。别忘了任何人都可以上传包到 PyPI(pypi.org)而无需任何审查。正如你将在本书后面看到的,任何人创建和上传一个包只需要几分钟。

  • 根据你如何安装 Python,它可能会与你的包管理器安装的现有包发生冲突。在 Ubuntu Linux 系统上,这意味着你可能会破坏pip甚至apt本身,因为pip install -U <package>会安装和更新包及其所有依赖项。

  • 它可能会破坏你的其他项目。许多项目尽力保持向后兼容,但每次pip install都可能引入新的/更新的依赖项,这可能会破坏与其他包和项目的兼容性。例如,Django Web 框架在不同版本之间的变化足够大,以至于使用 Django 的许多项目在升级到最新版本后可能需要进行几个更改。所以,当你将系统上的 Django 升级到最新版本,并且有一个为旧版本编写的项目时,你的项目很可能会被破坏。

  • 它会污染你的包列表,使得跟踪你的项目依赖变得困难。

除了缓解上述问题外,还有一个主要优势。在创建虚拟环境时,你可以指定 Python 版本(假设你已经安装了它)。这允许你轻松地在多个 Python 版本中测试和调试你的项目,同时保持除那之外的确切相同的包版本。

使用 venv 和 virtualenv

你可能已经熟悉了virtualenv,这是一个用于为你的 Python 安装创建虚拟环境的库。你可能不知道的是venv命令,它自 Python 3.3 版本以来就被包含在内,并且在大多数情况下可以作为virtualenv的替代品使用。为了简化问题,我建议创建一个目录来保存你所有的环境。有些人选择在项目内部使用env.venvvenv目录,但我基于几个原因不建议这样做:

  • 你的项目文件很重要,所以你可能希望尽可能频繁地备份它们。通过将包含所有已安装包的庞大环境保存在备份之外,你的备份会更快、更轻。

  • 你的项目目录保持可移植性。你甚至可以将其保存在远程驱动器或闪存驱动器上,而不用担心虚拟环境只能在单个系统上工作。

  • 它可以防止你意外地将虚拟环境文件添加到你的源代码控制系统中。

如果你确实决定将虚拟环境放在你的项目目录中,请确保将该目录添加到你的.gitignore文件(或类似文件)中,以便于你的版本控制系统。如果你想更快、更轻地备份,请将其排除在备份之外。有了正确的依赖跟踪,虚拟环境应该足够容易重建。

创建 venv

创建venv是一个相对简单的过程,但根据所使用的操作系统,它略有不同。

以下示例直接使用virtualenv模块,但为了方便,我建议使用稍后在本章中介绍的poetry,这个模块会在首次使用时自动为你创建虚拟环境。然而,在你升级到poetry之前,了解虚拟环境的工作原理是非常重要的。

自 Python 3.6 以来,pyvenv命令已被弃用,转而使用python -m venv

在 Ubuntu 的案例中,必须通过 apt 安装 python3-venv 包,因为 Ubuntu 开发者通过不包含 ensurepip 来破坏了默认的 Python 安装。

对于 Linux/Unix/OS X,使用 zshbash 作为 shell,操作如下:

$ python3 -m venv envs/your_env
$ source envs/your_env/bin/activate
(your_env) $ 

对于 Windows 的 cmd.exe(假设 python.exe 已添加到你的 PATH),操作如下:

C:\Users\wolph>python.exe -m venv envs\your_env
C:\Users\wolph>envs\your_env\Scripts\activate.bat
(your_env) C:\Users\wolph> 

PowerShell 也支持类似的方式使用:

PS C:\Users\wolph>python.exe -m venv envs\your_env
PS C:\Users\wolph> envs\your_env\Scripts\Activate.ps1
(your_env) PS C:\Users\wolph> 

第一个命令创建环境,第二个命令激活环境。激活环境后,pythonpip 等命令将使用特定版本的环境,因此 pip install 只会在你的虚拟环境中安装。激活环境的一个有用副作用是在命令前加上环境名称的前缀,在这个例子中是 (your_env)

注意,我们使用 sudo 或其他提升权限的方法。提升权限既是不必要的,也可能是一个潜在的安全风险,如 Why virtual environments are a good idea 部分所述。

使用 virtualenv 而不是 venv 的方法很简单,只需替换以下命令:

$ python3 -m venv envs/your_env 

使用这个:

$ virtualenv envs/your_env 

使用 virtualenv 而不是 venv 的一个额外优点是,你可以指定 Python 解释器:

$ virtualenv -p python3.8 envs/your_env 

而使用 venv 命令时,它会使用当前运行的 Python 安装,因此你需要通过以下调用来更改它:

$ python3.8 -m venv envs/your_env 

激活 venv/virtualenv

每次你关闭 shell 后回到项目,都需要重新激活环境。虚拟环境的激活包括:

  • 修改你的 PATH 环境变量,对于 Windows 使用 envs\your_env\Script,对于 Linux/Unix 则使用 envs/your_env/bin

  • 修改你的提示符,使其显示为 (your_env) $ 而不是 $,这表明你正在虚拟环境中工作。

poetry 的案例中,你可以使用 poetry shell 命令来创建一个新的带有激活环境的 shell。

虽然你可以轻松手动修改这些设置,但一个更简单的方法是运行创建虚拟环境时生成的 activate 脚本。

对于使用 zshbash 作为 shell 的 Linux/Unix,操作如下:

$ source envs/your_env/bin/activate
(your_env) $ 

对于使用 cmd.exe 的 Windows,操作如下:

C:\Users\wolph>envs\your_env\Scripts\activate.bat
(your_env) C:\Users\wolph> 

对于使用 PowerShell 的 Windows,操作如下:

PS C:\Users\wolph> envs\your_env\Scripts\Activate.ps1
(your_env) PS C:\Users\wolph> 

默认情况下,PowerShell 的权限可能过于严格,不允许这样做。你可以通过执行以下操作来更改当前 PowerShell 会话的策略:

Set-ExecutionPolicy Unrestricted -Scope Process 

如果你希望为当前用户的所有 PowerShell 会话永久更改它,请执行以下操作:

Set-ExecutionPolicy Unrestricted -Scope CurrentUser 

支持不同的 shell,如 fishcsh,分别使用 activate.fishactivate.csh 脚本。

当不使用交互式 shell(例如,使用 cron 作业时),你仍然可以使用 Python 解释器在 binscripts 目录中运行 Linux/Unix 或 Windows,分别代替运行 python script.py/usr/bin/python script.py,你可以使用:

/home/wolph/envs/your_env/bin/python script.py 

注意,通过 pip 安装的命令(以及 pip 本身)可以以类似的方式运行:

/home/wolph/envs/your_env/bin/pip 

安装包

在你的虚拟环境中安装包可以使用 pip 正常进行:

$ pip3 install <package> 

当查看已安装包的列表时,这是一个巨大的优势:

$ pip3 freeze 

因为我们的环境与系统隔离,所以我们只能看到我们明确安装的包和依赖项。

在某些情况下,完全隔离虚拟环境与系统 Python 包可能是一个缺点。它占用更多的磁盘空间,并且包可能与系统上的 C/C++ 库不同步。例如,PostgreSQL 数据库服务器通常与 psycopg2 包一起使用。虽然大多数平台都有可用的二进制文件,并且从源代码构建包相对容易,但有时使用与系统捆绑的包可能更方便。这样,你可以确信该包与已安装的 Python 和 PostgreSQL 版本兼容。

要将你的虚拟环境与系统包混合,你可以在创建环境时使用 --system-site-packages 标志:

$ python3 -m venv --system-site-packages envs/your_env 

当启用此标志时,环境将把系统 Python 环境的 sys.path 追加到你的虚拟环境的 sys.path 中,从而在虚拟环境中的 import 失败时,将系统包作为后备提供。

在你的虚拟环境中明确安装或更新一个包将有效地隐藏系统包在你的虚拟环境中。从你的虚拟环境中卸载该包将使其重新出现。

如你所料,这也影响了 pip freeze 的结果。幸运的是,pip freeze 可以被指示只列出虚拟环境本地的包,从而排除系统包:

$ pip3 freeze --local 

在本章的后面部分,我们将讨论 pipenv,它为你透明地处理虚拟环境的创建。

使用 pyenv

pyenv 库使得快速安装和切换多个 Python 版本变得非常容易。许多 Linux 和 Unix 系统的一个常见问题是包管理器选择稳定性而不是最新性。在大多数情况下,这确实是一个优点,但如果你正在运行一个需要最新和最佳 Python 版本或非常旧版本的项目,那么你需要手动编译和安装它。pyenv 包使这个过程对你来说变得非常容易,但仍然需要安装编译器。

为了测试目的,pyenv 的一个很好的补充是 tox 库。这个库允许你同时运行你的测试在一系列 Python 版本上。tox 的使用在 第十章测试和日志记录 – 准备处理错误 中有介绍。

要安装 pyenv,我建议访问 pyenv 项目页面,因为它高度依赖于你的操作系统和操作系统版本。对于 Linux/Unix,你可以使用常规的 pyenv 安装手册或 pyenv-installer (github.com/pyenv/pyenv-installer) 一行命令,如果你认为足够安全的话:

$ curl https://pyenv.run | bash 

确保遵循安装程序给出的说明。为了确保 pyenv 正确工作,你需要修改你的 .zshrc.bashrc 文件。

Windows 不原生支持 pyenv(除了 Windows Subsystem for Linux),但有一个可用的 pyenv 分支:github.com/pyenv-win/pyenv-win#installation

安装 pyenv 后,你可以使用以下命令查看支持的 Python 版本列表:

$ pyenv install --list 

列表相当长,但在 Linux/Unix 上使用 grep 可以缩短:

$ pyenv install --list | grep 3.10
  3.10.0
  3.10-dev
... 

一旦找到你喜欢的版本,你可以通过 install 命令来安装它:

$ pyenv install 3.10-dev
Cloning https://github.com/python/cpython...
Installing Python-3.10-dev...
Installed Python-3.10-dev to /home/wolph/.pyenv/versions/3.10-dev 

pyenv install 命令接受一个可选的 --debug 参数,它构建一个调试版本的 Python,使得使用像 gdb 这样的调试器调试 C/C++ 扩展成为可能。

一旦构建了 Python 版本,你可以全局激活它,但你也可以使用 pyenv-virtualenv 插件 (github.com/pyenv/pyenv-virtualenv) 为你新创建的 Python 环境创建一个 virtualenv

$ pyenv virtualenv 3.10-dev your_pyenv 

在前面的例子中,与 venvvirtualenv 命令相比,pyenv virtualenv 自动在 ~/.pyenv/versions/<version>/envs/ 目录中创建环境,因此不允许你完全指定自己的路径。然而,你可以通过 PYENV_ROOT 环境变量更改基本路径(~/.pyenv/)。使用环境目录中的 activate 脚本激活环境仍然是可能的,但这比必要的要复杂,因为有一个简单的快捷方式:

$ pyenv activate your_pyenv 

现在环境已经激活,你可以运行特定于环境的命令,例如 pip,并且它们只会修改你的环境。

使用 Anaconda

Anaconda 是一个支持 Python 和 R 编程语言的发行版。尽管它不仅仅是虚拟环境管理器;它是一个完全不同的 Python 发行版,拥有自己的虚拟环境系统和甚至一个完全不同的包系统。除了支持 PyPI,它还支持 conda-forge,该系统拥有大量专注于科学计算的包。

对于最终用户来说,最重要的区别是,包是通过conda命令而不是pip安装的。这为安装包时带来了更高级的依赖性检查。而pip会简单地安装一个包及其所有依赖项,而不考虑其他已安装的包,conda则会查看所有已安装的包,并确保不会安装一个不支持已安装包的版本。

conda包管理器在智能依赖性检查方面并不孤单。pipenv包管理器(本章稍后讨论)也做了类似的事情。

开始使用 Anaconda Navigator

在所有常见平台上安装 Anaconda 都非常简单。对于 Windows、OS X 和 Linux,您可以访问 Anaconda 网站并下载(图形)安装程序:www.anaconda.com/products/distribution#Downloads

安装完成后,继续的最简单方法是启动 Anaconda Navigator,它应该看起来像这样:

图 1.1:Anaconda Navigator – 主页

创建环境和安装包同样简单明了:

  1. 点击左侧的环境按钮。

  2. 点击下面的创建按钮。

  3. 输入您的姓名和 Python 版本。

  4. 点击创建以创建您的环境,并稍等片刻,直到 Anaconda 完成安装:

    图 1.2:Anaconda Navigator – 创建环境

一旦 Anaconda 完成了环境的创建,您应该会看到一个已安装包的列表。安装包可以通过将包列表的过滤器从已安装更改为所有,勾选您想要安装的包旁边的复选框,并应用更改来完成。

在创建环境时,Anaconda Navigator 会显示环境将被创建的位置。

开始使用 conda

虽然 Anaconda Navigator 是一个非常好的工具,可以用来获取概览,但能够从命令行运行代码也很方便。使用conda命令,这很幸运地非常简单。

首先,您需要打开conda shell。如果您愿意,可以从 Anaconda Navigator 中这样做,但您也可以直接运行它。在 Windows 上,您可以从开始菜单打开 Anaconda Prompt 或 Anaconda PowerShell Prompt。在 Linux 和 OS X 上,最方便的方法是初始化 shell 集成。对于 zsh,您可以使用:

$ conda init zsh 

对于其他 shell,过程类似。请注意,此过程会修改您的 shell 配置,以便每次打开 shell 时自动激活base环境。这可以通过简单的配置选项来禁用:

$ conda config --set auto_activate_base false 

如果自动激活未启用,您需要运行activate命令才能回到conda base环境:

$ conda activate
(base) $ 

如果您想激活您之前创建的环境而不是conda base环境,您需要指定名称:

$ conda activate conda_env
(conda_env) $ 

如果你还没有创建环境,你也可以使用命令行来创建:

$ conda create --name conda_env
Collecting package metadata (current_repodata.json): done
Solving environment: done
...
Proceed ([y]/n)? y

Preparing transaction: done
Verifying transaction: done
Executing transaction: done
... 

要列出可用的环境,可以使用conda info命令:

$ conda info --envs
# conda environments
#
base                  *  /usr/local/anaconda3
conda_env                /usr/local/anaconda3/envs/conda_env 

安装 conda 包

现在是安装包的时候了。对于conda包,你可以简单地使用conda install命令。例如,要安装我维护的progressbar2包,可以使用以下命令:

(conda_env) $ conda install progressbar2
Collecting package metadata (current_repodata.json): done
Solving environment: done

## Package Plan ##
  environment location: /usr/local/anaconda3/envs/conda_env

  added / updated specs:
    - progressbar2
The following packages will be downloaded:
...
The following NEW packages will be INSTALLED:
...
Proceed ([y]/n)? y

Downloading and Extracting Packages
... 

现在你可以运行 Python,看到包已经安装并且运行正常:

(conda_env) $ python
Python 3.8.0 (default, Nov  6 2019, 15:49:01)
[Clang 4.0.1 (tags/RELEASE_401/final)] :: Anaconda, Inc. on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import progressbar

>>> for _ in progressbar.progressbar(range(5)): pass
...
100% (5 of 5) |##############################| Elapsed Time: 0:00:00 Time:  0:00:00 

另一种验证包是否已安装的方法是运行conda list命令,它以类似于pip list的方式列出已安装的包:

(conda_env) $ conda list
# packages in environment at /usr/local/anaconda3/envs/conda_env:
#
# Name                    Version                   Build  Channel
... 

安装 PyPI 包

在 Anaconda 发行版中,对于 PyPI 包,我们有两种选项。最明显的是使用pip,但这有一个缺点,就是部分绕过了conda依赖检查器。虽然conda install会考虑通过 PyPI 安装的包,但pip命令可能会不希望地升级包。可以通过启用conda/pip互操作性设置来改善这种行为,但这会严重影响conda命令的性能:

$ conda config --set pip_interop_enabled True 

根据固定版本或conda性能对你有多重要,你也可以选择将包转换为conda包:

(conda_env) $ conda skeleton pypi progressbar2
Warning, the following versions were found for progressbar2
...
Use --version to specify a different version.
...
## Package Plan ##
...
The following NEW packages will be INSTALLED:
...
INFO:conda_build.config:--dirty flag and --keep-old-work not specified. Removing build/test folder after successful build/test. 

现在我们有了包,如果需要,我们可以修改文件,但使用自动生成的文件大多数时候都足够了。现在剩下的只是构建和安装包:

(conda_env) $ conda build progressbar2
...
(conda_env) $ conda install --use-local progressbar2
Collecting package metadata (current_repodata.json): done
Solving environment: done
... 

现在我们已经完成了!包是通过conda而不是pip安装的。

分享你的环境

当与他人协作时,拥有尽可能相似的环境是至关重要的,以避免调试本地问题。使用pip,我们可以简单地通过pip freeze创建一个需求文件,但这不会包括conda包。使用conda,实际上有一个更好的解决方案,它不仅存储了依赖和版本,还包括了安装渠道、环境名称和环境位置:

(conda_env) $ conda env export –file environment.yml
(conda_env) $ cat environment.yml
name: conda_env
channels:
  - defaults
dependencies:
...
prefix: /usr/local/anaconda3/envs/conda_env 

在创建环境时,可以安装从该环境文件中获取的包:

$ conda env create --name conda_env –file environment.yml 

或者,它们可以被添加到现有环境中:

(conda_env) $ conda env update --file environment.yml
Collecting package metadata (repodata.json): done
... 

管理依赖

管理依赖的最简单方法是存储在requirements.txt文件中。在其最简单的形式中,这是一个包名称列表,没有其他内容。此文件可以扩展以包含版本要求,甚至可以支持特定环境的安装。

安装和管理依赖的一种更高级的方法是使用像poetrypipenv这样的工具。内部,这些工具使用常规的pip安装方法,但它们构建了所有包的完整依赖图。这确保了所有包版本之间都是兼容的,并允许并行安装非依赖包。

使用 pip 和 requirements.txt 文件

requirements.txt 格式允许您以广泛或具体的方式列出您项目的所有依赖项。您可以轻松地自己创建此文件,也可以告诉 pip 为您生成它,甚至根据之前的 requirements.txt 文件生成新文件,以便您可以查看更改。我建议使用 pip freeze 生成初始文件,并选择您想要的依赖项(版本)。

例如,假设我们之前在我们的虚拟环境中运行了 pip freeze

(your_env) $ pip3 freeze
pkg-resources==0.0.0 

如果我们将该文件存储在 requirements.txt 文件中,安装一个包,并查看差异,我们得到以下结果:

(your_env) $ pip3 freeze > requirements.txt
(your_env) $ pip3 install progressbar2
Collecting progressbar2
...
Installing collected packages: six, python-utils, progressbar2
Successfully installed progressbar2-3.47.0 python-utils-2.3.0 six-1.13.0
(your_env) $ pip3 freeze -r requirements.txt 
pkg-resources==0.0.0
## The following requirements were added by pip freeze:
progressbar2==3.47.0
python-utils==2.3.0
six==1.13.0 

如您所见,pip freeze 命令自动检测了 sixprogressbar2python-utils 包的添加,并立即将这些版本锁定到当前已安装的版本。

requirements.txt 文件中的行在命令行上也被 pip 理解,因此要安装特定版本,您可以运行:

$ pip3 install 'progressbar2==3.47.0' 

版本指定符

然而,将版本严格锁定通常不是我们想要的,所以让我们将需求文件更改为只包含我们真正关心的内容:

# We want a progressbar that is at least version 3.47.0 since we've tested that.
# But newer versions are ok as well.
progressbar2>=3.47.0 

如果有人想安装此文件中的所有需求,他们可以简单地告诉 pip 包含该需求:

(your_env) $ pip3 install -r requirements.txt 
Requirement already satisfied: progressbar2>=3.47.0 in your_env/lib/python3.9/site-packages (from -r requirements.txt (line 1))
Requirement already satisfied: python-utils>=2.3.0 in your_env/lib/python3.9/site-packages (from progressbar2>=3.47.0->-r requirements.txt (line 1))
Requirement already satisfied: six in your_env/lib/python3.9/site-packages (from progressbar2>=3.47.0->-r requirements.txt (line 1)) 

在这种情况下,pip 会检查所有包是否已安装,并在需要时安装或更新它们。

-r requirements.txt 以递归方式工作,允许您包含多个需求文件。

现在假设我们遇到了最新版本中的错误,我们希望跳过它。我们可以假设只有这个特定的版本受到影响,因此我们只会将该版本列入黑名单:

# Progressbar 2 version 3.47.0 has a silly bug but anything beyond 3.46.0 still works with our code
progressbar2>=3.46,!=3.47.0 

最后,我们应该谈谈通配符。最常见的情况之一是需要特定的主要版本号,但仍然想要最新的安全更新和错误修复。有几种方式可以指定这些:

# Basic wildcard:
progressbar2 ==3.47.*
# Compatible release:
progressbar2 ~=3.47.1
# Compatible release above is identical to:
progressbar2 >=3.47.1, ==3.47.* 

使用兼容的发布模式 (~=),您可以选择同一主要版本中最新的版本,但至少是指定的版本。

版本标识和依赖性指定标准在 PEP 440 中有详细描述:

peps.python.org/pep-0440/

通过源控制仓库安装

现在假设我们非常不幸,该包还没有可用的有效版本,但它已经被修复在 Git 仓库的 develop 分支中。我们可以通过 pip 或通过一个 requirements.txt 文件来安装它,如下所示:

(your_env) $ pip3 install --editable 'git+https://github.com/wolph/python-progressbar@develop#egg=progressbar2'
Obtaining progressbar2 from git+https://github.com/wolph/python-progressbar@develop#egg=progressbar2
  Updating your_env/src/progressbar2 clone (to develop)
Requirement already satisfied: python-utils>=2.3.0 in your_env/lib/python3.9/site-packages (from progressbar2)
Requirement already satisfied: six in your_env/lib/python3.9/site-packages (from progressbar2)
Installing collected packages: progressbar2
  Found existing installation: progressbar2 3.47.0
    Uninstalling progressbar2-3.47.0:
      Successfully uninstalled progressbar2-3.47.0
  Running setup.py develop for progressbar2
Successfully installed progressbar2 

您可能会注意到 pip 不仅安装了包,而且还执行了 git cloneyour_env/src/progressbar2。这是由 --editable(简写选项:-e)标志引起的可选步骤,它还有额外的优势,即每次您重新运行命令时,git clone 都会更新。这也使得进入该目录、修改代码并创建带有修复的 pull request 变得相当容易。

除了 Git 之外,还支持其他源代码控制系统,如 Bazaar、Mercurial 和 Subversion。

使用扩展添加额外依赖

许多软件包为特定用例提供可选依赖。在progressbar2库的情况下,我添加了testsdocs扩展来安装运行包测试或构建文档所需的依赖。扩展可以通过用逗号分隔的方括号指定:

# Install the documentation and test extras in addition to the progressbar
progressbar2[docs,tests]
# A popular example is the installation of encryption libraries when using the requests library:
requests[security] 

使用环境标记的条件依赖

如果你的项目需要在多个系统上运行,你很可能会遇到不是所有系统都需要的依赖项。一个例子是某些操作系统需要但其他操作系统不需要的库。例如,我维护的portalocker包;在 Linux/Unix 系统上,所需的锁定机制是默认支持的。然而,在 Windows 上,它们需要pywin32包才能工作。包的install_requires部分(使用与requirements.txt相同的语法)包含以下行:

pywin32!=226; platform_system == "Windows" 

这指定了在 Windows 上需要pywin32软件包,由于一个错误,版本226被列入黑名单。

除了platform_system之外,还有几个其他标记,例如python_versionplatform_machine(例如包含x86_64架构)。

可以在 PEP 496 中找到完整的标记列表:peps.python.org/pep-0496/

另一个有用的例子是dataclasses库。这个库从 Python 3.7 版本开始就包含在 Python 中,所以我们只需要为旧版本的 Python 安装回溯包:

dataclasses; python_version < '3.7' 

使用poetry进行自动项目管理

poetry工具提供了一个非常易于使用的解决方案,用于创建、更新和共享你的 Python 项目。它也非常快速,这使得它成为项目的绝佳起点。

创建一个新的poetry项目

开始一个新项目非常简单。它会自动为你处理虚拟环境、依赖项和其他项目相关任务。要开始,我们将使用poetry init向导:

$ poetry init
This command will guide you through creating your pyproject.toml config.

Package name [t_00_poetry]:
Version [0.1.0]:
Description []:
Author [Rick van Hattem <Wolph@wol.ph>, n to skip]:
License []:
Compatible Python versions [³.10]:

Would you like to define your main dependencies interactively? (yes/no) [yes] no
Would you like to define your development dependencies interact...? (yes/no) [yes] no
...
Do you confirm generation? (yes/no) [yes] 

按照这几个问题,它会自动为我们创建一个包含所有输入数据和一些自动生成数据的pyproject.toml文件。正如你可能已经注意到的,它会自动为我们预填入几个值:

  • 项目名称。这是基于当前目录名称。

  • 版本。这是固定为0.1.0

  • 作者字段。这会查看你的git用户信息。这可以通过以下方式设置:

    $ git config --global user.name "Rick van Hattem"
    $ git config --global user.email "Wolph@wol.ph" 
    
  • Python 版本。这是基于你运行poetry时使用的 Python 版本,但可以通过poetry init --python=...进行自定义。

查看生成的pyproject.toml,我们可以看到以下内容:

[tool.poetry]
name = "t_00_poetry"
version = "0.1.0"
description = ""
authors = ["Rick van Hattem <Wolph@wol.ph>"]

[tool.poetry.dependencies]
python = "³.10"

[tool.poetry.dev-dependencies]

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" 

添加依赖项

一旦项目启动并运行,我们现在可以添加依赖项:

$ poetry add progressbar2
Using version ³.55.0 for progressbar2
...
Writing lock file
...
  • Installing progressbar2 (3.55.0) 

此命令会自动安装包,将其添加到pyproject.toml文件中,并将特定版本添加到poetry.lock文件中。在此命令之后,pyproject.toml文件在tool.poetry.dependencies部分添加了一行新内容:

[tool.poetry.dependencies]
python = "³.10"
progressbar2 = "³.55.0" 

poetry.lock文件要具体一些。虽然progressbar2依赖项可以有通配符版本,但poetry.lock文件存储了确切的版本、文件哈希以及所有已安装的依赖项:

[[package]]
name = "progressbar2"
version = "3.55.0"
... 
[package.dependencies]
python-utils = ">=2.3.0"
...
[package.extras]
docs = ["sphinx (>=1.7.4)"]
...
[metadata]
lock-version = "1.1"
python-versions = "³.10"
content-hash = "c4235fba0428ce7877f5a94075e19731e5d45caa73ff2e0345e5dd269332bff0"

[metadata.files]
progressbar2 = [
    {file = "progressbar2-3.55.0-py2.py3-none-any.whl", hash = "sha256:..."},
    {file = "progressbar2-3.55.0.tar.gz", hash = "sha256:..."},
]
... 

通过拥有所有这些数据,我们可以在另一个系统上构建或重建一个基于poetry的项目虚拟环境,使其与原始系统上创建的完全一致。为了按照poetry.lock文件中指定的方式安装、升级和/或降级包,我们需要一个单独的命令:

$ poetry install
Installing dependencies from lock file
... 

如果你熟悉npmyarn命令,这与你所熟悉的方式非常相似。

升级依赖项

在前面的示例中,我们只是添加了一个依赖项,而没有指定显式的版本。通常这是一个安全的方法,因为默认版本要求将允许该主要版本内的任何版本。

如果项目使用常规 Python 版本或语义版本(更多关于这一点在第十八章打包 - 创建您自己的库或应用程序),那应该很完美。至少,我的所有项目(如 progressbar2)通常都是向后兼容和大部分向前兼容的,所以仅仅修复主要版本就足够了。在这种情况下,poetry默认设置为版本³.55.0,这意味着任何大于或等于 3.55.0 的版本,直到(但不包括)4.0.0 都是有效的。

由于poetry.lock文件,poetry install将导致安装那些确切版本,而不是新版本。那么我们如何升级依赖项?为此,我们将首先安装progressbar2库的较旧版本:

$ poetry add 'progressbar2=3.1.0' 

现在,我们将pyproject.toml文件中的版本放宽到³.1.0

[tool.poetry.dependencies]
progressbar2 = "³.1.0" 

一旦我们这样做,poetry install仍然会保留3.1.0版本,但我们可以让poetry为我们更新依赖项:

$ poetry update
...
  • Updating progressbar2 (3.1.0 -> 3.55.0) 

现在,poetry已经很好地更新了我们的项目依赖项,同时仍然遵守我们在pyproject.toml文件中设定的要求。如果你将所有包的版本要求设置为*,它将始终更新到彼此兼容的最新版本。

运行命令

要使用poetry环境运行单个命令,你可以使用poetry run

$ poetry run pip 

然而,对于整个开发会话,我建议使用shell命令:

$ poetry shell 

在此之后,你可以像往常一样运行所有 Python 命令,但现在这些命令将是从激活的虚拟环境中运行的。

对于 cron 作业来说,情况类似,但你需要确保首先更改目录:

0 3 * * *       cd /home/wolph/workspace/poetry_project/ && poetry run python script.py 

此命令每天凌晨 3 点(24 小时制,所以是上午)运行。

注意,由于环境不同,cron 可能找不到 poetry 命令。在这种情况下,我建议使用 poetry 命令的绝对路径,这可以通过 which 命令找到:

$ which poetry
/usr/local/bin/poetry 

使用 pipenv 进行自动依赖项跟踪

对于大型项目,您的依赖项可能会经常变化,这使得手动操作 requirements.txt 文件相当繁琐。此外,在安装包之前必须创建虚拟环境,如果您在多个项目上工作,这也是一项相当重复的任务。pipenv 工具旨在为您透明地解决这些问题,同时确保所有依赖项都是兼容和更新的。作为最后的额外好处,它结合了严格和宽松的依赖项版本,这样您就可以确保您的生产环境使用与您测试时完全相同的版本。

初始使用很简单;进入您的项目目录并安装一个包。让我们试一试:

$ pipenv install progressbar2
Creating a virtualenv for this project...
...
Using /usr/local/bin/python3 (3.10.4) to create virtualenv...
...
![](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/ms-py-2e/img/B15882_01_001.png) Successfully created virtual environment!
...
Creating a Pipfile for this project...
Installing progressbar2...
Adding progressbar2 to Pipfile's [packages]...
![](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/ms-py-2e/img/B15882_01_001.png) Installation Succeeded
Pipfile.lock not found, creating...
...
![](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/ms-py-2e/img/B15882_01_001.png) Success!
Updated Pipfile.lock (996b11)!
Installing dependencies from Pipfile.lock (996b11)...
  ![](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/ms-py-2e/img/B15882_01_002.png) 0/0 — 00:00:0 

即使是简化的输出也相当多。但让我们看看发生了什么:

  • 创建了一个虚拟环境。

  • 创建了一个 Pipfile,其中包含您指定的依赖项。如果您指定了特定版本,它将被添加到 Pipfile 中;否则,它将是一个通配符要求,这意味着只要没有与其他包冲突,任何版本都将被接受。

  • 创建了一个包含已安装包和版本的 Pipfile.lock,这允许在不同的机器上使用完全相同的版本进行相同的安装。

生成的 Pipfile 包含以下内容:

[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]

[packages]
progressbar2 = "*"

[requires]
python_version = "3.10" 

Pipfile.lock 稍大一些,但立即显示了这种方法的优势:

{
    ...
    "default": {
        "progressbar2": {
            "hashes": [
                "sha256:14d3165a1781d053...",
                "sha256:2562ba3e554433f0..."
            ],
            "index": "pypi",
            "version": "==4.0.0"
        },
        "python-utils": {
            "hashes": [
                "sha256:4dace6420c5f50d6...",
                "sha256:93d9cdc8b8580669..."
            ],
            "markers": "python_version >= '3.7'",
            "version": "==3.1.0"
        },
        ...
    },
    "develop": {}
} 

如您所见,除了确切的包版本外,Pipfile.lock 还包含了包的哈希值。在这种情况下,该包提供了 .tar.gz(源文件)和 .whl(wheel 文件),这就是为什么有两个哈希值。此外,Pipfile.lock 包含了 pipenv 安装的所有包,包括所有依赖项。

使用这些哈希值,您可以确信在部署过程中,您将收到完全相同的文件,而不会是损坏的甚至恶意文件。

由于版本完全固定,您也可以确信任何使用 Pipfile.lock 部署您项目的用户都将获得完全相同的包版本。当与其他开发者合作工作时,这非常有用。

要安装 Pipfile 中指定的所有必要包(即使是初始安装),您只需运行:

$ pipenv install
Installing dependencies from Pipfile.lock (5c99e1)…
  ![](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/ms-py-2e/img/B15882_01_002.png) 3/3 — 00:00:00
To activate this project's virtualenv, run pipenv shell.
Alternatively, run a command inside the virtualenv with pipenv run. 

每次运行pipenv install package时,Pipfile将自动根据你的更改进行修改,并检查是否存在不兼容的包。但最大的缺点是pipenv对于大型项目可能会变得非常慢。我遇到过多个项目,其中无操作的pip install命令需要几分钟的时间,因为需要检索和检查整个依赖图。然而,在大多数情况下,这样做仍然是值得的;增加的功能可以为你节省很多麻烦。

不要忘记使用pipenv run前缀或从pipenv shell运行你的常规 Python 命令。

更新你的包

由于依赖图,你可以轻松更新你的包,而无需担心依赖冲突。一条命令就可以完成:

$ pipenv update 

如果你仍然因为某些包没有相互检查而遇到版本问题,你可以通过指定你想要或不需要的包的版本来修复这个问题:

$ pipenv install 'progressbar2!=3.47.0'
Installing progressbar2!=3.47.0…
Adding progressbar2 to Pipfile's [packages]…
![](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/ms-py-2e/img/B15882_01_001.png) Installation Succeeded 
Pipfile.lock (c9327e) out of date, updating to (5c99e1)…
![](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/ms-py-2e/img/B15882_01_001.png) Success! 
Updated Pipfile.lock (c9327e)!
Installing dependencies from Pipfile.lock (c9327e)…
  ![](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/ms-py-2e/img/B15882_01_002.png) 3/3 — 00:00:00 

通过运行该命令,Pipfile中的packages部分将更改为:

[packages]
progressbar2 = "!=3.47.0" 

部署到生产环境

在所有生产服务器上获取完全相同的版本对于防止难以追踪的错误至关重要。为此,你可以告诉pipenv安装Pipenv.lock文件中指定的所有内容,同时检查Pipfile.lock是否过时。一条命令就可以拥有一个完全功能的生产虚拟环境,其中包含所有已安装的包。

让我们创建一个新的目录,看看一切是否顺利:

$ mkdir ../pipenv_production
$ cp Pipfile Pipfile.lock ../pipenv_production/
$ cd ../pipenv_production/
$ pipenv install --deploy
Creating a virtualenv for this project...
Pipfile: /home/wolph/workspace/pipenv_production/Pipfile
Using /usr/bin/python3 (3.10.4) to create virtualenv...
...
![](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/ms-py-2e/img/B15882_01_001.png) Successfully created virtual environment!
...
Installing dependencies from Pipfile.lock (996b11)...
  ![](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/ms-py-2e/img/B15882_01_002.png) 2/2 — 00:00:01
$ pipenv shell
Launching subshell in virtual environment...
(pipenv_production) $ pip3 freeze
progressbar2==4.0.0
python-utils==3.1.0 

所有版本都完全符合预期,并已准备好使用。

运行 cron 命令

要在pipenv shell之外运行你的 Python 命令,你可以使用pipenv run前缀。而不是python,你会运行pipenv run python。在正常使用中,这比激活pipenv shell要少用得多,但对于非交互式会话,如 cron 作业,这是一个基本功能。例如,每天凌晨 3:00(24 小时制,所以是上午)运行的 cron 作业可能看起来像这样:

0 3 * * *       cd /home/wolph/workspace/pipenv_project/ && pipenv run python script.py 

练习

本章讨论的许多主题已经提供了完整的示例,几乎没有留下练习的空间。然而,还有其他资源可以探索。

阅读 Python 增强提案(PEPs)

了解本章(以及所有后续章节)讨论的主题的一个好方法是阅读 PEP 页面。这些提案是在更改被接受到 Python 核心之前编写的。请注意,Python 网站上并非所有 PEP 都被接受,但它们将保留在 Python 网站上:

结合 pyenv 和 poetry 或 pipenv

即使本章没有涉及,也没有什么阻止你告诉 poetrypipenv 使用基于 pyenv 的 Python 解释器。试试看吧!

将现有项目转换为 poetry 项目

本练习的一部分应该是创建一个新的 pyproject.toml 文件,或者将现有的 requirements.txt 文件转换为 pyproject.toml

摘要

在本章中,你学习了为什么虚拟环境是有用的,你发现了它们的几个实现及其优点。我们探讨了如何创建虚拟环境以及如何安装多个不同的 Python 版本。最后,我们介绍了如何管理 Python 项目的依赖项。

由于 Python 是一种解释型语言,因此直接从解释器运行代码而不是通过 Python 文件运行是很容易的。

默认的 Python 解释器已经具有命令历史记录,并且根据你的安装,还提供基本的自动补全功能。

但是,使用替代解释器,我们可以在解释器中获得更多功能,例如语法高亮、智能自动补全(包括文档)等。

下一章将向我们展示几个替代解释器和它们的优点。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第二章:交互式 Python 解释器

现在我们已经安装了一个有效的 Python,我们需要运行一些代码。最明显的方法是创建一个 Python 文件并执行它。然而,从交互式 Python 解释器中交互式地开发代码通常更快。虽然标准 Python 解释器已经很强大,但还有许多增强和替代方案可用。

替代解释器/外壳提供如下功能:

  • 智能自动完成

  • 语法高亮

  • 保存和加载会话

  • 自动缩进

  • 图形/图表输出

在本章中,您将了解:

  • 替代解释器:

    • bpython

    • ptpython

    • ipython

    • jupyter

  • 如何增强解释器

Python 解释器

标准 Python 解释器已经很强大,但通过自定义还有更多选项可用。首先,让我们从'Hello world!'开始。因为解释器使用 REPL,所有输出都将自动打印,我们只需创建一个字符串即可。

有时交互式解释器被称为REPL。这代表读取-评估-打印-循环。这实际上意味着您的所有语句都将立即执行并打印到您的屏幕上。

首先,我们需要启动解释器;然后,我们可以输入我们的命令:

$ python3
Python 3.9.0
[GCC 7.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 'Hello world!'
'Hello world!' 

这很简单。并且请注意,我们不必使用print('Hello world!')来显示输出。

许多解释器对 Windows 的支持仅限于有限。虽然它们在某种程度上都能工作,但使用 Linux 或 OS X 系统您的体验会更好。我建议至少尝试一次从(虚拟)Linux/Unix 机器上使用它们,以体验其全部功能。

修改解释器

作为我们的第一个增强功能,我们将向解释器的范围内添加一些方便的快捷键。我们不必每次启动解释器时都输入import pprint; pprint.pprint(...)来美化输出,使用pp(...)而不必每次都运行一个import语句会更有用。为此,我们将创建一个 Python 文件,每次我们运行 Python 时都会执行它。在 Linux 和 OS X 系统上,我建议使用~/.config/python/init.py;在 Windows 上,可能像C:\Users\rick\AppData\Local\Python\init.py这样的路径更合适。在这个文件中,我们可以添加将被执行的常规 Python 代码。

Python 不会自动找到该文件;您需要通过使用PYTHONSTARTUP环境变量来告诉 Python 在哪里查找该文件。在 Linux 和 OS X 上,您可以更改~/.zshrc~/.bashrc文件或您的 shell 拥有的任何文件,并添加:

$ export PYTHONSTARTUP=~/.config/python/init.py 

此文件每次您打开一个新的 shell 会话时都会自动执行。所以,一旦您打开一个新的 shell 会话,您就完成了。

如果您想激活当前 shell 的此功能,您也可以在当前 shell 中运行上面的 export 行。

在 Windows 上,您需要在高级系统设置中找到并更改该屏幕上的环境变量。

现在我们可以将这些行添加到文件中,以便默认启用并增强自动补全(pprint/pp)和美观格式化(pformat/pf):

from pprint import pprint as pp
from pprint import pformat as pf 

当我们运行 Python 解释器时,现在我们将在我们的作用域中拥有pppf

>>> pp(dict(spam=0xA, eggs=0xB))
{'eggs': 11, 'spam': 10}
>>> pf(dict(spam=0xA, eggs=0xB))
"{'eggs': 11, 'spam': 10}" 

通过这些小的改动,你可以使你的生活变得更加轻松。例如,你可以修改你的sys.path以包括一个包含自定义库的目录。你也可以使用sys.ps1sys.ps2变量来更改你的提示符。为了说明,我们将查看更改之前的解释器:

# Modifying prompt
>>> if True:
...     print('Hello!')
Hello! 

现在我们将修改sys.ps1sys.ps2,并再次运行相同的代码:

>>> import sys

>>> sys.ps1 = '> '
>>> sys.ps2 = '. '

# With modified prompt
> if True:
.     print('Hello!')
Hello! 

上述配置表明,如果你愿意,你可以轻松地将解释器更改为略有不同的输出。然而,出于一致性的目的,可能最好保持它不变。

启用并增强自动补全

解释器中最有用的添加之一是rlcompleter模块。此模块使你的解释器中启用 Tab 激活的自动补全,如果readline模块可用,则自动激活。

rlcompleter模块依赖于readline模块的可用性,该模块不是 Windows 系统上 Python 的捆绑模块。幸运的是,可以轻松安装一个替代品:

$ pip3 install pyreadline 

为自动补全添加一些额外选项将非常有用。首先,看看默认输出:

>>> sandwich = dict(spam=2, eggs=1, sausage=1)
>>> sandwich.<TAB>
sandwich.clear(       sandwich.fromkeys(    sandwich.items(       sandwich.pop(
sandwich.setdefault(  sandwich.values(      sandwich.copy(        sandwich.get(
sandwich.keys(        sandwich.popitem(     sandwich.update(
>>> sandwich[<TAB> 

如你所见,"."的 Tab 补全工作得很好,但"["的 Tab 补全没有任何作用。了解可用项将很有用,所以现在我们将努力添加这个功能。需要注意的是,这个例子使用了一些在后面的章节中解释的技术,但这对现在来说并不重要:

import __main__
import re
import atexit
import readline
import rlcompleter

class Completer(rlcompleter.Completer):
    ITEM_RE = re.compile(r'(?P<expression>.+?)\[(?P<key>[^\[]*)')

    def complete(self, text, state):
        # Init namespace. From 'rlcompleter.Completer.complete'
        if self.use_main_ns:
            self.namespace = __main__.__dict__

        # If we find a [, try and return the keys
        if '[' in text:
            # At state 0 we need to prefetch the matches, after
            # that we use the cached results
            if state == 0:
                self.matches = list(self.item_matches(text))

            # Try and return the match if it exists
            try:
                return self.matches[state]
            except IndexError:
                pass
        else:
            # Fallback to the normal completion
            return super().complete(text, state)

    def item_matches(self, text):
        # Look for the pattern expression[key
        match = self.ITEM_RE.match(text)
        if match:
            search_key = match.group('key').lstrip()
            expression = match.group('expression')

            # Strip quotes from the key
            if search_key and search_key[0] in {"'", '"'}:
                search_key = search_key.strip(search_key[0])

            # Fetch the object from the namespace
            object_ = eval(expression, self.namespace)

            # Duck typing, check if we have a 'keys()' attribute
            if hasattr(object_, 'keys'):
                # Fetch the keys by executing the 'keys()' method
                # Can you guess where the bug is?
                keys = object_.keys()
                for i, key in enumerate(keys):
                    # Limit to 25 items for safety, could be infinite
                    if i >= 25:
                        break

                    # Only return matching results
                    if key.startswith(search_key):
                        yield f'{expression}[{key!r}]'

# By default readline doesn't call the autocompleter for [ because
# it's considered a delimiter. With a little bit of work we can
# fix this however :)
delims = readline.get_completer_delims()
# Remove [, ' and " from the delimiters
delims = delims.replace('[', '').replace('"', '').replace("'", '')
# Set the delimiters
readline.set_completer_delims(delims)

# Create and set the completer
completer = Completer()
readline.set_completer(completer.complete)
# Add a cleanup call on Python exit
atexit.register(lambda: readline.set_completer(None))
print('Done initializing the tab completer') 

这段代码相当多,如果你仔细看,你会在这个有限的例子中注意到多个潜在的 bug。我只是试图在这里展示一个工作示例,而不引入太多的复杂性,所以没有考虑几个边缘情况。为了使脚本工作,我们需要像之前讨论的那样将其存储在PYTHONSTARTUP文件中。打开解释器后,你应该看到print()的结果,这样你可以验证脚本是否已加载。有了这个添加,我们现在可以完成字典键:

Done initializing the tab completer
>>> sandwich = dict(spam=2, eggs=1, sausage=1)
>>> sandwich['<TAB>
sandwich['eggs']     sandwich['sausage']  sandwich['spam'] 

自然地,你可以扩展它以包括颜色、其他补全和许多其他有用的功能。

由于这个补全调用object.keys(),这里存在潜在的风险。如果出于某种原因,object.keys()方法代码不安全执行,这段代码可能会很危险。也许你正在运行外部库,或者你的代码已覆盖keys()方法以执行一个重量级数据库函数。如果object.keys()是一个在执行一次后耗尽的生成器,那么在运行实际代码后你将没有任何结果。

此外,eval()函数在执行未知代码时可能很危险。在这种情况下,eval()只执行我们自己输入的行,所以这里的问题不大。

替代解释器

现在你已经看到了常规 Python 解释器的一些功能,让我们来看看一些增强的替代方案。有许多选项可用,但我们将限制在这里介绍最流行的几个:

  • bpython

  • ptpython

  • ipython

  • jupyter(基于网页的 ipython

让我们开始吧。

bpython

bpython 解释器是 Python 解释器的 curses 接口,提供了许多有用的功能,同时仍然非常类似于常规的 Python 解释器。

curses 库允许你创建一个完全功能的基于文本的用户界面TUI)。TUI 可以让你完全控制你想要写入屏幕的位置。常规的 Python 解释器是一个命令行界面CLI),通常只允许你向屏幕追加内容。使用 TUI,你可以将内容写入屏幕上的任何位置,使其功能与图形用户界面GUI)有某种程度的相似性。

bpython 的一些关键特性:

  • 输入时自动补全(与 rlcompleter 的制表符补全相反)

  • 输入时内联语法高亮

  • 自动函数参数文档

  • 一个可以撤销/倒退的功能,用于删除最后一行

  • 容易重新加载导入的模块,这样你就可以在不重新启动解释器的情况下测试外部代码的变化

  • 在外部编辑器中快速更改代码(对于多行函数/代码块来说很方便)

  • 能够将会话保存到文件/粘贴板

大多数这些功能都会为你完全自动且透明地工作。在我们开始使用 bpython 之前,我们需要安装它。一个简单的 pip install 就足够了:

$ pip3 install bpython 

为了说明自动启用的功能,以下是用于常规 Python 解释器补全的代码输出:

$ bpython
bpython version 0.21 on top of Python 3.9.6
>>> sandwich = dict(spam=2, eggs=1, sausage=1)
┌────────────────────────────────────────────────────────────────┐
│ dict: (self, *args, **kwargs)                                  │
│ Initialize self.  See help(type(self)) for accurate signature. │
└────────────────────────────────────────────────────────────────┘
>>> sandwich.
┌────────────────────────────────────────────────────────────────┐
│ clear               copy                fromkeys               │
│ get                 items               keys                   │
│ pop                 popitem             setdefault             │
│ update              values                                     │
└────────────────────────────────────────────────────────────────┘
>>> sandwich[
┌────────────────────────────────────────────────────────────────┐
│ 'eggs'     'sausage'  'spam'                                   │
└────────────────────────────────────────────────────────────────┘ 

如果你在自己的系统上运行此代码,你也会看到高亮显示以及自动补全的中间状态。我鼓励你试一试;前面的摘录不足以展示。

撤销你的会话

对于更高级的功能,让我们也尝试一下。首先,让我们从倒退功能开始。虽然它看起来只是简单地删除最后一行,但在后台,它实际上会重新播放你的整个历史记录,除了最后一行。这意味着如果你的代码不安全多次运行,可能会引起错误。以下代码说明了倒退功能的用法和限制:

>>> with open('bpython.txt', 'a') as fh:
...     fh.write('x')
...
1

>>> with open('bpython.txt') as fh:
...     print(fh.read())
...
x

>>> sandwich = dict(spam=2, eggs=1, sausage=1) 

现在如果我们按下 Ctrl + R 来“倒退”最后一行,我们会得到以下输出:

>>> with open('bpython.txt', 'a') as fh:
...     fh.write('x')
...
1

>>> with open('bpython.txt') as fh:
...     print(fh.read())
...
xx

>>> 

如你所见,最后一行现在消失了,但这还不是全部;fh.read() 行的输出现在是 xx 而不是 x,,这意味着写入 x 的行被执行了两次。此外,部分行也会被执行,所以当你撤销缩进的代码块时,你会看到一个错误,直到你再次执行有效的代码。

重新加载模块

在开发过程中,我经常在我的常规编辑器中编写代码,并在 Python 壳中测试执行。

当这样开发时,Python 的一个非常有用的特性是使用importlib.reload()重新加载导入的模块。当您有多个(嵌套)模块时,这可能会很快变得繁琐。这就是bpython中的重新加载快捷键能大量帮助的地方。通过使用键盘上的F6按钮,bpython不仅会在sys.modules中的所有模块上运行importlib.reload(),而且还会以类似您之前看到的回放功能的方式重新运行会话中的代码。

为了演示这一点,我们将首先创建一个名为bpython_reload.py的文件,并包含以下代码:

with open('reload.txt', 'a+') as fh:
    fh.write('x')
    fh.seek(0)
    print(fh.read()) 

这将以追加模式打开reload.txt文件进行读写。这意味着fh.write('x')将追加到文件末尾。fh.seek(0)将跳转到文件开头(位置 0),以便print(fh.read())可以将整个文件内容打印到屏幕上。

现在我们打开bpython shell 并导入模块:

>>> import bpython_reload
x 

如果我们在同一个 shell 中按F6按钮,我们会看到已经写入了一个额外的字符,并且代码已经被重新执行:

>>> import bpython_reload
xx
Reloaded at ... by user. 

这是一个非常有用的特性,与回放功能有相同的警告,即并非所有代码都是安全的,可以无副作用地重新执行。

ptpython

ptpython解释器比bpython(自 2009 年可用)年轻(自 2014 年可用),因此可能稍微不够成熟,功能也不够丰富。然而,它正在非常积极地开发,并且绝对值得提及。虽然目前没有与bpython中类似的代码重新加载功能,但还有一些其他有用的功能是bpython目前所缺少的:

  • 多行代码编辑

  • 鼠标支持

  • Vi 和 Emacs 键绑定

  • 输入时的语法检查

  • 历史浏览器

  • 输出突出显示

这些特性都是您需要亲自体验的;在这种情况下,一本书并不是演示的正确媒介。无论如何,这个解释器绝对值得一看。

安装可以通过简单的pip install完成:

$ pip3 install ptpython 

安装后,您可以使用ptpython命令运行它:

$ ptpython
>>> 

一旦解释器开始运行,您可以使用内置菜单配置ptpython(按F2)。在该菜单中,您可以配置和启用/禁用诸如字典完成、输入时完成、输入验证、颜色深度和突出显示颜色等功能。

IPython 和 Jupyter

IPython 解释器与之前提到的解释器完全不同。它不仅是功能最丰富的解释器,还是包括并行计算、与可视化工具包集成、交互式小部件和基于 Web 的解释器(Jupyter)在内的整个生态系统的一部分。

IPython 解释器的某些关键特性:

  • 简单的对象内省

  • 输出格式化(而不是repr(),IPython 调用pprint.pformat()

  • 命令历史可以通过新会话和旧会话中的变量和魔法方法访问

  • 保存和加载会话

  • 一系列魔法命令和快捷方式

  • 访问如 cdls 这样的常规 shell 命令

  • 可扩展的自动补全,不仅支持 Python 方法和函数,还支持文件名

IPython 项目的其他一些特性在关于调试、多进程、科学编程和机器学习的章节中有所介绍。

IPython 的基本安装可以使用 pip install 完成:

$ pip3 install ipython 

通过 Anaconda 安装也是一个不错的选择,尤其是如果你计划使用大量的数据科学包,这些包通常通过 conda 安装和管理要容易得多:

$ conda install ipython 

基本解释器使用

IPython 解释器可以像其他解释器一样使用,但与其他解释器的输出略有不同。以下是一个涵盖一些关键特性的示例:

$ ipython
Python 3.9.6 (default, Jun 29 2021, 05:25:02)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.25.0 -- An enhanced Interactive Python. Type '?' for help.
In [1]: sandwich = dict(spam=2, eggs=1, sausage=1)

In [2]: sandwich
Out[2]: {'spam': 2, 'eggs': 1, 'sausage': 1}

In [3]: sandwich = dict(spam=2, eggs=1, sausage=1, bacon=1, chees
   ...: e=2, lettuce=1, tomatoes=3, pickles=1)

In [4]: sandwich
Out[4]:
{'spam': 2,
 'eggs': 1,
 'sausage': 1,
 'bacon': 1,
 'cheese': 2,
 'lettuce': 1,
 'tomatoes': 3,
 'pickles': 1}

In [5]: _i1
Out[5]: 'sandwich = dict(spam=2, eggs=1, sausage=1)'

In [6]: !echo "$_i2"
sandwich 

第一行是一个简单的变量声明;那里没有什么特别之处。第二行显示了第一行声明的变量的打印输出。

现在我们声明一个具有更多项的类似字典。你可以看到,如果行太长而无法在屏幕上显示,输出将自动格式化并拆分到多行以提高可读性。这实际上相当于 print()pprint.pprint() 的区别。

In [5]: _i1, 我们可以看到一个有用的内部变量,即输入行。_i<N>_ih[<N>] 变量提供了你所写的行。同样,最后输入的三行分别通过 _i_ii_iii 可用。

如果命令生成了输出,它将通过 _<N> 提供。最后三个输出结果分别通过 ______ 提供。

最后,我们通过在行前加上 ! 并传递 Python 变量 _i2 来调用外部 shell 函数 echo。在执行外部 shell 函数时,我们可以通过在它们前面加上 $ 来传递 Python 变量。

保存和加载会话

能够保存和加载会话,以便你总能回到它,这是一个极其有用的功能。在 IPython 中,通常有多种方法可以实现这一目标。首先,每个会话已经自动为你保存,无需任何努力。要加载上一个会话,你可以运行:

In [1]: %load ~1/

In [2]: # %load ~1/
   ...: sandwich = dict(spam=2, eggs=1, sausage=1)

In [3]: sandwich
Out[3]: {'spam': 2, 'eggs': 1, 'sausage': 1} 

此命令使用与 %history 命令相同的语法。以下是 %history 语法快速概述:

  • 5: 第 5 行

  • -t 5: 第 5 行作为纯 Python(不带 IPython 魔法)

  • 10-20: 第 10 到 20 行

  • 10/20: 第 10 个会话的第 20 行

  • ~0/: 当前会话

  • ~1/10-20: 前一个会话的第 10 到 20 行

  • ~5/-~2: 从 5 个会话前到 2 个会话前的所有内容

如果你知道一个会话将非常重要,并且想要确保它被保存,可以使用 %logstart

In [1]: %logstart
Activating auto-logging. Current session state plus future input saved.
Filename       : ipython_log.py
Mode           : rotate
Output logging : False
Raw input log  : False
Timestamping   : False
State          : active 

如输出所示,此功能是可配置的。默认情况下,它将写入(如果存在,则旋转)ipython_log.py。一旦再次运行此命令,之前的日志文件将被重命名为ipython_log.001~,依此类推,对于较旧的文件。

使用%load命令进行加载,并将立即重新激活自动记录,因为它也在回放该行:

In [1]: %load ipython_log.py

In [2]: # %load ipython_log.py
   ...: # IPython log file
   ...:
   ...: get_ipython().run_line_magic('logstart', '')
   ...:
Activating auto-logging. Current session state plus future input saved.
Filename       : ipython_log.py
Mode           : rotate
Output logging : False
Raw input log  : False
Timestamping   : False
State          : active 

自然地,使用%save也可以手动保存。我建议添加-r参数,以便将会话以原始文件格式保存,而不是常规的 Python 文件。让我们来展示一下区别:

In [1]: %save session_filename ~0/
The following commands were written to file 'session_filename.py':
get_ipython().run_line_magic('save', 'session_filename ~0/')

In [2]: %save -r raw_session ~0/
The following commands were written to file 'raw_session.ipy':
%save session_filename ~0/
%save -r raw_session ~0/ 

如果你不需要从常规 Python 解释器运行会话,使用原始文件会更容易阅读。

常规 Python 提示符/doctest 模式

默认的ipython提示符非常有用,但有时可能会觉得有点冗长,而且你无法轻松地将结果复制到文件中进行 doctests(我们将在第十章中详细介绍 doctests,测试和日志记录 – 准备错误)。正因为如此,激活%doctest_mode魔法函数可能很方便,这样你的提示符看起来就像熟悉的 Python 解释器:

In [1]: sandwich = dict(spam=2, eggs=1, sausage=1, bacon=1, chees
   ...: e=2, lettuce=1, tomatoes=3, pickles=1)

In [2]: sandwich
Out[2]:
{'spam': 2,
 'eggs': 1,
 'sausage': 1,
 'bacon': 1,
 'cheese': 2,
 'lettuce': 1,
 'tomatoes': 3,
 'pickles': 1}

In [3]: %doctest_mode
Exception reporting mode: Plain
Doctest mode is: ON
>>> sandwich
{'spam': 2, 'eggs': 1, 'sausage': 1, 'bacon': 1, 'cheese': 2, 'lettuce': 1, 'tomatoes': 3, 'pickles': 1} 

正如你所见,这也影响了输出的格式,所以它与常规的 Python shell 非常相似。虽然仍然可以使用魔法函数,但输出几乎与常规 Python shell 相同。

反思与帮助

IPython 最有用的快捷键之一是?。这是访问 IPython 帮助、对象帮助和对象反思的快捷键。如果你正在寻找 IPython 解释器功能的最新概述,请先输入?并开始阅读。如果你打算使用 IPython,我强烈推荐这样做。

???既可以作为后缀也可以作为前缀使用。因此,?historyhistory?都会在%history命令的文档中返回。

因为?快捷键显示了文档,所以它对常规 Python 对象和 IPython 中的魔法函数都很有用。魔法函数实际上并不那么神奇;除了以%为前缀的名称外,它们只是常规的 Python 函数。除了?之外,还有??,,它试图显示对象的源代码:

In [1]: import pathlib

In [2]: pathlib.Path.name?
Type:        property
String form: <property object at 0x10c540ef0>
Docstring:   The final path component, if any.

In [3]: pathlib.Path.name??
Type:        property
String form: <property object at 0x10c540ef0>
Source:
# pathlib.Path.name.fget
@property
def name(self):
    """The final path component, if any."""
    parts = self._parts
    if len(parts) == (1 if (self._drv or self._root) else 0):
        return ''
    return parts[-1] 

自动补全

自动补全是ipython真正有趣的地方。除了常规的代码补全外,ipython还会补全文件名以及用于特殊字符的 LaTeX/Unicode。

真正有用的部分开始于创建你自己的对象时。虽然常规的自动补全可以无缝工作,但你还可以自定义补全,使其只返回特定项,或者如果需要,从数据库中进行动态查找。使用起来当然足够简单:

In [1]: class CompletionExample:
   ...:     def __dir__(self):
   ...:         return ['attribute', 'autocompletion']
   ...:
   ...:     def _ipython_key_completions_(self):
   ...:         return ['key', 'autocompletion']
   ...:

In [2]: completion = CompletionExample()

In [3]: completion.a<TAB>
                     attribute
                     autocompletion

In [4]: completion['aut<TAB>
                        %autoawait     %autoindent
                        %autocall      %automagic
                        autocompletion 

现在是 LaTeX/Unicode 字符补全的时候了。虽然这可能不是你经常需要使用的东西,但我发现当你需要它时,它非常有用:

In [1]: '\pi<TAB>'

In [1]: 'π 

Jupyter

Jupyter 项目提供了一个令人惊叹的基于网络的解释器(Jupyter Notebook),这使得 Python 对于需要编写一些脚本但不是职业程序员的用户来说更加易于访问。它允许无缝混合 Python 代码、LaTeX 和其他标记语言。

基于网络的解释器并不是 Jupyter 项目的唯一或最重要的功能。Jupyter 项目的最大优势是它允许您从本地机器连接到远程系统(称为“内核”)。

最初,该项目是 IPython 项目的一部分,当时 ipython 仍然是一个包含所有组件的庞大单体应用程序。从那时起,IPython 项目已经分裂成多个 IPython 项目和几个以 Jupyter 命名的项目。内部,它们仍然使用大量的相同代码库,Jupyter 严重依赖于 IPython。

在我们继续之前,我们应该看看 Jupyter 和 IPython 项目的当前结构,并描述最重要的项目:

  • jupyter:包含所有 Jupyter 项目的元包。

  • notebook:作为 Jupyter 项目一部分的基于网络的解释器。

  • lab:下一代基于网络的解释器,可以并排提供多个笔记本,甚至支持嵌入其他语言如 Markdown、R 和 LaTeX 的代码。

  • ipython:具有魔法功能的 Python 终端界面。

  • jupyter_console:Jupyter 版本的 ipython

  • ipywidgets:可以在 notebook 中用作用户输入的交互式小部件。

  • ipyparallel:用于在多个服务器上轻松并行执行 Python 代码的库。关于这一点,将在 第十四章多进程 - 当单个 CPU 核心不够用时 中详细介绍。

  • traitlets:IPython 和 Jupyter 所使用的配置系统,它允许您创建具有验证的可配置对象。关于这一点,将在 第八章元类 - 使类(而非实例)更智能 中详细介绍。

图 2.1 展示了 Jupyter 和 IPython 项目的复杂性和大小,以及它们是如何协同工作的:

项目关系架构图

图 2.1:Jupyter 和 IPython 项目结构

从这个概述中,您可能会想知道为什么同时存在 ipythonjupyter console。区别在于 ipython 在单个进程中完全本地运行,而 jupyter console 在远程内核上运行一切。当本地运行时,这意味着 Jupyter 将自动启动一个后台处理内核,任何 Jupyter 应用程序都可以连接到它。

Jupyter 项目本身就可以填满几本书,所以我们将在本章中仅介绍最常用的功能。此外,第十四章 更详细地介绍了多进程方面。而 第十五章科学 Python 和绘图 也依赖于 Jupyter Notebook。

安装 Jupyter

首先,让我们从安装开始。使用简单的 pip installconda install 进行安装就足够简单了:

$ pip3 install --upgrade jupyterlab 

现在,剩下的就是启动它了。一旦运行以下命令,您的网络浏览器应该会自动打开:

$ jupyter lab 

如果安装过程中遇到麻烦,或者您想要为大量依赖性强的包进行简单安装,Docker 镜像也是可用的。在本书后面的数据科学章节中,使用了 jupyter/tensorflow-notebook Docker 镜像:

$ docker run -p 8888:8888 jupyter/tensorflow-notebook 

这将运行 Docker 镜像并将端口 8888 转发到正在运行的 jupyter lab,以便您可以访问它。请注意,由于默认安全设置,您需要通过控制台提供的链接打开 jupyter lab,其中包含随机生成的安全令牌。它看起来可能像这样:

http://127.0.0.1:8888/?token=.......... 

一旦启动并运行,您在浏览器中应该会看到类似以下内容:

图 2.2:Jupyter 仪表板

现在,您可以创建一个新的笔记本:

图 2.3:Jupyter 中的新文件

并开始使用 tab 补全和所有类似 ipython 的功能进行输入:

图 2.4:Jupyter 标签补全

在笔记本中,您可以拥有多个单元格。每个单元格可以有多个代码行,并且与 IPython 解释器类似,只有一个关键区别:只有最后一行决定返回什么作为输出,而不是每行单独打印。但这并不妨碍您使用 print() 函数。

图 2.5:Jupyter 输出

如果需要,每个单元格都可以单独(重新)执行,或者一次性执行所有单元格,以确保笔记本仍然正常工作。除了代码单元格外,Jupyter 还支持多种标记语言,如 Markdown,以添加格式化的文档。

由于它是一种基于网络的格式,您可以附加各种对象,例如视频、音频文件、PDF 文件、图像和渲染。例如,LaTeX 公式在普通解释器中通常无法渲染,但使用 Jupyter,渲染 LaTeX 公式则变得非常容易:

图 2.6:Jupyter 中的 LaTeX 公式

最后,我们还有交互式小部件,这是使用笔记本而不是常规的 shell 会话的最佳特性之一:

图 2.7:Jupyter 小部件

通过移动滑块,函数将被再次调用,结果将立即更新。这在调试函数时非常有用。在关于用户界面的章节中,您将学习如何创建自己的界面。

IPython 摘要

IPython 和 Jupyter 项目中的所有功能列表单独成书也毫不夸张,所以我们只简要概述了解释器支持的一小部分功能。

后续章节将介绍项目的其他部分,但 IPython 文档是您的良师益友。文档非常详细,并且大部分内容都是最新的。

下面概述了一些您可能想要查看的快捷键/魔法函数:

  • %quickref:大多数解释器功能和魔法函数的快速参考列表。

  • %cd:更改ipython会话的当前工作目录。

  • %paste:从剪贴板粘贴预格式化的代码块,以确保您的缩进正确粘贴,而不是由于自动缩进而损坏/覆盖。

  • %edit:打开外部编辑器以轻松编辑代码块。这在快速测试多行代码块时非常有用。例如,%edit -p命令将重新编辑上一个(-p)代码块。

  • %timeit:使用timeit模块快速基准测试一行 Python 代码的快捷方式。

  • ?:查看任何对象的文档。

  • ??:查看任何 Python 对象的源代码。原生方法,如sum(),是编译的 C 代码,因此源代码无法轻松获取。

练习

  1. 我们创建的rlcompleter增强功能目前仅处理字典。尝试扩展代码,使其也支持列表、字符串和元组。

  2. 为补全器添加颜色(提示:使用colorama进行着色)。

  3. 而不是手动使用我们自己的对象内省来完成,尝试使用jedi库来自动完成,该库执行静态代码分析。

    静态代码分析在不执行代码的情况下检查代码。这意味着它在运行时完全安全,与之前我们编写的自动完成不同,后者在object.keys()中运行代码。

  4. 尝试创建一个Hello <ipywidget>,这样可以通过笔记本编辑人的名字,而无需更改代码。

  5. 尝试创建一个脚本,该脚本将遍历您所有的先前ipython会话以查找给定的模式。

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。鼓励您提交自己的解决方案,并从他人的替代方案中学习。

摘要

本章向您展示了可用的 Python 解释器的一些以及它们的优缺点。此外,您还简要了解了 IPython 和 Jupyter 能为我们提供的内容。第十五章科学 Python 和绘图几乎完全使用 Jupyter 笔记本,并演示了一些更强大的功能,例如绘图集成。

对于大多数通用的 Python 程序员,我建议使用bpythonptpython,因为它们确实是快速且轻量级的解释器,可以(重新)启动,同时仍然提供许多有用的功能。

如果您的重点是科学编程和/或处理大型数据集,那么 IPython 或 JupyterLab 可能更有用。这些工具功能更强大,但代价是启动时间和系统要求略高。我根据使用情况个人使用两者。当测试几行简单的 Python 代码和/或验证小代码块的行为时,我主要使用bpython/ptpython。当处理较大的代码块和数据时,我倾向于使用 IPython(或ptipython)或甚至 JupyterLab。

下一章涵盖了 Python 风格指南,其中哪些规则很重要,以及为什么它们很重要。可读性是 Python 哲学最重要的方面之一,你将学习编写更干净、更易读的 Python 代码的方法和风格。简而言之,你将了解什么是 Pythonic 代码以及如何编写它。

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第三章:Pythonic 语法和常见陷阱

在本章中,你将学习如何编写 Python 风格的代码,同时了解一些 Python 的常见陷阱以及如何规避它们。这些陷阱从将列表或字典(可变对象)作为参数传递到更高级的陷阱,如闭包中的后期绑定。你还将了解如何以干净的方式解决循环导入问题。本章示例中使用的某些技术可能对于如此早期的章节来说显得有些高级。不过,不要担心,因为其内部工作原理将在后面的内容中介绍。

本章我们将探讨以下主题:

  • 代码风格(PEP 8、pyflakesflake8等)

  • 常见陷阱(将列表或字典(可变对象)作为函数参数,值传递与引用传递,以及继承行为)

本章中使用的 Pythonic 代码的定义基于普遍接受的编码指南和我的主观意见。在项目工作中,保持与项目编码风格的一致性是最重要的。

Python 简史

Python 项目始于 1989 年 12 月,是 Guido van Rossum 在圣诞节休假期间的一个业余项目。他的目标是编写一个易于使用的 ABC 编程语言的继任者,并修复限制其适用性的问题。Python 的主要设计目标之一,并且始终如此,就是可读性。这就是本章第一部分的内容:可读性。

为了便于添加新功能和保持可读性,开发了Python 增强提案PEP)流程。此流程允许任何人提交一个 PEP 以添加新功能、库或其他内容。经过在 Python 邮件列表上的讨论和一些改进后,将决定接受或拒绝该提案。

Python 风格指南(PEP 8:[peps.python.org/pep-0008/](https://peps.python.org/pep-0008/))最初作为那些 PEP 之一被提交,被接受,并且自那时起一直得到定期改进。它包含许多广受好评的约定,以及一些有争议的约定。特别是,79 个字符的最大行长度是讨论的焦点。将行限制在 79 个字符确实有一些优点。最初,这个选择是因为终端宽度为 80 个字符,但如今,更大的显示器允许你将多个文件并排放置。对于文档字符串和注释,建议使用 72 个字符的限制以提高可读性。此外,这是 Linux/Unix man(手册)页面的常见约定。

虽然仅仅风格指南本身并不能使代码具有 Python 风格,正如Python 的禅宗(PEP 20:[https://peps.python.org/pep-0020/](https://peps.python.org/pep-0020/))优雅地所说:“优美胜于丑陋。”PEP 8 以精确的方式定义了代码的格式,而 PEP 20 则更多地是一种哲学和心态。

几乎 30 年来,Python 项目的所有重大决策都是由 Guido van Rossum 做出的,他被亲切地称为BDFL终身仁慈独裁者)。不幸的是,BDFL 中的“终身”部分在关于 PEP 572 的激烈辩论后并未实现。PEP 572(本章后面将详细介绍)是一个关于赋值运算符的提案,它允许在if语句内设置变量,这是 C、C++、C#等语言中的常见做法。Guido van Rossum 并不喜欢这种语法,并反对了 PEP。这引发了一场巨大的辩论,他遇到了如此大的阻力,以至于他不得不辞去 BDFL 的职务。许多人对此感到悲伤,因为 Guido van Rossum,这位社区普遍喜爱的决策者,觉得他不得不这样做。至少对我来说,我将非常怀念他作为决策者的洞察力。我希望我们还能看到他的“时间机器”发挥作用几次。Guido van Rossum 被认为拥有时间机器,因为他反复用“我昨晚刚刚实现了那个功能”来回答功能请求。

没有 BDFL 做出最终决定,Python 社区不得不想出一种新的决策方式,为此已经撰写了一系列提案来解决这一问题:

经过一番小辩论后,PEP 8016——指导委员会模型——被接受为解决方案。PEP 81XX 已被预留用于未来指导委员会的选举,其中 PEP 8100 用于 2019 年的选举,PEP 8101 用于 2020 年的选举,以此类推。

代码风格 - 什么是 Pythonic 代码?

当你第一次听说 Pythonic 代码时,你可能会认为它是一种编程范式,类似于面向对象或函数式编程。实际上,它更多的是一种设计哲学。Python 让你自由选择以面向对象、过程式、函数式、面向方面或甚至逻辑导向的方式编程。这些自由使得 Python 成为了一种极佳的编程语言,但它们也有缺点,即需要更多的纪律来保持代码的整洁和可读性。PEP 8 告诉我们如何格式化代码,而 PEP 20 则是关于风格以及如何编写 Pythonic 代码。PEP 20,Pythonic 哲学,是关于以下方面的代码:

  • 整洁

  • 简单

  • 美观

  • 明确性

  • 可读性

其中大部分听起来像是常识,我认为它们应该是。然而,有些情况下,编写代码并没有一个明显的方法(除非你是荷兰人,当然,你将在本章后面读到这一点)。这就是本章的目标——帮助你学习如何编写漂亮的 Python 代码,并理解 Python 风格指南中某些决策的原因。

让我们开始吧。

使用空白符而不是花括号

对于非 Python 程序员来说,Python 最常见的一个抱怨是使用空白符而不是花括号。两种情况都有可说的,最终,这并不那么重要。由于几乎每种编程语言默认都使用类似的缩进规则,即使有花括号,为什么不尽可能省略花括号,使代码更易读呢?这就是 Guido van Rossum 在设计 Python 语言时可能想到的。

在某个时候,一些程序员问 Guido van Rossum Python 是否将支持花括号。从那天起,通过__future__导入已经可以使用花括号了。试一试:

>>> from __future__ import braces 

接下来,让我们谈谈字符串的格式化。

字符串格式化——printf、str.format 还是 f-string?

Python 长期以来一直支持 printf 风格(%)和str.format,因此你很可能已经熟悉这两种方法。随着 Python 3.6 的引入,又增加了一个选项,即 f-string(PEP 498)。f-string 是str.format的便捷简写,有助于简洁(因此,我会争辩说,可读性)。

PEP 498 – 字面字符串插值:peps.python.org/pep-0498/

本书的前一版主要使用了 printf 风格,因为在代码示例中简洁性很重要。虽然按照 PEP 8 的规定最大行长度为 79 个字符,但本书在换行前限制为 66 个字符。有了 f-string,我们终于有了 printf 风格的简洁替代方案。

本书运行代码的小贴士

由于大部分代码包含>>>前缀,只需将其复制/粘贴到 IPython 中,它就会像常规 Python 代码一样执行。

或者,本书的 GitHub 仓库有一个脚本来自动将 doctest 风格示例转换为常规 Python:github.com/mastering-python/code_2/blob/master/doctest_to_python.py

为了展示 f-string 的力量,让我们看看str.format和 printf 风格并排的几个示例。

本章中的示例显示了 Python 控制台返回的输出。对于常规 Python 文件,您需要添加print()才能看到输出。

简单格式化

格式化一个简单的字符串:

# Simple formatting
>>> name = 'Rick'

>>> 'Hi %s' % name
'Hi Rick'

>>> 'Hi {}'.format(name)
'Hi Rick' 

使用两位小数格式化浮点数:

>>> value = 1 / 3

>>> '%.2f' % value
'0.33'

>>> '{:.2f}'.format(value)
'0.33' 

第一个真正的优势在于多次使用变量时。在不使用命名值的情况下,printf 风格无法做到这一点:

>>> name = 'Rick'
>>> value = 1 / 3

>>> 'Hi {0}, value: {1:.3f}. Bye {0}'.format(name, value)
'Hi Rick, value: 0.333\. Bye Rick' 

如您所见,我们通过使用引用{0}两次来使用name

命名变量

使用命名变量相当类似,这也是我们接触到 f 字符串魔力的地方:

>>> name = 'Rick'

>>> 'Hi %(name)s' % dict(name=name)
'Hi Rick'

>>> 'Hi {name}'.format(name=name)
'Hi Rick'

>>> f'Hi {name}'
'Hi Rick' 

如您所见,使用 f 字符串,变量会自动从作用域中获取。这基本上是一个简写形式:

>>> 'Hi {name}'.format(**globals())
'Hi Rick' 

任意表达式

任意表达式是 f 字符串真正强大之处。f 字符串的功能远超 printf 风格的字符串插值。f 字符串还支持完整的 Python 表达式,这意味着它们支持复杂对象、调用方法、if语句,甚至循环:

## Accessing dict items
>>> username = 'wolph'
>>> a = 123
>>> b = 456
>>> some_dict = dict(a=a, b=b)

>>> f'''a: {some_dict['a']}'''
'a: 123'

>>> f'''sum: {some_dict['a'] + some_dict['b']}'''
'sum: 579'

## Python expressions, specifically an inline if statement
>>> f'if statement: {a if a > b else b}'
'if statement: 456'

## Function calls
>>> f'min: {min(a, b)}'
'min: 123'

>>> f'Hi {username}. And in uppercase: {username.upper()}'
'Hi wolph. And in uppercase: WOLPH'

## Loops
>>> f'Squares: {[x ** 2 for x in range(5)]}'
'Squares: [0, 1, 4, 9, 16]' 

PEP 20,Python 的禅意

Python 的禅意,如前文Python 简史部分所述,是关于不仅能够工作,而且具有 Python 风格的代码。Python 风格的代码是可读的、简洁的且易于维护。PEP 20 说得最好:

“长期 Python 程序员 Tim Peters 简洁地传达了 BDFL(Python 之父)为 Python 设计所制定的指导原则,仅用 20 条格言,其中只有 19 条被记录下来。”

接下来的几段将用一些示例代码解释这 19 条格言的意图。

为了清晰起见,让我们在开始之前看看这些格言:

>>> import this
The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those! 

美比丑更好

美是主观的,当然,但仍然有一些风格规则是值得遵守的。例如(来自 PEP 8)的规则:

  • 使用空格而不是制表符缩进

  • 行长度限制

  • 每个语句都在单独的一行

  • 每个导入都在单独的一行

当有疑问时,始终牢记一致性比固定规则更重要。如果一个项目更喜欢使用制表符而不是空格,或者反之,最好是保持这种制表符/空格,而不是通过替换制表符/空格来可能破坏现有的代码(和版本控制历史)。

简而言之,与其使用这种难以阅读的代码,它显示了 10 以下的奇数:

>>> filter_modulo = lambda i, m: (i[j] for j in \
...                               range(len(i)) if i[j] % m)
>>> list(filter_modulo(range(10), 2))
[1, 3, 5, 7, 9] 

我更愿意:

>>> def filter_modulo(items, modulo):
...     for item in items:
...         if item % modulo:
...             yield item
...

>>> list(filter_modulo(range(10), 2))
[1, 3, 5, 7, 9] 

它更简单,更容易阅读,而且稍微更美观!

这些例子是生成器的早期介绍。生成器将在第七章生成器和协程——一次一步的无限中更详细地讨论。

明确优于隐晦

导入、参数和变量名只是许多情况下显式代码更容易阅读的例子,代价是编写代码时需要更多的努力和/或冗长。

这里有一个例子说明这可能会出错:

>>> from os import *
>>> from asyncio import *

>>> assert wait 

在这种情况下,wait从哪里来?你可能会说这很明显——它来自os。但有时你会犯错。在 Windows 上,os模块没有wait函数,所以应该是asyncio.wait

情况可能更糟:许多编辑器和代码清理工具都有排序导入功能。如果你的导入顺序发生变化,你的项目行为也会发生变化。

立即的修复方法很简单:

>>> from os import path
>>> from asyncio import wait

>>> assert wait 

使用这种方法,我们至少有了一种找出wait从何而来途径。但我建议更进一步,通过模块导入,这样执行代码立即显示哪个函数被调用:

>>> import os
>>> import asyncio

>>> assert asyncio.wait
>>> assert os.path 

对于 *args**kwargs 也是如此。虽然它们非常有用,但它们可能会使你的函数和类的使用变得不那么明显:

>>> def spam(eggs, *args, **kwargs):
...     for arg in args:
...         eggs += arg
...     for extra_egg in kwargs.get('extra_eggs', []):
...         eggs += extra_egg
...     return eggs

>>> spam(1, 2, 3, extra_eggs=[4, 5])
15 

不看函数内部的代码,你无法知道应该传递什么作为 **kwargs*args 做了什么。当然,一个合理的函数名在这里能有所帮助:

>>> def sum_ints(*args):
...     total = 0
...     for arg in args:
...         total += arg
...     return total

>>> sum_ints(1, 2, 3, 4, 5)
15 

对于这些情况,文档显然是有帮助的,我经常使用 *args**kwargs,但确实是一个好主意,至少让最常见的参数明确。即使这要求你为父类重复参数,这也使得代码更加清晰。在将来重构父类时,你会知道是否有子类仍在使用某些参数。

简单优于复杂

“简单优于复杂。复杂优于复杂化。”

保持事物简单往往比你想象的要困难得多。复杂性有逐渐渗透的趋势。你从一个美丽的脚本开始,然后不知不觉中,特性膨胀使其变得复杂(或者更糟,复杂化)。

>>> import math
>>> import itertools

>>> def primes_complicated():
...     sieved = dict()
...     i = 2
...     
...     while True:
...         if i not in sieved:
...             yield i
...             sieved[i * i] = [i]
...         else:
...             for j in sieved[i]:
...                 sieved.setdefault(i + j, []).append(j)
...             del sieved[i]
...         
...         i += 1

>>> list(itertools.islice(primes_complicated(), 10))
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29] 

初看,这段代码可能看起来有些困难。然而,如果你熟悉欧几里得筛法,你会很快意识到正在发生什么。只需一点努力,你就会发现这个算法并不复杂,但使用了一些技巧来减少必要的计算。

我们可以做得更好;让我们看看一个不同的例子,它展示了 Python 3.8 的赋值运算符:

>>> def primes_complex():
...     numbers = itertools.count(2)
...     while True:
...         yield (prime := next(numbers))
...         numbers = filter(prime.__rmod__, numbers)

>>> list(itertools.islice(primes_complex(), 10))
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29] 

这个算法看起来不那么令人畏惧,但我不认为它一开始就那么明显。prime := next(numbers) 是 Python 3.8 中在同一个语句中设置变量并立即返回它的版本。prime.__rmod__ 使用给定的数字进行取模操作,类似于之前的例子。

然而,可能令人困惑的是,numbers 变量在每个迭代中都被重新分配,并添加了过滤器。让我们看看更好的解决方案:

>>> def is_prime(number):
...     if number == 0 or number == 1:
...         return False
...     for modulo in range(2, number):
...         if not number % modulo:
...             return False
...     else:
...         return True

>>> def primes_simple():
...     for i in itertools.count():
...         if is_prime(i):
...             yield i

>>> list(itertools.islice(primes_simple(), 10))
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29] 

现在我们来到了生成素数的最明显的方法之一。is_prime 函数非常简单,立即显示了 is_prime 正在做什么。而 primes_simple 函数不过是一个带有过滤器的循环。

除非你真的有充分的理由选择复杂的方法,否则尽量让你的代码尽可能简单。当你未来阅读代码时,你(也许还有其他人)会为此感到感激。

平铺优于嵌套

嵌套的代码很快就会变得难以阅读和理解。这里没有严格的规则,但一般来说,当你有多个嵌套循环级别时,就是时候重构了。

只需看看以下示例,它打印了一个二维矩阵列表。虽然这里没有什么具体的问题,但将其拆分成更多函数可能会更容易理解其目的,也更容易进行测试:

>>> def between_and_modulo(value, a, b, modulo):
...     if value >= a:
...         if value <= b:
...             if value % modulo:
...                 return True
...     return False

>>> for i in range(10):
...     if between_and_modulo(i, 2, 9, 2):
...         print(i, end=' ')
3 5 7 9 

这是更简洁的版本:

>>> def between_and_modulo(value, a, b, modulo):
...     if value < a:
...         return False
...     elif value > b:
...         return False
...     elif not value % modulo:
...         return False
...     else:
...         return True

>>> for i in range(10):
...     if between_and_modulo(i, 2, 9, 2):
...         print(i, end=' ')
3 5 7 9 

这个例子可能有点牵强,但想法是合理的。深层嵌套的代码很容易变得难以阅读,将代码拆分成多行甚至函数可以大大提高可读性。

稀疏比密集好

空白通常是一件好事。是的,它会使你的文件更长,你的代码会占用更多空间,但如果你的代码逻辑上拆分得好,它可以帮助提高可读性。让我们举一个例子:

>>> f=lambda x:0**x or x*f(x-1)
>>> f(40)
815915283247897734345611269596115894272000000000 

通过查看输出和代码,你可能能够猜出这是一个阶乘函数。但它的运作原理可能并不立即明显。让我们尝试重新编写:

>>> def factorial(x):
...     if 0 ** x:
...         return 1
...     else:
...         return x * factorial(x - 1)

>>> factorial(40)
815915283247897734345611269596115894272000000000 

通过使用合适的名称,扩展if语句,并明确返回1,它突然变得非常明显发生了什么。

可读性很重要

短不一定意味着更容易阅读。让我们以斐波那契数为例。有很多人写这段代码的方法,其中许多很难阅读:

>>> from functools import reduce

>>> fib=lambda n:n if n<2 else fib(n-1)+fib(n-2)
>>> fib(10)
55

>>> fib=lambda n:reduce(lambda x,y:(x[0]+x[1],x[0]),[(1,1)]*(n-1))[0]
>>> fib(10)
55 

尽管解决方案中存在一种美和优雅,但它们并不易读。只需进行一些小的修改,我们就可以将这些函数改为更易读的函数,其功能相似:

>>> def fib(n):
...     if n < 2:
...         return n
...     else:
...         return fib(n - 1) + fib(n - 2)

>>> fib(10)
55

>>> def fib(n):
...     a = 0
...     b = 1
...     for _ in range(n):
...         a, b = b, a + b
...
...     return a

>>> fib(10)
55 

实用性胜过纯粹性

“特殊情况并不足以打破规则。尽管实用性胜过纯粹性。”

有时打破规则可能很有吸引力,但这是一个滑稽的斜坡。如果你的快速修复会打破规则,你真的应该立即尝试重构它。很可能你以后没有时间修复它,并且会后悔。

虽然没有必要做得太过分。如果解决方案足够好,重构会花费更多的工作,那么选择工作方法可能更好。尽管所有这些例子都涉及导入,但这个指南几乎适用于所有情况。

为了防止长行,可以通过使用几种方法来缩短导入,添加反斜杠,添加括号,或者只是缩短导入。我将在下面展示一些选项:

>>> from concurrent.futures import ProcessPoolExecutor, \
...     CancelledError, TimeoutError 

这个情况可以通过使用括号轻松避免:

>>> from concurrent.futures import (
...     ProcessPoolExecutor, CancelledError, TimeoutError) 

或者我个人的偏好,导入模块而不是单独的对象:

>>> from concurrent import futures 

但关于真正长的导入呢?

>>> from concurrent.futures.process import \
...     ProcessPoolExecutor 

在那种情况下,我建议使用括号。如果你需要将导入拆分到多行,我建议每行一个导入以提高可读性:

>>> from concurrent.futures.process import (
...     ProcessPoolExecutor
... )

>>> from concurrent.futures import (
...     ProcessPoolExecutor,
...     CancelledError,
...     TimeoutError,
... ) 

错误绝不应该默默通过

“错误绝不应该默默通过。除非明确地被压制。”

正确处理错误真的很困难,没有一种方法适用于所有情况。然而,有一些方法比其他方法更好或更差来捕获错误。

裸露或过于宽泛的异常捕获可能会在出现错误时使你的生活变得有些困难。完全不传递异常信息可能会让你(或正在编写代码的其他人)长时间对发生的事情感到困惑。

为了说明一个裸露的异常,最糟糕的选择如下:

>>> some_user_input = '123abc'

>>> try:
...     value = int(some_user_input)
... except:
...     pass 

一个更好的解决方案是明确捕获你需要的错误:

>>> some_user_input = '123abc'

>>> try:
...     value = int(some_user_input)
... except ValueError:
...     pass 

或者,如果你真的需要捕获所有异常,请确保正确地记录它们:

>>> import logging

>>> some_user_input = '123abc'

>>> try:
...     value = int(some_user_input)
... except Exception as exception:
...     logging.exception('Uncaught: {exception!r}') 

当在try块中使用多行时,由于有更多的代码可能负责隐藏的异常,跟踪错误的难题进一步加剧。当except意外地捕获了几层深处的函数的异常时,跟踪错误也变得更加困难。例如,考虑以下代码块:

>>> some_user_input_a = '123'
>>> some_user_input_b = 'abc'

>>> try:
...     value = int(some_user_input_a)
...     value += int(some_user_input_b)
... except:
...     value = 0 

如果抛出了异常,是哪一行引起的?在没有运行调试器的情况下,通过静默捕获错误,你无法知道。如果,而不是使用int(),你使用了一个更复杂的函数,异常甚至可能是在代码的几层深的地方引起的。

如果你在一个特定的代码块中测试特定的异常,更安全的方法是在try/except中使用elseelse只有在没有异常的情况下才会执行。

为了说明try/except:的全部威力,这里是一个包括elsefinallyBaseException的所有变体的例子:

>>> try:
...     1 / 0  # Raises ZeroDivisionError
... except ZeroDivisionError:
...     print('Got zero division error')
... except Exception as exception:
...     print(f'Got unexpected exception: {exception}')
... except BaseException as exception:
...     # Base exceptions are a special case for keyboard
...     # interrupts and a few other exceptions that are not
...     # technically errors.
...     print(f'Got base exception: {exception}')
... else:
...     print('No exceptions happened, we can continue')
... finally:
...     # Useful cleanup functions such as closing a file
...     print('This code is _always_ executed')
Got zero division error
This code is _always_ executed 

面对歧义,拒绝猜测的诱惑

尽管猜测在很多情况下都会有效,但如果你不小心,它们可能会给你带来麻烦。正如在明确优于隐含部分所展示的,当你有少量from ... import *时,你无法总是确定哪个模块为你提供了你期望的变量。

清晰且无歧义的代码会产生更少的错误,因此始终考虑当别人阅读你的代码时会发生什么总是一个好主意。一个歧义性的主要例子是函数调用。例如,以下两个函数调用:

>>> fh_a = open('spam', 'w', -1, None, None, '\n')
>>> fh_b = open(file='spam', mode='w', buffering=-1, newline='\n') 

这两个调用具有完全相同的结果。然而,在第二个调用中,很明显-1正在配置缓冲区。你可能对open()的前两个参数了如指掌,但其他参数则不太常见。

无论怎样,没有看到help(open)或以其他方式查看文档,你无法说这两个是否相同。

注意,我认为你不必在所有情况下都使用关键字参数,但如果涉及许多参数和/或难以识别的参数(例如数字),这可能是一个好主意。一个好的替代方案是使用好的变量名,这可以使函数调用更加明显:

>>> filename = 'spam'
>>> mode = 'w'
>>> buffers = -1

>>> fh_b = open(filename, mode, buffers, newline='\n') 

做这件事的一个明显方法

“应该有一个——最好是只有一个——明显的做法。虽然这个方法可能一开始并不明显,除非你是荷兰人。”

通常情况下,思考一段时间困难的问题后,你会发现有一个解决方案明显优于其他替代方案。然而,有时情况并非如此,在这种情况下,如果你是荷兰人,这可能是有用的。这里的笑话是,Python 的原始作者 Guido van Rossum 是荷兰人(我也是),而且在某些情况下,只有 Guido 知道明显的做法。

另一个笑话是 Perl 编程语言的口号正好相反:“有多种方法可以做到。”

现在比永远不做好

“现在比永远不做好。尽管永远通常比 现在 做好。”

立即解决问题比将其推迟到未来更好。然而,在某些情况下,立即解决问题并不是一个选择。在这种情况下,一个好的替代方案是将函数标记为已弃用,这样就没有忘记问题的风险:

>>> import warnings

>>> warnings.warn('Something deprecated', DeprecationWarning) 

难以解释,容易解释

“如果实现难以解释,那是个坏主意。如果实现容易解释,那可能是个好主意。”

总是保持尽可能简单。虽然复杂的代码可以很好地进行测试,但它更容易出现错误。你越能保持简单,就越好。

命名空间是一个非常好的想法

“命名空间是一个非常好的想法——让我们做更多这样的!”

命名空间可以使代码更易于使用。正确命名它们会使它变得更好。例如,假设在更大的文件中 import 没有显示在你的屏幕上。loads 行做什么?

>>> from json import loads

>>> loads('{}')
{} 

现在让我们看看带有命名空间版本的示例:

>>> import json

>>> json.loads('{}')
{} 

现在很明显 loads()json 加载器,而不是任何其他类型的加载器。

命名空间快捷方式仍然很有用。让我们看看 Django 中的 User 类,它在几乎每个 Django 项目中都被使用。默认情况下,User 类存储在 django.contrib.auth.models.User 中(可以被覆盖)。许多项目以以下方式使用该对象:

from django.contrib.auth.models import User
# Use it as: User 

虽然这相当清晰,但项目可能会使用多个名为 User 的类,这会模糊导入。此外,这也可能让人误以为 User 类是当前类的本地类。通过以下方式做可以让人知道它位于不同的模块中:

from django.contrib.auth import models
# Use it as: models.User 

然而,这很快就会与其他模型的导入发生冲突,所以我个人使用以下方法代替:

from django.contrib.auth import models as auth_models
# Use it as auth_models.User 

或者更简短的说法:

import django.contrib.auth.models as auth_models
# Use it as auth_models.User 

现在你应该对 Python 主义有所了解——创建以下代码:

  • 美观

  • 易读

  • 清晰无误

  • 足够明确

  • 不是完全没有空格

那么,让我们继续看看如何使用 Python 风格指南创建美观、易读和简单的代码的一些更多示例。

解释 PEP 8

前面的部分已经展示了使用 PEP 20 作为参考的许多示例,但还有一些其他重要的指南需要注意。PEP 8 风格指南指定了标准的 Python 编码约定。

仅遵循 PEP 8 标准并不能使你的代码具有 Python 风格,但它确实是一个很好的开始。你使用哪种风格并不是那么重要,只要你保持一致。最糟糕的事情不是使用合适的风格指南,而是对其不一致。

Duck typing

Duck typing 是一种通过行为处理变量的方法。引用 Alex Martelli(我的 Python 英雄之一,也被许多人昵称为 MartelliBot)的话:

“不要检查它是否是一只鸭子:检查它是否像鸭子一样嘎嘎叫,像鸭子一样走路,等等,具体取决于你需要用鸭子行为的一个子集来玩你的语言游戏。如果这个参数没有通过这个特定的鸭子属性子集测试,那么你可以耸耸肩,问‘为什么是一只鸭子?’”

在许多情况下,当人们进行if spam != ''这样的比较时,他们实际上只是在寻找任何被认为是真值的对象。虽然你可以将值与字符串值''进行比较,但你通常不必做得如此具体。在许多情况下,简单地做if spam:就足够了,而且实际上效果更好。

例如,以下代码行使用timestamp的值来生成一个文件名:

>>> timestamp = 12345

>>> filename = f'{timestamp}.csv' 

因为变量命名为timestamp,你可能会想检查它实际上是否是一个datedatetime对象,如下所示:

>>> import datetime

>>> timestamp = 12345

>>> if isinstance(timestamp, datetime.datetime):
...     filename = f'{timestamp}.csv'
... else:
...     raise TypeError(f'{timestamp} is not a valid datetime')
Traceback (most recent call last):
...
TypeError: 12345 is not a valid datetime 

虽然这本身并没有错,但在 Python 中,比较类型被认为是一种不好的做法,因为通常没有必要这么做。

在 Python 中,常用的风格是EAFP求原谅比求许可更容易docs.python.org/3/glossary.html#term-eafp),它假设不会出错,但在需要时可以捕获错误。在 Python 解释器中,如果没有抛出异常,try/except块非常高效。然而,实际捕获异常是昂贵的,因此这种方法主要推荐在你不期望try经常失败的情况下使用。

EAFP(先做后检查docs.python.org/3/glossary.html#term-lbyl)的相反做法是LBYL跳之前先看),在执行其他调用或查找之前检查先决条件。这种方法的一个显著缺点是在多线程环境中可能存在竞争条件。当你正在检查字典中键的存在时,另一个线程可能已经将其移除了。

这就是为什么在 Python 中,鸭子类型通常更受欢迎。只需测试变量是否具有你需要的特性,而不用担心实际的类型。为了说明这可能会对最终结果产生多小的差异,请看以下代码:

>>> import datetime

>>> timestamp = datetime.date(2000, 10, 5)
>>> filename = f'{timestamp}.csv'
>>> print(f'Filename from date: {filename}')
Filename from date: 2000-10-05.csv 

与字符串而不是日期进行比较:

>>> timestamp = '2000-10-05'
>>> filename = f'{timestamp}.csv'
>>> print(f'Filename from str: {filename}')
Filename from str: 2000-10-05.csv 

正如你所见,结果是相同的。

同样,将数字转换为浮点数或整数也是如此;而不是强制执行某种类型,只需要求某些特性。需要能通过作为数字的?只需尝试将其转换为intfloat。需要一个file对象?为什么不检查是否有read方法呢,使用hasattr

值比较和身份比较之间的差异

Python 中有许多比较对象的方法:大于、位运算符、等于等,但有一个比较器是特殊的:身份比较操作符。你不会使用if spam == eggs,而是使用if spam is eggs。第一个比较值,第二个比较身份或内存地址。因为它只比较内存地址,所以它是你可以得到的轻量级和严格的查找之一。而值检查需要确保类型是可比较的,可能还需要检查子值,而身份检查只是检查唯一标识符是否相同。

如果你曾经编写过 Java,你应该熟悉这个原则。在 Java 中,常规的字符串比较(spam == eggs)将使用身份而不是值。要比较值,你需要使用spam.equals(eggs)来获得正确的结果。

这些比较建议在期望对象身份保持不变时使用。一个明显的例子是与TrueFalseNone的比较。为了演示这种行为,让我们看看在按值比较时评估为TrueFalse的值,但实际上是不同的:

>>> a = 1
>>> a == True
True
>>> a is True
False

>>> b = 0
>>> b == False
True
>>> b is False
False 

类似地,你需要小心处理if语句和None值,这是默认函数参数的一个常见模式:

>>> def some_unsafe_function(arg=None):
...     if not arg:
...         arg = 123
...
...     return arg

>>> some_unsafe_function(0)
123
>>> some_unsafe_function(None)
123 

第二个确实需要默认参数,但第一个有一个实际应该使用的值:

>>> def some_safe_function(arg=None):
...     if arg is None:
...         arg = 123
...
...     return arg

>>> some_safe_function(0)
0
>>> some_safe_function(None)
123 

现在我们实际上得到了我们传递的值,因为我们使用了身份而不是值检查arg

尽管身份有一些陷阱。让我们看看一个没有意义的例子:

>>> a = 200 + 56
>>> b = 256
>>> c = 200 + 57
>>> d = 257

>>> a == b
True
>>> a is b
True
>>> c == d
True
>>> c is d
False 

虽然值相同,但身份不同。问题是 Python 保留了一个整数对象的内部数组,用于所有介于-5256之间的整数;这就是为什么它对256有效,但对257无效。

要查看 Python 实际上使用is操作符内部做了什么,你可以使用id函数。当执行if spam is eggs时,Python 将执行相当于if id(spam) == id(eggs)的操作,而id()(至少对于 CPython)返回内存地址。

循环

来自其他语言的人可能会倾向于使用带有计数器的for循环或while循环来处理listtuplestr等项。虽然这是有效的,但它比所需的更复杂。例如,考虑以下代码:

>>> my_range = range(5)
>>> i = 0
>>> while i < len(my_range ):
...     item = my_range [i]
...     print(i, item, end=', ')
...     i += 1
0 0, 1 1, 2 2, 3 3, 4 4, 

在 Python 中,没有必要构建自定义循环:你可以简单地迭代可迭代对象。尽管包括计数器的枚举也是容易实现的:

>>> my_range  = range(5)
>>> for item in my_range :
...     print(item, end=', ')
0, 1, 2, 3, 4,

>>> for i, item in enumerate(my_range ):
...     print(i, item, end=', ')
0 0, 1 1, 2 2, 3 3, 4 4, 

当然,这可以写得更短(尽管不是 100%相同,因为我们没有使用print),但我不建议在大多数情况下这样做,因为这会影响可读性:

>>> my_range  = range(5)
>>> [(i, item) for i, item in enumerate(my_range)]
[(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)] 

最后一个选项可能对一些人来说很清楚,但对所有人来说可能不是。一个常见的建议是将list/dict/set推导式和map/filter语句的使用限制在整行可以容纳整个语句的情况下。

最大行长度

许多 Python 程序员认为 79 个字符的限制太严格,因此他们只是让行更长。虽然我不会为 79 个字符的具体数字辩护,但设定一个低限是一个好主意,这样你可以轻松地将多个编辑器并排打开。我经常有四个 Python 文件并排打开。如果行宽超过 79 个字符,那就无法适应了。

PEP 8 告诉我们,当行过长时应该使用反斜杠。虽然我同意反斜杠比长行更可取,但我仍然认为如果可能的话应该避免使用,因为它们在通过复制/粘贴和重新排列代码时很容易产生语法错误。以下是一个来自 PEP 8 的例子:

with open('/path/to/some/file/you/want/to/read') as file_1, \
        open('/path/to/some/file/being/written', 'w') as file_2:
    file_2.write(file_1.read()) 

而不是使用反斜杠,我会通过引入额外的变量来重新格式化代码,这样所有行都容易阅读:

filename_1 = '/path/to/some/file/you/want/to/read'
filename_2 = '/path/to/some/file/being/written'
with open(filename_1) as file_1, open(filename_2, 'w') as file_2:
    file_2.write(file_1.read()) 

或者在这个特定的文件名案例中,通过使用 pathlib

import pathlib
filename_1 = pathlib.Path('/path/to/some/file/you/want/to/read')
filename_2 = pathlib.Path('/path/to/some/file/being/written')
with filename_1.open() as file_1, filename_2.open('w') as file_2:
    file_2.write(file_1.read()) 

当然,这并不总是可行的选择,但保持代码简短和可读性是一个很好的考虑。实际上,这实际上为代码添加了更多信息。如果你使用的是 filename_1 而不是传达文件名目标的名称,那么你立即就能清楚地知道你试图做什么。

验证代码质量,pep8,pyflakes 等

在 Python 中有很多检查代码质量和风格的工具。选项从检查与 PEP 8 相关的规则的 pycodestyle(之前命名为 pep8)到捆绑了许多工具的 flake8,这些工具可以帮助重构代码并追踪看似正常工作的代码中的错误。

让我们更详细地探讨一下。

pycodestyle/pep8

pycodestyle 包(之前命名为 pep8)是开始时的默认代码风格检查器。pycodestyle 检查器试图验证许多在 PEP 8 中提出的规则,这些规则被认为是社区的标准。它并不检查 PEP 8 标准中的所有内容,但它已经走了很长的路,并且仍然定期更新以添加新的检查。pycodestyle 检查的一些最重要的内容如下:

  • 缩进:虽然 Python 不会检查你使用多少空格来缩进,但这并不有助于提高代码的可读性

  • 缺少空白,例如 spam=123

  • 过多的空白,例如 def eggs(spam = 123):

  • 空行过多或过少

  • 行过长

  • 语法和缩进错误

  • 不正确和/或多余的比较(not inis notif spam is True,以及没有 isinstance 的类型比较)

如果某些特定规则不符合你的喜好,你可以轻松地调整它们以适应你的目的。除此之外,该工具并不太具有主观性,这使得它成为任何 Python 项目的理想起点。

值得一提的是 black 项目,这是一个 Python 格式化工具,可以自动将你的代码格式化为大量遵循 PEP 8 风格的代码。black 这个名字来源于亨利·福特的名言:“任何顾客都可以得到一辆任何颜色的车,只要它是黑色的。”

这立即显示了black的缺点:它在定制方面提供的非常有限。如果你不喜欢其中的某条规则,你很可能运气不佳。

pyflakes

pyflakes检查器通过解析(而不是导入)代码来检测你代码中的错误和潜在的错误。这使得它非常适合与编辑器集成,但它也可以用来警告你代码中可能存在的问题。它将警告你关于以下内容:

  • 未使用的导入

  • 通配符导入(from module import *

  • 不正确的__future__导入(在其他导入之后)

更重要的是,它警告你关于潜在的错误,例如以下内容:

  • 重新定义已导入的名称

  • 使用未定义的变量

  • 在赋值之前引用变量

  • 重复的参数名称

  • 未使用的局部变量

pep8-naming

PEP 8 的最后一点由pep8-naming包处理。它确保你的命名接近 PEP 8 指定的标准:

  • 类名使用CapWord

  • 函数、变量和参数名称全部小写

  • 常量使用全大写并被视为常量

  • 实例方法和类方法的第一参数分别为selfcls

McCabe

最后,是 McCabe 复杂度。它通过查看 Python 从源代码内部构建的抽象语法树(AST)来检查代码的复杂度。它找出有多少行、级别和语句,并在你的代码复杂度超过预配置的阈值时发出警告。通常,你会通过flake8使用 McCabe,但也可以手动调用。使用以下代码:

def noop():
    pass

def yield_cube_points(matrix):
    for x in matrix:
        for y in x:
            for z in y:
                yield (x, y, z)

def print_cube(matrix):
    for x in matrix:
        for y in x:
            for z in y:
                print(z, end='')
            print()
        print() 

McCabe 将给出以下输出:

$ pip3 install mccabe
...
$ python3 -m mccabe T_16_mccabe.py
1:0: 'noop' 1
5:0: 'yield_cube_points' 4
12:0: 'print_cube' 4 

起初,当你看到noop生成的1时,你可能会认为mccabe计算的是代码行数。经过进一步检查,你可以看到这并不是事实。有多个noop操作符不会增加计数,print_cube函数中的print语句也不会增加计数。

mccabe工具检查代码的循环复杂度。简而言之,这意味着它计算可能的执行路径数量。没有任何控制流语句(如if/for/while)的代码计为 1,正如你在noop函数中看到的那样。一个简单的ifif/else会产生两个选项:一个if语句为True的情况和一个if语句为False的情况。如果有嵌套的ifelif,这将进一步增加。循环计为 2,因为有项目时进入循环的流程,没有项目时不进入循环。

mccabe的默认警告阈值设置为 10,但可以配置。如果你的代码实际得分超过 10,那么是时候进行一些重构了。记住 PEP 20 的建议。

Mypy

Mypy 是一种用于检查代码中变量类型的工具。虽然指定固定类型与鸭子类型相矛盾,但确实有一些情况下这很有用,并且可以保护你免受错误的影响。

以以下代码为例:

some_number: int
some_number = 'test' 

mypy命令会告诉我们我们犯了一个错误:

$ mypy T_17_mypy.py
T_17_mypy.py:2: error: Incompatible types in assignment (expression has type "str", variable has type "int")
Found 1 error in 1 file (checked 1 source file) 

注意,这个语法依赖于 Python 3.5 中引入的类型提示。对于较旧的 Python 版本,你可以使用注释来代替类型提示:

some_number = 'test'  # type: int 

即使你不在自己的代码中使用代码提示,这也可以用来检查你的外部库调用是否正确。如果一个外部库的函数参数在更新中发生了变化,这可以快速告诉你错误位置有问题,而不是需要追踪整个代码中的错误。

flake8

要运行所有这些测试的组合,你可以使用默认运行pycodestylepyflakesmccabeflake8工具。运行这些命令后,flake8将它们的输出合并成单个报告。flake8生成的某些警告可能不符合你的口味,所以每个检查都可以禁用,无论是按文件还是按整个项目(如果需要的话)。例如,我个人为所有项目禁用了W391,它会警告你文件末尾有空白行。

这是我工作时发现很有用的一点,这样我就可以轻松地跳到文件末尾并开始编写代码,而不是先添加几行。

还有许多插件可以使flake8更加强大。

一些示例插件包括:

  • pep8-naming:测试 PEP 命名约定

  • flake8-docstrings:测试 docstrings 是否遵循 PEP 257、NumPy 或 Google 约定。关于这些约定的更多内容将在关于文档的章节中介绍。

  • flake8-bugbear:在代码中查找可能的错误和设计问题,例如裸露的 except。

  • flake8-mypy:测试值类型是否与声明的类型一致。

通常,在提交代码和/或将代码上线之前,只需从你的源目录中运行flake8来递归地检查一切。

这里有一些格式不佳的代码的演示:

def spam(a,b,c):print(a,b+c)
def eggs():pass 

这会导致以下结果:

$ pip3 install flake8
...
$ flake8 T_18_flake8.py
T_18_flake8.py:1:11: E231 missing whitespace after ','
T_18_flake8.py:1:13: E231 missing whitespace after ','
T_18_flake8.py:1:16: E231 missing whitespace after ':'
T_18_flake8.py:1:24: E231 missing whitespace after ','
T_18_flake8.py:2:11: E231 missing whitespace after ':' 

Python 语法的最近添加

在过去十年中,Python 语法在很大程度上保持不变,但我们已经看到了一些添加,比如 f-strings、类型提示和异步函数,当然。我们已经在本章开头介绍了 f-strings,其他两个分别在第九章第十三章中介绍,但还有一些其他最近添加到 Python 语法中的内容,你可能错过了。此外,在第四章中,你将看到 Python 3.9 中添加的字典合并操作符。

PEP 572:赋值表达式/海象操作符

我们在本章前面已经简要地介绍过这一点,但自从 Python 3.8 版本以来,我们有了赋值表达式。如果你有 C 或 C++的经验,你很可能之前见过类似的东西:

if((fh = fopen("filename.txt", "w")) == NULL) 

在 C 中,使用 fopen() 打开文件,将 fopen() 的结果存储在 fh 中,并检查 fopen() 调用的结果是否为 NULL。直到 Python 3.8,我们总是必须将这些两个操作分成一个赋值和一个 if 语句,假设我们的 Python 代码中也有 fopen()NULL

fh = fopen("filename.txt", "w")
if fh == NULL: 

自从 Python 3.8 以来,我们可以使用赋值表达式在一行中完成这个操作,类似于 C:

if (fh := fopen("filename.txt", "w")) == NULL: 

使用 := 运算符,您可以在一个操作中分配和检查结果。这在读取用户输入时非常有用,例如:

 while (line := input('Please enter a line: ')) != '':
        # Process the line here
    # The last line was empty, continue the script 

这个运算符通常被称为海象运算符,因为它看起来有点像海象的眼睛和獠牙(:=)。

PEP 634:结构化模式匹配,switch 语句

许多刚开始接触 Python 的程序员想知道为什么它不像大多数常见编程语言那样有 switch 语句。通常,switch 语句的缺失是通过字典查找或简单地使用一系列 if/elif/elif/elif/else 语句来解决的。虽然这些解决方案可以正常工作,但我个人觉得有时我的代码如果使用 switch 语句可能会更美观、更易读。

自从 Python 3.10 以来,我们终于拥有了一个与 switch 语句非常相似但功能更强大的特性。正如 Python 的三元运算符(即 true_value if condition else false_value)的情况一样,其语法与其它语言的直接复制相去甚远。在这种情况下,这反而更好。在大多数编程语言中,很容易忘记 switch 中的 break 语句,这可能会导致意外的副作用。

从表面上看,Python 的实现语法和功能似乎更简单。没有 break 语句,你可能会想知道如何一次性匹配多个模式。请耐心等待,我们将揭晓!模式匹配功能非常强大,并且提供了比您预期的更多功能。

基本的匹配语句

首先,让我们看一个基本示例。这个例子提供的帮助不大,但仍然比常规的 if/elif/else 语句更容易阅读:

>>> some_variable = 123

>>> match some_variable:
...     case 1:
...         print('Got 1')
...     case 2:
...         print('Got 2')
...     case _:
...         print('Got something else')
Got something else

>>> if some_variable == 1:
...     print('Got 1')
... elif some_variable == 1:
...     print('Got 2')
... else:
...     print('Got something else')
Got something else 

由于我们这里既有 if 语句也有 match 语句,您可以轻松地进行比较。在这种情况下,我会选择 if 语句,但不需要重复 some_variable == 部分的主要优势仍然很有用。

_ 是 match 语句的特殊通配符情况。它匹配任何值,因此它可以看作是 else 语句的等价物。

将后备存储为变量

一个稍微更有用的例子是在不匹配时自动存储结果。前面的例子使用了下划线(_),实际上并没有存储在 _ 中,因为它是一个特殊的情况,但如果我们给变量起不同的名字,我们就可以存储结果:

>>> some_variable = 123

>>> match some_variable:
...     case 1:
...         print('Got 1')
...     case other:
...         print('Got something else:', other)
Got something else: 123 

在这种情况下,我们将 else 情况存储在 other 变量中。请注意,您不能同时使用 _ 和变量名,因为它们做的是同一件事,这将是没有用的。

从变量中进行匹配

你看到,例如 case other: 这样的情况会将结果存储在 other 中,而不是与 other 的值进行比较,所以你可能想知道我们是否可以做等效的操作:

if some_variable == some_value: 

答案是我们可以,但有一个前提。由于任何裸露的 case variable: 都会导致将值存储到变量中,我们需要有某种不匹配该模式的东西。常见的绕过这种限制的方法是通过引入一个点:

>>> class Direction:
...     LEFT = -1
...     RIGHT = 1

>>> some_variable = Direction.LEFT

>>> match some_variable:
...     case Direction.LEFT:
...         print('Going left')
...     case Direction.RIGHT:
...         print('Going right')
Going left 

只要它不能被解释为变量名,这对你来说就会起作用。当然,在比较局部变量时,也可以使用 if 语句。

在单个情况中匹配多个值

如果你熟悉许多其他编程语言中的 switch 语句,你可能想知道在你 break 之前是否可以有多个 case 语句,例如(C++):

switch(variable){
    case Direction::LEFT:
    case Direction::RIGHT:
        cout << "Going horizontal" << endl;
        break;
    case Direction::UP:
    case Direction::DOWN:
        cout << "Going vertical" << endl;
} 

这大致意味着如果 variable 等于 LEFTRIGHT,则打印 "Going horizontal" 行并 break。由于 Python 的 match 语句没有 break,我们如何匹配这样的内容?嗯,为了这个目的,引入了一些特定的语法:

>>> class Direction:
...     LEFT = -1
...     UP = 0
...     RIGHT = 1
...     DOWN = 2

>>> some_variable = Direction.LEFT

>>> match some_variable:
...     case Direction.LEFT | Direction.RIGHT:
...         print('Going horizontal')
...     case Direction.UP | Direction.DOWN:
...         print('Going vertical')
Going horizontal 

正如你所见,使用 | 操作符(它也用于位运算),你可以同时测试多个值。

使用 guards 或额外条件匹配值

有时候你想要更高级的比较,比如 if variable > value:。幸运的是,即使这样也可以通过使用带有称为 guards 的 match 语句来实现。

>>> values = -1, 0, 1

>>> for value in values:
...     print('matching', value, end=': ')
...     match value:
...         case negative if negative < 0:
...             print(f'{negative} is smaller than 0')
...         case positive if positive > 0:
...             print(f'{positive} is greater than 0')
...         case _:
...             print('no match')
matching -1: -1 is smaller than 0
matching 0: no match
matching 1: 1 is greater than 0 

注意这使用了刚刚引入的变量名,但它是一个常规的 Python 正则表达式,所以你也可以比较其他内容。然而,你总是需要在 if 前面有变量名。这不会起作用:case if ...

匹配列表、元组和其它序列

如果你熟悉 tuple 解包,你可能可以猜出序列匹配是如何工作的:

>>> values = (0, 1), (0, 2), (1, 2)

>>> for value in values:
...     print('matching', value, end=': ')
...     match value:
...         case 0, 1:
...             print('exactly matched 0, 1')
...         case 0, y:
...             print(f'matched 0, y with y: {y}')
...         case x, y:
...             print(f'matched x, y with x, y: {x}, {y}')
matching (0, 1): exactly matched 0, 1
matching (0, 2): matched 0, y with y: 2
matching (1, 2): matched x, y with x, y: 1, 2 

第一个情况明确匹配了给定的两个值,这等同于 if value == (0, 1):

第二个情况明确匹配第一个值为 0,但将第二个值作为一个变量,并存储在 y 中。实际上这相当于 if value[0] == 0: y = value[1]

最后一个情况为 xy 值存储一个变量,并将匹配任何恰好有两个元素的序列。

匹配序列模式

如果你认为之前的变量解包示例很有用,你将喜欢这一部分。match 语句的一个真正强大的功能是基于模式进行匹配。

假设我们有一个函数,它接受最多三个参数,hostportprotocol。对于 portprotocol,我们可以假设 443https,这样只剩下 hostname 作为必需的参数。我们如何匹配这样,使得一个、两个、三个或更多的参数都得到支持并正确工作?让我们来看看:

>>> def get_uri(*args):
...     # Set defaults so we only have to store changed variables
...     protocol, port, paths = 'https', 443, ()
...     match args:
...         case (hostname,):
...             pass
...         case (hostname, port):
...             pass
...         case (hostname, port, protocol, *paths):
...             pass
...         case _:
...             raise RuntimeError(f'Invalid arguments {args}')
...
...     path = '/'.join(paths)
...     return f'{protocol}://{hostname}:{port}/{path}'

>>> get_uri('localhost')
'https://localhost:443/'
>>> get_uri('localhost', 12345)
'https://localhost:12345/'
>>> get_uri('localhost', 80, 'http')
'http://localhost:80/'
>>> get_uri('localhost', 80, 'http', 'some', 'paths')
'http://localhost:80/some/paths' 

如你所见,match 语句还处理不同长度的序列,这是一个非常有用的工具。你当然也可以用 if 语句来做这件事,但我从未找到一种真正漂亮的方式来处理它。当然,你仍然可以将其与前面的示例结合起来,所以如果你想要调用特定的行为,你可以有一个 case,例如:case (hostname, port, 'http'):。你还可以使用 *variable 来捕获所有额外的变量。* 匹配序列中的 0 个或多个额外项。

捕获子模式

除了指定一个变量名来保存所有值之外,你还可以存储显式的值匹配:

>>> values = (0, 1), (0, 2), (1, 2)

>>> for value in values:
...     print('matching', value, end=': ')
...     match value:
...         case 0 as x, (1 | 2) as y:
...             print(f'matched x, y with x, y: {x}, {y}')
...         case _:
...             print('no match')
matching (0, 1): matched x, y with x, y: 0, 1
matching (0, 2): matched x, y with x, y: 0, 2
matching (1, 2): no match 

在这种情况下,我们明确地将 0 作为 value 的第一部分进行匹配,将 12 作为 value 的第二部分进行匹配。并将这些分别存储在变量 xy 中。

这里需要注意的是,在 case 语句的上下文中,| 运算符始终按或操作符对 case 起作用,而不是按位或操作符对变量/值。通常 1 | 2 会得到 3,因为在二进制中 1 = 00012 = 0010,这两个数的组合是 3 = 0011

匹配字典和其他映射

自然地,也可以通过键来匹配映射(如 dict):

>>> values = dict(a=0, b=0), dict(a=0, b=1), dict(a=1, b=1)

>>> for value in values:
...     print('matching', value, end=': ')
...     match value:
...         case {'a': 0}:
...             print('matched a=0:', value)
...         case {'a': 0, 'b': 0}:
...             print('matched a=0, b=0:', value)
...         case _:
...             print('no match')
matching {'a': 0, 'b': 0}: matched a=0: {'a': 0, 'b': 0}
matching {'a': 0, 'b': 1}: matched a=0: {'a': 0, 'b': 1}
matching {'a': 1, 'b': 1}: no match 

注意,match 只检查给定的键和值,并不关心映射中的额外键。这就是为什么第一个案例匹配前两个项目。

正如前一个示例所示,匹配是按顺序发生的,并且它会在第一个匹配项处停止,而不是在最佳匹配项处停止。在这种情况下,第二个案例永远不会被触及。

使用 isinstance 和属性进行匹配

如果你认为之前的 match 语句示例很令人印象深刻,那么你准备好完全惊讶吧。match 语句可以匹配包括属性在内的实例的方式非常强大,并且可以非常实用。只需看看以下示例,并尝试理解正在发生的事情:

>>> class Person:
...     def __init__(self, name):
...         self.name = name

>>> values = Person('Rick'), Person('Guido')

>>> for value in values:
...     match value:
...         case Person(name='Rick'):
...             print('I found Rick')
...         case Person(occupation='Programmer'):
...             print('I found a programmer')
...         case Person() as person:
...             print('I found a person:', person.name)
I found Rick
I found a person: Guido 

虽然我必须承认语法有点令人困惑,甚至可以说不够 Pythonic,但它非常实用,所以仍然有意义。

首先,我们将查看 case Person() as person:。我们首先讨论这个,因为在我们继续其他示例之前,理解这里发生的事情非常重要。这一行实际上与 if isinstance(value, Person): 相同,在这个点上它并没有真正实例化 Person 类,这有点令人困惑。

其次,case Person(name='Rick') 匹配实例类型 Person,并且要求实例具有名为 name 的属性,其值为 Rick

最后,case Person(occupation='Programmer') 匹配 value 是一个 Person 实例,并且有一个名为 occupation 的属性,其值为 Programmer。由于该属性不存在,它默默地忽略了这个问题。

注意,这也适用于内置类型,并支持嵌套:

>>> class Person:
...     def __init__(self, name):
...         self.name = name

>>> value = Person(123)
>>> match value:
...     case Person(name=str() as name):
...         print('Found person with str name:', name)
...     case Person(name=int() as name):
...         print('Found person with int name:', name)
Found person with int name: 123 

我们已经介绍了几个新模式匹配功能的工作示例,但你可能还会想到更多。由于所有部分都可以嵌套,可能性真的是无限的。这可能不是解决所有问题的完美方案,语法可能感觉有点奇怪,但它是一个非常强大的解决方案,我建议任何 Python 程序员都应牢记于心。

常见陷阱

Python 是一种旨在清晰和易于阅读的语言,没有任何歧义和意外行为。不幸的是,这些目标并不是在所有情况下都能实现的,这就是为什么 Python 确实有一些边缘情况,它可能会做与你预期不同的事情。

本节将向你展示在编写 Python 代码时可能会遇到的一些问题。

范围很重要!

在 Python 中,有些情况下你可能会没有使用你实际期望的作用域。一些例子是在声明类和函数参数时,但最令人烦恼的是意外尝试覆盖一个global变量。

全局变量

从全局作用域访问变量时,一个常见问题是设置变量使其成为局部变量,即使是在访问全局变量时。

这一点是有效的:

>>> g = 1

>>> def print_global():
...     print(f'Value: {g}')

>>> print_global()
Value: 1 

但以下是不正确的:

>>> g = 1

>>> def print_global():
...     g += 1
...     print(f'Value: {g}')

>>> print_global()
Traceback (most recent call last):
    ...
UnboundLocalError: local variable 'g' referenced before assignment 

问题在于g += 1实际上翻译为g = g + 1,任何包含g =的操作都会使变量成为你作用域内的局部变量。由于在那个点正在分配局部变量,它还没有值,而你却在尝试使用它。

对于这些情况,有global语句,尽管通常建议完全避免写入global变量,因为这可能会在调试时使你的生活变得非常困难。现代编辑器可以大量帮助跟踪谁或什么正在写入你的global变量,但重构你的代码,使其明确地通过清晰路径传递和修改值,可以帮助你避免许多错误。

可变变量的引用传递

在 Python 中,变量是通过引用传递的。这意味着当你做类似x = y的操作时,xy都将指向同一个变量。当你更改任一xy的值(不是对象)时,另一个也会相应改变。

由于大多数变量类型,如字符串、整数、浮点数和元组是不可变的,所以这不是问题。执行x = 123不会影响y,因为我们没有改变x的值,而是用一个新的具有值123的对象替换了x

然而,对于可变变量,我们可以改变对象的值。让我们说明这种行为以及如何绕过它:

>>> x = []
>>> y = x
>>> z = x.copy()

>>> x.append('x')
>>> y.append('y')
>>> z.append('z')

>>> x
['x', 'y']
>>> y
['x', 'y']
>>> z
['z'] 

除非你明确地复制变量,就像我们用z做的那样,否则你的新变量将指向同一个对象。

现在,你可能想知道copy()是否总是有效。正如你可能猜到的,它并不总是有效。copy()函数只复制对象本身,而不是对象内的值。为此,我们有deepcopy(),它可以安全地处理递归:

>>> import copy

>>> x = [[1], [2, 3]]
>>> y = x.copy()
>>> z = copy.deepcopy(x)

>>> x.append('a')
>>> x[0].append(x)

>>> x
[[1, [...]], [2, 3], 'a']
>>> y
[[1, [...]], [2, 3]]
>>> z
[[1], [2, 3]] 

可变函数默认参数

虽然可以轻松避免可变参数的问题,并在大多数情况下看到这些问题,但函数默认参数的情况就明显不那么明显了:

>>> def append(list_=[], value='value'):
...    list_.append(value)
...    return list_

>>> append(value='a')
['a']
>>> append(value='b')
['a', 'b'] 

注意,这对于dictlistset以及collections中的几种类型都适用。此外,你自己定义的类默认是可变的。

为了解决这个问题,你可以考虑将函数改为以下形式:

>>> def append(list_=None, value='value'):
...    if list_ is None:
...        list_ = []
...    list_.append(value)
...    return list_

>>> append(value='a')
['a']
>>> append(value='b')
['b'] 

注意,我们在这里必须使用if list_ is None。如果我们使用if not list_,那么如果传递了一个空的list,它将忽略给定的list_

类属性

可变变量的问题在定义类时也会出现。很容易混淆类属性和实例属性。这可能会让人感到困惑,尤其是当你来自像 C#这样的其他语言时。让我们通过以下示例来说明:

>>> class SomeClass:
...     class_list = []
...
...     def __init__(self):
...         self.instance_list = []

>>> SomeClass.class_list.append('from class')
>>> instance = SomeClass()
>>> instance.class_list.append('from instance')
>>> instance.instance_list.append('from instance')

>>> SomeClass.class_list
['from class', 'from instance']
>>> SomeClass.instance_list
Traceback (most recent call last):
...
AttributeError: ... 'SomeClass' has no attribute 'instance_list'

>>> instance.class_list
['from class', 'from instance']
>>> instance.instance_list
['from instance'] 

就像函数参数一样,列表和字典是共享的。所以,如果你想为类定义一个不共享于所有实例的可变属性,你需要在__init__或其他任何实例方法中定义它。

在处理类时,还有另一个需要注意的重要事项,那就是类的属性将会被继承,这可能会让人感到困惑。在继承过程中,原始属性将保持对原始值的引用(除非被覆盖),即使在子类中也是如此:

>>> class Parent:
...     pass

>>> class Child(Parent):
...     pass

>>> Parent.parent_property = 'parent'
>>> Child.parent_property
'parent'

>>> Child.parent_property = 'child'
>>> Parent.parent_property
'parent'
>>> Child.parent_property
'child'

>>> Child.child_property = 'child'
>>> Parent.child_property
Traceback (most recent call last):
...
AttributeError: ... 'Parent' has no attribute 'child_property' 

虽然由于继承这是可以预料的,但其他人使用这个类时可能不会预料到变量会在同时改变。毕竟,我们修改的是Parent,而不是Child

有两种简单的方法可以防止这种情况。显然,你可以简单地为每个类分别设置属性。但更好的解决方案是从不修改类属性,除非在类定义之外。很容易忘记属性将在多个位置改变,而且如果它必须可修改,通常最好将其放在实例变量中。

覆盖和/或创建额外的内建函数

虽然在某些情况下可能很有用,但通常你想要避免覆盖全局函数。PEP 8 的函数命名约定——类似于内建语句、函数和变量——是使用尾随下划线。

所以,不要这样做:

list = [1, 2, 3] 

相反,使用以下方法:

list_ = [1, 2, 3] 

对于列表等,这只是一个好的约定。对于fromimportwith等语句,这是必需的。忘记这一点可能会导致非常令人困惑的错误:

>>> list = list((1, 2, 3))
>>> list
[1, 2, 3]

>>> list((4, 5, 6))
Traceback (most recent call last):
    ...
TypeError: 'list' object is not callable

>>> import = 'Some import'
Traceback (most recent call last):
    ...
SyntaxError: invalid syntax 

如果你确实想定义一个在任何地方都可以使用而不需要导入的内建函数,这是可能的。为了调试目的,我在开发过程中曾将此代码添加到项目中:

import builtins
import inspect
import pprint
import re

def pp(*args, **kwargs):
    '''PrettyPrint function that prints the variable name when
    available and pprints the data'''
    name = None
    # Fetch the current frame from the stack
    frame = inspect.currentframe().f_back
    # Prepare the frame info
    frame_info = inspect.getframeinfo(frame)

    # Walk through the lines of the function
    for line in frame_info[3]:
        # Search for the pp() function call with a fancy regexp
        m = re.search(r'\bpp\s*\(\s*([^)]*)\s*\)', line)
        if m:
            print('# %s:' % m.group(1), end=' ')
            break

    pprint.pprint(*args, **kwargs)

builtins.pf = pprint.pformat
builtins.pp = pp 

这段代码对于生产环境来说过于简陋,但在处理大型项目且需要打印语句进行调试时仍然很有用。替代(且更好的)调试解决方案可以在第十一章调试 – 解决错误中找到。

使用方法相当简单:

x = 10
pp(x) 

这里是输出:

# x: 10 

在迭代时修改

在某个时刻,你将遇到这个问题:在迭代一些可变对象,如dictset时,你不能修改它们。所有这些都会导致一个RuntimeError,告诉你不能在迭代过程中修改对象:

>>> dict_ = dict(a=123)
>>> set_ = set((456,))

>>> for key in dict_:
...     del dict_[key]
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
RuntimeError: dictionary changed size during iteration

>>> for item in set_:
...     set_.remove(item)
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
RuntimeError: Set changed size during iteration 

对于列表来说,这确实可行,但可能会导致非常奇怪的结果,因此绝对应该避免:

>>> list_ = list(range(10))
>>> list_
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

>>> for item in list_:
...     print(list_.pop(0), end=', ')
0, 1, 2, 3, 4,

>>> list_
[5, 6, 7, 8, 9] 

虽然这些问题可以通过在使用前复制集合来避免,但在许多情况下,如果你遇到这个问题,那么你做的是错误的。如果确实需要操作,构建一个新的集合通常是更简单的方法,因为代码看起来会更明显。当未来有人查看这样的代码时,他们可能会尝试通过移除list()来重构它,因为乍一看这似乎是徒劳的:

>>> list_ = list(range(10))

>>> for item in list(list_):
...     print(list_.pop(0), end=', ')
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 

捕获和存储异常

在 Python 中捕获和存储异常时,你必须记住,出于性能原因,存储的异常是except块本地的。结果是,你需要显式地将异常存储在不同的变量中。在try/except块之前简单地声明变量是不起作用的,并且会使你的变量消失:

>>> exception = None

>>> try:
...     1 / 0
... except ZeroDivisionError as exception:
...     pass

>>> exception
Traceback (most recent call last):
    ...
NameError: name 'exception' is not defined 

将结果存储在新变量中是有效的:

>>> try:
...     1 / 0
... except ZeroDivisionError as exception:
...     new_exception = exception

>>> new_exception
ZeroDivisionError('division by zero') 

如你或许已经看到的,这段代码现在确实有一个错误。如果我们没有遇到异常,new_exception还没有被定义。我们可能需要给try/except添加一个else,或者,更好的做法是在try/except之前预先声明变量。

我们确实需要显式地保存它,因为 Python 3 在except语句结束时自动删除使用as variable保存的任何内容。原因在于 Python 3 中的异常包含一个__traceback__属性。拥有这个属性使得垃圾收集器更难检测到哪些内存应该被释放,因为它引入了递归自引用循环。

具体来说,这是 exception -> traceback -> exception -> traceback ... 的过程。

这确实意味着你应该记住,存储这些异常可能会将内存泄漏引入到你的程序中。

Python 的垃圾收集器足够智能,能够理解变量不再可见,并最终删除变量,但这可能需要更多的时间,因为这是一个更复杂的垃圾收集过程。垃圾收集是如何实际工作的,在第十二章性能 – 跟踪和减少你的内存和 CPU 使用中有详细说明。

动态绑定和闭包

闭包是实现代码中局部作用域的一种方法。它们使得可以在局部定义变量而不覆盖父(或全局)作用域中的变量,并在之后将变量隐藏在外部作用域中。Python 中闭包的问题在于,Python 为了性能原因尽可能晚地绑定其变量。虽然通常很有用,但它确实有一些意外的副作用:

>>> functions = [lambda: i for i in range(3)]

>>> for function in functions:
...     print(function(), end=', ')
2, 2, 2, 

你可能期望的是 0, 1, 2,但由于延迟绑定,所有函数都获取 i 的最后一个值,即 2

我们应该怎么做呢?与前面段落中的情况一样,变量需要被本地化。一个选项是通过使用 partial 强制立即绑定函数:

>>> from functools import partial

>>> functions = [partial(lambda x: x, i) for i in range(3)]

>>> for function in functions:
...     print(function(), end=', ')
0, 1, 2, 

更好的解决方案是避免绑定问题,不引入额外的作用域(如 lambda)使用外部变量。如果将 i 指定为 lambda 的参数,这就不会是问题。

循环导入

尽管 Python 对循环导入相当宽容,但仍然有一些情况下你会遇到错误。

假设我们有两个文件:

T_28_circular_imports_a.py:

import T_28_circular_imports_b

class FileA:
    pass

class FileC(T_28_circular_imports_b.FileB):
    pass 

T_28_circular_imports_b.py:

import T_28_circular_imports_a

class FileB(T_28_circular_imports_a.FileA):
    pass 

运行这些文件中的任何一个都会导致循环导入错误:

Traceback (most recent call last):
  File "T_28_circular_imports_a.py", line 1, in <module>
    import T_28_circular_imports_b
  File "T_28_circular_imports_b.py", line 1, in <module>
    import T_28_circular_imports_a
  File "T_28_circular_imports_a.py", line 8, in <module>
    class FileC(T_28_circular_imports_b.FileB):
AttributeError: partially initialized module 'T_28_circular_imports_b' has no attribute 'FileB' (most likely due to a circular import) 

解决这个问题的方法有几个。最简单的解决方案是将 import 语句移动,使得循环导入不再发生。在这种情况下,import T_28_circular_imports_a.py 需要在 FileAFileB 之间移动。

在大多数情况下,更好的解决方案是重构代码。将公共基类移动到单独的文件中,这样就不需要再进行循环导入了。对于上面的例子,它看起来可能像这样:

T_29_circular_imports_a.py:

class FileA:
    pass 

T_29_circular_imports_b.py:

import T_29_circular_imports_a

class FileB(T_29_circular_imports_a.FileA):
    pass 

T_29_circular_imports_c.py:

import T_29_circular_imports_b

class FileC(T_29_circular_imports_b.FileB):
    pass 

如果这也行不通,可以在运行时而不是导入时从函数中导入,这可能很有用。当然,这对于类继承来说不是一个容易的选择,但如果你只需要在运行时导入,你可以推迟导入。

最后,还有动态导入的选项,例如 Django 框架用于 ForeignKey 字段的选项。除了实际的类之外,ForeignKey 字段还支持字符串,这些字符串在需要时将自动导入。

虽然这是一个非常有效的解决方法,但它确实意味着你的编辑器、linting 工具和其他工具不会理解你正在处理的对象。对这些工具来说,它看起来就像一个字符串,所以除非为这些工具添加特定的黑客技巧,否则它们不会假设值除了字符串之外的其他任何内容。

此外,由于 import 只在运行时发生,你只有在执行函数时才会注意到导入问题。这意味着那些通常会在你运行脚本或应用程序时立即出现的错误现在只有在调用函数时才会显示出来。这是一个很好的难以追踪的 bug 配方,它不会发生在你身上,但会发生在其他代码使用者身上。

这种模式对于插件系统等场景仍然很有用,但只要小心避免提到的注意事项。这里有一个简单的例子来动态导入:

>>> import importlib

>>> module_name = 'sys'
>>> attribute = 'version_info'

>>> module = importlib.import_module(module_name)
>>> module
<module 'sys' (built-in)>
>>> getattr(module, attribute).major
3 

使用 importlib,动态导入模块相当容易,通过使用 getattr,你可以从模块中获取特定的对象。

导入冲突

一个可能非常令人困惑的问题是存在冲突的导入——多个包/模块具有相同的名称。我收到了不少关于这类情况的错误报告。

以我的numpy-stl项目为例,代码被放在一个名为stl的包中。许多人创建了一个名为stl.py的测试文件。当从stl.py导入stl时,它会导入自身而不是stl包。

此外,还存在包之间不兼容的问题。几个包可能会使用相同的名称,因此在安装一系列类似包时要小心,因为它们可能正在共享相同的名称。如果有疑问,只需创建一个新的虚拟环境并再次尝试。这样做可以节省你大量的调试时间。

摘要

本章向您展示了 Python 哲学是什么以及背后的某些推理。此外,您还了解了 Python 的 Zen 以及 Python 社区中认为美丽和丑陋的东西。虽然代码风格非常个人化,但 Python 有一些非常有用的指南,至少能让人保持大致相同的页面和风格。

最后,我们都是同意的成年人;每个人都有权按照自己的方式编写代码。但我确实请求您请阅读风格指南,并尽量遵守它们,除非您有非常好的理由不这样做。

权力越大,责任越大,尽管陷阱并不多。有些陷阱足够复杂,以至于我经常被它们愚弄,而且我已经写 Python 很长时间了!尽管如此,Python 一直在改进。自 Python 2 以来,已经解决了许多陷阱,但一些将始终存在。例如,循环导入和定义在大多数支持它们的语言中很容易让你上当,但这并不意味着我们会停止努力改进 Python。

Python 多年来改进的一个很好的例子是collections模块。它包含了许多用户添加的有用集合,因为存在这种需求。其中大部分实际上是用纯 Python 实现的,因此它们足够简单,任何人都可以阅读。理解它们可能需要更多的努力,但我真心相信,如果你能读到这本书的结尾,你将不会对集合的功能有任何问题。然而,我无法保证完全理解其内部工作方式;其中一些部分更多地涉及通用计算机科学而不是 Python 精通。

下一章将向您展示一些 Python 中可用的集合以及它们是如何在内部构建的。尽管你无疑熟悉列表和字典等集合,但你可能并不了解某些操作的性能特性。如果本章的一些示例不够清晰,你不必担心。下一章至少会回顾其中的一些,更多内容将在后面的章节中介绍。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

第四章:Pythonic 设计模式

上一章涵盖了在 Python 中应该做什么和避免做什么的大量指南。接下来,我们将探索一些使用 Python 内置模块以 Python 风格工作的示例。

设计模式在很大程度上依赖于存储数据;为此,Python 自带了几个非常有用的集合。最基本的数据结构,如listtuplesetdict,您可能已经熟悉,但 Python 还自带了更高级的集合。这些集合中的大多数只是将基本类型组合起来以提供更强大的功能。在本章中,我将解释如何以 Python 风格使用这些数据类型和集合。

在我们能够正确讨论数据结构和相关性能之前,需要基本理解时间复杂度(特别是大 O 符号)。这个概念非常简单,但如果没有它,我无法轻易解释操作的性能特征以及为什么看起来很漂亮的代码可能会表现得很糟糕。

在本章中,一旦大 O 符号变得清晰,我们将讨论一些数据结构,并展示一些示例设计模式,以及如何使用它们。我们将从以下基本数据结构开始:

  • list

  • dict

  • set

  • tuple

在基本数据结构的基础上,我们将继续探讨更高级的集合,例如以下内容:

  • 类似字典的类型:

    • ChainMap

    • Counter

    • Defaultdict

    • OrderedDict

  • 列表类型:heapq

  • 元组类型:dataclass

  • 其他类型:enum

时间复杂度 – 大 O 符号

在我们开始本章之前,有一个简单的符号你需要理解。本章使用大 O 符号来表示操作的复杂度。如果你已经熟悉这个符号,可以自由跳过这一节。虽然这个符号听起来非常复杂,但概念实际上非常简单。

大 O 字母指的是希腊字母奥米克戎的大写版本,它代表小-o(米克戎-o)。

当我们说一个函数需要O(1)时间时,这意味着它通常只需要1步来执行。同样,一个O(n)时间的函数将需要n步来执行,其中n通常是对象的大小(或长度)。这种时间复杂度只是执行代码时预期的一个基本指标,因为它通常是最重要的。

除了 O 之外,文献中可能会出现几个其他字符。以下是这些字符的概述:

  • Ο 大Ω:上界/最坏情况。

  • Ω 大Ω:下界/最佳情况。

  • Θ 大Θ:紧界,意味着 O 和Ω是相同的。

一个算法在这些方面差异很大的好例子是 quicksort 算法。quicksort 算法是最广泛使用的排序算法之一,如果你只根据时间复杂度(大 O)来看,这可能会让你感到惊讶。quicksort 的最坏情况是O(n**2),最佳情况是Ω(n log n)Ω(n),这取决于实现方式。

考虑到最坏情况为O(n**2),你可能不会期望算法被大量使用,但它一直是许多编程语言的默认排序算法。在 C 语言中,它仍然是默认的;对于 Java,它曾是 Java 6 的默认算法;Python 则使用它直到 2002 年。那么,为什么/为什么 quicksort 如此受欢迎?对于 quicksort 来说,查看平均情况非常重要,这比最坏情况更有可能发生。实际上,平均情况是O(n log n),这对于排序算法来说是非常好的。

大 O 记号的目的在于根据需要执行的操作步数来指示操作的近似性能。一个执行单个步骤比另一个版本快 1,000 倍的代码,如果对于n等于 10 或更多,它仍然需要执行O(2**n)步骤,那么它仍然会比另一个版本慢。

这是因为2**n对于n=102**10=1024,这意味着执行相同代码需要 1,024 步。这使得选择正确的算法非常重要,即使在像C/C++这样的语言中,这些语言通常预期会比 Python 的 CPython 解释器有更好的性能。如果代码使用了错误的算法,对于非平凡n值,它仍然会更慢。

例如,假设你有一个包含 1,000 个项目的列表,并且你逐个检查它们。这将需要O(n)时间,因为共有n=1000个项目。检查一个项目是否存在于列表中意味着以类似的方式静默地遍历项目,这意味着它也花费O(n)时间,所以是 1,000 步。

如果你用有 1,000 个键/项的dictset做同样的操作,它将只需要O(1)步,因为dict/set的结构方式。dictset是如何在内部结构化的,将在本章后面讨论。

这意味着,如果你想在那个listdict中检查 100 个项目的存在,对于list来说需要100*O(n),而对于dictset来说则是100*O(1)。这就是 100 步和 100,000 步之间的区别,这意味着在这种情况下dict/setlist快 1,000 倍。

即使代码看起来非常相似,性能特征差异却很大:

>>> n = 1000
>>> a = list(range(n))
>>> b = dict.fromkeys(range(n))

>>> for i in range(100):
...     assert i in a  # takes n=1000 steps
...     assert i in b  # takes 1 step 

为了说明O(1)O(n)O(n**2)函数:

>>> def o_one(items):
...     return 1  # 1 operation so O(1)

>>> def o_n(items):
...     total = 0
...     # Walks through all items once so O(n)
...     for item in items:
...         total += item
...     return total

>>> def o_n_squared(items):
...     total = 0
...     # Walks through all items n*n times so O(n**2)
...     for a in items:
...         for b in items:
...             total += a * b
...     return total

>>> n = 10
>>> items = range(n)
>>> o_one(items)  # 1 operation
1
>>> o_n(items)  # n = 10 operations
45
>>> o_n_squared(items)  # n*n = 10*10 = 100 operations
2025 

为了说明这一点,我们首先将查看一些增长较慢的函数:

图 4.1:n=1 到 n=10,000 时慢增长函数的时间复杂度

如你所见,O(log(n))函数与较大的数字配合得非常好;这就是为什么二分查找对于大型数据集来说如此之快。在本章的后面部分,你将看到一个二分查找算法的例子。

O(n*log(n))的结果显示了一种相当快的增长,这是不希望的,但比一些替代方案要好,如你在图 4.2中看到的更快增长的函数:

图片

图 4.2:n=1 到 n=10 的快速增长函数的时间复杂度

通过查看这些图表,与O(n*log(n))相比,看起来相当不错。正如你将在本章后面看到的那样,许多排序算法使用O(n*log(n))函数,一些使用O(n**2)

这些算法迅速增长到无法计算的大小;例如,O(2**n)函数在 10 个项目上已经需要 1,024 步,并且每一步都会翻倍。一个著名的例子是解决汉诺塔问题的当前解决方案,其中n是盘子的数量。

O(n!)阶乘函数要糟糕得多,在几步之后就会变得无法计算。最著名的例子之一是旅行商问题:找到覆盖一系列城市且恰好访问一次的最短路线。

接下来,我们将深入探讨核心集合。

核心集合

在我们能够查看本章后面部分更高级的合并集合之前,你需要了解核心 Python 集合的工作原理。这不仅仅关于它们的用法;还涉及到它们的时间复杂度,这可能会强烈影响你的应用程序随着增长的行为。如果你对这些对象的时间复杂度了如指掌,并且对 Python 3 的元组打包和解包了如指掌,那么你可以自由地跳转到高级集合部分。

list – 一个可变的项目列表

list很可能是你在 Python 中使用最多的容器结构。在用法上很简单,并且在大多数情况下,它表现出优异的性能。

虽然你可能已经非常熟悉list的使用,但你可能并不了解list对象的时间复杂度。幸运的是,list的许多时间复杂度都非常低;append、获取操作、设置操作和len都只需要O(1)时间——这是最好的。然而,你可能不知道removeinsert的最坏情况时间复杂度是O(n)。所以,要从 1,000 个项目中删除一个项目,Python 可能需要遍历 1,000 个项目。在内部,removeinsert操作执行的操作大致如下:

>>> def remove(items, value):
...     new_items = []
...     found = False
...     for item in items:
...         # Skip the first item which is equal to value
...         if not found and item == value:
...             found = True
...             continue
...         new_items.append(item)
...
...     if not found:
...         raise ValueError('list.remove(x): x not in list')
...
...     return new_items

>>> def insert(items, index, value):
...     new_items = []
...     for i, item in enumerate(items):
...         if i == index:
...             new_items.append(value)
...         new_items.append(item)
...     return new_items
>>> items = list(range(10))
>>> items
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

>>> items = remove(items, 5)
>>> items
[0, 1, 2, 3, 4, 6, 7, 8, 9]

>>> items = insert(items, 2, 5)
>>> items
[0, 1, 5, 2, 3, 4, 6, 7, 8, 9] 

要从/向列表中移除或插入单个项目,Python 需要在插入/删除点之后移动列表中的其余部分。对于大型 list,这可能会成为性能负担,如果可能的话,应通过使用 append 而不是 insert 来避免。当只执行一次时,当然,这并不是那么糟糕。但是,当执行大量 remove 操作时,使用 filterlist 简化表达式会是一个更快的方法,因为如果结构合理,它只需要复制一次列表。

例如,假设我们希望从列表中删除一组特定的数字。我们有很多选择来做这件事。第一个是使用 remove 的解决方案,如果需要删除的项目数量变得很大,它就会变慢。

接下来是构建一个新的列表,一个 list 简化表达式,或者一个 filter 语句。第五章函数式编程 – 可读性 versus 简洁性,将更详细地解释 list 简化表达式和 filter 语句。但首先,让我们看看一些例子:

>>> primes = set((1, 2, 3, 5, 7))

# Classic solution
>>> items = list(range(10))
>>> for prime in primes:
...     items.remove(prime)
>>> items
[0, 4, 6, 8, 9]

# List comprehension
>>> items = list(range(10))
>>> [item for item in items if item not in primes]
[0, 4, 6, 8, 9]

# Filter
>>> items = list(range(10))
>>> list(filter(lambda item: item not in primes, items))
[0, 4, 6, 8, 9] 

后两个例子对于大型项目列表来说要快得多。这是因为操作要快得多。为了比较使用 n=len(items)m=len(primes),第一个例子需要 O(m*n)=5*10=50 次操作,而后两个例子需要 O(n*1)=10*1=10 次操作。

第一种方法实际上比所说的稍微好一些,因为 n 在循环中会减少。所以,它实际上是 10+9+8+7+6=40,但这是一个可以忽略不计的效果。在 n=1000 的情况下,这将是 1000+999+998+997+996=49905*1000=5000 之间的差异,这在现实世界中没有真正的区别。

当然,minmaxin 也都采用 O(n),但对于这种没有针对这些类型查找进行优化的结构来说,这是预期的。

它们可以像这样实现:

>>> def in_(items, value):
...     for item in items:
...         if item == value:
...             return True
...     return False

>>> def min_(items):
...     current_min = items[0]
...     for item in items[1:]:
...         if current_min > item:
...             current_min = item
...     return current_min

>>> def max_(items):
...     current_max = items[0]
...     for item in items[1:]:
...         if current_max < item:
...             current_max = item
...     return current_max

>>> items = range(5)
>>> in_(items, 3)
True
>>> min_(items)
0
>>> max_(items)
4 

通过这些例子,也很明显,in 操作符是一个很好的例子,其中最佳、最坏和平均情况差异很大。最佳情况是 O(1),这是幸运地在我们找到的第一个项目。最坏情况是 O(n),因为可能不存在,或者它可能是最后一个项目。从这个角度来看,您可能会期望平均情况是 O(n/2),但您会错了。平均情况仍然是 O(n),因为项目根本不在列表中的可能性很大。

dict – 项目的映射

dict 可能是您将选择使用的容器结构。您可能没有意识到,您一直在不断地使用它,而没有明确地使用 dict。每次函数调用和变量访问都会通过 dict 来从 local()global() 范围字典中查找名称。

dict 快速、简单易用,并且对于广泛的用例非常有效。对于 getsetdelete 操作的平均时间复杂度是 O(1)

然而,你需要注意这个时间复杂度有一些例外。dict的工作方式是通过使用hash函数(该函数调用作为键的对象的__hash__方法)将键转换为哈希,并将其存储在哈希表中。

魔法方法,例如 __hash__,被称为魔法方法双下划线方法,其中双下划线是双下划线的缩写。

然而,哈希表有两个问题。第一个也是最明显的问题是,项目将按哈希排序,这在大多数情况下看起来是随机的。哈希表的第二个问题是它们可能发生哈希冲突,哈希冲突的结果是在最坏的情况下,所有之前的操作都可以用O(n)完成。哈希冲突并不太可能发生,但它们确实可能发生,如果一个大的dict表现不佳,那就是需要查看的地方。

自从 Python 3.6 版本以来,CPython 中的默认dict实现已经改为按插入顺序排序的版本。从 Python 3.7 版本开始,这被保证为一种行为,因为其他 Python 版本,如 Jython 和 PyPy,在 3.7 版本之前可能使用不同的实现。

让我们看看这在实践中是如何工作的。为了这个示例,我将使用我能想到的最简单的哈希算法之一,它使用数字的最显著位。所以,对于12345的情况,这个哈希函数将返回1,而对于56789,它将返回5

>>> def most_significant(value):
...     while value >= 10:
...         value //= 10
...     return value

>>> most_significant(12345)
1
>>> most_significant(99)
9
>>> most_significant(0)
0 

现在,我们将使用具有这种哈希方法的listlist来模拟dict。我们知道我们的哈希方法只能返回从09的数字,所以我们的列表中只需要 10 个桶。现在,我们将添加一些值并看看contains函数是如何工作的:

>>> def add(collection, key, value):
...     index = most_significant(key)
...     collection[index].append((key, value))

>>> def contains(collection, key):
...     index = most_significant(key)
...     for k, v in collection[index]:
...         if k == key:
...             return True
...     return False

# Create the collection of 10 lists
>>> collection = [[], [], [], [], [], [], [], [], [], []]
# Add some items, using key/value pairs
>>> add(collection, 123, 'a')
>>> add(collection, 456, 'b')
>>> add(collection, 789, 'c')
>>> add(collection, 101, 'c')

# Look at the collection
>>> collection
[[], [(123, 'a'), (101, 'c')], [], [],
 [(456, 'b')], [], [], [(789, 'c')], [], []]

# Check if the contains works correctly
>>> contains(collection, 123)
True
>>> contains(collection, 1)
False 

这段代码显然与dict实现不完全相同,但它是相似的。由于我们可以通过简单的索引直接获取值为123的项1,所以在一般情况下,我们只有O(1)的查找成本。然而,由于两个键123101都位于1桶中,在最坏的情况下,运行时间实际上可以增加到O(n),即所有键都有相同的哈希。如前所述,这就是哈希冲突。为了缓解hash()函数已经做的哈希冲突之外的问题,Python dict使用探测序列在需要时自动移动哈希。这种方法的具体细节在 Python 源代码的dictobject.c文件中有很好的解释。

要调试哈希冲突,你可以使用hash()函数与collections.Counter配合使用。这将快速显示哈希冲突发生的位置,但它不考虑dict的探测序列。

除了哈希碰撞性能问题之外,还有另一种可能让你感到惊讶的行为。当你从字典中删除项目时,实际上并不会在内存中对字典进行大小调整。结果是,复制和遍历整个字典都需要O(m)时间(其中m是字典的最大大小);n,当前的项目数量,没有被使用。所以,如果你向一个dict中添加 1,000 个项目并删除 999 个,遍历和复制仍然需要 1,000 步。解决这个问题的唯一方法是通过重新创建字典,这是copyinsert操作都会做的事情。请注意,在insert操作期间的重创建不保证,并且取决于内部可用的空闲槽位数量。

set – 类似于没有值的dict

一个set是一个使用hash()函数来获取唯一值集合的结构。在内部,它与dict非常相似,存在相同的哈希碰撞问题,但set有一些实用的特性需要展示:

# All output in the table below is generated using this function
>>> def print_set(expression, set_):
...     'Print set as a string sorted by letters'
...     print(expression, ''.join(sorted(set_)))

>>> spam = set('spam')
>>> print_set('spam:', spam)
spam: amps

>>> eggs = set('eggs')
>>> print_set('eggs:', eggs)
eggs: egs 

前几项基本上符合预期。当我们到达运算符时,事情变得有趣:

表达式 输出 说明
spam amps 所有唯一项。set不允许重复项。
eggs egs
spam & eggs s 在两个中的每个项。
spam &#124; eggs aegmps 要么在其中一个或两个中的每个项。
spam ^ eggs aegmp 要么在其中一个但不在两个中的每个项。
spam - eggs amp 在第一个中但不在后者中的每个项。
eggs - spam eg
spam > eggs False 如果后者中的每个项都在第一个中则为真。
eggs > spam False
spam > sp True
spam < sp False 如果第一个中的每个项都包含在后者中则为真。

set操作的一个有用示例是计算两个对象之间的差异。例如,假设我们有两个列表:

  • current_users:组中的当前用户

  • new_users:组中的新用户列表

在权限系统中,这是一个非常常见的场景——大量添加和/或从组中删除用户。在许多权限数据库中,一次设置整个列表并不容易,所以你需要一个用于插入的列表和一个用于删除的列表。这就是set真正派上用场的地方:

# The set function takes a sequence as argument so the double ( is required.
>>> current_users = set((
...     'a',
...     'b',
...     'd',
... ))

>>> new_users = set((
...     'b',
...     'c',
...     'd',
...     'e',
... ))

>>> to_insert = new_users - current_users
>>> sorted(to_insert)
['c', 'e']
>>> to_delete = current_users - new_users
>>> sorted(to_delete)
['a']
>>> unchanged = new_users & current_users
>>> sorted(unchanged)
['b', 'd'] 

现在,我们有了所有添加、删除和未更改的用户列表。请注意,sorted只需要用于一致的输出,因为set没有预定义的排序顺序。

tuple – 不可变列表

tuple是另一个你可能经常使用而甚至没有意识到的对象。当你最初看它时,它似乎是一个无用的数据结构。它就像一个你不能修改的列表,所以为什么不直接使用list呢?实际上,有一些情况下tuple提供了一些非常实用的功能,而list则没有。

首先,它们是可哈希的。这意味着你可以使用tuple作为dict的键或set的项,这是list无法做到的:

>>> spam = 1, 2, 3
>>> eggs = 4, 5, 6

>>> data = dict()
>>> data[spam] = 'spam'
>>> data[eggs] = 'eggs'

>>> import pprint  # Using pprint for consistent and sorted output

>>> pprint.pprint(data)
{(1, 2, 3): 'spam', (4, 5, 6): 'eggs'} 

然而,元组可以包含比简单的数字更多的内容。你可以使用嵌套元组、字符串、数字以及任何hash()函数返回一致结果的任何其他内容:

>>> spam = 1, 'abc', (2, 3, (4, 5)), 'def'
>>> eggs = 4, (spam, 5), 6

>>> data = dict()
>>> data[spam] = 'spam'
>>> data[eggs] = 'eggs'

>>> import pprint  # Using pprint for consistent and sorted output

>>> pprint.pprint(data)
{(1, 'abc', (2, 3, (4, 5)), 'def'): 'spam',
 (4, ((1, 'abc', (2, 3, (4, 5)), 'def'), 5), 6): 'eggs'} 

你可以使其变得尽可能复杂。只要元组的所有部分都是可哈希的,你将没有问题对元组进行哈希。你仍然可以构建一个包含list或其他不可哈希类型的元组,而不会出现问题,但这将使元组不可哈希。

可能更有用的是这样一个事实,即元组也支持元组打包和解包:

# Assign using tuples on both sides
>>> a, b, c = 1, 2, 3
>>> a
1

# Assign a tuple to a single variable
>>> spam = a, (b, c)
>>> spam
(1, (2, 3))

# Unpack a tuple to two variables
>>> a, b = spam
>>> a
1
>>> b
(2, 3) 

除了常规的打包和解包,从 Python 3 开始,我们实际上可以使用可变数量的项目进行打包和解包对象:

# Unpack with variable length objects which assigns a list instead
# of a tuple
>>> spam, *eggs = 1, 2, 3, 4
>>> spam
1
>>> eggs
[2, 3, 4]

# Which can be unpacked as well of, course
>>> a, b, c = eggs
>>> c
4

# This works for ranges as well
>>> spam, *eggs = range(10)
>>> spam
0
>>> eggs
[1, 2, 3, 4, 5, 6, 7, 8, 9]

# And it works both ways
>>> a, b, *c = a, *eggs
>>> a, b
(2, 1)
>>> c
[2, 3, 4, 5, 6, 7, 8, 9] 

打包和解包可以应用于函数参数:

>>> def eggs(*args):
...     print('args:', args)

>>> eggs(1, 2, 3)
args: (1, 2, 3) 

当从函数返回时,它们同样非常有用:

>>> def spam_eggs():
...     return 'spam', 'eggs'

>>> spam, eggs = spam_eggs()
>>> spam
'spam'
>>> eggs
'eggs' 

现在你已经看到了核心 Python 集合及其局限性,你应该更好地理解何时某些集合是一个好主意(或不是一个好主意)。更重要的是,如果一个数据结构的表现不符合你的预期,你将理解为什么。

不幸的是,现实世界中的问题通常不像你在本章中看到的那样简单,因此你将不得不权衡数据结构的优缺点,并为你的情况选择最佳解决方案。或者,你也可以通过组合这些结构中的几个来构建一个更高级的数据结构。然而,在你开始构建自己的结构之前,请继续阅读,因为我们将现在深入探讨更多高级集合,它们正是为此而设计的:结合核心集合。

使用高级集合的 Pythonic 模式

以下集合主要是基础集合的扩展;其中一些相对简单,而其他一些则更高级。对于所有这些,了解底层结构的特征是非常重要的。如果不理解它们,将很难理解集合的特征。

由于性能原因,有一些集合是用原生 C 代码实现的,但它们也可以很容易地在纯 Python 中实现。以下示例不仅将展示这些集合的功能和特征,还将展示一些它们可能有用处的示例设计模式。当然,这不是一个详尽的列表,但它应该能给你一个可能性方面的概念。

使用数据类进行类型提示的智能数据存储

Python(自 3.5 以来)最有用的最近添加之一是类型提示。通过类型注解,你可以向你的编辑器、文档生成器以及其他阅读你代码的人提供类型提示。

在 Python 中,我们通常期望自己是“同意的成年人”,这意味着提示不会被强制执行。这与 Python 中的私有和受保护变量不被强制执行的方式相似。这意味着我们可以很容易地给出与我们的提示完全不同的类型:

>>> spam: int
>>> __annotations__['spam']
<class 'int'>

>>> spam = 'not a number'
>>> __annotations__['spam']
<class 'int'> 

即使有int类型提示,我们仍然可以插入一个str

dataclasses模块是在 Python 3.7 中引入的(Python 3.6 有后向兼容版本),它使用类型提示系统自动生成类,包括基于这些类型的文档和构造函数:

>>> import dataclasses

>>> @dataclasses.dataclass
... class Sandwich:
...     spam: int
...     eggs: int = 3

>>> Sandwich(1, 2)
Sandwich(spam=1, eggs=2)

>>> sandwich = Sandwich(4)
>>> sandwich
Sandwich(spam=4, eggs=3)
>>> sandwich.eggs
3
>>> dataclasses.asdict(sandwich)
{'spam': 4, 'eggs': 3}
>>> dataclasses.astuple(sandwich)
(4, 3) 

基本类看起来相当简单,似乎没有什么特别之处,但如果你仔细观察,dataclass已经为我们生成了多个方法。哪些方法被生成,当查看dataclass参数时就会变得明显:

>>> help(dataclasses.dataclass)
Help on ... dataclass(..., *, init=True, repr=True, eq=True,
order=False, unsafe_hash=False, frozen=False) ... 

如您所见,dataclass有几个布尔标志,用于决定生成什么内容。

首先,init标志告诉dataclass创建一个类似于下面的__init__方法:

>>> def __init__(self, spam, eggs=3):
...    self.spam = spam
...    self.eggs = eggs 

此外,dataclass还有以下标志:

  • repr:这生成一个__repr__魔法函数,生成一个像Sandwich(spam=1, eggs=2)这样既美观又易读的输出,而不是像<__main__.Sandwich object at 0x...>这样的输出。

  • eq:这生成一个自动比较方法,当执行if sandwich_a == sandwich_b时,通过它们的值来比较两个Sandwich实例。

  • order:这生成一系列方法,以便比较运算符如>=<通过比较dataclasses.astuple的输出来工作。

  • unsafe_hash:这将强制生成一个__hash__方法,以便您可以在其上使用hash()函数。默认情况下,只有当对象的所有部分都被视为不可变时,才会生成__hash__函数。这样做的原因是hash()应该始终保持一致。如果您希望将对象存储在set中,它需要有一个一致的哈希值。由于set使用hash()来决定使用哪个内存地址,如果对象发生变化,set需要移动该对象。

  • frozen:这将防止在实例创建后进行更改。这种用法的主要目的是确保对象的hash()保持一致。

  • slots:这自动添加一个__slots__属性,使属性访问和存储更快、更高效。关于插槽的更多信息请参阅第十二章性能 – 跟踪和减少您的内存和 CPU 使用

唯一添加验证的标志是frozen标志,它使所有内容都变为只读,并阻止我们更改__setattr____getattr__方法,否则这些方法可以用来修改实例。

类型提示系统仍然只提供提示;然而,这些提示以任何方式都不会被强制执行。在第六章装饰器 – 通过装饰实现代码重用中,您将看到我们如何使用自定义装饰器将这些类型的强制执行添加到我们的代码中。

为了提供一个包含依赖关系的更有用的示例,假设我们有一些用户,他们属于系统中的一个或多个组:

>>> import typing

>>> @dataclasses.dataclass
... class Group:
...     name: str
...     parent: 'Group' = None

>>> @dataclasses.dataclass
... class User:
...     username: str
...     email: str = None
...     groups: typing.List[Group] = None

>>> users = Group('users')
>>> admins = Group('admins', users)
>>> rick = User('rick', groups=[admins])
>>> gvr = User('gvanrossum', 'guido@python.org', [admins])

>>> rick.groups
[Group(name='admins', parent=Group(name='users', parent=None))]

>>> rick.groups[0].parent
Group(name='users', parent=None) 

除了将数据类相互链接之外,这还展示了如何将集合作为字段创建,以及如何有递归定义。如您所见,Group类引用了自己的定义作为父类。

这些数据类在用于从数据库或 CSV 文件读取数据时特别有用。你可以轻松扩展数据类的行为以包括自定义方法,这使得它们成为存储自定义数据模型的一个非常有用的基础。

使用ChainMap组合多个作用域

从 Python 3.3 开始引入的ChainMap允许你将多个映射(例如字典)组合成一个。当组合多个上下文时,这特别有用。例如,当在当前作用域中查找变量时,默认情况下,Python 会搜索locals()globals(),最后是builtins

要明确编写代码来完成这个任务,我们可以这样做:

>>> import builtins

>>> builtin_vars = vars(builtins)

>>> key = 'something to search for'

>>> if key in locals():
...     value = locals()[key]
... elif key in globals():
...     value = globals()[key]
... elif key in builtin_vars:
...     value = builtin_vars[key]
... else:
...     raise NameError(f'name {key!r} is not defined')
Traceback (most recent call last):
...
NameError: name 'something to search for' is not defined 

这确实可行,但至少看起来很丑。我们可以通过删除一些重复的代码来让它更美观:

>>> mappings = locals(), globals(), vars(builtins)

>>> for mapping in mappings:
...     if key in mapping:
...         value = mapping[key]
...         break
... else:
...     raise NameError(f'name {key!r} is not defined')
Traceback (most recent call last):
...
NameError: name 'something to search for' is not defined 

这好多了!此外,这实际上可以被认为是一个不错的解决方案。但自从 Python 3.3 以来,这甚至更容易。现在,我们只需简单地使用以下代码:

>>> import collections

>>> mappings = collections.ChainMap(
...     locals(), globals(), vars(builtins))
>>> mappings[key]
Traceback (most recent call last):
...
KeyError: 'something to search for' 

如您所见,ChainMap类会自动将请求的值通过每个给定的dict合并,直到找到匹配项。如果找不到值,则会引发KeyError,因为它表现得像dict

这对于从多个来源读取配置并简单地获取第一个匹配项非常有用。对于命令行应用程序,这可以从命令行参数开始,然后是本地配置文件,然后是全球配置文件,最后是默认值。为了说明一些类似于我在小型命令行脚本中使用的代码:

>>> import json
>>> import pathlib
>>> import argparse
>>> import collections

>>> DEFAULT = dict(verbosity=1)

>>> config_file = pathlib.Path('config.json')
>>> if config_file.exists():
...     config = json.load(config_file.open())
... else:
...     config = dict()

>>> parser = argparse.ArgumentParser()
>>> parser.add_argument('-v', '--verbose', action='count',
...                     dest='verbosity')
_CountAction(...)

>>> args, _ = parser.parse_known_args()
>>> defined_args = {k: v for k, v in vars(args).items() if v}
>>> combined = collections.ChainMap(defined_args, config, DEFAULT)
>>> combined['verbosity']
1

>>> args, _ = parser.parse_known_args(['-vv'])
>>> defined_args = {k: v for k, v in vars(args).items() if v}
>>> combined = collections.ChainMap(defined_args, config, DEFAULT)
>>> combined['verbosity']
2 

可以清楚地看到继承。当给出特定的命令行参数(-vv)时,将使用该结果。否则,代码将回退到DEFAULTS或任何其他可用的变量。

使用defaultdict设置默认字典值

defaultdict是我在collections包中最喜欢的对象之一。在它被添加到核心之前,我写过几个类似的对象。虽然它是一个相当简单的对象,但它对各种设计模式非常有用。你不必每次都要检查键的存在并添加一个值,你只需从一开始就声明默认值,就无需担心其他事情。

例如,假设我们正在从一个连接节点的列表中构建一个非常基本的图结构。

这是我们的连接节点列表(单向):

nodes = [
    ('a', 'b'),
    ('a', 'c'),
    ('b', 'a'),
    ('b', 'd'),
    ('c', 'a'),
    ('d', 'a'),
    ('d', 'b'),
    ('d', 'c'),
] 

现在,让我们把这个图放入一个正常的字典中:

>>> graph = dict()
>>> for from_, to in nodes:
...     if from_ not in graph:
...         graph[from_] = []
...     graph[from_].append(to)

>>> import pprint

>>> pprint.pprint(graph)
{'a': ['b', 'c'],
 'b': ['a', 'd'],
 'c': ['a'],
 'd': ['a', 'b', 'c']} 

当然,可能有一些变体,例如使用setdefault。然而,它们仍然比必要的更复杂。

真正的 Python 风格版本使用defaultdict

>>> import collections

>>> graph = collections.defaultdict(list)
>>> for from_, to in nodes:
...     graph[from_].append(to)

>>> import pprint

>>> pprint.pprint(graph)
defaultdict(<class 'list'>,
            {'a': ['b', 'c'],
             'b': ['a', 'd'],
             'c': ['a'],
             'd': ['a', 'b', 'c']}) 

这段代码不是很好看吗?defaultdict也可以用作Counter对象的基本版本。它没有Counter那么花哨,也没有所有那些装饰,但在许多情况下它都能完成任务:

>>> counter = collections.defaultdict(int)
>>> counter['spam'] += 5
>>> counter
defaultdict(<class 'int'>, {'spam': 5}) 

defaultdict 的默认值需要是一个可调用对象。在前面的例子中,这些是 intlist,但你可以轻松定义自己的函数来用作默认值。这就是以下示例所使用的,尽管我不建议在生产环境中使用,因为它缺乏一些可读性。然而,我相信,这是一个展示 Python 力量的美丽示例。

这就是我们在 Python 中一行代码创建 tree 的方法:

import collections
def tree(): return collections.defaultdict(tree) 

太棒了,下面是如何实际使用的例子:

>>> import json
>>> import collections

>>> def tree():
...     return collections.defaultdict(tree)

>>> colours = tree()
>>> colours['other']['black'] = 0x000000
>>> colours['other']['white'] = 0xFFFFFF
>>> colours['primary']['red'] = 0xFF0000
>>> colours['primary']['green'] = 0x00FF00
>>> colours['primary']['blue'] = 0x0000FF
>>> colours['secondary']['yellow'] = 0xFFFF00
>>> colours['secondary']['aqua'] = 0x00FFFF
>>> colours['secondary']['fuchsia'] = 0xFF00FF

>>> print(json.dumps(colours, sort_keys=True, indent=4))
{
    "other": {
        "black": 0,
        "white": 16777215
    },
    "primary": {
        "blue": 255,
        "green": 65280,
        "red": 16711680
    },
    "secondary": {
        "aqua": 65535,
        "fuchsia": 16711935,
        "yellow": 16776960
    }
} 

好处在于你可以让它深入到你想要的程度。由于 defaultdict 的基础,它会递归地生成。

枚举 – 一组常量

Python 3.4 中引入的 enum 包在功能上与其他许多编程语言(如 C 和 C++)中的枚举非常相似。它有助于为你的模块创建可重用的常量,这样你可以避免使用任意常量。一个基本的例子如下:

>>> import enum

>>> class Color(enum.Enum):
...     red = 1
...     green = 2
...     blue = 3

>>> Color.red
<Color.red: 1>
>>> Color['red']
<Color.red: 1>
>>> Color(1)
<Color.red: 1>
>>> Color.red.name
'red'
>>> Color.red.value
1
>>> isinstance(Color.red, Color)
True
>>> Color.red is Color['red']
True
>>> Color.red is Color(1)
True 

enum 包的一些实用功能包括对象是可迭代的,可以通过数值和文本值表示访问,并且,通过适当的继承,甚至可以与其他类进行比较。

以下代码展示了基本 API 的使用:

>>> for color in Color:
...     color
<Color.red: 1>
<Color.green: 2>
<Color.blue: 3>

>>> colors = dict()
>>> colors[Color.green] = 0x00FF00
>>> colors
{<Color.green: 2>: 65280} 

enum 包的一个不太为人所知的功能是,除了通常使用的身份比较之外,你还可以使值比较工作。这对所有类型都有效——不仅限于整数,还包括(你自己的)自定义类型。

对于常规的 enum,只有身份检查(即 a is b)是有效的:

>>> import enum

>>> class Spam(enum.Enum):
...     EGGS = 'eggs'

>>> Spam.EGGS == 'eggs'
False 

当我们将 enum 继承 str 时,它开始比较值以及身份:

>>> import enum

>>> class Spam(str, enum.Enum):
...     EGGS = 'eggs'

>>> Spam.EGGS == 'eggs'
True 

除了前面的例子之外,enum 包还有一些其他变体,如 enum.Flagenum.IntFlag,它们允许进行位运算。这些可以用于表示权限,如下所示:permissions = Perm.READ | Perm.Write

每当你有一组可以一起分组的常量时,考虑使用 enum 包。它使得验证比多次使用 if/elif/elif/else 清洁得多。

使用 heapq 对集合进行排序

heapq 模块是一个很棒的模块,它使得在 Python 中创建优先队列变得非常容易。这是一个数据结构,它总是以最小的(或最大的,取决于实现)项可用,且无需太多努力。API 非常简单,其使用的一个最佳例子可以在 OrderedDict 对象中看到。虽然你可能不经常需要它,但如果需要,它是一个非常有用的结构。如果你希望了解 OrderedDict 等类的工作原理,理解其内部工作方式很重要。

如果你正在寻找一个始终保持列表排序的结构,请尝试下一节中介绍的 bisect 模块。

heapq 的基本用法简单,但一开始可能有些令人困惑:

>>> import heapq

>>> heap = [1, 3, 5, 7, 2, 4, 3]
>>> heapq.heapify(heap)
>>> heap
[1, 2, 3, 7, 3, 4, 5]

>>> while heap:
...     heapq.heappop(heap), heap
(1, [2, 3, 3, 7, 5, 4])
(2, [3, 3, 4, 7, 5])
(3, [3, 5, 4, 7])
(3, [4, 5, 7])
(4, [5, 7])
(5, [7])
(7, []) 

在这里需要注意的一个重要事项——你可能已经从前面的例子中理解到了——heapq模块并不创建一个特殊对象。它包含一些方法,可以将普通列表视为一个heap。这并不使它变得不那么有用,但这是需要考虑的事情。

初看起来,最令人困惑的部分是排序顺序。实际上,数组是排序的,但不是作为一个列表;它是作为一个树来排序的。为了说明这一点,请看以下树,它显示了树应该如何读取:

 1
 2   3
7 3 4 5 

最小的数字始终位于树的顶部,最大的数字始终位于树的底部行。正因为如此,找到最小的数字非常容易,但找到最大的数字就不那么容易了。要获取堆的排序版本,我们只需简单地不断移除树的顶部,直到所有项都消失。因此,堆排序算法可以按以下方式实现:

>>> def heapsort(iterable):
...     heap = []
...     for value in iterable:
...         heapq.heappush(heap, value)
...
...     while heap:
...         yield heapq.heappop(heap)

>>> list(heapsort([1, 3, 5, 2, 4, 1]))
[1, 1, 2, 3, 4, 5] 

由于heapq负责重负载,因此编写你自己的sorted()函数版本变得极其简单。

由于heappushheappop函数都具有O(log(n))的时间复杂度,因此它们可以被认为是真的很快。将它们结合用于前面可迭代对象的n个元素,我们得到heapsort函数的O(n*log(n))heappush方法内部使用list.append()并交换列表中的项以避免list.insert()O(n)时间复杂度。

log(n)指的是以 2 为底的对数函数。为了计算这个值,可以使用math.log2()函数。这导致每次数字大小加倍时,值增加 1。对于n=2log(n)的值是1,因此对于n=4n=8,对数值分别是23。而n=1024的结果是一个对数仅为10

这意味着一个 32 位数字,即2**32 = 4294967296,其对数是32

使用 bisect 在排序集合中进行搜索

上一节中的heapq模块为我们提供了一种简单的方法来排序一个结构并保持其排序。但如果我们想搜索一个排序集合以查看项目是否存在?或者如果不存在,下一个最大/最小项目是什么?这就是bisect算法帮助我们的地方。

bisect模块以这种方式在对象中插入项,使它们保持排序并易于搜索。如果你的主要目的是搜索,那么bisect应该是你的选择。如果你经常修改你的集合,heapq可能更适合你。

heapq类似,bisect实际上并不创建一个特殊的数据结构。bisect模块期望一个list,并期望这个list始终保持排序状态。理解这一点的性能影响是很重要的。虽然向list中添加项的时间复杂度为O(1),但插入的时间复杂度为O(n),这使得它成为一个非常耗时的操作。实际上,使用bisect创建一个排序列表的时间复杂度为O(n*n),这相当慢,尤其是因为使用heapqsorted()创建相同的排序列表只需要O(n*log(n))

如果你有一个已排序的结构,并且只需要添加一个单独的项,那么可以使用bisect算法进行插入。否则,通常直接添加项并在之后调用list.sort()sorted()会更快速。

为了说明,我们有这些行:

>>> import bisect

# Using the regular sort:
>>> sorted_list = []
>>> sorted_list.append(5)  # O(1)
>>> sorted_list.append(3)  # O(1)
>>> sorted_list.append(1)  # O(1)
>>> sorted_list.append(2)  # O(1)
>>> sorted_list.sort()  # O(n * log(n)) = 4 * log(4) = 8
>>> sorted_list
[1, 2, 3, 5]

# Using bisect:
>>> sorted_list = []
>>> bisect.insort(sorted_list, 5)  # O(n) = 1
>>> bisect.insort(sorted_list, 3)  # O(n) = 2
>>> bisect.insort(sorted_list, 1)  # O(n) = 3
>>> bisect.insort(sorted_list, 2)  # O(n) = 4
>>> sorted_list
[1, 2, 3, 5] 

对于少量项,这种差异是可以忽略不计的,但使用bisect进行排序所需的操作数量会迅速增长到差异很大的程度。对于n=4,差异只是4 * 1 + 8 = 121 + 2 + 3 + 4 = 10之间的差异,使得bisect解决方案更快。但如果我们插入 1,000 个项,它将是1000 + 1000 * log(1000) = 109661 + 2 + … 1000 = 1000 * (1000 + 1) / 2 = 500500。所以,在插入多个项时要非常小心。

在列表中进行搜索非常快;因为它是有序的,我们可以使用一个非常简单的二分搜索算法。例如,如果我们想检查几个数字是否存在于列表中?最简单的算法,如下所示,只是简单地遍历列表并检查所有项,导致最坏情况下的性能为O(n)

>>> sorted_list = [1, 2, 5]

>>> def contains(sorted_list, value):
...     for item in sorted_list:
...         if item > value:
...             break
...         elif item == value:
...             return True
...     return False

>>> contains(sorted_list, 2)  # Need to walk through 2 items, O(n) = 2
True
>>> contains(sorted_list, 4)  # Need to walk through 3 items, O(n) = 3
False
>>> contains(sorted_list, 6)  # Need to walk through 3 items, O(n) = 3
False 

然而,使用bisect算法,就没有必要遍历整个列表:

>>> import bisect

>>> sorted_list = [1, 2, 5]
>>> def contains(sorted_list, value):
...     i = bisect.bisect_left(sorted_list, value)
...     return i < len(sorted_list) and sorted_list[i] == value

>>> contains(sorted_list, 2)  # Found it after the first step, O(log(n)) = 1
True
>>> contains(sorted_list, 4)  # No result after 2 steps, O(log(n)) = 2
False
>>> contains(sorted_list, 6)  # No result after 2 steps, O(log(n)) = 2
False 

bisect_left函数试图找到数字应该放置的位置。这正是bisect.insort所做的;它通过搜索数字的位置来在正确的位置插入数字。

这些方法之间最大的区别是bisect在内部执行二分搜索,这意味着它从中间开始,根据列表中的值是否大于我们正在寻找的值,跳到左或右部分的中间。为了说明,我们将在一个从014的数字列表中搜索4

sorted_list = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
Step 1: 4 > 7                       ^
Step 2: 4 > 3           ^
Step 3: 4 > 5                 ^
Step 4: 4 > 5              ^ 

如你所见,经过仅四步,我们就找到了我们搜索的数字。根据数字(例如7),它可能更快,但找到数字的步骤永远不会超过O(log(n))

对于一个常规列表,搜索将简单地遍历所有项,直到找到所需的项。如果你很幸运,它可能是你遇到的第一个数字,但如果你不幸,它可能是最后一个项。在 1,000 个项的情况下,这将是 1,000 步与log(1000) = 10步之间的区别。

虽然非常快速和高效,但bisect模块一点也不像 Python 风格。让我们通过创建自己的SortedList类来解决这个问题:

>>> import bisect
>>> import collections

>>> class SortedList:
...     def __init__(self, *values):
...         self._list = sorted(values)
...     
...     def index(self, value):
...         i = bisect.bisect_left(self._list, value)
...         if i < len(self._list) and self._list[i] == value:
...             return index
...
...     def delete(self, value):
...         del self._list[self.index(value)]
...
...     def add(self, value):
...         bisect.insort(self._list, value)
...
...     def __iter__(self):
...         for value in self._list:
...             yield value
...
...     def __exists__(self, value):
...         return self.index(value) is not None

>>> sorted_list = SortedList(1, 3, 6, 2)
>>> 3 in sorted_list
True
>>> 5 in sorted_list
False
>>> sorted_list.add(5)
>>> 5 in sorted_list
True
>>> list(sorted_list)
[1, 2, 3, 5, 6] 

虽然这个实现是功能性的,但显然仍然有点局限。但如果你需要这种结构,这无疑是一个很好的起点。

使用 Borg 或单例模式的全局实例

大多数程序员都会熟悉单例模式,该模式确保一个类只有一个实例存在。在 Python 中,对此的一个常见替代解决方案是 Borg 模式,该模式以《星际迷航》中的博格命名。单例模式强制执行单个实例,而 Borg 模式则强制所有实例和子类都保持单一状态。由于 Python 中类创建的方式,Borg 模式比单例模式更容易实现和修改。

为了说明这两个示例:

Borg 类:

>>> class Borg:
...     _state = {}
...     def __init__(self):
...         self.__dict__ = self._state

>>> class SubBorg(Borg):
...     pass

>>> a = Borg()
>>> b = Borg()
>>> c = Borg()
>>> a.a_property = 123
>>> b.a_property
123
>>> c.a_property
123 

单例类:

>>> class Singleton:
...     def __new__(cls):
...         if not hasattr(cls, '_instance'):
...             cls._instance = super(Singleton, cls).__new__(cls)
...
...         return cls._instance

>>> class SubSingleton(Singleton):
...     pass

>>> a = Singleton()
>>> b = Singleton()
>>> c = SubSingleton()
>>> a.a_property = 123
>>> b.a_property
123
>>> c.a_property
123 

Borg 模式通过覆盖包含实例状态的实例的__dict__来实现。单例通过覆盖__new__(注意,不是__init__)方法,以确保我们始终只返回该类的单个实例。

使用属性不需要 getters 和 setters

在许多语言(尤其是 Java)中,访问实例变量的常见设计模式是使用 getters 和 setters,这样你可以在未来需要时修改行为。在 Python 中,我们可以透明地更改现有类的属性行为,而无需修改调用代码:

>>> class Sandwich:
...     def __init__(self, spam):
...         self.spam = spam
...
...     @property
...     def spam(self):
...         return self._spam
...
...     @spam.setter
...     def spam(self, value):
...         self._spam = value
...         if self._spam >= 5:
...             print('You must be hungry')
...
...     @spam.deleter
...     def spam(self):
...         self._spam = 0

>>> sandwich = Sandwich(2)
>>> sandwich.spam += 1
>>> sandwich.spam += 2
You must be hungry 

调用代码根本不需要改变。我们可以完全透明地改变属性的调用行为。

字典联合操作符

这实际上不是一个单独的高级集合,但它是对dict集合的高级使用。自 Python 3.9 以来,我们有几种简单的方法来组合多个dict实例。旧解决方案是使用dict.update(),可能还结合使用dict.copy()来创建一个新实例。虽然这可以正常工作,但它相当冗长且有点笨拙。

由于这是一个几个示例比仅仅解释更有用的案例,让我们看看旧解决方案是如何工作的:

>>> a = dict(x=1, y=2)
>>> b = dict(y=1, z=2)

>>> c = a.copy()
>>> c
{'x': 1, 'y': 2}
>>> c.update(b)

>>> a
{'x': 1, 'y': 2}
>>> b
{'y': 1, 'z': 2}
>>> c
{'x': 1, 'y': 1, 'z': 2} 

该解决方案效果很好,但自 Python 3.9 及以上版本,我们可以用更简单、更短的方式来做:

>>> a = dict(x=1, y=2)
>>> b = dict(y=1, z=2)

>>> a | b
{'x': 1, 'y': 1, 'z': 2} 

这是一个在指定函数参数时非常有用的功能,特别是如果你想自动用默认参数填充关键字参数时:

some_function(**(default_arguments | given_arguments)) 

现在你已经看到了 Python 附带的一些更高级的集合,你应该对何时应用哪种类型的集合有一个相当好的了解。你可能还了解了一些新的 Python 设计模式。

练习

除了增强本章中的示例外,还有很多其他的练习:

  • 创建一个使用keyfunc来决定排序顺序的SortedDict集合。

  • 创建一个具有O(log(n))插入操作和每次迭代都返回排序列表的SortedList集合。

  • 创建一个具有每个子类状态的 Borg 模式。

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。我们鼓励你提交自己的解决方案,并从他人的替代方案中学习。

摘要

在某些方面,Python 与其他语言略有不同,而在其他语言中常见的几种设计模式在 Python 中几乎没有什么意义。在本章中,你已经看到了一些常见的 Python 设计模式,但还有很多模式存在。在你开始根据这些模式实现自己的集合之前,快速在网上搜索一下是否已经存在现成的解决方案。特别是,collections模块经常更新,所以你的问题可能已经被解决了。

如果你曾好奇这些结构是如何工作的,可以查看以下源代码:github.com/python/cpython/blob/master/Lib/collections/__init__.py

完成这一章后,你应该了解基本 Python 结构的时复杂度。你也应该熟悉一些解决特定问题的 Python 方法。许多示例使用了collections模块,但本章并未列出collections模块中的所有类。

在你的应用程序中选择正确的数据结构是代码性能最重要的因素。这使得对性能特性的基本知识对于任何严肃的程序员来说都是必不可少的。

在下一章中,我们将继续介绍函数式编程,包括lambda函数、list推导式、dict推导式、set推导式以及一系列相关主题。此外,你还将了解函数式编程的数学背景。

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第五章:函数式编程 – 可读性与简洁性的权衡

本章将向您展示 Python 中函数式编程的一些酷技巧,并解释 Python 实现的一些局限性。为了学习和娱乐,我们还将简要讨论使用 lambda 演算的数学等价物,以Y 组合子为例。

最后几段将列出并解释functoolsitertools库的用法。如果您熟悉这些库,请随意跳过,但请注意,其中一些将在后续章节(第六章装饰器)、生成器(第七章)和性能(第十二章)中大量使用。

本章将涵盖以下主题:

  • 函数式编程背后的理论

  • listdictset推导式

  • lambda函数

  • functoolspartialreduce

  • itertoolsaccumulatechaindropwhilestarmap等)

首先,我们将从 Python 中函数式编程的历史以及函数式编程的实际含义开始。

函数式编程

函数式编程是一种起源于 lambda 演算(λ-calculus)的范式,这是一种可以用来模拟任何图灵机的数学形式系统。不深入探讨λ-calculus,这意味着计算仅使用函数参数作为输入,输出由一个新变量组成,而不修改输入变量。在严格函数式编程语言中,这种行为将被强制执行,但鉴于 Python 不是严格函数式语言,这并不一定成立。

由于混合范式可能会导致未预见的错误,正如在第三章Pythonic 语法和常见错误中讨论的那样,坚持这种范式仍然是一个好主意。

纯函数式

纯函数式编程期望函数没有副作用。这意味着传递给函数的参数不应被修改,任何其他外部状态也不应被修改。让我们用一个简单的例子来说明这一点:

>>> def add_value_functional(items, value):
...     return items + [value]

>>> items = [1, 2, 3]
>>> add_value_functional(items, 5)
[1, 2, 3, 5]
>>> items
[1, 2, 3]

>>> def add_value_regular(items, value):
...     items.append(value)
...     return items

>>> add_value_regular(items, 5)
[1, 2, 3, 5]
>>> items
[1, 2, 3, 5] 

这基本上显示了常规函数和纯函数之间的区别。第一个函数仅基于输入返回一个值,没有任何其他副作用。这与第二个函数形成对比,该函数修改了给定的输入或其作用域之外的变量。

即使在函数式编程之外,仅将更改限制在局部变量上也是一个好主意。保持函数的纯函数性(仅依赖于给定的输入)可以使代码更清晰、更容易理解,并且更容易测试,因为依赖项更少。在math模块中可以找到一些著名的例子。这些函数(sincospowsqrt等)的输入和输出严格依赖于输入。

函数式编程和 Python

Python 是少数几种,或者至少是最早的非函数式编程语言之一,它添加了函数式编程特性。最初的几个函数式编程函数大约在 1993 年引入,它们是lambdareducefiltermap。从那时起,Guido van Rossum 对它们的存在一直不太满意,因为它们常常使可读性受损。此外,mapfilter这样的函数很容易用list推导式来复制。因此,Guido 希望在 Python 3 版本中删除这些函数,但经过很多反对意见后,他选择了至少将reduce函数移动到functools.reduce

从那时起,Python 已经添加了几个其他函数式编程特性:

  • list/dict/set推导式

  • 生成器表达式

  • 生成器函数

  • 协程

functoolsitertools模块中也有许多有用的函数。

函数式编程的优点

当然,最大的问题是,你为什么要选择函数式编程而不是常规/过程式编程呢?以函数式风格编写代码有多个优点:

  • 写纯函数式代码的一个主要优点是它变得非常容易并行运行。因为没有外部变量需要,也没有外部变量被改变,你可以轻松地将代码并行化,以便在多个处理器或甚至多台机器上运行。当然,假设你可以轻松地传输输入变量和输出结果。

  • 因为函数是自包含的,并且没有副作用,所以它们减轻了几种类型的错误。例如,就地修改函数参数是一个很好的错误来源。此外,一个看似无用的函数调用,它修改父作用域中的变量,在纯函数式代码库中是不可能存在的。

  • 它使得测试变得容易得多。如果一个函数只有给定的输入和输出,并且不触及那些之外的任何东西,你就可以测试而无需为该函数设置整个环境。它也省略了在测试函数时进行沙箱化的需要。

自然,函数式编程也有一些缺点,其中一些是由相同的优点引起的。

在某些情况下,始终传递所有有用的参数可能会很麻烦。例如,在修改数据库时,你需要以某种方式获取数据库连接。如果你决定将数据库连接作为参数传递,并且没有为此做好准备,那么你不仅需要修改那个函数,还需要修改所有调用该函数的函数来传递那个参数。在这些情况下,一个包含数据库连接的全局可访问变量可以节省你很多工作。

函数式编程经常被提到的另一个缺点是递归。虽然递归是一个非常有用的工具,但它可以使追踪代码执行路径变得非常困难,这在解决错误时可能是一个问题。

函数式编程有其位置和时机。它并不适合每一种情况,但正确应用时,它是非常有用的工具之一。现在让我们继续看看一些函数式编程的例子。

列表、集合和字典推导式

Python 的 listsetdict 推导式是应用函数或过滤器到项目列表的一个非常简单的方法。

当正确使用时,list/set/dict 推导式可以非常方便地对列表、集合和字典进行快速过滤或转换。同样可以使用“函数式”函数 mapfilter 来实现相同的结果,但 list/set/dict 推导式通常更容易使用和阅读。

基本列表推导式

让我们直接进入几个例子。list 推导式的基本前提看起来是这样的:

>>> squares = [x ** 2 for x in range(10)]
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81] 

我们可以很容易地通过一个过滤器来扩展这个功能:

>>> odd_squares = [x ** 2 for x in range(10) if x % 2]
>>> odd_squares
[1, 9, 25, 49, 81] 

这带我们来到了大多数函数式语言中常见的使用 mapfilter 的版本:

>>> def square(x):
...     return x ** 2

>>> def odd(x):
...     return x % 2

>>> squares = list(map(square, range(10)))
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

>>> odd_squares = list(filter(odd, map(square, range(10))))
>>> odd_squares
[1, 9, 25, 49, 81] 

看到这个之后,就稍微明显一些为什么吉多·范罗苏姆想要从语言中移除这些功能。特别是,使用 filtermap 的版本,考虑到括号的数量,并不是那么易于阅读,除非你习惯于 Lisp 编程语言。

map 的最重要应用实际上不是使用 map 本身,而是使用类似 multiprocessing.pool.Pool.mapmap-like 函数以及 map_asyncimapstarmapstarmap_asyncimap_unordered 等变体,这些函数可以在多个处理器上自动并行执行函数。

虽然我个人并不反对 mapfilter,但我认为它们的用法应该保留在那些你有现成函数可用以在 mapfilter 调用中使用的场合。一个更有用的例子可能是:

>>> import os

>>> directories = filter(os.path.isdir, os.listdir('.'))
# Versus:
>>> directories = [x for x in os.listdir('.') if os.path.isdir(x)] 

在这种情况下,filter 版本可能比 list 推导式稍微容易阅读一些。

对于 list 推导式来说,语法与常规 Python 循环非常相似,但 if 语句和自动存储结果使其能够稍微压缩代码。常规 Python 的等效代码并不长:

>>> odd_squares = []
>>> for x in range(10):
...     if x % 2:
...         odd_squares.append(x ** 2)

>>> odd_squares
[1, 9, 25, 49, 81] 

集合推导式

除了 list 推导式之外,我们还可以使用 set 推导式,它具有相同的语法,但返回一个唯一的无序集合(所有集合都是无序的):

# List comprehension
>>> [x // 2 for x in range(3)]
[0, 0, 1]

# Set comprehension
>>> numbers = {x // 2 for x in range(3)}
>>> sorted(numbers)
[0, 1] 

字典推导式

最后,我们有 dict 推导式,它返回一个 dict 而不是 listset

除了返回类型之外,唯一的真正区别是你需要返回一个键和一个值。以下是一个基本示例:

>>> {x: x ** 2 for x in range(6)}
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

>>> {x: x ** 2 for x in range(6) if x % 2}
{1: 1, 3: 9, 5: 25} 

由于输出是一个字典,键需要是可哈希的,dict 推导式才能工作。我们在 第四章 中介绍了哈希,但简而言之,hash(key) 需要为你对象返回一个一致的价值。这意味着无法对可变对象(如列表)进行哈希。

有趣的是,当然你可以混合这两种,以获得更多难以阅读的魔法:

>>> {x ** 2: [y for y in range(x)] for x in range(5)}
{0: [], 1: [0], 4: [0, 1], 16: [0, 1, 2, 3], 9: [0, 1, 2]} 

显然,你需要小心使用这些。如果使用得当,它们非常有用,但输出很快就会变得难以阅读,即使有适当的空白。

列表推导陷阱

当使用列表推导时,必须小心。某些类型的操作可能不像你想象的那么明显。这次,我们正在寻找大于0.5的随机数:

>>> import random

>>> [random.random() for _ in range(10) if random.random() >= 0.5]
[0.5211948104577864, 0.650010512129705, 0.021427316545174158] 

看看那个最后的数字?它实际上小于0.5。这是因为第一个和最后一个随机调用实际上是单独的调用,并返回不同的结果。

一种对抗这种情况的方法是单独创建列表和过滤器:

>>> import random

>>> numbers = [random.random() for _ in range(10)]
>>> [x for x in numbers if x >= 0.5]
[0.715510247827078, 0.8426277505519564, 0.5071133900377911] 

这显然是有效的,但并不那么美观。那么还有其他什么选择呢?好吧,有几个,但可读性有点可疑,所以这些不是我会推荐的方法。然而,至少看到它们一次是好的。

这里有一个嵌套在列表推导中的列表推导:

>>> import random

>>> [x for x in [random.random() for _ in range(10)] if x >= 0.5] 

这里有一个很快就会变得难以理解的列表推导:

>>> import random

>>> [x for _ in range(10) for x in [random.random()] if x >= 0.5] 

使用这些选项时需要小心,因为双重列表推导实际上像嵌套的for循环一样工作,所以它会快速生成大量结果。为了详细说明这一点,请考虑:

>>> [(x, y) for x in range(3) for y in range(3, 5)]
[(0, 3), (0, 4), (1, 3), (1, 4), (2, 3), (2, 4)] 

这实际上执行以下操作:

>>> results = []
>>> for x in range(3):
...     for y in range(3, 5):
...         results.append((x, y))
...
>>> results
[(0, 3), (0, 4), (1, 3), (1, 4), (2, 3), (2, 4)] 

这些在某些情况下可能很有用,但我强烈建议不要嵌套列表推导,因为这很快会导致代码难以阅读。然而,理解正在发生的事情仍然是有用的,所以让我们再看一个例子。下面的列表推导将列数和行数互换,因此一个 3 x 4 的矩阵变成了 4 x 3:

>>> matrix = [
...     [1, 2, 3, 4],
...     [5, 6, 7, 8],
...     [9, 10, 11, 12],
... ]

>>> reshaped_matrix = [
...     [
...         [y for x in matrix for y in x][i * len(matrix) + j]
...         for j in range(len(matrix))
...     ]
...     for i in range(len(matrix[0]))
... ]

>>> import pprint

>>> pprint.pprint(reshaped_matrix, width=40)
[[1, 2, 3],
 [4, 5, 6],
 [7, 8, 9],
 [10, 11, 12]] 

即使有额外的缩进,列表推导仍然不是那么易于阅读。当然,有四个嵌套循环时,这是预料之中的。在极少数情况下,嵌套列表推导可能是合理的,例如非常基本的矩阵操作。然而,在一般情况下,我不会推荐使用嵌套列表推导。

接下来,我们将探讨lambda函数,它可以与mapfilter结合使用,以创建简短方便的函数。

lambda函数

Python 中的lambda语句只是一个匿名函数。由于语法限制,它比常规函数稍微有限一些,但可以通过它做很多事情。然而,如往常一样,可读性很重要,所以通常最好尽可能保持简单。其中一个更常见的用例是作为sorted函数的sort键:

>>> import operator

>>> values = dict(one=1, two=2, three=3)

>>> sorted(values.items())
[('one', 1), ('three', 3), ('two', 2)]

>>> sorted(values.items(), key=lambda item: item[1])
[('one', 1), ('two', 2), ('three', 3)]

>>> get_value = operator.itemgetter(1)
>>> sorted(values.items(), key=get_value)
[('one', 1), ('two', 2), ('three', 3)] 

第一个版本按键排序,第二个版本按值排序。最后一个示例展示了使用operator.itemgetter生成一个获取特定项的函数的替代选项。

正规(非lambda)函数不会更加冗长,但在这些情况下,lambda函数是一个非常有用的简写。为了完整性,让我们看看两个相同的函数:

>>> key = lambda item: item[1]

>>> def key(item):
...     return item[1] 

请注意,PEP8 规定将 lambda 赋值给变量是一个坏主意(peps.python.org/pep-0008/#programming-recommendations)。从逻辑上讲,这是正确的。匿名函数的想法就是它是匿名的,没有名字。如果你给它一个身份,你应该将其定义为普通函数。

在我看来,lambda 函数的唯一有效用途是作为 sorted() 等函数的匿名一行参数。

Y 演算子

这一节可以很容易地跳过。它主要是一个 λ 表达式数学价值的例子。

Y 演算子可能是 λ 演算最著名的例子:

所有这些都看起来非常复杂,但这主要是因为它使用了λ演算的符号,如果你超越了这些特殊字符,其实并不那么困难。

为了说明,你应该将这个语法,,读作一个匿名(lambda)函数,它接受 x 作为输入并返回 。在 Python 中,这几乎可以完全按照原始的 λ 演算来表示,除了将 替换为 lambda. 替换为 :,因此结果是 lambda x: x**2

通过一些代数(en.wikipedia.org/wiki/Fixed-point_combinator#Fixed-point_combinators_in_lambda_calculus),Y 演算子可以简化为 ,或者是一个接受 函数并将其应用于自身的函数。该函数的 λ 演算表示法如下:

这里是 lambda 函数的 Python 表示法:

Y = lambda f: lambda *args: f(Y(f))(*args) 

或者是常规函数版本:

def Y(f):
    def y(*args):
        y_function = f(Y(f))
        return y_function(*args)
    return y 

这一切归结为一个接受函数 f 作为参数的函数,该函数使用 Y 演算子以该函数作为参数进行调用。

这可能仍然有点不清楚,所以让我们看看一个实际使用它的例子:

>>> Y = lambda f: lambda *args: f(Y(f))(*args)

>>> def factorial(combinator):
...     def _factorial(n):
...         if n:
...             return n * combinator(n - 1)
...         else:
...             return 1
...     return _factorial

>>> Y(factorial)(5)
120 

以下是其简短版本,其中 Y 演算子的力量更为明显,使用递归匿名函数:

>>> Y = lambda f: lambda *args: f(Y(f))(*args)

>>> Y(lambda c: lambda n: n and n * c(n - 1) or 1)(5)
120 

注意,n and n * c(n – 1) or 1 这部分是函数较长版本中使用的 if 语句的简写。或者,这也可以使用 Python 的三元运算符来写:

>>> Y = lambda f: lambda *args: f(Y(f))(*args)

>>> Y(lambda c: lambda n: n * c(n - 1) if n else 1)(5)
120 

你可能想知道这个练习的目的是什么。你完全可以写一个更短、更简单、更符合 Python 习惯的阶乘函数。那么 Y 演算子的意义在哪里?Y 演算子允许我们以递归的方式执行非递归函数。

然而,更重要的是,我认为这是一个有趣的 Python 力量的展示——如何在几行 Python 中实现像 λ 演算这样基本的东西。我认为它的实现具有一定的美感。

我们将通过几行代码中quicksort的定义给出Y组合子的一个最终示例:

>>> quicksort = Y(lambda f:
...     lambda x: (
...         f([item for item in x if item < x[0]])
...         + [y for y in x if x[0] == y]
...         + f([item for item in x if item > x[0]])
...     ) if x else [])

>>> quicksort([1, 3, 5, 4, 1, 3, 2])
[1, 1, 2, 3, 3, 4, 5] 

虽然 Y 组合子在 Python 中可能没有太多实际用途,但它确实展示了lambda语句的力量以及 Python 与它背后的基本数学的接近程度。本质上,区别只在于符号,而不是功能。

现在我们知道了如何编写自己的lambda和函数式函数,我们将看看 Python 中捆绑的函数式函数。

functools

除了list/dict/set推导式之外,Python 还有一些(更高级的)函数,在函数式编程时非常方便。functools库是一组返回可调用对象的函数。其中一些函数用作装饰器(我们将在第六章装饰器 – 通过装饰实现代码重用中详细介绍),但我们将要讨论的是用作直接函数来简化你的生活。

partial – 预填充函数参数

partial函数对于向常用但无法(或不想)重新定义的函数添加一些默认参数来说非常方便。在面向对象的代码中,通常可以绕过类似的情况,但在过程式代码中,你通常会不得不重复你的参数。让我们以第四章中Pythonic 设计模式heapq函数为例:

>>> import heapq

>>> heap = []
>>> heapq.heappush(heap, 1)
>>> heapq.heappush(heap, 3)
>>> heapq.heappush(heap, 5)
>>> heapq.heappush(heap, 2)
>>> heapq.heappush(heap, 4)
>>> heapq.nsmallest(3, heap)
[1, 2, 3] 

几乎所有的heapq函数都需要一个heap参数,因此我们将创建一个快捷方式来自动填充heap变量。当然,这可以通过一个常规函数轻松完成:

>>> def push(*args, **kwargs):
...     return heapq.heappush(heap, *args, **kwargs) 

然而,有一个更简单的方法。Python 附带了一个名为functools.partial的函数,它可以生成一个带有预填充参数的函数:

>>> import functools
>>> import heapq

>>> heap = []
>>> push = functools.partial(heapq.heappush, heap)
>>> smallest = functools.partial(heapq.nsmallest, iterable=heap)

>>> push(1)
>>> push(3)
>>> push(5)
>>> push(2)
>>> push(4)
>>> smallest(3)
[1, 2, 3] 

使用functools.partial,我们可以自动为我们填充位置参数和/或关键字参数。因此,对push(...)的调用会自动展开为heapq.heappush(heap, ...)

我们为什么要使用partial而不是编写一个lambda参数呢?嗯,这主要是因为方便,但它也有助于解决在第三章中讨论的晚期绑定问题,即Pythonic 语法和常见陷阱。此外,偏函数仍然与原始函数有某种相似的行为,这意味着它们仍然有可用的文档,并且可以被序列化,而lambda表达式则不能。

Python 中的pickle模块允许序列化许多复杂的 Python 对象,但默认情况下并非所有对象。lambda函数默认没有定义pickle方法,但可以通过在copy_reg.dispatch_table中定义自己的 lambda-pickle 方法来解决这个问题。实现这一点的一个简单方法是使用dill库,它包含了一系列pickle辅助函数。

为了说明lambdafunctools.partial之间的区别,请看以下示例:

>>> lambda_push = lambda x: heapq.heappush(heap, x)

>>> heapq.heappush
<built-in function heappush>
>>> push
functools.partial(<built-in function heappush>, [1, 2, 5, 3, 4])
>>> lambda_push
<function <lambda> at ...>

>>> heapq.heappush.__doc__
'Push item onto heap, maintaining the heap invariant.'
>>> push.__doc__
'partial(func, *args, **keywords) - new function ...'
>>> lambda_push.__doc__ 

注意到lambda_push.__doc__不返回任何内容,而lambda只有一个非常不实用的<function <lambda> ...>表示字符串。这是functools.partial在实际使用中远比它方便的一个原因。它显示了参考函数的文档;表示字符串显示了它确切在做什么,并且它可以不经过修改地进行序列化。

第六章装饰器 – 通过装饰实现代码重用(特别是关于functools.wraps的部分),我们将看到我们如何使函数以类似functools.partial复制文档的方式从其他函数复制属性。

reduce – 将成对元素合并成一个结果

reduce函数实现了一种称为折叠的数学技术。它将给定列表中前一个结果和下一个元素配对,并应用于传递给函数。

reduce函数被许多语言支持,但在大多数情况下使用不同的名称,如curryfoldaccumulateaggregate。Python 实际上已经支持reduce很长时间了,但自从 Python 3 以来,它已经被从全局作用域移动到functools库中。一些代码可以使用reduce语句进行非常漂亮的简化;然而,它是否可读是值得商榷的。

实现阶乘函数

reduce最常用的例子之一是计算阶乘,这确实很简单:

>>> import operator
>>> import functools

>>> functools.reduce(operator.mul, range(1, 5))
24 

前面的代码使用operator.mul而不是lambda a, b: a * b。虽然它们产生相同的结果,但前者可能要快得多。

内部,reduce函数将执行以下操作:

>>> from operator import mul

>>> mul(mul(mul(1, 2), 3), 4)
24 

或者,创建一个自动循环的reduce函数看起来像这样:

>>> import operator

>>> def reduce(function, iterable):
...     print(f'iterable={iterable}')
...     # Fetch the first item to prime 'result'
...     result, *iterable = iterable
...
...     for item in iterable:
...         old_result = result
...         result = function(result, item)
...         print(f'{old_result} * {item} = {result}')
...
...     return result

>>> iterable = list(range(1, 5))
>>> iterable
[1, 2, 3, 4]

>>> reduce(operator.mul, iterable)
iterable=[1, 2, 3, 4]
1 * 2 = 2
2 * 3 = 6
6 * 4 = 24
24 

使用形式a, *b = c,我们可以将可迭代对象在第一个项和其余项之间分割。这意味着a, *b = [1, 2, 3]将得到a=1, b=[2, 3]

在这个例子中,这意味着我们首先初始化result变量,使其包含初始值,然后继续使用当前结果和下一个项调用函数,直到iterable耗尽。

实际上,这归结为:

  1. iterable = [1, 2, 3, 4]

  2. result, *iterable = iterable

    这给我们result=1iterable = [2, 3, 4]

  3. 接下来是第一次调用operator.mul,参数是resultitem,这个值存储在result中。这是reducemap之间的一个重大区别。而map只将函数应用于给定的项,reduce则将前一个结果和当前项都应用于函数。因此,它实际上执行的是result = operator.mul(result, item)。填充变量后,我们得到result = 1 * 2 = 2

  4. 下一个调用实际上重复了同样的过程,但由于前一个调用,我们的初始result值现在是2,下一个item3result = 2 * 3 = 6

  5. 我们再次重复这个过程,因为我们的iterable现在已经耗尽了。最后的调用将执行result = 6 * 4 = 24

处理树

树是reduce函数真正大放异彩的例子。还记得第四章Pythonic 设计模式中用defaultdict定义的一行树吗?如何访问该对象内部的键呢?给定一个树项的路径,我们可以使用reduce轻松访问内部项。首先,让我们构建一个树:

>>> import json
>>> import functools
>>> import collections

>>> def tree():
...     return collections.defaultdict(tree)

# Build the tree:
>>> taxonomy = tree()
>>> reptilia = taxonomy['Chordata']['Vertebrata']['Reptilia']
>>> reptilia['Squamata']['Serpentes']['Pythonidae'] = [
...     'Liasis', 'Morelia', 'Python']

# The actual contents of the tree
>>> print(json.dumps(taxonomy, indent=4))
{
    "Chordata": {
        "Vertebrata": {
            "Reptilia": {
                "Squamata": {
                    "Serpentes": {
                        "Pythonidae": [
                            "Liasis",
                            "Morelia",
                            "Python"
                        ]
                    }
                }
            }
        }
    }
} 

首先,我们通过使用collections.defaultdict的递归定义创建了一个tree结构。这允许我们不需要显式定义就可以将tree嵌套多层。

为了提供可读性较好的输出,我们使用json模块导出tree(实际上是一个嵌套字典的列表)。

现在是查找的时候了:

# Let's build the lookup function
>>> import operator

>>> def lookup(tree, path):
...     # Split the path for easier access
...     path = path.split('.')
...
...     # Use 'operator.getitem(a, b)' to get 'a[b]'
...     # And use reduce to recursively fetch the items
...     return functools.reduce(operator.getitem, path, tree)

>>> path = 'Chordata.Vertebrata.Reptilia.Squamata.Serpentes'
>>> dict(lookup(taxonomy, path))
{'Pythonidae': ['Liasis', 'Morelia', 'Python']}

# The path we wish to get
>>> path = 'Chordata.Vertebrata.Reptilia.Squamata'
>>> lookup(taxonomy, path).keys()
dict_keys(['Serpentes']) 

现在我们有一个非常简单的方法,只需几行代码就可以递归地遍历结构。

反向减少

熟悉函数式编程的人可能会想知道为什么 Python 只有fold_left的等价物而没有fold_right。实际上,你并不真的需要两者,因为你可以轻松地反转操作。公平地说,同样的话也可以说关于reduce,因为我们已经在上一段中看到,它很容易实现。

正常的reduce——fold left操作:

fold_left = functools.reduce(
    lambda x, y: function(x, y),
    iterable,
    initializer,
) 

反之——fold right操作:

fold_right = functools.reduce(
    lambda x, y: function(y, x),
    reversed(iterable),
    initializer,
) 

对于reduce可能没有太多有用的用例,但确实有一些。特别是,使用reduce遍历递归数据结构要容易得多,因为否则将涉及更复杂的循环或递归函数。

既然我们已经看到了 Python 中的一些函数式函数,现在是时候看看一些专注于可迭代对象的函数了。

itertools

itertools库包含受函数式语言中可用函数启发的可迭代函数。所有这些都是可迭代的,并且被构建成只需要最小的内存即可处理甚至最大的数据集。虽然你可以轻松地自己编写这些函数中的大多数,但我仍然建议使用itertools库中提供的函数。这些函数都是快速的、内存高效的,也许更重要的是——经过测试。我们现在要探索几个:accumulatechaincompressdropwhile/takewhilecountgroupby

accumulate – 带中间结果的 reduce

accumulate函数与reduce函数非常相似,这就是为什么一些语言实际上有accumulate而不是reduce作为折叠操作符。

这两个函数的主要区别在于accumulate函数返回即时结果。例如,在汇总一家公司的销售额时,这可能很有用:

>>> import operator
>>> import itertools

# Sales per month
>>> months = [10, 8, 5, 7, 12, 10, 5, 8, 15, 3, 4, 2]
>>> list(itertools.accumulate(months, operator.add))
[10, 18, 23, 30, 42, 52, 57, 65, 80, 83, 87, 89] 

应该注意的是,在这个情况下,operator.add函数实际上是可选的,因为accumulate的默认行为是求和结果。在某些其他语言和库中,这个函数有时被称为cumsum(累积和)。

chain – 结合多个结果

chain 函数是一个简单但很有用的函数,它将多个迭代器的结果组合起来。如果你有多个列表、迭代器等,这个函数非常简单且非常有用——只需用简单的链将它们组合起来:

>>> import itertools

>>> a = range(3)
>>> b = range(5)
>>> list(itertools.chain(a, b))
[0, 1, 2, 0, 1, 2, 3, 4] 

应该注意的是,存在一个名为 chain 的函数的微小变体,它接受一个包含可迭代的可迭代对象,即 chain.from_iterable。这个函数几乎与原函数完全相同,唯一的区别在于你需要传递一个可迭代对象项,而不是传递一个参数列表。

你的初始反应可能是,这可以通过解包 (*args) 元组来实现,正如我们将在 第七章 中看到的,即生成器和协程——无限,一次一步。然而,情况并不总是如此。现在,只需记住,如果你有一个包含可迭代的可迭代对象,最简单的方法是使用 itertools.chain.from_iterable。用法正如你所预期的那样:

>>> import itertools

>>> iterables = [range(3), range(5)]
>>> list(itertools.chain.from_iterable(iterables))
[0, 1, 2, 0, 1, 2, 3, 4] 

compress – 使用布尔列表选择项目

compress 函数是你不太经常需要使用的函数之一,但在你需要的时候它可能非常有用。它对你的可迭代对象应用布尔过滤器,使其只返回你实际需要的元素。在这里需要注意的最重要的事情是 compress 执行的是惰性操作,并且 compress 会在数据耗尽或不再获取任何元素时停止。因此,即使对于无限范围,它也能顺利工作:

>>> import itertools

>>> list(itertools.compress(range(1000), [0, 1, 1, 1, 0, 1]))
[1, 2, 3, 5] 

如果你想在不修改原始可迭代对象的情况下创建一个较大可迭代对象的过滤视图,compress 函数可能很有用。如果计算过滤器是一个耗时的操作,并且可迭代对象中的实际值可能会改变,这可能会非常有用。基于上面的示例:

>>> primes = [0, 0, 1, 1, 0, 1, 0, 1]
>>> odd = [0, 1, 0, 1, 0, 1, 0, 1]
>>> numbers = ['zero', 'one', 'two', 'three', 'four', 'five']

# Primes:
>>> list(itertools.compress(numbers, primes))
['two', 'three', 'five']

# Odd numbers
>>> list(itertools.compress(numbers, odd))
['one', 'three', 'five']

# Odd primes
>>> list(itertools.compress(numbers, map(all, zip(odd, primes))))
['three', 'five'] 

在这种情况下,过滤器和可迭代对象都是预定义的,并且非常小。但是,如果你有一个需要花费大量时间计算(或从外部资源获取)的大集合,这个方法可以用来快速过滤,而无需重新计算一切,特别是由于过滤器可以很容易地使用 mapallzip 的组合来结合。如果你想看到两个的结果,可以使用 any 代替 all

dropwhile/takewhile – 使用函数选择项目

dropwhile 函数将跳过所有结果,直到给定的谓词评估为真。如果你正在等待一个设备最终返回预期的结果,这可能会很有用。这在书中可能有点难以演示,所以我们只提供了一个基本用法示例——等待一个大于 3 的数字:

>>> import itertools

>>> list(itertools.dropwhile(lambda x: x <= 3, [1, 3, 5, 4, 2]))
[5, 4, 2] 

如你所料,takewhile 函数是这个的逆过程。它将简单地返回所有行,直到谓词变为假:

>>> import itertools

>>> list(itertools.takewhile(lambda x: x <= 3, [1, 3, 5, 4, 2]))
[1, 3] 

dropwhiletakewhile 的结果相加将再次给出所有元素,因为它们是对方的对立面。

count – 带小数步长的无限范围

count 函数与 range 函数非常相似,但有两个显著的区别:

  • 第一点是,这个范围是无限的,所以甚至不要尝试执行list(itertools.count())。您肯定会立即耗尽内存,甚至可能冻结您的系统。

  • 第二个区别是,与range函数不同,您实际上可以在这里使用浮点数,因此不需要整数。

由于列出整个范围会杀死我们的 Python 解释器,我们将使用itertools.islice函数限制结果,该函数与常规切片类似(例如some_list[10:20]),但也可以处理无限大的输入。

count这样的无限大函数不可切片,因为它们是无限生成器,这是我们将在第七章生成器和协程 – 一步一步的无限中讨论的主题。

count函数有两个可选参数:一个start参数,默认为0,以及一个step参数,默认为1

>>> import itertools

>>> list(itertools.islice(itertools.count(), 10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

>>> list(itertools.islice(itertools.count(), 5, 10, 2))
[5, 7, 9]

>>> list(itertools.islice(itertools.count(10, 2.5), 5))
[10, 12.5, 15.0, 17.5, 20.0] 

groupby – 对排序的可迭代对象进行分组

groupby函数是一个非常方便的函数,用于分组结果。它允许您根据特定的分组函数将对象列表转换为分组列表。

groupby使用的基本示例:

>>> import operator
>>> import itertools

>>> words = ['aa', 'ab', 'ba', 'bb', 'ca', 'cb', 'cc']

# Gets the first element from the iterable
>>> getter = operator.itemgetter(0)

>>> for group, items in itertools.groupby(words, key=getter):
...     print(f'group: {group}, items: {list(items)}')
group: a, items: ['aa', 'ab']
group: b, items: ['ba', 'bb']
group: c, items: ['ca', 'cb', 'cc'] 

我们可以在这里看到,通过非常少的努力,单词可以根据第一个字符进行分组。例如,这可以是一个非常方便的实用工具,用于在用户界面中按部门分组员工。

然而,在使用此函数时,有一些重要的事情需要记住:

  • 输入需要按group参数进行排序。否则,每个重复的组都将作为一个单独的组添加。

  • 结果只能使用一次。因此,处理完一组数据后,它将不再可用。如果您希望迭代结果两次,请将结果包裹在list()tuple()中。

下面是一个包含未排序副作用groupby的例子:

>>> import itertools

>>> raw_items = ['spam', 'eggs', 'sausage', 'spam']

>>> def keyfunc(group):
...     return group[0]

>>> for group, items in itertools.groupby(raw_items, key=keyfunc):
...     print(f'group: {group}, items: {list(items)}')
group: s, items: ['spam']
group: e, items: ['eggs']
group: s, items: ['sausage', 'spam']

>>> raw_items.sort()
>>> for group, items in itertools.groupby(raw_items, key=keyfunc):
...     print(f'group: {group}, items: {list(items)}')
group: e, items: ['eggs']
group: s, items: ['sausage', 'spam', 'spam'] 

groupby函数绝对是一个非常有用的函数,您可以在各种场景中使用它。例如,对用户输出进行分组可以使结果更容易阅读。

练习

现在您已经了解了如何在 Python 中使用一些函数式编程特性,也许您可以尝试将快速排序算法作为(一系列)常规函数来编写,而不是难以阅读的 Y-combinator 版本。

您还可以尝试自己编写一个groupby函数,该函数不受排序影响,并返回可以多次使用的而不是仅使用一次的结果列表。

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。您被鼓励提交自己的解决方案,并从他人的替代方案中学习。

摘要

函数式编程最初可能会让很多人感到害怕,但实际上它不应该这样。函数式编程与过程式编程(在 Python 中)之间最重要的区别是思维方式。所有操作都是通过只依赖于输入变量的简单函数来执行的,并且不会在局部作用域之外产生任何副作用。

主要优势包括:

  • 由于副作用较少,代码之间相互影响较少,因此你会遇到更少的错误。

  • 由于函数始终具有可预测的输入和输出,它们可以轻松地在多个处理器或甚至多台机器之间并行化。

本章涵盖了 Python 中函数式编程的基础以及其背后的少量数学知识。此外,还介绍了一些可以通过函数式编程非常方便地使用的许多有用库。

最重要的是以下要点:

  • Lambda 表达式本身并不坏,但最好只让它们使用局部作用域中的变量,并且它们不应该超过一行。

  • 函数式编程可能非常强大,但容易变得难以阅读。必须小心使用。

  • list/dict/set 推导式非常有用,但容易迅速变得难以阅读。特别是,嵌套推导式在几乎所有情况下都难以阅读,应该尽量避免。

最终,这是一个个人偏好的问题。为了可读性,我建议在没有明显好处的情况下限制函数式范式的使用。话虽如此,如果正确执行,它可以是一件美丽的事情。

接下来是装饰器——一种将函数和类包装在其他函数和/或类中的方法,以修改其行为并扩展其功能。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第六章:装饰器 – 通过装饰实现代码重用

在本章中,你将学习关于 Python 装饰器的内容。前几章已经展示了几个装饰器的用法,但现在你将了解更多关于它们的信息。装饰器本质上是对函数/类的包装,可以在执行之前修改输入、输出,甚至函数/类本身。这种包装同样可以通过拥有一个调用内部函数的单独函数,或者通过继承通常称为 mixins 的小功能类来实现。与许多 Python 构造一样,装饰器不是达到目标的唯一方法,但在许多情况下确实很方便。

虽然你可以不用太多了解装饰器就能过得很好,但它们提供了大量的“重用能力”,因此在框架库(如 Web 框架)中得到了广泛使用。Python 实际上附带了一些有用的装饰器,最著名的是 @property@classmethod@staticmethod 装饰器。

然而,有一些特定的注意事项需要注意:包装一个函数会创建一个新的函数,并使得访问内部函数及其属性变得更加困难。一个例子是 Python 的 help(function) 功能;默认情况下,你、你的编辑器和你的文档生成器可能会丢失函数属性,如帮助文本和函数所在的模块。

本章将涵盖函数和类装饰器的用法,以及装饰类内函数时需要了解的复杂细节。

以下是一些涵盖的主题:

  • 装饰函数

  • 装饰类函数

  • 装饰类

  • Python 标准库中的有用装饰器

装饰函数

装饰器是包装其他函数和/或类的函数或类。在其最基本的形式中,你可以将常规函数调用视为 add(1, 2),当应用装饰器时,它将转换为 decorator(add(1, 2))。这还有更多内容,但我们会稍后讨论。让我们来实现这个 decorator() 函数:

>>> def decorator(function):
...     return function

>>> def add(a, b):
...     return a + b

>>> add = decorator(add) 

为了使语法更容易使用,Python 为这种情况提供了一种特殊的语法。因此,你可以在函数下方添加一行,如前面的示例,而不是使用 @ 操作符作为快捷方式来装饰一个函数:

>>> @decorator
... def add(a, b):
...     return a + b 

这个例子展示了最简单且最无用的装饰器:简单地返回输入函数而不做其他任何事情。

从这个例子中,你可能会想知道装饰器的用途以及它们有什么特别之处。装饰器的一些可能性包括:

  • 注册函数/类

  • 修改函数/类输入

  • 修改函数/类输出

  • 记录函数调用/类实例化

所有这些内容将在本章的后续部分进行介绍,但现在我们先从简单开始。

我们的第一个装饰器将展示我们如何修改函数调用的输入和输出。此外,它还添加了一些 logging 调用,以便我们可以看到发生了什么:

>>> import functools

>>> def decorator(function):
...    # This decorator makes sure we mimic the wrapped function
...    @functools.wraps(function)
...    def _decorator(a, b):
...        # Pass the modified arguments to the function
...        result = function(a, b + 5)
...
...        # Log the function call
...        name = function.__name__
...        print(f'{name}(a={a}, b={b}): {result}')
...
...        # Return a modified result
...        return result + 4
...
...    return _decorator

>>> @decorator
... def func(a, b):
...     return a + b

>>> func(1, 2)
func(a=1, b=2): 8
12 

这应该能展示出装饰器的强大之处。我们可以修改、添加和/或删除参数。我们可以修改返回值,或者如果我们想的话,甚至可以调用一个完全不同的函数。而且,如果需要,我们可以轻松地记录所有行为,这在调试时非常有用。我们可以返回与 return function(...) 完全不同的内容。

更多关于如何使用装饰器进行日志记录的示例,请参阅第十二章,“调试 – 解决错误”。

通用函数装饰器

我们之前编写的装饰器明确使用了 ab 参数,因此它仅适用于具有非常类似接受 ab 参数签名的函数。如果我们想使生成器更通用,我们可以将 a, b 替换为 *args**kwargs 来分别获取位置参数和关键字参数。然而,这引入了一个新问题。我们需要确保只使用常规参数或关键字参数,否则检查将变得越来越困难:

>>> import functools

>>> def decorator(function):
...    @functools.wraps(function)
...    def _decorator(*args, **kwargs):
...        a, b = args
...        return function(a, b + 5)
...
...    return _decorator

>>> @decorator
... def func(a, b):
...     return a + b

>>> func(1, 2)
8

>>> func(a=1, b=2)
Traceback (most recent call last):
...
ValueError: not enough values to unpack (expected 2, got 0) 

如所示,在这种情况下,关键字参数被破坏。为了解决这个问题,我们有几种不同的方法。我们可以将参数更改为仅位置参数或仅关键字参数:

此代码使用仅位置参数(/ 作为最后一个函数参数),这自 Python 3.8 起已被支持。对于旧版本,你可以使用 *args 而不是显式参数来模拟此行为。

>>> def add(a, b, /):
...     return a + b

>>> add(a=1, b=2)
Traceback (most recent call last):
...
TypeError: add() got some positional-only arguments passed ...

>>> def add(*, a, b):
...     return a + b

>>> add(1, 2)
Traceback (most recent call last):
...
TypeError: add() takes 0 positional arguments but 2 were given 

或者,我们可以让 Python 自动处理这个问题,通过获取签名并将其绑定到给定的参数:

>>> import inspect
>>> import functools

>>> def decorator(function):
...    # Use the inspect module to get function signature. More
...    # about this in the logging chapter
...    signature = inspect.signature(function)
... 
...    @functools.wraps(function)
...    def _decorator(*args, **kwargs):
...        # Bind the arguments to the given *args and **kwargs.
...        # If you want to make arguments optional, use
...        # signature.bind_partial instead.
...        bound = signature.bind(*args, **kwargs)
...
...        # Apply the defaults so b is always filled
...        bound.apply_defaults()
...
...        # Extract the filled arguments. If the number of
...        # arguments is still expected to be fixed, you can use
...        # tuple unpacking: 'a, b = bound.arguments.values()'
...        a = bound.arguments['a']
...        b = bound.arguments['b']
...        return function(a, b + 5)
...
...    return _decorator

>>> @decorator
... def func(a, b=3):
...     return a + b

>>> func(1, 2)
8

>>> func(a=1, b=2)
8

>>> func(a=1)
9 

通过使用这种方法,函数变得更加灵活。我们可以轻松地向 add 函数添加参数,并仍然确保装饰器函数正常工作。

functools.wraps 的重要性

每次编写装饰器时,务必确保添加 functools.wraps 以包装内部函数。如果不进行包装,你将失去原始函数的所有属性,这可能导致混淆和意外的行为。看看以下没有 functools.wraps 的代码:

>>> def decorator(function):
...    def _decorator(*args, **kwargs):
...        return function(*args, **kwargs)
...
...    return _decorator

>>> @decorator
... def add(a, b):
...     '''Add a and b'''
...     return a + b

>>> help(add)
Help on function _decorator in module ...:
<BLANKLINE>
_decorator(*args, **kwargs)
<BLANKLINE>

>>> add.__name__
'_decorator' 

现在,我们的 add 方法已经没有文档说明,名称也消失了。它已经被重命名为 _decorator。由于我们确实在调用 _decorator,这是可以理解的,但这对依赖于这些信息的代码来说非常不方便。现在我们将尝试相同的代码,但略有不同;我们将使用 functools.wraps

>>> import functools

>>> def decorator(function):
...     @functools.wraps(function)
...     def _decorator(*args, **kwargs):
...         return function(*args, **kwargs)
...
...     return _decorator

>>> @decorator
... def add(a, b):
...     '''Add a and b'''
...     return a + b

>>> help(add)
Help on function add in module ...:
<BLANKLINE>
add(a, b)
    Add a and b
<BLANKLINE>

>>> add.__name__
'add' 

在没有进行任何其他更改的情况下,我们现在有了文档和预期的函数名称。functools.wraps 的工作原理并不神奇;它复制并更新了几个属性。具体来说,以下属性被复制:

  • __doc__

  • __name__

  • __module__

  • __annotations__

  • __qualname__

此外,__dict__ 使用 _decorator.__dict__.update(add.__dict__) 进行更新,并添加了一个名为 __wrapped__ 的新属性,它包含原始函数(在这种情况下是 add)。实际的 wraps 函数可在你的 Python 发行版的 functools.py 文件中找到。

连接或嵌套装饰器

由于我们正在包装函数,我们无法阻止添加多个包装器。然而,需要注意的是顺序,因为装饰器是从内部开始初始化的,但却是从外部开始调用的。此外,拆卸也是从内部开始的:

>>> import functools

>>> def track(function=None, label=None):
...     # Trick to add an optional argument to our decorator
...     if label and not function:
...         return functools.partial(track, label=label)
...
...     print(f'initializing {label}')
...
...     @functools.wraps(function)
...     def _track(*args, **kwargs):
...         print(f'calling {label}')
...         function(*args, **kwargs)
...         print(f'called {label}')
...
...     return _track

>>> @track(label='outer')
... @track(label='inner')
... def func():
...     print('func')
initializing inner
initializing outer

>>> func()
calling outer
calling inner
func
called inner
called outer 

如您在输出中看到的,在运行函数之前,装饰器是从外部到内部调用的,而在处理结果时是从内部到外部调用的。

使用装饰器注册函数

我们已经看到了如何跟踪调用,修改参数,以及更改返回值。现在,我们将看到如何使用装饰器注册一个对注册插件、回调等有用的函数。

这种情况在用户界面中非常有用。让我们假设我们有一个 GUI,它有一个可以被点击的按钮。通过创建一个可以注册回调的系统,我们可以使按钮触发一个“点击”信号,并将函数连接到该事件。

要创建一个类似的事件管理器,我们现在将创建一个类来跟踪所有已注册的函数并允许触发事件:

>>> import collections

>>> class EventRegistry:
...     def __init__(self):
...         self.registry = collections.defaultdict(list)
... 
...     def on(self, *events):
...         def _on(function):
...             for event in events:
...                 self.registry[event].append(function)
...             return function
... 
...         return _on
... 
...     def fire(self, event, *args, **kwargs):
...         for function in self.registry[event]:
...             function(*args, **kwargs)

>>> events = EventRegistry()

>>> @events.on('success', 'error')
... def teardown(value):
...     print(f'Tearing down got: {value}')

>>> @events.on('success')
... def success(value):
...     print(f'Successfully executed: {value}')

>>> events.fire('non-existing', 'nothing to see here')
>>> events.fire('error', 'Oops, some error here')
Tearing down got: Oops, some error here
>>> events.fire('success', 'Everything is fine')
Tearing down got: Everything is fine
Successfully executed: Everything is fine 

首先,我们创建EventRegistry类来处理所有事件并存储所有回调。之后,我们将一些函数注册到注册表中。最后,我们触发一些事件以查看它是否按预期工作。

虽然这个示例相当基础,但这种模式可以应用于许多场景:处理网络服务器的事件,让插件注册事件,让插件在应用程序中注册,等等。

使用装饰器的记忆化

记忆化是一个简单的技巧,用于记住结果,以便在特定场景中使代码运行得更快。这里的技巧是存储输入和预期输出的映射,这样你只需要计算一次值。这种技术最常见的一个例子是简单的(递归)斐波那契函数。

斐波那契序列从 0 或 1 开始(这取决于你如何看待它),每个连续的数字都是前两个数字之和。为了说明从初始的01加法开始的模式:

`1 = 0 + 1`
`2 = 1 + 1`
`3 = 1 + 2`
`5 = 2 + 3`
`8 = 3 + 5` 

我现在将展示如何构建一个非常基本的记忆化函数装饰器,以及如何使用它:

>>> import functools

>>> def memoize(function):
...     # Store the cache as attribute of the function so we can
...     # apply the decorator to multiple functions without
...     # sharing the cache.
...     function.cache = dict()
...
...     @functools.wraps(function)
...     def _memoize(*args):
...         # If the cache is not available, call the function
...         # Note that all args need to be hashable
...         if args not in function.cache:
...             function.cache[args] = function(*args)
...         return function.cache[args]
...
...     return _memoize 

memoize装饰器必须无参数使用,并且缓存也可以被检查:

>>> @memoize
... def fibonacci(n):
...     if n < 2:
...         return n
...     else:
...         return fibonacci(n - 1) + fibonacci(n - 2)

>>> for i in range(1, 7):
...     print(f'fibonacci {i}: {fibonacci(i)}')
fibonacci 1: 1
fibonacci 2: 1
fibonacci 3: 2
fibonacci 4: 3
fibonacci 5: 5
fibonacci 6: 8

>>> fibonacci.__wrapped__.cache
{(1,): 1, (0,): 0, (2,): 1, (3,): 2, (4,): 3, (5,): 5, (6,): 8} 

当给出参数时,它将崩溃,因为装饰器并没有被构建来支持这些参数:

# It breaks keyword arguments:
>>> fibonacci(n=2)
Traceback (most recent call last):
...
TypeError: _memoize() got an unexpected keyword argument 'n' 

此外,参数需要是可哈希的才能与这个实现一起工作:

# Unhashable types don't work as dict keys:
>>> fibonacci([123])
Traceback (most recent call last):
...
TypeError: unhashable type: 'list' 

当使用小的n值时,示例将很容易工作而不需要记忆化,但对于较大的数字,它将运行非常长的时间。对于n=2,函数将递归地执行fibonacci(n - 1)fibonacci(n - 2),导致指数级的时间复杂度。对于n=30,斐波那契函数已经被调用 2,692,537 次;在n=50时,它可能会停滞或甚至崩溃你的系统。

没有缓存的情况下,调用栈会变成一个树,它很快就会迅速增长。为了说明,让我们假设我们想要计算 fibonacci(4)

首先,fibonacci(4) 调用了 fibonacci(3)fibonacci(2)。这里没有什么特别之处。

现在,fibonacci(3) 调用了 fibonacci(2)fibonacci(1)。你会注意到现在我们第二次得到了 fibonacci(2)fibonacci(4) 也执行了它。

每次调用时的这种分割正是问题所在。每个函数调用开始两个新的函数调用,这意味着每次调用都会翻倍。而且它们会一次又一次地翻倍,直到我们达到计算的末尾。

由于缓存版本缓存了结果并且只需要计算每个数字一次,它甚至不费吹灰之力,对于 n=30 只需要执行 31 次。

这个装饰器还展示了如何将上下文附加到函数本身。在这种情况下,缓存属性成为内部(包装的 fibonacci)函数的一个属性,这样就不会与任何其他装饰的函数发生冲突。

然而,请注意,由于 Python 在 3.2 版本中引入了 lru_cache最近最少使用缓存),自己实现缓存函数现在通常不再那么有用。lru_cache 与前面的 memoize 装饰器函数类似,但更高级。它维护一个固定的缓存大小(默认为 128),以节省内存,并存储统计信息,这样你就可以检查是否应该增加缓存大小。

如果你只关心统计信息并且不需要缓存,你也可以将 maxsize 设置为 0。或者,如果你想放弃 LRU 算法并保存所有内容,可以将 maxsize 传递为 None。具有固定大小的 lru_cache 将只保留最近访问的项目,并在填满后丢弃最旧的项。

在大多数情况下,我建议使用 lru_cache 而不是你自己的装饰器,但如果你需要存储所有项目或者需要在存储之前处理键,你总是可以自己实现。至少,了解如何编写这样的装饰器是有用的。

为了演示 lru_cache 内部是如何工作的,我们将计算 fibonacci(100),如果没有缓存,这将使我们的计算机忙碌到宇宙的尽头。此外,为了确保我们实际上可以看到 fibonacci 函数被调用的次数,我们将添加一个额外的装饰器来跟踪计数,如下所示:

>>> import functools

# Create a simple call counting decorator
>>> def counter(function):
...     function.calls = 0
...     @functools.wraps(function)
...     def _counter(*args, **kwargs):
...         function.calls += 1
...         return function(*args, **kwargs)
...
...     return _counter

# Create a LRU cache with size 3 
>>> @functools.lru_cache(maxsize=3)
... @counter
... def fibonacci(n):
...     if n < 2:
...         return n
...     else:
...         return fibonacci(n - 1) + fibonacci(n - 2)

>>> fibonacci(100)
354224848179261915075

# The LRU cache offers some useful statistics
>>> fibonacci.cache_info()
CacheInfo(hits=98, misses=101, maxsize=3, currsize=3)

# The result from our counter function which is now wrapped both by
# our counter and the cache
>>> fibonacci.__wrapped__.__wrapped__.calls
101 

你可能会想知道为什么我们只需要 3 的缓存大小进行 101 次调用。那是因为我们递归地只需要 n - 1n - 2,所以在这种情况下我们不需要更大的缓存。如果你的缓存没有按预期工作,缓存大小可能是罪魁祸首。

此外,这个例子展示了为单个函数使用两个装饰器的情况。你可以把它们看作是洋葱的层。当调用 fibonacci 时,执行顺序如下:

  1. functools.lru_cache

  2. counter

  3. fibonacci

返回值的工作顺序是相反的,当然;fibonacci 将其值返回给 counter,然后 counter 将值传递给 lru_cache

带有(可选)参数的装饰器

之前的例子大多使用了不带任何参数的简单装饰器。正如您已经通过 lru_cache 看到的,装饰器也可以接受参数,因为它们只是普通的函数,但这给装饰器增加了一个额外的层次。这意味着我们需要检查装饰器的参数,以确定它们是被装饰的方法还是普通参数。唯一的注意事项是可选参数不应该可调用。如果参数必须可调用,您需要将其作为关键字参数传递。

下面的代码展示了具有可选(关键字)参数的装饰器:

>>> import functools

>>> def add(function=None, add_n=0):
...     # function is not callable so it's probably 'add_n'
...     if not callable(function):
...         # Test to make sure we don't pass 'None' as 'add_n'
...         if function is not None:
...             add_n = function
...         return functools.partial(add, add_n=add_n)
...     
...     @functools.wraps(function)
...     def _add(n):
...         return function(n) + add_n
...
...     return _add

>>> @add
... def add_zero(n):
...     return n

>>> @add(1)
... def add_one(n):
...     return n

>>> @add(add_n=2)
... def add_two(n):
...     return n

>>> add_zero(5)
5

>>> add_one(5)
6

>>> add_two(5)
7 

此装饰器使用 callable() 测试来查看参数是否是可调用的,例如函数。这种方法在许多情况下都有效,但如果您的 add() 装饰器的参数是可调用的,这将导致错误,因为它将被调用而不是函数。

每当您有选择时,我建议您要么使用带参数的装饰器,要么使用不带参数的装饰器。拥有可选参数会使函数的流程不那么明显,当出现问题时,调试稍微困难一些。

使用类创建装饰器

与我们创建常规函数装饰器的方式类似,也可以使用类来创建装饰器。正如类总是那样,这使得存储数据、继承和重用比函数更方便。毕竟,函数只是一个可调用对象,而类也可以实现可调用接口。以下装饰器的工作方式与之前使用的 debug 装饰器类似,但使用的是类而不是普通函数:

>>> import functools

>>> class Debug(object):
...
...     def __init__(self, function):
...         self.function = function
...         # functools.wraps for classes
...         functools.update_wrapper(self, function)
...
...     def __call__(self, *args, **kwargs):
...         output = self.function(*args, **kwargs)
...         name = self.function.__name__
...         print(f'{name}({args!r}, {kwargs!r}): {output!r}')
...         return output

>>> @Debug
... def add(a, b=0):
...     return a + b
...

>>> output = add(3)
add((3,), {}): 3

>>> output = add(a=4, b=2)
add((), {'a': 4, 'b': 2}): 6 

函数和类之间唯一的显著区别是,在 __init__ 方法中,functools.wraps 现在由 functools.update_wrapper 替换。

由于类方法除了常规参数外还有一个 self 参数,您可能会想知道装饰器是否会在那种情况下工作。下一节将介绍类内部装饰器的使用。

装饰类函数

装饰类函数与常规函数非常相似,但您需要意识到所需的首个参数 self——类实例。您可能已经使用了一些类函数装饰器。例如,classmethodstaticmethodproperty 装饰器在许多不同的项目中都有使用。为了解释这一切是如何工作的,我们将构建自己的 classmethodstaticmethodproperty 装饰器版本。首先,让我们看看一个简单的类函数装饰器,以展示它与常规装饰器的区别:

>>> import functools

>>> def plus_one(function):
...     @functools.wraps(function)
...     def _plus_one(self, n, *args):
...         return function(self, n + 1, *args)
...
...     return _plus_one

>>> class Adder(object):
...     @plus_one
...     def add(self, a, b=0):
...         return a + b

>>> adder = Adder()
>>> adder.add(0)
1
>>> adder.add(3, 4)
8 

正如常规函数的情况一样,类函数装饰器现在将 self 作为实例传递。没有什么意外的!

跳过实例 – 类方法和静态方法

classmethodstaticmethod 之间的区别相当简单。classmethod 传递一个类对象而不是类实例(self),而 staticmethod 完全跳过类和实例。这实际上使得 staticmethod 在类外部与普通函数非常相似。

在以下示例中,我们将使用 pprint.pprint(... width=60) 来考虑书籍的宽度。此外,locals() 是 Python 的内置函数,显示所有局部变量。同样,globals() 函数也是可用的。

在我们重新创建 classmethodstaticmethod 之前,我们需要看一下这些方法的预期行为:

>>> import pprint

>>> class Spam(object):
...     def some_instancemethod(self, *args, **kwargs):
...         pprint.pprint(locals(), width=60)
...
...     @classmethod
...     def some_classmethod(cls, *args, **kwargs):
...         pprint.pprint(locals(), width=60)
...
...     @staticmethod
...     def some_staticmethod(*args, **kwargs):
...         pprint.pprint(locals(), width=60)

# Create an instance so we can compare the difference between
# executions with and without instances easily
>>> spam = Spam() 

以下示例将使用上面的示例来说明普通(类实例)方法、classmethodstaticmethod 之间的区别。请注意 spam(小写)的实例和 Spam(大写)的类之间的区别:

# With an instance (note the lowercase spam)
>>> spam.some_instancemethod(1, 2, a=3, b=4)
{'args': (1, 2),
 'kwargs': {'a': 3, 'b': 4},
 'self': <__main__.Spam object at ...>}

# Without an instance (note the capitalized Spam)
>>> Spam.some_instancemethod()
Traceback (most recent call last):
    ...
TypeError: some_instancemethod() missing ... argument: 'self'

# But what if we add parameters? Be very careful with these!
# Our first argument is now used as an argument, this can give
# very strange and unexpected errors
>>> Spam.some_instancemethod(1, 2, a=3, b=4)
{'args': (2,), 'kwargs': {'a': 3, 'b': 4}, 'self': 1} 

特别是,最后一个示例相当棘手。因为我们向函数传递了一些参数,这些参数自动作为 self 参数传递。同样,最后一个示例展示了如何使用这个参数处理来使用给定的实例调用方法。Spam.some_instancemethod(spam)spam.some_instancemethod() 相同。

现在让我们看看 classmethod

# Classmethods are expectedly identical
>>> spam.some_classmethod(1, 2, a=3, b=4)
{'args': (1, 2),
 'cls': <class '__main__.Spam'>,
 'kwargs': {'a': 3, 'b': 4}}

>>> Spam.some_classmethod()
{'args': (), 'cls': <class '__main__.Spam'>, 'kwargs': {}}

>>> Spam.some_classmethod(1, 2, a=3, b=4)
{'args': (1, 2),
 'cls': <class '__main__.Spam'>,
 'kwargs': {'a': 3, 'b': 4}} 

主要区别在于,我们现在有 cls 而不是 self,它包含类(Spam)而不是实例(spam)。

selfcls 是约定俗成的命名,并且没有任何强制要求。你可以轻松地将它们命名为 sc 或者其他完全不同的名称。

接下来是 staticmethodstaticmethod 在类外部表现得与普通函数相同。

# Staticmethods are also identical
>>> spam.some_staticmethod(1, 2, a=3, b=4)
{'args': (1, 2), 'kwargs': {'a': 3, 'b': 4}}

>>> Spam.some_staticmethod()
{'args': (), 'kwargs': {}}

>>> Spam.some_staticmethod(1, 2, a=3, b=4)
{'args': (1, 2), 'kwargs': {'a': 3, 'b': 4}} 

在我们继续使用装饰器之前,你需要了解 Python 描述符是如何工作的。描述符可以用来修改对象属性的绑定行为。这意味着如果描述符被用作属性值,你可以在这些操作被调用时修改设置的值、获取的值和删除的值。以下是一个基本示例:

>>> class Spam:
...     def __init__(self, spam=1):
...         self.spam = spam
...
...     def __get__(self, instance, cls):
...         return self.spam + instance.eggs
...
...     def __set__(self, instance, value):
...         instance.eggs = value - self.spam

>>> class Sandwich:
...     spam = Spam(5)
...
...     def __init__(self, eggs):
...         self.eggs = eggs

>>> sandwich = Sandwich(1)
>>> sandwich.eggs
1
>>> sandwich.spam
6

>>> sandwich.eggs = 10
>>> sandwich.spam
15 

如你所见,每次我们从 sandwich.spam 设置或获取值时,实际上是在调用 Spam__get____set__,它不仅有权访问自己的变量,还可以访问调用类。这是一个非常有用的特性,对于自动转换和类型检查非常有用,我们将在下一节中看到的 property 装饰器只是这种技术的更方便的实现。

现在你已经了解了描述符的工作原理,我们可以继续创建 classmethodstaticmethod 装饰器。对于这两个装饰器,我们只需要修改 __get__ 而不是 __call__,这样我们就可以控制传递的类型实例(或根本不传递):

>>> import functools

>>> class ClassMethod(object):
...     def __init__(self, method):
...         self.method = method
... 
...     def __get__(self, instance, cls):
...         @functools.wraps(self.method)
...         def method(*args, **kwargs):
...             return self.method(cls, *args, **kwargs)
...
...         return method

>>> class StaticMethod(object):
...     def __init__(self, method):
...         self.method = method
... 
...     def __get__(self, instance, cls):
...         return self.method

>>> class Sandwich:
...     spam = 'class'
...
...     def __init__(self, spam):
...         self.spam = spam
...
...     @ClassMethod
...     def some_classmethod(cls, arg):
...         return cls.spam, arg
...
...     @StaticMethod
...     def some_staticmethod(arg):
...         return Sandwich.spam, arg

>>> sandwich = Sandwich('instance')
>>> sandwich.spam
'instance'
>>> sandwich.some_classmethod('argument')
('class', 'argument')
>>> sandwich.some_staticmethod('argument')
('class', 'argument') 

ClassMethod 装饰器仍然具有一个子函数来实际生成一个可工作的装饰器。查看该函数,你很可能会猜到它是如何工作的。它不是将 instance 作为 self.method 的第一个参数传递,而是传递 cls

StaticMethod 更简单,因为它完全忽略了 instancecls。它可以返回未经修改的原始方法。因为它返回的是未经修改的原始方法,所以我们不需要 functools.wraps 调用。

属性 - 智能描述符使用

property 装饰器可能是 Python 中使用最广泛的装饰器。它允许你向现有实例属性添加获取器和设置器,这样你就可以添加验证器并在将值设置到实例属性之前修改它们。

property 装饰器既可以作为赋值使用,也可以作为装饰器使用。以下示例展示了两种语法,以便你知道 property 装饰器可以期待什么。

Python 3.8 添加了 functools.cached_property,该函数与 property 功能相同,但每个实例只执行一次。

>>> import functools

>>> class Sandwich(object):
...     def get_eggs(self):
...         print('getting eggs')
...         return self._eggs
...
...     def set_eggs(self, eggs):
...         print('setting eggs to %s' % eggs)
...         self._eggs = eggs
...
...     def delete_eggs(self):
...         print('deleting eggs')
...         del self._eggs
...
...     eggs = property(get_eggs, set_eggs, delete_eggs)
...
...     @property
...     def spam(self):
...         print('getting spam')
...         return self._spam
...
...     @spam.setter
...     def spam(self, spam):
...         print('setting spam to %s' % spam)
...         self._spam = spam
...
...     @spam.deleter
...     def spam(self):
...         print('deleting spam')
...         del self._spam
...
...     @functools.cached_property
...     def bacon(self):
...         print('getting bacon')
...         return 'bacon!'

>>> sandwich = Sandwich()

>>> sandwich.eggs = 123
setting eggs to 123

>>> sandwich.eggs
getting eggs
123
>>> del sandwich.eggs
deleting eggs
>>> sandwich.bacon
getting bacon
'bacon!' 
>>> sandwich.bacon
'bacon!' 

类似于我们实现 classmethodstaticmethod 装饰器的方式,我们再次需要 Python 描述符。这次,我们需要描述符的全部功能,不仅仅是 __get__,还包括 __set____delete__。然而,为了简洁起见,我们将跳过处理文档和一些错误处理:

>>> class Property(object):
...     def __init__(self, fget=None, fset=None, fdel=None):
...         self.fget = fget
...         self.fset = fset
...         self.fdel = fdel
... 
...     def __get__(self, instance, cls):
...         if instance is None:
...             # Redirect class (not instance) properties to self
...             return self
...         elif self.fget:
...             return self.fget(instance)
... 
...     def __set__(self, instance, value):
...         self.fset(instance, value)
... 
...     def __delete__(self, instance):
...         self.fdel(instance)
... 
...     def getter(self, fget):
...         return Property(fget, self.fset, self.fdel)
... 
...     def setter(self, fset):
...         return Property(self.fget, fset, self.fdel)
... 
...     def deleter(self, fdel):
...         return Property(self.fget, self.fset, fdel) 

这看起来并不那么复杂,对吧?描述符构成了大部分代码,相当直接。只有 getter/setter/deleter 函数可能看起来有点奇怪,但它们实际上也很直接。

为了确保 property 仍然按预期工作,类在复制其他方法的同时返回一个新的 Property 实例。这里要使这个功能正常工作的小提示是在 __get__ 方法中的 return self

>>> class Sandwich:
...     @Property
...     def eggs(self):
...         return self._eggs
...
...     @eggs.setter
...     def eggs(self, value):
...         self._eggs = value
...
...     @eggs.deleter
...     def eggs(self):
...         del self._eggs

>>> sandwich = Sandwich()
>>> sandwich.eggs = 5
>>> sandwich.eggs
5 

如预期的那样,我们的 Property 装饰器按预期工作。但请注意,这是一个比内置的 property 装饰器更有限的版本;我们的版本没有对边缘情况进行检查。

自然地,作为 Python,有更多方法可以达到属性的效果。在先前的例子中,你看到了裸描述符实现,在我们的先前的例子中,你看到了 property 装饰器。现在我们将通过实现 __getattr____getattribute__ 来查看一个通用的解决方案。以下是一个简单的演示:

>>> class Sandwich(object):
...     def __init__(self):
...         self.registry = {}
...
...     def __getattr__(self, key):
...         print('Getting %r' % key)
...         return self.registry.get(key, 'Undefined')
...
...     def __setattr__(self, key, value):
...         if key == 'registry':
...             object.__setattr__(self, key, value)
...         else:
...             print('Setting %r to %r' % (key, value))
...             self.registry[key] = value
...
...     def __delattr__(self, key):
...         print('Deleting %r' % key)
...         del self.registry[key]

>>> sandwich = Sandwich()

>>> sandwich.a
Getting 'a'
'Undefined'

>>> sandwich.a = 1
Setting 'a' to 1

>>> sandwich.a
Getting 'a'
1

>>> del sandwich.a
Deleting 'a' 

__getattr__ 方法查找现有属性,例如,它检查键是否存在于 instance.__dict__ 中,并且仅在它不存在时调用。这就是为什么我们从未看到对注册属性的 __getattr__ 的调用。__getattribute__ 方法在所有情况下都会被调用,这使得它使用起来稍微有些危险。使用 __getattribute__ 方法时,你需要对 registry 进行特定的排除,因为它在尝试访问 self.registry 时会无限递归执行。

很少有必要查看描述符,但它们被几个内部 Python 进程使用,例如在继承类时使用的super()方法。

现在你已经知道了如何为常规函数和类方法创建装饰器,让我们继续通过装饰整个类来继续。

装饰类

Python 2.6 引入了类装饰器语法。与函数装饰器语法一样,这也不是一项新技术。即使没有语法,也可以通过执行DecoratedClass = decorator(RegularClass)简单地装饰一个类。在之前的章节中,你应该已经熟悉了编写装饰器。类装饰器与常规装饰器没有区别,只是它们接受一个类而不是一个函数。与函数一样,这发生在声明时间,而不是在实例化/调用时间。

由于有相当多的方法可以修改类的工作方式,例如标准继承、混入和元类(更多内容请参阅第八章元类 – 使类(而非实例)更智能),类装饰器从未是严格必需的。这并不减少它们的有用性,但它确实解释了为什么你不太可能看到太多类装饰的示例。

单例 – 只有一个实例的类

单例是始终只允许存在一个实例的类。因此,你总是得到同一个实例,而不是为你的调用获取一个特定的实例。这对于像数据库连接池这样的东西非常有用,你不想总是打开连接,但想重用原始的连接:

>>> import functools

>>> def singleton(cls):
...     instances = dict()
...     @functools.wraps(cls)
...     def _singleton(*args, **kwargs):
...         if cls not in instances:
...             instances[cls] = cls(*args, **kwargs)
...         return instances[cls]
...     return _singleton

>>> @singleton
... class SomeSingleton(object):
...     def __init__(self):
...         print('Executing init')

>>> a = SomeSingleton()
Executing init
>>> b = SomeSingleton()

>>> a is b

True
>>> a.x = 123
>>> b.x
123 

正如你在a is b比较中看到的,两个对象具有相同的身份,因此我们可以得出结论,它们确实是同一个对象。正如常规装饰器的情况一样,由于functools.wraps功能,如果需要,我们仍然可以通过Spam.__wrapped__访问原始类。

is运算符通过身份比较对象,这在 CPython 中实现为内存地址。如果a is b返回True,我们可以得出结论,ab是同一个实例。

完全排序 – 使类可排序

在某个时候,你可能需要排序数据结构。虽然使用sorted函数的键参数可以轻松实现这一点,但如果需要经常这样做,有一个更方便的方法——通过实现__gt____ge____lt____le____eq__函数。这听起来有点冗长,不是吗?如果你想获得最佳性能,这仍然是一个好主意,但如果你可以接受一点性能损失和一些稍微复杂一点的堆栈跟踪,那么total_ordering可能是一个不错的选择。

total_ordering 类装饰器可以根据具有 __eq__ 函数和其中一个比较函数(__lt____le____gt____ge__)的类实现所有所需的排序函数。这意味着你可以大大缩短你的函数定义。让我们比较一下常规函数定义和使用 total_ordering 装饰器的函数定义:

>>> import functools

>>> class Value(object):
...     def __init__(self, value):
...         self.value = value
...                                                               
...     def __repr__(self):
...         return f'<{self.__class__.__name__} {self.value}>'

>>> class Spam(Value):
...     def __gt__(self, other):
...         return self.value > other.value
...                                                                
...     def __ge__(self, other):
...         return self.value >= other.value
...                                                                
...     def __lt__(self, other):
...         return self.value < other.value
...                                                                
...     def __le__(self, other):
...         return self.value <= other.value
...                                                                
...     def __eq__(self, other):
...         return self.value == other.value

>>> @functools.total_ordering
... class Egg(Value):
...     def __lt__(self, other):
...         return self.value < other.value
...                                                                  
...     def __eq__(self, other):
...         return self.value == other.value 

如你所见,没有 functools.total_ordering,创建一个完全可排序的类需要相当多的工作。现在我们将测试它们是否确实以类似的方式排序:

>>> numbers = [4, 2, 3, 4]
>>> spams = [Spam(n) for n in numbers]
>>> eggs = [Egg(n) for n in numbers]

>>> spams
[<Spam 4>, <Spam 2>, <Spam 3>, <Spam 4>]

>>> eggs
[<Egg 4>, <Egg 2>, <Egg 3>, <Egg 4>]

>>> sorted(spams)
[<Spam 2>, <Spam 3>, <Spam 4>, <Spam 4>]

>>> sorted(eggs)
[<Egg 2>, <Egg 3>, <Egg 4>, <Egg 4>]

# Sorting using key is of course still possible and in this case
# perhaps just as easy:
>>> values = [Value(n) for n in numbers]
>>> values
[<Value 4>, <Value 2>, <Value 3>, <Value 4>]

>>> sorted(values, key=lambda v: v.value)
[<Value 2>, <Value 3>, <Value 4>, <Value 4>] 

现在,你可能想知道,“为什么没有类装饰器可以用来通过指定的键属性使类可排序?”好吧,这确实可能是 functools 库的一个好主意,但现在还没有。所以,让我们看看我们如何在仍然使用 functools.total_ordering 的同时实现类似的功能:

>>> def sort_by_attribute(attr, keyfunc=getattr):
...     def _sort_by_attribute(cls):
...         def __lt__(self, other):
...             return getattr(self, attr) < getattr(other, attr)
...                                           
...         def __eq__(self, other):
...             return getattr(self, attr) <= getattr(other, attr)
...                                           
...         cls.__lt__ = __lt__               
...         cls.__eq__ = __eq__               
...                                 
...         return functools.total_ordering(cls)
...
...     return _sort_by_attribute

>>> class Value(object):
...     def __init__(self, value):
...         self.value = value
...         
...     def __repr__(self):
...         return f'<{self.__class__.__name__} {self.value}>'

>>> @sort_by_attribute('value')
... class Spam(Value):
...     pass

>>> numbers = [4, 2, 3, 4]
>>> spams = [Spam(n) for n in numbers]
>>> sorted(spams)
[<Spam 2>, <Spam 3>, <Spam 4>, <Spam 4>] 

当然,这大大简化了创建可排序类的过程。如果你更愿意使用自己的键函数而不是 getattr,那就更容易了。只需将 getattr(self, attr) 调用替换为 key_function(self),同样对 other 也这样做,并将装饰器的参数更改为你的函数。你甚至可以使用它作为基本函数,并通过简单地传递一个包装的 getattr 函数来实现 sort_by_attribute

现在你已经知道了如何创建所有类型的装饰器,让我们看看 Python 内置的一些有用的装饰器示例。

有用的装饰器

除了本章中提到的那些之外,Python 还内置了一些其他有用的装饰器。有些装饰器目前不在标准库中(也许将来会加入)。

单分发 – Python 中的多态

如果你之前使用过 C++ 或 Java,你可能已经习惯了有专门的泛型多态可用——根据参数类型调用不同的函数。Python 作为一种动态类型语言,大多数人不会期望存在单分发模式。然而,Python 不仅是一种动态类型语言,而且是一种强类型语言,这意味着我们可以依赖我们接收到的类型。

动态类型语言不需要严格的类型定义。而像 C 这样的语言需要以下内容来声明一个整数:

`int some_integer = 123;` 

Python 只是接受我们的值具有类型:

`some_integer = 123` 

虽然我们可以使用类型提示来做同样的事情:

`some_integer: int = 123` 

然而,与 JavaScript 和 PHP 等语言相比,Python 做的隐式类型转换非常少。在 Python 中,以下将返回错误,而 JavaScript 会无任何问题执行它:

`'spam' + 5` 

在 Python 中,结果是 TypeError。在 JavaScript 中,结果是 'spam5'

单分发的基本思想是,根据你传递的类型,调用正确的函数。由于在 Python 中 str + int 会导致错误,这可以在将参数传递给函数之前自动转换参数变得非常方便。这可以用来将函数的实际工作与类型转换分离。

自从 Python 3.4 以来,有一个装饰器可以轻松地在 Python 中实现单分派模式。如果你需要根据输入变量的type()执行不同的函数,这个装饰器很有用。以下是一个基本示例:

>>> import functools

>>> @functools.singledispatch
... def show_type(argument):
...     print(f'argument: {argument}')

>>> @show_type.register(int)
... def show_int(argument):
...     print(f'int argument: {argument}')

>>> @show_type.register
... def show_float(argument: float):
...     print(f'float argument: {argument}')

>>> show_type('abc')
argument: abc

>>> show_type(123)
int argument: 123

>>> show_type(1.23)
float argument: 1.23 

singledispatch装饰器会自动调用作为第一个参数传递的类型对应的正确函数。正如你在示例中看到的,这在使用类型注解和显式传递类型到register函数时都有效。

让我们看看我们如何自己实现这个方法的简化版本:

>>> import functools

>>> registry = dict()

>>> def register(function):
...     # Fetch the first type from the type annotation but be
...     # careful not to overwrite the 'type' function
...     type_ = next(iter(function.__annotations__.values()))
...     registry[type_] = function
...
...     @functools.wraps(function)
...     def _register(argument):
...         # Fetch the function using the type of argument, and
...         # fall back to the main function
...         new_function = registry.get(type(argument), function)
...         return new_function(argument)
...
...     return _register

>>> @register
... def show_type(argument: any):
...     print(f'argument: {argument}')

>>> @register
... def show_int(argument: int):
...     print(f'int argument: {argument}')

>>> show_type('abc')
argument: abc

>>> show_type(123)
int argument: 123 

自然地,这种方法有点基础,它使用单个全局注册表,这限制了它的应用。但这个确切的模式可以用来注册插件或回调函数。

在命名函数时,确保不要覆盖原始的singledispatch函数。如果你将show_int命名为show_type,它将覆盖初始的show_type函数。这将使得无法访问原始的show_type函数,并导致之后的register操作也失败。

现在,一个稍微有用一点的例子——区分文件名和文件句柄:

>>> import json
>>> import functools

>>> @functools.singledispatch
... def write_as_json(file, data):
...     json.dump(data, file)

>>> @write_as_json.register(str)
... @write_as_json.register(bytes)
... def write_as_json_filename(file, data):
...     with open(file, 'w') as fh:
...         write_as_json(fh, data)

>>> data = dict(a=1, b=2, c=3)
>>> write_as_json('test1.json', data)
>>> write_as_json(b'test2.json', 'w')
>>> with open('test3.json', 'w') as fh:
...     write_as_json(fh, data) 

因此,我们现在有一个单一的write_as_json函数;它根据类型调用正确的代码。如果它是一个strbytes对象,它将自动打开文件并调用write_as_json的常规版本,该版本接受文件对象。

当然,编写一个执行此操作的装饰器并不难,但仍然很方便在基础库中有singledispatch装饰器。它无疑比手动使用isinstance()if/elif/elif/else语句检查给定的参数类型要方便得多。

要查看哪个函数将被调用,你可以使用write_as_json.dispatch函数和一个特定的类型。当传递一个str时,你会得到write_as_json_filename函数。需要注意的是,分派函数的名称完全是任意的。它们当然可以作为常规函数访问,但你喜欢怎么命名就怎么命名。

要检查已注册的类型,你可以通过write_as_json.registry访问注册表,它是一个字典:

>>> write_as_json.registry.keys()
dict_keys([<class 'bytes'>, <class 'object'>, <class 'str'>]) 

contextmanager — 使 with 语句变得简单

使用contextmanager类,我们可以使创建上下文包装器变得非常简单。上下文包装器在每次使用with语句时都会使用。一个例子是open函数,它也作为一个上下文包装器工作,允许你使用以下代码:

with open(filename) as fh:
    pass 

让我们假设现在open函数不能用作上下文管理器,我们需要构建自己的函数来完成这个任务。创建上下文管理器的标准方法是通过创建一个实现__enter____exit__方法的类:

>>> class Open:
...     def __init__(self, filename, mode):
...         self.filename = filename
...         self.mode = mode
...
...     def __enter__(self):
...         self.handle = open(self.filename, self.mode)
...         return self.handle
...
...     def __exit__(self, exc_type, exc_val, exc_tb):
...         self.handle.close()

>>> with Open('test.txt', 'w') as fh:
...     print('Our test is complete!', file=fh) 

虽然这样工作得很好,但有点冗长。使用contextlib.contextmanager,我们可以在几行代码中实现相同的行为:

>>> import contextlib

>>> @contextlib.contextmanager
... def open_context_manager(filename, mode='r'):
...     fh = open(filename, mode)
...     yield fh
...     fh.close()

>>> with open_context_manager('test.txt', 'w') as fh:
...     print('Our test is complete!', file=fh) 

简单吗?然而,我应该提到,对于这个特定的案例——对象的关闭——contextlib中有一个专门的函数,而且使用起来甚至更简单。

对于文件对象、数据库连接和连接,始终有一个close()调用来清理资源是很重要的。在文件的情况下,它告诉操作系统将数据写入磁盘(而不是临时缓冲区),而在网络连接和数据库连接的情况下,它释放两端的网络连接和相关资源。对于数据库连接,它还会通知服务器该连接不再需要,因此这部分也可以优雅地处理。

没有这些调用,你可能会迅速遇到“打开的文件太多”或“连接太多”的错误。

让我们用一个最基本的情况来演示closing()何时会有用:

>>> import contextlib

>>> with contextlib.closing(open('test.txt', 'a')) as fh:
...     print('Yet another test', file=fh) 

对于文件对象,你通常也可以使用with open(...),因为它本身就是一个上下文管理器,但如果代码的其他部分处理了打开操作,你就不总是有这种便利,在这些情况下,你需要自己关闭它。此外,一些对象,如urllib发出的请求,不支持以这种方式自动关闭,并从中受益于这个函数。

但等等;还有更多!除了可以在with语句中使用外,从 Python 3.2 开始,contextmanager的结果实际上也可以用作装饰器。在较老的 Python 版本中,contextmanager只是一个小的包装器,但自从 Python 3.2 以来,它基于ContextDecorator类,这使得它成为一个装饰器。

open_context_manager上下文管理器并不真正适合作为装饰器,因为它有一个yield <value>而不是空的yield(更多内容请参阅第七章生成器和协程 – 一次一步的无限),但我们可以考虑其他函数:

>>> @contextlib.contextmanager
... def debug(name):
...     print(f'Debugging {name}:')
...     yield
...     print(f'Finished debugging {name}')

>>> @debug('spam')
... def spam():
...     print('This is the inside of our spam function')

>>> spam()
Debugging spam:
This is the inside of our spam function
Finished debugging spam 

对于这个,有很多很好的用例,但至少,它是一个方便地将函数包装在上下文中的方法,而不需要所有的(嵌套)with语句。

验证、类型检查和转换

虽然在 Python 中检查类型通常不是最好的方法,但在某些情况下,如果你知道你需要一个特定的类型(或可以转换为该类型的某个东西),它可能是有用的。为了方便起见,Python 3.5 引入了类型提示系统,这样你就可以做以下操作:

>>> def sandwich(bacon: float, eggs: int):
...     pass 

在某些情况下,将提示转换为要求可能是有用的。我们不再使用isinstance(),而是简单地通过类型转换来强制执行类型,这更接近鸭子类型。

鸭子类型的本质是:如果它看起来像鸭子,走起路来像鸭子,叫起来也像鸭子,那么它可能就是一只鸭子。本质上,这意味着我们不在乎值是duck还是其他什么,只要它支持我们需要的quack()方法。

为了强制执行类型提示,我们可以创建一个装饰器:

>>> import inspect
>>> import functools

>>> def enforce_type_hints(function):
...     # Construct the signature from the function which contains
...     # the type annotations
...     signature = inspect.signature(function)
... 
...     @functools.wraps(function)
...     def _enforce_type_hints(*args, **kwargs):
...         # Bind the arguments and apply the default values
...         bound = signature.bind(*args, **kwargs)
...         bound.apply_defaults()
... 
...         for key, value in bound.arguments.items():
...             param = signature.parameters[key]
...             # The annotation should be a callable
...             # type/function so we can cast as validation
...             if param.annotation:
...                 bound.arguments[key] = param.annotation(value)
... 
...         return function(*bound.args, **bound.kwargs)
... 
...     return _enforce_type_hints

>>> @enforce_type_hints
... def sandwich(bacon: float, eggs: int):
...     print(f'bacon: {bacon!r}, eggs: {eggs!r}')

>>> sandwich(1, 2)
bacon: 1.0, eggs: 2
>>> sandwich(3, 'abc')
Traceback (most recent call last):
...
ValueError: invalid literal for int() with base 10: 'abc' 

这是一个相当简单但非常通用的类型强制器,应该可以与大多数类型注解一起工作。

无用的警告 - 如何安全地忽略它们

当用 Python 编写代码时,警告在编写代码时通常非常有用。然而,在执行时,每次运行你的脚本/应用程序时都收到相同的消息是没有用的。所以,让我们创建一些代码,允许轻松隐藏预期的警告,但不是所有的警告,这样我们就可以轻松捕捉到新的警告:

>>> import warnings
>>> import functools

>>> def ignore_warning(warning, count=None):
...     def _ignore_warning(function):
...         @functools.wraps(function)
...         def __ignore_warning(*args, **kwargs):
...             # Execute the code while catching all warnings
...             with warnings.catch_warnings(record=True) as ws:
...                 # Catch all warnings of the given type
...                 warnings.simplefilter('always', warning)
...                 # Execute the function
...                 result = function(*args, **kwargs)
... 
...             # Re-warn all warnings beyond the expected count
...             if count is not None:
...                 for w in ws[count:]:
...                     warnings.warn(w.message)
... 
...             return result
...
...         return __ignore_warning
...
...     return _ignore_warning

>>> @ignore_warning(DeprecationWarning, count=1)
... def spam():
...     warnings.warn('deprecation 1', DeprecationWarning)
...     warnings.warn('deprecation 2', DeprecationWarning)

# Note, we use catch_warnings here because doctests normally
# capture the warnings quietly
>>> with warnings.catch_warnings(record=True) as ws:
...     spam()
...
...     for i, w in enumerate(ws):
...         print(w.message)
deprecation 2 

使用这种方法,我们可以捕捉到第一个(预期的)警告,同时仍然看到第二个(意外的)警告。

现在你已经看到了一些有用的装饰器示例,是时候继续进行一些练习,看看你能自己写多少了。

练习

装饰器有巨大的用途范围,所以在阅读完这一章后,你可能自己就能想到一些,但你很容易就可以详细阐述我们之前编写的某些装饰器:

  • track函数扩展以监控执行时间。

  • track函数扩展以包含最小/最大/平均执行时间和调用次数。

  • 修改记忆化函数以使其能够处理不可哈希的类型。

  • 修改记忆化函数,使其每个函数都有自己的缓存而不是全局缓存。

  • 创建一个版本的functools.cached_property,可以根据需要重新计算。

  • 创建一个单次调用的装饰器,它考虑所有或可配置数量的参数,而不是只考虑第一个参数。

  • 增强type_check装饰器,包括额外的检查,例如要求一个数字大于或小于给定的值。

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。我们鼓励你提交自己的解决方案,并从他人的替代方案中学习。

概述

本章向您展示了装饰器可以用于使我们的代码更简单,并为非常简单的函数添加一些相当复杂的行为。诚实地讲,大多数装饰器比直接添加功能的功能性函数要复杂,但将相同的模式应用于许多函数和类所带来的额外优势通常是非常值得的。

装饰器有如此多的用途,可以让你的函数和类更智能、更方便使用:

  • 调试

  • 验证

  • 参数便利性(预填充或转换参数)

  • 输出便利性(将输出转换为特定类型)

本章最重要的收获应该是永远不要忘记在包装函数时使用functools.wraps。由于(意外的)行为修改,调试装饰函数可能相当困难,但丢失属性也会使这个问题变得更糟。

下一章将向您展示如何以及何时使用generatorscoroutines。这一章已经简要介绍了with语句的用法,但generatorscoroutines在这方面可以做得更多。我们仍然会经常使用装饰器,无论是在这本书中还是在一般使用 Python 时,所以请确保您对它们的工作方式有很好的理解。

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

第七章:生成器和协程 – 一次一步,无限可能

生成器函数是一种通过逐个生成返回值来表现得像迭代器的函数。当传统方法构建并返回一个具有固定长度的listtuple时,生成器只有在被调用者请求时才会yield单个值。副作用是,这些生成器可以无限大,因为你可以永远地持续生成。

除了生成器之外,还有对生成器语法的变体,它创建协程。协程是允许在不要求多个线程或进程的情况下进行多任务的函数。与生成器只能根据初始参数向调用者yield值不同,协程在运行时允许与调用函数进行双向通信。Python 中协程的现代实现是通过asyncio模块,这在第十三章“asyncio – 无线程的多线程”中有详细说明,但其基础源于本章讨论的协程。如果协程或asyncio适用于你的情况,它们可以提供巨大的性能提升。

在本章中,我们将涵盖以下主题:

  • 生成器的优缺点

  • 生成器的特性和怪癖

  • 使用常规函数创建生成器

  • 类似于listdictset的生成器推导式

  • 使用类创建生成器

  • Python 附带生成器

  • 协程的基本实现及其一些怪癖

生成器

生成器是一个非常有用的工具,但它们附带一些需要记住的规则。

首先,让我们来探索生成器的优势:

  • 生成器通常比生成列表的函数更容易编写。你不需要声明list、使用list.append(value)return,你只需要yield value

  • 内存使用。项目可以一次处理一个,因此通常不需要在内存中保留整个列表。

  • 结果可能依赖于外部因素。而不是有一个静态的列表,你将在请求时生成值。例如,考虑处理队列/栈。

  • 生成器是惰性的。这意味着如果你只使用生成器的前五个结果,其余的结果甚至不会被计算。此外,在获取项目之间,生成器是完全冻结的。

最重要的缺点是:

  • 结果只能使用一次。在处理生成器的结果后,它不能再被使用。

  • 大小未知。在你完成处理之前,你无法获取生成器的大小信息。它甚至可能是无限的。这使得list(some_infinite_generator)成为一个危险的操作。它可能会迅速崩溃你的 Python 解释器,甚至整个系统。

  • 无法进行切片操作,所以some_generator[10:20]将不会工作。你可以使用itertools.islice来解决这个问题,就像你将在本章后面看到的那样,但这实际上会丢弃未使用的索引。

  • 与切片类似,索引生成器也是不可能的。这意味着以下操作将不起作用:some_generator[5]

现在你已经知道了可以期待什么,让我们创建一些生成器。

创建生成器

最简单的生成器是一个包含yield语句而不是return语句的函数。与包含return的常规函数的关键区别在于,你的函数中可以有多个yield语句。

下面是一个包含几个固定yield语句的生成器示例以及它在几个操作中的表现:

>>> def generator():
...     yield 1
...     yield 'a'
...     yield []
...     return 'end'

>>> result = generator()

>>> result
<generator object generator at ...>

>>> len(result)
Traceback (most recent call last):
    ...
TypeError: object of type 'generator' has no len()

>>> result[:10]
Traceback (most recent call last):
    ...
TypeError: 'generator' object is not subscriptable

>>> list(result)
[1, 'a', []]

>>> list(result)
[] 

在这个例子中,生成器的一些缺点立即显现出来。当查看其repr()、获取len()(长度)或切片时,result并不提供多少有意义的 信息。而且尝试再次使用list()来获取值是不起作用的,因为生成器已经耗尽。

此外,你可能已经注意到函数的return值似乎完全消失了。实际上并非如此;return的值仍然被使用,但作为生成器抛出的StopIteration异常的值,以指示生成器已耗尽:

>>> def generator_with_return():
...     yield 'some_value'
...     return 'The end of our generator'

>>> result = generator_with_return()

>>> next(result)
'some_value'
>>> next(result)
Traceback (most recent call last):
    ...
StopIteration: The end of our generator 

以下示例演示了生成器的惰性执行:

>>> def lazy():
...     print('before the yield')
...     yield 'yielding'
...     print('after the yield')

>>> generator = lazy()

>>> next(generator)
before the yield
'yielding'

>>> next(generator)
Traceback (most recent call last):
    ...
StopIteration 

正如你在本例中所见,yield之后的代码没有执行。这是由StopIteration异常引起的;如果我们正确地捕获这个异常,代码将会执行:

>>> def lazy():
...     print('before the yield')
...     yield 'yielding'
...     print('after the yield')

>>> generator = lazy()

>>> next(generator)
before the yield
'yielding'

>>> try:
...     next(generator)
... except StopIteration:
...     pass
after the yield

>>> for item in lazy():
...     print(item)
before the yield
yielding
after the yield 

要正确处理生成器,你总是需要自己捕获StopIteration,或者使用循环或其他隐式处理StopIteration的结构。

创建无限生成器

创建一个无限生成器(如第五章中讨论的itertools.count迭代器,函数式编程 – 可读性与简洁性)同样简单。如果我们不在函数中像上一个函数那样有固定的yield <value>行,而是在无限循环中yield,我们就可以轻松地创建一个无限生成器。

itertools.count()生成器相反,我们将添加一个stop参数以简化测试:

>>> def count(start=0, step=1, stop=None):
...     n = start
...     while stop is not None and n < stop:
...         yield n
...         n += step

>>> list(count(10, 2.5, 20))
[10, 12.5, 15.0, 17.5] 

由于生成器的潜在无限性,需要谨慎。如果没有stop变量,简单地执行list(count())会导致无限循环,这会很快导致内存不足的情况。

那么,这是怎么工作的呢?本质上它只是一个普通的循环,但与返回项目列表的常规方法相比,yield语句一次返回一个项目,这意味着你只需要计算所需的项目,而不需要将所有结果都保存在内存中。

包装可迭代对象的生成器

当从零开始生成值时,生成器已经非常有用,但真正的力量在于包装其他可迭代对象。为了说明这一点,我们将创建一个生成器,它会自动平方给定输入的所有数字:

>>> def square(iterable):
...     for i in iterable:
...         yield i ** 2

>>> list(square(range(5)))
[0, 1, 4, 9, 16] 

自然,您不能阻止您在循环之外添加额外的yield语句:

>>> def padded_square(iterable):
...     yield 'begin'
...     for i in iterable:
...         yield i ** 2
...     yield 'end'

>>> list(padded_square(range(5)))
['begin', 0, 1, 4, 9, 16, 'end'] 

由于这些生成器是可迭代的,您可以通过多次包装它们来将它们链接在一起。将square()odd()生成器链接在一起的基本示例是:

>>> import itertools

>>> def odd(iterable):
...     for i in iterable:
...         if i % 2:
...             yield i

>>> def square(iterable):
...     for i in iterable:
...         yield i ** 2

>>> list(square(odd(range(10))))
[1, 9, 25, 49, 81] 

如果我们分析代码的执行方式,我们需要从内部到外部开始:

  1. range(10)语句为我们生成 10 个数字。

  2. odd()生成器过滤输入值,所以从[0, 1, 2 … ]值中只返回[1, 3, 5, 7, 9]

  3. square()函数对给定的输入进行平方,这是由odd()生成的奇数列表。

链接的真正力量在于,生成器只有在请求值时才会执行操作。如果我们用next()而不是list()请求单个值,这意味着只有square()中的第一个循环迭代会被运行。然而,对于odd()range(),它必须处理两个值,因为odd()会丢弃range()给出的第一个值,并且不会yield任何内容。

生成器推导式

在前面的章节中,您已经看到了listdictset的推导式,它们可以生成集合。使用生成器推导式,我们可以创建类似的集合,但使它们变得懒加载,这样它们只会在需要时才被评估。基本前提与list推导式相同,但使用圆括号/括号而不是方括号:

>>> squares = (x ** 2 for x in range(4))

>>> squares
<generator object <genexpr> at 0x...>

>>> list(squares)
[0, 1, 4, 9] 

当您需要包装不同生成器的结果时,这非常有用,因为它只计算您请求的值:

>>> import itertools

>>> result = itertools.count()
>>> odd = (x for x in result if x % 2)
>>> sliced_odd = itertools.islice(odd, 5)
>>> list(sliced_odd)
[1, 3, 5, 7, 9]

>>> result = itertools.count()
>>> sliced_result = itertools.islice(result, 5)
>>> odd = (x for x in sliced_result if x % 2)
>>> list(odd)
[1, 3] 

您可能已经从结果中推断出,这对于无限大小的生成器,如itertools.count(),可能是危险的。操作顺序非常重要,因为itertools.islice()函数在该点切片结果,而不是原始生成器。这意味着如果我们用永远不会对给定集合求值为True的函数替换odd(),它将永远运行,因为它永远不会yield任何结果。

基于类的生成器和迭代器

除了通过常规函数和生成器推导式创建生成器之外,我们还可以使用类来创建生成器。这对于需要记住状态或可以使用继承的更复杂的生成器来说是有益的。

首先,让我们看看创建一个基本的生成器class的示例,该类模仿了itertools.count()的行为,并添加了stop参数:

>>> class CountGenerator:
...     def __init__(self, start=0, step=1, stop=None):
...         self.start = start
...         self.step = step
...         self.stop = stop
...
...     def __iter__(self):
...         i = self.start
...         while self.stop is None or i < self.stop:
...             yield i
...             i += self.step

>>> list(CountGenerator(start=2.5, step=0.5, stop=5))
[2.5, 3.0, 3.5, 4.0, 4.5] 

现在,让我们将生成器类转换为具有更多功能的迭代器:

>>> class CountIterator:
...     def __init__(self, start=0, step=1, stop=None):
...         self.i = start
...         self.start = start
...         self.step = step
...         self.stop = stop
...
...     def __iter__(self):
...         return self
...
...     def __next__(self):
...         if self.stop is not None and self.i >= self.stop:
...             raise StopIteration
...
...         # We need to return the value before we increment to
...         # maintain identical behavior
...         value = self.i
...         self.i += self.step
...         return value

>>> list(CountIterator(start=2.5, step=0.5, stop=5))
[2.5, 3.0, 3.5, 4.0, 4.5] 

生成器和迭代器之间最重要的区别是,我们现在有一个完整的类,它充当迭代器,这意味着我们也可以将其扩展到常规生成器的功能之外。

正常生成器的一些限制是它们没有长度,我们无法对它们进行切片。使用迭代器,我们可以在需要的情况下显式定义这些场景的行为:

>>> import itertools

>>> class AdvancedCountIterator:
...     def __init__(self, start=0, step=1, stop=None):
...         self.i = start
...         self.start = start
...         self.step = step
...         self.stop = stop
...
...     def __iter__(self):
...         return self
...
...     def __next__(self):
...         if self.stop is not None and self.i >= self.stop:
...             raise StopIteration
...
...         value = self.i
...         self.i += self.step
...         return value
...
...     def __len__(self):
...         return int((self.stop - self.start) // self.step)
...
...     def __contains__(self, key):
...         # To check 'if 123 in count'.
...         # Note that this does not look at 'step'!
...         return self.start < key < self.stop
...
...     def __repr__(self):
...         return (
...             f'{self.__class__.__name__}(start={self.start}, '
...             f'step={self.step}, stop={self.stop})')
...
...     def __getitem__(self, slice_):
...         return itertools.islice(self, slice_.start,
...                                 slice_.stop, slice_.step) 

现在我们有了支持 len()inrepr() 等功能的先进计数迭代器,我们可以测试它是否按预期工作:

>>> count = AdvancedCountIterator(start=2.5, step=0.5, stop=5)

# Pretty representation using '__repr__'
>>> count
AdvancedCountIterator(start=2.5, step=0.5, stop=5)

# Check if item exists using '__contains__'
>>> 3 in count
True
>>> 3.1 in count
True
>>> 1 in count
False

# Getting the length using '__len__'
>>> len(count)
5
# Slicing using '__getitem__' with a slice as a parameter
>>> count[:3]
<itertools.islice object at 0x...>

>>> list(count[:3])
[2.5, 3.0, 3.5]

>>> list(count[:3])
[4.0, 4.5] 

除了解决一些限制之外,在最后一个示例中,您还可以看到生成器的一个非常有用的功能。我们可以逐个耗尽项目,并随时停止/开始。由于我们仍然可以完全访问该对象,我们可以更改 count.i 来重新启动迭代器。

生成器示例

现在您已经知道了如何创建生成器,让我们看看一些有用的生成器和它们的使用示例。

在您开始为项目编写生成器之前,请务必查看 Python 的 itertools 模块。它包含大量有用的生成器,涵盖了广泛的使用案例。以下部分展示了几个自定义生成器和标准库中最有用的生成器。

这些生成器适用于所有可迭代对象,而不仅仅是生成器。因此,您也可以将它们应用于 listtuplestring 或其他类型的可迭代对象。

将可迭代对象拆分成块/组

当在数据库中执行大量查询或在多个进程中运行任务时,通常更高效的做法是将操作分块。单个巨大的操作可能会导致内存不足的问题;由于启动/拆除序列,许多微小的操作可能会很慢。

为了提高效率,一个很好的方法是按块拆分输入。Python 文档(docs.python.org/3/library/itertools.html?highlight=chunk#itertools-recipes)已经提供了一个使用 itertools.zip_longest() 来实现此操作的示例:

>>> import itertools

>>> def grouper(iterable, n, fillvalue=None):
...     '''Collect data into fixed-length chunks or blocks'''
...     args = [iter(iterable)] * n 
...     return itertools.zip_longest(*args, fillvalue=fillvalue)

>>> list(grouper('ABCDEFG', 3, 'x'))
[('A', 'B', 'C'), ('D', 'E', 'F'), ('G', 'x', 'x')] 

这段代码是一个很好的例子,说明了如何轻松地将数据分块,但它必须将整个块保留在内存中。为了解决这个问题,我们可以创建一个版本,为块生成子生成器:

>>> def chunker(iterable, chunk_size):
...     # Make sure 'iterable' is an iterator
...     iterable = iter(iterable)
...
...     def chunk(value):
...         # Make sure not to skip the given value
...         yield value
...         # We already yielded a value so reduce the chunk_size
...         for _ in range(chunk_size - 1):
...             try:
...                 yield next(iterable)
...             except StopIteration:
...                 break
...
...     while True:
...         try:
...             # Check if we're at the end by using 'next()'
...             yield chunk(next(iterable))
...         except StopIteration:
...             break

>>> for chunk in chunker('ABCDEFG', 3):
...     for value in chunk:
...         print(value, end=', ')
...     print()
A, B, C,
D, E, F,
G, 

由于我们需要捕获 StopIteration 异常,这个例子在我看来并不好看。部分代码可以通过使用 itertools.islice()(将在下一部分介绍)来改进,但这仍然会留下我们无法知道何时达到末尾的问题。

如果您感兴趣,可以使用 itertools.islice()itertools.chains() 在本书的 GitHub 上找到的实现:github.com/mastering-python/code_2

itertools.islice – 可迭代对象的切片

生成器的一个限制是它们不能被切片。您可以通过在切片之前将生成器转换为 list 来解决这个问题,但对于无限生成器来说这是不可能的,如果您只需要几个值,这可能会很低效。

为了解决这个问题,itertools库有一个islice()函数,它可以切片任何可迭代对象。该函数是切片操作符的生成器版本,类似于切片,支持startstopstep参数。以下说明了常规切片和itertools.islice()的比较:

>>> import itertools

>>> some_list = list(range(1000))
>>> some_list[:5]
[0, 1, 2, 3, 4]
>>> list(itertools.islice(some_list, 5))
[0, 1, 2, 3, 4]

>>> some_list[10:20:2]
[10, 12, 14, 16, 18]
>>> list(itertools.islice(some_list, 10, 20, 2))
[10, 12, 14, 16, 18] 

需要注意的是,尽管输出是相同的,但这些方法在内部并不等价。常规切片仅适用于可切片的对象;实际上,这意味着对象必须实现__getitem__(self, slice)方法。

此外,我们期望切片对象是一个快速且高效的操作。对于listtuple来说,这当然是对的,但对于给定的生成器来说可能并非如此。

如果对于大小为n=1000的列表,我们取任何k=10个元素的切片,我们可以期望其时间复杂度仅为O(k);也就是说,10 步。我们做some_list[:10]some_list[900:920:2]都无关紧要。

对于itertools.islice()来说,情况并非如此,因为它所做的唯一假设是输入是可迭代的。这意味着获取前 10 个元素很容易;只需遍历元素,返回前 10 个,然后停止。因此,itertools.islice(some_list, 10)也需要 10 步。然而,获取第 900 到第 920 个元素意味着需要遍历并丢弃前 900 个元素,然后只返回接下来的 20 个元素中的 10 个。因此,这是 920 步。

为了说明这一点,这里有一个对itertools.islice()的略微简化的实现,它期望始终有一个stop可用:

>>> def islice(iterable, start, stop=None, step=1):
...     # 'islice' has signatures: 'islice(iterable, stop)' and:
...     # 'islice(iterable, start, stop[, step])'
...     # 'fill' stop with 'start' if needed
...     if stop is None and step == 1 and start is not None:
...         start, stop = 0, start
...
...     # Create an iterator and discard the first 'start' items
...     iterator = iter(iterable)
...     for _ in range(start):
...         next(iterator)
...
...     # Enumerate the iterator making 'i' start at 'start'
...     for i, item in enumerate(iterator, start):
...         # Stop when we've reached 'stop' items
...         if i >= stop:
...             return
...         # Use modulo 'step' to discard non-matching items
...         if i % step:
...             continue
...         yield item

>>> list(islice(range(1000), 10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

>>> list(islice(range(1000), 900, 920, 2))
[900, 902, 904, 906, 908, 910, 912, 914, 916, 918]

>>> list(islice(range(1000), 900, 910))
[900, 901, 902, 903, 904, 905, 906, 907, 908, 909] 

如您所见,startstep部分都丢弃了不需要的元素。这并不意味着您不应该使用itertools.islice(),但要注意其内部机制。同样,如您所预期的那样,这个生成器不支持索引为负值,并期望所有值都是正数。

itertools.chain – 连接多个可迭代对象

itertools.chain()生成器是 Python 库中最简单但最有用的生成器之一。它简单地按顺序返回每个传递的可迭代对象的每个元素,并且可以用仅仅三行代码实现:

>>> def chain(*iterables):
...     for iterable in iterables:
...         yield from iterable

>>> a = 1, 2, 3
>>> b = [4, 5, 6]
>>> c = 'abc'
>>> list(chain(a, b, c))
[1, 2, 3, 4, 5, 6, 'a', 'b', 'c']

>>> a + b + c
Traceback (most recent call last):
    ...
TypeError: can only concatenate tuple (not "list") to tuple 

如您可能注意到的,这也引入了一个尚未讨论的功能:yield from表达式。yield from确实如您从其名称中可以预期的那样,从给定的可迭代对象中产生所有元素。因此,itertools.chain()也可以用稍微冗长的形式替换:

>>> def chain(*iterables):
...     for iterable in iterables:
...         for i in iterable:
...             yield i 

有趣的是,这种方法比添加集合更强大,因为它不关心类型,只要它们是可迭代的——这就是鸭子类型的最纯粹形式。

itertools.tee – 使用输出多次

如前所述,生成器最大的缺点之一是结果只能使用一次。幸运的是,Python 有一个函数允许你将输出复制到多个生成器。如果你习惯于在 Linux/Unix 命令行 shell 中工作,tee这个名字可能对你来说很熟悉。tee程序允许你将输出写入屏幕和文件,这样你就可以在保持实时查看的同时存储输出。

Python 版本中的itertools.tee()做类似的事情,但它返回几个迭代器,允许你分别处理结果。

默认情况下,tee会将你的生成器拆分为一个包含两个不同生成器的元组,这就是为什么在这里使用元组解包工作得很好。通过传递n参数,你可以告诉itertools.tee()创建超过两个生成器。以下是一个示例:

>>> import itertools

>>> def spam_and_eggs():
...     yield 'spam'
...     yield 'eggs'

>>> a, b = itertools.tee(spam_and_eggs())
>>> next(a)
'spam'
>>> next(a)
'eggs'
>>> next(b)
'spam'
>>> next(b)
'eggs'
>>> next(b)
Traceback (most recent call last):
    ...
StopIteration 

在看到这段代码后,你可能会对tee的内存使用情况感到好奇。它是否需要为你存储整个列表?幸运的是,不需要。tee函数在处理这个问题上相当聪明。假设你有一个包含 1,000 个元素的生成器,你同时从a中读取前 100 个元素和从b中读取前 75 个元素。然后tee将只保留差异(100 - 75 = 25个元素)在内存中,并在迭代结果时丢弃其余部分。

是否tee是你情况下的最佳解决方案取决于情况。如果实例a在读取实例b之前(几乎)从开始到结束都被读取,那么使用tee可能不是一个好主意。简单地将生成器转换为list会更快,因为它涉及的操作要少得多。

contextlib.contextmanager – 创建上下文管理器

你已经在第五章函数式编程 – 可读性与简洁性之间的权衡第六章装饰器 – 通过装饰实现代码重用中看到了上下文管理器,但还有许多其他有用的东西可以用上下文管理器来完成。虽然contextlib.contextmanager()生成器并不是像本章前面看到的例子那样用于生成结果的生成器,但它确实使用了yield,所以它是一个非标准生成器使用的良好示例。

一些有用的示例是将输出记录到文件并测量函数执行时间:

>>> import time
>>> import datetime
>>> import contextlib

# Context manager that shows how long a context was active
>>> @contextlib.contextmanager
... def timer(name):
...     start_time = datetime.datetime.now()
...     yield
...     stop_time = datetime.datetime.now()
...     print('%s took %s' % (name, stop_time - start_time))

>>> with timer('basic timer'):
...     time.sleep(0.1)
basic timer took 0:00:00.1...

# Write standard print output to a file temporarily
>>> @contextlib.contextmanager
... def write_to_log(name):
...     with open(f'{name}.txt', 'w') as fh:
...         with contextlib.redirect_stdout(fh):
...             with timer(name):
...                 yield

# Using as a decorator also works in addition to with-statements
>>> @write_to_log('some_name')
... def some_function():
...     print('This will be written to 'some_name.txt'')

>>> some_function() 

所有这些都工作得很好,但代码可以更漂亮。有三个级别的上下文管理器往往难以阅读,这通常可以通过装饰器来解决,如第六章中所述。然而,在这种情况下,我们需要一个上下文管理器的输出作为下一个上下文管理器的输入,这将使装饰器设置更加复杂。

这就是ExitStack上下文管理器发挥作用的地方。它允许轻松组合多个上下文管理器,而不会增加缩进级别:

>>> import contextlib

>>> @contextlib.contextmanager
... def write_to_log(name):
...     with contextlib.ExitStack() as stack:
...         fh = stack.enter_context(open(f'{name}.txt', 'w'))
...         stack.enter_context(contextlib.redirect_stdout(fh))
...         stack.enter_context(timer(name))
...         yield

>>> @write_to_log('some_name')
... def some_function():
...     print('This will be written to 'some_name.txt'')

>>> some_function() 

看起来简单一些,不是吗?虽然这个例子在没有 ExitStack 上下文管理器的情况下仍然相当易于阅读,但当需要执行特定的清理操作时,ExitStack 的便利性很快就会变得明显。除了之前看到的自动处理之外,还可以将上下文转移到新的 ExitStack 中以手动处理关闭:

>>> import contextlib

>>> with contextlib.ExitStack() as stack:
...     fh = stack.enter_context(open('file.txt', 'w'))
...     # Move the context(s) to a new ExitStack
...     new_stack = stack.pop_all()

>>> bytes_written = fh.write('fh is still open')

# After closing we can't write anymore
>>> new_stack.close()
>>> fh.write('cant write anymore')
Traceback (most recent call last):
    ...
ValueError: I/O operation on closed file. 

大多数 contextlib 函数在 Python 手册中都有详细的文档。特别是 ExitStack,它使用许多示例进行了文档说明,可以在 docs.python.org/3/library/contextlib.html#contextlib.ExitStack 找到。我建议关注 contextlib 文档,因为它随着每个 Python 版本的更新而不断改进。

现在我们已经涵盖了常规生成器,是时候继续介绍协程了。

协程

协程是通过多个入口点提供非抢占式多任务处理的子程序。基本前提是协程允许两个函数在单个线程中运行时相互通信。通常,这种类型的通信仅限于多任务或多线程解决方案,但协程提供了一种相对简单的方法来实现这一点,而几乎不需要额外的性能开销。

由于生成器默认是惰性的,你可能能够猜到协程是如何工作的。直到结果被消费,生成器会处于休眠状态;但在消费结果时,生成器变得活跃。常规生成器和协程之间的区别在于,使用协程时,通信是双向的;协程不仅可以接收值,还可以将值 yield 给调用函数。

如果你熟悉 asyncio,你可能会注意到 asyncio 和协程之间有很强的相似性。这是因为 asyncio 是基于协程的概念构建的,并且已经从一点语法糖发展成为一个完整的生态系统。出于实用目的,我建议使用 asyncio 而不是这里解释的协程语法;然而,出于教育目的,了解它们是如何工作的非常有用。asyncio 模块正在非常活跃地开发中,并且拥有一个不那么笨拙的语法。

一个基本示例

在前面的章节中,你看到了常规生成器如何 yield 值。但生成器可以做更多;它们实际上可以通过 yield 接收值。基本用法相当简单:

>>> def generator():
...     value = yield 'value from generator'
...     print('Generator received:', value)
...     yield f'Previous value: {value!r}'

>>> g = generator()
>>> print('Result from generator:', next(g))
Result from generator: value from generator

>>> print(g.send('value from caller'))
Generator received: value from caller
Previous value: 'value from caller' 

就这些了。函数会在 send 方法被调用之前保持冻结状态,此时它将处理到下一个 yield 语句。从这个限制中你可以看到一个限制是协程不能自己醒来。值交换只能在调用代码运行 next(generator)generator.send() 时发生。

预激

由于生成器是惰性的,你不能直接向一个全新的生成器发送一个值。在值可以发送到生成器之前,必须使用next()获取结果,或者发出send(None)以便代码实际上能够执行。这是可以理解的,但有时会有些繁琐。让我们创建一个简单的装饰器来省略这个需求:

>>> import functools

>>> def coroutine(function):
...     # Copy the 'function' description with 'functools.wraps'
...     @functools.wraps(function)
...     def _coroutine(*args, **kwargs):
...         active_coroutine = function(*args, **kwargs)
...         # Prime the coroutine and make sure we get no values
...         assert not next(active_coroutine)
...         return active_coroutine
...
...     return _coroutine

>>> @coroutine
... def our_coroutine():
...     while True:
...         print('Waiting for yield...')
...         value = yield
...         print('our coroutine received:', value)

>>> generator = our_coroutine()
Waiting for yield...

>>> generator.send('a')
our coroutine received: a
Waiting for yield... 

如你所注意到的,尽管生成器仍然是惰性的,但它现在会自动执行所有代码,直到再次遇到yield语句。到那时,它将保持休眠状态,直到发送新的值。

注意,从现在开始,本章将一直使用coroutine装饰器。为了简洁,以下示例中将省略协程函数的定义。

关闭和抛出异常

与常规生成器不同,一旦输入序列耗尽,生成器就会立即退出,而协程通常使用无限while循环,这意味着它们不会以常规方式被销毁。这就是为什么协程也支持closethrow方法,这些方法可以退出函数。这里重要的是关闭的可能性,而不是关闭本身。本质上,这与上下文包装器使用__enter____exit__方法非常相似,但在这个情况下是协程。

以下示例展示了使用上一段中的coroutine装饰器的协程,包括正常和异常退出情况:

>>> from coroutine_decorator import coroutine

>>> @coroutine
... def simple_coroutine():
...     print('Setting up the coroutine')
...     try:
...         while True:
...             item = yield
...             print('Got item:', item)
...     except GeneratorExit:
...         print('Normal exit')
...     except Exception as e:
...         print('Exception exit:', e)
...         raise
...     finally:
...         print('Any exit') 

这个simple_coroutine()函数可以向我们展示协程的一些内部流程以及它们是如何被中断的。特别是try/finally行为可能会让你感到惊讶:

>>> active_coroutine = simple_coroutine()
Setting up the coroutine
>>> active_coroutine.send('from caller')
Got item: from caller
>>> active_coroutine.close()
Normal exit
Any exit

>>> active_coroutine = simple_coroutine()
Setting up the coroutine
>>> active_coroutine.throw(RuntimeError, 'caller sent an error')
Traceback (most recent call last):
    ...
RuntimeError: caller sent an error

>>> active_coroutine = simple_coroutine()
Setting up the coroutine
>>> try:
...     active_coroutine.throw(RuntimeError, 'caller sent an error')
... except RuntimeError as exception:
...     print('Exception:', exception)
Exception exit: caller sent an error
Any exit
Exception: caller sent an error 

大部分输出都是你预期的,但就像生成器中的StopIteration一样,你必须捕获异常以确保正确处理清理。

混合生成器和协程

尽管生成器和协程由于yield语句而看起来非常相似,但它们实际上是两种不同的生物。让我们创建一个双向管道来处理给定的输入,并在过程中传递给多个协程:

# The decorator from the Priming section in this chapter
>>> from coroutine_decorator import coroutine

>>> lines = 'some old text', 'really really old', 'old old old'

>>> @coroutine
... def replace(search, replace):
...     while True:
...         item = yield
...         print(item.replace(search, replace))

>>> old_replace = replace('old', 'new')
>>> for line in lines:
...     old_replace.send(line)
some new text
really really new
new new new 

给定这个示例,你可能想知道为什么我们现在打印值而不是产生它。我们可以yield这个值,但记住,生成器在产生值之前会冻结。让我们看看如果我们简单地yield值而不是调用print会发生什么。默认情况下,你可能会倾向于这样做:

>>> @coroutine
... def replace(search, replace):
...     while True:
...         item = yield
...         yield item.replace(search, replace)

>>> old_replace = replace('old', 'new')
>>> for line in lines:
...     old_replace.send(line)
'some new text'
'new new new' 

现在已经消失了一半的值;我们的“really really new"行已经消失了。注意第二个yield没有存储结果,并且yield实际上使这个函数成为一个生成器而不是协程。我们需要从那个yield存储结果:

>>> @coroutine
... def replace(search, replace):
...     item = yield
...     while True:
...         item = yield item.replace(search, replace)

>>> old_replace = replace('old', 'new')
>>> for line in lines:
...     old_replace.send(line)
'some new text'
'really really new'
'new new new' 

但这还远远不够优化。我们实际上正在使用协程来模仿生成器的行为。它确实有效,但有点多余,并且没有带来真正的益处。

让我们这次创建一个真正的管道,其中协程将数据发送到下一个协程或协程。这展示了协程的真正力量,即能够将多个协程链接在一起:

>>> @coroutine
... def replace(target, search, replace):
...     while True:
...         target.send((yield).replace(search, replace))

# Print will print the items using the provided formatstring
>>> @coroutine
... def print_(formatstring):
...     count = 0
...     while True:
...         count += 1
...         print(count, formatstring.format((yield)))
# tee multiplexes the items to multiple targets
>>> @coroutine
... def tee(*targets):
...     while True:
...         item = yield
...         for target in targets:
...             target.send(item) 

现在我们有了我们的协程函数,让我们看看我们如何将这些函数链接在一起:

# Because we wrap the results we need to work backwards from the
# inner layer to the outer layer.

# First, create a printer for the items:
>>> printer = print_('print: {}')

# Create replacers that send the output to the printer
>>> old_replace = replace(printer, 'old', 'new')
>>> current_replace = replace(printer, 'old', 'current')

# Send the input to both replacers
>>> branch = tee(old_replace, current_replace)

# Send the data to the tee routine for processing
>>> for line in lines:
...     branch.send(line)
1 print: some new text
2 print: some current text
3 print: really really new
4 print: really really current
5 print: new new new
6 print: current current current 

这使代码更加简单和易于阅读,并展示了如何将单个输入源同时发送到多个目的地。乍一看,这个例子并不那么令人兴奋,但令人兴奋的部分是,尽管我们使用 tee() 分割了输入并通过两个独立的 replace() 实例进行处理,但我们最终仍然到达了具有相同状态的同一个 print_() 函数。这意味着你可以根据你的方便来路由和修改你的数据,同时几乎不需要任何努力就能到达同一个终点。

目前来说,最重要的收获是,在大多数情况下,混合生成器和协程不是一个好主意,因为如果使用不当,可能会产生非常奇怪的副作用。尽管两者都使用 yield 语句,但它们是显著不同的实体,具有不同的行为。下一节将演示混合协程和生成器可以有用的一小部分情况。

使用状态

现在你已经知道了如何编写基本的协程以及需要注意哪些陷阱,那么写一个需要记住状态的函数怎么样?也就是说,一个总是给你所有发送值的平均值的函数。这是少数几种仍然相对安全和有用的结合协程和生成器语法的情况之一:

>>> import itertools

>>> @coroutine
... def average():
...     total = yield
...     for count in itertools.count(start=1):
...         total += yield total / count

>>> averager = average()
>>> averager.send(20)
20.0
>>> averager.send(10)
15.0 

尽管如此,它仍然需要一些额外的逻辑才能正常工作。我们需要使用 yield 来初始化我们的协程,但在那个时刻我们不发送任何数据,因为第一个 yield 是初始化器,在我们得到值之前执行。一旦一切准备就绪,我们就可以轻松地在求和的同时产生平均值。这并不那么糟糕,但纯协程版本稍微简单一些,因为我们只有一个执行路径,因为我们不必担心初始化。为了说明这一点,这里是有纯协程版本:

>>> import itertools

>>> @coroutine
... def print_(formatstring):
...     while True:
...         print(formatstring.format((yield)))

>>> @coroutine
... def average(target):
...     total = 0
...     for count in itertools.count(start=1):
...         total += yield
...         target.send(total / count)

>>> printer = print_('{:.1f}')
>>> averager = average(printer)
>>> averager.send(20)
20.0
>>> averager.send(10)
15.0 

虽然这个例子比包含生成器的版本多几行,但它更容易理解。让我们分析它以确保工作原理清晰:

  1. 我们将 total 设置为 0 以开始计数。

  2. 我们通过使用 itertools.count() 来跟踪测量次数,我们将其配置为从 1 开始计数。

  3. 我们使用 yield 来获取下一个值。

  4. 我们将平均值发送给给定的协程,而不是返回值,以使代码更易于理解。

另一个很好的例子是 itertools.groupby,它也相当简单,可以使用协程来重新创建。为了比较,我将继续展示生成器协程和纯协程版本:

>>> @coroutine
... def groupby():
...     # Fetch the first key and value and initialize the state
...     # variables
...     key, value = yield
...     old_key, values = key, []
...     while True:
...         # Store the previous value so we can store it in the
...         # list
...         old_value = value
...         if key == old_key:
...             key, value = yield
...         else:
...             key, value = yield old_key, values
...             old_key, values = key, []
...         values.append(old_value)

>>> grouper = groupby()
>>> grouper.send('a1')
>>> grouper.send('a2')
>>> grouper.send('a3')
>>> grouper.send('b1')
('a', ['1', '2', '3'])
>>> grouper.send('b2')
>>> grouper.send('a1')
('b', ['1', '2'])
>>> grouper.send('a2')
>>> grouper.send((None, None))
('a', ['1', '2']) 

如您所见,这个函数使用了一些技巧。首先,我们存储了之前的keyvalue,这样我们就可以检测到组(key)何时发生变化。其次,我们显然不能在组发生变化之前识别一个组,因此只有在组发生变化之后才会返回结果。这意味着只有当在最后一个组之后发送了不同的组时,才会发送最后一个组,这就是为什么有(None, None)

示例使用字符串的元组解包,将'a1'拆分为组'a'和值'1'。或者,您也可以使用grouper.send(('a', 1))

现在是纯协程版本:

>>> @coroutine
... def print_(formatstring):
...     while True:
...         print(formatstring.format(*(yield)))

>>> @coroutine
... def groupby(target):
...     old_key = None
...     while True:
...         key, value = yield
...         if old_key != key:
...             # A different key means a new group so send the
...             # previous group and restart the cycle.
...             if old_key and values:
...                 target.send((old_key, values))
...             values = []
...             old_key = key
...         values.append(value)

>>> grouper = groupby(print_('group: {}, values: {}'))
>>> grouper.send('a1')
>>> grouper.send('a2')
>>> grouper.send('a3')
>>> grouper.send('b1')
group: a, values: ['1', '2', '3']
>>> grouper.send('b2')
>>> grouper.send('a1')
group: b, values: ['1', '2']
>>> grouper.send('a2')
>>> grouper.send((None, None))
group: a, values: ['1', '2'] 

虽然这些函数相当相似,但协程版本的控制路径更简单,只需要在一个地方yield。这是因为我们不必考虑初始化和可能丢失值的问题。

练习

生成器有各种各样的用途,您可能可以直接在自己的代码中使用它们。尽管如此,以下练习可能有助于您更好地理解其特性和局限性:

  • 创建一个类似于itertools.islice()的生成器,允许使用负步长,以便您可以执行some_list[20:10:-1]

  • 创建一个类,它包装一个生成器,使其可以通过内部使用itertools.islice()来切片。

  • 编写一个生成斐波那契数的生成器。

  • 编写一个使用欧几里得筛法生成素数的生成器。

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。鼓励您提交自己的解决方案,并从他人的替代方案中学习。

摘要

本章向您展示了如何创建生成器以及它们所具有的优缺点。此外,现在应该很清楚如何克服它们的局限性及其影响。

通常,我总是推荐使用生成器而不是传统的集合生成函数。它们更容易编写,消耗的内存更少,如果需要,可以通过将some_generator()替换为list(some_generator())或一个为您处理该问题的装饰器来减轻其缺点。

虽然关于协程的段落提供了一些关于它们是什么以及如何使用的见解,但这只是对协程的温和介绍。纯协程和协程生成器组合仍然有些笨拙,这就是为什么创建了asyncio库。第十三章,“asyncio – 无线程的多线程”,详细介绍了asyncio,并介绍了asyncawait语句,这使得协程的使用比yield更加直观。

在上一章中,你看到了我们如何使用类装饰器来修改类。在下一章中,我们将介绍如何使用元类来创建类。使用元类,你可以在创建类本身的过程中修改类。请注意,我所说的不是类的实例,而是实际的类对象。使用这种技术,你可以创建自动注册的插件系统,向类添加额外的属性,等等。

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

第八章:元类 – 使类(非实例)更智能

前面的章节已经向我们展示了如何使用装饰器修改类和函数。但这并不是修改或扩展类的唯一选项。在创建之前修改你的类的一个更高级的技术是使用元类。名称已经给你一个提示,它可能是什么;元类是一个包含有关类的元信息的类。

元类的基本前提是一个在定义时为你生成另一个类的类,所以通常你不会用它来改变类实例,而只会改变类定义。通过改变类定义,可以自动向类添加一些属性,验证某些属性是否已设置,改变继承,自动将类注册到管理器,以及许多其他事情。

虽然元类通常被认为比(类)装饰器更强大的一种技术,但实际上它们在可能性上并没有太大的区别。选择通常取决于便利性或个人偏好。

在本章中,我们将涵盖以下主题:

  • 基本动态类创建

  • 带参数的元类

  • 抽象基类、示例和内部工作原理

  • 使用元类的自动插件系统

  • 类创建的内部和操作顺序

  • 存储类属性的定义顺序

动态创建类

元类是 Python 中创建新类的工厂。实际上,即使你可能没有意识到,Python 在创建类时总是会执行type元类。

一些元类内部使用的常见示例包括abc(抽象基类)、dataclasses和 Django 框架,该框架严重依赖于元类来创建Model类。

以过程式创建类时,使用type元类作为一个接受三个参数的函数:namebasesdictname将变成__name__属性,bases是继承的基类列表,并将存储在__bases__中,dict是包含所有变量的命名空间字典,并将存储在__dict__中。

应该注意的是,type()函数还有另一个用途。给定上述文档化的参数,它将创建一个具有那些规格的类。给定一个类实例的单个参数(例如,type(spam)),它将返回类对象/定义。

你的下一个问题可能是,如果我调用type()一个类定义而不是类实例会发生什么?嗯,那返回的是类的元类,默认情况下是type

让我们通过几个示例来澄清这一点:

>>> class Spam(object):
...     eggs = 'my eggs'

>>> Spam = type('Spam', (object,), dict(eggs='my eggs')) 

上面的两个Spam定义完全相同;它们都创建了一个具有实例属性eggs和以object为基类的类。让我们测试一下它是否真的像你预期的那样工作:

>>> class Spam(object):
...     eggs = 'my eggs'

>>> spam = Spam()
>>> spam.eggs
'my eggs'
>>> type(spam)
<class ' ...Spam'>
>>> type(Spam)
<class 'type'>

>>> Spam = type('Spam', (object,), dict(eggs='my eggs'))

>>> spam = Spam()
>>> spam.eggs
'my eggs'
>>> type(spam)
<class '...Spam'>
>>> type(Spam)
<class 'type'> 

如预期的那样,两个的结果是相同的。当创建一个类时,Python 会默默地添加type元类,而自定义元类是继承自type的类。一个简单的类定义有一个无声的元类,使得一个简单的定义如下:

class Spam(object):
    pass 

实质上等同于:

class Spam(object, metaclass=type):
    pass 

这引发了以下问题:如果每个类都是由一个(无声的)元类创建的,那么type的元类是什么?这是一个递归定义;type的元类是type。这就是自定义元类的本质:一个继承自type的类,允许在不修改类定义本身的情况下修改类。

一个基本的元类

由于元类可以修改任何类属性,你可以做任何你想做的事情。在我们继续讨论更高级的元类之前,让我们创建一个执行以下操作的元类:

  1. 使类继承int

  2. 向类添加lettuce属性

  3. 改变类的名称

首先,我们创建元类。然后,我们创建一个带有和不带有元类的类:

# The metaclass definition, note the inheritance of type instead
# of object
>>> class MetaSandwich(type):
...     # Notice how the __new__ method has the same arguments
...     # as the type function we used earlier?
...     def __new__(metaclass, name, bases, namespace):
...         name = 'SandwichCreatedByMeta'
...         bases = (int,) + bases
...         namespace['lettuce'] = 1
...         return type.__new__(metaclass, name, bases, namespace) 

首先,普通的三明治:

>>> class Sandwich(object):
...     pass

>>> Sandwich.__name__
'Sandwich'
>>> issubclass(Sandwich, int)
False
>>> Sandwich.lettuce
Traceback (most recent call last):
    ...
AttributeError: type object 'Sandwich' has no attribute 'lettuce' 

现在,元-Sandwich:

>>> class Sandwich(object, metaclass=MetaSandwich):
...     pass

>>> Sandwich.__name__
'SandwichCreatedByMeta'
>>> issubclass(Sandwich, int)
True
>>> Sandwich.lettuce
1 

如你所见,现在具有自定义元类的类继承了int,具有lettuce属性,并且具有不同的名称。

使用元类,你可以修改类定义的任何方面。这使得它们成为一个既非常强大又可能非常令人困惑的工具。通过一些小的修改,你可以在你的(或他人的)代码中引起最奇怪的 bug。

元类的参数

向元类添加参数的可能性是一个鲜为人知的功能,但仍然非常有用。在许多情况下,仅仅向类定义添加属性或方法就足以检测要做什么,但有些情况下,更具体一些是有用的:

>>> class AddClassAttributeMeta(type):
...     def __init__(metaclass, name, bases, namespace, **kwargs):
...         # The kwargs should not be passed on to the
...         # type.__init__
...         type.__init__(metaclass, name, bases, namespace)
...
...     def __new__(metaclass, name, bases, namespace, **kwargs):
...         for k, v in kwargs.items():
...             # setdefault so we don't overwrite attributes
...             namespace.setdefault(k, v)
...
...         return type.__new__(metaclass, name, bases, namespace)

>>> class WithArgument(metaclass=AddClassAttributeMeta, a=1234):
...     pass

>>> WithArgument.a
1234
>>> with_argument = WithArgument()
>>> with_argument.a
1234 

这个简单的例子可能没有太大用处,但可能性是存在的。例如,一个自动在插件注册表中注册插件的元类可以使用这个特性来指定插件名称别名。

使用这个特性,你不需要将所有创建类的参数作为属性和方法包含在类中,你可以传递这些参数而不污染你的类。你需要记住的唯一一点是,为了使这个功能正常工作,__new____init__ 方法都需要被扩展,因为参数是传递给元类构造函数(__init__)的。

然而,从 Python 3.6 开始,我们已经有了这个效果的更简单替代方案。Python 3.6 引入了__init_subclass__魔法方法,它以稍微简单一些的方式允许进行类似的修改:

>>> class AddClassAttribute:
...     def __init_subclass__(cls, **kwargs):
...         super().__init_subclass__()
...
...         for k, v in kwargs.items():
...             setattr(cls, k, v)

>>> class WithAttribute(metaclass=AddClassAttributeMeta, a=1234):
...     pass

>>> WithAttribute.a
1234
>>> with_attribute = WithAttribute()
>>> with_attribute.a
1234 

本章中的一些元类可以用__init_subclass__方法替换,这对于小的修改来说是一个非常实用的选项。对于更大的更改,我建议使用完整的元类,以便使普通类和元类之间的区别更加明显。

通过类访问元类属性

当使用元类时,可能会让人困惑的是,类实际上做的不仅仅是简单地构造类;它实际上在创建过程中继承了类。为了说明:

>>> class Meta(type):
...     @property
...     def some_property(cls):
...         return 'property of %r' % cls
...
...     def some_method(self):
...         return 'method of %r' % self

>>> class SomeClass(metaclass=Meta):
...     pass

# Accessing through the class definition
>>> SomeClass.some_property
"property of <class '...SomeClass'>"
>>> SomeClass.some_method
<bound method Meta.some_method of <class '__main__.SomeClass'>>
>>> SomeClass.some_method()
"method of <class '__main__.SomeClass'>"

# Accessing through an instance
>>> some_class = SomeClass()
>>> some_class.some_property
Traceback (most recent call last):
    ...
AttributeError: 'SomeClass' object has no attribute 'some_property'
>>> some_class.some_method
Traceback (most recent call last):
    ...
AttributeError: 'SomeClass' object has no attribute 'some_method' 

如前例所示,这些方法仅对类对象可用,而不是实例。some_propertysome_method不能通过实例访问,而可以通过类访问。这可以用于使某些函数仅对类(而不是实例)可用,并使您的类命名空间更干净。

然而,在一般情况下,我怀疑这只会增加混淆,所以我通常会建议反对这样做。

使用 collections.abc 的抽象类

抽象基类(也称为接口类)模块是 Python 中元类最有用和最广泛使用的例子之一,因为它使得确保类遵循特定接口而无需大量手动检查变得容易。我们已经在之前的章节中看到了一些抽象基类的例子,但现在我们还将探讨它们的内部工作原理和一些更高级的功能,例如自定义抽象基类(ABC)。

抽象类的内部工作原理

首先,让我们演示常规抽象基类的用法:

>>> import abc

>>> class AbstractClass(metaclass=abc.ABCMeta):
...     @abc.abstractmethod
...     def some_method(self):
...         raise NotImplemented()

>>> class ConcreteClass(AbstractClass):
...     pass

>>> ConcreteClass()
Traceback (most recent call last):
    ...
TypeError: Can't instantiate abstract class ConcreteClass with
abstract methods some_method

>>> class ImplementedConcreteClass(ConcreteClass):
...     def some_method():
...         pass

>>> instance = ImplementedConcreteClass() 

如您所见,抽象基类阻止我们实例化类,直到所有抽象方法都被继承。这在您的代码期望某些属性或方法可用,但没有合理的默认值时非常有用。一个常见的例子是与插件和数据模型的基类。

除了常规方法外,还支持propertystaticmethodclassmethod

>>> import abc

>>> class AbstractClass(object, metaclass=abc.ABCMeta):
...     @property
...     @abc.abstractmethod
...     def some_property(self):
...         raise NotImplemented()
...
...     @classmethod
...     @abc.abstractmethod
...     def some_classmethod(cls):
...         raise NotImplemented()
...
...     @staticmethod
...     @abc.abstractmethod
...     def some_staticmethod():
...         raise NotImplemented()
...
...     @abc.abstractmethod
...     def some_method():
...         raise NotImplemented() 

那么 Python 内部是如何做的呢?当然,您可以阅读abc.py源代码,但我认为一个简单的解释会更好。

首先,abc.abstractmethod将函数的__isabstractmethod__属性设置为True。所以如果您不想使用装饰器,您可以通过以下类似的方式简单地模拟行为:

some_method.__isabstractmethod__ = True 

之后,abc.ABCMeta元类遍历namespace中的所有项,并查找__isabstractmethod__属性评估为True的对象。除此之外,它还会遍历所有bases,并检查每个基类的__abstractmethods__集合,以防类继承自抽象类。所有__isabstractmethod__仍然评估为True的项都将被添加到存储在类中的__abstractmethods__集合中,作为一个frozenset

注意,我们不使用 abc.abstractpropertyabc.abstractclassmethodabc.abstractstaticmethod。从 Python 3.3 开始,这些已经被弃用,因为 classmethodstaticmethodproperty 装饰器被 abc.abstractmethod 识别,所以简单的 property 装饰器后面跟着 abc.abstractmethod 也会被识别。在排序装饰器时要小心;abc.abstractmethod 需要是最内层的装饰器,这样才能正常工作。

接下来的问题是实际检查在哪里,即检查类是否完全实现。这实际上是通过几个 Python 内部机制来实现的:

>>> class AbstractMeta(type):
...     def __new__(metaclass, name, bases, namespace):
...         cls = super().__new__(metaclass, name, bases,
...                               namespace)
...         cls.__abstractmethods__ = frozenset(('something',))
...         return cls

>>> class ConcreteClass(metaclass=AbstractMeta):
...     pass

>>> ConcreteClass()
Traceback (most recent call last):
    ...
TypeError: Can't instantiate abstract class ConcreteClass with 
abstract methods something 

我们可以很容易地使用元类自己模拟相同的行为,但应该注意的是,abc.ABCMeta 实际上做得更多,我们将在下一节中演示。为了说明上述行为,让我们创建一个模拟 abc.ABCMeta 的抽象基类元类:

>>> import functools

>>> class AbstractMeta(type):
...     def __new__(metaclass, name, bases, namespace):
...         # Create the class instance
...         cls = super().__new__(metaclass, name, bases,
...                               namespace)
...
...         # Collect all local methods marked as abstract
...         abstracts = set()
...         for k, v in namespace.items():
...             if getattr(v, '__abstract__', False):
...                 abstracts.add(k)
...
...         # Look for abstract methods in the base classes and
...         # add them to the list of abstracts
...         for base in bases:
...             for k in getattr(base, '__abstracts__', ()):
...                 v = getattr(cls, k, None)
...                 if getattr(v, '__abstract__', False):
...                     abstracts.add(k)
...
...         # Store the abstracts in a frozenset so they cannot be
...         # modified
...         cls.__abstracts__ = frozenset(abstracts)
...
...         # Decorate the __new__ function to check if all
...         # abstract functions were implemented
...         original_new = cls.__new__
...         @functools.wraps(original_new)
...         def new(self, *args, **kwargs):
...             for k in self.__abstracts__:
...                 v = getattr(self, k)
...                 if getattr(v, '__abstract__', False):
...                     raise RuntimeError(
...                         '%r is not implemented' % k)
...
...             return original_new(self, *args, **kwargs)
...
...         cls.__new__ = new
...         return cls

# Create a decorator that sets the '__abstract__' attribute
>>> def abstractmethod(function):
...     function.__abstract__ = True
...     return function 

现在我们有了创建抽象类的元类和装饰器,让我们看看它是否按预期工作:

>>> class ConcreteClass(metaclass=AbstractMeta):
...     @abstractmethod
...     def some_method(self):
...         pass

# Instantiating the function, we can see that it functions as the
# regular ABCMeta does
>>> ConcreteClass()
Traceback (most recent call last):
    ...
RuntimeError: 'some_method' is not implemented 

实际实现要复杂得多,因为它需要处理如 propertyclassmethodstaticmethod 这样的装饰器。它还有一些缓存特性,但此代码涵盖了实现中最有用的部分。这里需要注意的一个最重要的技巧是,实际的检查是通过装饰实际类的 __new__ 函数来执行的。此方法在类中只执行一次,因此我们可以避免多次实例化时的检查开销。

抽象方法的实际实现可以通过在以下文件中查找 __isabstractmethod__ 来找到 Python 源代码:Objects/descrobject.cObjects/funcobject.cObjects/object.c。实现中的 Python 部分可以在 Lib/abc.py 中找到。

自定义类型检查

当然,使用抽象基类定义自己的接口是很好的。但也可以很方便地告诉 Python 你的类实际上类似于什么,以及哪些类型是相似的。为此,abc.ABCMeta 提供了一个注册函数,允许你指定哪些类型是相似的。例如,一个将 list 类型视为相似的定制 list

>>> import abc

>>> class CustomList(abc.ABC):
...     '''This class implements a list-like interface'''

>>> class CustomInheritingList(list, abc.ABC):
...     '''This class implements a list-like interface'''

>>> issubclass(list, CustomList)
False
>>> issubclass(list, CustomInheritingList)
False

>>> CustomList.register(list)
<class 'list'>

# We can't make it go both ways, however
>>> CustomInheritingList.register(list)
Traceback (most recent call last):
    ...
RuntimeError: Refusing to create an inheritance cycle

>>> issubclass(list, CustomList)
True
>>> issubclass(list, CustomInheritingList)
False

# We need to inherit list to make it work the other way around
>>> issubclass(CustomList, list)
False
>>> isinstance(CustomList(), list)
False
>>> issubclass(CustomInheritingList, list)
True
>>> isinstance(CustomInheritingList(), list)
True 

如最后八行所示,这是一个单向关系。反过来则需要继承 list,但由于继承循环,不能双向进行。否则,CustomInheritingList 将继承 list,而 list 将继承 CustomInheritingList,这可能导致在 issubclass() 调用期间无限递归。

为了能够处理这些情况,abc.ABCMeta 中还有一个有用的特性。当子类化 abc.ABCMeta 时,可以扩展 __subclasshook__ 方法来自定义 issubclassisinstance 的行为:

>>> import abc

>>> class UniversalClass(abc.ABC):
...    @classmethod
...    def __subclasshook__(cls, subclass):
...        return True

>>> issubclass(list, UniversalClass)
True
>>> issubclass(bool, UniversalClass)
True
>>> isinstance(True, UniversalClass)
True
>>> issubclass(UniversalClass, bool)
False 

__subclasshook__ 应返回 TrueFalseNotImplemented,这将导致 issubclass 返回 TrueFalse 或在返回 NotImplemented 时的常规行为。

自动注册插件系统

使用元类的一个非常有用方法是将类自动注册为插件/处理器。

而不是在创建类后手动添加注册调用或通过添加装饰器来添加,你可以让它对用户来说完全自动。这意味着你的库或插件系统的用户不会意外忘记添加注册调用。

注意区分注册和导入的区别。虽然这个第一个例子展示了自动注册,但自动导入将在后面的章节中介绍。

这些示例可以在许多项目中看到,例如网络框架。例如,Django 网络框架使用元类来处理其数据库模型(实际上是表),根据类和属性名称自动生成表和列名称。

尽管这些项目的实际代码库过于庞大,无法在此有用地解释,因此我们将展示一个更简单的示例,以展示元类作为自注册插件系统的强大功能:

>>> import abc

>>> class Plugins(abc.ABCMeta):
...     plugins = dict()
...
...     def __new__(metaclass, name, bases, namespace):
...         cls = abc.ABCMeta.__new__(metaclass, name, bases,
...                                   namespace)
...         if isinstance(cls.name, str):
...             metaclass.plugins[cls.name] = cls
...         return cls
...
...     @classmethod
...     def get(cls, name):
...         return cls.plugins[name]

>>> class PluginBase(metaclass=Plugins):
...     @property
...     @abc.abstractmethod
...     def name(self):
...         raise NotImplemented()

>>> class PluginA(PluginBase):
...     name = 'a'

>>> class PluginB(PluginBase):
...     name = 'b'

>>> Plugins.get('a')
<class '...PluginA'>

>>> Plugins.plugins
{'a': <class '...PluginA'>,
 'b': <class '...PluginB'>} 

当然,这个例子有点简单,但它是许多插件系统的基础。

虽然元类在定义时运行,但模块仍然需要被 导入 才能工作。有几种方法可以做到这一点;如果可能的话,通过 get 方法按需加载将是我投票的选择,因为这样也不会增加插件未使用时的加载时间。

以下示例将使用以下文件结构来获得可重复的结果。所有文件都将包含在 plugins 目录中。请注意,本书的所有代码,包括此示例,都可以在 GitHub 上找到:github.com/mastering-python/code_2

__init__.py 文件用于创建快捷方式,所以简单的 import plugins 将导致 plugins.Plugins 可用,而不是需要显式导入 plugins.base

# plugins/__init__.py
from .base import Plugin
from .base import Plugins

__all__ = ['Plugin', 'Plugins'] 

下面是包含 Plugins 集合和 Plugin 基类 的 base.py 文件:

# plugins/base.py
import abc

class Plugins(abc.ABCMeta):
    plugins = dict()

    def __new__(metaclass, name, bases, namespace):
        cls = abc.ABCMeta.__new__(
            metaclass, name, bases, namespace)
        metaclass.plugins[name.lower()] = cls
        return cls

    @classmethod
    def get(cls, name):
        return cls.plugins[name]

class Plugin(metaclass=Plugins):
    pass 

以及两个简单的插件,a.pyb.py(由于它与 a.py 功能上相同,所以被省略):

from . import base

class A(base.Plugin):
    pass 

现在我们已经设置了插件和自动注册,我们需要注意 a.pyb.py 的加载。虽然 AB 将在 Plugins 中自动注册,但如果忘记导入它们,它们将不会注册。为了解决这个问题,我们有几种选择;首先我们将看看按需加载。

按需导入插件

解决导入问题的第一个方案是在 Plugins 元类的 get 方法中处理它。每当插件在注册表中找不到时,get 方法应自动从 plugins 目录中 import 模块。

这种方法的优势在于插件不需要显式预加载,同时插件也只有在需要时才会加载。未使用的插件不会被触及,因此这种方法可以帮助减少应用程序的加载时间。

缺点是代码将不会运行或测试,因此它可能完全损坏,而你直到它最终加载时才知道。关于这个问题的解决方案将在第十章“测试”中介绍。另一个问题是,如果代码在应用程序的其他部分自我注册,那么该代码也不会被执行,除非你在代码的其他部分添加所需的import,也就是说。

修改Plugins.get方法,我们得到以下结果:

import importlib

# Plugins class omitted for brevity
class PluginsOnDemand(Plugins):
    @classmethod
    def get(cls, name):
        if name not in cls.plugins:
            print('Loading plugins from plugins.%s' % name)
            importlib.import_module('plugins.%s' % name)
        return cls.plugins[name] 

现在我们从这个 Python 文件中运行它:

import plugins

print(plugins.PluginsOnDemand.get('a'))
print(plugins.PluginsOnDemand.get('a')) 

这会导致以下结果:

Loading plugins from plugins.a
<class 'plugins.a.A'>
<class 'plugins.a.A'> 

正如你所见,这种方法只会运行一次import;第二次,插件将在插件字典中可用,因此不需要再次加载。

通过配置导入插件

虽然只加载所需的插件是有用的,因为它可以减少你的初始加载时间和内存开销,但关于预先加载你可能会需要的插件也有一些话要说。根据 Python 的禅意,明确优于隐晦,所以一个明确的插件加载列表通常是一个好的解决方案。这种方法的优势在于,你可以确保注册更加高级,因为你可以保证它会运行,并且你可以从多个包中加载插件。当然,缺点是你需要明确定义要加载哪些插件,这可能会被视为违反 DRY(不要重复自己)原则。

这次我们不再在get方法中导入,而是添加一个load方法,该方法导入所有给定的模块名称:

# PluginsOnDemand class omitted for brevity
class PluginsThroughConfiguration(PluginsOnDemand):
    @classmethod
    def load(cls, *plugin_names):
        for plugin_name in plugin_names:
            cls.get(plugin_name) 

可以使用以下代码调用:

import plugins

plugins.PluginsThroughConfiguration.load(
    'a',
    'b',
)

print('After load')
print(plugins.PluginsThroughConfiguration.get('a'))
print(plugins.PluginsThroughConfiguration.get('a')) 

这会导致以下输出:

Loading plugins from plugins.a
Loading plugins from plugins.b
After load
<class 'plugins.a.A'>
<class 'plugins.a.A'> 

一个相当简单直接的系统,根据设置加载插件,这可以很容易地与任何类型的设置系统结合使用,以填充load方法。这种方法的一个例子是 Django 中的INSTALLED_APPS

通过文件系统导入插件

加载插件最方便的方法是无需思考的方法,因为它会自动发生。虽然这非常方便,但应该考虑一些非常重要的注意事项。

首先,它们往往会使调试变得更加困难。Django 中类似的自动导入系统给我带来了不少麻烦,因为它们往往会模糊化错误,甚至完全隐藏它们,让你花费数小时进行调试。

其次,这可能会带来安全风险。如果有人有权写入你的插件目录之一,他们可以有效地在你的应用程序中执行代码。

话虽如此,特别是对于初学者和/或你的框架的新用户,自动插件加载可以非常方便,并且确实值得演示。

这次,我们继承了在前面示例中创建的PluginsThroughConfiguration类,并添加了一个autoload方法来检测可用的插件。

import re
import pathlib
import importlib

CURRENT_FILE = pathlib.Path(__file__)
PLUGINS_DIR = CURRENT_FILE.parent
MODULE_NAME_RE = re.compile('[a-z][a-z0-9_]*', re.IGNORECASE)

class PluginsThroughFilesystem(PluginsThroughConfiguration):
    @classmethod
    def autoload(cls):
        for filename in PLUGINS_DIR.glob('*.py'):
            # Skip __init__.py and other non-plugin files
            if not MODULE_NAME_RE.match(filename.stem):
                continue
                cls.get(filename.stem)

            # Skip this file
            if filename == CURRENT_FILE:
                continue

            # Load the plugin
            cls.get(filename.stem) 

现在,让我们尝试运行这段代码:

import pprint
import plugins

plugins.PluginsThroughFilesystem.autoload()

print('After load')
pprint.pprint(plugins.PluginsThroughFilesystem.plugins) 

这会导致:

Loading plugins from plugins.a
Loading plugins from plugins.b
After load
{'a': <class 'plugins.a.A'>,
 'b': <class 'plugins.b.B'>,
 'plugin': <class 'plugins.base.Plugin'>} 

现在,plugins目录中的每个文件都将自动加载。但请注意,这可能会掩盖某些错误。例如,如果你的某个插件导入了一个你没有安装的库,你将不会从实际库中收到ImportError,而是从插件中收到。

要使这个系统更智能一些(甚至导入 Python 路径之外的包),你可以使用importlib.abc中的抽象基类创建一个插件加载器;请注意,你很可能仍然需要以某种方式列出文件和/或目录。为了改进这一点,你还可以查看importlib中的加载器。使用这些加载器,你可以从 ZIP 文件和其他来源加载插件。

现在我们已经完成了插件系统,是时候看看如何使用元类而不是装饰器来实现dataclasses了。

Dataclasses

第四章Pythonic 设计模式中,我们已经看到了dataclasses模块,它使得在类中实现简单的类型提示甚至强制某些结构成为可能。

现在,让我们看看我们如何使用元类实现自己的版本。实际的dataclasses模块主要依赖于类装饰器,但这不是问题。元类可以被视为类装饰器的更强大版本,所以它们将正常工作。使用元类,你可以使用继承来重用它们,或者使类继承其他类,但最重要的是,它们允许你修改类对象,而不是使用装饰器修改实例。

dataclasses模块中有几个非平凡的技巧,难以复制。除了添加文档和一些实用方法之外,它还生成一个与dataclass字段匹配的__init__方法。由于整个dataclasses模块大约有 1,300 行,我们的实现将无法接近。因此,我们将实现__init__()方法,包括为类型提示生成的signature__annotations__,以及一个__repr__方法来显示结果:

import inspect

class Dataclass(type):
    def _get_signature(namespace):
        # Get the annotations from the class
        annotations = namespace.get('__annotations__', dict())

        # Signatures are immutable so we need to build the
        # parameter list before creating the signature
        parameters = []
        for name, annotation in annotations.items():

            # Create Parameter shortcut for readability
            Parameter = inspect.Parameter
            # Create the parameter with the correct type
            # annotation and default. You could also choose to
            # make the arguments keyword/positional only here
            parameters.append(Parameter(
                name=name,
                kind=Parameter.POSITIONAL_OR_KEYWORD,
                default=namespace.get(name, Parameter.empty),
                annotation=annotation,
            ))

        return inspect.Signature(parameters)

    def _create_init(namespace, signature):
        # If init exists we don't need to do anything
        if '__init__' in namespace:
            return

        # Create the __init__ method and use the signature to
        # process the arguments
        def __init__(self, *args, **kwargs):
            bound = signature.bind(*args, **kwargs)
            bound.apply_defaults()

            for key, value in bound.arguments.items():
                # Convert to the annotation to enforce types
                parameter = signature.parameters[key]
                # Set the casted value
                setattr(self, key, parameter.annotation(value))

        # Override the signature for __init__ so help() works
        __init__.__signature__ = signature

        namespace['__init__'] = __init__

    def _create_repr(namespace, signature):
        def __repr__(self):
            arguments = []
            for key, value in vars(self).items():
                arguments.append(f'{key}={value!r}')
            arguments = ', '.join(arguments)
            return f'{self.__class__.__name__}({arguments})'

        namespace['__repr__'] = __repr__

    def __new__(metaclass, name, bases, namespace):
        signature = metaclass._get_signature(namespace)
        metaclass._create_init(namespace, signature)
        metaclass._create_repr(namespace, signature)

        cls = super().__new__(metaclass, name, bases, namespace)

        return cls 

乍一看,这可能会看起来很复杂,但实际上整个过程相当简单:

  1. 我们从类的__annotations__和默认值中生成一个签名。

  2. 我们根据签名生成一个__init__方法。

  3. 我们让__init__方法使用签名来自动绑定传递给函数的参数并将它们应用到实例上。

  4. 我们生成一个__repr__方法,该方法简单地打印出类的名称和存储在实例中的值。请注意,这个方法相当有限,会显示你添加到类中的任何内容。

注意,作为额外的小细节,我们有一个转换为注解类型的强制转换,以确保类型的正确性。

让我们通过使用第四章中的dataclass示例并添加一些小的修改来测试类型转换,看看它是否按预期工作:

>>> from T_10_dataclasses import Dataclass

>>> class Sandwich(metaclass=Dataclass):
...     spam: int
...     eggs: int = 3

>>> Sandwich(1, 2)
Sandwich(spam=1, eggs=2)

>>> sandwich = Sandwich(4)
>>> sandwich
Sandwich(spam=4, eggs=3)
>>> sandwich.eggs
3

>>> help(Sandwich.__init__)
Help on function __init__ in ...
<BLANKLINE>
__init__(spam: int, eggs: int = 3)
<BLANKLINE>

>>> Sandwich('a')
Traceback (most recent call last):
    ...
ValueError: invalid literal for int() with base 10: 'a'
>>> Sandwich('1234', 56.78)
Sandwich(spam=1234, eggs=56) 

所有这些按预期工作,输出与原始dataclass相似。当然,它的功能要有限得多,但它展示了如何动态生成自己的类和函数,以及如何轻松地将基于自动注解的类型转换添加到代码中。

接下来,我们将深入探讨类的创建和实例化。

实例化类时的操作顺序

在调试动态创建和/或修改的类的问题时,操作顺序非常重要。假设一个错误的顺序可能会导致难以追踪的 bug。类的实例化按照以下顺序进行:

  1. 查找元类

  2. 准备命名空间

  3. 执行类体

  4. 创建类对象

  5. 执行类装饰器

  6. 创建类实例

我们现在将逐一介绍这些内容。

查找元类

元类来自类或bases中显式给出的元类,或者使用默认的type元类。

对于每个类,包括类本身和基类,将使用以下匹配中的第一个:

  • 显式给出的元类

  • bases显式地定义元类

  • type()

注意,如果没有找到所有候选元类的子类型元类,将引发TypeError。这种情况不太可能发生,但在使用元类和多重继承/混入时确实有可能发生。

准备命名空间

通过上述选择的元类准备类命名空间。如果元类有一个__prepare__方法,它将被调用为namespace = metaclass.__prepare__(names, bases, **kwargs),其中**kwargs来自类定义。如果没有__prepare__方法可用,结果将是namespace = dict()

注意,有多种方法可以实现自定义命名空间。正如我们在上一节中看到的,type()函数调用也接受一个dict参数,可以用来更改命名空间。

执行类体

类体的执行与正常代码执行非常相似,只有一个关键区别:独立的命名空间。由于类有一个独立的命名空间,不应污染globals()/locals()命名空间,因此它在那个上下文中执行。生成的调用看起来像这样:

exec(body, globals(), namespace) 

其中namespace是之前生成的命名空间。

创建类对象(不是实例)

现在我们已经准备好了所有组件,可以实际生成类对象。这是通过class_ = metaclass(name, bases, namespace, **kwargs)调用完成的,如您所见,这实际上与之前讨论的type()调用相同。这里的**kwargs与之前传递给__prepare__方法的相同。

可能需要注意,这也是super()不带参数引用的对象。

执行类装饰器

现在类对象实际上已经完成,类装饰器将被执行。由于这仅在类对象中的所有其他内容都已构建之后执行,因此修改类属性(如正在继承的类和类的名称)变得困难。通过修改__class__对象,你仍然可以修改或覆盖这些属性,但这至少是更困难的。

创建类实例

从上面产生的类对象,我们现在可以最终创建实际的实例,就像通常使用类一样。需要注意的是,与上面的步骤不同,这一步和类装饰器步骤是每次实例化类时唯一执行的步骤。这两个步骤之前的步骤每个类定义只执行一次。

示例

理论已经足够了——让我们通过展示类对象的创建和实例化来检查操作顺序:

>>> import functools

>>> def decorator(name):
...     def _decorator(cls):
...         @functools.wraps(cls)
...         def __decorator(*args, **kwargs):
...             print('decorator(%s)' % name)
...             return cls(*args, **kwargs)
...
...         return __decorator
...
...     return _decorator

>>> class SpamMeta(type):
...     @decorator('SpamMeta.__init__')
...     def __init__(self, name, bases, namespace, **kwargs):
...         print('SpamMeta.__init__()')
...         return type.__init__(self, name, bases, namespace)
...
...     @staticmethod
...     @decorator('SpamMeta.__new__')
...     def __new__(cls, name, bases, namespace, **kwargs):
...         print('SpamMeta.__new__()')
...         return type.__new__(cls, name, bases, namespace)
...
...     @classmethod
...     @decorator('SpamMeta.__prepare__')
...     def __prepare__(cls, names, bases, **kwargs):
...         print('SpamMeta.__prepare__()')
...         namespace = dict(spam=5)
...         return namespace 

使用创建的类和装饰器,我们现在可以说明__prepare____new__等方法的调用时机:

>>> @decorator('Spam')
... class Spam(metaclass=SpamMeta):
...     @decorator('Spam.__init__')
...     def __init__(self, eggs=10):
...         print('Spam.__init__()')
...         self.eggs = eggs
decorator(SpamMeta.__prepare__)
SpamMeta.__prepare__()
decorator(SpamMeta.__new__)
SpamMeta.__new__()
decorator(SpamMeta.__init__)
SpamMeta.__init__()

# Testing with the class object
>>> spam = Spam
>>> spam.spam
5
>>> spam.eggs
Traceback (most recent call last):
  ...
  File "<doctest T_11_order_of_operations.rst[6]>", line 1, in ...
AttributeError: 'function' object has no attribute 'eggs'

# Testing with a class instance
>>> spam = Spam()
decorator(Spam)
decorator(Spam.__init__)
Spam.__init__()
>>> spam.spam
5
>>> spam.eggs
10 

示例清楚地显示了类的创建顺序:

  1. 通过__prepare__准备命名空间

  2. 使用__new__创建类体

  3. 使用__init__初始化元类(注意:这不是类的__init__

  4. 通过类装饰器初始化类

  5. 通过类__init__函数初始化类

从这一点我们可以注意到,类装饰器是在类实际实例化时执行,而不是在此之前。这当然既有优点也有缺点,但如果你希望构建所有子类的注册表,使用元类肯定更方便,因为装饰器只有在实例化类之后才会注册。

此外,在实际上创建类对象(而不是实例)之前修改命名空间的能力也非常强大。这可以方便地在几个类对象之间共享一定的作用域,例如,或者确保某些项目始终在作用域中可用。

按定义顺序存储类属性

有时候定义顺序会起作用。例如,假设我们正在创建一个表示 CSV(逗号分隔值)格式的类。CSV 格式期望字段有特定的顺序。在某些情况下,这可能会由标题指示,但保持一致的字段顺序仍然很有用。类似系统在 ORM 系统如 SQLAlchemy 中用于存储表定义的列顺序,以及在 Django 表单中的输入字段顺序。

没有元类的经典解决方案

存储字段顺序的一个简单方法是为字段实例提供一个特殊的__init__方法,该方法在每次定义时递增,因此字段具有递增的索引属性。这种解决方案可以被认为是经典解决方案,因为它在 Python 2 中也能工作:

>>> import itertools

>>> class Field(object):
...     counter = itertools.count()
...
...     def __init__(self, name=None):
...         self.name = name
...         self.index = next(Field.counter)
...
...     def __repr__(self):
...         return '<%s[%d] %s>' % (
...             self.__class__.__name__,
...             self.index,
...             self.name,
...         )

>>> class FieldsMeta(type):
...     def __new__(metaclass, name, bases, namespace):
...         cls = type.__new__(metaclass, name, bases, namespace)
...         fields = []
...         for k, v in namespace.items():
...             if isinstance(v, Field):
...                 fields.append(v)
...                 v.name = v.name or k
...
...         cls.fields = sorted(fields, key=lambda f: f.index)
...         return cls

>>> class Fields(metaclass=FieldsMeta):
...     spam = Field()
...     eggs = Field()

>>> Fields.fields
[<Field[0] spam>, <Field[1] eggs>]

>>> fields = Fields()
>>> fields.eggs.index
1
>>> fields.spam.index
0
>>> fields.fields
[<Field[0] spam>, <Field[1] eggs>] 

为了方便和使事物看起来更美观,我们添加了FieldsMeta类。

这里不是严格必需的,但它会自动处理在需要时填充name,并添加包含字段排序列表的fields列表。

使用元类获取排序命名空间

之前的方法更直接一些,也支持 Python 2,但使用 Python 3 我们有更多的选择。正如您在前一节中看到的,Python 3 给了我们__prepare__方法,它返回命名空间。从第四章,您可能还记得collections.OrderedDict,那么让我们看看当我们结合它们会发生什么:

>>> import collections

>>> class Field(object):
...     def __init__(self, name=None):
...         self.name = name
...
...     def __repr__(self):
...         return '<%s %s>' % (
...             self.__class__.__name__,
...             self.name,
...         )

>>> class FieldsMeta(type):
...     @classmethod
...     def __prepare__(metaclass, name, bases):
...         return collections.OrderedDict()
...
...     def __new__(metaclass, name, bases, namespace):
...         cls = type.__new__(metaclass, name, bases, namespace)
...         cls.fields = []
...         for k, v in namespace.items():
...             if isinstance(v, Field):
...                 cls.fields.append(v)
...                 v.name = v.name or k
...
...         return cls

>>> class Fields(metaclass=FieldsMeta):
...     spam = Field()
...     eggs = Field()

>>> Fields.fields
[<Field spam>, <Field eggs>]
>>> fields = Fields()
>>> fields.fields
[<Field spam>, <Field eggs>] 

如您所见,字段确实按照我们定义的顺序排列。首先是 Spam,然后是鸡蛋。由于类命名空间现在是一个collections.OrderedDict实例,我们知道顺序是有保证的。需要注意的是,自 Python 3.6 以来,普通dict的顺序也是一致的,但__prepare__的使用示例仍然有用。它展示了元类如何方便地以通用方式扩展你的类。与自定义__init__方法相比,元类的一个重大优势是,如果用户忘记调用父__init__方法,他们不会丢失功能。除非添加了不同的元类,否则元类总是会执行。

练习

本章最重要的要点是教您如何内部工作元类:元类只是一个创建类的类,而这个类反过来又由另一个元类创建(最终递归到type)。然而,如果您想挑战自己,您还可以用元类做更多的事情:

  • 验证是元类可以非常有用的一个最突出的例子。你可以验证属性/方法是否存在,你可以检查是否继承了所需的类,等等。可能性是无限的。

  • 创建一个元类,它将每个方法包装在装饰器中(可能对日志记录/调试目的有用),具有如下签名:

    class SomeClass(metaclass=WrappingMeta, wrapper=some_wrapper): 
    

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。我们鼓励您提交自己的解决方案,并从他人的替代方案中学习。

摘要

Python 的元类系统是每个 Python 程序员都在使用的,也许他们甚至都不知道。每个类都是通过某种(子类)type创建的,这允许进行无限定制的魔法。

你现在可以像平时一样创建类,并在定义过程中动态地添加、修改或从你的类中删除属性;非常神奇但非常有用。然而,魔法成分也是为什么应该非常谨慎地使用元类的原因。虽然它们可以使你的生活变得更加容易,但它们也是产生完全无法理解的代码的最简单方法之一。

不论如何,元类有一些非常好的用例,许多库如 SQLAlchemy 和 Django 都使用元类来使你的代码工作得更加容易,并且可以说是更好。实际上理解这些库内部使用的魔法通常不是使用这些库所必需的,这使得这些用例有可辩护性。

问题变成了是否一个对初学者来说更好的体验值得一些内部的黑暗魔法,并且从这些库的成功来看,我认为在这种情况下答案是肯定的。

总结来说,当考虑使用元类时,请记住蒂姆·彼得斯曾经说过的话:

“元类比 99%的用户应该关心的任何东西都要深奥。如果你想知道你是否需要它们,那么你就不需要。”

随着类装饰器和__init_subclass____set_name__等方法的引入,对元类的需求进一步减少。所以当你犹豫不决时,你可能真的不需要它们。

现在我们将继续介绍一种解决方案来移除元类生成的一些魔法——文档。下一章将展示你的代码如何进行文档化,如何测试这些文档,以及最重要的是,如何通过注释类型使文档变得更加智能。

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第九章:文档 – 如何使用 Sphinx 和 reStructuredText

记录代码可以既有趣又有用!我必须承认,许多程序员对记录代码有强烈的厌恶,这是可以理解的。编写文档可能是一项枯燥的工作,而且传统上,只有其他人能从这种努力中获得好处。然而,Python 可用的工具几乎可以毫不费力地生成有用且最新的文档。实际上,生成文档已经变得如此简单,以至于我经常在开始使用 Python 包之前就创建和生成文档。假设它还没有可用的话。

除了简单的文本文档来解释函数的功能之外,还可以添加元数据,例如类型提示。这些类型提示可以用来使函数或类的参数和返回类型在文档中可点击。但更重要的是,许多现代 IDE 和编辑器,如 VIM,都有可用的插件,这些插件解析类型提示并用于智能自动完成。所以如果你输入 'some_string.',你的编辑器将自动完成字符串对象的特定属性和方法,这在传统上只有像 Java、C 和 C++ 这样的静态类型语言中才是可行的。

本章将解释 Python 中可用的文档类型以及如何轻松创建完整的一套文档。有了 Python 提供的惊人工具,你可以在几分钟内拥有功能齐全的文档。

本章涵盖的主题如下:

  • 类型提示

  • reStructuredText 语法

  • Markdown 语法

  • 使用 Sphinx 设置文档

  • Sphinx、Google 和 NumPy 风格的 docstrings

类型提示

自 Python 3.5 以来,我们有一个名为类型提示的功能,这可以说是 Python 3 中最有用的添加之一。它允许你指定变量和返回值的类型,这意味着你的编辑器将能够提供智能自动完成。这使得它对所有 Python 程序员都很有用,无论水平如何,并且与一个好的编辑器搭配使用时可以使你的生活变得更加容易。

基本示例

大多数编辑器已经足够智能,可以识别这些常规变量中的基本类型:

>>> a = 123
>>> b = 'test'
>>> c = True 

当我们不是有 a = 123 这样的代码,而是有 a = some_function() 这样的代码时,编辑器的工作会变得更加困难。在某些情况下,函数的返回类型是明显的(即 return True),但如果返回类型依赖于输入变量或是不一致的,编辑器理解正在发生的事情就会变得非常困难。

正如 Python 的禅所说,明确优于隐晦。在函数返回类型的情况下,这通常是正确的,并且可以非常容易地实现:

>>> def pow(base: int, exponent: int) -> int:
...     return base ** exponent

>>> help(pow)
Help on function pow in module __main__:
<BLANKLINE>
pow(base: int, exponent: int) -> int
<BLANKLINE>

>>> pow.__annotations__
{'base': <class 'int'>,
 'exponent': <class 'int'>,
 'return': <class 'int'>}

>>> pow(2, 10)
1024
>>> pow(pow(9, 2) + pow(19, 2) / 22, 0.25)
3.1415926525826463 

这正如预期的那样工作。使用简单的 -> type,你可以指定函数的返回类型,这会自动反映在 __annotations__ 中,这在 help() 中也是可见的。并且可以使用 name: type 来指定参数(和变量)的类型。

在这种情况下,你可能注意到,尽管我们指定了函数返回一个 int,但实际上它也可以返回一个 float,因为 Python 只有类型提示,没有类型约束/强制。

虽然基本类型如 intfloatstrdictlistset 可以仅使用 variable: int 来指定,但对于更高级的类型,我们需要使用 typing 模块。

由于 Python 3.9,你可以使用 variable: list[int]。对于 Python 的旧版本,你需要使用 variable: typing.List[int] 来指定所有需要使用 getitem ([])操作符的集合类型,如dict/list/set`。

typing 模块包含如 typing.Any 允许一切,typing.Optional 允许 None,以及 typing.Union 指定多个允许的类型,我们现在将演示这些:

>>> import typing

>>> int_or_float = typing.Union[int, float]

>>> def pow(base: int, exponent: int) -> int_or_float:
...     return base ** exponent

>>> help(pow)
Help on function pow in module __main__:
<BLANKLINE>
pow(base: int, exponent: int) -> Union[int, float]
<BLANKLINE> 

使用 typing.Union,我们可以指定一个类型列表,同样,可以使用 typing.Optional[int] 来指定一个可选类型,表示类型可以是 intNone,这实际上等同于 typing.Union[int, None]。此外,从 Python 3.10 开始,我们可以将其写成 int | None

自定义类型

由于常规 Python 对象是其自己的类型,你通常甚至不需要考虑它们的类型。只需指定对象,它就会工作:

>>> class Sandwich:
...     pass

>>> def get_sandwich() -> Sandwich:
...     return Sandwich() 

但如果遇到循环定义或其他你还没有类型可用的情况呢?在这种情况下,你可以通过指定类型为字符串来解决这个问题:

>>> class A:
...     @staticmethod
...     def get_b() -> 'B':
...         return B()

>>> class B:
...     @staticmethod
...     def get_a() -> A:
...         return A() 

在可能的情况下,我建议不要使用这种方法,因为它不能保证类型实际上可以被解决:

# Works without an issue
>>> some_variable: 'some_non_existing_type'

# Error as expected
>>> some_variable: some_non_existing_type
Traceback (most recent call last):
    ...
NameError: name 'some_non_existing_type' is not defined 

自然地,这只会检查类型实际上是否存在。为了进行正确的类型检查,我们可以使用像 mypy 这样的工具,这将在下一节中介绍。为了确保你的类型检查器可以解析类型,你可以将你的导入放在一个 if typing.TYPE_CHECKING 块中,如下所示:

>>> if typing.TYPE_CHECKING:
...     # Add your import for some_non_existing_type here
...     ... 

typing.TYPE_CHECKING 常量通常不会被设置,但可以被像 mypy 这样的类型检查器设置,以确保所有类型都正常工作。

在上面的例子中,我们已经看到了自定义类作为自定义类型,但如果我们想从一个现有的内置类型创建一个自定义类型呢?这也是可能的,使用 typing.NewType 创建一个新类型,它表现得像基类型,但可以被静态类型检查器检查:

>>> import typing

>>> Username = typing.NewType('Username', str)

>>> rick = Username('Rick')

>>> type(rick)
<class 'str'> 

在这里,我们创建了一个名为 Username 的类型,在这种情况下它被视为 str 的子类。

泛型

在某些情况下,你不想静态指定函数的类型,而是让它依赖于输入。因此,Python 类型系统支持泛型。如果你熟悉 Java、C++ 或 C#,你可能已经熟悉它们。

泛型允许你创建一个泛型类型,其唯一约束是它在所有情况下都相同。这意味着如果你将泛型类型指定为函数的输入和输出,它将被假定是相同的;如果你将int输入到函数中,你将收到一个int

首先,我们需要指定一个泛型类型,然后我们才能将其指定为函数的参数:

>>> import typing

>>> T = typing.TypeVar('T', int, str)

>>> def add(a: T, b: T) -> T:
...     return a + b

>>> add(1, 2)
3
>>> add('a', 'b')
'ab' 

在这种情况下,我们创建了一个泛型类型,其约束是它必须是intstr。当类型检查器运行时,它将检查abreturn值是否有相同的类型。这意味着即使int对于类型T是有效的,如果你将a设为strb和输出也必须是str

类型检查

现在我们知道了如何指定和创建类型提示,是时候运行类型检查器了。类型检查的参考实现是mypy工具。它可以彻底检查你的代码,并警告潜在的问题。

首先,我们需要安装mypy——幸运的是,使用pip来做这件事很容易:

$ pip3 install -U mypy 

现在我们将使用mypy来检查一些之前带有错误的一些示例:

import typing

def pow(base: int, exponent: int) -> int:
    return base ** exponent

pow(2.5, 10) 

由于我们提示baseint2.5不是一个有效的值,因为它是一个float

$ mypy T_01_type_hinting.py
T_01_type_hinting.py:8: error: Argument 1 to "pow" has incompatible type "float"; expected "int" 

现在有一个自定义类型的示例:

Username = typing.NewType('Username', str)

rick = Username('Rick')

def print_username(username: Username):
    print(f'Username: {username}')

print_username(rick)
print_username(str(rick)) 

在这里,我们指定了print_username()应该接收一个Username类型。即使Username继承了str,它也不被认为是有效的:

$ mypy T_01_type_hinting.py
T_01_type_hinting.py:22: error: Argument 1 to "print_username" has incompatible type "str"; expected "Username" 

最后,我们将创建一个泛型类型:

T = typing.TypeVar('T')

def to_string(value: T) -> T:
    return str(value)

to_string(1) 

由于to_string()接收了一个int,它应该返回一个int,但这并不是情况。让我们运行mypy来看看哪里出了问题:

error: Incompatible return value type (got "str", expected "T") 

在编写代码时,mypy可以通过警告你关于不正确的类型使用来为你节省大量的调试时间。

Python 类型接口文件

Python 类型提示文件(.pyi),也称为存根文件,是允许你为文件指定所有类型提示而不修改原始文件的文件。这对于你无法写入的库或你不想在文件中添加类型提示的情况非常有用。

这些文件使用的是常规的 Python 语法,但函数并不打算包含任何除了仅提示类型的存根之外的内容。上面提到的print_username()函数的一个示例存根可能是:

import typing

Username = typing.NewType('Username', str)

def print_username(username: Username): ... 

这些文件没有什么特别之处,但它们在与缺少类型提示的库交互时特别有用。如果你的常规文件名为test.py,那么pyi文件将命名为test.pyi

类型提示结论

在本节中,你已经看到了一些非常基本的示例,说明了如何应用类型提示以及如何检查类型。Python 的typing模块仍在不断改进,mypy有非常详尽的文档,如果你在自己的代码中应用这些,可能会很有用。如果你有任何具体问题,请确保查看文档;它质量很高,非常实用。

当涉及到在自己的项目中使用类型提示时,我的建议是只要它增强了你的工作流程就使用它,但不要过度使用。在许多情况下,你的编辑器足够智能,可以自动识别参数,或者这并不真的那么重要。但是,当传递更高级的类时,你可能会忘记该类的方法,这时它就成为一个非常有用的特性。拥有智能自动补全功能可以真正为你节省大量时间。

现在我们已经涵盖了类型提示,是时候继续记录我们的代码以及用于此任务的标记语言了。

reStructuredText 和 Markdown

reStructuredText格式(也称为RSTReSTreST)于 2002 年开发,作为一种实现足够标记以可用的语言,但足够简单以供纯文本阅读。这两个特性使得它足够易于在代码中使用,同时仍然足够灵活以生成美观且有用的文档。

Markdown 格式与 reStructuredText 非常相似,在很大程度上可以比较。虽然 reStructuredText 比 Markdown(2014 年)稍早(2012 年),但 Markdown 格式因其更简单且不那么以 Python 为中心而获得了更多的流行。这两个标准都非常适合编写易于阅读的文本,并且可以轻松转换为其他格式,如 HTML 或 PDF 文件。

reST 的主要优点是:

  • 一个非常广泛的功能集

  • 一个严格定义的标准

  • 易于扩展

Markdown 的主要优点是:

  • 它不那么以 Python 为中心,这导致它获得了更广泛的应用

  • 一个更宽容且不那么严格的解析器,这使得编写更加容易

reStructuredText 和 Markdown 最棒的地方在于它们非常直观易写,并且被大多数(社交)编码平台(如 GitHub、GitLab、BitBucket 和 PyPI)原生支持。

即使不了解任何关于标准的信息,你也能轻松地用这种风格编写文档。然而,更高级的技术,如图片和链接,确实需要一些解释。

对于 Python 文档本身,reStructuredText 是最方便的标准,因为它得到了 Sphinx 和 docutils 等工具的良好支持。对于 GitHub 和 Python 包索引等网站上的 readme 文件,Markdown 标准通常得到更好的支持。

要轻松地在 reStructuredText 和 Markdown 等格式之间进行转换,请使用 Pandoc 工具,该工具可在pandoc.org/找到。

基本语法看起来就像文本,接下来的几段将展示一些更高级的功能。然而,让我们从一个简单的例子开始,展示 reStructuredText 或 Markdown 文件可以有多简单:

Documentation, how to use Sphinx and reStructuredText
##################################################################

Documenting code can be both fun and useful! ...

Additionally, adding ...

... So that typing 'some_string.' will automatically ...

Topics covered in this chapter are as follows:

 - The reStructuredText syntax
 - Setting up documentation using Sphinx
 - Sphinx style docstrings
 - Google style docstrings
 - NumPy style docstrings

The reStructuredText syntax
******************************************************************

The reStructuredText format (also known as ... 

这就是将本章到目前为止的文本转换为 reStructuredText 或 Markdown 有多简单。上面的例子在两者中都适用。但是为了让 Markdown 文件看起来相似,我们需要稍微修改一下标题:

# Documentation, how to use Sphinx and reStructuredText

...

## The reStructuredText syntax

... 

下面的段落将涵盖以下功能:

  1. 内联标记(斜体、粗体、代码和链接)

  2. 列表

  3. 标题

  4. 高级链接

  5. 图片

  6. 替换

  7. 包含代码、数学和其他内容的块

开始使用 reStructuredText

要快速将 reStructuredText 文件转换为 HTML,我们可以使用docutils库。本章后面讨论的sphinx库实际上内部使用docutils库,但它有一些我们最初不需要的额外功能。要开始,我们只需要安装docutils

$ pip3 install docutils 

之后,我们可以轻松地将 reStructuredText 转换为 PDF、LaTeX、HTML 和其他格式。对于本段中的示例,我们将使用 HTML 格式,它可以通过以下命令轻松生成:

$ rst2html.py file.rst file.html 

reStructuredText 语言有两个基本组件:

  • 允许对输出进行内联修改的角色,例如:code:, :math:, :emphasis:, 和 :literal:.

  • 生成标记的指令,例如多行代码示例。它们看起来像这样:

    .. code:: python
    
      print('Hello world') 
    

在纯 reStructuredText 中,指令是最重要的,但我们将在本章后面的Sphinx 角色部分看到角色的许多用途。

开始使用 Markdown

要快速将 Markdown 文件转换为 HTML,我们有多种选择可用。但是,因为我们使用 Python,我们将使用markdown包:

$ pip3 install markdown 

现在我们可以使用以下命令将我们的文件转换为 HTML:

$ markdown_py file.md -f file.html 

应该注意的是,此转换器仅支持纯 Markdown,不支持 GitHub 风格的 Markdown,后者还支持代码语法高亮。

grip(GitHub Readme Instant Preview)Python 包通过使用 GitHub 服务器支持 GitHub 风格的 Markdown 的实时渲染,这在编写 Markdown 时可能很有用。

内联标记

内联标记是在常规文本行中使用的标记。这些示例包括着重、内联代码示例、链接、图片和项目符号列表。

在 reStructuredText 中,这些通过角色实现,但通常有有用的简写。您可以使用*text*而不是:emphasis:'text'

例如,可以通过将单词封装在一到两个星号之间来添加着重。例如,这个句子可以通过在两侧添加单个星号来添加一点*着重*,或者通过在两侧添加两个星号来添加很多**着重**。有许许多多的内联标记指令,所以我们只列出最常见的。完整的列表始终可以在 reStructuredText 主页docutils.sourceforge.io/docs/和 Markdown 主页daringfireball.net/projects/markdown/syntax中找到。

以下是一些既适用于 reST 也适用于 Markdown 的示例:

  • 着重(斜体)文本:*这个短语的着重*.

  • 额外的着重(粗体)文本:**这个短语的额外着重**.

  • 对于不带数字的列表,只需在后面加一个空格的简单破折号:

    • - 项目 1

    • - 项目 2

注意

破折号后面的空格对于 reStructuredText 识别列表是必需的。

  • 对于带数字的列表,数字后面跟着一个句点和空格:

    • 1. 项目 1

    • 2. 项目 2

  • 对于编号列表,数字后面的句点和空格是必需的。

  • 解释性文本:这些是特定领域的。在 Python 文档中,默认的角色是代码,这意味着用反引号包围的文本将被转换为使用代码标签,例如,'if spam and eggs:'

  • 内联字面量:这是使用等宽字体格式化的,这使得它非常适合内联代码。只需在 ''添加一些代码'' 中添加两个反引号即可。对于 Markdown,单引号和双引号在输出中没有明显的区别,但它可以用来转义单引号:''some code ' with backticks''

  • 在 reST 中可以使用 \ 进行转义,类似于 Python 中的转义:''some code \' with backticks''

对于 reStructuredText,有一些额外的选项可以使用角色,类似于我们之前看到的解释性文本角色。这些角色可以通过角色前缀或后缀根据你的偏好设置;例如,':math:'E=mc²' 用于显示数学方程式。

可以通过尾随下划线添加引用。它们可以指向标题、链接、标签等。下一节将介绍更多关于这些的内容,但基本语法是简单的 reference_,或者当引用包含空格时用反引号包围 – 'some reference link'_

有很多更多可供选择,但当你编写 reStructuredText 时,你将最常使用这些。

标题

标题用于指示文档、章节、部分或段落的开始。因此,它是文档中你需要的第一种结构。虽然不是严格必需的,但它的使用被高度推荐,因为它有多个用途:

  1. 标题会根据其级别进行一致的格式化。

  2. 可以从标题生成目录树。

  3. 所有标题自动作为标签使用,这意味着你可以创建指向它们的链接。

标题所需的格式在 reST 和 Markdown 之间略有重叠,但为了清晰起见,我们将分别介绍。

使用 reStructuredText 的标题

在创建标题时,一致性是少数约束之一;使用的字符数相当任意,级别数也是如此。

个人来说,我默认使用一个具有固定大小的标题系统,但我建议至少遵循 Python 文档的默认部分、章节、部分、子部分、次子部分和段落的默认设置,大致如下:

Part
################################################################

Chapter
****************************************************************

Section
================================================================

Subsection
----------------------------------------------------------------

Subsubsection
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Paragraph
""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""

Content 

这将创建以下输出:

标题

图 9.1:使用 reStructuredText 的标题

这只是标题的常见用法,但 reStructuredText 的主要思想是你可以使用任何你觉得自然的东西,这意味着你可以使用以下任何字符:= - ' : " ~ ^ _ * + # <>。它还支持下划线和上划线,所以如果你更喜欢这样,它们也是选项之一:

################################################################
Part
################################################################

****************************************************************
Chapter
****************************************************************

================================================================
Section
================================================================

----------------------------------------------------------------
Subsection
----------------------------------------------------------------

^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Subsubsection
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
Paragraph
""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""

Content 

尽管我尽量保持字符数量固定为 78 个字符,如 PEP8第三章Pythonic 语法和常见错误)所建议的 Python,但使用的字符数量大多是任意的,但至少要和标题文本一样长。这允许它接受以下结果:

Section
======= 

但不是这个:

Section
==== 

使用 Markdown 的标题

使用 Markdown,你可以根据个人喜好选择多种标题选项。类似于 reST,你可以使用 = 字符进行下划线标记,但仅限这些,并且它们后面的长度和空白行并不重要:

Part
=
Chapter
- 

如果你需要更多级别,你可以通过使用 # 前缀和可选后缀最多使用 6 个级别:

# Part
## Chapter
### Section
#### Subsection
##### Subsubsection
###### Paragraph
Content
###### Paragraph with suffix ######
Content 

这将产生:

图 9.2:Markdown 中的标题

如你所见,Markdown 在标题方面比 reStructuredText 略显不灵活,但在大多数情况下,它提供的功能足够多,完全可以使用。

列表

reStructuredText 格式有几种列表样式:

  1. 列表项

  2. 项目符号

  3. 选项

  4. 定义

在介绍部分已经展示了列表的最简单形式,但实际上可以使用许多不同的字符进行编号,如字母、罗马数字等。在演示了基本列表类型之后,我们将继续介绍列表和结构的嵌套,这使得它们更加强大。需要注意空白量的多少,因为过多的空格可能会导致结构被识别为普通文本而不是结构。

列表项

列表项对于各种列举都非常方便。列表项的基本前提是数字或字母字符后跟一个点、一个右括号或两侧的括号。此外,# 字符还充当自动编号。例如:

1\. With
2\. Numbers

a. With
#. letters

i. Roman
#. numerals

(1) With
(2) Parenthesis 

输出可能比你预期的要简单一些。原因是它取决于输出格式。以下图显示了渲染的 HTML 输出,它不支持括号。例如,如果你输出 LaTeX,差异可以变得明显。

列表项

图 9.3:使用 HTML 输出格式生成的列表项

Markdown 也支持列表项,但在选项上略显有限。它只支持常规编号列表。不过,它在支持这些列表项方面更为方便;无需显式编号,重复 1. 也不会有问题:

1\. With
1\. Numbers 

项目符号列表

如果列表的顺序不重要,你只需要一个不带编号的项目列表,那么你应该使用项目符号列表。要使用项目符号创建一个简单的列表,项目符号需要以 *+-、‣ 或 ⁃ 开始。这个列表主要是任意的,可以通过扩展 Sphinx 或 Docutils 来修改。例如:

- dashes
- and more dashes

* asterisk
* stars

+ plus
+ and plus 

如下图所示,在 HTML 输出中,所有项目符号再次看起来相同。

当生成 LaTeX(以及随后 PDF 或 Postscript)格式的文档时,这些可能会有所不同。

由于基于 Web 的文档是目前 Sphinx 最常见的输出格式,我们默认使用该格式。渲染的 HTML 输出如下:

项目符号列表

图 9.4:带有 HTML 输出的项目符号列表

如您所见,在这种情况下,所有项目符号列表都被渲染为相同。然而,这取决于渲染器,所以检查输出是否与您的偏好相符是个好主意。

选项列表

选项 列表是专门用于记录程序命令行参数的。关于语法的特殊之处在于,逗号空格被识别为选项的分隔符:

-s, --spam  This is the spam option
--eggs      This is the eggs option 

下面的输出结果如下:

选项列表

图 9.5:选项列表

在 Markdown 中,没有对选项列表的支持,但你可以通过创建表格来实现类似的效果:

| Argument     | Help                    |
|--------------|-------------------------|
| '-s, --spam' | This is the spam option |
| '--eggs'     | This is the eggs option | 

注意,在大多数 Markdown 实现中,表格的标题是必需的。但此处所进行的标题对齐是可选的,以下方式也能达到相同的效果:

| Argument | Help |
|-|-|
| '-s, --spam' | This is the spam option |
| '--eggs' | This is the eggs option | 

定义列表(仅限 reST)

定义列表比其他类型的列表稍微难以理解,因为其实际结构仅由空白字符组成。因此,它的使用相当直接,但在文件中不一定容易识别,并且仅由 reST 支持:

spam
    Spam is a canned pork meat product
eggs
    Is, similar to spam, also food 

下面的输出结果如下:

定义列表

图 9.6:定义列表

定义列表在解释文档中某些关键词的含义时特别有用。

嵌套列表

项目嵌套实际上不仅限于列表,还可以使用多种类型的块来实现,但基本思想是相同的。例如,你可以在项目符号列表中嵌套代码块。只需确保缩进级别正确。如果不这样做,它可能不会被识别为单独的级别,或者你会得到一个错误:

1\. With
2\. Numbers

   (food) food

    spam
        Spam is a canned pork meat product
    eggs
        Is, similar to spam, also food

    (other) non-food stuff 

下图显示了输出结果:

嵌套列表

图 9.7:嵌套列表

对于 Markdown,只要使用正确的列表类型,就可以实现类似的嵌套。

链接、引用和标签

Markdown 和 reStructuredText 之间的链接语法相当不同,但它们提供了类似的功能。两者都支持内联链接和使用参考列表的链接。

最简单的带有协议(如 python.org)的链接将被大多数 Markdown 和 reStructuredText 解析器自动识别。对于自定义标签,语法略有不同:

  • reStructuredText: 'Python <http://python.org>'_

  • Markdown: [Python](http://python.org)

这两种方法对于不会经常重复的简单链接来说都很不错,但通常,将标签附加到链接上会更方便,这样它们就可以被重复使用,而且不会使文本过于拥挤。

例如,参考以下 reStructuredText 示例:

The switch to reStructuredText and Sphinx was made with the
'Python 2.6 <https://docs.python.org/whatsnew/2.6.html>'_
release. 

现在比较以下内容:

The switch to reStructuredText and Sphinx was made with the
'python 2.6'_ release.
.. _'Python 2.6': https://docs.python.org/whatsnew/2.6.html 

输出如下:

链接、引用和标签

图 9.8:带有自定义标签的链接

以及 Markdown 的等效形式:

The switch to reStructuredText and Sphinx was made with the [Python 2.6](https://docs.python.org/whatsnew/2.6.html) release.

The switch to reStructuredText and Sphinx was made with the [Python 2.6] release.

[Python 2.6]: https://docs.python.org/whatsnew/2.6.html 

使用标签,你可以在指定位置轻松地有一个引用列表,而不会使实际文本难以阅读。

对于 reStructuredText,这些标签不仅可以用于外部链接。类似于在旧编程语言中找到的 GOTO 语句,你可以创建标签并在文档的其他部分引用它们:

.. _label: 

在 HTML 或 PDF 输出中,这可以用来在文本的任何位置创建一个可点击的链接,使用下划线链接。创建一个指向标签的可点击链接就像在文本中包含 label_ 那样简单。

注意,reStructuredText 忽略大小写差异,因此大小写链接都可以正常工作。即使我们不太可能犯这个错误,但在单个文档中只有大小写差异的相同标签会导致错误,以确保不会发生重复。

将引用与标题结合使用的方式非常自然;你可以像平常一样引用它们,并添加一个下划线来使其成为链接:

The introduction section
================================================================

This section contains:

- 'chapter 1'_
- :ref:'chapter2'

  1\. my_label_

  2\. 'And a label link with a custom title <my_label>'_
Chapter 1

----------------------------------------------------------------

Jumping back to the beginning of 'chapter 1'_ is also possible.
Or jumping to :ref:'Chapter 2 <chapter2>'

.. _chapter2:

Chapter 2 With a longer title
----------------------------------------------------------------

The next chapter.

.. _my_label:

The label points here.

Back to 'the introduction section'_ 

输出如下:

链接、引用和标签

图 9.9:链接、标签和引用

对于 Markdown,根据所使用的渲染器,你可以部分地获得类似的结果。在 GitHub 解析器的情况下,所有标题都会自动转换为 HTML 锚点,因此一个像 # Some header 这样的标题可以通过 链接名称 来链接。

虽然这种方法对于简单情况来说很方便,但它带来了一些缺点:

  • 当标题改变时,指向它的所有链接都会断开

  • 当多个标题具有相同的名称时,只有第一个可以被链接到

  • 只有标题可以被链接到

图片

图片是 reStructuredText 和 Markdown 之间实现差异很大的一个功能。

reStructuredText 中的图片

在 reStructuredText 中,图片指令看起来与标签语法非常相似。它们实际上略有不同,但模式相当相似。图片指令只是 reStructuredText 支持的许多指令之一。我们将在介绍 Sphinx 和 reStructuredText 扩展时了解更多关于这一点。目前,只需知道指令以两个点和一个空格开始,后跟指令名称和两个冒号:

 .. name_of_directive:: 

在图片的情况下,指令被称为image当然:

.. image:: python.png 

这里是缩放后的输出,因为实际图片要大得多:

图片

图 9.10:使用 reStructuredText 的图片输出

注意指令后的双冒号。

但如何指定大小和其他属性?图片指令有许多其他选项(大多数其他指令也是如此)可以用来:docutils.sourceforge.io/docs/ref/rst/directives.html#images;它们大多数相当明显。要指定图片的宽度和高度或缩放(以百分比表示):

.. image:: python.png
   :width: 150
   :height: 100

.. image:: python.png
   :scale: 10 

以下是其输出:

图片

图 9.11:使用 reStructuredText 缩放的图片

scale选项如果可用,将使用widthheight选项,并回退到 PIL(Python Imaging Library)或 Pillow 库来检测图片。如果既没有width/height也没有 PIL/Pillow 可用,scale选项将被静默忽略。

除了image指令外,还有一个figure指令。区别在于figure为图片添加了标题。除此之外,用法与image相同:

.. figure:: python.png
   :scale: 10

   The Python logo 

输出如下:

图片

图 9.12:使用 reStructuredText 添加图注

现在,让我们比较一下我们刚刚看到的与使用 Markdown 处理图片的方法。

Markdown 中的图片

Markdown 对图片的支持与对链接的支持类似,但需要在前面添加一个!

![python](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/ms-py-2e/img/python.png) 

就像链接一样,你也可以使用引用:

![python]

[python]: python.png 

然而,更改其他属性,如大小,大多数 Markdown 实现都不支持。

替换

在编写文档时,你经常会反复使用相同的图片和链接。虽然你可以直接添加它们,但这通常非常冗长、繁琐且难以维护。

在 reStructuredText 页面内,我们已经有了一个内部标签系统,可以为我们处理很多情况。对于外部链接和图片,我们则需要使用 reStructuredText 的其他功能。通过替换定义,你可以缩短指令以便于重复使用。在常见的 Markdown 实现中,没有与此等效的功能。

假设我们有一个在文本中经常使用的标志。与其输入整个 .. image:: <url>,不如有一个简写来简化操作会非常方便。这正是替换非常有用的地方:

.. |python| image:: python.png
   :scale: 1

The Python programming language uses the logo: |python| 

如您所见,您可以使用管道字符在文本的任何位置创建和使用替换。在大多数语言中,如果您需要在替换之外使用管道字符,可以使用反斜杠(\)来转义该字符。

输出结果如下:

替换

图 9.13:使用图像指令替换渲染的 reStructuredText

这些替换可以与许多指令一起使用,尽管它们在文档的许多地方输出变量时特别有用。例如:

.. |author| replace:: Rick van Hattem

This book was written by |author| 

以下为输出结果:

替换

图 9.14:使用作者名称的文本替换渲染的 reStructuredText

这些类型的替换在编写文档时非常有用,因为它们使您的 reStructuredText 文件更易于阅读,但它们还允许您通过更新单个变量来更改整个文档。与通常容易出错的搜索/替换操作相比。

在编写本章时,如果有一个替换 |rest| 返回 reStructuredText 的方法将会非常有用。

块、代码、数学、注释和引用

在编写文档时,一个常见的场景是需要包含不同类型内容的块,例如带有数学公式的解释、代码示例等。

这些指令的使用方式与图像指令类似。以下是一个代码块的示例:

.. code:: python

   def spam(*args):
       print('spam got args', args) 

输出结果如下:

块、代码、数学、注释和引用

图 9.15:代码块输出

这是在 Markdown 中使用起来相对简单的一个案例。使用纯 Markdown,代码块只需要缩进:

Code below:

    def spam(*args):
        print('spam got args', args) 

或者使用带有语法高亮的 GitHub 风格 Markdown:

'''python
def spam(*args):
    print('spam got args', args)
''' 

使用 reStructuredText,您有更多选项。您还可以使用 LaTeX 语法显示数学公式。例如,这是微积分的基本定理:

.. math::

    \int_a^b f(x)\,dx = F(b) - F(a) 

以下为输出结果:

块、代码、数学、注释和引用

图 9.16:数学公式输出

通过使用“空”指令后跟缩进,可以轻松地注释大量文本/命令。实际上,这意味着任何指令中的两个点,但省略了 directive:: 部分:

Before comments

.. Everything here will be commented

   And this as well
   .. code:: python
      def even_this_code_sample():
          pass  # Will be commented

After comments 

输出结果如下:

块、代码、数学、注释和引用

图 9.17:输出(带隐藏注释)

使用 Markdown,您没有真正的方法来添加注释,但在一些有限的案例中,您可以使用链接作为绕过这一限制的技巧:

[_]: <> (this will not be shown) 

虽然这种方法是可行的,但当然还不够美观。通常,你最好将内容移动到单独的临时文件中,或者删除内容而不是注释它,并在需要时使用像 Git 这样的版本控制系统来检索数据。

引用文本在 reStructuredText 和 Markdown 中都受到支持,但语法冲突。在 reStructuredText 中,你可以通过缩进来创建一个块引用,这在 Markdown 中会导致代码格式化:

Normal text

    Quoted text 

输出结果如下:

块、代码、数学、注释和引用

图 9.18:引用文本

在 Markdown 中,格式与基于文本的电子邮件客户端通常引用回复的方式相似:

Normal text
> Quoted text 

结论

reStructuredText 和 Markdown 都是创建文档非常有用的语言。大部分语法在编写纯文本笔记时自然出现。然而,关于 reST 所有复杂性的完整指南可能需要另一本书来解释。前面的演示应该已经提供了足够的介绍,足以完成你项目文档工作时至少 90% 的工作。除此之外,Sphinx 将在接下来的章节中提供大量帮助。

通常,我建议使用 reStructuredText 来编写实际的文档,因为它比 Markdown 具有更多的功能。然而,Markdown 通常更方便用于 PyPI 和 GitHub 上的基本 README 文件,主要是因为你可以为这两种情况使用相同的 README 文件,而且 GitHub 对 Markdown 的支持略好于 reStructuredText。

Sphinx 文档生成器

Sphinx 文档生成器是在 2008 年为 Python 2.6 版本创建的,用于替换 Python 的旧 LaTeX 文档。它是一个生成器,使得为编程项目生成文档变得几乎易如反掌,但即使在编程世界之外,它也可以轻松使用。在编程项目中,对以下领域(编程语言)有特定的支持:

  • Python

  • C

  • C++

  • JavaScript

  • reStructuredText

在这些语言之外,还有许多其他语言的扩展可用,例如 CoffeeScript、MATLAB、PHP、Ruby Lisp、Go 和 Scala。如果你只是寻找代码片段高亮显示,内部使用的 Pygments 高亮器支持超过 120 种语言,并且如果需要,可以轻松扩展以支持新的语言。

Sphinx 最重要的优势是几乎可以从你的源代码中自动生成几乎所有内容。结果是,你的文档总是最新的。

开始使用 Sphinx

首先,我们必须确保已经安装了 Sphinx。尽管 Python 的核心文档是用 Sphinx 编写的,但它仍然是一个独立维护的项目,必须单独安装。幸运的是,使用 pip 来安装非常简单:

$ pip3 install sphinx 

安装 Sphinx 后,有两种方式开始一个项目:sphinx-quickstart 脚本和 sphinx-apidoc 脚本。

如果你想要创建和自定义整个 Sphinx 项目,那么我建议使用 sphinx-quickstart 命令,因为它可以帮助你配置一个功能齐全的 Sphinx 项目。

如果你想要快速开始并为现有的 Python 项目生成一些 API 文档,那么 sphinx-apidoc 可能更适合,因为它只需一个命令和没有进一步的输入就可以创建项目。运行后,你将拥有基于你的 Python 源代码的完整功能文档。

最后,这两种方法都是创建 Sphinx 项目的有效选项,并且我个人通常使用 sphinx-quickstart 生成初始配置,每次添加 Python 模块时都调用 sphinx-apidoc 命令来添加新模块。

sphinx-apidoc 命令默认不会覆盖任何文件,这使得它可以安全地重复运行。

使用 sphinx-quickstart

sphinx-quickstart 脚本会交互式地询问你在 Sphinx 项目中最重要的决策。无需担心拼写错误;配置存储在 conf.py 文件中,可以像常规 Python 文件一样进行修改。

使用方法很简单。默认情况下,我建议在单独的 docs 目录中创建文档,这是许多项目的惯例。输出使用以下约定:

  • 内联注释以 # 开头

  • 用户输入行以 > 开头

  • 裁剪后的输出用 ... 表示,并且所有跳过的中间问题都使用默认设置。

注意命令后面的 docs

$ sphinx-quickstart docs
Welcome to the Sphinx 3.2.1 quickstart utility.

Please enter values for the following settings (just press Enter to
accept a default value, if one is given in brackets).

Selected root path: docs

You have two options for placing the build directory for Sphinx output.
Either, you use a directory "_build" within the root path, or you separate
"source" and "build" directories within the root path.
> Separate source and build directories (y/n) [n]:

The project name will occur in several places in the built documentation.
> Project name: Mastering Python
> Author name(s): Rick van Hattem
> Project release []:

... 

现在,你应该填充你的主文件 docs/index.rst,并创建其他文档源文件。使用 Makefile 来构建文档,如下所示:

$ make <builder> 

其中 “<builder>" 是支持的构建器之一,例如,htmllatexlinkcheck。运行此命令后,我们应该有一个包含 Sphinx 项目的 docs 目录。让我们看看命令实际上为我们创建了什么:

$ find docs
docs
docs/index.rst
docs/_templates
docs/Makefile
docs/conf.py
docs/_static
docs/make.bat
docs/_build 

_build_static_templates 目录最初是空的,现在可以忽略。_build 目录用于输出生成的文档,而 _static 目录可以用来轻松包含自定义 CSS 文件等。_templates 目录使得可以按你的喜好样式化 HTML 输出。这些示例可以在 Sphinx Git 仓库中找到,网址为 www.sphinx-doc.org/en/master/usage/theming.html#builtin-themes

Makefilemake.bat 可以用来生成文档输出。Makefile 可以用于支持 make 工具的任何操作系统,而 make.bat 则是为了直接支持 Windows 系统。现在让我们看看 index.rst 源文件:

Welcome to Mastering Python's documentation!
============================================

.. toctree::
   :maxdepth: 2
   :caption: Contents:

Indices and tables
==================

* :ref:'genindex'
* :ref:'modindex'
* :ref:'search' 

我们可以看到预期的文档标题,然后是toctree(目录树;本章后面将详细介绍),以及索引和搜索的链接。toctree会自动从所有可用的文档页面的标题生成一个树状结构。

索引和表格是自动生成的 Sphinx 页面,非常有用,但在设置方面我们无需担心。

现在是时候生成HTML输出了:

$ cd docs
$ make html 

make html命令为您生成文档,结果放置在_build/html/中。只需在浏览器中打开index.html即可查看结果。现在您应该看到以下类似的内容:

图片

图 9.19:查看 index.html

只需执行这个单一命令并回答几个问题,我们现在就有一个包含索引、搜索和目录的文档项目了。

除了 HTML 输出外,还有许多默认支持的格式,尽管其中一些需要外部库才能实际工作:

$ make help
Sphinx v3.2.1
Please use 'make target' where target is one of
  html        to make standalone HTML files
  dirhtml     to make HTML files named index.html in directories
  singlehtml  to make a single large HTML file
  pickle      to make pickle files
  json        to make JSON files
  htmlhelp    to make HTML files and an HTML help project
  qthelp      to make HTML files and a qthelp project
  devhelp     to make HTML files and a Devhelp project
  epub        to make an epub
  latex       to make LaTeX files, you can set PAPER=a4 or ...
  latexpdf    to make LaTeX and PDF files (default pdflatex)
  latexpdfja  to make LaTeX files and run them through platex/...
  text        to make text files
  man         to make manual pages
  texinfo     to make Texinfo files
  info        to make Texinfo files and run them through makeinfo
  gettext     to make PO message catalogs
  changes     to make an overview of all changed/added/... items
  xml         to make Docutils-native XML files
  pseudoxml   to make pseudoxml-XML files for display purposes
  linkcheck   to check all external links for integrity
  doctest     to run all doctests embedded in the documentation
  coverage    to run coverage check of the documentation 

使用 sphinx-apidoc

通常,sphinx-apidoc命令与sphinx-quickstart一起使用。可以使用--full参数生成整个项目,但通常更好的做法是使用sphinx-quickstart生成整个项目,然后简单地通过sphinx-apidoc添加 API 文档。

为了正确演示sphinx-apidoc命令,我们需要一些 Python 文件,因此我们将在一个名为apidoc_example的项目中创建两个文件。

第一个是apidoc_example/a.py,包含一个名为A的类和一些方法:

class A(object):
    def __init__(self, arg, *args, **kwargs):
        pass

    def regular_method(self, arg):
        pass

    @classmethod
    def decorated_method(self, arg):
        pass

    def _hidden_method(self):
        pass 

接下来,我们有一个包含继承自A类的B类的apidoc_example/b.py文件:

from . import a

class B(a.A):
    def regular_method(self):
        '''This regular method overrides
        :meth:'a.A.regular_method'
        '''
        pass 

现在我们有了源文件,是时候生成实际的 API 文档了:

$ sphinx-apidoc apidoc_example -o docs
Creating file docs/apidoc_example.rst.
Creating file docs/modules.rst. 

仅此还不够,我们需要将 API 添加到toctree中。幸运的是,这就像在index.rst文件中将模块添加到toctree中一样简单,看起来如下所示:

.. toctree::
   :maxdepth: 2

   modules 

在本章的后面部分将更详细地讨论toctree指令。

我们还必须确保模块可以被导入,否则 Sphinx 将无法读取 Python 文件。为了做到这一点,我们只需将父目录(从docs目录的视角看)添加到sys.path;这可以在conf.py文件的任何地方进行:

import os
import sys

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

此外,需要在conf.py中启用autodoc模块:

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

现在是时候再次使用html构建器生成文档了:

$ make html
Running Sphinx v3.2.1
making output directory... done
building [mo]: targets for 0 po files that are out of date
building [html]: targets for 3 source files that are out of date
updating environment: [new config] 3 added, 0 changed, 0 removed
reading sources... [100%] modules
looking for now-outdated files... none found
pickling environment... done
checking consistency... done
preparing documents... done
writing output... [100%] modules
generating indices...  genindex py-modindexdone
writing additional pages...  searchdone
copying static files... ... done
copying extra files... done
dumping search index in English (code: en)... done
dumping object inventory... done
build succeeded.

The HTML pages are in _build/html. 

再次打开docs/_build/index.html文件。为了简洁起见,文档的重复部分将从截图中被省略。裁剪后的输出如下:

图片

图 9.20:查看内容

但实际上它生成了更多内容。当运行 sphinx-apidoc 命令时,它会递归地查看指定目录中的所有 Python 模块,并为每个模块生成一个 rst 文件。在生成所有这些文件之后,它会将它们全部添加到一个名为 modules.rst 的文件中,这使得将它们添加到你的文档中变得很容易。

modules.rst 文件非常直接明了;仅仅是一个以包名为标题的模块列表:

apidoc_example
==============

.. toctree::
   :maxdepth: 4

   apidoc_example 

apidoc_example 页面的输出如下:

图 9.21:apidoc_example 页面

apidoc_example.rst 文件简单地列出了所有在 automodule 指令中记录的模块,以及一些设置:

apidoc\_example package
=======================

Submodules
----------

apidoc\_example.a module
------------------------

.. automodule:: apidoc_example.a
   :members:
   :undoc-members:
   :show-inheritance:

apidoc\_example.b module
------------------------

.. automodule:: apidoc_example.b
   :members:
   :undoc-members:
   :show-inheritance:

Module contents
---------------

.. automodule:: apidoc_example
   :members:
   :undoc-members:
   :show-inheritance: 

但正如你在之前的截图中所见,它不包括隐藏或魔法方法。通过向 automodule 指令添加一些额外的参数,我们可以改变这一点:

apidoc\_example package
=======================

Submodules
----------

apidoc\_example.a module
------------------------

.. automodule:: apidoc_example.a
   :members:
   :undoc-members:
   :show-inheritance:
 `:private-members:`
 `:special-members:`
 `:inherited-members:`

apidoc\_example.b module
------------------------

.. automodule:: apidoc_example.b
   :members:
   :undoc-members:
   :show-inheritance:
 `:private-members:`
 `:special-members:`
 `:inherited-members:`

Module contents
---------------

.. automodule:: apidoc_example
   :members:
   :undoc-members:
   :show-inheritance:
 `:private-members:`
 `:special-members:`
 `:inherited-members:` 

通过这些额外的设置(private-membersspecial-membersinherited-members),我们得到了很多额外且可能有用的文档:

图 9.22:更新后的 apidoc_example 页面

这些设置中有哪些对你有用取决于你的用例,当然。但它展示了我们如何轻松地为类生成完整的文档,几乎不需要任何努力。而且所有如基类和重写方法这样的引用都可以点击访问。

新文件不会自动添加到你的文档中。重新运行 sphinx-apidoc 命令可以添加新文件,但它不会更新现有的文件。尽管可以使用 --force 选项强制覆盖文件,但我建议在现有文件中手动编辑它们。正如我们将在下一节中看到的,在生成文件之后手动修改它们有相当多的原因。

Sphinx 指令

Sphinx 在 reStructuredText 的默认指令之上添加了一些指令,并提供了一个简单的 API 来添加新的指令。其中大部分通常并不需要修改,但正如预期的那样,Sphinx 有相当好的文档,如果你需要了解更多关于它们的信息。

我们已经看到了标签、图像、数学、替换、代码和注释 reST 指令。但也有一些是 Sphinx 特有的指令。其中大部分并不是特别重要,但也许值得一看:www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html

我们已经涵盖了最重要的一个,即 autodoc 模块,它是 automodule 指令所使用的。然而,还有一个需要稍微覆盖一下:toctree 指令。我们之前已经看到过它的使用,但它有几个非常有趣的配置选项,对于大型项目来说非常有用。

toctree 指令是 Sphinx 中最重要的指令之一;它生成目录树。toctree 指令有几个选项,但最重要的可能是 maxdepth,它指定树需要深入到多深。toctree 的顶层需要手动指定要读取的文件,但超出这个范围,文档中的每个级别(章节、段落等)都可以是 toctree 的另一个级别,当然这取决于深度。尽管 maxdepth 选项是可选的,但没有它,所有可用的级别都会显示出来,这通常比所需的要多。在大多数情况下,maxdepth 的 2 是一个很好的默认值,这使得基本示例看起来像这样:

.. toctree::
   :maxdepth: 2 

toctree 中的项目是同一目录下的 .rst 文件,不包括扩展名。这可以包括子目录,在这种情况下,目录之间用 .(点)分隔:

.. toctree::
   :maxdepth: 2

 `module.a`
 `module.b`
 `module.c` 

另一个非常有用的选项是 glob 选项。它告诉 toctree 使用 Python 中的 glob 模块自动添加所有匹配模式的文档。只需添加一个带有 glob 模式的目录,你就可以添加该目录中的所有文件。这使得之前的 toctree 变得非常简单:

.. toctree::
   :maxdepth: 2
   :glob:

   module.* 

如果由于某种原因,文档标题不是你期望的样子,你可以轻松地将标题更改为自定义的标题:

.. toctree::
   :maxdepth: 2

   The A module <module.a> 

Sphinx 作用域

我们已经看到了 Sphinx 指令,它们是独立的块。现在我们将讨论 Sphinx 作用域,它们可以内联使用。一个作用域允许你告诉 Sphinx 如何解析某些输入。这些作用域的例子包括链接、数学、代码和标记。但最重要的是,Sphinx 作用域中的角色可以用于引用其他类,甚至外部项目。在 Sphinx 中,默认的作用域是 Python,所以像 :py:meth: 这样的角色也可以用作 :meth:。这些作用域对于链接到不同的包、模块、类、方法和其他对象非常有用。基本用法很简单。要链接到一个类,使用以下格式:

Spam: :class:'spam.Spam' 

输出如下:

Sphinx 作用域

图 9.23:链接到类

对于几乎任何其他对象,函数、异常、属性等也是如此。Sphinx 文档提供了一个支持的对象列表:www.sphinx-doc.org/domains.html#cross-referencing-python-objects

Sphinx 的一个很好的特性是这些引用可以超出你的项目范围。类似于我们如何在上文中添加到类的链接,使用 :obj:'int' 可以轻松地添加到标准 Python 文档中的 int 对象的引用。在其他的文档集和网站上添加你自己的项目的引用也是以类似的方式进行的。

对于跨项目链接,你需要在 conf.py 中启用 intersphinx 模块:

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

之后,我们需要通过在conf.py中添加intersphinx_mapping来告诉intersphinx它可以在哪里找到其他项目的文档:

intersphinx_mapping = {
    'python': ('https://docs.python.org/', None),
    'sphinx': ('https://www.sphinx-doc.org/', None),
} 

现在我们可以轻松地链接到斯芬克斯主页上的文档:

Link to the intersphinx module: :mod:'sphinx.ext.intersphinx' 

以下是其输出:

Sphinx roles

图 9.24:链接到另一个项目

这链接到www.sphinx-doc.org/en/master/ext/intersphinx.html

现在我们已经知道了如何使用 Sphinx 从我们的代码中生成文档,让我们增强这些文档,使其更加有用。

记录代码

目前 Sphinx 支持三种不同的文档风格:原始的斯芬克斯风格、较新的 NumPy 和谷歌风格。它们之间的主要区别在于风格,但实际上还略有不同。

斯芬克斯风格是通过使用一系列的 reStructuredText 角色开发的,这是一种非常有效的方法,但过度使用时可能会损害可读性。你可能能猜到以下代码的作用,但这并不是最优雅的语法:

:param number: The number of eggs to return
:type number: int 

根据其名称,谷歌风格是由谷歌开发的。目标是拥有一个简单/可读的格式,它既适用于代码文档,也适用于 Sphinx 的可解析性。在我看来,它更接近 reStructuredText 的原始理念,这是一种非常接近你本能地如何进行文档化的格式。以下示例与前面展示的斯芬克斯风格示例具有相同的意义:

Args:
    number (int): The number of eggs to return 

NumPy 风格是专门为 NumPy 项目创建的。NumPy 项目有许多函数,拥有大量的文档,并且通常每个参数都有很多文档。它比谷歌格式稍微冗长一些,但也很容易阅读:

Parameters
----------
number : int
    The number of eggs to return 

随着 Python 3.5 中引入的类型提示注解,至少这些语法的参数类型部分已经变得不那么有用。从 Sphinx 3.0 开始,你可以通过在 Sphinx conf.py中添加以下行来告诉 Sphinx 使用类型提示而不是手动添加类型:

autodoc_typehints = 'description' 

使用斯芬克斯风格记录类

首先,让我们看看传统的风格,即斯芬克斯风格。虽然理解所有参数的含义很容易,但它有点冗长,这多少降低了可读性。尽管如此,其含义立即清晰,并且绝对不是一种不好的使用风格:

class Eggs:
    pass

class Spam(object):
    '''
    The Spam object contains lots of spam
    :param arg: The arg is used for ...
    :type arg: str
    :param '*args': The variable arguments are used for ...
    :param '**kwargs': The keyword arguments are used for ...
    :ivar arg: This is where we store arg
    :vartype arg: str
    '''

    def __init__(self, arg: str, *args, **kwargs):
        self.arg: str = arg

    def eggs(self, number: int, cooked: bool) -> Eggs:
        '''We can't have spam without eggs, so here are the eggs

        :param number: The number of eggs to return
        :type number: int
        :param bool cooked: Should the eggs be cooked?
        :raises: :class:'RuntimeError': Out of eggs

        :returns: A bunch of eggs
        :rtype: Eggs
        '''
        pass 

输出看起来像这样:

图 9.25:斯芬克斯风格文档

这确实是一个非常有用的输出,其中记录了函数、类和参数。更重要的是,类型也得到了记录,从而生成了一个可点击的链接到实际类型。指定类型的额外优势是,许多编辑器理解这些文档,并将根据给定的类型提供自动完成功能。

你可能也注意到,我们既在文档中又通过类型提示指定了变量类型。虽然技术上不是必需的,但它们应用于文档的不同部分。函数本身显示的类型是通过类型提示完成的:eggs(number: int, cooked: bool) -> 13_sphinx_style.EggsParametersReturn type是通过文档中的:type指定的。

为了解释这里实际发生的事情,Sphinx 在 docstrings 中有几个角色,它们提供了关于我们正在记录的内容的提示。

与名称配对的param角色设置了具有该名称的参数的文档。与名称配对的type角色告诉 Sphinx 参数的数据类型。这两个角色都是可选的,如果省略它们,参数将没有任何附加的文档,但param角色对于任何文档的显示总是必需的。仅添加type角色而不添加param角色将不会产生任何输出,所以请注意始终将它们配对。

returns角色在文档方面与param角色类似。虽然param角色记录了一个参数,但returns角色记录了返回的对象。然而,它们略有不同。与param角色不同,returns角色不依赖于rtype角色,反之亦然。它们两者都是独立工作的,这使得可以使用其中一个或两个角色。

如你所期望的,rtype告诉 Sphinx(以及几个编辑器)函数返回的对象类型。然而,随着类型提示的引入,rtype角色几乎变得毫无用处,因为你有一个更容易指定返回类型的方法。

使用 Google 风格记录一个类

Google 风格只是 Sphinx 风格文档的一个更易读的版本。它实际上并不支持更多或更少,但它使用起来非常直观。以下是Spam类的 Google 风格版本:

class Eggs:
    pass

class Spam(object):
    r'''
    The Spam object contains lots of spam
    Args:
        arg: The arg is used for ...
        \*args: The variable arguments are used for ...
        \*\*kwargs: The keyword arguments are used for ...
    Attributes:
        arg: This is where we store arg,
    '''

    def __init__(self, arg: str, *args, **kwargs):
        self.arg: str = arg

    def eggs(self, number: int, cooked: bool) -> Eggs:
        '''We can't have spam without eggs, so here are the eggs

        Args:
            number (int): The number of eggs to return
            cooked (bool): Should the eggs be cooked?

        Raises:
            RuntimeError: Out of eggs

        Returns:
            Eggs: A bunch of eggs
        '''
        pass 

与 Sphinx 风格相比,这种风格对眼睛更友好,并且具有相同数量的可能性。对于较长的参数文档,这不太方便。想象一下number的多行描述会是什么样子。这就是为什么开发了 NumPy 风格,为它的参数提供了大量的文档。

使用 NumPy 风格记录一个类

NumPy 风格旨在拥有大量的文档。说实话,大多数人太懒惰了,所以对于大多数项目来说,它可能不是一个好的选择。如果你计划对你的函数及其所有参数进行广泛的文档记录,NumPy 风格可能是一个不错的选择。它比 Google 风格更冗长,但可读性非常好,尤其是在更详细的文档中。以下是Spam类的 NumPy 版本:

class Eggs:
    pass

class Spam(object):
    r'''
    The Spam object contains lots of spam
    Parameters
    ----------
    arg : str
        The arg is used for ...
    \*args
        The variable arguments are used for ...
    \*\*kwargs
        The keyword arguments are used for ...
    Attributes
    ----------
    arg : str
        This is where we store arg,
    '''

    def __init__(self, arg, *args, **kwargs):
        self.arg = arg

    def eggs(self, number, cooked):
        '''We can't have spam without eggs, so here are the eggs

        Parameters
        ----------
        number : int
            The number of eggs to return
        cooked : bool
            Should the eggs be cooked?

        Raises
        ------
        RuntimeError
            Out of eggs

        Returns
        -------
        Eggs
            A bunch of eggs
        '''
        pass 

虽然 NumPy 风格绝对不是不好的,但它确实非常冗长。仅这个例子就比替代方案长 1.5 倍。因此,对于更长和更详细的文档来说,这是一个非常好的选择,但如果你的计划是无论如何都要有简短的文档,那么就使用 Google 风格吧。

选择哪种风格

对于大多数项目来说,Google 风格是最好的选择,因为它既易读又不过于冗长。如果您计划为每个参数使用大量的文档,那么 NumPy 风格也可能是一个不错的选择。

选择 Sphinx 风格的唯一原因就是历史遗留问题。尽管 Google 风格可能更易读,但一致性更为重要。

练习

为了稍微练习一下 Python 类型提示,将一些此类文档添加到自己的项目中会很好。

一些不太平凡的类型提示示例包括:

  • 字典

  • 嵌套或甚至是递归的类型

  • 为没有类型提示的外部项目生成存根以进行文档化

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。鼓励您提交自己的解决方案,并从他人的替代方案中学习。

摘要

在本章中,您学习了如何使用内置类型和您自己的自定义类型在代码中添加、使用和测试类型提示。您学习了如何编写 Markdown 和 reStructuredText 来文档化您的项目和代码本身。最后,您学习了如何使用 Sphinx 文档生成器为您的项目生成完整的文档。

文档可以极大地帮助项目的普及,而糟糕的文档会扼杀生产力。我认为在库中,没有哪个方面比文档对第三方使用的影响更大。因此,在许多情况下,文档在决定项目使用方面比实际代码质量更为重要。这就是为什么始终尝试保持良好的文档非常重要。在这种情况下,Sphinx 是一个巨大的帮助,因为它使得保持文档与代码同步并更新变得容易得多。没有文档比不正确和/或过时的文档更糟糕。

使用 Sphinx,生成文档非常容易。只需几分钟的时间,您就可以拥有一个带有文档的完整功能网站,或者是一个 PDF、ePub,或者许多其他输出格式之一。现在没有文档真的没有借口了。即使您自己不太使用文档,向您的编辑器提供类型提示也可以大大提高生产力。使您的编辑器更智能始终有助于提高生产力。例如,我添加了类型提示到几个外部项目,仅仅是为了提高我的生产力。

下一章将解释如何在 Python 中测试代码,文档的某些部分将返回那里。使用 doctest,可以将示例代码、文档和测试合并在一起。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第十章:测试和日志记录 – 为 bug 做准备

当编程时,大多数开发者会稍微规划一下,然后立即开始编写代码。毕竟,我们都期望编写无 bug 的代码!不幸的是,我们并不总是能做到。在某个时候,一个错误的假设、误解,或者仅仅是愚蠢的错误是不可避免的。调试(在第十一章“调试 - 解决 bug”中介绍)在某个时候总是需要的,但你可以使用几种方法来防止 bug,或者至少在它们发生时使解决它们变得容易得多。

为了防止 bug 从一开始就出现,测试驱动开发或至少功能/回归/单元测试非常有用。仅标准 Python 安装就提供了几个选项,如 doctestunittesttest 模块。doctest 模块允许你将测试与示例文档结合起来。unittest 模块允许你轻松编写回归测试。test 模块仅用于内部使用,所以除非你打算修改 Python 核心,否则你可能不需要这个模块。

本章我们将讨论的测试模块包括:

  • doctest

  • py.test(以及为什么它比 unittest 更方便)

  • unittest.mock

py.test 模块与 unittest 模块大致具有相同的目的,但使用起来更方便,并且有更多的选项和插件可用。

在学习了如何避免 bug 之后,我们将来看看日志记录,这样我们就可以检查程序中发生了什么以及为什么。Python 中的 logging 模块非常可配置,几乎可以调整到任何使用场景。如果你曾经编写过 Java 代码,你应该对 logging 模块感到非常熟悉,因为其设计在很大程度上基于 log4j 模块,在实现和命名上都非常相似。后者使它成为 Python 中一个有点奇怪的模块,因为它是不遵循 pep8 命名标准的少数模块之一。

本章将解释以下主题:

  • 使用 doctest 将文档与测试结合起来

  • 使用 py.testunittest 进行回归和单元测试

  • 使用 unittest.mock 进行模拟对象测试

  • 使用 tox 测试多个环境

  • 有效使用 logging 模块

  • 结合 loggingpy.test

使用 doctest 将文档作为测试

doctest 模块是 Python 中最有用的模块之一。它允许你将代码文档与测试结合起来,以确保代码按预期工作。

到现在为止,格式应该对你来说非常熟悉;本书中的大多数代码示例都使用 doctest 格式,它提供了输入和输出交织显示的优势。特别是在演示中,这比代码块后面跟着输出要方便得多。

一个简单的 doctest 示例

让我们从一个小例子开始:一个平方输入的函数。以下示例是一个完全功能的命令行应用程序,不仅包含代码,还包含功能测试。前几个测试覆盖了函数在正常执行时的预期行为,然后是一些测试来演示预期的错误:

def square(n: int) -> int:
    '''
    Returns the input number, squared
    >>> square(0)
    0
    >>> square(1)
    1
    >>> square(2)
    4
    >>> square(3)
    9
    >>> square()
    Traceback (most recent call last):
    ...
    TypeError: square() missing 1 required positional argument: 'n'
    >>> square('x')
    Traceback (most recent call last):
    ...
    TypeError: can't multiply sequence by non-int of type 'str'
    Args:
        n (int): The number to square

    Returns:
        int: The squared result
    '''
    return n * n

if __name__ == '__main__':
    import doctest
    doctest.testmod() 

它可以像任何 Python 脚本一样执行,但常规命令不会输出任何内容,因为所有测试都成功了。幸运的是,doctest.testmod函数有可变参数:

$ python3 T_00_simple_doctest.py -v
Trying:
    square(0)
Expecting:
    0
ok
Trying:
    square(1)
Expecting:
    1
ok
Trying:
    square(2)
Expecting:
    4
ok
Trying:
    square(3)
Expecting:
    9
ok
Trying:
    square()
Expecting:
    Traceback (most recent call last):
    ...
    TypeError: square() missing 1 required positional argument: 'n'
ok
Trying:
    square('x')
Expecting:
    Traceback (most recent call last):
    ...
    TypeError: can't multiply sequence by non-int of type 'str'
ok
1 items had no tests:
    __main__
1 items passed all tests:
   6 tests in __main__.square
6 tests in 2 items.
6 passed and 0 failed.
Test passed. 

此外,由于它使用的是 Google 语法(如第九章所述,文档 – 如何使用 Sphinx 和 reStructuredText,文档章节),我们可以使用 Sphinx 生成漂亮的文档:

一个简单的 doctests 示例

图 10.1:使用 Sphinx 生成的文档

然而,代码并不总是正确的,当然。如果我们修改代码使得测试不再通过,会发生什么呢?

这次,我们使用的是n ** 2而不是n * n。两者都平方一个数,所以结果必须相同。对吧?这些就是那些导致错误的假设类型,也是使用一些基本测试很容易捕捉到的假设类型。由于大多数结果都是相同的,我们将在示例中跳过它们,但有一个测试现在有不同的结果:

def square(n: int) -> int:
    '''
    >>> square('x')
    Traceback (most recent call last):
    ...
    TypeError: unsupported operand type(s) for ** or pow(): ...
    '''
    return n ** 2

if __name__ == '__main__':
    import doctest
    doctest.testmod(optionflags=doctest.ELLIPSIS) 

我们对代码做的唯一修改是将n * n替换为n ** 2,这相当于幂函数。由于乘法不等于取一个数的幂,结果略有不同,但在实践中足够相似,以至于大多数程序员不会注意到差异。

然而,由于这种差异,错误从can't multiply sequence ...变成了不支持的操作类型(s) for ** 或 pow(): ...。这是一个无辜的错误,但一个程序员的快速优化可能会无意中将其改变,并可能得到错误的结果。例如,如果__pow__方法被重载为不同的行为,这可能会导致更大的问题。

这个例子向我们展示了这些测试有多么有用。在重写或优化代码时,很容易做出错误的假设,而这就是测试非常有用的地方——当你破坏代码时立即知道,而不是几个月后才发现。

编写 doctests

也许你已经注意到,从前面的例子中,语法非常类似于常规 Python 控制台,这是因为它是。doctest输入不过是常规 Python 外壳会话的输出。这就是使用这个模块进行测试如此直观的原因;只需在 Python 控制台中编写代码,然后将输出复制到文档字符串中即可获得测试。以下是一个示例:

$ python3
>>> from square import square

>>> square(5)
25
>>> square()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: square() missing 1 required positional argument: 'n' 

这就是为什么这可能是测试代码的最简单方式。几乎不需要任何努力,您就可以检查代码是否按预期工作,添加测试,并添加文档。只需将解释器的输出复制到您的函数或类文档中,您就有了一个功能性的 doctests。

使用文档进行测试

函数、类和模块中的文档字符串通常是向代码添加 doctests 的最明显方式,但它们并不是唯一的方式。正如我们在上一章中讨论的,Sphinx 文档也支持 doctest 模块。

要在 Sphinx 中启用 doctest 支持,您需要在 Sphinx 中添加 sphinx.ext.doctest 扩展,这将告诉 Sphinx 也要运行这些测试。由于代码中的并非所有示例都有用,让我们看看我们是否可以将它们分成真正有用的和仅与文档相关的部分。此外,为了查看结果,我们将在文档中添加一个错误。

square.py

def square(n: int) -> int:
    '''
    Returns the input number, squared
    >>> square(2)
    4
    Args:
        n (int): The number to square
    Returns:
        int: The squared result
    '''
    return n * n

if __name__ == '__main__':
    import doctest
    doctest.testmod() 

square.rst

square module
=============

.. automodule:: square
    :members:
    :undoc-members:
    :show-inheritance:

Examples:

.. testsetup::

    from square import square

.. doctest::
    # pytest does not recognize testsetup
    >>> from square import square

    >>> square(100)
    10000
    >>> square(0)
    0
    >>> square(1)
    1
    >>> square(3)
    9
    >>> square()
    Traceback (most recent call last):
    ...
    TypeError: square() missing 1 required positional argument: 'n'

    >>> square('x')
    Traceback (most recent call last):
    ...
    TypeError: can't multiply sequence by non-int of type 'str' 

现在,是时候执行测试了。在 Sphinx 的情况下,有一个特定的命令用于此操作:

$ make doctest
Running Sphinx v3.2.1
loading pickled environment... done
building [mo]: targets for 0 po files that are out of date
building [doctest]: targets for 2 source files that are out of date
updating environment: 0 added, 0 changed, 0 removed
looking for now-outdated files... none found
running tests...

Document: square
----------------
1 items passed all tests:
   8 tests in default
8 tests in 1 items.
8 passed and 0 failed.
Test passed.

Doctest summary
===============
    8 tests
    0 failures in tests
    0 failures in setup code
    0 failures in cleanup code
build succeeded.

Testing of doctests in the sources finished, look at the results in _build/doctest/output.txt. 

如预期,我们得到了一个不完整的 doctest 错误,但除此之外,所有测试都执行正确。为了确保测试知道 square 是什么,我们不得不添加 testsetup 指令,这仍然生成一个相当好的输出:

图 10.2:渲染的 Sphinx 输出

Sphinx 优雅地渲染了代码的文档和突出显示的代码示例。

doctest 标志

doctest 模块具有几个选项标志,这些标志会影响 doctest 处理测试的方式。这些选项标志可以通过测试套件全局传递,在运行测试时通过命令行参数传递,也可以通过内联命令传递。对于本书,我已经通过一个 pytest.ini 文件全局启用了以下选项标志(我们将在本章后面更详细地介绍 py.test):

doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE 

没有这些选项标志,本书中的一些示例可能无法正常工作。这是因为它们必须重新格式化以适应。接下来的几段将介绍以下选项标志:

  • DONT_ACCEPT_TRUE_FOR_1

  • NORMALIZE_WHITESPACE

  • ELLIPSIS

还有其他一些选项标志可用,但它们的效果各有不同,但最好还是查阅 Python 文档:docs.python.org/3/library/doctest.html#option-flags

True 和 False 与 1 和 0

在大多数情况下,将 True 评估为 1 和将 False 评估为 0 是有用的,但如果实际上你期望的是一个 bool 而不是一个 int,则可能会得到意外的结果。为了展示这种差异,我们有以下这些行:

'''
>>> False
0
>>> True
1
'''
if __name__ == '__main__':
    import doctest
    doctest.testmod()
    doctest.testmod(optionflags=doctest.DONT_ACCEPT_TRUE_FOR_1) 

当我们运行这个命令时,它将运行带有和不带有 DONT_ACCEPT_TRUE_FOR_1 标志的测试:

$ python3 T_03_doctest_true_for_1_flag.py -v
Trying:
    False
Expecting:
    0
ok
Trying:
    True
Expecting:
    1
ok
1 items passed all tests:
   2 tests in __main__
2 tests in 1 items.
2 passed and 0 failed.
Test passed.
Trying:
    False
Expecting:
    0
**********************************************************************
File "T_03_doctest_true_for_1_flag.py", line 2, in __main__
Failed example:
    False
Expected:
    0
Got:
    False
Trying:
    True
Expecting:
    1
**********************************************************************
File "T_03_doctest_true_for_1_flag.py", line 4, in __main__
Failed example:
    True
Expected:
    1
Got:
    True
**********************************************************************
1 items had failures:
   2 of   2 in __main__
2 tests in 1 items.
0 passed and 2 failed.
***Test Failed*** 2 failures. 

如您所见,DONT_ACCEPT_TRUE_FOR_1 标志使 doctest 拒绝将 1 作为 True 的有效响应,以及将 0 作为 False 的有效响应。

正规化空白

由于 doctests 既用于文档又用于测试目的,因此保持它们可读性几乎是一个基本要求。然而,如果没有空白字符规范化,这可能会很棘手。考虑以下示例:

>>> [list(range(5)) for i in range(3)]
[[0, 1, 2, 3, 4], [0, 1, 2, 3, 4], [0, 1, 2, 3, 4]] 

虽然不是特别糟糕,但这种输出对于可读性来说并不是最好的。通过空白字符规范化,我们可以这样做:

>>> # doctest: +NORMALIZE_WHITESPACE
... [list(range(5)) for i in range(3)]
[[0, 1, 2, 3, 4],
 [0, 1, 2, 3, 4],
 [0, 1, 2, 3, 4]] 

以这种方式格式化输出既更易于阅读,又便于保持行较短。

省略号

ELLIPSIS标志非常有用,但也有些危险,因为它很容易导致不正确的匹配。它使...匹配任何子字符串,这在异常处理中非常有用,但在其他情况下则很危险:

>>> {10: 'a', 20: 'b'}  # doctest: +ELLIPSIS
{...}
>>> [True, 1, 'a']  # doctest: +ELLIPSIS
[...]
>>> True,  # doctest: +ELLIPSIS
(...)
>>> [1, 2, 3, 4]  # doctest: +ELLIPSIS
[1, ..., 4]
>>> [1, 0, 0, 0, 0, 0, 4]  # doctest: +ELLIPSIS
[1, ..., 4] 

这些情况在现实场景中并不太有用,但它们展示了ELLIPSIS选项标志的功能。它们也指出了危险。[1, 2, 3, 4][1, 0, ... , 4]都与[1, ..., 4]测试匹配,这可能是无意为之,所以使用ELLIPSIS时要非常小心。

一个更有用的例子是在记录类实例时:

>>> class Spam(object):
...     pass

>>> Spam()  # doctest: +ELLIPSIS
<__main__.Spam object at 0x...> 

没有使用ELLIPSIS标志时,内存地址(即0x...部分)永远不会是你期望的那样。让我们在一个正常的 CPython 实例中演示一个实际的运行情况:

Failed example:
    Spam()
Expected:
    <__main__.Spam object at 0x...>
Got:
    <__main__.Spam object at 0x10d9ad160> 

Doctest 怪异之处

之前讨论的三个选项标志处理了 doctests 中发现的许多怪异之处,但还有一些更多的情况需要小心处理。在这些情况下,你只需要稍微小心一些,并绕过doctest模块的限制。doctest模块实际上使用表示字符串,而这些字符串并不总是一致的。

表示字符串可以使用repr(object)生成,并在内部使用__repr__魔法方法。对于没有特定__repr__方法的常规类,这看起来像<module.className instance at 0x....>,其中0x...是对象的内存地址,它会随着每次运行和每个对象而变化。

最重要的情况是浮点数的不精确性和随机值,例如计时器。以下示例中,浮点数示例将返回系统上的一致结果,但在不同的系统上可能会失败。time示例几乎肯定会总是失败:

>>> 1./7.
0.14285714285714285

>>> import time

>>> time.time() - time.time()
-9.5367431640625e-07 

所有这些问题都有几种可能的解决方案,这些解决方案主要在风格和你的个人偏好上有所不同。

测试字典

由于 Python 最近版本中字典的实现已经改变,这个确切的问题你可能不会再遇到。然而,仍然有一些情况下类似的解决方案是有用的。

以前字典的问题在于它们有一个实际上随机的表示顺序。由于doctest系统需要一个与docstring在意义上相同(当然,除了某些doctest标志之外)的表示字符串,这并不适用。自然地,有几种可行的解决方案,每种都有其优缺点。

第一个是使用pprint(美化打印)库以美观和一致的方式格式化字典:

>>> import pprint

>>> data = dict.fromkeys('spam')
>>> pprint.pprint(data)
{'a': None, 'm': None, 'p': None, 's': None} 

由于pprint库在输出之前总是对项目进行排序,这解决了随机表示顺序的问题。然而,它确实需要额外的导入和函数调用,有些人可能希望避免。

另一个选项是手动对项目进行排序:

>>> data = dict.fromkeys('spam')
>>> sorted(data.items())
[('a', None), ('m', None), ('p', None), ('s', None)] 

这里的问题是,输出中并没有显示data是一个字典,这使得输出不太易读。

最后,比较两个包含相同元素的dict也是可行的:

>>> data = dict.fromkeys('spam')
>>> data == {'a': None, 'm': None, 'p': None, 's': None}
True 

当然,这是一个完全可行的解决方案!但True并不是最清晰的输出,尤其是如果比较没有成功的话:

Failed example:
    data == {'a': None, 'm': None, 'p': None}
Expected:
    True
Got:
    False 

另一方面,之前提出的其他选项正确地显示了预期的值和返回的值:

Failed example:
    sorted(data.items())
Expected:
    [('a', None), ('m', None), ('p', None)]
Got:
    [('a', None), ('m', None), ('p', None), ('s', None)]

Failed example:
    pprint.pprint(data)
Expected:
    {'a': None, 'm': None, 'p': None}
Got:
    {'a': None, 'm': None, 'p': None, 's': None} 

个人而言,在所提出的解决方案中,我会推荐使用pprint,因为我发现它是可读性最高的解决方案,但所有解决方案都有其优点。

浮点数测试

同样地,由于浮点数比较可能存在问题(即1/3 == 0.333),表示字符串的比较也可能有问题。最简单的解决方案是四舍五入或截断值,但ELLIPSIS标志在这里也是一个选项。以下是一些解决方案的列表:

>>> 1/3  # doctest: +ELLIPSIS
0.333...
>>> '%.3f' % (1/3)
'0.333'
>>> '{:.3f}'.format(1/3)
'0.333'
>>> round(1/3, 3)
0.333
>>> 0.333 < 1/3 < 0.334
True 

你选择哪种解决方案应该取决于你自己的偏好或与你在工作的项目的一致性。一般来说,我的选择将是全局启用ELLIPSIS选项标志,并选择这个解决方案,因为它在我看来看起来最干净。

时间和持续时间

对于计时,你将遇到的问题与浮点数问题相当相似。在测量代码片段的执行时间时,总会存在一些变化。这就是为什么限制精度是时间相关测试的最简单解决方案。为了实现这一点,我们可以检查两个时间之间的差值是否小于某个特定数值:

>>> import time

>>> a = time.time()
>>> b = time.time()
>>> (b - a) < 0.01
True 

然而,对于timedelta对象来说,情况稍微复杂一些。但在这里,ELLIPSIS标志确实非常有用:

>>> import datetime

>>> a = datetime.datetime.now()
>>> b = datetime.datetime.now()
>>> str(b - a)  # doctest: +ELLIPSIS
'0:00:00.000... 

ELLIPSIS选项标志的替代方案是分别比较timedelta中的天数、小时、分钟和微秒。或者,你可以使用timedelta.total_seconds()timedelta转换为秒,然后进行常规的浮点数比较。

在后面的段落中,我们将看到使用模拟对象解决这些问题的完全稳定的解决方案。然而,对于 doctests 来说,这通常过于冗余。

现在我们已经完成了doctest,是时候继续使用更明确的测试,即py.test

使用 py.test 进行测试

py.test工具使得编写和运行测试变得非常容易。还有一些其他选项,如nose2和捆绑的unittest模块,但py.test库提供了非常好的可用性和活跃开发相结合。在过去,我是一名狂热的nose用户,但后来转而使用py.test,因为在我的经验中,它更容易使用,并且有更好的社区支持。无论如何,nose2仍然是一个不错的选择,如果你已经在使用nosenose2,那么几乎没有理由切换并重写你所有的测试。然而,在编写新项目的测试时,py.test可以更加方便。

现在,我们将使用py.test运行之前讨论过的square.py文件中的 doctests。

当然,首先安装py.test

$ pip3 install pytest pytest-flake8 

我们还安装了pytest-flake8,因为此项目的默认pytest.ini依赖于它。我们将在本章后面讨论它所做的工作以及如何配置它。

现在你可以进行测试运行,让我们尝试一下square.py中的 doctests:

$ py.test --doctest-modules -v square.py
===================== test session starts ======================
collected 2 items

square.py::square.square PASSED [100%]

====================== 1 passed in 0.03s ======================= 

我们可以看到py.test能够为给定的文件找到两个测试:square.square中的测试本身,以及来自pytest-flake8插件的flake8测试,我们将在本章后面看到。

unittest 和 py.test 输出之间的差异

我们在square.py中有 doctests。让我们创建一个新的类cube,并在代码外部创建一组适当的测试。

首先,我们有cube.py的代码,与square.py类似,但减去了 doctests,因为它们大多数情况下都不会工作:

def cube(n: int) -> int:
    '''
    Returns the input number, cubed
    Args:
        n (int): The number to cube
    Returns:
        int: The cubed result
    '''
    return n ** 3 

现在让我们从unittest示例开始,T_09_test_cube.py

import cube
import unittest

class TestCube(unittest.TestCase):
    def test_0(self):
        self.assertEqual(cube.cube(0), 0)

    def test_1(self):
        self.assertEqual(cube.cube(1), 1)

    def test_2(self):
        self.assertEqual(cube.cube(2), 8)

    def test_3(self):
        self.assertEqual(cube.cube(3), 27)

    def test_no_arguments(self):
        with self.assertRaises(TypeError):
            cube.cube()

    def test_exception_str(self):
        with self.assertRaises(TypeError):
            cube.cube('x')

if __name__ == '__main__':
    unittest.main() 

这可以通过执行文件本身来完成:

$ python3 T_09_test_cube.py -v
test_0 (__main__.TestCube) ... ok
test_1 (__main__.TestCube) ... ok
test_2 (__main__.TestCube) ... ok
test_3 (__main__.TestCube) ... ok
test_exception_str (__main__.TestCube) ... ok
test_no_arguments (__main__.TestCube) ... ok

----------------------------------------------------------------
Ran 6 tests in 0.000s

OK 

或者,也可以通过unittest模块来完成:

$ python3 -m unittest -v T_09_test_cube.py
... 

但它也可以与其他工具如py.test一起工作:

$ py.test -v T_09_test_cube.py
===================== test session starts ======================

collected 7 items

T_09_test_cube.py::FLAKE8 SKIPPED                        [ 14%]
T_09_test_cube.py::TestCube::test_0 PASSED               [ 28%]
T_09_test_cube.py::TestCube::test_1 PASSED               [ 42%]
T_09_test_cube.py::TestCube::test_2 PASSED               [ 57%]
T_09_test_cube.py::TestCube::test_3 PASSED               [ 71%]
T_09_test_cube.py::TestCube::test_exception_str PASSED   [ 85%]
T_09_test_cube.py::TestCube::test_no_arguments PASSED    [100%]

================= 6 passed, 1 skipped in 0.08s ================= 

并且其他工具如nose也是可能的。首先,我们需要使用 pip 安装它:

$ pip3 install nose 

之后,我们可以使用nosetests命令来运行:

$ nosetests -v T_09_test_cube.py
test_0 (T_09_test_cube.TestCube) ... ok
test_1 (T_09_test_cube.TestCube) ... ok
test_2 (T_09_test_cube.TestCube) ... ok
test_3 (T_09_test_cube.TestCube) ... ok
test_exception_str (T_09_test_cube.TestCube) ... ok
test_no_arguments (T_09_test_cube.TestCube) ... ok

-------------------------------------------------------------
Ran 6 tests in 0.001s

OK 

只要所有结果都成功,unittestpy.test的输出之间的差异很小。然而,这一次,我们将故意破坏代码,以展示实际重要时的差异。我们将添加square代码,从square返回n ** 2,而不是n ** 3

为了减少输出量,我们不会在这里运行命令的详细版本。

首先,我们有常规的unittest输出:

$ python3 T_09_test_cube.py
..FF..
================================================================
FAIL: test_2 (__main__.TestCube)
----------------------------------------------------------------
Traceback (most recent call last):
  File " T_09_test_cube.py", line 14, in test_2
    self.assertEqual(cube.cube(2), 8)
AssertionError: 4 != 8

================================================================
FAIL: test_3 (__main__.TestCube)
----------------------------------------------------------------
Traceback (most recent call last):
  File " T_09_test_cube.py", line 17, in test_3
    self.assertEqual(cube.cube(3), 27)
AssertionError: 9 != 27

----------------------------------------------------------------
Ran 6 tests in 0.001s

FAILED (failures=2) 

并不是那么糟糕,因为每次测试都会返回一个包含值和一切的漂亮堆栈跟踪。然而,当我们与py.test运行进行比较时,这里可以观察到一些细微的差异:

$ py.test T_09_test_cube.py
===================== test session starts ======================
collected 7 items

T_09_test_cube.py s..FF..                                [100%]

=========================== FAILURES ===========================
_______________________ TestCube.test_2 ________________________

self = <T_09_test_cube.TestCube testMethod=test_2>

    def test_2(self):
>       self.assertEqual(cube.cube(2), 8)
E       AssertionError: 4 != 8

T_09_test_cube.py:14: AssertionError
_______________________ TestCube.test_3 ________________________

self = <T_09_test_cube.TestCube testMeth
od=test_3>

    def test_3(self):
>       self.assertEqual(cube.cube(3), 27)
E       AssertionError: 9 != 27

T_09_test_cube.py:17: AssertionError
=================== short test summary info ====================
FAILED T_09_test_cube.py::TestCube::test_2 - AssertionError: 4..
FAILED T_09_test_cube.py::TestCube::test_3 - AssertionError: 9..
============ 2 failed, 4 passed, 1 skipped in 0.17s ============ 

在这些小案例中,差异并不那么明显,但当测试具有大型堆栈跟踪的复杂代码时,它变得更加有用。然而,对我个人来说,看到周围的测试代码是一个很大的优势。

在刚才讨论的例子中,self.assertEqual(...) 行显示了整个测试,但在许多其他情况下,你需要更多信息。常规的 unittest 模块和 py.test 模块之间的区别在于,使用 py.test 你可以看到整个函数以及所有的代码和输出。在本章的后面部分,我们将看到这在进行更高级的测试时是多么强大。

要真正欣赏 py.test 的输出,我们需要颜色。不幸的是,在这个书的限制下这是不可能的,但我强烈建议如果你还没有使用 py.test,那么尝试一下。

也许你现在在想,“这就是全部吗?py.testunittest 之间的唯一区别就是一点颜色和稍微不同的输出?” 嗯,远不止如此;还有很多其他的区别,但仅此一点就足以让我们尝试一下。

unittest 和 py.test 测试之间的区别

改进的输出确实有所帮助,但改进的输出和编写测试的更简单方式结合起来,使得 py.test 非常有用。有相当多的方法可以使测试更简单、更易读,在许多情况下,你可以选择你喜欢的。像往常一样,可读性很重要,所以明智地选择,并尽量避免过度设计解决方案。

简化断言

unittest 库需要使用 self.assertEqual 来比较变量时,py.test 允许使用常规的 assert 语句,同时仍然理解变量之间的比较。

以下测试文件包含三种测试风格,因此可以轻松比较:

import unittest
import cube

n = 2
expected = 8

# Regular unit test
class TestCube(unittest.TestCase):
    def test_2(self):
        self.assertEqual(cube.cube(n), expected)

    def test_no_arguments(self):
        with self.assertRaises(TypeError):
            cube.cube()

# py.test class
class TestPyCube:
    def test_2(self):
        assert cube.cube(n) == expected

# py.test functions
def test_2():
    assert cube.cube(n) == expected 

要转换为 py.test,我们只需将 self.assertEqual 替换为 assert ... == ...。这确实是一个小的改进,但真正的益处体现在失败输出上。前两个使用的是 unittest 风格,后两个使用的是 py.test 风格,无论是在类内部还是作为单独的函数:

$ py.test T_10_simplifying_assertions.py
...
=========================== FAILURES ===========================
_______________________ TestCube.test_2 ________________________

self = <TestCube testMethod=test_2>

    def test_2(self):
>       self.assertEqual(cube.cube(n), expected)
E       AssertionError: 4 != 8

T_10_simplifying_assertions.py:12: AssertionError
______________________ TestPyCube.test_2 _______________________

self = <TestPyCube object at 0x...>

    def test_2(self):
>       assert cube.cube(n) == expected
E       assert 4 == 8
E        +  where 4 = <function cube at 0x...>(2)
E        +    where <function cube at 0x...> = cube.cube

T_10_simplifying_assertions.py:23: AssertionError
____________________________ test_2 ____________________________

    def test_2():
>       assert cube.cube(n) == expected
E       assert 4 == 8
E        +  where 4 = <function cube at 0x...>(2)
E        +    where <function cube at 0x...> = cube.cube

T_10_simplifying_assertions.py:28: AssertionError
=================== short test summary info ====================
FAILED T_10_simplifying_assertions.py::TestCube::test_2 - Ass...
FAILED T_10_simplifying_assertions.py::TestPyCube::test_2 - a...
FAILED T_10_simplifying_assertions.py::test_2 - assert 4 == 8
============ 3 failed, 1 passed, 1 skipped in 0.15s ============ 

除了可以看到比较的值之外,我们实际上还可以看到被调用的函数以及它接收到的输入参数。使用常规的 unittest,我们无法知道 2 是否被输入为 cube() 函数的参数。

标准的 py.test 行为适用于大多数测试用例,但对于某些自定义类型可能不够。例如,假设我们有一个具有 name 属性的 User 对象,该属性应该与另一个对象的 name 属性进行比较。这部分可以通过在 User 上实现 __eq__ 方法轻松实现,但这并不提高清晰度。由于 name 是我们比较的属性,如果在错误显示时测试显示了 name,那将是有用的。

首先是包含两个测试的类,一个正常工作,一个损坏,以演示常规输出:

T_11_representing_assertions.py

class User:
    def __init__(self, name):
        self.name = name

    def __eq__(self, other):
        return self.name == other.name

def test_user_equal():
    a = User('Rick')
    b = User('Guido')

    assert a == b 

下面是常规的 py.test 输出:

_______________________ test_user_equal ________________________

    def test_user_equal():
        a = User('Rick')
        b = User('Guido')

>       assert a == b
E       assert <T_11_representing_assertions.User object at 0x...> == <T_11_representing_assertions.User object at 0x...>

T_11_representing_assertions.py:13: AssertionError
=================== short test summary info ====================
FAILED T_11_representing_assertions.py::test_user_equal - asse...
================= 1 failed, 1 skipped in 0.17s ================= 

默认测试输出仍然可用,因为函数相当直接,并且由于它在构造函数中可用,name的值是可见的。然而,如果我们能够明确地看到name的值,那将更有用。通过向conftest.py文件添加pytest_assertrepr_compare函数,我们可以修改assert语句的行为。

conftest.py文件是py.test的一个特殊文件,可以用来覆盖或扩展py.test。请注意,此文件将自动由该目录中的每个测试运行加载,因此我们需要测试操作符左右两边的类型。在这种情况下,是ab

conftest.py

from T_12_assert_representation import User

def is_user(value):
    return isinstance(value, User)

def pytest_assertrepr_compare(config, op, left, right):
    if is_user(left) and is_user(right) and op == '==':
        return [
            'Comparing User instances:',
            f'    name: {left.name} != {right.name}',
        ] 

上述函数将被用作我们测试的输出。因此,当它失败时,这次我们得到了我们自己的、稍微更有用的输出:

 def test_user_equal():
        a = User('Rick')
        b = User('Guido')

>       assert a == b
E       assert Comparing User instances:
E             name: Rick != Guido

T_12_assert_representation.py:13: AssertionError 

在这种情况下,我们也可以轻松地更改User__repr__函数,但有许多情况下修改py.test的输出可能很有用——例如,如果你需要更多的调试输出。与此类似,对许多类型有特定的支持,例如集合、字典和文本。

参数化测试

到目前为止,我们已经分别指定了每个测试,但我们可以通过参数化来大大简化测试。平方和立方测试非常相似;一定的输入产生一定的输出。

你可以通过在测试中创建循环来解决此问题,但测试中的循环将被作为一个单独的测试执行。这意味着如果循环的某个测试迭代失败,整个测试将失败,这意味着如果你比较较旧和较新的测试输出,你无法轻易地看到到底出了什么问题。在这个用数字的例子中,结果是明显的,但如果你将文件名列表应用于复杂的处理测试,发生的事情将不那么明显。

在这些情况下,参数化测试可以大有帮助。在创建参数列表和预期输出数据后,你可以为每个参数组合单独运行测试函数:

import cube
import pytest

cubes = (
    (0, 0),
    (1, 1),
    (2, 8),
    (3, 27),
)

@pytest.mark.parametrize('n,expected', cubes)
def test_cube(n, expected):
    assert cube.cube(n) == expected 

如您可能已经预料到的那样,它输出了以下内容:

=========================== FAILURES ===========================
________________________ test_cube[2-8] ________________________

n = 2, expected = 8
    @pytest.mark.parametrize('n,expected', cubes)
    def test_cube(n, expected):
>       assert cube.cube(n) == expected
E       assert 4 == 8
E        +  where 4 = <function cube at 0x...>(2)
E        +    where <function cube at 0x...> = cube.cube

T_13_parameterizing_tests.py:15: AssertionError
_______________________ test_cube[3-27] ________________________

n = 3, expected = 27

    @pytest.mark.parametrize('n,expected', cubes)
    def test_cube(n, expected):
>       assert cube.cube(n) == expected
E       assert 9 == 27
E        +  where 9 = <function cube at 0x...>(3)
E        +    where <function cube at 0x...> = cube.cube

T_13_parameterizing_tests.py:15: AssertionError
=================== short test summary info ====================
FAILED T_13_parameterizing_tests.py::test_cube[2-8] - assert ...
FAILED T_13_parameterizing_tests.py::test_cube[3-27] - assert...
============ 2 failed, 2 passed, 1 skipped in 0.16s ============ 

使用参数化测试,我们可以清楚地看到参数,这意味着我们可以不费任何额外努力地看到所有输入和输出。

在运行时动态生成测试列表也是可能的,使用全局函数。类似于我们之前添加到conftest.py中的pytest_assertrepr_compare函数,我们可以添加一个pytest_generate_tests函数,该函数生成测试。

创建pytest_generate_tests函数可能只对测试配置选项的子集有用。然而,如果可能的话,我建议尝试使用固定装置来配置选择性测试,因为它们相对更明确。我们将在下一节中介绍这一点。pytest_generate_tests等函数的问题在于它们是全局的,并且不会区分特定的测试,如果你没有预料到这种情况,可能会导致奇怪的行为。

使用固定装置的自动参数

py.test固定装置系统是py.test最神奇的功能之一。它神奇地执行与你的参数具有相同名称的固定装置函数。让我们创建一个基本的固定装置来演示这一点:

import pytest

@pytest.fixture
def name():
    return 'Rick'

def test_something(name):
    assert name == 'Rick' 

当执行test_something()测试时,name参数将自动填充name()函数的输出。

由于参数是由固定装置自动填充的,因此参数的命名变得非常重要,因为固定装置很容易与其他固定装置冲突。为了防止冲突,默认情况下将作用域设置为function。然而,classmodulesession也是有效的作用域选项。默认情况下有几个固定装置可用,其中一些你可能会经常使用,而其他的一些可能永远不会使用。可以通过以下命令生成完整的列表:

$ py.test --quiet --fixtures
...
capsys
    enables capturing of writes to sys.stdout/sys.stderr and
    makes captured output available via ''capsys.readouterr()''
    method calls which return a ''(out, err)'' tuple.
...
monkeypatch
    The returned ''monkeypatch'' funcarg provides these helper 
    methods to modify objects, dictionaries or os.environ::

    monkeypatch.setattr(obj, name, value, raising=True)
    monkeypatch.delattr(obj, name, raising=True)
    monkeypatch.setitem(mapping, name, value)
    monkeypatch.delitem(obj, name, raising=True)
    monkeypatch.setenv(name, value, prepend=False)
    monkeypatch.delenv(name, value, raising=True)
    monkeypatch.syspath_prepend(path)
    monkeypatch.chdir(path)
    All modifications will be undone after the requesting
    test function has finished. The ''raising''
    parameter determines if a KeyError or AttributeError
    will be raised if the set/deletion operation has no target.
...
tmpdir
    return a temporary directory path object which is unique to
    each test function invocation, created as a sub directory of
    the base temporary directory. The returned object is a
    'py.path.local'_ path object. 

接下来的几段将演示一些固定装置的使用,而monkeypatch固定装置将在本章后面介绍。

缓存

cache固定装置既简单又有用;有一个get函数和一个set函数,并且cache状态在单独的py.test运行之间保持不变。为了说明如何从cache获取和设置值,请看以下示例:

 def test_cache(cache):
    counter = cache.get('counter', 0) + 1
    assert counter
    cache.set('counter', counter) 

在此情况下,cache.get函数需要默认值(0)。

可以通过--cache-clear命令行参数清除缓存,并且可以通过--cache-show显示所有缓存。内部,cache固定装置使用json模块来编码/解码值,因此任何可 JSON 编码的内容都可以工作。

自定义固定装置

包含的固定装置非常有用,但在大多数项目中,你将想要创建自己的固定装置以使事情更简单。固定装置使得重复需要更频繁的代码变得非常简单。你很可能会想知道这与常规函数、上下文包装器或其他东西有什么不同,但固定装置的特殊之处在于它们自身也可以接受固定装置。所以,如果你的函数需要pytestconfig变量,它可以请求这些变量,而无需修改调用函数。

你可以从任何有用的可重用内容创建固定装置。基本前提很简单:一个带有pytest.fixture装饰器的函数,它返回一个将作为参数传递的值。此外,该函数可以像任何测试一样接受参数和固定装置。

唯一值得注意的变化是 pytest.yield_fixture。这种固定装置变化有一个小小的不同:实际的测试将在 yield 时执行(多个 yield 会导致错误),而函数前后的代码作为设置/清理代码,这对于数据库连接和文件句柄等操作非常有用。一个 fixtureyield_fixture 的基本例子如下:

import pytest

@pytest.yield_fixture
def some_yield_fixture():
    with open(__file__ + '.txt', 'w') as fh:
        # Before the function
        yield fh
        # After the function

@pytest.fixture
def some_regular_fixture():
    # Do something here
    return 'some_value_to_pass_as_parameter'

def some_test(some_yield_fixture, some_regular_fixture):
    some_yield_fixture.write(some_regular_fixture) 

这些固定装置不接受任何参数,只是简单地将一个参数传递给 py.test 函数。一个更有用的例子是设置数据库连接并在事务中执行查询:

import pytest
import sqlite3

@pytest.fixture(params=[':memory:'])
def connection(request):
    return sqlite3.connect(request.param)

@pytest.yield_fixture
def transaction(connection):
    with connection:
        yield connection

def test_insert(transaction):
    transaction.execute('create table test (id integer)')
    transaction.execute('insert into test values (1), (2), (3)') 

首先是 connection() 固定装置,它使用特殊的参数 params。我们不仅可以在 sqlite3 中使用 :memory: 数据库,还可以使用不同的数据库名称或多个名称。这就是为什么 params 是一个列表;测试将为 params 中的每个值执行。

transaction() 固定装置使用 connection() 打开数据库连接,将其 yield 给该固定装置的用户,并在之后进行清理。这可以很容易地省略,并在 transation() 中立即完成,但它节省了一个缩进级别,并在需要时允许你在单个位置进一步自定义连接。

最后,test_insert() 函数使用 transaction() 固定装置在数据库上执行查询。需要注意的是,如果我们向 params 传递了更多的值,这个测试将为每个值执行。

打印语句和日志记录

尽管打印语句通常不是调试代码的最佳方式,我承认这仍然是我的默认调试方法。这意味着当运行和尝试测试时,我会包含许多打印语句。然而,让我们看看当我们用 py.test 尝试时会发生什么。以下是测试代码:

import os
import sys
import logging

def test_print():
    print('Printing to stdout')
    print('Printing to stderr', file=sys.stderr)
    logging.debug('Printing to debug')
    logging.info('Printing to info')
    logging.warning('Printing to warning')
    logging.error('Printing to error')
    # We don't want to display os.environ so hack around it
    fail = 'FAIL' in os.environ
    assert not fail 

以下是实际输出:

$ py.test -v T_15_print_statements_and_logging.py 
T_15_print_statements_and_logging.py::test_print PASSED  [100%]

================= 1 passed, 1 skipped in 0.06s ================= 

那么,我们所有的打印语句和日志记录都被丢弃了吗?实际上并不是这样。在这种情况下,py.test 假设这对您来说并不相关,因此它忽略了输出。但是,如果运行时出现错误呢?

$ FAIL=true py.test -v T_15_print_statements_and_logging.py 
=========================== FAILURES ===========================
__________________________ test_print __________________________

    def test_print():
        print('Printing to stdout')
        print('Printing to stderr', file=sys.stderr)
        logging.debug('Printing to debug')
        logging.info('Printing to info')
        logging.warning('Printing to warning')
        logging.error('Printing to error')
        # We don't want to display os.environ so hack around it
        fail = 'FAIL' in os.environ
>       assert not fail
E       assert not True

T_15_print_statements_and_logging.py:15: AssertionError
--------------------- Captured stdout call ---------------------
Printing to stdout
--------------------- Captured stderr call ---------------------
Printing to stderr
---------------------- Captured log call -----------------------
WARNING  root:T_15_print_statements_and_logging.py:11 Printing t
o warning
ERROR    root:T_15_print_statements_and_logging.py:12 Printing t
o error
=================== short test summary info ====================
FAILED T_15_print_statements_and_logging.py::test_print - ass...
================= 1 failed, 1 skipped in 0.16s ================= 

正如我们所见,当它真正有用时,我们确实会得到 stdoutstderr 输出。此外,现在可以看到 WARNING 级别或更高的日志记录。DEBUGINFO 仍然不可见,但关于这一点,我们将在本章后面的 日志记录 部分了解更多。

然而,使用打印语句进行调试有一个很大的缺点:由于它们写入 stdout,它们可能会迅速破坏你的 doctests。因为 doctest 会查看所有生成的输出,所以你的打印语句将被包括为预期的输出。

插件

py.test最强大的功能之一是插件系统。在py.test中,几乎所有内容都可以使用可用的钩子进行修改;结果是编写插件几乎很简单。实际上,如果你一直在输入,你已经在之前的段落中编写了一些插件而没有意识到。通过将conftest.py打包在不同的包或目录中,它变成了一个py.test插件。我们将在第十八章打包 – 创建你自己的库或应用程序中解释更多关于打包的内容。

通常,你不需要编写自己的插件,因为很可能你寻求的插件已经可用。可以在py.test网站上找到一小部分插件列表,网址为pytest.org/latest/plugins.html,这里有自动生成的插件列表:docs.pytest.org/en/latest/reference/plugin_list.html,以及一个更长且未经编辑的列表(目前超过 8,000 个),可以通过 Python 包索引在pypi.org/search/?q=pytest-找到。

默认情况下,py.test已经覆盖了许多期望的功能,所以你可以很容易地不使用插件,但在我自己编写的包中,我通常默认使用以下列表:

  • pytest-cov

  • pytest-flake8

  • pytest-mypy

通过使用这些插件,维护你项目的代码质量变得容易得多。为了理解为什么,我们将在接下来的段落中更详细地查看这些包。

pytest-cov

使用pytest-cov包,你可以查看你的代码是否被测试覆盖得恰当。内部,它使用coverage包来检测代码被测试的部分。

确保你已经安装了pytest-cov

$ pip3 install pytest-cov 

为了演示原理,我们将检查cube_root函数的覆盖率。

首先,让我们创建一个包含一些有用默认值的.coveragerc文件:

[report]
# The test coverage you require. Keeping to 100% is not easily
# possible for all projects but it's a good default for new projects.
fail_under = 100

# These functions are generally only needed for debugging and/or
# extra safety so we want to ignore them in the coverage
# requirements
exclude_lines =
    # Make it possible to ignore blocks of code
    pragma: no cover

    # Generally only debug code uses this
    def __repr__

    # If a debug setting is set, skip testing
    if self\.debug:
    if settings.DEBUG

    # Don't worry about safety checks and expected errors
    raise AssertionError
    raise NotImplementedError

    # Do not complain about code that will never run
    if 0:
    if __name__ == .__main__.:
    @abc.abstractmethod

[run]
# Make sure we require that all branches of the code are covered. 
# So both the if and the else
branch = True

# No need to require coverage of testing code
omit =
    test_*.py 

由于 Linux 和 Mac 系统隐藏以.开头的文件(例如.coveragerc),GitHub 仓库中的文件名为_coveragerc。要使用该文件,你可以选择复制/重命名它,或者设置COVERAGE_RCFILE环境变量以覆盖文件名。

对于你的项目来说,哪些默认值是好的当然是一个个人决定,但我发现上面的默认值非常有用。但是,请务必仔细阅读这些内容,而不是盲目地复制;也许你想要确保所有的AssertionErrors都被测试,而不是在覆盖率输出中默默地忽略它们。

这里是cube_root.py代码:

 def cube_root(n: int) -> int:
    '''
    Returns the cube root of the input number

    Args:
        n (int): The number to cube root

    Returns:
        int: The cube root result
    '''
    if n >= 0:
        return n ** (1 / 3)
    else:
        raise ValueError('A number larger than 0 was expected') 

以及T_16_test_cube_root.py代码:

import pytest
import cube_root

cubes = (
    (0, 0),
    (1, 1),
    (8, 2),
    (27, 3),
)

@pytest.mark.parametrize('n,expected', cubes)
def test_cube_root(n, expected):
    assert cube_root.cube_root(n) == expected 

现在,让我们看看当我们启用覆盖率时会发生什么:

$ py.test --cov-report=html --cov-report=term-missing \
  --cov=cube_root --cov-branch T_16_test_cube_root.py
Name           Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------
cube_root.py       4      1      2      1    67%   14
Coverage HTML written to dir htmlcov
================= 4 passed, 1 skipped in 0.12s ================= 

这里发生了什么?看起来我们忘记测试代码的某些部分:第 14 行和从第 11 行到第 14 行的分支。这个输出并不那么易于阅读,这就是为什么我们添加了--cov-report=html,以便在htmlcov目录中获得易于阅读的 HTML 输出:

图片

图 10.3:由--cov-report=html 生成的覆盖率报告

完美!所以现在我们知道——我们忘记测试小于0的值了。

黄色线条(第 11 行)表明只有分支的一部分被执行了((n >= 0) == True),而没有执行另一部分((n >= 0) == False)。这种情况出现在if语句、循环和其他至少有一个分支未被覆盖的地方。例如,如果遍历空数组的循环是一个不可能的场景,那么可以通过注释来部分跳过测试:

for i in range(10):  # pragma: no branch 

但既然我们知道问题所在,即缺少对ValueError的测试,让我们添加测试用例:

# Previous test cases omitted
...
def test_cube_root_below_zero():
    with pytest.raises(ValueError):
        cube_root.cube_root(-1) 

然后我们再次运行测试:

$ py.test --cov-report=html --cov-report=term-missing \
  --cov=cube_root --cov-branch T_17_test_cube_root_subzero.py
Name           Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------
cube_root.py       4      0      2      0   100%
Coverage HTML written to dir htmlcov
================= 5 passed, 1 skipped in 0.12s ================= 

完美!现在我们的函数已经达到了 100%的测试覆盖率。至少,在理论上是这样。我可以想到几个其他类型的值,这些值没有被覆盖。所以请记住,100%的测试覆盖率并不能保证代码没有 bug。

但如果我们有一个实际上不需要测试的分支,因为它是有意不实现的呢?如果我们对小于0的值抛出NotImplementedError而不是ValueError,我们也可以在不添加该测试的情况下获得 100%的测试覆盖率。

这是因为我们在.coveragerc文件中将raise NotImplementedError添加到了忽略列表中。即使我们在测试文件中测试NotImplementedError,覆盖率报告仍然会忽略这一行。

pytest-flake8

代码质量测试工具对于使你的代码可读、一致和符合pep8规范非常有用。pytest-flake8插件在运行实际测试之前自动执行这些检查。要安装它,只需执行以下命令:

$ pip3 install pytest-flake8 

我们在本章的早期部分已经安装了pytest-flake8,因为本书中代码的默认配置依赖于它。

现在我们将创建一些糟糕的代码:

import os
def test(a,b):
    return c 

之后,我们可以通过将其添加到pytest.ini或通过以下方式运行py.test来使用pytest-flake8插件进行检查:

$ py.test --flake8 T_18_bad_code.py
=========================== FAILURES ===========================
____________ FLAKE8-check(ignoring W391 E402 F811) _____________
T_18_bad_code.py:1:1: F401 'os' imported but unused
T_18_bad_code.py:2:1: E302 expected 2 blank lines, found 0
T_18_bad_code.py:2:11: E231 missing whitespace after ','
T_18_bad_code.py:3:12: F821 undefined name 'c'

---------------------- Captured log call -----------------------
WARNING  flake8.options.manager:manager.py:207 option --max-complexity: please update from optparse string 'type=' to argparse callable 'type=' -- this will be an error in the future
WARNING  flake8.checker:checker.py:119 The multiprocessing module is not available. Ignoring --jobs arguments. 

pytest-flake8的输出,正如预期的那样,与内部调用的flake8命令的输出非常相似,该命令结合了pyflakespep8命令来测试代码质量。

根据你的情况,你可能会选择在提交到仓库之前进行代码质量测试,或者如果你认为代码质量不是那么重要,你可以按需运行它。毕竟,虽然代码质量考虑因素很重要,但这并不意味着没有它们代码就不能工作,而且一个好的编辑器在输入时就会通知你代码质量问题。

pytest-mypy

pytest-mypy插件运行mypy静态类型检查器,它使用类型提示来检查输入和输出是否符合预期。首先,我们需要使用pip安装它:

$ pip3 install pytest-mypy 

当我们将此应用于我们的cube_root.py文件时,我们就可以看到一个可能出现的错误:

$ py.test --mypy cube_root.py
=========================== FAILURES ===========================
_________________________ cube_root.py _________________________
12: error: Incompatible return value type (got "float", expected
 "int") 

与返回intcube.py文件相反,当传递一个整数时,一个数的立方根不必是整数。虽然8的立方根是2,但4的立方根返回一个大约为1.587的浮点数。

这是一个在没有像mypy这样的工具的情况下容易被忽视的错误。

配置插件

为了确保所有插件都得到执行并对其进行配置,只需将设置添加到pytest.ini文件中。以下示例可以是开发的一个合理默认值,但对于生产版本,您可能需要处理UnusedImport警告。

pytest.ini

[pytest]
python_files =
    your_project_source/*.py
    tests/*.py

addopts =
    --doctest-modules
    --cov your_project_source
    --cov-report term-missing
    --cov-report html
    --flake8
    --mypy

# W391 is the error about blank lines at the end of a file
flake8-ignore =
    *.py W391 

使用pytest.ini中的addopts设置,您可以将选项添加到py.test命令中,就像您在运行时将它们添加到命令中一样。

当调试以找出测试失败的原因时,查看第一个失败的测试可能很有用。py.test模块提供了-x/--exitfirst标志,在第一次失败后停止,以及--maxfail=nn次失败后停止。

此外,--ff/--failed-first选项在首先运行之前失败的测试时很有用。

或者,您可以使用--lf/--last-failed选项来仅运行之前失败的测试。

现在我们已经对py.test的可能性有了很好的理解,是时候继续编写测试了。接下来是使用mock来模拟对象的主题。

模拟对象

在编写测试时,您会发现您不仅正在测试自己的代码,而且还在测试与外部资源的交互,例如硬件、数据库、网络主机、服务器等。其中一些可以安全运行,但某些测试太慢、太危险,甚至无法运行。在这些情况下,模拟对象是您的朋友;它们可以用来模拟任何东西,这样您可以确信您的代码仍然返回预期的结果,而不会受到外部因素的影响。

使用unittest.mock

unittest.mock库提供了两个基本对象,MockMagicMock,以便轻松模拟任何外部资源。Mock对象只是一个通用的模拟对象,而MagicMock基本上相同,但它定义了所有 Python 魔法方法,如__contains____len__。除此之外,它还可以使您的生活更加轻松。这是因为除了手动创建模拟对象之外,还可以直接使用patch装饰器/上下文管理器来修补对象。

以下函数使用 random 返回 TrueFalse,其概率受某种概率分布控制。由于这种函数的随机性,它很难测试,但使用 unittest.mock 就容易多了。使用 unittest.mock,很容易得到可重复的结果:

from unittest import mock
import random

@mock.patch('random.random')
def test_random(mock_random):
    # Specify our mock return value
    mock_random.return_value = 0.1
    # Test for the mock return value
    assert random.random() == 0.1
    assert mock_random.call_count == 1

def test_random_with():
    with mock.patch('random.random') as mock_random:
        mock_random.return_value = 0.1
        assert random.random() == 0.1 

真是太棒了,不是吗?在不修改原始代码的情况下,我们可以确保 random.random() 现在返回 0.1 而不是某个随机数。如果你的代码中有一个 if 语句,它只运行 10% 的时间(if random.random() < 0.1),你现在可以明确测试两种情况会发生什么。

模拟对象的可能性能近无限。它们从在访问时引发异常到模拟整个 API 并在多次调用时返回不同的结果。例如,让我们模拟删除一个文件:

import os
from unittest import mock

def delete_file(filename):
    while os.path.exists(filename):
        os.unlink(filename)

@mock.patch('os.path.exists', side_effect=(True, False, False))
@mock.patch('os.unlink')
def test_delete_file(mock_exists, mock_unlink):
    # First try:
    delete_file('some non-existing file')

    # Second try:
    delete_file('some non-existing file') 

在这个例子中有相当多的魔法!side_effect 参数告诉 mock 按照那个顺序返回那些值,确保第一次调用 os.path.exists 返回 True,其他两次返回 False。没有特定参数的 mock.patch 调用简单地返回一个可调用的对象,它什么都不做,接受任何东西。

使用 py.test monkeypatch

py.test 中的 monkeypatch 对象是一个可以用于模拟的固定装置。虽然在看到 unittest.mock 的可能性之后,它可能看起来没有用,但总的来说,它是有用的。一些功能确实有重叠,但 unittest.mock 专注于控制和记录对象的行为,而 monkeypatch 固定装置则专注于简单和临时的环境变化。以下是一些示例:

  • 使用 monkeypatch.setattrmonkeypatch.delattr 设置和删除属性

  • 使用 monkeypatch.setitemmonkeypatch.delitem 设置和删除字典项

  • 使用 monkeypatch.setenvmonkeypatch.delenv 设置和删除环境变量

  • 使用 monkeypatch.syspath_prepend 在所有其他路径之前插入额外的路径

  • 使用 monkeypatch.chdir 更改目录

要撤销所有修改,只需使用 monkeypatch.undo。当然,在测试函数的末尾,monkeypatch.undo() 将会自动调用。

例如,假设对于某个测试,我们需要从一个不同的目录中工作。使用 mock,你的选择是模拟几乎所有的文件函数,包括 os.path 函数,即使在那种情况下,你也可能忘记一些。所以,在这种情况下,它肯定不是很有用。另一个选择是将整个测试放入一个 try...finally 块中,并在测试代码前后执行 os.chdir。这是一个相当好且安全的方法,但需要额外的工作,所以让我们比较这两种方法:

import os

def test_chdir_monkeypatch(monkeypatch):
    monkeypatch.chdir('/')
    assert os.getcwd() == '/'

def test_chdir():
    original_directory = os.getcwd()
    try:
        os.chdir('/')
        assert os.getcwd() == '/'
    finally:
        os.chdir(original_directory) 

它们实际上做的是同一件事,但一个需要一行代码来临时更改目录,而另一个需要四行,如果将os导入也计算在内的话,则需要五行。当然,所有这些都可以通过几行额外的代码轻松解决,但代码越简单,你犯的错误就越少,代码的可读性也越高。

现在我们知道了如何伪造对象,让我们看看我们如何可以使用tox在多个平台上同时运行我们的测试。

使用 tox 测试多个环境

现在我们已经编写了测试,并且能够为我们自己的环境运行它们,现在是时候确保其他人也能轻松运行测试了。tox可以为所有指定的 Python 版本创建沙盒环境(假设它们已安装),并在需要时自动并行运行它们。这对于测试你的依赖规范是否是最新的特别有用。虽然你可能在本地环境中安装了大量的包,但其他人可能没有那些包。

开始使用 tox

在我们能够做任何事情之前,我们需要安装tox命令。简单的 pip 安装就足够了:

$ pip3 install --upgrade tox 

安装完成后,我们可以通过创建一个tox.ini文件来指定我们想要运行的内容。最简单的方法是使用tox-quickstart,但如果你已经从一个不同的项目中有一个功能正常的tox.ini文件,你可以轻松地复制并修改它:

$ tox-quickstart
Welcome to the tox 3.20.1 quickstart utility.
This utility will ask you a few questions and then generate a simple configuration file to help get you started using tox.
Please enter values for the following settings (just press Enter to accept a default value, if one is given in brackets).

What Python versions do you want to test against?
            [1] py37
            [2] py27, py37
            [3] (All versions) py27, py35, py36, py37, pypy, jython
            [4] Choose each one-by-one
> Enter the number of your choice [3]: 1
What command should be used to test your project? Examples:            
            - pytest
            - python -m unittest discover
            - python setup.py test
            - trial package.module
> Type the command to run your tests [pytest]:
What extra dependencies do your tests have?
default dependencies are: ['pytest']
> Comma-separated list of dependencies: pytest-flake8,pytest-mypy,pytest-cov
Finished: ./tox.ini has been created. For information on this file, see https://tox.readthedocs.io/en/latest/config.html
Execute 'tox' to test your project. 

现在我们已经完成了第一个tox配置。tox-quickstart命令已经创建了一个包含一些合理默认值的tox.ini文件。

当查看tox-quickstart的输出时,你可能会想知道为什么没有列出较新的 Python 版本。原因是,在写作时,Python 版本被硬编码在tox-quickstart命令中。这个问题预计将在不久的将来得到解决,但在任何情况下都不应该是一个大问题,因为版本可以在tox.ini文件中相当容易地更改。

tox.ini 配置文件

默认情况下,tox.ini文件非常基础:

[tox]
envlist = py37

[testenv]
deps =
    pytest-flake8
    pytest-mypy
    pytest-cov
    pytest
commands =
    pytest 

tox.ini文件通常由两种主要类型的部分组成,即toxtestenv部分。

tox部分配置了tox命令本身并指定了如下的选项:

  • envlist:指定要运行的默认环境列表,可以通过运行tox -e <env>来覆盖。

  • requires:指定与tox一起所需的包(和特定版本)。这可以用来指定特定的setuptools版本,以便你的包可以正确安装。

  • skip_missing_interpreters:一个非常实用的功能,允许你测试系统上所有可用的环境,但跳过那些未安装的环境。

testenv部分配置了你的实际环境。其中一些最有用的选项包括:

  • basepython:要运行的 Python 可执行文件,如果你的 Python 二进制文件有一个非标准名称,这很有用;更常见的是,当使用自定义环境名称时更有用。

  • commands:测试时运行的命令,在我们的例子中是pytest

  • install_command:运行以安装包的命令,默认为python -m pip install {opts} {packages}(ARGV)

  • allowlist_externals:允许哪些外部命令,如makermlscd,以便它们可以从包或脚本中运行。

  • changedir:在运行测试之前切换到特定目录;例如,切换到包含测试的目录。

  • deps:要安装的 Python 包,使用pip命令语法。可以通过-rrequirements.txt指定requirements.txt文件。

  • platform:将环境限制为sys.platform的特定值。

  • setenv:设置环境变量,非常有用,可以让测试知道它们是从tox中运行的,例如。

  • skipsdist:启用此标志后,你可以测试普通目录,而不仅仅是可安装的 Python 包。

配置中最有趣的部分是testenv部分前缀。虽然上面的testenv选项可以全局配置所有环境,但你也可以使用如[testenv:my_custom_env]这样的部分,仅适用于你的自定义环境。在这些情况下,你需要指定basepython选项,这样tox就知道要执行什么。

除了单个环境之外,你还可以使用如[testenv:py{27,38}]这样的模式同时配置多个环境,以指定py27py38环境。

对于所有其他选项,也可以使用如py{27,38}这样的扩展,因此要指定整个 Python 环境列表,你可以这样做:

envlist = py27, py3{7,8,9}, docs, coverage, flake8 

此外,tox.ini中的所有选项都允许基于一系列可用变量进行变量插值,例如{envname},也可以基于其他环境中的选项。下一个示例显示了如何从py39环境复制basepython变量:

[testenv:custom_env]
basepython = {[py39]basepython} 

自然地,也可以从环境变量中进行插值:

{env:NAME_OF_ENV_VARIABLE} 

可选默认值:

{env:NAME_OF_ENV_VARIABLE:some default value} 

运行 tox

现在我们已经了解了tox的一些基本配置选项,让我们运行一个简单的测试来展示它有多方便。

首先,我们需要创建一个tox.ini文件来配置tox

[tox]
envlist = py3{8,9}
skipsdist = True

[testenv]
deps =
    pytest
commands =
    pytest test.py 

接下来,我们将创建一个包含 Python 3.9 dict合并操作符的test.py文件:

def test_dict_merge():
    a = dict(a=123)
    b = dict(b=456)
    assert a | b 

现在当运行tox时,它将显示这个语法在 Python 3.8 上失败,在 Python 3.9 上按预期工作:

$ tox
py38 installed: ...
py38 run-test: commands[0] | pytest test.py
===================== test session starts ======================
=========================== FAILURES ===========================
_______________________ test_dict_merge ________________________
    def test_dict_merge():
        a = dict(a=123)
        b = dict(b=456)
>       assert a | b
E       TypeError: unsupported operand type(s) for |: 'dict' and 'dict'
...
ERROR:   py38: commands failed
  py39: commands succeeded 

所有这些都看起来很好——Python 3.8 的一个错误和一个完全工作的 Python 3.9 运行。这就是tox真正有用的地方;你可以轻松地同时测试多个 Python 版本和多个环境,如果你使用tox -p<processes>参数,甚至可以并行测试。而且最好的是,因为它创建了一个完全空的 Python 环境,你还可以测试你的需求规范。

现在我们已经知道了如何在多个 Python 环境中同时运行我们的测试,现在是时候继续本章的最后一个部分——logging了。虽然简单的打印语句在调试时非常有用,但在处理更大的或分布式系统时,它可能不再是最佳选择。这就是logging模块可以帮助你极大地调试问题的地方。

日志记录

Python 的logging模块是那些极其有用但往往很难正确使用的模块之一。结果是人们通常会完全禁用日志记录,并使用print语句代替。虽然这可以理解,但这浪费了 Python 中非常广泛的日志系统。

Python 的logging模块在很大程度上基于 Java 的log4j库,如果你之前写过 Java,可能会很熟悉。这也是我认为logging模块最大的问题之一;Python 不是 Java,由于这个原因,logging模块感觉非常不符合 Python 风格。这并不意味着它是一个糟糕的库,但适应其设计需要一点努力。

logging模块最重要的对象如下:

  • 记录器:实际的日志接口

  • 处理器:处理日志语句并将它们输出

  • 格式化器:将输入数据格式化为字符串

  • 过滤器:允许过滤某些消息

在这些对象中,你可以将日志级别设置为以下默认级别之一:

  • CRITICAL: 50

  • ERROR: 40

  • WARNING: 30

  • INFO: 20

  • DEBUG: 10

  • NOTSET: 0

这些日志级别的数字值。虽然你可以一般忽略它们,但在设置最小级别时,顺序显然很重要。另外,当定义自定义级别时,如果它们具有相同的数字值,你必须覆盖现有级别。

配置

配置日志系统有几种方法,从纯代码到 JSON 文件,甚至远程配置。示例将使用本章后面讨论的logging模块的部分,但这里重要的是配置系统的使用。如果你对logging模块的内部工作不感兴趣,你应该能够仅通过本节中的这一段来理解。

基本日志配置

最基本的日志配置当然是不配置,但这不会给你带来多少有用的输出:

import logging

logging.debug('debug')
logging.info('info')
logging.warning('warning')
logging.error('error')
logging.critical('critical') 

使用默认的日志级别,你将只会看到WARNING及以上:

$ python3 T_23_logging_basic.py
WARNING:root:warning
ERROR:root:error
CRITICAL:root:critical 

配置的一个快速简单的方法是 logging.basicConfig()。如果你只是需要为编写的脚本进行一些快速日志记录,而不需要用于完整的应用程序,我建议使用这个方法。虽然你可以配置几乎任何你希望的东西,但一旦你有一个更复杂的设置,通常有更多方便的选项。我们将在后面的段落中更多地讨论这一点,但首先,我们有 logging.basicConfig(),它创建一个 logging.StreamHandler,并将其添加到根记录器中,配置为将所有输出写入 sys.stderr(标准错误)。请注意,如果根记录器已经有处理程序,则 logging.basicConfig() 函数不会做任何事情(除非 force=True)。

如果根记录器没有配置日志处理程序,则日志函数(debug()info()warning()error()critical())将自动调用 logging.basicConfig() 为你设置记录器。这意味着,如果你的日志语句在 logging.basicConfig() 调用之前,它将被忽略。

为了说明如何使用带有一些自定义设置的 basicConfig()

import logging

log_format = '%(levelname)-8s %(name)-12s %(message)s'

logging.basicConfig(
    filename='debug.log',
    format=log_format,
    level=logging.DEBUG,
)

formatter = logging.Formatter(log_format)
handler = logging.StreamHandler()
handler.setLevel(logging.WARNING)
handler.setFormatter(formatter)
logging.getLogger().addHandler(handler) 

现在我们可以测试代码:

logging.debug('debug')
logging.info('info')
some_logger = logging.getLogger('some')
some_logger.warning('warning')
some_logger.error('error')
other_logger = some_logger.getChild('other')
other_logger.critical('critical') 

这将在我们的屏幕上给出以下输出:

$ python3 T_24_logging_basic_formatted.py
WARNING  some         warning
ERROR    some         error
CRITICAL some.other   critical 

下面是 debug.log 文件中的输出:

DEBUG    root         debug
INFO     root         info
WARNING  some         warning
ERROR    some         error
CRITICAL some.other   critical 

此配置显示了如何使用单独的配置、日志级别以及(如果你选择的话)格式来配置日志输出。然而,它往往变得难以阅读,这就是为什么通常更好的想法是只为不涉及多个处理程序的基本配置使用 basicConfig

字典配置

dictConfig 使得命名所有部分成为可能,以便它们可以轻松重用,例如,为多个记录器和处理程序使用单个格式化程序。让我们使用 dictConfig 重写我们之前的配置:

from logging import config

config.dictConfig({
    'version': 1,
    'formatters': {
        'standard': {
            'format': '%(levelname)-8s %(name)-12s %(message)s',
        },
    },
    'handlers': {
        'file': {
            'filename': 'debug.log',
            'level': 'DEBUG',
            'class': 'logging.FileHandler',
            'formatter': 'standard',
        },
        'stream': {
            'level': 'WARNING',
            'class': 'logging.StreamHandler',
            'formatter': 'standard',
        },
    },
    'loggers': {
        '': {
            'handlers': ['file', 'stream'],
            'level': 'DEBUG',
        },
    },
}) 

你可能已经注意到了与我们之前使用的 logging.basicConfig() 调用的相似之处。它只是 logging 配置的不同语法。

字典配置的好处在于它非常容易扩展和/或覆盖日志配置。例如,如果你想更改所有日志的格式化程序,你可以简单地更改 standard 格式化程序,甚至遍历 handlers

JSON 配置

由于 dictConfig 可以接受任何类型的字典,因此实现使用 JSON 或 YAML 文件的不同类型的读取器实际上非常简单。这对于它们对非 Python 程序员来说更加友好。与 Python 文件相比,它们可以从 Python 之外轻松地读取和写入。

假设我们有一个 T_26_logging_json_config.json 文件,如下所示:

{
    "version": 1,
    "formatters": {
        "standard": {
            "format": "%(levelname)-8s %(name)-12s %(message)s"
        }
    },
    "handlers": {
        "file": {
            "filename": "debug.log",
            "level": "DEBUG",
            "class": "logging.FileHandler",
            "formatter": "standard"
        },
        "stream": {
            "level": "WARNING",
            "class": "logging.StreamHandler",
            "formatter": "standard"
        }
    },
    "loggers": {
        "": {
            "handlers": ["file", "stream"],
            "level": "DEBUG"
        }
    }
} 

我们可以简单地使用以下代码来读取配置:

import json
from logging import config

with open('T_26_logging_json_config.json') as fh:
    config.dictConfig(json.load(fh)) 

自然,你可以使用任何可以生成 dict 的来源,但请注意来源。由于 logging 模块将导入指定的类,因此可能存在潜在的安全风险。

ini 文件配置

文件配置可能是非程序员最易读的格式。它使用 ini-style 配置格式,并在内部使用 configparser 模块。缺点是它可能有点冗长,但足够清晰,并且使得在不担心覆盖其他配置的情况下合并多个配置文件变得容易。话虽如此,如果 dictConfig 是一个选项,那么它可能是一个更好的选择。这是因为 fileConfig 在某些时候略显有限且有些笨拙。以处理器为例:

[formatters]
keys=standard

[handlers]
keys=file,stream

[loggers]
keys=root

[formatter_standard]
format=%(levelname)-8s %(name)-12s %(message)s

[handler_file]
level=DEBUG
class=FileHandler
formatter=standard
args=('debug.log',)

[handler_stream]
level=WARNING
class=StreamHandler
formatter=standard
args=(sys.stderr,)

[logger_root]
handlers=file,stream
level=DEBUG 

读取文件却非常简单:

from logging import config

config.fileConfig('T_27_logging_ini_config.ini') 

然而,有一点需要注意,如果您仔细观察,您会看到这个配置与其他配置略有不同。使用 fileConfig,您不能仅使用关键字参数。对于 FileHandlerStreamHandler,都需要 args 参数。

网络配置

网络配置是一种很少使用但非常方便的方式,可以在多个进程中配置您的日志记录器。这种配置相当晦涩,如果您不需要这种设置,请随意跳转到 日志记录器 部分。

网络配置的主要缺点是它可能很危险,因为它允许在您的应用程序/脚本仍在运行时动态配置您的日志记录器。危险的部分在于配置是通过使用 eval 函数(部分)读取的,这允许人们远程在您的应用程序中执行代码。尽管 logging.config.listen 只监听本地连接,但如果您在共享/不安全的主机上执行代码,其他人也可以运行代码,这仍然可能很危险。

如果您的系统不安全,您可以将 verify 作为可调用参数传递给 listen(),这可以实现在配置评估之前对配置进行签名验证或加密。默认情况下,verify 函数类似于 lambda config: config。作为最简单的验证方法,您可以使用类似以下的内容:

def verify(config):
    if config.pop('secret', None) != 'some secret':
        raise RuntimeError('Access denied')
    return config 

为了展示网络配置的工作原理,我们需要两个脚本。一个脚本将不断向日志记录器打印几条消息,另一个将更改日志配置。我们将从之前相同的测试代码开始,但将其保持在一个带有 sleep 的无限循环中运行:

import sys

def receive():
    import time
    import logging
    from logging import config

    listener = config.listen()
    listener.start()

    try:
        while True:
            logging.debug('debug')
            logging.info('info')
            some_logger = logging.getLogger('some')
            some_logger.warning('warning')
            some_logger.error('error')
            other_logger = some_logger.getChild('other')
            other_logger.critical('critical')

            time.sleep(5)

    except KeyboardInterrupt:
        # Stop listening and finish the listening thread
        config.stopListening()
        listener.join()

def send():
    import os
    import struct
    import socket
    from logging import config

    ini_filename = os.path.splitext(__file__)[0] + '.ini'
    with open(ini_filename, 'rb') as fh:
        data = fh.read()

    # Open the socket
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # Connect to the server
    sock.connect(('127.0.0.1',
                  config.DEFAULT_LOGGING_CONFIG_PORT))
    # Send the magic logging packet
    sock.send(struct.pack('>L', len(data)))
    # Send the config
    sock.send(data)
    # And close the connection again
    sock.close()

if __name__ == '__main__':
    if sys.argv[-1] == 'send':
        send()
    elif sys.argv[-1] == 'receive':
        receive()
    else:
        print(f'Usage: {sys.argv[0]} [send/receive]') 

现在,我们需要同时运行这两个脚本。首先,我们启动 receive 脚本,它将开始输出数据:

$ python3 T_28_logging_network_config.py receive
WARNING:some:warning
ERROR:some:error
CRITICAL:some.other:critical
# The send command was run here
WARNING  some         warning
ERROR    some         error
CRITICAL some.other   critical 

同时,我们运行 send 命令:

$ python3 T_28_logging_network_config.py send 

正如您所看到的,在代码仍在运行时,日志配置已被更新。这对于需要调试但不想重新启动的长运行脚本非常有用。

除了屏幕上的输出外,额外的输出被发送到 debug.log 文件,现在看起来像这样:

DEBUG    root         debug
INFO     root         info
WARNING  some         warning
ERROR    some         error
CRITICAL some.other   critical 

这允许您将大部分无用的调试输出重定向到单独的日志文件,同时仍然在屏幕上保留最重要的消息。

记录器

您将始终与 logging 模块一起使用的主体对象是 Logger 对象。此对象包含您将需要用于实际日志记录的所有 API。大多数都很简单,但有些需要注意。

首先,记录器默认继承父设置。正如我们之前看到的,通过 propagate 设置,默认情况下,所有设置都会从父级传播。当在文件中包含记录器时,这非常有用。

假设您的模块使用合理的名称和导入路径,我建议以下命名记录器的风格:

import logging

logger = logging.getLogger(__name__)

class MyClass(object):
    def __init__(self, count):
        self.logger = logger.getChild(self.__class__.__name__) 

使用这种风格,您的记录器将获得如 main_module.sub_module.ClassName 这样的名称。这不仅使您的日志更容易阅读,而且可以轻松地通过日志设置的传播来按模块启用或禁用日志。要创建一个记录 main_module.sub_module 中所有内容的新的日志文件,我们可以简单地这样做:

import logging

logger = logging.getLogger('main_module.sub_module')
logger.addHandler(logging.FileHandler('sub_module.log')) 

或者,当然,您也可以使用您选择的配置选项来配置它。相关的一点是,使用子记录器,您可以对您的记录器有非常细粒度的控制。

这包括增加日志级别:

import logging

logger = logging.getLogger('main_module.sub_module')
logger.setLevel(logging.DEBUG) 

用法

Logger 对象的使用基本上与裸 logging 模块相同,但 Logger 实际上支持更多。这是因为裸 logging 模块只是调用根记录器的函数。Logger 对象有几个非常有用的属性,尽管这些属性中的大多数在库中未记录:

  • propagate:是否将事件传递给此记录器或父记录器的处理程序。如果没有这个设置,对 main_module.sub_module 的日志消息将不会被 main_module 记录。

    • handle 方法会持续寻找父处理程序,只要这些记录器将 propagate 设置为 true,这是默认值。
  • filters:这些是附加到记录器的过滤器。它们可以通过 addFilterremoveFilter 设置。要查看消息是否会过滤,可以使用 filter 方法。

  • disabled:通过设置此属性,可以禁用特定的记录器。常规 API 只允许禁用低于一定级别的所有记录器。这提供了一些细粒度的控制。

  • handlers:这些是附加到记录器的处理程序。它们可以通过 addHandlerremoveHandler 添加。任何(继承的)处理程序的存在可以通过 hasHandlers 函数检查。

  • level:这实际上是一个内部属性,因为它只有一个数值而没有名称。但除此之外,它不考虑继承,因此最好避免使用该属性,而使用 getEffectiveLevel 函数代替。例如,要检查是否为 DEBUG 设置启用设置,您可以直接这样做 logger.isEnabledFor(logging.DEBUG)。当然,可以通过 setLevel 函数设置属性。

  • name:正如这个属性的名称所暗示的,它当然对您的参考非常有用。

现在你已经了解了属性,是时候讨论日志函数本身了。你将最常使用的函数是 logdebuginfowarningerrorcritical 日志函数。它们可以非常简单地使用,但它们也支持字符串格式化,这非常有用:

import logging

logger = logging.getLogger()
exception = 'Oops...'
logger.error('Some horrible error: %r', exception) 

格式化

当看到前面的示例时,你可能想知道为什么我们使用 logger.error('error: %r', error) 而不是使用 f-strings、%string.format 的常规字符串格式化。原因是当使用参数而不是预格式化的字符串时,处理器将它们作为参数接收。结果是你可以通过原始字符串对日志消息进行分组,这正是像 Sentry (github.com/getsentry/sentry) 这样的工具所使用的。

然而,这还远不止这些。在参数方面,*args 仅用于字符串格式化,但使用 extra 关键字参数可以向日志对象添加额外的参数:

import logging

logger = logging.getLogger()
logger.error('simple error', extra=dict(some_variable='my value')) 

这些 extra 参数可以在日志格式化器中使用,以显示额外信息,就像标准格式化选项一样:

import logging

logging.basicConfig(format='%(some_variable)s: %(message)s')
logger = logging.getLogger()
logger.error('the message', extra=dict(some_variable='my value')) 

这会导致以下结果:

$ python3 T_30_formatting.py
simple error
my value: the message 

然而,最有用的功能之一是异常的支持:

import logging

logging.basicConfig()
logger = logging.getLogger()

try:
    raise RuntimeError('some runtime error')
except Exception as exception:
    logger.exception('Got an exception: %s', exception)

logger.error('And an error') 

这会导致异常的堆栈跟踪,但不会终止代码:

$ python3 T_31_exception.py
ERROR:root:Got an exception: some runtime error
Traceback (most recent call last):
  File "T_31_exception.py", line 7, in <module>
    raise RuntimeError('some runtime error')
RuntimeError: some runtime error
ERROR:root:And an error 

使用 f-strings 和 str.format 的现代格式化

Python 的 logging 模块仍然主要基于“旧”的格式化语法,并且对 str.format 的支持不多。对于 Formatter 本身,你可以轻松地使用新式格式化,但最终这大多是无用的,因为你很少修改 Formatter,而主要需要在记录消息时进行格式化。

不论如何,语法足够简单,可以启用:

import logging

formatter = logging.Formatter('{levelname} {message}', style='{')
handler = logging.StreamHandler()
handler.setFormatter(formatter)

logging.error('formatted message?') 

这会导致:

$ python3 T_32_str_format.py
ERROR:root:formatted message? 

对于需要格式化的实际消息,我们则需要自己实现,然而。一个日志适配器是最简单的解决方案:

import logging

class FormattingMessage:
    def __init__(self, message, kwargs):
        self.message = message
        self.kwargs = kwargs

    def __str__(self):
        return self.message.format(**self.kwargs)

class FormattingAdapter(logging.LoggerAdapter):
    def process(self, msg, kwargs):
        msg, kwargs = super().process(msg, kwargs)
        return FormattingMessage(msg, kwargs), dict()

logger = FormattingAdapter(logging.root, dict())
logger.error('Hi {name}', name='Rick') 

当执行代码时,这会产生以下输出:

$ python3 T_33_logging_format.py
Hi Rick 

在我看来,这个解决方案仍然不太美观,但它有效。因为 logging 模块中无法轻松覆盖日志消息的格式化,我们创建了一个单独的 FormattingMessage,它在调用 str(message) 时会自动进行格式化。这样我们就可以通过简单的 logging.LoggerAdapter 来覆盖格式化,而无需替换 logging 库的大部分内容。

请注意,如果你想要将 kwargs 的值发送到像 Sentry 这样的日志记录器,你需要确保操作顺序正确,因为此方法无法传递 kwargs,否则标准日志格式化器会报错。

此外,你可能想知道为什么我们在 process() 方法中使用 FormattingMessage 而不是运行 msg.format(**kwargs)。原因是我们希望尽可能避免字符串格式化。

如果日志记录器没有活动处理程序或处理程序忽略此级别的消息,这意味着我们做了无用功。根据实现方式,字符串格式化可能是一个非常耗时的操作,而 logging 系统旨在尽可能轻量,直到启用。

日志陷阱

日志传播是 logging 模块最有用的功能之一,也是最大的问题。我们已经看到了日志设置是如何从父日志记录器继承的,但如果你覆盖了它们怎么办?好吧,让我们来看看:

import logging

a = logging.getLogger('a')
ab = logging.getLogger('a.b')

ab.error('before setting level')
a.setLevel(logging.CRITICAL)
ab.error('after setting level') 

当我们运行此代码时,我们得到以下输出:

$ python3 T_34_logging_pitfalls.py
before setting level 

在这种情况下很明显,a.setLevel(...) 导致了问题,但如果这种情况发生在你不知道的某些外部代码中,你可能会长时间寻找。

反过来也可以发生;日志记录器上的显式级别会忽略你的父级级别:

import logging

a = logging.getLogger('a')
ab = logging.getLogger('a.b')

ab.setLevel(logging.ERROR)
ab.error('before setting level')
a.setLevel(logging.CRITICAL)
ab.error('after setting level') 

当我们执行此操作时,我们注意到设置级别被完全忽略:

$ python3 T_35_logging_propagate_pitfalls.py
before setting level
after setting level 

再次强调,在这种情况下没有问题,但如果这种情况发生在你不知道的某些外部库中,它肯定会引起头痛。

调试日志记录器

关于日志记录器最重要的规则是,除非你覆盖它们,否则它们会继承父日志记录器的设置。如果你的日志记录没有按预期工作,大多数情况下是由于一些继承问题造成的,这可能很难调试。

根据 Python 手册,日志流程看起来像这样:

../_images/logging_flow.png

图 10.4:日志流程。版权所有 © 2001-2021 Python 软件基金会;保留所有权利

现在我们知道了日志流程应该如何进行,我们可以开始创建一个方法来显示当前的日志记录器结构和设置:

import logging

def get_handlers(logger):
    handlers = []
    # Walk through the loggers and their parents recursively to
    # fetch the handlers
    while logger:
        handlers += logger.handlers

        if logger.propagate:
            logger = logger.parent
        else:
            break

    # Python has a lastResort handler in case no handlers are
    # defined
    if not handlers and logging.lastResort:
        handlers.append(logging.lastResort)

    return handlers

def debug_loggers():
    logger: logging.Logger
    for name, logger in logging.root.manager.loggerDict.items():
        # Placeholders are loggers without settings
        if isinstance(logger, logging.PlaceHolder):
            print('skipping', name)
            continue

        level = logging.getLevelName(logger.getEffectiveLevel())
        handlers = get_handlers(logger)
        print(f'{name}@{level}: {handlers}')

if __name__ == '__main__':
    a = logging.getLogger('a')
    a.setLevel(logging.INFO)

    handler = logging.StreamHandler()
    handler.setLevel(logging.INFO)
    ab = logging.getLogger('a.b')
    ab.setLevel(logging.DEBUG)
    ab.addHandler(handler)

    debug_loggers() 

get_handlers() 函数递归地遍历日志记录器和其所有父级以收集所有传播的处理程序。debug_loggers() 函数遍历 logging 模块的内部配置以列出所有配置的日志记录器并通过 get_handlers() 获取匹配的处理程序。

这当然只是一个基本的调试函数,但它真的可以帮助你在想知道为什么你的日志没有按预期工作时。输出看起来像这样:

$ python3 T_36_logger_debugging.py
a@INFO: [<_StderrHandler <stderr> (WARNING)>]
a.b@DEBUG: [<StreamHandler <stderr> (INFO)>] 

现在我们可以看到,a 日志记录器具有 INFO 级别,但只有一个 WARNING 级别的处理程序。所以,我们的所有 INFO 消息都不会显示。同样,a.b 日志记录器具有 DEBUG 级别,但处理程序在 INFO 级别,所以它只会显示 INFO 和更高级别的消息。

练习

现在你已经看到了几个测试和日志选项,是时候自己尝试一下了。

一些挑战:

  • 创建一个函数来测试给定函数/类的 doctests。

  • 为了更大的挑战,创建一个函数,该函数递归地测试给定模块中每个函数和类的所有 doctests。

  • 创建一个 py.test 插件来检查所有测试文件是否具有文件级别的文档。提示:使用 pytest_collect_file

  • 创建一个自定义的 tox 环境,以在项目中运行 flake8mypy

  • 创建一个 LoggerAdapter,它可以将多个消息基于某个任务 ID 合并成一条消息。这在调试长时间运行的任务时非常有用。

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。鼓励你提交自己的解决方案,并从他人的解决方案中学习。

摘要

本章展示了如何编写 doctests,利用 py.test 提供的快捷方式,以及使用 logging 模块。在测试中,没有一种适合所有情况的解决方案。虽然 doctest 系统在许多情况下对于同时提供文档和测试非常有用,但在许多函数中,有些边缘情况对于文档来说并不重要,但仍需要测试。这就是常规单元测试介入的地方,而 py.test 在这里非常有帮助。

我们也看到了如何使用 tox 在多个沙盒环境中运行测试。如果你有一个项目也需要在不同的计算机上或在不同的 Python 版本上运行,我强烈建议你使用它。

当配置正确时,logging 模块非常有用,如果你的项目变得相对较大,那么快速配置它就变得非常有用。现在,日志系统的使用应该足够清晰,足以应对大多数常见用例,只要你能控制好 propagate 参数,在实现日志系统时你应该不会有问题。

接下来是调试,测试有助于防止错误。我们将看到如何有效地解决这些问题。此外,本章中添加的日志记录将在该领域发挥很大作用。

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第十一章:调试 – 解决错误

上一章向您展示了如何将日志和测试添加到您的代码中,但无论您有多少测试,您总会遇到错误。最大的问题将是外部变量,如用户输入和不同的环境。迟早,我们需要调试我们的代码,或者更糟糕的是,别人的代码。

有许多调试技术,并且肯定您已经使用了一些。在本章中,我们将重点关注打印/跟踪调试和交互式调试。

使用打印语句、堆栈跟踪和日志记录进行调试是工作中最灵活的方法之一,这很可能是你第一次使用的调试类型。甚至一个print('Hello world')也可以被认为是这种类型,因为输出会显示你的代码正在正确执行。显然,解释如何以及在哪里放置打印语句来调试代码是没有意义的,但使用装饰器和其他 Python 模块有很多不错的技巧,可以使这种类型的调试变得更有用,例如faulthandler

交互式调试是一种更复杂的调试方法。它允许你在程序仍在运行时进行调试。使用这种方法,甚至可以在应用程序运行时更改变量,并在任何想要的点上暂停应用程序。缺点是它需要一些关于调试器命令的知识才能真正有用。

总结一下,我们将涵盖以下主题:

  • 使用printtraceloggingfaulthandler的非交互式调试

  • 使用pdbipythonjupyter和其他调试器和调试服务进行交互式调试

非交互式调试

调试的最基本形式是在你的代码中添加一个简单的打印语句,以查看哪些还在工作,哪些没有。这在各种情况下都很有用,并且很可能会帮助你解决大多数问题。

在本章的后面部分,我们将展示一些交互式调试方法,但它们并不总是合适的。交互式调试在以下情况下往往变得困难或甚至不可能:

  • 多线程环境

  • 多个服务器

  • 难以(或需要很长时间)复现的错误

  • 关闭的远程服务器,如 Google App Engine 或 Heroku

交互式和非交互式调试方法都有其优点,但我在 90%的情况下会选择非交互式调试,因为简单的打印/日志语句通常足以分析问题的原因。我发现交互式调试在编写使用大型和复杂外部库的代码时非常有帮助,在这些情况下,很难分析对象有哪些属性、属性和方法可用。

一个基本的示例(我做过类似的事情)可以使用生成器如下:

>>> def hiding_generator():
...     print('a')
...     yield 'first value'
...     print('b')
...     yield 'second value'
...     print('c')

>>> generator = hiding_generator()

>>> next(generator)
a
'first value'

>>> next(generator)
b
'second value'

>>> next(generator)
Traceback (most recent call last):
...
StopIteration 

这显示了代码的确切执行位置,以及没有执行的位置。如果没有这个例子,你可能预期第一个 print 会立即跟在 hiding_generator() 调用之后。然而,由于它是一个生成器,所以直到我们 yield 一个项目之前,什么都不会执行。假设你有一些在第一个 yield 之前的设置代码,它不会运行,直到 next 实际被调用。此外,print('c') 永远不会被执行,可以被认为是不可达的代码。

虽然这是使用 print 调用来调试函数的一种简单方法,但并不总是最方便的方法。我们可以先创建一个自动打印函数,它会打印它将要执行的代码行:

>>> import os
>>> import inspect
>>> import linecache

>>> def print_code():
...     while True:
...         info = inspect.stack()[1]
...         lineno = info.lineno + 1
...         function = info.function
...         # Fetch the next line of code
...         code = linecache.getline(info.filename, lineno)
...         print(f'{lineno:03d} {function}: {code.strip()}')
...         yield

# Always prime the generator
>>> print_code = print_code()

>>> def some_test_function(a, b):
...     next(print_code)
...     c = a + b
...     next(print_code)
...     return c

>>> some_test_function('a', 'b')
003 some_test_function: c = a + b
005 some_test_function: return c
'ab' 

如你所见,它会自动打印行号、函数名称以及它将要执行的下一行代码。这样,如果你有一段运行缓慢的代码,你可以看到是哪一行导致的延迟,因为它会在执行前被打印出来。

在这个特定实例中,生成器并没有真正的用途,但你可以轻松地加入一些计时,以便你可以看到两个 next(print_code) 语句之间的延迟。或者也许是一个计数器,用来查看这段特定的代码运行了多少次。

使用跟踪来检查你的脚本

简单的打印语句在许多情况下都很有用,因为你可以轻松地将打印语句集成到几乎每一个应用中。它无关乎是远程还是本地,线程还是使用多进程。它几乎在所有地方都能工作,使其成为最通用的解决方案——除了日志记录之外。然而,通用的解决方案通常并不是每个情况下最好的解决方案。我们之前函数的一个很好的替代方案是 trace 模块。它为你提供了一种方法来跟踪每一条执行的代码,包括运行时间。但是,跟踪这么多数据的缺点是它可能会很快变得过于冗长,正如我们将在下一个例子中看到的那样。

为了演示,我们将使用我们之前的代码,但不包含打印语句:

def some_test_function(a, b):
    c = a + b
    return c

print(some_test_function('a', 'b')) 

现在我们使用 trace 模块来执行代码:

$ python3 -m trace --trace --timing T_01_trace.py
 --- modulename: T_01_trace, funcname: <module>
0.00 T_01_trace.py(1): def some_test_function(a, b):
0.00 T_01_trace.py(6): print(some_test_function('a', 'b'))
 --- modulename: T_01_trace, funcname: some_test_function
0.00 T_01_trace.py(2):     c = a + b
0.00 T_01_trace.py(3):     return c
ab 

trace 模块会显示确切执行了哪一行,包括函数名称,更重要的是,哪一行是由哪个(或哪些)语句引起的。此外,它还会显示相对于程序开始时间的执行时间。这是由于 --timing 标志。

从输出的角度来看,这似乎是相当合理的,对吧?在这个例子中,它之所以合理,是因为这可能是最基础的代码了。一旦你添加了一个 import,例如,你的屏幕就会被输出信息淹没。尽管你可以通过使用命令行参数来选择性地忽略特定的模块和目录,但在许多情况下,它仍然过于冗长。

我们也可以通过一点努力有选择性地启用 trace 模块:

import sys
import trace as trace_module
import contextlib

@contextlib.contextmanager
def trace(count=False, trace=True, timing=True):
    tracer = trace_module.Trace(
        count=count, trace=trace, timing=timing)
    sys.settrace(tracer.globaltrace)
    yield tracer
    sys.settrace(None)

    result = tracer.results()
    result.write_results(show_missing=False, summary=True)

def some_test_function(a, b):
    c = a + b
    return c

with trace():
    print(some_test_function('a', 'b')) 

此代码展示了如何通过临时启用和禁用trace模块来选择性地跟踪代码的上下文管理器。在这个例子中,我们使用了sys.settrace并将tracer.globaltrace作为参数,但您也可以连接到自己的跟踪函数来自定义输出。

当执行此操作时,我们得到以下输出:

$ python3 T_02_selective_trace.py
 --- modulename: T_02_selective_trace, funcname: some_test_function
0.00 T_02_selective_trace.py(19):     c = a + b
0.00 T_02_selective_trace.py(20):     return c
ab
 --- modulename: contextlib, funcname: __exit__
0.00 contextlib.py(122):         if type is None:
0.00 contextlib.py(123):             try:
0.00 contextlib.py(124):                 next(self.gen)
 --- modulename: T_02_selective_trace, funcname: trace
0.00 T_02_selective_trace.py(12):     sys.settrace(None) 

现在,为了说明,如果我们启用跟踪模块运行相同的代码,我们会得到大量的输出:

$ python3 -m trace --trace --timing T_02_selective_trace.py | wc
    256    2940   39984 

wc(单词计数)命令显示此命令给出了252行,2881个单词,或38716个字符的输出,因此我通常会建议使用上下文装饰器。对任何合理大小的脚本执行跟踪将生成大量的输出。

trace模块有一些额外的选项,例如显示哪些代码被执行(或未执行),这有助于检测代码覆盖率。

除了我们已传递给trace的参数外,我们还可以通过包装或替换sys.settrace()参数中的tracer.globaltrace来轻松更改输出或添加额外的过滤器。作为参数,该函数需要接受frameeventarg

frame是 Python 堆栈帧,其中包含对代码和文件名的引用,可以用来检查堆栈中的作用域。这是您在使用traceback模块时可以提取的相同帧。

event参数是一个字符串,可以有以下值(来自标准 Python 文档):

参数 描述
call 调用一个函数(或进入其他代码块)。调用全局跟踪函数;argNone。返回值指定局部跟踪函数。
line 解释器即将执行新的代码行或重新执行循环的条件。调用局部跟踪函数;argNone;返回值指定新的局部跟踪函数。有关此功能的详细解释,请参阅Objects/lnotab_notes.txt(位于 Python 源代码库中)。可以通过在frame上设置f_trace_linesFalse来禁用每行事件。
return 函数(或另一个代码块)即将返回。调用局部跟踪函数;arg是即将返回的值,如果事件是由抛出异常引起的,则为None。跟踪函数的返回值被忽略。
exception 这表示发生了异常。调用局部跟踪函数;arg是一个元组(exceptionvaluetraceback)。返回值指定新的局部跟踪函数。
opcode 解释器即将执行一个新的操作码(有关操作码的详细信息,请参阅dis模块)。调用局部跟踪函数;argNone;返回值指定新的局部跟踪函数。默认情况下不会发出每操作码事件:必须在帧上显式请求将f_trace_opcodes设置为True

最后,arg参数依赖于event参数,如上述文档所示。一般来说,如果argNone,则此函数的返回值将用作局部跟踪函数,允许你为特定作用域覆盖它。对于exception事件,它将是一个包含exceptionvaluetraceback的元组。

现在让我们创建一个可以按文件名筛选来选择性跟踪我们代码的小片段:

import sys
import trace as trace_module
import contextlib

@contextlib.contextmanager
def trace(filename):
    tracer = trace_module.Trace()

    def custom_trace(frame, event, arg):
        # Only trace for the given filename
        if filename != frame.f_code.co_filename:
            return custom_trace

        # Let globaltrace handle the rest
        return tracer.globaltrace(frame, event, arg)

    sys.settrace(custom_trace)
    yield tracer
    sys.settrace(None)

    result = tracer.results()
    result.write_results(show_missing=False, summary=True)

def some_test_function(a, b):
    c = a + b
    return c

# Pass our current filename as '__file__'
with trace(filename=__file__):
    print(some_test_function('a', 'b')) 

通过使用frame参数,我们可以检索当前正在执行的代码,并从中获取代码当前所在的文件名。自然地,你也可以根据不同的函数或仅过滤到特定的深度进行筛选。由于我们将跟踪和输出委托给tracer.globaltrace(),我们只检查堆栈中的filename。你也可以返回trace()并自行处理print()

当执行此代码时,你应该得到:

$ python3 T_03_filename_trace.py
 --- modulename: T_03_filename_trace, funcname: some_test_function
T_03_filename_trace.py(27):     c = a + b
T_03_filename_trace.py(28):     return c
ab
 --- modulename: T_03_filename_trace, funcname: trace
T_03_filename_trace.py(20):     sys.settrace(None)
lines   cov%   module   (path)
    3   100%   T_03_filename_trace   (T_03_filename_trace.py) 

如你所见,这排除了contextlib中的代码,我们在前面的例子中已经看到了。

使用日志进行调试

第十章测试和日志 – 准备错误中,我们看到了如何创建自定义记录器,为它们设置级别,并将处理程序添加到特定级别。我们现在将使用logging.DEBUG级别进行日志记录,这本身并没有什么特别之处,但通过几个装饰器,我们可以添加一些非常有用的仅调试代码。

当我在调试时,总是觉得知道函数的输入和输出非常有用。使用装饰器的基本版本足够简单,只需打印argskwargsreturn值即可完成。以下示例更进一步。通过使用inspect模块,我们可以检索默认参数,使得在所有情况下都能显示所有参数的名称和值,即使参数未指定:

import pprint
import inspect
import logging
import functools

def debug(function):
    @functools.wraps(function)
    def _debug(*args, **kwargs):
        # Make sure 'result' is always defined
        result = None
        try:
            result = function(*args, **kwargs)
            return result
        finally:
            # Extract the signature from the function
            signature = inspect.signature(function)
            # Fill the arguments
            arguments = signature.bind(*args, **kwargs)
            # NOTE: This only works for Python 3.5 and up!
            arguments.apply_defaults()
            logging.debug('%s(%s): %s' % (
                function.__qualname__,
                ', '.join('%s=%r' % (k, v) for k, v in
                          arguments.arguments.items()),
                pprint.pformat(result),
            ))

    return _debug

@debug
def add(a, b=123):
    return a + b

if __name__ == '__main__':
    logging.basicConfig(level=logging.DEBUG)

    add(1)
    add(1, 456)
    add(b=1, a=456) 

让我们分析一下这段代码是如何执行的:

  1. 装饰器会以正常方式执行function(),同时将未修改的*args**kwargs传递给函数,并将结果存储起来以供稍后显示和return

  2. try/finallyfinally部分从function()生成一个inspect.Signature()对象。

  3. 现在我们通过使用之前生成的signature*args**kwargs绑定,生成一个inspect.BoundArguments()对象。

  4. 现在我们可以告诉inspect.BoundArguments()对象应用默认参数,这样我们就可以看到未在*args**kwargs中传递的参数的值。

  5. 最后,我们输出完整的函数名、格式化的参数和result

当我们执行代码时,我们应该看到以下内容:

$ python3 T_04_logging.py
DEBUG:root:add(a=1, b=123): 124
DEBUG:root:add(a=1, b=456): 457
DEBUG:root:add(a=456, b=1): 457 

当然,这很好,因为我们能清楚地看到函数何时被调用,使用了哪些参数,以及返回了什么。然而,这通常是你在积极调试代码时才会执行的操作。

你还可以通过添加一个特定于调试的记录器来使你的代码中的常规logging.debug语句更有用,该记录器显示更多信息。只需将前一个示例的日志配置替换为以下内容:

import logging

log_format = (
    '[%(relativeCreated)d %(levelname)s] '
    '%(filename)s:%(lineno)d:%(funcName)s: %(message)s'
)
logging.basicConfig(level=logging.DEBUG, format=log_format) 

然后你的结果将类似于以下内容:

$ python3 T_05_logging_config.py
[DEBUG] T_05_logging_config.py:20:_debug: add(a=1, b=123): 124
[DEBUG] T_05_logging_config.py:20:_debug: add(a=1, b=456): 457
[DEBUG] T_05_logging_config.py:20:_debug: add(a=456, b=1): 457 

它显示相对于应用程序开始的时间(以毫秒为单位)和日志级别。接着是一个标识块,显示产生日志的文件名、行号和函数名。当然,在最后还有一个message,其中包含我们日志调用的结果。

无异常显示调用堆栈

当查看代码是如何以及为什么被运行时,查看整个堆栈跟踪通常很有用。当然,抛出一个异常也是一个选择。然而,这将终止当前的代码执行,这通常不是我们想要的。这就是traceback模块派上用场的地方。只需简单调用traceback.print_stack(),我们就能得到完整的堆栈列表:

import sys
import traceback

class ShowMyStack:
    def run(self, limit=None):
        print('Before stack print')
        traceback.print_stack(limit=limit)
        print('After stack print')

class InheritShowMyStack(ShowMyStack):
    pass

if __name__ == '__main__':
    show_stack = InheritShowMyStack()

    print('Stack without limit')
    show_stack.run()
    print()

    print('Stack with limit 1')
    show_stack.run(1) 

ShowMyStack.run()函数显示了一个常规的traceback.print_stack()调用,它显示了堆栈中的整个堆栈跟踪。你可以在代码的任何地方放置traceback.print_stack()以查看它是如何被调用的。

由于完整的堆栈跟踪可能相当大,因此通常很有用使用limit参数只显示几个级别,这就是我们在第二次运行中所做的。

这会导致以下结果:

$ python3 T_06_stack.py
Stack without limit
Before stack print
  File "T_06_stack.py", line 20, in <module>
    show_stack.run()
  File "T_06_stack.py", line 8, in run
    traceback.print_stack(limit=limit)
After stack print

Stack with limit 1
Before stack print
  File "T_06_stack.py", line 8, in run
    traceback.print_stack(limit=limit)
After stack print 

如您所见,堆栈跟踪简单地打印出来,没有任何异常。实际上,traceback模块还有许多其他方法可以基于异常等打印堆栈跟踪,但你可能不会经常需要它们。最有用的可能就是我们已经展示的limit参数。正数限制数字只显示特定数量的帧。在大多数情况下,你不需要完整的堆栈跟踪,所以这可以非常有用,以限制输出。

或者,我们也可以指定一个负数限制,这将从另一侧裁剪堆栈。这在从装饰器打印堆栈时很有用,你希望隐藏装饰器从跟踪中。如果你想限制两边,你必须手动使用format_list(stack)extract_stack(f, limit)的堆栈,其用法与print_stack()函数类似。

Python 3.5 中添加了对负数限制的支持。在此之前,只支持正数限制。

使用 faulthandler 处理崩溃

faulthandler模块在调试真正低级别的崩溃时很有帮助,即当使用低级别访问内存(如 C 扩展)时才可能发生的崩溃。

例如,以下是一段会导致你的 Python 解释器崩溃的代码:

import ctypes

# Get memory address 0, your kernel shouldn't allow this:
ctypes.string_at(0) 

结果类似于以下内容:

$ python3 T_07_faulthandler.py
zsh: segmentation fault  python3 T_07_faulthandler.py 

当然,这是一个相当丑陋的响应,并且没有给你处理错误的可能性。如果你想知道,拥有 try/except 结构在这些情况下也不会帮助你。以下代码将以完全相同的方式崩溃:

import ctypes

try:
    # Get memory address 0, your kernel shouldn't allow this:
    ctypes.string_at(0)
except Exception as e:
    print('Got exception:', e) 

在这种情况下,faulthandler 模块有所帮助。它仍然会导致你的解释器崩溃,但至少你会看到一个适当的错误消息被抛出,所以如果你(或任何子库)与原始内存有任何交互,这是一个好的默认设置:

import ctypes
import faulthandler

faulthandler.enable()

# Get memory address 0, your kernel shouldn't allow this:
ctypes.string_at(0) 

结果大致如下:

$ python3 T_09_faulthandler_enabled.py
Fatal Python error: Segmentation fault

Current thread 0x0000000110382e00 (most recent call first):
  File python3.9/ctypes/__init__.py", line 517 in string_at
  File T_09_faulthandler.py", line 7 in <module>
zsh: segmentation fault  python3 T_09_faulthandler_enabled.py 

显然,以这种方式让 Python 应用程序退出是不可取的,因为代码不会以正常清理的方式退出。资源将无法被干净地关闭,并且你的退出处理程序不会被调用。如果你需要以某种方式捕获这种行为,最好的办法是将 Python 可执行文件包裹在一个单独的脚本中,使用类似 subprocess.run([sys.argv[0], ' T_09_faulthandler_enabled.py']) 的方法。

交互式调试

现在我们已经讨论了始终有效的基本调试方法,我们将探讨一些更高级的调试技术中的交互式调试。之前的调试方法通过修改代码和/或预见性来使变量和堆栈可见。这一次,我们将看看一个稍微聪明一点的方法,即交互式地做同样的事情,但一旦需要。

需求控制台

在测试一些 Python 代码时,你可能已经使用过交互式控制台几次,因为它是一个简单而有效的测试 Python 代码的工具。你可能不知道的是,实际上从你的代码中启动自己的 shell 非常简单。所以,无论何时你想从代码的特定点进入一个常规 shell,这都是很容易实现的:

import code

def start_console():
    some_variable = 123
    print(f'Launching console, some_variable: {some_variable}')
    code.interact(banner='console:', local=locals())
    print(f'Exited console, some_variable: {some_variable}')

if __name__ == '__main__':
    start_console() 

当执行时,我们将进入一个交互式控制台的中途:

$ python3 T_10_console.py
Launching console, some_variable: 123
console:
>>> some_variable = 456
>>>
now exiting InteractiveConsole...
Exited console, some_variable: 123 

要退出这个控制台,我们可以在 Linux/Mac 系统上使用 ^d (Ctrl + D),在 Windows 系统上使用 ^z (Ctrl + Z)。

这里需要注意的一个重要事情是,局部作用域在这两个之间不是共享的。尽管我们传递了 locals() 以便方便地共享局部变量,但这种关系不是双向的。

结果是,尽管我们在交互会话中将 some_variable 设置为 456,但它并没有传递到外部函数。如果你愿意,你可以通过直接操作(例如,设置属性)在外部作用域中修改变量,但所有局部声明的变量将保持局部。

自然地,修改可变变量将影响两个作用域。

使用 Python 调试器 (pdb) 进行调试

当真正调试代码时,常规的交互式控制台并不适合。经过一些努力,你可以让它工作,但这对调试来说并不那么方便,因为你只能看到当前的作用域,而且不能轻易地在堆栈中跳转。使用 pdb (Python 调试器),这很容易实现。所以,让我们看看使用 pdb 的一个简单例子:

import pdb

def go_to_debugger():
    some_variable = 123
    print('Starting pdb trace')
    pdb.set_trace()
    print(f'Finished pdb, some_variable: {some_variable}')

if __name__ == '__main__':
    go_to_debugger() 

这个例子几乎与前一段落中的例子相同,只是这次我们最终进入的是 pdb 控制台而不是常规的交互式控制台。所以,让我们尝试一下交互式调试器:

$ python3 T_11_pdb.py
Starting pdb trace
> T_11_pdb.py(8)go_to_debugger()
-> print(f'Finished pdb, some_variable: {some_variable}')
(Pdb) some_variable
123
(Pdb) some_variable = 456
(Pdb) continue
Finished pdb, some_variable: 456 

如你所见,我们实际上已经修改了 some_variable 的值。在这种情况下,我们使用了完整的 continue 命令,但所有的 pdb 命令都有简写版本。所以,使用 c 而不是 continue 会得到相同的结果。只需键入 some_variable(或任何其他变量)即可显示其内容,设置变量将简单地将其设置,就像我们期望从交互式会话中那样。 |

要开始使用 pdb,首先,这里显示了最有用的(完整)堆栈移动和操作命令及其缩写列表:

命令 说明
h(elp) 这显示了命令列表(此列表)。
h(elp) command 这显示了给定命令的帮助。
w(here) 当前堆栈跟踪,并在当前帧处有一个箭头。
d(own) 向下/移动到堆栈中的较新帧。
u(p) 向上/移动到堆栈中的较旧帧。
s(tep) 执行当前行,并尽可能快地停止。
n(ext) 执行当前行,并在当前函数内的下一行停止。
r(eturn) 继续执行直到函数返回。
c(ont(inue)) 继续执行直到下一个断点。
l(ist) [first[, last]] 列出当前行周围的源代码行(默认为 11 行)。
ll &#124; longlist 列出当前函数或帧的所有源代码。
source expression 列出给定对象的源代码。这与 longlist 类似。
a(rgs) 打印当前函数的参数。
pp expression 美化打印给定表达式。
! statement 在堆栈的当前点执行语句。通常不需要 ! 符号,但如果与调试器命令有冲突,这可能很有用。例如,尝试 b = 123
interact 打开一个类似于前一段落的交互式 Python shell 会话。

许多其他命令可用,其中一些将在以下段落中介绍。不过,所有命令都包含在内置的帮助中,所以如果需要,请务必使用 h/help [command] 命令。

断点

断点是调试器将停止代码执行并允许你从该点进行调试的点。我们可以使用代码或命令来创建断点。首先,让我们使用 pdb.set_trace() 进入调试器。这实际上是一个硬编码的断点:

import pdb

def print_value(value):
    print('value:', value)

if __name__ == '__main__':
    pdb.set_trace()
    for i in range(5):
        print_value(i) 

到目前为止,还没有发生任何新的事情,但现在让我们打开交互式调试会话并尝试一些断点命令。在我们开始之前,这里有一份最常用断点命令的列表:

命令 说明
b(reak) 显示断点列表。
b(reak) [filename:]lineno 在给定的行号和可选的文件中放置断点。
b(reak) function[, condition] 在指定的函数处设置断点。条件是一个表达式,必须评估为 True 才能使断点生效。
cl(ear) [filename:]lineno 清除此行上的断点(或断点)。
cl(ear) breakpoint [breakpoint ...] 使用这些数字清除断点(或断点)。

现在,让我们执行这段代码并进入交互式调试器来尝试这些命令:

$ python3 T_12_pdb_loop.py
> T_12_pdb_loop.py (10)<module>()
-> for i in range(5):
(Pdb) source print_value  # View the source of print_value
  4     def print_value(value):
  5         print('value:', value)
(Pdb) b 5  # Add a breakpoint to line 5
Breakpoint 1 at T_12_pdb_loop.py:5
(Pdb) w  # Where shows the current line
> T_12_pdb_loop.py (10)<module>()
-> for i in range(5):
(Pdb) c  # Continue (until the next breakpoint or exception)
> T_12_pdb_loop.py(5)print_value()
-> print('value:', value)
(Pdb) w  # Where shows the current line and the calling functions
  T_12_pdb_loop.py(11)<module>()
-> print_value(i)
> T_12_pdb_loop.py(5)print_value()
-> print('value:', value)
(Pdb) ll  # List the lines of the current function
  4     def print_value(value):
  5 B->     print('value:', value)
(Pdb) b  # Show the breakpoints
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at T_12_pdb_loop.py:5
        breakpoint already hit 1 time
(Pdb) cl 1  # Clear breakpoint 1
Deleted breakpoint 1 at T_12_pdb_loop.py:5
(Pdb) c  # Continue the application until the end
value: 0
value: 1
value: 2
value: 3
value: 4 

虽然输出很多,但实际上并不像看起来那么复杂:

  1. 首先,我们使用 source print_value 命令查看 print_value 函数的源代码。

  2. 之后,我们知道第一个 print 语句的行号,我们使用它来在行 5 处设置断点 (b 5)。

  3. 为了检查我们是否仍然在正确的位置,我们使用了 w 命令。

  4. 由于已经设置了断点,我们使用 c 继续执行到下一个断点。

  5. 在第 5 行断点处停止后,我们再次使用 w 来确认并显示当前的堆栈。

  6. 我们使用 ll 列出了当前函数的代码。

  7. 我们使用 b 列出了断点。

  8. 我们使用 cl 1 和之前命令中的断点编号再次使用 cl 命令移除了断点。

  9. 我们继续 (c) 直到程序退出或达到下一个断点(如果有的话)。

开始时似乎有点复杂,但一旦你尝试了几次,你会发现这实际上是一种非常方便的调试方式。

为了让它更好,这次我们只在 value = 3 时执行断点:

$ python3 T_12_pdb_loop.py
> T_12_pdb_loop.py(10)<module>()
-> for i in range(5):
# print the source to find the variable name and line number:
(Pdb) source print_value
  4     def print_value(value):
  5         print('value:', value)
(Pdb) b 5, value == 3  # add a breakpoint at line 5 when value=3
Breakpoint 1 at T_12_pdb_loop.py:5
(Pdb) c  # continue until breakpoint
value: 0
value: 1
value: 2
> T_12_pdb_loop.py(5)print_value()
-> print('value:', value)
(Pdb) a  # show the arguments for the function
value = 3
(Pdb) value = 123  # change the value before the print
(Pdb) c  # continue, we see the new value now
value: 123
value: 4 

要列出我们所做的一切:

  1. 首先,使用 source print_value,我们查找行号和变量名。

  2. 之后,我们使用 value == 3 条件设置了断点。

  3. 然后,我们使用 c 继续执行。正如你所看到的,值 012 被正常打印出来。

  4. 在值 3 处达到了断点。为了验证,我们使用 a 来查看函数参数。

  5. print() 执行之前,我们更改了变量。

  6. 我们继续执行代码的其余部分。

捕获异常

所有这些都是在手动调用 pdb.set_trace() 函数,但通常你只是在运行你的应用程序,并不真正期望出现问题。这就是异常捕获非常有用的地方。除了自己导入 pdb,你还可以通过 pdb 作为模块运行脚本。让我们检查一下这段代码,它在达到零除时立即崩溃:

print('This still works')
1 / 0
print('We will never reach this') 

如果我们通过 pdb 模块运行它,我们可以在它崩溃时进入 Python 调试器:

$ python3 -m pdb T_13_pdb_catching_exceptions
> T_13_pdb_catching_exceptions(1)<module>()
-> print('This still works')
(Pdb) w  # Where
  bdb.py(431)run()
-> exec(cmd, globals, locals)
  <string>(1)<module>()
> T_13_pdb_catching_exceptions(1)<module>()
-> print('This still works')
(Pdb) s  # Step into the next statement
This still works
> T_13_pdb_catching_exceptions(2)<module>()
-> 1/0
(Pdb) c  # Continue
Traceback (most recent call last):
  File "pdb.py", line 1661, in main
    pdb._runscript(mainpyfile)
  File "pdb.py", line 1542, in _runscript
    self.run(statement)
  File "bdb.py", line 431, in run
    exec(cmd, globals, locals)
  File "<string>", line 1, in <module>
  File "T_13_pdb_catching_exceptions", line 2, in <module>
    1/0
ZeroDivisionError: division by zero
Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
> T_13_pdb_catching_exceptions(2)<module>()
-> 1/0 

pdb 中一个有用的技巧是使用 Enter 按钮,默认情况下,它将再次执行之前执行的命令。这在逐步执行程序时非常有用。

别名

别名可以是一个非常实用的功能,可以让你的生活更轻松。如果你像我一样“生活在”Linux/Unix shell 中,你可能已经熟悉它们了,但本质上别名只是一个简写,可以让你不必输入(甚至记住)一个长而复杂的命令。

哪些别名对你有用取决于你的个人喜好,但就我个人而言,我喜欢为 pprint(美化打印)模块设置别名。在我的项目中,我经常使用 pf=pprint.pformatpp=pprint.pprint 作为别名,但在 pdb 中,我发现 pd 是一个用于美化打印给定对象 __dict__ 的有用缩写。

pdb 命令的别名相对简单且非常易于使用:

alias 列出所有别名。
alias name command 创建一个别名。命令可以是任何有效的 Python 表达式,因此您可以执行以下操作以打印对象的全部属性:alias pd pp %1.__dict__
unalias name 删除一个别名。

确保充分利用这些功能。在 Linux/Unix 系统中,您可能已经注意到许多命令(lsrmcd)非常简短,以节省您输入;您可以使用这些别名做同样的事情。

命令

commands 命令有些复杂但非常有用。它允许您在遇到特定断点时执行命令。为了说明这一点,让我们再次从一个简单的例子开始:

def do_nothing(i):
    pass

for i in range(10):
    do_nothing(i) 

代码很简单,所以现在我们将添加断点和命令,如下所示:

$ python3 -m pdb T_14_pdb_commands.py
> T_14_pdb_commands.py(1)<module>()
-> def do_nothing(i):
(Pdb) b do_nothing  # Add a breakpoint to function do_nothing
Breakpoint 1 at T_14_pdb_commands.py:1
(Pdb) commands 1  # add command to breakpoint 1
(com) print(f'The passed value: {i}')
(com) end  # end command
(Pdb) c  # continue
The passed value: 0
> 16_pdb_commands.py(2)do_nothing()
-> pass
(Pdb) q  # quit 

如您所见,我们可以轻松地向断点添加命令。在移除断点后,这些命令将不再执行,因为它们与断点相关联。

这些功能可以非常方便地添加一些自动调试 print 语句到您的断点;例如,查看局部作用域中所有变量的值。当然,您始终可以手动执行 print(locals()),但这些可以在调试过程中节省您大量时间。

使用 IPython 进行调试

虽然通用的 Python 控制台很有用,但它可能有些粗糙。IPython 控制台提供了一整套额外功能,使其成为一个更舒适的终端来工作。其中之一是一个更方便的调试器。

首先,请确保您已安装 ipython

$ pip3 install ipython 

接下来,让我们尝试使用一个非常基本的脚本来使用调试器:

def print_value(value):
    print('value:', value)

if __name__ == '__main__':
    for i in range(5):
        print_value(i) 

接下来,我们运行 IPython 并告诉它以调试模式运行脚本:

$ ipython
Python 3.10.0
Type 'copyright', 'credits' or 'license' for more information
IPython 7.19.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: %run -d T_15_ipython.py
Breakpoint 1 at T_15_ipython.py:1
NOTE: Enter 'c' at the ipdb>  prompt to continue execution.
> T_15_ipython.py(1)<module>()
1---> 1 def print_value(value):
      2     print('value:', value)
      3
      4
      5 if __name__ == '__main__':

ipdb> b print_value, value == 3  # Add a breakpoint when value=3
Breakpoint 2 at T_15_ipython.py:1
ipdb> c
value: 0
value: 1
value: 2
> T_15_ipython.py(2)print_value()
2     1 def print_value(value):
----> 2     print('value:', value)
      3
      4
      5 if __name__ == '__main__':
ipdb> value
3
ipdb> value = 123  # Change the value
ipdb> c  # Continue
value: 123
value: 4 

如您所见,与 pdb 并无太大差异。但它会自动以可读的格式显示周围的代码,这非常有用。此外,显示的代码具有语法高亮,这有助于提高可读性。

如果您安装了 ipdb 模块,您将获得与 pdb 模块类似的功能,这允许您从代码中触发断点。

使用 Jupyter 进行调试

Jupyter 对于临时开发非常出色,使得查看小脚本中的代码变得非常容易。对于较大的脚本,这可能会迅速变得困难,因为您通常只能获得非交互式的堆栈跟踪,并且必须求助于不同的方法来更改外部代码。

然而,自 2020 年以来,Jupyter 添加了一个(目前处于实验性阶段)的视觉调试器,以便以非常方便的方式调试你的代码。要开始,请确保你有最新的 Jupyter 版本,并安装 @jupyterlab/debugger 扩展和 Jupyter 的 xeus-python(XPython)内核。为了确保一切都能轻松工作,我强烈建议使用 conda 进行此操作:

$ conda create -n jupyter-debugger -c conda-forge xeus-python=0.8.6 notebook=6 jupyterlab=2 ptvsd nodejs
...
## Package Plan ##

  added / updated specs:
    - jupyterlab=2
    - nodejs
    - notebook=6
    - ptvsd
    - xeus-python=0.8.6
...
$ conda activate jupyter-debugger

(jupyter-debugger) $ jupyter labextension install @jupyterlab/debugger

Building jupyterlab assets (build:prod:minimize) 

Conda 的当前安装说明可以在 JupyterLab 调试器 GitHub 页面上找到:jupyterlab.readthedocs.io/en/latest/user/debugger.html

对于常规的 Python 虚拟环境,你可以尝试二进制轮(.whl)包,这样你就不需要编译任何东西。由于这个功能目前处于实验性阶段,因此它尚未在所有环境中得到支持。在撰写本文时,二进制轮适用于 Python 3.6、3.7 和 3.8,适用于 OS X、Linux 和 Windows。可用的版本列表可以在以下位置找到:pypi.org/project/xeus-python/#files

现在我们可以像平常一样启动 jupyter lab

(jupyter-debugger) $ jupyter lab
[I LabApp] JupyterLab extension loaded from jupyterlab
[I LabApp] Jupyter Notebook 6.1.4 is running at:
[I LabApp] http://localhost:8888/?token=...
[I LabApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation). 

如果一切按预期工作,你现在应该能看到 JupyterLab 启动器,其中提供了 Python 3 和 XPython 内核:

图 11.1:JupyterLab Python 和 XPython 内核

由于只有 xeus-python(XPython)目前支持调试,我们不得不打开它。现在我们将添加之前的脚本,以便我们可以演示调试器。如果一切正常工作,你应该能在屏幕右上角看到调试按钮:

图 11.2:常规 Jupyter 控制台输出

现在我们可以按照以下步骤开始调试:

  1. 启用右上角的调试切换。

  2. 点击一行以添加断点。

  3. 运行代码。

如果一切设置正确,它应该看起来像这样:

图 11.3:使用 Jupyter 进行调试

从现在开始,你可以使用右侧调试面板中的按钮来跳过/进入/退出下一语句,以遍历代码。

其他调试器

pdb 调试器只是 Python 的默认调试器,但远非调试 Python 代码的唯一选项。一些目前值得注意的调试器如下:

  • ipdb:将 pdb 调试器包装在 IPython 壳中的调试器

  • pudb:一个全屏的命令行调试器

  • pdbpp(pdb++):pdb 模块的一个扩展,它为 pdb 添加了自动补全、语法高亮和一些其他有用的功能

  • Werkzeug:一个基于 Web 的调试器,允许在应用程序运行时进行调试

当然,还有很多其他的选择,而且没有一个绝对是最棒的。就像所有工具一样,它们都有各自的优点和缺点,最适合你当前目的的工具只能由你自己决定。很可能你的当前 Python IDE 已经集成了调试器。例如,PyCharm IDE 甚至提供了内置的远程调试功能,这样你就可以从本地的图形界面调试在云服务提供商上运行的应用程序。

调试服务

除了在遇到问题时进行调试之外,有时你只需要跟踪错误以供以后调试。如果你的应用程序运行在远程服务器或不受你控制的计算机上,这可能会特别困难。对于这种错误跟踪,有一些非常实用的开源软件包可用。

Elastic APM

Elastic APM 是 Elastic Stack 的一部分,可以为你跟踪错误、性能、日志和其他数据。这个系统不仅可以跟踪 Python 应用程序,还支持一系列其他语言和应用程序。Elastic Stack(它围绕 Elasticsearch 构建)是一个极其灵活且维护得非常好的软件堆栈,我强烈推荐。

Elastic Stack 的唯一缺点是它是一套非常庞大的应用程序,很快就需要大量的专用服务器来维持合理的性能。然而,它扩展得非常好;如果你需要更多的处理能力,你只需向你的集群添加一台新机器,一切都会自动为你重新平衡。

Sentry

Sentry 是一个开源的错误管理系统,允许你从广泛的编程语言和框架中收集错误。一些显著的功能包括:

  • 将错误分组,这样你只会收到一种(或可配置数量的)错误通知

  • 能够将错误标记为“已修复”,这样当它再次发生时,它会重新提醒你,同时仍然显示之前的出现

  • 显示完整的堆栈跟踪,包括周围的代码

  • 跟踪代码版本/发布,以便你知道哪个版本(重新)引入了错误

  • 将错误分配给特定的开发者修复

虽然 Sentry 应用程序主要关注 Web 应用程序,但它也可以很容易地用于常规应用程序和脚本。

从历史上看,Sentry 最初是一个小的错误分组应用程序,它可以作为一个现有 Django 应用程序中的应用程序使用,或者根据你的需求作为单独的安装。从那时起,那种轻量级结构的大部分已经不复存在;它已经发展成为一个完整的错误跟踪系统,它对许多编程语言和框架提供了原生支持。

随着时间的推移,Sentry 越来越多地转向商业托管平台,因此自行托管应用程序变得更加困难。简单使用pip install sentry就能运行的时代已经一去不复返了。如今,Sentry 是一个重量级的应用程序,依赖于以下运行的服务:

  • PostgreSQL

  • Redis

  • Memcached

  • 符号化器

  • Kafka

  • Snuba

所以如果你想尝试 Sentry,我建议先尝试托管 Sentry 的免费套餐,看看你是否喜欢它。手动安装不再是可行的选项,所以如果你想自行托管,你唯一现实的选择是使用提供的docker-compose文件。

当自行托管时,你应该记住它是一个重量级的应用程序,需要大量的资源来运行,并且很容易填满一个相当大的专用服务器。然而,它仍然比 Elastic APM 轻。

根据我的经验,你需要至少大约 2-3 GiB 的 RAM 和大约 2 个 CPU 核心来运行当前版本的 Sentry。根据你的负载,你可能需要更重的配置,但这是最低要求。

练习

对于本地开发,一些小的实用函数可以使你的生活更加轻松。我们已经看到了print_code生成器和trace上下文包装器的例子。看看你是否可以扩展其中一个到:

  • 以超时方式执行代码,以便你可以看到应用程序在哪里停滞

  • 测量执行时长

  • 显示特定代码块被执行了多少次

  • 这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。我们鼓励你提交自己的解决方案,并从他人的解决方案中学习其他方法。

摘要

本章解释了几种不同的调试技术和注意事项。当然,关于调试还有很多可以说的,但我希望你现在已经对调试 Python 代码有了很好的理解。交互式调试技术在单线程应用程序和有交互会话的地方非常有用。

但由于情况并不总是如此,我们也讨论了一些非交互式选项。

回顾一下,在本章中,我们讨论了使用print语句、loggingtracetracebackasynciofaulthandler的非交互式调试。我们还探讨了使用 Python 调试器、IPython 和 Jupyter 的交互式调试,以及了解其他调试器。

在下一章中,我们将看到如何监控和改进 CPU 和内存性能,以及查找和修复内存泄漏。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第十二章:性能 - 跟踪和减少你的内存和 CPU 使用

在我们讨论性能之前,首先需要考虑的是 Donald Knuth 的一句话:

“真正的问题在于程序员在错误的地方和错误的时间花费了太多的时间去担心效率;过早的优化是编程中所有邪恶(至少是大部分邪恶)的根源。”

Donald Knuth 通常被称为算法分析的鼻祖。他的书系《计算机程序设计艺术》可以被认为是所有基本算法的圣经。

只要你选择正确的数据结构和合适的算法,性能就不应该是你担心的事情。这并不意味着你应该完全忽略性能,但只是确保你选择了正确的战斗,并且只有在真正需要的时候才进行优化。微/过早的优化确实很有趣,但只有非常少的情况下才有用。

我们已经在第二章《Pythonic 语法和常见陷阱》中看到了许多数据结构的性能特征,所以我们将不会讨论这一点,但我们会向你展示如何测量性能以及如何检测问题。在某些情况下,微优化会有所帮助,但你不知道直到你测量了性能。

在本章中,我们将涵盖:

  • 分析 CPU 使用情况

  • 分析内存使用情况

  • 学习如何正确比较性能指标

  • 优化性能

  • 寻找和修复内存泄漏

从全局来看,本章分为 CPU 使用和/或 CPU 时间以及内存使用。本章的前半部分主要关注 CPU/时间;后半部分涵盖内存使用。

什么是性能?

性能是一个非常广泛的概念。它有许多不同的含义,在许多情况下,它被错误地定义。在本章中,我们将尝试从 CPU 使用/时间和内存使用方面来测量和改进性能。这里的大多数例子都是执行时间和内存使用之间的权衡。请注意,一个只能使用单个 CPU 核心的快速算法,在执行时间上可能会被一个足够多的 CPU 核心就能轻松并行化的较慢算法所超越。

当涉及到关于性能的错误陈述时,你可能已经听到了类似“语言 X 比 Python 快”这样的说法。这个说法本身是错误的。Python 既不快也不慢;Python 是一种编程语言,而一种语言根本没有任何性能指标。如果你要说 CPython 解释器比语言 X 的解释器 Y 快或慢,那是可能的。代码的性能特征在不同解释器之间可能会有很大的差异。只需看看这个小的测试(它使用 ZSH shell 脚本):

$ export SCRIPT='"".join(str(i) for i in range(10000))'

$ for p in pypy3 pyston python3.{8..10}; do echo -n "$p: "; $p -m timeit "$SCRIPT"; done
pypy3: ... 2000 loops, average of 7: 179 +- 6.05 usec per loop ...
pyston: 500 loops, best of 5: 817 usec per loop
python3.8: 200 loops, best of 5: 1.21 msec per loop
python3.9: 200 loops, best of 5: 1.64 msec per loop
python3.10: 200 loops, best of 5: 1.14 msec per loop 

五种不同的 Python 解释器,每个都有不同的性能!它们都是 Python,但解释器显然各不相同。

你可能还没有听说过 PyPy3 和 Pyston 解释器。

PyPy3 解释器是一个替代 Python 解释器,它使用 JIT(即时)编译,在许多情况下比 CPython 表现更好,但当然并非所有情况。PyPy3 的一个大问题是,那些在 C 中有速度提升且依赖于 CPython 扩展(这是大量性能关键库的一部分)的代码要么不支持 PyPy3,要么会遭受性能损失。

Pyston 试图成为 CPython 的替代品,并添加了 JIT 编译。虽然 JIT 编译可能很快就会添加到 CPython 中,但截至 Python 3.10,这还不是事实。这就是为什么 Pyston 可以提供比 CPython 更大的性能优势。缺点是它目前仅支持 Unix/Linux 系统。

看到这个基准测试,你可能想完全放弃 CPython 解释器,只使用 PyPy3。这样的基准测试的危险在于,它们很少提供任何有意义的成果。在这个有限的例子中,Pypy 解释器比 CPython3.10 解释器快了大约 200 倍,但这对于一般情况几乎没有相关性。可以安全得出的唯一结论是,这个特定版本的 PyPy3 解释器在这个特定测试中比这个特定版本的 CPython3 快得多。对于任何其他测试和解释器版本,结果可能会有很大不同。

测量 CPU 性能和执行时间

当谈论性能时,你可以测量许多事物。当涉及到 CPU 性能时,我们可以测量:

  • “墙上的时间”(时钟上的绝对时间)。

  • 相对时间(当比较多次运行或多个函数时)

  • 使用 CPU 时间。由于多线程、多进程或异步处理,这可能与墙上的时间有很大差异。

  • 当检查真正低级别的性能时,需要测量 CPU 周期数和循环计数。

除了所有这些不同的测量选项之外,你还应该考虑观察者效应。简单来说,测量需要时间,并且根据你如何测量性能,影响可能很大。

在本节中,我们将探讨几种检查代码 CPU 性能和执行时间的方法。在测量后提高性能的技巧将在本章后面介绍。

Timeit – 比较代码片段性能

在我们开始提高执行/CPU 时间之前,我们需要一个可靠的方法来测量它们。Python 有一个非常棒的模块(timeit),其特定目的是测量代码片段的执行时间。它多次执行一小段代码,以确保尽可能少的变异性,并使测量尽可能干净。如果你想要比较几个代码片段,这非常有用。以下是一些示例执行:

$ python3 -m timeit 'x=[]; [x.insert(0, i) for i in range(10000)]'
10 loops, best of 3: 30.2 msec per loop
$ python3 -m timeit 'x=[]; [x.append(i) for i in range(10000)]'
1000 loops, best of 3: 1.01 msec per loop
$ python3 -m timeit 'x=[i for i in range(10000)]'
1000 loops, best of 3: 381 usec per loop
$ python3 -m timeit 'x=list(range(10000))'
10000 loops, best of 3: 212 usec per loop 

这几个示例展示了 list.insertlist.append、列表推导式和 list 函数之间的性能差异。正如我们在 第四章 中所看到的,执行 list.insert 非常低效,并且在这里很快就会显示出来,在这种情况下比 list.append 慢 30 倍。

然而,更重要的是,这段代码展示了我们如何使用 timeit 模块以及它是如何工作的。正如你在输出中可以看到的,list.append 变体只执行了 10 次,而 list 调用执行了 10000 次。这是 timeit 模块最方便的特性之一:它自动为你计算出一些有用的参数,并显示“最佳 3 次”以尝试减少测试中的方差。

timeit 模块非常适合比较代码库中相似代码片段的性能。使用 timeit 比较不同 Python 解释器的执行时间通常是无用的,因为这很少能代表你整个应用程序的性能。

自然地,这个命令也可以与常规脚本一起使用,但它不会像命令行界面那样自动确定重复次数。因此,我们必须自己来做这件事:

import timeit

def test_list():
    return list(range(10000))

def test_list_comprehension():
    return [i for i in range(10000)]

def test_append():
    x = []
    for i in range(10000):
        x.append(i)

    return x

def test_insert():
    x = []
    for i in range(10000):
        x.insert(0, i)

    return x

def benchmark(function, number=100, repeat=10):
    # Measure the execution times. Passing the globals() is an
    # easy way to make the functions available.
    times = timeit.repeat(function, number=number,
                          globals=globals())
    # The repeat function gives 'repeat' results so we take the
    # min() and divide it by the number of runs
    time = min(times) / number
    print(f'{number} loops, best of {repeat}: {time:9.6f}s :: ',
          function.__name__)

if __name__ == '__main__':
    benchmark(test_list)
    benchmark(test_list_comprehension)
    benchmark(test_append)
    benchmark(test_insert) 

当执行此操作时,你将得到以下类似的结果:

$ python3 T_00_timeit.py
100 loops, best of 10:  0.000168s ::  test_list
100 loops, best of 10:  0.000322s ::  test_list_comprehension
100 loops, best of 10:  0.000573s ::  test_append
100 loops, best of 10:  0.027552s ::  test_insert 

如你所注意到的,这个脚本仍然有点基础。虽然命令行版本的 timeit 会一直尝试,直到达到 0.2 秒或更长时间,但这个脚本只有固定的执行次数。从 Python 3.6 开始,我们确实有使用 timeit.Timer.autorange 来复制此行为的选项,但这使用起来不太方便,并且在我们的当前情况下会产生更多的输出。然而,根据你的使用情况,尝试这个基准代码可能是有用的:

def autorange_benchmark(function):
    def print_result(number, time_taken):
        # The autorange function keeps trying until the total
        # runtime (time_taken) reaches 0.2 seconds. To get the
        # time per run we need to divide it by the number of runs
        time = time_taken / number
        name = function.__name__
        print(f'{number} loops, average: {time:9.6f}s :: {name}')

    # Measure the execution times. Passing the globals() is an
    # easy way to make the functions available.
    timer = timeit.Timer(function, globals=globals())
    timer.autorange(print_result) 

如果你想要交互式地使用 timeit,我建议使用 IPython,因为它有一个魔法命令 %timeit,可以显示更多有用的输出:

$ ipython
In [1]: %timeit x=[]; [x.insert(0, i) for i in range(100000)]
2.5 s ± 112 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [2]: %timeit x=[]; [x.append(i) for i in range(100000)]
6.67 ms ± 252 µs per loop (mean ± std. dev. of 7 runs, 100 loops each) 

在这种情况下,IPython 会自动处理字符串包装和 globals() 的传递。尽管如此,这仍然非常有限,并且仅适用于比较执行同一任务的不同方法。当涉及到完整的 Python 应用程序时,还有更多可用的方法,我们将在本章后面看到。

要查看 IPython 函数和常规模块的源代码,在 IPython 命令行中输入 object?? 会返回源代码。在这种情况下,只需输入 timeit?? 来查看 timeit IPython 函数的定义。

实现类似于 %timeit 函数的最简单方法是通过调用 timeit.main

import timeit

timeit.main(args=['[x for x in range(1000000)]']) 

这实际上与以下操作相同:

$ python3 -m timeit '[x for x in range(1000000)]' 

timeit 模块的内部结构并没有什么特别之处,但请注意尽量减少一些可能导致不准确的因素,例如设置和清理代码。此外,该模块报告的是最快的运行时间,因为系统上的其他进程可能会干扰测量。

基本版本可以通过调用几次time.perf_counter(Python 中可用的最高分辨率计时器)来实现,该计时器也被timeit内部使用。timeit.default_timer函数仅仅是time.perf_counter的一个引用。timeit函数的基本实现与timeit模块的内部实现相当:

import gc
import time
import functools

assert time

TIMEIT_TEMPLATE = '''
def run(number):
    {setup}
    start = time.perf_counter()
    for i in range(number):
        {statement}
    stop = time.perf_counter()
    return stop - start
'''

def timeit(statement, setup='', number=1000000, globals_=None):
    # Get or create globals
    globals_ = globals() if globals_ is None else globals_

    # Create the test code so we can separate the namespace
    src = TIMEIT_TEMPLATE.format(
        statement=statement,
        setup=setup,
        number=number,
    )
    # Compile the source
    code = compile(src, '<source>', 'exec')

    # Define locals for the benchmarked code
    locals_ = {}

    # Execute the code so we can get the benchmark fuction
    exec(code, globals_, locals_)

    # Get the run function from locals() which was added by 'exec'
    run = functools.partial(locals_['run'], number=number)

    # Disable garbage collection to prevent skewing results
    gc.disable()
    try:
        result = run()
    finally:
        gc.enable()

    return result 

实际的timeit代码在检查输入方面要复杂一些,但这个例子大致展示了如何实现timeit.timeit函数,包括为提高精度而添加的几个特性:

  • 首先,我们可以看到代码有一个默认值为 1 百万的number参数。这样做是为了稍微减少结果的变化性,正如我们在运行代码时将看到的。

  • 其次,代码禁用了 Python 垃圾回收器,这样我们就不会因为 Python 决定清理其内存而出现任何减速。

当我们实际调用这段代码时,我们将看到为什么number的高值可能很重要:

>>> from T_02_custom_timeit import timeit

>>> statement = '[x for x in range(100)]'

>>> print('{:.7f}'.format(timeit(statement, number=1)))
0.0000064
>>> print('{:.7f}'.format(timeit(statement) / 1000000))
0.0000029
>>> print('{:.7f}'.format(timeit(statement, number=1)))
0.0000287
>>> print('{:.7f}'.format(timeit(statement) / 1000000))
0.0000029 

尽管我们每次都调用了完全相同的代码,但第一次运行的单次重复时间比 1 百万次重复版本多两倍以上,第二次运行则比 1 百万次重复版本多 10 倍以上。为了使你的结果在运行之间更加一致和可靠,总是重复测试几次是个好主意,而timeit可以在这方面提供帮助。

timeit.repeat函数简单地多次调用timeit.timeit函数,可以使用列表推导来模拟:

[timeit(statement) for _ in range(repeat)] 

既然我们已经知道了如何测试简单的代码语句,那么让我们看看如何找到代码中的慢速语句。

cProfile – 寻找最慢的组件

profile/cProfile modules are only useful for relative results because profiling increases the runtime. There are ways to make the results more accurate, but more about that later.

profilecProfile模块提供了完全相同的接口,但后者是用 C 编写的,速度要快得多。如果系统上可用,我建议使用cProfile。如果不可用,你可以在以下示例中安全地将任何cProfile的出现替换为profile

首次分析运行

让我们分析第六章中的斐波那契函数,装饰器 – 通过装饰实现代码重用,既有缓存函数也有无缓存函数的情况。首先,代码:

import sys
import functools

@functools.lru_cache()
def fibonacci_cached(n):
    if n < 2:
        return n
    else:
        return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)

def fibonacci(n):
    if n < 2:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

if __name__ == '__main__':
    n = 30
    if sys.argv[-1] == 'cache':
        fibonacci_cached(n)
    else:
        fibonacci(n) 

为了提高可读性,所有cProfile统计信息都将从所有cProfile输出中的percall列中去除。这些列包含每次函数调用的持续时间,在这些示例中,这些值几乎总是为 0 或与cumtime(累积时间)列相同,因此对于这些示例来说是不相关的。

首先,我们将不使用缓存执行该函数:

$ python3 -m cProfile T_03_profile_fibonacci.py no_cache
   2692557 function calls (21 primitive calls) in 0.596 seconds

   Ordered by: standard name

   ncalls tottime cumtime filename:lineno(function)
        1   0.000   0.596 T_03_profile_fibonacci.py:1(<module>)
2692537/1   0.596   0.596 T_03_profile_fibonacci.py:13(fibonacci)
        1   0.000   0.000 functools.py:35(update_wrapper)
        1   0.000   0.000 functools.py:479(lru_cache)
        1   0.000   0.000 functools.py:518(decorating_function)
        1   0.000   0.596 {built-in method builtins.exec}
        7   0.000   0.000 {built-in method builtins.getattr}
        1   0.000   0.000 {built-in method builtins.isinstance}
        5   0.000   0.000 {built-in method builtins.setattr}
        1   0.000   0.000 {method 'disable' of '_lsprof.Profile...
        1   0.000   0.000 {method 'update' of 'dict' objects} 

我们看到总共有2692557次调用,这相当多。我们几乎调用了 300 万次test_fibonacci函数。这就是分析模块提供大量见解的地方。让我们进一步分析这些指标,按照它们出现的顺序:

  • ncalls: 调用该函数的次数。

  • tottime: 该函数中花费的总时间,不包括子函数。

  • percall: 每次调用(不包括子函数)的时间:tottime / ncalls

  • cumtime:在这个函数中花费的总时间,包括子函数。

  • percall:包括子函数的每次调用时间:cumtime / ncalls。这个指标与上面的percall指标名称相同,但含义不同。

哪个最有用取决于你的用例。使用默认输出中的-s参数更改排序顺序非常简单。但现在让我们看看使用缓存版本的结果。再次,使用去除输出的方式:

$ python3 -m cProfile T_03_profile_fibonacci.py cache
         51 function calls (21 primitive calls) in 0.000 seconds

   Ordered by: standard name

ncalls tottime cumtime filename:lineno(function)
     1  0.000  0.000 T_03_profile_fibonacci.py:1(<module>)
  31/1  0.000  0.000 T_03_profile_fibonacci.py:5(fibonacci_cached)
     1  0.000  0.000 functools.py:35(update_wrapper)
     1  0.000  0.000 functools.py:479(lru_cache)
     1  0.000  0.000 functools.py:518(decorating_function)
     1  0.000  0.000 {built-in method builtins.exec}
     7  0.000  0.000 {built-in method builtins.getattr}
     1  0.000  0.000 {built-in method builtins.isinstance}
     5  0.000  0.000 {built-in method builtins.setattr}
     1  0.000  0.000 {method 'disable' of '_lsprof.Profiler' ...}
     1  0.000  0.000 {method 'update' of 'dict' objects} 

这次我们看到一个tottime0.000,因为它太快而无法测量。此外,尽管fibonacci_cached函数仍然是执行次数最多的函数,但它只执行了 31 次,而不是 300 万次。

校准剖析器

为了说明profilecProfile之间的区别,让我们再次尝试使用profile模块而不是缓存运行。提醒一下:这会慢得多,所以如果你发现它稍微卡顿,不要感到惊讶:

$ python3 -m profile T_03_profile_fibonacci.py no_cache
         2692558 function calls (22 primitive calls) in 4.541 seconds

   Ordered by: standard name

   ncalls  tottime cumtime filename:lineno(function)
        1    0.000   4.530 :0(exec)
        7    0.000   0.000 :0(getattr)
        1    0.000   0.000 :0(isinstance)
        5    0.000   0.000 :0(setattr)
        1    0.010   0.010 :0(setprofile)
        1    0.000   0.000 :0(update)
        1    0.000   4.530 T_03_profile_fibonacci.py:1(<module>)
2692537/1    4.530   4.530 T_03_profile_fibonacci.py:13(fibonacci)
        1    0.000   0.000 functools.py:35(update_wrapper)
        1    0.000   0.000 functools.py:479(lru_cache)
        1    0.000   0.000 functools.py:518(decorating_function)
        1    0.000   4.541 profile:0(<code object <module> at ...
        0    0.000   0.000 profile:0(profiler) 

代码现在运行速度慢了近 10 倍,唯一的区别是使用纯 Python 的profile模块而不是cProfile模块。这确实表明profile模块存在一个大问题。模块本身的开销足够大,足以扭曲结果,这意味着我们应该考虑到这个偏差。

这正是Profile.calibrate()函数负责的,因为它计算了剖析模块引起的性能偏差。为了计算偏差,我们可以使用以下脚本:

import profile

if __name__ == '__main__':
    profiler = profile.Profile()
    for i in range(10):
        print(profiler.calibrate(100000)) 

数字会有轻微的变化,但你应该能够得到一个关于profile模块引入到你的代码中的性能偏差的合理估计。它实际上在启用和禁用剖析的情况下运行了一小段代码,并计算一个乘数,将其应用于所有结果,使它们更接近实际持续时间。

如果数字仍然变化很大,你可以将试验次数从100000增加到更大。

注意,在许多现代处理器中,CPU 的突发性能(前几秒)与持续性能(2 分钟或更长时间)可能会有很大的差异。

CPU 性能也高度依赖于温度,所以如果你的系统有一个大型的 CPU 散热器或者水冷,在 100% CPU 负载下,它可能需要 20 分钟才能使 CPU 性能变得一致。那 20 分钟之后的偏差将完全无法作为冷 CPU 的偏差使用。

这种校准类型仅适用于profile模块,并且应该有助于实现更准确的结果。对于所有新创建的剖析器,偏差可以全局设置:

import profile

# The number here is bias calculated earlier
profile.Profile.bias = 9.809351906482531e-07 

或者,对于特定的Profile实例:

import profile

profiler = profile.Profile(bias=9.809351906482531e-07) 

注意,一般来说,使用较小的偏差比使用较大的偏差更好,因为大的偏差可能会导致非常奇怪的结果。如果偏差足够大,你甚至可能会得到负的时间值。让我们在我们的斐波那契代码上试一试:

import sys
import pstats
import profile

...
if __name__ == '__main__':
    profiler = profile.Profile(bias=9.809351906482531e-07)
    n = 30

    if sys.argv[-1] == 'cache':
        profiler.runcall(fibonacci_cached, n)
    else:
        profiler.runcall(fibonacci, n)

    stats = pstats.Stats(profiler).sort_stats('calls')
    stats.print_stats() 

在运行时,确实看起来我们使用了一个太大的偏差:

$ python3 T_05_profiler_large_bias.py
      2692539 function calls (3 primitive calls) in -0.746 seconds

   Ordered by: call count

   ncalls tottime cumtime filename:lineno(function)
2692537/1  -0.747  -0.747 T_05_profiler..._bias.py:15(fibonacci)
        1   0.000  -0.746 profile:0(<function fibonacci at ...>)
        1   0.000   0.000 :0(setprofile)
        0   0.000   0.000 profile:0(profiler) 

尽管如此,它仍然显示了代码的正确使用方法。您甚至可以在脚本中使用类似这样的片段将偏差计算包含在内:

import profile

if __name__ == '__main__':
    profiler = profile.Profile()
    profiler.bias = profiler.calibrate(100000) 
profile module. The only cost is the duration of the calibrate() run, and with a small number of trials (say, 10000), it only takes about 0.2 seconds on my current system while still greatly increasing the accuracy of the results. Because of this properly calculated bias, the results can actually be more accurate than the cProfile module.

使用装饰器的选择性分析

使用装饰器计算简单的计时很容易,但分析可以显示更多内容,并且也可以通过装饰器或上下文包装器有选择性地应用。让我们看看 timerprofiler 装饰器:

import cProfile
import datetime
import functools

def timer(function):
    @functools.wraps(function)
    def _timer(*args, **kwargs):
        start = datetime.datetime.now()
        try:
            return function(*args, **kwargs)
        finally:
            end = datetime.datetime.now()
            print(f'{function.__name__}: {end - start}')

    return _timer

def profiler(function):
    @functools.wraps(function)
    def _profiler(*args, **kwargs):
        profiler = cProfile.Profile()
        try:
            profiler.enable()
            return function(*args, **kwargs)
        finally:
            profiler.disable()
            profiler.print_stats()

    return _profiler 

现在我们已经创建了装饰器,我们可以使用它们来分析和计时我们的函数:

@profiler
def profiled_fibonacci(n):
    return fibonacci(n)

@timer
def timed_fibonacci(n):
    return fibonacci(n)
def fibonacci(n):
    if n < 2:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

if __name__ == '__main__':
    timed_fibonacci(32)
    profiled_fibonacci(32) 

代码很简单:只是一个基本的 timerprofiler 装饰器打印一些默认统计信息。哪个最适合您取决于您的用例,当然。timer() 装饰器在开发过程中用于快速性能跟踪和/或合理性检查非常有用。profiler() 装饰器在您积极工作于函数的性能特征时非常出色。

这种选择性分析的附加优势是输出更有限,这有助于可读性,尽管仍然比 timer() 装饰器冗长得多:

$ python3 T_06_selective_profiling.py
timed_fibonacci: 0:00:00.744912
         7049157 function calls (3 primitive calls) in 1.675 seconds

   Ordered by: standard name

   ncalls  tottime cumtime filename:lineno(function)
        1    0.000   1.675 T_06_select...py:31(profiled_fibonacci)
7049155/1    1.675   1.675 T_06_selec...profiling.py:41(fibonacci)
        1    0.000   0.000 {method 'disable' of '_lsprof.Profil... 

如您所见,分析器仍然使代码大约慢了两倍,但绝对是可用的。

使用配置文件统计信息

为了获得更有趣的分析结果,我们将使用 pyperformance.benchmarks.bm_float 脚本进行分析。

pyperformance 库是针对 CPython 解释器优化的官方 Python 基准测试库。它包含大量(持续增长)的基准测试,以监控 CPython 解释器在许多场景下的性能。

可以通过 pip 进行安装:

$ pip3 install pyperformance 

首先,让我们使用此脚本创建统计信息:

import sys
import pathlib
import pstats
import cProfile

import pyperformance

# pyperformance doesn't expose the benchmarks anymore so we need
# to manually add the path
pyperformance_path = pathlib.Path(pyperformance.__file__).parent
sys.path.append(str(pyperformance_path / 'data-files'))

# Now we can import the benchmark
from benchmarks.bm_float import run_benchmark as bm_float  # noqa

def benchmark():
    for i in range(10):
        bm_float.benchmark(bm_float.POINTS)

if __name__ == '__main__':
    profiler = cProfile.Profile()
    profiler.runcall(benchmark)
    profiler.dump_stats('bm_float.profile')
    stats = pstats.Stats('bm_float.profile')
    stats.strip_dirs()
    stats.sort_stats('calls', 'cumtime')
    stats.print_stats(10) 

在执行脚本时,您应该得到类似以下内容:

$ python3 T_07_profile_statistics.py
Sun May  1 06:14:26 2022    bm_float.profile

         6000012 function calls in 2.501 seconds

   Ordered by: call count, cumulative time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
  1000000    0.446    0.000    0.682    0.000 run_benchmark.py:15(__init__)
  1000000    0.525    0.000    0.599    0.000 run_benchmark.py:23(normalize)
  1000000    0.120    0.000    0.120    0.000 {built-in method math.cos}
  1000000    0.116    0.000    0.116    0.000 {built-in method math.sin}
  1000000    0.073    0.000    0.073    0.000 {built-in method math.sqrt}
   999990    0.375    0.000    0.375    0.000 run_benchmark.py:32(maximize)
       10    0.625    0.063    2.446    0.245 run_benchmark.py:46(benchmark)
       10    0.165    0.017    0.540    0.054 run_benchmark.py:39(maximize)
        1    0.055    0.055    2.501    2.501 T_07_profile_statistics.py:17(benchmark)
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects} 

运行脚本后,您应该有一个包含分析结果的 bm_float.profile 文件。正如我们在脚本中所见,这些统计信息可以通过 pstats 模块查看。

在某些情况下,结合多个测量的结果可能很有趣。这可以通过指定多个文件或使用 stats.add(*filenames) 实现。

将这些配置文件结果保存到文件中的主要优势是,多个应用程序支持这种输出,并且可以以更清晰的方式可视化它。一个选项是 SnakeViz,它使用您的网络浏览器以交互方式渲染配置文件结果。此外,我们还有 QCacheGrind,这是一个非常好的配置文件统计信息可视化器,但需要一些手动编译才能运行,或者当然需要寻找二进制文件。

让我们看看 QCacheGrind 的输出。在 Windows 的情况下,QCacheGrindWin 软件包提供了一个二进制文件,而在 Linux 中,它很可能通过您的软件包管理器提供,而在 OS X 中,您可以尝试 brew install qcachegrind

然而,您还需要一个额外的软件包:pyprof2calltree 软件包。它将 profile 输出转换为 QCacheGrind 可以理解的格式。因此,在简单的 pip install pyprof2calltree 之后,我们现在可以将 profile 文件转换为 callgrind 文件:

$ pyprof2calltree -i bm_float.profile -o bm_float.callgrind
writing converted data to: bm_float.callgrind
$ qcachegrind bm_float.callgrind 

这会导致QCacheGrind应用程序的运行。切换到适当的标签后,你应该能看到以下截图类似的内容:

图片

图 12.1:QCacheGrind

对于这样一个简单的脚本,几乎所有的输出都是有效的。然而,对于完整的应用程序,像 QCacheGrind 这样的工具是无价的。查看 QCacheGrind 生成的输出,可以立即看出哪个进程花费了最多时间。右上角的布局显示,如果花费的时间更多,则更大的矩形,这是对使用的 CPU 时间块非常有用的可视化。左边的列表与cProfile非常相似,因此没有什么新内容。右下角的树可能非常有价值,也可能毫无价值,就像在这个例子中一样。它显示了函数中占用的 CPU 时间百分比,更重要的是,该函数与其他函数的关系。

由于这些工具根据输入进行扩展,因此结果对几乎所有应用程序都很有用。无论函数需要 100 毫秒还是 100 分钟,都没有区别——输出将清楚地显示慢的部分,这正是我们试图修复的部分。

行性能分析器 – 按行跟踪性能

line_profiler实际上不是一个与 Python 捆绑的包,但它非常实用,不能忽视。虽然常规的profile模块在某个块内对所有的(子)函数进行性能分析,但line_profiler允许对函数中的每一行进行逐行性能分析。斐波那契函数在这里并不适用,但我们可以使用素数生成器。但首先,安装line_profiler

$ pip3 install line_profiler 

现在我们已经安装了line_profiler模块(以及kernprof命令),让我们测试line_profiler

import itertools

@profile
def primes():
    n = 2
    primes = set()
    while True:
        for p in primes:
            if n % p == 0:
                break
        else:
            primes.add(n)
            yield n
        n += 1

if __name__ == '__main__':
    total = 0
    n = 2000
    for prime in itertools.islice(primes(), n):
        total += prime

    print('The sum of the first %d primes is %d' % (n, total)) 

你可能想知道profile装饰器是从哪里来的。它起源于line_profiler模块,这就是为什么我们必须使用kernprof命令运行脚本的原因:

$ kernprof --line-by-line T_08_line_profiler.py
The sum of the first 2000 primes is 16274627
Wrote profile results to T_08_line_profiler.py.lprof 

正如命令所说,结果已经写入T_08_line_profiler.py.lprof文件,因此我们现在可以查看该文件的输出。为了便于阅读,我们已跳过Line #列:

$ python3 -m line_profiler T_08_line_profiler.py.lprof
Timer unit: 1e-06 s

Total time: 1.34623 s
File: T_08_line_profiler.py
Function: primes at line 4
   Hits         Time  Per Hit   % Time  Line Contents
=====================================================
                                        @profile
                                        def primes():
      1          3.0      3.0      0.0      n = 2
      1          1.0      1.0      0.0      primes = set()
                                            while True:
2055131     625266.0      0.3     46.4          for p in primes:
2053131     707403.0      0.3     52.5              if n % p == 0:
  15388       4893.0      0.3      0.4                  break
                                                else:
   2000       1519.0      0.8      0.1              primes.add(n)
   2000        636.0      0.3      0.0              yield n
  17387       6510.0      0.4      0.5          n += 1 

多么棒的输出,不是吗?它使得在一段代码中找到慢的部分变得非常简单。在这段代码中,缓慢的原因显然来自循环,但在其他代码中可能并不那么明显。

此模块也可以作为 IPython 扩展添加,这将在 IPython 中启用%lprun命令。要从 IPython shell 中加载扩展,可以使用load_ext命令,%load_ext line_profiler

我们已经看到了几种测量 CPU 性能和执行时间的方法。现在是时候看看如何提高性能了。由于这主要适用于 CPU 性能而不是内存性能,我们将首先介绍这一点。在本章的后面部分,我们将探讨内存使用和泄漏。

提高执行时间

关于性能优化可以有很多说法,但说实话,如果你已经阅读了这本书到目前为止的所有内容,你就知道大多数 Python 特定的快速代码编写技术。整体应用程序性能最重要的因素始终是算法的选择,以及由此扩展的数据结构。在listO(n))中搜索一个项目几乎总是比在dictsetO(1))中搜索一个项目更糟糕,正如我们在第四章中看到的。

自然,还有更多因素和技巧可以帮助使你的应用程序更快。然而,所有性能提示的极度简略版本非常简单:尽可能少做。无论你使计算和操作多快,什么都不做总是最快的。以下章节将涵盖 Python 中最常见的性能瓶颈,并测试一些关于性能的常见假设,例如try/except块与if语句的性能,这在许多语言中可能产生巨大的影响。

本节中的一些技巧将在内存和执行时间之间进行权衡;其他技巧则会在可读性和性能之间进行权衡。当不确定时,默认选择可读性,并且只有在必要时才提高性能。

使用正确的算法

在任何应用程序中,选择正确的算法无疑是最重要的性能特征,这就是为什么我要重复强调这一点,以说明错误选择的结果。考虑以下情况:

In [1]: a = list(range(1000000))

In [2]: b = dict.fromkeys(range(1000000))

In [3]: %timeit 'x' in a
12.2 ms ± 245 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

In [4]: %timeit 'x' in b
40.1 ns ± 0.446 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each) 

检查一个项目是否在list中是一个O(n)操作,而检查一个项目是否在dict中是一个O(1)操作。当n=1000000时,这会带来巨大的差异;在这个简单的测试中,我们可以看到对于一百万个项目,它快了 300,000 倍。

大 O 符号(O(...))在第四章中有更详细的介绍,但我们可以提供一个快速回顾。

O(n)表示对于len(some_list) = nlist,执行操作需要n步。因此,O(1)表示无论集合的大小如何,它都需要恒定的时间。

所有其他性能提示加在一起可能使你的代码速度提高一倍,但使用适合工作的正确算法可以带来更大的改进。使用一个需要O(n)时间而不是O(n²)时间的算法,当n=1000时,将使你的代码快1000倍,而对于更大的n,差异只会进一步扩大。

全局解释器锁

CPython 解释器中最神秘的部分之一是全局解释器锁GIL),这是一个互斥锁mutex),用于防止内存损坏。Python 内存管理器不是线程安全的,这就是为什么需要 GIL。没有 GIL,多个线程可能会同时更改内存,导致各种意外和可能危险的结果。GIL 在第十四章中有更详细的介绍。

GIL 在现实生活中的应用有什么影响?在单线程应用程序中,它没有任何区别,实际上是一种非常快速的内存一致性方法。

然而,在多线程应用程序中,这可能会稍微减慢你的应用程序,因为一次只能有一个线程访问 GIL。如果你的代码需要频繁访问 GIL,那么进行一些重构可能会有所帮助。

幸运的是,Python 提供了一些其他并行处理选项。我们将在第十三章中看到的asyncio模块,可以通过在等待慢速操作时切换任务来提供很大帮助。在第十四章中,我们将看到multiprocessing库,它允许我们同时使用多个处理器。

try 与 if

在许多语言中,try/except类型的块会带来相当大的性能损失,但在 Python 中,只要你没有进入except块,这并不是问题。如果你触发了except,它可能比if语句稍微重一些,但在大多数情况下并不明显。

并非if语句本身很重,而是如果你预期你的try/except在大多数情况下都会成功,只有在罕见情况下才会失败,那么这绝对是一个有效的替代方案。但是,一如既往地,要关注可读性和传达代码的目的。如果代码的意图使用if语句更清晰,就使用if语句。如果try/except以更好的方式传达意图,就使用它。

大多数编程语言都依赖于LBYL(先检查后执行)理念。这意味着你在尝试之前总是进行检查,所以如果你要从dict中获取some_key,你应该使用:

if some_key in some_dict:
    process_value(some_dict[some_key]) 

因为你总是在做if,这暗示some_key通常不是some_dict的一部分。

在 Python 中,当适用时,通常使用EAFP(先做后检查)理念。这意味着代码假设一切都会按预期工作,但仍然会捕获错误:

try:
    process_value(some_dict[some_key])
except KeyError:
    pass 

这两个例子功能上大致相同,但后者给出了你期望键是可用的,并在需要时捕获错误的印象。这是 Python 的 Zen(明确优于隐含)适用的一个案例。

上述代码的唯一缺点是,你可能会意外地捕获process_value()KeyError,所以如果你想避免这种情况,你应该使用以下代码:

try:
    value = some_dict[some_key]
except KeyError:
    pass
else:
    process_value(value) 

你使用哪个主要取决于个人喜好,但应该记住的是,在 Python 中,这两种选项都是完全有效的,并且性能相似。

列表与生成器

使用生成器懒加载代码几乎总是比计算整个数据集更好。性能优化的最重要规则可能是你不应该计算你不会使用的东西。如果你不确定你是否需要它,就不要计算它。

不要忘记你可以轻松地链式多个生成器,这样只有在实际需要时才会进行计算。但务必小心,这不会导致重复计算;通常来说,使用itertools.tee()比完全重新计算结果更好。

回顾一下第七章中提到的itertools.tee(),一个常规生成器只能被消费一次,所以如果你需要两次或更多次处理结果,可以使用itertools.tee()来存储中间结果:

>>> import itertools

# Without itertools.tee:
>>> generator = itertools.count()
>>> list(itertools.islice(generator, 5))
[0, 1, 2, 3, 4]
>>> list(itertools.islice(generator, 5))
[5, 6, 7, 8, 9]

>>> generator_a, generator_b = itertools.tee(itertools.count())
>>> list(itertools.islice(generator_a, 5))
[0, 1, 2, 3, 4]
>>> list(itertools.islice(generator_b, 5))
[0, 1, 2, 3, 4] 

如你所见,如果你忘记在这里使用itertools.tee(),你只会处理一次结果,并且它们会处理不同的值。另一种修复方法是使用list()并存储中间结果,但这会消耗更多的内存,并且你需要预先计算所有项目,而不知道你是否真的需要它们。

字符串连接

你可能看到过基准测试说使用+=比连接字符串慢得多,因为str对象(就像bytes一样)是不可变的。结果是每次你在字符串上使用+=时,它都必须创建一个新的对象。在某个时刻,这确实造成了很大的差异。然而,在 Python 3 中,大多数差异都已经消失了:

In [1]: %%timeit
   ...: s = ''
   ...: for i in range(1000000):
   ...:     s += str(i)
   ...:
1 loops, best of 3: 362 ms per loop

In [2]: %%timeit
   ...: ss = []
   ...: for i in range(1000000):
   ...:     ss.append(str(i))
   ...: s = ''.join(ss)
   ...:
1 loops, best of 3: 332 ms per loop

In [3]: %timeit ''.join(str(i) for i in range(1000000))
1 loops, best of 3: 324 ms per loop

In [4]: %timeit ''.join([str(i) for i in range(1000000)])
1 loops, best of 3: 294 ms per loop 

当然,还有一些差异,但它们非常小,我建议你简单地忽略它们,并选择最易读的选项。

添加与生成器的比较

就像字符串连接一样,在较老的 Python 版本中,循环中的添加操作速度显著较慢,但现在差异已经小到可以忽略不计:

In [1]: %%timeit
   ...: x = 0
   ...: for i in range(1000000):
   ...:     x += i
   ...:
10 loops, best of 3: 73.2 ms per loop

In [2]: %timeit x = sum(i for i in range(1000000))
10 loops, best of 3: 75.3 ms per loop

In [3]: %timeit x = sum([i for i in range(1000000)])
10 loops, best of 3: 71.2 ms per loop

In [4]: %timeit x = sum(range(1000000))
10 loops, best of 3: 25.6 ms per loop 

然而,真正有帮助的是让 Python 使用原生函数内部处理所有操作,正如最后一个例子所示。

map()与生成器和列表推导式的比较

再次强调,可读性通常比性能更重要,所以只有在确实有影响的情况下才进行重写。有些情况下map()比列表推导式和生成器快,但这仅限于map()函数可以使用预定义函数的情况。一旦你需要使用lambda,实际上会更慢。不过这并不重要,因为可读性应该是关键。如果map()使你的代码比生成器或列表推导式更易读,那么你可以自由使用它。否则,我不推荐使用它:

In [1]: %timeit list(map(lambda x: x/2, range(1000000)))
10 loops, best of 3: 182 ms per loop

In [2]: %timeit list(x/2 for x in range(1000000))
10 loops, best of 3: 122 ms per loop

In [3]: %timeit [x/2 for x in range(1000000)]
10 loops, best of 3: 84.7 ms per loop 

如你所见,列表推导式比生成器快得多。在许多情况下,我仍然会推荐使用生成器而不是列表推导式,这主要是因为内存使用和潜在的惰性。

如果在生成 1,000 个项目时,你只打算使用前 10 个项目,那么计算完整的项目列表仍然会浪费很多资源。

缓存

我们已经在 第六章 中介绍了 functools.lru_cache 装饰器,装饰器 – 通过装饰实现代码重用,但它的作用不容小觑。无论你的代码有多快、多聪明,不需要计算结果总是更好的,这正是缓存的作用。根据你的使用场景,有许多选项可供选择。在一个简单的脚本中,functools.lru_cache 是一个非常好的选择,但在应用程序的多次执行之间,cPickle 模块也可以成为救命稻草。

我们在本章的 cProfile 部分已经看到了 fibonacci_cached 函数的这种影响,该函数使用了 functools.lru_cache()

然而,有几个场景需要更强大的解决方案:

  • 如果你需要在脚本多次执行之间进行缓存

  • 如果你需要跨多个进程共享缓存

  • 如果你需要在多个服务器之间共享缓存

至少对于前两种场景,你可以将缓存写入本地 pickle/CSV/JSON/YAML/DBM 等文件。这是一个完全有效的解决方案,我经常使用。

如果你需要更强大的解决方案,我强烈建议查看 Redis。Redis 服务器是一个完全基于内存的服务器,速度极快,并提供许多有用的数据结构。如果你看到有关使用 Memcached 提高性能的文章或教程,只需将 Memcached 替换为 Redis 即可。Redis 在各个方面都优于 Memcached,并且在其最基本的形式中,API 是兼容的。

懒加载导入

应用程序加载时间的一个常见问题是,程序开始时立即加载所有内容,而实际上,对于许多应用程序来说,这实际上并不需要,应用程序的某些部分只有在实际使用时才需要加载。为了方便起见,你可以偶尔将导入移动到函数内部,以便按需加载。

虽然在某些情况下这是一个有效的策略,但我通常不推荐以下两个原因:

  • 它会使你的代码更不清晰;将所有导入以相同风格放在文件顶部可以提高可读性。

  • 它并不会使代码更快,因为它只是将加载时间移到了不同的部分。

使用 slots

__slots__ 功能是由 Guido van Rossum 编写的,旨在提高 Python 性能。实际上,__slots__ 功能的作用是为类指定一个固定的属性列表。当使用 __slots__ 时,会对类进行一些更改,并必须考虑一些(副作用):

  • 所有属性都必须在 __slots__ 中显式命名。如果 some_variable 不在 __slots__ 中,则无法执行 some_instance.some_variable = 123

  • 由于 __slots__ 中的属性列表是固定的,因此不再需要 __dict__ 属性,这节省了内存。

  • 属性访问更快,因为没有通过 __dict__ 进行中间查找。

  • 如果两个父类都定义了 __slots__,则无法使用多重继承。

那么,__slots__能给我们带来多少性能上的好处呢?让我们来测试一下:

import timeit
import functools

class WithSlots:
    __slots__ = 'eggs',

class WithoutSlots:
    pass

with_slots = WithSlots()
no_slots = WithoutSlots()

def test_set(obj):
    obj.eggs = 5

def test_get(obj):
    return obj.eggs

timer = functools.partial(
    timeit.timeit,
    number=20000000,
    setup='\n'.join((
        f'from {__name__} import with_slots, no_slots',
        f'from {__name__} import test_get, test_set',
    )),
)
for function in 'test_set', 'test_get':
    print(function)
    print('with slots', timer(f'{function}(with_slots)'))
    print('with slots', timer(f'{function}(no_slots)')) 

当我们实际运行这段代码时,我们可以肯定地看到使用__slots__带来的某些改进:

$ python3 T_10_slots_performance.py
test_set
with slots 1.748628467
with slots 2.0184642979999996
test_get
with slots 1.5832197570000002
with slots 1.6575410809999997 

在大多数情况下,我会说 5-15%的性能差异并不会对你有很大帮助。然而,如果它应用于接近应用程序核心且经常执行的一小段代码,它可能会有所帮助。

不要期望这种方法能带来奇迹,但当你需要时请使用它。

使用优化库

这是一个非常广泛的建议,但仍然很有用。如果你有一个高度优化的库适合你的目的,你很可能无法在不付出大量努力的情况下超越其性能。例如numpypandasscipysklearn等库在性能上高度优化,它们的原生操作可以非常快。如果它们适合你的目的,请务必尝试一下。

在你能够使用numpy之前,你需要安装它:pip3 install numpy

仅为了说明numpy与纯 Python 相比有多快,请参考以下内容:

In [1]: import numpy

In [2]: a = list(range(1000000))

In [3]: b = numpy.arange(1000000)

In [4]: %timeit c = [x for x in a if x > 500000]
10 loops, best of 3: 44 ms per loop

In [5]: %timeit d = b[b > 500000]
1000 loops, best of 3: 1.61 ms per loop 

numpy代码与 Python 代码完全相同,只是它使用numpy数组而不是 Python 列表。这个小小的差异使得代码的速度提高了 25 倍以上。

即时编译

即时编译JIT)是一种在运行时动态编译(应用程序的)部分的方法。因为运行时可以提供更多信息,这可以产生巨大的影响,使你的应用程序运行得更快。

当谈到即时编译时,你目前有三个选项:

  • Pyston:一个替代品,目前仅支持 Linux,是 CPython 兼容的 Python 解释器。

  • Pypy:一个真正快速的替代 Python 解释器,但不完全兼容 CPython。

  • Numba:一个允许按函数进行即时编译并在 CPU 或 GPU 上执行的包。

  • CPython 3.12 和 3.13?在撰写本文时,关于即将发布的 Python 版本的数据很少,但有计划大幅提高 CPython 解释器的性能。具体能实现多少以及效果如何目前尚不清楚,但雄心勃勃的计划是在接下来的 5 个版本中使 CPython 快 5 倍(3.10 是该系列的第一版)。预期将在 CPython 3.12 中添加即时编译,并在 3.13 中进一步扩展。

如果你正在寻找在现有项目中实现全局即时编译,我目前可以推荐尝试 Pyston。它是一个 CPython 分支,承诺在不修改任何代码的情况下提高大约 30%的性能。此外,因为它与 CPython 兼容,你仍然可以使用常规的 CPython 模块。

然而,它的缺点是目前仅支持 Linux 系统,并且,正如分支通常的情况一样,它落后于当前的 Python 版本。在撰写本文时,CPython 是 Python 3.10.1,而 Pyston 是 Python 3.8。

如果你不需要与所有 CPython 模块兼容,并且不需要 Python 中太新的功能,PyPy3 在许多情况下也可以提供惊人的性能。它们支持到 Python 3.7,而主 Python 版本在撰写本文时是 3.10.1。这使得 PyPy 在功能上比 CPython 约落后 2-3 年,但我怀疑这不会是一个大问题。Python 3.7、3.8、3.9 和 3.10 之间的差异主要是增量性的,而 Python 3.7 已经是一个非常完善的 Python 版本。

numba 包为你提供了选择性的 JIT 编译,允许你标记与 JIT 编译器兼容的函数。本质上,如果你的函数遵循仅基于输入进行计算的函数式编程范式,那么它很可能与 JIT 编译器兼容。

这里是一个如何使用 numba JIT 编译器的基本示例:

import numba

@numba.jit
def sum(array):
    total = 0.0
    for value in array:
        total += value
    return value 

如果你正在使用 numpypandas,你很可能从查看 numba 中受益。

另一个值得注意的有趣事实是,numba 不仅支持 CPU 优化的执行,还支持 GPU。这意味着对于某些操作,你可以使用显卡中的快速处理器来处理结果。

将代码的部分转换为 C

我们将在第十七章“C/C++ 扩展、系统调用和 C/C++ 库”中了解更多关于这个内容,但如果确实需要高性能,那么一个本地的 C 函数可以非常有帮助。这甚至不必那么困难;Cython 模块使得用接近原生 C 代码的性能编写代码的部分变得非常简单。

以下是一个来自 Cython 手册的示例,用于估算 π 的值:

cdef inline double recip_square(int i):
    return 1./(i*i)

def approx_pi(int n=10000000):
    cdef double val = 0.
    cdef int k
    for k in range(1,n+1):
        val += recip_square(k)
    return (6 * val)**.5 

虽然有一些小的差异,例如 cdef 而不是 def,以及类型定义,如 int i 而不是仅仅 i 用于值和参数,但代码在很大程度上与常规 Python 相同,但肯定要快得多。

内存使用

到目前为止,我们只是简单地查看执行时间,而很大程度上忽略了脚本的内存使用。在许多情况下,执行时间是最重要的,但内存使用不应被忽视。在几乎所有情况下,CPU 和内存都是可以互换的;一个算法要么使用大量的 CPU 时间,要么使用大量的内存,这意味着两者都很重要。

在本节中,我们将探讨以下内容:

  • 分析内存使用

  • 当 Python 泄露内存以及如何避免这些情况

  • 如何减少内存使用

tracemalloc

监控内存使用曾经是通过外部 Python 模块,如 DowserHeapy 来实现的。虽然这些模块仍然有效,但现在由于 tracemalloc 模块的存在,它们部分已经过时。让我们尝试一下 tracemalloc 模块,看看现在监控内存使用有多简单:

import tracemalloc

if __name__ == '__main__':
    tracemalloc.start()

    # Reserve some memory
    x = list(range(1000000))

    # Import some modules
    import os
    import sys
    import asyncio

    # Take a snapshot to calculate the memory usage
    snapshot = tracemalloc.take_snapshot()
    for statistic in snapshot.statistics('lineno')[:10]:
        print(statistic) 

这导致:

$ python3 T_11_tracemalloc.py
T_11_tracemalloc.py:8: size=34.3 MiB, count=999746, average=36 B
<frozen importlib._bootstrap_external>:587: size=1978 KiB, coun...
<frozen importlib._bootstrap>:228: size=607 KiB, count=5433, av...
abc.py:85: size=32.6 KiB, count=155, average=215 B
enum.py:172: size=26.2 KiB, count=134, average=200 B
collections/__init__.py:496: size=24.1 KiB, count=117, average=...
enum.py:225: size=23.3 KiB, count=451, average=53 B
enum.py:391: size=15.0 KiB, count=21, average=729 B
<frozen importlib._bootstrap_external>:64: size=14.3 KiB, count...
enum.py:220: size=12.2 KiB, count=223, average=56 B 

你可以很容易地看到代码的每一部分分配了多少内存,以及它可能在哪些地方被浪费。虽然可能仍然不清楚哪个部分实际上导致了内存使用,但也有一些选项,我们将在以下章节中看到。

内存分析器

memory_profiler 模块与之前讨论的 line_profiler 非常相似,但用于内存使用。安装它就像 pip install memory_profiler 一样简单,但强烈推荐(在 Windows 上是必需的)安装可选的 pip install psutil,因为它可以大幅提高你的性能。为了测试 memory_profiler,我们将使用以下脚本:

import memory_profiler

@memory_profiler.profile
def main():
    n = 100000
    a = [i for i in range(n)]
    b = [i for i in range(n)]
    c = list(range(n))
    d = list(range(n))
    e = dict.fromkeys(a, b)
    f = dict.fromkeys(c, d)

if __name__ == '__main__':
    main() 

注意,我们在这里实际上导入了 memory_profiler,尽管这不是严格必要的。它也可以通过 python3 -m memory_profiler your_scripts.py 执行:

Filename: CH_12_performance/T_12_memory_profiler.py

Mem usage  Increment  Occurrences  Line Contents
===============================================
 14.7 MiB   14.7 MiB           1  @memory_profiler.profile
                                  def main():
 14.7 MiB    0.0 MiB           1      n = 100000
 18.5 MiB    3.8 MiB      100003      a = [i for i in range(n)]
 22.4 MiB    3.9 MiB      100003      b = [i for i in range(n)]
 26.3 MiB    3.9 MiB           1      c = list(range(n))
 30.2 MiB    3.9 MiB           1      d = list(range(n))
 39.9 MiB    9.8 MiB           1      e = dict.fromkeys(a, b)
 44.9 MiB    5.0 MiB           1      f = dict.fromkeys(c, d)
 44.9 MiB    0.0 MiB           1      assert e
 44.9 MiB    0.0 MiB           1      assert f 

尽管一切运行如预期,但你可能仍在想这里代码行使用的内存量为何会有所不同。

为什么 e 占用 9.8 MiBf 占用 5.0 MiB?这是由 Python 内存分配代码引起的;它以较大的块保留内存,这些块在内部细分并重复使用。另一个问题是 memory_profiler 在内部进行快照,这导致在某些情况下内存被错误地分配给错误的变量。这些变化应该足够小,不会在最终结果中造成大的差异,但一些变化是可以预见的。

此模块也可以作为 IPython 扩展添加,这将在 IPython 中启用 %mprun 命令。要从 IPython 壳中加载扩展,可以使用 load_ext 命令:%load_ext memory_profiler。另一个非常有用的命令是 %memit,它是 %timeit 命令的内存等效命令。

内存泄漏

这些模块的使用通常限于搜索内存泄漏。特别是,tracemalloc 模块有几个功能使得这变得相当容易。Python 内存管理系统相当简单;它有一个简单的引用计数器来查看对象是否(仍然)被使用。虽然这在大多数情况下工作得很好,但当涉及到循环引用时,它很容易引入内存泄漏。带有泄漏检测代码的内存泄漏的基本原理如下:

 1 import tracemalloc
  2
  3
  4 class SomeClass:
  5     pass
  6
  7
  8 if __name__ == '__main__':
  9     # Initialize some variables to ignore them from the leak
 10     # detection
 11     n = 100000
 12
 13     tracemalloc.start()
 14     # Your application should initialize here
 15
 16     snapshot_a = tracemalloc.take_snapshot()
 17     instances = []
 18
 19     # This code should be the memory leaking part
 20     for i in range(n):
 21         a = SomeClass()
 22         b = SomeClass()
 23         # Circular reference. a references b, b references a
 24         a.b = b
 25         b.a = a
 26         # Force Python to keep the object in memory for now
 27         instances.append(a)
 28
 29     # Clear the list of items again. Now all memory should be
 30     # released, right?
 31     del instances
 32     snapshot_b = tracemalloc.take_snapshot()
 33
 34     statistics = snapshot_b.compare_to(snapshot_a, 'lineno')
 35     for statistic in statistics[:10]:
 36         print(statistic) 

上述代码中的行号提供为 tracemalloc 输出的参考,并且不是代码的功能部分。

这段代码中的大问题是,我们有两个相互引用的对象。正如我们所见,a.b 引用了 b,而 b.a 引用了 a。这个循环使得 Python 无法立即理解这些对象可以从内存中安全删除。

让我们看看这段代码实际上泄漏有多严重:

$ python3 T_12_memory_leaks.py
T_12_memory_leaks.py:25: size=22.1 MiB (+22.1 MiB), count=199992 (+199992), average=116 B
T_12_memory_leaks.py:24: size=22.1 MiB (+22.1 MiB), count=199992 (+199992), average=116 B
T_12_memory_leaks.py:22: size=4688 KiB (+4688 KiB), count=100000 (+100000), average=48 B
T_12_memory_leaks.py:21: size=4688 KiB (+4688 KiB), count=100000 (+100000), average=48 B
tracemalloc.py:423: size=88 B (+88 B), count=2 (+2), average=44 B
tracemalloc.py:560: size=48 B (+48 B), count=1 (+1), average=48 B
tracemalloc.py:315: size=40 B (+40 B), count=1 (+1), average=40 B
T_12_memory_leaks.py:20: size=28 B (+28 B), count=1 (+1), average=28 B 

这个例子显示了由于近 200,000 个 SomeClass 实例而导致的 22.1 兆字节的泄漏。Python 正确地让我们知道,这段内存是在第 24 行和第 25 行分配的,这真的有助于在尝试确定应用程序中导致内存使用的部分时。

Python 垃圾回收器(gc)足够智能,最终会清理像这样的循环引用,但它不会在达到一定限制之前清理它们。关于这一点,我们稍后会详细介绍。

循环引用

当您想要有一个不会引起内存泄漏的循环引用时,weakref模块是可用的。它创建的引用不计入对象引用计数。在我们查看weakref模块之前,让我们通过 Python 垃圾回收器(gc)的眼睛来看看对象引用本身:

import gc

class SomeClass(object):
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f'<{self.__class__.__name__}: {self.name}'

# Create the objects
a = SomeClass('a')
b = SomeClass('b')
# Add some circular references
a.b = a
b.a = b

# Remove the objects
del a
del b

# See if the objects are still there
print('Before manual collection:')
for object_ in gc.get_objects():
    if isinstance(object_, SomeClass):
        print('\t', object_, gc.get_referents(object_))

print('After manual collection:')
gc.collect()
for object_ in gc.get_objects():
    if isinstance(object_, SomeClass):
        print('\t', object_, gc.get_referents(object_))

print('Thresholds:', gc.get_threshold()) 

首先,我们创建了两个SomeClass的实例,并在它们之间添加了一些循环引用。一旦完成,我们就从内存中删除它们,但它们实际上并不会被删除,直到垃圾回收器运行。

为了验证这一点,我们通过gc.get_objects()检查内存中的对象,直到我们告诉垃圾回收器手动收集,它们都会保留在内存中。

一旦我们运行gc.collect()来手动调用垃圾回收器,对象就会从内存中消失:

$ python3 T_14_garbage_collection.py
Before manual collection:
         <SomeClass: a> [{'name': 'a', 'b': <SomeClass: a>}, <class '__main__.SomeClass'>]
         <SomeClass: b> [{'name': 'b', 'a': <SomeClass: b>}, <class '__main__.SomeClass'>]
After manual collection:
Thresholds: (700, 10, 10) 

现在,您可能会想知道,您是否总是需要手动调用gc.collect()来删除这些引用?不,这不是必需的,因为 Python 垃圾回收器会在达到阈值时自动收集。

默认情况下,Python 垃圾回收器的阈值设置为700, 10, 10,用于收集三个代的对象。收集器跟踪 Python 中的所有内存分配和释放,一旦分配的数量减去释放的数量达到700,对象要么不再被引用时被移除,要么如果它仍然有引用,则移动到下一代。对于第 2 代和第 3 代也是如此,尽管阈值较低为 10。

这引发了一个问题:在哪里以及何时手动调用垃圾回收器是有用的?由于 Python 内存分配器会重用内存块,并且很少释放它们,对于长时间运行的脚本,垃圾回收器非常有用。这正是我推荐使用它的地方:在内存受限的环境中长时间运行的脚本,以及在您分配大量内存之前。如果您在执行内存密集型操作之前调用垃圾回收器,您可以最大限度地提高 Python 之前预留的内存的重用率。

使用垃圾回收器分析内存使用情况

gc模块在查找内存泄漏时也能帮您很多忙。tracemalloc模块可以显示占用最多内存的字节数,但gc模块可以帮助您找到最常出现的对象类型(例如,SomeClassintlist)。只是在设置垃圾回收器调试设置(如gc.set_debug(gc.DEBUG_LEAK))时要小心;即使您没有预留任何内存,这也会返回大量的输出。让我们看看最基本脚本之一的结果:

import gc
import collections
if __name__ == '__main__':
    objects = collections.Counter()
    for object_ in gc.get_objects():
        objects[type(object_)] += 1

    print(f'Different object count: {len(objects)}')
    for object_, count in objects.most_common(10):
        print(f'{count}: {object_}') 

现在,当我们运行代码时,你可以看到这样一个简单的脚本添加到我们的内存中的内容:

$ python3 T_15_garbage_collection_viewing.py
Different object count: 42
1058: <class 'wrapper_descriptor'>
887: <class 'function'>
677: <class 'method_descriptor'>
652: <class 'builtin_function_or_method'>
545: <class 'dict'>
484: <class 'tuple'>
431: <class 'weakref'>
251: <class 'member_descriptor'>
238: <class 'getset_descriptor'>
76: <class 'type'> 

如你所见,实际上有 42 种不同的对象类型应该在这里显示,但即使没有这些,内存中不同对象的数量也相当惊人,至少在我看来。只需一点额外的代码,输出就可以迅速爆炸,如果没有显著的过滤,将变得无法使用。

弱引用

让垃圾收集器的工作变得更简单的一个简单方法是使用弱引用。这些是对变量的引用,在计算变量的引用数时不会被包括在内。由于垃圾收集器在引用计数达到零时从内存中删除对象,这可以帮助大量减少内存泄漏。

在前面的例子中,我们看到对象直到我们手动调用gc.collect()才会被删除。现在我们将看到如果我们使用weakref模块会发生什么:

import gc
import weakref
class SomeClass(object):
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return '<%s: %s>' % (self.__class__.__name__, self.name)

def print_mem(message):
    print(message)
    for object_ in gc.get_objects():
        if isinstance(object_, SomeClass):
            print('\t', object_, gc.get_referents(object_))

# Create the objects
a = SomeClass('a')
b = SomeClass('b')

# Add some weak circular references
a.b = weakref.ref(a)
b.a = weakref.ref(b)

print_mem('Objects in memory before del:')

# Remove the objects
del a
del b

# See if the objects are still there
print_mem('Objects in memory after del:') 

现在让我们看看这次还剩下什么:

$ python3 T_16_weak_references.py
Objects in memory before del:
         <SomeClass: a> [{'name': 'a', 'b': ...}, ...]
         <SomeClass: b> [{'name': 'b', 'a': ...}, ...]
Objects in memory after del: 

完美——在del之后,内存中不再存在SomeClass的实例,这正是我们希望看到的。

Weakref 的限制和陷阱

你可能想知道当你仍然尝试引用已经删除的weakref时会发生什么。正如你所预期的那样,对象现在已经不存在了,所以你不能再使用它了。更重要的是,并不是所有对象都可以通过弱引用直接使用:

>>> import weakref

>>> weakref.ref(dict(a=123))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: cannot create weak reference to 'dict' object

>>> weakref.ref([1, 2, 3])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: cannot create weak reference to 'list' object

>>> weakref.ref('test')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: cannot create weak reference to 'str' object

>>> weakref.ref(b'test')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: cannot create weak reference to 'bytes' object

>>> a = weakref.WeakValueDictionary(a=123)
Traceback (most recent call last):
    ...
TypeError: cannot create weak reference to 'int' object 

尽管如此,我们仍然可以使用weakref来为自定义类,因此我们可以在创建weakref之前对这些类型进行子类化:

>>> class CustomDict(dict):
...     pass

>>> weakref.ref(CustomDict())
<weakref at 0x...; dead> 

对于dictset实例,weakref库还有weakref.WeakKeyDictionaryweakref.WeakValueDictionaryweakref.WeakSet类。这些行为与常规的dictset实例类似,但基于键或值删除值。

当然,在使用weakref时,我们需要小心。一旦所有常规引用都被删除,对象将变得不可访问:

>>> class SomeClass:
...     def __init__(self, name):
...         self.name = name

>>> a = SomeClass('a')
>>> b = weakref.proxy(a)
>>> b.name
'a'
>>> del a
>>> b.name
Traceback (most recent call last):
    ...
ReferenceError: weakly-referenced object no longer exists 

在删除a之后,它是SomeClass实例的唯一实际引用,我们不能再使用该实例了。虽然这是可以预料的,但如果你的主要引用有可能消失,你应该对此问题保持警惕。

当你处理大型自引用数据结构时,使用weakref模块可能是个好主意。然而,在使用它之前,别忘了检查你的实例是否仍然存在。

减少内存使用

通常,内存使用可能不是 Python 中最大的问题,但了解你可以做什么来减少它仍然很有用。在尝试减少内存使用时,了解 Python 如何分配内存是非常重要的。

在 Python 内存管理器中,你需要了解四个概念:

  • 首先,我们有。堆是所有 Python 管理的内存的集合。请注意,这与常规堆是分开的,混合两者可能会导致内存损坏和崩溃。

  • 第二是区域。这些是 Python 从系统请求的块。每个块的大小固定为 256 KiB,它们是构成堆的对象。

  • 第三是。这些是构成区域(arenas)的内存块。这些块的大小是 4 KiB。由于池和区域有固定的大小,它们是简单的数组。

  • 第四和最后,我们有。Python 对象存储在这些块中,每个块都有特定的格式,这取决于数据类型。由于整数比字符占用更多空间,为了效率,使用了不同的块大小。

现在我们知道了内存是如何分配的,我们也可以理解它如何返回到操作系统,以及为什么这通常非常困难。

将一个块返回到池中是足够简单的:一个简单的 del some_variable 后跟一个 gc.collect() 就应该可以解决问题。问题是这并不能保证内存会返回到操作系统。

为了说明内存释放到操作系统的必要条件:

  • 在池可以释放之前,池中的所有块都需要被释放。

  • 在区域可以释放之前,区域中的所有池都需要被释放。

  • 一旦区域被释放到堆中,内存可能会被释放到操作系统,但这取决于 C 运行时和/或操作系统。

正因如此,我总是建议在开始分配大块内存之前,在长时间运行的脚本中运行 gc.collect()

这是一个常见且错误的误解,即 Python 从不向系统释放任何内存。在 Python 2.5 之前,这确实是事实,因为区域从未被释放到堆中。

让我们通过两次分配和释放内存来阐述分配和释放内存的影响:

import os
import psutil

def print_usage(message):
    process = psutil.Process(os.getpid())
    usage = process.memory_info().rss / (1 << 20)
    print(f'Memory usage {message}: {usage:.1f} MiB')

def allocate_and_release():
    # Allocate large block of memory
    large_list = list(range(1000000))
    print_usage('after allocation')

    del large_list
    print_usage('after releasing')

print_usage('initial')
allocate_and_release()
allocate_and_release() 

你可能会预期在第二个块被释放后,内存使用量将与第一个块释放后几乎相同,或者甚至回到原始状态。让我们看看实际上会发生什么:

$ python3 T_18_freeing_memory.py
Memory usage initial: 9.4 MiB
Memory usage after allocation: 48.1 MiB
Memory usage after releasing: 17.3 MiB
Memory usage after allocation: 55.7 MiB
Memory usage after releasing: 25.0 MiB 

这很奇怪,不是吗?两次分配之间的内存使用量增加了。事实是,我稍微挑选了一些结果,并且输出在每次运行之间都会变化,因为将内存释放回操作系统并不是操作系统会立即处理它的保证。在某些其他情况下,内存已经正确地返回到 17 MiB。

中间的一些人可能会怀疑结果是否因为忘记了 gc.collect() 而有偏差。在这种情况下,答案是不会有偏差,因为内存分配足够大,可以立即触发垃圾收集器,而且差异是可以忽略不计的。

这大约是最佳情况,即只有几个连续的内存块。真正的挑战在于当你有许多变量时,只有部分池/区域被使用。Python 使用一些启发式方法在空区域中找到空间,这样在存储新变量时就不需要分配新的区域,但当然并不总是成功。在这种情况下,在分配之前运行gc.collect()可能会有所帮助,因为它可以告诉 Python 哪些池现在是空闲的。

需要注意的是,常规堆和 Python 堆是分开维护的,因为混合它们可能会导致损坏和/或应用程序崩溃。除非你用 C/C++编写自己的 Python 扩展,否则你很可能永远不需要担心手动内存分配。

生成器与列表的比较

最重要的提示是尽可能使用生成器。Python 3 已经在用生成器替换列表方面取得了很大的进步,但记住这一点确实很有好处,因为它不仅节省了内存,还节省了 CPU,因为不需要同时保留所有内存。

为了说明这种差异:

Line #    Mem usage    Increment   Line Contents
================================================
     4     11.0 MiB      0.0 MiB   @memory_profiler.profile
     5                             def main():
     6     11.0 MiB      0.0 MiB    a = range(1000000)
     7     49.7 MiB     38.6 MiB    b = list(range(1000000)) 

range()生成器占用的内存如此之少,以至于甚至无法检测到,而数字列表则占用38.6 MiB

重新创建集合与移除项的比较

关于 Python 中的集合的一个非常重要的细节是,其中许多只能增长;它们不会自行缩小。为了说明:

Mem usage    Increment   Line Contents
======================================
 11.5 MiB      0.0 MiB   @memory_profiler.profile
                         def main():
                         # Generate a huge dict
 26.3 MiB     14.8 MiB   a = dict.fromkeys(range(100000))

                         # Remove all items
 26.3 MiB      0.0 MiB   for k in list(a.keys()):
 26.3 MiB      0.0 MiB   del a[k]

                         # Recreate the dict
 23.6 MiB     -2.8 MiB   a = dict((k, v) for k, v in a.items()) 

即使从dict中移除了所有项,内存使用量仍然保持不变。这是使用列表和字典时最常见的内存使用错误之一。唯一恢复内存的方法是重新创建对象。或者,通过使用生成器根本不分配内存。

使用槽位

除了本章前面提到的使用__slots__的性能优势外,__slots__还可以帮助减少内存使用。回顾一下,__slots__允许你指定你想要在类中存储的字段,并且通过不实现instance.__dict__来跳过所有其他字段。

虽然这种方法确实可以在类定义中节省一点内存,但效果通常有限。对于一个几乎为空且只有一个极小的属性,如boolbyte,这可以产生很大的差异。对于实际存储一些数据的类,效果可能会迅速减弱。

__slots__的最大缺点是,如果父类都定义了__slots__,则多重继承是不可能的。除此之外,它几乎可以用在所有情况下。

你可能会想知道__slots__是否会限制动态属性赋值,实际上阻止你执行Spam.eggs = 123,如果eggs不是__slots__的一部分。你是对的——至少部分正确。在有标准固定属性列表的__slots__中,你不能动态添加新属性——但如果你将__dict__添加到__slots__中,你可以。

我很尴尬地说,我大约花了 15 年才了解到这个特性,但了解这个特性使得__slots__变得如此多功能,我真的觉得我应该提到它。

现在我们来展示内存使用量的差异:

import memory_profiler

class Slots(object):
    __slots__ = 'index', 'name', 'description'

    def __init__(self, index):
        self.index = index
        self.name = 'slot %d' % index
        self.description = 'some slot with index %d' % index

class NoSlots(object):
    def __init__(self, index):
        self.index = index
        self.name = 'slot %d' % index
        self.description = 'some slot with index %d' % index

@memory_profiler.profile
def main():
    slots = [Slots(i) for i in range(25000)]
    no_slots = [NoSlots(i) for i in range(25000)]
    return slots, no_slots

if __name__ == '__main__':
    main() 

还有内存使用情况:

Mem usage Increment Occurrences Line Contents
============================================
38.4 MiB  38.4 MiB          1 @memory_profiler.profile
                              def main():
44.3 MiB   5.9 MiB      25003     slots = [Slots(i) for i in range(25000)]
52.4 MiB   8.1 MiB      25003     no_slots = [NoSlots(i) for i in range(25000)]
52.4 MiB   0.0 MiB          1     return slots, no_slots 

你可能会争辩说这不是一个公平的比较,因为它们都存储了大量的数据,这扭曲了结果。你确实是对的,因为“裸”比较,只存储index而不存储其他内容,给出的是2 MiB4.5 MiB。但是,让我们说实话,如果你不打算存储数据,那么创建类实例有什么意义呢?我并不是说__slots__没有用途,但不要过分,因为优势通常是有限的。

有一种结构甚至更节省内存:array模块。它以几乎与 C 语言中的裸内存数组相同的方式存储数据。请注意,这通常比列表慢,并且使用起来不那么方便。如果你需要存储大量的数字,我建议查看numpy.arrayscipy.sparse

性能监控

到目前为止,我们已经看到了如何衡量和改进 CPU 和内存性能,但有一部分我们完全跳过了。由于外部因素(如数据量的增加)引起的性能变化非常难以预测。在实际应用中,瓶颈不是恒定的。它们一直在变化,曾经非常快的代码一旦应用更多的负载,可能会变得缓慢。

由于这个原因,我建议实施一个监控解决方案,该方案可以随着时间的推移跟踪任何事物和一切的性能。性能监控的大问题在于你无法知道未来什么会变慢以及原因是什么。我甚至有过网站因为 Memcached 和 Redis 调用而变慢的情况。这些是仅存储内存的缓存服务器,响应速度极快,通常在毫秒内完成,这使得变慢的可能性非常低,直到你做了超过 100 次缓存调用,并且向缓存服务器的延迟从 0.1 毫秒增加到 2 毫秒,突然间这 100 次调用需要 200 毫秒而不是 10 毫秒。尽管 200 毫秒听起来仍然非常少,但如果你的总页面加载时间通常低于 100 毫秒,那么这突然之间就是一个巨大的增加,并且肯定是可以注意到的。

为了监控性能,能够跟踪随时间的变化并找到负责的组件,我可以个人推荐几个用于性能监控的系统:

  • 对于简单的短期(最多几周)应用性能跟踪,Prometheus 监控系统非常容易设置,并且与 Grafana 配对时,你可以创建最漂亮的图表来监控你的性能。

  • 如果你需要一个更长期的性能跟踪解决方案,该解决方案可以很好地扩展到大量变量,那么你可能对InfluxDB更感兴趣。它还可以与 Grafana 配合使用,以实现非常有用的交互式图表:

图 12.2:Grafana 响应时间热图

图 12.3:Grafana 请求延迟图表

要将这些系统中的数据输入,你有几种选择。你可以使用本机 API,但也可以使用一个中间系统,例如StatsD。StatsD 系统本身不存储数据,但它使得从你的系统中触发和忘记性能指标变得非常容易,而无需担心监控系统是否仍在运行。因为该系统通常使用 UDP 发送信息,即使监控服务器完全关闭且无法访问,你的应用程序也不会察觉到任何差异。

要使用这些工具,你必须将应用程序的指标发送到 StatsD 服务器。为此,我编写了 Python-StatsD (pypi.org/project/python-statsd/) 和 Django-StatsD (pypi.org/project/django-statsd/) 包。这些包允许你从开始到结束监控你的应用程序,在 Django 的情况下,你将能够按应用程序或视图监控性能,并在其中查看所有组件,例如数据库、模板和缓存层。这样,你就可以确切地知道是什么导致了你的网站(或应用程序)的减速。而且最好的是,它是(近)实时进行的。

练习

现在你已经了解了许多性能测量和优化的可用工具,尝试创建一些有用的装饰器或上下文包装器,以帮助你防止问题:

  • 尝试创建一个装饰器来监控函数的每次运行,如果每次运行内存使用量增加,则警告你。

  • 尝试创建一个装饰器来监控函数的运行时间,如果它偏离上一次运行太多,则警告你。可选地,你还可以让该函数生成(运行中的)平均运行时间。

  • 尝试为你的类创建一个内存管理器,当超过配置的实例数量时警告你。如果你从未期望某个类的实例超过 5 个,当这个数字超过时,你可以警告用户。

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。你被鼓励提交自己的解决方案,并从他人的解决方案中学习其他替代方案。

摘要

当谈到性能时,没有神圣的秘诀,没有单一的事情可以确保在所有情况下都能达到最佳性能。然而,这不应该让你担心,因为在大多数情况下,你永远不会需要调整性能,如果你确实需要,一次微调可能就能解决问题。你现在应该能够找到代码中的性能问题和内存泄漏,这是最重要的,所以只要尽力控制自己,只在真正需要时进行微调。

下面是本章工具的快速回顾:

  • 测量 CPU 性能:timeitprofile/cProfileline_profiler

  • 分析性能分析结果:SnakeViz、pyprof2calltree 和 QCacheGrind

  • 测量内存使用:tracemallocmemory_profiler

  • 减少内存使用和泄漏:weakrefgc(垃圾收集器)

如果你知道如何使用这些工具,你应该能够追踪并修复代码中的大多数性能问题。

本章最重要的收获是:

  • 在投入任何努力之前进行测试。使一些函数更快似乎是一项伟大的成就,但这通常很少需要。

  • 选择正确的数据结构/算法比任何其他性能优化都更有效。

  • 循环引用会消耗内存,直到垃圾收集器开始清理。

  • 槽位有一些注意事项,所以我建议限制使用。

下一章将正确介绍我们如何使用 asyncio 模块异步工作。此模块使得在等待外部 I/O 时可以“后台”运行。而不是让你的前台线程保持忙碌,当你的代码等待 TCP、UDP、文件和进程等端点时,它可以切换到不同的线程。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

第十三章:asyncio – 无线程的多线程

上一章向我们展示了如何跟踪应用程序的性能。在这一章中,我们将使用异步编程在需要等待输入/输出I/O)操作时在函数之间切换。这有效地模拟了多线程或多进程的效果,而不会引入这些解决方案带来的开销。在下一章中,我们还将涵盖多线程和多进程的情况,其中 I/O 不是瓶颈,或者asyncio不是一个选项。

当你处理外部资源,如读取/写入文件、与 API 或数据库交互以及其他 I/O 操作时,使用asyncio可以带来巨大的好处。在正常情况下,单个阻塞的远程连接可以使整个进程挂起,而使用asyncio,它将简单地切换到你的代码的另一个部分。

本章将解释如何在 Python 中使用异步函数,以及如何重构代码,使其仍然可以工作,即使它不遵循标准的返回值的程序性编码模式。潜在的缺点是,与使用多线程和多进程类似,代码执行可能以意外的顺序进行。

本章节涵盖以下主题:

  • asyncio简介

  • asyncio基本概念,包括协程、事件循环、未来和任务

  • 使用async defasync forasync withawait的函数

  • 并行执行

  • 使用asyncio的示例,包括客户端和服务器

  • 调试asyncio

asyncio简介

asyncio库的创建是为了使使用异步处理更加容易和可预测。它原本是作为asyncore模块的替代品,该模块已经存在很长时间(自 Python 1.5 以来),但并不那么易于使用。《asyncio》库正式引入 Python 3.4,并且随着每个新版本的 Python 发布,它都经历了许多改进。

简而言之,asyncio库允许你在需要等待 I/O 操作时切换到执行不同的函数。因此,而不是 Python 等待操作系统为你完成文件读取,在这个过程中阻塞整个应用程序,它可以在同时执行另一个函数中的有用操作。

向后兼容性和 async/await 语句

在我们继续任何示例之前,了解asyncio在 Python 版本中的变化是很重要的。尽管asyncio库是在 Python 3.4 中引入的,但大部分通用语法在 Python 3.5 中已经被替换。使用旧的 Python 3.4 语法仍然是可能的,但引入了一种更简单、因此更推荐的语法,即使用await

在所有示例中,除非特别说明,否则本章将假设使用 Python 3.7 或更高版本。然而,如果你仍在运行较旧版本,请查看以下部分,这些部分说明了如何在较旧的系统上运行 asyncio。如果你有 Python 3.7+,可以自由跳转到标题为 并行执行的基本示例 的部分。

Python 3.4

对于传统的 Python 3.4 使用,需要考虑以下几点:

  • 函数应该使用 asyncio.coroutine 装饰器声明

  • 应该使用 yield from coroutine() 来获取异步结果

  • 异步循环不支持直接使用,但可以使用 while True: yield from coroutine() 来模拟

示例:

import asyncio

@asyncio.coroutine
def main():
    print('Hello from main')
    yield from asyncio.sleep(1)

loop = asyncio.new_event_loop()
loop.run_until_complete(main())
loop.close() 

Python 3.5

Python 3.5 的语法比 Python 3.4 版本更明显。虽然考虑到协程在早期 Python 版本中的起源,yield from 是可以理解的,但实际上这个名字并不适合这项工作。让 yield 用于生成器,而 await 用于协程。

  • 应该使用 async def 而不是 def 来声明函数

  • 应该使用 await coroutine() 来获取异步结果

  • 可以使用 async for ... in ... 创建异步循环

示例:

import asyncio

async def main():
    print('Hello from main')
    await asyncio.sleep(1)

loop = asyncio.new_event_loop()
loop.run_until_complete(main())
loop.close() 

Python 3.7

自从 Python 3.7 以来,运行 asyncio 代码已经变得稍微容易一些,也更明显。如果你有使用较新 Python 版本的便利,你可以使用以下方法来运行你的 async 函数:

import asyncio

async def main():
    print('Hello from main')
    await asyncio.sleep(1)

asyncio.run(main()) 

对于较旧的 Python 版本,我们需要一段相当高级的代码来正确地替换 asyncio.run(),但如果你不关心可能重用现有的事件循环(关于事件循环的详细信息可以在本章后面找到)并且自己处理任务的关闭,你可以使用以下代码:

import asyncio

async def main():
    print('Hello from main')
    await asyncio.sleep(1)

loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
    loop.run_until_complete(main())
finally:
    # Run the loop again to finish pending tasks
    loop.run_until_complete(asyncio.sleep(0))

    asyncio.set_event_loop(None)
    loop.close() 

或者一个更简短的版本,虽然不一定等效,但可以处理许多测试用例:

import asyncio

async def main():
    print('Hello from main')
    await asyncio.sleep(1)

loop = asyncio.get_event_loop()
loop.run_until_complete(main()) 

如果可能的话,我当然会推荐使用 asyncio.run()。即使没有 asyncio.run(),你也可能会遇到与较旧版本的 Python 的库兼容性问题。

然而,如果你必须这样做,你可以在 Python Git 中找到 asyncio.run() 的源代码,这样你可以自己实现一个简化的版本:github.com/python/cpython/blob/main/Lib/asyncio/runners.py

并行执行的基本示例

当涉及到代码性能时,你通常会遇到以下两种瓶颈之一:

  • 等待外部 I/O,如网络服务器、文件系统、数据库服务器、任何网络相关的内容以及其他

  • 在进行大量计算的情况下,CPU

如果你的 CPU 由于大量计算而成为瓶颈,你需要求助于使用更快的算法、更快的处理器或将计算卸载到专用硬件(如显卡)。在这些情况下,asyncio 库对你帮助不大。

如果你的代码大部分时间都在等待用户、内核、文件系统或外部服务器,asyncio可以在很大程度上帮助你,同时它是一个相对简单且副作用较少的解决方案。正如我们将在asyncio 概念部分看到的那样,然而也有一些注意事项。使现有代码与asyncio兼容可能是一项大量工作。

让我们从一个非常简单的例子开始,以展示在需要等待时常规代码和asyncio代码之间的区别。

首先,执行两次 1 秒sleep的常规 Python 版本:

>>> import time
>>> import asyncio

>>> def normal_sleep():
...     print('before sleep')
...     time.sleep(1)
...     print('after sleep')

>>> def normal_sleeps(n):
...     for _ in range(n):
...         normal_sleep()

# Normal execution
>>> start = time.time()
>>> normal_sleeps(2)
before sleep
after sleep
before sleep
after sleep
>>> print(f'duration: {time.time() - start:.0f}')
duration: 2 

现在让我们看看执行两次 1 秒sleepasyncio版本:

>>> async def asyncio_sleep():
...     print('before sleep')
...     await asyncio.sleep(1)
...     print('after sleep')

>>> async def asyncio_sleeps(n):
...     coroutines = []
...     for _ in range(n):
...         coroutines.append(asyncio_sleep())
...
...     await asyncio.gather(*coroutines)

>>> start = time.time()
>>> asyncio.run(asyncio_sleeps(2))
before sleep
before sleep
after sleep
after sleep
>>> print(f'duration: {time.time() - start:.0f}')
duration: 1 

如你所见,它仍然需要等待 1 秒钟来实际sleep,但它可以并行运行它们。asyncio_sleep()函数是同时开始的,正如sleep 前输出所示。

让我们分析这个例子中使用的组件:

  • async def: 这告诉 Python 解释器我们的函数是一个协程函数而不是常规函数。

  • asyncio.sleep(): 这是time.sleep()的异步版本。这两个函数之间最大的区别在于,time.sleep()在睡眠时会保持 Python 进程忙碌,而asyncio.sleep()则允许在事件循环中切换到不同的任务。这个过程与大多数操作系统中任务切换的工作原理非常相似。

  • asyncio.run(): 这是一个包装器,用于在默认事件循环中执行协程。这实际上是asyncio任务切换器;更多关于这一点将在下一节中介绍。

  • asyncio.gather(): 包装一系列可等待对象,并为你收集结果。等待时间可配置,等待的方式也可配置。你可以选择等待直到第一个结果出现,直到所有结果都可用,或者直到第一个异常发生。

这立即展示了asyncio代码的一些注意事项和陷阱。

如果我们不小心使用了time.sleep()而不是asyncio.sleep(),代码将需要 2 秒钟才能运行,并且在执行过程中会阻塞整个循环。更多关于这一点的内容将在下一节中介绍。

如果我们在最后没有使用await asyncio.gather()而是使用await asyncio.sleep(),代码将按顺序运行,而不是并行运行,正如你可能期望的那样。

现在我们已经看到了asyncio的一个基本示例,我们需要了解更多关于其内部结构,以便更明显地看到局限性。

asyncio 概念

在进一步探讨示例和用法之前,asyncio库有几个基本概念需要解释。上一节中显示的示例实际上使用了其中的一些,但关于如何和为什么的解释可能仍然有用。

asyncio的主要概念是协程和事件循环。在这些概念中,有几种辅助类可用,例如StreamsFuturesProcesses。接下来的几段将解释它们的基本知识,以便我们可以在后面的章节中理解实现示例。

协程、未来和任务

coroutineasyncio.Futureasyncio.Task对象本质上是对结果的承诺;如果它们可用,则返回结果,并且如果它们尚未完成处理,则可以用来取消承诺的执行。应该注意的是,这些对象的创建并不能保证代码将被执行。实际的执行开始发生在你await结果或告诉事件循环执行承诺的时候。这将在下一节关于事件循环的讨论中介绍。

当使用asyncio时,你将遇到的最基本对象是coroutine。任何常规async def(如asyncio.sleep())的结果都是一个coroutine对象。一旦你await那个coroutine,它将被执行,你将得到结果。

asyncio.Futureasyncio.Task类也可以通过await执行,但还允许你注册回调函数,以便在结果(或异常)可用时接收它们。此外,它们在内部维护一个状态变量,允许外部方取消未来并停止(或防止)其执行。API 与concurrent.futures.Future类非常相似,但它们并不完全兼容,所以请确保不要混淆这两个。

为了进一步澄清,所有这些都是可等待的,但具有不同的抽象级别:

  • coroutine:一个尚未被等待的调用async def的结果。你将主要使用这些。

  • asyncio.Future:一个表示最终结果的类。它不需要封装coroutine,结果可以手动设置。

  • asyncio.Taskasyncio.Future的一个实现,旨在封装coroutine以提供一个方便且一致的接口。

通常,这些类的创建不是你需要直接担心的事情;而不是自己创建类,推荐的方式是通过asyncio.create_task()loop.create_task()。前者实际上内部执行loop.create_task(),但如果你只想通过asyncio.get_running_loop()在运行的事件循环上执行它,而不需要指定它,那么它会更方便。如果你需要出于某种原因扩展Task类,那通过loop.set_task_factory()方法很容易实现。

在 Python 3.7 之前,asyncio.create_task()被称为asyncio.ensure_future()

事件循环

事件循环的概念实际上是asyncio中最重要的一点。你可能怀疑协程本身是关于一切的,但没有事件循环它们是无用的。事件循环充当任务切换器,类似于操作系统在 CPU 上切换活动任务的方式。即使有多核处理器,仍然需要一个主进程来告诉 CPU 哪些任务要运行,哪些需要等待或稍作休眠。这正是事件循环所做的:它决定运行哪个任务。

实际上,每次你执行await时,事件循环都会查看挂起的 awaitables,并继续执行当前挂起的那个。这也是单个事件循环危险的地方。如果你在协程中有一个慢速/阻塞函数,例如不小心使用time.sleep()而不是asyncio.sleep(),它将阻塞整个事件循环,直到它完成。

实际上,这意味着await asyncio.sleep(5)只能保证你的代码将等待至少 5 秒。如果在那个await期间,其他协程阻塞了事件循环 10 秒,那么asyncio.sleep(5)将至少需要 10 秒。

事件循环实现

到目前为止,我们只看到了asyncio.run(),它内部使用asyncio.get_event_loop()来返回具有默认事件循环策略的默认事件循环。目前有两个捆绑的事件循环实现:

  • 默认情况下在 Unix 和 Linux 系统上使用的asyncio.SelectorEventLoop实现。

  • 仅在 Windows 上支持(且为默认)的asyncio.ProactorEventLoop实现。

内部,asyncio.ProactorEventLoop实现使用 I/O 完成端口,这是一个据说比 Windows 系统上asyncio.SelectorEventLoopselect实现更快更高效的系统。

asyncio.SelectorEventLoop是基于选择器的,自 Python 3.4 以来,通过核心 Python 模块中的select模块提供。有几种选择器可用:传统的selectors.SelectSelector,它内部使用select.select,但也包括更现代的解决方案,如selectors.KqueueSelectorselectors.EpollSelectorselectors.DevpollSelector。尽管asyncio.SelectorEventLoop默认会选择最有效的选择器,但在某些情况下,最有效的选择器可能以某种方式不适用。

最有效的选择器是通过排除法选择的。如果select模块具有kqueue属性,则将使用KqueueSelector。如果kqueue不可用,则将按照以下顺序选择下一个最佳选项:

  1. KqueueSelectorkqueue是 BSD 系统的事件通知接口。目前支持 FreeBSD、NetBSD、OpenBSD、DragonFly BSD 和 macOS(OS X)。

  2. EpollSelectorepollkqueue的 Linux 内核版本。

  3. DevpollSelector:此选择器使用/dev/poll,这是一个类似于kqueueepoll的系统,但支持在 Solaris 系统上。

  4. PollSelectorpoll()是一个系统调用,当有更新可用时将调用你的函数。实际实现取决于系统。

  5. SelectSelector:与poll()非常相似,但select()为所有文件描述符构建一个位图,并在每次更新时遍历该列表,这比poll()低效得多。

在这些情况下,选择器事件循环允许你指定不同的选择器:

>>> import asyncio
>>> import selectors

>>> selector = selectors.SelectSelector()
>>> loop = asyncio.SelectorEventLoop(selector)
>>> asyncio.set_event_loop(loop) 

应该注意的是,这些之间的差异通常太小,在大多数实际应用中几乎察觉不到。这就是为什么我会建议尽可能忽略这些优化,因为它们可能效果甚微,如果使用不当甚至可能引起问题。我唯一遇到这些差异真正重要的情况是构建一个需要处理大量并发连接的服务器。这里的“大量”指的是单个服务器上超过 100,000 个并发连接,这是地球上只有少数人需要解决的问题。

如果你认为性能很重要(并且你正在运行 Linux/OS X),我建议查看 uvloop,这是一个基于 libuv 的非常快速的事件循环,libuv 是一个用 C 编写的异步 I/O 库,支持大多数平台。根据 uvloop 的基准测试,它可以让你的事件循环速度提高 2-4 倍。

事件循环策略

事件循环策略仅仅是为你存储和创建事件循环的构造,并且考虑到最大程度的灵活性。我能想到修改事件循环策略的唯一原因可能是因为你想让特定的事件循环在特定的处理器和/或系统上运行,例如,如果你正在运行 Linux 或 OS X,则仅启用 uvloop。除此之外,它提供的灵活性比大多数人需要的都要多。如果你想将 uvloop 设置为默认循环(如果已安装),可以执行以下操作:

import asyncio

class UvLoopPolicy(asyncio.DefaultEventLoopPolicy):
    def new_event_loop(self):
        try:
            from uvloop import Loop
            return Loop()
        except ImportError:
            return super().new_event_loop()

asyncio.set_event_loop_policy(UvLoopPolicy()) 

除了覆盖 new_event_loop() 来自定义新事件循环的创建之外,你还可以通过覆盖 get_event_loop()set_event_loop() 方法来覆盖事件循环的重用方式。我个人从未在启用 uvloop 之外使用过它。

事件循环的使用

现在我们已经知道了事件循环是什么,它们的作用以及如何选择事件循环,让我们看看它们如何在 asyncio.run() 之外的应用。

如果你开始运行自己的事件循环,你可能会使用 loop.run_forever(),正如你所期望的,它会永远运行。或者至少直到运行了 loop.stop()。但你也可以使用 loop.run_until_complete() 运行单个任务。后者对于一次性操作非常有用,但在某些场景中可能会引起错误。如果你从一个非常小/快速的协程创建任务,那么任务可能没有时间运行,因此它将不会在下次执行 loop.run_until_complete()loop.run_forever() 时执行。关于这一点,我们将在本章后面详细讨论;现在,我们将假设使用 loop.run_forever() 的长时间运行循环。

由于我们现在有一个永远运行的事件循环,我们需要向其中添加任务——这就是事情变得有趣的地方。默认事件循环中有许多可用的选择:

  • call_soon():将一个项目添加到(FIFO)队列的末尾,这样函数将按照它们插入的顺序执行。

  • call_soon_threadsafe(): 与 call_soon() 相同,但它是线程安全的。call_soon() 方法不是线程安全的,因为线程安全需要使用 全局解释器锁GIL),这实际上使得程序在线程安全时变为单线程。第十四章,多进程——当单个 CPU 核心不够用时 详细解释了 GIL 和线程安全。

  • call_later(): 在给定秒数后调用函数;如果两个任务会在同一时间运行,它们将以未定义的顺序执行。如果未定义的顺序是一个问题,你也可以选择使用 asyncio.gather() 或稍微增加两个任务中的一个的 delay 参数。请注意,delay 是一个最小值——如果事件循环被锁定/忙碌,它可能会稍后运行。

  • call_at(): 在与 loop.time() 输出相关的特定时间调用函数,loop.time()loop 开始运行以来的秒数。所以,如果 loop.time() 的当前值为 90(这意味着 loop 从开始运行已经过去了 90 秒),那么你可以运行 loop.call_at(95, ...) 来在 5 秒后执行。

所有这些函数都返回 asyncio.Handle 对象。这些对象允许通过 handle.cancel() 函数取消尚未执行的任务。但是,请注意,从其他线程取消时,取消操作也不是线程安全的。为了以线程安全的方式执行它,我们必须也将取消函数作为一个任务来执行:loop.call_soon_threadsafe(handle.cancel)

示例用法:

>>> import time
>>> import asyncio

>>> def printer(name):
...     print(f'Started {name} at {loop.time() - offset:.1f}')
...     time.sleep(0.2)
...     print(f'Finished {name} at {loop.time() - offset:.1f}')

>>> loop = asyncio.new_event_loop()
>>> _ = loop.call_at(loop.time() + .2, printer, 'call_at')
>>> _ = loop.call_later(.1, printer, 'call_later')
>>> _ = loop.call_soon(printer, 'call_soon')
>>> _ = loop.call_soon_threadsafe(printer, 'call_soon_threadsafe')

>>> # Make sure we stop after a second
>>> _ = loop.call_later(1, loop.stop)

# Store the offset because the loop requires time to start
>>> offset = loop.time()

>>> loop.run_forever()
Started call_soon at 0.0
Finished call_soon at 0.2
Started call_soon_threadsafe at 0.2
Finished call_soon_threadsafe at 0.4
Started call_later at 0.4
Finished call_later at 0.6
Started call_at at 0.6
Finished call_at at 0.8 

你可能想知道为什么我们在这里使用 time.sleep() 而不是 asyncio.sleep()。这是一个有意的选择,以展示这些函数中的任何一个都没有提供任何关于函数执行时间的保证,如果 loop 以某种方式被阻塞。尽管我们为 loop.call_later() 调用指定了 0.1 秒的延迟,但实际上它花了 0.4 秒才开始执行。如果我们使用 asyncio.sleep(),函数将并行运行。

call_soon(), call_soon_threadsafe()call_later() 函数都是 call_at() 函数的包装器。在 call_soon() 的情况下,它只是将 call_later() 延迟设置为 0 进行包装,而 call_at() 则是添加了 asyncio.time() 延迟的 call_soon()

根据事件循环的类型,实际上还有许多创建连接、文件处理器等其他方法,类似于 asyncio.create_task()。这些方法将在后面的章节中通过示例进行解释,因为它们与事件循环的关系较少,更多的是关于使用协程进行编程。

执行器

即使是简单的 time.sleep() 也可能完全阻塞你的事件循环,你可能想知道 asyncio 的实际用途是什么。这意味着你可能需要重写整个代码库以使其与 asyncio 兼容,对吧?理想情况下,这将是最好的解决方案,但我们可以通过使用执行器从 asyncio 代码中执行同步代码来绕过这个限制。Executor 创建了之前提到的另一种类型的 Futureconcurrent.futures.Futureasyncio.Future 相比),并在单独的线程或进程中运行你的代码,以提供对同步代码的 asyncio 接口。

这是一个基本的例子,展示了通过执行器执行的同步 time.sleep() 以使其异步:

>>> import time
>>> import asyncio

>>> def executor_sleep():
...     print('before sleep')
...     time.sleep(1)
...     print('after sleep')

>>> async def executor_sleeps(n):
...     loop = asyncio.get_running_loop()
...     futures = []
...     for _ in range(n):
...         future = loop.run_in_executor(None, executor_sleep)
...         futures.append(future)
...
...     await asyncio.gather(*futures)

>>> start = time.time()
>>> asyncio.run(executor_sleeps(2))
before sleep
before sleep
after sleep
after sleep
>>> print(f'duration: {time.time() - start:.0f}')
duration: 1 

因此,我们不是直接运行 executor_sleep(),而是通过 loop.run_in_executor() 创建一个未来。这使得 asyncio 通过默认执行器执行这个函数,这通常是 concurrent.futures.ThreadPoolExecutor,并在完成后返回结果。你需要意识到线程安全性,因为它是在单独的线程中处理的,但关于这个话题的更多内容将在下一章中介绍。

对于阻塞但不是 CPU 密集型操作(换句话说,没有重计算),默认基于线程的执行器将工作得很好。对于 CPU 密集型操作,它不会帮助你,因为操作仍然限制在单个 CPU 核心上。对于这些场景,我们可以使用 concurrent.futures.ProcessPoolExecutor()

import time
import asyncio
import concurrent.futures

def executor_sleep():
    print('before sleep')
    time.sleep(1)
    print('after sleep')

async def executor_sleeps(n):
    loop = asyncio.get_running_loop()
    futures = []
    with concurrent.futures.ProcessPoolExecutor() as pool:
        for _ in range(n):
            future = loop.run_in_executor(pool, executor_sleep)
            futures.append(future)

        await asyncio.gather(*futures)

if __name__ == '__main__':
    start = time.time()
    asyncio.run(executor_sleeps(2))
    print(f'duration: {time.time() - start:.0f}') 

虽然这个例子看起来几乎与上一个例子相同,但内部机制相当不同,使用多个 Python 进程而不是多个线程带来了几个注意事项:

  • 进程之间很难共享内存。这意味着你想传递作为参数的任何东西以及你需要返回的任何东西都必须由 pickle 进程支持,这样 Python 才能通过网络发送数据到另一个 Python 进程。这将在第十四章中详细解释。

  • 主脚本必须从 if __name__ == '__main__' 块中运行,否则执行器最终会陷入无限循环,不断繁殖自己。

  • 大多数资源不能在进程之间共享。这类似于无法共享内存,但不仅如此。如果你在主进程中有一个数据库连接,那么这个连接不能从该进程使用,因此它需要自己的连接。

  • 终止/退出进程可能更困难,因为终止主进程并不总是能保证终止子进程。

  • 根据你的操作系统,每个新的进程都将使用自己的内存,这会导致内存使用量大幅增加。

  • 创建新进程通常比创建新线程要重得多,所以你有很多开销。

  • 进程之间的同步比线程慢得多。

所有这些原因绝对不应该阻止你使用ProcessPoolExecutor,但你应该始终问自己你是否真的需要它。如果你需要并行运行许多重量级计算,它可以是一个惊人的解决方案。如果可能的话,我建议使用带有ProcessPoolExecutor的功能性编程。第十四章,“当单个 CPU 核心不够用时——多进程”,详细介绍了多进程。

现在我们对asyncio有了基本的了解,是时候继续一些asyncio可能很有用的示例了。

异步示例

导致脚本和应用程序停滞不前的最常见原因之一是使用远程资源,其中“远程”意味着与网络、文件系统或其他资源的任何交互。使用asyncio,至少大部分问题都可以轻松解决。从多个远程资源获取数据并服务于多个客户端比以前容易得多,也轻量得多。虽然在这种情况下也可以使用多线程和多进程,但asyncio是一个更轻量级的替代方案,在很多情况下实际上更容易管理。

接下来的几节将展示一些使用asyncio实现某些操作的示例。

在你开始实现自己的代码并复制这里的示例之前,我建议你在网上快速搜索你正在寻找的库,看看是否有可用的asyncio版本。

通常,查找“asyncio <协议>”会给你很好的结果。或者,许多库使用aio前缀作为库名称,例如aiohttp,这也可以帮助你搜索。

进程

到目前为止,我们只是在 Python 中执行了简单的async函数,如asyncio.sleep(),但有些事情在异步运行时稍微困难一些。例如,假设我们有一个运行时间较长的外部应用程序,我们希望在不完全阻塞主线程的情况下运行它。

在非阻塞模式下运行外部进程的选项通常有:

  • 线程

  • 多进程

  • 轮询(定期检查)输出

第十四章涵盖了线程和多进程。

在没有求助于更复杂的解决方案,如线程和进程多线程,这些会引入可变同步问题的情况下,我们只剩下轮询。使用轮询,我们会在一定的时间间隔内检查是否有新的输出,这可能会因为轮询间隔而减慢你的结果。也就是说,如果你的轮询间隔是 1 秒,而进程在最后一次轮询后 0.1 秒生成输出,那么接下来的 0.9 秒都是浪费在等待上。为了缓解这种情况,你可以减少轮询间隔,当然,但轮询间隔越小,检查是否有结果所浪费的时间就越多。

使用asyncio,我们可以拥有轮询方法的优势,而无需在轮询间隔之间浪费时间。使用asyncio.create_subprocess_shellasyncio.create_subprocess_exec,我们可以像其他协程一样await输出。类的使用与subprocess.run非常相似,只是函数已被异步化,从而消除了轮询函数,当然。

以下示例期望您的环境中可用sleep命令。在所有 Unix/Linux/BSD 系统中,默认情况下都是这种情况。在 Windows 上,默认情况下不可用,但可以轻松安装。可以使用timeout命令作为替代。

如果您希望使用sleep和其他 Unix 工具,我找到的最简单方法是安装 Git for Windows,并让它安装可选的 Unix 工具

图片

图 13.1:Git for Windows 安装程序

首先,让我们看看通过subprocess模块运行外部进程(在这种情况下是sleep命令)的传统顺序脚本版本:

>>> import time
>>> import subprocess

>>> def subprocess_sleep():
...     print(f'Started sleep at: {time.time() - start:.1f}')
...     process = subprocess.Popen(['sleep', '0.1'])
...     process.wait()
...     print(f'Finished sleep at: {time.time() - start:.1f}')

>>> start = time.time() 

在第一次print()之后,我们使用subprocess.Popen()运行带有参数0.1sleep命令,使其睡眠 0.1 秒。与subprocess.run()不同,后者会阻塞您的 Python 进程并等待外部进程运行完成,subprocess.Popen()创建并启动进程并返回对运行进程的引用,但它不会自动等待输出。

这允许我们显式调用process.wait()来等待或轮询结果,正如我们将在下一个示例中看到的那样。内部,subprocess.run()实际上是一个方便的快捷方式,用于subprocess.Popen()的常见用法。

在运行代码时,我们会得到以下输出,正如您所期望的那样:

>>> for _ in range(2):
...     subprocess_sleep()
Started sleep at: 0.0
Finished sleep at: 0.1
Started sleep at: 0.1
Finished sleep at: 0.2 

由于所有操作都是顺序执行的,所以它需要两倍于sleep命令睡眠时间的 0.1 秒。这当然是最坏的情况:它完全阻塞了正在运行的 Python 进程。

而不是在运行sleep命令后立即等待,我们现在将以并行方式启动所有进程,并且只有在它们都在后台启动后才开始等待结果:

>>> import time
>>> import subprocess

>>> def subprocess_sleep():
...     print(f'Started sleep at: {time.time() - start:.1f}')
...     return subprocess.Popen(['sleep', '0.1'])

>>> start = time.time() 

如您所见,我们通过返回subprocess.Popen()而不执行process.wait()来返回进程。

现在我们立即启动所有进程,并且只有在它们都启动后才开始等待输出:

>>> processes = []
>>> for _ in range(2):
...     processes.append(subprocess_sleep())
Started sleep at: 0.0
Started sleep at: 0.0 

进程现在应该在后台运行,所以让我们等待结果:

>>> for process in processes:
...     returncode = process.wait()
...     print(f'Finished sleep at: {time.time() - start:.1f}')
Finished sleep at: 0.1
Finished sleep at: 0.1 

虽然在运行时看起来要好得多,但在我们运行process.wait()时,它仍然会阻塞主进程。它还要求以这种方式重新组织结构,即拆解(Finished打印语句)不在与启动进程相同的块中,正如早期示例中的情况。这意味着如果您的应用程序出现错误,您需要手动跟踪哪个进程失败,这有点不方便。

asyncio 版本中,我们再次可以回到在一个函数中处理与 sleep 命令相关的所有事情,这与第一个例子中的 subprocess.Popen() 非常相似:

>>> import time
>>> import asyncio

>>> async def async_process_sleep():
...     print(f'Started sleep at: {time.time() - start:.1f}')
...     process = await asyncio.create_subprocess_exec('sleep', '0.1')
...     await process.wait()
...     print(f'Finished sleep at: {time.time() - start:.1f}')

>>> async def main():
...     coroutines = []
...     for _ in range(2):
...         coroutines.append(async_process_sleep())
...     await asyncio.gather(*coroutines)

>>> start = time.time()
>>> asyncio.run(main())
Started sleep at: 0.0
Started sleep at: 0.0
Finished sleep at: 0.1
Finished sleep at: 0.1 

如你所见,以这种方式同时运行多个应用程序是非常简单的。语法基本上与不阻塞或轮询时的 subprocess 相同。

如果你从这个长时间运行的 asyncio 事件循环中运行,并且不需要捕获结果,你可以跳过整个 asyncio.gather() 步骤,而是使用 asyncio.create_task(async_process_sleep()) 代替。

交互式进程

启动进程是容易的部分;更困难的部分是与进程进行交互式输入和输出。asyncio 模块有几种措施来简化这部分,但在实际处理结果时仍然可能很困难。

这里有一个将 Python 解释器作为外部子进程调用的例子,执行一些代码,然后以简单的一次性方式退出:

>>> import time
>>> import asyncio

>>> async def run_python_script(script):
...     print(f'Executing: {script!r}')
...     process = await asyncio.create_subprocess_exec(
...         'python3',
...         stdout=asyncio.subprocess.PIPE,
...         stdin=asyncio.subprocess.PIPE,
...     )
...     stdout, stderr = await process.communicate(script)
...     print(f'stdout: {stdout!r}')

>>> asyncio.run(run_python_script(b'print(2 ** 20)'))
Executing: b'print(2 ** 20)'
stdout: b'1048576\n' 

在这种情况下,我们向 stdout(标准输出)和 stdin(标准输入)添加了一个管道,这样我们就可以手动从 stdout 读取并写入 stdin。一旦进程启动,我们就可以使用 process.communicate()stdin 写入,如果 stdoutstderr 可用,process.communicate() 将自动读取所有输出。由于我们没有声明 stderr 应该是什么,Python 会自动将所有 process.stderr 输出发送到 sys.stderr,因此在这里我们可以忽略 stderr,因为它将是 None

现在真正的挑战在于我们想要具有通过 stdin/stdout/stderr 进行双向通信的交互式子进程,并且可以持续运行更长时间。当然,这也是可能的,但在双方都等待输入的情况下,避免死锁可能很困难。以下是一个非常简单的 Python 子进程示例,它实际上与上面的 communicate() 做的事情相同,但手动进行,以便你可以对进程的输入和输出有更精细的控制:

>>> import asyncio

>>> async def run_script():
...     process = await asyncio.create_subprocess_exec(
...         'python3',
...         stdout=asyncio.subprocess.PIPE,
...         stdin=asyncio.subprocess.PIPE,
...     )
... 
...     # Write a simple Python script to the interpreter
...     process.stdin.write(b'print("Hi~")')
... 
...     # Make sure the stdin is flushed asynchronously
...     await process.stdin.drain()
...     # And send the end of file so the Python interpreter will
...     # start processing the input. Without this the process will
...     # stall forever.
...     process.stdin.write_eof()
... 
...     # Fetch the lines from the stdout asynchronously
...     async for line in process.stdout:
...         # Decode the output from bytes and strip the whitespace
...         # (newline) at the right
...         print('stdout:', line.rstrip())
... 
...     # Wait for the process to exit
...     await process.wait()

>>> asyncio.run(run_script())
stdout: b'Hi~' 

代码可能看起来与你预期的基本相同,但有一些部分在使用时并不明显,但却是必需的。虽然子进程的创建与前面的例子相同,但写入 stdin 的代码略有不同。

我们现在不再使用 process.communicate(),而是直接写入 process.stdin 管道。当你运行 process.stdin.write() 时,Python 将会 尝试 向流中写入,但由于进程尚未开始运行,可能无法写入。因此,我们需要手动使用 process.stdin.drain() 清空这些缓冲区。一旦完成,我们发送一个文件结束(EOF)字符,这样 Python 子进程就知道没有更多的输入了。

一旦输入被写入,我们需要从 Python 子进程读取输出。我们可以使用process.stdout.readline()在循环中完成此操作,但类似于我们可以使用for line in open(filename),我们也可以使用async for循环逐行读取process.stdout,直到流关闭。

如果可能的话,我建议避免使用stdin向子进程发送数据,而应使用某种网络、管道或文件通信。正如我们将在下一节中看到回声客户端和服务器时,这些方法处理起来更加方便,并且不太可能发生死锁。

回声客户端和服务器

你可以得到的最基本的服务器类型是“回声”服务器,它会将接收到的所有消息发送回去。由于我们可以使用asyncio并行运行多个任务,因此我们可以在同一个脚本中运行服务器和客户端。当然,将它们分成两个进程也是可能的。

创建基本的客户端和服务器很容易:

>>> import asyncio

>>> HOST = '127.0.0.1'
>>> PORT = 1234

>>> async def echo_client(message):
...     # Open the connection to the server
...     reader, writer = await asyncio.open_connection(HOST, PORT)
... 
...     print(f'Client sending {message!r}')
...     writer.write(message)
... 
...     # We need to drain and write the EOF to stop sending
...     writer.write_eof()
...     await writer.drain()
... 
...     async for line in reader:
...         print(f'Client received: {line!r}')
... 
...     writer.close()

>>> async def echo(reader, writer):
...     # Read all lines from the reader and send them back
...     async for line in reader:
...         print(f'Server received: {line!r}')
...         writer.write(line)
...         await writer.drain()
... 
...     writer.close()

>>> async def echo_server():
...     # Create a TCP server that listens on 'HOST'/'PORT' and
...     # calls 'echo' when a client connects.
...     server = await asyncio.start_server(echo, HOST, PORT)
... 
...     # Start listening
...     async with server:
...         await server.serve_forever()

>>> async def main():
...     # Create and run the echo server
...     server_task = asyncio.create_task(echo_server())
... 
...     # Wait a little for the server to start
...     await asyncio.sleep(0.01)
... 
...     # Create a client and send the message
...     await echo_client(b'test message')
... 
...     # Kill the server
...     server_task.cancel()

>>> asyncio.run(main())
Client sending b'test message'
Server received: b'test message'
Client received: b'test message' 

在这个例子中,我们可以看到我们使用asyncio.create_task()将服务器发送到后台。之后,我们必须等待一小段时间,以便后台任务开始工作,我们使用asyncio.sleep()来完成这个操作。0.01的睡眠时间是任意选择的(0.001可能也足够),但它应该足以让大多数系统与内核通信以创建监听套接字。一旦服务器开始运行,我们就启动客户端发送消息并等待响应。

自然地,这个例子可以用许多不同的方式编写。你不必使用async for,你可以使用reader.readline()读取到下一个换行符,或者你可以使用reader.read(number_of_bytes)读取特定数量的字符。这完全取决于你希望编写的协议。在 HTTP/1.1 协议的情况下,服务器期望一个Connection: close;在 SMTP 协议的情况下,应该发送一个QUIT消息。在我们的情况下,我们使用EOF字符作为指示符。

异步文件操作

你可能更希望某些操作是异步的,比如文件操作。尽管存储设备在近年来变得更快,但你并不总是使用快速的本地存储。例如,如果你想通过 Wi-Fi 连接写入网络驱动器,你可能会遇到相当多的延迟。通过使用asyncio,你可以确保这不会使你的整个解释器停滞不前。

不幸的是,目前还没有一种简单的方法可以在跨平台上通过asyncio执行文件操作,因为大多数操作系统都没有(可扩展的)异步文件操作支持。幸运的是,有人为这个问题创建了一个解决方案。aiofiles库在内部使用threading库为你提供一个asyncio接口来执行文件操作。虽然你可以轻松地使用Executor来为你处理文件操作,但aiofiles库是一个非常方便的包装器,我推荐使用它。

首先,安装库:

$ pip3 install aiofiles 

现在我们可以使用 aiofiles 通过 asyncio 以非阻塞方式打开、读取和写入文件:

>>> import asyncio
>>> import aiofiles

>>> async def main():
...     async with aiofiles.open('aiofiles.txt', 'w') as fh:
...         await fh.write('Writing to file')
...
...     async with aiofiles.open('aiofiles.txt', 'r') as fh:
...         async for line in fh:
...             print(line) 

>>> asyncio.run(main())
Writing to file 

aiofiles 的使用与常规的 open() 调用非常相似,除了在所有情况下都有 async 前缀。

创建异步生成器以支持异步 for

在前面的例子中,你可能想知道如何支持 async for 语句。本质上,这样做非常简单;你不再需要使用 __iter____next__ 魔法函数在类中创建常规生成器,而是现在使用 __aiter____anext__

>>> import asyncio

>>> class AsyncGenerator:
...     def __init__(self, iterable):
...         self.iterable = iterable
...
...     async def __aiter__(self):
...         for item in self.iterable:
...             yield item

>>> async def main():
...     async_generator = AsyncGenerator([4, 2])
...
...     async for item in async_generator:
...         print(f'Got item: {item}')

>>> asyncio.run(main())
Got item: 4
Got item: 2 

实际上,代码与常规生成器和 with 语句相同,但你也可以从函数中访问 asyncio 代码。这些方法真正特殊的地方只是它们需要 async 前缀和名称中的 a,因此你得到 __aiter__ 而不是 __iter__

创建异步上下文管理器以支持异步 with

与异步生成器类似,我们也可以创建一个异步上下文管理器。现在,我们不再需要替换 __iter__ 方法,而是用 __enter____exit__ 方法分别替换 __aenter____aexit__

实际上,代码与 with 语句相同,但你也可以从函数中访问 asyncio 代码:

>>> import asyncio

>>> class AsyncContextManager:
...     async def __aenter__(self):
...         print('Hi :)')
...
...     async def __aexit__(self, exc_type, exc_value, traceback):
...         print('Bye :(')

>>> async def main():
...     async_context_manager = AsyncContextManager()
...
...     print('Before with')
...     async with async_context_manager:
...         print('During with')
...     print('After with')

>>> asyncio.run(main())
Before with
Hi :)
During with
Bye :(
After with 

与异步生成器类似,这些方法实际上并没有什么特殊之处。但特别是异步上下文管理器对于设置/清理方法非常有用,我们将在下一节中看到。

异步构造函数和析构函数

在某个时候,你可能想在构造函数和/或析构函数中运行一些异步代码,可能是为了初始化数据库连接或其他类型的网络连接。不幸的是,这实际上是不可能的。

自然地,使用 __await__ 或元类,你可以绕过这一点来处理构造函数。并且使用 asyncio.run(...) 你可以为析构函数做类似的事情。但这两个都不是很好的解决方案——我建议重新结构化你的代码。

根据场景,我建议使用以下任一方法:

  • 使用 async with 语句正确地进入/退出上下文管理器

  • 在工厂模式中,一个 async def 为你生成和初始化类,同时还有一个 async def close() 作为异步析构函数

我们已经在上一节中看到了上下文管理器,这将是大多数情况下我会推荐的方法,例如创建数据库连接和/或事务,因为使用这种方法你不会意外忘记运行拆解操作。

工厂设计模式使用一个函数来简化对象的创建。在这种情况下,这意味着你将不再执行 instance = SomeClass(...),而是使用 instance = await SomeClass.create(...),这样你就可以有一个异步的初始化方法。

但当然,具有显式创建和关闭方法的工厂模式也是一个好选择:

>>> import asyncio

>>> class SomeClass:
...     def __init__(self, *args, **kwargs):
...         print('Sync init')
...
...     async def init(self, *args, **kwargs):
...         print('Async init')
...
...     @classmethod
...     async def create(cls, *args, **kwargs):
...         # Create an instance of 'SomeClass' which calls the
...         # sync init: 'SomeClass.__init__(*args, **kwargs)'
...         self = cls(*args, **kwargs)
...         # Now we can call the async init:
...         await self.init(*args, **kwargs)
...         return self
...
...     async def close(self):
...         print('Async destructor')
...
...     def __del__(self):
...         print('Sync destructor')

>>> async def main():
...     # Note that we use 'SomeClass.create()' instead of
...     # 'SomeClass()' so we also run 'SomeClass().init()'
...     some_class = await SomeClass.create()
...     print('Using the class here')
...     await some_class.close()
...     del some_class

>>> asyncio.run(main())
Sync init
Async init
Using the class here
Async destructor
Sync destructor 

按照之前显示的操作顺序,您可以正确地创建和拆除asyncio类。作为一个安全措施(明确调用close()始终是更好的解决方案),您可以通过调用循环来向您的__del__添加一个async析构函数。

对于下一个示例,我们将使用asyncpg库,所以请确保首先安装它:

$ pip3 install asyncpg 

现在,一个asyncio数据库连接到 PostgreSQL 可以像这样实现:

import typing
import asyncio
import asyncpg

class AsyncPg:
    _connection: typing.Optional[asyncpg.Connection]

    async def init(self):
        self._connection = asyncpg.connect(...)

    async def close(self):
        await self._connection.close()

    def __del__(self):
        if self._connection:
            loop = asyncio.get_event_loop()
            if loop.is_running():
                loop.create_task(self.close())
            else:
                loop.run_until_complete(self.close())

            self._connection = None 

您也可以创建一个注册表,以便轻松关闭所有创建的类,这样您就不会忘记在退出时这样做。但如果可能的话,我仍然建议使用上下文管理器风格的解决方案。您还可以通过创建contextlib.ContextDecoratorasync版本来使用装饰器创建一个方便的快捷方式。

接下来,我们将探讨如何调试asyncio代码以及如何捕捉常见的错误。

调试asyncio

asyncio模块有一些特殊规定,使调试变得相对容易。鉴于asyncio中函数的异步性质,这是一个非常受欢迎的特性。虽然多线程/多进程函数或类的调试可能很困难——因为并发类可以并行更改环境变量——但使用asyncio,这同样困难,甚至更困难,因为asyncio后台任务在事件循环的堆栈中运行,而不是您的自己的堆栈。

如果您希望跳过本章的这一部分,我强烈建议您至少阅读关于在所有任务完成之前退出的部分。这涵盖了asyncio的一个巨大的陷阱。

调试asyncio的第一种也是最明显的方法是使用事件循环调试模式。我们有几个选项可以启用调试模式:

  • PYTHONASYNCIODEBUG环境变量设置为True

  • 使用PYTHONDEVMODE环境变量或通过执行带有-X dev命令行选项的 Python 来启用 Python 开发模式

  • debug=True参数传递给asyncio.run()

  • 调用loop.set_debug()

在这些方法中,我建议使用PYTHONASYNCIODEBUGPYTHONDEVMODE环境变量,因为这些变量应用得非常早,因此可以捕获其他方法可能遗漏的几个错误。我们将在下一节关于忘记await语句的例子中看到这一点。

关于设置环境变量的说明

在大多数 Linux/Unix/Mac shell 会话中,可以使用variable=value作为前缀来设置环境变量:

SOME_ENVIRONMENT_VARIABLE=value python3 script.py 

此外,您还可以使用export为当前 shell(当使用 ZSH 或 Bash)会话配置环境变量:

export SOME_ENVIRONMENT_VARIABLE=value 

当前值可以使用以下行获取:

echo $SOME_ENVIRONMENT_VARIABLE 

在 Windows 上,您可以使用set命令为您的本地 shell 会话配置环境变量:

set SOME_ENVIRONMENT_VARIABLE=value 

当前值可以使用以下行获取:

set SOME_ENVIRONMENT_VARIABLE 

当启用调试模式时,asyncio模块将检查一些常见的asyncio错误和问题。具体来说:

  • 未调用的协程将引发异常。

  • 从“错误”的线程调用协程会引发异常。这可能会发生,如果你有代码在不同的线程中运行,而当前事件循环是在不同的线程中运行的。这实际上是一个线程安全问题,这在第十四章中有介绍。

  • 选择器的执行时间将被记录。

  • 慢速协程(超过 100 毫秒)将被记录。这个超时可以通过loop.slow_callback_duration来修改。

  • 当资源没有正确关闭时,将引发警告。

  • 在执行前被销毁的任务将被记录。

让我们展示一些这些错误。

忘记 await 协程

这可能是最常见的asyncio错误,它已经咬了我很多次。做some_coroutine()而不是await some_coroutine()很容易,你通常会在已经太晚的时候发现它。

幸运的是,Python 可以帮助我们解决这个问题,所以让我们看看当将PYTHONASYNCIODEBUG设置为1时忘记await协程会发生什么:

async def printer():
    print('This is a coroutine')

printer() 

这导致printer协程出现错误,我们忘记使用await

$ PYTHONASYNCIODEBUG=1 python3 T_13_forgot_await.py
T_13_forgot_await.py:5: RuntimeWarning: coroutine 'printer' was never awaited
  printer()
RuntimeWarning: Enable tracemalloc to get the object allocation traceback 

注意,这只会发生在事件循环被关闭时。事件循环无法知道你是否打算在稍后执行协程,因此这仍然可能很难调试。

这也是使用PYTHONASYNCIODEBUG环境变量而不是loop.set_debug(True)可能有所不同的案例之一。考虑一个场景,你可能有多个事件循环,并且忘记为它们全部启用调试模式,或者在一个忘记启用调试模式之前创建了协程,这意味着它不会被跟踪。

慢速阻塞函数

不考虑一个函数可能很慢并且会阻塞循环是很容易做到的。如果它有点慢但不足以让你注意到,除非你启用调试模式,否则你很可能永远不会发现它。让我们看看调试模式是如何帮助我们在这里的:

import time
import asyncio

async def main():
    # Oh no... a synchronous sleep from asyncio code
    time.sleep(0.2)

asyncio.run(main(), debug=True) 

在这种情况下,我们“意外”地使用了time.sleep()而不是asyncio.sleep()

对于这些问题,debug=True效果很好,但在开发时使用PYTHONASYNCIODEBUG=1永远不会有害:

$ PYTHONASYNCIODEBUG=1 python3 T_14_slow_blocking_code.py
Executing <Task finished ...> took 0.204 seconds 

如我们所预期,我们得到了这个慢速函数的警告。

默认警告阈值设置为 100 毫秒,而我们睡眠了 200 毫秒,因此被报告了。如果需要,可以通过loop.slow_callback_duration=<seconds>来更改阈值。如果你正在使用较慢的系统,如树莓派,或者想要查找慢速代码,这可能很有用。

忘记检查结果或提前退出

使用asyncio编写代码的常见方式是使用asyncio.create_task()的 fire-and-forget,而不存储结果 future。虽然这本身并不是错误的,但如果你的代码中意外发生异常,如果没有启用调试模式,可能很难找到原因。

为了说明,我们将使用以下未捕获的异常,并在启用和未启用调试模式的情况下执行它:

import asyncio

async def throw_exception():
    raise RuntimeError()

async def main():
    # Ignoring an exception from an async def
    asyncio.create_task(throw_exception())

asyncio.run(main()) 

如果我们不启用调试模式执行此操作,我们会得到以下输出:

$ python3 T_15_forgotten_exception.py
Task exception was never retrieved
future: <Task finished ... at T_15_forgotten_exception.py:4> exception=RuntimeError()>
Traceback (most recent call last):
  File "T_15_forgotten_exception.py", line 5, in throw_exception
    raise RuntimeError()
RuntimeError 

虽然这很好地显示了异常发生的位置和发生的异常,但它并没有显示是谁或什么创建了协程。

现在如果我们启用调试模式重复同样的操作,我们得到以下结果:

$ PYTHONASYNCIODEBUG=1 python3 T_15_forgotten_exception.py
Task exception was never retrieved
future: <Task finished ... at T_15_forgotten_exception.py:4> exception=RuntimeError() created at asyncio/tasks.py:361>
source_traceback: Object created at (most recent call last):
  File "T_15_forgotten_exception.py", line 13, in <module>
    asyncio.run(main())
  File "asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "asyncio/base_events.py", line 629, in run_until_complete
    self.run_forever()
  File "asyncio/base_events.py", line 596, in run_forever
    self._run_once()
  File "asyncio/base_events.py", line 1882, in _run_once
    handle._run()
  File "asyncio/events.py", line 80, in _run
    self._context.run(self._callback, *self._args)
  File "T_15_forgotten_exception.py", line 10, in main
    asyncio.create_task(throw_exception())
  File "asyncio/tasks.py", line 361, in create_task
    task = loop.create_task(coro)
Traceback (most recent call last):
  File "T_15_forgotten_exception.py", line 5, in throw_exception
    raise RuntimeError()
RuntimeError 

这可能仍然有点难以阅读,但现在我们可以看到异常起源于asyncio.create_task(throw_exception()),我们甚至可以看到asyncio.run(main())的调用。

对于一个稍微大一点的代码库,这在追踪你的异常来源时可能是至关重要的。

在所有任务完成之前退出

注意这里,因为这个问题是极其微妙的,但如果你没有注意到,它可能会产生巨大的后果。

与忘记获取结果类似,当你在一个循环正在拆解时创建一个任务,该任务不一定会运行。在某些情况下,它没有运行的机会,你很可能不会注意到这一点。

看一下这个例子,我们有一个任务在生成另一个任务:

import asyncio

async def sub_printer():
    print('Hi from the sub-printer')

async def printer():
    print('Before creating the sub-printer task')
    asyncio.create_task(sub_printer())
    print('After creating the sub-printer task')

async def main():
    asyncio.create_task(printer())

asyncio.run(main()) 

在这种情况下,即使是调试模式也无法帮助你。为了说明这一点,让我们看看当我们启用调试模式调用这个函数时会发生什么:

$ PYTHONASYNCIODEBUG=1 python3 T_16_early_exit.py
Before creating the sub-printer task
After creating the sub-printer task 

sub_printer()的调用似乎消失了。实际上并没有消失,但我们没有明确等待它完成,所以它从未有机会运行。

到目前为止,最好的解决方案是跟踪由asyncio.create_task()创建的所有 future,并在你的main()函数的末尾执行await asyncio.gather(*futures)。但这并不总是可行的选项——你可能无法访问由其他库创建的 future,或者 future 可能是在你无法轻松访问的作用域中创建的。那么你能做什么呢?

作为一个非常简单的解决方案,你可以在main()函数的末尾简单地等待:

import asyncio

async def sub_printer():
    print('Hi from the sub-printer')

async def printer():
    print('Before creating the sub-printer task')
    asyncio.create_task(sub_printer())
    print('After creating the sub-printer task')

async def main():
    asyncio.create_task(printer())
    await asyncio.sleep(0.1)

asyncio.run(main()) 

对于这种情况,添加一点睡眠时间可以解决问题:

$ python3 T_17_wait_for_exit.py
Before creating the sub-printer task
After creating the sub-printer task
Hi from the sub-printer 

但这只有在你的任务足够快或者你增加了睡眠时间的情况下才有效。如果我们有一个需要几秒钟的数据库拆解方法,我们仍然可能会遇到问题。作为一个非常粗略的解决方案,将此添加到你的代码中可能是有用的,因为当你缺少一个任务时,这会更为明显。

一个稍微好一点的解决方案是询问asyncio哪些任务仍在运行,并等待它们完成。这种方法的一个缺点是,如果你有一个永远运行的任务(换句话说,while True),你将永远等待脚本退出。

所以,让我们看看我们如何实现这样一个具有固定超时时间的功能,这样我们就不会永远等待:

import asyncio

async def sub_printer():
    print('Hi from the sub-printer')

async def printer():
    print('Before creating the sub-printer task')
    asyncio.create_task(sub_printer())
    print('After creating the sub-printer task')

async def main():
    asyncio.create_task(printer())
    await shutdown()

async def shutdown(timeout=5):
    tasks = []
    # Collect all tasks from 'asyncio'
    for task in asyncio.all_tasks():
        # Make sure we skip our current task so we don't loop
        if task is not asyncio.current_task():
            tasks.append(task)

    for future in asyncio.as_completed(tasks, timeout=timeout):
        await future

asyncio.run(main()) 

这次,我们添加了一个shutdown()方法,它使用asyncio.all_tasks()asyncio获取所有任务。在收集任务后,我们需要确保我们不会得到我们的当前任务,因为这会导致鸡生蛋的问题。shutdown()任务将在等待shutdown()任务完成时永远不会退出。

当所有任务都收集完毕后,我们使用asyncio.as_completed()等待它们完成并返回。如果等待时间超过timeout秒,asyncio.as_completed()将为我们抛出一个asyncio.TimeoutError

您可以轻松地修改它以尝试取消所有任务,这样所有非受保护的任务将立即被取消。如果待处理任务在您的用例中不是关键的,您也可以将异常更改为警告。

task = asyncio.shield(...)可以防止task.cancel()和类似操作,就像洋葱一样。单个asyncio.shield()可以防止单个task.cancel();要防止多次取消,您需要在循环中屏蔽,或者至少多次屏蔽。

最后,应该注意的是,这个解决方案也不是没有缺陷。在运行过程中,任务可能会产生新的任务;这不是这个实现所处理的,而且处理不当可能会导致永远等待。

现在我们知道了如何调试最常见的asyncio问题,是时候通过一些练习来结束了。

练习

在整个开发过程中,使用asyncio将需要积极的思考。除了asyncio.run()和类似的方法外,没有其他方法可以从同步代码中运行async def。这意味着您主async def和需要asyncio的代码之间的每个中间函数都必须是async的。

您可以将同步函数返回一个协程,这样父函数中的一个就可以在事件循环中运行它。但通常这会导致代码执行顺序非常混乱,所以我不会推荐走这条路。

简而言之,这意味着您尝试的任何启用asyncio调试设置的asyncio项目都是良好的实践。然而,我们可以提出一些挑战:

  • 尝试创建一个asyncio基类,当您完成时可以自动注册所有实例,以便于轻松关闭/解构

  • 使用 executors 为文件或网络操作等同步过程创建asyncio包装类

  • 将您的脚本或项目转换为asyncio

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。我们鼓励您提交自己的解决方案,并从他人的替代方案中学习。

概述

在本章中,我们看到了:

  • asyncio的基本概念及其交互方式

  • 如何使用asyncio运行外部进程

  • 如何使用asyncio创建服务器和客户端

  • 如何使用asyncio创建上下文管理器

  • 如何使用asyncio创建生成器

  • 如何调试使用asyncio时常见的错误

  • 如何避免未完成任务的陷阱

到现在为止,您应该知道如何在等待结果的同时保持主循环的响应性,而无需求助于轮询。在 第十四章,“当单个 CPU 核心不够用时——多进程”,我们将学习 threadingmultiprocessing 作为在并行运行多个函数时的 asyncio 替代方案。

对于新项目,我强烈建议从头开始使用 asyncio,因为它通常是处理外部资源的最快解决方案。然而,对于现有脚本,这可能是一个非常侵入性的过程。因此,了解 threadingmultiprocessing 确实很重要,也因为 asyncio 可以利用它们,您应该了解线程和进程的安全性。

当基于 asyncio 库构建工具时,请确保搜索现成的库来解决您的问题,因为 asyncio 正在每年获得更多的采用。在许多情况下,有人已经为您创建了一个库。

接下来是使用 threadingmultiprocessing 的并行执行。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第十四章:多进程 – 当单个 CPU 核心不够用时

在上一章中,我们讨论了asyncio,它可以使用threadingmultiprocessing模块,但主要使用单线程/单进程并行化。在本章中,我们将看到如何直接使用多个线程或进程来加速我们的代码,以及需要注意的注意事项。实际上,本章可以被视为性能技巧列表的扩展。

threading模块使得在单个进程中并行运行代码成为可能。这使得threading对于与 I/O 相关的任务(如读写文件或网络通信)非常有用,但对于缓慢且计算量大的任务则不是一个有用的选项,这正是multiprocessing模块大放异彩的地方。

使用multiprocessing模块,你可以在多个进程中运行代码,这意味着你可以在多个 CPU 核心、多个处理器或甚至多台计算机上运行代码。这是绕过在第十二章性能 – 跟踪和减少你的内存和 CPU 使用中讨论的全局解释器锁GIL)的一种简单方法。

multiprocessing模块提供了一个相对容易使用的接口,具有许多便利功能,但threading模块相对基础,需要你手动创建和管理线程。为此,我们还有concurrent.futures模块,它提供了一种简单的方法来执行一系列任务,无论是通过线程还是进程。此接口也与我们在上一章中看到的asyncio功能部分可比。

总结来说,本章涵盖了:

  • 全局解释器锁(GIL)

  • 多线程与多进程的比较

  • 锁定、死锁和线程安全

  • 进程间的数据共享和同步

  • 在多线程、多进程和单线程之间进行选择

  • 超线程与物理核心的比较

  • 使用multiprocessingipyparallel进行远程多进程

全局解释器锁(GIL)

GIL(全局解释器锁)在本书中已经被提及多次,但我们并没有对其进行详细讲解,它确实需要更多的解释才能继续本章的内容。

简而言之,名称已经解释了它的功能。它是一个 Python 解释器的全局锁,因此它一次只能执行一个语句。在并行计算中,互斥锁互斥)是一种同步原语,可以阻止并行执行。有了锁,你可以确保在你工作时没有人可以触摸你的变量。

Python 提供了几种类型的同步原语,如threading.Lockthreading.Semaphore。这些内容在本章的线程和进程间共享数据部分有更详细的介绍。

这意味着即使使用threading模块,你同时也只能执行一个 Python 语句。因此,当涉及到纯 Python 代码时,你的多线程解决方案总是会比单线程解决方案慢,因为threading引入了一些同步开销,而在这种情况下并没有提供任何好处。

让我们继续深入了解 GIL 的更多信息。

多线程的使用

由于 GIL 只允许同时执行一个 Python 语句,那么线程有什么用呢?其有效性很大程度上取决于你的目标。类似于第十三章中的asyncio示例,如果你正在等待外部资源,threading可以给你带来很多好处。

例如,如果你正在尝试获取一个网页,打开一个文件(记住aiofiles模块实际上使用线程),或者如果你想定期执行某些操作,threading可以非常有效。

当编写一个新应用程序时,如果将来有即使是微小的可能性成为 I/O 受限,我通常会建议你让它准备好使用asyncio。在以后的时间重新编写以适应asyncio可能是一项巨大的工作量。

asyncio相对于threading有几个优点:

  • asyncio通常比线程更快,因为你没有线程同步开销。

  • 由于asyncio通常是单线程的,你不必担心线程安全问题(关于线程安全的内容将在本章后面详细介绍)。

我们为什么需要 GIL?

GIL 目前是 CPython 解释器的一个基本组成部分,因为它确保内存管理始终是一致的。

为了解释这是如何工作的,我们需要了解一些关于 CPython 解释器如何管理其内存的信息。

在 CPython 中,内存管理系统和垃圾回收系统依赖于引用计数。这意味着 CPython 会计算你有多少个名称链接到一个值。如果你有一行 Python 代码像这样:a = SomeObject(),这意味着这个SomeObject实例有 1 个引用,即a。如果我们执行b = a,引用计数将增加到 2。当引用计数达到 0 时,变量将在垃圾回收器运行时被删除。

你可以使用sys.getrefcount(variable)来检查引用的数量。你应该注意,对sys.getrefcount()的调用会增加你的引用计数 1,所以如果它返回 2,实际的数量是 1。

由于 GIL 确保同时只能执行一个 Python 语句,你永远不会遇到多个代码块同时操作内存的问题,或者内存被释放到实际上并未空闲的系统。

如果引用计数器没有正确管理,这很容易导致内存泄漏或 Python 解释器崩溃。记得我们在第十一章调试 – 解决错误中看到的段错误?这就是没有 GIL 时可能发生的事情,它将立即杀死你的 Python 解释器。

我们为什么仍然有 GIL?

当 Python 最初被创建时,许多操作系统甚至没有线程的概念,所有常见的处理器都只有一个核心。简而言之,GIL 存在有两个主要原因:

  • 初始时,创建一个处理线程的复杂解决方案并没有什么意义

  • GIL 是一个针对非常复杂问题的简单解决方案

幸运的是,这似乎并不是讨论的终点。最近(2021 年 5 月),吉多·范罗苏姆(Guido van Rossum)从退休状态中复出,他计划通过为线程创建子解释器来解决 GIL 的限制。当然,如何在实践中实现这一点还有待观察,但雄心勃勃的计划是将 CPython 3.15 的速度提升到 CPython 3.10 的 5 倍,这将是一个惊人的性能提升。

现在我们已经知道 GIL 限制了 CPython 线程,让我们看看我们如何创建和使用多个线程和进程。

多线程和多进程

multiprocessing模块在 Python 2.6 中被引入,它在处理 Python 中的多个进程方面是一个游戏规则的改变。具体来说,它使得绕过 GIL 的限制变得相当容易,因为每个进程都有自己的 GIL。

multiprocessing模块的使用在很大程度上与threading模块相似,但它有几个非常有用的额外功能,这些功能在多进程中使用时更有意义。或者,你也可以使用concurrent.futures.ProcessPoolExecutor,它的接口几乎与concurrent.futures.ThreadPoolExecutor相同。

这些相似之处意味着在许多情况下,你可以简单地替换模块,你的代码仍然会按预期运行。然而,不要被误导;尽管线程仍然可以使用相同的内存对象,并且只需要担心线程安全和死锁,但多个进程也存在这些问题,并且在共享内存、对象和结果时还会引入其他问题。

在任何情况下,处理并行代码都伴随着注意事项。这也是为什么使用多个线程或进程的代码有难以工作的声誉。许多这些问题并不像看起来那么可怕;如果你遵循一些规则,那就是了。

在我们继续示例代码之前,你应该意识到,在使用multiprocessing时,将你的代码放在if __name__ == '__main__'块中至关重要。当multiprocessing模块启动额外的 Python 进程时,它将执行相同的 Python 脚本,所以如果不使用这个块,你将陷入无限循环的启动进程。

在本节中,我们将介绍:

  • 使用 threadingmultiprocessingconcurrent.futures 的基本示例

  • 清洁退出线程和进程

  • 批处理

  • 在进程间共享内存

  • 线程安全性

  • 死锁

  • 线程局部变量

其中一些,如竞态条件和锁定,不仅限于线程,对multiprocessing也可能很有趣。

基本示例

要创建线程和进程,我们有几种选择:

  • concurrent.futures:一个易于使用的接口,用于在线程或进程中运行函数,类似于asyncio

  • threading:一个用于直接创建线程的接口

  • multiprocessing:一个具有许多实用和便利函数的接口,用于创建和管理多个 Python 进程

让我们看看每个示例。

concurrent.futures

让我们从concurrent.futures模块的基本示例开始。在这个例子中,我们运行了两个并行运行和打印的计时器任务:

import time
import concurrent.futures

def timer(name, steps, interval=0.1):
    '''timer function that sleeps 'steps * interval' '''
    for step in range(steps):
        print(name, step)
        time.sleep(interval)

if __name__ == '__main__':
    # Replace with concurrent.futures.ProcessPoolExecutor for
    # multiple processes instead of threads
    with concurrent.futures.ThreadPoolExecutor() as executor:
        # Submit the function to the executor with some arguments
        executor.submit(timer, steps=3, name='a')
        # Sleep a tiny bit to keep the output order consistent
        time.sleep(0.1)
        executor.submit(timer, steps=3, name='b') 

在我们执行代码之前,让我们看看我们在这里做了什么。首先,我们创建了一个timer函数,该函数运行time.sleep(interval)并执行steps次。在休眠之前,它打印出name和当前的step,这样我们就可以轻松地看到发生了什么。

然后,我们使用concurrent.futures.ThreadPoolExecutor创建一个executor来执行函数。

最后,我们将要执行的函数及其相应的参数提交以启动两个线程。在启动它们之间,我们休眠了很短的时间,所以在这个例子中我们的输出是一致的。如果我们不执行time.sleep(0.1),输出顺序将是随机的,因为有时a会更快,而有时b会更快。

包含短暂休眠的主要原因是测试。本书中的所有代码都可在 GitHub 上找到(github.com/mastering-python/code_2),并且会自动进行测试。

现在当我们执行这个脚本时,我们得到以下结果:

$ python3 T_00_concurrent_futures.py
a 0
b 0
a 1
b 1
a 2
b 2 

如预期的那样,它们紧挨着运行,但由于我们添加了微小的time.sleep(0.1),结果是一致地交织在一起。在这种情况下,我们使用默认参数启动了ThreadPoolExecutor,这导致没有特定名称的线程和自动计算的线程数。

线程数取决于 Python 版本。在 Python 3.8 之前,工作进程的数量等于机器中超线程 CPU 核心的数量乘以 5。因此,如果你的机器有 2 个启用超线程的核心,那么结果将是 4 个核心 * 5 = 20 个线程。对于 64 核的机器,这将导致 320 个线程,这可能会产生比好处更多的同步开销。

对于 Python 3.8 及以上版本,这已被更改为min(32, cores + 4),这应该足以始终至少有 5 个线程用于 I/O 操作,但不会太多以至于在多核机器上使用大量资源。对于相同的64核机器,这仍然限制在32个线程。

ProcessPoolExecutor 的情况下,将使用包括超线程在内的处理器核心数。这意味着如果您的处理器有 4 个核心并且启用了超线程,您将默认获得 8 个进程。

自然地,传统的 threading 模块仍然是一个不错的选择,它提供了更多的控制,同时仍然拥有易于使用的接口。

在 Python 3 之前,thread 模块也作为线程的低级 API 可用。此模块仍然可用,但已重命名为 _thread。在内部,concurrent.futures.ThreadPoolExecutorthreading 都仍然使用它,但您通常不需要直接访问它。

threading

现在我们将看看如何使用 threading 模块重新创建 concurrent.futures 的示例:

import time
import threading

def timer(name, steps, interval=0.1):
    '''timer function that sleeps 'steps * interval' '''
    for step in range(steps):
        print(name, step)
        time.sleep(interval)

# Create the threads declaratively
a = threading.Thread(target=timer, kwargs=dict(name='a', steps=3))
b = threading.Thread(target=timer, kwargs=dict(name='b', steps=3))

# Start the threads
a.start()
# Sleep a tiny bit to keep the output order consistent
time.sleep(0.1)
b.start() 

timer 函数与前面的示例相同,所以这里没有差异。但是,执行方式略有不同。

在这种情况下,我们通过直接实例化 threading.Thread() 来创建线程,但继承 threading.Thread 也是一个选项,正如我们将在下一个示例中看到的。可以传递 args 和/或 kwargs 参数来提供 target 函数的参数,但如果您不需要它们或者已经使用 functools.partial 填充了它们,则这些参数是可选的。

在前面的示例中,我们创建了一个 ThreadPoolExecutor(),它会创建许多线程并在这些线程上运行函数。在这个示例中,我们明确创建线程来运行单个函数并在函数完成后退出。这对于长时间运行的背景线程非常有用,因为这个方法需要为每个函数设置和拆除线程。通常,启动线程的开销非常小,但它取决于您的 Python 解释器(CPython、PyPy 等)和操作系统。

现在对于相同的示例,但继承 threading.Thread 而不是对 threading.Thread() 的声明性调用:

import time
import threading

class Timer(threading.Thread):
    def __init__(self, name, steps, interval=0.1):
        self.steps = steps
        self.interval = interval
        # Small gotcha: threading.Thread has a built-in name
        # parameter so be careful not to manually override it
        super().__init__(name=name)

    def run(self):
        '''timer function that sleeps 'steps * interval' '''
        for step in range(self.steps):
            print(self.name, step)
            time.sleep(self.interval)
a = Timer(name='a', steps=3)
b = Timer(name='b', steps=3)

# Start the threads
a.start()
# Sleep a tiny bit to keep the output order consistent
time.sleep(0.1)
b.start() 

代码大致与直接调用 threading.Thread() 的过程式版本相同,但有两大关键差异您需要注意:

  • namethreading.Thread 的一个保留属性。在 Linux/Unix 机器上,您的进程管理器(例如,top)可以显示此名称而不是 /usr/bin/python3

  • 默认的目标函数是 run()。请小心覆盖 run() 方法而不是 start() 方法,否则当您调用 start() 时,您的代码将不会在单独的线程中执行,而将像常规函数调用一样执行。

过程式和基于类的版本在内部使用完全相同的 API,并且同样强大,因此选择它们主要取决于个人偏好。

multiprocessing

最后,我们也可以使用 multiprocessing 来重新创建早期的定时器脚本。首先是通过 multiprocessing.Process() 的过程调用:

import time
import multiprocessing

def timer(name, steps, interval=0.1):
    '''timer function that sleeps 'steps * interval' '''
    for step in range(steps):
        print(name, step)
        time.sleep(interval)

if __name__ == '__main__':
    # Create the processes declaratively
    a = multiprocessing.Process(target=timer, kwargs=dict(name='a', steps=3))
    b = multiprocessing.Process(target=timer, kwargs=dict(name='b', steps=3))

    # Start the processes
    a.start()
    # Sleep a tiny bit to keep the output order consistent
    time.sleep(0.1)
    b.start() 

代码看起来几乎相同,只有一些小的变化。我们使用了multiprocessing.Process而不是threading.Thread,并且我们必须从if __name__ == '__main__'块中运行代码。除此之外,在这个简单的例子中,代码和执行都是相同的。

最后,为了完整性,让我们也看看基于类的版本:

import time
import multiprocessing

class Timer(multiprocessing.Process):
    def __init__(self, name, steps, interval=0.1):
        self.steps = steps
        self.interval = interval
        # Similar to threading.Thread, multiprocessing.Process
        # also supports the name parameter but you are not
        # required to use it here.
        super().__init__(name=name)

    def run(self):
        '''timer function that sleeps 'steps * interval' '''
        for step in range(self.steps):
            print(self.name, step)
            time.sleep(self.interval)

if __name__ == '__main__':
    a = Timer(name='a', steps=3)
    b = Timer(name='b', steps=3)

    # Start the process
    a.start()
    # Sleep a tiny bit to keep the output order consistent
    time.sleep(0.1)
    b.start() 

再次强调,我们必须使用if __name__ == '__main__'块。但除此之外,代码与threading版本几乎相同。就像threading一样,选择过程式和基于类的风格仅取决于你个人的偏好。

现在我们已经知道了如何启动线程和进程,让我们看看我们如何可以干净地关闭它们。

清理退出长时间运行的线程和进程

threading模块主要用于处理外部资源的长时间运行的线程。一些示例场景:

  • 当创建服务器并希望持续监听新的连接

  • 当连接到 HTTP WebSockets 并且需要保持连接打开时

  • 当你需要定期保存你的更改

自然地,这些场景也可以使用multiprocessing,但threading通常更方便,我们将在后面看到。

在某个时候,你可能需要从外部关闭线程;例如,在主脚本的退出过程中。等待自行退出的线程是微不足道的;你需要做的只是future.result()some_thread.join(timeout=...),然后你就完成了。更困难的部分是告诉线程自行关闭并在它仍在做某事时运行清理。

解决这个问题的唯一真正的方法,如果你幸运的话,是一个简单的while循环,它会一直运行,直到你给出停止信号,如下所示:

import time
import threading

class Forever(threading.Thread):
    def __init__(self):
        self.stop = threading.Event()
        super().__init__()

    def run(self):
        while not self.stop.is_set():
            # Do whatever you need to do here
            time.sleep(0.1)

thread = Forever()
thread.start()
# Do whatever you need to do here
thread.stop.set()
thread.join() 

此代码使用threading.Event()作为标志来告诉线程在需要时退出。虽然你可以使用bool代替threading.Event()与当前的 CPython 解释器一起使用,但无法保证这将在未来的 Python 版本和/或其他类型的解释器中工作。目前这之所以对 CPython 来说是安全的,是因为由于 GIL,所有 Python 操作实际上都是单线程的。这就是为什么线程对于等待外部资源很有用,但会对你的 Python 代码的性能产生负面影响。

此外,如果你要将此代码翻译成多进程,你可以简单地用multiprocessing.Event()替换threading.Event(),并且它应该在没有其他更改的情况下继续工作,假设你没有与外部变量交互。在多个 Python 进程中,你不再受到单个 GIL 的保护,因此在修改变量时需要更加小心。关于这个话题的更多内容将在本章后面的线程和进程间共享数据部分中介绍。

现在我们有了stop事件,我们可以运行stop.set(),这样线程就知道何时退出,并在最多 0.1 秒的睡眠后退出。

这是一个理想的场景:有一个循环,循环条件会定期检查,循环间隔是你的最大线程关闭延迟。如果线程正忙于执行某些操作而没有检查while条件会发生什么?正如你可能猜到的,在这些场景中设置stop事件是无用的,你需要一个更强大的方法来退出线程。

处理这种场景,你有几种选择:

  • 通过使用asynciomultiprocessing来完全避免这个问题。在性能方面,asyncio无疑是你的最佳选择,但如果你的代码适合,multiprocessing也可以工作得很好。

  • 通过在启动线程之前将your_thread.daemon = True设置为守护线程。这样,当主进程退出时,线程会自动终止,因此这不是一个优雅的关闭。你仍然可以使用atexit模块添加拆卸。

  • 通过告诉操作系统发送终止/杀死信号或在主线程中从线程内部抛出异常来从外部杀死线程。你可能想尝试这种方法,但我强烈建议不要这样做。这不仅不可靠,还可能导致你的整个 Python 解释器崩溃,所以这绝对不是你应该考虑的选项。

我们已经在上一章中看到了如何使用asyncio,所以让我们看看我们如何使用multiprocessing来终止。然而,在我们开始之前,你应该注意,适用于threading的限制在很大程度上也适用于multiprocessing。虽然multiprocessing确实有一个内置的终止进程的解决方案,与线程不同,但这仍然不是一个干净的方法,它也不会(可靠地)运行你的退出处理程序、finally子句等。这意味着你应该始终首先尝试一个事件,但当然使用multiprocessing.Event而不是threading.Event

为了说明我们如何强制终止或杀死一个线程(同时冒着内存损坏的风险):

import time
import multiprocessing

class Forever(multiprocessing.Process):
    def run(self):
        while True:
            # Do whatever you need to do here
            time.sleep(0.1)

if __name__ == '__main__':
    process = Forever()
    process.start()

    # Kill our "unkillable" process
    process.terminate()
    # Wait for 10 seconds to properly exit      
    process.join(10)

    # If it still didn't exit, kill it
    if process.exitcode is None:
        process.kill() 

在这个例子中,我们首先尝试一个常规的terminate(),它在 Unix 机器上发送SIGTERM信号,在 Windows 上是TerminateProcess()。如果那不起作用,我们再次尝试使用kill(),它在 Unix 上发送SIGKILL信号,目前在 Windows 上没有等效的信号,所以在 Windows 上kill()terminate()方法的行为相同,并且两者都有效地终止了进程而没有进行拆卸。

使用concurrent.futures进行批处理

如我们在先前的例子中所见,以“点火并忘记”的方式启动线程或进程是足够简单的。然而,通常,你想要启动几个线程或进程,并等待它们全部完成。

这是一个concurrent.futuresmultiprocessing真正闪耀的案例。它们允许您以与我们在第五章中看到的方式非常相似地调用executor.map()pool.map(),即第五章函数式编程 – 可读性与简洁性之间的权衡。实际上,您只需要创建一个要处理的项目列表,调用[executor/pool].map()函数,就完成了。如果您想找点乐子,可以使用threading模块构建类似的东西,但除此之外,它的用途很少。

为了测试我们的系统,让我们获取一些关于应该使用系统 DNS 解析系统的主机名的信息。由于它查询外部资源,我们使用线程时应该期待良好的结果,对吧?好吧...让我们试一试,看看结果如何:

import timeit
import socket
import concurrent.futures

def getaddrinfo(*args):
    # Call getaddrinfo but ignore the given parameter
    socket.getaddrinfo('localhost', None)

def benchmark(threads, n=1000):
    if threads > 1:
        # Create the executor
        with concurrent.futures.ThreadPoolExecutor(threads) \
                as executor:
            executor.map(getaddrinfo, range(n))

    else:
        # Make sure to use 'list'. Otherwise the generator will
        # not execute because it is lazy
        list(map(getaddrinfo, range(n)))

if __name__ == '__main__':
    for threads in (1, 10, 50, 100):
        print(f'Testing with {threads} threads and n={10} took: ',
              end='')
        print('{:.1f}'.format(timeit.timeit(
            f'benchmark({threads})',
            setup='from __main__ import benchmark',
            number=10,
        ))) 

让我们分析一下这段代码。首先,我们有一个getaddrinfo()函数,它尝试通过您的操作系统获取关于主机名的一些信息,这是一个可能从多线程中受益的外部资源。

第二,我们有一个benchmark()函数,如果threads设置为大于 1 的数字,它将使用多个线程进行map()。如果没有,它将使用常规的map()

最后,我们对11050100个线程进行了基准测试,其中1是常规的非线程方法。那么线程能帮我们多少呢?这个测试强烈依赖于您的计算机、操作系统、网络等,所以您的结果可能会有所不同,但这是我使用 CPython 3.10 在我的 OS X 机器上发生的情况:

$ python3 T_07_thread_batch_processing.py
Testing with 1 threads and n=10 took: 2.1
Testing with 10 threads and n=10 took: 1.9
Testing with 50 threads and n=10 took: 1.9
Testing with 100 threads and n=10 took: 13.9 

您期待这些结果吗?虽然1个线程确实比10个线程和50个线程慢,但在100个线程时,我们明显看到了收益递减和拥有100个线程的开销。此外,由于socket.getaddrinfo()相当快,使用多线程的好处在这里相当有限。

如果我们从慢速网络文件系统读取大量文件,或者如果我们用它来并行获取多个网页,我们会看到更大的差异。这立即显示了线程的缺点:它只有在外部资源足够慢,以至于同步开销是合理的时才会带来好处。对于快速的外部资源,您可能会遇到减速,因为全局解释器锁(GIL)成为了瓶颈。CPython 一次只能执行一个语句,这可能会迅速变成问题。

当谈到性能时,您应该始终运行基准测试以查看最适合您情况的方法,尤其是在线程数量方面。正如您在早期示例中看到的,更多并不总是更好,100 线程版本比单线程版本慢得多。

那么,如果我们尝试使用进程而不是线程来执行相同的操作会怎样呢?为了简洁起见,我们将跳过实际的代码,因为我们实际上只需要将 concurrent.futures.ThreadPoolExecutor() 替换为 concurrent.futures.ProcessPoolExecutor(),然后我们就完成了。如果你感兴趣,测试的代码可以在 GitHub 上找到。当我们执行这段代码时,我们得到以下结果:

$ python3 T_08_process_batch_processing.py
Testing with 1 processes and n=10 took: 2.1
Testing with 10 processes and n=10 took: 3.2
Testing with 50 processes and n=10 took: 8.3
Testing with 100 processes and n=10 took: 15.0 

如你所见,当我们使用多个进程时,我们得到了普遍较慢的结果。虽然多进程在 GIL 或单个 CPU 核心是限制时可以提供很多好处,但开销可能会在其他场景中影响你的性能。

使用多进程进行批量处理

在上一节中,我们看到了如何使用 concurrent.futures 进行批量处理。你可能想知道为什么我们想要直接使用 multiprocessing,而不是 concurrent.futures 可以为我们处理它。原因相当简单:concurrent.futures 是一个易于使用且非常简单的接口,用于 threadingmultiprocessing,但 multiprocessing 提供了几个高级选项,这些选项可以非常方便,甚至可以在某些场景中帮助提高你的性能。

在之前的例子中,我们只看到了 multiprocessing.Process,它是 threading.Thread 的进程类似物。然而,在这种情况下,我们将使用 multiprocessing.Pool,它创建了一个与 concurrent.futures 执行器非常相似的进程池,但提供了几个额外的功能:

  • map_async(func, iterable, [..., callback, ...])

    map_async() 方法与 concurrent.futures 中的 map() 方法相似,但它返回一个 AsyncResult 对象的列表,这样你就可以在你需要的时候获取结果。

  • imap(func, iterable[, chunksize])

    imap() 方法实际上是 map() 的生成器版本。它的工作方式大致相同,但它不会预先加载可迭代对象中的项,因此如果你需要,可以安全地处理大型可迭代对象。如果你需要处理许多项,这可以 大大 提高速度。

  • imap_unordered(func, iterable[, chunksize])

    imap_unordered() 方法实际上与 imap() 相同,但它会在处理完结果后立即返回结果,这可以进一步提高性能。如果你的结果顺序不重要,可以尝试一下,因为它可以使你的代码更快。

  • starmap(func, iterable[, chunksize])

    starmap() 方法与 map() 方法非常相似,但通过像 *args 这样的方式支持多个参数。如果你要运行 starmap(function, [(1, 2), (3, 4)])starmap() 方法将调用 function(1, 2)function(3, 4)。这在与 zip() 结合使用时可以非常实用,以组合多个参数列表。

  • starmap_async(func, iterable, [..., callback, ...])

    如你所想,starmap_async() 实际上是非阻塞的 starmap() 方法,但它返回一个 AsyncResult 对象的列表,这样你就可以在你方便的时候获取它们。

multiprocessing.Pool()的使用在很大程度上类似于concurrent.future.SomeExecutor(),除了上面提到的额外方法之外。根据你的场景,它可能比concurrent.futures慢,速度相似,或者更快,所以总是确保为你的特定用例进行基准测试。以下这段基准代码应该会给你一个很好的起点:

import timeit
import functools
import multiprocessing
import concurrent.futures

def triangle_number(n):
    total = 0
    for i in range(n + 1):
        total += i

    return total

def bench_mp(n, count, chunksize):
    with multiprocessing.Pool() as pool:
        # Generate a generator like [n, n, n, ..., n, n]
        iterable = (n for _ in range(count))
        list(pool.imap_unordered(triangle_number, iterable,
                                 chunksize=chunksize))

def bench_ft(n, count, chunksize):
    with concurrent.futures.ProcessPoolExecutor() as executor:
        # Generate a generator like [n, n, n, ..., n, n]
        iterable = (n for _ in range(count))
        list(executor.map(triangle_number, iterable,
                          chunksize=chunksize))

if __name__ == '__main__':
    timer = functools.partial(timeit.timeit, number=5)

    n = 1000
    chunksize = 50
    for count in (100, 1000, 10000):
        # Using <6 formatting for consistent alignment
        args = ', '.join((
            f'n={n:<6}',
            f'count={count:<6}',
            f'chunksize={chunksize:<6}',
        ))
        time_mp = timer(
            f'bench_mp({args})',
            setup='from __main__ import bench_mp',
        )
        time_ft = timer(
            f'bench_ft({args})',
            setup='from __main__ import bench_ft',
        )

        print(f'{args} mp: {time_mp:.2f}, ft: {time_ft:.2f}') 

在我的机器上,这给出了以下结果:

$ python3 T_09_multiprocessing_pool.py
n=1000  , count=100   , chunksize=50     mp: 0.71, ft: 0.42
n=1000  , count=1000  , chunksize=50     mp: 0.76, ft: 0.96
n=1000  , count=10000 , chunksize=50     mp: 1.12, ft: 1.40 

在我进行基准测试之前,我没有预料到concurrent.futures在某些情况下会快得多,而在其他情况下会慢得多。分析这些结果,你可以看到,使用concurrent.futures处理 1,000 个项目比在这个特定情况下使用多进程处理 10,000 个项目花费的时间要多。同样,对于 100 个项目,multiprocessing模块几乎慢了两倍。自然地,每次运行都会产生不同的结果,并且没有单一的选项会在每种情况下都表现良好,但这是需要记住的。

现在我们知道了如何在多个线程或进程中运行我们的代码,让我们看看我们如何安全地在线程/进程之间共享数据。

在线程和进程之间共享数据

数据共享实际上是关于多进程、多线程以及一般分布式编程中最困难的部分:要传递哪些数据,要共享哪些数据,以及要跳过哪些数据。然而,理论实际上非常简单:尽可能不要传输任何数据,不要共享任何数据,并保持一切局部。这本质上就是函数式编程范式,这也是为什么函数式编程与多进程结合得如此之好的原因。在实践中,遗憾的是,这并不总是可能的。multiprocessing库提供了几个共享数据的选择,但内部它们归结为两种不同的选项:

  • 共享内存:这是迄今为止最快的解决方案,因为它几乎没有开销,但它只能用于不可变类型,并且仅限于通过multiprocessing.sharedctypes创建的少数几种类型和自定义对象。如果你只需要存储原始类型,如intfloatboolstrbytes以及/或固定大小的列表或字典(其中子项是原始类型),这是一个极好的解决方案。

  • multiprocessing.ManagerManager类提供了一系列存储和同步数据的选择,例如锁、信号量、队列、列表、字典等。如果可以序列化,就可以与Manager一起使用。

对于线程,解决方案甚至更简单:所有内存都是共享的,因此默认情况下,所有对象都可以从每个线程中访问。有一个例外称为线程局部变量,我们稍后会看到。

然而,共享内存有其自身的注意事项,正如我们将在“线程安全”部分看到的那样,在threading的情况下。由于多个线程和/或进程可以同时写入同一块内存,这是一个固有的风险操作。最坏的情况是,您的更改可能会因为冲突的写入而丢失;最坏的情况是,您的内存可能会损坏,这甚至可能导致解释器崩溃。幸运的是,Python 在保护您方面做得相当不错,所以如果您没有做任何太特别的事情,您不必担心解释器崩溃。

进程间的共享内存

Python 提供了几种不同的结构来确保进程间内存共享的安全性:

  • multiprocessing.Value

  • multiprocessing.Array

  • multiprocessing.shared_memory.SharedMemory

  • multiprocessing.shared_memory.ShareableList

让我们深入了解这些类型中的一些,以演示如何使用它们。

对于共享原始值,您可以使用multiprocessing.Valuemultiprocessing.Array。它们基本上是相同的,但Array可以存储多个值,而Value只是一个单个值。作为参数,它们期望一个与 Python 中array模块工作方式相同的 typecode,这意味着它们映射到 C 类型。这导致d是一个双精度(浮点)数字,i是一个有符号整数,b是一个有符号字符等。

对于更多选项,请查看array模块的文档:docs.python.org/3/library/array.html

对于更高级的类型,您可以查看multiprocessing.sharedctypes模块,这也是ValueArray类起源的地方。

multiprocessing.Valuemultiprocessing.Array都不难使用,但它们在我看来并不非常 Pythonic:

import multiprocessing

some_int = multiprocessing.Value('i', 123)
with some_int.get_lock():
    some_int.value += 10
print(some_int.value)

some_double_array = multiprocessing.Array('d', [1, 2, 3])
with some_double_array.get_lock():
    some_double_array[0] += 2.5
print(some_double_array[:]) 

如果您需要共享内存并且性能对您来说很重要,请随意使用它们。然而,如果可能的话,我建议您避免使用它们(或者在可能的情况下避免共享内存),因为其使用起来至少是笨拙的。

multiprocessing.shared_memory.SharedMemory对象类似于Array,但它是一个更低级的结构。它为您提供了一个接口,可以读写一个可选的命名内存块,这样您也可以通过名称从其他进程访问它。此外,当您使用完毕后,必须调用unlink()来释放内存:

from multiprocessing import shared_memory

# From process A we could write something
name = 'share_a'
share_a = shared_memory.SharedMemory(name, create=True, size=4)
share_a.buf[0] = 10

# From a different process, or the same one, we can access the data
share_a = shared_memory.SharedMemory(name)
print(share_a.buf[0])

# Make sure to clean up after. And only once!
share_a.unlink() 

如此例所示,第一次调用有一个create=True参数,用于请求操作系统内存。只有在那时(并且在调用unlink()之前),我们才能从其他(或相同的)进程引用该块。

再次强调,这并不是最 Pythonic 的接口,但它可以有效地共享内存。由于名称是可选的,否则会自动生成,因此您可以在创建共享内存块时省略它,并从share_a.name读取它。同样,像ArrayValue对象一样,它也有一个固定的大小,不能在不替换它的情况下增长。

最后,我们有 multiprocessing.shared_memory.ShareableList 对象。虽然这个对象比 ArraySharedMemory 略为方便,因为它允许你灵活地处理类型(例如,item[0] 可以是 str,而 item[1] 可以是 int),但它仍然是一个难以使用的接口,并且不允许你调整大小。虽然你可以更改项目类型,但不能调整对象的大小,所以用较大的字符串替换数字将不起作用。至少它的使用比其他选项更符合 Python 风格:

from multiprocessing import shared_memory

shared_list = shared_memory.ShareableList(['Hi', 1, False, None])
# Changing type from str to bool here
shared_list[0] = True
# Don't forget to unlink()
shared_list.shm.unlink() 

在看到这些进程间共享内存的选项后,你应该使用它们吗?是的,如果你需要高性能的话。

这应该是一个很好的迹象,为什么在并行处理中最好保持内存局部化。进程间共享内存是一个复杂的问题。即使有这些方法,它们是最快且最简单的,但仍然有点麻烦。

那么,内存共享对性能的影响有多大?让我们运行一些基准测试来看看共享变量和返回变量进行后处理之间的差异。首先,不使用共享内存作为性能基准的版本:

import multiprocessing

def triangle_number_local(n):
    total = 0
    for i in range(n + 1):
        total += i

    return total

def bench_local(n, count):
    with multiprocessing.Pool() as pool:
        results = pool.imap_unordered(
            triangle_number_local,
            (n for _ in range(count)),
        )
        print('Sum:', sum(results)) 

triangle_number_local() 函数计算从 n 到包括 n 在内的所有数字之和,并返回它,类似于阶乘函数,但使用加法代替。

bench_local() 函数调用 triangle_number_local() 函数 count 次,并存储结果。之后,我们使用 sum() 函数来验证输出。

现在让我们看看使用共享内存的版本:

import multiprocessing

class Shared:
    pass

def initializer(shared_value):
    Shared.value = shared_value

def triangle_number_shared(n):
    for i in range(n + 1):
        with Shared.value.get_lock():
            Shared.value.value += i

def bench_shared(n, count):
    shared_value = multiprocessing.Value('i', 0)

    # We need to explicitly share the shared_value. On Unix you
    # can work around this by forking the process, on Windows it
    # would not work otherwise
    pool = multiprocessing.Pool(
        initializer=initializer,
        initargs=(shared_value,),
    )

    iterable = (n for _ in range(count))
    list(pool.imap_unordered(triangle_number_shared, iterable))
    print('Sum:', shared_value.value)

    pool.close() 

在这种情况下,我们创建了一个 Shared 类作为命名空间来存储共享变量,但使用全局变量也是一个选项。

为了确保共享变量可用,我们需要使用 initializer 方法参数将其发送到 pool 中的所有工作进程。

此外,由于 += 操作不是原子的(不是一个单一的操作,因为它需要 fetch, add, set),我们需要确保使用 get_lock() 方法锁定变量。

本章后面的 线程安全 部分将更详细地介绍何时需要锁定以及何时不需要。

为了运行基准测试,我们使用以下代码:

import timeit

if __name__ == '__main__':
    n = 1000
    count = 100
    number = 5

    for function in 'bench_local', 'bench_shared':
        statement = f'{function}(n={n}, count={count})'
        result = timeit.timeit(
            statement, number=number,
            setup=f'from __main__ import {function}',
        )
        print(f'{statement}: {result:.3f}') 

现在当我们执行这个操作时,我们看到如果不共享内存会更好:

bench_local(n=1000, count=100): 0.598
bench_shared(n=1000, count=100): 4.157 

使用共享内存的代码大约慢了 8 倍,这是有道理的,因为我的机器有 8 个核心。由于共享内存示例的大部分时间都花在锁定/解锁(只能由一个进程同时执行)上,我们实际上使代码再次在单个核心上运行。

我应该指出,这几乎是共享内存的最坏情况。由于所有函数所做的只是写入共享变量,大部分时间都花在锁定和解锁变量上。如果你在函数中实际进行处理,并且只写入结果,那会好得多。

你可能好奇我们如何正确地重写这个示例,同时仍然使用共享变量。在这种情况下,这相当简单,但这在很大程度上取决于你的具体用例,这可能不适合你:

def triangle_number_shared_efficient(n):
    total = 0
    for i in range(n + 1):
        total += i

    with Shared.value.get_lock():
        Shared.value.value += total 

这段代码的运行速度几乎与 bench_local() 函数一样快。作为一个经验法则,只需记住尽可能减少锁的数量和写入次数。

使用管理器在进程间共享数据

现在我们已经看到了如何直接共享内存以获得最佳性能,让我们看看一个更方便、更灵活的解决方案:multiprocessing.Manager 类。

与共享内存限制我们只能使用原始类型相比,如果我们愿意牺牲一点性能,使用 Manager 我们可以非常容易地共享任何可以被序列化的东西。它使用的机制非常不同;它通过网络连接连接。这种方法的一个巨大优势是,你甚至可以在多个设备上使用它(我们将在本章后面看到)。

Manager 本身不是你经常使用的对象,尽管你可能会使用 Manager 提供的对象。列表很多,所以我们只详细说明其中的一些,但你总是可以查看 Python 文档以获取当前选项列表:docs.python.org/3/library/multiprocessing.html#managers

在使用 multiprocessing 共享数据时,最方便的选项之一是 multiprocessing.Namespace 对象。Namespace 对象的行为与常规对象非常相似,区别在于它可以作为共享内存对象从所有进程中访问。只要你的对象可以被序列化,你就可以将它们用作 Namespace 实例的属性。为了说明 Namespace 的用法:

import multiprocessing

manager = multiprocessing.Manager()
namespace = manager.Namespace()

namespace.spam = 123
namespace.eggs = 456 

如你所见,你可以像对待常规对象一样简单地设置 namespace 的属性,但它们在所有进程之间是共享的。由于锁定现在是通过网络套接字进行的,所以开销甚至比共享内存还要大,所以只有在必须时才写入数据。将早期的共享内存示例直接转换为使用 Namespace 和显式的 LockNamespace 没有提供 get_lock() 方法)会产生以下代码:

def triangle_number_namespace(namespace, lock, n):
    for i in range(n + 1):
        with lock:
            namespace.total += i

def bench_manager(n, count):
    manager = multiprocessing.Manager()
    namespace = manager.Namespace()
    namespace.total = 0
    lock = manager.Lock()
    with multiprocessing.Pool() as pool:
        list(pool.starmap(
            triangle_number_namespace,
            ((namespace, lock, n) for _ in range(count)),
        ))
        print('Sum:', namespace.total) 

与共享内存示例一样,这是一个非常低效的情况,因为我们为循环的每次迭代都进行了锁定,这一点非常明显。虽然本地版本大约需要 0.6 秒,共享内存版本大约需要 4 秒,但这个版本在实际上相同的操作中却需要惊人的 90 秒。

再次强调,我们可以通过减少在同步/锁定代码中花费的时间来轻松提高速度:

def triangle_number_namespace_efficient(namespace, lock, n):
    total = 0
    for i in range(n + 1):
        total += i

    with lock:
        namespace.total += i 

当使用之前相同的基准代码对这一版本进行基准测试时,我们可以看到它仍然比本地版本得到的 0.6 秒慢得多:

bench_local(n=1000, count=100): 0.637
bench_manager(n=1000, count=100): 1.476 

话虽如此,这至少比我们原本可能得到的 90 秒要可接受得多。

为什么这些锁如此慢?为了设置一个合适的锁,所有各方都需要同意数据被锁定,这是一个需要时间的过程。这个简单的事实比大多数人预期的要慢得多。运行Manager的服务器/进程需要向客户端确认它已经获得了锁;只有完成这一步后,客户端才能读取、写入并再次释放锁。

在常规的硬盘设置中,由于锁定和磁盘延迟,数据库服务器无法处理每秒超过大约 10 次同一行的事务。使用懒同步文件、SSD 和电池备份的 RAID 缓存,这种性能可以提高,以处理,也许,每秒 100 次同一行的事务。这些都是简单的硬件限制;因为你有多个进程试图写入单个目标,你需要同步进程之间的操作,这需要花费很多时间。

即使有最快的硬件,同步也可能锁定所有进程并产生巨大的减速,所以如果可能的话,尽量减少在多个进程之间共享数据。简单来说,如果所有进程都在不断从/向同一对象读取和写入,那么通常使用单个进程更快,因为锁定实际上会有效地限制你只能使用单个进程。

Redis,这是可用的最快的数据存储系统之一,直到 2020 年一直完全单线程超过十年,因为锁定开销不值得其带来的好处。即使是当前的线程版本,实际上也是一组具有自己内存空间的单线程服务器,以避免锁定。

线程安全

当与线程或进程一起工作时,你需要意识到你可能在某个时间点不是唯一修改变量的人。有许多场景中这不会成为问题,而且通常你很幸运,它不会影响你,但一旦发生,它可能会引起极其难以调试的错误。

例如,想象有两个代码块同时增加一个数字,想象一下可能会出错的情况。最初,让我们假设值是 10。在多个线程的情况下,这可能会导致以下序列:

  1. 两个线程将数字取到本地内存中增加。目前对两个都是 10。

  2. 两个线程将它们本地内存中的数字增加到 11。

  3. 两个线程都将数字从本地内存(对两个都是 11)写回全局内存,所以全局数字现在是 11。

由于两个线程同时获取了数字,一个线程用它的增加覆盖了另一个线程的增加。所以,你现在的变量只增加了一次,而不是增加两次。

在许多情况下,CPython 中当前的 GIL 实现会在使用threading时保护你免受这些问题的影响,但你绝不应该把这个保护当作理所当然,并确保在多个线程可能同时更新你的变量时保护你的变量。

可能一个实际的代码示例可以使场景更加清晰:

import time
import concurrent.futures

counter = 10

def increment(name):
    global counter
    current_value = counter
    print(f'{name} value before increment: {current_value}')
    counter = current_value + 1
    print(f'{name} value after increment: {counter}')

print(f'Before thread start: {counter}')

with concurrent.futures.ThreadPoolExecutor() as executor:
    executor.map(increment, range(3))
print(f'After thread finish: {counter}') 

如你所见,increment函数将counter存储在一个临时变量中,打印它,并在将其加 1 后写入counter。这个例子显然是有点牵强的,因为你通常会做counter += 1,这样可以减少意外行为的发生,但即使在这种情况下,你也没有保证你的结果是正确的。

为了说明这个脚本的输出:

$ python3 T_12_thread_safety.py
Before thread start: 10
0 value before increment: 10
0 value after increment: 11
1 value before increment: 11
1 value after increment: 12
2 value before increment: 11
2 value after increment: 12
4 value before increment: 12
4 value after increment: 13
3 value before increment: 12
3 value after increment: 13
After thread finish: 13 

为什么最后结果是 13?纯粹是运气。我的一些尝试结果是 15,一些是 11,还有一些是 14。这就是线程安全问题如此难以调试的原因;在一个复杂的代码库中,很难找出导致错误的真正原因,而且你无法可靠地重现这个问题。

当在多线程/多进程系统中遇到奇怪且难以解释的错误时,确保查看它们在单线程运行时是否也会发生。这样的错误很容易犯,并且很容易被引入第三方代码,而这些代码并不是为了线程安全而设计的。

要使你的代码线程安全,你有几种不同的选择:

  • 这可能看起来很明显,但如果你不并行地从多个线程/进程更新共享变量,那么就没有什么好担心的。

  • 在修改你的变量时使用原子操作。原子操作是在单个指令中执行的,因此永远不会出现冲突。例如,增加一个数字可以是原子操作,其中获取、增加和更新都在单个指令中完成。在 Python 中,增加通常使用counter += 1来完成,这实际上是一个counter = counter + 1的缩写。你能看到这里的问题吗?Python 不会在内部增加counter,而是会将新的值写入变量counter,这意味着它不是原子操作。

  • 使用锁来保护你的变量。

了解这些线程安全代码的选项后,你可能想知道哪些操作是线程安全的,哪些不是。幸运的是,Python 确实有一些关于这个问题的文档,我强烈建议你查看它,因为将来可能会发生变化:docs.python.org/3/faq/library.html#what-kinds-of-global-value-mutation-are-thread-safe

对于当前的 CPython 版本(至少是 CPython 3.10 及以下版本),由于 GIL 在保护我们,我们可以假设这些操作是原子的,因此是线程安全的:

L.append(x)
L1.extend(L2)
x = L[i]
x = L.pop()
L1[i:j] = L2
L.sort()
x = y
x.field = y
D[x] = y
D1.update(D2)
D.keys() 

这些不是原子的,也不是线程安全的:

i = i+1
L.append(L[-1])
L[i] = L[j]
D[x] = D[x] + 1 

我们可以做些什么来使i = i + 1线程安全?最明显的解决方案是使用我们自己的锁,类似于 GIL:

# This lock needs to be the same object for all threads
lock = threading.Lock()
i = 0

def increment():
    global i
    with lock():
        i += 1 

如你所见,我们可以很容易地使用锁来保护变量的更新。我应该指出,尽管我们在这个例子中使用了global变量,但同样的限制也适用于类实例的属性和其他变量。

自然,这同样适用于多进程,细微的区别在于变量在默认情况下不会在多个进程间共享,因此你需要做些事情来明确地引发问题。话虽如此,如果你从这些先前的共享内存和Manager示例中移除锁,它们会立即崩溃。

死锁

现在你已经知道了如何以线程安全的方式更新变量,你可能希望我们已经解决了线程的限制。不幸的是,事实正好相反。我们用来使变量更新线程安全的锁实际上可能引入另一个问题,这个问题可能更加难以解决:死锁

当线程或进程在等待另一个线程/进程释放锁的同时持有锁时,可能会发生死锁。在某些情况下,甚至可能有一个线程/进程正在等待自己。为了说明这一点,让我们假设我们有锁ab以及两个不同的线程。现在发生以下情况:

  1. 线程 0 锁定a

  2. 线程 1 锁定b

  3. 线程 0 等待锁b

  4. 线程 1 等待锁a

现在,线程 1 正在等待线程 0 完成,反之亦然。它们都不会完成,因为它们在互相等待。

为了说明这个场景:

import time
import threading

a = threading.Lock()
b = threading.Lock()

def thread_0():
    print('thread 0 locking a')
    with a:
        time.sleep(0.1)
        print('thread 0 locking b')
        with b:
            print('thread 0 everything locked')

def thread_1():
    print('thread 1 locking b')
    with b:
        time.sleep(0.1)
        print('thread 1 locking a')
        with a:
            print('thread 1 everything locked')

threading.Thread(target=thread_0).start()
threading.Thread(target=thread_1).start() 

代码相对简单,但至少需要一些解释。如前所述,thread_0函数首先锁定a,然后是b,而thread_1则按相反的顺序进行。这就是导致死锁的原因;它们会各自等待对方完成。为了确保我们在这个例子中确实达到了死锁,我们有一个小的休眠,以确保thread_0thread_1开始之前不会完成。在现实世界的场景中,你会在那段代码中放入一些需要花费时间的代码。

我们如何解决这类锁定问题?锁定策略和解决这些问题可以单独填满一章,并且有几种不同的锁定问题和解决方案。甚至可能存在一个活锁问题,其中两个线程都在尝试用相同的方法解决死锁问题,导致它们也互相等待,但锁却在不断变化。

要可视化活锁,可以想象一条狭窄的道路,两辆车从相反方向驶来。两辆车都会试图同时驾驶,并在注意到对方车辆移动时退后。重复这个过程,你就得到了一个活锁。

通常,你可以采用几种策略来避免死锁:

  • 死锁只能在有多个锁的情况下发生,所以如果你的代码每次只获取一个锁,就不会有问题发生。

  • 尽量保持锁的部分小,这样就不太可能意外地在那个块中添加另一个锁。这也可以帮助性能,因为锁可以使你的并行代码再次变成单线程。

  • 这可能是解决死锁最重要的提示。始终保持一致的锁定顺序。 如果你总是以相同的顺序锁定,你就永远不会遇到死锁。让我们解释一下这如何帮助:在先前的例子和两个锁ab中,问题发生是因为线程 0 正在等待b,而线程 1 正在等待a。如果它们都尝试先锁定a然后是b,我们就永远不会达到死锁状态,因为其中一个线程会锁定a,这会导致另一个线程在b被锁定之前就长时间停滞。

线程局部变量

我们已经看到了如何锁定变量,使得只有一个线程可以同时修改一个变量。我们也看到了在使用锁时如何防止死锁。如果我们想给一个线程提供一个独立的全局变量呢?这就是threading.local发挥作用的地方:它为你的当前线程提供了一个特定的上下文。例如,对于数据库连接来说,你可能希望每个线程都有自己的数据库连接,但传递连接很不方便,所以全局变量或连接管理器是一个更方便的选择。

这个部分不适用于multiprocessing,因为变量不会在进程之间自动共享。然而,一个派生的进程可以继承父进程的变量,因此必须小心显式初始化非共享资源。

让我们用一个简单的例子来说明线程局部变量的用法:

import threading
import concurrent.futures

context = threading.local()

def init_counter():
    context.counter = 10

def increment(name):
    current_value = context.counter
    print(f'{name} value before increment: {current_value}')
    context.counter = current_value + 1
    print(f'{name} value after increment: {context.counter}')

init_counter()
print(f'Before thread start: {context.counter}')

with concurrent.futures.ThreadPoolExecutor(
        initializer=init_counter) as executor:
    executor.map(increment, range(5))

print(f'After thread finish: {context.counter}') 

这个例子在很大程度上与线程安全示例相同,但我们现在使用threading.local()作为上下文来设置counter变量,而不是使用全局的counter变量。在这里,我们还使用了concurrent.futures.ThreadPoolExecutor的一个额外功能,即initializer函数。由于线程局部变量只存在于那个线程中,并且不会自动复制到其他线程,所以所有线程(包括主线程)都需要单独设置counter。如果没有设置它,我们会得到一个AttributeError

当运行代码时,我们可以看到所有线程都在独立地更新它们的变量,而不是我们在线程安全示例中看到的完全混合的版本:

$ python3 T_15_thread_local.py
Before thread start: 10
0 value before increment: 10
0 value after increment: 11
1 value before increment: 10
2 value before increment: 11
1 value after increment: 11
3 value before increment: 10
3 value after increment: 11
2 value after increment: 12
4 value before increment: 10
4 value after increment: 11
After thread finish: 10 

如果可能的话,我总是建议从一个线程返回变量或将它们追加到后处理队列中,而不是更新全局变量或全局状态,因为这更快且更不容易出错。在这些情况下使用线程局部变量真的可以帮助你确保只有一个连接或集合类的实例。

现在我们已经知道了如何共享(或停止共享)变量,是时候了解使用线程而不是进程的优点和缺点了。我们现在应该对线程和进程的内存管理有一个基本的了解。有了所有这些选项,我们应该选择哪一个,为什么?

进程、线程,还是单线程?

现在我们已经知道了如何使用multiprocessingthreadingconcurrent.futures,对于你的情况,你应该选择哪一个?

由于concurrent.futures实现了threadingmultiprocessing,你可以在这个部分心理上将threading替换为concurrent.futures.ThreadPoolExecutor。当然,对于multiprocessingconcurrent.futures.ProcessPoolExecutor也是同样的道理。

当我们考虑单线程、多线程和多进程之间的选择时,有多个因素我们可以考虑。

你应该问自己的第一个也是最重要的问题是,你是否真的需要使用threadingmultiprocessing。通常,代码已经足够快,你应该问问自己处理内存共享等潜在副作用的开销是否值得。不仅当涉及到并行处理时编写代码变得更加复杂,调试的复杂性也会随之增加。

其次,你应该问问自己是什么限制了你的表现。如果限制是外部 I/O,那么使用asynciothreading来处理可能会有所帮助,但这并不能保证。

例如,如果你正在从慢速硬盘读取大量文件,线程可能甚至帮不上忙。如果硬盘是限制因素,无论你尝试什么,它都不会变快。所以在你重写整个代码库以使用threading之前,确保测试你的解决方案是否有任何成功的可能性。

假设你的 I/O 瓶颈可以得到缓解,那么你仍然可以选择asynciothreading之间的选择。由于asyncio是所有可用选项中最快的,如果它与你的代码库兼容,我会选择这个解决方案,但当然使用threading也不是一个坏的选择。

如果由于 Python 代码中的大量计算,GIL 是你的瓶颈,那么multiprocessing可以帮到你很多。但即使在那些情况下,multiprocessing也不是你的唯一选择;对于许多慢速过程,使用快速库如numpy也可能有所帮助。

我非常喜爱multiprocessing库,它是迄今为止我见过的最简单的多进程代码实现之一,但它仍然伴随着一些需要注意的问题,比如更复杂的内存管理和死锁,正如我们所看到的。所以始终考虑你是否真的需要这个解决方案,以及你的问题是否适合多进程。如果大部分代码是用函数式编程编写的,那么实现起来可能非常简单;如果你需要与大量外部资源,如数据库,进行交互,那么实现起来可能非常困难。

threadingconcurrent.futures

当您有选择时,您应该使用threading还是concurrent.futures?在我看来,这取决于您想要做什么。

threading相对于concurrent.futures的优势是:

  • 我们可以显式指定线程的名称,这在许多操作系统的任务管理器中可以看到。

  • 我们可以显式创建并启动一个长时间运行的线程来执行一个函数,而不是依赖于线程池中的可用性。

如果您的场景允许您选择,我相信您应该使用concurrent.futures而不是threading,以下是一些原因:

  • 使用concurrent.futures,您可以通过使用concurrent.futures.ProcessPoolExecutor而不是concurrent.futures.ThreadPoolExecutor在线程和进程之间切换。

  • 使用concurrent.futures,您有map()方法可以轻松批量处理项目列表,而无需设置和关闭线程的(潜在)开销。

  • concurrent.futures方法返回的concurrent.futures.Future对象允许对结果和处理的细粒度控制。

multiprocessingconcurrent.futures

当涉及到多进程时,我认为concurrent.futures接口相比线程提供的优势要少得多,尤其是multiprocessing.Pool基本上提供了与concurrent.futures.ProcessPoolExecutor几乎相同的接口。

multiprocessing相对于concurrent.futures的优势是:

  • 许多高级映射方法,如imap_unorderedstarmap

  • 对池有更多控制(即terminate()close())。

  • 它可以在多台机器上使用。

  • 您可以手动指定启动方法(forkspawnforkserver),这使您能够控制变量从父进程复制的方式。

  • 您可以选择 Python 解释器。使用multiprocessing.set_executable(),您可以在运行 Python 3.9 的主进程的同时运行 Python 3.10 的进程池。

concurrent.futures相对于multiprocessing的优势是:

  • 您可以轻松切换到concurrent.futures.ThreadPoolExecutor

  • multiprocessing使用的AsyncResult对象相比,返回的Future对象允许对结果处理进行更细粒度的控制。

个人而言,如果您不需要与threads兼容的映射方法,我更倾向于使用multiprocessing

超线程与物理 CPU 核心

超线程是一种技术,它为物理核心提供额外的虚拟 CPU 核心。其理念是,由于这些虚拟 CPU 核心有独立的缓存和其他资源,您可以在多个任务之间更有效地切换。如果您在两个重负载进程之间进行任务切换,CPU 就不必卸载/重新加载所有缓存。然而,当涉及到实际的 CPU 指令处理时,它并不会帮助您。

当你真正最大化 CPU 使用时,通常最好只使用物理处理器数量。为了展示这如何影响性能,我们将使用几个进程数运行一个简单的测试。由于我的处理器有 8 个核心(如果包括超线程则是 16 个),我们将使用 12481632 个进程来展示它如何影响性能:

import timeit
import multiprocessing

def busy_wait(n):
    while n > 0:
        n -= 1

def benchmark(n, processes, tasks):
    with multiprocessing.Pool(processes=processes) as pool:
        # Execute the busy_wait function 'tasks' times with
        # parameter n
        pool.map(busy_wait, [n for _ in range(tasks)])
    # Create the executor

if __name__ == '__main__':
    n = 100000
    tasks = 128
    for exponent in range(6):
        processes = int(2 ** exponent)
        statement = f'benchmark({n}, {processes}, {tasks})'
        result = timeit.timeit(
            statement,
            number=5,
            setup='from __main__ import benchmark',
        )
        print(f'{statement}: {result:.3f}') 

为了让处理器保持忙碌,我们在 busy_wait() 函数中使用从 n0while 循环。对于基准测试,我们使用具有给定进程数的 multiprocessing.Pool() 实例,并运行 busy_wait(100000) 128 次:

$ python3 T_16_hyper_threading.py
benchmark(100000, 1): 3.400
benchmark(100000, 2): 1.894
benchmark(100000, 4): 1.208
benchmark(100000, 8): 0.998
benchmark(100000, 16): 1.124
benchmark(100000, 32): 1.787 

如你所见,在我的启用超线程的 8 核心 CPU 上,具有 8 线程的版本显然是最快的。尽管操作系统任务管理器显示 16 个核心,但使用超过 8 个物理核心并不总是更快的。此外,由于现代处理器的提升行为,你可以看到使用 8 个处理器仅比单线程版本快 3.4 倍,而不是预期的 8 倍加速。

这说明了在用指令大量加载处理器时超线程的问题。一旦单个进程实际上使用了 CPU 核心的 100%,进程之间的任务切换实际上会降低性能。由于只有 8 个物理核心,其他进程必须争夺在处理器核心上完成一些事情。别忘了系统上的其他进程以及操作系统本身也会消耗一些处理能力。

如果你确实因为 CPU 密集型问题而迫切需要性能,那么匹配物理 CPU 核心通常是最佳解决方案,但如果锁定是瓶颈,那么由于 CPU 提升行为,单线程可能比任何多线程解决方案都快。

如果你预期不会一直最大化所有核心,那么我建议不要将 processes 参数传递给 multiprocessing.Pool(),这将使其默认为 os.cpu_count(),它返回所有核心,包括超线程核心。

然而,这完全取决于你的用例,而确定唯一的方法是测试你的特定场景。作为一个经验法则,我建议以下做法:

  • 磁盘 I/O 受限?单个进程可能是最佳选择。

  • CPU 受限?物理 CPU 核心的数量是你的最佳选择。

  • 网络 I/O 受限?从默认值开始,如有需要则调整。这是在 8 核心处理器上仍然可以使用 128 线程的少数情况之一。

  • 没有明显的限制,但需要许多(数百个)并行过程?也许你应该尝试使用 asyncio 而不是 multiprocessing

注意,创建多个进程在内存和打开的文件方面并不是免费的;虽然你可以有几乎无限的协程数量,但对于进程来说并非如此。根据你的操作系统配置,你可能在达到 100 个进程之前就达到最大打开文件限制,即使你达到了这些数字,CPU 调度也将成为你的瓶颈。

如果我们的 CPU 核心不够用,我们应该怎么做?简单:使用更多的 CPU 核心。我们从哪里得到这些核心?多台计算机!是时候过渡到分布式计算了。

远程进程

到目前为止,我们只在多个本地处理器上执行了我们的脚本,但实际上我们可以做得更多。使用 multiprocessing 库,实际上在远程服务器上执行作业非常容易,但目前的文档仍然有些晦涩。实际上有几种方式可以以分布式的方式执行进程,但最明显的方法并不是最容易的方法。multiprocessing.connection 模块提供了 ClientListener 类,它们以简单的方式促进了客户端和服务器之间的安全通信。

通信与进程管理和队列管理并不相同;这些功能需要额外的努力。multiprocessing 库在这方面仍然比较简单,但只要有几个不同的进程,这绝对是可能的。

使用多进程的分布式处理

我们将从一个模块开始,其中包含一些应该由所有客户端和服务器共享的常量,这样秘密密码和服务器的主机名就可以对所有客户端和服务器可用。除此之外,我们还将添加我们的素数计算函数,这些函数我们稍后会用到。以下模块中的导入将期望此文件存储为 T_17_remote_multiprocessing/constants.py,但只要你确保导入和引用正常工作,你可以随意命名:

host = 'localhost'
port = 12345
password = b'some secret password' 

接下来,我们定义需要供服务器和客户端都可用到的函数。我们将将其存储为 T_17_remote_multiprocessing/functions.py

def primes(n):
    for i, prime in enumerate(prime_generator()):
        if i == n:
            return prime

def prime_generator():
    n = 2
    primes = set()
    while True:
        for p in primes:
            if n % p == 0:
                break
        else:
            primes.add(n)
            yield n
        n += 1 

现在是时候创建实际的连接函数和作业队列的服务器了。我们将将其存储为 T_17_remote_multiprocessing/server.py

import multiprocessing
from multiprocessing import managers

import constants
import functions

queue = multiprocessing.Queue()
manager = managers.BaseManager(address=('', constants.port),
                               authkey=constants.password)

manager.register('queue', callable=lambda: queue)
manager.register('primes', callable=functions.primes)

server = manager.get_server()
server.serve_forever() 

在创建服务器之后,我们需要有一个客户端脚本来发送作业。你可以使用单个脚本进行发送和处理,但为了保持逻辑清晰,我们将使用单独的脚本。

以下脚本将把 0999 添加到队列中进行处理。我们将将其存储为 T_17_remote_multiprocessing/submitter.py

from multiprocessing import managers

import constants

manager = managers.BaseManager(
    address=(constants.host, constants.port),
    authkey=constants.password)
manager.register('queue')
manager.connect()

queue = manager.queue()
for i in range(1000):
    queue.put(i) 

最后,我们需要创建一个客户端来实际处理队列。我们将将其存储为 T_17_remote_multiprocessing/client.py

from multiprocessing import managers

import functions

manager = managers.BaseManager(
    address=(functions.host, functions.port),
    authkey=functions.password)
manager.register('queue')
manager.register('primes')
manager.connect()

queue = manager.queue()
while not queue.empty():
    print(manager.primes(queue.get())) 

从前面的代码中,您可以看到我们如何传递函数;管理器允许注册可以从客户端调用的函数和类。有了这个,我们就传递了一个来自多进程类的队列,这个队列对多线程和多进程都是安全的。

现在,我们需要启动进程本身。首先,持续运行的服务器:

$ python3 T_17_remote_multiprocessing/server.py 

然后,运行生产者以生成素数生成请求:

$ python3 T_17_remote_multiprocessing/submitter.py 

现在,我们可以在多台机器上运行多个客户端以获取前 1,000 个素数。由于这些客户端现在正在打印前 1,000 个素数,输出有点太长,无法在此显示,但您可以简单地并行多次运行或在多台机器上运行以生成您的输出:

$ python3 T_17_remote_multiprocessing/client.py 

如果您想将输出发送到不同的进程,您可以使用队列或管道代替打印。但是,如您所见,并行处理事物仍然需要一些工作,并且需要一些代码同步才能工作。有几个替代方案可用,例如 RedisØMQCeleryDaskIPython Parallel。哪个是最好的和最适合取决于您的用例。如果您只是想处理多个 CPU 上的任务,那么 multiprocessing、Dask 和 IPython Parallel 可能是您最好的选择。如果您正在寻找后台处理和/或轻松地将任务卸载到多台机器上,那么 ØMQ 和 Celery 是更好的选择。

使用 Dask 进行分布式处理

Dask 库正在迅速成为分布式 Python 执行的标准。它与许多科学 Python 库(如 NumPy 和 Pandas)有非常紧密的集成,使得在许多情况下并行执行完全透明。这些库在 第十五章科学 Python 和绘图 中有详细的介绍。

Dask 库提供了一个易于使用的并行接口,可以执行单线程、使用多个线程、使用多个进程,甚至使用多台机器。只要您牢记多线程、进程和多台机器的数据共享限制,您就可以轻松地在它们之间切换,以查看哪个最适合您的用例。

安装 Dask

Dask 库由多个包组成,您可能不需要所有这些包。总的来说,Dask 包只是核心,我们可以从几个附加功能中选择,这些可以通过 pip install dask[extra] 安装:

  • array:添加了一个类似于 numpy.ndarray 的数组接口。内部,这些结构由多个 numpy.ndarray 实例组成,分布在您的 Dask 集群中,以便于并行处理。

  • dataframe:类似于数组接口,这是一个 pandas.DataFrame 对象的集合。

  • diagnostics:添加了分析器、进度条,甚至一个完全交互式的仪表板,可以实时显示当前运行作业的信息。

  • distributed:运行 Dask 在多个系统上而不是仅本地所需的包。

  • complete:所有上述附加功能。

对于本章的演示,我们需要至少安装distributed扩展,因此您需要运行以下之一:

$ pip3 install -U "dask[distributed]" 

或者:

$ pip3 install -U "dask[complete]" 

如果您正在使用 Jupyter 笔记本进行实验,diagnostics扩展中的进度条也支持 Jupyter,这可能很有用。

基本示例

让我们从执行一些代码的基本示例开始,通过 Dask 而不显式设置集群。为了说明这如何有助于性能,我们将使用busy-wait循环来最大化 CPU 负载。在这种情况下,我们将使用dask.distributed子模块,它有一个与concurrent.futures非常相似的接口:

import sys
import datetime

from dask import distributed

def busy_wait(n):
    while n > 0:
        n -= 1

def benchmark_dask(client):
    start = datetime.datetime.now()

    # Run up to 1 million
    n = 1000000
    tasks = int(sys.argv[1])  # Get number of tasks from argv

    # Submit the tasks to Dask
    futures = client.map(busy_wait, [n] * tasks, pure=False)
    # Gather the results; this blocks until the results are ready
    client.gather(futures)

    duration = datetime.datetime.now() - start
    per_second = int(tasks / duration.total_seconds())
    print(f'{tasks} tasks at {per_second} per '
          f'second, total time: {duration}')

if __name__ == '__main__':
    benchmark_dask(distributed.Client()) 

代码大部分都很直接,但有一些小细节需要注意。首先,当将任务提交给 Dask 时,您需要告诉 Dask 它是一个不纯的函数。

如果您还记得第五章函数式编程 – 可读性与简洁性之间的权衡,函数式编程中的纯函数是没有副作用的一个;其输出是一致的,并且只依赖于输入。返回随机值的函数是不纯的,因为重复调用会返回不同的结果。

对于纯函数,Dask 会自动缓存结果。如果您有两个相同的调用,Dask 只会执行一次函数。

为了排队任务,我们需要使用client.map()client.submit()等函数。这些函数在concurrent.futures的情况下与executor.submit()非常相似。

最后,我们需要从未来中获取结果。这可以通过调用future.result()或批量使用client.gather(futures)来完成。再次强调,这与concurrent.futures非常相似。

为了使代码更加灵活,我们使任务数量可配置,以便在您的系统上合理的时间内运行。如果您有一个速度慢得多或快得多的系统,您可能需要调整它以获得有用的结果。

当我们执行脚本时,我们得到以下结果:

$ python3 T_18_dask.py 128
128 tasks at 71 per second, total time: 0:00:01.781836 

这就是您如何轻松地在所有 CPU 核心上执行一些代码。当然,我们也可以在单线程或分布式模式下进行测试;我们唯一需要改变的是如何初始化distributed.Client()

单线程运行

让我们以单线程模式运行相同的代码:

if __name__ == '__main__':
    benchmark_dask(distributed.Client()) 

现在如果我们运行它,我们可以看到 Dask 确实在之前使用了多个进程:

$ python3 T_19_dask_single.py 128
128 tasks at 20 per second, total time: 0:00:06.142977 

这对于调试线程安全问题很有用。如果问题在单线程模式下仍然存在,那么线程安全问题可能不是你的问题。

在多台机器上分布式执行

为了实现更令人印象深刻的成就,让我们同时运行多台机器上的代码。要同时运行 Dask,有许多可用的部署选项:

  • 使用dask-schedulerdask-worker命令进行手动设置

  • 使用dask-ssh命令通过 SSH 自动部署

  • 直接部署到运行 Kubernetes、Hadoop 等现有计算集群

  • 将应用程序部署到云服务提供商,如亚马逊、谷歌和微软 Azure

在这个例子中,我们将使用dask-scheduler,因为它是在几乎任何可以运行 Python 的机器上运行的解决方案。

注意,如果 Dask 版本和依赖项不匹配,可能会遇到错误,因此在开始之前更新到最新版本是一个好主意。

首先,我们启动dask-scheduler

$ dask-scheduler
[...]
distributed.scheduler - INFO - Scheduler at:  tcp://10.1.2.3:8786
distributed.scheduler - INFO - dashboard at:                :8787 

一旦dask-scheduler启动,它也将托管上面提到的仪表板,显示当前状态:http://localhost:8787/status

现在,我们可以在所有需要参与的计算机上运行dask-worker进程:

$ dask-worker --nprocs auto tcp://10.1.2.3:8786 

使用--nprocs参数,你可以设置要启动的进程数。设置为auto时,它将设置为包括超线程在内的 CPU 核心数。当设置为正数时,它将启动该确切数量的进程;当设置为负数时,该数值将添加到 CPU 核心数。

你的仪表板屏幕和控制台现在应该显示所有已连接的客户端。现在是时候再次运行我们的脚本了,但这次是分布式运行:

if __name__ == '__main__':
    benchmark_dask(distributed.Client('localhost:8786')) 

这就是我们需要做的唯一一件事:配置调度器运行的位置。注意,我们也可以使用 IP 地址或主机名从其他机器连接。

让我们运行它并看看它是否变得更快:

$ python3 T_20_dask_distributed.py 2048
[...]
2048 tasks at 405 per second, total time: 0:00:05.049570 

哇,这真是一个很大的区别!在单线程模式下我们每秒可以做20个任务,或者在多进程模式下每秒可以做71个任务,而现在我们每秒可以处理405个这样的任务。正如你所见,设置起来也非常简单。

Dask 库有许多更多选项来提高效率、限制内存、优先处理工作等等。我们甚至还没有涵盖通过链式连接任务或对捆绑结果运行reduce来组合任务。如果你的代码可以从同时在多个系统上运行中受益,我强烈建议考虑使用 Dask。

使用 ipyparallel 进行分布式处理

IPython Parallel 模块与 Dask 类似,使得同时处理多台计算机上的代码成为可能。需要注意的是,你可以在ipyparallel之上运行 Dask。该库支持比你可能需要的更多功能,但基本用法在需要执行可以受益于多台计算机的繁重计算时很重要。

首先,让我们安装最新的ipyparallel包和所有 IPython 组件:

$ pip3 install -U "ipython[all]" ipyparallel 

尤其是在 Windows 上,使用 Anaconda 安装 IPython 可能更容易,因为它包括许多科学、数学、工程和数据分析包的二进制文件。为了获得一致的安装,Anaconda 安装程序也适用于 OS X 和 Linux 系统。

其次,我们需要一个集群配置。技术上,这是可选的,但既然我们打算创建一个分布式 IPython 集群,使用特定的配置文件来配置一切将更加方便:

$ ipython profile create --parallel --profile=mastering_python
[ProfileCreate] Generating default config file: '~/.ipython/profile_mastering_python/ipython_config.py'
[ProfileCreate] Generating default config file: '~/.ipython/profile_mastering_python/ipython_kernel_config.py'
[ProfileCreate] Generating default config file: '~/.ipython/profile_mastering_python/ipcontroller_config.py'
[ProfileCreate] Generating default config file: '~/.ipython/profile_mastering_python/ipengine_config.py'
[ProfileCreate] Generating default config file: '~/.ipython/profile_mastering_python/ipcluster_config.py' 

这些配置文件包含大量的选项,所以我建议搜索特定的部分而不是逐个查看。快速列出这五个文件的总配置行数约为 2,500 行。文件名已经提供了关于配置文件用途的提示,但我们将通过解释它们的目的和一些最重要的设置来遍历这些文件。

ipython_config.py

这是通用的 IPython 配置文件;你几乎可以在这里自定义 IPython shell 的任何内容。它定义了你的 shell 应该如何看起来,默认应该加载哪些模块,是否加载 GUI,以及更多。对于本章的目的来说,这并不是特别重要,但如果你打算更频繁地使用 IPython,它绝对值得一看。你可以在这里配置的一些事情包括自动加载扩展,例如在第十二章中讨论的line_profilermemory_profiler。例如:

c.InteractiveShellApp.extensions = [
    'line_profiler',
    'memory_profiler',
] 

ipython_kernel_config.py

此文件配置你的 IPython 内核,并允许你覆盖/扩展ipython_config.py。为了理解其目的,了解 IPython 内核是什么很重要。在这个上下文中,内核是运行和检查代码的程序。默认情况下,这是IPyKernel,它是一个常规的 Python 解释器,但还有其他选项,如IRubyIJavascript,分别用于运行 Ruby 或 JavaScript。

其中一个更有用的选项是配置内核的监听端口和 IP 地址。默认情况下,端口都设置为使用随机数,但重要的是要注意,如果你在运行内核时有人可以访问同一台机器,他们可以连接到你的 IPython 内核,这在共享机器上可能是危险的。

ipcontroller_config.py

ipcontroller是 IPython 集群的主进程。它控制引擎和任务的分配,并负责诸如日志记录等任务。

在性能方面最重要的参数是TaskScheduler设置。默认情况下,c.TaskScheduler.scheme_name设置被设置为使用 Python LRU 调度器,但根据你的工作负载,其他如leastloadweighted可能更好。如果你必须在一个如此大的集群上处理如此多的任务,以至于调度器成为瓶颈,还有一个plainrandom调度器,如果所有机器的规格相似且任务持续时间相似,它的工作效果出奇地好。

为了我们的测试目的,我们将控制器的 IP 设置为*,这意味着将接受所有IP 地址,并且接受每个网络连接。如果您处于不安全的环境/网络中,并且/或者没有允许您选择性地启用某些 IP 地址的防火墙,那么这种方法不推荐使用!在这种情况下,我建议通过更安全的选择启动,例如SSHEngineSetLauncherWindowsHPCEngineSetLauncher

假设您的网络确实安全,将工厂 IP 设置为所有本地地址:

c.HubFactory.client_ip = '*'
c.RegistrationFactory.ip = '*' 

现在启动控制器:

$ ipcontroller --profile=mastering_python
[IPControllerApp] Hub listening on tcp://*:58412 for registration.
[IPControllerApp] Hub listening on tcp://127.0.0.1:58412 for registration.
...
 [IPControllerApp] writing connection info to ~/.ipython/profile_mastering_python/security/ipcontroller-client.json
[IPControllerApp] writing connection info to ~/.ipython/profile_mastering_python/security/ipcontroller-engine.json
... 

请注意写入配置目录安全目录的文件。它们包含ipengine用于查找和连接到ipcontroller的认证信息,例如加密密钥和端口信息。

ipengine_config.py

ipengine是实际的工作进程。这些进程运行实际的计算,因此为了加快处理速度,您需要在尽可能多的机器上运行这些进程。您可能不需要更改此文件,但如果您想配置集中式日志记录或需要更改工作目录,它可能很有用。通常,您不希望手动启动ipengine进程,因为您很可能会希望每台计算机启动多个进程。这就是我们下一个命令ipcluster的作用所在。

ipcluster_config.py

ipcluster命令实际上是一个简单的快捷方式,用于同时启动ipcontrolleripengine的组合。对于简单的本地处理集群,我建议使用这个命令,但在启动分布式集群时,使用ipcontrolleripengine分别使用可以提供更好的控制。在大多数情况下,该命令提供了足够多的选项,因此您可能不需要单独的命令。

最重要的配置选项是c.IPClusterEngines.engine_launcher_class,因为它控制着引擎和控制器之间的通信方法。此外,它也是进程之间安全通信最重要的组件。默认情况下,它设置为ipyparallel.apps.launcher.LocalControllerLauncher,这是为本地进程设计的,但如果您想使用 SSH 与客户端通信,ipyparallel.apps.launcher.SSHEngineSetLauncher也是一个选项。另外,还有ipyparallel.apps.launcher.WindowsHPCEngineSetLauncher用于 Windows HPC。

在我们可以在所有机器上创建集群之前,我们需要传输配置文件。您的选择是传输所有文件,或者简单地传输 IPython 配置文件security目录中的文件。

现在是启动集群的时候了。由于我们之前已经单独启动了ipcontroller,我们只需要启动引擎。在本地机器上,我们只需简单地启动它,但其他机器还没有配置。一个选择是复制整个 IPython 配置文件目录,但真正需要复制的只有一个文件,即security/ipcontroller-engine.json;在使用配置创建命令创建配置之后。所以,除非你打算复制整个 IPython 配置文件目录,否则你需要再次执行配置创建命令:

$ ipython profile create --parallel --profile=mastering_python 

之后,只需复制ipcontroller-engine.json文件,任务就完成了。现在我们可以启动实际的引擎:

$ ipcluster engines --profile=mastering_python -n 4
[IPClusterEngines] IPython cluster: started
[IPClusterEngines] Starting engines with [daemon=False]
[IPClusterEngines] Starting 4 Engines with LocalEngineSetLauncher 

注意,这里的4是为了四核处理器选择的,但任何数字都可以。默认情况下将使用逻辑处理器核心的数量,但根据工作负载,可能最好匹配物理处理器核心的数量。

现在,我们可以从我们的 IPython shell 中运行一些并行代码。为了展示性能差异,我们将使用从 0 到 10,000,000 的所有数字的简单求和。这不是一个特别重的任务,但当连续执行 10 次时,常规 Python 解释器会花费一些时间:

In [1]: %timeit for _ in range(10): sum(range(10000000))
1 loops, best of 3: 2.27 s per loop 

然而,这次,为了说明差异,我们将运行它 100 次,以展示分布式集群有多快。请注意,这只是一个三机集群,但仍然快得多:

In [1]: import ipyparallel

In [2]: client = ipyparallel.Client(profile='mastering_python')
In [3]: view = client.load_balanced_view()
In [4]: %timeit view.map(lambda _: sum(range(10000000)), range(100)).wait()
1 loop, best of 3: 909 ms per loop 

然而,更有趣的是在ipyparallel中定义并行函数。只需一个简单的装饰器,就可以将一个函数标记为并行:

In [1]: import ipyparallel

In [2]: client = ipyparallel.Client(profile='mastering_python')
In [3]: view = client.load_balanced_view()
In [4]: @view.parallel()
   ...: def loop():
   ...:     return sum(range(10000000))
   ...:
In [5]: loop.map(range(10))
Out[5]: <AsyncMapResult: loop> 

ipyparallel库提供了更多有用的功能,但这本书的范围之外。尽管ipyparallel是 Jupyter/IPython 其他部分的独立实体,但它很好地整合在一起,使得它们结合变得足够容易。

练习

虽然为多线程和多进程做准备比为asyncio做准备不那么侵入性,但如果需要传递或共享变量,仍然需要一些思考。所以,这实际上是一个关于你想要让自己多难的问题。

看看你是否能创建一个作为单独进程的回声服务器和客户端。尽管我们没有涵盖multiprocessing.Pipe(),但我相信你无论如何都能处理它。它可以通过a, b = multiprocessing.Pipe()创建,你可以使用[a/b].send()[a/b].recv()来使用它。

  • 读取目录中的所有文件,并通过使用threadingmultiprocessingconcurrent.futures(如果你想要一个更简单的练习)来读取每个文件,来计算文件的大小总和。如果你想增加难度,可以在运行时通过让线程/进程队列新项目来递归地遍历目录。

  • 创建一个通过multiprocessing.Queue()等待项目入队的工人池。如果你能使其成为一个安全的 RPC(远程过程调用)类型操作,那么你将获得额外的分数。

  • 应用你的函数式编程技能,以并行方式计算一些东西。也许并行排序?

与您在野外可能遇到的情况相比,所有这些练习仍然很遗憾地很简单。如果您真的想挑战自己,开始将这些技术(特别是内存共享)应用到您现有的或新项目中,并希望(或不是)遇到真正的挑战。

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。我们鼓励您提交自己的解决方案,并从他人的替代方案中学习。

概述

在本章中,我们涵盖了众多不同的主题,所以让我们总结一下:

  • Python GIL 是什么,为什么我们需要它,以及我们如何绕过它

  • 何时使用线程,何时使用进程,以及何时使用 asyncio

  • 使用 threadingconcurrent.futures 在并行线程中运行代码

  • 使用 multiprocessingconcurrent.futures 在并行进程中运行代码

  • 在多台机器上运行分布式代码

  • 在线程和进程之间共享数据

  • 线程安全

  • 死锁

您可以从本章中学到的最重要的课程是线程和进程之间数据同步真的非常慢。只要可能,您应该只向函数发送数据,并在完成时返回,中间不发送任何数据。即使在那种情况下,如果您可以发送更少的数据,请发送更少的数据。如果可能,请保持您的计算和数据本地化。

在下一章,我们将学习关于科学 Python 库和绘图的内容。这些库可以帮助您在创纪录的时间内执行困难的计算和数据处理。这些库大多数都高度优化性能,与多进程或 Dask 库配合得很好。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第十五章:科学 Python 和绘图

Python 编程语言非常适合科学工作。这是因为编程非常容易,同时足够强大,几乎可以做你需要做的任何事情。这种组合催生了一系列(非常庞大)的 Python 项目,如 numpyscipymatplotlibpandas 等,这些项目在过去的几年中逐渐发展起来。虽然这些库都足够大,可以各自成为一本书的主题,但我们仍可以提供一些见解,让您了解它们何时何地有用,以便您知道从哪里开始。

本章涵盖的主要主题和库被分为三个部分:

  • 数组和矩阵:NumPy、Numba、SciPy、Pandas、statsmodels 和 xarray

  • 数学和精确计算:gmpy2、Sage、mpmath、SymPy 和 Patsy

  • 绘图、图表和图表:Matplotlib、Seaborn、Yellowbrick、Plotly、Bokeh 和 Datashader

很可能本章中并非所有库都与您相关,所以如果您没有阅读全部内容,请不要感到难过。然而,我建议您至少简要地查看 NumPy 和 Pandas 部分,因为它们在下一章关于机器学习的章节中被大量使用。

此外,我还建议您查看 Matplotlib 和 Plotly 部分,因为它们在许多场景中可能非常有用。

安装包

对于建立在 C 和其他非 Python 代码之上的 Python 库,安装通常非常依赖于平台。在大多数平台上,多亏了二进制轮,我们可以简单地这样做:

$ pip3 install <package> 

然而,对于本章和下一章,我建议使用一种替代解决方案。虽然一些库,如 numpy,在大多数平台上安装起来很容易,但其他一些库则更具挑战性。因此,我建议使用 Anaconda 发行版或 Jupyter Docker Stacks 之一。

Jupyter Docker Stacks 需要您在系统上运行 Docker,但如果您已经运行了 Docker,那么启动非常复杂的系统将变得极其简单。可用的堆栈列表可以在以下位置找到:jupyter-docker-stacks.readthedocs.io/en/latest/using/selecting.html#core-stacks

本章的一个良好起点是 jupyter/scipy-notebook 堆栈,它包括一个庞大的包列表,例如 numpyscipynumbamatplotlibcython 以及更多。运行此镜像(假设您已经运行了 Docker)就像这样简单:

$ docker run -p 8888:8888 jupyter/scipy-notebook 

运行命令后,它将为您提供如何在浏览器中打开 Jupyter 的相关信息。

数组和矩阵

矩阵是大多数科学 Python 和人工智能库的核心,因为它们非常适合存储大量相关数据。它们也适合进行快速的批量处理,并且在这些矩阵上的计算可以比使用许多单独变量更快地完成。在某些情况下,这些计算甚至可以卸载到 GPU 上以实现更快的处理。

注意,0 维矩阵实际上是一个单独的数字,1 维矩阵是一个常规数组,你可以使用的维度数量实际上没有真正的限制。应该注意的是,随着维度的增加,大小和处理时间都会迅速增加,当然。

NumPy – 快速数组和矩阵

numpy包催生了大多数科学 Python 开发,并且仍然被用于本章和下一章中涵盖的许多库的核心。该库大部分(至少在关键部分)是用 C 编写的,这使得它非常快;我们稍后会看到一些基准测试,但根据操作,它可能比纯 Python 的 CPython 解释器快 100 倍。

由于numpy具有众多功能,我们只能涵盖一些基础知识。但这些都已证明它有多么强大(并且快速),以及为什么它是本章中许多其他科学 Python 包的基础。

numpy库的核心特性是numpy.ndarray对象。numpy.ndarray对象是用 C 实现的,提供了一个非常快速且内存高效的数组。它可以表示为单维数组或具有非常强大切片功能的多元矩阵。你可以将这些数组中的任何一个存储任何 Python 对象,但要充分利用numpy的强大功能,你需要使用诸如整数或浮点数之类的数字。

关于numpy数组的一个重要注意事项是它们具有固定的大小,并且不能调整大小,因为它们保留了一个连续的内存块。如果你需要使它们更小或更大,你需要创建一个新的数组。

让我们看看这个数组的一些基本示例,以及为什么它非常方便:

# A commonly used shorthand for numpy is np
>>> import numpy as np

# Generate a list of numbers from 0 up to 1 million
>>> a = np.arange(1000000)
>>> a
array([     0,      1,      2, ..., 999997, 999998, 999999])

# Change the shape (still references the same data) to a
# 2-dimensional 1000x1000 array
>>> b = a.reshape((1000, 1000))
>>> b
array([[     0,      1,      2, ...,    997,    998,    999],
       [  1000,   1001,   1002, ...,   1997,   1998,   1999],
       ...,
       [998000, 998001, 998002, ..., 998997, 998998, 998999],
       [999000, 999001, 999002, ..., 999997, 999998, 999999]])

# The first row of the matrix
>>> b[0]
array([  0,   1,   2,   3, ..., 995, 996, 997, 998, 999])

# The first column of the matrix
>>> b[:, 0]
array([     0,   1000,   2000,   ..., 997000, 998000, 999000])

# Row 10 up to 12, the even columns between 20 and 30
>>> b[10:12, 20:30:2]
array([[10020, 10022, 10024, 10026, 10028],
       [11020, 11022, 11024, 11026, 11028]])

# Row 10, columns 5 up to 10:
>>> b[10, 5:10]
array([10005, 10006, 10007, 10008, 10009])

# Alternative syntax for the last slice
>>> b[10][5:10]
array([10005, 10006, 10007, 10008, 10009]) 

正如你所见,numpy的切片选项非常强大,但这些切片更有用的地方在于它们都是引用/视图而不是副本。

这意味着如果你修改了切片中的数据,原始数组也会被修改。为了说明,我们可以使用之前示例中创建的数组:

>>> b[0] *= 10
>>> b[:, 0] *= 20

>>> a
array([     0,     10,     20, ..., 999997, 999998, 999999])
>>> b[0:2]
array([[    0,    10,    20, ...,  9970,  9980,  9990],
       [20000,  1001,  1002, ...,  1997,  1998,  1999]]) 

正如你所见,在修改了每一行的第一行和第一列之后,我们现在可以看到ab以及ab的所有切片都已经修改;而且这一切都在一个操作中完成,而不是需要循环。

让我们尝试运行一个简单的基准测试,看看 numpy 在某些操作上有多快。如果你熟悉线性代数,你无疑知道什么是点积。如果不熟悉,点积是对两个长度相等的数字数组的代数运算,这些数组成对相乘并在之后求和。用数学术语来说,它看起来像这样:

图片

这是一个相当简单的程序,计算量不是很大,但仍然是通过 numpy 执行时快得多的事情。

点积的目标是将第二个向量(数组)的增长应用到第一个向量上。当应用于矩阵时,这可以用来移动/旋转/缩放一个点或甚至一个 n-维对象。简单来说,如果你有一个存储在 numpy 中的 3D 模型,你可以使用 numpy.dot 在其上运行完整的变换。这些操作的示例可以在我的 numpy-stl 包中找到:pypi.org/project/numpy-stl/

在这个例子中,我们将坚持两个一维数组的标准点积。

为了轻松计时结果,我们将从 IPython shell 中执行此操作:

In [1]: import numpy

In [2]: a = list(range(1000000))
In [3]: b = numpy.array(a)

In [4]: def dot(xs, ys):
   ...:     total = 0
   ...:     for x, y in zip(xs, ys):
   ...:         total += x * y
   ...:     return total
   ...:

In [5]: %timeit dot(a, a)
78.7 ms ± 1.03 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [6]: %timeit numpy.dot(b, b)
518 µs ± 27.6 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each) 

在这个基本示例中,我们可以看到纯 Python 版本需要 78.7 毫秒,而 numpy 版本需要 518 微秒。这意味着 numpy 版本快了 150 倍。根据你试图做什么以及数组的大小,优势可能会更大。

创建数组有多种选项可用,但根据我的经验,以下是最有用的:

  • numpy.array(source_array) 从不同的数组(如前一个示例所示)创建一个数组。

  • numpy.arange(n) 创建一个给定范围的数组。实际上等同于 numpy.array(range(n))

  • numpy.zeros(n) 创建一个大小为 n 的数组,填充为零。它还支持元组来创建矩阵:numpy.zeros((x, y, z))

  • numpy.fromfunction(function, (x, y, z)) 使用给定的函数创建具有给定形状的数组。需要注意的是,此函数将传递当前项的索引/索引,所以在这个例子中是 xyz 索引。

numpy 库有许多更多有用的函数,但至少它提供了一个几乎无与伦比的性能和非常易于使用的接口。

Numba – 在 CPU 或 GPU 上更快的 Python

我们已经在 第十二章 中介绍了 numba 的基础知识,性能 – 跟踪和减少您的内存和 CPU 使用量。结合 numpynumba 变得更加强大,因为它原生支持广播到 numpy 数组的函数(numpy 将这些称为 ufuncs通用函数),类似于内置的 numpy 函数的工作方式。普通 numba 函数和支持 numpy 每元素处理的函数之间的重要区别在于您使用的装饰器函数。通常您会使用 numba.jit();对于 numpy 每元素处理,您需要使用带有输入和输出类型参数的 numba.vectorize(...) 装饰器:

>>> import numpy
>>> import numba

>>> numbers = numpy.arange(500, dtype=numpy.int64)

>>> @numba.vectorize([numba.int64(numba.int64)])
... def add_one(x):
...     return x + 1

>>> numbers
array([  0,   1,   2, ..., 498, 499])

>>> add_one(numbers)
array([  1,   2,   3, ..., 499, 500]) 

加 1 当然是一个无用的例子,但你可以在这里做任何你想做的事情,这使得它非常有用。真正的要点是它有多容易;只要你的函数是纯函数式的(换句话说,不修改外部变量),就可以通过非常少的努力使其非常快。这也是为什么本章中其他几个库在性能上严重依赖 numba 的原因。

正如我们指定的 numba.vectorize([numba.int64(numba.int64)]),我们的函数将只接受 64 位整数并返回 64 位整数。要创建一个接受两个 32 位或 64 位浮点数并返回 64 位整数的函数,我们可以使用以下代码:

@numba.vectorize([
    numba.int64(numba.float32, numba.float32), 
    numba.int64(numba.float64, numba.float64),
]) 

除了 numba.vectorize() 装饰器之外,我们还有其他一些选项可用,例如用于 JIT 编译整个类的 numba.jitclass() 装饰器,或者用于增强整个模块的 numba.jit_module() 函数。

SciPy – 数学算法和 NumPy 工具

scipy(科学 Python)包包含针对许多不同问题的数学算法集合。函数范围从信号处理到空间算法到统计函数。

这里列出了 scipy 库中当前可用的某些子包(根据 scipy 手册):

  • cluster:如 k-means 的聚类算法

  • fftpack:快速傅里叶变换例程

  • integrate:积分和常微分方程求解器

  • interpolate:插值和样条平滑函数

  • linalg:线性代数函数,如线性方程求解

  • ndimageN-维图像处理

  • odr:正交距离回归

  • optimize:优化和根查找例程

  • signal:信号处理函数,如峰值查找和频谱分析

  • sparse:稀疏矩阵及其相关的内存节省例程

  • spatial:用于三角剖分和绘图的空間数据结构和算法

  • stats:统计分布和函数

如您所见,scipy 提供了广泛主题的算法,其中许多函数都非常快,因此绝对值得一看。

对于这些大多数主题,你可以通过它们的名称猜测它们是否适用于你的用例,但也有一些需要一个小例子来证明。所以,让我们看看一个例子。

稀疏矩阵

scipy(至少在我看来)最有用的功能之一是scipy.sparse模块。这个模块允许你创建稀疏数组,这可以为你节省大量的内存。而numpy数组大约占用你预留的内存量,而稀疏数组只存储非零值或非零块/行/列,具体取决于你选择的数据类型。在numpy的情况下,存储一百万个 64 位整数需要 6400 万个比特或 8MB。

自然地,稀疏数组的好处伴随着一些缺点,比如某些操作或方向的处理速度较慢。例如,scipy.sparse.csc_matrix方法可以产生在列方向上切片非常快的稀疏矩阵,但在行方向上切片时则较慢。同时,scipy.sparse.csr_matrix则相反。

稀疏数组的使用大致与numpy数组一样简单,但在选择特定的稀疏矩阵类型时需要小心。选项包括:

  • bsr_matrix(arg1[, shape, dtype, copy, blocksize]): 块稀疏行矩阵

  • coo_matrix(arg1[, shape, dtype, copy]): 基于坐标格式的稀疏矩阵。

  • csc_matrix(arg1[, shape, dtype, copy]): 压缩稀疏列矩阵

  • csr_matrix(arg1[, shape, dtype, copy]): 压缩稀疏行矩阵

  • dia_matrix(arg1[, shape, dtype, copy]): 基于对角存储的稀疏矩阵

  • dok_matrix(arg1[, shape, dtype, copy]): 基于键的字典稀疏矩阵。

  • lil_matrix(arg1[, shape, dtype, copy]): 基于行的列表-列表稀疏矩阵

如果你只需要类似一个大单位矩阵的东西,这可以非常有用。它很容易构建,并且占用非常少的内存。以下两个矩阵在内容上是相同的:

>>> import numpy
>>> from scipy import sparse

>>> x = numpy.identity(10000)
>>> y = sparse.identity(10000)

>>> x.data.nbytes
800000000

# Summing the memory usage of scipy.sparse objects requires the summing
# of all internal arrays. We can test for these arrays using the
# nbytes attribute.
>>> arrays = [a for a in vars(y).values() if hasattr(a, 'nbytes')]

# Sum the bytes from all arrays
>>> sum(a.nbytes for a in arrays)
80004 

如你所见,非稀疏版本的单位矩阵(x)占用的内存是前者的 10000 倍。在这种情况下,它是 800MB 对 80KB,但如果你有一个更大的矩阵,这很快就会变得不可能。由于矩阵的大小是二次增长的(;上面的矩阵大小为 10,000x10,000=100,000,000),这可以造成非常显著的区别。稀疏矩阵(至少在这个例子中)是线性增长的(n)。

对于较小的非稀疏数组(多达十亿个数字),内存使用仍然可行,对于十亿个 64 位数字,大约需要 8GB 的内存,但当你超过这个范围时,大多数系统会很快耗尽内存。正如通常情况那样,这些内存节省是以增加许多操作的 CPU 时间为代价的,所以我不会建议将所有的numpy数组替换为稀疏数组。

总之,scipy 是一个多功能且非常有用的模块,支持广泛的计算和算法。如果 scipy 有适合您目标的算法,那么它很可能是您在 Python 生态系统中能找到的最快的选项之一。然而,许多函数非常特定于领域,因此您可能可以猜出哪些(以及哪些不是)对您有用。

Pandas – 现实世界数据分析

虽然 numpyscipysympy 的重点是数学,但 Pandas 更专注于现实世界的数据分析。使用 Pandas,通常期望您从外部源(如数据库或 CSV 文件)加载数据。一旦加载数据,您就可以轻松计算统计数据、可视化数据或与其他数据集合并数据。

要存储数据,Pandas 提供了两种不同的数据结构。pandas.Series 是一个一维数组,而 pandas.DataFrame 是一个二维矩阵,如果需要,列可以标记。内部这些对象封装了一个 numpy.ndarray,因此在这些对象上仍然可以进行所有 numpy 操作。

为什么我们需要在 numpy 之上使用 Pandas?这全部归结于便利性,Pandas 在 numpy 的基础上提供了几个对进行现实世界数据分析有益的特性:

  • 它可以优雅地处理缺失数据。在 numpy 浮点数中,您可以存储 NaN(不是一个数字),但并非所有 numpy 方法都能很好地处理它,除非进行自定义过滤。

  • 与固定大小的 numpy.ndarray 相比,可以根据需要向 numpy.DataFrame 中添加和删除列。

  • 它提供了捆绑的数据管理函数,可以轻松地对数据进行分组、聚合或转换。虽然您可以轻松修改 numpy 数据,但默认情况下,从 numpy 中分组数据要困难得多。

  • 它还提供了用于包含时间序列数据的实用函数,允许您轻松应用移动窗口统计并轻松比较新旧数据。

让我们创建一个简单的示例,该示例存储了主要 Python 版本的发布日期及其版本。数据来源于维基百科,它有一个很好的表格,我们可以快速使用并复制:en.wikipedia.org/wiki/History_of_Python#Table_of_versions

为了简洁起见,我们在这里展示了代码的简短版本,但您可以从维基百科复制/粘贴完整的表格,或者查看此书的 GitHub 项目。

首先,让我们将数据读入一个数据框中:

# A commonly used shorthand for pandas is pd
>>> import re
>>> import io

>>> import pandas as pd

>>> data = '''
... Version\tLatest micro version\tRelease date\tEnd of full support\tEnd ...
... 0.9\t0.9.9[2]\t1991-02-20[2]\t1993-07-29[a][2]
... ...
... 3.9\t3.9.5[60]\t2020-10-05[60]\t2022-05[61]\t2025-10[60][61]
... 3.10\t\t2021-10-04[62]\t2023-05[62]\t2026-10[62]
... '''.strip()

# Slightly clean up data by removing references
>>> data = re.sub(r'\[.+?\]', '', data)

# df is often used as a shorthand for pandas.DataFrame
>>> df = pd.read_table(io.StringIO(data)) 

在这种情况下,整个表都存储在 data 中,作为一个制表符分隔的字符串。由于这包括维基百科使用的引用,我们使用正则表达式清理所有看起来像 [...] 的内容。最后,我们使用 pandas.read_table() 将数据读入一个 pandas.DataFrame 对象。read_table() 函数支持文件名或文件句柄,由于我们拥有字符串形式的数据,我们使用 io.StringIO() 将字符串转换为文件句柄。

现在我们有了数据,让我们看看我们能用它做什么:

# List the columns
>>> df.columns
Index(['Version', ..., 'Release date', ...], dtype='object')
# List the versions:
>>> df['Version']
0     0.9
...
25    3.9
26    3.1
Name: Version, dtype: float64

# Oops... where did Python 3.10 go in the output above? The
# conversion to float trimmed the 0 so we need to disable that.
>>> df = pd.read_table(io.StringIO(data), dtype=dict(Version=str))

# Much better, we didn't lose the version info this time
>>> df['Version']
0      0.9
...
25     3.9
26     3.10
Name: Version, dtype: object 

现在我们已经知道了如何从表中读取数据,让我们看看我们如何能更有效地使用它。这次我们将将其转换为时间序列,这样我们就可以基于日期/时间进行分析了:

# The release date is read as a string by default, so we convert
# it to a datetime:
>>> df['Release date'] = pd.to_datetime(df['Release date'])

>>> df['Release date']
0      1991-02-20
...
26     2021-10-04
Name: Release date, dtype: datetime64[ns]

# Let's see which month is the most popular for Python releases.
# First we run groupby() on the release month and after that we
# run a count() on the version:
>>> df.groupby([df['Release date'].dt.month])['Version'].count()
Release date
1     2
2     2
3     1
4     2
6     3
7     1
9     4
10    8
11    1
12    3
Name: Version, dtype: int64 

虽然你可以用普通的numpy做所有这些,但使用pandas肯定要方便得多。

输入和输出选项

Pandas 的一个巨大优势是它提供了大量的现成输入和输出选项。让我们首先说,这个列表永远不会完整,因为你可以轻松实现自己的方法,或者安装一个库来为你处理其他类型。我们将在本章后面介绍xarray时看到这个例子。

在撰写本文时,pandas库原生支持大量的输入和/或输出格式:

  • 常见的格式,如 Pickle、CSV、JSON、HTML 和 XML

  • 如 Excel 文件之类的电子表格

  • 其他统计系统使用的数据格式,如 HDF5、Feather、Parquet、ORC、SAS、SPSS 和 Stata

  • 许多使用 SQLAlchemy 的数据库类型

如果你的首选格式不在列表中,你很可能可以轻松找到它的转换器。或者,自己编写一个转换器也很容易,因为你可以用纯 Python 实现它们。

交叉表和分组

Pandas 的一个非常有用的功能是能够交叉表逆交叉表DataFrame。在交叉表操作时,我们可以根据它们的值将行转换为列,从而有效地对它们进行分组。pandas库有几个选项来交叉表/逆交叉表你的数据:

  • pivot: 返回一个没有聚合(例如求和/计数等)支持的重新塑形的交叉表

  • pivot_table: 返回一个具有聚合支持的交叉表

  • melt: 反转pivot操作

  • wide_to_long: melt的一个更简单的版本,使用起来可能更方便

通过交叉表操作我们能实现什么?让我们创建一个包含一些温度测量的长列表的非常简单的例子,并将它们交叉表操作,这样我们就能将日期作为列而不是行:

>>> import pandas as pd
>>> import numpy as np

>>> df = pd.DataFrame(dict(
...     building=['x', 'x', 'y', 'x', 'x', 'y', 'z', 'z', 'z'],
...     rooms=['a', 'a', 'a', 'b', 'b', 'b', 'c', 'c', 'c'],
...     hours=[10, 11, 12, 10, 11, 12, 10, 11, 12],
...     
...     temperatures=np.arange(0.0, 9.0),
... ))

>>> df
  building rooms  hours  temperatures
0        x     a     10           0.0
1        x     a     11           1.0
...
7        z     c     11           7.0
8        z     c     12           8.0 

这种数据设置的方式类似于数据记录工具通常会返回的方式,每行代表一个单独的测量值。然而,这通常不是读取或分析数据最方便的方式,这正是交叉表能真正帮助的地方。

让我们看看每小时的平均室温:

>>> pd.pivot_table(
...     df, values='temperatures', index=['rooms'],
...     columns=['hours'], aggfunc=np.mean)
hours   10   11   12
rooms
a      0.0  1.0  2.0
b      3.0  4.0  5.0
c      6.0  7.0  8.0 

这显示了每个房间的行和每个小时的列,通过numpy.mean()生成的值。

我们还可以得到每栋楼、每个房间每小时的平均室温:

>>> pd.pivot_table(
...     df, values='temperatures', index=['building', 'rooms'],
...     columns=['hours'], aggfunc=np.mean)
hours            10   11   12
building rooms
x        a      0.0  1.0  NaN
         b      3.0  4.0  NaN
y        a      NaN  NaN  2.0
         b      NaN  NaN  5.0
z        c      6.0  7.0  8.0 

如您所见,pandas通过显示缺失数据的NaN来处理缺失值,并给出了一个非常好的聚合结果。

除了这些旋转功能之外,Pandas 提供了一大串分组函数,这些函数也允许你聚合结果。与旋转相比,分组功能的一个大优点是你可以对任意范围和函数进行分组。例如,对于基于时间的结果,你可以选择按秒、分钟、小时、5 分钟或任何对你有用的其他间隔进行分组。

作为上面的基本示例:

>>> df.groupby(pd.Grouper(key='hours')).mean()
       temperatures
hours
10              3.0
11              4.0
12              5.0 

这个例子已经展示了如何使用groupby功能,但真正的力量在于将其与时间戳结合使用。例如,你可以使用pd.Grouper(freq='5min')

合并

Pandas 的另一个极其有用的功能是,你可以合并数据,类似于在数据库中连接表。与旋转类似,pandas库有几种连接方法:

  • pandas.mergemerge函数基本上是数据库连接的直接等价。它可以执行内连接、外连接、左连接、右连接和交叉连接,类似于许多数据库。此外,它还可以验证列之间的关系是否正确(即一对一、一对多、多对一和多对多),类似于数据库中的引用完整性功能。

  • pandas.merge_ordered:类似于merge,但允许使用函数进行可选的填充/插值。

  • pandas.merge_asof:此函数在最近的关键值上执行左连接,而不是要求精确匹配。

容易合并多个DataFrame对象的能力是一个非常强大的功能,在处理现实世界数据时非常有价值。

滚动或扩展窗口

在 Pandas 中,窗口可以帮助你高效地对(扩展的)数据的滚动子集进行计算。当然,直接计算是可能的,但对于大型数据集来说可能非常低效和不可行。使用滚动窗口,你可以以高效的方式在固定窗口大小上获得移动平均、总和或其他函数。

为了说明,让我们假设你有一个包含 100 个项目的数组,并且你想使用窗口大小为 10 来获取平均值。直观的解决方案是先对前 10 个项目求和,然后除以 10,然后对 1 到 11 的项目重复此操作,依此类推。

对于这些,你将不得不遍历窗口中的所有 10 个项目。如果我们取n为数组的长度,w为窗口的大小,这将需要O(n*w)的时间。然而,如果我们跟踪中间的总和,我们可以做得更好;如果我们简单地添加下一个数字,并同时从我们的运行总和中去掉第一个数字,我们可以在O(n)内完成同样的工作。

让我们通过一个例子来说明pandas是如何为我们处理这些的:

>>> import pandas as pd
>>> import numpy as np

>>> pd_series = pd.Series(np.arange(100))  # [0, 1, 2, ... 99]

>>> # Create a rolling window with size 10
>>> window = pd_series.rolling(10)
>>> # Calculate the running mean and ignore the N/A values at the
>>> # beginning before the window is full
>>> window.mean().dropna()
9      4.5
10     5.5
      ...
99    94.5
Length: 91, dtype: float64 

如上所述,滚动窗口支持计数、求和、平均值、中位数、方差、标准差、分位数等函数。如果你需要特殊的功能,你也可以提供自己的函数。

这些窗口有一些额外功能。除了使用相同的权重计算所有项目外,你还可以使用加权窗口来改变项目的权重,使最近的数据比旧数据更有相关性。除了常规加权窗口外,你还可以选择指数加权窗口来进一步增强效果。

最后,我们还有扩展窗口。使用这些窗口,你可以从数据集的开始到当前点获取结果。如果你要计算一个包含值 1, 2, 3, 4, 5 的序列的总和,它将返回 1, 3, 6, 10, 15,其中每个项目都是从序列开始到该点的总和中。

总结来说,pandas 库对于分析来自不同来源的数据极为有用。由于它是建立在 numpy 之上的,因此它也非常快速,这使得它非常适合深入分析。

如果你需要处理大量数据,或者来自几个不同来源的数据,不妨试试 pandas,看看它是否能帮助你整理数据。

Statsmodels – 基于 Pandas 的统计模型

类似于 scipy 是建立在 numpy 之上的,我们也有 statsmodels 是建立在 pandas 之上的。最初,它是 scipy 包的一部分,但后来分离出来并得到了极大的改进。

statsmodels 库提供了一系列统计方法和绘图工具,可用于创建回归模型、选择模型、方差分析 (ANOVA)、预测等。

一个加权最小二乘回归的快速示例,它试图将一条线拟合到一组数据点,可以像这样应用:

# The common shorthand for statsmodels is sm
>>> import statsmodels.api as sm
>>> import numpy as np

>>> Y = np.arange(8)
>>> X = np.ones(8)

# Create the weighted-least-squares model
>>> model = sm.WLS(Y, X)

# Fit the model and generate the regression results
>>> fit = model.fit()

# Show the estimated parameters and the t-values:
>>> fit.params
array([3.5])
>>> fit.tvalues
array([4.04145188]) 

虽然它仍然需要一些关于统计学的背景知识才能正确应用,但它确实展示了如何容易地使用 statsmodels 进行回归。

以下是 statsmodels 手册中支持的模型和分析类型简短列表。

回归和线性模型:

  • 线性回归

  • 广义线性模型

  • 广义估计方程

  • 广义加性模型 (GAMs)

  • 鲁棒线性模型

  • 线性混合效应模型

  • 离散因变量的回归

  • 广义线性混合效应模型

  • 方差分析 (ANOVA)

时间序列分析:

  • 通用时间序列分析,如单变量和向量自回归模型 (ARs/VARs)

  • 状态空间方法进行的时间序列分析

  • 向量自回归

其他模型:

  • 生存和持续时间分析的方法

  • 非参数方法

  • 广义矩估计方法

  • 多变量统计

实际上,支持的功能列表相当长,但这应该能给你一个很好的指示,即它是否是一个对你有用的库。如果你熟悉统计模型,你应该能够快速开始使用 statsmodels,并且该包有很好的文档和示例。

xarray – 标记数组和数据集

xarray库与pandas非常相似,也是建立在numpy之上的。主要区别在于xarray是多维的,而pandas只支持一维和二维数据,并且它是基于netCDF网络公共数据格式)格式创建的。netCDF 格式通常用于科学研究数据,与例如 CSV 文件不同,它们包含数据以及元数据,如变量标签、数据描述和文档,这使得在多种软件中易于使用。

xarray库可以轻松与pandas协同工作,因此在这个例子中,我们将重新使用我们之前pandas示例中的数据。反过来,使用xarray.DataArray对象上的to_dataframe()方法(标准的xarray矩阵对象)也是同样容易的。在这个例子中,我们假设您仍然有之前pandas示例中的df变量可用:

# The common shorthand for xarray is xr
>>> import xarray as xr

>>> ds = xr.Dataset.from_dataframe(df)

# For reference, the pandas version of the groupby
# df.groupby([df['Release date'].dt.month])['Version'].count()
>>> ds.groupby('Release date.month').count()['Version']

<xarray.DataArray 'Version' (month: 10)>
array([2, 2, 1, 2, 3, 1, 4, 8, 1, 3])
Coordinates:
  * month    (month) int64 1 2 3 4 6 7 9 10 11 12 

groupby()的语法与pandas略有不同,并且由于使用字符串而不是变量,所以不太 Pythonic(如果问我),但它本质上归结为相同的操作。

pandas版本中,count()['Version']的顺序可以互换,使其更加相似。也就是说,以下也是有效的,并且返回相同的结果:

df.groupby([df['Release date'].dt.month]).count()['Version'] 

此外,对于这个用例,我会说xarray的输出并不那么易于阅读,但当然也不算差。通常,您会有如此多的数据点,以至于您根本不会太关心原始数据。

pandas相比,xarray(至少在我看来)的真正优势是支持多维数据。您可以向Dataset对象添加尽可能多的内容:

>>> import xarray as xr
>>> import numpy as np

>>> points = np.arange(27).reshape((3, 3, 3))
>>> triangles = np.arange(27).reshape((3, 3, 3))
>>> ds = xr.Dataset(dict(
...     triangles=(['p0', 'p1', 'p2'], triangles),
... ), coords=dict(
...     points=(['x', 'y', 'z'], points),
... ))

>>> ds
<xarray.Dataset>
Dimensions:    (p0: 3, p1: 3, p2: 3, x: 3, y: 3, z: 3)
Coordinates:
    points     (x, y, z) int64 0 1 2 3 4 5 ... 21 22 23 24 25 26
Dimensions without coordinates: p0, p1, p2, x, y, z
Data variables:
    triangles  (p0, p1, p2) int64 0 1 2 3 4 ... 21 22 23 24 25 26 

在这种情况下,我们只添加了trianglespoints,但您可以添加尽可能多的内容,并且可以使用xarray将这些内容组合起来,以便轻松引用多维对象。数据组合可以通过多种方法实现,例如连接、合并以将多个数据集合并为一个,基于字段值组合,通过逐行更新,以及其他方法。

当涉及到pandasxarray的比较时,我建议简单地尝试两者,看看哪一个更适合您的使用场景。这两个库在功能和可用性上非常相似,并且各自都有其优势。如果您需要,xarray的多维性相对于pandas来说是一个巨大的优势。

如果对您来说两者都一样,那么我目前会推荐使用pandas而不是xarray,仅仅因为它目前是两者中更常用的,这导致更多的文档/博客文章/书籍可用。

STUMPY – 在时间序列中寻找模式

stumpy 库提供了几个工具来自动检测时间序列矩阵中的模式和异常。它基于 numpyscipynumba 构建,以提供出色的性能,并为你提供了利用 GPU(显卡)处理数据以实现更快处理的可能性。

使用 stumpy,例如,你可以自动检测一个网站是否正在获得异常数量的访客。在这个场景中,stumpy 的一个很好的特性是,除了静态矩阵外,你还可以以流式的方式添加更多数据,这允许你进行实时分析而无需太多开销。

例如,让我们假设我们有一个客厅恒温器的温度列表,看看我们是否能找到任何重复的模式:

>>> import numpy as np
>>> import stumpy

>>> temperatures = np.array([22., 21., 22., 21., 22., 23.])

>>> window_size = 3

# Calculate a Euclidean distance matrix between the windows
>>> stump = stumpy.stump(temperatures, window_size)

# Show the distance matrix. The row number is the index in the
# input array. The first column is the distance; the next columns
# are the indices of the nearest match, the left match, and the
# right match.
>>> stump
array([[0.0, 2, -1, 2],
      [2.449489742783178, 3, -1, 3],
      [0.0, 0, 0, -1],
      [2.449489742783178, 1, 1, -1]], dtype=object)

# As we can see in the matrix above, the first window has a
# distance of 0 to the window at index 2, meaning that they are
# identical. We can easily verify that by showing both windows:

# The first window:
>>> temperatures[0:window_size]
array([22., 21., 22.])

# The window at index 2:
>>> temperatures[2:2 + window_size]
array([22., 21., 22.]) 

仔细观察的你们可能已经注意到,这个距离矩阵只有 46 个值,而不是传统的 n*n(在这个例子中是 6*6)距离矩阵。部分原因是因为我们使用了 3 的窗口大小,我们只查看窗口的数量(即 n-window_size+1=4)。更大的部分原因是 stumpy 只存储最近的配对,从而只需要 O(n) 的空间,而不是正常的 O(n*n)

虽然你也可以用普通的 numpy 进行这些类型的分析,但 stumpy 使用一个非常智能的算法,并且高度依赖 numba 以实现更快的处理,所以如果你可以使用这个库,我推荐你使用它。

数学与精确计算

Python 内置了许多数学函数和特性,但在某些情况下,你需要更高级的功能或更快的速度。在本节中,我们将讨论几个库,这些库通过引入许多额外的数学函数、提高数学精度和/或性能来提供帮助。

首先,让我们讨论一下 Python 核心库中存储数字和执行不同精度计算的选项:

  • int:在 Python 中,为了存储整数(例如 1, 2, 3),我们有 int 对象。只要它能在 64 位内适应,int 就会被直接转换为 C 的 int64。超出这个范围,它会被内部转换为 Python 的 long 类型(不要与 C 的 long 混淆),它可以任意大。这允许无限精度,但只有在使用整数时才有效。

  • fractions.FractionFraction 对象使得存储分数数(例如,1/21/32/3)成为可能,并且由于它们内部依赖于两个 int(或 long)对象作为分子和分母,它们是无限精确的。然而,这些只有在你要存储的数字可以表示为分数时才有效。例如,π 这样的无理数不能以这种方式表示。

  • float: 浮点数使得存储包含小数的数字(例如 1.234.56)变得非常容易。这些数字通常以 64 位浮点数的形式存储,它是由一个符号(1 位正或负)、指数(11 位)和分数(52 位)的组合,导致以下方程: 。这意味着像 0.5 这样的数字使用分数 0 和指数 -1 来存储,导致: 。在 0.5 的情况下,这可以完美存储;在许多其他情况下,这可能会出现问题,因为并非每个数字都可以像这样精确描述,这导致了浮点数的不精确。

  • decimal.Decimal: Decimal 对象允许进行具有任意但指定精度的计算。你可以选择你想要的位数,但它的速度并不快。

以下库中的几个提供了增强计算精度的解决方案。

gmpy2 – 快速且精确的计算

gmpy2 库使用用 C 语言编写的库,以实现真正快速的高精度计算。在 Linux/Unix 系统上,它将依赖于 GMP(因此得名);在 Windows 上,它将使用基于 GMP 的 MPIR。此外,MPFR 和 MPC 库分别用于正确地四舍五入浮点实数和复数。最后,它使用 mpz_lucasmpz_prp 进行快速素性测试。

这里有一个如何将 π 计算到 1000 位的微小示例,这用 Python 核心库是难以轻易做到的:

>>> import gmpy2

>>> gmpy2.const_pi(1000)
mpfr('3.14159265358979...33936072602491412736',1000) 

如果你需要快速且高精度的计算,这个库是无价的。

对于我的个人用例,gmpy 库(当时 gmpy2 还不存在)在参加名为 Project Euler 的有趣在线数学挑战项目时非常有帮助:projecteuler.net/

Sage – Mathematica/Maple/MATLAB 的替代品

如果你曾在大学或学院上过高级数学课程,那么你很可能遇到过 Mathematica、Maple、MATLAB 或 Magma 等软件。或者你可能使用过基于 Mathematica 的 WolframAlpha。Sage 项目旨在作为那些真正昂贵的软件包的免费和开源替代品。

作为参考,在撰写本文时,基本 Mathematica Home 版本,只能同时运行在 4 个 CPU 核心上,售价为 413 欧元(487 美元)。

Sage 软件包可以用来求解方程,无论是数值解还是精确解,绘制图表,以及从 Sage 解释器执行许多其他任务。类似于 IPython 和 Jupyter,Sage 提供了自己的解释器,并使用自定义语言,因此感觉更接近 Mathematica 等数学软件包。当然,你也可以从常规 Python 中导入 Sage 代码。

使用 Sage 和 Sage 解释器求解变量的一个小例子:

sage: x, y, z = var('x, y, z')
sage: solve([x + y == 10, x - y == 5, x + y + z == 1], x, y, z)
[[x == (15/2), y == (5/2), z == -9]] 

在这种情况下,我们要求 Sage 根据以下约束条件为我们解一个包含三个变量的方程:

图片

图片

根据 Sage(正确地),结果是:

图片

如果你正在寻找一个完整的数学软件系统(或其中的一些功能),Sage 是一个不错的选择。

mpmath – 方便、精确的计算

mpmath库是一个全面的数学库,提供了三角学、微积分、矩阵以及其他许多函数,同时仍然可以配置精度。

由于mpmath是纯 Python 编写且没有必需的依赖项,因此安装它非常简单,但如果它们可用,它确实提供了使用 Sage 和gmpy2的速度提升。如果这些库不可用,这结合了 Sage 和gmpy2库的速度优势,以及纯 Python 安装的便利性。

让我们通过一个例子来说明在 Python 中可配置精度与常规浮点数之间的优势:

>>> N = 10
>>> x = 0.1

# Regular addition
>>> a = 0.0
>>> for _ in range(N):
...     a += x  

>>> a
0.9999999999999999

# Using sum, the same result as addition
>>> sum(x for _ in range(N))
0.9999999999999999 

如你所见,常规加法和sum()都是不准确的。Python 确实有更好的方法可以用于这个问题:

# Sum using Python's optimized fsum:
>>> import math

>>> math.fsum(x for _ in range(N))
1.0 

然而,当涉及到一般情况时,浮点数学总是不准确的,有时这可能会成为问题。所以,如果你的计算确实需要浮点数学但希望有更高的精度,mpmath可以帮到你:

>>> import mpmath

# Increase the mpmath precision to 100 decimal places
>>> mpmath.mp.dps = 100
>>> y = mpmath.mpf('0.1')

# Using mpmath with addition:
>>> b = mpmath.mpf('0.0')
>>> for _ in range(N):
...     b += y

>>> b
mpf('1.00000000000000000000000000...00000000000000000000000014')

# Or a regular sum with mpmath:
>>> sum(y for _ in range(N))
mpf('1.00000000000000000000000000...00000000000000000000000014') 

虽然这些结果显然仍然不完美(你会假设结果应该是 1.0,就像math.fsum()产生的结果一样),但它可以帮助大大减少浮点误差。确保向mpmath提供字符串或整数,否则你的变量可能已经引入了浮点误差。如果我们用x而不是y来求和,它会导致与常规 Python 数学相似的浮点不精确。

自然地,fpmath可以做的不仅仅是减少你的浮点误差,比如绘图和微积分,但我将留给你去探索。如果你在寻找数学问题的解决方案,这个库应该在你的列表上。

SymPy – 符号数学

sympy模块是一个你可能永远都不需要用到的库,但它是一个如此出色的库,应该被介绍。sympy的目标是成为一个功能齐全的计算机代数系统(CAS),这样你就可以像在纸上那样操作数学表达式。

让我们从一个小演示开始,看看我们如何使用sympy表达和求解一个积分:

>>> from sympy import *

>>> init_printing(use_unicode=True)
>>> x, y, z = symbols('x y z')

>>> integral = Integral(x * cos(x), x)
>>> integral
⌠
| x cos(x) dx
⌡
>>> integral.doit()
x sin(x) + cos(x) 

如果这让你想起了某些微积分考试,我感到很抱歉,但我认为能够做到这一点真是太神奇了。这段代码首先使用通配符导入sympy,因为如果所有函数都需要以sympy为前缀,方程将很快变得难以阅读。

之后,我们使用带有 Unicode 标志的init_printing()函数来告诉sympy我们的 shell 支持 Unicode 字符。这允许渲染许多数学公式,但当然不是所有公式。这个替代方案是基本的 ASCII 渲染(正如你可以想象的那样,对于一个积分来说,这看起来并不太美观),以及 LaTeX 输出,它可以渲染为图像(例如,当使用 Jupyter 时)。实际上,还有其他几种渲染模式可用,但它们很大程度上取决于你的环境,所以我们不会深入探讨这些。

由于你可以在方程中使用任何变量名,我们需要特别声明xyz为变量。尽管在这个例子中我们只使用了x,但你通常还需要其他变量,所以为什么不提前声明它们呢?

现在我们使用Integral函数来声明积分。由于字体限制,上面的例子并不完美,但在你的 shell 或浏览器中渲染的积分应该看起来像这样:

图片

最后,我们告诉sympy使用doit()方法来解决积分。这正确地得到了以下方程:

图片

我在这里的唯一小问题是sympy默认省略了积分常数。理想情况下,它应该包括+ C

如果你正在寻找表示(并解决)方程的方法,sympy当然可以帮到你。我个人认为这是一个非常棒的库,尽管我很少使用它。

Patsy – 描述统计模型

sympy可以在 Python 中描述数学公式类似,patsy可以描述统计模型,这使得它与statsmodels包相辅相成。它还可以使用常规 Python 函数或直接应用numpy

>>> import patsy
>>> import numpy as np

>>> array = np.arange(2, 6)

>>> data = dict(a=array, b=array, c=array)
>>> patsy.dmatrix('a + np.square(b) + np.power(c, 3)', data)
DesignMatrix with shape (4, 4)
  Intercept  a  np.square(b)  np.power(c, 3)
          1  2             4               8
          1  3             9              27
          1  4            16              64
          1  5            25             125
  Terms:
    'Intercept' (column 0)
    'a' (column 1)
    'np.square(b)' (column 2)
    'np.power(c, 3)' (column 3) 

在这个例子中,我们创建了一个从 2 到 6 范围的numpy数组,并以abc的名称传递给patsy.dmatrix()函数,因为重复的名称将被忽略。之后,我们使用patsy创建了矩阵;正如你所看到的,patsy语言中的+告诉它添加一个新列。这些列可以是普通的列,如a,也可以调用函数,如np.square(b)

如果你熟悉向量和矩阵背后的数学,这个库可能对你来说非常自然。至少,它是一个稍微明显一些的方式来声明你的数据是如何交互的。

绘图、图形和图表

当然,能够读取、处理和写入数据很重要,但为了理解数据的意义,通常更方便的是创建一个图表、图形或图表。正如古老的谚语所说:“一图胜千言。”

如果你在这章前面提到的任何库中有所经验,你可能知道它们中的许多都提供了图形输出的选项。然而,在(几乎)所有情况下,这并不是一个真正的内置功能,而是一个方便的快捷方式,指向外部库,例如matplotlib

与本章中提到的几个库类似,存在多个具有相似功能和可能性的库,因此这当然不是一个详尽的列表。为了使可视化绘图更容易,对于这些示例,我们将主要依靠jupyter-notebook,并使用ipywidgets来创建交互式示例。一如既往,代码(在这些情况下,是jupyter-notebooks)可以在 GitHub 上找到,网址为github.com/mastering-python/code_2

Matplotlib

matplotlib库是可靠的绘图标准,并得到本章中许多科学库的支持。

本章前面提到的大多数库要么解释了如何使用matplotlib与库结合,要么甚至有实用函数来简化使用matplotlib的绘图。

这是否意味着matplotlib库是绘图的金标准?像往常一样,这取决于。虽然matplotlib无疑是使用最广泛的科学绘图 Python 库,具有大量功能,但它并不总是最美观的选项。这并不意味着你不能配置它使其变得美观,但出厂时,库专注于易于阅读、一致的结果,适用于所有人所有场景。一些更美观的库可能在网页上看起来很棒,并且具有非常实用的交互功能,但并不适合出版和打印。

基本示例非常简单:

# The common shorthand for pyplot is plt
import matplotlib.pyplot as plt
import numpy as np

# Enable in-line rendering for the Jupyter notebook
%matplotlib inline

a = np.arange(100) ** 2
plt.plot(a) 

实际上,我们只需要plt.plot()来绘制基本图表:

图 15.1:Matplotlib 图表

这个简单的示例非常容易绘制,但matplotlib可以做更多的事情。让我们看看我们如何结合几个图表,并使用ipywidgets使绘图交互式:

%matplotlib notebook

import matplotlib.pyplot as plt
import numpy as np
import ipywidgets as widgets

# Using interact, we create 2 sliders here for size and step.
# In this case we have size which goes from 1 to 25 with increments
# of 1, and step, which goes from 0.1 to 1 with increments of 0.1
@widgets.interact(size=(1, 25, 1), step=(0.1, 1, 0.1))
def plot(size, step):
    # Create a matplotlib figure
    # We will render everything onto this figure
    fig = plt.figure()

    # Add a subplot. You could add multiple subplots but only one will 
    # be shown when using '%matplotlib notebook'
    ax = fig.add_subplot(projection='3d')

    # We want X and Y to be the same, so generate a single range
    XY = np.arange(-size, size, step)

    # Convert the vectors into a matrix
    X, Y = np.meshgrid(XY, XY)

    R = np.sqrt(X**2 + Y**2)

    # Plot using sine
    Z = np.sin(R)
    ax.plot_surface(X, Y, Z)

    # Plot using cosine with a Z-offset of 10 to plot above each other
    Z = np.cos(R)
    ax.plot_surface(X, Y, Z + 10) 

此函数生成以下图表:

图 15.2:Jupyter Notebook 中的 Matplotlib,带有可调节的滑块

结合jupyter-notebookmatplotlib,我们可以创建交互式图表。如果你在自己的浏览器中运行它,不仅你可以拖动 3D 图表并在各个方向上查看它,你还可以通过拖动滑块来修改sizestep参数。

关于matplotlib支持的绘图类型,选项和变体实在太多,无法在此列出,但如果你在寻找任何类型的图表、图形或绘图,你很可能会在matplotlib中找到解决方案。此外,许多科学 Python 库原生支持它,这使得它成为一个容易的选择。这个简短的章节确实没有公正地体现matplotlib的深度和功能,但不用担心——我们离完成它还远着呢,因为它是本章中几个其他绘图库的基础。

Seaborn

seaborn 库与 matplotlib 的关系类似于 statsmodelspandas 之上的工作方式。它提供了一个针对统计数据的 matplotlib 接口。seaborn 的主要特点是它使得自动生成整个网格图变得非常容易。

此外,这对于我们的示例来说非常方便,seaborn 随带一些测试数据,因此我们可以基于真实数据展示完整的演示。为了说明,让我们看看我们如何轻松地创建一组非常复杂的图表:

%matplotlib notebook

import seaborn as sns

sns.pairplot(
    # Load the bundled Penguin dataset
    sns.load_dataset('penguins'),
    # Show a different "color" for each species
    hue='species',
    # Specify the markers (matplotlib.markers)
    markers=['o', 's', 'v'],
    # Gray was chosen due to the book being printed in black and white
    palette='Greys',
    # Specify which rows and columns to show. The default is to show all
    y_vars=['body_mass_g', 'flipper_length_mm'],
    x_vars=['body_mass_g', 'flipper_length_mm', 'bill_length_mm']) 

这会产生以下一系列图表:

图 15.3:Seaborn 对数图渲染

虽然这看起来仍然是一个非常复杂的调用,但实际上你只需使用 sns.pairplot(df) 就可以得到很好的结果。如果没有 hue=... 参数,结果将不会按物种分割。

seaborn 库支持许多类型的图表:

  • 关系图,如线图和散点图

  • 分布图,如直方图

  • 类别图,如箱线图

  • 矩阵图,如热图

seaborn 库也有许多创建图表集或使用如核密度估计等算法自动处理数据的快捷方式。

如果你正在寻找一个看起来很棒的绘图库,seaborn 是一个非常好的选择,尤其是由于其多图网格功能。上面列出的图表都是特定图表,但正如我们在 pairplot 中看到的,seaborn 只需一行代码就可以生成整个网格图,这非常实用。你可以直接使用 matplotlib 做同样的事情,但可能需要几十行代码。

Yellowbrick

seaborn 类似,yellowbrick 也是建立在 matplotlib 之上的。区别在于 yellowbrick 专注于可视化机器学习结果,并依赖于 scikit-learn (sklearn) 机器学习库。scikit-learn 的集成也使得这个库在这些场景中非常强大;它原生理解 scikit-learn 的数据结构,因此可以几乎无需配置就为你轻松绘制它们。在下一章中,我们将看到更多关于 scikit-learn 的内容。

这个例子直接来自 yellowbrick 手册,展示了你如何仅用一行代码可视化回归:

%matplotlib notebook

from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split as tts

from yellowbrick.datasets import load_concrete
from yellowbrick.regressor import residuals_plot

# Load the dataset and split into train/test (pandas.DataFrame) splits
X, y = load_concrete()

X_train, X_test, y_train, y_test = tts(X, y, test_size=0.2, shuffle=True)

# Create the visualizer, fit, score, and show it
viz = residuals_plot(RandomForestRegressor(), X_train, y_train, X_test, y_test) 

这会生成以下散点图:

图 15.4:Yellowbrick 回归图

这类快捷函数使得生成可用的输出并专注于回归分析变得非常容易,而不必担心如何正确地绘制数据。除了绘制回归图,yellowbrick 还提供了许多按分析类型组织的可视化工具。与 seaborn 类似,yellowbrick 可以为你处理不仅绘图,还包括计算和分析。

yellowbrick 库提供了许多类型分析的功能,例如:

  • 特征可视化:以散点图的形式显示特征,检测并排序它们,创建相关特征的圆形图等

  • 分类可视化:以线图、面积图或矩阵图的形式显示分类的阈值、精确度和错误预测

  • 回归可视化:显示散点图或散点图与直方图的组合

  • 聚类可视化:显示地图以可视化聚类之间的距离

  • 模型选择可视化:通过线条和面积或显示特征重要性的条形图来显示学习曲线

yellowbrick库是目前可视化 scikit-learn 输出的最方便的选择,但大多数图表选项也适用于其他数据类型,如pandas.DataFrame对象,所以如果seaborn不适合您的需求,值得一看。

Plotly

plotly库支持许多不同类型的图表,甚至有对滑块等控件的原生支持,因此您可以在通过网页浏览器查看时更改参数。此外,类似于seaborn在某些情况下使matplotlib的使用更加容易,plotly也包括 Plotly Express(通常表示为px),这使得使用变得极其简单。

为了说明 Plotly Express 有多容易使用,让我们尝试复制我们用seaborn制作的图表:

import seaborn as sns
import plotly.express as px

fig = px.scatter_matrix(
    # Load the Penguin dataset from seaborn
    sns.load_dataset('penguins'),
    # Show a different "color" for each species
    color='species',
    # Specify that the symbols/markers are species-dependent
    symbol='species',
    # Specify which rows and columns to show. The default is to show all
    dimensions=['body_mass_g', 'flipper_length_mm', 'bill_length_mm'],
)
fig.show() 

这是结果:

图片

图 15.5:Plotly Express 示例输出

虽然我可能会认为在这个特定情况下seaborn的输出稍微更美观一些,但它确实展示了使用 Plotly Express 创建有用图表的简便性。

您可能想知道使用常规plotly API 有多容易或困难,与 Plotly Express 相比。为此,让我们看看我们是否可以复制 3D matplotlib渲染:

import plotly
import numpy as np
import ipywidgets as widgets
import plotly.graph_objects as go

# Using interact, we create 2 sliders here for size and step.
# In this case we have size which goes from 1 to 25 with increments
# of 1, and step, which goes from 0.1 to 1 with increments of 0.1
@widgets.interact(size=(1, 25, 1), step=(0.1, 1, 0.1))
def plot(size, step):
    # Create a plotly figure, we will render everything onto this figure
    fig = go.Figure()

    # We want X and Y to be the same, so generate a single range
    XY = np.arange(-size, size, step)

    # Convert the vectors into a matrix
    X, Y = np.meshgrid(XY, XY)

    R = np.sqrt(X**2 + Y**2)

    # Plot using sine
    Z = np.sin(R)
    fig.add_trace(go.Surface(x=X, y=Y, z=Z))

    # Plot using cosine with a Z-offset of 10 to plot above each other
    Z = np.cos(R)
    fig.add_trace(go.Surface(x=X, y=Y, z=Z + 10))
    fig.show() 

这里是两个余弦函数在 3D 中绘制的最终结果:

图片

图 15.6:使用 plotly 的 3D 绘图

这基本上与matplotlib相同,我会说它甚至稍微好一些,因为它的交互性更强(遗憾的是,这本书无法有效地展示这一点)。默认情况下,plotly在您用鼠标悬停时提供了一个非常有用的值显示,并允许您进行交互式的缩放和过滤。

当涉及到在matplotlibplotly之间进行选择时,我建议查看您的具体用例。我认为plotly使用起来稍微容易一些,也更方便,但matplotlib与许多科学 Python 库深度集成,这使得它成为一个非常方便的选择。像往常一样,意见各不相同,所以请确保查看两者。

Bokeh

bokeh 库是一个美丽且功能强大的可视化库,它专注于在网页浏览器中进行交互式可视化。能够使图表交互式可以非常有助于分析结果。与我们在 seaborn 中看到的不同,你不需要在网格中创建多个图表,你可以使用单个网格并交互式地过滤。然而,由于这是一本书,我们无法真正展示 bokeh 的全部功能。

在我们开始一些示例之前,我们需要讨论你可以使用 bokeh 的两种方式。实际上,这归结为静态动态,静态版本使用所有显示数据的静态快照,而动态版本按需加载数据。

静态版本与 matplotlib 和大多数绘图库的工作方式相似:所有数据都包含在单个图像或单个网页上,无需加载外部资源。这对于许多情况来说效果很好,但并非所有情况都适用。

如果你有很多数据呢?这种可视化的一个很好的例子是谷歌地球。你永远不可能现实地将谷歌地球上的所有数据下载到你的电脑上(据估计,目前超过 100 个拍字节的数据),所以你需要在你移动地图时加载它。为此,bokeh 内置了一个服务器,以便在过滤时动态加载结果。对于这本书来说,这几乎没有意义,因为它在所有情况下都是静态的,但我们可以展示两个例子。

首先,让我们创建一个非常基础的图表:

import numpy as np

from bokeh.plotting import figure, show
from bokeh.io import output_notebook

# Load all javascript/css for bokeh
output_notebook()

# Create a numpy array of length 100 from 0 to 4 pi
x = np.linspace(0, 4*np.pi, 100)

# Create a bokeh figure to draw on
p = figure()
# Draw both a sine and a cosine
p.line(x, np.sin(x), legend_label='sin(x)', line_dash='dotted')
p.line(x, np.cos(x), legend_label='cos(x)')

# Render the output
show(p) 

从这个例子中,我们得到了以线条形式渲染的正弦和余弦函数:

图片

图 15.7:Bokeh 基本渲染

如您所见,将基本的 x/y 数据渲染为线条非常简单,并且看起来与 matplotlib 的输出没有太大区别。如果您仔细观察,您可能会注意到右侧的按钮。这些就是 bokeh 所称的工具,您可以通过滚动或绘制一个矩形来放大您希望看到的内容。通过拖动图像可以进行平移。您还可以将渲染保存为图像文件。如果需要,您还可以创建对鼠标点击或鼠标悬停做出响应的工具提示。

现在我们来看看我们是否可以重新创建一个像我们用 seaborn 创建的那样更高级的图表:

import numpy as np
import seaborn as sns

from bokeh.plotting import figure, show
from bokeh.io import output_notebook
from bokeh.layouts import gridplot
from bokeh.transform import factor_cmap, factor_mark

output_notebook()

# Load the seaborn penguin dataset (pandas.DataFrame)
penguins = sns.load_dataset('penguins')
# Get the unique list of species for the marker and color mapping
species = penguins['species'].unique()
# Specify the marker list which will be mapped to the 3 species
markers = ['circle', 'square', 'triangle']
# Create a list of rows so we can build the grid of plots
rows = []

for y in ('body_mass_g', 'flipper_length_mm'):
    row = []
    rows.append(row)

    for x in ('body_mass_g', 'flipper_length_mm', 'bill_length_mm'):
        # Create a figure with a fixed size and pass along the labels
        p = figure(width=250, height=250,
            x_axis_label=x, y_axis_label=y)
        row.append(p)

        if x == y:
            # Calculate the histogram using numpy and make sure to drop
            # the NaN values
            hist, edges = np.histogram(penguins[x].dropna(), bins=250)
            # Draw the histograms as quadrilaterals (rectangles)
            p.quad(top=hist, bottom=0, left=edges[:-1], right=edges[1:])
        else:
            # Create a scatter-plot
            p.scatter(
                # Specify the columns of the dataframe to show on the
                # x and y axis
                x, y,
                # Specify the datasource, the pandas.DataFrame is
                # natively supported by bokeh
                source=penguins,
                # Specify the column that contains the legend data
                legend_field='species',
                # Map the species onto our list of markers
                marker=factor_mark('species', markers, species),
                # Map the species to the Greys4 color palette
                color=factor_cmap('species', 'Greys4', factors=species),
                # Add transparency to the markers to make them easier
                # to see
                fill_alpha=0.2,
            )

# Show a grid of plots. Expects a 2D array
show(gridplot(rows)) 

这导致了一系列散点图和直方图:

图片

图 15.8:使用 Bokeh 创建类似 Seaborn 的图表

这在一定程度上类似于我们用 seaborn 创建的,但仍然需要相当多的努力。它确实展示了即使使用 pandas.DataFrame 作为数据源,我们也可以相当容易地将多个图表(和图表类型)组合在一起。

你应该使用bokeh吗?我认为bokeh是一个文档齐全的绘图库,有很多优点,但其他很多库也是如此。在我看来,bokeh的主要特点是支持通过bokeh服务器动态加载数据,这在某些情况下可能非常有用。与plotly不同,bokeh服务器有更多用于维护其自身状态的功能,因此可以轻松地在不重新计算的情况下进行图表更改。

Datashader

datashader库是一个特殊情况,但我相信它值得提及。datashader绘图库可以用于常规绘图,但它特别优化了高性能和大数据集。作为一个小例子,这个包含 1000 万个数据点的图表只需大约一秒钟就可以渲染:

import numpy as np, pandas as pd, datashader as ds
from datashader import transfer_functions as tf
from datashader.colors import inferno, viridis
from numba import jit
from math import sin, cos, sqrt, fabs

# Set the number of points to calculate, takes about a second with
# 10 million
n=10000000

# The Clifford attractor code, JIT-compiled using numba
@jit(nopython=True)
def Clifford(x, y, a, b, c, d, *o):
    return sin(a * y) + c * cos(a * x), \
           sin(b * x) + d * cos(b * y)

# Coordinate calculation, also JIT-compiled
@jit(nopython=True)
def trajectory_coords(fn, x0, y0, a, b=0, c=0, d=0, e=0, f=0, n=n):
    x, y = np.zeros(n), np.zeros(n)
    x[0], y[0] = x0, y0
    for i in np.arange(n-1):
        x[i+1], y[i+1] = fn(x[i], y[i], a, b, c, d, e, f)
    return x,y

def trajectory(fn, x0, y0, a, b=0, c=0, d=0, e=0, f=0, n=n):
    x, y = trajectory_coords(fn, x0, y0, a, b, c, d, e, f, n)
    return pd.DataFrame(dict(x=x,y=y))

# Calculate the pandas.DataFrame
df = trajectory(Clifford, 0, 0, -1.7, 1.5, -0.5, 0.7)

# Create a canvas and render
cvs = ds.Canvas()
agg = cvs.points(df, 'x', 'y')
tf.shade(agg, cmap=["white", "black"]) 

这里是计算了 1000 万个点生成的图表:

图片

图 15.9:Datashader 吸引子渲染

练习

由于本章的性质,我们只涵盖了所提及库的绝对基础,它们确实值得更多。在这种情况下,作为一个练习,我建议你尝试使用一些(或全部)所提及的库,看看你是否可以用它们做一些有用的事情,使用我们已介绍的各种示例作为灵感。

一些建议:

  • 创建你自己的美丽 datashader 图表

  • 渲染你个人工作空间中每个项目的代码行数

  • 从每个项目的代码行数继续,看看你是否可以按编程语言对项目进行聚类

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。我们鼓励你提交自己的解决方案,并从他人的替代方案中学习。

摘要

本章向我们展示了最常用和通用的科学 Python 库的样本。虽然它涵盖了大量的库,但还有更多可用的库,尤其是在你开始寻找特定领域库的时候。仅就绘图而言,至少还有几个其他非常大的库可能对你的用例很有用,但在这个章节中却是多余的。

回顾一下,我们已经涵盖了使用 NumPy 矩阵和 Pandas 数据对象的基础,这两者对于下一章都很重要。我们还看到了一些专注于数学和精确计算的库。最后,我们涵盖了几个绘图库,其中一些将在下一章中也会用到。

接下来是关于 Python 中人工智能和机器学习的章节。正如本章的情况一样,我们无法深入探讨,但我们可以涵盖最重要的技术和库,以便你知道该往哪里看。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

第十六章:人工智能

在上一章中,我们看到了一组科学 Python 库,它们允许快速轻松地处理大型数据文件。在这一章中,我们将使用其中的一些库和几个其他库来进行机器学习。

机器学习是一个复杂的主题,它内部许多完全不同的主题本身就是研究分支。然而,这不应该让你却步;本章中提到的许多库都非常强大,并且允许你以非常合理的努力开始。

应该注意的是,应用预训练模型和生成自己的模型之间有很大的区别。应用模型通常只需要几行代码,几乎不需要任何处理能力;而构建自己的模型通常需要许多行代码,并且需要数小时或更长时间来处理。这使得在除最简单情况之外的所有情况下,模型的训练都不在本书的范围内。在这些情况下,你将获得库可以做什么的概述,以及一些解释说明在哪里会有用,而不需要具体的示例。

人工智能是计算机科学的一个分支,涉及所有类型机器学习的理论研究,包括神经网络和深度学习、贝叶斯网络、进化算法、计算机视觉、自然语言处理(NLP)和支持向量机(SVM)等。

在这一章中,我们将涵盖以下主题:

  • 人工智能简介

  • 图像处理库

  • 自然语言处理库

  • 神经网络和深度学习库

  • 通用 AI 库和工具

人工智能简介

在我们继续本章内容之前,我们需要确立一些定义。因为人工智能(AI)是一个如此宽泛的主题,所以界限往往有些模糊,因此我们需要确保我们都在谈论同一件事。

首先,我们将 AI 定义为任何具有类似人类解决问题能力的算法。虽然我承认这个定义非常宽泛,但任何更窄的定义都会排除有效的 AI 策略。AI 是什么,什么不是 AI,这更多是一个哲学问题,而不是技术问题。虽然(几乎)每个人都认为神经网络是 AI,但一旦涉及到像(贝叶斯)决策树这样的算法,就不再人人同意了。

在心中牢记这个宽泛的定义,以下是我们将要介绍的技术和术语列表,以及它们是什么以及它们能做什么的简要说明。

人工智能类型

在 AI 的广泛范围内,我们有两个主要分支,机器学习(ML)和其他。机器学习包括任何可以自我学习的算法。你可能想知道,如果不涉及学习,这还是 AI 吗?这是一个有点哲学性质的问题,但我个人认为,有一些非学习算法仍然可以被认为是 AI,因为它们可以产生类似人类的决策。

在自学习系统中,我们还有进一步的区分,它们有各自的目标和应用:

  • 监督学习

  • 强化学习

  • 无监督学习

使用其中一种方法并不排除使用其他方法,因此许多实际应用都使用多种方法的组合。

非机器学习系统要多样化得多,因为它们几乎可以意味着任何东西,所以这里有一些非学习算法的例子,它们在某些方面可以与人类相媲美:

  • 自然语言处理(NLP):需要注意的是,NLP 本身并不使用机器学习。许多 NLP 算法仍然是手工编写的,因为人类向机器解释某些语法和语义如何以及为什么工作,比让计算机弄清楚人类语言的奇特性和复杂性要容易得多。然而,该领域正在迅速变化,这可能不会持续太久。

  • 专家系统:这是在实践上真正成功的第一种 AI。最早的专家系统是在 1970 年创建的,并且自那时以来一直在使用。这些系统通过向您提出一系列问题,并根据这些问题缩小潜在解决方案/答案的列表来工作。您肯定在某个时候遇到过这些问题,比如在网站上的常见问题解答(FAQ)或拨打帮助台时。这些系统允许捕获专家信息并将其压缩成一个简单的系统,该系统可以做出决策。许多这样的系统(并且至今仍在使用)被用于诊断医疗问题。

在我们继续实际的 AI 实现之前,查看一些作为许多 AI 示例基础的图像处理库是个好主意。

安装包

正如在第十五章中安装科学 Python 库时的情况一样,在本章中直接使用pip安装包在某些情况下可能会有麻烦。使用 Jupyter Docker Stacks 或conda可能更方便。此外,这些项目中的大多数都有针对许多场景的非常详细的安装说明。

对于本章的神经网络部分,最好获取一个包含大多数库的笔记本堆栈。我建议您尝试使用jupyter/tensorflow-notebook堆栈:jupyter-docker-stacks.readthedocs.io/en/latest/using/selecting.html#jupyter-tensorflow-notebook

图像处理

图像处理是许多类型机器学习(如计算机视觉(CV))的基本组成部分,因此我们在这里向您展示一些选项及其可能性是至关重要的。这些选项从仅图像的库到具有完整机器学习功能同时支持图像输入的库。

scikit-image

The scikit-image (skimage) library is part of the scikit project with the main project being scikit-learn (sklearn), covered later in this chapter. It offers a range of functions for reading, processing, transforming, and generating images. The library builds on scipy.ndimage, which provides several image processing options as well.

我们首先需要讨论在这些 Python 库中什么是图像。在scipy(以及随之而来的skimage)的情况下,图像是一个具有 2 个或更多维度的numpy.ndarray对象。约定如下:

  • 2D 灰度:行,列

  • 2D 颜色(例如,RGB):行,列,颜色通道

  • 3D 灰度:平面,行,列

  • 3D 颜色:平面,行,列,颜色通道

然而,所有这些都是约定;你还可以以其他方式塑造你的数组。多通道图像也可以意味着CMYK(青色,品红色,黄色和关键/黑色)颜色而不是RGB(红色,绿色和蓝色),或者完全不同的东西。

自然地,你也可以有更多的维度,例如时间维度(换句话说,视频)。由于数组是常规的numpy数组,你可以通过通常的切片方式来操作它们。

通常你不会直接使用 scikit-image 库进行机器学习,而是在你将图像数据输入机器学习算法之前进行预处理。在许多类型的检测中,例如,颜色并不那么相关,这意味着你可以通过从 RGB 转换为灰度使你的机器学习系统速度提高三倍。此外,通常有快速算法可用于预处理数据,这样你的机器学习系统只需查看图像的相关部分。

安装 scikit-image

The package is easily installable through pip for many platforms; I would suggest installing not just the base package but the optional extras as well, which add extra capabilities to scikit-image, such as parallel processing:

$ pip3 install -U 'scikit-image[optional]' 

边缘检测

让我们看看我们如何显示内置图像之一,并在其上进行一些基本处理:

%matplotlib inline
from skimage import io, data

coins = data.coins()
io.imshow(coins) 

在这种情况下,我们使用skimage附带的数据集coins。它包含一些硬币,我们可以用它来展示skimage的一些优秀功能。首先,让我们看看结果:

图片

图 16.1:scikit-image 硬币

作为我们可以进行何种处理的例子,让我们使用 Canny 边缘检测算法来进行一些边缘检测。这是一个非机器学习算法的典型例子,在将数据输入到机器学习系统之前,它对于预处理你的数据非常有用。为了更好地显示结果,我们首先将图像切割,只显示右上角的三个硬币。在图 16.1中,数字表示xy轴的实际像素索引,这些可以用来估计切割的位置。之后,我们将应用canny()函数来检测边缘:

%matplotlib inline
from matplotlib import pyplot as plt
from skimage import feature

# Get pixels 180 to the end in the X direction
x0, x1 = 180, -1
# Get pixels 0 to 90 in the Y direction 
y0, y1 = 0, 90
# Slice the image so only the top-right three coins are visible
three_coins = coins[y0:y1, x0:x1]
# Apply the canny algorithm
plt.imshow(feature.canny(three_coins), cmap='gray') 

结果显示在下面的图像中,你可以看到我们选择的硬币的自动检测到的边缘:

图片链接

图 16.2:边缘检测后的硬币

scikit-image 可以做更多的事情,但这是一个很好的基本示例,展示了你如何用一行代码进行边缘检测,这可以使你的数据对机器学习系统更有用。

人脸检测

现在,我们将使用来自出色的 scikit-image 文档中的一个示例:scikit-image.org/docs/dev/auto_examples/applications/plot_face_detection.html

这是一个使用预训练模型自动检测人脸的机器学习示例。该特定模型使用多块局部二值模式LBP)。LBP 查看中心点周围的点,并指示这些点是否大于(较亮)或小于(较暗)中心点。多块部分是此方法的可选扩展,并在多个块大小为 9 个相同大小的矩形的块上执行 LBP 算法。第一次迭代可能查看 3x3 像素的正方形;第二次迭代可能查看 6x6;第三次 9x9;依此类推。

该模型使用 OpenCV 级联分类器训练,它可以训练你的模型,生成样本,并运行检测。级联分类器将多个分类器的结果连接起来,形成一个预期比单独的分类器表现更好的组合模型。

为了测试人脸检测,我们将将其应用于 NASA 宇航员 Eileen Collins 的照片。首先,我们将导入库,加载图像,并告诉matplotlib绘制它:

%matplotlib inline
from skimage import data
from skimage.feature import Cascade

# We are using matplotlib directly so we can
# draw on the rendered output
import matplotlib.pyplot as plt
from matplotlib import patches

dpi = 300
color = 'white'
thickness = 1
step_ratio = 1
scale_factor = 1.2
min_object_size = 60, 60
max_object_size = 123, 123

# A photo of Astronaut Eileen Collins
img = data.astronaut()

# Plot the image as high resolution in grayscale
plt.figure(dpi=dpi)
plt.imshow(img.mean(axis=2), cmap='gray') 

观察上面的代码,你可能会注意到一些魔法数字,如scale_factorstep_ratiomin_object_sizemax_object_size。这些参数是你必须根据输入图像进行调整的。这些特定的数字直接来自 OpenCV 文档,但根据你的输入,你可能需要对这些值进行实验,直到它们适合你的场景。

由于这些参数有些任意且依赖于您的输入,因此应用一些自动化来寻找它们是一个好主意。进化算法可以帮助您找到有效的参数。

现在我们已经准备好开始检测并展示我们的发现:

# Load the trained file and initialize the detector cascade
detector = Cascade(data.lbp_frontal_face_cascade_filename())

# Apply the detector to find faces of varying sizes
out = detector.detect_multi_scale(
    img=img, step_ratio=step_ratio, scale_factor=scale_factor,
    min_size=min_object_size, max_size=max_object_size)

img_desc = plt.gca()
for box in out:
    # Draw a rectangle for every detected face
    img_desc.add_patch(patches.Rectangle(
        # Col and row as X and Y respectively
        (box['c'], box['r']), box['width'], box['height'],
        fill=False, color=color, linewidth=thickness)) 

在加载级联后,我们使用detect_multi_scale方法运行模型。该方法在min_sizemax_size之间搜索匹配的对象(人脸),这是必需的,因为我们不知道主题(人脸)的大小。一旦我们找到匹配项,我们就在它们周围画一个矩形来指示它们的位置:

图 16.3:由 scikit-image 检测到的人脸

单独来看,scikit-image 没有很多机器学习功能可用,但与其他库的结合使得这个库在机器学习中非常有用。除了我们上面加载的前脸数据集外,您还可以使用来自 OpenCV 的预训练级联。

OpenCV Git 仓库中提供了几个预训练模型:github.com/opencv/opencv/tree/master/data/lbpcascades

scikit-image 概述

scikit-image 库的功能远不止我们所涵盖的。以下是几个可用子模块的简要概述:

  • 曝光:用于分析和修复照片曝光水平的函数,这在将数据输入到您的 AI 系统之前清理数据时可能至关重要。

  • 特征:如我们之前使用的canny()边缘检测函数之类的特征检测。这允许检测对象、内容块等,以便在 AI 系统之前对输入进行预过滤,从而减少 AI 系统所需的处理时间。

  • 滤波器:图像滤波函数,例如阈值滤波以自动过滤噪声,以及其他许多功能。与曝光函数类似,这些功能在清理过程中非常有用。

  • 形态学:许多功能用于锐化边缘、填充区域、寻找最小/最大值等。

  • 注册:用于计算图像中光流的函数。使用这些函数,您可以估计图像的哪个部分在移动,以及物体移动的速度有多快。给定两个图像,这有助于计算中间图像。

  • 分割:用于分割图像的函数。在上述硬币的例子中,可以提取和/或标记单独的硬币。

如您所见,scikit-image 库提供了丰富的图像操作和处理函数。此外,它还很好地集成到科学 Python 生态系统中。

OpenCV

scikit-image 的“主要竞争对手”是OpenCV开源计算机视觉库)。OpenCV 库是用 C/C++编写的,但为 Python 和 Java 等几种语言提供了绑定。我把“竞争对手”放在引号中是因为这些库不必竞争;如果您愿意,可以轻松地结合两者的优势,我自己在几个项目中就是这样做的。

我们将首先查看如何安装 Python OpenCV 包。

安装 Python OpenCV

opencv-python 包根据您的需求有多种变体。除了主要的 OpenCV 包之外,OpenCV 还有许多“贡献”和“额外”包,这些包非常有用。贡献包主要用于跟随教程和尝试示例,而额外模块包含许多有用的附加算法。

额外模块的列表可以在文档中找到:docs.opencv.org/5.x/

我强烈建议安装额外模块,因为许多非常有用的模块都是额外包的一部分。

如果您在将包安装在将使用 GUI 的桌面机器上,您有以下选项:

  • opencv-python:主要模块,最基本的形式

  • opencv-contrib-python:包括 opencv-python 包中的主要模块,但也包括贡献和额外模块

对于不运行 GUI 的服务器,您有以下选项:

  • opencv-python-headless:除了不包含任何 GUI 输出函数,如 cv2.imshow(),这与 opencv-python 相同

  • opencv-contrib-python-headless:如上所述,这是 opencv-contrib-python 的无头版本

现在我们已经安装了 OpenCV,让我们看看是否可以使用 OpenCV 复制 scikit-image 中的 Canny 边缘检测。

边缘检测

让我们看看如何使用 OpenCV 执行 Canny 算法,类似于我们在之前的 scikit-image 示例中所做的。Canny 算法不是 OpenCV 核心的一部分,因此您需要安装 opencv-contrib-python 包:

$ pip3 install opencv-contrib-python 

我们将使用之前相同的硬币图像:

%matplotlib inline
import cv2
from matplotlib import pyplot as plt
from skimage import data

# Use the coins image from scikit-image
coins = data.coins()

# Get pixels 180 to the end in the X direction
x0, x1 = 180, -1
# Get pixels 0 to 90 in the Y direction 
y0, y1 = 0, 90
# Slice the image so only the top-right three coins are visible
three_coins = coins[y0:y1, x0:x1]
# scikit-image automatically guesses the thresholds, OpenCV does not
threshold_1, threshold_2 = 100, 200
# Apply the canny algorithm
output = cv2.Canny(three_coins, threshold_1, threshold_2)

# OpenCV's imshow() function does not work well with Jupyter so
# we use matplotlib to render to grayscale
plt.imshow(output, cmap='gray') 

初看代码看起来相当相似,但有一些差异。

首先,cv2.Canny() 函数需要两个额外的参数:threshold_1threshold_2,即下限和上限。这些参数决定了什么应该被视为噪声,哪些部分与边缘相关。通过增加或减少这些值,您可以在结果边缘中获得更细的细节,但这样做意味着算法也可能开始错误地将背景渐变检测为边缘,这在输出图像的右上角(图 16.4)已经发生。

虽然您可以选择将这些传递给 scikit-image,但默认情况下,scikit-image 会自动猜测一些合适的参数。使用 OpenCV,您可以轻松地做到这一点,但这不是默认包含的。scikit-image 用于此估计的算法可以在源代码中看到:github.com/scikit-image/scikit-image/blob/main/skimage/feature/_canny.py。其次,OpenCV 对 Jupyter 没有原生支持,所以我们使用 matplotlib 来渲染输出。或者,我们也可以使用 IPython.display 模块来显示图像。

生成的输出类似,然而:

图片路径

图 16.4:OpenCV Canny

为了获得更相似的结果,你甚至可以使用 scikit-image 来渲染 OpenCV 的输出。由于它们都操作在numpy数组上,如果需要,你可以轻松地混合和匹配函数。

目标检测

在 scikit-image 人脸检测示例中,我们实际上使用了一个由 OpenCV 生成的模型,因此我们可以直接使用该模型与opencv-python,只需做一些小的修改:

  • 代替skimage.feature.Cascade(filename),你需要使用cv2.CascadeClassifier(filename)

  • 代替cascade.detect_multi_scale()函数,应调用cascade.detectMultiScale()

这立即展示了 scikit-image 和python-opencv之间的一个区别。其中 scikit-image 使用 Python 在函数名单词之间使用下划线的约定,而opencv-python直接使用从 OpenCV 源中来的 camelCase 函数名。

使用 OpenCV,我们可以轻松地超越我们用于人脸检测的简单级联;这次我们将使用一个DNN深度神经网络)。

我们将要使用的网络被称为YOLOv3你只看一次,版本 3)并且能够检测许多类型的对象,如汽车、动物、水果等。自然地,这个模型也更大。人脸检测模型只有大约 50 KiB,而 YOLOv3 网络几乎大 5000 倍,达到 237 MiB。

在我们开始之前,我们需要下载一些文件以使 YOLO 网络完全可用:

一旦你有了这些文件,我们可以展示 YOLO 网络。首先,我们设置了一些导入和变量,然后加载图像:

%matplotlib inline
import cv2 as cv
import numpy as np
from matplotlib import pyplot as plt
from skimage import data

color = 0xFF, 0xFF, 0xFF  # White
dpi = 300
font = cv.FONT_HERSHEY_SIMPLEX
font_size = 3
image_dims = 320, 320
label_offset = 10, 70
min_score = 0.9
thickness = 2

# Load the astronaut image from scikit-image as before
img = data.astronaut()
# Convert the image into a 4-dimensional blob
# by subtracting the mean and rescaling
blob = cv.dnn.blobFromImage(img, 1 / 255, size=image_dims) 

现在我们已经准备好了导入,并将图像转换成了适合模型的 blob 格式,我们可以加载模型并展示结果:

# Load names of classes so we know what was detected
classes = open('coco.names').read().splitlines()
# Load the deep neural network model and configuration
net = cv.dnn.readNetFromDarknet('yolov3.cfg', 'yolov3.weights')
# Determine the output layer
ln = net.getLayerNames()
ln = [ln[i - 1] for i in net.getUnconnectedOutLayers()]

# Pass the blob to the net and calculate the output blobs
net.setInput(blob)
out = net.forward(ln)
# Loop through all outputs after stacking because
# the net attempts to match multiple sizes
for result in np.vstack(out):
    # x, y, w and h are numbers between 0 and 1 and need to be
    # scaled by the width and height
    result[:4] *= img.shape[1::-1] * 2
    x, y, w, h, *scores = result
    # Search the net for the best match
    match_index = np.argmax(scores)
    # Skip questionable matches
    if scores[match_index] < min_score:
        continue

    # Calculate the top left and bottom right points
    tl = np.array([x - w / 2, y - h / 2], dtype=int)
    br = np.array([x + w / 2, y + h / 2], dtype=int)
    cv.rectangle(img, tl, br, color, thickness)
    # Calculate the point to place the text
    cv.putText(img, classes[match_index], tl + label_offset,
               font, font_size, color, thickness)
    # Stop after the first match to prevent overlapping results
    break

plt.figure(dpi=dpi)
plt.imshow(img.mean(axis=2), cmap='gray') 

为了简洁,这个例子非常精简,但它展示了你如何在几行代码中完成像目标检测这样高级的操作。如果我们看输出,深度神经网络正确地将宇航员识别为一个人:

图片路径

图 16.5:宇航员上的目标检测

我强烈建议你尝试使用不同的图像自己尝试 YOLOv3 网络。对于一张老街道的图片,我得到了以下结果:

图片路径

图 16.6:在带有汽车的街道图像上应用 YOLOv3

难道不令人惊叹,如今进行目标检测是多么容易,而且效果多么好?如果您仔细观察图像,可能会注意到它甚至能够检测到部分遮挡的汽车和人。当然,训练一个新的深度神经网络及其研究是另一个完全不同的问题,但至少应用这些网络已经变得像玩一样简单,它们在不到一秒内就能执行完毕,包括网络的加载。

机会当然不止于此,如果您愿意,甚至可以使用这些技术来对视频流进行实时分析。OpenCV 库确实是一款令人印象深刻的软件。

OpenCV 与 scikit-image

scikit-image 和 OpenCV 各自都有相对于对方的优点。然而,这并不是您必须做出选择的情况;您可以轻松同时使用两者。

在我看来,OpenCV 相对于 scikit-image 有三个主要优势:

  • OpenCV 原生支持使用 GPU 进行处理。

  • 由于它是用 C++实现的,因此您可以在线程中执行并行处理,而无需担心全局解释器锁(GIL)。

  • OpenCV 的功能甚至比 scikit-image 更多。

当然,scikit-image 也有一些优势:

  • scikit-image 是用 Python 编写的,因此可以直接从您的编辑器中查看(或修改)算法,非常方便。

  • scikit-image 专注于 Python,因此命名约定感觉更自然。

  • 由于 scikit-image 仅适用于 Python,因此所有文档都立即相关。对于 OpenCV,您在网上(以及文档中)找到的许多示例都是关于 C++接口的,这略有不同。

如果您需要为视频流的实时处理提供高性能,那么 OpenCV 将是我的首选推荐,因为它有几个内置方法可以使这项任务变得更容易。如果您只需要读取和修改一些图像,并且可以使用 scikit-image,那么这将是我的首选推荐。

在任何情况下,这两个库都非常出色,我可以自信地推荐两者。如果您的需求跨越两者,请使用两者。

现在终于到了讨论人工智能库本身的时刻。

自然语言处理

NLP是解析文本并理解其意义的过程。这可以用来从文本片段中提取知识,理解文本之间的差异,等等。

有几个专为这个目的而开发的库,它们工作得相当不错。此外,还有托管预训练网络可供使用,例如GPT-3网络,可以通过 OpenAI API 访问。

这个网络可以生成如此高质量的文本,以至于它通常与人类生成的文本难以区分。

NLTK – 自然语言工具包

NLTK 并非像这里的大多数其他库那样是一个独立的机器学习库,但它却是许多自然语言处理库的基础。NLTK 项目始于 2001 年,旨在理解自然语言,并且绝对值得在这个列表中占有一席之地。

该项目附带了一个大量语料库和多种不同语言的预训练模型的集合。

语料库是大量结构化文本的集合,可用于训练和测试模型。

使用这些语料库和模型,它可以进行情感分析,对文本进行分词以找到相关关键词,等等。

首先,我们需要安装nltk

$ pip3 install nltk 

作为基本示例,让我们使用预训练的情感分析能力来看看一个句子的积极或消极程度:

>>> import nltk
>>> from nltk import sentiment

>>> nltk.download('vader_lexicon')
True

>>> sentences = [
...     'Python is a wonderful programming language',
...     'Weak-typed languages are prone to errors',
...     'I love programming in Python and I hate YAML',
... ]

>>> si = sentiment.SentimentIntensityAnalyzer()
>>> for sentence in sentences:
...     scores = si.polarity_scores(sentence)
...     print(sentence)
...     print('negative: {neg}, positive: {pos}'.format(**scores))
Python is a wonderful programming language
negative: 0.0, positive: 0.481
Weak-typed languages are prone to errors
negative: 0.324, positive: 0.0
I love programming in Python and I hate YAML
negative: 0.287, positive: 0.326 

我们首先下载用于情感分析的预训练模型。之后,我们可以使用SentimentIntensityAnalyzer来检测一个句子是负面的、中性的、积极的,还是它们的组合。

该库可以做更多的事情,但这已经为你提供了一个很好的起点。如果你需要任何基本的人类输入解析,请确保尝试一下,因为它提供了非常令人印象深刻的成果。

spaCy – 使用 Cython 进行自然语言处理

spaCy 库是一个非常令人印象深刻且速度极快的自然语言处理库。它包含 60 多种语言的预训练神经网络模型,并在文本分类和命名实体识别方面做得非常好。

文档非常出色,并且虽然它是完全开源的,但它是由公司 Explosion 开发的,该公司在跟上自然语言处理领域的最新发展方面做得非常好。如果你想对文本有一个高级的理解,这个库是你的最佳选择之一。如果你只需要基本的文本分词,那么我仍然会推荐NLTK,因为它更快更有效。

在我们继续示例之前,我们需要安装 spaCy 并下载模型:

$ pip3 install spacy
$ python3 -m spacy download en_core_web_sm 

en_core_web_sm 数据集是一个小型且快速的英语数据集。如果你需要一个更全面的数据集,你可以下载en_core_web_trf

要安装不同的语言,我建议你访问 spaCy 网站:spacy.io/usage#quickstart。例如,荷兰数据集被称为nl_core_news_sm,而不是你可能预期的nl_core_web_sm

现在我们已经处理好了这些,让我们尝试从一个句子中提取一些信息:

>>> import spacy
>>> import en_core_web_sm

>>> nlp = en_core_web_sm.load()
>>> _ = nlp.add_pipe("merge_entities")

>>> sentence = ('Python was introduced in 1989 by Guido van '
... 'Rossum at Stichting Mathematisch Centrum in Amsterdam.')

>>> for token in nlp(sentence):
...     if token.ent_type_:
...         print(f'{token.ent_type_}: {token.text}')
DATE: 1989
PERSON: Guido van Rossum
ORG: Stichting Mathematisch Centrum
GPE: Amsterdam 

在加载spacyen_core_web_sm模型后,我们添加了merge_entities管道。这个管道会自动将标记合并在一起,因此我们得到的是"Guido van Rossum"而不是"Guido""van""Rossum"作为单独的标记。

这难道不是一个令人惊叹的结果吗?它自动理解"Guido van Rossum"是一个人,"Stichting Mathematisch Centrum"是一个组织,而"Amsterdam"是一个地缘政治实体。

Gensim – 为人类进行主题建模

Gensim 库(radimrehurek.com/gensim/)为你处理 NLP。它与 NLTK 类似,但更专注于现代机器学习库。它有很好的文档,易于使用,可以用来计算文本之间的相似性,分析文本的主题,等等。虽然 NLTK 和 Gensim 之间有很大的重叠,但我认为 Gensim 是一个稍微高级一些的库,更容易入门。另一方面,NLTK 已经存在了 20 多年,由于这个原因,野外有大量的文档可用。

机器学习

机器学习是人工智能的一个分支,它可以自我学习。这可以是完全自主的学习,基于预标记数据的学习,或者这两种学习的组合。

在我们深入探讨这个主题的库和示例之前,我们需要一点背景信息。如果你已经熟悉机器学习的类型,可以自由地跳过这一节,直接查看库。

机器学习的类型

正如我们在简介中简要提到的,机器学习大致分为三种不同的方法,但通常使用几种方法的组合。为了回顾,我们有以下三个主要分支:

  • 监督学习

  • 强化学习

  • 无监督学习

自然地,这些有很多组合,所以我们将讨论一些基于上述分支的重要的、独特的学习类型。这些名称本身应该已经给你一些关于它们如何工作的提示,但我们将更深入地探讨。

监督学习

在监督学习的情况下,我们向系统提供大量的标记数据,以便机器可以学习输入数据和标签之间的关系。一旦它在这些数据上训练完毕,我们就可以使用新数据来测试它是否有效。如果结果不符合预期,就会调整参数或中间训练步骤,直到结果改善。

这些例子包括:

  • 分类模型,这些模型在大量照片上训练以识别照片中的对象。或者回答像:“我们是在看一只鸟吗?”这样的问题。

  • 文本情感分析。发信息的人是快乐的、悲伤的、敌对的,等等吗?

  • 天气预报。由于我们有大量的历史天气数据可用,这是一个监督学习的完美案例。

如果你已经有了数据,这可能是你的最佳选择。然而,在许多情况下,你可能没有数据,或者你有数据但没有高质量标签。这就是其他学习方法发挥作用的地方。

强化学习

强化学习与监督学习类似,但它不是使用标记的输入/输出对,而是使用评分奖励函数来提供反馈。在强化学习中需要调整的参数是是否重新使用现有知识或探索新的解决方案。过度依赖重新使用现有知识会导致“局部最优”,因为你将永远无法获得最佳(甚至良好的)结果,因为你会卡在你之前找到的解决方案上。然而,过度依赖对新解决方案的探索/研究,则会导致永远无法达到最优解。

这些例子包括:

  • 为游戏创建求解器/玩家。对于像围棋或象棋这样的游戏,你可以使用胜负作为评分函数。对于像乒乓球或俄罗斯方块这样的游戏,你可以使用得分作为奖励。

  • 机器人导航系统。作为一个评分系统,你可以使用“从起点移动的距离”加上“没有撞到墙”。

  • 群体智能。这些是许多(一群)独立、自我组织的系统,需要达到一个共同的目标。例如,一些在线超市使用机器人群体以这种方法自动抓取和包装杂货。群体智能负责避免碰撞和自动更换损坏的机器人。

强化学习是监督学习之后的最佳选择,因为它不需要大量高质量的数据。尽管如此,你可以很好地结合这些方法。创建一个好的评分函数可能很困难,你可以通过在已知良好数据上测试它来轻松验证你的函数。

无监督学习

仅从名称上看,你可能会对无监督学习感到困惑。

最后,如果一个无监督系统不知道何时达到有用的解决方案,它将如何工作呢?关键是,在无监督学习中,你不知道最终解决方案会是什么样子,但你可以说出解决方案可能会是什么样子。

由于无监督学习的解释有些模糊,我希望一些例子能有所帮助:

  • 聚类算法。在聚类中,你向算法提供包含许多变量(例如,在人的情况下,体重、身高、性别等)的数据,并告诉算法找到聚类。

  • 异常检测。这也是无监督学习可以真正大放异彩的领域。在异常检测中,你永远不知道你真正在寻找什么,但任何异常的图案都可能很重要。

无监督学习与其他两种我们之前介绍过的机器学习方法有很大不同,因为它通常没有已知的靶点。然而,这并不意味着它毫无用处。在看似随机的数据中寻找模式,在许多方面都可以非常有用,比如在正常运行/稳定性监控或电子商务网站的访客分析中。

现在是时候看看之前方法的组合了。

学习方法的组合

人工智能的发展正如火如荼,我预计这个领域在可预见的未来将继续增长。这就是为什么越来越多的算法变体正在被使用,这导致这些明确的定义变得更加灵活。

在某些情况下,例如,通过结合监督学习和强化学习,可以得到比单独使用这些方法更好的结果。这就是为什么所有这些方法之间的界限可能非常模糊,如果一种方法适用于你的目标,那么将它们结合起来并不是错误的。

深度学习

机器学习中最有效的例子之一是深度学习。这种机器学习类型在过去的几年中变得极其流行,因为它已被证明是实际应用中最有效的神经网络类型之一,在某些情况下甚至超过了人类专家。

这种网络被称为深度网络,因为神经网络具有多个(通常是许多)隐藏的内部层,而传统的神经网络通常只有一层或几层隐藏层。

此外,它只是一个普通的神经网络,可以是监督学习、无监督学习、强化学习或介于两者之间的任何一种。

人工神经网络和深度学习

当人们思考人工智能时,大多数人会立即想到人工神经网络ANNs)。这些网络试图通过拥有类似突触的人工神经元和它们之间的连接来模仿动物大脑的工作方式。

然而,有几个关键的区别。在动物大脑中,一个神经元可以同时作为输入和输出,而在人工神经网络(ANN)中,通常有一个输入层的输入神经元集合,一个输出层的神经元集合,以及处理中间层的中间层(s)。

目前(2021 年;它于 2020 年 6 月推出)最令人印象深刻的 ANN 是 GPT-3 网络,它被用于 NLP 训练。它拥有惊人的 1750 亿个机器学习参数,在某些情况下,它生成的文本与人类撰写的文本难以区分。

然而,这篇文本很快就会过时。GPT-3 网络已经比 2019 年发布的 GPT-2 大 100 倍,而 GPT-4 已经被宣布,预计比 GPT-3 大 500 倍。

应该注意的是,尽管 ANN(尤其是深度学习)网络非常强大并且可以自我学习,但其中许多都是静态的。一旦它们被训练过,它们就不会再改进或更新。

本节中的库是为了构建神经网络和实现深度学习而设计的。由于这是一个在人工智能中完全独立的领域,它确实值得拥有自己的部分。请注意,当然,如果需要,你仍然可以混合和匹配人工智能策略。

在 Python 中,有多个用于创建神经网络的库,但最大的两个库无疑是PyTorchTensorFlow/Keras。直到几年前,还有一个具有类似功能的大型库,名为 Theano。该库已被停止使用,并以新名称 Aesara 进行分支。这两个库现在都不太常用,但 Theano 被认为是原始的 Python 神经网络库。TensorFlow 库实际上是创建来替代 Google 中的 Theano 的。

张量

ANN 的基础是张量。张量是数据的数学表示,其中包含了可以应用于这些数据的有效变换的描述。当然,实际情况要复杂得多,但在这里讨论的目的上,你可以将张量视为与我们在上一章中看到的numpy.ndarray对象非常相似的多维数组。

当人们谈论零维或 0D 张量时,他们实际上是在谈论一个单独的数字。从那开始,一维张量是一个数组或向量,二维张量是一个矩阵。

目前最大的收获是,常规数字/数组/矩阵与张量之间的区别在于,张量指定了对其有效的变换。这基本上是list()与包含list()数据的自定义class之间的区别,该class还具有额外的属性。

PyTorch – 快速(深度)神经网络

PyTorch 是由 Facebook 开发的库,专注于使用张量构建神经网络,如深度学习网络。

PyTorch 中的张量使用自定义数据结构(而不是numpy.ndarray)以提高性能。PyTorch 库在性能优化方面做了大量工作,并内置了对 GPU 加速的支持,以进一步加快速度。

在许多情况下,你可以使用torch.Tensor作为numpy.ndarray的替代品来启用 GPU 加速。torch.Tensor API 与numpy.ndarray API 在很大程度上是相同的。

PyTorch(除了性能之外)的真正优势是包含了许多用于不同类型输入的实用库。你可以轻松使用这些 API 处理图像、视频、音频和文本,并且大多数过程都可以以分布式方式并行运行。

下面是一个关于最有用模块的简要概述:

  • torch.distributed:用于在单个系统或多个系统中的多个 GPU 上并行训练。

  • torchaudio:用于处理音频,无论是来自预先录制的文件还是直接来自(多个)麦克风。

  • torchtext:用于处理文本;你还可以将其与 NLP 库如 NLTK 结合使用。

  • torchvision:用于处理图像和图像序列(视频)。

  • torchserve:用于设置一个服务器,托管你的模型,这样你可以构建一个运行计算的服务。这很有用,因为启动进程和加载模型可能是一个缓慢且繁重的任务。

  • torch.utils:包含许多有用的实用函数,但最重要的是 TensorBoard。使用 TensorBoard,您可以通过网络界面交互式地检查您的模型并对模型参数进行更改。

是时候做一个小的例子了,但在我们开始之前,我们需要安装 pytorchtorchvision

$ pip3 install torch torchvision 

我们将使用预训练的 Mask R-CNN 模型进行对象识别。这是一个基于 区域 的卷积神经网络(R-CNN),它使用图像和标记的图像掩码(对象轮廓)的组合进行训练。

卷积神经网络(CNN)非常适合视觉应用,如图像分类和图像分割。它们还可以应用于其他类型的问题,如自然语言处理(NLP)。

R-CNN 是 CNN 的一个专门版本,专门用于计算机视觉任务,如目标检测。R-CNN 任务通过在一系列图像中指定 感兴趣区域(ROI)进行训练。Mask R-CNN 是一个专门化版本,它将 ROI 不是指定为矩形,而是指定为仅突出显示特定对象的掩码。

现在,我们将使用 PyTorch 进行一些对象识别。首先,我们加载照片并导入,将照片转换为张量:

%matplotlib inline
from PIL import Image
from matplotlib import pyplot as plt, patches
from torchvision import transforms
from torchvision.models import detection

dpi = 300
font_size = 14
color = 'white'
min_score = 0.8
min_size = 100
label_offset = 25, -25

# Load the img and convert it to a PyTorch Tensor
img = Image.open('amsterdam-street.jpg')
img_t = transforms.ToTensor()(img) 

将转换为张量的操作可以使用 ToTensor 转换操作完成。torchvision.transforms 模块提供了更多操作,例如调整大小、裁剪和颜色归一化,以便在我们将图像发送到模型之前预先过滤图像。

接下来是加载模型和标签:

# Read the labels from coco_labels. The entire COCO
# (Common Objects in COntext) dataset is available at:
# https://cocodataset.org/#download
labels = open('coco_labels.txt').read().splitlines()

# Load the R-CNN model and set it to eval mode for execution
model = detection.fasterrcnn_resnet50_fpn(pretrained=True)
model.eval()
# Apply the model to the img as a list and unpack after applying
out, = model([img_t]) 

标签文件可在本书的 GitHub 页面上找到。

如您所见,模型本身是捆绑在 PyTorch 中的。在加载模型并将其设置为 eval 模式(与训练模式相反)后,我们可以快速将模型应用于我们的图像。不幸的是,标签并没有捆绑在一起,因此我们需要自己获取这些标签。现在我们需要显示结果:

results = zip(out['boxes'].detach(), out['labels'], out['scores'])

# Increase the DPI to get a larger output image
plt.figure(dpi=dpi)
img_desc = plt.subplot()
# Walk through the list of detections and print the results
for (t, l, b, r), label_idx, score in results:
    # Skip objects that are questionable matches
    if score < min_score:
        continue

    # Skip tiny matches
    h, w = b - t, r - l,
    if w < min_size or h < min_size:
        continue

    # Draw the bounding box and label
    img_desc.add_patch(patches.Rectangle(
        (t, l), h, w, fill=False, color=color))
    label = f'{labels[label_idx]} {score * 100:.0f}%'
    img_desc.text(
        t + label_offset[0], r + label_offset[1], label,
        fontsize=font_size, color=color)

# Output the img as grayscale for print purposes
plt.imshow(img.convert('L'), cmap='gray')
plt.show() 

我们可以显示匹配项及其边界框,得到以下结果:

图片

图 16.7:阿姆斯特丹的街道,由 PyTorch 标注的对象

只需几行代码,我们就成功地创建了一个对象识别器,它可以正确地识别几辆汽车、自行车和一艘船。

在实践中,模型实际上在图像中识别了更多的对象,但我们过滤掉了小的匹配项,以便图像不会太杂乱。实际上,它还识别了七辆汽车、四个人和两艘船。

PyTorch Lightning 和 PyTorch Ignite – 高级 PyTorch API

PyTorch Lightning 和 PyTorch Ignite 库是方便的快捷方式,可以以更少的步骤和内置的几个有用功能来启动和运行您的网络。您可以直接使用 PyTorch 做同样的事情,但使用实用函数,您可以一次运行多个 PyTorch 步骤,这意味着在工作的过程中重复较少。

这些库是独立创建的,但大致上服务于相同的目标,并且在功能上可以比较。这取决于您的个人喜好,至于哪个最适合您。我最初建议您直接从 PyTorch 开始。虽然这些库非常出色,但在开始使用您可能不完全理解的快捷方式之前,理解底层原理是很重要的。PyTorch 文档很容易遵循,在操作上与 PyTorch Ignite 和 PyTorch Lightning 大致相同,除了稍微冗长一些。

Skorch – 混合 PyTorch 和 scikit-learn

如前所述,scikit-learn 本地支持神经网络,但其性能对于大规模网络来说还不够好。Skorch 库负责解决这个问题;如果您熟悉 scikit-learn API,您仍然可以使用它,但它内部运行在 PyTorch 上以实现出色的性能。

TensorFlow/Keras – 快速(深度)神经网络

TensorFlow 库由 Google 开发,专注于构建与 PyTorch 非常相似的深度神经网络。该库文档齐全,提供了大量可用的预训练模型;您可能永远不需要训练自己的模型,这可以是一个很大的优势。

与 PyTorch 类似,TensorFlow 也基于张量进行实际计算,并且它在许多平台上高度优化以实现性能,包括用于部署的手机、专门的 张量处理单元TPU)或用于训练模型的 GPU 硬件。

例如,我们将再次运行之前与 PyTorch 一起使用的 Mask R-CNN。由于此模型未与 tensorflow 一起打包,我们需要在安装 tensorflow 的同时安装 tensorflow-hub

$ pip3 install tensorflow tensorflow-hub 

如果您的平台支持,这将自动安装带有 GPU 支持的 tensorflow。目前,这意味着 Windows 或 Ubuntu Linux。现在我们可以测试一些 TensorFlow/Keras 代码。首先,我们导入所需的库,设置一些变量,并加载图像:

%matplotlib inline
import numpy as np
import tensorflow_hub as hub
from keras.preprocessing import image
from matplotlib import pyplot as plt, patches

dpi = 300
font_size = 14
color = 'white'
min_score = 0.8
min_size = 100
label_offset = 25, -25

# Load the img and convert it to a numpy array
img = image.load_img('amsterdam-street.jpg')
img_t = image.img_to_array(img)
img_w, img_h = img.size 

现在图像已加载,让我们使用 tensorflow_hub 加载模型并在我们的图像上应用它:

labels = open('coco_labels.txt').read().splitlines()
model = hub.load(    'https://tfhub.dev/tensorflow/mask_rcnn/inception_resnet_v2_1024x1024/1')
out = model(np.array([img_t]))

# The box coordinates are normalized to [0, 1]
img_dim = np.array([img.size[1], img.size[0]] * 2)
result = zip(
    out['detection_boxes'][0] * img_dim,
    out['detection_classes'][0],
    out['detection_scores'][0],
) 

再次强调,我们没有可用的标签,因此我们从 coco_labels.txt 文件中读取它。然而,一旦我们加载了模型,我们就可以轻松地将它应用于我们的图像。

现在我们需要准备结果以便于处理,并将它们显示出来:

# Increase the DPI to get a larger output image
plt.figure(dpi=dpi)
img_desc = plt.subplot()

# Walk through the list of detections and print the results
for (l, t, r, b), label_idx, score in result:
    label_idx = int(label_idx)
    # Skip objects that are questionable matches
    if score < min_score:
        continue

    # Skip tiny matches
    h, w = b - t, r - l,
    if w < min_size or h < min_size:
        continue

    # Draw the bounding box and label
    img_desc.add_patch(patches.Rectangle(
        (t, l), h, w, fill=False, color=color))
    label = f'{labels[label_idx]} {score * 100:.0f}%'
    img_desc.text(
        t + label_offset[0], r + label_offset[1], label,
        fontsize=font_size, color=color)

# Output the img as a large grayscale for print purposes
plt.imshow(img.convert('L'), cmap='gray') 

由于它使用了相同的预训练模型,代码在很大程度上与 PyTorch 代码相似。值得注意的是差异:

  • 我们使用 tensorflow_hub 加载了模型。这会自动从 tfhub.dev/ 下载并执行预训练模型。

  • 矩形框的坐标是从 0 到 1,而不是相对于图像大小。因此,在 20x20 图像中的 10x5 坐标将导致 0.5x0.25

  • 输出变量名不同。应该注意的是,这些取决于模型,可以在 TensorFlow Hub 上找到此模型的这些变量名:tfhub.dev/tensorflow/mask_rcnn/inception_resnet_v2_1024x1024/1

  • 箱子点使用左、上、右、下的顺序,而不是 PyTorch 中的上、左、下、右。

除了这些小的变化之外,代码实际上是相同的。

NumPy 兼容性

TensorFlow 中的实际张量对象与 PyTorch 张量略有不同。虽然 pytorch.Tensor API 可以用作 numpy.ndarray 的替代品,但 tensorflow.Tensor 的 API 略有不同。

有一个 tensorflow.Tensor.numpy() 方法,它返回数据的 numpy.ndarray。需要注意的是,这不是一个引用;修改 numpy 数组不会更新原始张量,因此你需要在修改后将其转换回来。

作为替代方案,TensorFlow 提供了一个实验性的 numpy API,如果你更喜欢这个 API,可以像这样启用:

>>> import tensorflow.experimental.numpy as tnp

>>> tnp.experimental_enable_numpy_behavior() 

使用相当简单,但绝不是完全与 numpy.ndarray 兼容:

>>> x = tnp.random.random([5])
>>> x[:5] += 10
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tensorflow.python.framework.ops.EagerTensor' object does not support item assignment 

Keras

TensorFlow 的 Keras 子模块类似于 PyTorch 的 PyTorch Lightning 和 PyTorch Ignite。它为 TensorFlow 提供了一个高级接口,使其更容易使用和入门。与上述 PyTorch 库相反,Keras 作为起点也非常合适。了解底层 TensorFlow 函数可能很有用,但不是使用 Keras 有效的必要条件。

如果你刚开始使用 TensorFlow,并想在项目中应用一些机器学习而不深入到兔子洞中,Keras 可能适合你。

TensorFlow 与 PyTorch 的比较

与 PyTorch 相比,TensorFlow 有一些优缺点,所以在继续之前,让我们列出这些优缺点。

这里有一些原因,你可能选择 TensorFlow 而不是 PyTorch:

  • TensorFlow 支持在网页浏览器中执行预训练模型。虽然 PyTorch 也有一些库可以做到这一点,但它们要么过时,要么在功能和稳定性方面远远落后于 TensorFlow。

  • TensorFlow 大部分是语言无关的。这意味着它为多种语言提供了绑定,而 PyTorch 主要只支持 Python。

  • TensorFlow,或者更具体地说,Keras,是一个非常高级的 API,它允许你快速入门。当比较 Keras 与 PyTorch Lightning/PyTorch Ignite 时,我个人觉得你可以用 TensorFlow 更快地得到一个可工作的结果。Keras 包含了许多实用函数和类,可以在创建模型时节省你一些工作。另一个大帮助是 TensorFlow Hub,它提供了许多预训练模型和示例代码,方便你使用。

  • TensorFlow 有一个稍微大一点的社区和更多的教程可用。

相反:

  • PyTorch 是围绕 Python 编写的,并且有一个更加 Pythonic 的 API。

  • PyTorch 提供了更细粒度的控制,并且可以轻松地给你许多可调整的参数。

  • 虽然这是一个个人观点,但我发现调试 PyTorch(至少从 Python 来看)比 TensorFlow 或 Keras 要好得多,因为代码库层次较少,看起来不那么复杂。使用常规 Python 调试器逐步执行你的模型执行效果很好,并且很容易在 PyTorch 中跟踪。在我的经验中,常规 Python 调试器根本无法与 TensorFlow 一起工作。

  • PyTorch 比 TensorFlow 快一点。这在开发和调试时可以提供巨大的帮助。

你应该使用哪个库取决于个人偏好以及你和你团队的其他成员的现有经验等因素。我当然可以推荐这两个库。

进化算法

进化算法是一种基于自然界进化的技术,通过使用适应度函数来确定质量,并通过进化解决方案来改进。

最常见的实现是遗传算法,它通常将解决方案或染色体编码成一个字符串或数组,可以通过适应度函数进行测试。这个染色体可以是一系列要应用的函数,一个函数的参数列表,或者完全是其他东西。你希望如何编码染色体完全取决于你,只要适应度函数可以使用它来计算适应度分数。

遗传算法将采用以下操作之一来尝试提高适应度分数:

  • 变异(Mutation):这可能是一个从 0 到 1 的位翻转,或者是一个更复杂的变异,即替换多个位。例如,如果我们有位串0101,那么一个变异可能导致0111

  • 选择(Selection):给定使用适应度函数测试的一组不同的染色体,只保留最好的几个。

  • 交叉(Crossover):给定几个不同的染色体,将它们的部分组合起来尝试新的解决方案。例如,如果我们有两个字符串,AABB 和 DEFG,交叉可以分割它们并组合它们;例如,你可以得到 AAFG,它结合了第一个字符串的前两个字符和第二个字符串的最后两个字符。

遗传算法需要一些参数来控制在给定运行中采用哪种策略。变异率设置变异发生的概率;精英主义参数决定在选择过程中保留多少结果;交叉率设置交叉发生的概率。困难的部分是调整这些参数以返回一个好的和稳定的解决方案(换句话说,一个在运行之间变化不大的解决方案),但不要陷入局部最优,在那里你的解决方案看起来是最好的,但通过尝试更多的遗传多样性可能会更好。

在许多应用场景中,遗传算法(或更普遍的遗传编程)是获取你问题良好解决方案的最可行选项之一。遗传算法表现卓越的一个主要例子是旅行商问题TSP)。在 TSP 中,你有一串想要访问的城市,你需要找到覆盖所有城市的最短路线。标准的暴力解决方案的时间复杂度为O(n!),这意味着对于 10 个城市,你需要大约 3,628,800 步来计算。这确实很多,但仍然容易管理。然而,对于 20 个城市,数字增长到 2,432,902,008,176,640,000,或者说 2 千万亿(2 亿亿),而且这种增长非常迅速。使用遗传编程,适应性问题的解决方案将几乎立即消除那些完全不可行的解决方案空间的部分,并相对快速地给出一个良好(但可能不是最佳)的解决方案。

尽管进化算法提供了很多功能,但实现它们相对容易,并且通常非常具体于你的特定用例。这使得在这种情况下,应用程序和库通常会选择编写自己的实现,而不是使用库来实现这个目标。

尽管如此,至少有一个值得注意的 Python 遗传算法库。PyGAD 库可以让你轻松地在项目中使用遗传算法。它还内置了对 Keras 和 PyTorch 的支持,以节省你一些工作。

让我们从安装 PyGAD 开始:

$ pip3 install pygad 

现在,我们将尝试解决你可能在现实世界中遇到的问题。假设你需要一个新的地板,并且你想要木地板。由于批量折扣,购买一大堆板子可能比购买几块单独的板子更便宜,所以让我们假设我们有几种不同的批量数量,并让我们的算法优化成本。首先,我们需要定义我们的批量大小列表及其价格。我们还将定义我们正在寻找的板子数量。最后,我们将定义适应度函数,告诉 PyGAD 解决方案有多好(或有多坏):

import numpy as np
import pygad
# Combination of number of boards with the prices per board
stack_prices = np.array(
    [
        [1, 10],  # $10 per board
        [5, 5 * 9],  # $9 per board
        [10, 10 * 8],  # $8 per board
        [25, 25 * 7],  # $7 per board
    ]
)

# The minimum number of boards to buy
desired_boards = 67

def fitness_function(solution: numpy.ndarray, solution_index):
    # We can't have a negative number of boards
    if (solution < 0).any():
        return float('-inf')

    # Make sure we have the minimum number of boards required
    total_area = stack_prices[:, 0] * solution
    if total_area.sum() < desired_boards:
        return float('-inf')

    # Calculate the price of the solution
    price = stack_prices[:, 1] * solution
    # The fitness function maximizes so invert the price
    return - price.sum() 

PyGAD 的适应度函数优化的是最高值;由于我们寻找的是最低价格,我们可以简单地反转价格。此外,当我们想要排除“不良”解决方案时,我们可以返回负无穷大。

为了获取一些中间结果,我们可以选择添加一个函数,它将在每一代显示状态:

def print_status(instance):
    # Only print the status every 100 iterations
    if instance.generations_completed % 100:
        return

    total = 0
    solution = instance.best_solution()[0]
    # Print the generation, bulk size, and the total price
    print(f'Generation {instance.generations_completed}', end=' ')
    for mp, (boards, price) in zip(solution, stack_prices):
        print(f'{mp:2d}x{boards},', end='')
        total += mp * price
    print(f' price: ${total}') 

现在是运行算法并显示输出的时间:

ga_instance = pygad.GA(
    num_generations=1000,
    num_parents_mating=10,
    # Every generation will have 100 solutions
    sol_per_pop=100,
    # We use 1 gene per stack size
    num_genes=stack_prices.shape[0],
    fitness_func=fitness_function,
    on_generation=print_status,
    # We can't buy half a board, so use integers
    gene_type=int,
    # Limit the solution space to our maximum number of boards
    gene_space=numpy.arange(desired_boards),
    # Limit how large the change in a mutation can be
    random_mutation_min_val=-2,
    random_mutation_max_val=2,
    # Disable crossover since it does not make sense in this case
    crossover_probability=0,
    # Set the number of genes that are allowed to mutate at once
    mutation_num_genes=stack_prices.shape[0] // 2,
)

ga_instance.run()
ga_instance.plot_fitness() 

这将为我们运行 1000 代,每代 100 个解决方案。单个解决方案包含每个堆叠大小需要购买的木材堆的数量。当我们运行这段代码时,我们应该得到类似以下的结果:

$ python3 T_02_pygad.py
Generation 100  3x1, 1x5, 6x10, 0x25, price: $555
Generation 200  3x1, 0x5, 4x10, 1x25, price: $525
...
Generation 900  2x1, 1x5, 1x10, 2x25, price: $495
Generation 1000  2x1, 1x5, 1x10, 2x25, price: $495 

我们的结果图:

图片

图 16.8:遗传算法适应度结果图

在这种情况下,495 实际上是最佳结果;然而,在大多数情况下,你不知道你是否已经达到了最佳结果。这本质上意味着你可以让你的代码永远运行,这就是为什么你应该配置一个固定的代数数量,或者告诉 PyGAD 在达到一定代数的稳定状态后停止。

然而,更重要的是,在大约 50 代之后,我们已经为我们的问题找到了一个非常出色且非常实用的解决方案,而最佳解决方案在这个运行中大约需要 700 代。在许多其他运行中,它甚至从未找到最佳解决方案。这表明遗传算法可以多么快速地给你一个有用的结果。

支持向量机

支持向量机SVMs)或支持向量网络是监督学习的常见模型。由于它是一种监督学习方法,它期望有一个已经标记的数据集(例如,带有正确标签的图片列表)来训练。一旦模型被训练,就可以用于分类和回归分析。

在统计学中,回归分析是展示变量之间关系的一种方法。这些可以用来拟合线、创建预测器、检测异常值等。我们也在第十五章科学 Python 和绘图中看到了几个回归分析的例子。

分类指的是统计分类,是一种分割数据的方法。例如,关于一封邮件是否为垃圾邮件的问题就是一种二元分类。

贝叶斯网络

贝叶斯网络基于这样的想法:我们有事件发生的概率。这通常表示为P(event),其中P(event)=1表示事件发生的 100%概率,而P(event)=0则表示完全没有概率。

这些可以用于各种应用,尤其是在专家系统中特别有用,因为它们可以根据你提供的数据做出推荐。例如,既然外面有雷暴,我们就知道下雨的概率比外面晴朗时要大。用贝叶斯术语来说,我们会这样描述:

P(rain) = The probability of rain
P(thunderstorm) = The probability of a thunderstorm
P(rain | thunderstorm) = The probability of rain given that there is a thunderstorm 

贝叶斯网络常用于查找特定关键词并计算邮件是否为垃圾邮件的垃圾邮件过滤器。贝叶斯网络的另一个可能用例是在打字时的文本预测。如果你用许多句子训练你的网络,你可以根据前面的单词或单词计算下一个最可能出现的单词。

正如你所见,有各种各样的机器学习模型,以及更多具有各自优缺点的子模型。这个例子列表是一个非常浓缩和简化的可用模型列表,但它应该至少给你一些关于这些不同算法可以在哪些场景下施展魔力的想法。

多功能 AI 库和工具

当涉及到开发 AI 系统时,Python 无疑是最受欢迎的语言。这种受欢迎的结果是,对于您能想到的 AI 的每一个分支,都有大量的库可供选择。几乎每种 AI 技术都至少有一个好的库,而且通常有几十个。

在本章的这一节中,您将找到一个经过精选(但不完整)的有用 AI 库列表,分为几个部分。由于过于具体、太新,或者仅仅是因为图书馆数量众多,许多库没有被提及。

scikit-learn – Python 中的机器学习

scikit-learn 库是一个极其通用的机器学习库,涵盖了众多 AI 主题;对于其中许多主题,这应该是您的起点。我们之前已经看到了 scikit-image 库,它是 scikit-learn 项目的一部分,但还有许多其他选项。

可能性的完整列表非常庞大,所以我将基于我个人认为有用的 scikit-learn 模块,尝试给您提供一个非常小的列表。还有很多其他方法可供选择,所以如果您对任何特定内容感兴趣,请务必阅读 scikit-learn 文档。

由于您的数据集是决定您用例算法的最重要因素,因此本节在监督学习和无监督选项之间划分。

监督学习

从监督学习开始,scikit-learn 在许多不同类别中提供了大量不同的选项。

线性模型

首先,scikit-learn 提供了数十种不同的线性模型,用于执行许多类型的回归。它具有针对许多特定用例的功能,例如:

  • 普通最小二乘法回归,正如我们在上一章中多次看到的。

  • 岭回归和分类器,一个类似于普通最小二乘法但更能抵抗共线性的函数。

  • LASSO最小绝对收缩和选择算子)模型,可以看作是针对特定用例的 Ridge 模型的继任者。LASSO 模型的一个优点是,在机器学习的情况下,它可以帮助用很少的数据过滤掉(通常是不相关的)特征。

  • 多项式回归:例如,普通最小二乘法通过创建一条直线来进行回归。然而,在某些情况下,直线可能永远无法正确地拟合您的数据。在这些情况下,多项式回归可以非常有帮助,因为它可以生成曲线。

此模块中还有许多其他方法,所以请确保查看文档:scikit-learn.org/stable/modules/linear_model.html

支持向量机

接下来是支持向量机。我们之前已经简要讨论了 SVMs,但简而言之,这些可以用于分类、回归和异常检测。与上述的线性(2D)模型相比,这些方法也适用于高维数据。

目前,scikit-learn 支持这些类型的 SVMs:

  • SVC/SVR: 基于 C libsvm 库的支持向量分类和回归。对于较小的数据集(几千个样本),这是 scikit-learn 中最有用和最灵活的 SVM 实现。这种方法还可以处理支持向量,这可以提高分类器的精度。

  • NuSVC/NuSVR: SVC/SVR 的一个修改版本,引入了一个参数 v(希腊字母 Nu),用于近似训练错误和支持向量的比例。

  • LinearSVC/LinearSVR: 一个快速(比 SVC/SVR 快)的线性支持向量分类和回归系统。对于大型数据集(超过 10,000 个样本),这是 SVC/SVR 的更好替代品,但它不处理单独的支持向量。

SVMs 是针对高维数据非常稳健的预测方法,同时仍然保持相当快的执行速度。

决策树

决策树DTs)也值得特别注意。虽然大多数机器学习模型在训练后仍然相对昂贵,但使用决策树时,你可以根据训练数据构建一个树,用于你的分类或回归。如果你熟悉树结构,你知道许多查找只需要 O(log(n)) 的时间。除了计算速度快之外,它还可以使你的数据可视化变得更加容易,因为 scikit-learn 可以将评估结果导出到 Graphviz,这是一个渲染图结构的工具。

要增强决策树,你还可以将它们组合成一个森林,使用 RandomForestClassifierRandomForestRegressor,这会导致方差减少。更进一步,你还可以使用 极端随机树 方法 ExtraTreesClassifierExtraTreesRegressor,这些方法还会随机化树之间的特定阈值,从而在正常森林方法之上进一步减少方差。

特征选择

使用特征选择,你可以输入大量输入参数,而不必指定它们的作用,让模型找出最重要的特征。

例如,假设你收集了大量天气和地理数据,例如温度、湿度、气压、海拔和坐标,你想要知道这些数据中哪些在回答是否会下雪的问题中起作用。在这种情况下,坐标和气压可能不如温度重要。

scikit-learn 库为特征选择提供了几种不同的选项:

  • sklearn.feature_selection.VarianceThreshold: 通过满足方程 Var[X]=p(1-p) 来排除方差较小的项

  • sklearn.feature_selection.SelectKBest:选择评分最高的 k 个特征

  • sklearn.feature_selection.SelectPercentile:选择评分最高的第 n 百分位数的特征

  • sklearn.feature_selection.SelectFromModel:一个特殊且非常有用的特征选择器,可以使用先前生成的模型(如 SVM)来过滤特征

还有其他几种特征选择和特征过滤方法可供选择,所以请确保查看文档,看看是否有更适合你特定用例的方法。

其他模型

除了这些方法之外,scikit-learn 还支持许多其他方法,例如:

  • 贝叶斯网络:高斯、多项式、补集、伯努利、分类和离核。

  • 线性与二次判别分析:这些与线性模型类似,但也提供了二次解决方案。

  • 核岭回归:岭回归和分类的结合。这可以是一个比 SVR 更快的替代方案。

  • 随机梯度下降:对于特定用例,是 SVM 的一个非常快速的回归/分类替代方案。

  • 最近邻:这些方法适用于多种不同的目的,并且是这个库中许多其他函数的核心。至少,请查看这一部分,因为像 KD 树这样的结构在机器学习之外也有许多应用。

尽管还有其他几种选择,但这些可能对你最有用。请注意,尽管 scikit-learn 支持多层感知器等神经网络,但我不会推荐你用它来达到这个目的。虽然实现效果不错,但它不支持 GPU(显卡)加速,这会带来巨大的性能差异。对于神经网络,我推荐使用前面章节中提到的 TensorFlow。

无监督学习

由于无监督学习的特性,它比监督学习要少用得多,但在某些场景下,无监督学习绝对是有意义的简单解决方案。虽然 scikit-learn 的无监督学习部分比监督学习部分小,但仍然有几个非常有用的函数可用。

聚类是无监督学习大放异彩的主要例子。这归结为给算法提供大量数据,并告诉它在找到模式的地方将其(分割)成有用的部分。为了便于实现这一点,scikit-learn 提供了一系列不同的算法。文档对此解释得非常清楚:scikit-learn.org/stable/modules/clustering.html#overview-of-clustering-methods

本文档的一个子部分如下:

方法名称 可扩展性 用例
K-Means 非常大的 n_samples,中等 n_clusters 使用 MiniBatch 代码 通用,聚类大小均匀,平坦几何,聚类数量不多,归纳
相似传播 无法与 n_samples 规模化 许多聚类,聚类大小不均匀,非平坦几何,归纳
均值漂移 无法与 n_samples 规模化 许多聚类,聚类大小不均匀,非平坦几何,归纳
谱聚类 中等 n_samples,小型 n_clusters 少数聚类,甚至聚类大小,非平坦几何,归纳
Ward 层次聚类 大型 n_samplesn_clusters 许多聚类,可能存在连接约束,归纳
聚类 大型 n_samplesn_clusters 许多聚类,可能存在连接约束,非欧几里得距离,归纳
DBSCAN 非常大的 n_samples,中等的 n_clusters 非平坦几何,聚类大小不均匀,归纳
OPTICS 非常大的 n_samples,大的 n_clusters 非平坦几何,聚类大小不均匀,聚类密度可变,归纳
高斯混合模型 无法规模化 平坦几何,适合密度估计,归纳
BIRCH 大型 n_clustersn_samples 大型数据集,异常值移除,数据降维,归纳

所有这些方法都有它们自己的应用场景,scikit-learn 文档对此的解释比我所能说的要好得多。然而,总的来说,我们在上一章中也使用过的 K-Means 算法是一个非常好的起点。

注意,聚类也可以用于学习特征及其之间的关系。一旦您学习了特征,您就可以在监督学习中使用特征选择来过滤它们以进行子选择。

总结来说,对于一般的机器学习案例,scikit-learn 可能是您的最佳选择。对于特殊案例,通常有更好的库可用;其中许多是在 scikit-learn 的基础上构建的,因此如果您计划使用机器学习,建议您熟悉这个库。

auto-sklearn – 自动 scikit-learn

scikit-learn 库能做很多事情,使用起来常常令人感到不知所措。在撰写本文时,有 34 个不同的回归函数和 25 种不同的分类器,这可能会使您选择正确的工具变得相当具有挑战性。

这就是 auto-sklearn 可以帮助的地方。它可以自动为您选择一个分类函数,并填写它所需工作的参数。如果您只是寻找一个能直接使用的东西,这可能是您的最佳选择。

mlxtend – 机器学习扩展

mlxtend 是一个包含一系列相对简单且文档齐全的机器学习示例的库。

它内部使用scikit-learnpandasmatplotlib来提供比 scikit-learn 更用户友好的机器学习界面。如果你是机器学习(或 scikit-learn)的初学者,这可以是一个很好的入门,因为它比直接使用 scikit-learn 要简单一些。

scikit-lego – scikit-learn 工具

尽管 scikit-learn 已经内置了一个庞大的函数和特性目录,但仍有许多事情它没有提供简单的接口。这就是 scikit-lego 库可以发挥作用的地方,它为 scikit-learn 和 pandas 提供了许多方便的函数,这样你就不需要经常重复自己。

在上一章中,我们多次使用了企鹅数据集。加载该数据集并绘制分布只需几行代码:

import collections

from matplotlib import pyplot as plt
from sklego import datasets

X, y = datasets.load_penguins(return_X_y=True)
counter = collections.Counter(y)
plt.bar(counter.keys(), counter.values())
plt.show() 

这会导致:

图片

图 16.9:企鹅分布

scikit-lego 可以自动为我们执行一些转换(这里的return_X_y参数),这样我们就可以轻松地绘制结果。还有许多其他这样的函数可用,这使得在 Scikit-learn 中玩耍变得非常容易。

XGBoost – 极端梯度提升

XGBoost 是一个用于梯度提升的快速高效库,梯度提升是一种产生决策树森林的回归/分类技术。与许多其他回归/分类算法相比,这种技术的优势在于可扩展性。使用 XGBoost,你可以轻松地将你的工作负载分散到多台计算机的集群中,并且它能够扩展到数十亿的数据点。

如果你拥有非常大的数据集,XGBoost 可能是你的最佳选择之一。

Featuretools – 特征检测和预测

Featuretools 库使得根据基于时间的数据集或关系型数据集将你的数据集转换为聚合特征矩阵变得非常容易。一旦特征矩阵构建完成,该库就可以用于对这些特征的预测。

例如,你可以根据多个行程的集合预测行程时长,或者预测客户何时会再次从你这里购买。

Snorkel – 自动改进你的机器学习数据

Snorkel 是一个试图使你的机器学习模型训练变得更容易的库。获取足够的训练数据以正确训练你的模型可能非常困难,而这个库有几种巧妙的方法来简化这个过程。

该库有三个核心操作来帮助你构建你的数据集:

  • 首先,为了帮助标注,Snorkel 库提供了几种启发式方法。虽然这些标签可能不是完美的,但手动标注所有数据可能是一项繁重的任务。

  • 第二个核心操作是对数据集进行转换和增强。再次强调,这些操作使用启发式方法(希望)来提高你的数据质量。

  • 最后的核心操作是数据的切片,这样你只得到与你用例相关的数据。这个操作也是基于启发式的。

如果你已经有高质量的数据可用,你不需要这个,但如果你的数据需要一些改进,那么查看它肯定是有价值的。正如机器学习通常的情况一样,必须小心避免过拟合或欠拟合数据。应用 Snorkel 方法可能会迅速加剧你的数据集中的问题,因为它使用数据集作为来源。

TPOT – 使用遗传编程优化机器学习模型

TPOT(茶壶)是一个通过遗传编程优化你的学习管道的库。我们之前已经讨论过进化算法,但为了提醒你,它们是通过进化改变自身或其参数来改进的算法。

尽管遗传算法本身相对容易实现,但复杂性来自于解决方案的编码,使其与遗传算法兼容。这正是 TPOT 库非常棒的地方;它使得编码你的特征、缓存管道的部分以及使用 Dask 并行运行尝试变得非常容易。

为了说明,以下是告诉 TPOT 自动优化 scikit-learn 分类器及其参数所需的代码:

import tpot
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    some_data, some_target, train_size=0.75, test_size=0.25)

tpot = tpot.TPOTClassifier(verbosity=2, max_time_mins=10)
tpot.fit(X_train, y_train)
tpot.export('optimized_classifier.py') 

一旦尝试了多个分类器,它将把优化的函数调用写入optimized_classifier.py。重要的是要注意,返回的分类器也取决于优化器的结果;它可能是sklearn.neighbors.KNeighborsClassifier,但你也可能得到sklearn.ensemble.RandomForestClassifier或其他。

不要假设TPOT是寻找参数的快速解决方案;使用遗传算法得到一个好的解决方案可能需要很长时间,在你应用这个算法之前减少你的测试集可能是有益的。

那是最后一个库,现在是时候在练习部分尝试自己动手操作了。

练习

由于本章的性质,所有主题仅涵盖所提及库的绝对基础知识,它们确实值得更多。在这种情况下,作为一个练习,我建议你尝试使用一些(或全部)所提及的库,看看你是否可以用它们做一些有用的事情。

一些建议:

  • 浏览 TensorFlow Hub,并将一些模型应用于你自己的数据。也许你可以将目标检测应用于你的假日照片。

  • 在将模型应用于你的照片后,尝试通过添加一些新对象和微调来改进模型。

  • 尝试通过应用本章摘要中的一个 NLP 算法来从本章中提取一些数据或信息。

人工智能是一个复杂的话题,即使是简单的示例指南也往往相当复杂。幸运的是,如今我们通常可以通过 Google Colab 或运行 Jupyter Notebook 立即在线尝试示例。大胆尝试,不要气馁;来自领域专家的优质信息量非常庞大。

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。鼓励您提交自己的解决方案,并从他人的替代方案中学习。

摘要

本章为您展示了某些最大和最受欢迎的 Python 人工智能库的示例,但还有许多(大型)库可用于您特定的用例。例如,还有许多库可用于特定主题,如天文学、地理信息系统(GISes)、蛋白质折叠和神经影像学。

在本章之后,您应该对在哪里开始搜索特定类型的 AI 库有所了解。此外,您应该对何时应用特定类型的 AI 有所了解。对于许多用例,您可能需要结合这些方法以高效的方式解决问题。例如,如果您拥有大量高质量、标记好的数据,监督式机器学习系统是一个绝佳的选择。通常情况下并非如此,这时其他算法就派上用场了。

令人惊讶的是,许多当前的“人工智能”初创公司实际上并没有使用人工智能来构建他们的推荐系统,而是使用人类,希望在未来某个时候,当他们收集到足够的训练数据时,能够升级到一个有效的 AI。实际上,他们正在试图用蛮力解决监督式机器学习系统的数据需求。同样,算法只是语音识别系统(如 Alexa、Google Assistant 或 Siri)成为可能的部分原因。另一个重要部分是过去几年训练数据的可获得性。自然地,这些系统不是基于一个特定的算法构建的,而是使用多个算法的组合;系统不仅试图将您的语音转换为文字,而且还试图通过不断交叉验证这些结果与逻辑句子结构,来理解您可能要说的话。

随着每年技术的进步和变化,人工智能领域也在快速发展。现在我们拥有的处理能力比过去有了更多选择。目前使用的深度学习人工智能模型在 20 年前是完全不可行的,而在 10 年后,模型将远远超出现在的可能性。如果您今天面临的问题没有解决方案,一年后的情况可能会有完全不同的变化。

完全跳过 Python 的这部分内容也是完全可以理解的。虽然 AI 正在成为 Python 所做事情中越来越大的部分,但其中很大一部分是在学术环境中进行的,可能对你的工作领域不感兴趣。AI 可以提供巨大的帮助,但它通常是一个比实际需要的更复杂的解决方案。

在下一章中,我们将学习如何在 C/C++ 中创建扩展来提高性能并允许对内存和其他硬件资源的低级访问。虽然这可以大大提高性能,但正如我们将看到的,性能很少是免费的。

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第十七章:C/C++ 扩展、系统调用和 C/C++ 库

最后几章向我们展示了众多机器学习和科学计算库。许多这些库并非纯 Python 编写,因为它们从现有库中复用代码,或者出于性能考虑。在本章中,我们将学习如何通过创建 C/C++ 扩展来实现其中的一些功能。

第十二章性能 – 跟踪和减少你的内存和 CPU 使用 中,我们了解到 cProfile 模块比 profile 模块快约 10 倍,这表明至少一些 C 扩展比它们的纯 Python 等效物更快。然而,本章不会过多关注性能。这里的目的是与非 Python 库的交互。用 Linus Torvalds 的话来说,任何性能提升都将是完全无意的结果。

如果性能是你的主要目标,你真的不应该考虑手动编写 C/C++ 扩展。对于 Python 核心模块,当然已经这样做了,但在大多数实际应用中,使用 numbacython 会更好。或者,如果用例允许,可以使用预存的库,如 numpyjax。使用本章中工具的主要原因应该是复用现有库,这样你就不必重新发明轮子。

在本章中,我们将讨论以下主题:

  • ctypes 用于处理来自 Python 的外部(C/C++)函数和数据

  • C 外部函数接口CFFI),类似于 ctypes,但采用略有不同的方法

  • 编写原生 C/C++ 以扩展 Python

设置工具

在我们开始之前,重要的是要注意,本章将需要一个与你的 Python 解释器兼容的编译器。不幸的是,这些编译器因平台而异。对于 Linux 发行版,通常可以通过一两个命令轻松实现,而无需太多麻烦。

对于 OS X,体验通常非常相似,主要是因为繁重的工作可以委托给包管理系统,如 Homebrew。对于 Windows,可能会稍微复杂一些,但这个过程在过去几年中已经简化了。

获取所需工具的一个良好且最新的起点是 Python 开发者指南:devguide.python.org/setup/.

对于构建实际的扩展,Python 手册可能会有所帮助:docs.python.org/3/extending/building.html.

你需要 C/C++ 模块吗?

在几乎所有情况下,我倾向于认为你不需要 C/C++模块。如果你真的需要最佳性能,那么几乎总是有高度优化的 Python 库可用,它们内部使用 C/C++/Fortran 等,并且适合你的需求。有些情况下,原生 C/C++(或只是“非 Python”)是必需的。如果你需要直接与具有特定时序的硬件通信,那么 Python 可能不起作用。然而,通常这类通信应留给操作系统内核级驱动程序来处理特定的时序。无论如何,即使你永远不会自己编写这些模块,当你调试项目时,你可能仍然需要了解它们是如何工作的。

Windows

对于 Windows,一般推荐使用 Visual Studio。具体版本取决于你的 Python 版本:

  • Python 3.4: Microsoft Visual Studio 2010

  • Python 3.5 和 3.6: Microsoft Visual Studio 2015 或 Visual Studio 2017

  • Python 3.7–3.10: Microsoft Visual Studio 2017

Visual Studio 2019 也受到支持,但 Python 3.7 到 Python 3.10 的官方构建仍然使用 Visual Studio 2017,因此这是推荐解决方案。

安装 Visual Studio 和编译 Python 模块的具体细节超出了本书的范围。幸运的是,Python 文档提供了一些文档来帮助你入门:devguide.python.org/setup/#windows

如果你正在寻找一个更类似 Linux/Unix 的解决方案,你也可以选择通过 MinGW 使用 GCC 编译器。

OS X

对于 Mac,这个过程主要是直接的,但也有一些针对 OS X 的特定提示。首先,通过 Mac App Store 安装 Xcode。

一旦完成这些,你应该能够运行以下命令:

$ xcode-select --install 

接下来是更有趣的部分。因为 OS X 自带了一个捆绑的 Python 版本(通常已经过时),我建议通过 Homebrew 安装一个新的 Python 版本。安装 Homebrew 的最新说明可以在 Homebrew 主页上找到(brew.sh/),但安装 Homebrew 的基本命令如下:

$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 

之后,请确保使用doctor命令检查一切是否设置正确:

$ brew doctor 

当所有这些完成后,只需通过 Homebrew 安装 Python,并确保在执行脚本时使用该 Python 版本:

$ brew install python3
$ python3 --version
Python 3.9.7
which python3
/usr/local/bin/python3 

还要确保 Python 进程在/usr/local/bin中,即 Homebrew 版本。常规 OS X 版本将在/usr/bin/中。

Linux/Unix

对于 Linux/Unix 系统的安装,很大程度上取决于发行版,但通常很简单。

对于使用yum作为包管理器的 Fedora、Red Hat、CentOS 和其他系统,使用以下命令:

$ sudo yum install yum-utils
$ sudo yum-builddep python3 

对于使用apt作为包管理器的 Debian、Ubuntu 和其他系统,使用以下命令:

$ sudo apt-get build-dep python3.10 

注意,Python 3.10 目前并非在所有地方都可用,因此你可能需要使用 Python 3.9 或甚至 Python 3.8。

对于大多数系统,要获取安装帮助,进行类似 <操作系统> python.h 的网络搜索应该可以解决问题。

使用 ctypes 调用 C/C++

ctypes 库使得从 C 库调用函数变得非常容易,但你确实需要小心内存访问和数据类型。Python 通常在内存分配和类型转换方面非常宽容;而 C 则绝对不是那么宽容。

平台特定库

尽管所有平台都会在某个地方提供标准 C 库,但它的位置和调用方式因平台而异。为了拥有一个简单且易于大多数人访问的环境,我将假设使用 Ubuntu(虚拟)机器。如果你没有可用的原生 Ubuntu 机器,你可以在 Windows、Linux 和 OS X 上通过 VirtualBox 运行它。

由于你通常会希望在本地系统上运行示例,我们将首先展示从标准 C 库加载 printf 的基础知识。

Windows

从 Python 调用 C 函数的一个问题是默认库是平台特定的。虽然以下示例在 Windows 系统上可以正常运行,但在其他平台上则无法运行:

>>> import ctypes

>>> ctypes.cdll
<ctypes.LibraryLoader object at 0x...>
>>> libc = ctypes.cdll.msvcrt
>>> libc
<CDLL 'msvcrt', handle ... at ...>
>>> libc.printf
<_FuncPtr object at 0x...> 

c types 库将 C/C++ 库(在本例中为 MSVCRT.DLL)的函数和属性暴露给 Python 安装。由于 ms 部分代表 Microsoft,这是一个你通常在非 Windows 系统上找不到的库。

在加载方面,Linux/Unix 和 Windows 之间也存在差异;在 Windows 上,模块通常会被自动加载,而在 Linux/Unix 系统上,你需要手动加载它们,因为这些系统通常会提供同一库的多个版本。

Linux/Unix

从 Linux/Unix 调用标准系统库确实需要手动加载,但幸运的是,这并不复杂。从标准 C 库获取 printf 函数相当简单:

>>> import ctypes

>>> ctypes.cdll
<ctypes.LibraryLoader object at 0x...>
>>> libc = ctypes.cdll.LoadLibrary('libc.so.6')
>>> libc
<CDLL 'libc.so.6', handle ... at ...>
>>> libc.printf
<_FuncPtr object at 0x...> 

OS X

对于 OS X,也需要显式加载,但除此之外,它与常规的 Linux/Unix 系统上的工作方式相当相似:

>>> import ctypes
>>> libc = ctypes.cdll.LoadLibrary('libc.dylib')
>>> libc
<CDLL 'libc.dylib', handle ... at 0x...>
>>> libc.printf
<_FuncPtr object at 0x...> 

使其变得简单

除了库的加载方式之外,不幸的是还有更多差异,但至少早期的示例为你提供了标准 C 库,这允许你直接从 C 实现中调用如 printf 这样的函数。如果你由于某种原因加载正确的库有困难,ctypes.util.find_library 函数总是可用。

如往常一样,我推荐使用显式声明而不是隐式声明,但在某些情况下,使用此函数可以使事情变得更容易。以下是在 OS X 系统上运行的示例:

# OS X
>>> from ctypes import util
>>> from ctypes import cdll

>>> library = util.find_library('libc')
>>> library
'/usr/lib/libc.dylib'

# Load the library
>>> libc = cdll.LoadLibrary(library)
>>> libc
<CDLL '/usr/lib/libc.dylib', handle ... at 0x...> 

调用函数和本地类型

通过ctypes调用函数几乎和调用原生 Python 函数一样简单。值得注意的是参数和return语句。这些应该转换为原生 C 变量。

这些示例假设你从上一段中的某个示例中已经有了libc

我们现在将创建一个 C 字符串,它实际上是一个内存块,字符为 ASCII 字符,并以空字符结尾。在创建 C 字符串后,我们将对字符串运行printf

>>> c_string = ctypes.create_string_buffer(b'some bytes')
>>> ctypes.sizeof(c_string)
11
>>> c_string.raw
b'some bytes\x00'
>>> c_string.value
b'some bytes'
>>> libc.printf(c_string)
10
some bytes>>> 

这个输出一开始可能看起来有点混乱,所以让我们分析一下。当我们对c_string调用libc.printf时,它将直接将字符串写入stdout。正因为如此,你可以看到输出是交织的(some bytes>>>)与 Python 输出,因为这绕过了 Python 输出缓冲区,Python 并不知道这件事正在发生。此外,你可以看到libc.printf返回了10,这是写入stdout的字节数。

要调用printf函数,你必须——我无法强调这一点——明确地将你的值从 Python 转换为 C。虽然一开始可能看起来不需要这样做也能工作,但实际上并不是这样:

>>> libc.printf(123)
segmentation fault (core dumped)  python3 

记得使用第十一章的faulthandler模块来调试segfaults

从示例中还可以注意到,ctypes.sizeof(c_string)返回11而不是10。这是由于 C 字符串所需的尾随空字符造成的,这在 C 字符串的原始属性中是可见的。

没有它,C 语言中的字符串函数(如printf)将不知道字符串在哪里结束,因为 C 字符串只是内存中的一块字节,C 只知道字符串的起始内存地址;结束由空字符指示。这就是为什么 C 语言中的内存管理需要非常注意。

如果你分配了一个大小为 5 的字符串并写入 10 个字节,你将写入你的变量之外的内存,这可能是另一个函数、另一个变量,或者程序内存之外。这将导致段错误。

Python 通常会保护你免受愚蠢的错误;C 和 C++则绝对不会。引用 Bjarne Stroustrup(C++的创造者)的话:

“C 语言让你容易踩到自己的脚;C++语言让你更难,但当你这么做时,它会让你失去整条腿。”

与 C 语言不同,C++确实有字符串类型来保护你在这些情况下的安全。然而,它仍然是一种你可以轻松访问内存地址的语言,错误很容易发生。

要将其他类型(如整数)传递给libc函数,我们不得不使用一些转换。在某些情况下,这是可选的:

>>> format_string = b'Number: %d\n'
>>> libc.printf(format_string, 123)
Number: 123
12

>>> x = ctypes.c_int(123)
>>> libc.printf(format_string, x)
Number: 123
12 

但并非所有情况都是这样,所以建议谨慎行事,并明确转换是更安全的选项:

>>> format_string = b'Number: %.3f\n'
>>> libc.printf(format_string, 123.45)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ctypes.ArgumentError: argument 2: <class 'TypeError'>: Don't know how to convert parameter 2

>>> x = ctypes.c_double(123.45)
>>> libc.printf(format_string, x)
Number: 123.450
16 

重要的是要注意,尽管这些值可以作为原生 C 类型使用,但它们仍然可以通过value属性进行修改:

>>> x = ctypes.c_double(123.45)
>>> x.value
123.45
>>> x.value = 456
>>> x
c_double(456.0) 

除非原始对象是不可变的,否则这种情况才会发生。这是一个非常重要的区别。create_string_buffer 对象创建了一个可变字符串对象,而 c_wchar_pc_char_pc_void_p 创建了对实际 Python 字符串的引用。由于 Python 中的字符串是不可变的,因此这些值也是不可变的。你仍然可以更改 value 属性,但它只会分配一个新的字符串。将其中一个不可变变量传递给一个会修改内部值的 C 函数会导致不可预测的行为和/或崩溃。

只有整数、字符串和字节应该能够无问题地转换为 C,但我个人建议你始终转换所有值,这样你就可以确定你会得到哪种类型以及如何处理它。

复杂数据结构

我们已经看到,我们不能直接将 Python 值传递给 C,但如果我们需要更复杂的对象,如类或元组怎么办?幸运的是,我们可以轻松地使用 ctypes 创建(并访问)C 结构:

>>> from _libc import libc
>>> import ctypes

>>> class ComplexStructure(ctypes.Structure):
...     _fields_ = [
...         ('some_int', ctypes.c_int),
...         ('some_double', ctypes.c_double),
...         ('some_char', ctypes.c_char),
...         ('some_string', ctypes.c_char_p),
...     ]
... 
>>> structure = ComplexStructure(123, 456.789, b'x', b'abc')
>>> structure.some_int
123
>>> structure.some_double
456.789
>>> structure.some_char
b'x'
>>> structure.some_string
b'abc' 

这支持任何基本数据类型,如整数、浮点数和字符串。嵌套也是支持的;例如,在这个例子中,其他结构可以使用 ComplexStructure 而不是 ctypes.c_int

数组

在 Python 中,我们通常使用 list 来表示对象的集合。这些非常方便,因为你可以轻松地添加和删除值。在 C 中,默认的集合对象是 array,它只是一个具有固定大小的内存块。

块的大小(以字节为单位)是通过将元素数量乘以类型大小来确定的。对于 char 类型,这是 8 位,所以如果你想要存储 100 个字符,你将需要 100 * 8 bits = 800 bits = 100 bytes

这实际上就是它——一个内存块——而你从 C 收到的唯一引用是内存块开始地址的指针。由于指针确实有类型,在这个例子中是 char*,C 将知道在尝试访问不同项时需要跳过多少字节。实际上,当尝试访问 char 数组中的第 25 项时,你只需执行 array_pointer + 24 * sizeof(char)。这有一个方便的快捷方式:array_pointer[24]。请注意,我们需要访问索引 24,因为我们从 0 开始计数,就像 Python 的集合如列表和字符串一样。

注意,C 语言不存储数组中元素的数量,因此尽管我们的数组只有 100 个元素,但这不会阻止我们执行 array_pointer[1000] 并读取其他(随机)内存。然而,在某个时刻,你将超出应用程序预留的内存,操作系统将用段错误来惩罚你。

如果考虑到所有这些限制,C 数组确实可以使用,但错误会很快出现,C 语言不会宽容。没有警告;只有崩溃和奇怪的行为代码。除此之外,让我们看看我们如何容易地使用 ctypes 声明一个数组:

>>> TenNumbers = 10 * ctypes.c_double
>>> numbers = TenNumbers()
>>> numbers[0]
0.0 

如您所见,由于固定大小和在使用前必须声明类型的要求,其使用略显笨拙。然而,它确实按您预期的那样工作。此外,与常规 C 语言不同,默认情况下值会被初始化为零,这可以在从 Python 访问时保护您免受越界错误。当然,这可以与之前创建的定制结构相结合:

>>> GrossComplexStructures = 144 * ComplexStructure 
>>> complex_structures = GrossComplexStructures()

>>> complex_structures[10].some_double = 123
>>> complex_structures[10]
<__main__.ComplexStructure object at ...>
>>> complex_structures
<__main__.ComplexStructure_Array_144 object at ...> 

尽管您不能简单地向这些数组追加内容以调整大小,但它们实际上在几个约束条件下是可以调整大小的。首先,新数组需要比原始数组大。其次,大小需要以字节为单位指定,而不是以项目为单位。为了说明这一点,我们有这个示例:

>>> TenNumbers = 10 * ctypes.c_double
>>> numbers = TenNumbers()

>>> ctypes.resize(numbers, 11 * ctypes.sizeof(ctypes.c_double))
>>> ctypes.resize(numbers, 10 * ctypes.sizeof(ctypes.c_double))
>>> ctypes.resize(numbers, 9 * ctypes.sizeof(ctypes.c_double))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: minimum size is 80

>>> numbers[:5] = range(5)
>>> numbers[:]
[0.0, 1.0, 2.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.0] 

作为起点,TenNumbers 数组有 10 个项目。接下来,我们尝试将数组大小调整为 11,这是可行的,因为它比原始的 10 大。将大小调整回 10 也是允许的,但将大小调整为 9 个项目是不允许的,因为这将少于我们最初拥有的 10 个项目。

最后,我们同时修改一个项目切片,这正如您所预期的那样工作。

内存管理的陷阱

除了明显的内存分配问题和混合可变和不可变对象之外,还有一个不那么明显的内存可变性问题。

在常规 Python 中,我们可以做一些像 a, b = b, a 的事情,并且它会按您预期的那样工作,因为 Python 使用内部临时变量。不幸的是,在常规 C 中,您没有这样的便利;在 ctypes 中,您确实有 Python 为您处理临时变量的好处,但有时这仍然可能出错:

>>> import ctypes

>>> class Point(ctypes.Structure):
...     _fields_ = ('x', ctypes.c_int), ('y', ctypes.c_int)

>>> class Vertex(ctypes.Structure):
...     _fields_ = ('c', Point), ('d', Point)

>>> a = Point(0, 1)
>>> b = Point(2, 3)
>>> a.x, a.y, b.x, b.y
(0, 1, 2, 3)

# Swap points a and b
>>> a, b = b, a
>>> a.x, a.y, b.x, b.y
(2, 3, 0, 1)

>>> v = Vertex()
>>> v.c = Point(0, 1)
>>> v.d = Point(2, 3)
>>> v.c.x, v.c.y, v.d.x, v.d.y
(0, 1, 2, 3)

# Swap points c and d
>>> v.c, v.d = v.d, v.c
>>> v.c.x, v.c.y, v.d.x, v.d.y
(2, 3, 2, 3) 

在第一个示例中,当我们交换 ab 时,我们得到预期的 2, 3, 0, 1。在第二个示例中,我们得到 2, 3, 2, 3。问题在于这些对象被复制到一个临时缓冲变量中,但在此期间对象本身正在被改变。

让我们进一步阐述以增加清晰度。在 Python 中,当你执行 a, b = b, a 时,它实际上会运行 temp = a; a = b; b = temp。这样,替换就会按预期工作,你将在 ab 中收到正确的值。

当你在 C 语言中执行 a, b = b, a 时,实际上得到的是 a = b; b = a。在执行 b = a 语句时,a 的值已经被 a = b 语句所改变,因此此时 ab 都将具有 b 在那个点的原始值。

CFFI

CFFI(C Foreign Function Interface)库提供了与 ctypes 非常相似的选择,但它更为直接。与 ctypes 库不同,CFFI 真正需要 C 编译器。有了它,您就有机会以简单的方式从 Python 直接调用您的 C 编译器。我们通过调用 printf 来说明:

>>> import cffi

>>> ffi = cffi.FFI()
>>> ffi.cdef('int printf(const char* format, ...);')
>>> libc = ffi.dlopen(None)
>>> arg = ffi.new('char[]', b'Printing using CFFI\n')
>>> libc.printf(arg)
20
Printing using CFFI 

好吧…这看起来有点奇怪,对吧?我们不得不定义printf函数的形态,并使用有效的 C 函数头指定printf的参数。此外,我们还需要手动指定 C 字符串为char[]数组。使用ctypes的话,这就不需要了,但与ctypes相比,CFFI有几个优点。

使用 CFFI,我们可以直接控制发送给 C 编译器的信息,这使得我们比使用ctypes有更多的内部控制权。这意味着你可以精确控制你提供给函数的类型以及你返回的类型,并且你可以使用 C 宏。

此外,CFFI 允许轻松重用现有的 C 代码。如果你使用的 C 代码有几个struct定义,你不需要手动将它们映射到ctypes.Structure类;你可以直接使用struct定义。你甚至可以直接在你的 Python 代码中编写 C 代码,CFFI 会为你调用编译器和构建库。

回到声明部分,你可能注意到我们使用ffi.dlopen时传入了None参数。当你向这个函数传递None时,它将自动加载整个 C 命名空间;至少在非 Windows 系统上是这样。在 Windows 系统上,你需要明确告诉 CFFI 要加载哪个库。

如果你记得ctypes.util.find_library函数,你可以在这种情况下再次使用它,具体取决于你的操作系统:

>>> from ctypes import util
>>> import cffi

# Initialize the FFI builder
>>> ffi = cffi.FFI()

# Find the libc library on OS X. Look back at the ctypes examples
# for other platforms.
>>> library = util.find_library('libc.dylib')
>>> library
'/usr/lib/libc.dylib'

# Load the library
>>> libc = ffi.dlopen(library)
>>> libc
<cffi.api._make_ffi_library.<locals>.FFILibrary object at ...>

# We do have printf available, but CFFI requires a signature
>>> libc.printf
Traceback (most recent call last):
  ...
AttributeError: printf

# Define the printf signature and call printf
>>> ffi.cdef('int printf(const char* format, ...);')
>>> libc.printf
<cdata 'int(*)(char*, ...)' ...> 

我们可以看到,最初的工作方式与ctypes相当,加载库也很简单。真正不同的是在调用函数和使用库属性时;那些需要明确定义。

幸运的是,函数签名几乎总是可以在 C 头文件中找到,以便你不必自己编写。这就是 CFFI 的一个优点:它允许你重用现有的 C 代码。

复杂数据结构

CFFI的定义与ctypes的定义有些相似,但与 Python 模拟 C 不同,它只是从 Python 可访问的纯 C。实际上,这只是一个小的语法差异。虽然ctypes是一个用于从 Python 访问 C 的库,同时尽可能接近 Python 语法,但 CFFI 使用纯 C 语法来访问 C 系统,这实际上消除了对熟悉 C 的人来说的一些困惑。我个人觉得 CFFI 更容易使用,因为我有 C 的经验,知道实际上发生了什么,而我对ctypes则不是总是 100%确定。

让我们用 CFFI 重复VertexPoint的例子:

>>> import cffi

>>> ffi = cffi.FFI()

# Create the structures as C structs
>>> ffi.cdef('''
... typedef struct {
...     int x;
...     int y;
... } point;
...
... typedef struct {
...     point a;
...     point b;
... } vertex;
... ''')

# Create a vertex and return the pointer
>>> v = ffi.new('vertex*')

# Set the data
>>> v.a.x, v.a.y, v.b.x, v.b.y = (0, 1, 2, 3)

# Print before change
>>> v.a.x, v.a.y, v.b.x, v.b.y
(0, 1, 2, 3)

>>> v.a, v.b = v.b, v.a

# Print after change
>>> v.a.x, v.a.y, v.b.x, v.b.y
(2, 3, 2, 3) 

如你所见,可变变量的问题仍然存在,但代码仍然可用。由于结构可以从你的 C 头文件中复制,你唯一需要做的是为顶点分配内存。

在 C 语言中,一个普通的int类型变量x看起来像int x;。一个指向具有int大小的内存地址的指针看起来像这样:int *x;。指针中的int部分告诉编译器在使用变量时需要获取多少内存。为了说明:

int a = 123; // Variable a contains integer 123
int* b = &a; // Variable b contains the memory address of a
int c = *b;  // Variable c contains 123, the value at memory address c 

&运算符返回变量的内存地址,而*运算符返回指针地址处的值。

CFFI 的特殊工作方式允许你简化这些操作。在 C 语言中,通常使用vertex*只会分配指针的内存,而不是vertex本身。在 CFFI 的情况下,这一点会自动处理。

数组

使用 CFFI 分配新变量的内存几乎是微不足道的。上一节向你展示了单个struct分配的例子。现在让我们看看我们如何分配结构体数组:

>>> import cffi

>>> ffi = cffi.FFI()

# Create arrays of size 10:
>>> x = ffi.new('int[10]')
>>> y = ffi.new('int[]', 10)

>>> x
<cdata 'int[10]' owning 40 bytes>
>>> y
<cdata 'int[]' owning 40 bytes>

>>> x[0:10] = range(10)
>>> list(x)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

>>> x[:] = range(10)
Traceback (most recent call last):
    ...
IndexError: slice start must be specified

>>> x[0:100] = range(100)
Traceback (most recent call last):
    ...
IndexError: index too large (expected 100 <= 10) 

在这种情况下,你可能会想知道为什么切片包括了起始和结束位置。这是 CFFI 的要求。虽然不是问题,但多少有点令人烦恼。幸运的是,正如你在上面的例子中可以看到的,CFFI 确实保护我们不会超出数组边界进行分配。

ABI 还是 API?

总是有些注意事项。到目前为止的例子部分使用了ABI应用程序二进制接口),它从库中加载二进制结构。使用标准 C 库通常很安全;使用其他库通常则不是。API应用程序编程接口)和 ABI 之间的区别在于后者在二进制级别调用函数,直接访问内存,直接调用内存位置,并期望它们是函数。

要能够这样做,所有的大小都需要保持一致。当作为 32 位二进制文件编译时,指针将是 32 位;当作为 64 位二进制文件编译时,指针将是 64 位。这意味着偏移量不一定是一致的,你可能会错误地将内存块作为函数调用。

在 CFFI 中,这是ffi.dlopenffi.set_source之间的区别。在这里,dlopen并不总是安全的,但set_source是安全的,因为它传递了一个编译器而不是仅仅猜测如何调用方法。使用set_source的缺点是你需要你打算使用的库的实际源代码。让我们看看使用ffi.set_source调用我们定义的函数的快速示例:

>>> import cffi

>>> ffi = cffi.FFI()

# In API mode, we can in-line the actual C code
>>> ffi.set_source('_sum', '''
... int sum(int* input, int n){
...     int result = 0;
...     while(n--)result += input[n];
...     return result;
... }
... ''')
>>> ffi.cdef('int sum(int*, int);')

>>> library = ffi.compile() 

CFFI 的初始化和往常一样正常,但不是使用ffi.dlopen(),我们现在使用ffi.set_source()直接将 C 代码传递给 CFFI。通过这样做,CFFI 可以为我们自己的系统编译特定的库,因此我们知道我们不会遇到 ABI 问题,因为我们是通过调用ffi.compile()自己创建 ABI 的。

ffi.compile()步骤完成后,CFFI 已经创建了一个_sum.dllsum.so_sum.cpython-...-os.so文件,它可以作为一个普通的 Python 库导入。现在我们将使用生成的库:

# Now we can import the library
>>> import _sum

# Or use 'ffi.dlopen()' with the results from the compile step
>>> _sum_lib = ffi.dlopen(library)

# Create an array with 5 items
>>> N = 5
>>> array = ffi.new('int[]', N)
>>> array[0:N] = range(N)

# Call our C function from either the import or the dlopen
>>> _sum.lib.sum(array, N)
10

>>> _sum_lib.sum(array, N)
10 

如你所见,import _sumffi.dlopen(library) 在这个情况下都有效。对于生产环境的应用,我推荐使用 import _sum 方法,但 ffi.dlopen() 方法对于像 Jupyter Notebooks 这样的长时间运行的应用来说可能非常方便。如果你使用 import _sum 并在库中做了更改,除非你首先调用 reload(_sum),否则它不会显示你的更改。

由于这是一个 C 函数,我们需要传递一个 C 数组来处理复杂数据类型,这就是为什么我们在这里使用 ffi.new()。之后,函数调用就很简单了,但由于 C 数组没有大小概念,我们需要传递数组大小才能使其工作。

你可以很容易地越界,用一些任意数字代替 N,函数很可能会正常运行而不会崩溃,但它会返回非常奇怪的结果,因为它会在你的内存中随机求和数据。

CFFI 或 ctypes?

这完全取决于你正在寻找什么。如果你有一个只需要调用的 C 库,而且你不需要任何特殊功能,那么 ctypes 很可能是更简单的选择。如果你实际上正在编写自己的 C 库并尝试从 Python 链接库,CFFI 可能是一个更方便的选项。

在 C/C++ 中,链接一个库意味着使用一个外部预编译的库,而不需要源代码。你确实需要拥有头文件,这些头文件包含诸如函数参数和返回类型等详细信息。这正是我们使用 CFFI 在 ABI 模式下所做的事情。

如果你不太熟悉 C 编程语言,我肯定会推荐 ctypes 或许是 cython

本地 C/C++ 扩展

我们迄今为止所使用的库只展示了如何在 Python 代码中访问 C/C++ 库。现在我们将来看故事的另一面:Python 中的 C/C++ 函数/模块是如何实际编写的,以及像 cPicklecProfile 这样的模块是如何创建的。

一个基本示例

在我们真正开始编写和使用本地的 C/C++ 扩展之前,我们有一些先决条件。首先,我们需要编译器和 Python 头文件;本章开头提供的说明应该已经为我们处理了这一点。之后,我们需要告诉 Python 要编译什么。setuptools 包主要处理这一点,但我们需要创建一个 setup.py 文件:

import pathlib
import setuptools

# Get the current directory
PROJECT_PATH = pathlib.Path(__file__).parent

sum_of_squares = setuptools.Extension('sum_of_squares', sources=[
    # Get the relative path to sum_of_squares.c
    str(PROJECT_PATH / 'sum_of_squares.c'),
])

if __name__ == '__main__':
    setuptools.setup(
        name='SumOfSquares',
        version='1.0',
        ext_modules=[sum_of_squares],
    ) 

这告诉 Python 我们有一个名为 sum_of_squaresExtension 对象,它将基于 sum_of_squares.c

现在,让我们编写一个 C 函数,该函数计算给定数字的所有完全平方数(2*23*3 等等)。Python 代码将存储在 sum_of_squares_python.py 中,看起来像这样:

def sum_of_squares(n):
    total = 0
    for i in range(n):
        if i * i < n:
            total += i * i
        else:
            break

    return total 

这段代码的原始 C 版本可能看起来像这样:

long sum_of_squares(long n){
    long total = 0;
    /* The actual summing code */
    for(int i=0; i<n; i++){
        if((i * i) < n){
            total += i * i;
        }else{
            break;
        }
    }

    return total;
} 

既然我们已经知道了 C 代码的样子,我们将创建我们将要使用的实际的 C Python 版本。

正如我们从ctypesCFFI中看到的那样,Python 和 C 有不同的数据类型,需要进行一些转换。由于 CPython 解释器是用 C 编写的,因此它有特定的定义来处理这个转换步骤。

要加载这些定义,我们需要包含Python.h,这是 CPython 头文件,应该包含你需要的一切。

如果你仔细观察,你会看到实际的求和代码与 C 版本相同,但我们需要相当多的转换步骤来让 Python 理解我们在做什么:

#include <Python.h>

static PyObject* sum_of_squares(PyObject *self, PyObject
        *args){
    /* Declare the variables */
    int n;
    int total = 0;

    /* Parse the arguments */
    if(!PyArg_ParseTuple(args, "i", &n)){
        return NULL;
    }

    /* The actual summing code */
    for(int i=0; i<n; i++){
        if((i * i) < n){
            total += i * i;
        }else{
            break;
        }
    }

    /* Return the number but convert it to a Python object first */
    return PyLong_FromLong(total);
}

static PyMethodDef methods[] = {
    /* Register the function */
    {"sum_of_squares", sum_of_squares, METH_VARARGS,
     "Sum the perfect squares below n"},
    /* Indicate the end of the list */
    {NULL, NULL, 0, NULL},
};

static struct PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "sum_of_squares", /* Module name */
    NULL, /* Module documentation */
    -1, /* Module state, -1 means global. This parameter is
           for sub-interpreters */
    methods,
};

/* Initialize the module */
PyMODINIT_FUNC PyInit_sum_of_squares(void){
    return PyModule_Create(&module);
} 

它看起来相当复杂,但实际上并不难。在这种情况下,只是有很多开销,因为我们只有一个函数。通常,你会有几个函数,在这种情况下,你只需要扩展methods数组并创建函数。我们将在稍后详细解释代码,但首先,让我们看看如何运行我们的第一个示例。我们需要构建和安装模块:

$ python3 T_09_native/setup.py build install
running build
running build_ext
building 'sum_of_squares' extension ...
...
Processing dependencies for SumOfSquares==1.0
Finished processing dependencies for SumOfSquares==1.0 

现在,让我们创建一个小型测试脚本,以测量 Python 版本和 C 版本之间的差异。首先,一些导入和设置:

import sys
import timeit
import argparse
import functools

from sum_of_squares_py import sum_of_squares as sum_py

try:
    from sum_of_squares import sum_of_squares as sum_c
except ImportError:
    print('Please run "python setup.py build install" first')
    sys.exit(1) 

现在我们已经导入了模块(或者如果你还没有运行构建步骤,你会得到一个错误),我们可以开始基准测试:

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('repetitions', type=int)
    parser.add_argument('maximum', type=int)
    args = parser.parse_args()

    timer = functools.partial(
        timeit.timeit, number=args.repetitions, globals=globals())

    print(f'Testing {args.repetitions} repetitions with maximum: '
          f'{args.maximum}')

    result = sum_c(args.maximum)
    duration_c = timer('sum_c(args.maximum)')
    print(f'C: {result} took {duration_c:.3f} seconds')

    result = sum_py(args.maximum)
    duration_py = timer('sum_py(args.maximum)')
    print(f'Py: {result} took {duration_py:.3f} seconds')

    print(f'C was {duration_py / duration_c:.1f} times faster') 

从本质上讲,我们有一个基本的基准测试脚本,其中我们将 C 版本与这里的 Python 版本进行比较,具有可配置的重复次数和最大测试数。现在,让我们执行它:

$ python3 T_09_native/test.py 10000 1000000
Testing 10000 repetitions with maximum: 1000000
C: 332833500 took 0.009 seconds
Py: 332833500 took 1.264 seconds
C was 148.2 times faster 

完美!结果完全相同,但速度要快得多。

如果你的目标是追求速度,那么你应该尝试使用numba。将@numba.njit装饰器添加到sum_of_squares_python中要简单得多,而且可能甚至更快。

写 C 模块的主要优势是重用现有的 C 代码,然而。对于速度提升,你通常使用cythonnumba或者将你的代码转换为使用numpyjax等库会更好。

C 不是 Python – 尺寸很重要

Python 语言使得编程变得如此简单,有时你可能会忘记底层数据结构;在 C 和 C++中,你不能承担这样的风险。只需参考我们上一节中的示例,但使用不同的参数:

$ python3 T_09_native/test.py 1000 10000000
Testing 1000 repetitions with maximum: 10000000
C sum of squares: 1953214233 took 0.003 seconds
Python sum of squares: 10543148825 took 0.407 seconds
C was 145.6 times faster 

它仍然非常快,但数字怎么了?Python 和 C 版本给出了不同的结果,195321423310543148825。这是由 C 中的整数溢出引起的。虽然 Python 数字本质上可以有任意大小,但 C 中一个常规数字有一个固定的大小。你得到多少取决于你使用的类型(intlong等)和你的架构(32 位、64 位等),但这绝对是一件需要小心的事情。在某些情况下,它可能要快数百倍,但如果结果不正确,那就毫无意义。

我们当然可以增加一点大小。这使得它更好:

typedef unsigned long long int bigint;

static PyObject* sum_of_large_squares(PyObject *self, PyObject *args){
    /* Declare the variables */
    bigint n;
    bigint total = 0;

    /* Parse the arguments */
    if(!PyArg_ParseTuple(args, "K", &n)){
        return NULL;
    }

    /* The actual summing code */
    for(bigint i=0; i<n; i++){
        if((i * i) < n){
            total += i * i;
        }else{
            break;
        }
    }

    /* Return the number but convert it to a Python object first */
    return PyLong_FromUnsignedLongLong(total);
} 

我们使用typedef创建了一个bigint别名,用于unsigned long long int

如果我们现在测试它,我们会发现它运行得很好:

$ python3 T_10_size_matters/test.py 1000 10000000
Testing 1000 repetitions with maximum: 10000000
C: 10543148825 took 0.001 seconds
Py: 10543148825 took 0.405 seconds
C was 270.3 times faster 

随着规模的增加,性能差异也随之增大。

将数字变得更大又会导致问题再次出现,因为即使是unsigned long long int也有其极限:

$ python3 T_10_size_matters/test.py 1 100000000000000
Testing 1 repetitions with maximum: 100000000000000
C: 1291890006563070912 took 0.004 seconds
Py: 333333283333335000000 took 1.270 seconds
C was 293.7 times faster 

那么,如何解决这个问题呢?简单的答案是您无法解决,Python 也没有真正解决这个问题。复杂的答案是,如果您使用不同的数据类型来存储数据,您就可以解决这个问题。C 语言本身并没有 Python 所具有的“大数支持”。

Python 通过在内存中组合几个常规数字来支持无限大的数字。当需要时,它会自动切换到这些类型的数字。在 Python 2 中,intlong类型之间的区别更为明显。在 Python 3 中,longint类型已经被合并到int类型中。您将不会注意到long类型的切换;它将在后台自动发生。

在 C 语言中,没有常见的提供这种功能的条款,因此没有简单的方法来实现这一点。但我们可以检查错误:

static unsigned long long int get_number_from_object(int* overflow, 
        PyObject* some_very_large_number){
    return PyLong_AsLongLongAndOverflow(sum, overflow);
} 

注意,这仅适用于PyObject*,这意味着它不适用于内部 C 溢出。然而,当然,您只需保留原始的 Python long 并在其上执行操作即可。因此,您在 C 语言中也可以不费太多力气地实现大数支持。

以下是对示例的解释

我们已经看到了示例的结果,但如果您不熟悉 Python C API,您可能会对函数参数看起来为什么是这个样子感到困惑。

sum_of_squares中的基本计算与常规 C 语言的sum_of_squares函数相同,但有一些细微的差别。首先,使用 Python C API 的函数的类型定义应该看起来像这样:

static PyObject* sum_of_squares(PyObject *self, PyObject *args); 

让我们分解这个问题。

静态

这意味着该函数是静态的。静态函数只能从同一编译单元中调用。这实际上导致了一个无法从其他模块链接(导入/使用)的函数,这使得编译器可以进一步优化。由于 C 语言中的函数默认是全局的,这可以非常有用,可以防止命名冲突。不过,为了确保安全,如果您使用一个不太可能唯一的名称,您可以在函数名称前加上模块的名称。

注意不要将这里的单词static与变量前的static混淆。它们是完全不同的概念。一个static变量意味着该变量将在整个程序运行期间存在,而不是仅在函数运行期间。

PyObject*

PyObject 类型是 Python 数据类型的基本类型,这意味着所有 Python 对象都可以转换为 PyObject*PyObject 指针)。实际上,它只告诉编译器期望哪种属性,这可以在以后的类型识别和内存管理中使用。通常,使用可用的宏,如 Py_TYPE(some_object),而不是直接访问 PyObject* 是更好的选择。内部,这个宏扩展为 (((PyObject*)(o))->ob_type),这就是为什么宏通常是一个更好的选择。除了难以阅读外,打字错误也容易发生。

属性列表很长,并且很大程度上取决于对象的类型。对于这些,你可以参考 Python 文档:docs.python.org/3/c-api/typeobj.html

整个 Python C API 可能能填满一本书,但幸运的是,它在 Python 手册中有很好的文档。另一方面,它的使用可能不那么明显。

解析参数

使用常规的 C 和 Python,你需要明确指定参数,因为 C 中处理可变大小的参数有点棘手。这是因为它们需要单独解析。PyObject* args 是指向包含实际值的对象的引用。为了解析这些,你需要知道期望多少种类型的变量。在示例中,我们使用了 PyArg_ParseTuple 函数,它仅将参数解析为位置参数,但使用 PyArg_ParseTupleAndKeywordsPyArg_VaParseTupleAndKeywords 也可以轻松地解析命名参数。这些函数之间的区别在于,前者使用可变数量的参数来指定目标,而后者使用 va_list 来设置值。

让我们分析实际示例中的代码:

if(!PyArg_ParseTuple(args, "i", &n)){
    return NULL;
} 

我们知道 args 是包含实际参数引用的对象。"i" 是一个格式字符串,在这种情况下将尝试解析一个整数。&n 告诉函数将值存储在 n 变量的内存地址中。

格式字符串在这里是重要的部分。根据字符的不同,你可以得到不同的数据类型,但有很多种;i 指定一个常规整数,而 s 将你的变量转换为 C 字符串(实际上是一个 char*,它是一个以空字符终止的字符数组)。应该注意的是,幸运的是,这个函数足够智能,能够考虑溢出。

解析多个参数相当类似;你需要在格式字符串中添加多个字符,以及多个目标变量:

PyObject* callback;
int n;

/* Parse the arguments */
if(!PyArg_ParseTuple(args, "Oi", &callback, &n)){
    return NULL;
} 

关键字参数的版本类似,但需要更多的代码更改,因为方法列表需要被告知该函数接受关键字参数。否则,kwargs 参数永远不会到达:

static PyObject* function(
        PyObject *self,
        PyObject *args,
        PyObject *kwargs){
    /* Declare the variables */
    PyObject* callback;
    int n;

    static char* keywords[] = {"callback", "n", NULL};

    /* Parse the arguments */
    if(!PyArg_ParseTupleAndKeywords(args, kwargs, "Oi", keywords,
                &callback, &n)){
        return NULL;
    }

    Py_RETURN_NONE;
}

static PyMethodDef methods[] = {
    /* Register the function with kwargs */
    {"function", function, METH_VARARGS | METH_KEYWORDS,
     "Some kwargs function"},
    /* Indicate the end of the list */
    {NULL, NULL, 0, NULL},
}; 

让我们看看与仅支持 *args 版本的区别:

  1. 与纯 Python 类似,我们的函数头现在包括 PyObject *kwargs

  2. 因为我们需要在 C 中预先分配字符串,所以我们有一个名为 keywords 的单词数组,其中包含我们计划解析的所有 kwargs

  3. 我们现在必须使用 PyArg_ParseTupleAndKeywords 而不是 PyArg_ParseTuple。这个函数与 PyArg_ParseTuple 函数重叠,并通过遍历先前定义的 keywords 数组添加了关键字解析。

  4. 在函数注册表中,我们需要指定该函数支持关键字参数,除了 METH_VARARGS 标志外,还要添加 METH_KEYWORDS 标志。

注意,这仍然支持正常参数,但现在也支持关键字参数。

C 不是 Python – 错误是沉默的或致命的

正如我们在前面的示例中所看到的,整数溢出通常不会引起你的注意,而且不幸的是,没有好的跨平台方法来捕获它们。然而,这些实际上是更容易处理的错误;最糟糕的是通常内存管理。在 Python 中,如果你得到一个错误,你会得到一个异常,你可以捕获它。在 C 中,你实际上无法优雅地处理它。以除以零为例:

$ python3 -c '1/0'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ZeroDivisionError: division by zero 

这足够简单,可以用 try: ... except ZeroDivisionError: ... 来捕获。另一方面,如果 C 中出现错误,它将杀死整个进程。但调试 C 代码是 C 编译器调试器的作用,要找到错误的原因,可以使用在第十一章“调试 – 解决错误”中讨论的 faulthandler 模块。现在,让我们看看我们如何从 C 中正确地抛出错误:

static PyObject* count_eggs(PyObject *self, PyObject *args){
    PyErr_SetString(PyExc_RuntimeError, "Too many eggs!");
    return NULL;
} 

当执行此操作时,它实际上会运行 raise RuntimeError('Too many eggs!')。语法略有不同——使用 PyErr_SetString 而不是 raise——但基本原理相同。

从 C 调用 Python – 处理复杂类型

我们已经看到了如何从 Python 调用 C 函数,但现在让我们尝试从 C 调用 Python 并返回。我们不会使用现成的 sum 函数,而是将构建一个自己的函数,它使用回调并处理任何类型的可迭代对象。虽然这听起来足够简单,但实际上确实需要一点类型处理,因为您只能期望作为参数的 PyObject*。这与简单的类型(如整数、字符和字符串)相反,这些类型会立即转换为原生 Python 版本。

为了清晰起见,这只是一个被拆分成多个部分的单个函数。

首先,我们从 include 函数签名开始,并声明我们需要的变量。请注意,totalcallback 的值是默认值,以防这些参数未指定:

#include <Python.h>

static PyObject* custom_sum(PyObject* self, PyObject* args){
    long long int total = 0;
    int overflow = 0;
    PyObject* iterator;
    PyObject* iterable;
    PyObject* callback = NULL;
    PyObject* value;
    PyObject* item; 

现在我们解析一个 PyObject*,后面可以跟一个可选的 PyObject* 和一个 long long int。这由 O|OL 参数指定。结果将存储在 iterablecallbacktotal 的内存地址中(& 发送变量的内存地址):

 if(!PyArg_ParseTuple(args, "O|OL", &iterable, &callback, &total)){
        return NULL;
    } 

我们检查是否可以从可迭代对象中创建一个迭代器。这在 Python 中相当于 iter(iterable)

 iterator = PyObject_GetIter(iterable);
    if(iterator == NULL){
        PyErr_SetString(PyExc_TypeError,
                "Argument is not iterable");
        return NULL;
    } 

接下来,我们检查回调是否存在或未指定。如果已指定,检查它是否可调用:

 if(callback != NULL && !PyCallable_Check(callback)){
        PyErr_SetString(PyExc_TypeError, "Callback is not callable");
        return NULL;
    } 

在遍历可迭代对象时,如果我们有一个可用的回调,我们将调用它。否则,我们只需使用item作为value

 while((item = PyIter_Next(iterator))){
        if(callback == NULL){
            value = item;
        }else{
            value = PyObject_CallFunction(callback, "O", item);
        } 

我们将值添加到total中并检查溢出:

 total += PyLong_AsLongLongAndOverflow(value, &overflow);
        if(overflow > 0){
            PyErr_SetString(PyExc_RuntimeError, "Integer overflow");
            return NULL;
        }else if(overflow < 0){
            PyErr_SetString(PyExc_RuntimeError, "Integer underflow");
            return NULL;
        } 

如果我们确实使用了回调,由于它现在是一个单独的对象,我们将减少该值的引用计数。

我们还需要取消引用item和迭代器。忘记这样做会导致内存泄漏,因为它会减少 Python 垃圾回收器的引用计数。

因此,始终确保在使用PyObject*类型后调用PyDECREF函数:

 if(callback != NULL){
            Py_DECREF(value);
        }
        Py_DECREF(item);
    }
    Py_DECREF(iterator); 

最后,我们需要将total转换为正确的返回类型并返回它:

 return PyLong_FromLongLong(total);
} 

这个函数可以通过三种不同的方式调用。当只提供一个可迭代对象时,它将求和可迭代对象并返回值。可选地,我们可以传递一个回调函数,该函数将在求和之前应用于可迭代对象中的每个值。作为第二个可选参数,我们可以指定初始值:

>>> x = range(10)
>>> custom_sum(x)
45
>>> custom_sum(x, lambda y: y + 5)
95
>>> custom_sum(x, lambda y: y + 5, 5)
100 

另一个重要的问题是,尽管我们在将值转换为long long int时捕获了溢出错误,但此代码仍然不安全。如果我们对两个非常大的数字(接近long long int限制)求和,我们仍然会溢出:

>>> import spam

>>> n = (2 ** 63) - 1
>>> x = n,
>>> spam.sum(x)
9223372036854775807
>>> x = n, n
>>> spam.sum(x)
-2 

在这种情况下,您可以通过执行类似if(value > INT_MAX - total)的操作来测试这一点,但这个解决方案并不总是适用,因此在使用 C 时,最重要的是要意识到溢出和下溢。

练习

外部库的可能性是无限的,所以你可能已经有了关于要实现什么的一些想法。如果没有,这里有一些灵感:

  • 尝试使用ctypesCFFI和本地扩展对数字列表进行排序。您可以使用stdlib中的qsort函数。

  • 尝试通过添加适当的错误处理来提高我们创建的custom_sum函数的安全性,以处理溢出/下溢问题。此外,当对多个数字求和时,如果只有溢出或下溢,需要捕获这些错误。

这些练习应该是利用您新获得的知识做一些有用事情的一个很好的起点。如果您正在寻找更多本地的 C/C++示例,我建议查看 CPython 源代码。有许多示例可供参考:github.com/python/cpython/tree/main/Modules。我建议从一个相对简单的模块开始,例如bisect模块。

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。鼓励您提交自己的解决方案,并从他人的替代方案中学习。

摘要

在本章中,您学习了在 C/C++中编写和使用扩展。作为一个快速回顾,我们涵盖了:

  • 使用ctypes加载外部(系统)库,例如stdlib

  • 使用ctypesCFFI创建和处理复杂的数据结构

  • 使用 ctypesCFFI 处理数组

  • 结合 C 和 Python 函数

  • 关于数字类型、数组、溢出和其他错误处理的注意事项

尽管你现在可以创建 C/C++ 扩展,但我仍然建议如果可能的话避免使用它们,因为很容易出现错误。即使是本章中的代码示例也没有处理许多可能的错误场景,而且与 Python 中的错误不同,如果这些错误发生在 C 中,它们可能会完全杀死你的解释器或应用程序。

如果你的目标是更好的性能,那么我建议尝试使用 numbacython。然而,如果你确实需要与非 Python 库进行互操作,这些库是不错的选择。这些通用库的几个例子包括 TensorFlow 和 OpenCV,它们在许多语言中都有可用,并且提供了 Python 封装以方便使用。

在构建本章的示例时,你可能已经注意到我们使用了 setup.py 文件并从 setuptools 库中导入。这就是下一章将要介绍的内容:将你的代码打包成可安装的 Python 库并在 Python 包索引中分发。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

第十八章:打包 – 创建您自己的库或应用程序

到目前为止的章节已经涵盖了如何编写、测试和调试 Python 代码。有了这一切,只剩下了一件事:打包和分发您的 Python 库和应用程序。为了创建可安装的包,我们将使用随 Python 一起打包的 setuptools 包。如果您之前创建过包,可能会记得 distributedistutils2,但非常重要的一点是,这些已经被 setuptoolsdistutils 取代,您不应再使用它们!

我们有几种包类型和打包方法需要涵盖:

  • 使用 PEP 517/518 的 pyproject.toml 文件构建新式包

  • 使用 setup.py 文件进行高级包构建

  • 包类型:wheels、eggs、源码包和其他

  • 安装可执行文件和自定义 setuptools 命令

  • 包含 C/C++ 扩展的包

  • 在包上运行测试

包的介绍

当涉及到打包时,Python 的历史非常混乱。在 Python 存在的几十年里,我们有过 distutilsdistutils2distributebuildoutsetuptoolspackagingdistlibpoetry 以及其他几个库。所有这些项目都是出于改善现状的最好意图而开始的,不幸的是,它们的成功程度各不相同。而且,这还不包括所有不同的包类型,如 wheels、源码包和二进制包,如 eggs、Red Hat .rpm 文件和 Windows .exe/.msi 文件。

好消息是,尽管打包有着复杂的历史,但近几年来情况已经有所改善,并且有了很大的进步。构建包变得更加容易,现在维护一个稳定的项目依赖状态也变得容易可行。

包的类型

Python 有(曾经有)许多种包类型,但如今真正重要的只有两种:

  • Wheels:这些是小型、可直接安装的 .zip 文件,扩展名为 .whl,只需解压即可,而无需像构建源包那样进行编译。此外,这些可以是源码或二进制,具体取决于包的类型。

  • 源码包:这些可以有许多扩展,例如 .zip.tar.tar.gz.tar.bz2.tar.xz.tar.Z。它们包含安装和构建包所需的 Python/C 等 source 和数据文件。

现在我们将更详细地介绍这些格式。

Wheels – 新的 eggs

对于纯 Python 包,源码包一直足够使用。然而,对于二进制 C/C++ 包,这却是一个不太方便的选项。C/C++ 源码包的问题在于需要编译,这需要不仅需要一个编译器,还经常需要系统上的头文件和库。而二进制包通常不需要编译器或安装任何其他库,因为所需的库已经包含在包中;Python 本身就足够了。

传统上,Python 使用 .egg 格式用于二进制软件包。.egg 格式本质上是一个重命名的 .zip 文件,包含源代码和元数据,在二进制 .egg 文件的情况下还包含编译后的二进制文件。虽然这个想法很棒,但 .egg 文件从未真正完全解决问题;一个 .egg 文件可能匹配多个环境,但这并不能保证它实际上能在那些系统上运行。

正因如此,引入了 wheel 格式(PEP-427),这是一种可以包含源代码和二进制文件的软件包格式,可以在 Windows、Linux、macOS X 和其他系统上安装,而无需编译器。

作为额外的好处,轮子可以更快地安装纯 Python 和二进制软件包,因为没有构建、安装或后处理步骤,并且它们更小。安装轮子只需将 .whl 文件提取到 Python 环境的 site-packages 目录,然后您就完成了。

二进制轮子相对于蛋文件(eggs)解决的最大问题是文件命名。使用轮子(wheels),这一过程简单且一致,因此仅通过文件名就可以检查是否存在兼容的轮子。文件使用以下格式:

{distribution}-{version}[-{build tag}]-{python tag}-{abi tag}-{platform tag}.whl 

让我们深入探讨这些字段,看看它们的可能值。但首先,语法。花括号 {}} 之间的名称是字段,方括号 [] 之间的括号表示可选字段:

  • 分发: 软件包的名称,例如 numpyscipy 等。

  • 版本: 该软件包的版本,例如 1.2.3

  • 构建标签: 作为多个匹配轮子的一个可选的区分器。

  • python 标签: Python 版本和平台。对于 CPython 3.10,这将是一个 cp310。对于 PyPy 3.10,这将是一个 pp310。更多关于这个话题的信息可以在 PEP-425 中找到。对于纯 Python 软件包,这可以是支持 Python 3 的 py3,或者支持 Python 2 和 Python 3 的通用软件包的 py2.py3

  • abi 标签: ABI(应用程序二进制接口)标签表示所需的 Python ABI。例如,cp310d 表示启用调试的 CPython 3.10。更多详情可以在 PEP-3149 中找到。对于纯 Python 软件包,这通常是 none

  • 平台标签: 平台标签告诉您它将在哪些操作系统上运行。这可以是 32 位或 64 位 Windows 的 win32win_amd64。对于 macOS X,这可能类似于 macosx_11_0_arm64。对于纯 Python 软件包,这通常是 any

在所有这些不同选项中,你可能已经猜到,为了支持许多平台,你需要许多轮子。这实际上是一件好事,因为它解决了蛋文件(egg files)的一个大问题,即可安装文件并不总是能正常工作。如果你能找到适合你系统的匹配轮子,你可以期待它没有任何问题就能运行。

这些轮子的构建时间是一个缺点。例如,numpy 包在写作时拥有 29 个不同的轮子。每个轮子的构建时间在 15 到 45 分钟之间,如果我们按每个轮子平均 30 分钟来计算,那么每个 numpy 发布版本就需要 15 小时的构建时间。当然,它们可以并行构建,但这仍然是一个需要考虑的因素。

尽管我们有 29 个不同的轮子可供 numpy 使用,但仍有许多平台,如 FreeBSD,没有支持,因此源包的需求仍然存在。

源包

源包是所有 Python 包类型中最灵活的。它们包含源代码、构建脚本,以及可能包含许多其他文件,如文档和测试。这些允许你在你的系统上构建和/或编译包。源包可能有多种不同的扩展名,如 .zip.tar.bz2,但基本上是整个项目目录和相关文件的一个略微精简版本。

由于这些包通常不仅包含直接的源文件,还包含测试和文档,因此它们占用的空间更多,安装速度比轮子慢。以 numpy 的源包为例,我目前看到有 1941 个文件,而轮子只包含 710 个文件。这种差异实际上也可能是有用的,因为你可能需要测试文件或文档。如果你希望跳过二进制文件,因为你希望有原始源代码,或者如果你想为你的特定系统进行优化构建,你可以选择通过告诉 pip 跳过二进制文件来安装源文件。

从源代码而不是二进制文件安装包可以导致二进制文件更小和/或更快,因为它只会链接到系统上可用的库,而不是通用库。

连接到 PostgreSQL 数据库的 psycopg 包是这方面的一个好例子。它提供了三种可能的安装选项,通过 pip 安装,优先级从高到低:

  • psycopg[c]: 用于本地构建和编译的 Python 和 C 源代码

  • psycopg[binary]: Python 源代码和预编译的二进制文件

  • psycopg: 仅 Python 源代码;在这种情况下,你需要在你的系统上安装 libpq 库,它通过 ctypes 访问

要安装而不使用任何预编译的二进制文件:

$ pip3 install --no-binary ... 

由于源包附带构建脚本,仅安装本身就可能存在风险。虽然轮子只会解压而不会运行任何内容,但源包在安装过程中会执行构建脚本。曾经有一个名为俄罗斯轮盘赌的包在 PyPI 上,安装时会有 1/6 的几率删除系统上的文件,以此来展示这种方法的危险性。

我个人认为,在安装过程中执行构建脚本的安全风险远不如在计划安装之前对包进行审查重要。无论您是否实际执行代码,在您的系统上安装可能有害的包都是一件坏事。

包工具

那么,我们今天还需要和使用的安装工具是什么?

distributedistutilsdistutils2包已被setuptools largely 取代。要安装基于setup.py的源包,通常需要setuptools,而setuptoolspip捆绑在一起,因此您应该已经具备了这个要求。当涉及到安装 wheel 时,需要wheel包;这也方便地捆绑在pip中。在大多数系统上,这意味着一旦安装了 Python,您就应该拥有安装额外包所需的一切。

不幸的是,Ubuntu Linux 发行版是一个值得注意的例外,它附带了一个损坏的 Python 安装,缺少pipensurepip命令。这可以通过单独安装pip来修复:

$ apt install python3-pip 

如果这不起作用,您可以通过运行get-pip.py脚本安装pipbootstrap.pypa.io/get-pip.py

由于setuptoolspip在过去几年中已经得到了相当多的开发,因此无论如何升级这些包都是一个好主意:

$ pip3 install --upgrade pip setuptools wheel 

现在我们已经安装了所有先决条件,我们可以继续构建自己的包。

包版本控制

虽然有众多版本控制方案可用,但许多 Python 包以及 Python 本身都使用 PEP-440 进行版本规范。

有些人坚持使用稍微严格一点的版本,称为语义版本控制SemVer),但两者在很大程度上是兼容的。

简短而简化的解释是使用如 1.21.2.3 这样的版本号。例如,查看版本 1.2.3

  • 1 是主版本,表示破坏 API 的不兼容更改

  • 2 是次要版本,表示向后兼容的功能添加

  • 3 是补丁版本,用于向后兼容的 bug 修复

在主版本的情况下,一些库选择使版本非连续,并使用日期作为版本,例如 2022.5

预发布版本,如 alpha 和 beta,可以通过次要版本中的字母来指定。选项有 a 表示 alpha,b 表示 beta,rc 表示发布候选。例如,对于 1.2 alpha 3,结果是 1.2a3

在语义版本控制的情况下,这通过在末尾添加预发布标识符来处理,例如 1.2.3-beta1.2.3-beta.1 用于多个 beta 版本。

最后,PEP-440 允许使用后发布版本,例如使用 1.2.post3 代替 1.2.3 进行次要 bug 修复,以及类似地使用 1.2.dev2 进行开发版本。

无论你使用哪种版本控制系统,在开始你的项目之前都要仔细考虑。不考虑未来可能会在长期内造成问题。一个例子是 Windows。一些应用程序在支持 Windows 10 时遇到了麻烦,因为版本号的字母顺序将 Windows 10 放在 Windows 8 之下(毕竟,1 小于 8)。

构建包

Python 包传统上使用包含(部分)构建脚本的 setup.py 文件进行构建。这种方法通常依赖于 setuptools,并且仍然是大多数包的标准,但如今我们有更简单的方法可用。如果你的项目要求不高,你可以使用一个小的 pyproject.toml 文件,这可能会更容易维护。

让我们尝试这两种方法,看看构建一个基本的 Python 包有多容易。

使用 pyproject.toml 进行打包

pyproject.toml 文件允许根据所使用的工具轻松地进行打包。它是在 2015 年通过 PEP-517PEP-518 引入的。这种方法是为了改进 setup.py 文件而创建的,通过引入构建时依赖项、自动配置,并使其更容易以 DRY(不要重复自己)的方式工作。

TOML 代表“Tom 的明显、最小化语言”,在某种程度上与 YAML 和 INI 文件相似,但更简单。由于它是一种如此简单的语言,它可以很容易地包含在像 pip 这样的包中,几乎没有开销。这使得它在需要扁平结构且不需要复杂功能(如继承和包含)的场景中非常完美。

在我们继续之前,我们需要澄清一些事情。当我们谈论 setup.py 文件时,我们通常实际上是在谈论 setuptools 库。与 Python 打包在一起的 distutils 库也可以使用,但由于 pip 依赖于 setuptools,它通常是更好的选择;它具有更多功能,并且与 pip 一起更新,而不是与你的 Python 安装一起更新。

类似于 setup.py 通常意味着 setuptools,使用 pyproject.toml 我们也有多个库可用于构建和管理 PEP-517 风格的包。这种方法创建标准并依赖社区项目进行实现,在 Python 中过去已经工作得相当好,这使得它是一个明智的选择。这种方法的例子是 Python 网络服务器网关接口(WSGI),它作为 PEP-333 引入,目前有几种优秀的实现可用。

PEP-517 的参考解决方案是 pep517 库,它虽然可用但功能相当有限。另一个选择是 build 库,由 Python 包权威机构(PyPA)维护,它也维护了 Python 包索引(PyPI)。虽然这个库可用,但在功能方面也相当有限,我并不推荐使用。

在我看来,最好的选择无疑是 poetry 工具。poetry 工具不仅为你处理包的构建,还负责:

  • 并行快速安装依赖项

  • 创建虚拟环境

  • 为可运行的脚本创建易于访问的入口点

  • 通过指定智能版本约束(例如,主版本和次版本,将在本章后面详细说明)来管理依赖项

  • 构建包

  • 发布到 PyPI

  • 使用 pyenv 处理多个 Python 版本

对于大多数情况,pyproject.toml 可以完全替代传统的 setup.py 文件,但也有一些情况你需要一些额外的工具。

在构建 C/C++ 扩展和其他情况时,你可能需要一个 setup.py 文件或以其他方式指定如何构建扩展。这个选项之一是使用 poetry 工具并将构建脚本添加到 pyproject.toml 工具中。我们将在关于 C/C++ 扩展的部分进一步讨论这个问题。

可编辑安装(即 pip install -e ...)直到 2021 年才成为可能,但已被 PEP-660 解决。

创建一个基本包

让我们从使用 poetry 在当前目录中创建一个基本的 pyproject.toml 文件开始:

$ poetry new .
Created package t_00_basic_pyproject in . 

由于我们的父目录名为 t_00_basic_pyprojectpoetry 自动将其作为新项目名称。或者,你也可以执行 poetry new some_project_name,它将为你创建一个目录。

poetry 命令为我们创建了以下文件:

README.rst
pyproject.toml
t_00_basic_pyproject
t_00_basic_pyproject/__init__.py
tests
tests/__init__.py
tests/test_t_00_basic_pyproject.py 

这是一个非常简单的模板,包含足够的内容来启动你的项目。t_00_basic_pyproject/__init__.py 文件包含版本(默认为 0.1.0)和 tests/test_t_00_basic_pyproject.py 文件作为示例测试来测试这个版本。然而,更有趣的部分是 pyproject.toml 文件,所以现在让我们看看它:

[tool.poetry]
name = "T_00_basic_pyproject"
version = "0.1.0"
description = ""
authors = ["Rick van Hattem <Wolph@wol.ph>"]

[tool.poetry.dependencies]
python = "³.10"

[tool.poetry.dev-dependencies]
pytest = "⁵.2"

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" 

如你所见,poetry 已经自动配置了名称和版本。它还通过查看我的系统上的 git 配置将我添加为作者。你可以通过运行以下命令轻松配置:

$ git config --global user.email 'your@email.tld'
$ git config --global user.name 'Your Name' 

接下来,我们可以看到它自动将 Python 3.10 设置为要求,并添加 pytest 5.2 作为开发依赖项。对于构建包,它添加了 poetry-core 作为依赖项,这是 poetrysetuptools 等价物。

安装开发包

对于开发目的,我们通常以 可编辑模式 安装包。在可编辑模式下,包不会被复制到你的 site-packages 目录,而是创建到你的源目录的链接,因此所有对源目录的更改都会立即生效。如果没有可编辑模式,每次你对包进行更改时,你都需要执行 pip install,这对于开发来说非常不方便。

使用 pip,你可以通过以下命令以可编辑模式安装:

$ pip3 install --editable <package-directory> 

对于在当前目录中安装,你可以使用 . 作为目录,结果如下:

$ pip3 install --editable . 

使用 poetry 命令,以可编辑模式安装(或对于 poetry 的旧版本,可能是类似的功能)会自动发生。它还为我们处理虚拟环境的创建,同时使用 pyenv 来处理 pyproject.toml 文件中指定的 Python 版本。要安装包及其所有依赖项,您只需运行:

$ poetry install 

如果您希望直接访问创建的虚拟环境中的所有命令,您可以使用:

$ poetry shell
(name-of-your-project) $ 

poetry shell 命令会启动一个新的 shell,将当前项目的名称添加到您的命令行前缀中,并将虚拟环境脚本目录添加到您的 PATH 环境变量中。这会导致 pythonpip 等命令在您的虚拟环境中执行。

添加代码和数据

在基本示例中,我们没有指定哪个目录包含源代码,或者 t_00_basic_pyproject 目录必须包含在目录中。默认情况下,这会隐式处理,但我们可以修改 pyproject.toml 文件来显式包含目录或文件模式作为 Python 源:

[tool.poetry]
...
packages = [
    {include="T_00_basic_pyproject"},
    {include="some_directory/**/*.py"},
] 

注意,添加 packages 参数会禁用自动检测包,因此您需要在此处指定所有包含的包。

要包含其他数据文件,例如文档,我们可以使用 includeexclude 参数。exclude 参数会覆盖由 packages 参数包含的文件:

[tool.poetry]
...
include = ["CHANGELOG.rst"]
exclude = ["T_00_basic_pyproject/local.py"] 

对于一个基本项目,您可能不需要查看这个。但,就像往常一样,明确总是优于隐式,所以我建议您快速查看一下,以防止意外地将错误的文件包含在您的包中。

添加可执行命令

一些包,如 numpy,仅是库,这意味着它们被导入但没有可运行的命令。其他包,如 pippoetry,包含可运行的脚本,在安装过程中作为新命令安装。毕竟,当 poetry 包被安装后,您可以从 shell 中使用 poetry 命令。

要创建我们自己的命令,我们需要指定新命令的名称、模块和相应的函数,这样 poetry 就会知道要运行什么。例如:

[tool.poetry.scripts]
our_command = 'T_00_basic_pyproject.main:run' 

这将执行名为 T_00_basic_pyproject/main.py 的文件中的 run() 函数。安装包后,您可以从您的 shell 中执行 our_command 来运行脚本。在 poetry 开发期间,您可以使用 poetry run our_command,这将自动在 poetry 创建的虚拟环境中运行命令。

管理依赖项

我们创建的 pyproject.toml 文件已经为开发和构建添加了一些要求,但您可能还想向项目中添加其他依赖项。例如,如果我们想添加进度条,我们可以运行以下命令:

$ poetry add progressbar2
Using version ⁴.0.0 for progressbar2
... 

这会自动为我们安装 progressbar2 包,并将其添加到 pyproject.toml 文件中,如下所示:

[tool.poetry.dependencies]
...
progressbar2 = "⁴.0.0" 

此外,poetry 将创建或更新一个 poetry.lock 文件,其中包含已安装的确切包版本,因此在新环境中可以轻松地重现安装。在上面的例子中,我们只是告诉 poetry 安装 progressbar2 的任何版本,这导致 poetry 将版本要求设置为 ⁴.0.0,但我们也可以放宽这些要求,这样 poetry 将自动安装包的最新补丁、小版本或主要版本。

默认情况下,poetry 将将依赖项添加到 [tool.poetry.dependencies] 部分,但您也可以使用 --dev-D 命令行参数将它们添加为开发依赖项。如果您想添加其他类型的依赖项,例如 build-system 依赖项或测试依赖项,那么您需要手动编辑 pyproject.toml 文件。

版本指定符期望与 SemVer 兼容的版本,并按以下方式工作。为了允许更新非主要版本,您可以使用连字符 (^)。它查看第一个非零数字,因此 ¹.2.3 的行为与 ⁰.1.2 不同,如下所示:

  • ¹.2.3 表示 >=1.2.3<2.0.0

  • ¹.2 表示 >=1.2.0<2.0.0

  • ¹ 表示 >=1.0.0<2.0.0

  • ⁰.1.2 表示 >=0.1.2<0.2.0

接下来是波浪号 (~) 要求,它们指定了最小版本,但允许进行小版本更新。它们比连字符版本简单一些,实际上指定了数字应该从哪里开始:

  • ~1.2.3 表示 >=1.2.3<1.3.0>

  • ~1.2 表示 >=1.2.0<1.3.0>

  • ~1 表示 >=1.0.0<2.0.0。请注意,上述两个选项都允许进行小版本更新,而这是唯一允许进行主要版本更新的选项。

使用星号 (*) 也可以进行通配符要求:

  • 1.2.* 表示 >=1.2.0<1.3.0

  • 1.* 表示 >=1.0.0<2.0.0

版本控制系统与 requirements.txt 中使用的格式兼容,允许使用如下版本:

  • >= 1.2.3

  • >= 1.2.3, <1.4.0

  • >= 1.2.3, <1.4.0, != 1.3.0

  • != 1.5.0

我个人更喜欢这种最后的语法,因为它很清晰,不需要太多的先验知识,但您当然可以使用您喜欢的任何一种。默认情况下,poetry 在添加依赖项时会使用 ¹.2.3 格式。

现在,假设我们有一个类似 progressbar2 = "³.5" 的要求,并且我们在 poetry.lock 文件中有 3.5.0 版本。如果我们运行 poetry install,它将安装确切的 3.5.0 版本,因为我们知道这个版本是好的。

作为开发者,您可能希望将那个依赖项更新到新版本,以便测试新版本是否也能正常工作。这也是我们可以向 poetry 提出的要求:

$ poetry update
Updating dependencies
...
Package operations: 0 installs, 1 update, 0 removals
  • Updating progressbar2 (3.5.0 -> 3.55.0) 

现在 poetry 将会自动在 pyproject.toml 的约束范围内升级包并更新 poetry.lock 文件。

构建包

现在我们已经配置了 pyproject.toml 文件和所需的依赖项,我们可以使用 poetry 构建包。幸运的是,这非常简单。构建包只需要一个命令:

$ poetry build
Building T_00_basic_pyproject (0.1.0)
  - Building sdist
  - Built T_00_basic_pyproject-0.1.0.tar.gz
  - Building wheel
  - Built T_00_basic_pyproject-0.1.0-py3-none-any.whl 

只需一个命令,poetry 就为我们创建了一个源包和一个 wheel。所以,如果您一直在关注,您会意识到我们实际上可以用两个命令创建和构建一个包:poetry newpoetry build

构建 C/C++ 扩展

在我们开始本节之前,我需要提供一点免责声明。截至撰写本文时(2021 年底),构建 C/C++ 扩展并不是 poetry 的稳定和受支持的功能,这意味着它将来可能会被不同的机制所取代。然而,目前有一个可用的解决方案用于构建 C/C++ 扩展,并且未来的版本可能会以类似的方式工作。

如果您现在正在寻找一个稳定且受良好支持的解决方案,我建议您选择基于 setup.py 的项目,这将在本章后面进行介绍。

我们需要首先修改我们的 pyproject.toml 文件,并在 [tool.poetry] 部分添加以下行:

build = "build_extension.py" 

如果您希望使用 PyPA 构建命令,请确保不要将文件命名为 build.py

一旦完成,当我们运行 poetry build 时,poetry 将会执行 build_extension.py 文件,因此现在我们需要创建 build_extension.py 文件,以便 setuptools 为我们构建扩展:

import pathlib
import setuptools

# Get the current directory
PROJECT_PATH = pathlib.Path(__file__).parent

# Create the extension object with the references to the C source
sum_of_squares = setuptools.Extension('sum_of_squares', sources=[
    # Get the relative path to sum_of_squares.c
    str(PROJECT_PATH / 'sum_of_squares.c'),
])

def build(setup_kwargs):
    setup_kwargs['ext_modules'] = [sum_of_squares] 

此脚本基本上与您会放入 setup.py 文件中的内容相同。原因是它实际上是在注入相同的函数调用。如果您仔细查看 build() 函数,您会看到它更新了 setup_kwargs 并在该函数中设置了 ext_modules 项。该参数直接传递给 setuptools.setup() 函数。本质上,我们只是在模拟使用 setup.py 文件。

注意,对于我们的 C 文件,我们使用了来自 第十七章C/C++ 扩展、系统调用和 C/C++ 库sum_of_squares.c 文件。您会看到其余的代码在很大程度上与我们在 第十七章 中使用的 setup.py 文件相似。

当我们执行 poetry build 命令时,poetry 将会内部调用 setuptools 并构建二进制轮:

$ poetry build
Building T_01_pyproject_extensions (0.1.0)
  - Building sdist
  - Built T_01_pyproject_extensions-0.1.0.tar.gz
  - Building wheel
running build
running build_py
creating build
...
running build_ext
building 'sum_of_squares' extension
... 

这样,我们就完成了。我们现在有一个包含构建的 C 扩展的 wheel 文件。

使用 setuptools 和 setup.py 或 setup.cfg 打包

setup.py 文件是构建 Python 包的传统方法,但仍然被广泛使用,并且是一种创建包的非常灵活的方法。

第十七章 已经向我们展示了构建扩展时的几个示例,但让我们重申并回顾一下实际上最重要的部分做了什么。您将在整个章节中使用的核心函数是 setuptools.setup()

Python 附带的标准distutils包在大多数情况下也足够使用,但我仍然推荐使用setuptoolssetuptools包具有许多distutils所缺乏的出色功能,并且由于它包含在pip中,几乎所有的 Python 环境都会提供setuptools

在我们继续之前,确保您拥有pipwheelsetuptools的最新版本总是一个好主意:

$ pip3 install -U pip wheel setuptools 

setuptoolsdistutils包在过去几年中发生了显著变化,2014 年之前编写的文档/示例很可能已经过时。请小心不要实现已弃用的示例,并且我建议跳过任何使用distutils的文档/示例。

作为setup.py文件的替代或补充,您还可以使用setup.cfg文件来配置所有元数据。这使用 INI 格式,对于不需要(或不想)Python 语法开销的简单元数据来说可能更方便一些。

您甚至可以选择仅使用setup.cfg并跳过setup.py;然而,如果您这样做,您将需要一个单独的构建工具。对于这些情况,我建议安装 PyPA 的build库:

$ pip3 install build
... 

创建一个基本包

现在我们已经具备了所有先决条件,让我们使用setup.py文件创建一个包。虽然最基础的setuptools.setup()调用在技术上不需要任何参数,但如果您计划将包发布到 PyPI,您确实应该包括至少nameversionpackagesurlauthorauthor_email字段。以下是一个包含这些字段的非常基础的示例:

import setuptools

if __name__ == '__main__':
    setuptools.setup(
        name='T_02_basic_setup_py',
        version='0.1.0',
        packages=setuptools.find_packages(),
        url='https://wol.ph/',
        author='Rick van Hattem',
        author_email='wolph@wol.ph',
    ) 

作为将这些配置为setup()参数的替代方案,您还可以使用一个setup.cfg文件,它使用 INI 格式,但实际工作方式与之前相同:

[metadata]
name = T_03_basic_setup_cfg
version = 0.1.0
url='https://wol.ph/',
author='Rick van Hattem',
author_email='wolph@wol.ph',

[options]
packages = find: 

setup.cfg的主要优势是它比setup.py文件更简洁、更简单的文件格式。例如,看看packages部分;setuptools.find_packages()find:要详细得多。

缺点是您需要将一个setup.cfg文件与一个setup.pypyproject.toml文件配对,才能构建它。仅凭setup.cfg本身不足以构建一个包,这使得setup.cfg成为将元数据与设置代码分离的一种既简洁又清晰的方式。此外,许多库如pytesttox都原生支持setup.cfg文件,因此您也可以通过该文件进行配置。

为了将setup.cfg和/或setup.pypyproject.toml文件配对,我们需要将这些行添加到pyproject.toml文件中:

[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta" 

注意,仅凭一个pyproject.toml文件本身并不能为您提供poetry支持;为了获得poetry支持,您需要添加一个[tool.poetry]部分。

安装开发版本的包

要安装用于本地开发的包,我们可以再次使用 -e--editable 标志,如本章中 poetry 部分所述。这将在你的源目录和 site-packages 目录之间创建一个链接,以便实际使用源文件,而不是让 setuptools 将所有源文件复制到 site-packages 目录。

简而言之,从项目目录中,你可以使用 setup.py 文件:

$ python3 setup.py develop 

或者 pip

$ pip3 install -e . 

添加包

在基本示例中,你可以看到我们使用 find_packages() 作为包的参数。这个函数会自动检测所有源目录,通常作为默认值是足够的,但有时你需要更多的控制。find_packages() 函数还允许你添加 includeexclude 参数,如果你希望从包中排除测试和其他文件,例如这样:

setuptools.find_packages(
    include=['a', 'b', 'c.*'],
    exclude=['a.excluded'],
) 

find_packages() 的参数也可以翻译到具有不同语法的 setup.cfg 文件中:

[options]
packages = find:

[options.packages.find]
include =
    a
    b
    c.*
exclude = a.excluded 

添加包数据

在大多数情况下,你可能不需要包含包数据,例如测试数据或文档文件,但有些情况下需要额外的文件。例如,Web 应用程序可能附带 htmljavascriptcss 文件。

在包含与你的包一起的额外文件方面,有几个不同的选项。首先,了解你的源包默认包含哪些文件是很重要的:

  • 包目录中的 Python 源文件及其所有子目录

  • setup.pysetup.cfgpyproject.toml 文件

  • 如果有的话,包括自述文件,例如 README.rstREADME.txtREADME.md

  • 包含包名称、版本、入口点、文件哈希等元数据文件的元数据文件

对于 Python 轮子(wheels),列表甚至更短,默认情况下只打包 Python 源文件和元数据文件。

这意味着如果我们想包含其他文件,我们需要指定这些文件需要被添加。我们有两种不同的选项来将其他类型的数据添加到我们的包中。

首先,我们可以将 include_package_data 标志作为 setup() 的参数启用:

 setuptools.setup(
        ...
        include_package_data=True,
    ) 

一旦启用该标志,我们就可以在 MANIFEST.in 文件中指定我们想要的文件模式。此文件包含包含、排除和其他模式的模式。includeexclude 命令使用模式进行匹配。这些模式是 glob-style 模式(有关文档,请参阅 glob 模块:docs.python.org/3/library/glob.html),对于 includeexclude 命令都有三种变体:

  • include/exclude:这些命令只针对给定的路径,不涉及其他

  • recursive-include/recursive-exclude:这些命令与 include/exclude 命令类似,但会递归地处理给定的路径

  • global-include/global-exclude:对这些要非常小心,因为它们会在源树中的任何位置包含或排除这些文件

除了 include/exclude 命令之外,还有两个其他命令:graftprune 命令,它们包括或排除给定目录下的所有目录。这对于测试和文档可能很有用,因为它们可以包含非标准文件。除了这些示例之外,几乎总是更好的做法是明确包含你需要的文件,并忽略所有其他文件。以下是一个示例 MANIFEST.in 文件:

# Include all documentation files
include-recursive *.rst
include LICENSE

# Include docs and tests
graft tests
graft docs

# Skip compiled python files
global-exclude *.py[co]

# Remove all build directories
prune docs/_build
prune build
prune dist 

或者,我们可以使用 package_dataexclude_package_data 参数,并将它们添加到 setup.py 中:

 setuptools.setup(
        ...
        package_data={
            # Include all documentation files
            '': ['*.rst'],

            # Include docs and tests
            'tests': ['*'],
            'docs': ['*'],
        },
        exclude_package_data={
            '': ['*.pyc', '*.pyo'],
            'dist': ['*'],
            'build': ['*'],
        },
    ) 

自然,这些也有等效的 setup.cfg 格式:

[options]
...
include_package_data=True,

[options.package_data]
# Include all documentation files
* = *.rst

# Include docs and tests
tests = *
docs = *

[options.exclude_package_data]
* = *.pyc, *.pyo
dist = *
build = * 

注意,这些参数使用 package_data 而不是 data 是有原因的。所有这些都需要你使用一个包。这意味着数据只有在它位于一个合适的 Python 包(换句话说,如果它包含一个 __init__.py)中时才会被包含。

你可以选择你喜欢的任何格式和方法。

管理依赖项

当你使用 setup.pysetup.cfg 文件时,你不会得到 poetry 提供的简单依赖项管理。添加新的依赖项并不困难,除了你需要添加要求并自己安装包,而不是在一个命令中完成所有操作。

就像 pyproject.toml 一样,你可以声明多种类型的依赖项:

  • [build-system] requires:这是构建项目所需的依赖项。这些通常是 setuptoolswheel,对于基于 setuptools 的包;对于 poetry,这将是一个 poetry-core

  • [options] install_requires:这些是运行包所需的依赖项。例如,像 pandas 这样的项目将需要 numpy

  • [options.extras_require] NAME_OF_EXTRA:如果你的项目在特定情况下有可选的依赖项,额外的依赖项可以帮助。例如,要安装具有 redis 支持的 portalocker,你可以运行此命令:

$ pip3 install "portalocker[redis]" 

如果你有过创建包的经验,你可能想知道为什么这里没有显示 tests_require。原因是自从添加了 extras_require 之后,就不再真正需要它了。你可以简单地添加一个额外的 testsdocs 的要求。

下面是一个向 setup.py 文件添加一些要求的示例:

setuptools.setup(
    ...
    setup_requires=['pytest-runner'],
    install_requires=['portalocker'],
    extras_require={
        'docs': ['sphinx'],
        'tests': ['pytest'],
    },
) 

setup.cfg 文件中,这是等效的:

[build-system]
requires =
    setuptools
    wheel

[options]
install_requires =
    portalocker

[options.extras_require]
docs = sphinx
tests = pytest 

添加可执行命令

就像基于 pyproject.toml 的项目一样,我们也可以使用 setup.pysetup.cfg 文件来指定可执行命令。要添加一个类似于我们可以运行 pipipython 命令的基本可执行命令,我们可以在 setup.py 文件中添加 entry_points

setuptools.setup(
...
    entry_points={
        'console_scripts': [
            'our_command = T_02_basic_setup_py.main:run',
        ],
    }, 

或者 setup.cfg 的等效格式:

[options.entry_points]
console_scripts =
    our_command = T_03_basic_setup_cfg.main:run 

一旦安装了这个包,你就可以从你的 shell 中运行 our_command,就像你运行 pipipython 命令一样。

从上面的示例中,您可能会想知道我们是否有除了console_scripts之外的其他选项,答案是肯定的。一个例子是distutils.commands,它可以用来向setup.py添加额外的命令。通过在那个命名空间中添加一个命令,您可以这样做:

$ python3 setup.py our_command 

然而,这种行为最突出的例子是pytest库。pytest库使用这些入口点来自动检测与pytest兼容的插件。我们可以轻松地创建自己的等效版本:

[options.entry_points]
our.custom.plugins =
    some_plugin = T_03_basic_setup_cfg.some_plugin:run 

一旦安装了这样的软件包,您可以通过importlib查询它们,如下所示:

>>> from importlib import metadata

>>> metadata.entry_points()['our.custom.plugins']
[EntryPoint(name='some_plugin', value='...some_plugin:run', ...] 

这是一个非常有用的功能,可以自动在库之间注册插件。

构建软件包

要实际构建软件包,我们有几种选择。如果可用,我个人会使用setup.py文件:

$ python3 setup.py build sdist bdist_wheel
running build
...
creating 'dist/T_02_basic_setup-0.1.0-py3-none-any.whl' and adding ... 

如果您只有setup.cfgpyproject.toml可用,您需要安装一个包来调用构建器。除了poetry之外,PyPA 还提供了一个名为build的工具,用于创建构建软件包的隔离环境:

$ python3 -m build
* Creating venv isolated environment...
...
Successfully built T_02_basic_setup-0.1.0.tar.gz and T_02_basic_setup-0.1.0-py3-none-any.whl 

轮和源包都写入到dist目录,它们已准备好发布。

发布软件包

现在我们已经构建了软件包,我们需要将它们实际发布到 PyPI。我们可以使用几种不同的选项,但让我们先讨论一些可选的软件包元数据。

添加 URL

我们的setup.pysetup.cfg文件已经包含了一个url参数,该参数将用作 PyPI 上的软件包主页。然而,我们可以通过配置project_urls设置添加更多相关的 URL,这是一个名称/URL 对的任意映射。对于settings.py

 setuptools.setup(
        ...
        project_urls=dict(
            docs='https://progressbar-2.readthedocs.io/',
        ),
    ) 

或者对于settings.cfg

[options]
project_urls=
    docs=https://progressbar-2.readthedocs.io/ 

类似地,对于使用poetrypyproject.toml

[tool.poetry.urls]
docs='https://progressbar-2.readthedocs.io/' 

PyPI trove 分类器

为了提高您的软件包在 PyPI 上的曝光度,添加一些分类器可能很有用。一些分类器,如 Python 版本和许可证,会自动为您添加,但指定您正在编写的库或应用程序的类型可能很有用。

对于对您的软件包感兴趣的人,有许多有用的分类器示例:

  • 开发状态:这可以从“规划”到“成熟”不等,告诉用户应用程序是否已准备好投入生产。当然,人们对什么是稳定或测试版的定义各不相同,所以这通常只被视为一个提示。

  • 框架:您正在使用或扩展的框架。这可能包括 Jupyter、IPython、Django、Flask 等等。

  • 主题:这是否是一个软件开发包、科学、游戏等等。

可以在 PyPI 网站上找到完整的分类器列表:pypi.org/classifiers/

上传到 PyPI

将您的软件包上传并发布到 PyPI 非常简单。也许太简单了,正如我们将在twine的案例中看到的那样。

在我们开始之前,为了防止你意外地将你的包发布到 PyPI,你应该了解 PyPI 测试服务器:packaging.python.org/en/latest/guides/using-testpypi/

poetry 的情况下,我们可以这样配置测试仓库:

$ poetry config repositories.testpypi https://test.pypi.org/simple/
$ poetry config pypi-token.testpypi <token> 

首先,如果你使用 poetry,那么它就像这样简单:

$ poetry publish --repository=testpypi 

如果你没有使用 poetry 并且不想使用兼容 poetrypyproject.toml,你需要一个不同的解决方案。PyPA 的官方解决方案是使用由 PyPA 维护的 twine 工具。在你使用 python3 -m build 构建包之后,你可以使用 twine 进行上传:

警告!如果你已经认证,此命令将立即注册并上传包到 pypi.org。这就是为什么添加了 --repository testpypi 来上传到测试 PyPI 服务器的原因。如果你省略该参数,你将立即将你的包发布到 PyPI。

$ twine upload --repository testpypi dist/* 

在你开始将你的包发布到 PyPI 之前,你应该问自己几个问题:

  • 包是否处于工作状态?

  • 你计划支持这个包吗?

不幸的是,PyPI 仓库充满了声称有可用包名的人的空包。

C/C++ 扩展

上一章和本章前面的部分已经简要介绍了 C/C++ 组件的编译,但这个主题足够复杂,足以拥有一个单独的部分,提供更深入的说明。

为了方便,我们将从一个基本的 setup.py 文件开始,该文件编译一个 C 扩展:

import setuptools

sum_of_squares = setuptools.Extension('sum_of_squares', sources=[
    # Get the relative path to sum_of_squares.c
    str(PROJECT_PATH / 'sum_of_squares.c'),
])

setuptools.setup(
    name='T_04_C_extensions',
    version='0.1.0',
    ext_modules=[sum_of_squares],
) 

在开始使用这些扩展之前,你应该学习以下 setup.py 命令:

  • build_ext:此命令构建 C/C++ 扩展,以便在包以开发/可编辑模式安装时使用。

  • clean:这个命令会清理 build 命令的结果。这通常不是必需的,但有时检测需要重新编译以工作的文件是不正确的。如果你遇到奇怪或意外的错误,请先尝试清理项目。

你可以选择使用 PyPA 的 build 命令来代替 python3 setup.py build_ext,但这并不是一个方便的开发选项。如果你使用 python3 setup.py build,你可以重用你的 build 目录并选择性地构建你的 C/C++ 扩展,这为你节省了大量时间,特别是对于较大的 C/C++ 模块。PyPA 的 build 命令旨在生成干净、可用于生产的包,强烈推荐用于部署和发布,但不推荐用于开发。

正规的 C/C++ 扩展

setuptools.Extension 类告诉 setuptools,名为 sum_of_squares 的模块使用源文件 sum_of_squares.c。这只是扩展的最简单版本——一个名称和一组源文件——但通常你需要的不仅仅是 C 文件,还需要来自其他库的一些头文件。

一个典型的例子是用于图像处理的 pillow 库。当库正在构建时,它会自动检测系统上可用的库,并基于此添加扩展。对于 .jpeg 支持,你需要安装 libjpeg;对于 .tiff 图像,你需要 libtiff;等等。由于这些扩展包括二进制库,因此需要一些额外的编译标志和 C 头文件。基本的 PIL 模块本身并不太复杂,但 setup.py 文件充满了自动检测代码,用于检测哪些 libs(库)可用,以及匹配的 C 宏定义以启用这些库。

C 中的宏是预处理器指令。这些指令在真正的编译步骤发生之前执行,这使得它们非常适合条件代码。例如,你可以有一个依赖于 DEBUG 标志的调试代码的条件块:

#ifdef DEBUG
/* your debug code here */
#endif 

如果设置了 DEBUG,代码将成为编译二进制的一部分。如果没有设置此标志,代码块将永远不会出现在最终的二进制文件中。这导致二进制文件更小、运行更快,因为这些条件是在编译时而不是在运行时发生的。

这里是一个来自较旧版本的 pillow setup.py 文件的 Extension 部分示例:

exts = [(Extension("PIL._imaging", files, libraries=libs,
    define_macros=defs))] 

新的版本相当不同,pillow 项目的 setup.py 文件目前有超过 1,000 行。freetype 扩展也有类似之处:

if feature.freetype:
    exts.append(Extension(
        "PIL._imagingft", ["_imagingft.c"], libraries=["freetype"])) 

添加和编译 C/C++ 扩展确实可能具有挑战性,所以如果你需要处理这个问题,我建议从像 pillownumpy 这样的项目中汲取灵感。它们可能有点复杂,但应该为你提供一个很好的起点,几乎涵盖了所有场景。

Cython 扩展

在处理扩展方面,setuptools 库比常规的 distutils 库要聪明一些:它实际上为 Extension 类增加了一个小技巧。还记得在第十二章中对 cython 的简要介绍吗?关于性能?setuptools 库使得编译 Cython 扩展变得更加方便。Cython 手册建议使用以下类似代码:

from setuptools import setup
from Cython.Build import cythonize

setup(
    ext_modules = cythonize("src/*.pyx")
) 

这种方法的缺点是,如果没有安装 Cython,setup.py 将会因为 ImportError 而出错:

$ python3 setup.py build
Traceback (most recent call last):
  File "setup.py", line 2, in <module>
    import Cython
ImportError: No module named 'Cython' 

为了防止这个问题,我们将让 setuptools 处理 Cython 编译:

import setuptools

setuptools.setup(
    name='T_05_cython',
    version='0.1.0',
    ext_modules=[
        setuptools.Extension(
            'sum_of_squares',
            sources=['T_05_cython/sum_of_squares.pyx'],
        ),
    ],
    setup_requires=['cython'],
) 

现在,如果需要,Cython 将会自动安装,代码将正常工作:

$ python3 setup.py build
running build
running build_ext
cythoning T_05_cython/sum_of_squares.pyx to T_05_cython/sum_of_squares.c
building 'sum_of_squares' extension
... 

然而,为了开发目的,Cython 也提供了一种更简单的方法,不需要手动构建,即 pyximport

$ python3
>>> import pyximport

>>> pyximport.install()
(None, <pyximport.pyximport.PyxImporter object at ...>)

>>> from T_05_cython import sum_of_squares

>>> sum_of_squares.sum_of_squares(10)
14 

这样就可以轻松地运行 pyx 文件而无需显式编译。

测试

第十章测试和日志记录 – 准备错误中,我们看到了许多 Python 测试系统中的几个。正如您所怀疑的,其中至少有一些与 setup.py 集成。应该注意的是,setuptools 甚至有一个专门的 test 命令(在撰写本文时),但此命令已被弃用,setuptools 文档现在建议使用 tox。虽然我是 tox 的忠实粉丝,但对于即时本地开发,它通常会带来相当大的开销。我发现直接执行 py.test 会更快,因为您可以快速测试仅更改的代码部分。

unittest

在开始之前,我们应该为我们的包创建一个测试脚本。对于实际的测试,请参阅第十章;在这种情况下,我们只是使用一个空操作测试,test.py

import unittest

class Test(unittest.TestCase):
    def test(self):
        pass 

标准的 python setup.py test 命令已被弃用,因此我们将直接运行 unittest

$ python3 -m unittest -v test
running test
... 

unittest 库仍然相当有限,因此我建议直接跳转到 py.test

py.test

py.test 包目前会自动注册为 setuptools 中的一个额外命令,因此安装后您可以直接运行 python3 setup.py pytest。然而,由于 setuptools 正在积极减少与 setup.py 的所有交互,我建议直接使用 py.testtox 调用。

如前所述,建议使用 tox 来初始化环境并全面测试项目。然而,对于快速本地开发,我建议安装 pytest 模块并直接运行测试。

注意,可能仍然有一些旧的文档建议使用 pytest-runner、带有别名或自定义命令的 python setup.py test,或者生成一个 runtests.py 文件,但所有这些解决方案都已弃用,不应再使用。

配置 py.test 时,我们有几种选项取决于您的偏好。以下所有文件都将有效:

  • pytest.ini

  • pyproject.toml

  • tox.ini

  • setup.cfg

对于我维护的项目,我已经将测试需求定义为额外的依赖项,因此可以使用(例如)pip3 install -e "./progressbar2[tests]" 来安装。之后,您可以轻松地运行 py.test 来以 tox 运行测试的方式运行测试。当然,tox 也可以使用相同的额外依赖项来安装需求,这确保了您使用的是相同的测试环境。

要在您的 setup.cfg(或 setup.py / pyproject.toml 的等效文件)中启用此功能:

[options.extras_require]
tests = pytest 

对于本地开发,我们现在可以以可编辑模式安装包和额外依赖项以进行快速测试:

$ pip3 install -e '.[tests]' 

这样就足以能够直接使用 py.test 进行测试:

$ py.test 

要使用 tox 进行测试,您需要创建一个 tox.ini 文件,但为此,我建议您查看第十章

练习

现在您已经到达了本书的结尾,当然有很多事情可以尝试。您可以构建和发布自己的应用程序和库,或者扩展现有的库和应用。

在尝试本章的示例时,请注意不要意外地将包发布到 PyPI,如果不是你的意图。只需一个twine命令就可能导致意外注册和上传包,而 PyPI 上已经充斥着没有实际用途的包。

对于一些实际练习:

  • 创建一个setuptools命令来提升你包的版本

  • 通过交互式询问进行主要、次要或补丁升级来扩展版本提升命令

  • 尝试将现有的项目从setup.py转换为pyproject.toml结构

这些练习的示例答案可以在 GitHub 上找到:github.com/mastering-python/exercises。我们鼓励你提交自己的解决方案,并从他人的解决方案中学习。

摘要

在阅读完这一章后,你应该能够创建包含纯 Python 文件、额外数据、编译的 C/C++扩展、文档和测试的 Python 包。有了所有这些工具,你现在能够制作出高质量的 Python 包,这些包可以轻松地在其他项目和包中重用。

Python 基础设施使得创建新包并将你的项目拆分为多个子项目变得非常简单。这允许你创建简单且可重用的包,因为一切都可以轻松测试,从而减少错误。虽然你不应该过度拆分包,但如果一个脚本或模块有其自身的目的,那么它就是单独打包的候选者。

随着这一章的结束,我们来到了这本书的结尾。我真诚地希望你喜欢阅读它,并了解了一些新的有趣的话题。任何和所有的反馈都将非常受重视,所以请随时通过我的网站wol.ph/联系我。

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:discord.gg/QMzJenHuJf

二维码

posted @ 2025-09-23 21:57  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报