精通-Python(全)

精通 Python(全)

原文:zh.annas-archive.org/md5/37ba6447e713c9bd5373842650e2e5f3

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Python 是一种易于学习的语言,从一开始就非常强大和方便。然而,精通 Python 是一个完全不同的问题。

你将遇到的每个编程问题都至少有几种可能的解决方案和/或范式可以应用于 Python 的广泛可能性之内。本书不仅将说明一系列不同和新的技术,还将解释何时何地应该应用某种方法。

这本书不是 Python 3 的初学者指南。它是一本可以教你 Python 中更高级技术的书。具体针对 Python 3.5 及以上版本,还演示了一些 Python 3.5 独有的特性,比如 async def 和 await 语句。

作为一名有多年经验的 Python 程序员,我将尝试用相关背景信息来理性地解释本书中所做的选择。然而,这些理性化并不是严格的指导方针。其中几个案例归根结底是个人风格的问题。只需知道它们源自经验,并且在许多情况下是 Python 社区推荐的解决方案。

本书中的一些参考对你来说可能不明显,如果你不是蒙提·派森的粉丝。本书在代码示例中广泛使用 spam 和 eggs 而不是 foo 和 bar。为了提供一些背景信息,我建议观看蒙提·派森的“垃圾食品”小品。它非常愚蠢!

本书涵盖内容

《第一章》《入门-每个项目一个环境》介绍了使用 virtualenv 或 venv 来隔离 Python 项目中的包的虚拟 Python 环境。

《第二章》《Pythonic 语法,常见陷阱和风格指南》解释了 Pythonic 代码是什么,以及如何编写符合 Python 哲学的 Pythonic 代码。

《第三章》《容器和集合-正确存储数据》是我们使用 Python 捆绑的许多容器和集合来创建快速且可读的代码的地方。

《第四章》《函数式编程-可读性与简洁性》涵盖了 Python 中可用的列表/字典/集合推导和 lambda 语句等函数式编程技术。此外,它还说明了它们与涉及的数学原理的相似之处。

《第五章》《装饰器-通过装饰实现代码重用》不仅解释了如何创建自己的函数/类装饰器,还解释了内部装饰器(如 property,staticmethod 和 classmethod)的工作原理。

《第六章》《生成器和协程-无限,一步一步》展示了生成器和协程如何用于惰性评估无限大小的结构。

《第七章》《异步 IO-无需线程的多线程》演示了使用 async def 和 await 的异步函数的用法,以便外部资源不再阻塞 Python 进程。

《第八章》《元类-使类(而不是实例)更智能》深入探讨了类的创建以及如何完全修改类的行为。

第九章,“文档-如何使用 Sphinx 和 reStructuredText”,展示了如何使用 Sphinx 自动记录你的代码,几乎不费吹灰之力。此外,它还展示了如何使用 Napoleon 语法来记录函数参数,这种方式在代码和文档中都很清晰。

第十章,“测试和日志-为错误做准备”,解释了如何测试代码以及如何添加日志以便在以后出现错误时进行轻松调试。

第十一章,“调试-解决错误”,演示了使用跟踪、日志和交互式调试来追踪错误的几种方法。

第十二章,“性能-跟踪和减少内存和 CPU 使用”,展示了几种测量和改进 CPU 和内存使用的方法。

第十三章,“多处理-当单个 CPU 核心不够用时”,说明了多处理库可以用于执行代码,不仅可以在多个处理器上执行,甚至可以在多台机器上执行。

第十四章,“C/C++扩展、系统调用和 C/C++库”,涵盖了调用 C/C++函数以实现互操作性和性能的方法,使用 Ctypes、CFFI 和本地 C/C++。

第十五章,“打包-创建自己的库或应用程序”,演示了使用 setuptools 和 setup.py 在 Python 包索引(PyPI)上构建和部署软件包。

你需要这本书

这本书的唯一硬性要求是 Python 解释器。建议使用 Python 3.5 或更新的解释器,但许多代码示例也可以在旧版本的 Python 中运行,比如 2.7,在文件顶部添加一个简单的 from future import print_statement。

此外,第十四章,“C/C++扩展、系统调用和 C/C++库”需要 C/C++编译器,如 GCC、Visual Studio 或 XCode。Linux 机器是执行 C/C++示例最简单的机器,但在 Windows 和 OS X 机器上也应该可以轻松执行。

这本书是为谁准备的

如果你已经超越了绝对的 Python 初学者水平,那么这本书适合你。即使你已经是一名专业的 Python 程序员,我保证你会在这本书中找到一些有用的技巧和见解。

至少,它将允许 Python 2 程序员更多地了解 Python 3 中引入的新功能,特别是 Python 3.5。

需要基本的 Python 熟练,因为 Python 解释器的安装和基本的 Python 语法没有涵盖。

约定

在这本书中,你会发现许多文本样式,用来区分不同类型的信息。以下是一些这些样式的例子和它们的含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“应该注意,type()函数还有另一个用途。”

代码块设置如下:

import abc
import importlib

class Plugins(abc.ABCMeta):
    plugins = dict()

    def __new__(metaclass, name, bases, namespace):
        cls = abc.ABCMeta.__new__(
            metaclass, name, bases, namespace)

任何命令行输入或输出都写成如下形式,其中>>>表示 Python 控制台,#表示常规的 Linux/Unix shell:

>>> class Spam(object):
…     eggs = 'my eggs'

>>> Spam = type('Spam', (object,), dict(eggs='my eggs'))

注意

警告或重要提示以这样的方式出现在一个框中。

提示

提示和技巧看起来像这样。

第一章:入门-每个项目一个环境

Python 哲学的一个方面一直以来都是最重要的,也将永远如此——可读性,或者说 Pythonic 代码。这本书将帮助你掌握编写 Python 的方式:可读、美观、明确,尽可能简单。简而言之,它将是 Pythonic 代码。这并不是说复杂的主题不会被涵盖。当然会,但每当 Python 的哲学受到影响时,你将被警告何时何地使用这种技术是合理的。

本书中的大部分代码将在 Python 2 和 Python 3 上运行,但主要目标是 Python 3。这样做有三个原因:

  1. Python 3 于 2008 年发布,这在快速变化的软件世界中已经是很长的时间了。它不再是新鲜事物,而是稳定的、可用的,最重要的是,它是未来。

  2. Python 2 的开发在 2009 年基本停止了。某些功能已经从 Python 3 回溯到 Python 2,但任何新的开发都将首先针对 Python 3。

  3. Python 3 已经成熟。我必须承认,Python 3.2 和更早版本仍存在一些小问题,这使得很难编写能在 Python 2 和 3 上运行的代码,但 Python 3.3 在这方面有了很大的改进,我认为它已经成熟。这一点可以从 Python 3.4 和 3.5 中略有修改的语法以及许多非常有用的功能得到证实,这些都在本书中有所涵盖。

总之,Python 3 是对 Python 2 的改进。我自己也是长期的怀疑论者,但我没有看到不使用 Python 3 进行新项目的理由,甚至将现有项目迁移到 Python 3 通常只需要进行一些小的更改。有了 Python 3.5 中的async with等新功能,你会想要升级来尝试一下。

这一章将向你展示如何正确设置环境,创建一个新的隔离环境,并确保在不同的机器上运行相同代码时获得类似的结果。大多数 Python 程序员已经在使用virtualenv创建虚拟 Python 环境,但在 Python 3.3 中引入的venv命令是一个非常好的替代方案。它本质上是virtualenv包的一个克隆,但稍微简单一些,并且与 Python 捆绑在一起。虽然它的使用方法大部分类似于virtualenv,但有一些有趣的变化值得知道。

其次,我们将讨论pip命令。使用ensurepip包通过venv自动安装pip命令,这是在 Python 3.4 中引入的一个包。这个包会在现有的 Python 库中自动引导pip,同时保持独立的 Python 和pip版本。在 Python 3.4 之前,venv没有pip,需要手动安装。

最后,我们将讨论如何安装使用distutils创建的包。纯 Python 包通常很容易安装,但涉及 C 模块时可能会有挑战。

在本章中,将涵盖以下主题:

  • 使用venv创建虚拟 Python 环境

  • 使用ensurepip引导 pip 的引导

  • 使用pip基于distutils(C/C++)安装包

使用 venv 创建虚拟 Python 环境

大多数 Python 程序员可能已经熟悉venvvirtualenv,但即使你不熟悉,现在开始使用也不算晚。venv模块旨在隔离你的 Python 环境,这样你就可以安装特定于当前项目的包,而不会污染全局命名空间。此外,由于包是本地安装的,你不需要系统(root/administrator)访问权限来安装它们。

结果是,您可以确保在本地开发机器和生产机器上具有完全相同版本的软件包,而不会干扰其他软件包。例如,有许多 Django 软件包需要 Django 项目的特定版本。使用venv,您可以轻松地为项目 A 安装 Django 1.4,为项目 B 安装 Django 1.8,而它们永远不会知道其他环境中安装了不同的版本。默认情况下,甚至配置了这样的环境,以便全局软件包不可见。这样做的好处是,要获得环境中安装的所有软件包的确切列表,只需pip freeze即可。缺点是,一些较重的软件包(例如numpy)将必须在每个单独的环境中安装。不用说,哪种选择对您的项目最好取决于项目。对于大多数项目,我会保持默认设置,即不具有全局软件包,但是在处理具有大量 C/C++扩展的项目时,简单地启用全局站点软件包会很方便。原因很简单;如果您没有编译器可用,安装软件包可能会很困难,而全局安装对于 Windows 有可执行文件,对于 Linux/Unix 有可安装软件包可用。

注意

venv模块(docs.python.org/3/library/venv.html)可以看作是virtualenv工具(virtualenv.pypa.io/)的一个略微简化的版本,自 Python 3.3 版本以来已经捆绑在一起(参见 PEP 0405 -- Python 虚拟环境:www.python.org/dev/peps/pep-0405/)。

virtualenv包通常可以用作venv的替代品,特别是对于不捆绑venv的较旧的 Python 版本(3.3 以下)来说,这一点尤为重要。

创建您的第一个 venv

创建环境非常容易。基本命令是pyvenv PATH_TO_THE_NEW_VIRTUAL_ENVIRONMENT,所以让我们试一试。请注意,此命令适用于 Linux、Unix 和 Mac;Windows 命令将很快跟进:

# pyvenv test_venv
# . ./test_venv/bin/activate
(test_venv) #

注意

一些 Ubuntu 版本(特别是 14.04 LTS)通过不在ensurepip中包含完整的pyvenv包来削弱 Python 安装。标准的解决方法是调用pyvenv --without-pip test_env,这需要通过pip主页上提供的get_pip.py文件手动安装pip

这将创建一个名为test_venv的环境,第二行激活该环境。

在 Windows 上,一切都略有不同,但总体上是相似的。默认情况下,pyvenv命令不会在您的 PATH 中,因此运行该命令略有不同。三个选项如下:

  • Python\Tools\Scripts\目录添加到您的 PATH

  • 运行模块:

python -m venv test_venv

  • 直接运行脚本:
python Python\Tools\Scripts\pyvenv.py test_venv

为了方便起见,我建议您无论如何将Scripts目录添加到您的 PATH,因为许多其他应用程序/脚本(如pip)也将安装在那里。

以下是 Windows 的完整示例:

C:\envs>python -m venv test_venv
C:\envs>test_venv\Scripts\activate.bat
(test_venv) C:\envs>

提示

在使用 Windows PowerShell 时,可以通过使用test_venv\Scripts\Activate.ps1来激活环境。请注意,这里确实需要反斜杠。

venv 参数

到目前为止,我们只是创建了一个普通的和常规的venv,但是有一些非常有用的标志可以根据您的需求定制您的venv

首先,让我们看一下venv的帮助:

参数 描述
--system-site-packages 它使虚拟环境可以访问system-site-packages目录
--symlinks 尝试在平台不默认使用符号链接时使用symlinks而不是副本
--copies 尝试使用副本而不是符号链接,即使符号链接是平台的默认值
--clear 在环境创建之前删除环境目录的内容,如果存在的话
--upgrade 升级环境目录以使用 Python 的这个版本,假设 Python 已经被原地升级
--without-pip 这将跳过在虚拟环境中安装或升级 pip(pip 默认情况下是引导的)

要注意的最重要的参数是--system-site-packages,它可以在环境中启用全局站点包。这意味着如果你在全局 Python 版本中安装了一个包,它也将在你的环境中可用。但是,如果你尝试将其更新到不同的版本,它将被安装在本地。在可能的情况下,我建议禁用--system-site-packages标志,因为它可以为你提供一个简单的环境,而不会有太多的变量。否则,简单地更新系统包可能会破坏你的虚拟环境,更糟糕的是,没有办法知道哪些包是本地需要的,哪些只是为其他目的安装的。

要为现有环境启用这个功能,你可以简单地再次运行环境创建命令,但这次加上--system-site-packages标志以启用全局站点包。

要再次禁用它,你可以简单地运行环境创建命令,不带标志。这将保留在环境中安装的本地包,但会从你的 Python 范围中删除全局包。

提示

在使用virtualenvwrapper时,也可以通过在激活的环境中使用toggleglobalsitepackages命令来完成这个操作。

--symlinks--copies参数通常可以忽略,但了解它们的区别很重要。这些参数决定文件是从基本 Python 目录复制还是创建符号链接。

注意

符号链接是 Linux/Unix/Mac 的东西;它不是复制文件,而是创建一个符号链接,告诉系统在哪里找到实际的文件。

默认情况下,venv会尝试创建符号链接,如果失败,它会退而使用复制。自从 Windows Vista 和 Python 3.2 以来,这在 Windows 上也得到支持,所以除非你使用的是一个非常旧的系统,你很可能会在你的环境中使用符号链接。符号链接的好处是它节省了磁盘空间,并且与你的 Python 安装保持同步。缺点是,如果你的系统的 Python 版本升级了,它可能会破坏你的环境中安装的包,但这可以通过使用pip重新安装包来轻松解决。

最后,--upgrade参数在系统 Python 版本被原地升级后非常有用。这个参数的最常见用法是在使用复制(而不是符号链接)环境后修复损坏的环境。

virtualenv 和 venv 之间的区别

由于venv模块本质上是virtualenv的一个简化版本,它们大部分是相同的,但有些地方是不同的。此外,由于virtualenv是一个与 Python 分开分发的包,它确实有一些优势。

以下是venv相对于virtualenv的优势:

  • venv随 Python 3.3 及以上版本一起分发,因此不需要单独安装

  • venv简单直接,除了基本必需品之外没有其他功能

virtualenv相对于venv的优势:

  • virtualenv是在 Python 之外分发的,因此可以单独更新。

  • virtualenv适用于旧的 Python 版本,但建议使用 Python 2.6 或更高版本。然而,使用较旧版本(1.9.x 或更低版本)可以支持 Python 2.5。

  • 它支持方便的包装器,比如virtualenvwrapper (virtualenvwrapper.readthedocs.org/)

简而言之,如果venv对您足够了,就使用它。如果您使用的是旧版本的 Python 或需要一些额外的便利,比如virtualenvwrapper,则使用virtualenv。这两个项目本质上是做同样的事情,并且已经努力使它们之间易于切换。两者之间最大和最显著的区别是virtualenv支持的 Python 版本的种类。

使用 ensurepip 引导 pip

自 2008 年推出以来,pip软件包管理器一直在逐渐取代easy_install。自 Python 3.4 以来,它甚至已成为默认选项,并与 Python 捆绑在一起。从 Python 3.4 开始,它默认安装在常规 Python 环境和pyvenv中;在此之前,需要手动安装。要在 Python 3.4 及以上版本自动安装pip,需要使用ensurepip库。这是一个处理pip的自动安装和/或升级的库,因此至少与ensurepip捆绑的版本一样新。

ensurepip 用法

使用ensurepip非常简单。只需运行 python -m ensurepip来保证pip的版本,或者运行 python -m ensurepip --upgrade来确保pip至少是与ensurepip捆绑的版本一样新。

除了安装常规的pip快捷方式外,这还将安装pipXpipX.Y链接,允许您选择特定的 Python 版本。当同时使用 Python 2 和 Python 3 时,这允许您使用pip2pip3在 Python 2 和 Python 3 中安装软件包。这意味着如果您在 Python 3.5 上使用 python -m ensurepip,您将在您的环境中安装pippip3pip3.5命令。

手动 pip 安装

如果您使用的是 Python 3.4 或更高版本,ensurepip软件包非常好。然而,在此之下,您需要手动安装pip。实际上,这非常容易。只需要两个步骤:

  1. 下载get-pip.py文件:bootstrap.pypa.io/get-pip.py

  2. 执行get-pip.py文件:python get-pip.py

提示

如果ensurepip命令由于权限错误而失败,提供--user参数可能会有用。这允许您在用户特定的站点包目录中安装pip,因此不需要 root/admin 访问权限。

安装 C/C++软件包

大多数 Python 软件包纯粹是 Python,并且安装起来非常容易,只需简单的pip install packagename就可以了。然而,有些情况涉及到编译,安装不再是简单的 pip install,而是需要搜索几个小时以查看安装某个软件包所需的依赖关系。

特定的错误消息会根据项目和环境而异,但这些错误中有一个共同的模式,了解您所看到的内容可以在寻找解决方案时提供很大帮助。

例如,在标准的 Ubuntu 机器上安装pillow时,您会得到几页错误、警告和其他消息,最后是这样的:

 **x86_64-linux-gnu-gcc: error: build/temp.linux-x86_64-3.4/libImaging/Jpeg2KDecode.o: No such file or directory
 **x86_64-linux-gnu-gcc: error: build/temp.linux-x86_64-3.4/libImaging/Jpeg2KEncode.o: No such file or directory
 **x86_64-linux-gnu-gcc: error: build/temp.linux-x86_64-3.4/libImaging/BoxBlur.o: No such file or directory
 **error: command 'x86_64-linux-gnu-gcc' failed with exit status 1

 **----------------------------------------
Command "python3 -c "import setuptools, tokenize;__file__='/tmp/pip-build-_f0ryusw/pillow/setup.py';exec(compile(getattr(tokenize, 'open', open)(__file__).read().replace('\r\n', '\n'), __file__, 'exec'))" install --record /tmp/pip-kmmobum2-record/install-record.txt --single-version-externally-managed --compile --install-headers include/site/python3.4/pillow" failed with error code 1 in /tmp/pip-build-_f0ryusw/pillow

看到这样的消息后,您可能会想要搜索其中的一行,比如x86_64-linux-gnu-gcc: error: build/temp.linux-x86_64-3.4/libImaging/Jpeg2KDecode.o: No such file or directory。虽然这可能会给您一些相关的结果,但很可能不会。在这种安装中的技巧是向上滚动,直到看到有关缺少头文件的消息。这是一个例子:

 **In file included from libImaging/Imaging.h:14:0,
 **from libImaging/Resample.c:16:
 **libImaging/ImPlatform.h:10:20: fatal error: Python.h: No such file or directory
 **#include "Python.h"
 **^
 **compilation terminated.

这里的关键消息是缺少Python.h。这些是 Python 头文件的一部分,需要用于 Python 中大多数 C/C++软件包的编译。根据操作系统的不同,解决方案也会有所不同,不幸的是。因此,我建议您跳过本段中与您的情况无关的部分。

Debian 和 Ubuntu

在 Debian 和 Ubuntu 中,要安装的软件包是python3-devpython2-dev(如果您仍在使用 Python 2)。要执行的命令如下:

# sudo apt-get install python3-dev

但是,这只安装了开发头文件。如果您希望编译器和其他头文件与安装捆绑在一起,那么build-dep命令也非常有用。以下是一个示例:

# sudo apt-get build-dep python3

Red Hat、CentOS 和 Fedora

Red Hat、CentOS 和 Fedora 是基于 rpm 的发行版,它们使用yum软件包管理器来安装所需的软件。大多数开发头文件都可以通过<package-name>-devel获得,并且可以轻松安装。要安装 Python 3 开发头文件,请使用以下命令:

# sudo apt-get install python3-devel

为了确保您具有构建软件包(如 Python)所需的所有要求,例如开发头文件和编译器,yum-builddep命令是可用的:

# yum-builddep python3

OS X

在实际安装软件包之前,OS X 上的安装过程包括三个步骤。

首先,您需要安装 Xcode。这可以通过 OS X App Store 完成,网址为itunes.apple.com/en/app/xcode/id497799835?mt=12

然后,您需要安装 Xcode 命令行工具:

# xcode-select --install

最后,您需要安装Homebrew软件包管理器。步骤可在brew.sh/找到,但安装命令如下:

# /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

注意

其他软件包管理器,如Macports,也是可能的,但Homebrew目前是 OS X 上开发和社区最活跃的软件包管理器。

完成所有这些步骤后,您应该有一个可用的 Homebrew 安装。可以使用brew doctor命令验证Homebrew的工作情况。如果输出中没有主要错误,那么您应该准备通过 brew 安装您的第一个软件包。现在我们只需要安装 Python,就完成了:

# brew install python3

Windows

在 Windows 上,手动编译 C Python 软件包通常是一个非常不容易的任务。大多数软件包都是针对 Linux/Unix 系统编写的(OS X 属于 Unix 类别),而 Windows 对开发人员来说只是一个附带的功能。结果是,由于测试软件包的人很少,许多库需要手动安装,因此在 Windows 上编译软件包非常繁琐。因此,除非您确实必须这样做,否则请尽量避免在 Windows 上手动编译 Python 软件包。大多数软件包都可以通过一些搜索获得可安装的二进制下载,并且还有诸如 Anaconda 之类的替代方案,其中包括大多数重要的 C Python 软件包的二进制软件包。

如果您仍然倾向于手动编译 C Python 软件包,那么还有另一种选择,通常是更简单的替代方案。Cygwin 项目(cygwin.com/)试图使 Linux 应用程序在 Windows 上原生运行。这通常是一个比让软件包与 Visual Studio 配合工作更容易的解决方案。

如果您确实希望选择 Visual Studio 路径,我想指向第十四章,C/C++扩展、系统调用和 C/C++库,其中涵盖了手动编写 C/C++扩展以及有关您的 Python 版本所需的 Visual Studio 版本的一些信息。

摘要

随着pipvenv等包的加入,我觉得 Python 3 已经成为一个完整的包,应该适合大多数人。除了遗留应用程序外,再也没有理由不选择 Python 3 了。2008 年初版的 Python 3 相比于同年发布的成熟的 Python 2.6 版本确实有些粗糙,但在这方面已经发生了很多变化。最后一个重要的 Python 2 版本是 Python 2.7,发布于 2010 年;在软件世界中,这是非常非常长的时间。虽然 Python 2.7 仍然在接受维护,但它将不会获得 Python 3 正在获得的任何惊人的新功能——比如默认的 Unicode 字符串、dict生成器(第六章,生成器和协程-无限,一步一步)以及async方法(第七章,异步 IO-无需线程的多线程)。

完成本章后,您应该能够创建一个干净且可重现的虚拟环境,并知道如果 C/C++包的安装失败应该去哪里查找。

这一章最重要的笔记如下:

  • 为了创建一个干净简洁的环境,请使用venv。如果需要与 Python 2 兼容,请使用virtualenv

  • 如果 C/C++包安装失败,请查找有关缺少包含文件的错误。

下一章将介绍 Python 风格指南,重要的规则以及它们的重要性。可读性是 Python 哲学中最重要的方面之一,您将学习编写更干净、更易读的 Python 代码的方法和风格。

第二章:Pythonic 语法,常见陷阱和风格指南

Python 编程语言的设计和开发一直由其原始作者 Guido van Rossum 掌握,他常常被亲切地称为终身仁慈独裁者BDFL)。尽管 van Rossum 被认为拥有一台时光机(他曾多次回答功能请求说“我昨晚刚实现了这个”:www.catb.org/jargon/html/G/Guido.html),但他仍然只是一个人,需要帮助来维护和发展 Python。为了方便这一点,Python Enhancement ProposalPEP)流程已经被开发出来。这个流程允许任何人提交一个带有功能技术规范和为其有用性辩护的理由的 PEP。在 Python 邮件列表上进行讨论并可能进行一些改进后,BDFL 将做出接受或拒绝提案的决定。

Python 风格指南(PEP 8www.python.org/dev/peps/pep-0008/)曾经作为其中一个 PEP 提交,自那以后它一直被接受和不断改进。它有很多伟大和广泛接受的惯例,也有一些有争议的。特别是,79 个字符的最大行长度是许多讨论的话题。然而,将一行限制在 79 个字符确实有一些优点。除此之外,虽然风格指南本身并不能使代码成为 Pythonic,正如“Python 之禅”(PEP 20www.python.org/dev/peps/pep-0020/)所说的那样:“美丽胜过丑陋。” PEP 8定义了代码应该以确切的方式进行格式化,而PEP 20更多的是一种哲学和心态。

常见的陷阱是一系列常见的错误,从初学者的错误到高级错误不等。它们范围广泛,从将列表或字典(可变的)作为参数传递到闭包中的延迟绑定问题。更重要的问题是如何以一种清晰的方式解决循环导入的问题。

本章中使用的一些技术可能对于这样一个早期的章节来说有点过于先进,但请不要担心。本章是关于风格和常见陷阱的。使用的技术的内部工作将在后面的章节中介绍。

我们将在本章中涵盖以下主题:

  • 代码风格(PEP 8pyflakesflake8等)

  • 常见陷阱(列表作为函数参数,按值传递与按引用传递,以及继承行为)

注意

Pythonic 代码的定义是非常主观的,主要反映了本作者的观点。在项目中工作时,与该项目的编码风格保持一致比遵循 Python 或本书给出的编码指南更重要。

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

Pythonic code - 当你第一次听到它时,你可能会认为它是一种编程范式,类似于面向对象或函数式编程。虽然有些地方可以被认为是这样,但实际上它更多的是一种设计哲学。Python 让你可以自由选择以面向对象,过程式,函数式,面向方面甚至逻辑导向的方式进行编程。这些自由使 Python 成为一个很好的编程语言,但是,自由总是需要很多纪律来保持代码的清晰和可读性。PEP8标准告诉我们如何格式化代码,但 Pythonic 代码不仅仅是语法。这就是 Pythonic 哲学(PEP20)的全部内容,即代码应该是:

  • 清晰

  • 简单

  • 美丽

  • 显式

  • 可读性

大多数听起来都像是常识,我认为它们应该是。然而,也有一些情况,没有一个明显的方法来做(除非你是荷兰人,当然,你将在本章后面读到)。这就是本章的目标 - 学习什么样的代码是美丽的,以及为什么在 Python 风格指南中做出了某些决定。

注意

有些程序员曾经问过 Guido van Rossum,Python 是否会支持大括号。从那天起,大括号就可以通过__future__导入使用了:

>>> from __future__ import braces
 **File "<stdin>", line 1
SyntaxError: not a chance

格式化字符串 - printf-style还是str.format

Python 长期以来一直支持printf-style%)和str.format,所以你很可能已经对两者都很熟悉了。

在本书中,printf-style格式将被用于一些原因:

  • 最重要的原因是这对我来说很自然。我已经在许多不同的编程语言中使用printf大约 20 年了。

  • 大多数编程语言都支持printf语法,这使得它对很多人来说很熟悉。

  • 尽管这只与本书中的示例有关,但它占用的空间稍微少一些,需要较少的格式更改。与显示器相反,书籍多年来并没有变得更宽。

一般来说,大多数人现在推荐使用str.format,但这主要取决于个人偏好。printf-style更简单,而str.format方法更强大。

如果你想了解更多关于如何用str.format替换printf-style格式(或者反过来,当然也可以),我推荐访问 PyFormat 网站pyformat.info/

PEP20,Python 之禅

大部分 Python 哲学可以通过 PEP20 来解释。Python 有一个小彩蛋,可以始终提醒你PEP20。只需在 Python 控制台中键入import this,就会得到PEP20的内容。引用PEP20

"长期的 Python 程序员 Tim Peters 简洁地表达了 BDFL 对 Python 设计的指导原则,总共有 20 条格言,其中只有 19 条被记录下来。"

接下来的几段将解释这 19 行的意图。

注意

PEP20 部分的示例在工作上并不完全相同,但它们确实有相同的目的。这里的许多示例都是虚构的,除了解释段落的理由外,没有其他目的。

为了清晰起见,在我们开始之前,让我们看一下import this的输出:

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

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

美丽胜过丑陋

尽管美是相当主观的,但有一些 Python 风格规则需要遵守:限制行长度,保持语句在单独的行上,将导入拆分为单独的行等等。

简而言之,与这样一个相当复杂的函数相比:

 def filter_modulo(items, modulo):
    output_items = []
    for i in range(len(items)):
        if items[i] % modulo:
            output_items.append(items[i])
    return output_items

或者这样:

filter_modulo = lambda i, m: [i[j] for i in range(len(i))
                              if i[j] % m]

只需执行以下操作:

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

更简单,更易读,更美丽一些!

注意

这些示例的结果并不相同。前两个返回列表,而最后一个返回生成器。生成器将在第六章中更详细地讨论,生成器和协程-无限,一步一次

显式胜过隐式

导入、参数和变量名只是许多情况中的一些,显式代码更容易阅读,但编写代码时需要付出更多的努力和/或冗长。

这是一个例子:

from spam import *
from eggs import *

some_function()

虽然这样可以节省一些输入,但很难看出some_function是在哪里定义的。它是在foo中定义的吗?在bar中定义的吗?也许在两个模块中都定义了?有一些具有高级内省功能的编辑器可以帮助你,但为什么不明确地保持,这样每个人(即使只是在线查看代码)都能看到它在做什么呢?

import spam
import eggs

spam.some_function()
eggs.some_function()

额外的好处是我们可以明确地从spameggs中调用函数,每个人都会更清楚代码的作用。

对于具有*args**kwargs的函数也是一样。它们有时可能非常有用,但它们的缺点是很难确定哪些参数对于函数是有效的:

def spam(egg, *args, **kwargs):
    processed_egg = process_egg(egg, *args, **kwargs)
    return Spam(processed_egg)

文档显然对这样的情况有所帮助,我并不反对一般情况下使用*args**kwargs,但至少保留最常见的参数是个好主意。即使这需要你重复父类的参数,这样代码会更清晰。在未来重构父类时,你会知道是否还有子类使用了一些参数。

简单胜于复杂

"简单胜于复杂。复杂胜于混乱。"

在开始一个新项目时,问自己最重要的问题是:它需要多复杂?

例如,假设我们已经编写了一个小程序,现在我们需要存储一些数据。我们有哪些选择?

  • 完整的数据库服务器,比如 PostgreSQL 或 MySQL

  • 简单的文件系统数据库,比如 SQLite 或 AnyDBM

  • 平面文件存储,比如 CSV 和 TSV

  • 结构化存储,比如 JSON、YAML 或 XML

  • 序列化的 Python,比如 Pickle 或 Marshal

所有这些选项都有自己的用例以及根据用例的优势和劣势:

  • 你存储了大量数据吗?那么完整的数据库服务器和平面文件存储通常是最方便的选择。

  • 它是否能够轻松地在不需要任何包安装的不同系统上移植?这使得除了完整的数据库服务器之外的任何选项都很方便。

  • 我们需要搜索数据吗?这在使用其中一个数据库系统时要容易得多,无论是文件系统还是完整的服务器。

  • 是否有其他应用需要能够编辑数据?这使得像平面文件存储和结构化存储这样的通用格式成为方便的选择,但排除了序列化的 Python。

很多问题!但最重要的一个是:它需要多复杂?在pickle文件中存储数据是可以在三行内完成的,而连接到数据库(即使是 SQLite)将会更复杂,并且在许多情况下是不需要的:

import pickle  # Or json/yaml
With open('data.pickle', 'wb') as fh:
    pickle.dump(data, fh, pickle.HIGHEST_PROTOCOL)

对比:

import sqlite3
connection = sqlite3.connect('database.sqlite')
cursor = connection.cursor()
cursor.execute('CREATE TABLE data (key text, value text)')
cursor.execute('''INSERT INTO data VALUES ('key', 'value')''')
connection.commit()
connection.close()

当然,这些例子远非相同,一个存储了完整的数据对象,而另一个只是在 SQLite 数据库中存储了一些键值对。然而,重点不在于此。重点是,尽管使用适当的库可以简化这个过程,但在许多情况下,代码更加复杂,而实际上却不够灵活。简单胜于复杂,如果不需要复杂性,最好避免它。

扁平胜于嵌套

嵌套的代码很快变得难以阅读和理解。这里没有严格的规则,但通常当你有三层嵌套循环时,就是重构的时候了。

只需看下面的例子,它打印了一个二维矩阵的列表。虽然这里没有明显的错误,但将其拆分为更多的函数可能会使目的更容易理解,也更容易测试:

def print_matrices():
    for matrix in matrices:
        print('Matrix:')
        for row in matrix:
            for col in row:
                print(col, end='')
            print()
        print()

稍微扁平化的版本如下:

def print_row(row):
    for col in row:
        print(col, end='')

def print_matrix(matrix):
    for row in matrix:
        print_row(row)
        print()

def print_matrices(matrices):
    for matrix in matrices:
        print('Matrix:')
        print_matrix(matrix)
        print()

这个例子可能有点复杂,但思路是正确的。深度嵌套的代码很容易变得难以阅读。

稀疏胜于密集

空白通常是件好事。是的,它会使你的文件变得更长,你的代码会占用更多的空间,但如果你按逻辑拆分你的代码,它可以帮助很多可读性:

>>> def make_eggs(a,b):'while',['technically'];print('correct');\
...     {'this':'is','highly':'unreadable'};print(1-a+b**4/2**2)
...
>>> make_eggs(1,2)
correct
4.0

虽然从技术上讲是正确的,但这并不是所有人都能读懂的。我相信这需要一些努力才能找出代码实际在做什么,以及它会打印出什么数字,而不是尝试它。

>>> def make_eggs(a, b):
...     'while', ['technically']
...     print('correct')
...     {'this': 'is', 'highly': 'unreadable'}
...     print(1 - a + ((b ** 4) / (2 ** 2)))
...
>>> make_eggs(1, 2)
correct
4.0

不过,这还不是最佳代码,但至少在代码中发生了什么更加明显了一些。

可读性很重要

更短并不总是意味着更容易阅读:

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

虽然简短的版本在简洁上有一定的美感,但我个人觉得下面的更美观:

def fib(n):
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

实用性胜过纯粹

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

违反规则有时会很诱人,但往往会导致一连串的问题。当然,这适用于所有规则。如果你的快速修复会违反规则,你应该立即尝试重构它。很可能你以后没有时间来修复它,并会后悔。

不需要过分。如果解决方案已经足够好,而重构会更费力,那么选择有效的方法可能更好。尽管所有这些例子都涉及导入,但这个指导原则几乎适用于所有情况。

为了防止行过长,可以通过几种方法使导入变得更短,比如添加反斜杠、添加括号,或者只是缩短导入:

from spam.eggs.foo.bar import spam, eggs, extra_spam, extra_eggs, extra_stuff  from spam.eggs.foo.bar import spam, eggs, extra_spam, extra_eggs

这种情况可以很容易地避免,只需遵循PEP8(每行一个导入):

from spam.eggs.foo.bar import spam from spam.eggs.foo.bar import eggs from spam.eggs.foo.bar import extra_spam from spam.eggs.foo.bar import extra_eggs from spam.eggs.foo.bar import extra_stuff  from spam.eggs.foo.bar import spam
from spam.eggs.foo.bar import eggs
from spam.eggs.foo.bar import extra_spam
from spam.eggs.foo.bar import extra_eggs

但是长导入怎么办?

from spam_eggs_and_some_extra_spam_stuff import my_spam_and_eggs_stuff_which_is_too_long_for_a_line

是的…即使通常不建议为导入添加反斜杠,但在某些情况下这仍然是最佳选择:

from spam_eggs_and_some_extra_spam_stuff \
    import my_spam_and_eggs_stuff_which_is_too_long_for_a_line

错误不应该悄悄地传递

*“错误不应该悄悄地传递。除非明确地被压制。”

用 Jamie Zawinsky 的话来说:有些人在遇到错误时,会想“我知道了,我会使用try/except/pass块。”现在他们有了两个问题。

裸露或过于宽泛的异常捕获已经是一个坏主意了。不传递它们会让你(或者其他人在处理代码时)长时间猜测发生了什么:

try:
    value = int(user_input)
except:
    pass

如果你真的需要捕获所有错误,就要非常明确地表达出来:

try:
    value = int(user_input)
except Exception as e:
    logging.warn('Uncaught exception %r', e)

或者更好的是,明确捕获并添加一个合理的默认值:

try:
    value = int(user_input)
except ValueError:
    value = 0

问题实际上更加复杂。对于依赖异常内部发生情况的代码块怎么办?例如,考虑以下代码块:

try:
    value = int(user_input)
    value = do_some_processing(value)
    value = do_some_other_processing(value)
except ValueError:
    value = 0

如果引发了ValueError,是哪一行导致的?是int(user_input)do_some_processing(value),还是do_some_other_processing(value)?如果错误被悄悄地捕获,那么在正常执行代码时就无法知道,这可能非常危险。如果由于某种原因其他函数的处理发生了变化,那么以这种方式处理异常就会成为一个问题。所以,除非确实打算这样做,否则请使用这种方式:

try:
    value = int(user_input)
except ValueError:
    value = 0
else:
    value = do_some_processing(value)
    value = do_some_other_processing(value)

面对模棱两可,拒绝猜测

虽然猜测在许多情况下都有效,但如果不小心就会出问题。正如在“明确胜于含糊”一段中已经展示的,当有一些from ... import *时,你并不能总是确定哪个模块提供了你期望的变量。

通常应该避免模棱两可,以避免猜测。清晰明了的代码会产生更少的错误。模棱两可可能出现的一个有用情况是函数调用。比如,以下两个函数调用:

spam(1, 2, 3, 4, 5)
spam(spam=1, eggs=2, a=3, b=4, c=5)

它们可能是相同的,但也可能不是。没有看到函数的情况下是无法说的。如果函数是以以下方式实现的,那么两者之间的结果将会大不相同:

def spam(a=0, b=0, c=0, d=0, e=0, spam=1, eggs=2):
    pass

我并不是说你应该在所有情况下使用关键字参数,但如果涉及许多参数和/或难以识别的参数(比如数字),那么这是个好主意。你可以选择逻辑变量名来传递参数,只要从代码中清楚地传达了含义。

举个例子,以下是一个类似的调用,使用自定义变量名来传达意图:

a = 3
b = 4
c = 5
spam(a, b, c)

一种明显的方法

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

一般来说,经过一段时间思考一个困难的问题后,你会发现有一种解决方案明显优于其他选择。当然也有例外情况,这时如果你是荷兰人就会很有用。这里的笑话是指 Python 的 BDFL 和原始作者 Guido van Rossum 是荷兰人(就像我一样)。

现在总比永远好

“现在比不做要好。尽管不做通常比立刻做要好。”

最好立即解决问题,而不是将问题推到未来。然而,有些情况下,立即解决问题并不是一个选择。在这些情况下,一个很好的选择可能是将一个函数标记为已弃用,这样就不会有意外忘记问题的机会:

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

难以解释,易于解释

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

一如既往,尽量保持简单。虽然复杂的代码可能很好测试,但更容易出现错误。你能保持事情简单,就越好。

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

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

命名空间可以使代码更加清晰易用。正确命名它们会让它们变得更好。例如,下面这行代码是做什么的?

load(fh)

不太清楚,对吧?

带有命名空间的版本怎么样?

pickle.load(fh)

现在我们明白了。

举一个命名空间的例子,其完整长度使其难以使用,我们将看一下 Django 中的User类。在 Django 框架中,User类存储在django.contrib.auth.models.User中。许多项目以以下方式使用该对象:

from django.contrib.auth.models import User
# Use it as: 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 as auth_models
# Use it as auth_models.User

结论

现在我们应该对 Python 的思想有了一些了解。创建代码:

  • 美观

  • 可读

  • 明确的

  • 足够明确

  • 并非完全没有空格

所以让我们继续看一些使用 Python 风格指南创建美观、可读和简单代码的更多例子。

解释 PEP8

前面的段落已经展示了很多使用PEP20作为参考的例子,但还有一些其他重要的指南需要注意。PEP8 风格指南规定了标准的 Python 编码约定。简单地遵循 PEP8 标准并不能使你的代码变得 Pythonic,但这绝对是一个很好的开始。你使用哪种风格并不是那么重要,只要你保持一致。没有比不使用适当的风格指南更糟糕的事情了,不一致地使用更糟糕。

鸭子类型

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

“不要检查它是否是一只鸭子:检查它是否像一只鸭子一样嘎嘎叫,像一只鸭子一样走路,等等,根据你需要玩语言游戏的鸭子行为子集。如果参数未通过这个特定的鸭子测试,那么你可以耸耸肩,问一句‘为什么是一只鸭子?’”

在许多情况下,当人们进行比较,比如if spam != '':,他们实际上只是在寻找任何被认为是真值的东西。虽然你可以将值与字符串值''进行比较,但你通常不必这么具体。在许多情况下,只需使用if spam:就足够了,而且实际上功能更好。

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

filename = '%s.csv' % timestamp

因为它被命名为timestamp,有人可能会想要检查它实际上是一个datedatetime对象,像这样:

import datetime
if isinstance(timestamp, (datetime.date, datetime.datetime)):
    filename = '%s.csv' % timestamp
else:
    raise TypeError(
        'Timestamp %r should be date(time) object, got %s'
        % (timestamp, type(timestamp))) 

虽然这并不是本质上错误的,但在 Python 中,比较类型被认为是一种不好的做法,因为通常情况下并不需要。在 Python 中,更倾向于鸭子类型。只需尝试将其转换为字符串,不必在乎它实际上是什么。为了说明这对最终结果几乎没有什么影响,看下面的代码:

import datetime
timestamp = datetime.date(2000, 10, 5)
filename = '%s.csv' % timestamp
print('Filename from date: %s' % filename)

timestamp = '2000-10-05'
filename = '%s.csv' % timestamp
print('Filename from str: %s' % filename)

正如你所期望的那样,结果是相同的:

Filename from date: 2000-10-05.csv
Filename from str: 2000-10-05.csv

同样适用于将数字转换为浮点数或整数;而不是强制执行某种类型,只需要求某些特性。需要一个可以作为数字传递的东西?只需尝试转换为intfloat。需要一个file对象?为什么不只是检查是否有一个带有hasattrread方法呢?

所以,不要这样做:

if isinstance(value, int):

相反,只需使用以下内容:

value = int(value)

而不是这样:

import io

if isinstance(fh, io.IOBase):

只需使用以下行:

if hasattr(fh, 'read'):

值和身份比较之间的差异

在 Python 中有几种比较对象的方法,标准的大于和小于,等于和不等于。但实际上还有一些其他方法,其中一个有点特殊。那就是身份比较运算符:不是使用if spam == eggs,而是使用if spam is eggs。最大的区别在于一个比较值,另一个比较身份。这听起来有点模糊,但实际上相当简单。至少在 CPython 实现中,比较的是内存地址,这意味着这是你可以得到的最轻量级的查找之一。而值需要确保类型是可比较的,也许需要检查子值,身份检查只是检查唯一标识符是否相同。

注意

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

看看这个例子:

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

print('%r == %r: %r' % (a, b, a == b))
print('%r is %r: %r' % (a, b, a is b))
print('%r == %r: %r' % (c, d, c == d))
print('%r is %r: %r' % (c, d, c is d))

虽然值是相同的,但身份是不同的。这段代码的实际结果如下:

256 == 256: True
256 is 256: True
257 == 257: True
257 is 257: False

问题在于 Python 为所有介于-5256之间的整数保留了一个内部整数对象数组;这就是为什么对256有效但对257无效的原因。

你可能会想知道为什么有人会想要使用is而不是==。有多个有效的答案;取决于情况,一个是正确的,另一个不是。但性能也可以是一个非常重要的考虑因素。基本准则是,当比较 Python 的单例对象,如TrueFalseNone时,总是使用is进行比较。

至于性能考虑,考虑以下例子:

spam = range(1000000)
eggs = range(1000000)

当执行spam == eggs时,这将比较两个列表中的每个项目,因此在内部实际上进行了 100 万次比较。将其与使用spam is eggs时的简单身份检查进行比较。

要查看 Python 在内部实际上使用is运算符时的操作,可以使用id函数。当执行if spam is eggs时,Python 实际上会在内部执行if id(spam) == id(eggs)

循环

对于来自其他语言的人来说,可能会倾向于使用for循环或甚至while循环来处理listtuplestr等的项目。虽然有效,但比必要的复杂。例如,考虑这段代码:

i = 0
while i < len(my_list):
    item = my_list[i]
    i += 1
    do_something(i, item)

而不是你可以这样做:

for i, item in enumerate(my_list):
    do_something(i, item)

虽然这可以写得更短,但通常不建议这样做,因为它不会提高可读性:

[do_something(i, item) for i, item in enumerate(my_list)]

最后一个选项对一些人可能是清晰的,但对一些人可能不是。我个人更倾向于在实际存储结果时才使用列表推导、字典推导和 map 和 filter 语句。

例如:

spam_items = [x for x in items if x.startswith('spam_')]

但前提是不会影响代码的可读性。

考虑一下这段代码:

eggs = [is_egg(item) or create_egg(item) for item in list_of_items if egg and hasattr(egg, 'egg_property') and isinstance(egg, Egg)]  eggs = [is_egg(item) or create_egg(item) for item in list_of_items
        if egg and hasattr(egg, 'egg_property')
        and isinstance(egg, Egg)]

不要把所有东西都放在列表推导中,为什么不把它分成几个函数呢?

def to_egg(item):
    return is_egg(item) or create_egg(item)

def can_be_egg(item):
    has_egg_property = hasattr(egg, 'egg_property')
    is_egg_instance = isinstance(egg, Egg)
    return egg and has_egg_property and is_egg_instance

eggs = [to_egg(item) for item in list_of_items if can_be_egg(item)]  eggs = [to_egg(item) for item in list_of_items if
        can_be_egg(item)]

虽然这段代码有点长,但我个人认为这样更易读。

最大行长度

许多 Python 程序员认为 79 个字符太过约束,只是保持行长。虽然我不会特别为 79 个字符辩论,但设置一个低且固定的限制,比如 79 或 99 是一个好主意。虽然显示器变得越来越宽,限制你的行仍然可以帮助你提高可读性,并且允许你将多个文件放在一起。我经常会打开四个 Python 文件并排放在一起。如果行宽超过 79 个字符,那就根本放不下了。

PEP8 指南告诉我们在行变得太长的情况下使用反斜杠。虽然我同意反斜杠比长行更可取,但我仍然认为应尽量避免使用。以下是 PEP8 的一个例子:

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())

或者可能是以下内容:

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:
    with open(filename_2, 'w') as file_2:
        file_2.write(file_1.read())

当然并非总是一个选择,但保持代码简洁和可读是一个很好的考虑。它实际上为代码添加了更多信息的奖励。如果您使用传达文件名目标的名称,而不是filename_1,那么您正在尝试做什么就立即变得更清晰。

验证代码质量,pep8,pyflakes 等

有许多用于检查 Python 代码质量的工具。最简单的工具,比如pep8,只验证一些简单的PEP8错误。更先进的工具,比如pylint,进行高级内省,以检测潜在的错误在其他情况下工作的代码。pylint提供的大部分内容对许多项目来说有点过头,但仍然值得一看。

flake8

flake8工具将 pep8、pyflakes 和 McCabe 结合起来,为代码设置了一个质量标准。flake8工具是我维护代码质量中最重要的包之一。我维护的所有包都要求 100%的flake8兼容性。它并不承诺可读的代码,但至少要求一定程度的一致性,这在与多个程序员一起编写项目时非常重要。

Pep8

用于检查 Python 代码质量的最简单的工具之一是pep8包。它并不检查 PEP8 标准中的所有内容,但它走了很长一段路,并且仍然定期更新以添加新的检查。pep8检查的一些最重要的事情如下:

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

  • 缺少空格,比如spam=123

  • 太多的空格,比如def eggs(spam = 123):

  • 太多或太少的空行

  • 行太长

  • 语法和缩进错误

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

结论是,pep8工具在测试空格和一些常见的样式问题方面帮助很大,但仍然相当有限。

pyflakes

这就是 pyflakes 的用武之地。pyflakes 比pep8更智能,它会警告你一些风格问题,比如:

  • 未使用的导入

  • 通配符导入(from module import *

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

但更重要的是,它会警告潜在的错误,比如以下内容:

  • 重新定义已导入的名称

  • 使用未定义的变量

  • 在赋值之前引用变量

  • 重复的参数名称

  • 未使用的局部变量

PEP8 的最后一部分由 pep8-naming 包涵盖。它确保您的命名接近 PEP8 规定的标准:

  • 类名为CapWord

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

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

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

McCabe

最后,还有 McCabe 复杂性。它通过查看抽象语法树AST)来检查代码的复杂性。它会找出有多少行、级别和语句,并在您的代码比预先配置的阈值更复杂时警告您。通常,您将通过flake8使用 McCabe,但也可以手动调用。使用以下代码:

def spam():
    pass

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

McCabe 将给我们以下输出:

# pip install mccabe
...
# python -m mccabe cabe_test.py 1:1: 'spam' 1
5:1: 'eggs' 4

当然,您的最大阈值是可配置的,但默认值为 10。 McCabe 测试返回一个受函数大小、嵌套深度和其他一些参数影响的数字。如果您的函数达到 10,可能是时候重构代码了。

flake8

所有这些组合在一起就是flake8,这是一个将这些工具结合起来并输出单个报告的工具。flake8生成的一些警告可能不符合您的口味,因此如果需要,每一项检查都可以在文件级别和整个项目级别上禁用。例如,我个人在所有项目中都禁用W391,它会警告文件末尾的空行。这是我在编写代码时发现很有用的,这样我就可以轻松地跳到文件末尾并开始编写代码,而不必先添加几行。

一般来说,在提交代码和/或将其放在网上之前,只需从源目录运行flake8以递归检查所有内容。

以下是一些格式不佳的代码演示:

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

def eggs():
    pass

它的结果如下:

# pip install flake8
...
# flake8 flake8_test.py
flake8_test.py:1:11: E231 missing whitespace after ','
flake8_test.py:1:13: E231 missing whitespace after ','
flake8_test.py:2:12: E231 missing whitespace after ','
flake8_test.py:2:14: E226 missing whitespace around arithmetic operator
flake8_test.py:4:1: E302 expected 2 blank lines, found 1

Pylint

pylint是一个更先进的——在某些情况下更好的——代码质量检查器。然而,pylint的强大功能也带来了一些缺点。而flake8是一个非常快速、轻量级和安全的质量检查工具,pylint具有更先进的内省,因此速度要慢得多。此外,pylint很可能会给出大量无关或甚至错误的警告。这可能被视为pylint的缺陷,但实际上更多的是被动代码分析的限制。诸如pychecker之类的工具实际上会加载和执行您的代码。在许多情况下,这是安全的,但也有一些情况是不安全的。想象一下执行一个删除文件的命令可能会发生什么。

虽然我对pylint没有意见,但一般来说,我发现大多数重要的问题都可以通过flake8来处理,其他问题也可以通过一些适当的编码标准轻松避免。如果配置正确,它可能是一个非常有用的工具,但如果没有配置,它会非常冗长。

常见陷阱

Python 是一种旨在清晰可读且没有任何歧义和意外行为的语言。不幸的是,这些目标并非在所有情况下都能实现,这就是为什么 Python 确实有一些特殊情况,它可能会做一些与您期望的不同的事情。

本节将向您展示编写 Python 代码时可能遇到的一些问题。

范围很重要!

在 Python 中有一些情况,您可能没有使用您实际期望的范围。一些例子是在声明类和使用函数参数时。

函数参数

以下示例显示了由于默认参数的粗心选择而导致的一个案例:

def spam(key, value, list_=[], dict_={}):
    list_.append(value)
    dict_[key] = value

    print('List: %r' % list_)
    print('Dict: %r' % dict_)

spam('key 1', 'value 1')
spam('key 2', 'value 2')

您可能会期望以下输出:

List: ['value 1']
Dict: {'key 1': 'value 1'}
List: ['value 2']
Dict: {'key 2': 'value 2'}

但实际上是这样的:

List: ['value 1']
Dict: {'key 1': 'value 1'}
List: ['value 1', 'value 2']
Dict: {'key 1': 'value 1', 'key 2': 'value 2'}

原因是list_dict_实际上是在多次调用之间共享的。唯一有用的情况是在做一些巧妙的事情时,所以请避免在函数中使用可变对象作为默认参数。

相同示例的安全替代如下:

def spam(key, value, list_=None, dict_=None):
    if list_ is None:
        list_ = []

    if dict_ is None:
        dict_ {}

    list_.append(value)
    dict_[key] = value

类属性

在定义类时也会出现问题。很容易混淆类属性和实例属性。特别是对于从其他语言(如 C#)转过来的人来说,这可能会令人困惑。让我们来举个例子:

class Spam(object):
    list_ = []
    dict_ = {}

    def __init__(self, key, value):
        self.list_.append(value)
        self.dict_[key] = value

        print('List: %r' % self.list_)
        print('Dict: %r' % self.dict_)

Spam('key 1', 'value 1')
Spam('key 2', 'value 2')

与函数参数一样,列表和字典是共享的。因此,输出如下:

List: ['value 1']
Dict: {'key 1': 'value 1'}
List: ['value 1', 'value 2']
Dict: {'key 1': 'value 1', 'key 2': 'value 2'}

更好的选择是在类的__init__方法中初始化可变对象。这样,它们不会在实例之间共享:

class Spam(object):
    def __init__(self, key, value):
        self.list_ = [key]
        self.dict_ = {key: value}

        print('List: %r' % self.list_)
        print('Dict: %r' % self.dict_)

处理类时需要注意的另一件重要事情是,类属性将被继承,这可能会让事情变得混乱。在继承时,原始属性将保留(除非被覆盖),即使在子类中也是如此:

 **>>> class A(object):
...     spam = 1

>>> class B(A):
...     pass

Regular inheritance, the spam attribute of both A and B are 1 as
you would expect.
>>> A.spam
1
>>> B.spam
1

Assigning 2 to A.spam now modifies B.spam as well
>>> A.spam = 2

>>> A.spam
2
>>> B.spam
2

虽然由于继承而可以预料到这一点,但使用类的其他人可能不会怀疑变量在此期间发生变化。毕竟,我们修改了A.spam,而不是B.spam

有两种简单的方法可以避免这种情况。显然,可以简单地为每个类单独设置spam。但更好的解决方案是永远不要修改类属性。很容易忘记属性将在多个位置更改,如果它必须是可修改的,通常最好将其放在实例变量中。

修改全局范围的变量

从全局范围访问变量时的一个常见问题是,设置变量会使其成为局部变量,即使访问全局变量也是如此。

这样可以工作:

 **>>> def eggs():
...     print('Spam: %r' % spam)

>>> eggs()
Spam: 1

但以下内容不是:

 **>>> spam = 1

>>> def eggs():
...     spam += 1
...     print('Spam: %r' % spam)

>>> eggs()
Traceback (most recent call last):
 **...
UnboundLocalError: local variable 'spam' referenced before assignment

问题在于spam += 1实际上转换为spam = spam + 1,而包含spam =的任何内容都会使变量成为您的范围内的局部变量。由于在那一点上正在分配局部变量,它还没有值,您正在尝试使用它。对于这些情况,有global语句,尽管我真的建议您完全避免使用全局变量。

覆盖和/或创建额外的内置函数

虽然在某些情况下可能有用,但通常您会希望避免覆盖全局函数。命名函数的PEP8约定-类似于内置语句、函数和变量-是使用尾随下划线。

因此,不要使用这个:

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

在迭代时修改

在某个时候,您将遇到这个问题:在迭代可变对象(如列表、字典或集合)时,您不能修改它们。所有这些都会导致RuntimeError告诉您在迭代期间不能修改对象:

dict_ = {'spam': 'eggs'}
list_ = ['spam']
set_ = {'spam', 'eggs'}

for key in dict_:
    del dict_[key]

for item in list_:
    list_.remove(item)

for item in set_:
    set_.remove(item)

这可以通过复制对象来避免。最方便的选项是使用list函数:

dict_ = {'spam': 'eggs'}
list_ = ['spam']
set_ = {'spam', 'eggs'}

for key in list(dict_):
    del dict_[key]

for item in list(list_):
    list_.remove(item)

for item in list(set_):
    set_.remove(item)

捕获异常- Python 2 和 3 之间的区别

使用 Python 3,捕获异常并存储它已经变得更加明显,使用as语句。问题在于许多人仍然习惯于except Exception, variable语法,这种语法已经不再起作用。幸运的是,Python 3 的语法已经回溯到 Python 2,所以现在您可以在任何地方使用以下语法:

try:
    ... # do something here
except (ValueError, TypeError) as e:
    print('Exception: %r' % e)

另一个重要的区别是,Python 3 使这个变量局限于异常范围。结果是,如果您想要在try/except块之后使用它,您需要在之前声明异常变量:

def spam(value):
    try:
        value = int(value)
    except ValueError as exception:
        print('We caught an exception: %r' % exception)

    return exception

spam('a')

您可能期望由于我们在这里得到一个异常,这样可以工作;但实际上,它不起作用,因为在return语句的那一点上exception不存在。

实际输出如下:

We caught an exception: ValueError("invalid literal for int() with base 10: 'a'",)
Traceback (most recent call last):
  File "test.py", line 14, in <module>
    spam('a')
  File "test.py", line 11, in spam
    return exception
UnboundLocalError: local variable 'exception' referenced before assignment

就个人而言,我会认为前面的代码在任何情况下都是错误的:如果没有异常怎么办?它会引发相同的错误。幸运的是,修复很简单;只需将值写入到作用域之外的变量中。这里需要注意的一点是,你需要明确保存变量到父作用域。这段代码也不起作用:

def spam(value):
    exception = None
    try:
        value = int(value)
    except ValueError as exception:
        print('We caught an exception: %r' % exception)

    return exception

我们真的需要明确保存它,因为 Python 3 会自动删除在except语句结束时使用as variable保存的任何内容。这样做的原因是 Python 3 的异常包含一个__traceback__属性。拥有这个属性会让垃圾收集器更难处理,因为它引入了一个递归的自引用循环(exception -> traceback -> exception -> traceback… ad nauseum)。为了解决这个问题,Python 基本上执行以下操作:

exception = None
try:
    value = int(value)
except ValueError as exception:
    try:
        print('We caught an exception: %r' % exception)
    finally:
        del exception

解决方案非常简单 - 幸运的是 - 但你应该记住,这可能会在程序中引入内存泄漏。Python 的垃圾收集器足够聪明,可以理解这些变量不再可见,并最终会删除它,但这可能需要更长的时间。垃圾收集实际上是如何工作的在第十二章中有介绍,性能 - 跟踪和减少内存和 CPU 使用情况。这是代码的工作版本:

def spam(value):
    exception = None
    try:
        value = int(value)
    except ValueError as e:
        exception = e
        print('We caught an exception: %r' % exception)

    return exception

延迟绑定 - 要小心闭包

闭包是在代码中实现局部作用域的一种方法。它使得可以在本地定义变量,而不会覆盖父(或全局)作用域中的变量,并且稍后将变量隐藏在外部作用域中。Python 中闭包的问题在于出于性能原因,Python 尝试尽可能晚地绑定其变量。虽然通常很有用,但它确实具有一些意想不到的副作用:

eggs = [lambda a: i * a for i in range(3)]

for egg in eggs:
    print(egg(5))

预期结果?应该是这样的,对吧?

0
5
10

不,不幸的是。这类似于类继承与属性的工作方式。由于延迟绑定,变量i在调用时从周围的作用域中调用,而不是在实际定义时调用。

实际结果如下:

10
10
10

那么应该怎么做呢?与前面提到的情况一样,需要将变量设为局部变量。一种替代方法是通过使用partial对函数进行柯里化来强制立即绑定。

import functools

eggs = [functools.partial(lambda i, a: i * a, i) for i in range(3)]

for egg in eggs:
    print(egg(5))

更好的解决方案是通过不引入额外的作用域(lambda)来避免绑定问题,这些作用域使用外部变量。如果ia都被指定为lambda的参数,这将不是一个问题。

循环导入

尽管 Python 对循环导入相当宽容,但也有一些情况会出现错误。

假设我们有两个文件。

eggs.py

from spam import spam

def eggs():
    print('This is eggs')
    spam()

spam.py

from eggs import eggs

def spam():
    print('This is spam')

if __name__ == '__main__':
    eggs()

运行spam.py将导致循环import错误:

Traceback (most recent call last):
  File "spam.py", line 1, in <module>
    from eggs import eggs
  File "eggs.py", line 1, in <module>
    from spam import spam
  File "spam.py", line 1, in <module>
    from eggs import eggs
ImportError: cannot import name 'eggs'

有几种方法可以解决这个问题。重新构造代码通常是最好的方法,但最佳解决方案取决于问题。在前面的情况下,可以很容易地解决。只需使用模块导入而不是函数导入(无论是否存在循环导入,我都建议这样做)。

eggs.py

import spam

def eggs():
    print('This is eggs')
    spam.spam()

spam.py

import eggs

def spam():
    print('This is spam')

if __name__ == '__main__':
    eggs.eggs()

另一种解决方案是将导入语句移到函数内部,以便在运行时发生。这不是最漂亮的解决方案,但在许多情况下都能解决问题。

eggs.py

def eggs():
    from spam import spam
    print('This is eggs')
    spam()

spam.py

def spam():
    from eggs import eggs
    print('This is spam')

if __name__ == '__main__':
    eggs()

最后还有一种解决方案,即将导入移到实际使用它们的代码下面。这通常不被推荐,因为它可能会使导入的位置不明显,但我仍然认为这比在函数调用中使用import更可取。

eggs.py

def eggs():
    print('This is eggs')
    spam()

from spam import spam

spam.py

def spam():
    print('This is spam')

from eggs import eggs

if __name__ == '__main__':
    eggs()

是的,还有其他解决方案,比如动态导入。其中一个例子是 Django 的ForeignKey字段支持字符串而不是实际类。但这些通常是一个非常糟糕的想法,因为它们只会在运行时进行检查。因此,错误只会在执行使用它的任何代码时引入,而不是在修改代码时引入。因此,请尽量避免这些情况,或者确保添加适当的自动化测试以防止意外错误。特别是当它们在内部引起循环导入时,它们将成为一个巨大的调试痛点。

导入冲突

一个极其令人困惑的问题是导入冲突——多个具有相同名称的包/模块。我在我的包上收到了不少 bug 报告,例如,有人试图使用我的numpy-stl项目,它位于名为stl的包中的一个名为stl.py的测试文件。结果是:它导入了自身而不是stl包。虽然这种情况很难避免,至少在包内部,相对导入通常是一个更好的选择。这是因为它还告诉其他程序员,导入来自本地范围而不是另一个包。因此,不要写import spam,而是写from . import spam。这样,代码将始终从当前包加载,而不是任何偶然具有相同名称的全局包。

除此之外,还存在包之间不兼容的问题。常见名称可能被几个包使用,因此在安装这些包时要小心。如果有疑问,只需创建一个新的虚拟环境,然后再试一次。这样做可以节省大量的调试时间。

摘要

本章向我们展示了 Python 哲学的全部内容,并向我们解释了 Python 之禅的含义。虽然代码风格是非常个人化的,但 Python 至少有一些非常有帮助的指导方针,至少能让人们大致保持在同一页面和风格上。最后,我们都是成年人;每个人都有权利按照自己的意愿编写代码。但我请求您。请阅读风格指南,并尽量遵守它们,除非您有一个真正充分的理由不这样做。

随着这种力量而来的是巨大的责任,也有一些陷阱,尽管并不多。有些陷阱足够棘手,以至于经常会让我困惑,而我已经写了很长时间的 Python 了!Python 不断改进。自 Python 2 以来,许多陷阱已经得到解决,但有些将永远存在。例如,递归导入和定义在大多数支持它们的语言中很容易让你掉进陷阱,但这并不意味着我们会停止努力改进 Python。

Python 多年来的改进的一个很好的例子是 collections 模块。它包含了许多有用的集合,这些集合是由用户添加的,因为有这样的需求。其中大多数实际上是用纯 Python 实现的,因此它们很容易被任何人阅读。理解可能需要更多的努力,但我真的相信,如果你能读完这本书,你将没有问题理解这些集合的作用。但我不能保证完全理解内部工作;其中一些部分更多地涉及通用计算机科学而不是 Python 掌握。

下一章将向您展示 Python 中可用的一些集合以及它们的内部构造。尽管您无疑熟悉列表和字典等集合,但您可能不清楚某些操作涉及的性能特征。如果本章中的一些示例不够清晰,您不必担心。下一章将至少重新讨论其中一些,并且更多内容将在后续章节中介绍。

第三章:容器和集合-正确存储数据

Python 捆绑了几个非常有用的集合,其中一些是基本的 Python 集合数据类型。其余的是这些类型的高级组合。在本章中,我们将解释其中一些集合的使用方法,以及它们各自的优缺点。

在我们正式讨论数据结构和相关性能之前,需要对时间复杂度(特别是大 O 符号)有基本的了解。不用担心!这个概念非常简单,但没有它,我们无法轻松地解释操作的性能特征。

一旦大 O 符号清晰,我们将讨论基本数据结构:

  • list

  • dict

  • set

  • tuple

在基本数据结构的基础上,我们将继续介绍更高级的集合,例如以下内容:

  • 类似字典的类型:

  • ChainMap

  • Counter

  • Defaultdict

  • OrderedDict

  • 列表类型:

  • Deque

  • Heapq

  • 元组类型:

  • NamedTuple

  • 其他类型:

  • Enum

时间复杂度-大 O 符号

在开始本章之前,您需要了解一个简单的符号。本章大量使用大 O 符号来指示操作的时间复杂度。如果您已经熟悉这个符号,可以跳过这一段。虽然这个符号听起来很复杂,但实际概念非常简单。

当我们说一个函数需要O(1)的时间时,这意味着通常只需要1步来执行。同样,一个具有O(n)的函数将需要n步来执行,其中n通常是对象的大小。这种时间复杂度只是对执行代码时可以预期的基本指示,因为这通常是最重要的。

该系统的目的是指示操作的大致性能;这与代码速度无关,但仍然相关。执行单个步骤的代码1000次更快,但需要执行O(2**n)步骤的代码仍然比另一个版本慢,因为对于 n 等于10或更高的值,它只需要O(n)步骤。这是因为n=102**n2**10=1024,也就是说,执行相同代码需要 1,024 步。这使得选择正确的算法非常重要。即使C代码通常比 Python 快,如果使用错误的算法,也毫无帮助。

例如,假设您有一个包含1000个项目的列表,并且您遍历它们。这将花费O(n)的时间,因为有n=1000个项目。检查项目是否存在于列表中需要O(n)的时间,因此需要 1,000 步。这样做 100 次将花费100*O(n) = 100 * 1000 = 100,000步。当您将其与dict进行比较时,检查项目是否存在只需要O(1)的时间,差异是巨大的。使用dict,将是100*O(1) = 100 * 1 = 100步。因此,对于包含 1000 个项目的对象,使用dict而不是list将大约快 1,000 倍:

n = 1000
a = list(range(n))
b = dict.fromkeys(range(n))
for i in range(100):
    i in a  # takes n=1000 steps
    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
o_n(items)  # n = 10 operations
o_n_squared(items)  # n*n = 10*10 = 100 operations

应该注意,本章中的大 O 是关于平均情况,而不是最坏情况。在某些情况下,它们可能更糟,但这些情况很少,可以忽略不计。

核心集合

在本章稍后讨论更高级的组合集合之前,您需要了解核心 Python 集合的工作原理。这不仅仅是关于使用,还涉及到时间复杂度,这会对应用程序随着增长而产生强烈影响。如果您熟悉这些对象的时间复杂度,并且熟记 Python 3 的元组打包和解包的可能性,那么可以直接跳到高级集合部分。

list - 一个可变的项目列表

list很可能是您在 Python 中最常用的容器结构。它的使用简单,对于大多数情况,性能很好。

虽然你可能已经熟悉了列表的使用,但你可能不知道list对象的时间复杂度。幸运的是,list的许多时间复杂度非常低;appendgetsetlen都需要O(1)的时间-这是最好的可能性。但是,你可能不知道removeinsert的时间复杂度是O(n)。因此,要从 1000 个项目中删除一个项目,Python 将不得不遍历 1000 个项目。在内部,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 需要复制整个列表,这在列表较大时特别耗费资源。当执行一次时,当然不是那么糟糕。但是当执行大量删除时,filterlist推导是一个更快的解决方案,因为如果结构良好,它只需要复制列表一次。例如,假设我们希望从列表中删除一组特定的数字。我们有很多选项。第一个是使用remove,然后是列表推导,然后是filter语句。第四章, 功能编程-可读性与简洁性,将更详细地解释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),因为它可能不存在,如果不存在,那么所有的值都需要被检查。

dict-无序但快速的项目映射

dict必须至少是你在 Python 中使用的前三种容器结构之一。它快速,易于使用,非常有效。平均时间复杂度正如你所期望的那样-O(1)对于getsetdel-但也有一些例外。dict的工作方式是通过使用hash函数(调用对象的__hash__函数)将键转换为哈希并将其存储在哈希表中。然而,哈希表有两个问题。第一个和最明显的问题是,项目将按哈希排序,这在大多数情况下是随机的。哈希表的第二个问题是它们可能会发生哈希冲突,哈希冲突的结果是在最坏的情况下,所有先前的操作可能需要O(n)。哈希冲突并不太可能发生,但它们可能发生,如果一个大的dict表现不佳,那就是需要查看的地方。

让我们看看这在实践中是如何工作的。为了举例说明,我将使用我能想到的最简单的哈希算法,即数字的最高位。所以,对于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

现在我们将使用这种哈希方法使用一个列表的列表来模拟一个dict。我们知道我们的哈希方法只能返回09之间的数字,所以我们在列表中只需要 10 个桶。现在我们将添加一些值,并展示 spam in eggs 可能如何工作:

>>> 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()函数与计数集合配对,这在counter – keeping track of the most occurring elements部分有讨论。

除了散列冲突性能问题,还有另一种可能让你感到惊讶的行为。当从字典中删除项时,它实际上不会立即调整内存中的字典大小。结果是复制和迭代整个字典都需要O(m)时间(其中 m 是字典的最大大小);当前项数 n 不会被使用。因此,如果向dict中添加 1000 个项并删除 999 个项,迭代和复制仍将需要 1000 步。解决此问题的唯一方法是重新创建字典,这是copyinsert操作都会在内部执行的操作。请注意,insert操作期间的重新创建不是保证的,而是取决于内部可用的空闲插槽数量。

set - 没有值的字典

set是一种使用散列方法获取唯一值集合的结构。在内部,它与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:', spam)
eggs: amps

前几个基本上都是预期的。在操作符处,它变得有趣起来。

表达式 输出 解释
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仅用于一致的输出,因为setdict类似,没有预定义的排序顺序。

元组 - 不可变列表

tuple是一个你经常使用而甚至都没有注意到的对象。当你最初看到它时,它似乎是一个无用的数据结构。它就像一个你无法修改的列表,那么为什么不只使用list呢?有一些情况下,tuple提供了一些list没有的非常有用的功能。

首先,它们是可散列的。这意味着你可以将tuple用作dict中的键,这是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'}

然而,它实际上可以比简单的数字更复杂。只要tuple的所有元素都是可散列的,它就可以工作。这意味着你可以使用嵌套的元组、字符串、数字和任何其他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'}

你可以使它们变得如你所需的那样复杂。只要所有部分都是可散列的,它就会按预期运行。

也许更有用的是元组也支持元组打包和解包:

# 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 actually assigns as a
list, not 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]

# Which works both ways
>>> a
2
>>> 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()
>>> print('spam: %s, eggs: %s' % (spam, eggs))
spam: spam, eggs: eggs

高级集合

以下集合大多只是基本集合的扩展,其中一些非常简单,另一些则稍微复杂一些。不过,对于所有这些集合,了解底层结构的特性是很重要的。如果不了解它们,将很难理解这些集合的特性。

出于性能原因,有一些集合是用本机 C 代码实现的,但所有这些集合也可以很容易地在纯 Python 中实现。

ChainMap - 字典列表

在 Python 3.3 中引入的ChainMap允许您将多个映射(例如字典)合并为一个。这在合并多个上下文时特别有用。例如,在查找当前作用域中的变量时,默认情况下,Python 会在locals()globals(),最后是builtins中搜索。

通常,您会这样做:

import builtins

builtin_vars = vars(builtins)
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('name %r is not defined' % key)

这样做是有效的,但至少可以说很丑陋。当然,我们可以让它更漂亮:

import builtins

mappings = globals(), locals(), vars(builtins)
for mapping in mappings:
    if key in mapping:
        value = mapping[key]
        break
else:
    raise NameError('name %r is not defined' % key)

好多了!而且,这实际上可以被认为是一个不错的解决方案。但自从 Python 3.3 以来,它变得更容易了。现在我们可以简单地使用以下代码:

import builtins
import collections

mappings = collections.ChainMap(globals(), locals(), vars(builtins))
value = mappings[key]

ChainMap集合对于命令行应用程序非常有用。最重要的配置是通过命令行参数进行的,然后是目录本地配置文件,然后是全局配置文件,最后是默认配置:

import argparse
import collections

defaults = {
    'spam': 'default spam value',
    'eggs': 'default eggs value',
}

parser = argparse.ArgumentParser()
parser.add_argument('--spam')
parser.add_argument('--eggs')

args = vars(parser.parse_args())
# We need to check for empty/default values so we can't simply use vars(args)
filtered_args = {k: v for k, v in args.items() if v}

combined = collections.ChainMap(filtered_args, defaults)

print(combined ['spam'])

请注意,仍然可以访问特定的映射:

print(combined.maps[1]['spam'])

for map_ in combined.maps:
    print(map_.get('spam'))

counter - 跟踪最常出现的元素

counter是一个用于跟踪元素出现次数的类。它的基本用法如您所期望的那样:

>>> import collections

>>> counter = collections.Counter('eggs')
>>> for k in 'eggs':
...     print('Count for %s: %d' % (k, counter[k]))
Count for e: 1
Count for g: 2
Count for g: 2
Count for s: 1

但是,counter不仅仅可以返回计数。它还有一些非常有用且快速(它使用heapq)的方法来获取最常见的元素。即使向计数器添加了一百万个元素,它仍然在一秒内执行:

>>> import math
>>> import collections

>>> counter = collections.Counter()
>>> for i in range(0, 100000):
...    counter[math.sqrt(i) // 25] += 1

>>> for key, count in counter.most_common(5):
...     print('%s: %d' % (key, count))
11.0: 14375
10.0: 13125
9.0: 11875
8.0: 10625
12.0: 10000

但等等,还有更多!除了获取最频繁的元素之外,还可以像我们之前看到的set操作一样添加、减去、交集和"联合"计数器。那么添加两个计数器和对它们进行联合有什么区别呢?正如您所期望的那样,它们是相似的,但有一点不同。让我们看看它的工作原理:

>>> import collections

>>> def print_counter(expression, counter):
...     sorted_characters = sorted(counter.elements())
...     print(expression, ''.join(sorted_characters))

>>> eggs = collections.Counter('eggs')
>>> spam = collections.Counter('spam')
>>> print_counter('eggs:', eggs)
eggs: eggs
>>> print_counter('spam:', spam)
spam: amps
>>> print_counter('eggs & spam:', eggs & spam)
eggs & spam: s
>>> print_counter('spam & eggs:', spam & eggs)
spam & eggs: s
>>> print_counter('eggs - spam:', eggs - spam)
eggs - spam: egg
>>> print_counter('spam - eggs:', spam - eggs)
spam - eggs: amp
>>> print_counter('eggs + spam:', eggs + spam)
eggs + spam: aeggmpss
>>> print_counter('spam + eggs:', spam + eggs)
spam + eggs: aeggmpss
>>> print_counter('eggs | spam:', eggs | spam)
eggs | spam: aeggmps
>>> print_counter('spam | eggs:', spam | eggs)
spam | eggs: aeggmps

前两个是显而易见的。eggs字符串只是一个包含两个"g",一个"s"和一个"e"的字符序列,spam 几乎相同,但字母不同。

spam & eggs的结果(以及反向)也是非常可预测的。spam 和 eggs 之间唯一共享的字母是s,因此这就是结果。在计数方面,它只是对来自两者的共享元素执行min(element_a, element_b),并得到最低值。

从 eggs 中减去字母spam,剩下eg。同样,从 spam 中删除egs,剩下pam

现在,添加就像您所期望的那样 - 只是对两个计数器的每个元素进行逐个相加。

那么联合(OR)有什么不同呢?它获取每个计数器中元素的max(element_a, element_b),而不是将它们相加;与添加的情况一样。

最后,正如前面的代码所示,elements 方法返回一个由计数重复的所有元素扩展列表。

注意

Counter对象将在执行数学运算期间自动删除零或更少的元素。

deque - 双端队列

deque(双端队列)对象是最古老的集合之一。它是在 Python 2.4 中引入的,所以到目前为止已经有 10 多年的历史了。一般来说,这个对象对于大多数目的来说现在都太低级了,因为许多操作本来会使用它,现在有很好的支持库可用,但这并不使它变得不那么有用。

在内部,deque被创建为一个双向链表,这意味着每个项目都指向下一个和上一个项目。由于deque是双端的,列表本身指向第一个和最后一个元素。这使得从列表的开头/结尾添加和删除项目都是非常轻松的O(1)操作,因为只需要改变指向列表开头/结尾的指针,并且需要添加指针到第一个/最后一个项目,具体取决于是在开头还是结尾添加项目。

对于简单的堆栈/队列目的,使用双端队列似乎是浪费的,但性能足够好,我们不必担心产生的开销。deque类是完全在 C 中实现的(使用 CPython)。

它作为队列的使用非常简单:

>>> import collections

>>> queue = collections.deque()
>>> queue.append(1)
>>> queue.append(2)
>>> queue
deque([1, 2])
>>> queue.popleft()
1
>>> queue.popleft()
2
>>> queue.popleft()
Traceback (most recent call last):
 **...
IndexError: pop from an empty deque

正如预期的那样,由于只有两个项目,我们尝试获取三个项目,所以会出现IndexError

作为堆栈的使用几乎相同,但我们必须使用pop而不是popleft(或者使用appendleft而不是append):

>>> import collections

>>> queue = collections.deque()
>>> queue.append(1)
>>> queue.append(2)
>>> queue
deque([1, 2])
>>> queue.pop()
2
>>> queue.pop()
1
>>> queue.pop()
Traceback (most recent call last):
 **...
IndexError: pop from an empty deque

另一个非常有用的功能是deque可以使用maxlen参数作为循环队列。通过使用这个参数,它可以用来保留最后的n个状态消息或类似的东西:

>>> import collections

>>> circular = collections.deque(maxlen=2)
>>> for i in range(5):
...     circular.append(i)
...     circular
deque([0], maxlen=2)
deque([0, 1], maxlen=2)
deque([1, 2], maxlen=2)
deque([2, 3], maxlen=2)
deque([3, 4], maxlen=2)
>>> circular
deque([3, 4], maxlen=2)

每当您需要单线程应用程序中的队列或堆栈类时,deque是一个非常方便的选择。如果您需要将对象同步到多线程操作,则queue.Queue类更适合。在内部,它包装了deque,但它是一个线程安全的替代方案。在同一类别中,还有一个用于异步操作的asyncio.Queue和一个用于多进程操作的multiprocessing.Queueasyncio和多进程的示例分别可以在第七章和第十三章中找到。

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的功能,但在许多情况下它可以胜任:

>>> 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的基础,它会递归生成自己。

namedtuple - 带有字段名称的元组

namedtuple对象确实就像名字暗示的那样 - 一个带有名称的元组。它有一些有用的用例,尽管我必须承认我在实际中并没有找到太多用例,除了一些 Python 模块,比如 inspect 和urllib.parse。2D 或 3D 空间中的点是它明显有用的一个很好的例子:

>>> import collections

>>> Point = collections.namedtuple('Point', ['x', 'y', 'z'])
>>> point_a = Point(1, 2, 3)
>>> point_a
Point(x=1, y=2, z=3)

>>> point_b = Point(x=4, z=5, y=6)
>>> point_b
Point(x=4, y=6, z=5)

关于namedtuple,并没有太多可以说的;它做你期望的事情,最大的优势是属性可以通过名称和索引执行,这使得元组解包非常容易:

>>> x, y, z = point_a
>>> print('X: %d, Y: %d, Z: %d' % (x, y, z))
X: 1, Y: 2, Z: 3
>>> print('X: %d, Y: %d, Z: %d' % point_b)
X: 4, Y: 6, Z: 5
>>> print('X: %d' % point_a.x)

enum - 一组常量

enum包与namedtuple非常相似,但目标和接口完全不同。基本的enum对象使得在模块中拥有常量变得非常容易,同时避免了魔术数字。这是一个基本的例子:

>>> 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

>>> import enum

>>> class Spam(enum.Enum):
...     EGGS = 'eggs'

>>> Spam.EGGS == 'eggs'
False

以下是带有str继承的enum

>>> import enum

>>> class Spam(str, enum.Enum):
...     EGGS = 'eggs'

>>> Spam.EGGS == 'eggs'
True

OrderedDict - 插入顺序很重要的字典

OrderdDict是一个跟踪插入顺序的dict。而普通的dict会按照哈希的顺序返回键,OrderedDict会按照插入的顺序返回键。所以,它不是按键或值排序的,但这也很容易实现:

>>> import collections

>>> spam = collections.OrderedDict()
>>> spam['b'] = 2
>>> spam['c'] = 3
>>> spam['a'] = 1
>>> spam
OrderedDict([('b', 2), ('c', 3), ('a', 1)])

>>> for key, value in spam.items():
...     key, value
('b', 2)
('c', 3)
('a', 1)

>>> eggs = collections.OrderedDict(sorted(spam.items()))
>>> eggs
OrderedDict([('a', 1), ('b', 2), ('c', 3)])

虽然你可能猜到了它是如何工作的,但内部可能会让你有点惊讶。我知道我原本期望的实现方式是不同的。

在内部,OrderedDict使用普通的dict来存储键/值,并且除此之外,它还使用一个双向链表来跟踪下一个/上一个项目。为了跟踪反向关系(从双向链表返回到键),还有一个额外的dict存储在内部。

简而言之,OrderedDict可以是一个非常方便的工具,用来保持你的dict排序,但它是有代价的。这个系统的结构使得setget非常快速(O(1)),但是与普通的dict相比,这个对象仍然更加沉重(内存使用量增加一倍或更多)。当然,在许多情况下,内部对象的内存使用量将超过dict本身的内存使用量,但这是需要记住的一点。

heapq - 有序列表

heapq模块是一个非常好的小模块,它可以非常容易地在 Python 中创建一个优先队列。这种结构总是可以在最小的(或最大的,取决于实现)项目上进行最小的努力。API 非常简单,它的使用最好的例子之一可以在OrderedDict对象中看到。你可能不想直接使用heapq,但了解内部工作原理对于分析诸如OrderedDict之类的类是很重要的。

提示

如果你正在寻找一个结构来保持你的列表始终排序,可以尝试使用bisect模块。

基本用法非常简单:

>>> 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。这并不使它变得不那么有用,但这是需要考虑的一点。你可能也会想为什么heap没有排序。实际上,它是排序的,但不是你期望的方式。如果你将heap视为一棵树,它就会变得更加明显:

   1
 2   3
7 3 4 5

最小的数字总是在顶部,最大的数字总是在树的底部。因此,找到最小的数字非常容易,但找到最大的数字就不那么容易了。要获得堆的排序版本,我们只需要不断地删除树的顶部,直到所有项目都消失。

bisect - 排序列表

我们在前一段中看到了heapq模块,它使得从列表中始终获取最小的数字变得非常简单,因此也很容易对对象列表进行排序。heapq模块将项目附加到形成类似树的结构,而bisect模块以使它们保持排序的方式插入项目。一个很大的区别是,使用heapq模块添加/删除项目非常轻便,而使用bisect模块查找项目非常轻便。如果您的主要目的是搜索,那么bisect应该是您的选择。

heapq一样,bisect并不真正创建一个特殊的数据结构。它只是在一个标准的list上操作,并期望该list始终保持排序。重要的是要理解这一点的性能影响;仅仅使用bisect算法向列表添加项目可能会非常慢,因为在列表上插入需要O(n)的时间。实际上,使用 bisect 创建一个排序列表需要O(n*n)的时间,这相当慢,特别是因为使用heapq或 sorted 创建相同的排序列表只需要O(n * log(n))的时间。

注意

log(n)是指以 2 为底的对数函数。要计算这个值,可以使用math.log2()函数。这意味着每当数字的大小加倍时,值就会增加 1。对于n=2log(n)的值为1,因此对于n=4n=8,对数值分别为23

这意味着 32 位数字,即2**32 = 4294967296,具有32的对数。

如果您有一个排序的结构,并且只需要添加一个单个项目,那么可以使用bisect算法进行插入。否则,通常更快的方法是简单地附加项目,然后调用.sort()

为了说明,我们有这些行:

>>> 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)) = O(4 * log(4)) = O(8)
>>> sorted_list
[1, 2, 3, 5]

Using bisect:
>>> sorted_list = []
>>> bisect.insort(sorted_list, 5)  # O(n) = O(1)
>>> bisect.insort(sorted_list, 3)  # O(n) = O(2)
>>> bisect.insort(sorted_list, 1)  # O(n) = O(3)
>>> bisect.insort(sorted_list, 2)  # O(n) = O(4)
>>> sorted_list
[1, 2, 3, 5]

对于少量项目,差异是可以忽略的,但它很快就会增长到一个差异很大的程度。对于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。因此,在插入许多项目时要非常小心。

不过,在列表中进行搜索非常快;因为它是排序的,我们可以使用一个非常简单的二分搜索算法。例如,如果我们想要检查列表中是否存在一些数字呢?

>>> import bisect

>>> sorted_list = [1, 2, 3, 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)
True
>>> contains(sorted_list, 4)
False
>>> contains(sorted_list, 6)
False

如您所见,bisect_left函数找到了数字应该在的位置。这实际上也是insort函数所做的;它通过搜索数字的位置来将数字插入到正确的位置。

那么这与sorted_list中的常规值有什么不同呢?最大的区别在于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 个项目来说,这将是 1000 步和log(1000) = 10步之间的差异。

总结

Python 内置了一些非常有用的集合。由于越来越多的集合定期添加,最好的做法就是简单地跟踪集合手册。你是否曾经想过任何结构是如何工作的,或者为什么会这样?只需在这里查看源代码:

hg.python.org/cpython/file/default/Lib/collections/__init__.py

完成本章后,你应该了解核心集合和集合模块中最重要的集合,更重要的是这些集合在几种情景下的性能特征。在应用程序中选择正确的数据结构是你的代码将经历的最重要的性能因素,这对于任何程序员来说都是必不可少的知识。

接下来,我们将继续讨论函数式编程,其中包括lambda函数、list推导、dict推导、set推导以及一系列相关主题。这包括一些涉及的数学背景信息,可能会很有趣,但可以安全地跳过。

第四章:函数式编程-可读性与简洁性

Python 是少数(或至少是最早的)不是函数式语言的语言之一,它包含了函数式特性。虽然 Guido van Rossum 曾试图多次删除其中一些特性,但它们已经深入到 Python 社区中,list推导(dictset推导很快也会跟进)在各种代码中被广泛使用。代码最重要的事情不应该是您的reduce语句有多酷,或者您如何能够用一个难以理解的列表推导将整个函数放入一行。可读性很重要(再次,PEP20)!

本章将向您展示 Python 函数式编程提供的一些很酷的技巧,并解释 Python 实现的一些限制。虽然我们会尽量避免使用λ演算(λ-演算),但会简要讨论Y 组合子

最后几段将列出(并解释)functoolsitertools库的用法。如果您熟悉这些库,请随意跳过它们,但请注意,这些库中的一些将在后面关于装饰器(第五章,装饰器-通过装饰实现代码重用)、生成器(第六章,生成器和协程-无限,一步一步)和性能(第十二章,性能-跟踪和减少内存和 CPU 使用)的章节中大量使用。

本章涵盖的主题包括:

  • 函数式编程的理论

  • list推导

  • dict推导

  • set推导

  • lambda函数

  • functoolspartialreduce

  • itertoolsaccumulatechaindropwhilestarmap等)

函数式编程

函数式编程是源自λ演算的一种范式。不深入λ演算(λ-演算),这大致意味着计算是通过使用数学函数来执行的,这避免了可变数据和改变周围状态的情况。严格功能语言的想法是所有函数输出仅依赖于输入,而不依赖于任何外部状态。由于 Python 并不严格是一种函数式编程语言,这并不一定成立,但遵循这种范式是一个好主意,因为混合这些可能会导致意想不到的错误,正如第二章中讨论的那样,Pythonic Syntax, Common Pitfalls, and Style Guide

即使在函数式编程之外,这也是一个好主意。保持函数纯粹功能(仅依赖于给定输入)使代码更清晰,更易理解,并且更易于测试,因为依赖性更少。math模块中可以找到一些著名的例子。这些函数(sincospowsqrt等)具有严格依赖于输入的输入和输出。

列表推导

Python 的list推导是将函数或过滤器应用于一系列项目的非常简单的方法。如果使用正确,list推导可以非常有用,但如果不小心使用,可能会非常难以阅读。

让我们立即看几个例子。list推导的基本前提看起来是这样的:

>>> squares = [x ** 2 for x in range(10)]
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

我们可以很容易地扩展这个过滤器:

>>> uneven_squares = [x ** 2 for x in range(10) if x % 2]
>>> uneven_squares
[1, 9, 25, 49, 81]

语法与常规 Python for 循环非常接近,但if语句和自动存储结果使其在某些情况下非常有用。常规的 Python 等价物并不长,但是:

>>> uneven_squares = []
>>> for x in range(10):
...     if x % 2:
...         uneven_squares.append(x ** 2)

>>> uneven_squares
[1, 9, 25, 49, 81]

但必须小心,由于特殊的列表推导结构,某些类型的操作并不像你期望的那样明显。这一次,我们正在寻找大于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]]

即使有额外的缩进,列表推导也并不那么易读。当然,有四个嵌套循环,这是可以预料的。有些情况下,嵌套列表推导可能是合理的,但通常我不建议使用它们。

dict 推导

dict推导与列表推导非常相似,但结果是一个dict。除此之外,唯一的真正区别是你需要返回一个键和一个值,而列表推导接受任何类型的值。以下是一个基本示例:

>>> {x: x ** 2 for x in range(10)}
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36, 7: 49, 8: 64, 9: 81}

>>> {x: x ** 2 for x in range(10) if x % 2}
{1: 1, 3: 9, 9: 81, 5: 25, 7: 49}

注意

由于输出是一个字典,键需要是可散列的才能使dict推导工作。

有趣的是你可以混合这两个,当然,这样会更加难以阅读:

>>> {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]}

显然,你需要小心处理这些。如果使用正确,它们可能非常有用,但即使有适当的空格,输出也很快变得难以阅读。

集合推导

就像你可以使用花括号({})创建一个set一样,你也可以使用set推导来创建一个集合。它们的工作方式类似于列表推导,但值是唯一的(并且没有排序顺序):

>>> [x*y for x in range(3) for y in range(3)]
[0, 0, 0, 0, 1, 2, 0, 2, 4]

>>> {x*y for x in range(3) for y in range(3)}
{0, 1, 2, 4}

注意

与常规集合一样,set推导只支持可散列类型。

lambda 函数

Python 中的lambda语句只是一个匿名函数。由于语法的限制,它比常规函数稍微有限,但可以通过它完成很多事情。但是,可读性很重要,所以通常最好尽可能简单。其中一个更常见的用例是sorted函数的sort关键字:

>>> class Spam(object):
...     def __init__(self, value):
...         self.value = value
...
...     def __repr__(self):
...         return '<%s: %s>' % (self.__class__.__name__, self.value)
...
>>> spams = [Spam(5), Spam(2), Spam(4), Spam(1)]
>>> sorted_spams = sorted(spams, key=lambda spam: spam.value)
>>> spams
[<Spam: 5>, <Spam: 2>, <Spam: 4>, <Spam: 1>]
>>> sorted_spams
[<Spam: 1>, <Spam: 2>, <Spam: 4>, <Spam: 5>]

虽然在这种情况下函数可以单独编写,或者Spam__cmp__方法可以被重写,但在许多情况下,这是一个快速获得排序函数的简单方法。

并不是说常规函数会很啰嗦,但是通过使用匿名函数,你有一个小优势;你不会用额外的函数污染你的本地范围:

>>> def key_function(spam):
...     return spam.value

>>> spams = [Spam(5), Spam(2), Spam(4), Spam(1)]
>>> sorted_spams = sorted(spams, key=lambda spam: spam.value)

至于风格,请注意PEP8规定将 lambda 分配给变量是一个坏主意。逻辑上也是如此。匿名函数的概念就是这样——匿名的。如果你给它一个身份,你应该将它定义为一个普通函数。如果你想保持它简短,实际上并不会长太多。请注意,以下两个语句都被认为是不好的风格,仅用于示例目的:

>>> def key(spam): return spam.value

>>> key = lambda spam: spam.value

在我看来,lambda函数的唯一有效用例是作为函数参数使用的匿名函数,最好只有在它们足够短以适合单行时。

Y 组合子

注意

请注意,此段落可以轻松跳过。这主要是 lambda 语句的数学价值的一个例子。

Y 组合子可能是λ-演算中最著名的例子:

The Y combinator

所有这些看起来非常复杂,但这也是因为它使用了 lambda 演算符号。您应该将此语法读作,The Y combinator,作为一个接受x作为输入并返回The Y combinator的匿名(lambda)函数。在 Python 中,这几乎可以与原始 lambda 演算符号完全相同地表达,只是用 lambda 替换The Y combinator,用:替换.,因此结果是 lambda x: x²

通过一些代数运算,这可以简化为The Y combinator,或者一个接受f函数并将其应用于自身的函数。该函数的λ-演算表示如下:

The Y combinator

以下是 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

这可能对您来说仍然有点不清楚,所以让我们看一个实际使用它的例子:

>>> 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

请注意,nn * c(n – 1)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

您可能会想知道整个练习的重点是什么。难道你不能更短/更容易地写一个阶乘吗?是的,你可以。Y 组合子的重要性在于它可以应用于任何函数,并且非常接近数学定义。

最后一个 Y 组合子的例子将通过在几行中定义quicksort来给出:

>>> 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 与数学定义的接近程度。基本上,区别只在于表示法,而不在功能上。

functools

除了list/dict/set推导,Python 还有一些(更高级)函数,在函数编程时可能非常方便。functools库是一组返回可调用对象的函数。其中一些函数用作装饰器(我们将在第五章中详细介绍),但我们要讨论的函数是直接用作函数,以使您的生活更轻松。

partial-无需每次重复所有参数

partial函数非常方便,可以为经常使用但无法(或不想)重新定义的函数添加一些默认参数。在面向对象的代码中,通常可以解决类似这样的情况,但在过程式代码中,您经常需要重复参数。让我们以第三章中的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参数,那么为什么不为它创建一个快捷方式呢?这就是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]

看起来更清晰了,对吧?在这种情况下,两个版本都相当简短和可读,但这是一个方便的函数。

为什么我们应该使用partial而不是编写一个lambda参数?嗯,这主要是为了方便,但它还有助于解决第二章中讨论的延迟绑定问题。此外,partial 函数可以被 pickled,而lambda语句不行。

reduce-将一对组合成单个结果

reduce函数实现了一种称为fold的数学技术。它基本上将一个函数应用于第一个和第二个元素,使用该结果将第三个元素一起应用,并继续直到列表耗尽。

reduce函数受许多语言支持,但在大多数情况下使用不同的名称,如curryfoldaccumulateaggregate。Python 实际上很长时间以来一直支持reduce,但自 Python 3 以来,它已经从全局范围移动到functools库。一些代码可以使用reduce语句进行简化;它是否可读是值得商榷的。

实现阶乘函数

reduce最常用的例子之一是计算阶乘,这确实非常简单:

>>> import operator
>>> import functools
>>> functools.reduce(operator.mul, range(1, 6))
120

注意

上述代码使用operator.mul而不是lambda a, b: a * b。虽然它们产生相同的结果,但前者可能更快。

在内部,reduce函数将执行以下操作:

>>> import operator
>>> f = operator.mul
>>> f(f(f(f(1, 2), 3), 4), 5)
120

为了进一步澄清这一点,让我们这样看:

>>> iterable = range(1, 6)
>>> import operator

# The initial values:
>>> a, b, *iterable = iterable
>>> a, b, iterable
(1, 2, [3, 4, 5])

# First run
>>> a = operator.mul(a, b)
>>> b, *iterable = iterable
>>> a, b, iterable
(2, 3, [4, 5])

# Second run
>>> a = operator.mul(a, b)
>>> b, *iterable = iterable
>>> a, b, iterable
(6, 4, [5])

# Third run
>>> a = operator.mul(a, b)
>>> b, *iterable = iterable
>>> a, b, iterable
(24, 5, [])

# Fourth and last run
>>> a = operator.mul (a, b)
>>> a
120

或者使用deque集合的简单while循环:

>>> import operator
>>> import collections
>>> iterable = collections.deque(range(1, 6))

>>> value = iterable.popleft()
>>> while iterable:
...     value = operator.mul(value, iterable.popleft())

>>> value
120

处理树

树是reduce函数真正发挥作用的一个案例。还记得在第三章中使用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"
 **]
 **}
 **}
 **}
 **}
 **}
}

# The path we wish to get
>>> path = 'Chordata.Vertebrata.Reptilia.Squamata.Serpentes'

# Split the path for easier access
>>> path = path.split('.')

# Now fetch the path using reduce to recursively fetch the items
>>> family = functools.reduce(lambda a, b: a[b], path, taxonomy)
>>> family.items()
dict_items([('Pythonidae', ['Liasis', 'Morelia', 'Python'])])

# The path we wish to get
>>> path = 'Chordata.Vertebrata.Reptilia.Squamata'.split('.')

>>> suborder = functools.reduce(lambda a, b: a[b], path, taxonomy)
>>> suborder.keys()
dict_keys(['Serpentes'])

最后,有些人可能会想知道为什么 Python 只有fold_left而没有fold_right。在我看来,你实际上不需要这两者,因为你可以很容易地反转操作。

常规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,
)

尽管这在纯函数式语言中绝对非常有用-这些操作经常被使用-最初计划在引入 Python 3 时从 Python 中删除reduce函数。幸运的是,这个计划被修改了,而不是被删除,它已经从reduce移动到functools.reduce。也许reduce没有太多有用的用例,但确实有一些很酷的用例。特别是使用reduce更容易地遍历递归数据结构,因为否则将涉及更复杂的循环或递归函数。

itertools

itertools库包含受函数式语言启发的可迭代函数。所有这些都是可迭代的,并且已经以这样一种方式构建,即使是处理最大的数据集也只需要最少量的内存。虽然你可以使用一个简单的函数轻松地编写这些函数中的大多数,但我仍然建议使用itertools库中提供的函数。这些都很快,内存效率高,而且更重要的是经过测试。

注意

尽管段落的标题是大写的,但函数本身不是。小心不要意外输入Accumulate而不是accumulate

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

组合 – Python 中的组合数学

combinations迭代器产生的结果与您从数学定义中所期望的完全相同。从给定的项目列表中选择特定长度的所有组合:

>>> import itertools
>>> list(itertools.combinations(range(3), 2))
[(0, 1), (0, 2), (1, 2)]

注意

combinations函数给出了给定长度的给定项目的所有可能组合。可能组合的数量由二项式系数给出,许多计算器上的nCr按钮。通常表示如下:

组合 – Python 中的组合数学

在这种情况下,我们有n=2k=4

这是元素重复的变体:

>>> import itertools
>>> list(itertools.combinations_with_replacement(range(3), 2))
[(0, 0), (0, 1), (0, 2), (1, 1), (1, 2), (2, 2)]

注意

combinations_with_repetitions函数与常规的combinations函数非常相似,只是项目也可以与自身组合。要计算结果的数量,可以使用前面描述的二项式系数,参数为n=n+k-1k=k

让我们看一个使用组合和链的小组合,生成一个powerset

>>> import itertools

>>> def powerset(iterable):
...     return itertools.chain.from_iterable(
...         itertools.combinations(iterable, i)
...         for i in range(len(iterable) + 1))
>>> list(powerset(range(3)))
[(), (0,), (1,), (2,), (0, 1), (0, 2), (1, 2), (0, 1, 2)]

注意

powerset本质上是从0n的所有组合的组合结果,这意味着它还包括具有零个项目(空集,或())的元素,具有1个项目的元素,一直到npowerset中的项目数量可以使用幂运算符轻松计算:2**n

permutations – 顺序很重要的组合

permutations函数与combinations函数非常相似。唯一的真正区别是(a, b)被认为与(b, a)不同。换句话说,顺序很重要:

>>> import itertools
>>> list(itertools.permutations(range(3), 2))
[(0, 1), (0, 2), (1, 0), (1, 2), (2, 0), (2, 1)]

compress – 使用布尔列表选择项目

compress函数是那种您不经常需要的函数之一,但在您需要时它可能非常有用。它对您的可迭代对象应用布尔过滤器,使其仅返回您实际需要的项目。这里最重要的一点是,它都是懒执行的,如果数据或选择器集合耗尽,compress将停止。因此,即使有无限范围,它也可以正常工作:

>>> import itertools
>>> list(itertools.compress(range(1000), [0, 1, 1, 1, 0, 1]))
[1, 2, 3, 5]

dropwhile/takewhile – 使用函数选择项目

dropwhile函数将删除所有结果,直到给定的谓词求值为 true。如果您正在等待设备最终返回预期结果,这可能很有用。这在这里有点难以证明,所以我只会展示一个基本用法的例子——等待大于3的数字:

>>> import itertools
>>> list(itertools.dropwhile(lambda x: x <= 3, [1, 3, 5, 4, 2]))
[5, 4, 2]

正如您可能期望的,takewhile函数是其相反。它将简单地返回所有行,直到谓词变为 false:

>>> import itertools
>>> list(itertools.takewhile(lambda x: x <= 3, [1, 3, 5, 4, 2]))
[1, 3]

简单地将两者相加将再次给出原始结果。

count – 无限范围,带有小数步长

count函数与range函数非常相似,但有两个重大区别。

第一个区别是这个范围是无限的,所以甚至不要尝试list(itertools.count())。您肯定会立即耗尽内存,甚至可能会冻结系统。

第二个区别是,与range函数不同,您实际上可以在这里使用浮点数,因此不需要整数/整数。

由于列出整个范围将使我们的 Python 解释器崩溃,我们将简单地使用zip来限制结果并比较常规range函数的结果。在后面的段落中,我们将看到使用itertools.islice的更方便的选项。count函数有两个可选参数:start参数,默认为0step参数,默认为1

>>> import itertools

# Except for being infinite, the standard version returns the same
# results as the range function does.
>>> for a, b in zip(range(3), itertools.count()):
...     a, b
(0, 0)
(1, 1)
(2, 2)

# With a different starting point the results are still the same
>>> for a, b in zip(range(5, 8), itertools.count(5)):
...     a, b
(5, 5)
(6, 6)
(7, 7)

# And a different step works the same as well
>>> for a, b in zip(range(5, 10, 2), itertools.count(5, 2)):
...     a, b
(5, 5)
(7, 7)
(9, 9)

# Unless you try to use floating point numbers
>>> range(5, 10, 0.5)
Traceback (most recent call last):
 **...
TypeError: 'float' object cannot be interpreted as an integer

# Which does work for count
>>> for a, b in zip(range(5, 10), itertools.count(5, 0.5)):
...     a, b
(5, 5)
(6, 5.5)
(7, 6.0)
(8, 6.5)
(9, 7.0)

itertools.islice函数在与itertools.count结合使用时也非常有用,我们将在后面的段落中看到。

groupby - 对排序后的可迭代对象进行分组

groupby函数是一个非常方便的用于分组结果的函数。使用方法和用例可能很清楚,但在使用此函数时有一些重要的事项需要牢记:

  • 输入需要按group参数进行排序。否则,它将被添加为一个单独的组。

  • 结果只能使用一次。因此,在处理完一个组之后,它将不再可用。

以下是groupby的正确用法示例:

>>> import itertools
>>> items = [('a', 1), ('a', 2), ('b', 2), ('b', 0), ('c', 3)]

>>> for group, items in itertools.groupby(items, lambda x: x[0]):
...     print('%s: %s' % (group, [v for k, v in items]))
a: [1, 2]
b: [2, 0]
c: [3]

然后还有一些情况可能会产生意外的结果:

>>> import itertools
>>> items = [('a', 1), ('b', 0), ('b', 2), ('a', 2), ('c', 3)]
>>> groups = dict()

>>> for group, items in itertools.groupby(items, lambda x: x[0]):
...     groups[group] = items
...     print('%s: %s' % (group, [v for k, v in items]))
a: [1]
b: [0, 2]
a: [2]
c: [3]

>>> for group, items in sorted(groups.items()):
...     print('%s: %s' % (group, [v for k, v in items]))
a: []
b: []
c: []

现在我们看到两个包含a的组。所以,在尝试分组之前,请确保按分组参数进行排序。另外,第二次遍历相同的组不会产生结果。这可以很容易地通过使用groups[group] = list(items)来修复,但如果你不知道这一点,它可能会导致一些意外的错误。

islice - 对任何可迭代对象进行切片

在使用itertools函数时,您可能会注意到无法对这些对象进行切片。这是因为它们是生成器,这是我们将在第六章中讨论的一个主题,生成器和协程-无限,一步一次。幸运的是,itertools库也有一个用于对这些对象进行切片的函数——islice

让我们以之前的itertools.counter为例:

>>> import itertools
>>> list(itertools.islice(itertools.count(), 2, 7))
[2, 3, 4, 5, 6]

所以,不要使用常规的slice

itertools.count()[:10]

我们将slice参数输入到函数中:

itertools.islice(itertools.count(), 10)

您应该注意的不仅仅是无法对对象进行切片。不仅切片不起作用,而且也无法获取长度—至少不是不逐个计算所有项目的情况下—并且对于无限迭代器,甚至这也是不可能的。您从生成器中实际上只能获得一次理解,即您可以一次获取一个项目。您甚至事先不知道自己是否已经到达生成器的末尾。

总结

由于某种原因,函数式编程是一种让许多人感到恐惧的范式,但实际上不应该。在 Python 中,函数式编程和过程式编程的最重要区别是思维方式。一切都是使用简单的(通常是数学等价物的转换)函数执行,没有任何变量的存储。简单来说,函数式程序由许多具有简单输入和输出的函数组成,而不使用(甚至没有)任何外部范围或上下文来访问。Python 不是纯粹的函数式语言,因此很容易作弊并在局部范围之外工作,但这是不推荐的。

本章介绍了 Python 中函数式编程的基础知识以及其中的一些数学知识。除此之外,还介绍了一些可以通过使用函数式编程非常方便地使用的许多有用的库。

最重要的要点应该是以下内容:

  • Lambda 语句本质上并不是坏的,但最好让它们只使用局部范围的变量,并且不应超过一行。

  • 函数式编程可能非常强大,但很容易变得难以阅读。必须小心。

  • list/dict/set推导式非常有用,但通常不应嵌套,并且为了可读性,它们也应该保持简短。

最终,这是一个偏好的问题。为了可读性,我建议在没有明显好处时限制使用功能范式。话虽如此,当正确执行时,它可以成为一种美丽的事物。

接下来是装饰器——用来包装您的函数和类的方法,以修改它们的行为并扩展它们的功能。

第五章:装饰器 - 通过装饰实现代码重用

在本章中,您将学习 Python 装饰器。装饰器本质上只是可以用于修改输入、输出甚至在执行之前修改函数/类本身的函数/类包装器。这种包装可以通过有一个调用内部函数的单独函数或通过混合来轻松实现。与许多 Python 构造一样,装饰器并不是达到目标的唯一方法,但在许多情况下确实很方便。

虽然您可以完全不了解装饰器,但它们给您带来了很多“重用能力”,因此在框架库中被广泛使用,例如 Web 框架。Python 实际上附带了一些有用的装饰器,最著名的是property装饰器。

但是,有一些需要注意的特殊情况:包装函数会创建一个新函数,并且使得难以访问内部函数及其属性。Python 的一个例子是help(function)功能;默认情况下,您将丢失函数属性,例如帮助文本和函数存在的模块。

本章将涵盖函数和类装饰器的用法,以及在类内装饰函数时需要了解的复杂细节。

以下是涵盖的主题:

  • 装饰函数

  • 装饰类函数

  • 装饰类

  • 使用类作为装饰器

  • Python 标准库中有用的装饰器

装饰函数

本质上,装饰器只不过是一个函数或类包装器。如果我们有一个名为spam的函数和一个名为eggs的装饰器,那么以下内容将使用eggs装饰spam

spam = eggs(spam)

为了使语法更易于使用,Python 对此情况有一个特殊的语法。因此,您可以使用@运算符简单地装饰一个函数,而不是在函数下面添加一行如上面的行:

@eggs
def spam():
    pass

装饰器只是接收函数并返回一个通常不同的函数。最简单的装饰器是:

def eggs(function):
    return function

看看前面的例子,我们意识到这将spam作为function的参数,并再次返回该函数,实际上什么也没有改变。但大多数装饰器会嵌套函数。以下装饰器将打印发送到spam的所有参数,并将它们不加修改地传递给spam

>>> import functools

>>> def eggs(function):
...    @functools.wraps(function)
...    def _eggs(*args, **kwargs):
...        print('%r got args: %r and kwargs: %r' % (
...            function.__name__, args, kwargs))
...        return function(*args, **kwargs)
...
...    return _eggs

>>> @eggs
... def spam(a, b, c):
...     return a * b + c

>>> spam(1, 2, 3)
'spam' got args: (1, 2, 3) and kwargs: {}
5

这应该表明装饰器可以有多么强大。通过修改*args**kwargs,您可以完全添加、修改和删除参数。此外,返回语句也可以被修改。您可以返回完全不同的东西,而不是return function(...)

为什么 functools.wraps 很重要

每当编写装饰器时,一定要确保添加functools.wraps来包装内部函数。如果不包装它,您将丢失原始函数的所有属性,这可能会导致混淆。看看下面的代码,没有functools.wraps

>>> def eggs(function):
...    def _eggs(*args, **kwargs):
...        return function(*args, **kwargs)
...    return _eggs

>>> @eggs
... def spam(a, b, c):
...     '''The spam function Returns a * b + c'''
...     return a * b + c

>>> help(spam)
Help on function _eggs in module ...:
<BLANKLINE>
_eggs(*args, **kwargs)
<BLANKLINE>

>>> spam.__name__
'_eggs'

现在,我们的spam方法再也没有文档了,名称也消失了。它已被重命名为_eggs。由于我们确实调用了_eggs,这是可以理解的,但对于依赖这些信息的代码来说非常不方便。现在我们将尝试使用functools.wraps进行相同的代码,只有一个细微的区别:

>>> import functools

>>> def eggs(function):
...     @functools.wraps(function)
...     def _eggs(*args, **kwargs):
...         return function(*args, **kwargs)
...     return _eggs

>>> @eggs
... def spam(a, b, c):
...     '''The spam function Returns a * b + c'''
...     return a * b + c

>>> help(spam)
Help on function spam in module ...:
<BLANKLINE>
spam(a, b, c)
 **The spam function Returns a * b + c
<BLANKLINE>

>>> spam.__name__
'spam'

没有任何进一步的更改,我们现在有了文档和预期的函数名称。然而,functools.wraps的工作并不神奇;它只是复制和更新了几个属性。具体来说,复制了以下属性:

  • __doc__

  • __name__

  • __module__

  • __annotations__

  • __qualname__

此外,使用_eggs.__dict__.update(spam.__dict__)更新__dict__,并添加一个名为__wrapped__的新属性,其中包含原始(在本例中为spam)函数。实际的wraps函数可以在 Python 分发的functools.py文件中找到。

装饰器有什么用?

装饰器的用例很多,但其中一些最有用的用例是调试。关于这一点的更多详细示例将在第十一章中进行介绍,调试 - 解决错误,但我可以给你一个小窥探,看看如何使用装饰器来跟踪代码的运行情况。

假设你有一堆可能被调用或可能不被调用的函数,并且你并不完全确定每个函数的输入和输出是什么。在这种情况下,你当然可以修改函数,并在开始和结束时添加一些打印语句来打印输出。然而,这很快就会变得乏味,这是一个简单的装饰器可以让你轻松做同样的事情的情况之一。

对于这个例子,我们使用了一个非常简单的函数,但我们都知道在现实生活中,我们并不总是那么幸运:

>>> def spam(eggs):
...     return 'spam' * (eggs % 5)
...
>>> output = spam(3)

让我们拿我们简单的spam函数,并添加一些输出,这样我们就可以看到内部发生了什么:

>>> def spam(eggs):
...     output = 'spam' * (eggs % 5)
...     print('spam(%r): %r' % (eggs, output))
...     return output
...
>>> output = spam(3)
spam(3): 'spamspamspam'

虽然这样做是有效的,但是有一个小装饰器来解决这个问题会不会更好呢?

>>> def debug(function):
...     @functools.wraps(function)
...     def _debug(*args, **kwargs):
...         output = function(*args, **kwargs)
...         print('%s(%r, %r): %r' % (function.__name__, args, kwargs, output))
...         return output
...     return _debug
...
>>>
>>> @debug
... def spam(eggs):
...     return 'spam' * (eggs % 5)
...
>>> output = spam(3)
spam((3,), {}): 'spamspamspam'

现在我们有一个装饰器,可以轻松地重用于打印输入、输出和函数名称的任何函数。这种类型的装饰器在日志应用程序中也非常有用,我们将在第十章中看到,测试和日志 - 为错误做准备。值得注意的是,即使无法修改包含原始代码的模块,也可以使用此示例。我们可以在本地包装函数,甚至在需要时对模块进行 monkey-patch:

import some_module

# Regular call
some_module.some_function()

# Wrap the function
debug_some_function = debug(some_module.some_function)

# Call the debug version
debug_some_function()

# Monkey patch the original module
some_module.some_function = debug_some_function

# Now this calls the debug version of the function
some_module.some_function()

当然,在生产代码中使用 monkey-patching 并不是一个好主意,但在调试时可能非常有用。

使用装饰器进行记忆化

记忆化是使某些代码运行速度更快的一个简单技巧。这里的基本技巧是存储输入和期望输出的映射,这样你只需要计算一次值。这种技术最常见的示例之一是演示天真(递归)的斐波那契函数:

>>> import functools

>>> def memoize(function):
...     function.cache = dict()
...
...     @functools.wraps(function)
...     def _memoize(*args):
...         if args not in function.cache:
...             function.cache[args] = function(*args)
...         return function.cache[args]
...     return _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('fibonacci %d: %d' % (i, fibonacci(i)))
fibonacci 1: 1
fibonacci 2: 1
fibonacci 3: 2
fibonacci 4: 3
fibonacci 5: 5
fibonacci 6: 8

>>> fibonacci.__wrapped__.cache
{(5,): 5, (0,): 0, (6,): 8, (1,): 1, (2,): 1, (3,): 2, (4,): 3}

虽然这个例子在没有任何记忆化的情况下也可以正常工作,但对于更大的数字,它会使系统崩溃。对于n=2,函数将递归执行fibonacci(n - 1)fibonacci(n - 2),有效地给出指数时间复杂度。此外,对于n=30,斐波那契函数被调用了 2,692,537 次,尽管这仍然是可以接受的。在n=40时,计算将需要很长时间。

然而,记忆化版本甚至不费吹灰之力,只需要执行31次,n=30

这个装饰器还展示了如何将上下文附加到函数本身。在这种情况下,cache 属性成为内部(包装的fibonacci)函数的属性,因此不同对象的额外memoize装饰器不会与任何其他装饰的函数发生冲突。

然而,需要注意的是,自己实现记忆化函数通常不再那么有用,因为 Python 在 Python 3.2 中引入了lru_cache(最近最少使用缓存)。lru_cache类似于前面的 memoize 函数,但更加先进。它只保持一个固定的(默认为 128)缓存大小以节省内存,并使用一些统计数据来检查是否应增加缓存大小。

为了演示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时,将首先调用lru_cache,因为它是列表中的第一个装饰器。假设尚未有缓存可用,将调用counter装饰器。在计数器内部,将调用实际的fibonacci函数。

返回值当然是按相反的顺序工作的;fibonacci将其值返回给counter,后者将该值传递给lru_cache

带(可选)参数的装饰器

以前的示例大多使用了没有任何参数的简单装饰器。正如我们已经在lru_cache中看到的那样,装饰器也可以接受参数,因为它们只是常规函数,但这会给装饰器增加一个额外的层。这意味着添加参数可以像下面这样简单:

>>> import functools

>>> def add(extra_n=1):
...     'Add extra_n to the input of the decorated function'
...
...     # The inner function, notice that this is the actual
...     # decorator
...     def _add(function):
...         # The actual function that will be called
...         @functools.wraps(function)
...         def __add(n):
...             return function(n + extra_n)
...
...         return __add
...
...     return _add

>>> @add(extra_n=2)
... def eggs(n):
...     return 'eggs' * n

>>> eggs(2)
'eggseggseggseggs'

然而,可选参数是另一回事,因为它们使额外的函数层变得可选。有参数时,您需要三层,但没有参数时,您只需要两层。由于装饰器本质上是返回函数的常规函数,区别在于返回子函数或子子函数,取决于参数。这只留下一个问题——检测参数是函数还是常规参数。举例说明,使用参数的实际调用如下所示:

add(extra_n=2)(eggs)(2)

没有参数的调用将如下所示:

add(eggs)(2)

要检测装饰器是使用函数还是常规参数作为参数调用的,我们有几种选择,但在我看来都不是完全理想的:

  • 使用关键字参数作为装饰器参数,以便常规参数始终是函数

  • 检测第一个且唯一的参数是否可调用

在我看来,第一种使用关键字参数的方法是两种选项中更好的,因为它有点更明确,留下的混淆空间较少。如果您的参数也是可调用的话,第二种选项可能会有问题。

使用第一种方法,普通(非关键字)参数必须是装饰函数,其他两个检查仍然适用。我们仍然可以检查函数是否确实可调用,以及是否只有一个可用参数。以下是使用先前示例的修改版本的示例:

>>> import functools

>>> def add(*args, **kwargs):
...     'Add n to the input of the decorated function'
...
...     # The default kwargs, we don't store this in kwargs
...     # because we want to make sure that args and kwargs
...     # can't both be filled
...     default_kwargs = dict(n=1)
...
...     # The inner function, notice that this is actually a
...     # decorator itself
...     def _add(function):
...         # The actual function that will be called
...         @functools.wraps(function)
...         def __add(n):
...             default_kwargs.update(kwargs)
...             return function(n + default_kwargs['n'])
...
...         return __add
...
...     if len(args) == 1 and callable(args[0]) and not kwargs:
...         # Decorator call without arguments, just call it
...         # ourselves
...         return _add(args[0])
...     elif not args and kwargs:
...         # Decorator call with arguments, this time it will
...         # automatically be executed with function as the
...         # first argument
...         default_kwargs.update(kwargs)
...         return _add
...     else:
...         raise RuntimeError('This decorator only supports '
...                            'keyword arguments')

>>> @add
... def spam(n):
...     return 'spam' * n

>>> @add(n=3)
... def eggs(n):
...     return 'eggs' * n

>>> spam(3)
'spamspamspamspam'

>>> eggs(2)
'eggseggseggseggseggs'

>>> @add(3)
... def bacon(n):
...     return 'bacon' * n
Traceback (most recent call last):
  ...
RuntimeError: This decorator only supports keyword arguments

每当您有选择时,我建议您要么有带参数的装饰器,要么没有,而不是使用可选参数。但是,如果您有一个真正充分的理由使参数可选,那么您有一种相对安全的方法来实现这一点。

使用类创建装饰器

与创建常规函数装饰器的方式类似,也可以使用类来创建装饰器。毕竟,函数只是一个可调用对象,类也可以实现可调用接口。以下装饰器与我们之前使用的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)
...         print('%s(%r, %r): %r' % (
...             self.function.__name__, args, kwargs, output))
...         return output

>>> @Debug
... def spam(eggs):
...     return 'spam' * (eggs % 5)
...
>>> output = spam(3)
spam((3,), {}): 'spamspamspam'

函数和类之间唯一显著的区别是,在__init__方法中,functools.wraps现在被functools.update_wrapper替换了。

装饰类函数

装饰类函数与常规函数非常相似,但您需要注意所需的第一个参数self——类实例。您很可能已经使用了一些类函数装饰器。例如,classmethodstaticmethodproperty装饰器在许多不同的项目中都被使用。为了解释所有这些是如何工作的,我们将构建自己版本的classmethodstaticmethodproperty装饰器。首先,让我们看一个简单的用于类函数的装饰器,以展示与常规装饰器的区别:

>>> import functools

>>> def plus_one(function):
...     @functools.wraps(function)
...     def _plus_one(self, n):
...         return function(self, n + 1)
...     return _plus_one

>>> class Spam(object):
...     @plus_one
...     def get_eggs(self, n=2):
...         return n * 'eggs'

>>> spam = Spam()
>>> spam.get_eggs(3)
'eggseggseggseggs'

与常规函数一样,类函数装饰器现在会将self作为实例传递。没有什么意外的!

跳过实例 - 类方法和静态方法

classmethodstaticmethod之间的区别非常简单。classmethod传递的是类对象而不是类实例(self),而staticmethod完全跳过了类和实例。这使得staticmethod在类外部非常类似于常规函数。

在重新创建classmethodstaticmethod之前,我们需要了解这些方法的预期行为:

>>> import pprint

>>> class Spam(object):
...
...     def some_instancemethod(self, *args, **kwargs):
...         print('self: %r' % self)
...         print('args: %s' % pprint.pformat(args))
...         print('kwargs: %s' % pprint.pformat(kwargs))
...
...     @classmethod
...     def some_classmethod(cls, *args, **kwargs):
...         print('cls: %r' % cls)
...         print('args: %s' % pprint.pformat(args))
...         print('kwargs: %s' % pprint.pformat(kwargs))
...
...     @staticmethod
...     def some_staticmethod(*args, **kwargs):
...         print('args: %s' % pprint.pformat(args))
...         print('kwargs: %s' % pprint.pformat(kwargs))

# Create an instance so we can compare the difference between
# executions with and without instances easily
>>> spam = Spam()

# With an instance (note the lowercase spam)
>>> spam.some_instancemethod(1, 2, a=3, b=4)
self: <...Spam object at 0x...>
args: (1, 2)
kwargs: {'a': 3, 'b': 4}

# Without an instance (note the capitalized Spam)
>>> Spam.some_instancemethod()
Traceback (most recent call last):

 **...
TypeError: some_instancemethod() missing 1 required positional 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)
self: 1
args: (2,)
kwargs: {'a': 3, 'b': 4}

# Classmethods are expectedly identical
>>> spam.some_classmethod(1, 2, a=3, b=4)
cls: <class '...Spam'>
args: (1, 2)
kwargs: {'a': 3, 'b': 4}

>>> Spam.some_classmethod()
cls: <class '...Spam'>
args: ()
kwargs: {}

>>> Spam.some_classmethod(1, 2, a=3, b=4)
cls: <class '...Spam'>
args: (1, 2)
kwargs: {'a': 3, 'b': 4}

# 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}

请注意,如果在没有实例的情况下调用some_instancemethod,会导致缺少self的错误。正如预期的那样(因为在这种情况下我们没有实例化类),对于带有参数的版本,它似乎可以工作,但实际上是有问题的。这是因为现在假定第一个参数是self。在这种情况下显然是不正确的,因为您传递的是一个整数,但如果您传递了其他类实例,这可能是非常奇怪的错误的根源。classmethodstaticmethod都可以正确处理这种情况。

在继续使用装饰器之前,您需要了解 Python 描述符的工作原理。描述符可用于修改对象属性的绑定行为。这意味着如果将描述符用作属性的值,您可以修改在对属性进行这些操作时设置、获取和删除的值。以下是这种行为的基本示例:

>>> class MoreSpam(object):
...
...     def __init__(self, more=1):
...         self.more = more
...
...     def __get__(self, instance, cls):
...         return self.more + instance.spam
...
...     def __set__(self, instance, value):
...         instance.spam = value - self.more

>>> class Spam(object):
...
...     more_spam = MoreSpam(5)
...
...     def __init__(self, spam):
...         self.spam = spam

>>> spam = Spam(1)
>>> spam.spam
1
>>> spam.more_spam
6

>>> spam.more_spam = 10
>>> spam.spam
5

正如你所看到的,无论我们从more_spam中设置或获取值,实际上都会调用MoreSpam上的__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

ClassMethod装饰器仍然具有一个子函数来实际生成一个可工作的装饰器。看看这个函数,你很可能猜到它的功能。它不是将instance作为self.method的第一个参数传递,而是传递cls

StaticMethod更简单,因为它完全忽略了instancecls。它可以直接返回原始方法而不进行任何修改。因为它返回原始方法而不进行任何修改,我们也不需要functools.wraps调用。

属性 - 智能描述符用法

property装饰器可能是 Python 中最常用的装饰器。它允许您向现有实例属性添加 getter/setter,以便您可以在将它们设置为实例属性之前添加验证器和修改值。property装饰器可以用作赋值和装饰器。下面的示例展示了这两种语法,以便我们知道从property装饰器中可以期望到什么:

>>> class Spam(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

>>> spam = Spam()
>>> spam.eggs = 123
setting eggs to 123
>>> spam.eggs
getting eggs
123
>>> del spam.eggs
deleting eggs

注意

请注意,property装饰器仅在类继承object时才起作用。

与我们实现classmethodstaticmethod装饰器的方式类似,我们再次需要 Python 描述符。这一次,我们需要描述符的全部功能,而不仅仅是__get__,还有__set____delete__

class Property(object):
    def __init__(self, fget=None, fset=None, fdel=None,
                 doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        # If no specific documentation is available, copy it
        # from the getter
        if fget and not doc:
            doc = fget.__doc__
        self.__doc__ = doc

    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)
        else:
            raise AttributeError('unreadable attribute')

    def __set__(self, instance, value):
        if self.fset:
            self.fset(instance, value)
        else:
            raise AttributeError("can't set attribute")

    def __delete__(self, instance):
        if self.fdel:
            self.fdel(instance)
        else:
            raise AttributeError("can't delete attribute")

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel)

正如你所看到的,大部分Property实现只是描述符方法的实现。gettersetterdeleter函数只是使装饰器的使用变得可能的快捷方式,这就是为什么如果没有instance可用,我们必须return self

当然,还有更多实现这种效果的方法。在前面的段落中,我们看到了裸描述符的实现,在我们之前的例子中,我们看到了属性装饰器。一个更通用的类的解决方案是实现 __getattr____getattribute__。这里有一个简单的演示:

>>> class Spam(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]

>>> spam = Spam()

>>> spam.a
Getting 'a'
'Undefined'

>>> spam.a = 1
Setting 'a' to 1

>>> spam.a
Getting 'a'
1

>>> del spam.a
Deleting 'a'

__getattr__ 方法首先在 instance.__dict__ 中查找键,并且只有在键不存在时才会被调用。这就是为什么我们从来没有看到 registry 属性的 __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 Spam(object):
...     def __init__(self):
...         print('Executing init')

>>> a = Spam()
Executing init
>>> b = Spam()

>>> 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 函数的 key 参数轻松实现这一点,但如果你经常需要这样做,还有一种更方便的方法 - 通过实现 __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 '<%s[%d]>' % (self.__class__, 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

>>> numbers = [4, 2, 3, 4]
>>> spams = [Spam(n) for n in numbers]
>>> eggs = [Egg(n) for n in numbers]

>>> spams
[<<class 'H05.Spam'>[4]>, <<class 'H05.Spam'>[2]>,
<<class 'H05.Spam'>[3]>, <<class 'H05.Spam'>[4]>]

>>> eggs
[<<class 'H05.Egg'>[4]>, <<class 'H05.Egg'>[2]>,
<<class 'H05.Egg'>[3]>, <<class 'H05.Egg'>[4]>]

>>> sorted(spams)
[<<class 'H05.Spam'>[2]>, <<class 'H05.Spam'>[3]>,
<<class 'H05.Spam'>[4]>, <<class 'H05.Spam'>[4]>]

>>> sorted(eggs)
[<<class 'H05.Egg'>[2]>, <<class 'H05.Egg'>[3]>,
<<class 'H05.Egg'>[4]>, <<class 'H05.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
[<<class 'H05.Value'>[4]>, <<class 'H05.Value'>[2]>,
<<class 'H05.Value'>[3]>, <<class 'H05.Value'>[4]>]

>>> sorted(values, key=lambda v: v.value)
[<<class 'H05.Value'>[2]>, <<class 'H05.Value'>[3]>,
<<class 'H05.Value'>[4]>, <<class 'H05.Value'>[4]>]

现在,你可能会想,“为什么没有一个类装饰器来使用指定的键属性使类可排序?”嗯,这确实可能是functools库的一个好主意,但它还没有。所以让我们看看我们如何实现类似的东西:

>>> def sort_by_attribute(attr, keyfunc=getattr):
...     def _sort_by_attribute(cls):
...         def __gt__(self, other):
...             return getattr(self, attr) > getattr(other, attr)
...
...         def __ge__(self, other):
...             return getattr(self, attr) >= getattr(other, attr)
...
...         def __lt__(self, other):
...             return getattr(self, attr) < getattr(other, attr)
...
...         def __le__(self, other):
...             return getattr(self, attr) <= getattr(other, attr)
...
...         def __eq__(self, other):
...             return getattr(self, attr) <= getattr(other, attr)
...
...         cls.__gt__ = __gt__
...         cls.__ge__ = __ge__
...         cls.__lt__ = __lt__
...         cls.__le__ = __le__
...         cls.__eq__ = __eq__
...
...         return cls
...     return _sort_by_attribute

>>> class Value(object):
...     def __init__(self, value):
...         self.value = value
...
...     def __repr__(self):
...         return '<%s[%d]>' % (self.__class__, self.value)

>>> @sort_by_attribute('value')
... class Spam(Value):
...     pass

>>> numbers = [4, 2, 3, 4]
>>> spams = [Spam(n) for n in numbers]
>>> sorted(spams)
[<<class '...Spam'>[2]>, <<class '...Spam'>[3]>,
<<class '...Spam'>[4]>, <<class '...Spam'>[4]>]

当然,这极大地简化了可排序类的制作。如果你宁愿使用自己的键函数而不是getattr,那就更容易了。只需用key_function(self)替换getattr(self, attr)调用,对other也这样做,并将装饰器的参数更改为你的函数。你甚至可以将其用作基本函数,并通过简单地传递一个包装的getattr函数来实现sort_by_attribute

有用的装饰器

除了本章已经提到的之外,Python 还捆绑了一些其他有用的装饰器。还有一些不在标准库中的(还没有?)。

Python 中的单分派——多态

如果你以前使用过 C++或 Java,你可能已经习惯了可用的特定多态性——根据参数类型调用不同的函数。Python 作为一种动态类型语言,大多数人不会期望存在单分派模式的可能性。然而,Python 不仅是一种动态类型的语言,而且是一种强类型的语言,这意味着我们可以依赖我们收到的类型。

注意

动态类型的语言不需要严格的类型定义。另一方面,像 C 这样的语言需要以下内容来声明一个整数:

int some_integer = 123;

Python 只是接受你的值有一个类型:

some_integer = 123

然而,与 JavaScript 和 PHP 等语言相反,Python 几乎不进行隐式类型转换。在 Python 中,以下内容将返回错误,而在 JavaScript 中将无任何问题地执行:

'spam' + 5

在 Python 中,结果是TypeError。在 Javascript 中,是'spam5'

单分派的想法是,根据你传递的类型,调用正确的函数。由于在 Python 中str + int会导致错误,这可以非常方便地在将参数传递给函数之前自动转换你的参数。这对于将函数的实际工作与类型转换分离开来非常有用。

自 Python 3.4 以来,有一个装饰器可以轻松实现 Python 中的单分派模式。对于那些需要处理与正常执行不同的特定类型的情况之一。这是一个基本的例子:

>>> import functools

>>> @functools.singledispatch
... def printer(value):
...     print('other: %r' % value)

>>> @printer.register(str)
... def str_printer(value):
...     print(value)

>>> @printer.register(int)
... def int_printer(value):
...     printer('int: %d' % value)

>>> @printer.register(dict)
... def dict_printer(value):
...     printer('dict:')
...     for k, v in sorted(value.items()):
...         printer('    key: %r, value: %r' % (k, v))

>>> printer('spam')
spam

>>> printer([1, 2, 3])
other: [1, 2, 3]

>>> printer(123)
int: 123

>>> printer({'a': 1, 'b': 2})
dict:
 **key: 'a', value: 1
 **key: 'b', value: 2

看到了吗,根据类型,其他函数被调用了吗?这种模式对于减少接受多种类型参数的单个函数的复杂性非常有用。

注意

在命名函数时,请确保不要覆盖原始的singledispatch函数。如果我们将str_printer命名为printer,它将覆盖最初的printer函数。这将使得无法访问原始的printer函数,并且在此之后的所有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的常规版本,该版本接受文件对象。

当然,编写一个能够做到这一点的装饰器并不难,但在基本库中拥有它仍然非常方便。这肯定比在函数中进行几次isinstance调用要好。要查看将调用哪个函数,可以使用特定类型的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'>])

上下文管理器,简化了 with 语句

使用contextmanager类,我们可以很容易地创建上下文包装器。上下文包装器在使用with语句时使用。一个例子是open函数,它也可以作为上下文包装器工作,允许你使用以下代码:

with open(filename) as fh:
    pass

让我们暂时假设open函数不能作为上下文管理器使用,我们需要构建自己的函数来实现这一点。创建上下文管理器的标准方法是创建一个实现__enter____exit__方法的类,但这有点冗长。我们可以让它更短更简单:

>>> 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中有一个专门的函数,它甚至更容易使用。让我们来演示一下:

>>> import contextlib

>>> with contextlib.closing(open('test.txt', 'a')) as fh:
...     print('Yet another test', file=fh)

对于file对象,这当然是不需要的,因为它已经作为上下文管理器起作用。然而,一些对象,比如urllib发出的请求,不支持以这种方式自动关闭,并且从这个函数中受益。

但等等;还有更多!除了可以在with语句中使用之外,contextmanager的结果实际上也可以作为装饰器使用,自 Python 3.2 起。在较早的 Python 版本中,它只是一个小包装器,但自 Python 3.2 起,它基于ContextDecorator类,这使它成为一个装饰器。之前的装饰器并不适合这个任务,因为它产生了一个结果(关于这一点,可以在第六章中了解更多,生成器和协程-无限,一步一步),但我们可以考虑其他函数:

>>> @contextlib.contextmanager
... def debug(name):
...     print('Debugging %r:' % name)
...     yield
...     print('End of debugging %r' % 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
End of debugging 'spam'

这有很多很好的用例,但至少,它是一个方便的方式来在上下文中包装一个函数,而不需要所有(嵌套的)with语句。

验证、类型检查和转换

虽然在 Python 中检查类型通常不是最佳选择,但有时如果你知道你需要一个特定的类型(或者可以转换为该类型的东西),它可能是有用的。为了方便起见,Python 3.5 引入了类型提示系统,这样你就可以这样做:

def spam(eggs: int):
    pass

由于 Python 3.5 还不太常见,这里有一个装饰器,它实现了更高级的类型检查。为了允许这种类型的检查,必须使用一些魔法,特别是使用inspect模块。就我个人而言,我不太喜欢检查代码来执行这样的技巧,因为它们很容易被破坏。这段代码实际上在函数和这个装饰器之间使用一个常规装饰器(不复制argspec)时会出错,但它仍然是一个很好的例子:

>>> import inspect
>>> import functools

>>> def to_int(name, minimum=None, maximum=None):
...     def _to_int(function):
...         # Use the method signature to map *args to named
...         # arguments
...         signature = inspect.signature(function)
...
...         # Unfortunately functools.wraps doesn't copy the
...         # signature (yet) so we do it manually.
...         # For more info: http://bugs.python.org/issue23764
...         @functools.wraps(function, ['__signature__'])
...         @functools.wraps(function)
...         def __to_int(*args, **kwargs):
...             # Bind all arguments to the names so we get a single
...             # mapping of all arguments
...             bound = signature.bind(*args, **kwargs)
...
...             # Make sure the value is (convertible to) an integer
...             default = signature.parameters[name].default
...             value = int(bound.arguments.get(name, default))
...
...             # Make sure it's within the allowed range
...             if minimum is not None:
...                 assert value >= minimum, (
...                     '%s should be at least %r, got: %r' %
...                     (name, minimum, value))
...
...             if maximum is not None:
...                 assert value <= maximum, (
...                     '%s should be at most %r, got: %r' %
...                     (name, maximum, value))
...
...             return function(*args, **kwargs)
...         return __to_int
...     return _to_int

>>> @to_int('a', minimum=10)
... @to_int('b', maximum=10)
... @to_int('c')
... def spam(a, b, c=10):
...     print('a', a)
...     print('b', b)
...     print('c', c)

>>> spam(10, b=0)
a 10
b 0
c 10

>>> spam(a=20, b=10)
a 20
b 10
c 10

>>> spam(1, 2, 3)
Traceback (most recent call last):
 **...
AssertionError: a should be at least 10, got: 1

>>> spam()
Traceback (most recent call last):
 **...
TypeError: 'a' parameter lacking default value

>>> spam('spam', {})
Traceback (most recent call last):
 **...
ValueError: invalid literal for int() with base 10: 'spam'

由于inspect的魔法,我仍然不确定是否推荐像这样使用装饰器。相反,我会选择一个更简单的版本,它完全不使用inspect,只是从kwargs中解析参数:

>>> import functools

>>> def to_int(name, minimum=None, maximum=None):
...     def _to_int(function):
...         @functools.wraps(function)
...         def __to_int(**kwargs):
...             value = int(kwargs.get(name))
...
...             # Make sure it's within the allowed range
...             if minimum is not None:
...                 assert value >= minimum, (
...                     '%s should be at least %r, got: %r' %
...                     (name, minimum, value))
...
...             if maximum is not None:
...                 assert value <= maximum, (
...                     '%s should be at most %r, got: %r' %
...                     (name, maximum, value))
...
...             return function(**kwargs)
...         return __to_int
...     return _to_int

>>> @to_int('a', minimum=10)
... @to_int('b', maximum=10)
... def spam(a, b):
...     print('a', a)
...     print('b', b)

>>> spam(a=20, b=10)
a 20
b 10

>>> spam(a=1, b=10)
Traceback (most recent call last):
 **...
AssertionError: a should be at least 10, got: 1

然而,正如所示,支持argskwargs并不是不可能的,只要记住默认情况下不会复制__signature__。没有__signature__,inspect 模块就不知道哪些参数是允许的,哪些是不允许的。

注意

缺少的__signature__问题目前正在讨论中,可能会在未来的 Python 版本中得到解决:

bugs.python.org/issue23764

无用的警告-如何忽略它们

通常在编写 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 recording all warnings
            with warnings.catch_warnings(record=True) as ws:
                # Catch all warnings of this type
                warnings.simplefilter('always', warning)
                # Execute the function
                result = function(*args, **kwargs)

            # Now that all code was executed and the warnings
            # collected, re-send all warnings that are beyond our
            # expected number of warnings
            if count is not None:
                for w in ws[count:]:
                    warnings.showwarning(
                        message=w.message,
                        category=w.category,
                        filename=w.filename,
                        lineno=w.lineno,
                        file=w.file,
                        line=w.line,
                    )

            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)

使用这种方法,我们可以捕获第一个(预期的)警告,并且仍然可以看到第二个(不期望的)警告。

总结

本章向我们展示了装饰器可以用于简化代码并向非常简单的函数添加一些相当复杂的行为的一些地方。事实上,大多数装饰器比直接添加功能的常规函数更复杂,但将相同的模式应用于许多函数和类的附加优势通常是非常值得的。

装饰器有很多用途,可以使您的函数和类更智能、更方便使用:

  • 调试

  • 验证

  • 参数方便(预填充或转换参数)

  • 输出方便(将输出转换为特定类型)

本章最重要的收获应该是在包装函数时永远不要忘记functools.wraps。由于(意外的)行为修改,调试装饰函数可能会非常困难,但丢失属性也会使这个问题变得更糟。

下一章将向我们展示如何以及何时使用生成器协程。本章已经向我们展示了with语句的使用,但生成器协程在这方面更进一步。尽管如此,我们仍然经常使用装饰器,所以确保你对它们的工作原理有很好的理解。

第六章:生成器和协程-无限,一步一步

生成器是一种通过函数生成值的特定类型的迭代器。传统方法构建并返回项目的list,而生成器只会在调用者请求时单独yield每个值。这种方法有几个好处:

  • 生成器完全暂停执行,直到下一个值被产生,这使得它们完全是惰性的。如果从生成器中获取五个项目,只会生成五个项目,因此不需要其他计算。

  • 生成器不需要保存值。而传统函数需要创建一个list并存储所有结果,直到它们被返回,生成器只需要存储一个单一的值。

  • 生成器可以具有无限的大小。没有必要在某一点停止。

然而,这些好处是有代价的。这些好处的直接结果是一些缺点:

  • 在处理完成之前,您永远不知道还有多少值;甚至可能是无限的。这在某些情况下使用是危险的;执行list(some_infinite_generator)将耗尽内存。

  • 您无法切片生成器。

  • 您无法在产生指定的项目之前获取所有值。

  • 您无法重新启动生成器。所有值只产生一次。

除了生成器之外,还有一种变体的生成器语法,可以创建协程。协程是允许进行多任务处理而不需要多个线程或进程的函数。生成器只能向调用者产生值,而协程实际上可以在运行时从调用者那里接收值。虽然这种技术有一些限制,但如果符合您的目的,它可以以非常低的成本实现出色的性能。

简而言之,本章涵盖的主题有:

  • 生成器的特点和用途

  • 生成器推导

  • 生成器函数

  • 生成器类

  • 捆绑生成器

  • 协程

生成器是什么?

生成器,最简单的形式是一个函数,它一次返回一个元素,而不是返回一组项目。这样做的最重要的优点是它需要非常少的内存,而且不需要预先定义的大小。创建一个无限的生成器(比如在第四章中讨论的itertools.count迭代器,功能编程-可读性与简洁性)实际上是相当容易的,但当然也是有代价的。没有对象的大小可用,使得某些模式难以实现。

编写生成器(作为函数)的基本技巧是使用yield语句。让我们以itertools.count生成器为例,并用一个stop变量扩展它:

>>> def count(start=0, step=1, stop=10):
...     n = start
...     while n <= stop:
...         yield n
...         n += step

>>> for x in count(10, 2.5, 20):
...     print(x)
10
12.5
15.0
17.5
20.0

由于生成器可能是无限的,因此需要谨慎。如果没有stop变量,简单地执行list(count())将很快导致内存不足的情况。

那么这是如何工作的呢?这只是一个普通的for循环,但这与返回项目列表的常规方法之间的重要区别在于yield语句一次返回一个项目。这里需要注意的一点是,return语句会导致StopIteration,并且将某些东西传递给return将成为StopIteration的参数。应该注意的是,这种行为在 Python 3.3 中发生了变化;在 Python 3.2 和更早的版本中,除了None之外,根本不可能返回任何东西。这里有一个例子:

>>> def generator():
...     yield 'this is a generator'
...     return 'returning from a generator'

>>> g = generator()
>>> next(g)
'this is a generator'
>>> next(g)
Traceback (most recent call last):
 **...
StopIteration: returning from a generator

当然,与以往一样,有多种使用 Python 创建生成器的方法。除了函数之外,还有生成器推导和类可以做同样的事情。生成器推导与列表推导几乎完全相同,但使用括号而不是方括号,例如:

>>> generator = (x ** 2 for x in range(4))

>>> for x in generator:
...    print(x)
0
1
4
9

为了完整起见,count函数的类版本如下:

>>> class Count(object):
...     def __init__(self, start=0, step=1, stop=10):
...         self.n = start
...         self.step = step
...         self.stop = stop
...
...     def __iter__(self):
...         return self
...
...     def __next__(self):
...         n = self.n
...         if n > self.stop:
...             raise StopIteration()
...
...         self.n += self.step
...         return n

>>> for x in Count(10, 2.5, 20):
...     print(x)
10
12.5
15.0
17.5
20.0

类和基于函数的方法之间最大的区别是你需要显式地引发StopIteration而不是简单地返回它。除此之外,它们非常相似,尽管基于类的版本显然增加了一些冗余。

生成器的优缺点

你已经看到了一些生成器的例子,并了解了你可以用它们做什么的基础知识。然而,重要的是要记住它们的优缺点。

以下是最重要的优点:

  • 内存使用。项目可以一次处理一个,因此通常不需要将整个列表保存在内存中。

  • 结果可能取决于外部因素,而不是具有静态列表。例如,考虑处理队列/堆栈。

  • 生成器是懒惰的。这意味着如果你只使用生成器的前五个结果,剩下的甚至不会被计算。

  • 一般来说,编写生成函数比编写列表生成函数更简单。

最重要的缺点:

  • 结果只能使用一次。处理生成器的结果后,不能再次使用。

  • 在处理完成之前,大小是未知的,这可能对某些算法有害。

  • 生成器是不可索引的,这意味着some_generator[5]是行不通的。

考虑到所有的优缺点,我的一般建议是尽可能使用生成器,只有在实际需要时才返回listtuple。将生成器转换为list就像list(some_generator)一样简单,所以这不应该阻止你,因为生成函数往往比生成list的等效函数更简单。

内存使用的优势是可以理解的;一个项目需要的内存比许多项目少。然而,懒惰部分需要一些额外的解释,因为它有一个小问题:

>>> def generator():
...     print('Before 1')
...     yield 1
...     print('After 1')
...     print('Before 2')
...     yield 2
...     print('After 2')
...     print('Before 3')
...     yield 3
...     print('After 3')

>>> g = generator()
>>> print('Got %d' % next(g))
Before 1
Got 1

>>> print('Got %d' % next(g))
After 1
Before 2
Got 2

正如你所看到的,生成器在yield语句后有效地冻结,所以即使After 23被产生之前也不会打印。

这有重要的优势,但这绝对是你需要考虑的事情。你不能在yield后立即清理,因为它直到下一个yield才会执行。

管道-生成器的有效使用

生成器的理论可能性是无限的(无意冒犯),但它们的实际用途可能很难找到。如果你熟悉 Unix/Linux shell,你可能以前使用过管道,比如ps aux | grep python',例如列出所有 Python 进程。当然,有很多方法可以做到这一点,但让我们在 Python 中模拟类似的东西,以便看到一个实际的例子。为了创建一个简单和一致的输出,我们将创建一个名为lines.txt的文件,其中包含以下行:

spam
eggs
spam spam
eggs eggs
spam spam spam
eggs eggs eggs

现在,让我们来看下面的 Linux/Unix/Mac shell 命令,以读取带有一些修改的文件:

# cat lines.txt | grep spam | sed 's/spam/bacon/g'
bacon
bacon bacon
bacon bacon bacon

这使用cat读取文件,使用grep输出包含spam的所有行,并使用sed命令将spam替换为bacon。现在让我们看看如何可以利用 Python 生成器来重新创建这个过程:

>>> def cat(filename):
...     for line in open(filename):
...         yield line.rstrip()
...
>>> def grep(sequence, search):
...     for line in sequence:
...         if search in line:
...             yield line
...
>>> def replace(sequence, search, replace):
...     for line in sequence:
...         yield line.replace(search, replace)
...
>>> lines = cat('lines.txt')
>>> spam_lines = grep(lines, 'spam')
>>> bacon_lines = replace(spam_lines, 'spam', 'bacon')

>>> for line in bacon_lines:
...     print(line)
...
bacon
bacon bacon
bacon bacon bacon

# Or the one-line version, fits within 78 characters:
>>> for line in replace(grep(cat('lines.txt'), 'spam'),
...                     'spam', 'bacon'):
...     print(line)
...
bacon
bacon bacon
bacon bacon bacon

这就是生成器的最大优势。你可以用很少的性能影响多次包装一个列表或序列。在请求值之前,涉及的任何函数都不会执行任何操作。

tee-多次使用输出

如前所述,生成器最大的缺点之一是结果只能使用一次。幸运的是,Python 有一个函数允许你将输出复制到多个生成器。如果你习惯在命令行 shell 中工作,tee这个名字可能对你来说很熟悉。tee程序允许你将输出同时写到屏幕和文件,这样你就可以在保持实时查看的同时存储输出。

Python 版本的itertools.tee也做了类似的事情,只是它返回了几个迭代器,允许你分别处理结果。

默认情况下,tee会将您的生成器分成一个包含两个不同生成器的元组,这就是为什么元组解包在这里能很好地工作。通过传递n参数,这可以很容易地改变以支持超过 2 个生成器。这是一个例子:

>>> 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会更快,因为它涉及的操作要少得多。

从生成器生成

正如我们之前所看到的,我们可以使用生成器来过滤、修改、添加和删除项。然而,在许多情况下,您会注意到在编写生成器时,您将从子生成器和/或序列中返回。一个例子是使用itertools库创建powerset时:

>>> import itertools

>>> def powerset(sequence):
...     for size in range(len(sequence) + 1):
...         for item in itertools.combinations(sequence, size):
...             yield item

>>> for result in powerset('abc'):
...     print(result)
()
('a',)
('b',)
('c',)
('a', 'b')
('a', 'c')
('b', 'c')
('a', 'b', 'c')

这种模式是如此常见,以至于yield语法实际上得到了增强,使得这更加容易。Python 3.3 引入了yield from语法,使这种常见模式变得更加简单:

>>> import itertools

>>> def powerset(sequence):
...     for size in range(len(sequence) + 1):
...         yield from itertools.combinations(sequence, size)

>>> for result in powerset('abc'):
...     print(result)
()
('a',)
('b',)
('c',)
('a', 'b')
('a', 'c')
('b', 'c')
('a', 'b', 'c')

这就是你只用三行代码创建一个幂集的方法。

也许,这种情况下更有用的例子是递归地扁平化一个序列。

>>> def flatten(sequence):
...     for item in sequence:
...         try:
...             yield from flatten(item)
...         except TypeError:
...             yield item
...
>>> list(flatten([1, [2, [3, [4, 5], 6], 7], 8]))
[1, 2, 3, 4, 5, 6, 7, 8]

请注意,此代码使用TypeError来检测非可迭代对象。结果是,如果序列(可能是一个生成器)返回TypeError,它将默默地隐藏它。

还要注意,这是一个非常基本的扁平化函数,没有任何类型检查。例如,包含str的可迭代对象将被递归地扁平化,直到达到最大递归深度,因为str中的每个项也会返回一个str

上下文管理器

与本书中描述的大多数技术一样,Python 也捆绑了一些有用的生成器。其中一些(例如itertoolscontextlib.contextmanager)已经在第四章和第五章中讨论过,但我们可以使用一些额外的例子来演示它们可以多么简单和强大。

Python 上下文管理器似乎与生成器没有直接关联,但这是它们内部使用的一个很大的部分:

>>> 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))

# The write to log function writes all stdout (regular print data) to
# a file. The contextlib.redirect_stdout context wrapper
# temporarily redirects standard output to a given file handle, in
# this case the file we just opened for writing.
>>> @contextlib.contextmanager
... def write_to_log(name):
...     with open('%s.txt' % name, 'w') as fh:
...         with contextlib.redirect_stdout(fh):
...             with timer(name):
...                 yield

# Use the context manager as a decorator
>>> @write_to_log('some function')
... def some_function():
...     print('This function takes a bit of time to execute')
...     ...
...     print('Do more...')

>>> some_function()

虽然所有这些都可以正常工作,但是三层上下文管理器往往会变得有点难以阅读。通常,装饰器可以解决这个问题。然而,在这种情况下,我们需要一个上下文管理器的输出作为下一个上下文管理器的输入。

这就是ExitStack的用武之地。它允许轻松地组合多个上下文管理器:

>>> import contextlib

>>> @contextlib.contextmanager
... def write_to_log(name):
...     with contextlib.ExitStack() as stack:
...         fh = stack.enter_context(open('stdout.txt', 'w'))
...         stack.enter_context(contextlib.redirect_stdout(fh))
...         stack.enter_context(timer(name))
...
...         yield

>>> @write_to_log('some function')
... def some_function():
...     print('This function takes a bit of time to execute')
...     ...
...     print('Do more...')

>>> some_function()

看起来至少简单了一点,不是吗?虽然在这种情况下必要性有限,但当您需要进行特定的拆卸时,ExitStack的便利性很快就会显现出来。除了之前看到的自动处理外,还可以将上下文传递给一个新的ExitStack并手动处理关闭:

>>> import contextlib

>>> with contextlib.ExitStack() as stack:
...     spam_fh = stack.enter_context(open('spam.txt', 'w'))
...     eggs_fh = stack.enter_context(open('eggs.txt', 'w'))
...     spam_bytes_written = spam_fh.write('writing to spam')
...     eggs_bytes_written = eggs_fh.write('writing to eggs')
...     # Move the contexts to a new ExitStack and store the
...     # close method
...     close_handlers = stack.pop_all().close

>>> spam_bytes_written = spam_fh.write('still writing to spam')
>>> eggs_bytes_written = eggs_fh.write('still writing to eggs')

# After closing we can't write anymore
>>> close_handlers()
>>> spam_bytes_written = spam_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 版本中都有很大的改进。

协程

协程是通过多个入口点提供非抢占式多任务处理的子例程。基本前提是,协程允许两个函数在运行时相互通信。通常,这种类型的通信仅保留给多任务处理解决方案,但协程以几乎没有额外性能成本的相对简单方式提供了这种实现。

由于生成器默认是惰性的,协程的工作方式是非常明显的。直到结果被消耗,生成器都会休眠;但在消耗结果时,生成器会变得活跃。普通生成器和协程之间的区别在于,协程不仅仅将值返回给调用函数,还可以接收值。

一个基本的例子

在前面的段落中,我们看到了普通生成器如何产出值。但生成器能做的不仅仅是这些。它们也可以接收值。基本用法非常简单:

>>> def generator():
...     value = yield 'spam'
...     print('Generator received: %s' % value)
...     yield 'Previous value: %r' % value

>>> g = generator()
>>> print('Result from generator: %s' % next(g))
Result from generator: spam
>>> print(g.send('eggs'))
Generator received: eggs
Previous value: 'eggs'

就是这样。在调用send方法之前,函数会被冻结,此时它将处理到下一个yield语句。

启动

由于生成器是惰性的,你不能直接向全新的生成器发送一个值。在值被发送到生成器之前,要么必须使用next()获取结果,要么必须发出send(None),以便实际到达代码。这种需求是可以理解的,但有时有点乏味。让我们创建一个简单的装饰器来省略这个需求:

>>> import functools

>>> def coroutine(function):
...     @functools.wraps(function)
...     def _coroutine(*args, **kwargs):
...         active_coroutine = function(*args, **kwargs)
...         next(active_coroutine)
...         return active_coroutine
...     return _coroutine

>>> @coroutine
... def spam():
...     while True:
...         print('Waiting for yield...')
...         value = yield
...         print('spam received: %s' % value)

>>> generator = spam()
Waiting for yield...

>>> generator.send('a')
spam received: a
Waiting for yield...

>>> generator.send('b')
spam received: b
Waiting for yield...

你可能已经注意到,即使生成器仍然是惰性的,它现在会自动执行所有代码,直到再次到达yield语句。在那时,它将保持休眠状态,直到发送新值。

注意

请注意,从现在开始,coroutine装饰器将在本章中使用。为简洁起见,我们将在以下示例中省略它。

关闭和抛出异常

与普通生成器不同,一旦输入序列耗尽,协程通常采用无限的while循环,这意味着它们不会以正常方式被关闭。这就是为什么协程也支持closethrow方法,它们将退出函数。这里重要的不是关闭,而是添加拆卸方法的可能性。从本质上讲,这与上下文包装器如何使用__enter____exit__方法的方式非常相似,但在这种情况下是协程:

@coroutine
def simple_coroutine():
    print('Setting up the coroutine')
    try:
        while True:
            item = yield
            print('Got item: %r' % item)
    except GeneratorExit:
        print('Normal exit')
    except Exception as e:
        print('Exception exit: %r' % e)
        raise
    finally:
        print('Any exit')

print('Creating simple coroutine')
active_coroutine = simple_coroutine()
print()

print('Sending spam')
active_coroutine.send('spam')
print()

print('Close the coroutine')
active_coroutine.close()
print()

print('Creating simple coroutine')
active_coroutine = simple_coroutine()
print()

print('Sending eggs')
active_coroutine.send('eggs')
print()

print('Throwing runtime error')
active_coroutine.throw(RuntimeError, 'Oops...')
print()

这将生成以下输出,应该是预期的——没有奇怪的行为,只是退出协程的两种方法:

# python3 H06.py
Creating simple coroutine
Setting up the coroutine

Sending spam
Got item: 'spam'

Close the coroutine
Normal exit
Any exit

Creating simple coroutine
Setting up the coroutine

Sending eggs
Got item: 'eggs'

Throwing runtime error
Exception exit: RuntimeError('Oops...',)
Any exit
Traceback (most recent call last):
...
 **File ... in <module>
 **active_coroutine.throw(RuntimeError, 'Oops...')
 **File ... in simple_coroutine
 **item = yield
RuntimeError: Oops...

双向管道

在前面的段落中,我们看到了管道;它们按顺序处理输出并且是单向的。然而,有些情况下这还不够——有时你需要一个不仅将值发送到下一个管道,而且还能从子管道接收信息的管道。我们可以通过这种方式在执行之间保持生成器的状态,而不是始终只有一个单一的列表被处理。因此,让我们首先将之前的管道转换为协程。首先,再次使用lines.txt文件:

spam
eggs
spam spam
eggs eggs
spam spam spam
eggs eggs eggs

现在,协程管道。这些函数与以前的相同,但使用协程而不是生成器:

>>> @coroutine
... def replace(search, replace):
...     while True:
...         item = yield
...         print(item.replace(search, replace))

>>> spam_replace = replace('spam', 'bacon')
>>> for line in open('lines.txt'):
...     spam_replace.send(line.rstrip())
bacon
eggs
bacon bacon
eggs eggs
bacon bacon bacon
eggs eggs eggs

鉴于这个例子,你可能会想知道为什么我们现在打印值而不是产出它。嗯!我们可以,但要记住生成器会冻结,直到产出一个值。让我们看看如果我们只是yield值而不是调用print会发生什么。默认情况下,你可能会想这样做:

>>> @coroutine
... def replace(search, replace):
...     while True:
...         item = yield
...         yield item.replace(search, replace)

>>> spam_replace = replace('spam', 'bacon')
>>> spam_replace.send('spam')
'bacon'
>>> spam_replace.send('spam spam')
>>> spam_replace.send('spam spam spam')
'bacon bacon bacon'

现在一半的值已经消失了,所以问题是,“它们去哪了?”注意第二个yield没有存储结果。这就是值消失的地方。我们需要将它们也存储起来:

>>> @coroutine
... def replace(search, replace):
...     item = yield
...     while True:
...         item = yield item.replace(search, replace)

>>> spam_replace = replace('spam', 'bacon')
>>> spam_replace.send('spam')
'bacon'
>>> spam_replace.send('spam spam')
'bacon bacon'
>>> spam_replace.send('spam spam spam')
'bacon bacon bacon'

但即使这样还远非最佳。我们现在基本上是在使用协程来模仿生成器的行为。虽然它能工作,但有点傻而且不是很清晰。这次让我们真正建立一个管道,让协程将数据发送到下一个协程(或多个协程),并通过将结果发送到多个协程来展示协程的力量:

# Grep sends all matching items to the target
>>> @coroutine
... def grep(target, pattern):
...     while True:
...         item = yield
...         if pattern in item:
...             target.send(item)

# Replace does a search and replace on the items and sends it to
# the target once it's done
>>> @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):
...     while True:
...         print(formatstring % (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_('%s')

# Create replacers that send the output to the printer
>>> replacer_spam = replace(printer, 'spam', 'bacon')
>>> replacer_eggs = replace(printer, 'spam spam', 'sausage')

# Create a tee to send the input to both the spam and the eggs
# replacers
>>> branch = tee(replacer_spam, replacer_eggs)

# Send all items containing spam to the tee command
>>> grepper = grep(branch, 'spam')

# Send the data to the grepper for all the processing
>>> for line in open('lines.txt'):
...     grepper.send(line.rstrip())
bacon
spam
bacon bacon
sausage
bacon bacon bacon
sausage spam

这使得代码更简单、更易读,但更重要的是,它展示了如何将单一源拆分为多个目的地。虽然这看起来可能不那么令人兴奋,但它肯定是。如果你仔细观察,你会发现tee方法将输入分成两个不同的输出,但这两个输出都写回到同一个print_实例。这意味着你可以将数据沿着任何方便的方式路由,而无需任何努力就可以将其最终发送到同一个终点。

尽管如此,这个例子仍然不是那么有用,因为这些函数仍然没有充分利用协程的全部功能。最重要的特性,即一致的状态,在这种情况下并没有真正被使用。

从这些行中学到的最重要的一课是,在大多数情况下混合使用生成器和协程并不是一个好主意,因为如果使用不正确,它可能会产生非常奇怪的副作用。尽管两者都使用yield语句,但它们是具有不同行为的显著不同的实体。下一段将展示混合协程和生成器可以有用的为数不多的情况之一。

使用状态

既然我们知道如何编写基本的协程以及需要注意的陷阱,那么如何编写一个需要记住状态的函数呢?也就是说,一个始终给出所有发送值的平均值的函数。这是为数不多的情况之一,仍然相对安全和有用地结合协程和生成器语法:

>>> @coroutine
... def average():
...     count = 1
...     total = yield
...     while True:
...         total += yield total / count
...         count += 1

>>> averager = average()
>>> averager.send(20)
20.0
>>> averager.send(10)
15.0
>>> averager.send(15)
15.0
>>> averager.send(-25)
5.0

尽管这仍然需要一些额外的逻辑才能正常工作。为了确保我们不会除以零,我们将count初始化为1。之后,我们使用yield获取我们的第一个项目,但在那时我们不发送任何数据,因为第一个yield是启动器,并且在我们获得值之前执行。一旦设置好了,我们就可以轻松地在求和的同时产生平均值。并不是太糟糕,但纯协程版本稍微更容易理解,因为我们不必担心启动:

>>> @coroutine
... def print_(formatstring):
...     while True:
...         print(formatstring % (yield))

>>> @coroutine
... def average(target):
...     count = 0
...     total = 0
...     while True:
...         count += 1
...         total += yield
...         target.send(total / count)

>>> printer = print_('%.1f')
>>> averager = average(printer)
>>> averager.send(20)
20.0
>>> averager.send(10)
15.0
>>> averager.send(15)
15.0
>>> averager.send(-25)
5.0

就像应该的那样,只需保持计数和总值,然后简单地为每个新值发送新的平均值。

另一个很好的例子是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(('a', 1))
>>> grouper.send(('a', 2))
>>> grouper.send(('a', 3))
>>> grouper.send(('b', 1))
('a', [1, 2, 3])
>>> grouper.send(('b', 2))
>>> grouper.send(('a', 1))
('b', [1, 2])
>>> grouper.send(('a', 2))
>>> grouper.send((None, None))
('a', [1, 2])

正如你所看到的,这个函数使用了一些技巧。我们存储了前一个keyvalue,以便我们可以检测到组(key)何时发生变化。这就是第二个问题;显然我们只有在组发生变化后才能识别出一个组,因此只有在组发生变化后才会返回结果。这意味着最后一组只有在它之后发送了不同的组之后才会发送,因此是(None, None)。现在,这是纯协程版本:

>>> @coroutine
... def print_(formatstring):
...     while True:
...         print(formatstring % (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: %s, values: %s'))
>>> grouper.send(('a', 1))
>>> grouper.send(('a', 2))
>>> grouper.send(('a', 3))
>>> grouper.send(('b', 1))
group: a, values: [1, 2, 3]
>>> grouper.send(('b', 2))
>>> grouper.send(('a', 1))
group: b, values: [1, 2]
>>> grouper.send(('a', 2))
>>> grouper.send((None, None))
group: a, values: [1, 2]

虽然这些函数非常相似,但纯协程版本再次要简单得多。这是因为我们不必考虑启动和可能丢失的值。

总结

本章向我们展示了如何创建生成器以及它们的优势和劣势。此外,现在应该清楚如何解决它们的限制以及这样做的影响。

虽然关于协程的段落应该已经提供了一些关于它们是什么以及如何使用它们的见解,但并非一切都已经展示出来。我们看到了纯协程和同时是生成器的协程的构造,但它们仍然是同步的。协程允许将结果发送给许多其他协程,因此可以有效地同时执行许多函数,但如果某个操作被阻塞,它们仍然可以完全冻结 Python。这就是我们下一章将会帮助解决的问题。

Python 3.5 引入了一些有用的功能,比如asyncawait语句。这使得协程可以完全异步和非阻塞,而本章节使用的是自 Python 2.5 以来可用的基本协程功能。

下一章将扩展新功能,包括asyncio模块。这个模块使得使用协程进行异步 I/O 到诸如 TCP、UDP、文件和进程等端点变得几乎简单。

第七章:异步 IO - 无需线程的多线程

上一章向我们展示了同步协程的基本实现。然而,当涉及到外部资源时,同步协程是一个坏主意。只要一个远程连接停顿,整个进程就会挂起,除非你使用了多进程(在第十三章中有解释,多进程 - 当单个 CPU 核心不够用)或异步函数。

异步 IO 使得可以访问外部资源而无需担心减慢或阻塞应用程序。Python 解释器不需要主动等待结果,而是可以简单地继续执行其他任务,直到再次需要。这与 Node.js 和 JavaScript 中的 AJAX 调用的功能非常相似。在 Python 中,我们已经看到诸如asyncoregeventeventlet等库多年来已经实现了这一点。然而,随着asyncio模块的引入,使用起来变得更加容易。

本章将解释如何在 Python(特别是 3.5 及以上版本)中使用异步函数,以及如何重构代码,使其仍然能够正常运行,即使它不遵循标准的过程式编码模式来返回值。

本章将涵盖以下主题:

  • 使用以下函数:

  • async def

  • async for

  • async with

  • await

  • 并行执行

  • 服务器

  • 客户端

  • 使用Future来获取最终结果

介绍 asyncio 库

asyncio库的创建是为了使异步处理更加容易,并且结果更加可预测。它的目的是取代asyncore模块,后者已经可用了很长时间(事实上自 Python 1.5 以来)。asyncore模块从来没有很好地可用,这促使了geventeventlet第三方库的创建。geventeventlet都比asyncore更容易实现异步编程,但我觉得随着asyncio的引入,它们已经基本过时了。尽管我不得不承认asyncio仍然有一些问题,但它正在积极开发中,这让我认为所有问题很快就会被核心 Python 库或第三方包解决。

asyncio库是在 Python 3.4 中正式引入的,但是可以通过 Python 包索引为 Python 3.3 提供后向端口。考虑到这一点,虽然本章的一些部分可以在 Python 3.3 上运行,但大部分是以 Python 3.5 和新引入的asyncawait关键字为基础编写的。

异步和等待语句

在继续任何示例之前,重要的是要了解 Python 3.4 和 Python 3.5 代码语法之间的关系。尽管asyncio库仅在 Python 3.4 中引入,但 Python 3.5 中已经替换了大部分通用语法。虽然不是强制性的,但更简单,因此推荐使用asyncawait的语法已经被引入。

Python 3.4

对于传统的 Python 3.4 用法,需要考虑一些事项:

  • 函数应使用asyncio.coroutine装饰器声明

  • 应使用yield from coroutine()来获取异步结果

  • 不直接支持异步循环,但可以使用while True: yield from coroutine()来模拟

以下是一个例子:

import asyncio

@asyncio.coroutine
def sleeper():
    yield from asyncio.sleep(1)

Python 3.5

在 Python 3.5 中,引入了一种新的语法来标记函数为异步的。可以使用async关键字来代替asyncio.coroutine装饰器。此外,Python 现在支持await语句,而不是令人困惑的yield from语法。yield from语句稍微令人困惑,因为它可能让人觉得正在交换值,而这并不总是情况。

以下是async语句:

async def some_coroutine():
    pass

它可以代替装饰器:

import asyncio

@asyncio.coroutine
def some_coroutine():
    pass

在 Python 3.5 中,以及很可能在未来的版本中,coroutine装饰器仍然受到支持,但如果不需要向后兼容性,我强烈推荐使用新的语法。

此外,我们可以使用更合乎逻辑的await语句,而不是yield from语句。因此,前面段落中的示例变得和以下示例一样简单:

import asyncio

async def sleeper():
    await asyncio.sleep(1)

yield from语句源自 Python 中原始协程实现,并且是在同步协程中使用的yield语句的一个逻辑扩展。实际上,yield from语句仍然有效,而await语句只是它的一个包装器,增加了一些检查。在使用await时,解释器会检查对象是否是可等待对象,这意味着它需要是以下对象之一:

  • 使用async def语句创建的本地协程

  • 使用asyncio.coroutine装饰器创建的协程

  • 实现__await__方法的对象

这个检查本身就使得await语句比yield from语句更可取,但我个人认为await更好地传达了语句的含义。

总之,要转换为新的语法,进行以下更改:

  • 函数应该使用async def声明,而不是def

  • 应该使用await coroutine()来获取异步结果

  • 可以使用async for ... in ...创建异步循环

  • 可以使用async with ...创建异步with语句

在 3.4 和 3.5 语法之间进行选择

除非你真的需要 Python 3.3 或 3.4 支持,我强烈推荐使用 Python 3.5 语法。新的语法更清晰,支持更多功能,比如异步for循环和with语句。不幸的是,它们并不完全兼容,所以你需要做出选择。在async def(3.5)中,我们不能使用yield from,但我们只需要用await替换yield from就可以解决这个问题。

单线程并行处理的简单示例

并行处理有很多用途:服务器同时处理多个请求,加快繁重任务的速度,等待外部资源等等。通用协程在某些情况下可以帮助处理多个请求和外部资源,但它们仍然是同步的,因此受到限制。使用asyncio,我们可以超越通用协程的限制,轻松处理阻塞资源,而不必担心阻塞主线程。让我们快速看一下代码如何在多个并行函数中不会阻塞:

>>> import asyncio

>>> async def sleeper(delay):
...     await asyncio.sleep(delay)
...     print('Finished sleeper with delay: %d' % delay)

>>> loop = asyncio.get_event_loop()
>>> results = loop.run_until_complete(asyncio.wait((
...     sleeper(1),
...     sleeper(3),
...     sleeper(2),
... )))
Finished sleeper with delay: 1
Finished sleeper with delay: 2
Finished sleeper with delay: 3

即使我们按顺序开始了睡眠器,1、3、2,它们会按照相应的时间睡眠,asyncio.sleep结合await语句实际上告诉 Python,它应该继续处理需要实际处理的任务。普通的time.sleep实际上会阻塞 Python 任务,这意味着它们会按顺序执行。这使得它更加透明,可以处理任何类型的等待,我们可以将其交给asyncio,而不是让整个 Python 线程忙碌。因此,我们可以用while True: fh.read()来代替,只要有新数据就可以立即响应。

让我们分析一下这个例子中使用的组件:

  • asyncio.coroutine:这个装饰器使得可以从async def协程中进行 yield。除非你使用这种语法,否则没有真正需要这个装饰器,但如果只用作文档,这是一个很好的默认值。

  • asyncio.sleep:这是time.sleep的异步版本。这两者之间的主要区别是,time.sleep在睡眠时会让 Python 进程保持忙碌,而asyncio.sleep允许在事件循环中切换到不同的任务。这个过程与大多数操作系统中的任务切换的工作方式非常相似。

  • asyncio.get_event_loop:默认事件循环实际上是asyncio任务切换器;我们将在下一段解释更多关于这些的内容。

  • asyncio.wait:这是用于包装一系列协程或未来并等待结果的协程。等待时间是可配置的,等待方式也是可配置的(首先完成,全部完成,或者第一个异常)。

这应该解释了示例的基本工作原理:sleeper函数是异步协程,经过给定的延迟后退出。wait函数在退出之前等待所有协程完成,event循环用于在三个协程之间切换。

asyncio的概念

asyncio库有几个基本概念,必须在我们进一步探讨示例和用法之前加以解释。前一段中显示的示例实际上使用了大部分这些概念,但对于如何以及为什么可能仍然有一些解释是有用的。

asyncio的主要概念是协程事件循环。在其中,还有几个可用的辅助类,如StreamsFuturesProcesses。接下来的几段将解释基础知识,以便你能够理解后面段落中的示例中的实现。

未来和任务

asyncio.Future类本质上是一个结果的承诺;如果结果可用,它会返回结果,并且一旦接收到结果,它将把结果传递给所有注册的回调函数。它在内部维护一个状态变量,允许外部方将未来标记为已取消。API 与concurrent.futures.Future类非常相似,但由于它们并不完全兼容,所以请确保不要混淆两者。

Future类本身并不那么方便使用,这就是asyncio.Task发挥作用的地方。Task类包装了一个协程,并自动处理执行、结果和状态。协程将通过给定的事件循环执行,或者如果没有给定,则通过默认事件循环执行。

这些类的创建并不是你需要直接担心的事情。这是因为推荐的方式是通过asyncio.ensure_futureloop.create_task来创建类。前者实际上在内部执行了loop.create_task,但如果你只想在主/默认事件循环上执行它而不必事先指定,那么这种方式更方便。使用起来非常简单。要手动创建自己的未来,只需告诉事件循环为你执行create_task。下面的示例由于所有的设置代码而有点复杂,但 C 的使用应该是清楚的。最重要的一点是事件循环应该被链接,以便任务知道如何/在哪里运行:

>>> import asyncio

>>> async def sleeper(delay):
...     await asyncio.sleep(delay)
...     print('Finished sleeper with delay: %d' % delay)

# Create an event loop
>>> loop = asyncio.get_event_loop()

# Create the task
>>> result = loop.call_soon(loop.create_task, sleeper(1))

# Make sure the loop stops after 2 seconds
>>> result = loop.call_later(2, loop.stop)

# Start the loop and make it run forever. Or at least until the loop.stop gets
# called in 2 seconds.
>>> loop.run_forever()
Finished sleeper with delay: 1

现在,稍微了解一下调试异步函数。调试异步函数曾经非常困难,甚至是不可能的,因为没有好的方法来查看函数在哪里以及如何停滞。幸运的是,情况已经改变。在Task类的情况下,只需调用task.get_stacktask.print_stack就可以看到它当前所在的位置。使用方法可以简单到如下:

>>> import asyncio

>>> async def stack_printer():
...     for task in asyncio.Task.all_tasks():
...         task.print_stack()

# Create an event loop
>>> loop = asyncio.get_event_loop()

# Create the task
>>> result = loop.run_until_complete(stack_printer())

事件循环

事件循环的概念实际上是asyncio中最重要的一个。你可能已经怀疑协程本身就是一切的关键,但没有事件循环,它们就毫无用处。事件循环就像任务切换器一样工作,就像操作系统在 CPU 上切换活动任务的方式一样。即使有多核处理器,仍然需要一个主进程告诉 CPU 哪些任务需要运行,哪些需要等待/休眠一段时间。这正是事件循环所做的:它决定要运行哪个任务。

事件循环实现

到目前为止,我们只看到了asyncio.get_event_loop,它返回默认的事件循环和默认的事件循环策略。目前,有两种捆绑的事件循环实现:async.SelectorEventLoopasync.ProactorEventLoop实现。哪一种可用取决于您的操作系统。后一种事件循环仅在 Windows 机器上可用,并使用 I/O 完成端口,这是一个据说比asyncio.SelectorEventLoopSelect实现更快更高效的系统。如果性能是一个问题,这是需要考虑的事情。幸运的是,使用起来相当简单:

import asyncio

loop = asyncio.ProActorEventLoop()
asyncio.set_event_loop(loop)

备用事件循环基于选择器,自 Python 3.4 以来,可以通过核心 Python 安装中的selectors模块获得。selectors模块是在 Python 3.4 中引入的,以便轻松访问低级异步 I/O 操作。基本上,它允许您通过使用 I/O 多路复用来打开和读取许多文件。由于asyncio为您处理了所有复杂性,通常不需要直接使用该模块,但如果需要,使用起来相当简单。以下是将函数绑定到标准输入的读事件(EVENT_READ)的示例。代码将简单地等待,直到其中一个注册的文件提供新数据:

import sys
import selectors

def read(fh):
    print('Got input from stdin: %r' % fh.readline())

if __name__ == '__main__':
    # Create the default selector
    selector = selectors.DefaultSelector()

    # Register the read function for the READ event on stdin
    selector.register(sys.stdin, selectors.EVENT_READ, read)

    while True:
        for key, mask in selector.select():
            # The data attribute contains the read function here
            callback = key.data
            # Call it with the fileobj (stdin here)
            callback(key.fileobj)

有几种选择器可用,例如传统的selectors.SelectSelector(内部使用select.select),但也有更现代的解决方案,如selectors.KqueueSelectorselectors.EpollSelectorselectors.DevpollSelector。尽管默认情况下应该选择最有效的选择器,但在某些情况下,最有效的选择器可能不适合。在这些情况下,选择器事件循环允许您指定不同的选择器:

import asyncio
import selectors

selector = selectors.SelectSelector()
loop = asyncio.SelectorEventLoop(selector)
asyncio.set_event_loop(loop)

应该注意的是,这些选择器之间的差异在大多数实际应用程序中通常太小而难以注意到。我遇到的唯一一种情况是在构建一个必须处理大量同时连接的服务器时,这种优化才会有所不同。当我说“大量”时,我指的是在单个服务器上有超过 100,000 个并发连接的问题,这只有少数人在这个星球上需要处理。

事件循环策略

事件循环策略是创建和存储实际事件循环的对象。它们被设计为最大灵活性,但通常不需要修改。我能想到的唯一原因修改事件循环策略是如果您想要使特定事件循环在特定处理器和/或系统上运行,或者如果您希望更改默认事件循环类型。除此之外,它提供的灵活性超出了大多数人所需的范围。通过以下代码,使自己的事件循环(在这种情况下是ProActorEventLoop)成为默认事件循环是完全可能的:

import asyncio

class ProActorEventLoopPolicy(
        asyncio.events.BaseDefaultEventLoopPolicy):
    _loop_factory = asyncio.SelectorEventLoop

policy = ProActorEventLoopPolicy()
asyncio.set_event_loop_policy(policy)

事件循环使用

到目前为止,我们只看到了loop.run_until_complete方法。当然,还有其他一些方法。你最有可能经常使用的是loop.run_forever。这个方法,正如你所期望的那样,会一直运行下去,或者至少直到loop.stop被运行。

所以,假设我们现在有一个永远运行的事件循环,我们需要向其中添加任务。这就是事情变得有趣的地方。在默认事件循环中有很多选择:

  • call_soon:将项目添加到(FIFO)队列的末尾,以便按照插入的顺序执行函数。

  • call_soon_threadsafe:这与call_soon相同,只是它是线程安全的。call_soon方法不是线程安全的,因为线程安全需要使用全局解释器锁(GIL),这在线程安全时会使您的程序变成单线程。性能章节将更彻底地解释这一点。

  • call_later:在给定的秒数后调用函数。如果两个任务将同时运行,它们将以未定义的顺序运行。请注意,延迟是最小值。如果事件循环被锁定/忙碌,它可能会稍后运行。

  • call_at:在与loop.time的输出相关的特定时间调用函数。loop.time之后的每个整数都会增加一秒。

所有这些函数都返回asyncio.Handle对象。只要任务尚未执行,这些对象就允许通过handle.cancel函数取消任务。但是要小心取消来自其他线程,因为取消也不是线程安全的。要以线程安全的方式执行它,我们还必须将取消函数作为任务执行:loop.call_soon_threadsafe(handle.cancel)。以下是一个示例用法:

>>> import time
>>> import asyncio

>>> t = time.time()

>>> def printer(name):
...     print('Started %s at %.1f' % (name, time.time() - t))
...     time.sleep(0.2)
...     print('Finished %s at %.1f' % (name, time.time() - t))

>>> loop = asyncio.get_event_loop()
>>> result = loop.call_at(loop.time() + .2, printer, 'call_at')
>>> result = loop.call_later(.1, printer, 'call_later')
>>> result = loop.call_soon(printer, 'call_soon')
>>> result = loop.call_soon_threadsafe(printer, 'call_soon_threadsafe')

>>> # Make sure we stop after a second
>>> result = loop.call_later(1, loop.stop)

>>> 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

你可能会想知道为什么我们在这里没有使用协程装饰器。原因是循环不允许直接运行协程。要通过这些调用函数运行协程,我们需要确保它被包装在asyncio.Task中。正如我们在前一段中看到的那样,这很容易——幸运的是:

>>> import time
>>> import asyncio

>>> t = time.time()

>>> async def printer(name):
...     print('Started %s at %.1f' % (name, time.time() - t))
...     await asyncio.sleep(0.2)
...     print('Finished %s at %.1f' % (name, time.time() - t))

>>> loop = asyncio.get_event_loop()

>>> result = loop.call_at(
...     loop.time() + .2, loop.create_task, printer('call_at'))
>>> result = loop.call_later(.1, loop.create_task,
...     printer('call_later'))
>>> result = loop.call_soon(loop.create_task,
...     printer('call_soon'))

>>> result = loop.call_soon_threadsafe(
...     loop.create_task, printer('call_soon_threadsafe'))

>>> # Make sure we stop after a second
>>> result = loop.call_later(1, loop.stop)

>>> loop.run_forever()
Started call_soon at 0.0
Started call_soon_threadsafe at 0.0
Started call_later at 0.1
Started call_at at 0.2
Finished call_soon at 0.2
Finished call_soon_threadsafe at 0.2
Finished call_later at 0.3
Finished call_at at 0.4

这些调用方法可能看起来略有不同,但内部实际上都归结为通过heapq实现的两个队列。loop._scheduled用于计划操作,loop._ready用于立即执行。当调用_run_once方法(run_forever方法在while True循环中包装了这个方法)时,循环将首先尝试使用特定的循环实现(例如SelectorEventLoop)处理loop._ready堆中的所有项目。一旦loop._ready中的所有项目都被处理,循环将继续将loop._scheduled堆中的项目移动到loop._ready堆中,如果它们已经到期。

call_sooncall_soon_threadsafe都写入loop._ready堆。而call_later方法只是call_at的一个包装,其计划时间是当前值加上asyncio.time,它写入loop._scheduled堆。

这种处理方法的结果是,通过call_soon*方法添加的所有内容都将始终在通过call_at/call_later方法添加的所有内容之后执行。

至于ensure_futures函数,它将在内部调用loop.create_task来将协程包装在Task对象中,当然,这是Future对象的子类。如果出于某种原因需要扩展Task类,可以通过loop.set_task_factory方法轻松实现。

根据事件循环的类型,实际上有许多其他方法可以创建连接、文件处理程序等。这些将在后面的段落中通过示例进行解释,因为它们与事件循环的关系较小,更多地涉及使用协程进行编程。

进程

到目前为止,我们只是执行了特定的异步 Python 函数,但有些事情在 Python 中异步运行起来会更困难。例如,假设我们有一个长时间运行的外部应用程序需要运行。subprocess模块将是运行外部应用程序的标准方法,并且它运行得相当好。通过一些小心,甚至可以确保它们不会通过轮询输出来阻塞主线程。然而,这仍然需要轮询。然而,事件会更好,这样我们在等待结果时可以做其他事情。幸运的是,这很容易通过asyncio.Process安排。与FutureTask类似,这个类是通过事件循环创建的。在使用方面,这个类与subprocess.Popen类非常相似,只是函数已经变成了异步的。当然,这会导致轮询函数的消失。

首先,让我们看传统的顺序版本:

>>> import time
>>> import subprocess
>>>
>>>
>>> t = time.time()
>>>
>>>
>>> def process_sleeper():
...     print('Started sleep at %.1f' % (time.time() - t))
...     process = subprocess.Popen(['sleep', '0.1'])
...     process.wait()
...     print('Finished sleep at %.1f' % (time.time() - t))
...
>>>
>>> for i in range(3):
...     process_sleeper()
Started sleep at 0.0
Finished sleep at 0.1
Started sleep at 0.1
Finished sleep at 0.2
Started sleep at 0.2
Finished sleep at 0.3

由于一切都是按顺序执行的,所以等待的时间是休眠命令休眠的 0.1 秒的三倍。因此,与其同时等待所有这些,这次让我们并行运行它们:

>>> import time
>>> import subprocess


>>> t = time.time()

>>> def process_sleeper():
...     print('Started sleep at %.1f' % (time.time() - t))
...     return subprocess.Popen(['sleep', '0.1'])
...
>>>
>>> processes = []
>>> for i in range(5):
...     processes.append(process_sleeper())
Started sleep at 0.0
Started sleep at 0.0
Started sleep at 0.0
Started sleep at 0.0
Started sleep at 0.0

>>> for process in processes:
...     returncode = process.wait()
...     print('Finished sleep at %.1f' % (time.time() - t))
Finished sleep at 0.1
Finished sleep at 0.1
Finished sleep at 0.1
Finished sleep at 0.1
Finished sleep at 0.1

虽然从运行时间上看这样做要好得多,但我们的程序结构现在有点混乱。我们需要两个循环,一个用于启动进程,另一个用于测量完成时间。此外,我们还必须将打印语句移到函数外部,这通常也是不可取的。这次,我们将尝试asyncio版本:

>>> import time
>>> import asyncio

>>> t = time.time()

>>> async def async_process_sleeper():
...     print('Started sleep at %.1f' % (time.time() - t))
...     process = await asyncio.create_subprocess_exec('sleep', '0.1')
...     await process.wait()
...     print('Finished sleep at %.1f' % (time.time() - t))

>>> loop = asyncio.get_event_loop()
>>> for i in range(5):
...     task = loop.create_task(async_process_sleeper())

>>> future = loop.call_later(.5, loop.stop)

>>> loop.run_forever()
Started sleep at 0.0
Started sleep at 0.0
Started sleep at 0.0
Started sleep at 0.0
Started sleep at 0.0
Finished sleep at 0.1
Finished sleep at 0.1
Finished sleep at 0.1
Finished sleep at 0.1
Finished sleep at 0.1

如您所见,这种方式很容易同时运行多个应用程序。但这只是简单的部分;处理进程的难点在于交互式输入和输出。asyncio模块有几种措施可以使其更容易,但在实际处理结果时仍然可能会有困难。以下是调用 Python 解释器、执行一些代码并再次退出的示例:

import asyncio

async def run_script():
    process = await asyncio.create_subprocess_shell(
        'python3',
        stdout=asyncio.subprocess.PIPE,
        stdin=asyncio.subprocess.PIPE,
    )

    # Write a simple Python script to the interpreter
    process.stdin.write(b'\n'.join((
        b'import math',
        b'x = 2 ** 8',
        b'y = math.sqrt(x)',
        b'z = math.sqrt(y)',
        b'print("x: %d" % x)',
        b'print("y: %d" % y)',
        b'print("z: %d" % z)',
        b'for i in range(int(z)):',
        b'    print("i: %d" % i)',
    )))
    # 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 out in process.stdout:
        # Decode the output from bytes and strip the whitespace
        # (newline) at the right
        print(out.decode('utf-8').rstrip())

    # Wait for the process to exit
    await process.wait()

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(run_script())
    loop.close()

代码足够简单,但这段代码中有一些对我们来说不明显但却需要的部分。虽然创建子进程和编写代码是相当明显的,但您可能会对process.stdin.write_eof()这一行感到疑惑。问题在于缓冲。为了提高性能,大多数程序默认会对输入和输出进行缓冲。在 Python 程序的情况下,结果是除非我们发送文件结束eof),否则程序将继续等待更多的输入。另一种选择是关闭stdin流或以某种方式与 Python 程序通信,告诉它我们不会再发送任何输入。然而,这当然是需要考虑的事情。另一个选择是使用yield from process.stdin.drain(),但那只处理了代码的发送方;接收方可能仍在等待更多的输入。不过,让我们看一下输出:

# python3 processes.py
x: 256
y: 16
z: 4
i: 0
i: 1
i: 2
i: 3

使用这种实现方式,我们仍然需要一个循环来从stdout流中获取所有的结果。不幸的是,asyncio.StreamReaderprocess.stdout所属的类)类尚不支持async for语法。如果支持的话,一个简单的async for out in process.stdout就可以工作了。一个简单的yield from process.stdout.read()也可以工作,但通常逐行阅读更方便使用。

如果可能的话,我建议您避免使用stdin向子进程发送数据,而是使用一些网络、管道或文件通信。正如我们将在下面的段落中看到的,这些更方便处理。

异步服务器和客户端

导致脚本和应用程序停滞的最常见原因之一是使用远程资源。使用asyncio,至少其中的大部分是很容易解决的。获取多个远程资源并为多个客户端提供服务比以前要容易得多,也更轻量级。虽然多线程和多进程也可以用于这些情况,但asyncio是一个更轻量级的替代方案,实际上更容易管理。创建客户端和服务器有两种主要方法。协程方式是使用asyncio.open_connectionasyncio.start_server。基于类的方法要求您继承asyncio.Protocol类。虽然它们本质上是相同的,但工作方式略有不同。

基本回显服务器

基本的客户端和服务器版本编写起来相当简单。asyncio模块负责所有底层连接处理,我们只需要连接正确的方法。对于服务器,我们需要一个处理传入连接的方法,对于客户端,我们需要一个创建连接的函数。为了说明发生了什么以及在何时发生,我们将添加一个专门的打印函数,打印自服务器进程启动以来的时间和给定的参数:

import time
import sys
import asyncio

HOST = '127.0.0.1'
PORT = 1234

start_time = time.time()

def printer(start_time, *args, **kwargs):
    '''Simple function to print a message prefixed with the
    time relative to the given start_time'''
    print('%.1f' % (time.time() - start_time), *args, **kwargs)

async def handle_connection(reader, writer):
    client_address = writer.get_extra_info('peername')
    printer(start_time, 'Client connected', client_address)

    # Send over the server start time to get consistent
    # timestamps
    writer.write(b'%.2f\n' % start_time)
    await writer.drain()

    repetitions = int((await reader.readline()))
    printer(start_time, 'Started sending to', client_address)

    for i in range(repetitions):
        message = 'client: %r, %d\n' % (client_address, i)
        printer(start_time, message, end='')
        writer.write(message.encode())
        await writer.drain()

    printer(start_time, 'Finished sending to', client_address)
    writer.close()

async def create_connection(repetitions):
    reader, writer = await asyncio.open_connection(
        host=HOST, port=PORT)

    start_time = float((await reader.readline()))

    writer.write(repetitions.encode() + b'\n')
    await writer.drain()

    async for line in reader:
        # Sleeping a little to emulate processing time and make
        # it easier to add more simultaneous clients
        await asyncio.sleep(1)

        printer(start_time, 'Got line: ', line.decode(),
                end='')

    writer.close()

if __name__ == '__main__':
    loop = asyncio.get_event_loop()

    if sys.argv[1] == 'server':
        server = asyncio.start_server(
            handle_connection,
            host=HOST,
            port=PORT,
        )
        running_server = loop.run_until_complete(server)

        try:
            result = loop.call_later(5, loop.stop)
            loop.run_forever()
        except KeyboardInterrupt:
            pass

        running_server.close()
        loop.run_until_complete(running_server.wait_closed())
    elif sys.argv[1] == 'client':
        loop.run_until_complete(create_connection(sys.argv[2]))

    loop.close()

现在我们将运行服务器和两个同时的客户端。由于这些是并行运行的,服务器输出当然有点奇怪。因此,我们将从服务器到客户端同步启动时间,并在所有打印语句前加上自服务器启动以来的秒数。

服务器:

# python3 simple_connections.py server
0.4 Client connected ('127.0.0.1', 59990)
0.4 Started sending to ('127.0.0.1', 59990)
0.4 client: ('127.0.0.1', 59990), 0
0.4 client: ('127.0.0.1', 59990), 1
0.4 client: ('127.0.0.1', 59990), 2
0.4 Finished sending to ('127.0.0.1', 59990)
2.0 Client connected ('127.0.0.1', 59991)
2.0 Started sending to ('127.0.0.1', 59991)
2.0 client: ('127.0.0.1', 59991), 0
2.0 client: ('127.0.0.1', 59991), 1
2.0 Finished sending to ('127.0.0.1', 59991)

第一个客户端:

# python3 simple_connections.py client 3
1.4 Got line:  client: ('127.0.0.1', 59990), 0
2.4 Got line:  client: ('127.0.0.1', 59990), 1
3.4 Got line:  client: ('127.0.0.1', 59990), 2

第二个客户端:

# python3 simple_connections.py client 2
3.0 Got line:  client: ('127.0.0.1', 59991), 0
4.0 Got line:  client: ('127.0.0.1', 59991), 1

由于输入和输出都有缓冲区,我们需要在写入后手动排空输入,并在从对方读取输出时使用yield from。这正是与常规外部进程通信更困难的原因。进程的标准输入更侧重于用户输入而不是计算机输入,这使得使用起来不太方便。

注意

如果您希望使用reader.read(BUFFER)而不是reader.readline(),也是可能的。只是请注意,您需要明确地分隔数据,否则可能会意外地被附加。所有写操作都写入同一个缓冲区,导致一个长的返回流。另一方面,尝试在reader.readline()中没有新行(\n)的情况下进行写入将导致客户端永远等待。

摘要

在本章中,我们看到了如何在 Python 中使用asyncio进行异步 I/O。对于许多场景,asyncio模块仍然有些原始和未完成,但不应该有任何使用上的障碍。创建一个完全功能的服务器/客户端设置仍然有点复杂,但asyncio最明显的用途是处理基本的网络 I/O,如数据库连接和外部资源,如网站。特别是后者只需使用asyncio就可以实现几行代码,从您的代码中删除一些非常重要的瓶颈。

本章的重点是理解如何告诉 Python 在后台等待结果,而不是像通常那样简单地等待或轮询结果。在第十三章中,多处理-当单个 CPU 核心不够用,您将了解多处理,这也是处理停滞资源的选项。然而,多处理的目标实际上是使用多个处理器,而不是处理停滞资源。当涉及潜在缓慢的外部资源时,我建议您尽可能使用asyncio

在基于asyncio库构建实用程序时,确保搜索预制库来解决您的问题,因为其中许多目前正在开发中。在撰写本章时,Python 3.5 尚未正式发布,因此很可能很快会出现更多使用async/await语法的文档和库。为了确保您不重复他人已完成的工作,请在撰写扩展asyncio的代码之前彻底搜索互联网。

下一章将解释一个完全不同的主题-使用元类构建类。常规类是使用 type 类创建的,但现在我们将看到如何扩展和修改默认行为,使类几乎可以做任何我们想要的事情。元类甚至可以实现自动注册插件,并以非常神奇的方式向类添加功能-简而言之,如何定制不仅类实例而且类定义本身。

第八章:元类-使类(而不是实例)更智能

前几章已经向我们展示了如何使用装饰器修改类和函数。但这并不是修改或扩展类的唯一选项。在创建类之前修改你的类的更高级的技术是使用元类。这个名字已经暗示了它可能是什么;元类是一个包含有关类的元信息的类。

元类的基本前提是在定义时为你生成另一个类的类,因此通常你不会用它来改变类实例,而只会用它来改变类定义。通过改变类定义,可以自动向类添加一些属性,验证是否设置了某些属性,改变继承关系,自动将类注册到管理器,并做许多其他事情。

尽管元类通常被认为是比(类)装饰器更强大的技术,但实际上它们在可能性上并没有太大的区别。选择通常取决于方便性或个人偏好。

本章涵盖了以下主题:

  • 基本的动态类创建

  • 带参数的元类

  • 类创建的内部工作原理,操作顺序

  • 抽象基类、示例和内部工作原理

  • 使用元类的自动插件系统

  • 存储类属性的定义顺序

动态创建类

元类是在 Python 中创建新类的工厂。实际上,即使你可能不知道,Python 在你创建一个类时总是会执行type元类。

在以程序方式创建类时,type元类被用作一个函数。这个函数接受三个参数:namebasesdictname将成为__name__属性,bases是继承的基类列表,将存储在__bases__中,dict是包含所有变量的命名空间字典,将存储在__dict__中。

应该注意type()函数还有另一个用途。根据之前记录的参数,它会根据这些规格创建一个类。给定一个类实例的单个参数,它也会返回该类,但是从实例中返回。你下一个问题可能是,“如果我在类定义而不是类实例上调用type()会发生什么?”嗯,这会返回类的元类,默认为type

让我们用几个例子来澄清这一点:

>>> class Spam(object):
>>>     eggs = 'my eggs'

>>> Spam = type('Spam', (object,), dict(eggs='my eggs'))

前两个Spam的定义完全相同;它们都创建了一个具有eggsobject作为基类的类。让我们测试一下这是否像你期望的那样工作:

>>> 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元类,而custom元类只是继承type的类。一个简单的类定义有一个隐式的元类,使得一个简单的定义如下:

class Spam(object):
 **pass

本质上与:

class Spam(object, metaclass=type):
 **pass

这引发了一个问题,即如果每个类都是由一个(隐式的)元类创建的,那么type的元类是什么?这实际上是一个递归定义;type的元类是type。这就是自定义元类的本质:一个继承了 type 的类,允许在不需要修改类定义本身的情况下修改类。

一个基本的元类

由于元类可以修改任何类属性,你可以做任何你想做的事情。在我们继续讨论更高级的元类之前,让我们看一个基本的例子:

# The metaclass definition, note the inheritance of type instead
# of object
>>> class MetaSpam(type):
...
...     # Notice how the __new__ method has the same arguments
...     # as the type function we used earlier?
...     def __new__(metaclass, name, bases, namespace):
...         name = 'SpamCreatedByMeta'
...         bases = (int,) + bases
...         namespace['eggs'] = 1
...         return type.__new__(metaclass, name, bases, namespace)

# First, the regular Spam:
>>> class Spam(object):
...     pass

>>> Spam.__name__
'Spam'
>>> issubclass(Spam, int)
False
>>> Spam.eggs
Traceback (most recent call last):
 **...
AttributeError: type object 'Spam' has no attribute 'eggs'

# Now the meta-Spam
>>> class Spam(object, metaclass=MetaSpam):
...     pass

>>> Spam.__name__
'SpamCreatedByMeta'
>>> issubclass(Spam, int)
True
>>> Spam.eggs
1

正如你所看到的,使用元类可以轻松修改类定义的所有内容。这使得它既是一个非常强大又是一个非常危险的工具,因为你可以很容易地引起非常意外的行为。

元类的参数

向元类添加参数的可能性是一个鲜为人知但非常有用的特性。在许多情况下,简单地向类定义添加属性或方法就足以检测要做什么,但也有一些情况下更具体的指定是有用的。

>>> class MetaWithArguments(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():
...             namespace.setdefault(k, v)
...
...         return type.__new__(metaclass, name, bases, namespace)

>>> class WithArgument(metaclass=MetaWithArguments, spam='eggs'):
...     pass

>>> with_argument = WithArgument()
>>> with_argument.spam
'eggs'

这个简单的例子可能没有用,但可能性是存在的。你需要记住的唯一一件事是,为了使其工作,__new____init__ 方法都需要被扩展。

通过类访问元类属性

在使用元类时,可能会感到困惑,注意到类实际上不仅仅是构造类,它实际上在创建时继承了类。举个例子:

>>> class Meta(type):
...
...     @property
...     def spam(cls):
...         return 'Spam property of %r' % cls
...
...     def eggs(self):
...         return 'Eggs method of %r' % self

>>> class SomeClass(metaclass=Meta):
...     pass

>>> SomeClass.spam
"Spam property of <class '...SomeClass'>"
>>> SomeClass().spam
Traceback (most recent call last):
 **...
AttributeError: 'SomeClass' object has no attribute 'spam'

>>> SomeClass.eggs()
"Eggs method of <class '...SomeClass'>"
>>> SomeClass().eggs()
Traceback (most recent call last):
 **...
AttributeError: 'SomeClass' object has no attribute 'eggs'

正如前面的例子中所示,这些方法仅适用于 class 对象,而不适用于实例。spam 属性和 eggs 方法无法通过实例访问,但可以通过类访问。我个人认为这种行为没有任何有用的情况,但它确实值得注意。

使用 collections.abc 的抽象类

抽象基类模块是 Python 中最有用和最常用的元类示例之一,因为它可以轻松确保类遵循特定接口,而无需进行大量手动检查。我们已经在前几章中看到了一些抽象基类的示例,但现在我们将看看这些抽象基类的内部工作原理和更高级的特性,比如自定义 ABC。

抽象类的内部工作原理

首先,让我们演示常规抽象基类的用法:

>>> import abc

>>> class Spam(metaclass=abc.ABCMeta):
...
...     @abc.abstractmethod
...     def some_method(self):
...         raise NotImplemented()

>>> class Eggs(Spam):
...     def some_new_method(self):
...         pass

>>> eggs = Eggs()
Traceback (most recent call last):
 **...
TypeError: Can't instantiate abstract class Eggs with abstract
methods some_method

>>> class Bacon(Spam):
...     def some_method():
...         pass

>>> bacon = Bacon()

正如你所看到的,抽象基类阻止我们在继承所有抽象方法之前实例化类。除了常规方法外,还支持 propertystaticmethodclassmethod

>>> import abc

>>> class Spam(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 元类遍历命名空间中的所有项目,并查找 __isabstractmethod__ 属性评估为 True 的对象。除此之外,它还遍历所有基类,并检查每个基类的 __abstractmethods__ 集合,以防类继承了一个 abstract 类。所有 __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 Spam(metaclass=AbstractMeta):
...     pass

>>> eggs = Spam()
Traceback (most recent call last):
 **...
TypeError: Can't instantiate abstract class Spam with ...

我们可以很容易地自己使用 metaclass 模拟相同的行为,但应该注意 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

>>> def abstractmethod(function):
...     function.__abstract__ = True
...     return function

>>> class Spam(metaclass=AbstractMeta):
...     @abstractmethod
...     def some_method(self):
...         pass

# Instantiating the function, we can see that it functions as the
# regular ABCMeta does
>>> eggs = Spam()
Traceback (most recent call last):
 **...
RuntimeError: 'some_method' is not implemented

实际的实现要复杂一些,因为它仍然需要处理旧式类和propertyclassmethodstaticmethod 类型的方法。此外,它还具有缓存功能,但这段代码涵盖了实现的最有用部分。这里最重要的技巧之一是实际的检查是通过装饰实际类的 __new__ 函数来执行的。这个方法在类中只执行一次,所以我们可以避免为多个实例化添加这些检查的开销。

注意

抽象方法的实际实现可以通过在 Python 源代码中查找 Objects/descrobject.cObjects/funcobject.cObjects/object.c 文件中的 __isabstractmethod__ 来找到。实现的 Python 部分可以在 Lib/abc.py 中找到。

自定义类型检查

当然,使用抽象基类来定义自己的接口是很好的。但是告诉 Python 你的类实际上类似于什么样的类型也是非常方便的。为此,abc.ABCMeta 提供了一个注册函数,允许你指定哪些类型是相似的。例如,一个自定义的列表将列表类型视为相似的:

>>> import abc

>>> class CustomList(abc.ABC):
...     'This class implements a list-like interface'
...     pass

>>> CustomList.register(list)
<class 'list'>

>>> issubclass(list, CustomList)
True
>>> isinstance([], CustomList)
True
>>> issubclass(CustomList, list)
False
>>> isinstance(CustomList(), list)
False

正如最后四行所示,这是一个单向关系。反过来通常很容易通过继承列表来实现,但在这种情况下不起作用。abc.ABCMeta 拒绝创建继承循环。

>>> import abc

>>> class CustomList(abc.ABC, list):
...     'This class implements a list-like interface'
...     pass

>>> CustomList.register(list)
Traceback (most recent call last):
 **...
RuntimeError: Refusing to create an inheritance cycle

为了能够处理这样的情况,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 时的通常行为。

在 Python 3.4 之前使用 abc.ABC

我们在本段中使用的 abc.ABC 类仅在 Python 3.4 及更高版本中可用,但在旧版本中实现它是微不足道的。它只是 metaclass=abc.ABCMeta 的语法糖。要自己实现它,你可以简单地使用以下代码片段:

import abc

class ABC(metaclass=abc.ABCMeta):
    pass

自动注册插件系统

元类最常见的用途之一是让类自动注册为插件/处理程序。这些示例可以在许多项目中看到,比如 Web 框架。这些代码库太庞大了,在这里无法有用地解释。因此,我们将展示一个更简单的例子,展示元类作为自注册的 plugin 系统的强大功能:

>>> 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 SpamPlugin(PluginBase):
...     name = 'spam'

>>> class EggsPlugin(PluginBase):
...     name = 'eggs'

>>> Plugins.get('spam')
<class '...SpamPlugin'>
>>> Plugins.plugins
{'spam': <class '...SpamPlugin'>,
 **'eggs': <class '...EggsPlugin'>}

当然,这个例子有点简单,但它是许多插件系统的基础。这是在实现这样的系统时需要注意的一个非常重要的事情;然而,尽管元类在定义时运行,模块仍然需要被导入才能工作。有几种选项可以做到这一点;通过 get 方法进行按需加载是我的选择,因为这样即使插件没有被使用也不会增加加载时间。

以下示例将使用以下文件结构以获得可重现的结果。所有文件将包含在一个名为 plugins 的目录中。

__init__.py 文件用于创建快捷方式,因此简单的导入 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)
        if isinstance(cls.name, str):
            metaclass.plugins[cls.name] = cls
        return cls

    @classmethod
    def get(cls, name):
        return cls.plugins[name]

class Plugin(metaclass=Plugins):
    @property
    @abc.abstractmethod
    def name(self):
        raise NotImplemented()

和两个简单的插件,spam.py

from . import base

class Spam(base.Plugin):
    name = 'spam'

eggs.py

from . import base

class Eggs(base.Plugin):
    name = 'eggs'

按需导入插件

解决导入问题的第一个解决方案是在 Plugins 元类的 get 方法中处理它。每当在注册表中找不到插件时,它应该自动从 plugins 目录加载模块。

这种方法的优势在于,不仅插件不需要显式预加载,而且只有在需要时才加载插件。未使用的插件不会被触及,因此这种方法有助于减少应用程序的加载时间。

缺点是代码不会被运行或测试,所以它可能完全失效,直到最终加载时你才会知道。这个问题的解决方案将在测试章节中介绍,第十章,测试和日志 - 为错误做准备。另一个问题是,如果代码自注册到应用程序的其他部分,那么该代码也不会被执行。

修改Plugins.get方法,我们得到以下结果:

import abc
import importlib

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):
        if name not in cls.plugins:
            print('Loading plugins from plugins.%s' % name)
            importlib.import_module('plugins.%s' % name)
        return cls.plugins[name]

执行时会得到以下结果:

>>> import plugins
>>> plugins.Plugins.get('spam')
Loading plugins from plugins.spam
<class 'plugins.spam.Spam'>

>>> plugins.Plugins.get('spam')
<class 'plugins.spam.Spam'>

正如你所看到的,这种方法只会导入一次import。第二次,插件将在插件字典中可用,因此不需要加载。

通过配置导入插件

通常只加载所需的插件是一个更好的主意,但预加载可能需要的插件也有其优点。显式比隐式更好,显式加载插件列表通常是一个很好的解决方案。这种方法的附加优势是,首先你可以使注册更加先进,因为你保证它被运行,其次你可以从多个包中加载插件。

get方法中,我们将这次添加一个load方法;一个导入所有给定模块名称的load方法:

import abc
import importlib

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]

    @classmethod
    def load(cls, *plugin_modules):
        for plugin_module in plugin_modules:
            plugin = importlib.import_module(plugin_module)

可以使用以下代码调用:

>>> import plugins

>>> plugins.Plugins.load(
...     'plugins.spam',
...     'plugins.eggs',
... )

>>> plugins.Plugins.get('spam')
<class 'plugins.spam.Spam'>

一个相当简单和直接的系统,根据设置加载插件,这可以很容易地与任何类型的设置系统结合使用来填充load方法。

通过文件系统导入插件

在可能的情况下,最好避免让系统依赖于文件系统上模块的自动检测,因为这直接违反了PEP8。特别是,“显式比隐式更好”。虽然这些系统在特定情况下可以正常工作,但它们经常会使调试变得更加困难。在 Django 中类似的自动导入系统给我带来了不少头疼,因为它们往往会混淆错误。话虽如此,基于插件目录中所有文件的自动插件加载仍然是一个值得演示的可能性。

import os
import re
import abc
import importlib

MODULE_NAME_RE = re.compile('[a-z][a-z0-9_]*', re.IGNORECASE)

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]

    @classmethod
    def load_directory(cls, module, directory):
        for file_ in os.listdir(directory):
            name, ext = os.path.splitext(file_)
            full_path = os.path.join(directory, file_)
            import_path = [module]
            if os.path.isdir(full_path):
                import_path.append(file_)
            elif ext == '.py' and MODULE_NAME_RE.match(name):
                import_path.append(name)
            else:
                # Ignoring non-matching files/directories
                continue

            plugin = importlib.import_module('.'.join(import_path))

    @classmethod
    def load(cls, **plugin_directories):
        for module, directory in plugin_directories.items():
            cls.load_directory(module, directory)

如果可能的话,我会尽量避免使用完全自动的导入系统,因为它很容易出现意外错误,并且会使调试变得更加困难,更不用说导入顺序无法轻松地通过这种方式进行控制。为了使这个系统变得更加智能(甚至导入 Python 路径之外的包),你可以使用importlib.abc中的抽象基类创建一个插件加载器。请注意,你很可能仍然需要通过os.listdiros.walk列出目录。

实例化类时的操作顺序

在调试动态创建和/或修改的类时,类实例化的操作顺序非常重要。类的实例化按以下顺序进行。

查找元类

元类来自于类的显式给定的元类或bases,或者使用默认的type元类。

对于每个类,类本身和 bases,将使用以下匹配的第一个:

  • 显式给定的元类

  • 从 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

>>> @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):
 **...
AttributeError: ... 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类。这里并不严格需要它,但它会自动填写名称(如果需要的话),并添加包含字段排序列表的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在前,eggs在后。由于类命名空间现在是collections.OrderedDict实例,我们知道顺序是有保证的。而不是 Python dict的常规非确定性顺序。这展示了元类在以通用方式扩展类时可以多么方便。元类的另一个重要优势是,与自定义的__init__方法不同,如果用户忘记调用父类的__init__方法,他们也不会失去功能。元类总是会被执行,除非添加了不同的元类。

总结

Python 元类系统是每个 Python 程序员一直在使用的东西,也许甚至不知道。每个类都应该通过某个(子类)type来创建,这允许无限的定制和魔法。现在,你可以像平常一样创建类,并在定义期间动态添加、修改或删除类的属性;非常神奇但非常有用。然而,魔法组件也是它应该谨慎使用的原因。虽然元类可以让你的生活变得更轻松,但它们也是产生完全难以理解的代码的最简单方式之一。

尽管如此,元类有一些很好的用例,许多库如SQLAlchemyDjango都使用元类来使你的代码工作更加轻松,而且可以说更好。实际上,理解内部使用的魔法通常对于使用这些库并不是必需的,这使得这些情况是可以辩护的。问题在于,对于初学者来说,是否值得使用更好的体验来换取一些内部的黑魔法,从这些库的成功来看,我会说在这种情况下是值得的。

总之,当考虑使用元类时,请记住蒂姆·彼得斯曾经说过的话:“元类比 99%的用户应该担心的更深奥。如果你想知道自己是否需要它们,那就不需要。”

现在我们将继续解决一些元类产生的魔法:文档。下一章将向我们展示如何为代码编写文档,如何测试文档,并且最重要的是,如何通过在文档中注释类型来使文档更加智能。

第九章:文档 - 如何使用 Sphinx 和 reStructuredText

记录代码既有趣又有用!我承认许多程序员对记录代码有强烈的厌恶,这是可以理解的。撰写文档可能是一项枯燥的工作,传统上只有其他人才能从这一努力中获益。然而,Python 提供的工具使得生成有用且最新的文档几乎是轻而易举的。生成文档实际上变得如此容易,以至于在使用 Python 包之前,我会先创建和生成文档。假设它之前并不存在的话。

除了简单的文本文档解释函数的作用,还可以添加元数据,例如类型提示。这些类型提示可以用于使函数或类的参数和返回类型在文档中可点击。但更重要的是,许多现代 IDE 和编辑器,如 VIM,都有可用的插件,可以解析类型提示并用于智能自动补全。因此,如果您键入Spam.eggs,您的编辑器将自动完成 eggs 对象的特定属性和方法;这在传统上只适用于静态类型语言,如 Java、C 和 C++。

本章将解释 Python 中可用的文档类型以及如何轻松创建一套完整的文档。借助 Python 提供的惊人工具,您可以在几分钟内拥有完全运行的文档。

本章涵盖的主题如下:

  • reStructuredText 语法

  • 使用 Sphinx 设置文档

  • Sphinx 风格的文档字符串

  • Google 风格的文档字符串

  • NumPy 风格的文档字符串

reStructuredText 语法

reStructuredText格式(也称为RSTReSTreST)于 2002 年开发,是一种简单的语言,实现了足够的标记以便使用,但又足够简单以便作为纯文本可读。这两个特点使得它足够可读以用于代码,但又足够灵活以生成漂亮且有用的文档。

reStructuredText 最棒的地方在于它非常直观。即使不了解标准的任何内容,您也可以轻松以这种风格编写文档,而不必知道它会被识别为一种语言。然而,更高级的技术,如图片和链接,确实需要一些解释。

除了 reStructuredText 之外,还有诸如Markdown之类的语言,它们在使用上相当相似。在 Python 社区中,reStructuredText 已经成为标准文档语言超过 10 年,因此成为了推荐的解决方案。

提示

要轻松在 reStructuredText 和 Markdown 等格式之间转换,请使用 Pandoc 工具,网址为pandoc.org/

基本语法读起来就像文本,接下来的几段将展示一些更高级的功能。然而,让我们从一个简单的例子开始,演示 reStructuredText 文件可以有多简单。

Documentation, how to use Sphinx and reStructuredText
##################################################################

Documenting code can be both fun and useful! ...

Additionally, adding ...

... So that typing `Spam.eggs.` 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 的简单方法。接下来的段落将涵盖以下功能:

  1. 内联标记(斜体、粗体、代码和链接)

  2. 列表

  3. 标题

  4. 高级链接

  5. 图片

  6. 替换

  7. 包含代码、数学和其他内容的块

使用 reStructuredText 快速入门

要快速将 reStructuredText 文件转换为 HTML,我们可以使用docutils库。本章后面讨论的sphinx库实际上在内部使用了docutils库,但具有一些我们最初不需要的额外功能。要开始,我们只需要安装docutils

pip install docutils

之后,我们可以轻松地将 reStructuredText 转换为 PDF、LaTeX、HTML 和其他格式。在本段中的示例中,我们将使用 HTML 格式,可以使用以下命令轻松生成:

rst2html.py file.rst file.html

reStructuredText 的基本组件是角色,用于对输出进行内联修改,以及指令来生成标记块。在纯 reStructuredText 中,指令是最重要的,但在关于 Sphinx 的部分中,我们将看到角色的许多用途。

内联标记

内联标记是在正常文本行内使用的标记。这些示例包括强调、内联代码示例、链接、图像和项目列表。

例如,可以通过在一个或两个星号之间封装单词来添加强调。例如,通过在两侧添加一个星号或在两侧添加两个星号,可以为这个句子添加一点*强调*或很多**强调**。有许多不同的内联标记指令,因此我们只列出最常见的。完整列表始终可以在 reStructuredText 主页docutils.sourceforge.net上找到。

以下是一些例子:

  • 强调(斜体)文本:*对这个短语进行强调*

  • 额外强调(粗体)文本:**对这个短语进行额外强调**

  • 对于没有数字的列表,一个简单的破折号后面跟着空格:

- item 1
- item 2

注意

破折号后面的空格是 reStructuredText 识别列表所必需的。

  • 对于带有数字的列表,数字后面跟着一个句点和一个空格:
1\. item 1
2\. item 2
  • 对于编号列表,数字后面的句点是必需的。

  • 解释文本:这些是特定于域的。在 Python 文档中,默认角色是代码,这意味着用反引号括起来的文本将转换为使用代码标记的代码。例如,if spam and eggs:

根据您的喜好,可以通过角色前缀或后缀来设置不同的角色。例如,:math:`E=mc²`可以显示数学方程式。

  • 内联文字:这是用等宽字体格式化的,非常适合内联代码。只需在`add some code`后面加两个反引号。

  • 引用:可以通过下划线创建引用。它们可以指向标题、链接、标签等。下一节将更多地介绍这些内容,但基本语法就是简单的reference_或者在引用包含空格时用反引号括起来,``some reference link_

  • 要转义前面的字符,可以使用反斜杠。因此,如果您希望使用强调符号,可以使用*\**,这与 Python 字符串中的转义非常相似。

还有许多其他可用的,但这些是您在编写 reStructuredText 时最常使用的。

标题

标题用于指示文档、章节、章节或段落的开始。因此,它是文档中您需要的第一个结构。虽然不是严格必需的,但强烈建议使用,因为它有几个目的:

  1. 标题的格式一致地按照其级别进行格式化。

  2. Sphinx 可以从标题生成目录树。

  3. 所有标题都自动作为标签,这意味着您可以创建指向它们的链接。

创建标题时,一致性是为数不多的约束之一;所使用的字符是相当任意的,级别的数量也是任意的。

就我个人而言,我默认使用一个简单的系统,带有固定大小的标题,但我建议至少在部分、章节、节、子节、子子节和段落方面遵循 Python 文档的默认设置。大致如下:

Part
################################################################

Chapter
****************************************************************

Section
================================================================

Subsection
----------------------------------------------------------------

Subsubsection
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Paragraph
""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""

Content

输出:

标题

这只是标题的常见用法,但 reStructuredText 的主要思想是您可以使用几乎任何对您来说自然的东西,这意味着您可以使用以下任何字符:= - : ' " ~ ^ _ * + # <>`。它还支持下划线和上划线,因此如果您喜欢,也可以选择它们:

################################################################
Part
################################################################

****************************************************************
Chapter
****************************************************************

================================================================
Section
================================================================

----------------------------------------------------------------
Subsection
----------------------------------------------------------------

^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Subsubsection
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
Paragraph
""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""

Content

虽然我尽量保持字符数固定为 78 个字符,就像PEP8(第二章,Pythonic Syntax, Common Pitfalls, and Style Guide)为 Python 推荐的那样,但使用的字符数大多是任意的,但必须至少与标题文本一样长。这使得它可以得到以下结果:

Section
=======

但不是这样:

Section
====

列表

reStructuredText 格式有几种列表样式:

  1. 枚举

  2. 项目符号

  3. 选项

  4. 定义

最简单的列表形式已经在介绍部分显示了,但实际上可以使用许多不同的字符,如字母、罗马数字和其他字符进行枚举。在演示了基本列表类型之后,我们将继续讨论列表和结构的嵌套,使它们变得更加强大。必须注意空格的数量,因为太多的空格会导致结构被识别为普通文本而不是结构。

枚举列表

枚举列表对各种枚举很方便。枚举列表的基本前提是字母数字字符后跟一个句点、右括号或两侧括号。另外,#字符作为自动枚举。例如:

1\. With
2\. Numbers

a. With
#. letters

i. Roman
#. numerals

(1) With
(2) Parenthesis

输出可能比你期望的简单一些。原因是它取决于输出格式。这些是使用 HTML 输出格式生成的,该格式不支持括号。例如,如果输出 LaTeX,差异就会变得明显。以下是渲染的 HTML 输出:

枚举列表

项目符号列表

如果列表的顺序不重要,只需要一个项目列表而不需要枚举,那么项目符号列表就是你需要的。要创建一个只使用项目符号的简单列表,项目符号需要以*+-开头。这个列表大多是任意的,可以通过扩展 Sphinx 或 Docutils 进行修改。例如:

- dashes
- and more dashes

* asterisk
* stars

+ plus
+ and plus

正如你所看到的,使用 HTML 输出时,所有项目符号看起来都是相同的。当生成 LaTeX 文档(以及随后的 PDF 或 Postscript)时,它们可能会有所不同。由于基于 Web 的文档是 Sphinx 最常见的输出格式,因此我们默认使用该输出。渲染的 HTML 输出如下:

项目符号列表

选项列表

option列表是专门用于记录程序命令行参数的。语法的特殊之处在于逗号空格被识别为选项的分隔符。

-s, --spam  This is the spam option
--eggs      This is the eggs option

以下是输出:

选项列表

定义列表

定义列表比其他类型的列表更加隐晦,因为实际结构只包含空格。因此,使用起来非常简单,但在文件中并不总是容易识别。

spam
 **Spam is a canned pork meat product
eggs
 **Is, similar to spam, also food

以下是输出:

定义列表

嵌套列表

嵌套项目实际上不仅限于列表,还可以使用多种类型的块,但思想是相同的。只需小心保持正确级别的缩进。如果不这样做,它要么不会被识别为单独的级别,要么会出错。

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

以下是输出:

嵌套列表

链接、引用和标签

reStructuredText 支持许多类型的链接,其中最简单的是带有协议的链接,例如python.org,大多数解析器会自动识别。但是,也可以使用前面看到的解释文本语法来自定义标签:``Python http://python.org_

这两种都适用于不太频繁重复的简单链接,但通常更方便的是给链接附加标签,这样它们可以被重复使用,而不会过多地占据文本。

例如,请参考以下内容:

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

输出如下:

链接,引用和标签

使用标签,您可以在指定位置轻松创建引用列表,而不会使实际文本变得更难阅读。这些标签不仅可以用于外部链接;类似于在旧的编程语言中找到的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`_

输出如下:

链接,引用和标签

图像

图像指令看起来与标签语法非常相似。它们实际上有些不同,但模式非常相似。图像指令只是 reStructuredText 支持的许多指令中的一个。我们将在稍后介绍 Sphinx 和 reStructuredText 扩展时详细了解更多。目前,知道指令以两个句点开头,后跟一个空格,指令的名称和两个冒号就足够了:

 **.. name_of_directive::

在图像的情况下,指令当然被称为image

.. image:: python.png

由于实际图像要大得多,因此输出为缩放后的图像:

图像

注意

请注意指令后面有两个冒号。

但是如何指定大小和其他属性呢?图像指令有许多其他选项(大多数其他指令也是如此)可以使用:docutils.sourceforge.net/docs/ref/rst/directives.html#images,它们大多数都是相当明显的。要指定图像的宽度和高度或比例(以百分比表示):

.. image:: python.png
 **:width: 150
 **:height: 100

.. image:: python.png
 **:scale: 10

输出如下:

图像

注意

scale选项如果可用则使用widthheight选项,并回退到 PIL(Python Imaging Library)或 Pillow 库来检测图像。如果宽度/高度和 PIL/Pillow 都不可用,则scale选项将被静默忽略。

除了image指令之外,还有figure指令。不同之处在于figure为图像添加了标题。除此之外,使用方式与image相同:

.. figure:: python.png
 **:scale: 10

 **The Python logo

输出如下:

图像

替换

在编写文档时,经常会发生重复使用构造的情况,链接有自己的标签系统,但在 reStructuredText 中还有更多的方法。替换定义使得缩短指令成为可能,因此它们可以轻松地被重复使用。

假设我们有一个徽标,在文本中经常使用。与其输入整个.. image:: <url>,使用简写会更方便。这就是替换非常有用的地方:

.. |python| image:: python.png
 **:scale: 1

The Python programming language uses the logo: |python|

输出如下:

替换

这些替换可以与许多指令一起使用,尽管它们特别适用于在文档的许多地方输出变量。例如:

.. |author| replace:: Rick van Hattem

This book was written by |author|

输出如下:

替换

块,代码,数学,注释和引用

在编写文档时,常见的情况是需要包含不同类型内容的块,包括数学公式的解释,代码示例等。这些指令的使用方式类似于图像指令。以下是一个代码块的示例:

.. code:: python

 **def spam(*args):
 **print('spam got args', args)

输出如下:

块,代码,数学,注释和引用

或使用 LaTeX 语法的数学,微积分基本定理:

.. math::

 **\int_a^b f(x)\,dx = F(b) - F(a)

以下是输出:

块、代码、数学、注释和引用

通过使用“空”指令后跟缩进轻松地对一堆文本/命令进行注释:

Before comments

.. Everything here will be commented

 **And this as well
 **.. code:: python
 **def even_this_code_sample():
 **pass  # Will be commented

After comments

输出如下:

块、代码、数学、注释和引用

最简单的是块引用。块引用只需要简单的缩进。

Normal text

 **Quoted text

输出如下:

块、代码、数学、注释和引用

结论

reStructuredText 既是一个非常简单又是一个非常广泛的语言;当写纯文本注释时,大部分语法都是自然而然的。然而,所有细节的完整指南可能需要一本单独的书来填满。之前的演示应该已经足够介绍至少 90%的工作,您在记录项目时将需要。此外,Sphinx 将在接下来的部分中帮助很多。

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 很容易:

pip install sphinx

安装 Sphinx 后,有两种启动项目的方法,sphinx-quickstart脚本和sphinx-apidoc脚本。如果要创建和自定义整个 Sphinx 项目,那么sphinx-quickstart可能是最好的选择,因为它可以帮助您配置一个功能齐全的 Sphinx 项目。如果您只是想要现有项目的 API 文档,那么sphinx-apidoc可能更适合,因为它只需要一个命令和没有进一步的输入来创建项目。

最后,两者都是创建 Sphinx 项目的有效选项,我个人通常最终使用sphinx-quickstart生成初始配置,并在每次添加 Python 模块时调用sphinx-apidoc命令以添加新模块。由于sphinx-apidoc默认不覆盖任何文件,因此这是一个安全的操作。

使用 sphinx-quickstart

sphinx-quickstart脚本会与您互动地询问有关 Sphinx 项目中最重要的决定。但是,如果您不小心打错了字,也不用担心。大部分配置存储在conf.py目录中,因此稍后编辑配置仍然很容易,以防您仍然想要启用某个模块。

使用起来很容易,作为默认值,我建议使用以下设置。输出使用以下约定:

  • 内联注释以#开头

  • 用户输入行以>开头

  • 裁剪输出用...表示,所有问题之间跳过的默认设置

# sphinx-quickstart
Welcome to the Sphinx 1.3.3 quickstart utility.

...

Enter the root path for documentation.
> Root path for the documentation [.]: docs

...

The project name will occur in several places in the built documentation.
> Project name: Mastering Python
> Author name(s): Rick van Hattem

# As version you might want to start below 1.0 or add an extra digit
# but I would recommend leaving the default and modify the
# configuration file instead. Just make it import from the Python
# package instead. An example can be found in the numpy-stl package:
# https://github.com/WoLpH/numpy-stl/blob/develop/docs/conf.py
...
> Project version: 1.0
> Project release [1.0]:

...

# Enabling the epub builder can be useful for people using e-readers to
# read the documentation.
Sphinx can also add configuration for epub output:
> Do you want to use the epub builder (y/n) [n]: y

...
# Autodoc is required to document the code, definitely recommended to
# enable
> autodoc: automatically insert docstrings from
 **modules (y/n) [n]: y

# With the doctest feature we can run tests embedded in the
# documentation. This is meant for doctests in the .rst files.
> doctest: automatically test code snippets in
 **doctest blocks (y/n) [n]: y

# Intersphinx enables linking between Sphinx documentation sets
# allowing for links to external documentation. After enabling this
# you can make str link to the regular Python documentation about str
# for example.
> intersphinx: link between Sphinx documentation
 **of different projects (y/n) [n]: y
...
# Mathjax enables LaTeX style mathematical rendering, not strictly
# needed but very useful for rendering equations.
> mathjax: include math, rendered in the browser
 **by MathJax (y/n) [n]: y
...
> viewcode: include links to the source code of
 **documented Python objects (y/n) [n]: y

...

Creating file docs/conf.py.
Creating file docs/index.rst.
Creating file docs/Makefile.
Creating file docs/make.bat.

Finished: An initial directory structure has been created.

现在,您应该填充您的主文件docs/index.rst并创建其他文档源文件。使用 Makefile 构建文档,就像这样:

 **make builder
where "builder" is one of the supported builders, e.g. html, latex or linkcheck.

运行后,我们应该有一个包含 Sphinx 项目的docs目录。让我们看看这个命令实际为我们创建了什么:

# find docs
docs
docs/_build
docs/_static
docs/_templates
docs/conf.py
docs/index.rst
docs/make.bat
docs/Makefile

_build_static_templates目录最初是空的,现在可以忽略。_build目录用于输出生成的文档,而_static目录可用于轻松包含自定义 CSS 文件等。_templates目录还可以让您根据自己的喜好样式化 HTML 输出。这些示例可以在 Sphinx Git 存储库中找到:github.com/sphinx-doc/sphinx/tree/master/sphinx/themes

Makefilemake.bat可用于生成文档输出。Makefile可用于支持 make 实用程序的任何操作系统,而make.bat则可用于直接支持 Windows 系统。现在让我们看一下index.rst源文件:

Welcome to Mastering Python's documentation!
============================================

Contents:

.. toctree::
 **:maxdepth: 2

Indices and tables
==================

* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

我们看到了预期的文档标题,接着是toctree(目录树;本章后面会详细介绍),以及索引和搜索的链接。toctree会自动生成所有可用文档页面的标题树。索引和表格是自动生成的 Sphinx 页面,非常有用,但在设置方面我们不需要担心。

现在是时候生成HTML输出了:

cd docs
make html

make html命令会为您生成文档,并将结果放在_build/html/中。只需在浏览器中打开index.html即可查看结果。您现在应该看到类似以下的内容:

使用 sphinx-quickstart

只需这一个命令,回答几个问题,我们现在有了一个包含索引、搜索和所有页面的目录的文档项目。

除了 HTML 输出之外,默认支持许多其他格式,尽管有些需要外部库才能真正工作:

# make
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 a HTML help project
 **qthelp     to make HTML files and a qthelp project
 **applehelp  to make an Apple Help Book
 **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 files and run them through 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/deprecate...
 **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 文件,因此我们将在名为h09的项目中创建两个文件。

第一个是h09/spam.py,其中包含一个名为Spam的类和一些方法:

class Spam(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

接下来是h09/eggs.py,其中包含一个继承SpamEggs类:

import spam

class Eggs(spam.Spam):
 **def regular_method(self):
 **'''This regular method overrides
 **:meth:`spam.Spam.regular_method`
 **'''
 **pass

现在我们有了源文件,是时候生成实际的 API 文档了:

# sphinx-apidoc h09 -o docs
Creating file docs/eggs.rst.
Creating file docs/spam.rst.
Creating file docs/modules.rst.

仅此还不足以将 API 包含在文档中。它需要添加到toctree中。幸运的是,只需在index.rst文件中的toctree中添加模块即可,看起来像这样:

.. toctree::
 **:maxdepth: 2

 **modules

toctree指令将在本章后面更详细地讨论。

我们还必须确保可以导入模块,否则 Sphinx 将无法读取 Python 文件。为此,我们只需将h09目录添加到sys.path中;这可以放在conf.py文件的任何位置:

import os
sys.path.insert(0, os.path.join(os.path.abspath('..'), 'h09'))

现在是时候再次生成文档了:

cd docs
make html

再次打开docs/_build/index.html文件。为了简洁起见,文档的重复部分将从截图中省略。裁剪后的输出如下:

使用 sphinx-apidoc

但它实际上生成了更多。运行sphinx-apidoc命令时,它会递归查看指定目录中的所有 Python 模块,并为每个生成一个rst文件。在生成所有这些单独的文件之后,它将所有这些添加到一个名为modules.rst的文件中,这样就可以轻松地将它们添加到您的文档中。

modules.rst文件非常简单明了;只是一个以包名称为标题的模块列表:

h09
===

.. toctree::
 **:maxdepth: 4

 **eggs
 **spam

输出如下:

使用 sphinx-apidoc

spam.rsteggs.rst同样简单,但在定制方面更为重要。在这些文件中,它添加了automodule指令,该指令导入 Python 模块并列出方法。列出的方法可以进行配置,默认情况下我们已经得到了非常有用的输出:

eggs module
===========

.. automodule:: eggs
 **:members:
 **:undoc-members:
 **:show-inheritance:

以下是输出:

使用 sphinx-apidoc

漂亮,不是吗?所有这些几乎可以毫不费力地从大多数 Python 项目中生成。这里的好处是我们添加到Eggs.regular_method的文档立即添加到这里,继承的基类(spam.Spam)是指向spam.Spam文档页面的可点击链接,:func:角色也使spam.Spam.regular_method立即可点击。

spam 模块的输出类似:

使用 sphinx-apidoc

注意

新文件不会自动添加到您的文档中。重新运行sphinx-apidoc命令以添加新文件是安全的,但它不会更新您现有的文件。即使--force选项可以用于强制覆盖文件,我建议手动编辑现有文件。正如我们将在接下来的部分中看到的,有很多理由在生成的文件之后手动修改它们。

Sphinx 指令

Sphinx 在 reStructuredText 的默认指令之上添加了一些指令,并提供了一个简单的 API 来添加新的指令。其中大多数通常不那么重要,但是正如人们所期望的那样,Sphinx 在需要了解更多信息时有非常好的文档。有一些非常常用的指令,我们将在下面讨论。

目录树指令(toctree)

这是 Sphinx 中最重要的指令之一;它生成toctree(目录树)。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>

Autodoc,记录 Python 模块、类和函数

Sphinx 最强大的功能是自动记录模块、类和函数的可能性。 sphinx-apidoc命令已经为我们生成了一些文件,所以让我们使用这些文件来为SpamEggs类扩展文档。

sphinx-apidoc的原始结果是:

eggs module
===========

.. automodule:: eggs
 **:members:
 **:undoc-members:
 **:show-inheritance:

这将呈现为:

Autodoc,记录 Python 模块、类和函数

Eggs类目前只有一个函数。当然,我们可以轻松地点击到父类,但在许多情况下,查看类中所有可用的函数是很有用的。因此,让我们也添加从Spam继承的所有函数:

eggs module
===========

.. automodule:: eggs
 **:members:
 **:undoc-members:
 **:show-inheritance:
 **:inherited-members:

输出如下:

Autodoc,记录 Python 模块、类和函数

已经更有用了,但我们仍然缺少隐藏的方法。让我们也添加私有成员:

eggs module
===========

.. automodule:: eggs
 **:members:
 **:undoc-members:
 **:show-inheritance:
 **:inherited-members:
 **:private-members:

以下是输出:

Autodoc,记录 Python 模块、类和函数

现在所有的方法都显示出来了,但members选项呢?如果没有members选项或*-members选项,将不再显示任何函数。

如果您想要有Bases: ...部分,以便可以单击到父类,show-inheritance是有用的。

当然,也可以手动创建类。虽然这几乎没有实际用途,但它确实展示了 Sphinx 中 Python 类的内部结构。

然而,有一个实际的情况,如果您正在动态创建类,那么autodoc将无法始终正确地记录文档,并且需要一些额外的帮助。然而,还有更多,虽然通常情况下并不那么有用,因为这样做相当于重复工作。在某些情况下,autodoc扩展无法正确识别类的成员。例如,在动态类/函数生成的情况下就是如此。对于这种情况,向模块/类/函数添加一些手动文档可能会有用:

eggs module
===========

.. automodule:: eggs
 **:members:
 **:undoc-members:
 **:show-inheritance:

 **.. class:: NonExistingClass
 **This class doesn't actually exist, but it's in the documentation now.

 **.. method:: non_existing_function()

 **And this function does not exist either.

以下是输出:

Autodoc,记录 Python 模块、类和函数

如果可能的话,我会尽量避免这种用法。Sphinx 最大的好处是它可以自动生成大部分文档。通过手动记录,您可能会产生比没有文档更糟糕的东西,即不正确的文档。这些陈述主要用于元文档;记录类可能看起来如何,而不是实际示例。

Sphinx 角色

我们已经看到了 Sphinx 指令,它们是单独的块。现在我们将讨论 Sphinx 角色,这些角色可以在行内使用。角色允许您告诉 Sphinx 如何解析某些输入。这些角色的示例包括链接、数学、代码和标记。但最重要的是 Sphinx 领域内用于引用其他类的角色,甚至是外部项目。在 Sphinx 中,默认领域是 Python 领域,因此像:py:meth:这样的角色也可以使用:meth:。这些角色非常有用,可以链接到不同的包、模块、类、方法和其他对象。基本用法非常简单。要链接到一个类,请使用以下内容:

Spam: :class:`spam.Spam`

输出是:

斯芬克斯角色

同样适用于几乎任何其他对象,函数、异常、属性等。Sphinx 文档提供了支持的对象列表:sphinx-doc.org/domains.html#cross-referencing-python-objects

Sphinx 的一个更好的功能是,这实际上也可以跨项目实现,通过使用:obj:int``可以轻松地在标准 Python 文档中添加对int对象的引用。在其他站点上添加对您自己项目的引用也相当简单。也许您还记得sphinx-quickstart脚本中的intersphinx问题:

> intersphinx: link between Sphinx documentation
 **of different projects (y/n) [n]: y

这就是使外部 Sphinx 文档和您的本地文档之间的交叉引用成为可能的原因。使用intersphinx,您可以在项目之间添加链接,几乎不需要任何努力。conf.py中的标准intersphinx_mapping有点有限:

intersphinx_mapping = {'https://docs.python.org/': None}

然而,它可以很容易地扩展到其他文档站点:

intersphinx_mapping = {
 **'https://docs.python.org/': None,
 **'sphinx': ('http://sphinx-doc.org/', None),
}

现在我们可以轻松地链接到 Sphinx 主页上的文档:

Link to the intersphinx module: :mod:`sphinx.ext.intersphinx`

以下是输出:

斯芬克斯角色

这将链接到www.sphinx-doc.org/en/stable/ext/intersphinx.html

记录代码

目前,Sphinx 支持三种不同的文档样式:原始的 Sphinx 样式和较新的 NumPy 和 Google 样式。它们之间的区别主要在于样式,但实际上略有不同。

Sphinx 风格是使用一堆 reStructuredText 角色开发的,这是一种非常有效的方法,但是如果经常使用,可能会对可读性产生不利影响。你可能能够猜出以下内容的含义,但它的语法并不是最好的:

:param amount: The amount of eggs to return
:type amount: int

Google 风格(顾名思义)是由 Google 开发的。其目标是具有简单/易读的格式,既可以作为代码内文档,又可以被 Sphinx 解析。在我看来,这更接近于 reStructuredText 的原始理念,这是一种非常接近你本能地记录文档的格式。这个例子与之前展示的 Sphinx 风格例子具有相同的含义:

Args:
 **amount (int): The amount of eggs to return

NumPy 风格是专门为 NumPy 项目创建的。NumPy 项目有许多函数,文档量很大,通常每个参数都有很多文档。它比 Google 格式稍微冗长,但同样易于阅读:

Parameters
----------
amount : int
 **The amount of eggs to return

注意

在将来,随着 Python 3.5 类型提示注释的出现,至少这些语法中的参数类型部分可能会变得无用。目前,Sphinx 还没有针对注释的特定支持,因此必须通过文档进行显式类型提示。但也许我们很快就可以使用以下内容:

def eggs(amount: int):
    pass

使用 Sphinx 风格记录一个类

首先,让我们看看传统风格,即 Sphinx 风格。虽然很容易理解所有参数的含义,但有点冗长,不太易读。尽管如此,它非常清晰,绝对不是一个糟糕的风格。

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, *args, **kwargs):
 **self.arg = arg

 **def eggs(self, amount, cooked):
 **'''We can't have spam without eggs, so here's the eggs

 **:param amount: The amount of eggs to return
 **:type amount: int
 **:param bool cooked: Should the eggs be cooked?
 **:raises: :class:`RuntimeError`: Out of eggs

 **:returns: A bunch of eggs
 **:rtype: Eggs
 **'''
 **pass

以下是输出:

使用 Sphinx 风格记录一个类

这确实是一个非常有用的输出,其中包括了文档化的函数、类和参数。更重要的是,类型也被记录下来,从而产生了指向实际类型的可点击链接。指定类型的一个额外优势是,许多编辑器都能理解文档,并将根据给定的类型提供自动补全。

为了解释这里实际发生了什么,Sphinx 在文档字符串中有一些角色,提供了我们正在记录的提示。

param角色与名称配对,设置了具有该名称的参数的文档。type角色与名称配对,告诉 Sphinx 参数的数据类型。这两个角色都是可选的,如果省略它们,参数就不会有任何额外的文档,但param角色对于任何文档都是必需的。只是添加type角色而不添加param角色将导致没有任何输出,因此请注意始终将它们配对使用。

returns角色类似于param角色,用于文档记录。param角色记录参数,returns角色记录返回的对象。但它们有一些不同。与param角色相反,returns角色不依赖于rtype角色,反之亦然。它们都可以独立工作,可以使用其中一个或两个角色。

rtype告诉 Sphinx(以及一些编辑器)函数返回的对象的类型。

使用 Google 风格记录一个类

Google 风格只是 Sphinx 风格文档的更易读版本。它实际上并不支持更多或更少,但使用起来更直观。唯一需要记住的是,这是 Sphinx 的一个相当新的特性。在旧版本中,你需要安装sphinxcontrib-napoleon包。如今它已经捆绑在 Sphinx 中,但仍然需要通过conf.py文件启用。因此,根据 Sphinx 的版本(Napoleon 是在 Sphinx 1.3 中添加的),你需要在conf.py的扩展列表中添加sphinx.ext.napoleonsphinxcontrib.napoleon

一旦你正确配置了所有内容,我们可以同时使用 Google 和 NumPy 风格。这是Spam类的 Google 风格版本:

class Spam(object):
 **'''
 **The Spam object contains lots of spam

 **Args:
 **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, amount, cooked):
 **'''We can't have spam without eggs, so here's the eggs

 **Args:
 **amount (int): The amount of eggs to return
 **cooked (bool): Should the eggs be cooked?

 **Raises:
 **RuntimeError: Out of eggs

 **Returns:
 **Eggs: A bunch of eggs
 **'''
 **pass

这比 Sphinx 风格更容易阅读,并且具有相同数量的可能性。对于更长的参数文档,这并不方便。想象一下amount的多行描述会是什么样子。这就是为什么开发了 NumPy 风格,用于其参数的大量文档。

使用 NumPy 风格记录类

NumPy 风格适用于大量文档。老实说,大多数人都太懒了,所以对于大多数项目来说,这并不合适。如果您计划对函数及其所有参数进行广泛的文档记录,那么 NumPy 风格可能是一个不错的选择。它比 Google 风格更冗长,但非常易读,特别是在更详细的文档中。请记住,与 Google 风格类似,这需要 Sphinx 的 Napoleon 扩展,因此请确保您已安装了 Sphinx 1.3 或更高版本。以下是Spam类的 NumPy 版本:

class Spam(object):
 **'''
 **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, amount, cooked):
 **'''We can't have spam without eggs, so here's the eggs

 **Parameters
 **----------
 **amount : int
 **The amount 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 风格可能更易读,但一致性更重要。

总结

文档可以极大地帮助项目的受欢迎程度,而糟糕的文档可能会破坏生产力。我认为在库的几个方面中,没有比文档对第三方使用的影响更大的了。因此,在许多情况下,文档是决定项目使用的更重要因素,而不是实际的代码质量。这就是为什么始终努力提供一些文档非常重要。

使用 Sphinx 实际上很容易生成文档。只需花几分钟时间,您就可以拥有一个完全运行的网站,可用的文档,或者 PDF,ePub 或其他许多输出格式之一。现在真的没有不编写文档的借口了。即使您自己不怎么使用文档,为编辑器提供类型提示也可以大大提高生产力。让您的编辑器更智能应该总是有助于提高生产力。我本人已经在几个项目中添加了类型提示,只是为了提高我的生产力。

下一章将解释如何在 Python 中测试代码,文档的一部分将在那里返回。使用doctest,可以将示例代码、文档和测试合而为一。

第十章:测试和日志 - 为错误做准备

在编程时,大多数开发人员会稍微计划一下,然后立即继续编写代码。毕竟,我们都希望写出没有错误的代码!不幸的是,我们并没有。在某个时候,一个错误的假设、一个误解,或者只是一个愚蠢的错误都是不可避免的。调试(在第十一章中讨论,调试 - 解决错误)总是需要的,但有几种方法可以用来预防错误,或者至少在发生错误时更容易解决它们。

为了防止错误发生,测试驱动开发或者至少是功能/回归/单元测试非常有用。标准的 Python 安装本身就提供了几个选项,比如doctestunittesttest模块。doctest模块允许你将测试与示例文档结合起来。unittest模块允许你轻松编写回归测试。test模块仅用于内部使用,所以除非你打算修改 Python 核心,否则可能不需要这个模块。

我们将在本章讨论以下测试模块:

  • doctest

  • py.test(以及为什么它比unittest更方便)

  • unittest.mock

py.test模块的目的与unittest模块大致相同,但使用起来更加方便,并且有一些额外的选项。

在学习如何避免错误之后,现在是时候看看日志记录,以便我们可以检查程序中发生了什么以及为什么。Python 中的日志记录模块是高度可配置的,可以根据几乎任何用例进行调整。如果你曾经编写过 Java 代码,你应该对logging模块感到非常熟悉,因为它的设计在很大程度上基于log4j模块,并且在实现和命名上非常相似。后者也使得它在 Python 中有点奇怪,因为它是少数几个不遵循pep8命名标准的模块之一。

本章将解释以下主题:

  • 使用doctest结合文档和测试

  • 使用py.testunittest进行回归和单元测试

  • 使用unittest.mock使用虚假对象进行测试

  • 有效使用logging模块

  • 使用loggingpy.test结合

使用 doctest 作为测试的示例

doctest模块是 Python 中最有用的模块之一。它允许你将代码文档化和测试结合起来,以确保它能够按照预期的方式继续工作。

一个简单的 doctest 示例

让我们从一个快速示例开始:一个对输入进行平方的函数。以下示例是一个完全功能的命令行应用程序,包含了代码和运行测试。前几个测试覆盖了函数在正常执行时应该如何行为,然后是一些测试来演示预期的错误:

def square(n):
 **'''
 **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函数接受冗长参数:

# python square.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 生成漂亮的文档:

一个简单的 doctest 示例

然而,代码并不总是正确的。如果我们修改代码,使得测试不再通过,会发生什么?

这次,我们使用n ** 2而不是n * n。两者都是对一个数字求平方,对吧?所以结果必须是相同的。对吧?这些是导致错误的假设类型,也是通过一些基本测试轻松捕捉到的假设类型:

def square(n):
 **'''
 **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 ** 2

if __name__ == '__main__':
 **import doctest
 **doctest.testmod()

让我们再次执行测试,看看这次会发生什么。为简洁起见,这次我们将跳过冗长标志:

# python square.py
**********************************************************************
File "square.py", line 17, in __main__.square
Failed example:
 **square('x')
Expected:
 **Traceback (most recent call last):
 **...
 **TypeError: can't multiply sequence by non-int of type 'str'
Got:
 **Traceback (most recent call last):
 **File "doctest.py", line 1320, in __run
 **compileflags, 1), test.globs)
 **File "<doctest __main__.square[5]>", line 1, in <module>
 **square('x')
 **File "square.py", line 28, in square
 **return n ** 2
 **TypeError: unsupported operand type(s) for ** or pow(): 'str' and 'int'
**********************************************************************
1 items had failures:
 **1 of   6 in __main__.square
***Test Failed*** 1 failures.

我们对代码进行的唯一修改是用n ** 2替换了n * n,这相当于幂函数。由于乘法与取幂的操作不同,结果略有不同,但在实践中足够相似,以至于大多数程序员不会注意到这种差异。

代码更改导致的唯一区别是我们现在有了一个不同的异常——一个无辜的错误,在这种情况下只是破坏了测试。但它显示了这些测试是多么有用。在重写代码时,很容易做出错误的假设,这就是测试最有用的地方——知道您在破坏代码的同时破坏了它,而不是在几个月后发现。

编写 doctest

也许你已经注意到前面的示例中,语法与常规 Python 控制台非常相似,这正是重点所在。doctest输入只不过是常规 Python shell 会话的输出。这就是使用此模块进行测试如此直观的原因;只需在 Python 控制台中编写代码,然后将输出复制到文档字符串中进行测试。这里有一个例子:

# python
>>> 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'

这可能是测试代码的最简单方式。几乎不费吹灰之力,您就可以检查代码是否按您的预期工作,添加测试并同时添加文档。只需将解释器的输出复制到函数或类文档中,您就有了可用的 doctest。

使用纯文档进行测试

函数、类和模块中的文档字符串通常是向代码添加 doctest 的最明显方式,但并非唯一的方式。正如我们在上一章中讨论的,Sphinx 文档也支持doctest模块。您可能还记得,在创建 Sphinx 项目时,我们启用了doctest模块:

> doctest: automatically test code snippets in doctest blocks (y/n) [n]:y

此标志在 Sphinx 中启用了sphinx.ext.doctest扩展,告诉 Sphinx 也要运行这些测试。由于代码中并非所有示例都有用,让我们看看是否可以将它们分为实际有用和仅与文档相关的示例。此外,为了查看结果,我们将在文档中添加一个错误:

square.py

def square(n):
 **'''
 **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::

 **>>> square(100)

 **>>> 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
sphinx-build -b doctest -d _build/doctrees . _build/doctest
Running Sphinx v1.3.3
loading translations [en]... done
loading pickled environment... done
building [mo]: targets for 0 po files that are out of date
building [doctest]: targets for 3 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
----------------
**********************************************************************
File "square.rst", line 16, in default
Failed example:
 **square(100)
Expected nothing
Got:
 **10000
**********************************************************************
1 items had failures:
 **1 of   7 in default
7 tests in 1 items.
6 passed and 1 failed.
***Test Failed*** 1 failures.

Doctest summary
===============
 **7 tests
 **1 failure in tests
 **0 failures in setup code
 **0 failures in cleanup code
build finished with problems.
make: *** [doctest] Error 1

正如预期的那样,对于不完整的doctest,我们得到了一个错误,但除此之外,所有测试都执行正确。为了确保测试知道square是什么,我们必须添加testsetup指令,这仍然生成了一个漂亮的输出:

使用纯文档进行测试

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,在大多数情况下是有用的,但可能会产生意想不到的结果。为了演示差异,我们有以下几行:

'''
>>> False
0
>>> True
1
>>> False  # doctest: +DONT_ACCEPT_TRUE_FOR_1
0
>>> True  # doctest: +DONT_ACCEPT_TRUE_FOR_1
1
'''

if __name__ == '__main__':
 **import doctest
 **doctest.testmod()

这是DONT_ACCEPT_TRUE_FOR_1标志的结果:

# python test.py
**********************************************************************
File "test.py", line 6, in __main__
Failed example:
 **False  # doctest: +DONT_ACCEPT_TRUE_FOR_1
Expected:
 **0
Got:
 **False
**********************************************************************
File "test.py", line 8, in __main__
Failed example:
 **True  # doctest: +DONT_ACCEPT_TRUE_FOR_1
Expected:
 **1
Got:
 **True
**********************************************************************
1 items had failures:
 **2 of   4 in __main__
***Test Failed*** 2 failures.

正如您所看到的,DONT_ACCEPT_TRUE_FOR_1标志使doctest拒绝1作为True的有效响应,以及0作为False的有效响应。

标准化空白

由于 doctest 用于文档和测试目的,因此保持可读性几乎是必需的。但是,如果不规范化空格,这可能有些棘手。考虑以下示例:

>>> [list(range(5)) for i in range(5)]
[[0, 1, 2, 3, 4], [0, 1, 2, 3, 4], [0, 1, 2, 3, 4], [0, 1, 2, 3, 4], [0, 1, 2, 3, 4]]

虽然并不是很糟糕,但这种输出对于可读性并不是最佳的。通过规范化空格,我们可以做到这一点:

>>> [list(range(5)) for i in range(5)]  # doctest: +NORMALIZE_WHITESPACE
[[0, 1, 2, 3, 4],
 **[0, 1, 2, 3, 4],
 **[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 怪癖

前面讨论的三个选项标志解决了 doctest 中发现的许多怪癖,但还有几种情况需要注意。在这些情况下,您只需要稍微小心,并解决doctest模块的限制。doctest模块有效地使用表示字符串,而这些表示字符串并不总是一致的。

最重要的情况是浮点不准确性、字典和随机值,例如计时器。以下示例大多数情况下会失败,因为 Python 中的某些类型没有一致的排序,并且取决于外部变量:

>>> dict.fromkeys('spam')
{'s': None, 'p': None, 'a': None, 'm': None}
>>> 1./7.
0.14285714285714285

>>> import time
>>> time.time() - time.time()
-9.5367431640625e-07

所有问题都有几种可能的解决方案,主要在风格和个人偏好上有所不同。

测试字典

字典的问题在于它们在内部实现为哈希表,导致有效的随机表示顺序。由于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与由相同元素组成的不同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中的天、小时、分钟和微秒。

在后面的段落中,我们将看到使用模拟对象的完全稳定的解决方案。然而,对于 doctests 来说,这通常是过度的。

使用 py.test 进行测试

py.test工具使编写测试和运行测试变得非常容易。还有一些其他选项,如nose和捆绑的unittest模块可用,但py.test库提供了非常好的可用性和积极的开发组合。过去,我是一个狂热的nose用户,但后来转而使用py.test,因为在我看来,它更容易使用并且有更好的社区支持。不过,nose仍然是一个不错的选择,如果你已经在使用它,就没有理由切换和重写所有的测试。然而,在为新项目编写测试时,py.test可能更加方便。

现在,我们将使用py.test运行先前讨论的square.py文件中的 doctests。

首先,当然要安装py.test

pip install pytest

现在您可以进行一次测试运行,让我们尝试一下square.py中的 doctests:

# py.test --doctest-modules -v square.py
======================== test session starts ========================
platform darwin -- Python 3.5.1, pytest-2.8.2, py-1.4.30, pluggy-0.3.1 -- python3.5
cachedir: .cache
rootdir: code, inifile: pytest.ini
collected 1 items

square.py::square.square PASSED

===================== 1 passed in 0.02 seconds ======================

单元测试和 py.test 输出之间的区别

我们在square.py中有 doctests。让我们创建一个名为cube的新类,并在代码之外创建一组适当的测试。

首先,我们有cube.py的代码,类似于square.py,但减去了 doctests,因为我们不再需要它们:

def cube(n):
 **'''
 **Returns the input number, cubed

 **Args:
 **n (int): The number to cube

 **Returns:
 **int: The cubed result
 **'''
 **return n ** 3

现在让我们从unittest示例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()

这可以通过执行文件本身来执行:

# python 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.001s

OK

或者,可以通过模块来完成:

# python -m unittest -v test_cube.py
test_0 (test_cube.TestCube) ... ok
test_1 (test_cube.TestCube) ... ok
test_2 (test_cube.TestCube) ... ok
test_3 (test_cube.TestCube) ... ok
test_exception_str (test_cube.TestCube) ... ok
test_no_arguments (test_cube.TestCube) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.001s

OK

这是通过py.test执行的:

# py.test -v test_cube.py
====================== test session starts ======================
platform darwin -- Python 3.5.1, pytest-2.8.5, py-1.4.31, pluggy-0.3.1 -- python3.5
cachedir: ../.cache
rootdir: code, inifile: pytest.ini
collected 6 items

test_cube.py::TestCube::test_0 PASSED
test_cube.py::TestCube::test_1 PASSED
test_cube.py::TestCube::test_2 PASSED
test_cube.py::TestCube::test_3 PASSED
test_cube.py::TestCube::test_exception_str PASSED
test_cube.py::TestCube::test_no_arguments PASSED

=================== 6 passed in 0.02 seconds ====================

我们甚至有nose

# nosetests -v test_cube.py
test_0 (test_cube.TestCube) ... ok
test_1 (test_cube.TestCube) ... ok
test_2 (test_cube.TestCube) ... ok
test_3 (test_cube.TestCube) ... ok
test_exception_str (test_cube.TestCube) ... ok
test_no_arguments (test_cube.TestCube) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.001s

OK

只要所有的结果都成功,unittestpy.test之间的差异就很小。在unittestnose的情况下,结果是相同的。然而,这一次,我们将打破代码以展示实际重要时的差异。我们将添加square代码而不是cube代码。所以从square返回n ** 2而不是n ** 3

首先,我们有常规的unittest输出:

# python test_cube.py -v
test_0 (__main__.TestCube) ... ok
test_1 (__main__.TestCube) ... ok
test_2 (__main__.TestCube) ... FAIL
test_3 (__main__.TestCube) ... FAIL
test_exception_str (__main__.TestCube) ... ok
test_no_arguments (__main__.TestCube) ... ok

======================================================================
FAIL: test_2 (__main__.TestCube)
----------------------------------------------------------------------
Traceback (most recent call last):
 **File "test_cube.py", line 13, in test_2
 **self.assertEqual(cube.cube(2), 8)
AssertionError: 4 != 8

======================================================================
FAIL: test_3 (__main__.TestCube)
----------------------------------------------------------------------
Traceback (most recent call last):
 **File "test_cube.py", line 16, 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 -v test_cube.py
======================= test session starts ========================
platform darwin -- Python 3.5.1, pytest-2.8.5, py-1.4.31, pluggy-0.3.1 -- python3.5
cachedir: ../.cache
rootdir: code, inifile: pytest.ini
collected 6 items

test_cube.py::TestCube::test_0 PASSED
test_cube.py::TestCube::test_1 PASSED
test_cube.py::TestCube::test_2 FAILED
test_cube.py::TestCube::test_3 FAILED
test_cube.py::TestCube::test_exception_str PASSED
test_cube.py::TestCube::test_no_arguments PASSED

============================= FAILURES =============================
_________________________ TestCube.test_2 __________________________

self = <test_cube.TestCube testMethod=test_2>

 **def test_2(self):
>       self.assertEqual(cube.cube(2), 8)
E       AssertionError: 4 != 8

test_cube.py:13: AssertionError
_________________________ TestCube.test_3 __________________________

self = <test_cube.TestCube testMethod=test_3>

 **def test_3(self):
>       self.assertEqual(cube.cube(3), 27)
E       AssertionError: 9 != 27

test_cube.py:16: AssertionError
================= 2 failed, 4 passed in 0.03 seconds ================

在这些小案例中,差异并不是很明显,但是在测试具有大堆栈跟踪的复杂代码时,它变得更加有用。然而,对我个人来说,看到周围的测试代码是一个很大的优势。在刚刚讨论的例子中,self.assertEqual(...)行显示了整个测试,但在许多其他情况下,您将需要更多的信息。常规unittest模块和py.test模块之间的区别在于您可以看到包含所有代码和输出的整个函数。在本章的后面,我们将看到在编写更高级的测试时,这是多么强大。

要真正欣赏py.test的输出,我们还需要启用颜色。当然,颜色取决于您的本地颜色方案,但至少有一次将它们并排显示是有用的,如下所示:

单元测试和 py.test 输出之间的区别

也许你现在想知道,“就这些吗?”py.testunittest之间唯一的区别是一点颜色和略有不同的输出?远非如此,还有许多其他区别,但仅仅这一点就足以让你试一试。

unittest 和 py.test 测试之间的区别

改进的输出确实有所帮助,但改进的输出和更简单的编写测试的结合才是使py.test如此有用的原因。有许多方法可以使测试更简单、更易读,在许多情况下,您可以选择自己喜欢的方法。一如既往,可读性很重要,所以明智地选择,并尽量不要过度设计解决方案。

简化断言

unittest 库需要使用self.assertEqual来比较变量,而py.test使用一些魔法来允许使用常规的assert语句进行更简单的测试。

以下测试文件包含了两种测试样式,因此它们可以很容易地进行比较:

import cube
import pytest
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')

class TestPyCube(object):
    def test_0(self):
        assert cube.cube(0) == 0

    def test_1(self):
        assert cube.cube(1) == 1

    def test_2(self):
        assert cube.cube(2) == 8

    def test_3(self):
        assert cube.cube(3) == 27

    def test_no_arguments(self):
        with pytest.raises(TypeError):
            cube.cube()

    def test_exception_str(self):
        with pytest.raises(TypeError):
            cube.cube('x')

那么我们做了什么?嗯,我们只是用assert ... == ...替换了self.assertEqual,用with pytest.raises替换了with self.assertRaises。确实是一个小改进,但实际的好处在于失败的输出。前两个使用了unittest风格,后两个使用了py.test风格:

============================= FAILURES =============================
_________________________ TestCube.test_2 __________________________

self = <test_cube.TestCube testMethod=test_2>

 **def test_2(self):
>       self.assertEqual(cube.cube(2), 8)
E       AssertionError: 4 != 8

test_cube.py:14: AssertionError
_________________________ TestCube.test_3 __________________________

self = <test_cube.TestCube testMethod=test_3>

 **def test_3(self):
>       self.assertEqual(cube.cube(3), 27)
E       AssertionError: 9 != 27

test_cube.py:17: AssertionError
________________________ TestPyCube.test_2 _________________________

self = <test_cube.TestPyCube object at 0x107c7bef0>

 **def test_2(self):
>       assert cube.cube(2) == 8
E       assert 4 == 8
E        +  where 4 = <function cube at 0x107bb7c80>(2)
E        +    where <function cube at 0x107bb7c80> = cube.cube

test_cube.py:36: AssertionError
________________________ TestPyCube.test_3 _________________________

self = <test_cube.TestPyCube object at 0x107c56a90>

 **def test_3(self):
>       assert cube.cube(3) == 27
E       assert 9 == 27
E        +  where 9 = <function cube at 0x107bb7c80>(3)
E        +    where <function cube at 0x107bb7c80> = cube.cube

test_cube.py:39: AssertionError
================ 4 failed, 8 passed in 0.05 seconds ================

因此,除了看到被比较的值,我们实际上还可以看到调用的函数以及它接收的输入参数。对于我们这里的静态数字,可能并不那么有用,但在使用变量时,它是非常宝贵的,正如我们将在接下来的段落中看到的那样。

注意

前面的测试都存储在一个类中。在py.test中,这是完全可选的。如果可读性或继承使得将测试封装在一个类中变得有用,那么请随意这样做,但就py.test而言,没有任何优势。

标准的py.test行为适用于大多数测试用例,但对于一些自定义类型可能不够。例如,假设我们有一个Spam对象,它有一个应该与另一个对象上的count属性进行比较的count属性。通过在Spam上实现__eq__方法,可以轻松实现这一部分,但这并没有提高清晰度。由于我们比较的是count属性,如果测试显示错误时显示count将会很有用。首先是一个包含两个测试样式的类,一个工作正常,一个损坏以演示常规输出:

test_spam.py

class Spam(object):
    def __init__(self, count):
        self.count = count

    def __eq__(self, other):
        return self.count == other.count

def test_spam_equal_correct():
    a = Spam(5)
    b = Spam(5)

    assert a == b

def test_spam_equal_broken():
    a = Spam(5)
    b = Spam(10)

    assert a == b

这是常规的py.test输出:

============================= FAILURES =============================
______________________ test_spam_equal_broken ______________________

 **def test_spam_equal_broken():
 **a = Spam(5)
 **b = Spam(10)

>       assert a == b
E       assert <test_spam.Spam object at 0x105b484e0> == <test_spam.Spam object at 0x105b48518>

test_spam.py:20: AssertionError
================ 1 failed, 1 passed in 0.01 seconds ================

默认的测试输出仍然可用,因为函数相当简单,而count的值也是可见的,因为它在构造函数中可用。然而,如果我们能明确看到count的值会更有用。通过在conftest.py文件中添加pytest_assertrepr_compare函数,我们可以修改assert语句的行为。

注意

这是一个专门为py.test设计的特殊文件,可以用来覆盖或扩展py.test。请注意,该文件将自动加载该目录中的每个测试运行,因此我们需要测试操作符的左侧和右侧的类型。在这种情况下,它是ab

conftest.py

import test_spam

def pytest_assertrepr_compare(config, op, left, right):
    left_spam = isinstance(left, test_spam.Spam)
    right_spam = isinstance(right, test_spam.Spam)
    if left_spam and right_spam and op == '==':
        return [
            'Comparing Spam instances:',
            '    counts: %s != %s' % (left.count, right.count),
        ]

前面的函数将被用作我们的测试输出。所以当它失败时,这次我们会得到我们自己的,稍微更有用的输出:

============================= FAILURES =============================
______________________ test_spam_equal_broken ______________________

 **def test_spam_equal_broken():
 **a = Spam(5)
 **b = Spam(10)

>       assert a == b
E       assert Comparing Spam instances:
E             counts: 5 != 10

test_spam.py:20: AssertionError
================ 1 failed, 1 passed in 0.01 seconds ================

在这种情况下,我们也可以轻松地更改Spam__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 0x106576268>(2)
E        +    where <function cube at 0x106576268> = cube.cube

test_cube.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 0x106576268>(3)
E        +    where <function cube at 0x106576268> = cube.cube

test_cube.py:15: AssertionError
================ 2 failed, 2 passed in 0.02 seconds ================

通过参数化测试,我们可以清楚地看到参数,这意味着我们可以在没有任何额外努力的情况下看到所有的输入和输出。

在运行时动态生成测试列表也是可能的,可以使用一个全局函数。类似于我们之前添加到conftest.pypytest_assertrepr_compare函数,我们可以添加一个pytest_generate_tests函数,用于生成测试。

创建pytest_generate_tests函数只有在根据配置选项测试子集时才有用。然而,如果可能的话,我建议尝试使用装置来配置选择性测试,因为它们更加明确。像pytest_generate_tests这样的函数的问题在于它们是全局的,并且不能区分特定的测试,如果你没有预料到,会导致奇怪的行为。

使用装置自动参数

装置系统是py.test最神奇的功能之一。它会以与你的参数相同名称的装置函数来执行装置函数。因此,参数的命名变得非常重要,因为它们很容易与其他装置发生冲突。为了防止冲突,默认情况下将作用域设置为function。然而,classmodulesession也是作用域的有效选项。默认情况下,有几个装置可用,其中一些你经常会使用,而其他一些则很可能永远不会使用。可以使用以下命令生成完整的列表:

# py.test --quiet --fixtures
cache
 **Return a cache object that can persist state between testing sessions.

 **cache.get(key, default)
 **cache.set(key, value)

 **Keys must be a ``/`` separated value, where the first part is usually the
 **name of your plugin or application to avoid clashes with other cache users.

 **Values can be any object handled by the json stdlib module.
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.
capfd
 **enables capturing of writes to file descriptors 1 and 2 and makes
 **captured output available via ``capfd.readouterr()`` method calls
 **which return a ``(out, err)`` tuple.
record_xml_property
 **Fixture that adds extra xml properties to the tag for the calling test.
 **The fixture is callable with (name, value), with value being automatically
 **xml-encoded.
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.
pytestconfig
 **the pytest config object with access to command line opts.
recwarn
 **Return a WarningsRecorder instance that provides these methods:

 *** ``pop(category=None)``: return last warning matching the category.
 *** ``clear()``: clear list of warnings

 **See http://docs.python.org/library/warnings.html for information
 **on warning categories.
tmpdir_factory
 **Return a TempdirFactory instance for the test session.
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.

标准的装置都有相当详细的文档,但是一些例子从来没有伤害过。下面的段落演示了装置的使用。

缓存

缓存装置就像它一样简单又有用;有一个get函数和一个set函数,并且在会话之间保持不变。例如,这个测试将允许五次执行,并在此之后每次都会引发错误。虽然这不是最有用和精心设计的例子,但它确实展示了cache函数的工作原理:

def test_cache(cache):
    counter = cache.get('counter', 0)
    assert counter < 5
    cache.set('counter', counter + 1)

注意

cache.get函数需要默认值(在这种情况下是0)。

可以通过--cache-clear命令行参数清除缓存,并且可以通过--cache-show显示所有缓存。

自定义装置

捆绑的装置非常有用,但在大多数项目中,你需要创建自己的装置来简化事情。装置使得重复需要的代码变得微不足道。你很可能想知道这与常规函数、上下文包装器或其他东西有何不同,但装置的特殊之处在于它们本身也可以接受装置。因此,如果你的函数需要pytestconfig变量,它可以在不需要修改调用函数的情况下请求它。

fixture 的使用情况强烈依赖于项目,因此很难生成一个普遍有用的例子,但理论上是可行的。基本前提是相当简单的:一个带有pytest.fixture装饰器的函数,返回一个将作为参数传递的值。此外,该函数可以像任何测试一样接受参数和 fixture。唯一值得注意的变化是pytest.yield_fixture。这个 fixture 变体有一个小的不同之处;实际测试将在yield时执行(多个yield会导致错误),并且在函数之前/之后的代码作为设置/拆卸代码。具有yield_fixturefixture的最基本示例看起来像这样:

import pytest

@pytest.yield_fixture
def some_yield_fixture():
    # Before the function
    yield 'some_value_to_pass_as_parameter'
    # After the function

@pytest.fixture
def some_regular_fixture():
    # Do something here
    return 'some_value_to_pass_as_parameter'

这些 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)')

自然地,我们可以使用不同的数据库名称(或多个)来代替在sqlite3中使用:memory:数据库。

打印语句和日志

尽管打印语句通常不是调试代码的最佳方式,但我承认这仍然是我的默认调试方法。这意味着在运行和尝试测试时,我会包含许多打印语句。然而,让我们看看当我们尝试在py.test中使用这个时会发生什么。这是测试代码:

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')

以下是实际输出:

# py.test test_print.py -v
======================= test session starts ========================
platform darwin -- Python 3.5.1, pytest-2.8.5, py-1.4.31, pluggy-0.3.1
cachedir: ../.cache
rootdir: code, inifile: pytest.ini
collected 1 items

test_print.py .

===================== 1 passed in 0.01 seconds =====================

那么,我们所有的打印语句和日志都被丢弃了?嗯,并不完全是这样。在这种情况下,py.test假设这对您来说并不重要,因此忽略了输出。但是同样的测试出现错误会怎么样呢?

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')
    assert False, 'Dying because we can'

那么带有错误的输出呢?

============================= 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')
>       assert False, 'Dying because we can'
E       AssertionError: Dying because we can
E       assert False

test_print.py:12: AssertionError
------------------------ Captured stdout call ------------------------
Printing to stdout
------------------------ Captured stderr call ------------------------
Printing to stderr
WARNING:root:Printing to warning
ERROR:root:Printing to error
===================== 1 failed in 0.01 seconds =====================

哇!你看到了吗?stdoutstderr和带有WARNING或更高级别的日志现在都有输出了。DEBUGINFO仍然不可见,但我们稍后在本章的日志部分将会更多地了解到。

插件

py.test最强大的功能之一是插件系统。在py.test中,几乎可以使用可用的钩子修改几乎所有内容,其结果是编写插件几乎是简单的。实际上,您在之前的段落中已经写了一些插件,而没有意识到。通过将conftest.py打包在不同的包或目录中,它就成为了一个py.test插件。我们将在第十五章中更多地解释打包,打包-创建自己的库或应用程序。通常情况下,不需要编写自己的插件,因为您寻找的插件很可能已经可用。可以在py.test网站的pytest.org/latest/plugins.html上找到一小部分插件列表,也可以在 Python 包索引的pypi.python.org/pypi?%3Aaction=search&term=pytest-上找到更多插件列表。

默认情况下,py.test确实涵盖了相当多的理想功能,因此您可以轻松地不使用插件,但在我自己编写的包中,我通常默认使用以下列表:

  • pytest-cov

  • pytest-pep8

  • pytest-flakes

通过使用这些插件,可以更轻松地维护项目的代码质量。为了理解原因,我们将在以下段落中更仔细地看看这些包。

pytest-cov

使用pytest-cov包,您可以查看代码是否被测试覆盖。在内部,它使用coverage包来检测有多少代码被测试。为了演示原理,我们将检查cube_root函数的覆盖范围。

注意

确保您已安装了pytest-cov

pip install pytest-cov

首先,让我们创建一个.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 from 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

    # This code will probably never run so don't complain about that
    if 0:
    if __name__ == .__main__.:
    @abc.abstractmethod

[run]
# Make sure we require that all branches of the code is covered. So
# both the if and the else
branch = True

# No need to test the testing code
omit =
    test_*.py

这是cube_root.py代码:

def cube_root(n):
    '''
    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')

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

现在让我们看看当我们使用--cov-report=html参数运行时会发生什么:

# py.test test_cube_root.py --cov-report=html --cov-report=term-missing --cov=cube_root.py
======================= test session starts ========================
platform darwin -- Python 3.5.1, pytest-2.8.5, py-1.4.31, pluggy-0.3.1
rootdir: code, inifile: pytest.ini
plugins: cov-2.2.0
collected 4 items

test_cube_root.py ....
--------- coverage: platform darwin, python 3.5.1-final-0 ----------
Name           Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------
cube_root.py       4      1      2      1    67%   14, 11->14
Coverage HTML written to dir htmlcov
Traceback (most recent call last):
...
pytest_cov.plugin.CoverageError: Required test coverage of 100% not reached. Total coverage: 66.67%

这里发生了什么?看起来我们忘记测试代码的某些部分:第14行和从第11行到第14行的分支。这个输出并不那么易读,这就是为什么我们还指定了 HTML 输出:

pytest-cov

太好了!现在我们知道了。我们忘记测试小于0的值。

黄线表示只执行了分支的一部分((n >= 0) == True),而没有执行另一部分((n >= 0) == False),这发生在if语句、循环和其他至少有一个分支未覆盖的情况下。例如,如果对空数组进行循环是不可能的情况,那么测试可以部分跳过:

#  pragma: no branch

但是既然我们知道问题,也就是缺少对ValueError的测试,让我们添加测试用例:

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

def test_cube_root_below_zero():
    with pytest.raises(ValueError):
        cube_root.cube_root(-1)

然后我们再次运行测试:

# py.test test_cube_root.py --cov-report=html --cov-report=term-missing --cov=cube_root.py
======================= test session starts ========================
platform darwin -- Python 3.5.1, pytest-2.8.5, py-1.4.31, pluggy-0.3.1
rootdir: code, inifile: pytest.ini
plugins: cov-2.2.0
collected 5 items

test_cube_root.py .....
---------- coverage: platform darwin, python 3.5.1-final-0 -----------
Name           Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------
cube_root.py       4      0      2      0   100%
Coverage HTML written to dir htmlcov

===================== 5 passed in 0.03 seconds =====================

太好了!100%的覆盖率没有问题,HTML 输出也正是我们所期望的:

pytest-cov

但是如果代码稍有不同呢?如果不是为小于0的值引发ValueError,而是引发NotImplementedError呢?

def cube_root(n):
    '''
    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 NotImplementedError(
            'A number larger than 0 was expected')

并且也删除额外的测试:

import cube_root
import pytest

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 test_cube_root.py --cov-report=html --cov-report=term-missing --cov=cube_root.py
======================= test session starts ========================
platform darwin -- Python 3.5.1, pytest-2.8.5, py-1.4.31, pluggy-0.3.1
rootdir: code, inifile: pytest.ini
plugins: cov-2.2.0
collected 4 items

test_cube_root.py ....
---------- coverage: platform darwin, python 3.5.1-final-0 -----------
Name           Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------
cube_root.py       3      0      0      0   100%
Coverage HTML written to dir htmlcov

===================== 4 passed in 0.03 seconds =====================

您可能会想知道为什么现在我们获得了 100%的测试覆盖率,尽管我们实际上并没有覆盖NotImplementedError。这是因为我们在.coveragerc文件中将raise NotImplementedError添加到了忽略列表中。这也使我们在 HTML 输出中得到了不同的结果:

pytest-cov

即使在测试文件中添加了NotImplementedError的测试,覆盖报告仍将忽略该行。

pytest-pep8 和 pytest-flakes

Pyflakes 和 pep8 是非常有用的代码质量测试工具,可以使您的代码可读且符合 pep8。pytest-pep8pytest-flakes模块在运行实际测试之前会自动执行这些检查。要安装它们,只需执行这行:

# pip install pytest-flakes pytest-pep8

之后,您可以像这样运行它们:


# py.test --flakes --pep8 cube_root.py
======================= test session starts ========================
platform darwin -- Python 3.5.1, pytest-2.8.5, py-1.4.31, pluggy-0.3.1
rootdir: code, inifile: pytest.ini
plugins: cov-2.2.0, flakes-1.0.1, pep8-1.0.6
collected 2 items

cube_root.py ..

===================== 2 passed in 0.01 seconds =====================

配置插件

为了确保所有插件都被执行并进行配置,只需将设置添加到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
    --pep8
    --flakes

# W391 is the error about blank lines at the end of a file
pep8ignore =
    *.py W391

# Ignore unused imports
flakes-ignore =
    *.py UnusedImport

提示

在调试中找出测试失败的原因时,简单地查看第一个失败的测试可能很有用。py.test模块提供了-x标志,在第一个失败后停止,以及--maxfail=nn次失败后停止。

模拟对象

在编写测试时,经常会发生这种情况:您不仅在测试自己的代码,还在测试与外部资源的交互,如硬件、数据库、网络主机、服务器等。其中一些可以安全运行,但某些测试太慢、太危险,甚至无法运行。在这些情况下,模拟对象是您的朋友;它们可以用来伪造任何东西,因此您可以确信您的代码仍然返回预期的结果,而不会受到外部因素的任何变化的影响。

使用 unittest.mock

unittest.mock库提供了两个基本对象,MockMagicMock,可以轻松模拟任何外部资源。Mock对象只是一个通用的通用模拟对象,MagicMock大致相同,但它具有所有魔术方法,如__contains____len__。除此之外,它还可以让你的生活更轻松。这是因为除了手动创建模拟对象之外,还可以使用patch装饰器/上下文管理器直接修补对象。

以下函数使用random返回TrueFalse,由某种概率分布控制。由于这样的函数的随机性质,测试起来非常困难,但使用unittest.mock就不是了。使用unittest.mock,可以获得可重复的结果:

from unittest import mock
import random

def bernoulli(p):
    return random.random() > p

@mock.patch('random.random')
def test_bernoulli(mock_random):
    # Test for random value of 0.1
    mock_random.return_value = 0.1
    assert bernoulli(0.0)
    assert not bernoulli(0.1)
    assert mock_random.call_count == 2

很棒,不是吗?在不修改原始代码的情况下,我们可以确保random.random现在返回0.1而不是随机数。为了完整起见,这里提供了使用上下文管理器的版本:

from unittest import mock
import random

def bernoulli(p):
    return random.random() > p

def test_bernoulli():
    with mock.patch('random.random') as mock_random:
        mock_random.return_value = 0.1
        assert bernoulli(0.0)
        assert not bernoulli(0.1)
        assert mock_random.call_count == 2

使用模拟对象的可能性几乎是无穷无尽的。它们从在访问时引发异常到伪造整个 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参数告诉模拟按照顺序返回这些值,确保对os.path.exists的第一次调用返回True,而其他两次返回False。没有参数的mock.patch简单地返回一个什么都不做的可调用对象。

使用 py.test monkeypatch

py.test中的monkeypatch对象是一个允许模拟的 fixture。虽然在看到unittest.mock的可能性后,它可能看起来毫无用处,但总的来说,它并不是。一些功能确实重叠,但unittest.mock专注于控制和记录对象的操作,而monkeypatch fixture 专注于简单和临时的环境更改。以下是一些示例:

  • 使用monkeypatch.setattrmonkeypatch.delattr设置和删除属性

  • 使用monkeypatch.setitemmonkeypatch.delitem设置和删除字典项

  • 使用monkeypatch.setenvmonkeypatch.delenv设置和删除环境变量

  • 在所有其他路径之前插入额外的路径到sys.path使用monkeypatch.syspath_prepend

  • 使用monkeypatch.chdir更改目录

要撤消所有修改,只需使用monkeypatch.undo

例如,假设对于某个测试,我们需要从不同的目录中工作。使用模拟,你的选项将是模拟几乎所有文件函数,包括os.path函数,即使在这种情况下,你可能会忘记一些。因此,在这种情况下,它绝对没有用。另一个选择是将整个测试放入try…finally块中,并在测试代码之前和之后执行os.chdir。这是一个相当好的安全解决方案,但需要额外的工作,因此让我们比较这两种方法:

import os

def test_chdir_monkeypatch(monkeypatch):
    monkeypatch.chdir('/dev')
    assert os.getcwd() == '/dev'
    monkeypatch.chdir('/')
    assert os.getcwd() == '/'

def test_chdir():
    original_directory = os.getcwd()
    try:
        os.chdir('/dev')
        assert os.getcwd() == '/dev'
        os.chdir('/')
        assert os.getcwd() == '/'
    finally:
        os.chdir(original_directory)

它们实际上是一样的,但一个需要四行代码,而另一个需要八行。当然,所有这些都可以很容易地通过几行额外的代码来解决,但代码越简单,你就会犯的错误就越少,可读性就越强。

日志

Python 日志模块是那些非常有用但很难正确使用的模块之一。结果通常是人们完全禁用日志记录并使用打印语句代替。这很有见地,但浪费了 Python 中非常庞大的日志系统。如果你以前写过 Java 代码,你可能熟悉 Log4j Java 库。Python 日志模块在很大程度上主要基于该库。

日志模块的最重要对象如下:

  • Logger:实际的日志接口

  • Handler:处理日志语句并输出它们

  • Formatter:将输入数据格式化为字符串

  • Filter:允许过滤某些消息

在这些对象中,你可以将日志级别设置为默认级别之一:

  • CRITICAL: 50

  • ERROR: 40

  • WARNING: 30

  • INFO: 20

  • DEBUG: 10

  • NOTSET: 0

这些数字是这些日志级别的数值。虽然你通常可以忽略它们,但在设置最低级别时顺序显然很重要。此外,在定义自定义级别时,如果它们具有相同的数值,你将不得不覆盖现有级别。

配置

有几种配置日志系统的方法,从纯代码到 JSON 文件甚至远程配置。后面将在本章中讨论的日志模块的部分将使用这些示例,但这里只关注配置系统的使用。如果您对日志模块的内部工作不感兴趣,那么您应该能够通过日志部分的这一段来完成。

基本日志配置

最基本的日志配置当然是没有配置,但这不会给您带来太多有用的输出:

import logging

logging.debug('debug')
logging.info('info')
logging.warning('warning')
logging.error('error')
logging.critical('critical')

使用默认日志级别,您只会看到警告和更高级别的日志:

# python log.py
WARNING:root:warning
ERROR:root:error
CRITICAL:root:critical

配置的快速简单开始是basicConfig。如果您只需要为您编写的脚本进行一些快速记录,而不是用于完整的应用程序,我建议使用这个。虽然您可以配置几乎任何您希望的东西,但一旦您获得更复杂的设置,通常会有更方便的选项。我们将在后面的段落中更多地讨论这一点,但首先,我们有一个basicConfig,它配置我们的记录器以显示一些更多的信息,包括记录器名称:

import logging

log_format = (
    '[%(asctime)s] %(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')

这将在我们的屏幕上给出以下输出:

# python log.py
[2015-12-02 15:56:19,449] WARNING  some         warning
[2015-12-02 15:56:19,449] ERROR    some         error
[2015-12-02 15:56:19,449] CRITICAL some.other   critical

这是debug.log文件中的输出:

[2015-12-02 15:56:19,449] DEBUG    root         debug
[2015-12-02 15:56:19,449] INFO     root         info
[2015-12-02 15:56:19,449] WARNING  some         warning
[2015-12-02 15:56:19,449] ERROR    some         error
[2015-12-02 15:56:19,449] CRITICAL some.other   critical

这个配置显示了如何使用单独的配置、日志级别以及(如果您选择)格式化来配置日志输出。但它往往变得难以阅读,这就是为什么通常最好只使用basicConfig来进行简单的配置,而不涉及多个处理程序。

字典配置

dictconfig使得可以命名所有部分,以便它们可以轻松地被重用,例如,多个记录器和处理程序的单个格式化程序。因此,让我们使用dictconfig重新编写我们之前的配置:

from logging import config

config.dictConfig({
    'version': 1,
    'formatters': {
        'standard': {
            'format': '[%(asctime)s] %(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',
        },
    },
})

字典配置的好处是非常容易扩展和/或覆盖日志配置。例如,如果您想要更改所有日志的格式化程序,您可以简单地更改standard格式化程序,甚至循环使用handlers

JSON 配置

由于dictconfig接受任何类型的字典,因此实际上可以很容易地实现使用 JSON 或 YAML 文件的不同类型的读取器。这是特别有用的,因为它们对非 Python 程序员来说更加友好。与 Python 文件相比,它们易于从 Python 之外进行读取和写入。

假设我们有一个log_config.json文件,如下所示:

{
    "version": 1,
    "formatters": {
        "standard": {
            "format": "[%(asctime)s] %(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('log_config.json') as fh:
    config.dictConfig(json.load(fh))

Ini 文件配置

文件配置可能是非程序员最可读的格式。它使用ini-style配置格式,并在内部使用configparser模块。缺点是它可能有点冗长,但它足够清晰,并且使得可以轻松地组合几个配置文件,而无需过多担心覆盖其他配置。话虽如此,如果dictConfig是一个选项,那么它很可能是一个更好的选项。这是因为fileConfig有时稍微有限且笨拙。只需看处理程序作为一个例子:

[formatters]
keys=standard

[handlers]
keys=file,stream

[loggers]
keys=root

[formatter_standard]
format=[%(asctime)s] %(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('log_config.ini')

然而,需要注意的一点是,如果您仔细观察,您会发现这个配置与其他配置略有不同。使用fileConfig时,您不能仅仅使用关键字参数。对于FileHandlerStreamHandler,都需要args

网络配置

网络配置既非常方便,又有点危险,因为它允许您在应用程序/脚本仍在运行时即时配置记录器。危险的部分是配置部分地使用了eval函数,这允许人们潜在地在您的应用程序中远程执行代码。即使logging.config.listen只监听本地连接,如果您在共享/不安全的主机上执行代码,它仍然可能是危险的。

幸运的是,自 Python 3.4 版本以来,可以添加一个verify参数,这是一个将被执行以将输入转换为输出的函数。默认值显然是类似于lambda config: config的东西,但可以配置为返回几乎任何内容。

为了通过示例证明这一点,我们需要两个脚本。一个脚本将不断向记录器打印一些消息,另一个将更改日志配置。我们将从之前的相同测试代码开始,但保持它在无限循环中运行,并在其中间使用sleep

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
    logging.config.stopListening()
    listener.join()

现在来发送配置文件的代码:

import struct
import socket
from logging import config

with open('log_config.ini') 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()

接下来,让我们看看输出。在循环的第一次执行之后,我们将执行第二个脚本来读取日志配置:

# python log_networkconfig.py
WARNING:some:warning
ERROR:some:error
CRITICAL:some.other:critical

您可能想知道其余输出在哪里。没有。debug.log文件已经填满了这样的消息:

[2015-12-03 12:32:38,894] DEBUG    root         debug
[2015-12-03 12:32:38,894] INFO     root         info

那么发生了什么?这就是我们在使用记录器后自定义记录器和配置的陷阱所在。logging.config.listen函数将根记录器按要求修改,但由于其他记录器(somesome.other)没有指定,它们没有被修改。我们修改配置以包括它们,如下所示:

[formatters]
keys=standard

[handlers]
keys=file,stream

[loggers]
keys=root,some

[formatter_standard]
format=[%(asctime)s] %(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

[logger_some]
level=DEBUG
qualname=some
handlers=

现在它按预期工作:

# python log_networkconfig.py
WARNING:some:warning
ERROR:some:error
CRITICAL:some.other:critical
[2015-12-03 12:42:05,621] WARNING  some         warning
[2015-12-03 12:42:05,622] ERROR    some         error
[2015-12-03 12:42:05,622] CRITICAL some.other   critical

您可能会注意到我们没有向some记录器添加任何处理程序。那是因为处理程序已经存在-在根级别。但是,如果没有手动告诉日志模块记录器在那里,它就不会再将其发送到处理程序。这通常不是问题,但在运行时修改日志配置时,这是一个危险的陷阱。

另一种配置它的方法是完全禁用传播,但这将创建一个全新的记录器,并且会忘记添加到根记录器的任何配置。因此,如果您在根记录器上有一个处理错误级别的处理程序,将其发送到错误报告系统,那么它将不再到达。然而,在这种情况下,配置略微更清晰:

[logger_some]
handlers=file,stream
level=DEBUG
qualname=some
propagate=0

记录器

您将一直使用logging模块的主要对象是Logger对象。此对象包含您需要执行实际记录的所有 API。大多数都很简单,但有些需要注意。

首先,记录器默认继承父设置。正如我们之前在传播设置中看到的那样,默认情况下,所有设置都将从父级传播。当在文件中合并记录器时,这非常有用。假设您的模块使用合理的名称和导入路径,我建议您使用以下样式命名您的记录器:

import logging

logger = logging.getLogger(__name__)

class Spam(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模块只是调用根记录器上的函数。它有一些非常有用的属性,尽管大多数在库中没有记录:

  • 传播:是否将事件传递给此记录器或父记录器的处理程序。如果没有这个,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)

您可能会想为什么我们不简单地使用%string.format进行常规字符串格式化。原因是当使用参数而不是预格式化的字符串时,处理程序会将它们作为参数。结果是您可以按原始字符串对日志消息进行分组,这就是诸如 sentry(github.com/getsentry/sentry)等工具使用的方法。

然而,还有更多内容。在参数方面,*args仅用于字符串格式化,但可以使用extra关键字参数向日志对象添加额外参数:

import logging

logger = logging.getLogger()
logger.error('simple error', extra=dict(spam='some spam'))

这些extra参数可以在日志格式化程序中使用,以显示额外信息,就像标准格式化选项一样:

import logging

logging.basicConfig(format='%(spam)s: %(message)s')
logger = logging.getLogger()
logger.error('the message', extra=dict(spam='some spam'))

这导致以下结果:

# python test_spam.py
some spam: the message

然而,最有用的功能之一是对异常的支持:

import logging

logger = logging.getLogger()

try:
    raise RuntimeError('Not enough spam')
except:
    logger.exception('Got an exception')

logger.error('And an error')

这会导致异常的堆栈跟踪,但不会终止代码:

# python test_spam.py
Got an exception
Traceback (most recent call last):
 **File "test_spam.py", line 6, in <module>
 **raise RuntimeError('Not enough spam')
RuntimeError: Not enough spam
And an error

总结

本章向我们展示了如何编写doctests,利用py.test提供的快捷方式,并使用logging模块。在测试中,从来没有一种适合所有情况的解决方案。虽然doctest系统在许多情况下非常有用,可以同时提供文档和测试,但在许多函数中,有一些边缘情况对于文档来说并不重要,但仍然需要进行测试。这就是常规单元测试和py.test发挥作用的地方。

由于py.test库一直在不断发展,本章无法完全涵盖您所需的一切,但它应该为您提供了足够的基础,能够有效地使用它,并在需要时进行扩展。

日志记录模块非常有用,但如果配置不正确,也会很麻烦。不幸的是,当多个模块同时尝试配置日志记录时,正确的配置可能有点难以理解。日志系统的使用现在应该对大多数常见用例足够清晰了,只要您保持propagate参数的检查,实现日志系统时应该没问题。

接下来是调试,测试有助于防止错误。我们将看到如何有效地解决它们。此外,本章中添加的日志记录将在这方面帮助很多。

第十一章:调试-解决错误

上一章向您展示了如何向代码添加日志记录和测试,但无论您有多少测试,您总会有 bug。最大的问题始终是用户输入,因为不可能测试所有可能的输入,这意味着在某个时候,我们将需要调试代码。

有许多调试技术,而且很可能您已经使用了其中一些。在本章中,我们将专注于打印/跟踪调试和交互式调试。

使用打印语句、堆栈跟踪和日志记录进行调试是最通用的方法之一,很可能是您使用过的第一种调试方法。即使print 'Hello world'也可以被视为这种类型,因为输出将向您显示代码正在正确执行。显然没有必要解释如何以及在何处放置打印语句来调试代码,但使用装饰器和其他 Python 模块有一些很好的技巧,使得这种类型的调试更加有用,比如faulthandler

交互式调试是一种更复杂的调试方法。它允许您在程序运行时调试程序。使用这种方法,甚至可以在应用程序运行时更改变量并在任何所需的地方暂停应用程序。缺点是它需要一些关于调试器命令的知识才能真正有用。

总之,我们将涵盖以下主题:

  • 使用printtraceloggingfaulthandler进行调试

  • 使用pdb进行交互式调试

非交互式调试

最基本的调试形式是在代码中添加简单的打印语句,以查看仍在工作和不在工作的内容。这在各种情况下都很有用,并且可能有助于解决大部分问题。在本章后面,我们将展示一些交互式调试方法,但这些方法并不总是适用。在多线程环境中,交互式调试往往变得困难甚至不可能,而在封闭的远程服务器上,您可能也需要不同的解决方案。这两种方法都有其优点,但我个人 90%的时间都选择非交互式调试,因为简单的打印/日志语句通常足以分析问题的原因。

这是一个基本示例(我已经知道做类似的事情)使用生成器可以如下所示:

>>> def spam_generator():
...     print('a')
...     yield 'spam'
...     print('b')
...     yield 'spam!'
...     print('c')
...     yield 'SPAM!'
...     print('d')

>>> generator = spam_generator()

>>> next(generator)
a
'spam'

>>> next(generator)
b
'spam!'

这清楚地显示了代码的执行情况,因此也清楚地显示了代码未执行的情况。如果没有这个例子,您可能会期望在spam_generator()调用之后立即出现第一个打印,因为它是一个生成器。然而,执行完全停滞,直到我们yield一个项目。假设在第一个yield之前有一些设置代码,它将不会在实际调用next之前运行。

虽然这是使用打印语句调试函数的最简单方法之一,但绝对不是最佳方法。我们可以从制作一个自动打印函数开始,该函数会自动递增字母:

>>> import string

>>> def print_character():
...     i = 0
...     while True:
...         print('Letter: %r' % string.ascii_letters[i])
...         i = (i + 1) % len(string.ascii_letters)
...         yield
>>> # Always initialize
>>> print_character = print_character()

>>> next(print_character)
Letter: 'a'
>>> next(print_character)
Letter: 'b'
>>> next(print_character)
Letter: 'c'

虽然打印语句生成器比裸打印语句稍好一些,但帮助并不是很大。在运行代码时,看到实际执行了哪些行将更有用。我们可以使用inspect.currentframe手动执行此操作,但没有必要进行黑客攻击。Python 为您提供了一些专用工具。

使用跟踪检查脚本

简单的打印语句在许多情况下都很有用,因为您几乎可以在几乎每个应用程序中轻松地加入打印语句。无论是远程还是本地,使用线程还是使用多进程,都没有关系。它几乎可以在任何地方工作,使其成为最通用的解决方案,除了日志记录之外。然而,通用解决方案通常不是最佳解决方案。对于最常见的情况,有更好的解决方案可用。其中之一是trace模块。它为您提供了一种跟踪每次执行、函数之间关系以及其他一些内容的方法。

为了演示,我们将使用我们之前的代码,但不包括打印语句:

def eggs_generator():
    yield 'eggs'
    yield 'EGGS!'

def spam_generator():
    yield 'spam'
    yield 'spam!'
    yield 'SPAM!'

generator = spam_generator()
print(next(generator))
print(next(generator))

generator = eggs_generator()
print(next(generator))

我们将使用 trace 模块执行它:

# python3 -m trace --trace --timing tracing.py
 **--- modulename: tracing, funcname: <module>
0.00 tracing.py(1): def eggs_generator():
0.00 tracing.py(6): def spam_generator():
0.00 tracing.py(11): generator = spam_generator()
0.00 tracing.py(12): print(next(generator))
 **--- modulename: tracing, funcname: spam_generator
0.00 tracing.py(7):     yield 'spam'
spam
0.00 tracing.py(13): print(next(generator))
 **--- modulename: tracing, funcname: spam_generator
0.00 tracing.py(8):     yield 'spam!'
spam!
0.00 tracing.py(15): generator = eggs_generator()
 **--- modulename: tracing, funcname: spam_generator
0.00 tracing.py(16): print(next(generator))
 **--- modulename: tracing, funcname: eggs_generator
0.00 tracing.py(2):     yield 'eggs'
eggs
 **--- modulename: trace, funcname: _unsettrace
0.00 trace.py(77):         sys.settrace(None)

相当不错,不是吗?它准确地显示了正在执行的每一行代码以及函数名称,更重要的是,显示了每一行代码是由哪个语句(或多个语句)引起的。此外,它还显示了它相对于程序开始时间的执行时间。这是由于--timing标志。

你可能期望,这个输出有点太啰嗦了,不能普遍适用。尽管你可以选择使用命令行参数来忽略特定的模块和目录,但在许多情况下仍然太啰嗦了。所以让我们来尝试下一个解决方案——上下文管理器。前面的输出已经揭示了一些trace的内部情况。最后一行显示了一个sys.settrace调用,这正是我们需要的手动跟踪:

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 eggs_generator():
    yield 'eggs'
    yield 'EGGS!'

def spam_generator():
    yield 'spam'
    yield 'spam!'
    yield 'SPAM!'

with trace():
    generator = spam_generator()
    print(next(generator))
    print(next(generator))

generator = eggs_generator()
print(next(generator))

当作为常规 Python 文件执行时,返回:

# python3 tracing.py
 **--- modulename: tracing, funcname: spam_generator
0.00 tracing.py(24):     yield 'spam'
spam
 **--- modulename: tracing, funcname: spam_generator
0.00 tracing.py(25):     yield 'spam!'
spam!
 **--- modulename: contextlib, funcname: __exit__
0.00 contextlib.py(64):         if type is None:
0.00 contextlib.py(65):             try:
0.00 contextlib.py(66):                 next(self.gen)
 **--- modulename: tracing, funcname: trace
0.00 tracing.py(12):     sys.settrace(None)

这段代码立即揭示了跟踪代码的内部操作:它使用sys.settrace告诉 Python 解释器在执行每个语句时将其发送到哪里。鉴于此,将函数编写为装饰器显然是微不足道的,但如果你需要的话,我会把它留给你作为一个练习。

从中还可以得到的另一个收获是,你可以通过包装tracer.globaltrace轻松地向你的跟踪函数添加额外的过滤器。该函数接受以下参数(来自标准 Python 文档):

参数 描述
Call 调用函数(或进入某些其他代码块)。调用全局跟踪函数;argNone。返回值指定了本地跟踪函数。
Line 解释器即将执行新的一行代码或重新执行循环的条件。调用本地跟踪函数;argNone。返回值指定了新的本地跟踪函数。有关其工作原理的详细解释,请参阅Objects/lnotab_notes.txt
return 一个函数(或其他代码块)即将返回。调用本地跟踪函数;arg是将要返回的值,或者如果事件是由引发异常引起的,则为None。跟踪函数的返回值被忽略。
exception 这意味着发生了异常。调用本地跟踪函数;arg是一个元组(exceptionvaluetraceback)。返回值指定了新的本地跟踪函数。
c_call 即将调用一个 C 函数。这可能是一个扩展函数或内置函数。arg是 C 函数对象。
c_return 一个 C 函数已经返回,arg是 C 函数对象。
c_exception 一个 C 函数引发了异常,arg是 C 函数对象。

正如你所期望的那样,通过一个简单的过滤函数,你可以轻松地确保只返回特定的函数,而不是通常会得到的长列表。你真的不应该低估使用几个导入来跟踪代码生成的数据量。前面的上下文管理器代码产生了 300 多行输出。

使用日志进行调试

在第十章中,测试和日志 - 为错误做准备,我们看到了如何创建自定义记录器,为它们设置级别,并为特定级别添加处理程序。我们将使用logging.DEBUG级别进行日志记录,这本身并不特别,但通过一些装饰器,我们可以添加一些非常有用的仅用于调试的代码。

每当我调试时,我总是发现了解函数的输入和输出非常有用。使用装饰器的基本版本足够简单;只需打印argskwargs,就完成了。以下示例稍微深入一些。通过使用inspect模块,我们还可以检索默认参数,从而可以在所有情况下显示所有参数及其参数名和值,即使未指定参数也可以。

import pprint
import inspect
import logging
import functools

logging.basicConfig(level=logging.DEBUG)

def debug(function):
    @functools.wraps(function)
    def _debug(*args, **kwargs):
        try:
            result = function(*args, **kwargs)
        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 spam(a, b=123):
    return 'some spam'

spam(1)
spam(1, 456)
spam(b=1, a=456)

返回以下输出:

# python3 logged.py
DEBUG:root:spam(a=1, b=123): 'some spam'
DEBUG:root:spam(a=1, b=456): 'some spam'
DEBUG:root:spam(a=456, b=1): 'some spam'

当然非常好,因为我们清楚地知道函数何时被调用,使用了哪些参数,以及返回了什么。但是,这可能只有在您积极调试代码时才会执行。您还可以通过添加特定于调试的记录器使代码中的常规logging.debug语句更加有用,该记录器显示更多信息。只需用前面示例的日志配置替换此示例:

import logging

log_format = (
    '[%(relativeCreated)d %(levelname)s] '
    '%(pathname)s:%(lineno)d:%(funcName)s: %(message)s'
)
logging.basicConfig(level=logging.DEBUG, format=log_format)

那么你的结果会是这样的:

# time python3 logged.py
[0 DEBUG] logged.py:31:_debug: spam(a=1, b=123): 'some spam'
[0 DEBUG] logged.py:31:_debug: spam(a=1, b=456): 'some spam'
[0 DEBUG] logged.py:31:_debug: spam(a=456, b=1): 'some spam'
python3 logged.py  0.04s user 0.01s system 96% cpu 0.048 total

它显示相对于应用程序启动的时间(毫秒)和日志级别。然后是一个标识块,显示产生日志的文件名、行号和函数名。当然,最后还有一条消息。

显示无异常的调用堆栈

在查看代码的运行方式和原因时,通常有必要查看整个堆栈跟踪。当然,简单地引发异常是一个选择。但是,那将终止当前的代码执行,这通常不是我们要寻找的。这就是traceback模块派上用场的地方。只需几行简单的代码,我们就可以得到完整的(或有限的,如果您愿意的话)堆栈列表:

import traceback

class Spam(object):

    def run(self):
        print('Before stack print')
        traceback.print_stack()
        print('After stack print')

class Eggs(Spam):
    pass

if __name__ == '__main__':
    eggs = Eggs()
    eggs.run()

这导致以下结果:

# python3 traceback_test.py
Before stack print
 **File "traceback_test.py", line 18, in <module>
 **eggs.run()
 **File "traceback_test.py", line 8, in run
 **traceback.print_stack()
After stack print

如您所见,回溯只是简单地打印而没有任何异常。traceback模块实际上有很多其他方法,用于基于异常等打印回溯,但您可能不经常需要它们。最有用的可能是limit参数;此参数允许您将堆栈跟踪限制为有用的部分。例如,如果您使用装饰器或辅助函数添加了此代码,则可能不需要在堆栈跟踪中包含它们。这就是limit参数的作用所在:

import traceback

class Spam(object):

    def run(self):
        print('Before stack print')
        traceback.print_stack(limit=-1)
        print('After stack print')

class Eggs(Spam):
    pass

if __name__ == '__main__':
    eggs = Eggs()
    eggs.run()

这导致以下结果:

# python3 traceback_test.py
Before stack print
 **File "traceback_test.py", line 18, in <module>
 **eggs.run()
After stack print

如您所见,print_stack函数本身现在已从堆栈跟踪中隐藏,这使得一切都变得更加清晰。

注意

在 Python 3.5 中添加了负限制支持。在那之前,只支持正限制。

调试 asyncio

asyncio模块有一些特殊规定,使得调试变得更容易一些。鉴于asyncio内部函数的异步特性,这是一个非常受欢迎的功能。在调试多线程/多进程函数或类时可能会很困难——因为并发类可以轻松并行更改环境变量——而使用asyncio,情况可能会更加困难。

注意

在大多数 Linux/Unix/Mac shell 会话中,可以使用它作为前缀设置环境变量:

SOME_ENVIRONMENT_VARIABLE=value python3 script.py

此外,可以使用export为当前 shell 会话进行配置:

export SOME_ENVIRONMENT_VARIABLE=value

可以使用以下行来获取当前值:

echo $SOME_ENVIRONMENT_VARIABLE

在 Windows 上,可以使用set命令为本地 shell 会话配置环境变量:

set SOME_ENVIRONMENT_VARIABLE=value

可以使用以下行来获取当前值:

set SOME_ENVIRONMENT_VARIABLE

使用PYTHONASYNCIODEBUG环境设置启用调试模式时,asyncio模块将检查每个定义的协程是否实际运行:

import asyncio

@asyncio.coroutine
def printer():
    print('This is a coroutine')

printer()

这导致打印器协程出现错误,这里从未产生过:

# PYTHONASYNCIODEBUG=1 python3 asyncio_test.py
<CoroWrapper printer() running, defined at asyncio_test.py:4, created at asyncio_test.py:8> was never yielded from
Coroutine object created at (most recent call last):
 **File "asyncio_test.py", line 8, in <module>
 **printer()

另外,event循环默认会有一些日志消息:

import asyncio
import logging

logging.basicConfig(level=logging.DEBUG)
loop = asyncio.get_event_loop()

这导致以下调试消息:

# PYTHONASYNCIODEBUG=1 python3 asyncio_test.py
DEBUG:asyncio:Using selector: KqueueSelector
DEBUG:asyncio:Close <_UnixSelectorEventLoop running=False closed=False debug=True>

你可能会想为什么我们使用PYTHONASYNCIODEBUG标志而不是loop.set_debug(True)。原因是有些情况下这种方法不起作用,因为调试启用得太晚。例如,当尝试在前面的printer()中使用loop.set_debug(True)时,你会发现单独使用loop.set_debug(True)时不会出现任何错误。

启用调试后,以下内容将发生变化:

  • 未被 yield 的协程(如前面的行所示)将引发异常。

  • 从“错误”的线程调用协程会引发异常。

  • 选择器的执行时间将被记录。

  • 慢回调(超过 100 毫秒)将被记录。可以通过loop.slow_callback_duration修改此超时时间。

  • 当资源未正确关闭时,将引发警告。

  • 在执行之前被销毁的任务将被记录。

使用 faulthandler 处理崩溃

faulthandler模块在调试真正低级的崩溃时很有帮助,也就是说,只有在使用对内存的低级访问时才可能发生的崩溃,比如 C 扩展。

例如,这里有一小段代码,会导致你的 Python 解释器崩溃:

import ctypes

# Get memory address 0, your kernel shouldn't allow this:
ctypes.string_at(0)

它会产生类似以下的结果:

# python faulthandler_test.py
zsh: segmentation fault  python faulthandler_test.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)

它会产生类似以下的结果:

# python faulthandler_test.py
Fatal Python error: Segmentation fault

Current thread 0x00007fff79171300 (most recent call first):
 **File "ctypes/__init__.py", line 491 in string_at
 **File "faulthandler_test.py", line 7 in <module>
zsh: segmentation fault  python faulthandler_test.py

显然,以这种方式退出 Python 应用程序是不可取的,因为代码不会以正常的清理退出。资源不会被干净地关闭,退出处理程序也不会被调用。如果你以某种方式需要捕获这种行为,最好的办法是将 Python 可执行文件包装在一个单独的脚本中。

交互式调试

现在我们已经讨论了一些基本的调试方法,这些方法总是有效的,我们将看一些更高级的调试技术。之前的调试方法通过修改代码和/或预见使变量和堆栈可见。这一次,我们将看一种稍微更智能的方法,即在需要时以交互方式执行相同的操作。

按需控制台

在测试一些 Python 代码时,你可能已经使用过交互式控制台几次,因为它是测试 Python 代码的一个简单而有效的工具。你可能不知道的是,从你的代码中启动自己的 shell 实际上是很简单的。因此,每当你想从代码的特定点进入常规 shell 时,这是很容易实现的:

import code

def spam():
    eggs = 123
    print('The begin of spam')
    code.interact(banner='', local=locals())
    print('The end of spam')
    print('The value of eggs: %s' % eggs)

if __name__ == '__main__':
    spam()

在执行时,我们将在交互式控制台中间停下来:

# python3 test_code.py
The begin of spam
>>> eggs
123
>>> eggs = 456
>>>
The end of spam
The value of eggs: 123

要退出这个控制台,我们可以在 Linux/Mac 系统上使用^dCtrl + d),在 Windows 系统上使用^zCtrl + Z)。

这里需要注意的一件重要的事情是,这两者之间的范围是不共享的。尽管我们传递了locals()以便共享本地变量以方便使用,但这种关系并不是双向的。结果是,即使我们在交互会话中将eggs设置为456,它也不会传递到外部函数。如果你愿意,你可以通过直接操作(例如设置属性)来修改外部范围的变量,但所有在本地声明的变量都将保持本地。

使用 pdb 进行调试

在实际调试代码时,常规的交互式控制台并不适用。通过一些努力,你可以让它工作,但它并不方便调试,因为你只能看到当前的范围,不能轻松地在堆栈中跳转。使用pdb(Python 调试器)可以轻松实现这一点。让我们看一个使用pdb的简单例子:

import pdb

def spam():
    eggs = 123
    print('The begin of spam')
    pdb.set_trace()
    print('The end of spam')
    print('The value of eggs: %s' % eggs)

if __name__ == '__main__':
    spam()

这个例子与前一段中的例子几乎完全相同,只是这一次我们最终进入了pdb控制台,而不是常规的交互式控制台。所以让我们试试交互式调试器:

# python3 test_pdb.py
The begin of spam
> test_pdb.py(8)spam()
-> print('The end of spam')
(Pdb) eggs
123
(Pdb) eggs = 456
(Pdb) continue
The end of spam
The value of eggs: 456

正如你所看到的,我们现在实际上修改了eggs的值。在这种情况下,我们使用了完整的continue命令,但所有pdb命令也有简写版本。因此,使用c而不是continue会得到相同的结果。只需输入eggs(或任何其他变量)将显示内容,并且设置变量将简单地设置它,就像我们从交互式会话中期望的那样。

要开始使用pdb,首先显示了最有用的(完整)命令列表及其简写:

Command Explanation
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 漂亮地打印给定的表达式。
b(reak) 显示断点列表。
b(reak) [filename:]lineno 在给定的行号和(可选)文件处设置断点。
b(reak) function[, condition] 在给定的函数处设置断点。条件是一个必须评估为True的表达式,断点才能起作用。
cl(ear) [filename:]lineno 清除这一行的断点(或断点)。
cl(ear) breakpoint [breakpoint ...] 清除这些编号的断点(或断点)。
Command 列出所有定义的命令。
command breakpoint 指定在遇到给定断点时执行的命令列表。使用end命令结束列表。
Alias 列出所有别名。

| alias name command | 创建一个别名。命令可以是任何有效的 Python 表达式,所以你可以这样做来打印对象的所有属性:

alias pd pp %1.__dict__** 

|

unalias name 删除别名。
! statement 在堆栈的当前位置执行语句。通常情况下不需要!符号,但如果与调试器命令发生冲突,这可能会有用。例如,尝试b = 123
Interact 打开一个类似于前一段的交互式会话。请注意,设置在该局部范围内的变量不会被传递。

断点

这是一个相当长的列表,但你可能会经常使用其中的大部分。为了突出显示前表中显示的选项之一,让我们演示断点的设置和使用:

import pdb

def spam():
    print('The begin of spam')
    print('The end of spam')

if __name__ == '__main__':
    pdb.set_trace()
    spam()

到目前为止,没有发生什么新的事情,但现在让我们打开交互式调试会话,如下所示:

# python3 test_pdb.py
> test_pdb.py(11)<module>()
-> while True:
(Pdb) source spam  # View the source of spam
 **4     def spam():
 **5         print('The begin of spam')
 **6         print('The end of spam')

(Pdb) b 5  # Add a breakpoint to line 5
Breakpoint 1 at test_pdb.py:5

(Pdb) w  # Where shows the current line
> test_pdb.py(11)<module>()
-> while True:

(Pdb) c  # Continue (until the next breakpoint or exception)
> test_pdb.py(5)spam()
-> print('The begin of spam')

(Pdb) w  # Where again
 **test_pdb.py(12)<module>()
-> spam()
> test_pdb.py(5)spam()
-> print('The begin of spam')

(Pdb) ll  # List the lines of the current function
 **4     def spam():
 **5 B->     print('The begin of spam')
 **6         print('The end of spam')

(Pdb) b  # Show the breakpoints
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at test_pdb.py:5
 **breakpoint already hit 1 time

(Pdb) cl 1  # Clear breakpoint 1
Deleted breakpoint 1 at test_pdb.py:5

输出很多,但实际上并不像看起来那么复杂:

  1. 首先,我们使用source spam命令查看spam函数的源代码。

  2. 在那之后,我们知道了第一个print语句的行号,我们用它在第 5 行放置了一个断点(b 5)。

  3. 为了检查我们是否仍然在正确的位置,我们使用了w命令。

  4. 由于断点已设置,我们使用c继续到下一个断点。

  5. 在第 5 行的断点停下后,我们再次使用w来确认。

  6. 使用ll列出当前函数的代码。

  7. 使用b列出断点。

  8. 再次使用cl 1移除断点,断点号来自于前一个命令。

一开始似乎有点复杂,但你会发现,一旦你尝试了几次,它实际上是一种非常方便的调试方式。

为了使它更好用,这次我们将只在eggs = 3时执行断点。代码基本上是一样的,尽管在这种情况下我们需要一个变量:

import pdb

def spam(eggs):
    print('eggs:', eggs)

if __name__ == '__main__':
    pdb.set_trace()
    for i in range(5):
        spam(i)

现在,让我们执行代码,并确保它只在特定时间中断:

# python3 test_breakpoint.py
> test_breakpoint.py(10)<module>()
-> for i in range(5):
(Pdb) source spam
 **4     def spam(eggs):
 **5         print('eggs:', eggs)
(Pdb) b 5, eggs == 3  # Add a breakpoint to line 5 whenever eggs=3
Breakpoint 1 at test_breakpoint.py:5
(Pdb) c  # Continue
eggs: 0
eggs: 1
eggs: 2
> test_breakpoint.py(5)spam()
-> print('eggs:', eggs)
(Pdb) a  # Show function arguments
eggs = 3
(Pdb) c  # Continue
eggs: 3
eggs: 4

总结我们所做的:

  1. 首先,使用source spam,我们查找了行号。

  2. 之后,我们使用eggs == 3条件放置了一个断点。

  3. 然后我们使用c继续执行。如你所见,值012都正常打印出来了。

  4. 断点在值3处被触发。为了验证这一点,我们使用a来查看函数参数。

  5. 然后我们继续执行剩下的代码。

捕获异常

所有这些都是手动调用pdb.set_trace()函数,但一般情况下,你只是运行你的应用程序,并不真的期望出现问题。这就是异常捕获非常有用的地方。除了自己导入pdb,你也可以将脚本作为模块通过pdb运行。让我们来看看这段代码,一旦它遇到零除法就会中断:

print('This still works')
1/0
print('We shouldnt reach this code')

如果我们使用pdb参数运行它,每当它崩溃时我们就会进入 Python 调试器:

# python3 -m pdb test_zero.py
> test_zero.py(1)<module>()
-> print('This still works')
(Pdb) w  # Where
 **bdb.py(431)run()
-> exec(cmd, globals, locals)
 **<string>(1)<module>()
> test_zero.py(1)<module>()
-> print('This still works')
(Pdb) s  # Step into the next statement
This still works
> test_zero.py(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 "test_zero.py", line 2, in <module>
 **1/0
ZeroDivisionError: division by zero
Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
> test_zero.py(2)<module>()
-> 1/0

提示

pdb中一个有用的小技巧是使用Enter按钮,默认情况下,它会再次执行先前执行的命令。当逐步执行程序时,这非常有用。

命令

commands命令有点复杂,但非常有用。它允许你在遇到特定断点时执行命令。为了说明这一点,让我们再从一个简单的例子开始:

import pdb

def spam(eggs):
    print('eggs:', eggs)

if __name__ == '__main__':
    pdb.set_trace()
    for i in range(5):
        spam(i)

代码足够简单,所以现在我们将添加断点和命令,如下所示:

# python3 test_breakpoint.py
> test_breakpoint.py(10)<module>()
-> for i in range(3):
(Pdb) b spam  # Add a breakpoint to function spam
Breakpoint 1 at test_breakpoint.py:4
(Pdb) commands 1  # Add a command to breakpoint 1
(com) print('The value of eggs: %s' % eggs)
(com) end  # End the entering of the commands
(Pdb) c  # Continue
The value of eggs: 0
> test_breakpoint.py(5)spam()
-> print('eggs:', eggs)
(Pdb) c  # Continue
eggs: 0
The value of eggs: 1
> test_breakpoint.py(5)spam()
-> print('eggs:', eggs)
(Pdb) cl 1  # Clear breakpoint 1
Deleted breakpoint 1 at test_breakpoint.py:4
(Pdb) c  # Continue
eggs: 1
eggs: 2

正如你所看到的,我们可以很容易地向断点添加命令。在移除断点后,这些命令显然不会再被执行。

使用 ipdb 进行调试

通用的 Python 控制台虽然有用,但有时会有点粗糙。IPython 控制台提供了许多额外功能,使其成为一个更好用的控制台。其中一个功能是更方便的调试器。

首先确保你已经安装了ipdb

pip install ipdb

接下来,让我们再次尝试使用我们之前的脚本进行调试。唯一的小改变是,我们现在导入的是ipdb而不是pdb

import ipdb

def spam(eggs):
    print('eggs:', eggs)

if __name__ == '__main__':
    ipdb.set_trace()
    for i in range(3):
        spam(i)

然后我们执行它:

# python3 test_ipdb.py
> test_ipdb.py(10)<module>()
 **9     ipdb.set_trace()
---> 10     for i in range(3):
 **11         spam(i)

ipdb> b spam  # Set a breakpoint
Breakpoint 1 at test_ipdb.py:4
ipdb> c  # Continue (until exception or breakpoint)
> test_ipdb.py(5)spam()
1     4 def spam(eggs):
----> 5     print('eggs:', eggs)
 **6

ipdb> a  # Show the arguments
eggs = 0
ipdb> c  # Continue
eggs: 0
> test_ipdb.py(5)spam()
1     4 def spam(eggs):
----> 5     print('eggs:', eggs)
 **6

ipdb>   # Repeat the previous command, so continue again
eggs: 1
> test_ipdb.py(5)spam()
1     4 def spam(eggs):
----> 5     print('eggs:', eggs)
 **6

ipdb> cl 1  # Remove breakpoint 1
Deleted breakpoint 1 at test_ipdb.py:4
ipdb> c  # Continue
eggs: 2

命令都是一样的,但在我看来输出更易读一些。实际版本还包括语法高亮,使输出更容易跟踪。

简而言之,在大多数情况下,你可以简单地用ipdb替换pdb来获得一个更直观的调试器。但我也会给你推荐ipdb上下文管理器:

import ipdb

with ipdb.launch_ipdb_on_exception():
    main()

这就像看起来的那样方便。它只是将ipdb连接到你的异常中,这样你可以在需要时轻松调试。将其与应用程序的调试标志结合使用,可以轻松地在需要时允许调试。

其他调试器

pdbipdb只是众多可用于 Python 的调试器中的两个。目前一些值得注意的调试器如下:

  • pudb:这提供了一个全屏命令行调试器

  • pdbpp:这是对常规pdb的一个钩子

  • rpdb2:这是一个允许连接到运行中(远程)应用程序的远程调试器

  • Werkzeug:这是一个基于 Web 的调试器,允许在运行时调试 Web 应用程序

当然还有许多其他调试器,并没有一个绝对最好的。就像所有工具一样,它们都有各自的优势和缺陷,而最适合你当前目的的工具只有你自己才能决定。很可能你当前使用的 Python IDE 已经集成了调试器。

调试服务

除了在遇到问题时进行调试之外,有时您只需要跟踪错误以供以后调试。特别是在与远程服务器一起工作时,这些可以非常宝贵,可以检测 Python 进程何时以及如何发生故障。此外,这些服务还提供错误分组,使它们比简单的异常类型脚本更有用,后者可能会快速填满您的收件箱。

一个很好的开源解决方案,用于跟踪错误的是sentry。如果您需要一个提供性能跟踪的完整解决方案,那么 Opbeat 和 Newrelic 都是非常好的解决方案;它们提供免费和付费版本。请注意,所有这些解决方案还支持跟踪其他语言,例如 JavaScript。

总结

本章介绍了一些不同的调试技术和陷阱。当然,关于调试还有很多可以说的,但我希望您现在已经获得了一个很好的调试 Python 代码的视角。交互式调试技术对于单线程应用程序和可用交互式会话的位置非常有用。但由于情况并非总是如此,我们还讨论了一些非交互式选项。

以下是本章讨论的所有要点概述:

  • 使用非交互式调试:

  • 打印

  • 记录

  • 跟踪

  • 回溯

  • asyncio

  • 故障处理程序

  • 使用pdbipdb进行交互式调试

在下一章中,我们将看到如何监视和改善 CPU 和内存性能,以及查找和修复内存泄漏。

第十二章:性能-跟踪和减少内存和 CPU 使用

在我们谈论性能之前,有一句Donald Knuth的话您需要首先考虑:

“真正的问题在于程序员花了太多时间在错误的地方和错误的时间上担心效率;过早的优化是编程中所有邪恶的根源(或至少是大部分)。”

注意

Donald Knuth 经常被称为算法分析之父。他的书系计算机编程艺术可以被认为是所有基本算法的圣经。

只要您选择了正确的数据结构和正确的算法,性能就不应该成为一个值得担忧的问题。这并不意味着您应该完全忽略性能,而是确保您选择正确的战斗,并且只在实际需要时进行优化。微观/过早的优化肯定很有趣,但很少有用。

我们已经在第二章中看到了许多数据结构的性能特征,Pythonic Syntax, Common Pitfalls, and Style Guide,所以我们不会讨论那个,但我们会向您展示如何测量性能以及如何检测问题。有些情况下,微观优化会产生影响,但在测量性能之前,您不会知道。

在本章中,我们将涵盖:

  • 分析 CPU 使用情况

  • 分析内存使用

  • 学习如何正确比较性能指标

  • 优化性能

  • 查找和修复内存泄漏

什么是性能?

性能是一个非常广泛的术语。它有许多不同的含义,在许多情况下被错误地定义。您可能听过类似于“语言 X 比 Python 快”的说法。然而,这种说法本质上是错误的。Python 既不快也不慢;Python 是一种编程语言,语言根本没有性能指标。如果有人说 CPython 解释器对于语言 X 比解释器 Y 快或慢,那是可能的。代码的性能特征在不同的解释器之间可能有很大的差异。只需看看这个小测试:

# python3 -m timeit '"".join(str(i) for i in range(10000))'
100 loops, best of 3: 2.91 msec per loop
# python2 -m timeit '"".join(str(i) for i in range(10000))'
100 loops, best of 3: 2.13 msec per loop
# pypy -m timeit '"".join(str(i) for i in range(10000))'
1000 loops, best of 3: 677 usec per loop

三种不同的解释器,性能差异巨大!所有都是 Python,但解释器显然不同。看到这个基准测试,您可能会想要完全放弃 CPython 解释器,只使用 Pypy。这类基准测试的危险在于它们很少提供任何有意义的结果。对于这个有限的例子,Pypy 解释器比 CPython3 解释器快大约四倍,但这与一般情况毫无关系。唯一可以安全得出的结论是,这个特定版本的 Pypy 解释器比这个特定版本的 CPython3 快四倍以上,对于任何其他测试和解释器版本,结果可能大不相同。

Timeit-比较代码片段的性能

在我们开始改进性能之前,我们需要一种可靠的方法来衡量它。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函数之间的性能差异。但更重要的是,它演示了如何使用timeit命令。当然,该命令也可以用于常规脚本,但timeit模块只接受要执行的语句作为字符串,这有点麻烦。幸运的是,您可以通过将代码包装在一个函数中并计时该函数来轻松解决这个问题:

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
    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('%d loops, best of %d: %9.6fs :: %s' % (
        number, repeat, time, function))

if __name__ == '__main__':
    benchmark('test_list()')
    benchmark('test_list_comprehension()')
    benchmark('test_append()')
    benchmark('test_insert()')

执行此操作时,您将得到以下内容:

# python3 test_timeit.py
100 loops, best of 10:  0.000238s :: test_list()
100 loops, best of 10:  0.000407s :: test_list_comprehension()
100 loops, best of 10:  0.000838s :: test_append()
100 loops, best of 10:  0.031795s :: test_insert()

您可能已经注意到,这个脚本仍然有点基础。而常规版本会一直尝试,直到达到 0.2 秒或更多,这个脚本只有固定数量的执行。不幸的是,timeit模块并没有完全考虑重用,所以除了从脚本中调用timeit.main()之外,你几乎没有办法重用这个逻辑。

个人建议使用 IPython,因为它可以更轻松地进行测量:

# ipython3
In [1]: import test_timeit
In [2]: %timeit test_timeit.test_list()
1000 loops, best of 3: 255 µs per loop
In [3]: %timeit test_timeit.test_list_comprehension()
1000 loops, best of 3: 430 µs per loop
In [4]: %timeit test_timeit.test_append()
1000 loops, best of 3: 934 µs per loop
In [5]: %timeit test_timeit.test_insert()
10 loops, best of 3: 31.6 ms per loop

在这种情况下,IPython 会自动处理字符串包装和globals()的传递。不过,这一切都非常有限,只适用于比较多种执行相同操作的方法。在完整的 Python 应用程序中,有更多的方法可用。

提示

要查看 IPython 函数和常规模块的源代码,可以在 IPython shell 中输入object??来返回源代码。在这种情况下,只需输入timeit??来查看timeit IPython 函数的定义。

您可以自己实现%timeit函数的最简单方法就是简单地调用timeit.main

import timeit

timeit.main(args=['[x for x in range(1000000)]'])

timeit模块的内部并没有什么特别之处。一个基本版本可以只用evaltime.perf_counter(Python 中可用的最高分辨率计时器)的组合来实现:

import time
import functools

TIMEIT_TEMPLATE = '''
import time

def run(number):
    %(setup)s
    start = time.perf_counter()
    for i in range(number):
        %(statement)s
    return time.perf_counter() - start
'''

def timeit(statement='pass', setup='pass', repeat=1, 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 % dict(
        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
    run = functools.partial(locals_['run'], number=number)
    for i in range(repeat):
        yield run()

timeit代码实际上在检查输入方面更加先进,但这个例子大致展示了timeit.repeat函数如何实现。

要在 IPython 中注册自己的函数,需要使用一些 IPython 魔术。请注意,这个魔术并不是双关语。负责这些命令的 IPython 模块实际上被称为magic。为了演示:

from IPython.core import magic

@magic.register_line_magic(line):
    import timeit
    timeit.main(args[line])

要了解更多关于 IPython 中自定义魔术的信息,请查看 IPython 文档ipython.org/ipython-doc/3/config/custommagics.html

cProfile – 查找最慢的组件

profile模块使得分析脚本/应用程序中使用的相对 CPU 周期变得非常容易。但一定要小心,不要将其与timeit模块的结果进行比较。虽然timeit模块尽可能准确地提供执行代码片段所需的绝对时间的基准,但profile模块只对相对结果有用。原因是,分析代码本身会导致减速,因此结果与非分析代码不可比。然而,有一种方法可以使其更加准确,但稍后再详细介绍。

注意

在这一部分,我们将讨论profile模块,但在示例中我们实际上将使用cProfile模块。cProfile模块是纯 Pythonprofile模块的高性能仿真。

首次分析运行

让我们从第五章中的 Fibonacci 函数进行分析,装饰器-通过装饰实现代码重用,分别使用缓存函数和不使用缓存函数。首先,代码:

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输出中剥离percallcumtime列。这些列对于这些示例的目的来说是无关紧要的。

首先我们将不使用缓存来执行函数:

# python3 -m cProfile -s calls test_fibonacci.py no_cache
 **2692557 function calls (21 primitive calls) in 0.815
 **seconds

 **Ordered by: call count

 **ncalls tottime percall filename:lineno(function)
2692537/1   0.815   0.815 test_fibonacci.py:13(fibonacci)
 **7   0.000   0.000 {built-in method builtins.getattr}
 **5   0.000   0.000 {built-in method builtins.setattr}
 **1   0.000   0.000 {method 'update' of 'dict' objects}
 **1   0.000   0.000 {built-in method builtins.isinstance}
 **1   0.000   0.000 functools.py:422(decorating_function)
 **1   0.000   0.815 test_fibonacci.py:1(<module>)
 **1   0.000   0.000 {method 'disable' of '_lsprof.Profiler'}
 **1   0.000   0.815 {built-in method builtins.exec}
 **1   0.000   0.000 functools.py:43(update_wrapper)
        1   0.000   0.000 functools.py:391(lru_cache)

这是相当多的调用,不是吗?显然,我们调用了test_fibonacci函数将近 300 万次。这就是分析模块提供了很多见解的地方。让我们进一步分析一下指标:

  • Ncalls:对函数进行的调用次数

  • Tottime:在此函数内部花费的总时间(以秒为单位),不包括所有子函数

Percall,tottime / ncalls

  • Cumtime:在此函数内部花费的总时间,包括子函数

Percall,cumtime / ncalls

哪个对你的用例最有用取决于情况。使用默认输出中的-s参数可以很容易地改变排序顺序。但现在让我们看看缓存版本的结果。再次,只有简化的输出:

# python3 -m cProfile -s calls test_fibonacci.py cache
 **51 function calls (21 primitive calls) in 0.000 seconds

 **Ordered by: call count

 **ncalls tottime percall filename:lineno(function)
 **31/1   0.000   0.000 test_fibonacci.py:5(fibonacci_cached)
 **7   0.000   0.000 {built-in method builtins.getattr}
 **5   0.000   0.000 {built-in method builtins.setattr}
 **1   0.000   0.000 test_fibonacci.py:1(<module>)
 **1   0.000   0.000 {built-in method builtins.isinstance}
 **1   0.000   0.000 {built-in method builtins.exec}
 **1   0.000   0.000 functools.py:422(decorating_function)
 **1   0.000   0.000 {method 'disable' of '_lsprof.Profiler'}
 **1   0.000   0.000 {method 'update' of 'dict' objects}
 **1   0.000   0.000 functools.py:391(lru_cache)
 **1   0.000   0.000 functools.py:43(update_wrapper)

这次我们看到tottime0.000,因为它太快了,无法测量。但是,虽然fibonacci_cached函数仍然是执行次数最多的函数,但它只执行了 31 次,而不是 300 万次。

校准你的性能分析器

为了说明profilecProfile之间的差异,让我们再次尝试使用profile模块进行未缓存的运行。提醒一下,这会慢得多,所以如果它有点停滞,不要感到惊讶:

# python3 -m profile -s calls test_fibonacci.py no_cache
         2692558 function calls (22 primitive calls) in 7.696 seconds

   Ordered by: call count

   ncalls tottime percall filename:lineno(function)
2692537/1   7.695   7.695 test_fibonacci.py:13(fibonacci)
        7   0.000   0.000 :0(getattr)
        5   0.000   0.000 :0(setattr)
        1   0.000   0.000 :0(isinstance)
        1   0.001   0.001 :0(setprofile)
        1   0.000   0.000 :0(update)
        1   0.000   0.000 functools.py:43(update_wrapper)
        1   0.000   7.696 profile:0(<code object <module> ...>)
        1   0.000   7.695 test_fibonacci.py:1(<module>)
        1   0.000   0.000 functools.py:391(lru_cache)
        1   0.000   7.695 :0(exec)
        1   0.000   0.000 functools.py:422(decorating_function)
        0   0.000         profile:0(profiler)

巨大的差异,不是吗?现在代码几乎慢了 10 倍,唯一的区别是使用了纯 Python 的profile模块而不是cProfile模块。这确实表明了profile模块存在很大的问题。模块本身的开销足以扭曲结果,这意味着我们应该考虑这种偏差。这就是Profile.calibrate()函数要处理的问题,它计算了 profile 模块所产生的偏差。为了计算偏差,我们可以使用以下脚本:

import profile

if __name__ == '__main__':
    profiler = profile.Profile()
    for i in range(10):
        print(profiler.calibrate(100000))

数字会略有不同,但是你应该能够使用这段代码得到一个公平的偏差估计。如果数字仍然有很大的变化,你可以将试验次数从100000增加到更大的值。这种校准只适用于 profile 模块,但是如果你想要更准确的结果,而cProfile模块由于继承或者不受你的平台支持而无法使用,你可以使用这段代码来全局设置你的偏差并获得更准确的结果:

import profile

# The number here is bias calculated earlier
profile.Profile.bias = 2.0939406059394783e-06

对于特定的Profile实例:

import profile

profiler = profile.Profile(bias=2.0939406059394783e-06)

一般来说,较小的偏差比较大的偏差更好,因为较大的偏差可能会导致非常奇怪的结果。在某些情况下,甚至会得到负的时间。让我们试试我们的斐波那契代码:

import sys
import pstats
import profile
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__':
    profiler = profile.Profile(bias=2.0939406059394783e-06)
    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 test_fibonacci.py no_cache
 **2692539 function calls (3 primitive calls) in -0.778
 **seconds

 **Ordered by: call count

 **ncalls tottime percall filename:lineno(function)
2692537/1  -0.778  -0.778 test_fibonacci.py:15(fibonacci)
 **1   0.000   0.000 :0(setprofile)
 **1   0.000  -0.778 profile:0(<function fibonacci at 0x...>)
 **0   0.000         profile:0(profiler)

不过,它展示了代码如何正确使用。你甚至可以在脚本中使用类似这样的片段来整合偏差计算:

import profile

if __name__ == '__main__':
    profiler = profile.Profile()
    profiler.bias = profiler.calibrate(100000)

使用装饰器进行选择性性能分析

使用装饰器计算简单的时间很容易,但是性能分析也很重要。两者都很有用,但是目标不同。让我们看看两种选择:

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('%s: %s' % (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)

代码足够简单,只是一个基本的计时器和性能分析器打印一些默认的统计数据。哪种对你来说更好取决于你的用例,但它们肯定都有用。这种选择性性能分析的额外优势是输出更有限,有助于可读性:

# python3 test_fibonacci.py
 **timed_fibonacci: 0:00:01.050200
 **7049157 function calls (3 primitive calls) in 2.024
 **seconds

 **Ordered by: standard name

 **ncalls tottime percall filename:lineno(function)
 **1   0.000   2.024 test_fibonacci.py:31(profiled_fibonacci)
7049155/1   2.024   2.024 test_fibonacci.py:41(fibonacci)
 **1   0.000   0.000 {method 'disable' of '_lsprof.Profiler'}

如你所见,性能分析器仍然使代码变慢了大约两倍,但它确实可用。

使用性能分析统计

为了获得更复杂的性能分析结果,我们将对pystone脚本进行性能分析。pystone脚本是一个内部的 Python 性能测试,它相当彻底地对 Python 解释器进行基准测试。首先,让我们使用这个脚本创建统计数据:

from test import pystone
import cProfile

if __name__ == '__main__':
    profiler = cProfile.Profile()
    profiler.runcall(pystone.main)
    profiler.dump_stats('pystone.profile')

在执行脚本时,你应该会得到类似这样的结果:

# python3 test_pystone.py
Pystone(1.2) time for 50000 passes = 0.725432
This machine benchmarks at 68924.4 pystones/second

运行脚本后,你应该会得到一个包含性能分析结果的pystone.profile文件。这些结果可以通过 Python 捆绑的pstats模块查看:

import pstats

stats = pstats.Stats('pystone.profile')
stats.strip_dirs()
stats.sort_stats('calls', 'cumtime')
stats.print_stats(10)

在某些情况下,将多次测量的结果结合起来可能是有趣的。可以通过指定多个文件或使用stats.add(*filenames)来实现。但首先,让我们看看常规输出:

# python3 parse_statistics.py

 **1050012 function calls in 0.776 seconds

 **Ordered by: call count, cumulative time
 **List reduced from 21 to 10 due to restriction <10>

 **ncalls  tottime  percall  cumtime  percall filename:lineno(function)
 **150000    0.032    0.000    0.032    0.000 pystone.py:214(Proc7)
 **150000    0.027    0.000    0.027    0.000 pystone.py:232(Func1)
 **100000    0.016    0.000    0.016    0.000 {built-in method builtins.chr}
 **100000    0.010    0.000    0.010    0.000 {built-in method builtins.ord}
 **50002    0.029    0.000    0.029    0.000 pystone.py:52(__init__)
 **50000    0.127    0.000    0.294    0.000 pystone.py:144(Proc1)
 **50000    0.094    0.000    0.094    0.000 pystone.py:219(Proc8)
 **50000    0.048    0.000    0.077    0.000 pystone.py:60(copy)
 **50000    0.051    0.000    0.061    0.000 pystone.py:240(Func2)
 **50000    0.031    0.000    0.043    0.000 pystone.py:171(Proc3)

显然,参数可以很容易地修改以改变排序顺序和输出行数。但这并不是统计数据的唯一可能性。有很多包可以解析这些结果并可视化它们。其中一个选择是 RunSnakeRun,虽然有用,但目前不支持 Python 3。此外,我们还有 QCacheGrind,一个非常好的性能分析统计可视化工具,但需要一些手动编译才能运行,或者当然也可以搜索到二进制文件。

让我们看看 QCacheGrind 的输出。在 Windows 的情况下,QCacheGrindWin 包提供了一个二进制文件,而在 Linux 中,它很可能通过您的软件包管理器提供,在 OS X 中,您可以尝试brew install qcachegrind --with-graphviz。但是还有一个包你需要:pyprof2calltree包。它将profile输出转换为 QCacheGrind 理解的格式。因此,在简单的pip install pyprof2calltree之后,我们现在可以将profile文件转换为callgrind文件:

# pyprof2calltree -i pystone.profile -o pystone.callgrind
writing converted data to: pystone.callgrind
# qcachegrind pystone.callgrind

这将导致运行QCacheGrind应用程序。切换到适当的标签后,您应该看到类似以下图像的东西:

使用 profile 统计信息

对于这样一个简单的脚本,几乎所有的输出都有效。然而,对于完整的应用程序,像 QCacheGrind 这样的工具是非常宝贵的。查看 QCacheGrind 生成的输出,立即就能看出哪个进程花费了最多的时间。右上角的结构显示了更大的矩形,如果花费的时间更长,这是对 CPU 时间块的非常有用的可视化。左边的列表与cProfile非常相似,因此没有什么新鲜的。右下角的树可能非常有价值,也可能非常无用,就像在这种情况下一样。它显示了函数中所占 CPU 时间的百分比,更重要的是,该函数与其他函数的关系。

因为这些工具根据输入进行缩放,所以结果对于几乎任何应用程序都是有用的。无论一个函数需要 100 毫秒还是 100 分钟,都没有关系,输出将显示慢的部分的清晰概述,这是我们要尝试修复的问题。

行分析器

line_profiler实际上不是 Python 捆绑的包,但它太有用了,不能忽视。虽然常规的profile模块对某个块内的所有(子)函数进行分析,但line_profiler允许在函数内逐行进行分析。斐波那契函数在这里并不是最合适的,但我们可以使用一个素数生成器。但首先,安装line_profiler

 **pip 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 -l test_primes.py
The sum of the first 2000 primes is 16274627
Wrote profile results to test_primes.py.lprof

正如命令所说,结果已经写入了test_primes.py.lprof文件。因此,为了便于阅读,让我们查看该文件的输出,跳过Time列:

# python3 -m line_profiler test_primes.py.lprof
Timer unit: 1e-06 s

Total time: 2.33179 s
File: test_primes.py
Function: primes at line 4

Line #      Hits   Per Hit   % Time  Line Contents
==================================================
 **4                               @profile
 **5                               def primes():
 **6         1       3.0      0.0      n = 2
 **7         1       1.0      0.0      primes = set()
 **8         1       0.0      0.0      while True:
 **9   2058163       0.5     43.1          for p in primes:
 **10   2056163       0.6     56.0              if n % p == 0:
 **11     15388       0.5      0.3                  break
 **12                                       else:
 **13      2000       1.2      0.1              primes.add(n)
 **14      2000       0.5      0.0              yield n
 **15     17387       0.6      0.4          n += 1

很棒的输出,不是吗?这使得在一小段代码中找到慢的部分变得微不足道。在这段代码中,慢显然是来自循环,但在其他代码中可能不那么清楚。

注意

这个模块也可以作为 IPython 扩展添加,这样就可以在 IPython 中使用%lprun命令。要加载扩展,可以在 IPython shell 中使用load_ext命令%load_ext line_profiler

提高性能

关于性能优化可以说很多,但实际上,如果你已经读完整本书,你就会知道大部分编写快速代码的 Python 特定技术。应用程序性能中最重要的因素始终是算法的选择,以及数据结构。在list中搜索项目几乎总是比在dictset中搜索项目更糟糕的想法。

使用正确的算法

在任何应用程序中,正确选择的算法是远远最重要的性能特征,这就是为什么我重复强调它以说明错误选择的结果:

In [1]: a = list(range(1000000))

In [2]: b = dict.fromkeys(range(1000000))

In [3]: %timeit 'x' in a
10 loops, best of 3: 20.5 ms per loop

In [4]: %timeit 'x' in b
10000000 loops, best of 3: 41.6 ns per loop

检查一个项目是否在list中是一个O(n)操作,而检查一个项目是否在dict中是一个O(1)操作。当n=1000000时有很大的差异,显然,在这个简单的测试中,对于 100 万个项目,它快了 500 倍。

所有其他性能提示结合在一起可能使您的代码速度加快一倍,但使用正确的算法可能会带来更大的改进。使用需要O(n)时间的算法而不是O(n²)时间将使您的代码对于n=10001000倍,对于更大的n,差异只会进一步增加。

全局解释器锁

CPython 解释器最隐晦的组件之一是全局解释器锁(GIL),这是为了防止内存损坏而需要的互斥锁。Python 内存管理器不是线程安全的,这就是为什么需要 GIL。没有 GIL,多个线程可能同时更改内存,导致各种意外和潜在的危险结果。

那么 GIL 在现实应用中的影响是什么?在单线程应用程序中,它根本没有任何影响,实际上是一种非常快速的内存一致性方法。然而,在多线程应用程序中,它可能会稍微减慢应用程序的速度,因为一次只有一个线程可以访问 GIL。因此,如果您的代码需要频繁访问 GIL,可能会受益于一些重构。

幸运的是,Python 提供了一些其他并行处理选项:我们之前看到的asyncio模块和我们将在第十三章中看到的multiprocessing库,多进程-当单个 CPU 核心不够用

尝试与 if

在许多语言中,try/except类型的块会带来相当大的性能损失,但在 Python 中并非如此。并不是说if语句很重,但如果您期望您的try/except大部分时间都成功,只在罕见情况下失败,那绝对是一个有效的替代方案。不过,始终专注于可读性和传达代码目的。如果使用if语句更清晰表达代码的意图,就使用if语句。如果try/except以更好的方式传达意图,就使用它。

列表与生成器

使用生成器懒惰地评估代码几乎总是比计算整个数据集更好的主意。性能优化的最重要规则可能是,不要计算任何您不打算使用的东西。如果您不确定自己是否需要它,请不要计算它。

不要忘记您可以轻松地链接多个生成器,因此只有在实际需要时才计算所有内容。但要小心,这不会导致重新计算;itertools.tee通常比完全重新计算结果更好。

字符串连接

您可能已经看到过一些基准测试,表明使用+=比连接字符串要慢得多。在某个时候,这确实产生了很大的差异。然而,使用 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

当然仍然存在一些差异,但它们非常小,建议忽略它们,选择最可读的选项。

加法与生成器

与字符串连接一样,一度显著差异现在已经微乎其微。

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函数可以使用预定义函数时才是如此。一旦您需要使用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

正如你所看到的,列表推导式显然比生成器快得多。然而,在许多情况下,我仍然会推荐使用生成器而不是列表推导式,仅仅是因为内存使用和潜在的惰性。如果由于某种原因你只打算使用前 10 个项目,那么通过计算完整的项目列表,你仍然会浪费大量资源。

缓存

我们已经在第五章中介绍了functools.lru_cache装饰器,装饰器-通过装饰实现代码重用,但它的重要性不容小觑。无论你的代码有多快多聪明,不必计算结果总是更好的,这就是缓存的作用。根据你的用例,有许多选项可用。在一个简单的脚本中,functools.lru_cache是一个很好的选择,但在多次执行应用程序时,cPickle模块也可以拯救生命。

如果涉及多个服务器,我建议看看Redis。Redis 服务器是一个单线程的内存服务器,非常快速,并且有许多有用的数据结构可用。如果你看到关于使用 Memcached 提高性能的文章或教程,只需在所有地方用 Redis 替换 Memcached。Redis 在各个方面都优于 Memcached,在其最基本的形式下,API 是兼容的。

延迟导入

应用程序加载时间的一个常见问题是,在程序启动时立即加载所有内容,而实际上许多应用程序并不需要这样做,应用程序的某些部分只在实际使用时才需要加载。为了方便起见,可以偶尔将导入移动到函数内部,以便按需加载。

虽然在某些情况下这是一个有效的策略,但我通常不推荐它,原因有两个:

  1. 它使你的代码不够清晰;在文件顶部以相同的风格放置所有导入可以提高可读性。

  2. 它并没有使代码更快,因为它只是将加载时间移到不同的部分。

使用优化库

这实际上是一个非常广泛的提示,但仍然很有用。如果有一个非常优化的库适合你的目的,你很可能无法在没有大量努力的情况下击败它的性能。像numpypandasscipysklearn这样的库都经过了高度优化,它们的本机操作可能非常快。如果它们适合你的目的,请务必尝试一下。只是为了说明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)编译是一种在运行时动态编译(部分)应用程序的方法。因为在运行时有更多的信息可用,这可能会产生巨大的影响,并使你的应用程序运行得更快。

numba包为你提供了选择性的 JIT 编译,允许你标记与 JIT 编译器兼容的函数。基本上,如果你的函数遵循基于输入的函数式编程范式,那么它很可能会与 JIT 编译器一起工作。

numba JIT 编译器的基本示例:

import numba

@numba.jit
def sum(array):
    total = 0.0
    for value in array:
        total += value
    return value

这些用例有限,但如果你使用numpy或 pandas,你很可能会从numba中受益。

另一个非常有趣的事实是,numba不仅支持 CPU 优化执行,还支持 GPU。这意味着对于某些操作,你可以使用视频卡中的快速处理器来处理结果。

将代码的部分转换为 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 xrange(1,n+1):
        val += recip_square(k)
    return (6 * val)**.5

虽然有一些小的差异,比如使用cdef代替def以及对值和参数进行类型定义,但代码基本上与常规 Python 代码相同,但速度肯定要快得多。

内存使用

到目前为止,我们只看了执行时间,忽略了脚本的内存使用。在许多情况下,执行时间是最重要的,但内存使用也不容忽视。在几乎所有情况下,CPU 和内存是相互交换的;代码要么使用大量 CPU,要么使用大量内存,这意味着两者都非常重要。

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 test_tracemalloc.py
test_tracemalloc.py:8: size=35.3 MiB, count=999745, average=37 B
<frozen importlib._bootstrap_external>:473: size=1909 KiB, count=20212, average=97 B
<frozen importlib._bootstrap>:222: size=895 KiB, count=3798, average=241 B
collections/__init__.py:412: size=103 KiB, count=1451, average=72 B
<string>:5: size=36.6 KiB, count=133, average=282 B
collections/__init__.py:406: size=29.9 KiB, count=15, average=2039 B
abc.py:133: size=26.1 KiB, count=102, average=262 B
ipaddress.py:608: size=21.3 KiB, count=182, average=120 B
<frozen importlib._bootstrap_external>:53: size=21.2 KiB, count=140, average=155 B
types.py:234: size=15.3 KiB, count=124, average=127 B

您可以很容易地看到代码的每个部分分配了内存以及可能浪费了多少。虽然可能仍然不清楚哪一部分实际上导致了内存使用,但我们将在接下来的部分中看到有关此问题的解决方案。

内存分析器

memory_profiler模块与前面讨论的line_profiler非常相似,但用于内存使用。安装它就像pip install memory_profiler一样容易,但是强烈建议(在 Windows 的情况下是必需的)安装可选的pip install psutil,因为它可以大大提高性能。为了测试line_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来执行:

# python3 test_memory_profiler.py
Filename: test_memory_profiler.py

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       n = 100000
 **7     14.6 MiB      3.5 MiB       a = [i for i in range(n)]
 **8     17.8 MiB      3.2 MiB       b = [i for i in range(n)]
 **9     21.7 MiB      3.9 MiB       c = list(range(n))
 **10     25.5 MiB      3.9 MiB       d = list(range(n))
 **11     38.0 MiB     12.5 MiB       e = dict.fromkeys(a, b)
 **12     44.1 MiB      6.1 MiB       f = dict.fromkeys(c, d)

尽管一切都按预期运行,但您可能会对这里代码行使用的内存量不同而感到困惑。为什么a占用3.5 MiB,而b只占用3.2 MiB?这是由 Python 内存分配代码引起的;它以较大的块保留内存,然后在内部进行细分和重复使用。另一个问题是memory_profiler在内部进行快照,这导致在某些情况下将内存归因于错误的变量。这些变化应该足够小,以至于最终不会产生很大的差异,但是应该预期会有一些变化。

注意

这个模块也可以作为 IPython 扩展添加,这样就可以在 IPython 中使用%mprun命令。要加载扩展,可以从 IPython shell 使用%load_ext memory_profiler命令。另一个非常有用的命令是%memit,它是%timeit命令的内存等价命令。

内存泄漏

这些模块的使用通常会受到对内存泄漏的搜索的限制。特别是tracemalloc模块具有一些功能,使得这一过程相当容易。Python 内存管理系统相当简单;它只是有一个简单的引用计数器来查看对象是否被使用。虽然在大多数情况下这很有效,但当涉及到循环引用时,它很容易引入内存泄漏。带有泄漏检测代码的内存泄漏的基本前提如下:

 1 import tracemalloc
 2
 3
 4 class Spam(object):
 5     index = 0
 6     cache = {}
 7
 8     def __init__(self):
 9         Spam.index += 1
10         self.cache[Spam.index] = self
11
12
13 class Eggs(object):
14     eggs = []
15
16     def __init__(self):
17         self.eggs.append(self)
18
19
20 if __name__ == '__main__':
21     # Initialize some variables to ignore them from the leak
22     # detection
23     n = 200000
24     spam = Spam()
25
26     tracemalloc.start()
27     # Your application should initialize here
28
29     snapshot_a = tracemalloc.take_snapshot()
30     # This code should be the memory leaking part
31     for i in range(n):
32         Spam()
33
34     Spam.cache = {}
35     snapshot_b = tracemalloc.take_snapshot()
36     # And optionally more leaking code here
37     for i in range(n):
38         a = Eggs()
39         b = Eggs()
40         a.b = b
41         b.a = a
42
43     Eggs.eggs = []
44     snapshot_c = tracemalloc.take_snapshot()
45
46     print('The first leak:')
47     statistics = snapshot_b.compare_to(snapshot_a, 'lineno')
48     for statistic in statistics[:10]:
49         print(statistic)
50
51     print('\nThe second leak:')
52     statistics = snapshot_c.compare_to(snapshot_b, 'lineno')
53     for statistic in statistics[:10]:
54         print(statistic)

让我们看看这段代码实际上有多糟糕的内存泄漏:

# python3 test_leak.py
The first leak:
tracemalloc.py:349: size=528 B (+528 B), count=3 (+3), average=176 B
test_leak.py:34: size=288 B (+288 B), count=2 (+2), average=144 B
test_leak.py:32: size=120 B (+120 B), count=2 (+2), average=60 B
tracemalloc.py:485: size=64 B (+64 B), count=1 (+1), average=64 B
tracemalloc.py:487: size=56 B (+56 B), count=1 (+1), average=56 B
tracemalloc.py:277: size=32 B (+32 B), count=1 (+1), average=32 B
test_leak.py:31: size=28 B (+28 B), count=1 (+1), average=28 B
test_leak.py:9: size=28 B (+28 B), count=1 (+1), average=28 B

The second leak:
test_leak.py:41: size=18.3 MiB (+18.3 MiB), count=400000 (+400000), average=48 B
test_leak.py:40: size=18.3 MiB (+18.3 MiB), count=400000 (+400000), average=48 B
test_leak.py:38: size=10.7 MiB (+10.7 MiB), count=200001 (+200001), average=56 B
test_leak.py:39: size=10.7 MiB (+10.7 MiB), count=200002 (+200002), average=56 B
tracemalloc.py:349: size=680 B (+152 B), count=6 (+3), average=113 B
test_leak.py:17: size=72 B (+72 B), count=1 (+1), average=72 B
test_leak.py:43: size=64 B (+64 B), count=1 (+1), average=64 B
test_leak.py:32: size=56 B (-64 B), count=1 (-1), average=56 B
tracemalloc.py:487: size=112 B (+56 B), count=2 (+1), average=56 B
tracemalloc.py:277: size=64 B (+32 B), count=2 (+1), average=32 B

在绝对内存使用上,增加并不是很大,但肯定有一点泄漏。第一个泄漏是微不足道的;在最后一次迭代中,我们看到增加了 28 字节,几乎可以忽略不计。然而第二个泄漏泄漏了很多,并在增加了 18.3 兆字节。这些都是内存泄漏,Python 垃圾收集器(gc)足够聪明,最终会清除循环引用,但在达到一定限制之前不会清除它们。很快就会了解更多。

每当你想要有一个不会导致内存泄漏的循环引用时,weakref模块是可用的。它创建的引用不计入对象引用计数。在我们看weakref模块之前,让我们通过 Python 垃圾收集器(gc)的眼睛来看一下对象引用本身:

import gc

class Eggs(object):

    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return '<%s: %s>' % (self.__class__.__name__, self.name)

# Create the objects
a = Eggs('a')
b = Eggs('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_, Eggs):
        print('\t', object_, gc.get_referents(object_))

print('After manual collection:')
gc.collect()
for object_ in gc.get_objects():
    if isinstance(object_, Eggs):
        print('\t', object_, gc.get_referents(object_))

print('Thresholds:', gc.get_threshold())

现在让我们看看输出:

# python3 test_refcount.py
Before manual collection:
 **<Eggs: a> [{'b': <Eggs: a>, 'name': 'a'}, <class '__main__.Eggs'>]
 **<Eggs: b> [{'name': 'b', 'a': <Eggs: b>}, <class '__main__.Eggs'>]
After manual collection:
Thresholds: (700, 10, 10)

正如我们在这里看到的,直到我们手动调用垃圾收集器之前,Eggs对象将一直留在内存中。即使明确删除了对象。那么这是否意味着你总是需要手动调用gc.collect()来删除这些引用?幸运的是,不需要,因为 Python 垃圾收集器在达到阈值后会自动进行收集。默认情况下,Python 垃圾收集器的阈值设置为三代被收集对象的700, 10, 10。收集器跟踪 Python 中所有的内存分配和释放,一旦分配次数减去释放次数达到 700,如果对象不再被引用,它就会被移除,如果它仍然有引用,它就会被移动到下一代。对于第 2 和第 3 代,重复相同的操作,尽管阈值较低为 10。

这引出了一个问题:在哪里和何时手动调用垃圾收集器是有用的?由于 Python 内存分配器重用内存块并且很少释放它,对于长时间运行的脚本,垃圾收集器可能非常有用。这正是我推荐使用它的地方:在内存紧张的环境中长时间运行的脚本,特别是在分配大量内存之前。

更重要的是,gc模块在寻找内存泄漏时也可以帮助你很多。tracemalloc模块可以显示占用最多内存的部分,但gc模块可以帮助你找到最多定义的对象。只是要小心设置垃圾收集器的调试设置,比如gc.set_debug(gc.DEBUG_LEAK);即使你自己没有保留任何内存,它也会返回大量输出。重新审视我们之前的SpamEggs脚本,让我们看看垃圾收集模块是如何使用内存的:

import gc
import collections

class Spam(object):
    index = 0
    cache = {}

    def __init__(self):
        Spam.index += 1
        self.cache[Spam.index] = self

class Eggs(object):
    eggs = []

    def __init__(self):
        self.eggs.append(self)

if __name__ == '__main__':
    n = 200000
    for i in range(n):
        Spam()

    for i in range(n):
        a = Eggs()
        b = Eggs()
        a.b = b
        b.a = a

    Spam.cache = {}
    Eggs.eggs = []
    objects = collections.Counter()
    for object_ in gc.get_objects():
        objects[type(object_)] += 1

    for object_, count in objects.most_common(5):
        print('%d: %s' % (count, object_))

输出可能与你已经预期的非常接近:

# python3 test_leak.py
400617: <class 'dict'>
400000: <class '__main__.Eggs'>
962: <class 'wrapper_descriptor'>
920: <class 'function'>
625: <class 'method_descriptor'>

大量的dict对象是因为类的内部状态,但除此之外,我们只看到了Eggs对象,就像我们所期望的那样。Spam对象被垃圾收集器正确地移除了,因为它们和所有引用都被移除了。Eggs对象无法被移除,因为存在循环引用。现在我们将使用weakref模块重复相同的示例,看看它是否有所不同:

import gc
import weakref
import collections

class Eggs(object):
    eggs = []

    def __init__(self):
        self.eggs.append(self)

if __name__ == '__main__':
    n = 200000
    for i in range(n):
        a = Eggs()
        b = Eggs()
        a.b = weakref.ref(b)
        b.a = weakref.ref(a)

    Eggs.eggs = []
    objects = collections.Counter()
    for object_ in gc.get_objects():
        objects[type(object_)] += 1

    for object_, count in objects.most_common(5):
        print('%d: %s' % (count, object_))

现在让我们看看这次剩下了什么:

# python3 test_leak.py
962: <class 'wrapper_descriptor'>
919: <class 'function'>
625: <class 'method_descriptor'>
618: <class 'dict'>
535: <class 'builtin_function_or_method'>

除了一些标准内置的 Python 对象,这正是我们所希望的。但要小心弱引用,因为如果被引用的对象消失了,它们很容易爆炸:

import weakref

class Eggs(object):
    pass

if __name__ == '__main__':
    a = Eggs()
    b = Eggs()
    a.b = weakref.ref(b)

    print(a.b())
    del b
    print(a.b())

这导致一个有效的引用和一个无效的引用:

# python3 test_weakref.py
<__main__.Eggs object at 0x104891a20>
None

减少内存使用

总的来说,在 Python 中,内存使用可能不是你最大的问题,但了解如何减少内存使用仍然是有用的。在尝试减少内存使用时,重要的是要了解 Python 如何分配内存。

Python 内存管理器中有四个你需要了解的概念:

  • 首先是堆。堆是所有 Python 管理内存的集合。请注意,这与常规堆是分开的,混合两者可能导致内存损坏和崩溃。

  • 其次是 arena。这些是 Python 从系统请求的块。这些块每个固定大小为 256 KiB,它们是构成堆的对象。

  • 第三,我们有 pools。这些是构成 arena 的内存块。这些块每个大小为 4 KiB。由于 pools 和 arenas 具有固定大小,它们是简单的数组。

  • 第四,最后,我们有 blocks。Python 对象存储在这些 blocks 中,每个 block 的格式取决于数据类型。由于整数占用的空间比字符多,为了效率使用了不同的块大小。

现在我们知道了内存是如何分配的,我们也可以理解它如何被返回给操作系统。每当一个 arena 完全为空时,它可以并且将被释放。为了增加这种情况发生的可能性,一些启发式方法被用来最大化更满的 arenas 的使用。

注意

重要的是要注意,常规堆和 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 中集合的一个非常重要的细节是,其中许多集合只能增长;它们不会自行收缩。为了说明:

Line #    Mem usage    Increment   Line Contents
================================================
 **4     11.5 MiB      0.0 MiB   @memory_profiler.profile
 **5                             def main():
 **6                             # Generate a huge dict
 **7     26.3 MiB     14.8 MiB   a = dict.fromkeys(range(100000))
 **8
 **9                             # Remove all items
 **10     26.3 MiB      0.0 MiB   for k in list(a.keys()):
 **11     26.3 MiB      0.0 MiB   del a[k]
 **12
 **13                             # Recreate the dict
 **14     23.6 MiB     -2.8 MiB   a = dict((k, v) for k, v in a.items())

这是使用列表和字典时最常见的内存使用错误之一。除了重新创建对象,当然还有使用生成器的选项,这样内存根本就不会被分配。

使用 slots

如果你长时间使用 Python,你可能已经见过类的 __slots__ 功能。它允许你指定你想在类中存储哪些字段,并通过不实现 instance.__dict__ 跳过所有其他字段。虽然这种方法确实在类定义中节省了一点内存,但我不建议使用它,因为使用它有几个缺点。最重要的一个是它使继承变得不明显(给没有 __slots__ 的子类添加 __slots__ 没有效果)。它还使得不可能在运行时修改类属性,并且默认情况下破坏了 weakref。最后,具有 slots 的类不能在没有定义 __getstate__ 函数的情况下被 pickle。

然而,为了完整起见,这里演示了 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()

和内存使用情况:

# python3 test_slots.py
Filename: test_slots.py

Line #    Mem usage    Increment   Line Contents
================================================
 **21     11.1 MiB      0.0 MiB   @memory_profiler.profile
 **22                             def main():
 **23     17.0 MiB      5.9 MiB   slots = [Slots(i) for i in range(25000)]
 **24     25.0 MiB      8.0 MiB   no_slots = [NoSlots(i) for i in range(25000)]
 **25     25.0 MiB      0.0 MiB   return slots, no_slots

你可能会认为这不是一个公平的比较,因为它们都存储了大量数据,这扭曲了结果。你的确是对的,因为“裸”比较只存储 index 而不存储其他内容,结果是 2 MiB 对比 4.5 MiB。但让我们诚实一点,如果你不打算存储数据,那么创建类实例有什么意义呢?这就是为什么我建议不要使用 __slots__,而是建议使用元组或 collections.namedtuple,如果内存很重要的话。还有一种更节省内存的结构,那就是 array 模块。它几乎是以裸内存数组的方式存储数据。请注意,这通常比列表慢得多,使用起来也不那么方便。

性能监控

到目前为止,我们已经看到了如何测量和改进 CPU 和内存性能,但有一个部分我们完全忽略了。由于数据量的增长等外部因素导致的性能变化是非常难以预测的。在现实生活中的应用程序中,瓶颈并不是恒定的。它们一直在变化,曾经非常快的代码一旦增加了更多的负载就可能变慢。

因此,我建议实施一个监控解决方案,随时间跟踪任何事物的性能。性能监控的一个大问题是,您无法知道未来会出现什么减速以及原因是什么。我甚至曾经因为 Memcached 和 Redis 调用而导致网站减速。这些都是仅用于缓存的内存服务器,响应时间在毫秒内,这使得减速变得极不可能,直到您进行了 100 次以上的缓存调用,缓存服务器的延迟从 0.1 毫秒增加到 2 毫秒,突然之间这 100 次调用需要 200 毫秒而不是 10 毫秒。即使 200 毫秒听起来仍然很少,但如果您的总页面加载时间通常低于 100 毫秒,那么这突然间就是一个巨大的增加,而且肯定是显而易见的。

为了监控性能并能够随时间跟踪变化并找到负责的组件,我个人非常喜欢 Statsd 统计收集服务器以及 Graphite 接口。尽管可用性有点欠缺,但结果是一个图形界面,您可以动态查询以分析性能何时、何地以及如何改变。为了能够使用这些,您将需要将应用程序的指标发送到 Statsd 服务器。为了做到这一点,我编写了 Python-Statsd(pypi.python.org/pypi/python-statsd)和 Django-Statsd(pypi.python.org/pypi/django-statsd)包。这些包允许您从头到尾监控您的应用程序,在 Django 的情况下,您将能够监控每个应用程序或视图的性能,并在其中查看所有组件,例如数据库、模板和缓存层。这样,您就可以准确地知道是什么导致了网站(或应用程序)的减速。

总结

在性能方面,没有圣杯,也没有一种方法可以确保在所有情况下都实现最佳性能。然而,这不应该让您担心,因为在大多数情况下,您永远不需要调整性能,如果需要,一个小调整可能就能解决您的问题。现在您应该能够找到代码中的性能问题和内存泄漏,这才是最重要的,所以请尽量克制自己,只有在真正需要时才进行调整。

本章最重要的要点是:

  • 在投入任何努力之前进行测试。使一些函数更快似乎是一个很大的成就,但实际上很少需要。

  • 选择正确的数据结构/算法比任何其他性能优化更有效。

  • 循环引用会耗尽内存,直到垃圾收集器开始清理。

  • 插槽不值得付出努力。

下一章将讨论多进程,这是一个使您的脚本能够轻松利用多个处理器的库。如果您无法从脚本中挤出更多性能,多进程可能是您的答案,因为每个(远程?)CPU 核心都可以使您的脚本更快。

第十三章:多进程-当单个 CPU 核心不够用时

在上一章中,我们讨论了影响性能的因素以及一些提高性能的方法。这一章实际上可以看作是性能提示列表的扩展。在本章中,我们将讨论多进程模块,这是一个使您的代码非常容易在多个 CPU 核心甚至多台机器上运行的模块。这是一个绕过前一章中讨论的全局解释器锁GIL)的简单方法。

总之,本章将涵盖:

  • 本地多进程

  • 远程多进程

  • 进程之间的数据共享和同步

多线程与多进程

在本书中,我们还没有真正涵盖多线程,但您可能以前看到过多线程代码。多线程和多进程之间的最大区别在于,多线程中的所有内容仍然在单个进程中执行。这实际上将性能限制在单个 CPU 核心。它实际上甚至限制了您的性能,因为代码必须处理 CPython 的 GIL 限制。

注意

GIL 是 Python 用于安全内存访问的全局锁。关于性能,它在第十二章中有更详细的讨论,性能-跟踪和减少内存和 CPU 使用情况

为了说明多线程代码并不总是有助于性能,并且实际上可能比单线程代码稍慢,请看这个例子:

import datetime
import threading

def busy_wait(n):
    while n > 0:
        n -= 1

if __name__ == '__main__':
    n = 10000000
    start = datetime.datetime.now()
    for _ in range(4):
        busy_wait(n)
    end = datetime.datetime.now()
    print('The single threaded loops took: %s' % (end - start))

    start = datetime.datetime.now()
    threads = []
    for _ in range(4):
        thread = threading.Thread(target=busy_wait, args=(n,))
        thread.start()
        threads.append(thread)

    for thread in threads:
        thread.join()

    end = datetime.datetime.now()
    print('The multithreaded loops took: %s' % (end - start))

使用 Python 3.5,它具有新的改进的 GIL 实现(在 Python 3.2 中引入),性能相当可比,但没有改进:

# python3 test_multithreading.py
The single threaded loops took: 0:00:02.623443
The multithreaded loops took: 0:00:02.597900

使用仍然具有旧 GIL 的 Python 2.7,单线程变体的性能要好得多:

# python2 test_multithreading.py
The single threaded loops took: 0:00:02.010967
The multithreaded loops took: 0:00:03.924950

从这个测试中,我们可以得出结论,Python 2 在某些情况下更快,而 Python 3 在其他情况下更快。你应该从中得出的结论是,没有性能原因特别选择 Python 2 还是 Python 3。只需注意,Python 3 在大多数情况下至少与 Python 2 一样快,如果不是这种情况,很快就会得到解决。

无论如何,对于 CPU 绑定的操作,线程不提供任何性能优势,因为它在单个处理器核心上执行。但是对于 I/O 绑定的操作,threading库确实提供了明显的好处,但在这种情况下,我建议尝试asynciothreading的最大问题是,如果其中一个线程阻塞,主进程也会阻塞。

multiprocessing库提供了一个与threading库非常相似的 API,但是利用多个进程而不是多个线程。优点是 GIL 不再是问题,可以利用多个处理器核心甚至多台机器进行处理。

为了说明性能差异,让我们重复使用multiprocessing模块而不是threading进行测试:

import datetime
import multiprocessing

def busy_wait(n):
    while n > 0:
        n -= 1

if __name__ == '__main__':
    n = 10000000
    start = datetime.datetime.now()

    processes = []
    for _ in range(4):
        process = multiprocessing.Process(
            target=busy_wait, args=(n,))
        process.start()
        processes.append(process)

    for process in processes:
        process.join()

    end = datetime.datetime.now()
    print('The multiprocessed loops took: %s' % (end - start))

运行时,我们看到了巨大的改进:

# python3 test_multiprocessing.py
The multiprocessed loops took: 0:00:00.671249

请注意,这是在四核处理器上运行的,这就是为什么我选择了四个进程。multiprocessing库默认为multiprocessing.cpu_count(),它计算可用的 CPU 核心数,但该方法未考虑 CPU 超线程。这意味着在我的情况下它会返回 8,这就是为什么我将其硬编码为 4 的原因。

注意

重要的是要注意,因为multiprocessing库使用多个进程,代码需要从子进程中导入。结果是multiprocessing库无法在 Python 或 IPython shell 中工作。正如我们将在本章后面看到的那样,IPython 有自己的多进程处理方式。

超线程与物理 CPU 核心

在大多数情况下,超线程非常有用并提高了性能,但当您真正最大化 CPU 使用率时,通常最好只使用物理处理器数量。为了演示这如何影响性能,我们将再次运行上一节中的测试。这次使用 1、2、4、8 和 16 个进程来演示它如何影响性能。幸运的是,multiprocessing库有一个很好的Pool类来为我们管理进程:

import sys
import datetime
import multiprocessing

def busy_wait(n):
    while n > 0:
        n -= 1

if __name__ == '__main__':
    n = 10000000
    start = datetime.datetime.now()
    if sys.argv[-1].isdigit():
        processes = int(sys.argv[-1])
    else:
        print('Please specify the number of processes')
        print('Example: %s 4' % ' '.join(sys.argv))
        sys.exit(1)

    with multiprocessing.Pool(processes=processes) as pool:
        # Execute the busy_wait function 8 times with parameter n
        pool.map(busy_wait, [n for _ in range(8)])

    end = datetime.datetime.now()
    print('The multithreaded loops took: %s' % (end - start))

池代码使得启动一组工作进程和处理队列变得更加简单。在这种情况下,我们使用了map,但还有其他几个选项,如imapmap_asyncimap_unorderedapplyapply_asyncstarmapstarmap_async。由于这些方法与同名的itertools方法工作方式非常相似,因此不会为所有这些方法提供具体示例。

但现在,测试不同数量的进程:

# python3 test_multiprocessing.py 1
The multithreaded loops took: 0:00:05.297707
# python3 test_multiprocessing.py 2
The multithreaded loops took: 0:00:02.701344
# python3 test_multiprocessing.py 4
The multithreaded loops took: 0:00:01.477845
# python3 test_multiprocessing.py 8
The multithreaded loops took: 0:00:01.579218
# python3 test_multiprocessing.py 16
The multithreaded loops took: 0:00:01.595239

您可能没有预料到这些结果,但这正是超线程的问题所在。一旦单个进程实际上使用了 CPU 核心的 100%,进程之间的任务切换实际上会降低性能。由于只有4个物理核心,其他4个核心必须争夺处理器核心上的任务。这场争斗需要时间,这就是为什么4个进程版本比8个进程版本稍快的原因。此外,调度效果也可以在使用12个核心的运行中看到。如果我们看单核版本,我们会发现它花了5.3秒,这意味着4个核心应该在5.3 / 4 = 1.325秒内完成,而实际上花了1.48秒。2核版本也有类似的效果,2.7 / 2 = 1.35秒,仍然比4核版本快。

如果您真的需要处理 CPU 绑定问题的性能,那么匹配物理 CPU 核心是最佳解决方案。如果您不希望始终最大化所有核心的使用,那么我建议将其保留为默认设置,因为超线程在其他情况下确实具有一些性能优势。

但这一切取决于您的用例,确切的方法是测试您特定情况的唯一方法:

  • 磁盘 I/O 绑定?单个进程很可能是您最好的选择。

  • CPU 绑定?物理 CPU 核心数量是您最好的选择。

  • 网络 I/O 绑定?从默认值开始,如果需要,进行调整。

  • 没有明显的限制,但需要许多并行进程?也许您应该尝试asyncio而不是multiprocessing

请注意,创建多个进程在内存和打开文件方面并不是免费的,而您可以拥有几乎无限数量的协程,但对于进程来说并非如此。根据您的操作系统配置,它可能在您甚至达到一百之前就达到最大值,即使您达到这些数字,CPU 调度也将成为瓶颈。

创建一个工作进程池

创建一个工作进程的处理池通常是一个困难的任务。您需要注意调度作业,处理队列,处理进程,以及最困难的部分是在进程之间处理同步而不会产生太多开销。

然而,使用multiprocessing,这些问题已经得到解决。您只需创建一个具有给定进程数的进程池,并在需要时添加任务即可。以下是map操作符的多进程版本的示例,并演示了处理不会使应用程序停滞:

import time
import multiprocessing

def busy_wait(n):
    while n > 0:
        n -= 1

if __name__ == '__main__':
    n = 10000000
    items = [n for _ in range(8)]
    with multiprocessing.Pool() as pool:
        results = []
        start = time.time()
        print('Start processing...')
        for _ in range(5):
            results.append(pool.map_async(busy_wait, items))
        print('Still processing %.3f' % (time.time() - start))
        for result in results:
            result.wait()
            print('Result done %.3f' % (time.time() - start))
        print('Done processing: %.3f' % (time.time() - start))

处理本身非常简单。关键是池保持可用,您不需要等待它。只需在需要时添加作业,并在异步结果可用时使用它们:

# python3 test_pool.py
Start processing...
Still processing 0.000
Result done 1.513
Result done 2.984
Result done 4.463
Result done 5.978
Result done 7.388
Done processing: 7.388

在进程之间共享数据

这确实是多进程、多线程和分布式编程中最困难的部分——要传递哪些数据,要跳过哪些数据。然而,理论上非常简单:尽可能不传输任何数据,不共享任何东西,保持一切本地。本质上是函数式编程范式,这就是为什么函数式编程与多进程非常搭配。不幸的是,在实践中,这并不总是可能的。multiprocessing库有几种共享数据的选项:PipeNamespaceQueue和其他一些选项。所有这些选项可能会诱使您一直在进程之间共享数据。这确实是可能的,但在许多情况下,性能影响要比分布式计算提供的额外性能更大。所有数据共享选项都需要在所有处理内核之间进行同步,这需要很长时间。特别是在分布式选项中,这些同步可能需要几毫秒,或者如果在全局范围内执行,可能会导致数百毫秒的延迟。

多进程命名空间的行为与常规对象的工作方式相同,只是有一个小差异,即所有操作都对多进程是安全的。有了这么多功能,命名空间仍然非常容易使用:

import multiprocessing
manager = multiprocessing.Manager()
namespace = manager.Namespace()
namespace.spam = 123
namespace.eggs = 456

管道也没有那么有趣。它只是一个双向通信端点,允许读和写。在这方面,它只是为您提供了一个读取器和一个写入器,因此您可以组合多个进程/端点。在同步数据时,您必须始终记住的唯一一件事是,锁定需要时间。为了设置适当的锁,所有参与方都需要同意数据已被锁定,这是一个需要时间的过程。这个简单的事实比大多数人预期的要慢得多。

在常规硬盘设置上,由于锁定和磁盘延迟,数据库服务器无法处理同一行上超过大约 10 个事务每秒。使用延迟文件同步、固态硬盘和带电池备份的 RAID 缓存,该性能可以增加到,也许,每秒处理同一行上的 100 个事务。这些都是简单的硬件限制,因为您有多个进程尝试写入单个目标,您需要在进程之间同步操作,这需要很长时间。

注意

“数据库服务器”统计数据是所有提供安全和一致数据存储的数据库服务器的常见统计数据。

即使使用最快的硬件,同步也可能锁定所有进程并导致巨大的减速,因此如果可能的话,尽量避免在多个进程之间共享数据。简而言之,如果所有进程都从/向同一对象读取和写入,通常使用单个进程会更快。

远程进程

到目前为止,我们只在多个本地处理器上执行了我们的脚本,但实际上我们可以进一步扩展。使用multiprocessing库,实际上非常容易在远程服务器上执行作业,但文档目前仍然有点晦涩。实际上有几种以分布式方式执行进程的方法,但最明显的方法并不是最容易的方法。multiprocessing.connection模块具有ClientListener类,可以以简单的方式促进客户端和服务器之间的安全通信。然而,通信并不同于进程管理和队列管理,这些功能需要额外的努力。在这方面,多进程库仍然有点简陋,但鉴于一些不同的进程,这是完全可能的。

使用多进程进行分布式处理

首先,我们将从一个包含一些常量的模块开始,这些常量应该在所有客户端和服务器之间共享,因此所有人都可以使用服务器的秘密密码和主机名。除此之外,我们将添加我们的质数计算函数,稍后我们将使用它们。以下模块中的导入将期望将此文件存储为constants.py,但是只要您修改导入和引用,可以随意将其命名为任何您喜欢的名称:

host = 'localhost'
port = 12345
password = b'some secret password'

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

现在是时候创建实际的服务器,将函数和作业队列链接起来了。

import constants
import multiprocessing
from multiprocessing import managers

queue = multiprocessing.Queue()
manager = managers.BaseManager(address=('', constants.port),
                               authkey=constants.password)

manager.register('queue', callable=lambda: queue)
manager.register('primes', callable=constants.primes)

server = manager.get_server()
server.serve_forever()

创建服务器后,我们需要一个发送作业的脚本,实际上将是一个常规客户端。这真的很简单,一个常规客户端也可以作为处理器,但为了保持事情合理,我们将它们用作单独的脚本。以下脚本将将 0 添加到 999 以进行处理:

from multiprocessing import managers
import functions

manager = managers.BaseManager(
    address=(functions.host, functions.port),
    authkey=functions.password)
manager.register('queue')
manager.connect()

queue = manager.queue()
for i in range(1000):
    queue.put(i)

最后,我们需要创建一个客户端来实际处理队列:

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 multiprocessing_server.py

之后,运行生产者生成质数生成请求:

# python3 multiprocessing_producer.py

现在我们可以在多台机器上运行多个客户端,以获得前 1000 个质数。由于这些客户端现在打印出前 1000 个质数,输出有点太长,无法在这里显示,但您可以简单地在多台机器上并行运行此操作以生成您的输出:

# python3 multiprocessing_client.py

您可以使用队列或管道将输出发送到不同的进程,而不是打印。但是,正如您所看到的,要并行处理事物仍然需要一些工作,并且需要一些代码同步才能正常工作。还有一些可用的替代方案,例如ØMQCeleryIPyparallel。哪种是最好和最合适的取决于您的用例。如果您只是想在多个 CPU 上处理任务,那么多进程和 IPyparallel 可能是您最好的选择。如果您正在寻找后台处理和/或轻松地将任务卸载到多台机器上,那么ØMQ 和 Celery 是更好的选择。

使用 IPyparallel 进行分布式处理

IPyparallel 模块(以前是 IPython Parallel)是一个模块,使得在多台计算机上同时处理代码变得非常容易。该库支持的功能比您可能需要的要多,但了解基本用法非常重要,以防您需要进行可以从多台计算机中受益的大量计算。首先,让我们从安装最新的 IPyparallel 包和所有 IPython 组件开始:

pip 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'

这些配置文件包含大量的选项,因此我建议搜索特定部分而不是逐个浏览它们。快速列出给我总共约 2500 行配置,分布在这五个文件中。文件名已经提供了关于配置文件目的的提示,但由于它们仍然有点令人困惑,我们将更详细地解释它们。

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] Hub using DB backend: 'NoDB'
[IPControllerApp] hub::created hub
[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
[IPControllerApp] task::using Python leastload Task scheduler
[IPControllerApp] Heartmonitor started
[IPControllerApp] Creating pid file: .ipython/profile_mastering_python/pid/ipcontroller.pid
[scheduler] Scheduler started [leastload]
[IPControllerApp] client::client b'\x00\x80\x00A\xa7' requested 'connection_request'
[IPControllerApp] client::client [b'\x00\x80\x00A\xa7'] connected

注意已写入配置文件目录的安全目录中的文件。它包含了ipengine用于找到ipcontroller的身份验证信息。它包含端口、加密密钥和 IP 地址。

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。或者对于 Windows HPC,可以选择ipyparallel.apps.launcher.WindowsHPCEngineSetLauncher

在所有机器上创建集群之前,我们需要传输配置文件。您可以选择传输所有文件,也可以选择仅传输 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 的独立实体,但它与之整合良好,这使得它们很容易结合起来。

使用 IPyParallel 最方便的方法之一是通过 Jupyter/IPython 笔记本。为了演示,我们首先必须确保在 Jupyter Notebook 中启用并行处理,因为 IPython 笔记本默认情况下是单线程执行的:

ipcluster nbextension enable

之后,我们可以启动notebook,看看它是怎么回事:

# jupyter notebook
Unrecognized JSON config file version, assuming version 1
Loading IPython parallel extension
Serving notebooks from local directory: ./
0 active kernels
The Jupyter Notebook is running at: http://localhost:8888/
Use Control-C to stop this server and shut down all kernels (twice to skip confirmation).

使用 Jupyter Notebook,您可以在 Web 浏览器中创建脚本,稍后可以轻松与他人共享。这对于共享脚本和调试代码非常有用,特别是因为 Web 页面(与命令行环境相反)可以轻松显示图像。这对于绘制数据有很大帮助。这是我们笔记本的屏幕截图:

ipcluster_config.py

总结

本章向我们展示了多进程的工作原理,我们如何可以汇集大量的工作,并且我们应该如何在多个进程之间共享数据。但更有趣的是,它还展示了我们如何可以在多台机器之间分发处理,这在加速繁重的计算方面非常有帮助。

您可以从本章中学到的最重要的一课是,您应该尽量避免在多个进程或服务器之间共享数据和同步,因为这样做会很慢,从而大大减慢应用程序的速度。在可能的情况下,保持计算和数据本地。

在下一章中,我们将学习如何在 C/C++中创建扩展,以提高性能并允许对内存和其他硬件资源进行低级访问。虽然 Python 通常会保护您免受愚蠢的错误,但 C 和 C++肯定不会。

“C 使得自己踩到脚趾头很容易;C++让这变得更难,但一旦你踩到了,它会把整条腿都炸掉。”
--Bjarne Stroustrup(C++的创造者)

第十四章:C/C++扩展,系统调用和 C/C++库

现在我们对性能和多处理有了更多了解,我们将解释另一个至少与性能有关的主题——使用 C 和/或 C++扩展。

有多个原因需要考虑 C/C++扩展。拥有现有库可用是一个重要原因,但实际上,最重要的原因是性能。在第十二章中,性能-跟踪和减少内存和 CPU 使用情况,我们看到cProfile模块大约比profile模块快 10 倍,这表明至少一些 C 扩展比它们的纯 Python 等效快。然而,本章不会太注重性能。这里的目标是与非 Python 库的交互。任何性能改进只会是一个完全无意的副作用。

在本章中,我们将讨论以下选项:

  • 用于处理 Python 中的外部(C/C++)函数和数据的 Ctypes

  • CFFIC Foreign Function Interface的缩写),类似于ctypes但是有稍微不同的方法

  • 使用本机 C/C++扩展 Python

介绍

在开始本章之前,重要的是要注意,本章将需要一个与你的 Python 解释器良好配合的工作编译器。不幸的是,这些因平台而异。虽然对于大多数 Linux 发行版来说通常很容易,但在 Windows 上可能是一个很大的挑战。对于 OS X 来说,通常很容易,只要你安装了正确的工具。

通用的构建说明始终可以在 Python 手册中找到:

docs.python.org/3.5/extending/building.html

你需要 C/C++模块吗?

在几乎所有情况下,我倾向于说你不需要 C/C++模块。如果你真的需要最佳性能,那么几乎总是有高度优化的库可用来满足你的目的。有一些情况下,需要本机 C/C++(或者只是“不是 Python”)。如果你需要直接与具有特定时间的硬件通信,那么 Python 可能对你来说行不通。然而,一般来说,这种通信应该留给负责特定时间的驱动程序。无论如何,即使你永远不会自己编写这些模块之一,当你调试项目时,你可能仍然需要知道它们的工作原理。

Windows

对于 Windows,一般建议使用 Visual Studio。具体的版本取决于你的 Python 版本:

  • Python 3.2 及更低版本:Microsoft Visual Studio 2008

  • Python 3.3 和 3.4:Microsoft Visual Studio 2010

  • Python 3.5 和 3.6:Microsoft Visual Studio 2015

安装 Visual Studio 和编译 Python 模块的具体细节有点超出了本书的范围。幸运的是,Python 文档中有一些文档可以帮助你入门:

docs.python.org/3.5/extending/windows.html

OS X

对于 Mac,这个过程大多是直接的,但是有一些特定于 OS X 的技巧。

首先,通过 Mac App Store 安装 Xcode。一旦你这样做了,你应该能够运行以下命令:

xcode-select --install

接下来是有趣的部分。因为 OS X 带有捆绑的 Python 版本(通常已过时),我建议通过 Homebrew 安装一个新的 Python 版本。安装 Homebrew 的最新说明可以在 Homebrew 主页上找到(brew.sh/),但安装 Homebrew 的要点是这个命令:

# /usr/bin/ruby -e "$(curl -fsSL \
 **https://raw.githubusercontent.com/Homebrew/install/master/install)"

之后,确保使用doctor命令检查一切是否设置正确:

# brew doctor

当所有这些都完成时,只需通过 Homebrew 安装 Python,并确保在执行脚本时使用该 Python 版本:

# brew install python3
# python3 –version
Python 3.5.1
which python3
/usr/local/bin/python3

还要确保 Python 进程在/usr/local/bin中,也就是自制版本。常规的 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.5

请注意,Python 3.5 并不是随处都可用的,所以您可能需要使用 Python 3.4。

提示

对于大多数系统,要获取安装帮助,可以通过类似<操作系统> 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...>

由于这些限制,不是所有示例都可以在每个 Python 版本和发行版上工作,而不需要手动编译。从外部库调用函数的基本前提是简单地将它们的名称作为ctypes导入的属性来访问。然而,有一个区别;在 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 系统上进行一次运行:

>>> from ctypes import util
>>> from ctypes import cdll
>>> libc = cdll.LoadLibrary(util.find_library('libc'))
>>> libc
<CDLL '/usr/lib/libc.dylib', handle ... at 0x...>

调用函数和本机类型

通过ctypes调用函数几乎和调用本机 Python 函数一样简单。显著的区别在于参数和返回语句。这些应该转换为本机 C 变量:

注意

这些示例将假定您在前几段中的一个示例中已经将libc纳入了范围。

>>> spam = ctypes.create_string_buffer(b'spam')
>>> ctypes.sizeof(spam)
5
>>> spam.raw
b'spam\x00'
>>> spam.value
b'spam'
>>> libc.printf(spam)
4
spam>>>

正如您所看到的,要调用printf函数,您必须——我无法再次强调这一点——将您的值从 Python 显式转换为 C。虽然最初可能看起来可以工作,但实际上并不行:

>>> libc.printf(123)
segmentation fault (core dumped)  python3

注意

请记住使用第十一章中的faulthandler模块,调试-解决错误来调试段错误。

从这个例子中需要注意的另一件事是 ctypes.sizeof(spam) 返回 5 而不是 4。这是由 C 字符串所需的尾随空字符引起的。这在 C 字符串的原始属性中是可见的。如果没有它,printf 函数就不知道字符串在哪里结束。

要将其他类型(如整数)传递给 libc 函数,我们也必须进行一些转换。在某些情况下,这是可选的:

>>> format_string = ctypes.create_string_buffer(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 = ctypes.create_string_buffer(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,但如果我们需要更复杂的对象呢?也就是说,不仅仅是直接可转换为 C 的裸值,而是包含多个值的复杂对象。幸运的是,我们可以很容易地使用 ctypes 创建(和访问)C 结构:

>>> class Spam(ctypes.Structure):
...     _fields_ = [
...         ('spam', ctypes.c_int),
...         ('eggs', ctypes.c_double),
...     ]
...>>> spam = Spam(123, 456.789)
>>> spam.spam
123
>>> spam.eggs
456.789

数组

在 Python 中,我们通常使用列表来表示对象的集合。这些非常方便,因为你可以很容易地添加和删除值。在 C 中,默认的集合对象是数组,它只是一个具有固定大小的内存块。

以字节为单位的块的大小是通过将项数乘以类型的大小来决定的。在 char 的情况下,这是 8 位,所以如果你想存储 100 个字符,你将有 100 * 8 位 = 800 位 = 100 字节

这实际上就是一个内存块,C 给你的唯一引用是指向内存块起始地址的指针。由于指针有类型,在这种情况下是 char*,C 就知道在尝试访问不同项时需要跳过多少字节。实际上,在尝试访问 char 数组中的第 25 项时,你只需要执行 array_pointer + 25 * sizeof(char)。这有一个方便的快捷方式:array_pointer[25]

请注意,C 不会存储数组中的项数,因此即使我们的数组只有 100 项,我们也可以执行 array_pointer[1000] 并读取其他(随机)内存。

如果你考虑了所有这些,它绝对是可用的,但错误很快就会发生,而且 C 是不可原谅的。没有警告,只有崩溃和奇怪的行为代码。除此之外,让我们看看我们如何使用 ctypes 轻松地声明一个数组:

>>> TenNumbers = 10 * ctypes.c_double
>>> numbers = TenNumbers()
>>> numbers[0]
0.0

正如你所看到的,由于固定的大小和在使用之前声明类型的要求,它的使用略显笨拙。然而,它确实像你期望的那样运行,并且这些值默认初始化为零。显然,这也可以与先前讨论的结构相结合:

>>> Spams = 5 * Spam
>>> spams = Spams()
>>> spams[0].eggs = 123.456
>>> spams
<__main__.Spam_Array_5 object at 0x...>
>>> spams[0]
<__main__.Spam object at 0x...>
>>> spams[0].eggs
123.456
>>> spams[0].spam
0

尽管你不能简单地追加这些数组来调整它们的大小,但它们实际上是可调整大小的,有一些限制。首先,新数组的大小需要大于原始数组。其次,大小需要以字节为单位指定,而不是项数。举个例子,我们有这个例子:

>>> 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]

内存管理的注意事项

除了明显的内存分配问题和混合可变和不可变对象之外,还有一个奇怪的内存可变性问题:

>>> class Point(ctypes.Structure):
...     _fields_ = ('x', ctypes.c_int), ('y', ctypes.c_int)
...
>>> class Vertex(ctypes.Structure):
...     _fields_ = ('a', Point), ('b', Point), ('c', Point)
...
>>> v = Vertex()
>>> v.a = Point(0, 1)
>>> v.b = Point(2, 3)
>>> v.c = Point(4, 5)
>>> v.a.x, v.a.y, v.b.x, v.b.y, v.c.x, v.c.y
(0, 1, 2, 3, 4, 5)
>>> v.a, v.b, v.c = v.b, v.c, v.a
>>> v.a.x, v.a.y, v.b.x, v.b.y, v.c.x, v.c.y
(2, 3, 4, 5, 2, 3)
>>> v.a.x = 123
>>> v.a.x, v.a.y, v.b.x, v.b.y, v.c.x, v.c.y
(123, 3, 4, 5, 2, 3)

为什么我们没有得到2, 3, 4, 5, 0, 1?问题在于这些对象被复制到一个临时缓冲变量中。与此同时,该对象的值正在发生变化,因为它在内部包含了单独的对象。之后,对象被传回,但值已经改变,导致了不正确的结果。

CFFI

CFFI库提供了与ctypes非常相似的选项,但它更直接一些。与ctypes库不同,C 编译器对于CFFI来说确实是必需的。它带来了直接以非常简单的方式调用你的 C 编译器的机会:

>>> import cffi
>>> ffi = cffi.FFI()
>>> ffi.cdef('int printf(const char* format, ...);')
>>> libc = ffi.dlopen(None)
>>> arg = ffi.new('char[]', b'spam')
>>> libc.printf(arg)
4
spam>>>

好吧...看起来有点奇怪对吧?我们不得不定义printf函数的外观,并用有效的 C 类型声明指定printf的参数。然而,回到声明,而不是Noneffi.dlopen,你也可以指定你希望加载的库。如果你记得ctypes.util.find_library函数,你可以在这种情况下再次使用它:

>>> from ctypes import util
>>> import cffi
>>> libc = ffi.dlopen(util.find_library('libc'))
>>> ffi.printf
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
AttributeError: 'FFI' object has no attribute 'printf'

但它仍然不会为你提供其定义。函数定义仍然是必需的,以确保一切都按照你希望的方式工作。

复杂的数据结构

CFFI的定义与ctypes的定义有些相似,但不是让 Python 模拟 C,而是直接从 Python 访问纯 C。实际上,这只是一个小的语法差异。而ctypes是一个用于从 Python 访问 C 的库,同时尽可能接近 Python 语法,CFFI使用纯 C 语法来访问 C 系统,这实际上消除了一些对于熟悉 C 的人的困惑。我个人发现CFFI更容易使用,因为我知道实际发生了什么,而对于ctypes,我并不总是 100%确定。让我们用 CFFI 重复VertexPoint的例子:

>>> import cffi
>>> ffi = cffi.FFI()
>>> ffi.cdef('''
... typedef struct {
...     int x;
...     int y;
... } point;
...
... typedef struct {
...     point a;
...     point b;
...     point c;
... } vertex;
... ''')
>>> vertices = ffi.new('vertex[]', 5)
>>> v = vertices[0]
>>> v.a.x = 1
>>> v.a.y = 2
>>> v.b.x = 3
>>> v.b.y = 4
>>> v.c.x = 5
>>> v.c.y = 6
>>> v.a.x, v.a.y, v.b.x, v.b.y, v.c.x, v.c.y
(1, 2, 3, 4, 5, 6)
v.a, v.b, v.c = v.b, v.c, v.a
v.a.x, v.a.y, v.b.x, v.b.y, v.c.x, v.c.y
>>> v.a, v.b, v.c = v.b, v.c, v.a
>>> v.a.x, v.a.y, v.b.x, v.b.y, v.c.x, v.c.y
(3, 4, 5, 6, 3, 4)

你可以看到,可变变量问题仍然存在,但代码仍然是可以使用的。

数组

使用CFFI为新变量分配内存几乎是微不足道的。前面的段落向你展示了数组分配的一个例子;现在让我们看看数组定义的可能性:

>>> import cffi
>>> ffi = cffi.FFI()
>>> x = ffi.new('int[10]')
>>> y = ffi.new('int[]', 10)
>>> x[0:10] = range(10)
>>> y[0:10] = range(10, 0, -1)
>>> list(x)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list(y)
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

在这种情况下,你可能会想知道为什么切片包括起始和结束。这实际上是CFFI的要求。并不总是有问题,但仍然有点烦人。然而,目前,这是不可避免的。

ABI 还是 API?

像往常一样,还有一些注意事项——不幸的是。到目前为止的例子部分使用了 ABI,它从库中加载二进制结构。对于标准 C 库,这通常是安全的;对于其他库,通常不是。API 和 ABI 之间的区别在于后者在二进制级别调用函数,直接寻址内存,直接调用内存位置,并期望它们是函数。实际上,这是ffi.dlopenffi.cdef之间的区别。在这里,dlopen并不总是安全的,但cdef是安全的,因为它传递了一个编译器,而不仅仅是猜测如何调用一个方法。

CFFI 还是 ctypes?

这实际上取决于你在寻找什么。如果你有一个 C 库,只需要调用而且不需要任何特殊的东西,那么ctypes很可能是更好的选择。如果你实际上正在编写自己的 C 库并尝试链接它,那么CFFI可能是一个更方便的选择。如果你不熟悉 C 编程语言,那么我肯定会推荐ctypes。或者,你会发现CFFI是一个更方便的选择。

本地 C/C++扩展

到目前为止,我们使用的库只是向我们展示了如何在我们的 Python 代码中访问 C/C++库。现在我们将看看故事的另一面——实际上是如何编写 Python 中的 C/C++函数/模块以及如何创建cPicklecProfile等模块。

一个基本的例子

在我们实际开始编写和使用本地 C/C++扩展之前,我们有一些先决条件。首先,我们需要编译器和 Python 头文件;本章开头的说明应该已经为我们处理了这些。之后,我们需要告诉 Python 要编译什么。setuptools包大部分会处理这个问题,但我们确实需要创建一个setup.py文件:

import setuptools

spam = setuptools.Extension('spam', sources=['spam.c'])

setuptools.setup(
    name='Spam',
    version='1.0',
    ext_modules=[spam],
)

这告诉 Python 我们有一个名为SpamExtension对象,它将基于spam.c

现在,让我们在 C 中编写一个函数,它将对给定数字之前的所有完全平方数(2*23*3等)进行求和。Python 代码将如下所示:

def sum_of_squares(n):
    sum = 0

    for i in range(n):
        if i * i < n:
            sum += i * i
        else:
            break

    return sum

这段代码的原始 C 版本看起来像这样:

long sum_of_squares(long n){
    long sum = 0;

    /* The actual summing code */
    for(int i=0; i<n; i++){
        if((i * i) < n){
            sum += i * i;
        }else{
            break;
        }
    }

    return sum;
}

Python C 版本看起来像这样:

#include <Python.h>

static PyObject* spam_sum_of_squares(PyObject *self, PyObject
        *args){
    /* Declare the variables */
    int n;
    int sum = 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){
            sum += i * i;
        }else{
            break;
        }
    }

    /* Return the number but convert it to a Python object first
     */
    return PyLong_FromLong(sum);
}

static PyMethodDef spam_methods[] = {
    /* Register the function */
    {"sum_of_squares", spam_sum_of_squares, METH_VARARGS,
     "Sum the perfect squares below n"},
    /* Indicate the end of the list */
    {NULL, NULL, 0, NULL},
};

static struct PyModuleDef spam_module = {
    PyModuleDef_HEAD_INIT,
    "spam", /* Module name */
    NULL, /* Module documentation */
    -1, /* Module state, -1 means global. This parameter is
           for sub-interpreters */
    spam_methods,
};

/* Initialize the module */
PyMODINIT_FUNC PyInit_spam(void){
    return PyModule_Create(&spam_module);
}

看起来很复杂,但实际上并不难。在这种情况下,只是有很多额外的开销,因为我们只有一个函数。通常情况下,你会有几个函数,这种情况下你只需要扩展spam_methods数组并创建函数。下一段将更详细地解释代码,但首先让我们看一下如何运行我们的第一个示例。我们需要构建并安装模块:

# python setup.py build install
running build
running build_ext
running install
running install_lib
running install_egg_info
Removing lib/python3.5/site-packages/Spam-1.0-py3.5.egg-info
Writing lib/python3.5/site-packages/Spam-1.0-py3.5.egg-info

现在,让我们创建一个小的测试脚本来测试 Python 版本和 C 版本之间的差异:

import sys
import spam
import timeit

def sum_of_squares(n):
    sum = 0

    for i in range(n):
        if i * i < n:
            sum += i * i
        else:
            break

    return sum

if __name__ == '__main__':
    c = int(sys.argv[1])
    n = int(sys.argv[2])
    print('%d executions with n: %d' % (c, n))
    print('C sum of squares: %d took %.3f seconds' % (
        spam.sum_of_squares(n),
        timeit.timeit('spam.sum_of_squares(n)', number=c,
                      globals=globals()),
    ))
    print('Python sum of squares: %d took %.3f seconds' % (
        sum_of_squares(n),
        timeit.timeit('sum_of_squares(n)', number=c,
                      globals=globals()),
    ))

现在让我们执行它:

# python3 test_spam.py 10000 1000000
10000 executions with n: 1000000
C sum of squares: 332833500 took 0.008 seconds
Python sum of squares: 332833500 took 1.778 seconds

太棒了!完全相同的结果,但速度快了 200 多倍!

C 不是 Python-大小很重要

Python 语言使编程变得如此简单,以至于你有时可能会忘记底层数据结构;而在 C 中,你不能这样做。只需拿我们上一章的示例,但使用不同的参数:

# python3 test_spam.py 1000 10000000
1000 executions with n: 10000000
C sum of squares: 1953214233 took 0.002 seconds
Python sum of squares: 10543148825 took 0.558 seconds

它仍然非常快,但数字发生了什么?Python 和 C 版本给出了不同的结果,195321423310543148825。这是由 C 中的整数溢出引起的。而 Python 数字基本上可以有任何大小,而 C 中,常规数字有固定的大小。你得到多少取决于你使用的类型(intlong等)和你的架构(32 位,64 位等),但这绝对是需要小心的事情。在某些情况下,它可能快上数百倍,但如果结果不正确,那就毫无意义了。

当然,我们可以稍微增加一点大小。这样会更好:

static PyObject* spam_sum_of_squares(PyObject *self, PyObject *args){
    /* Declare the variables */
    unsigned long long int n;
    unsigned long long int sum = 0;

    /* Parse the arguments */
    if(!PyArg_ParseTuple(args, "K", &n)){
        return NULL;
    }

    /* The actual summing code */
    for(unsigned long long int i=0; i<n; i++){
        if((i * i) < n){
            sum += i * i;
        }else{
            break;
        }
    }

    /* Return the number but convert it to a Python object first */
    return PyLong_FromUnsignedLongLong(sum);
}

如果我们现在测试它,我们会发现它运行得很好:

# python3 test_spam.py 1000 100000001000 executions with n: 10000000
C sum of squares: 10543148825 took 0.002 seconds
Python sum of squares: 10543148825 took 0.635 seconds

除非我们使数字更大:

# python3 test_spam.py 1 100000000000000 ~/Dropbox/Mastering Python/code/h14
1 executions with n: 100000000000000
C sum of squares: 1291890006563070912 took 0.006 seconds
Python sum of squares: 333333283333335000000 took 2.081 seconds

那么你该如何解决这个问题呢?简单的答案是你不能。复杂的答案是,如果你使用不同的数据类型来存储你的数据,你是可以的。C 语言本身并没有 Python 所具有的“大数支持”。Python 通过在实际内存中组合几个常规数字来支持无限大的数字。在 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 长整型并对其执行操作。因此,你可以在 C 中轻松获得大数支持。

示例解释

我们已经看到了我们示例的结果,但如果你不熟悉 Python C API,你可能会对为什么函数参数看起来像这样感到困惑。spam_sum_of_squares中的基本计算与常规 Csum_of_squares函数是相同的,但有一些小的不同。首先,使用 Python C API 定义函数的类型应该看起来像这样:

static PyObject* spam_sum_of_squares(PyObject *self, PyObject
 ***args)

静态

这意味着函数是static。静态函数只能从编译器内的同一翻译单元中调用。这实际上导致了一个函数,不能从其他模块链接,这允许编译器进一步优化。由于 C 中的函数默认是全局的,这可以非常有用地防止冲突。但为了确保,我们已经在函数名前加上了spam_前缀,以表明这个函数来自spam模块。

要小心,不要将此处的static与变量前面的static混淆。它们是完全不同的东西。static变量意味着该变量将存在于整个程序的运行时间,而不仅仅是函数的运行时间。

PyObject*

PyObject类型是 Python 数据类型的基本类型,这意味着所有 Python 对象都可以转换为PyObject*PyObject指针)。实际上,它只告诉编译器期望的属性类型,这些属性可以在以后用于类型识别和内存管理。而不是直接访问PyObject*,通常最好使用可用的宏,例如Py_TYPE(some_object)。在内部,这会扩展为(((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 */
    int sum = 0;

    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},
};

请注意,这仍然支持普通参数,但现在也支持关键字参数。

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 中抛出错误。让我们使用之前的spam模块,但为了简洁起见,我们将省略其余的 C 代码:

static PyObject* spam_eggs(PyObject *self, PyObject *args){
    PyErr_SetString(PyExc_RuntimeError, "Too many eggs!");
    return NULL;
}

static PyMethodDef spam_methods[] = {
    /* Register the function */
    {"eggs", spam_eggs, METH_VARARGS,
     "Count the eggs"},
    /* Indicate the end of the list */
    {NULL, NULL, 0, NULL},
};

这是执行过程:

# python3 setup.py clean build install
...
# python3 -c 'import spam; spam.eggs()'
Traceback (most recent call last):
 **File "<string>", line 1, in <module>
RuntimeError: Too many eggs!

语法略有不同——PyErr_SetString而不是raise——但基本原理是相同的,幸运的是。

从 C 调用 Python-处理复杂类型

我们已经看到如何从 Python 调用 C 函数,但现在让我们尝试从 C 返回 Python。我们将构建一个自己的回调函数,并处理任何类型的可迭代对象,而不是使用现成的sum函数。虽然这听起来足够简单,但实际上确实需要一些类型干涉,因为你只能期望PyObject*作为参数。这与简单类型相反,例如整数、字符和字符串,它们会立即转换为本机 Python 版本:

static PyObject* spam_sum(PyObject* self, PyObject* args){
    /* Declare all variables, note that the values for sum and
     * callback are defaults in the case these arguments are not
     * specified */
    long long int sum = 0;
    int overflow = 0;
    PyObject* iterator;
    PyObject* iterable;
    PyObject* callback = NULL;
    PyObject* value;
    PyObject* item;

    /* Now we parse a PyObject* followed by, optionally
     * (the | character), a PyObject* and a long long int */
    if(!PyArg_ParseTuple(args, "O|OL", &iterable, &callback,
                &sum)){
        return NULL;
    }

    /* See if we can create an iterator from the iterable. This is
     * effectively the same as doing iter(iterable) in Python */
    iterator = PyObject_GetIter(iterable);
    if(iterator == NULL){
        PyErr_SetString(PyExc_TypeError,
                "Argument is not iterable");
        return NULL;
    }

    /* Check if the callback exists or wasn't specified. If it was
     * specified check whether it's callable or not */
    if(callback != NULL && !PyCallable_Check(callback)){
        PyErr_SetString(PyExc_TypeError,
                "Callback is not callable");
        return NULL;
    }

    /* Loop through all items of the iterable */
    while((item = PyIter_Next(iterator))){
        /* If we have a callback available, call it. Otherwise
         * just return the item as the value */
        if(callback == NULL){
            value = item;
        }else{
            value = PyObject_CallFunction(callback, "O", item);
        }

        /* Add the value to sum and check for overflows */
        sum += 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;
        }

        /* If we were indeed using the callback, decrease the
         * reference count to the value because it is a separate
         * object now */
        if(callback != NULL){
            Py_DECREF(value);
        }
        Py_DECREF(item);
    }
    Py_DECREF(iterator);

    return PyLong_FromLongLong(sum);
}

确保您注意PyDECREF调用,这样可以确保您不会泄漏这些对象。如果没有它们,对象将继续使用,Python 解释器将无法清除它们。

这个函数可以以三种不同的方式调用:

>>> import spam
>>> x = range(10)
>>> spam.sum(x)
45
>>> spam.sum(x, lambda y: y + 5)
95
>>> spam.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

总结

在本章中,您学习了使用ctypesCFFI编写代码以及如何使用本机 C 扩展 Python 功能的最重要方面。这些主题本身就足够广泛,可以填满一本书,但是现在您应该掌握了最重要的主题。即使您现在能够创建 C/C++扩展,我仍然建议您尽量避免这样做。这是因为不够小心很容易出现错误。实际上,至少本章中的一些示例在内存管理方面可能存在错误,并且在给出错误输入时可能会使您的 Python 解释器崩溃。不幸的是,这是 C 的副作用。一个小错误可能会产生巨大的影响。

在构建本章中的示例时,您可能已经注意到我们使用了一个setup.py文件,并从setuptools库导入。下一章将涵盖这一点——将您的代码打包成可安装的 Python 库,并在 Python 软件包索引上进行分发。

第十五章:包装-创建您自己的库或应用程序

到目前为止,这些章节已经涵盖了如何编写、测试和调试 Python 代码。有了这一切,只剩下一件事,那就是打包和分发您的 Python 库/和应用程序。为了创建可安装的包,我们将使用 Python 这些天捆绑的setuptools包。如果您以前创建过包,您可能还记得distributedistutils2,但非常重要的是要记住,这些都已经被setuptoolsdistutils取代,您不应该再使用它们!

我们可以使用setuptools打包哪些类型的程序?我们将向您展示几种情况:

  • 常规包

  • 带有数据的包

  • 安装可执行文件和自定义setuptools命令

  • 在包上运行测试

  • 包含 C/C++扩展的包

安装包

在我们真正开始之前,重要的是要知道如何正确安装包。至少有四种不同的选项可以安装包。第一种最明显的方法是使用普通的pip命令:

pip install package

这也可以通过直接使用setup.py来实现:

cd package
python setup.py install

这将在您的 Python 环境中安装包,如果您使用它,可能是virtualenv/venv,否则是全局环境。

然而,对于开发来说,这是不推荐的。要测试您的代码,您需要为每个测试重新安装包,或者修改 Python 的site-packages目录中的文件,这意味着它将位于您的修订控制系统之外。这就是开发安装的用途;它们不是将包文件复制到 Python 包目录中,而是在site-packages目录中安装到实际包位置的路径的链接。这使您可以修改代码,并立即在运行的脚本和应用程序中看到结果,而无需在每次更改后重新安装代码。

与常规安装一样,pipsetup.py版本都可用:

pip install –e package_directory

以及setup.py版本:

cd package_directory
python setup.py develop

设置参数

之前的章节实际上已经向我们展示了一些示例,但让我们重申和回顾最重要的部分实际上是做什么。在整个本章中,您将使用的核心功能是setuptools.setup

注意

对于最简单的包,Python 捆绑的distutils包将足够,但无论如何我推荐setuptoolssetuptools包具有许多distutils缺乏的出色功能,并且几乎所有 Python 环境都会有setuptools可用。

在继续之前,请确保您拥有最新版本的pipsetuptools

pip install -U pip setuptools

注意

setuptoolsdistutils包在过去几年中发生了重大变化,2014 年之前编写的文档/示例很可能已经过时。小心不要实现已弃用的示例,并跳过使用distutils的任何文档/示例。

既然我们已经具备了所有先决条件,让我们创建一个包含最重要字段的示例,并附带内联文档:

import setuptools

if __name__ == '__main__':
    setuptools.setup(
        name='Name',
        version='0.1',

        # This automatically detects the packages in the specified
        # (or current directory if no directory is given).
        packages=setuptools.find_packages(),

        # The entry points are the big difference between
        # setuptools and distutils, the entry points make it
        # possible to extend setuptools and make it smarter and/or
        # add custom commands.
        entry_points={

            # The following would add: python setup.py
            # command_name
            'distutils.commands': [
                'command_name = your_package:YourClass',
            ],

            # The following would make these functions callable as
            # standalone scripts. In this case it would add the
            # spam command to run in your shell.
            'console_scripts': [
                'spam = your_package:SpamClass',
            ],
        },

        # Packages required to use this one, it is possible to
        # specify simply the application name, a specific version
        # or a version range. The syntax is the same as pip
        # accepts.
        install_requires=['docutils>=0.3'],

        # Extra requirements are another amazing feature of
        # setuptools, it allows people to install extra
        # dependencies if you are interested. In this example
        # doing a "pip install name[all]" would install the
        # python-utils package as well.
        extras_requires={
            'all': ['python-utils'],
        },

        # Packages required to install this package, not just for
        # running it but for the actual install. These will not be
        # installed but only downloaded so they can be used during
        # the install. The pytest-runner is a useful example:
        setup_requires=['pytest-runner'],

        # The requirements for the test command. Regular testing
        # is possible through: python setup.py test The Pytest
        # module installs a different command though: python
        # setup.py pytest
        tests_require=['pytest'],

        # The package_data, include_package_data and
        # exclude_package_data arguments are used to specify which
        # non-python files should be included in the package. An
        # example would be documentation files.  More about this
        # in the next paragraph
        package_data={
            # Include (restructured text) documentation files from
            # any directory
            '': ['*.rst'],
            # Include text files from the eggs package:
            'eggs': ['*.txt'],
        },

        # If a package is zip_safe the package will be installed
        # as a zip file. This can be faster but it generally
        # doesn't make too much of a difference and breaks
        # packages if they need access to either the source or the
        # data files. When this flag is omitted setuptools will
        # try to autodetect based on the existance of datafiles
        # and C extensions. If either exists it will not install
        # the package as a zip. Generally omitting this parameter
        # is the best option but if you have strange problems with
        # missing files, try disabling zip_safe.
        zip_safe=False,

        # All of the following fileds are PyPI metadata fields.
        # When registering a package at PyPI this is used as
        # information on the package page.
        author='Rick van Hattem',
        author_email='wolph@wol.ph',

        # This should be a short description (one line) for the
        # package
        description='Description for the name package',

        # For this parameter I would recommend including the
        # README.rst

        long_description='A very long description',
        # The license should be one of the standard open source
        # licenses: https://opensource.org/licenses/alphabetical
        license='BSD',

        # Homepage url for the package
        url='https://wol.ph/',
    )

这是相当多的代码和注释,但它涵盖了您在现实生活中可能遇到的大多数选项。这里讨论的最有趣和多功能的参数将在接下来的各个部分中单独介绍。

附加文档可以在pipsetuptools文档以及 Python 包装用户指南中找到:

在我们的例子中,我们只是使用packages=setuptools.find_packages()。在大多数情况下,这将工作得很好,但重要的是要理解它的作用。find_packages函数会查找给定目录中的所有目录,并在其中有__init__.py文件的情况下将其添加到列表中。因此,你通常可以使用['your_package']代替find_packages()。然而,如果你有多个包,那么这往往会变得乏味。这就是find_packages()有用的地方;只需指定一些包含参数(第二个参数)或一些排除参数(第三个参数),你就可以在项目中拥有所有相关的包。例如:

packages = find_packages(exclude=['tests', 'docs'])

入口点

entry_points参数可以说是setuptools最有用的功能。它允许你向setuptools中的许多东西添加钩子,但最有用的两个是添加命令行和 GUI 命令的可能性,以及扩展setuptools命令。命令行和 GUI 命令甚至会在 Windows 上转换为可执行文件。第一节中的例子已经演示了这两个功能:

entry_points={
    'distutils.commands': [
        'command_name = your_package:YourClass',
    ],
    'console_scripts': [
        'spam = your_package:SpamClass',
    ],
},

这个演示只是展示了如何调用函数,但没有展示实际的函数。

创建全局命令

第一个,一个简单的例子,没有什么特别的;只是一个作为常规main函数被调用的函数,在这里你需要自己指定sys.argv(或者更好的是使用argparse)。这是setup.py文件:

import setuptools

if __name__ == '__main__':
    setuptools.setup(
        name='Our little project',
        entry_points={
            'console_scripts': [
                'spam = spam.main:main',
            ],
        },
    )

当然,这里有spam/main.py文件:

import sys

def main():
    print('Args:', sys.argv)

一定不要忘记创建一个spam/__init__.py文件。它可以是空的,但它需要存在,以便 Python 知道它是一个包。

现在,让我们试着安装这个包:

# pip install -e .
Installing collected packages: Our-little-project
 **Running setup.py develop for Our-little-project
Successfully installed Our-little-project
# spam 123 abc
Args: ['~/envs/mastering_python/bin/spam', '123', 'abc']

看,创建一个在常规命令行 shell 中安装的spam命令是多么简单!在 Windows 上,它实际上会给你一个可执行文件,该文件将被添加到你的路径中,但无论在哪个平台上,它都将作为一个可调用的独立可执行文件。

自定义 setup.py 命令

编写自定义的setup.py命令非常有用。一个例子是sphinx-pypi-upload-2,我在所有的包中都使用它,它是我维护的unmaintained sphinx-pypi-upload包的分支。这是一个使构建和上传 Sphinx 文档到 Python 包索引变得非常简单的包,当分发你的包时非常有用。使用sphinx-pypi-upload-2包,你可以做以下操作(我在分发我维护的任何包时都会这样做):

python setup.py sdist bdist_wheel upload build_sphinx upload_sphinx

这个命令会构建你的包并将其上传到 PyPI,并构建 Sphinx 文档并将其上传到 PyPI。

但你当然想看看这是如何工作的。首先,这是我们spam命令的setup.py

import setuptools

if __name__ == '__main__':
    setuptools.setup(
        name='Our little project',
        entry_points={
            'distutils.commands': [
                'spam = spam.command:SpamCommand',
            ],
        },
    )

其次,SpamCommand类。基本要点是继承setuptools.Command并确保实现所有需要的方法。请注意,所有这些方法都需要实现,但如果需要,可以留空。这是spam/command.py文件:

import setuptools

class SpamCommand(setuptools.Command):
    description = 'Make some spam!'
# Specify the commandline arguments for this command here. This
# parameter uses the getopt module for parsing'
    user_options = [
        ('spam=', 's', 'Set the amount of spams'),
    ]

    def initialize_options(self):
# This method can be used to set default values for the
# options. These defaults can be overridden by
# command-line, configuration files and the setup script
# itself.
        self.spam = 3

    def finalize_options(self):
# This method allows you to override the values for the
# options, useful for automatically disabling
# incompatible options and for validation.
        self.spam = max(0, int(self.spam))

    def run(self):
        # The actual running of the command.
        print('spam' * self.spam)

执行它非常简单:

# pip install -e .
Installing collected packages: Our-little-project
 **Running setup.py develop for Our-little-project
Successfully installed Our-little-project-0.0.0
# python setup.py --help-commands
[...]
Extra commands:
 **[...]
 **spam              Make some spam!
 **test              run unit tests after in-place build
 **[...]

usage: setup.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
 **or: setup.py --help [cmd1 cmd2 ...]
 **or: setup.py --help-commands
 **or: setup.py cmd –help

# python setup.py --help spam
Common commands: (see '--help-commands' for more)

[...]

Options for 'SpamCommand' command:
 **--spam (-s)  Set the amount of spams

usage: setup.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
 **or: setup.py --help [cmd1 cmd2 ...]
 **or: setup.py --help-commands
 **or: setup.py cmd --help

# python setup.py spam
running spam
spamspamspam
# python setup.py spam -s 5
running spam
spamspamspamspamspam

实际上只有很少的情况下你会需要自定义的setup.py命令,但这个例子仍然很有用,因为它目前是setuptools的一个未记录的部分。

包数据

在大多数情况下,你可能不需要包含包数据,但在需要数据与你的包一起的情况下,有一些不同的选项。首先,重要的是要知道默认情况下包含在你的包中的文件有哪些:

  • 包目录中的 Python 源文件递归

  • setup.pysetup.cfg文件

  • 测试:test/test*.py

  • examples目录中的所有*.txt*.py文件

  • 在根目录中的所有*.txt文件

所以在默认值之后,我们有了第一个解决方案:setup函数的package_data参数。它的语法非常简单,一个字典,其中键是包,值是要包含的模式:

package_data = {
    'docs': ['*.rst'],
}

第二种解决方案是使用MANIFEST.in文件。该文件包含要包括、排除和其他的模式。includeexclude命令使用模式进行匹配。这些模式是通配符样式的模式(请参阅glob模块的文档:docs.python.org/3/library/glob.html),并且对于包括和排除命令都有三种变体:

  • include/exclude: 这些命令仅适用于给定的路径,而不适用于其他任何内容

  • recursive-include/recursive-exclude: 这些命令类似于include/exclude命令,但是递归处理给定的路径

  • global-include/global-exclude: 对于这些命令要非常小心,它们将在源树中的任何位置包含或排除这些文件

除了include/exclude命令之外,还有另外两个命令;graftprune命令,它们包括或排除包括给定目录下的所有文件的目录。这对于测试和文档可能很有用,因为它们可以包括非标准文件。除了这些例子之外,几乎总是最好明确包括您需要的文件并忽略所有其他文件。这是一个MANIFEST.in的例子:

# Comments can be added with a hash tag
include LICENSE CHANGES AUTHORS

# Include the docs, tests and examples completely
graft docs
graft tests
graft examples

# Always exclude compiled python files
global-exclude *.py[co]

# Remove documentation builds
prune docs/_build

测试软件包

在第十章,“测试和日志-为错误做准备”,测试章节中,我们看到了 Python 的许多测试系统。正如您可能怀疑的那样,至少其中一些已经集成到了setup.py中。

Unittest

在开始之前,我们应该为我们的包创建一个测试脚本。对于实际的测试,请参阅第十章,“测试和日志-为错误做准备”,测试章节。在这种情况下,我们将只使用一个无操作测试,test.py

import unittest

class Test(unittest.TestCase):

    def test(self):
        pass

标准的python setup.py test命令将运行常规的unittest命令:

# python setup.py -v test
running test
running "unittest --verbose"
running egg_info
writing Our_little_project.egg-info/PKG-INFO
writing dependency_links to Our_little_project.egg-info/dependency_links.txt
writing top-level names to Our_little_project.egg-info/top_level.txt
writing entry points to Our_little_project.egg-info/entry_points.txt
reading manifest file 'Our_little_project.egg-info/SOURCES.txt'
writing manifest file 'Our_little_project.egg-info/SOURCES.txt'
running build_ext
test (test.Test) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

可以通过使用--test-module--test-suite--test-runner参数告诉setup.py使用不同的测试。虽然这些很容易使用,但我建议跳过常规的test命令,而是尝试使用nosepy.test

py.test

py.test软件包有几种集成方法:pytest-runner,您自己的测试命令,以及生成runtests.py脚本进行测试的已弃用方法。如果您的软件包中仍在使用runtests.py,我强烈建议切换到其他选项之一。

但在讨论其他选项之前,让我们确保我们有一些测试。所以让我们在我们的包中创建一个测试。我们将把它存储在test_pytest.py中:

def test_a():
    pass

def test_b():
    pass

现在,其他测试选项。由于自定义命令实际上并没有增加太多内容,而且实际上使事情变得更加复杂,我们将跳过它。如果您想自定义测试的运行方式,请改用pytest.inisetup.cfg文件。最好的选项是pytest-runner,它使运行测试变得非常简单:

# pip install pytest-runner
Collecting pytest-runner
 **Using cached pytest_runner-2.7-py2.py3-none-any.whl
Installing collected packages: pytest-runner
Successfully installed pytest-runner-2.7
# python setup.py pytest
running pytest
running egg_info
writing top-level names to Our_little_project.egg-info/top_level.txt
writing dependency_links to Our_little_project.egg-info/dependency_links.txt
writing entry points to Our_little_project.egg-info/entry_points.txt
writing Our_little_project.egg-info/PKG-INFO
reading manifest file 'Our_little_project.egg-info/SOURCES.txt'
writing manifest file 'Our_little_project.egg-info/SOURCES.txt'
running build_ext
======================== test session starts =========================
platform darwin -- Python 3.5.1, pytest-2.8.7, py-1.4.31, pluggy-0.3.1
rootdir: h15, inifile: pytest.ini
collected 2 items

test_pytest.py ..

====================== 2 passed in 0.01 seconds ======================

为了正确地集成这种方法,我们应该对setup.py脚本进行一些更改。它们并不是严格需要的,但对于使用您的软件包的其他人来说,这会使事情变得更加方便,可能不知道您正在使用py.test,例如。首先,我们确保标准的python setup.py test命令实际上运行pytest命令,而不是通过修改setup.cfg来运行:

[aliases]
test=pytest

其次,我们要确保setup.py命令安装我们运行py.test测试所需的软件包。为此,我们还需要修改setup.py

import setuptools

if __name__ == '__main__':
    setuptools.setup(
        name='Our little project',
        entry_points={
            'distutils.commands': [
                'spam = spam.command:SpamCommand',
            ],
        },
        setup_requires=['pytest-runner'],
        tests_require=['pytest'],
    )

这种方法的美妙之处在于常规的python setup.py test命令可以工作,并且在运行测试之前会自动安装所有所需的要求。但是,由于pytest要求仅在tests_require部分中,如果未运行测试命令,则它们将不会被安装。唯一始终会被安装的软件包是pytest-runner软件包,这是一个非常轻量级的软件包,因此安装和运行起来非常轻便。

Nosetests

nose包只处理安装,并且与py.test略有不同。唯一的区别是py.test有一个单独的pytest-runner包用于测试运行器,而 nose 包有一个内置的nosetests命令。因此,以下是 nose 版本:

# pip install nose
Collecting nose
 **Using cached nose-1.3.7-py3-none-any.whl
Installing collected packages: nose
Successfully installed nose-1.3.7
# python setup.py nosetests
running nosetests
running egg_info
writing top-level names to Our_little_project.egg-info/top_level.txt
writing entry points to Our_little_project.egg-info/entry_points.txt
writing Our_little_project.egg-info/PKG-INFO
writing dependency_links to Our_little_project.egg-info/dependency_lin
ks.txt
reading manifest file 'Our_little_project.egg-info/SOURCES.txt'
writing manifest file 'Our_little_project.egg-info/SOURCES.txt'
..
----------------------------------------------------------------------
Ran 2 tests in 0.006s

OK

C/C++扩展

前一章已经在一定程度上涵盖了这一点,因为编译 C/C++文件是必需的。但是那一章并没有解释在这种情况下setup.py在做什么以及如何做。

为了方便起见,我们将重复setup.py文件:

import setuptools

spam = setuptools.Extension('spam', sources=['spam.c'])

setuptools.setup(
    name='Spam',
    version='1.0',
    ext_modules=[spam],
)

在开始使用这些扩展之前,你应该学习以下命令:

  • build:这实际上不是一个特定于 C/C++的构建函数(尝试build_clib),而是一个组合构建函数,用于在setup.py中构建所有内容。

  • clean:这会清理build命令的结果。通常情况下不需要,但有时重新编译工作的文件检测是不正确的。因此,如果遇到奇怪或意外的问题,请尝试先清理项目。

常规扩展

setuptools.Extension类告诉setuptools一个名为spam的模块使用源文件spam.c。这只是一个扩展的最简单版本,一个名称和一个源列表,但在许多情况下,你需要的不仅仅是简单的情况。

一个例子是pillow库,它会检测系统上可用的库,并根据此添加扩展。但是因为这些扩展包括库,所以需要一些额外的编译标志。基本的 PIL 模块本身似乎并不太复杂,但是库实际上都是包含了所有自动检测到的库和匹配的宏定义:

exts = [(Extension("PIL._imaging", files, libraries=libs,
                   define_macros=defs))]

freetype扩展有类似的东西:

if feature.freetype:
    exts.append(Extension(
        "PIL._imagingft", ["_imagingft.c"], libraries=["freetype"]))

Cython 扩展

setuptools库在处理扩展时实际上比常规的distutils库要聪明一些。它实际上向Extension类添加了一个小技巧。还记得第十二章中对性能的简要介绍吗?setuptools库使得编译这些变得更加方便。Cython手册建议你使用类似以下代码的东西:

from distutils.core import setup
from Cython.Build import cythonize

setup(
    ext_modules = cythonize("eggs.pyx")
)

这里的eggs.pyx包含:

def make_eggs(int n):
    print('Making %d eggs: %s' % (n, n * 'eggs '))

这种方法的问题是,除非你安装了Cython,否则setup.py会出现问题:

# python setup.py build
Traceback (most recent call last):
 **File "setup.py", line 2, in <module>
 **import Cython
ImportError: No module named 'Cython'

为了防止这个问题,我们只需要让setuptools处理这个问题:

import setuptools

eggs = setuptools.Extension('eggs', sources=['eggs.pyx'])

setuptools.setup(
    name='Eggs',
    version='1.0',
    ext_modules=[eggs],
    setup_requires=['Cython'],
)

现在,如果需要,Cython将被自动安装,并且代码将正常工作:

# python setup.py build
running build
running build_ext
cythoning eggs.pyx to eggs.c
building 'eggs' extension
...
# python setup.py develop
running develop
running egg_info
creating Eggs.egg-info
writing dependency_links to Eggs.egg-info/dependency_links.txt
writing top-level names to Eggs.egg-info/top_level.txt
writing Eggs.egg-info/PKG-INFO
writing manifest file 'Eggs.egg-info/SOURCES.txt'
reading manifest file 'Eggs.egg-info/SOURCES.txt'
writing manifest file 'Eggs.egg-info/SOURCES.txt'
running build_ext
skipping 'eggs.c' Cython extension (up-to-date)
copying build/... ->
Creating Eggs.egg-link (link to .)
Adding Eggs 1.0 to easy-install.pth file

Installed Eggs
Processing dependencies for Eggs==1.0
Finished processing dependencies for Eggs==1.0
# python -c 'import eggs; eggs.make_eggs(3)'
Making 3 eggs: eggs eggs eggs

然而,为了开发目的,Cython还提供了一种不需要手动构建的更简单的方法。首先,为了确保我们实际上正在使用这种方法,让我们安装Cython,并彻底卸载和清理eggs

# pip uninstall eggs -y
Uninstalling Eggs-1.0:
 **Successfully uninstalled Eggs-1.0
# pip uninstall eggs -y
Cannot uninstall requirement eggs, not installed
# python setup.py clean
# pip install cython

现在让我们尝试运行我们的eggs.pyx模块:

>>> import pyximport
>>> pyximport.install()
(None, <pyximport.pyximport.PyxImporter object at 0x...>)
>>> import eggs
>>> eggs.make_eggs(3)
Making 3 eggs: eggs eggs eggs

这就是在没有显式编译的情况下运行pyx文件的简单方法。

Wheels - 新的 eggs

对于纯 Python 包,sdist(源分发)命令一直足够了。但是对于 C/C++包来说,通常并不那么方便。C/C++包的问题在于,除非使用二进制包,否则需要进行编译。传统上,这些通常是.egg文件,但它们从未真正解决了问题。这就是为什么引入了wheel格式(PEP 0427),这是一种包含源代码和二进制代码的二进制包格式,可以在 Windows 和 OS X 上安装,而无需编译器。作为额外的奖励,它也可以更快地安装纯 Python 包。

实现起来幸运的是很简单。首先,安装wheel包:

# pip install wheel

现在你可以使用bdist_wheel命令来构建你的包。唯一的小问题是,默认情况下 Python 3 创建的包只能在 Python 3 上运行,因此 Python 2 安装将退回到sdist文件。为了解决这个问题,你可以将以下内容添加到你的setup.cfg文件中:

[bdist_wheel]
universal = 1

这里唯一需要注意的重要事项是,在 C 扩展的情况下,可能会出错。Python 3 的二进制 C 扩展与 Python 2 的不兼容。因此,如果您有一个纯 Python 软件包,并且同时针对 Python 2 和 3,启用该标志。否则,就将其保持为默认值。

分发到 Python Package Index

一旦您的一切都正常运行,经过测试和记录,就是时候将项目实际推送到Python Package IndexPyPI)了。在将软件包推送到 PyPI 之前,我们需要确保一切都井井有条。

首先,让我们检查setup.py文件是否有问题:

# python setup.py check
running check
warning: check: missing required meta-data: url

warning: check: missing meta-data: either (author and author_email) or (maintainer and maintainer_email) must be supplied

看起来我们忘记了指定urlauthormaintainer信息。让我们填写这些:

import setuptools

eggs = setuptools.Extension('eggs', sources=['eggs.pyx'])

setuptools.setup(
    name='Eggs',
    version='1.0',
    ext_modules=[eggs],
    setup_requires=['Cython'],
    url='https://wol.ph/',
    author='Rick van Hattem (Wolph)',
    author_email='wolph@wol.ph',
)

现在让我们再次检查:

# python setup.py check
running check

完美!没有错误,一切看起来都很好。

现在我们的setup.py已经井井有条了,让我们来尝试测试。由于我们的小测试项目几乎没有测试,这将几乎是空的。但是如果您正在启动一个新项目,我建议从一开始就尽量保持 100%的测试覆盖率。稍后实施所有测试通常更加困难,而在工作时进行测试通常会让您更多地考虑代码的设计决策。运行测试非常容易:

# python setup.py test
running test
running egg_info
writing dependency_links to Eggs.egg-info/dependency_links.txt
writing Eggs.egg-info/PKG-INFO
writing top-level names to Eggs.egg-info/top_level.txt
reading manifest file 'Eggs.egg-info/SOURCES.txt'
writing manifest file 'Eggs.egg-info/SOURCES.txt'
running build_ext
skipping 'eggs.c' Cython extension (up-to-date)
copying build/... ->

---------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

现在我们已经检查完毕,下一步是构建文档。如前所述,sphinxsphinx-pypi-upload-2软件包可以在这方面提供帮助:

# python setup.py build_sphinx
running build_sphinx
Running Sphinx v1.3.5
...

一旦我们确定一切都正确,我们就可以构建软件包并将其上传到 PyPI。对于纯 Python 版本的发布,您可以使用sdist(源分发)命令。对于使用本机安装程序的软件包,有一些选项可用,例如bdist_wininstbdist_rpm。我个人几乎在所有我的软件包中使用以下命令:

# python setup.py build_sphinx upload_sphinx sdist bdist_wheel upload

这将自动构建 Sphinx 文档,将文档上传到 PyPI,使用源构建软件包,并使用源上传软件包。

显然,只有在您是特定软件包的所有者并且被 PyPI 授权时,才能成功完成此操作。

注意

在上传软件包之前,您需要在 PyPI 上注册软件包。这可以使用register命令来完成,但由于这会立即在 PyPI 服务器上注册软件包,因此在测试时不应使用。

总结

阅读完本章后,您应该能够创建包含不仅是纯 Python 文件,还包括额外数据、编译的 C/C++扩展、文档和测试的 Python 软件包。有了这些工具,您现在可以制作高质量的 Python 软件包,这些软件包可以轻松地在其他项目和软件包中重复使用。

Python 基础设施使得创建新软件包并将项目拆分为多个子项目变得非常容易。这使您能够创建简单且可重用的软件包,因为一切都很容易进行测试。虽然您不应该过度拆分软件包,但是如果脚本或模块具有自己的目的,那么它就是可以单独打包的候选项。

通过本章,我们已经完成了本书。我真诚地希望您喜欢阅读,并了解了新颖有趣的主题。非常感谢您的任何反馈,所以请随时通过我的网站wol.ph/与我联系。

posted @ 2024-05-04 21:31  绝不原创的飞龙  阅读(34)  评论(0编辑  收藏  举报