Python-严肃编程-全-
Python 严肃编程(全)
原文:
zh.annas-archive.org/md5/8072595f6a199e5af5bf60790ad63034译者:飞龙
序言

如果你正在阅读这本书,那么你很可能已经使用 Python 一段时间了。也许你通过一些教程学习过它,深入研究了一些现有程序,或者从零开始。无论是哪种情况,你都已经通过自己的方式破解了学习它的方法。正是我通过这种方式熟悉了 Python,直到十年前开始参与大型开源项目。
一旦你写出第一个程序,可能会觉得自己已经掌握了 Python。这门语言的确很容易理解。然而,掌握它并深入理解它的优点和缺点需要数年的时间。
当我开始学习 Python 时,我是在“车库项目”的规模上构建自己的 Python 库和应用程序。事情在我开始和数百名开发者一起工作,开发成千上万的用户依赖的软件时发生了变化。例如,OpenStack 平台——这是我参与的一个项目——包含超过 900 万行 Python 代码,所有这些代码必须保持简洁、高效,并能够根据用户所需的云计算应用程序的需求进行扩展。当项目达到这种规模时,像测试和文档这样的问题绝对需要自动化,否则根本无法完成。
在开始从事这种规模的项目之前,我曾以为自己对 Python 了解很多——这种规模的项目在我刚开始时几乎无法想象——但我学到了更多。我也有机会结识一些业内最顶尖的 Python 黑客并向他们学习。他们教会了我从通用架构和设计原则到各种实用技巧和窍门的所有知识。通过本书,我希望分享我所学到的最重要的内容,以便你也能构建更好的 Python 程序——并且更高效地构建它们!
本书的第一版《黑客的 Python 指南》于 2014 年发布。现在《Serious Python》是第四版,内容进行了更新并加入了全新的内容。希望你喜欢它!
谁应该阅读本书,以及为什么
本书面向那些希望将自己 Python 技能提升到更高水平的 Python 编程人员和开发者。
在本书中,你将找到一些方法和建议,帮助你最大限度地发挥 Python 的潜力,构建未来可持续的程序。如果你已经在做项目,你将能够立即应用书中讨论的技巧,提升你当前的代码。如果你正在开始你的第一个项目,你将能够根据最佳实践创建一个蓝图。
我将向你介绍一些 Python 内部机制,以帮助你更好地理解如何编写高效的代码。你将深入了解这门语言的内在工作原理,帮助你识别问题或低效之处。
本书还提供了可应用的、经过实战验证的解决方案,解决了诸如测试、移植和扩展 Python 代码、应用程序和库等问题。这将帮助你避免他人曾经犯过的错误,并发现能够帮助你长期维护软件的策略。
关于本书
本书不一定需要按顺序从头到尾阅读。你可以根据自己的兴趣或工作相关性跳到感兴趣的章节。在本书中,你将找到各种建议和实用技巧。以下是每个章节内容的简要概述。
第一章 提供了在开始项目之前应考虑的事项,包含关于项目结构、版本编号、设置自动化错误检查等方面的建议。章节最后有对 Joshua Harlow 的采访。
第二章 介绍了 Python 模块、库和框架,并简要讨论了它们在幕后是如何工作的。你将获得关于使用sys模块、从pip包管理器中获得更多信息、选择最适合你的框架以及使用标准库和外部库的指导。还包含对 Doug Hellmann 的采访。
第三章 提供了关于如何在项目发展过程中,包括发布后,记录项目和管理 API 的建议。你将获得关于使用 Sphinx 自动化某些文档任务的具体指导。本章还包括对 Christophe de Vienne 的采访。
第四章 讨论了关于时区的老生常谈问题,并介绍了如何使用datetime对象和tzinfo对象在程序中最好地处理它们。
第五章 帮助你将软件分发给用户,提供了分发的指导。你将了解打包、分发标准、distutils和setuptools库,以及如何通过入口点轻松发现包中的动态功能。此章节包含对 Nick Coghlan 的采访。
第六章 提供了关于单元测试的建议,包括最佳实践技巧和使用pytest自动化单元测试的具体教程。你还将学习如何使用虚拟环境来增强测试的隔离性。该章节的采访对象是 Robert Collins。
第七章 深入探讨了方法和装饰器。本文将介绍如何使用 Python 进行函数式编程,提供如何以及何时使用装饰器的建议,以及如何为装饰器创建装饰器。我们还将探讨静态方法、类方法和抽象方法,并讨论如何将三者结合起来构建更强大的程序。
第八章 向你展示了可以在 Python 中实现的更多函数式编程技巧。该章节讨论了生成器、列表推导式、函数式函数及实现这些功能的常用工具,以及有用的functools库。
第九章 透视了语言本身,讨论了 Python 的抽象语法树(AST),它是 Python 内部的结构。我们还将学习如何扩展 flake8 以与 AST 配合使用,从而为程序引入更复杂的自动检查。本章最后通过一段对 Paul Tagliamonte 的访谈作结。
第十章 是关于通过使用合适的数据结构、有效定义函数以及应用动态性能分析来优化性能的指南,目的是识别代码中的瓶颈。我们还将讨论记忆化和减少数据复制中的浪费。你还会看到对 Victor Stinner 的访谈。
第十一章 探讨了多线程这一复杂主题,包括如何以及何时使用多线程而不是多进程,以及是否使用面向事件或面向服务的架构来创建可扩展的程序。
第十二章 涉及关系型数据库。我们将了解它们的工作原理,以及如何使用 PostgreSQL 高效地管理和流式传输数据。Dimitri Fontaine 进行了访谈。
最后,第十三章 提供了关于多个主题的实用建议:如何使代码兼容 Python 2 和 3,如何编写像 Lisp 一样的函数式代码,如何使用上下文管理器,以及如何使用 attr 库减少重复代码。
第一章:开始你的项目

在本章中,我们将讨论开始一个项目时的几个方面,以及在开始之前你应该考虑的内容,例如选择使用哪个 Python 版本、如何构建模块、如何有效地编号软件版本,以及如何通过自动错误检查确保最佳编码实践。
Python 的版本
在开始一个项目之前,你需要决定它将支持哪些版本的 Python。这并不是一个看似简单的决定。
Python 同时支持多个版本这并不是什么秘密。每个小版本的解释器都会提供 18 个月的错误修复支持和 5 年的安全支持。例如,Python 3.7 于 2018 年 6 月 27 日发布,将一直支持到 Python 3.8 发布,预计在 2019 年 10 月发布。大约在 2019 年 12 月,将会发布 Python 3.7 的最后一个错误修复版本,之后大家都需要切换到 Python 3.8。每个新版本的 Python 都会引入新功能,并淘汰旧功能。图 1-1 展示了这一时间表。

图 1-1:Python 发布时间表
此外,我们还需要考虑 Python 2 和 Python 3 的问题。使用(非常)旧平台的人可能仍然需要 Python 2 的支持,因为这些平台上没有提供 Python 3,但基本原则是,如果可以的话,就不要考虑 Python 2。
这里有一个快速方法来判断你需要哪个版本:
-
版本 2.6 及更早版本现在已经过时,因此我不建议你为这些版本提供支持。如果你出于某些原因确实打算支持这些旧版本,请注意,确保你的程序也支持 Python 3.x 将会非常困难。话虽如此,你仍然可能会在某些老旧系统上遇到 Python 2.6——如果是这样,那就抱歉了!
-
版本 2.7 是 Python 2.x 的最后一个版本。现在,每个系统基本上都能够运行或者已经运行 Python 3,所以除非你是在做考古学工作,否则不需要担心在新程序中支持 Python 2.7。Python 2.7 将在 2020 年后停止支持,因此最后你想做的事情就是基于它构建新软件。
-
截至目前,Python 3 分支的最新版本是 3.7,这也是你应该目标的版本。然而,如果你的操作系统出厂时自带版本是 3.6(大多数操作系统,除了 Windows,都自带 3.6 或更高版本),请确保你的应用程序也能兼容 3.6。
支持 Python 2.7 和 3.x 的程序编写技巧将在第十三章中讨论。
最后,请注意,本书是针对 Python 3 编写的。
项目布局
开始一个新项目总是有点像拼图。你不能确定项目的具体结构,所以你可能不知道该如何组织文件。然而,一旦你对最佳实践有了正确的理解,你就会知道应该从哪种基本结构开始。在这里,我将提供一些关于如何布局项目的建议,包含一些应该做和不应该做的事项。
应该做的事情
首先,考虑你的项目结构,应该保持相对简单。明智地使用包和层级结构:过深的层级结构可能会变得难以导航,而扁平的层级结构则往往会变得臃肿。
然后,避免犯一个常见的错误,即将单元测试存储在包目录之外。这些测试应该被包含在软件的子包中,以确保它们不会被setuptools(或其他打包库)误自动安装为tests顶级模块。通过将它们放入子包中,你确保它们可以被安装并最终供其他包使用,从而让用户可以构建自己的单元测试。
图 1-2 展示了标准的文件层次结构应该是什么样的。

图 1-2:标准包目录
Python 安装脚本的标准名称是setup.py。它配套有setup.cfg,其中应包含安装脚本的配置细节。运行时,setup.py将使用 Python 的分发工具安装你的包。
你还可以在README.rst(或README.txt,或任何你喜欢的文件名)中提供给用户的重要信息。最后,docs目录应包含包的文档,采用reStructuredText格式,这些文档将被 Sphinx 使用(见第三章)。
包通常需要提供额外的数据供软件使用,例如图像、shell 脚本等。不幸的是,没有一个被普遍接受的标准来确定这些文件应该存放在哪里,所以你应该根据文件的功能,将它们放在对你的项目最有意义的位置。例如,Web 应用程序的模板可以放在包根目录中的templates目录下。
以下顶级目录也经常出现:
-
etc 用于示例配置文件
-
tools 用于 shell 脚本或相关工具
-
bin 用于你编写的二进制脚本,这些脚本将通过setup.py进行安装
不应该做的事情
我经常在没有充分考虑的项目结构中遇到一个特定的设计问题:一些开发者会根据他们将存储的代码类型创建文件或模块。例如,他们可能会创建functions.py或exceptions.py文件。这是一种糟糕的做法,在开发者浏览代码时没有任何帮助。当阅读代码库时,开发者期望程序的功能区块被限制在特定的文件中。这种代码组织方式并没有带来任何好处,反而迫使读者无缘无故地在多个文件之间跳转。
根据功能而非类型来组织你的代码。
创建一个仅包含init.py文件的模块目录也是一个坏主意,因为这会造成不必要的嵌套。例如,你不应该创建一个名为hooks的目录,里面只有一个名为hooks/init.py的文件,而实际上hooks.py就足够了。如果你创建一个目录,它应该包含属于该目录代表的类别的多个其他 Python 文件。不必要地构建深层次的层级结构会让人困惑。
你还应该非常小心你放入init.py文件中的代码。这个文件将在第一次加载目录中包含的模块时被调用并执行。将错误的内容放入init.py文件中可能会产生不良的副作用。事实上,除非你知道自己在做什么,否则init.py文件通常应该是空的。不过,不要试图完全删除init.py文件,否则你将根本无法导入你的 Python 模块:Python 要求目录中存在init.py文件才能将其视为子模块。
版本编号
软件版本需要打上印记,以便用户知道哪个是更新的版本。对于每个项目,用户必须能够组织不断发展的代码时间线。
组织版本号的方式有无数种。然而,PEP 440 引入了一种版本格式,每个 Python 包,理想情况下每个应用程序,都应该遵循该格式,以便其他程序和包可以轻松可靠地识别他们需要的包的版本。
PEP 440 定义了以下用于版本编号的正则表达式格式:
N[.N]+[{a|b|c|rc}N][.postN][.devN]
这允许使用标准编号,如1.2或1.2.3。还有一些细节需要注意:
-
版本
1.2等同于1.2.0,1.3.4等同于1.3.4.0,依此类推。 -
与
N[.N]+匹配的版本被认为是最终版本。 -
基于日期的版本号,如
2013.06.22,被认为是无效的。旨在检测 PEP 440 格式版本号的自动化工具会(或者应该)在检测到版本号大于或等于1980时引发错误。 -
最终组件也可以使用以下格式:
-
N[.N]+aN(例如,1.2a1)表示一个 alpha 版本;这个版本可能不稳定并且缺少一些功能。 -
N[.N]+bN(例如,2.3.1b2)表示一个 beta 版本,一个可能功能完整但仍然存在 bug 的版本。 -
N[.N]+cN或N[.N]+rcN(例如,0.4rc1)表示一个(发布)候选版本。这是一个可能作为最终产品发布的版本,除非出现重大 bug。rc和c后缀有相同的含义,但如果两者都使用,rc版本被视为比c版本更新。
-
-
还可以使用以下后缀:
-
后缀.postN(例如,
1.4.post2)表示一个发布后版本。发布后版本通常用于解决发布过程中的小错误,如发布说明中的错误。在发布修复版本时,不应使用.postN后缀;应当增加次版本号。 -
后缀.devN(例如,
2.3.4.dev3)表示开发版本。它表示一个预发布版本,该版本在正式的 alpha、beta、候选版本或最终版本之前发布。例如,2.3.4.dev3表示2.3.4发布的第三个开发版本。此后缀不建议使用,因为它对人类解析起来较为困难。
-
该方案应该足以应对大多数常见的使用场景。
注意
你可能听说过语义化版本控制,它为版本编号提供了自己的指南。该规范部分与 PEP 440 重叠,但不完全兼容。例如,语义化版本控制推荐的预发布版本使用类似1.0.0-alpha+001的方案,这与 PEP 440 不兼容。
许多分布式版本控制系统(DVCS)平台,如 Git 和 Mercurial,能够使用标识哈希生成版本号(对于 Git,请参见git describe)。不幸的是,这种系统与 PEP 440 定义的方案不兼容:其中一个问题是,标识哈希无法排序。
编码风格与自动化检查
编码风格是一个敏感话题,但在我们深入讨论 Python 之前,我们应该先谈谈它。与许多编程语言不同,Python 使用缩进来定义代码块。虽然这为“我该把大括号放在哪里?”这个古老问题提供了简单的解决方案,但也带来了一个新问题:“我该如何缩进?”
这是社区提出的第一个问题之一,因此 Python 的开发者们在他们深邃的智慧下,提出了PEP 8: Python 代码风格指南(www.python.org/dev/peps/pep-0008/)。
本文档定义了编写 Python 代码的标准风格。指南的核心内容如下:
-
每个缩进级别使用四个空格。
-
限制所有行的最大字符数为 79 个字符。
-
顶级函数和类定义之间应使用两个空行分隔。
-
使用 ASCII 或 UTF-8 编码文件。
-
每个
import语句和每一行只导入一个模块。将导入语句放在文件顶部,注释和文档字符串之后,先按标准库导入,再按第三方库导入,最后按本地库导入。 -
不要在括号、方括号或大括号之间或逗号前使用多余的空格。
-
类名使用驼峰命名法(例如,
CamelCase),异常的后缀加上Error(如果适用),函数名使用小写字母并用下划线分隔(例如,separated_by_underscores)。对_private属性或方法使用前导下划线。
这些指南其实并不难遵循,而且非常有道理。大多数 Python 程序员在编写代码时都能轻松遵守这些规范。
然而,人非圣贤,仔细检查代码以确保符合 PEP 8 规范仍然是一件麻烦事。幸运的是,有一个pep8工具(可以在pypi.org/project/pep8/找到),它可以自动检查你发送的任何 Python 文件。通过pip安装pep8,然后你可以像下面这样使用它:
$ pep8 hello.py
hello.py:4:1: E302 expected 2 blank lines, found 1 $ echo $?
1
在我的文件hello.py中,我使用了pep8,输出结果显示了不符合 PEP 8 规范的行和列,并报告了每个问题的代码——这里是第 4 行和第 1 列。违反规范中MUST语句的部分会被报告为错误,其错误代码以E开头。较小的问题会被报告为警告,其错误代码以W开头。紧随其后的三位数字表示错误或警告的具体类型。
百位数字告诉你错误代码的大致类别:例如,以E2开头的错误表示空格问题,以E3开头的错误表示空行问题,以W6开头的警告表示使用了废弃的功能。这些代码都列在pep8的 readthedocs 文档中(pep8.readthedocs.io/)。
捕捉样式错误的工具
社区仍在争论是否应该验证 PEP 8 代码,虽然它不是标准库的一部分。我的建议是定期运行 PEP 8 验证工具检查你的源代码。你可以通过将其集成到持续集成系统中来轻松实现这一点。虽然这种做法可能看起来有些极端,但它是确保你在长期内始终遵守 PEP 8 指南的好方法。在《在tox中使用virtualenv》一节中,我们将在第 92 页讨论如何将pep8与tox集成,以自动执行这些检查。
大多数开源项目通过自动检查来强制执行 PEP 8 合规性。虽然从项目一开始就使用这些自动检查可能会让新人感到沮丧,但它也确保了代码库在项目的每个部分看起来都是一致的。这对于任何有多个开发者的大型项目来说非常重要,因为开发者们可能在空格排序等方面有不同的意见。你懂我的意思。
还可以使用 --ignore 选项将某些类型的错误和警告从代码中排除,像这样:
$ pep8 --ignore=E3 hello.py
$ echo $?
0
这将忽略我 hello.py 文件中的所有 E3 错误。--ignore 选项允许你有效地忽略你不想遵循的 PEP 8 规范部分。如果你在现有的代码库上运行 pep8,它还允许你忽略某些类型的问题,这样你可以专注于一次修复一个类别的问题。
注意
如果你为 Python 编写 C 代码(例如模块),PEP 7 标准描述了你应该遵循的编码风格。
捕获编码错误的工具
Python 还有一些工具检查实际的编码错误,而不是风格错误。以下是一些 notable(值得注意)的例子:
-
Pyflakes (
launchpad.net/pyflakes/): 可以通过插件进行扩展。 -
Pylint (
pypi.org/project/pylint/): 默认情况下检查 PEP 8 合规性,并执行代码错误检查;可以通过插件进行扩展。
这些工具都利用静态分析——也就是说,它们解析代码并进行分析,而不是直接执行代码。
如果你选择使用 Pyflakes,请注意,它不会自行检查 PEP 8 合规性,因此你需要使用第二个 pep8 工具来覆盖两者。
为了简化操作,Python 有一个名为 flake8 的项目(* pypi.org/project/flake8/*),它将 pyflakes 和 pep8 合并为一个命令。它还添加了一些新特性:例如,它可以跳过包含 # noqa 的行的检查,并且可以通过插件进行扩展。
有大量的插件可供 flake8 使用,你可以开箱即用。例如,安装 flake8-import-order(通过 pip install flake8-import-order)将扩展 flake8,使其检查源代码中的 import 语句是否按字母顺序排序。是的,某些项目需要这样做。
在大多数开源项目中,flake8 被广泛用于代码风格验证。一些大型开源项目甚至编写了自己的 flake8 插件,增加了如异常处理使用不当、Python 2/3 可移植性问题、导入风格、危险的字符串格式化、可能的本地化问题等错误检查。
如果你正在启动一个新项目,我强烈建议你使用这些工具来自动检查代码质量和风格。如果你已经有了一个没有实现自动代码检查的代码库,一个不错的方法是使用你选择的工具,在大多数警告被禁用的情况下运行,并一次解决一个类别的问题。
虽然这些工具可能并不是与你的项目或偏好 完美 贴合,但 flake8 是提升代码质量并让其更具持久性的好方法。
注意
许多文本编辑器,包括著名的 GNU Emacs 和 vim,都有可用的插件(例如 Flycheck),可以直接在你的代码缓冲区中运行工具,如 pep8 或 flake8,并互动式地高亮显示任何不符合 PEP 8 的代码部分。这是一种在编写代码时修复大多数风格错误的方便方法。
我们将在第九章中讨论如何通过我们自己的插件扩展这个工具集,以验证正确的方法声明。
Joshua Harlow 论 Python
Joshua Harlow 是一名 Python 开发者。他曾在 2012 至 2016 年间担任 Yahoo! OpenStack 团队的技术负责人,现在在 GoDaddy 工作。Josh 是多个 Python 库的作者,如 Taskflow、automaton 和 Zake。
是什么让你开始使用 Python 的?
我大约在 2004 年的 IBM 实习期间开始使用 Python 2.3 或 2.4,地点在纽约州 Poughkeepsie(我的大部分亲戚和家人都来自纽约州北部,向他们致敬!)。我已经忘记当时具体做了什么,但涉及到了 wxPython 和一些他们用来自动化某些系统的 Python 代码。
在那次实习之后,我回到了学校,去罗切斯特理工学院继续深造,并最终在 Yahoo! 工作。
我最终加入了 CTO 团队,我和其他几个人的任务是决定使用哪个开源云平台。我们选择了 OpenStack,它几乎完全是用 Python 编写的。
你喜欢和讨厌 Python 语言的哪些方面?
我喜欢的一些东西(不是全面列举):
-
它的简洁性——Python 对初学者非常友好,对于有经验的开发者也容易保持兴趣。
-
风格检查——稍后阅读自己写的代码是软件开发的重要部分,通过像
flake8、pep8和 Pylint 这样的工具强制执行一致性非常有帮助。 -
挑选和选择编程风格并根据需要进行混合的能力。
我不喜欢的一些东西(不是全面列举):
-
从 Python 2 到 3 的过渡有些痛苦(不过版本 3.6 已经解决了大部分问题)。
-
Lambda 函数过于简化,应该让它们更强大。
-
缺乏一个好的包管理器——我觉得
pip还需要一些改进,比如开发一个真正的依赖解析器。 -
全局解释器锁(GIL)以及它的必要性。它让我感到难过... 更多关于 GIL 的内容,请参见[第十一章]。
-
缺乏对多线程的原生支持——目前需要额外引入显式的
asyncio模式。 -
Python 社区的分裂;这主要围绕着 CPython 和 PyPy(以及其他变种)之间的分裂。
你在开发 debtcollector,这是一个管理弃用警告的 Python 模块。启动一个新库的过程是怎样的?
上面提到的简化使得创建一个新的库并发布它变得非常容易,这样其他人也可以使用它。由于这段代码来自我参与的另一个库(taskflow^(1)),所以在不必担心 API 设计不合理的情况下,移植并扩展这段代码相对容易。我很高兴其他人(无论是 OpenStack 社区内还是外部)都找到了它的需求/用途,并希望这个库能扩展,适应更多其他库(和应用程序?)发现有用的弃用模式。
你认为 Python 缺少什么?
Python 在即时编译(JIT)方面的表现可以更好。目前,大多数新兴的编程语言(例如 Rust、使用 Chrome V8 JavaScript 引擎的 Node.js 等)具备了 Python 的许多功能,但它们也是即时编译的。如果默认的 CPython 也能进行 JIT 编译,那就太好了,这样 Python 就能在性能上与这些新语言竞争。
Python 确实非常需要一套强大的并发模式;不仅是底层的 asyncio 和线程模式,还包括那些能够帮助在大规模下高效运行应用程序的更高层次概念。Python 库 goless 迁移了一些 Go 的概念,Go 提供了内建的并发模型。我认为这些更高层次的模式应该作为一等公民,内建到标准库中并得到维护,这样开发者可以在合适的地方使用它们。如果没有这些,我看不出 Python 如何与提供这些特性的其他语言竞争。
下次再见,继续编码,保持愉快!
第二章:模块、库和框架

模块是 Python 可扩展性的关键部分。没有它们,Python 将只是一个围绕单一解释器构建的语言;它无法在一个庞大的生态系统中蓬勃发展,开发者也无法通过组合扩展快速简便地构建应用程序。在这一章中,我将向你介绍一些使 Python 模块出色的特性,从你需要了解的内置模块到外部管理的框架。
导入系统
要在程序中使用模块和库,你必须使用import关键字导入它们。例如,清单 2-1 导入了 Python 之禅的核心指导原则。
>>> 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!
清单 2-1: Python 之禅
导入系统相当复杂,我假设你已经掌握了基础知识,因此在这里我将向你展示这个系统的一些内部工作原理,包括sys模块的工作方式、如何更改或添加导入路径,以及如何使用自定义导入器。
首先,你需要知道import关键字实际上是一个名为__import__的函数的封装器。以下是一个熟悉的导入模块方式:
>>> import itertools
>>> itertools
<module 'itertools' from '/usr/.../>
这与以下方法完全等效:
>>> itertools = __import__("itertools")
>>> itertools
<module 'itertools' from '/usr/.../>
你也可以模仿import的as关键字,就像这两种等效的导入方式所示:
>>> import itertools as it
>>> it
<module 'itertools' from '/usr/.../>
这是第二个示例:
>>> it = __import__("itertools")
>>> it
<module 'itertools' from '/usr/.../>
虽然import是 Python 中的一个关键字,但在内部它实际上是一个可以通过__import__名称访问的简单函数。了解__import__函数非常有用,因为在某些(边缘)情况下,你可能希望导入一个事先无法知道名称的模块,例如这样:
>>> random = __import__("RANDOM".lower())
>>> random
<module 'random' from '/usr/.../>
不要忘记,一旦导入,模块本质上是对象,它们的属性(类、函数、变量等)也是对象。
sys 模块
sys模块提供了与 Python 本身以及它所运行的操作系统相关的变量和函数。这个模块还包含关于 Python 导入系统的许多信息。
首先,你可以使用sys.modules变量检索当前导入的模块列表。sys.modules变量是一个字典,其键是你想要查看的模块名称,返回值是模块对象。例如,一旦导入了os模块,我们可以通过输入以下内容来检索它:
>>> import sys
>>> import os
>>> sys.modules['os']
<module 'os' from '/usr/lib/python2.7/os.pyc'>
sys.modules变量是一个标准的 Python 字典,包含所有已加载的模块。这意味着,例如调用sys.modules.keys()将返回已加载模块的名称的完整列表。
你还可以使用sys.builtin_module_names变量来检索内置模块的列表。根据传递给 Python 构建系统的编译选项,编译到解释器中的内置模块可能会有所不同。
导入路径
在导入模块时,Python 依赖一个路径列表来确定在哪里查找模块。这个列表存储在sys.path变量中。要查看你的解释器会搜索哪些路径,只需输入sys.path。
你可以修改这个列表,按需添加或删除路径,甚至通过修改PYTHONPATH环境变量来添加路径,而无需编写 Python 代码。如果你想将 Python 模块安装到非标准位置(例如测试环境),将路径添加到sys.path变量可能很有用。然而,在正常操作中,通常不需要更改路径变量。以下几种方法几乎等效——几乎因为路径在列表中的位置不同;这个差异在你的使用场景中可能不重要:
>>> import sys
>>> sys.path.append('/foo/bar')
这几乎和下面的情况一样:
$ PYTHONPATH=/foo/bar python
>>> import sys
>>> '/foo/bar' in sys.path
True
需要注意的是,这个列表会被迭代以查找请求的模块,因此sys.path中的路径顺序很重要。将最有可能包含你正在导入模块的路径放在列表前面,有助于加速搜索时间。这样做还可以确保,如果有两个同名的模块可用,首先匹配到的模块会被选中。
最后一项特性尤为重要,因为一个常见的错误是用你自己的模块覆盖 Python 的内置模块。当前目录会在 Python 标准库目录之前被搜索。这意味着,如果你决定将你的脚本命名为random.py,然后尝试使用import random,那么当前目录中的文件将被导入,而不是 Python 模块。
自定义导入器
你还可以使用自定义导入器扩展导入机制。这就是 Lisp-Python 方言Hy使用的技术,它教 Python 如何导入除标准的.py或.pyc文件以外的文件。(Hy是基于 Python 的 Lisp 实现,稍后将在第 145 页的“快速了解 Hy”一节中讨论。)
导入钩子机制,即此技术,由 PEP 302 定义。它允许你扩展标准导入机制,从而可以修改 Python 导入模块的方式并构建你自己的导入系统。例如,你可以编写一个扩展,通过网络从数据库导入模块,或者在导入任何模块之前进行一些合理性检查。
Python 提供了两种不同但相关的方式来扩展导入系统:用于sys.meta_path的元路径查找器和用于sys.path_hooks的路径条目查找器。
元路径查找器
元路径查找器是一个允许你加载自定义对象以及标准的.py文件的对象。元路径查找器对象必须暴露一个find_module(fullname, path=None)方法,该方法返回一个加载器对象。加载器对象还必须具有一个load_module(fullname)方法,负责从源文件加载模块。
为了说明,列表 2-2 展示了 Hy 如何使用自定义的元路径查找器,使 Python 能够导入以 .hy 结尾的源文件,而不是 .py。
class MetaImporter(object):
def find_on_path(self, fullname):
fls = ["%s/__init__.hy", "%s.hy"]
dirpath = "/".join(fullname.split("."))
for pth in sys.path:
pth = os.path.abspath(pth)
for fp in fls:
composed_path = fp % ("%s/%s" % (pth, dirpath))
if os.path.exists(composed_path):
return composed_path
def find_module(self, fullname, path=None):
path = self.find_on_path(fullname)
if path:
return MetaLoader(path)
sys.meta_path.append(MetaImporter())
列表 2-2:一个 Hy 模块导入器
一旦 Python 确定路径有效并且指向一个模块,就会返回一个 MetaLoader 对象,如 列表 2-3 所示。
class MetaLoader(object):
def __init__(self, path):
self.path = path
def is_package(self, fullname):
dirpath = "/".join(fullname.split("."))
for pth in sys.path:
pth = os.path.abspath(pth)
composed_path = "%s/%s/__init__.hy" % (pth, dirpath)
if os.path.exists(composed_path):
return True
return False
def load_module(self, fullname):
if fullname in sys.modules: return sys.modules[fullname]
if not self.path:
return
sys.modules[fullname] = None
➊ mod = import_file_to_module(fullname, self.path)
ispkg = self.is_package(fullname)
mod.__file__ = self.path
mod.__loader__ = self
mod.__name__ = fullname
if ispkg:
mod.__path__ = []
mod.__package__ = fullname
else:
mod.__package__ = fullname.rpartition('.')[0]
sys.modules[fullname] = mod
return mod
列表 2-3:一个 Hy 模块加载器对象
在 ➊ 处,import_file_to_module 读取一个 .hy 源文件,将其编译为 Python 代码,并返回一个 Python 模块对象。
这个加载器非常直接:一旦找到 .hy 文件,就将其传递给这个加载器,若有必要,它会编译文件、注册文件、设置一些属性,然后返回给 Python 解释器。
uprefix 模块是此功能应用的另一个很好的示例。Python 3.0 到 3.2 不支持 Python 2 中用于表示 Unicode 字符串的 u 前缀;uprefix 模块通过在编译前移除字符串中的 u 前缀,确保 Python 2 和 3 版本之间的兼容性。
有用的标准库
Python 附带了一个庞大的标准库,里面包含了几乎任何你能想到的工具和功能。习惯于为基本任务编写自己函数的 Python 新手,通常会被语言本身已经内置并准备好使用的如此丰富的功能所震惊。
每当你有写自己函数来处理简单任务的冲动时,先停下来查阅一下标准库。事实上,在你开始使用 Python 之前,至少浏览一遍标准库,这样下次需要某个函数时,你就能知道它是否已经存在于标准库中。
我们将在后续章节讨论这些模块中的一些,比如 functools 和 itertools,但这里有一些你肯定会发现有用的标准模块:
-
atexit允许你注册程序在退出时调用的函数。 -
argparse提供用于解析命令行参数的函数。 -
bisect提供用于排序列表的二分法算法(参见 第十章)。 -
calendar提供多个与日期相关的函数。 -
codecs提供用于编码和解码数据的函数。 -
collections提供多种有用的数据结构。 -
copy提供用于复制数据的函数。 -
csv提供用于读取和写入 CSV 文件的函数。 -
datetime提供处理日期和时间的类。 -
fnmatch提供用于匹配 Unix 风格文件名模式的函数。 -
concurrent提供异步计算(Python 3 中为原生支持,Python 2 通过 PyPI 提供)。 -
glob提供用于匹配 Unix 风格路径模式的函数。 -
io提供用于处理 I/O 流的函数。在 Python 3 中,它还包含 StringIO(在 Python 2 中是同名模块),允许你将字符串当作文件来处理。 -
json提供用于读取和写入 JSON 格式数据的函数。 -
logging提供对 Python 内置日志功能的访问。 -
multiprocessing允许你从应用程序中运行多个子进程,同时提供一个 API,使它们看起来像线程。 -
operator提供实现基本 Python 运算符的函数,你可以使用这些函数,而无需编写自己的 lambda 表达式(参见 第十章)。 -
os提供对基本操作系统功能的访问。 -
random提供生成伪随机数的函数。 -
re提供正则表达式功能。 -
sched提供一个事件调度器,而无需使用多线程。 -
select提供对select()和poll()函数的访问,用于创建事件循环。 -
shutil提供对高级文件操作的访问。 -
signal提供处理 POSIX 信号的函数。 -
tempfile提供用于创建临时文件和目录的函数。 -
threading提供对高级线程功能的访问。 -
urllib(以及 Python 2.x 中的urllib2和urlparse)提供处理和解析 URL 的函数。 -
uuid允许你生成全局唯一标识符(UUID)。
使用这个列表作为快速参考,了解这些有用库模块的功能。如果你能记住其中的一部分,那就更好了。你花在查找库模块上的时间越少,就可以花更多的时间编写你真正需要的代码。
大多数标准库是用 Python 编写的,因此没有任何阻止你查看模块和函数源代码的理由。如果有疑问,打开代码看看它自己是如何工作的。即使文档中包含了你需要知道的所有内容,依然有可能学到一些有用的东西。
外部库
Python 的“自带电池”理念是,一旦你安装了 Python,你应该拥有构建任何你想要的东西所需的一切。这是为了避免编程中的“拆开一个很棒的礼物,却发现赠送者忘记给它买电池”的情况。
不幸的是,Python 的开发者无法预测你可能想要制作的所有内容。即使他们能做到,大多数人也不愿意处理一个几 GB 的下载文件,尤其是当他们只是想写一个快速的脚本来重命名文件时。因此,即使 Python 标准库功能强大,它也不能覆盖所有内容。幸运的是,Python 社区的成员们创建了外部库。
Python 标准库是安全的、已充分探测的领域:其模块有丰富的文档,且足够多的人定期使用它,你可以放心地尝试它,而不会发生混乱的错误——即使它确实出现问题,你也可以确信有人会在短时间内修复它。另一方面,外部库就像地图上标注的“这里有龙”的地方:文档可能稀缺,功能可能有缺陷,更新可能零散甚至不存在。任何严肃的项目都可能需要外部库才能提供的功能,但你需要意识到使用它们的风险。
这是一个来自战壕的外部库危险故事。OpenStack 使用 SQLAlchemy,这是一个 Python 的数据库工具包。如果你熟悉 SQL,你知道数据库模式可能会随时间变化,因此 OpenStack 还使用了 sqlalchemy-migrate 来处理模式迁移需求。而且它曾经有效……直到它失效。漏洞开始堆积,但没有人去修复它们。那时,OpenStack 也开始考虑支持 Python 3,但 sqlalchemy-migrate 并没有向 Python 3 兼容性发展。到那个时候,显然 sqlalchemy-migrate 对我们来说已经没有用了,我们需要换成其他工具——我们的需求超出了这个外部库的能力范围。目前,OpenStack 项目正在向使用 Alembic 迁移,这是一个支持 Python 3 的新 SQL 数据库迁移工具。虽然这不是没有努力,但幸运的是,过程并没有带来太多痛苦。
外部库安全检查清单
所有这些都引出一个重要问题:你如何确保自己不会陷入外部库的陷阱?不幸的是,你无法确定:程序员也是人,也没有办法确切知道今天仍然积极维护的库,几个月后是否还能保持良好状态。然而,使用这些库可能值得承担一定的风险;重要的是要仔细评估你的情况。在 OpenStack 中,我们使用以下清单来决定是否使用外部库,我鼓励你也这样做。
Python 3 兼容性 即使你当前不针对 Python 3,未来你可能会需要它,所以最好检查你选择的库是否已经支持 Python 3,并承诺保持兼容性。
活跃开发 GitHub 和 Ohloh 通常提供足够的信息,帮助判断某个库是否正在被其维护者积极开发。
积极维护 即使一个库被认为已完成(即功能完整),其维护者也应该确保它保持无漏洞。检查项目的跟踪系统,看看维护者对漏洞的响应速度如何。
与操作系统分发版捆绑 如果一个库与主要的 Linux 发行版一起打包,这意味着其他项目也依赖于它——所以如果出现问题,你不会是唯一抱怨的人。如果你计划将软件发布给公众,检查这一点也是个好主意:如果它的依赖项已经安装在终端用户的机器上,你的代码将更容易分发。
API 兼容性承诺 没有什么比软件因其依赖的库更改了整个 API 而突然崩溃更糟糕的了。你可能想检查一下你选择的库是否曾经发生过类似的情况。
许可证 你需要确保许可证与你计划编写的软件兼容,并且它允许你按计划对代码进行分发、修改和执行。
将此检查清单应用于依赖项也是个不错的主意,尽管这可能是一个庞大的工作量。作为折衷,如果你知道你的应用程序将严重依赖某个特定的库,你应该将此检查清单应用于该库的每一个依赖项。
通过 API 封装保护你的代码
无论你最终使用哪些库,都需要把它们当作可能造成严重损害的有用工具来对待。为了安全起见,库应该像任何物理工具一样存放:放在工具棚里,远离易碎的贵重物品,但在需要时可以随时使用。
无论外部库多么有用,都要小心不要让它深入到你的源代码中。否则,如果出现问题需要切换库,你可能需要重写大量的程序代码。一个更好的主意是编写你自己的 API——一个封装外部库并将其隔离于源代码的包装器。你的程序永远不需要知道它使用了哪些外部库,只需知道你的 API 提供了哪些功能。然后,如果你需要使用不同的库,只需更改你的包装器。只要新库提供相同的功能,你就不需要触动代码库的其余部分。可能会有例外,但大多数库都是为了处理一个紧密集中的问题范围而设计的,因此可以轻松地进行隔离。
在第五章中,我们还会探讨如何利用入口点构建驱动系统,这样你就可以把项目的部分内容当作模块来随意切换。
包安装:从 pip 获取更多功能
pip 项目提供了一种非常简单的方法来处理包和外部库的安装。它正在积极开发,得到了良好的维护,并从 Python 3.4 版本开始与 Python 一起包含。它可以从 Python 包索引 (PyPI)、tar 包或 Wheel 归档文件安装或卸载包(我们将在第五章中讨论这些)。
它的使用非常简单:
$ pip install --user voluptuous
Downloading/unpacking voluptuous
Downloading voluptuous-0.8.3.tar.gz
Storing download in cache at ./.cache/pip/https%3A%2F%2Fpypi.python.org%2Fpa
ckages%2Fsource%2Fv%2Fvoluptuous%2Fvoluptuous-0.8.3.tar.gz
Running setup.py egg_info for package voluptuous
Requirement already satisfied (use --upgrade to upgrade): distribute in /usr/
lib/python2.7/dist-packages (from voluptuous)
Installing collected packages: voluptuous
Running setup.py install for voluptuous
Successfully installed voluptuous
Cleaning up...
通过在 PyPI 分发索引中查找,任何人都可以上传包以供其他人分发和安装,pip install 可以安装任何包。
你还可以提供 --user 选项,使 pip 将包安装到你的主目录中。这可以避免将包安装到系统目录中,从而污染操作系统的目录。
你可以使用 pip freeze 命令列出你已经安装的包,如下所示:
$ pip freeze
Babel==1.3
Jinja2==2.7.1
commando=0.3.4
--snip--
pip 也支持卸载包,可以使用 uninstall 命令:
$ pip uninstall pika-pool
Uninstalling pika-pool-0.1.3:
/usr/local/lib/python2.7/site-packages/pika_pool-0.1.3.dist-info/
DESCRIPTION.rst
/usr/local/lib/python2.7/site-packages/pika_pool-0.1.3.dist-info/INSTALLER
/usr/local/lib/python2.7/site-packages/pika_pool-0.1.3.dist-info/METADATA
--snip--
Proceed (y/n)? y
Successfully uninstalled pika-pool-0.1.3
pip 的一个非常有价值的功能是能够安装一个包而不复制包的文件。这个功能的典型使用场景是当你在积极开发一个包并且想避免每次需要测试更改时都重新安装的漫长乏味过程。你可以通过使用 -e <directory> 标志来实现:
$ pip install -e .
Obtaining file:///Users/jd/Source/daiquiri
Installing collected packages: daiquiri
Running setup.py develop for daiquiri
Successfully installed daiquiri
在这里,pip 不会从本地源目录复制文件,而是将一个名为 egg-link 的特殊文件放入你的分发路径中。例如:
$ cat /usr/local/lib/python2.7/site-packages/daiquiri.egg-link
/Users/jd/Source/daiquiri
egg-link 文件包含要添加到 sys.path 中以查找包的路径。结果可以通过运行以下命令轻松检查:
$ python -c "import sys; print('/Users/jd/Source/daiquiri' in sys.path)"
True
另一个有用的 pip 工具是 pip install 的 -e 选项,它有助于从各种版本控制系统的仓库中部署代码:支持 git、Mercurial、Subversion,甚至是 Bazaar。例如,你可以通过将仓库地址作为 URL 传递给 -e 选项来直接从 git 仓库安装任何库:
$ pip install -e git+https://github.com/jd/daiquiri.git\#egg=daiquiri
Obtaining daiquiri from git+https://github.com/jd/daiquiri.git#egg=daiquiri
Cloning https://github.com/jd/daiquiri.git to ./src/daiquiri
Installing collected packages: daiquiri
Running setup.py develop for daiquiri
Successfully installed daiquiri
为了正确安装,你需要通过在 URL 末尾添加 #egg= 来提供包的 egg 名称。然后,pip 仅使用 git clone 将仓库克隆到 src/<eggname> 目录下,并创建一个指向该克隆目录的 egg-link 文件。
当依赖于未发布版本的库或在持续测试系统中工作时,这个机制非常有用。然而,由于没有版本控制,-e 选项也可能非常麻烦。你无法提前知道远程仓库中的下一个提交是否会破坏一切。
最后,所有其他安装工具都被弃用,推荐使用 pip,所以你可以放心地将它作为你所有包管理需求的一站式解决方案。
使用和选择框架
Python 为各种 Python 应用程序提供了多种框架:如果你正在编写一个 Web 应用程序,可以使用 Django、Pylons、TurboGears、Tornado、Zope 或 Plone;如果你在寻找一个事件驱动框架,可以使用 Twisted 或 Circuits;等等。
框架和外部库之间的主要区别在于应用程序通过在框架上进行构建来使用框架:你的代码会扩展框架,而不是反过来。与库不同,库本质上是你可以引入的附加功能,来为你的代码增添一些额外的动力;框架则构成了你代码的 底盘:你所做的一切都以某种方式建立在这个底盘之上。这既有利也有弊。使用框架有许多优点,例如快速原型开发和快速开发,但也有一些值得注意的缺点,比如锁定效应。在决定是否使用框架时,你需要考虑这些因素。
在选择适合你的 Python 应用程序的框架时,要检查的推荐内容大体上与在《"外部库安全检查清单"》第 23 页 中描述的内容相同——这也说得通,因为框架通常作为 Python 库的捆绑包分发。有时框架还包括用于创建、运行和部署应用程序的工具,但这并不改变你应该应用的标准。我们已经确认,替换你已经编写了代码并依赖于的外部库是件麻烦事,但替换框架则更糟,通常需要从头开始完全重写程序。
举个例子,前面提到的 Twisted 框架仍然没有完全支持 Python 3:如果你几年前写了一个使用 Twisted 的程序并希望将其更新以在 Python 3 上运行,那么你可能会很失望。你要么需要重写整个程序以使用另一个框架,要么只能等到有人最终升级 Twisted,完全支持 Python 3。
一些框架比其他框架更轻量。例如,Django 有自己内置的 ORM 功能;而 Flask 则完全没有这种功能。框架为你做的事情越少,未来你遇到的问题就会越少。然而,每个框架缺失的功能都意味着你需要解决另一个问题,可能是通过编写自己的代码,或者麻烦地挑选另一个库来处理它。选择处理哪个场景完全取决于你,但请谨慎选择:当事情变得糟糕时,从框架迁移出去可能是一个艰巨的任务,且即使有其他所有功能,Python 也没有什么能够帮助你应对这种情况的工具。
Doug Hellmann,Python 核心开发者,谈 Python 库
Doug Hellmann 是 DreamHost 的高级开发人员,同时也是 OpenStack 项目的贡献者。他创办了名为 Python Module of the Week 的网站 (www.pymotw.com/),并撰写了一本优秀的书籍《Python 标准库实例解析》。他还是 Python 核心开发者。我向 Doug 提出了一些关于标准库以及围绕它设计库和应用程序的问题。
从零开始编写 Python 应用程序时,你的第一步是什么?
从零开始编写应用程序的步骤与修改现有应用程序在抽象层面上相似,但细节有所不同。
当我更改现有代码时,我首先弄清楚它是如何工作的,以及我的更改需要放在哪里。我可能会使用一些调试技术:添加日志或打印语句,或使用 pdb,并使用测试数据运行应用程序,以确保我理解它的运行方式。我通常会先手动做出更改并测试,然后再添加任何自动化测试,最后再提交补丁。
当我创建一个新应用程序时,我采取相同的探索性方法——先编写一些代码并手动运行,等基本功能实现后,我编写测试以确保覆盖所有边界情况。创建测试的过程也可能导致一些重构,使代码更易于使用。
这在 smiley [一个用于监视 Python 程序并记录其活动的工具] 中的确如此。我从实验 Python 的 trace API 开始,使用一些临时脚本,然后才构建真正的应用程序。最初,我打算有一个部分用于插装并收集另一个正在运行的应用程序的数据,另一个部分则用于收集通过网络发送的数据并保存它。在添加了一些报告功能后,我意识到重播收集数据的处理几乎与收集数据时的处理完全相同。我重构了几个类,并能够创建一个基类来处理数据收集、数据库访问和报告生成。使这些类符合相同的 API,使我能够轻松创建一个直接将数据写入数据库而不是通过网络发送信息的数据收集应用程序版本。
在设计应用程序时,我会考虑用户界面是如何工作的,但对于库,我更关注开发者将如何使用 API。有时,先为将要使用新库的程序编写测试,再编写库代码会更容易。我通常会创建一系列测试形式的示例程序,然后构建库来支持这些程序。
我还发现,在编写代码之前先为库编写文档有助于我思考功能和工作流,而不必立即决定实现细节,同时它也让我记录下我在设计中做出的选择,让读者理解不仅是如何使用库,还能理解我在创建库时的期望。
将一个模块添加到 Python 标准库的过程是什么?
提交模块到标准库的完整过程和指南可以在 Python 开发者指南中找到,网址是 docs.python.org/devguide/stdlibchanges.html。
在一个模块被添加之前,提交者需要证明它是稳定且广泛有用的。该模块应提供一些功能,这些功能要么是自己实现起来很难正确完成,要么是非常有用,很多开发者已经创建了自己的变体。API 应该清晰,任何模块的依赖项应仅限于标准库内部。
第一步是通过python-ideas邮件列表在社区中提出引入该模块到标准库的想法,非正式地评估大家的兴趣。如果反响积极,下一步是创建一个 Python 增强提案(PEP),其中应包括添加该模块的动机和实现细节,说明如何进行过渡。
由于包管理和发现工具已经变得非常可靠,尤其是pip和 PyPI,因此将新的库维护在 Python 标准库之外可能更为实际。单独发布允许更频繁地更新新特性和修复 bug,这对于处理新技术或 API 的库尤其重要。
你希望人们更多了解的标准库中的前三个模块是什么?
来自标准库的一个非常有用的工具是abc模块。我使用abc模块将动态加载的扩展的 API 定义为抽象基类,以帮助扩展作者理解哪些 API 方法是必需的,哪些是可选的。抽象基类内置于一些其他面向对象编程(OOP)语言中,但我发现很多 Python 程序员不知道我们也有它们。
bisect模块中的二分查找算法是一个很好的例子,它是一个有用的特性,但经常被错误实现,因此非常适合放入标准库。我特别喜欢它可以搜索稀疏列表,其中搜索值可能不在数据中。
在collections模块中有一些有用的数据结构,它们的使用频率并不像应有的那样高。我喜欢使用namedtuple来创建小型、类似类的数据结构,用于存储数据而不包含任何相关的逻辑。如果以后需要添加逻辑,将namedtuple转换为常规类非常简单,因为namedtuple支持通过名称访问属性。这个模块中的另一个有趣的数据结构是ChainMap,它非常适合做堆叠式命名空间。ChainMap可以用于创建渲染模板的上下文,或用于管理来自不同来源的配置设置,并明确规定优先级。
很多项目,包括 OpenStack 和外部库,都在标准库之上构建了自己的抽象层,比如日期/时间处理等方面。你认为程序员应该坚持使用标准库,自己编写函数,切换到外部库,还是开始向 Python 提交补丁?
上述所有建议!我倾向于避免重复造轮子,因此我强烈主张将修复和增强贡献给上游项目,这些项目可以作为依赖项使用。另一方面,有时创建另一个抽象并将代码单独维护,无论是在应用程序中还是作为新库,都有其合理性。
在你示例中使用的timeutils模块是对 Python datetime模块的一个相对薄的封装。大多数函数简短且简单,但创建一个包含最常见操作的模块可以确保它们在所有项目中一致地处理。由于很多函数是应用特定的,意味着它们强制决定了像时间戳格式字符串或“现在”意味着什么这样的事项,因此它们并不适合作为补丁贡献到 Python 库,或者作为一个通用库发布并被其他项目采用。
相比之下,我一直在努力将 OpenStack 中的 API 服务从项目初期创建的 WSGI [Web Server Gateway Interface]框架迁移到第三方 Web 开发框架。在 Python 中创建 WSGI 应用程序有很多选择,虽然我们可能需要增强其中一个框架,使其完全适用于 OpenStack 的 API 服务器,但将这些可重用的更改贡献到上游比维护一个“私有”框架更为可取。
对于在主要 Python 版本之间犹豫不决的开发者,你有什么建议?
支持 Python 3 的第三方库数量已经达到了临界点。现在比以往任何时候都更容易为 Python 3 构建新的库和应用程序,而且得益于 3.3 中新增的兼容性功能,维护对 Python 2.7 的支持也变得更容易。主要的 Linux 发行版正在致力于发布默认安装 Python 3 的版本。任何开始新项目的 Python 开发者都应该认真考虑 Python 3,除非他们有尚未迁移的依赖项。不过,到目前为止,不能运行在 Python 3 上的库几乎可以被归类为“未维护”的库。
从设计、提前规划、迁移等方面来看,将代码从应用程序分支到库的最佳方法是什么?
应用程序是将库按特定目的组合在一起的“胶水代码”集合。先将应用程序设计为具有实现该目的的功能的库,然后再构建应用程序,确保代码被正确地组织成逻辑单元,从而简化测试。这也意味着应用程序的功能可以通过库访问,并且可以重新组合以创建其他应用程序。如果不采取这种方法,就有可能使应用程序的功能与用户界面紧密绑定,从而使它们更难以修改和重用。
你会给计划设计自己 Python 库的人什么建议?
我总是建议从上到下设计库和 API,在每一层应用设计标准,例如单一职责原则(SRP)。考虑调用者希望如何使用库,并创建支持这些功能的 API。考虑实例中可以存储哪些值并由方法使用,以及每次方法调用时需要传递哪些参数。最后,考虑实现方式以及底层代码是否应该与公共 API 的代码组织方式有所不同。
SQLAlchemy 是一个应用这些准则的优秀示例。声明式 ORM [对象关系映射]、数据映射和表达式生成层都是分开的。开发者可以根据自己的需求而非库设计的限制,决定进入 API 和使用库的合适抽象层次。
你在阅读 Python 开发者代码时,遇到的最常见编程错误是什么?
Python 的惯用法在循环和迭代方面与其他语言有显著不同。例如,我看到的最常见的反模式之一是使用 for 循环过滤列表,方法是首先将项附加到新列表中,然后在第二个循环中处理结果(可能是在将列表作为参数传递给函数之后)。我几乎总是建议将这种过滤循环转换为生成器表达式,这样既更高效又更容易理解。也常见将多个列表合并在一起,以便它们的内容可以以某种方式一起处理,而不是使用 itertools.chain()。
在代码审查中,我经常建议其他一些更微妙的改进,比如使用 dict() 作为查找表,而不是使用长的 if:then:else 语句块,确保函数总是返回相同类型的对象(例如,返回一个空列表而不是 None),通过将相关值组合成元组或新类的对象来减少函数所需的参数数量,以及定义类以用于公共 API,而不是依赖字典。
你对框架的看法是什么?
框架就像其他任何工具一样。它们能提供帮助,但在选择框架时,你需要小心,确保它适合当前的工作需求。
将应用程序的公共部分提取到框架中有助于你将开发精力集中在应用程序的独特方面。框架还提供了大量的引导代码,用于执行像在开发模式下运行和编写测试套件等任务,帮助你更快地将应用程序带入有用的状态。它们还鼓励应用程序实现的一致性,这意味着你最终得到的代码更容易理解和复用。
当然,也存在一些潜在的陷阱。选择使用特定框架通常意味着对应用程序设计的某些假设。选择错误的框架可能会使应用程序的实现变得更加困难,特别是当这些设计约束与应用程序的需求不自然契合时。如果你尝试使用与框架推荐的模式或惯用法不同的方式,可能会发现自己不得不与框架“斗争”。
第三章:文档编写与良好的 API 实践

在本章中,我们将讨论文档编写;具体来说,我们将探讨如何通过 Sphinx 自动化文档编写中更棘手和更繁琐的部分。虽然你仍然需要自己编写文档,但 Sphinx 将简化你的任务。由于通常使用 Python 库提供功能,我们还将讨论如何管理和记录你的公共 API 变更。因为你的 API 必须随着功能的更改而不断演变,从一开始就完美构建一切是很罕见的,但我将展示一些方法,帮助你确保你的 API 尽可能地对用户友好。
本章的最后,我们将进行一次与 Christophe de Vienne 的采访,他是《Web Services Made Easy》框架的作者,在采访中他将讨论开发和维护 API 的最佳实践。
使用 Sphinx 编写文档
文档编写是软件开发中最重要的部分之一。不幸的是,许多项目没有提供适当的文档。编写文档被认为是复杂且令人生畏的,但其实不必如此:借助 Python 程序员可用的工具,编写文档和编写代码一样简单。
缺乏或没有文档的最大原因之一是许多人认为编写文档的唯一方式是手动编写。即使项目中有多人,这也意味着团队中的一位或多位成员将不得不在贡献代码和维护文档之间权衡——如果你问任何开发人员他们更愿意做哪项工作,可以确定他们会说更愿意编写软件,而不是编写 关于 软件的文档。
有时,文档编写过程与开发过程是完全分开的,这意味着文档是由没有编写实际代码的人编写的。此外,以这种方式生成的任何文档都可能是过时的:手动编写的文档几乎不可能跟上开发的步伐,无论由谁负责。
结论是:你的代码和文档之间的隔离程度越高,维护文档的难度就越大。那么,为什么要把它们分开呢?不仅可以直接在代码中编写文档,而且将这些文档转换为易于阅读的 HTML 和 PDF 文件也很简单。
Python 文档编写的最常见格式是 reStructuredText,简称 reST。它是一种轻量级标记语言(类似 Markdown),既易于人类阅读和编写,也易于计算机处理。Sphinx 是处理这种格式的最常用工具;Sphinx 可以读取 reST 格式的内容,并将文档输出为多种其他格式。
我建议你的项目文档始终包含以下内容:
-
你的项目旨在解决的问题,简明扼要地用一两句话说明。
-
您的项目所采用的许可协议。如果您的软件是开源的,您还应在每个代码文件的头部包含此信息;仅仅因为您已将代码上传到互联网上,并不意味着其他人会知道他们可以对其做什么。
-
一个小示例,展示您的代码是如何工作的。
-
安装说明。
-
指向社区支持、邮件列表、IRC、论坛等的链接。
-
一个指向您的错误跟踪系统的链接。
-
指向您的源代码的链接,方便开发者下载并立即开始研究。
您还应包含一个 README.rst 文件,解释您的项目功能。此 README 应显示在您的 GitHub 或 PyPI 项目页面上;这两个网站都知道如何处理 reST 格式。
注意
如果您使用 GitHub,您还可以添加一个 CONTRIBUTING.rst 文件,当有人提交拉取请求时,这个文件将会显示。它应提供一个用户在提交请求前遵循的清单,包括代码是否遵循 PEP 8,以及提醒用户运行单元测试等事项。Read the Docs 允许您自动在线构建和发布文档。注册和配置项目非常简单。然后 Read the Docs 会搜索您的 Sphinx 配置文件,构建文档并使其可以供用户访问。这是一个非常适合与代码托管网站配合使用的工具。
开始使用 Sphinx 和 reST
您可以从 www.sphinx-doc.org/ 获取 Sphinx。网站上有安装说明,但最简单的方法是通过 pip install sphinx 安装。
安装 Sphinx 后,在项目的顶层目录中运行 sphinx-quickstart。这将创建 Sphinx 所需的目录结构,并在 doc/source 文件夹中生成两个文件:conf.py,它包含 Sphinx 的配置设置(这是 Sphinx 正常运行所必需的),以及 index.rst,它作为文档的首页。一旦运行了快速启动命令,您将依次完成一系列步骤,用以指定命名约定、版本约定以及其他工具和标准的选项。
conf.py 文件包含一些文档化的变量,例如项目名称、作者以及用于 HTML 输出的主题。您可以随时编辑此文件。
创建好结构并设置好默认值后,您可以通过调用 sphinx-build 命令,传入源目录和输出目录作为参数,将文档构建为 HTML,如示例 3-1 所示。sphinx-build 命令会读取源目录中的 conf.py 文件,解析该目录中的所有 .rst 文件,并将其渲染为 HTML 输出到目标目录。
$ sphinx-build doc/source doc/build
import pkg_resources
Running Sphinx v1.2b1
loading pickled environment... done
No builder selected, using default: html
building [html]: targets for 1 source files that are out of date
updating environment: 0 added, 0 changed, 0 removed
looking for now-outdated files... none found
preparing documents... done
writing output... [100%] index
writing additional files... genindex search copying static files... done
dumping search index... done
dumping object inventory... done
build succeeded.
示例 3-1:构建一个基本的 Sphinx HTML 文档
现在你可以在你最喜欢的浏览器中打开 doc/build/index.html 并阅读你的文档。
注意
如果你正在使用 setuptools 或 pbr(参见 第五章)进行打包,Sphinx 扩展了它们以支持命令 setup.py build_sphinx,这将自动运行 sphinx-build。Sphinx 的 pbr 集成具有一些更合理的默认值,例如将文档输出到 /doc 子目录。
你的文档从 index.rst 文件开始,但不一定要以此结束:reST 支持 include 指令从其他 reST 文件中包含 reST 文件,因此没有什么能阻止你将文档分成多个文件。刚开始时不要太担心语法和语义;reST 提供了很多格式化选项,但你有足够的时间稍后深入参考文档。完整的参考文档(docutils.sourceforge.net/docs/ref/rst/restructuredtext.html) 解释了如何创建标题、项目符号列表、表格等。
Sphinx 模块
Sphinx 的扩展性非常强:它的基本功能仅支持手动文档,但它附带了一些有用的模块,可以启用自动文档生成和其他功能。例如,sphinx.ext.autodoc 会从你的模块中提取 reST 格式的文档字符串并生成 .rst 文件以供包含。这是 sphinx-quickstart 会询问你是否想要启用的选项之一。如果你没有选择该选项,你仍然可以编辑你的 conf.py 文件,并像这样将其作为扩展添加:
extensions = ['sphinx.ext.autodoc']
请注意,autodoc 不会 自动识别并包含你的模块。你需要明确指定你想要文档化的模块,通过在你的 .rst 文件中添加类似 清单 3-2 的内容。
.. automodule:: foobar
➊ :members:
➋ :undoc-members:
➌ :show-inheritance:
清单 3-2:指定自动文档化的模块
在 清单 3-2 中,我们提出了三个请求,所有这些请求都是可选的:打印所有已记录的成员 ➊,打印所有未记录的成员 ➋,以及显示继承关系 ➌。还需要注意以下几点:
-
如果你没有包含任何指令,Sphinx 将不会生成任何输出。
-
如果你只指定
:members:,模块、类或方法树中的未记录节点将被跳过,即使它们的所有成员都有文档。举例来说,如果你文档化了一个类的方法,但没有文档化类本身,:members:会排除该类及其方法。为了防止这种情况发生,你需要为该类写文档字符串,或者也可以指定:undoc-members:。 -
你的模块需要位于 Python 可以导入的地方。将
.,.., 和/或../..添加到sys.path可以帮助解决此问题。
autodoc 扩展使你能够将大部分文档内容直接包含在源代码中。你甚至可以选择哪些模块和方法进行文档化——这不是一个“全有或全无”的解决方案。通过将文档与源代码直接维护在一起,你可以轻松确保它始终保持最新。
使用 autosummary 自动生成目录
如果你正在编写一个 Python 库,通常你会希望用一个包含指向每个模块单独页面链接的目录来格式化你的 API 文档。
sphinx.ext.autosummary 模块是专门为处理这种常见用例而创建的。首先,你需要在 conf.py 中启用它,通过添加以下行:
extensions = ['sphinx.ext.autosummary']
然后,你可以将以下内容添加到 .rst 文件中,自动为指定的模块生成目录:
.. autosummary::
mymodule
mymodule.submodule
这将创建名为 generated/mymodule.rst 和 generated/mymodule.submodule.rst 的文件,其中包含前面提到的 autodoc 指令。使用相同的格式,你可以指定要包含在文档中的模块 API 部分。
注意
Sphinx-apidoc 命令可以自动为你创建这些文件;查看 Sphinx 文档了解更多信息。
使用 doctest 自动化测试
Sphinx 的另一个有用功能是能够在你构建文档时自动运行doctest来测试示例。标准的 Python doctest 模块会在文档中搜索代码片段,并测试它们是否准确地反映了代码的实际行为。每一个以主提示符 >>> 开头的段落都会被视为一个代码片段来进行测试。例如,如果你想文档化 Python 的标准 print 函数,你可以编写如下的文档片段,doctest 会检查结果:
To print something to the standard output, use the :py:func:`print`
function:
>>> print("foobar")
foobar
在文档中包含此类示例可以让用户理解你的 API。然而,随着 API 的发展,更新示例可能会被推迟,甚至忘记更新。幸运的是,doctest 帮助确保这一点不会发生。如果你的文档包括逐步教程,doctest 会通过测试每一行代码,确保它在开发过程中始终保持最新。
你还可以将 doctest 用于 文档驱动开发(DDD):先编写文档和示例,然后编写与文档相匹配的代码。利用这个功能非常简单,只需使用特殊的 doctest 构建器运行 sphinx-build,就像这样:
$ sphinx-build -b doctest doc/source doc/build
Running Sphinx v1.2b1
loading pickled environment... done
building [doctest]: targets for 1 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: index
---------------
1 items passed all tests:
1 tests in default
1 tests in 1 items.
1 passed and 0 failed.
Test passed.
Doctest summary
===============
1 test
0 failures in tests
0 failures in setup code
0 failures in cleanup code
build succeeded.
使用 doctest 构建器时,Sphinx 会读取常规的 .rst 文件,并执行其中包含的代码示例。
Sphinx 还提供了许多其他功能,无论是开箱即用的,还是通过扩展模块提供的,包括以下功能:
-
项目之间的链接
-
HTML 主题
-
图表和公式
-
输出到 Texinfo 和 EPUB 格式
-
链接到外部文档
你可能不会立刻需要所有这些功能,但如果将来需要,提前了解这些是很有帮助的。再次查看完整的 Sphinx 文档,了解更多信息。
编写 Sphinx 扩展
有时候现成的解决方案不足以应对,你需要创建自定义工具来处理特定情况。
假设你正在编写一个 HTTP REST API。Sphinx 只会记录你的 API 的 Python 部分,迫使你手动编写 REST API 文档,带来所有相关问题。Web Services Made Easy(WSME)的创建者(在本章末尾采访)提出了一个解决方案:一个名为sphinxcontrib-pecanwsme的 Sphinx 扩展,它分析 docstring 和实际的 Python 代码,自动生成 REST API 文档。
注意
对于其他 HTTP 框架,例如 Flask、Bottle 和 Tornado,你可以使用 sphinxcontrib.httpdomain。
我的观点是,每当你知道可以从代码中提取信息来构建文档时,你应该这么做,并且你也应该自动化这个过程。这比试图维护手动编写的文档要好,尤其是当你可以利用像 Read the Docs 这样的自动发布工具时。
我们将以sphinxcontrib-pecanwsme扩展为例,展示如何编写你自己的 Sphinx 扩展。第一步是编写一个模块——最好作为sphinxcontrib的子模块,只要你的模块足够通用——并为其选择一个名称。Sphinx 要求这个模块具有一个预定义的函数setup(app),该函数包含你将用来将代码连接到 Sphinx 事件和指令的方法。完整的方法列表可以在 Sphinx 扩展 API 中找到,地址是www.sphinx-doc.org/en/master/extdev/appapi.html。
例如,sphinxcontrib-pecanwsme扩展包含一个名为rest-controller的指令,可以通过setup(app)函数添加。这个添加的指令需要一个完全限定的控制器类名来生成文档,如清单 3-3 所示。
def setup(app):
app.add_directive('rest-controller', RESTControllerDirective)
清单 3-3:来自 sphinxcontrib.pecanwsme.rest.setup 的代码,添加了 rest-controller 指令
清单 3-3 中的add_directive方法注册了rest-controller指令,并将其处理委托给RESTControllerDirective类。这个RESTControllerDirective类暴露了某些属性,指示指令如何处理内容,是否有参数等等。该类还实现了一个run()方法,实际上从代码中提取文档并将解析后的数据返回给 Sphinx。
在bitbucket.org/birkenfeld/sphinx-contrib/src/的代码库中,有许多小模块可以帮助你开发自己的扩展。
注意
尽管 Sphinx 是用 Python 编写并默认针对 Python,但也有扩展可用,允许它支持其他语言。即使你的项目使用多种语言,你也可以使用 Sphinx 对你的项目进行全面文档化。
作为另一个例子,在我的一个项目 Gnocchi 中——这是一个用于大规模存储和索引时间序列数据的数据库——我使用了一个自定义的 Sphinx 扩展来自动生成文档。Gnocchi 提供了一个 REST API,通常为了文档化这样的 API,项目会手动编写 API 请求及其响应的示例。不幸的是,这种方法容易出错并且与实际情况不同步。
使用可用的单元测试代码来测试 Gnocchi API,我们构建了一个 Sphinx 扩展来运行 Gnocchi,并生成一个包含 HTTP 请求和响应的.rst文件,这些请求和响应是在真实的 Gnocchi 服务器上运行的。通过这种方式,我们确保文档是最新的:服务器响应不是手动编写的,如果手动编写的请求失败,那么文档过程也会失败,我们就知道必须修复文档。
将这些代码包括在书中会显得过于冗长,但你可以在线查看 Gnocchi 的源代码,并查看gnocchi.gendoc模块,以了解它是如何工作的。
管理 API 的变化
良好的文档化代码向其他开发者表明该代码适合被导入并用于构建其他内容。例如,在构建一个库并导出 API 供其他开发者使用时,你需要提供稳固文档的保证。
本节将介绍公共 API 的最佳实践。这些 API 将暴露给你库或应用的用户,虽然你可以随意处理内部 API,但公共 API 应当小心对待。
为了区分公共和私有 API,Python 的约定是将私有 API 的符号前缀加上下划线:foo是公共的,而_bar是私有的。你应该使用这种约定来判断另一个 API 是公共的还是私有的,并且用来命名你自己的 API。与其他语言(如 Java)不同,Python 并不强制限制访问标记为私有或公共的代码。命名约定仅仅是为了方便程序员之间的理解。
API 版本编号
当正确构建时,API 的版本号可以为用户提供大量信息。Python 没有特别的系统或约定来对 API 版本进行编号,但我们可以从 Unix 平台中汲取灵感,后者使用一个复杂的管理系统来处理库并使用细粒度的版本标识符。
通常,你的版本编号应该反映会影响用户的 API 变更。例如,当 API 有重大变化时,主版本号可能会从 1 变为 2;当仅添加了一些新的 API 调用时,次版本号可能会从 2.2 变为 2.3;如果只是修复了 bug,版本号可能会从 2.2.0 变为 2.2.1。一个很好的版本编号使用示例是 Python 的requests库(pypi.python.org/pypi/requests/)。该库根据每个新版本中的变更数量和这些变更可能对使用程序的影响来递增 API 编号。
版本号提示开发者应查看两个版本之间的变化,但单独使用版本号不足以完全指导开发者:你必须提供详细的文档来描述这些变化。
记录你的 API 变更
每当你对 API 进行更改时,首先也是最重要的事情就是进行详细的文档记录,以便代码的使用者能够快速了解正在发生的变化。你的文档应涵盖以下内容:
-
新接口的元素
-
被弃用的旧接口元素
-
如何迁移到新接口的说明
你还应确保不要立即删除旧接口。我建议在接口变得太麻烦之前保留旧接口。如果你已标记其为弃用,用户就会知道不要再使用它。
列表 3-4 是良好 API 变更文档的示例,展示了一个可以朝任意方向转向的汽车对象代码。由于某种原因,开发者决定撤回turn_left方法,取而代之的是提供一个通用的turn方法,该方法可以将方向作为参数传递。
class Car(object):
def turn_left(self):
"""Turn the car left.
.. deprecated:: 1.1
Use :func:`turn` instead with the direction argument set to left
"""
self.turn(direction='left')
def turn(self, direction):
"""Turn the car in some direction.
:param direction: The direction to turn to.
:type direction: str
"""
# Write actual code for the turn function here instead
pass
列表 3-4:汽车对象 API 变更文档示例
这里的三重引号"""表示文档字符串的开始和结束,当用户在终端输入help(Car.turn_left)或使用外部工具如 Sphinx 提取文档时,这些内容会被提取到文档中。car.turn_left方法的弃用通过.. deprecated 1.1来标示,其中1.1指的是发布此弃用代码的第一个版本。
使用这种弃用方法并通过 Sphinx 显示出来,清晰地告诉用户该函数不应再使用,并为他们提供直接访问新函数的途径,同时解释如何迁移旧代码。
图 3-1 展示了 Sphinx 文档,解释了一些弃用的函数。

图 3-1:一些弃用函数的解释
这种方法的缺点是,它依赖于开发者在升级到新版 Python 包时阅读你的变更日志或文档。然而,有一个解决方案:使用warnings模块标记你的弃用函数。
使用 warnings 模块标记弃用函数
尽管过时的模块应当在文档中标注清楚,以免用户尝试调用它们,Python 还提供了warnings模块,允许你的代码在调用已弃用的函数时发出各种警告。这些警告——DeprecationWarning和PendingDeprecationWarning——可以分别用来告诉开发者他们调用的函数已弃用或将被弃用。
注意
对于那些从事 C 语言工作的人来说,这是__attribute__((deprecated))GCC 扩展的一个方便的对照物。
回到列表 3-4 中的汽车对象示例,我们可以使用这个方法来警告用户当他们试图调用已弃用的函数时,如列表 3-5 所示。
import warnings
class Car(object):
def turn_left(self):
"""Turn the car left.
➊ .. deprecated:: 1.1
Use :func:`turn` instead with the direction argument set to "left".
"""
➋ warnings.warn("turn_left is deprecated; use turn instead",
DeprecationWarning)
self.turn(direction='left')
def turn(self, direction):
"""Turn the car in some direction.
:param direction: The direction to turn to.
:type direction: str
"""
# Write actual code here instead
pass
列表 3-5:使用 warnings 模块对汽车对象 API 进行的文档化更改
这里,turn_left函数已经被弃用 ➊。通过添加warnings.warn这一行,我们可以编写我们自己的错误信息 ➋。现在,如果任何代码调用turn_left函数,都会出现如下警告:
>>> Car().turn_left()
__main__:8: DeprecationWarning: turn_left is deprecated; use turn instead
Python 2.7 及以后的版本默认不会打印warnings模块发出的任何警告,因为这些警告会被过滤。若要查看这些警告,需要将-W选项传递给 Python 可执行文件。选项-W all会将所有警告打印到stderr。有关-W可能值的更多信息,请参见 Python 手册页。
在运行测试套件时,开发者可以使用带有-W错误选项的 Python 命令,这样每当调用过时的函数时,就会抛出一个错误。使用你库的开发者可以很容易地找到需要修复的代码位置。列表 3-6 展示了当 Python 使用-W错误选项时,如何将警告转换为致命异常。
>>> import warnings
>>> warnings.warn("This is deprecated", DeprecationWarning)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
DeprecationWarning: This is deprecated
列表 3-6:使用-W 错误选项运行 Python 并获得弃用错误
警告通常在运行时被忽略,而在生产系统中运行带有-W错误选项的 Python 命令通常不是一个好主意。另一方面,使用-W错误选项运行 Python 应用程序的测试套件,则是一个很好的捕获警告并及早修复它们的方法。
然而,手动编写所有这些警告、文档字符串更新等可能会变得乏味,因此创建了debtcollector库来帮助自动化其中的一部分。debtcollector库提供了一些装饰器,你可以在函数中使用它们,确保正确的警告被触发,并且文档字符串被正确更新。列表 3-7 展示了你可以通过一个简单的装饰器,标明一个函数已经被移动到其他地方。
from debtcollector import moves
class Car(object):
@moves.moved_method('turn', version='1.1')
def turn_left(self):
"""Turn the car left."""
return self.turn(direction='left')
def turn(self, direction):
"""Turn the car in some direction.
:param direction: The direction to turn to.
:type direction: str
"""
# Write actual code here instead
pass
清单 3-7:通过 debtcollector 自动化的 API 变更
在这里,我们使用了来自 debtcollector 的 moves() 方法,其 moved_method 装饰器使得每次调用 turn_left 时都会发出一个 DeprecationWarning。
总结
Sphinx 是 Python 项目的事实标准文档工具。它支持多种语法,如果你的项目有特定需求,添加新语法或功能也很容易。Sphinx 还可以自动化任务,如生成索引或从代码中提取文档,使得长期维护文档变得更加轻松。
记录 API 的变更至关重要,尤其是在你弃用某些功能时,这样用户就不会感到意外。记录弃用方法的方式包括使用 Sphinx 的 deprecated 关键字和 warnings 模块,而 debtcollector 库可以自动化维护这些文档。
Christophe de Vienne 关于开发 API 的看法
Christophe 是一名 Python 开发者,也是 WSME(Web Services Made Easy)框架的作者,该框架允许开发者以 Pythonic 的方式定义 web 服务,并支持多种 API,使得它可以与许多其他 web 框架兼容。
开发者在设计 Python API 时倾向于犯哪些错误?
在设计 Python API 时,我通过遵循这些规则避免了几种常见的错误:
-
不要让它太复杂。 保持简单。复杂的 API 很难理解,也难以文档化。虽然实际的库功能不一定需要简单,但将其设计得简单是明智的,这样用户就不会轻易犯错。例如,这个库非常简单直观,但它在背后做了复杂的事情。相比之下,
urllib的 API 几乎与其所做的事情一样复杂,使得它很难使用。 -
让魔法显现出来。 当你的 API 做了文档中没有解释的事情时,最终用户可能会想要打开代码,看看底层发生了什么。如果你背后有一些“魔法”在发生,这是可以的,但最终用户永远不应看到任何意外发生的情况,否则他们可能会感到困惑,或依赖于可能会改变的行为。
-
不要忘记使用案例。 当你全身心投入编写代码时,很容易忘记思考你的库实际上会如何被使用。构思良好的使用案例有助于更容易地设计 API。
-
编写单元测试。 TDD(测试驱动开发) 是编写库的一个非常有效的方法,尤其是在 Python 中,因为它迫使开发者从一开始就以最终用户的身份来设计,这有助于开发者设计出易于使用的 API。它是我所知道的唯一一种允许程序员彻底重写库作为最后手段的方法。
Python 的哪些方面可能影响设计库 API 的难易程度?
Python 没有内建的方式来定义哪些 API 部分是公开的,哪些是私有的,这既是一个问题也是一个优势。
这是一个问题,因为它可能导致开发者没有完全考虑哪些 API 部分是公开的,哪些应该保持私有。但只要有一点自律、文档和(如果需要的话)类似 zope.interface 的工具,这个问题很快就能解决。
当能够更快速、更轻松地重构 API,同时保持与先前版本的兼容性时,这是一个优势。
在考虑 API 的演进、弃用和移除时,你会考虑什么?
在做出任何关于 API 开发的决策时,我会权衡以下几个标准:
-
用户适应库代码的难度有多大? 考虑到有些人依赖你的 API,任何修改都必须值得付出适应它的努力。这个规则旨在防止对常用的 API 部分进行不兼容的更改。话虽如此,Python 的一个优点是相对容易重构代码以适应 API 的更改。
-
如何保证我的 API 易于维护? 简化实现,清理代码库,让 API 更易于使用,编写更完整的单元测试,使 API 一目了然……这些都会让你作为维护者的工作变得更加轻松。
-
如何在应用变更时保持 API 的一致性? 如果你的 API 中所有函数都遵循类似的模式(比如要求第一个参数位置相同),那么确保新函数也遵循该模式。此外,一次做太多事情很容易导致什么都做不好:保持 API 聚焦于它的核心功能。
-
用户从变更中能获得什么好处? 最后但同样重要的一点,始终考虑用户的视角。
关于 Python 中的 API 文档,你有什么建议?
好的文档使新用户能够轻松采用你的库。忽视文档会把很多潜在用户拒之门外——不仅仅是初学者。问题在于,编写文档是困难的,因此常常被忽视!
-
尽早编写文档,并将文档构建集成到持续集成中。 使用 Read the Docs 工具来创建和托管文档,没理由不构建和发布文档(至少对于开源软件而言)。
-
使用 docstring 来记录 API 中的类和函数。 如果你遵循 PEP 257(
www.python.org/dev/peps/pep-0257/) 的指南,开发者就不必阅读你的源码就能理解你的 API 功能。可以从 docstring 生成 HTML 文档——而且不要仅限于 API 参考文档。 -
始终提供实际的示例。 至少提供一个“入门指南”,向新手展示如何构建一个工作示例。文档的第一页应简要概述你 API 的基本和代表性用例。
-
详细记录你的 API 演变过程,按版本逐一记录。 版本控制系统(VCS)日志是不够的!
-
让你的文档易于访问,并尽可能使其舒适易读。 用户需要能够轻松找到它,并获得他们所需的信息,而不会感觉像在受折磨。通过 PyPI 发布文档是实现这一点的一种方式;在 Read the Docs 上发布也是一个不错的选择,因为用户会期望在那里找到你的文档。
-
最后,选择一个既高效又吸引人的主题。 我为 WSME 选择了 “Cloud” Sphinx 主题,但也有许多其他主题可以选择。你不需要是一个网页专家就能制作出漂亮的文档。
第四章:处理时间戳和时区

时区很复杂。大多数人认为处理时区仅仅是从协调世界时(UTC)中加减几个小时,从−12 小时到+12 小时。
然而,现实情况并非如此:时区并不符合逻辑,也不可预测。有些时区的粒度是 15 分钟;一些国家每年会调整时区两次;还有一些国家在夏季使用一种特殊时区,叫做夏令时,且夏令时的开始日期各不相同;此外,还有很多特殊情况和边缘案例。这些使得时区的历史既有趣又复杂,处理起来充满挑战。所有这些特殊情况应该在处理时区时引起你的关注。
本章将概述为什么处理时区很棘手,以及如何在程序中最好地处理它们。我们将讨论如何构建时间戳对象,如何以及为什么使它们具备时区感知能力,以及如何处理可能遇到的边缘案例。
缺失时区的问题
如果一个时间戳没有附带时区信息,它就没有任何有用的信息,因为没有时区,你无法推断出应用程序实际上所指的时间点。因此,缺少时区的时间戳不能进行比较;这就像没有日期的星期几比较——是否周一在周二之前,取决于它们属于哪个星期。没有附带时区的时间戳应该视为无关紧要。
因此,你的应用程序绝不应该处理没有时区的时间戳。如果没有提供时区,它必须抛出错误,或者明确假设默认时区——例如,选择 UTC 作为默认时区是一种常见做法。
你还必须小心在存储时间戳之前进行任何时区转换。假设某个用户在他们的本地时区(比如中欧时间 CET)每周三上午 10:00 创建一个重复事件。CET 比 UTC 快一个小时,如果你将该时间戳转换为 UTC 并存储,那么事件将被存储为每周三上午 09:00。CET 时区在夏季从 UTC+01:00 切换到 UTC+02:00,因此在夏季,应用程序会计算出每周三事件的开始时间为上午 11:00 CET。你可以看到,这个程序很快就变得冗余了!
现在你理解了处理时区的一般问题,让我们深入了解我们最喜欢的编程语言。Python 提供了一个名为datetime.datetime的时间戳对象,可以精确到微秒存储日期和时间。datetime.datetime对象可以是时区感知的,在这种情况下它会嵌入时区信息,或者是时区无感知的,在这种情况下它没有时区信息。不幸的是,datetime API 默认返回一个无感知时区的对象,正如你将在清单 4-1 中看到的那样。接下来我们来看看如何构建一个默认的时间戳对象,然后如何修正它,使其使用时区信息。
构建默认的 datetime 对象
要构建一个带有当前日期和时间值的datetime对象,你可以使用datetime.datetime.utcnow()函数。这个函数会获取当前 UTC 时区的日期和时间,如清单 4-1 所示。要使用机器所在地区时区的日期和时间来构建相同的对象,你可以使用datetime.datetime.now()方法。清单 4-1 检索了 UTC 和我所在地区的时区的日期和时间。
>>> import datetime
>>> datetime.datetime.utcnow()
➊ datetime.datetime(2018, 6, 15, 13, 24, 48, 27631) >>> datetime.datetime.utcnow().tzinfo is None
➋ True
清单 4-1:使用 datetime 获取一天中的时间
我们导入datetime库,并将datetime对象定义为使用 UTC 时区。这将返回一个 UTC 时间戳,其值依次为年份、月份、日期、小时、分钟、秒和微秒 ➊,如列表所示。我们可以通过检查tzinfo对象来检查这个对象是否包含时区信息,结果显示它没有 ➋。
然后,我们使用datetime.datetime.now()方法创建datetime对象,以检索当前机器区域的默认时区的日期和时间:
>>> datetime.datetime.now()
➌ datetime.datetime(2018, 6, 15, 15, 24, 52, 276161)
这个时间戳同样没有返回任何时区信息,正如我们从缺少tzinfo字段可以看出 ➌—如果时区信息存在,它会以类似tzinfo=<UTC>的形式出现在输出的末尾。
datetime API 默认总是返回无时区感知的datetime对象,而且由于输出中无法判断时区信息,这些对象几乎没有什么用处。
Flask 框架的创始人 Armin Ronacher 建议,应用程序应始终假设 Python 中的datetime对象默认是 UTC 时区。然而,正如我们刚刚看到的,这对于由datetime.datetime.now()返回的对象并不适用。当你构建datetime对象时,我强烈建议你始终确保它们是时区感知的。这样可以确保你能够直接比较对象,并检查它们是否正确返回了所需的时区信息。接下来我们来看如何使用tzinfo对象创建时区感知的时间戳。
附加内容:从日期构建 datetime 对象
你还可以通过传递你想要的日期的各个组件的值来构建你自己的datetime对象,如列表 4-2 所示。
>>> import datetime
>>> datetime.datetime(2018, 6, 19, 19, 54, 49)
datetime.datetime(2018, 6, 19, 19, 54, 49)
列表 4-2:构建你自己的timestamp对象
使用 dateutil 实现时区感知的时间戳
目前已经有很多现有的时区数据库,由 IANA(互联网号码分配局)等中央机构维护,并且这些数据库已经包含在所有主要操作系统中。因此,Python 开发者不需要创建自己的时区类并在每个 Python 项目中手动复制它们,而是依赖dateutil项目来获取tzinfo类。dateutil项目提供了 Python 模块tz,它直接提供时区信息,无需太多操作:tz模块可以访问操作系统的时区信息,并且可以携带和嵌入时区数据库,使得这些信息可以直接在 Python 中访问。
你可以通过使用pip命令pip install python-dateutil来安装dateutil。dateutil API 允许你基于时区名称获取一个tzinfo对象,示例如下:
>>> from dateutil import tz
>>> tz.gettz("Europe/Paris")
tzfile('/usr/share/zoneinfo/Europe/Paris')
>>> tz.gettz("GMT+1")
tzstr('GMT+1')
dateutil.tz.gettz()方法返回一个实现tzinfo接口的对象。这个方法接受多种字符串格式作为参数,比如基于位置的时区(例如,“Europe/Paris”)或相对于 GMT 的时区。dateutil时区对象可以直接作为tzinfo类使用,如列表 4-3 所示。
>>> import datetime
>>> from dateutil import tz
>>> now = datetime.datetime.now()
>>> now
datetime.datetime(2018, 10, 16, 19, 40, 18, 279100)
>>> tz = tz.gettz("Europe/Paris")
>>> now.replace(tzinfo=tz)
datetime.datetime(2018, 10, 16, 19, 40, 18, 279100, tzinfo=tzfile('/usr/share/zoneinfo/Europe/
Paris'))
列表 4-3:使用 dateutil 对象作为 tzinfo 类
只要你知道所需时区的名称,就可以获得与目标时区匹配的tzinfo对象。dateutil模块可以访问操作系统管理的时区,如果由于某种原因这些信息不可用,它将回退到其嵌入的时区列表。如果你需要访问这个嵌入的列表,可以通过datetutil.zoneinfo模块进行访问:
>>> from dateutil.zoneinfo import get_zonefile_instance
>>> zones = list(get_zonefile_instance().zones)
>>> sorted(zones)[:5] ['Africa/Abidjan', 'Africa/Accra', 'Africa/Addis_Ababa', 'Africa/Algiers', 'Africa/Asmara']
>>> len(zones)
592
在某些情况下,你的程序并不知道自己在哪个时区运行,因此你需要自己确定它。datetutil.tz.gettz()函数如果不传入参数,将返回你计算机的本地时区,如列表 4-4 所示。
>>> from dateutil import tz
>>> import datetime
>>> now = datetime.datetime.now()
>>> localzone = tz.gettz()
>>> localzone
tzfile('/etc/localtime')
>>> localzone.tzname(datetime.datetime(2018, 10, 19))
'CEST'
>>> localzone.tzname(datetime.datetime(2018, 11, 19))
'CET'
列表 4-4:获取本地时区
如你所见,我们分别将两个日期传递给localzone.tzname(datetime.datetime()),dateutil能够告诉我们一个是在中欧夏令时(CEST),另一个是在中欧时间(非夏令时)。如果你传入当前日期,你将得到你所在的当前时区。
你可以在tzinfo类中使用来自dateutil库的对象,而无需在应用程序中自己实现这些类。这使得将不具备时区信息的datetime对象转换为具备时区信息的datetime对象变得容易。
实现你自己的时区类
Python 中有一个类允许你自己实现时区类:datetime.tzinfo 类是一个抽象类,提供了实现表示时区的类的基础。如果你想实现一个表示时区的类,你需要使用它作为父类,并实现三个不同的方法:
-
utcoffset(dt),必须返回该时区相对于 UTC 的偏移量,以分钟为单位,表示从 UTC 向东的偏移。 -
dst(dt),必须返回该时区的夏令时调整,以 UTC 为基准,表示向东的分钟数。 -
tzname(dt),必须返回时区名称的字符串形式。
这三种方法将嵌入一个 tzinfo 对象,允许你将任何带有时区信息的datetime转换为另一个时区。
然而,如前所述,由于存在时区数据库,自己实现这些时区类是不实际的。
序列化带时区信息的 datetime 对象
你通常需要将一个datetime对象从一个地方传输到另一个地方,而这些地方可能不是 Python 原生的。现在的典型情况是通过 HTTP REST API,它必须将datetime对象序列化并返回给客户端。Python 原生方法 isoformat 可以用来将datetime对象序列化到非 Python 原生的地方,如 示例 4-5 所示。
>>> import datetime
>>> from dateutil import tz
➊ >>> def utcnow():
return datetime.datetime.now(tz=tz.tzutc())
>>> utcnow()
➋ datetime.datetime(2018, 6, 15, 14, 45, 19, 182703, tzinfo=tzutc())
➌ >>> utcnow().isoformat()
'2018-06-15T14:45:21.982600+00:00'
示例 4-5:序列化带时区信息的 datetime 对象
我们定义了一个新的函数 utcnow 并明确告诉它返回一个带有 UTC 时区的对象 ➊。如你所见,现在返回的对象包含时区信息 ➋。然后我们使用 ISO 格式 ➌ 格式化字符串,确保时间戳也包含一些时区信息(+00:00 部分)。
你可以看到我使用了 isoformat() 方法来格式化输出。我建议你始终使用 ISO 8601 格式来格式化你的datetime输入和输出字符串,使用方法 datetime.datetime.isoformat(),以返回带有时区信息的可读格式的时间戳。
你的 ISO 8601 格式字符串可以转换为原生的 datetime.datetime 对象。iso8601 模块只提供一个函数,parse_date,它完成了解析字符串并确定时间戳和时区值的所有繁重工作。iso8601 模块不是 Python 的内置模块,所以你需要使用 pip install iso8601 来安装它。示例 4-6 展示了如何使用 ISO 8601 解析时间戳。
>>> import iso8601
>>> import datetime
>>> from dateutil import tz
>>> now = datetime.datetime.utcnow()
>>> now.isoformat()
'2018-06-19T09:42:00.764337'
➊ >>> parsed = iso8601.parse_date(now.isoformat())
>>> parsed
datetime.datetime(2018, 6, 19, 9, 42, 0, 764337, tzinfo=<iso8601.Utc>)
>>> parsed == now.replace(tzinfo=tz.tzutc())
True
示例 4-6:使用 iso8601 模块解析 ISO 8601 格式的时间戳
在列表 4-6 中,使用iso8601模块从字符串构造datetime对象。通过对包含 ISO 8601 格式时间戳的字符串 ➊ 调用iso8601.parse_date,该库能够返回一个datetime对象。由于该字符串不包含任何时区信息,iso8601模块假设时区为 UTC。如果字符串包含正确的时区信息,iso8601模块会正确返回结果。
使用时区感知的datetime对象并将 ISO 8601 作为其字符串表示格式,是解决大多数时区问题的完美方案,确保不犯错误,并在你的应用程序与外部世界之间建立良好的互操作性。
解决模糊时间
在某些情况下,时间可能是模糊的;例如在夏令时转换期间,同一“钟表”时间一天出现两次。dateutil库提供了is_ambiguous方法来区分此类时间戳。为了展示这一点,我们将在列表 4-7 中创建一个模糊的时间戳。
>>> import dateutil.tz
>>> localtz = dateutil.tz.gettz("Europe/Paris")
>>> confusing = datetime.datetime(2017, 10, 29, 2, 30)
>>> localtz.is_ambiguous(confusing)
True
列表 4-7:一个令人困惑的时间戳,发生在夏令时交替期间
2017 年 10 月 30 日晚上,巴黎从夏令时切换到冬令时。切换发生在凌晨 3:00,此时时间回拨至 2:00 AM。如果我们尝试在该日期使用 2:30 的时间戳,这个对象无法确定它是在夏令时更改前还是后。
然而,通过使用fold属性,可以指定时间戳位于折叠的哪一侧,该属性通过 PEP 495(本地时间消歧义—www.python.org/dev/peps/pep-0495/)在 Python 3.6 中被添加到datetime对象中。此属性指示 datetime 位于折叠的哪一侧,如列表 4-8 所示。
>>> import dateutil.tz
>>> import datetime
>>> localtz = dateutil.tz.gettz("Europe/Paris")
>>> utc = dateutil.tz.tzutc()
>>> confusing = datetime.datetime(2017, 10, 29, 2, 30, tzinfo=localtz)
>>> confusing.replace(fold=0).astime zone(utc)
datetime.datetime(2017, 10, 29, 0, 30, tzinfo=tzutc())
>>> confusing.replace(fold=1).astime zone(utc)
datetime.datetime(2017, 10, 29, 1, 30, tzinfo=tzutc())
列表 4-8:消除模糊的时间戳
你只需要在非常少见的情况下使用此方法,因为模糊时间戳只会出现在一个小的时间窗口内。坚持使用 UTC 是一个很好的解决方法,可以保持生活简单,避免时区问题。然而,了解fold属性的存在以及dateutil在这种情况下能够提供帮助,还是很有用的。
总结
在本章中,我们已经看到携带时区信息在时间戳中是多么重要。内置的datetime模块在这方面并不完备,但dateutil模块是一个很好的补充:它允许我们获取与tzinfo兼容的对象,这些对象已准备好使用。dateutil模块还帮助我们解决了如夏令时模糊性等细微问题。
ISO 8601 标准格式是序列化和反序列化时间戳的绝佳选择,因为它在 Python 中可以轻松使用,并与任何其他编程语言兼容。
第五章:发布你的软件

可以肯定地说,某个时刻,你会希望发布你的软件。尽管你可能很想直接压缩你的代码并上传到互联网,Python 提供了工具来使你的最终用户更容易使用你的软件。你应该已经熟悉使用setup.py来安装 Python 应用程序和库,但你可能从未深入了解过它背后的工作原理,或者如何创建你自己的setup.py。
在本章中,你将了解setup.py的历史、该文件的工作原理,以及如何创建你自己的自定义setup.py。我们还将深入探讨一些打包安装工具pip的鲜为人知的功能,以及如何通过pip使你的软件可下载。最后,我们将看看如何使用 Python 的入口点,使得不同程序之间的函数可以轻松找到。掌握这些技能后,你就能让你的发布软件对最终用户更加可访问。
setup.py 的历史
distutils库最初由软件开发者 Greg Ward 创建,自 1998 年起就成为 Python 标准库的一部分。Ward 旨在为开发者创建一个简便的方式,以便自动化其终端用户的安装过程。软件包提供setup.py文件作为标准的 Python 安装脚本,并可以使用distutils进行安装,如列表 5-1 所示。
#!/usr/bin/python
from distutils.core import setup
setup(name="rebuildd",
description="Debian packages rebuild tool",
author="Julien Danjou",
author_email="acid@debian.org",
url="http://julien.danjou.info/software/rebuildd.html",
packages=['rebuildd'])
列表 5-1:使用 distutils 构建 setup.py
通过将setup.py文件作为项目的根文件,所有用户需要做的就是运行该文件,并将适当的命令作为参数传递。即使你的分发包除了本地 Python 模块外还包含 C 模块,distutils也能自动处理它们。
distutils的开发在 2000 年停止;从那时起,其他开发者接手了它的工作。一个显著的继任者是名为setuptools的打包库,它提供了更多的更新和高级功能,如自动依赖管理、Egg分发格式和easy_install命令。由于distutils在开发时仍然是 Python 标准库中接受的软件打包方式,setuptools在一定程度上保持了与其的向后兼容性。列表 5-2 展示了如何使用setuptools构建与列表 5-1 相同的安装包。
#!/usr/bin/env python
import setuptools
setuptools.setup(
name="rebuildd",
version="0.2",
author="Julien Danjou",
author_email="acid@debian.org",
description="Debian packages rebuild tool",
license="GPL",
url="http://julien.danjou.info/software/rebuildd/",
packages=['rebuildd'],
classifiers=[
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"License :: OSI Approved :: GNU General Public License (GPL)", "Operating System :: OS Independent",
"Programming Language :: Python"
],
)
列表 5-2:使用 setuptools 构建 setup.py
最终,setuptools的开发也放缓了,但没过多久,另一组开发者将其分叉,创建了一个新的库,名为distribute,它相比setuptools提供了几个优点,包括更少的 BUG 和对 Python 3 的支持。
所有最好的故事都有一个转折结局:2013 年 3 月,setuptools 和 distribute 背后的团队决定将它们的代码库合并到原始 setuptools 项目的旗下。因此,distribute 现在已经被弃用,setuptools 再次成为处理高级 Python 安装的标准方式。
在这一切发生的同时,另一个名为 distutils2 的项目也在开发中,旨在完全替代 Python 标准库中的 distutils。与 distutils 和 setuptools 不同,它将包的元数据存储在一个纯文本文件 setup.cfg 中,这对于开发者来说更容易编写,对于外部工具来说更容易读取。然而,distutils2 保留了 distutils 的一些缺点,例如命令行设计的晦涩,并且缺乏对 Windows 上入口点和原生脚本执行的支持——这两者是 setuptools 提供的特性。由于这些原因以及其他原因,将 distutils2(即 packaging)包含进 Python 3.3 标准库的计划未能实现,且该项目在 2012 年被放弃。
packaging 仍然有机会通过 distlib 重新崛起,后者是一个新兴的替代 distutils 的项目。在发布之前,曾有传闻称 distlib 包会成为 Python 3.4 标准库的一部分,但最终并未实现。distlib 包含了 packaging 的最佳特性,执行了与打包相关的 PEP 中描述的基本工作。
所以,回顾一下:
-
distutils 是 Python 标准库的一部分,能够处理简单的包安装。
-
setuptools,作为高级包安装的标准,最初曾被弃用,但现在已经重新进入活跃开发状态,并成为事实上的标准。 -
distribute已经在版本 0.7 中合并回setuptools;distutils2(即packaging)已被废弃。 -
distlib可能 会在未来取代distutils。
还有其他的打包库存在,但这些是你最常遇到的五个。研究这些库时要小心:由于上述复杂的历史,很多文档已经过时。然而,官方文档是最新的。
简而言之,setuptools 是目前使用的分发库,但未来要关注 distlib。
使用 setup.cfg 进行打包
你可能已经在某个时刻尝试过为某个包编写 setup.py,无论是从其他项目中复制,还是通过浏览文档自己构建。编写 setup.py 不是一项直观的任务。选择使用哪个工具只是第一道难题。在本节中,我想向你介绍 setuptools 的一个近期改进:setup.cfg 文件支持。
这就是使用 setup.cfg 文件的 setup.py 的样子:
import setuptools
setuptools.setup()
两行代码——就是这么简单。setup.py 所需的实际元数据存储在 setup.cfg 中,如 Listing 5-3 所示。
[metadata]
name = foobar
author = Dave Null
author-email = foobar@example.org
license = MIT
long_description = file: README.rst
url = http://pypi.python.org/pypi/foobar
requires-python = >=2.6
classifiers =
Development Status :: 4 - Beta
Environment :: Console
Intended Audience :: Developers
Intended Audience :: Information Technology
License :: OSI Approved :: Apache Software License
Operating System :: OS Independent
Programming Language :: Python
清单 5-3:setup.cfg 元数据
如你所见,setup.cfg 使用了一种易于编写和阅读的格式,直接受到了distutils2的启发。许多其他工具,如Sphinx或Wheel,也从这个 setup.cfg 文件中读取配置—仅这一点就是开始使用它的一个有力论据。
在 清单 5-3 中,项目的描述是从 README.rst 文件中读取的。良好的实践是始终包含一个 README 文件—最好是 RST 格式—以便用户快速了解项目的内容。仅凭这些基本的 setup.py 和 setup.cfg 文件,你的包就已经准备好发布,并供其他开发者和应用程序使用。如果需要,setuptools 文档提供了更多细节,例如,如果你在安装过程中有额外的步骤,或者想要包含额外的文件。
另一个有用的打包工具是 pbr,即 Python Build Reasonableness 的缩写。该项目最初在 OpenStack 中启动,作为 setuptools 的扩展,旨在简化包的安装和部署。pbr 打包工具与 setuptools 一起使用,实现了 setuptools 中缺失的功能,包括以下这些:
-
自动生成 Sphinx 文档
-
基于
git历史自动生成 AUTHORS 和 ChangeLog 文件 -
自动创建
git的文件列表 -
基于
git标签使用语义化版本控制进行版本管理
所有这些都几乎不需要你做任何额外的工作。要使用 pbr,你只需启用它,如 清单 5-4 所示。
import setuptools
setuptools.setup(setup_requires=['pbr'], pbr=True)
清单 5-4:使用 pbr 的 setup.py
setup_requires 参数指示 setuptools 在使用之前必须安装 pbr。pbr=True 参数确保 setuptools 加载并调用 pbr 扩展。
一旦启用,python setup.py 命令将增强 pbr 功能。例如,调用 python setup.py --version 将根据现有的 git 标签返回项目的版本号。运行 python setup.py sdist 将创建一个源代码 tarball,并自动生成 ChangeLog 和 AUTHORS 文件。
Wheel 格式分发标准
在 Python 的大部分历史中,并没有官方的标准分发格式。尽管不同的分发工具通常使用一些通用的归档格式—甚至由 setuptools 引入的 Egg 格式仅仅是一个具有不同扩展名的 zip 文件—它们的元数据和包结构彼此不兼容。当官方安装标准终于在 PEP 376 中定义时,这个问题更为严重,因为它与现有格式也不兼容。
为了解决这些问题,PEP 427 被编写出来,定义了 Python 分发包的新标准,称为 Wheel。该格式的参考实现可作为工具使用,亦称为 Wheel。
Wheel 从版本 1.4 开始被 pip 支持。如果你使用 setuptools 并且已经安装了 Wheel 包,它会自动集成为一个名为 bdist_wheel 的 setuptools 命令。如果你没有安装 Wheel,可以通过命令 pip install wheel 来安装它。列表 5-5 显示了调用 bdist_wheel 时的部分输出,已缩短以便打印。
$ python setup.py bdist_wheel
running bdist_wheel
running build
running build_py
creating build/lib
creating build/lib/daiquiri
creating build/lib/daiquiri/tests
copying daiquiri/tests/__init__.py -> build/lib/daiquiri/tests
--snip--
running egg_info
writing requirements to daiquiri.egg-info/requires.txt
writing daiquiri.egg-info/PKG-INFO
writing top-level names to daiquiri.egg-info/top_level.txt
writing dependency_links to daiquiri.egg-info/dependency_links.txt
writing pbr to daiquiri.egg-info/pbr.json
writing manifest file 'daiquiri.egg-info/SOURCES.txt'
installing to build/bdist.macosx-10.12-x86_64/wheel
running install
running install_lib
--snip--
running install_scripts
creating build/bdist.macosx-10.12-x86_64/wheel/daiquiri-1.3.0.dist-info/WHEEL
➊ creating '/Users/jd/Source/daiquiri/dist/daiquiri-1.3.0-py2.py3-none-any.whl'
and adding '.' to it
adding 'daiquiri/__init__.py'
adding 'daiquiri/formatter.py'
adding 'daiquiri/handlers.py'
--snip--
列表 5-5:调用 setup.py bdist_wheel
bdist_wheel 命令会在 dist 目录中创建一个 .whl 文件 ➊。与 Egg 格式一样,Wheel 存档只是一个具有不同扩展名的压缩文件。然而,Wheel 存档不需要安装——你只需添加一个斜杠后跟模块名,就可以加载并运行你的代码:
$ python wheel-0.21.0-py2.py3-none-any.whl/wheel -h
usage: wheel [-h]
{keygen,sign,unsign,verify,unpack,install,install-
scripts,convert,help}
--snip--
positional arguments:
--snip--
你可能会惊讶地发现,这并不是 Wheel 格式本身引入的功能。Python 也可以运行常规的 zip 文件,就像 Java 的 .jar 文件一样:
python foobar.zip
这等价于:
PYTHONPATH=foobar.zip python -m __main__
换句话说,你程序的 __main__ 模块会从 __main__.py 自动导入。你也可以通过追加斜杠后跟模块名,从指定的模块导入 __main__,就像使用 Wheel 一样:
python foobar.zip/mymod
这等价于:
PYTHONPATH=foobar.zip python -m mymod.__main__
Wheel 的一个优势是,它的命名约定允许你指定你的发行版是针对特定架构和/或 Python 实现(如 CPython、PyPy、Jython 等)。如果你需要分发用 C 编写的模块,这一点特别有用。
默认情况下,Wheel 包与构建它时所用的 Python 主要版本绑定。当使用 python2 setup.py bdist_wheel 调用时,Wheel 文件名的格式会类似于 library-version-py2-none-any.whl。
如果你的代码兼容所有主要的 Python 版本(即 Python 2 和 Python 3),你可以构建一个通用的 Wheel:
python setup.py bdist_wheel --universal
生成的文件名会有所不同,并且包含两个 Python 主要版本——类似于 library-version-py2.py3-none-any.whl。构建一个通用的 Wheel 可以避免当只需要一个 Wheel 覆盖两个 Python 主要版本时,产生两个不同的 Wheel 文件。
如果你不想在每次构建 Wheel 时都传递 --universal 标志,可以将其添加到你的 setup.cfg 文件中:
[wheel]
universal=1
如果你构建的 Wheel 包含二进制程序或库(例如用 C 编写的 Python 扩展),那么二进制 Wheel 可能没有你想象的那样便捷跨平台。它默认可以在一些平台上工作,比如 Darwin(macOS)或微软 Windows,但可能无法在所有 Linux 发行版上运行。PEP 513 (www.python.org/dev/peps/pep-0513) 通过定义一个新的平台标签 manylinux1 和一组保证在该平台上可用的最小库,来解决这个 Linux 问题。
Wheel 是一个用于分发准备安装的库和应用程序的优秀格式,因此建议你构建并将它们上传到 PyPI。
与世界分享你的作品
一旦你拥有一个合适的 setup.py 文件,构建一个可以分发的源代码 tarball 就变得很简单。sdist setuptools 命令正是完成这项工作的命令,正如列表 5-6 所示。
$ python setup.py sdist
running sdist
[pbr] Generating AUTHORS
running egg_info
writing requirements to ceilometer.egg-info/requires.txt
writing ceilometer.egg-info/PKG-INFO
writing top-level names to ceilometer.egg-info/top_level.txt
writing dependency_links to ceilometer.egg-info/dependency_links.txt
writing entry points to ceilometer.egg-info/entry_points.txt
[pbr] Processing SOURCES.txt
[pbr] In git context, generating filelist from git
warning: no previously-included files matching '*.pyc' found anywhere in
distribution
writing manifest file 'ceilometer.egg-info/SOURCES.txt'
running check
copying setup.cfg -> ceilometer-2014.1.a6-g772e1a7
Writing ceilometer-2014.1.a6-g772e1a7/setup.cfg
--snip--
Creating tar archive
removing 'ceilometer-2014.1.a6.g772e1a7' (and everything under it)
列表 5-6:使用 setup.py sdist 构建源 tarball
sdist 命令会在源代码树的 dist 目录下创建一个 tarball。该 tarball 包含源代码树中的所有 Python 模块。如前一节所示,你也可以使用 bdist_wheel 命令构建 Wheel 归档文件。Wheel 归档的安装速度更快,因为它们已经是正确的安装格式。
使代码可用的最后一步是将你的软件包导出到用户可以通过 pip 安装的地方。这意味着将你的项目发布到 PyPI。
如果这是你第一次将软件发布到 PyPI,建议先在安全的沙盒中测试发布过程,而不是直接在生产服务器上进行。你可以使用 PyPI 测试服务器,它复制了主索引的所有功能,但仅用于测试目的。
第一步是将你的项目注册到测试服务器。首先,打开你的 ~/.pypirc 文件并添加以下内容:
[distutils]
index-servers =
testpypi [testpypi]
username = <your username>
password = <your password>
repository = https://testpypi.python.org/pypi
保存文件,现在你可以在索引中注册你的项目:
$ python setup.py register -r testpypi
running register
running egg_info
writing requirements to ceilometer.egg-info/requires.txt
writing ceilometer.egg-info/PKG-INFO
writing top-level names to ceilometer.egg-info/top_level.txt
writing dependency_links to ceilometer.egg-info/dependency_links.txt
writing entry points to ceilometer.egg-info/entry_points.txt
[pbr] Reusing existing SOURCES.txt
running check
Registering ceilometer to https://testpypi.python.org/pypi
Server response (200): OK
这将连接到测试 PyPI 服务器实例并创建一个新的条目。不要忘记使用 -r 选项;否则,会使用真实的生产 PyPI 实例!
很明显,如果有同名的项目已经在 PyPI 注册,过程将会失败。请尝试使用一个新的名称,一旦你的程序成功注册并收到 OK 响应,你就可以上传源代码分发的 tarball,如列表 5-7 所示。
$ python setup.py sdist upload -r testpypi
running sdist
[pbr] Writing ChangeLog
[pbr] Generating AUTHORS
running egg_info
writing requirements to ceilometer.egg-info/requires.txt
writing ceilometer.egg-info/PKG-INFO
writing top-level names to ceilometer.egg-info/top_level.txt
writing dependency_links to ceilometer.egg-info/dependency_links.txt
writing entry points to ceilometer.egg-info/entry_points.txt
[pbr] Processing SOURCES.txt
[pbr] In git context, generating filelist from git
warning: no previously-included files matching '*.pyc' found anywhere in
distribution
writing manifest file 'ceilometer.egg-info/SOURCES.txt'
running check
creating ceilometer-2014.1.a6.g772e1a7
--snip--
copying setup.cfg -> ceilometer-2014.1.a6.g772e1a7
Writing ceilometer-2014.1.a6.g772e1a7/setup.cfg
Creating tar archive
removing 'ceilometer-2014.1.a6.g772e1a7' (and everything under it)
running upload Submitting dist/ceilometer-2014.1.a6.g772e1a7.tar.gz to https://testpypi
.python.org/pypi
Server response (200): OK
列表 5-7:将 tarball 上传到 PyPI
或者,你可以上传一个 Wheel 格式的归档文件,如列表 5-8 所示。
$ python setup.py bdist_wheel upload -r testpypi
running bdist_wheel
running build
running build_py
running egg_info
writing requirements to ceilometer.egg-info/requires.txt
writing ceilometer.egg-info/PKG-INFO
writing top-level names to ceilometer.egg-info/top_level.txt
writing dependency_links to ceilometer.egg-info/dependency_links.txt
writing entry points to ceilometer.egg-info/entry_points.txt
[pbr] Reusing existing SOURCES.txt
installing to build/bdist.linux-x86_64/wheel
running install
running install_lib
creating build/bdist.linux-x86_64/wheel
--snip--
creating build/bdist.linux-x86_64/wheel/ceilometer-2014.1.a6.g772e1a7
.dist-info/WHEEL
running upload
Submitting /home/jd/Source/ceilometer/dist/ceilometer-2014.1.a6
.g772e1a7-py27-none-any.whl to https://testpypi.python.org/pypi
Server response (200): OK
列表 5-8:将 Wheel 归档上传到 PyPI
一旦这些操作完成,你和其他用户就可以在 PyPI 测试服务器上搜索已上传的软件包,甚至可以使用 pip 安装这些软件包,只需要使用 -i 选项指定测试服务器:
$ pip install -i https://testpypi.python.org/pypi ceilometer
如果一切正常,你可以将你的项目上传到主 PyPI 服务器。在此之前,确保将你的凭证和服务器的详细信息添加到 ~/.pypirc 文件中,如下所示:
[distutils]
index-servers =
pypi
testpypi
[pypi]
username = <your username>
password = <your password> [testpypi]
repository = https://testpypi.python.org/pypi
username = <your username>
password = <your password>
现在,如果你使用 -r pypi 选项运行 register 和 upload,你的软件包应该会被上传到 PyPI。
注意
PyPI 可以在其索引中保留多个版本的软件,这样你就可以安装特定的旧版本,如果你需要的话。只需要在 pip install 命令中指定版本号;例如,pip install foobar==1.0.2。
这个过程非常简便,允许进行任意数量的上传。你可以根据需要频繁发布你的软件,用户也可以根据需要频繁安装和更新。
入口点
你可能已经在不知情的情况下使用了setuptools入口点。通过setuptools分发的软件包括描述特性的重要元数据,例如所需的依赖关系,以及——对本主题更为相关的——入口点的列表。入口点是其他 Python 程序可以用来发现一个包提供的动态功能的方法。
以下示例展示了如何在console_scripts入口点组中提供一个名为rebuildd的入口点:
#!/usr/bin/python
from distutils.core import setup
setup(name="rebuildd",
description="Debian packages rebuild tool",
author="Julien Danjou",
author_email="acid@debian.org",
url="http://julien.danjou.info/software/rebuildd.html",
entry_points={
'console_scripts': [
'rebuildd = rebuildd:main',
],
},
packages=['rebuildd'])
任何 Python 包都可以注册入口点。入口点按组组织:每个组由一组键值对组成。这些键值对使用格式path.to.module:variable_name。在前面的示例中,键是rebuildd,值是rebuildd:main。
入口点的列表可以通过各种工具进行操作,从setuptools到epi,正如我将在这里展示的那样。在接下来的几节中,我们将讨论如何使用入口点为我们的软件添加扩展性。
可视化入口点
可视化包中可用入口点的最简单方法是使用名为entry point inspector的包。你可以通过运行pip install entry-point-inspector来安装它。安装后,它提供了命令epi,你可以从终端运行它,交互式地发现已安装包提供的入口点。清单 5-9 展示了在我的系统上运行epi group list的示例。
$ epi group list
---------------------------
| Name |
--------------------------
| console_scripts |
| distutils.commands |
| distutils.setup_keywords |
| egg_info.writers |
| epi.commands |
| flake8.extension |
| setuptools.file_finders |
| setuptools.installation |
--------------------------
清单 5-9:获取入口点组列表
来自epi group list的输出在清单 5-9 中展示了系统上提供入口点的不同包。表格中的每一项都是一个入口点组的名称。请注意,这个列表包括了console_scripts,我们稍后会讨论它。我们可以使用epi命令与show命令来显示特定入口点组的详细信息,如清单 5-10 所示。
$ epi group show console_scripts
-------------------------------------------------
| Name | Module | Member | Distribution | Error |
-------------------------------------------------
| coverage | coverage | main | coverage 3.4 | |
清单 5-10:显示入口点组的详细信息
我们可以看到,在console_scripts组中,一个名为coverage的入口点指向模块coverage的成员main。这个入口点,特别是由coverage 3.4包提供,指示在执行命令行脚本coverage时应调用哪个 Python 函数。在这里,应该调用的函数是coverage.main。
epi工具只是位于完整 Python 库pkg_resources之上的一层薄薄的封装。这个模块允许我们发现任何 Python 库或程序的入口点。入口点对于多种用途很有价值,包括控制台脚本和动态代码发现,正如你将在接下来的几节中看到的那样。
使用控制台脚本
在编写 Python 应用程序时,你几乎总是需要提供一个可启动的程序——一个用户可以运行的 Python 脚本——并且该程序需要安装在系统路径中的某个目录内。
大多数项目都有一个类似这样的可启动程序:
#!/usr/bin/python
import sys
import mysoftware
mysoftware.SomeClass(sys.argv).run()
这种脚本是最佳情况:许多项目在系统路径中安装了更长的脚本。然而,这些脚本存在一些主要问题:
-
用户无法知道 Python 解释器的位置或它使用的是哪个版本。
-
该脚本泄漏了无法被软件或单元测试导入的二进制代码。
-
没有简单的方法来定义安装该脚本的位置。
-
如何以便携方式安装这个程序(例如,在 Unix 和 Windows 上)并不显而易见。
为了帮助我们绕过这些问题,setuptools提供了console_scripts功能。此入口点可以用于使setuptools在系统路径中安装一个小程序,该程序调用你模块中的特定函数。使用setuptools,你可以通过在console_scripts入口点组中设置键/值对来指定一个函数调用来启动你的程序:键是将要安装的脚本名称,值是指向你的函数的 Python 路径(类似my_module.main)。
假设有一个foobar程序,由客户端和服务器组成。每个部分分别写在自己的模块中——foobar.client和foobar.server,它们分别位于foobar/client.py中:
def main():
print("Client started")
然后在foobar/server.py中:
def main():
print("Server started")
当然,这个程序并没有做太多事情——我们的客户端和服务器甚至没有互相通信。不过,对于我们的示例来说,它们只需要打印一条消息,告诉我们它们已成功启动。
现在我们可以在根目录中编写以下setup.py文件,其中在setup.py中定义了入口点。
from setuptools import setup
setup(
name="foobar",
version="1",
description="Foo!",
author="Julien Danjou",
author_email="julien@danjou.info",
packages=["foobar"],
entry_points={
"console_scripts": [
➊ "foobard = foobar.server:main",
"foobar = foobar.client:main",
],
},
)
我们使用module.submodule:function的格式定义入口点。你可以看到,我们为client和server分别定义了一个入口点 ➊。
当运行python setup.py install时,setuptools会创建一个脚本,该脚本看起来像列表 5-11 中的内容。
#!/usr/bin/python
# EASY-INSTALL-ENTRY-SCRIPT: 'foobar==1','console_scripts','foobar'
__requires__ = 'foobar==1'
import sys
from pkg_resources import load_entry_point
if __name__ == '__main__':
sys.exit(
load_entry_point('foobar==1', 'console_scripts', 'foobar')()
)
列表 5-11:由 setuptools 生成的控制台脚本
这段代码扫描foobar包的入口点并从console_scripts组中获取foobar键,该键用于定位并运行相应的函数。load_entry_point的返回值将是对函数foobar.client.main的引用,该函数将在没有任何参数的情况下被调用,并且它的返回值将作为退出代码使用。
请注意,这段代码使用pkg_resources来发现并加载你 Python 程序中的入口点文件。
注意
如果你在setuptools上使用了 pbr,生成的脚本会比setuptools默认构建的脚本更简洁(因此更快速),因为它将直接调用你在入口点中编写的函数,而无需在运行时动态搜索入口点列表。
使用控制台脚本是一种技术,它消除了编写便携脚本的负担,同时确保你的代码保持在 Python 包中,并可以被其他程序导入(和测试)。
使用插件和驱动程序
入口点使得发现和动态加载其他包部署的代码变得容易,但这并不是它们唯一的用途。任何应用程序都可以提出并注册入口点和组,然后按需使用它们。
在本节中,我们将创建一个 cron 风格的守护进程 pycrond,它将允许任何 Python 程序注册一个命令,使其每隔几秒钟运行一次,通过在 pytimed 组中注册一个入口点。该入口点所指示的属性应为返回 number_of_seconds, callable 的对象。
这是我们使用 pkg_resources 来发现入口点的 pycrond 实现,在我命名为 pytimed.py 的程序中:
import pkg_resources
import time
def main():
seconds_passed = 0
while True:
for entry_point in pkg_resources.iter_entry_points('pytimed'):
try:
seconds, callable = entry_point.load()()
except:
# Ignore failure
pass
else:
if seconds_passed % seconds == 0:
callable()
time.sleep(1)
seconds_passed += 1
这个程序包含一个无限循环,它遍历 pytimed 组的每个入口点。每个入口点都使用 load() 方法加载。然后程序调用返回的方法,该方法需要返回在调用可调用对象之前等待的秒数以及上述可调用对象。
pytimed.py 中的程序是一个非常简单和天真的实现,但它足以用于我们的示例。现在我们可以编写另一个 Python 程序,命名为 hello.py,它需要定期调用其中的一个函数:
def print_hello():
print("Hello, world!")
def say_hello():
return 2, print_hello
一旦我们定义了这个函数,就可以使用适当的入口点在 setup.py 中注册它。
from setuptools import setup
setup(
name="hello",
version="1",
packages=["hello"],
entry_points={
"pytimed": [
"hello = hello:say_hello",
],
},)
setup.py 脚本在 pytimed 组中注册一个入口点,键为 hello,值指向 hello.say_hello 函数。一旦通过该 setup.py 安装了该包——例如使用 pip install——pytimed 脚本就能检测到新添加的入口点。
启动时,pytimed 将扫描 pytimed 组并找到键 hello。然后,它将调用 hello.say_hello 函数,得到两个值:每次调用之间等待的秒数和要调用的函数,这里是 2 秒和 print_hello。通过运行程序,就像我们在 Listing 5-12 中所做的那样,你可以看到“Hello, world!” 每 2 秒打印一次。
>>> import pytimed
>>> pytimed.main()
Hello, world!
Hello, world!
Hello, world!
Listing 5-12: 运行 pytimed
这种机制提供的可能性是巨大的:你可以轻松而通用地构建驱动程序系统、挂钩系统和扩展。如果每个程序都手动实现这个机制,肯定会很繁琐,但幸运的是,Python 有一个库可以帮我们处理这些枯燥的部分。
stevedore 库提供了基于我们之前示例中演示的相同机制的动态插件支持。本示例中的使用案例已经很简单,但我们仍然可以在这个脚本中进一步简化它,pytimed_stevedore.py:
from stevedore.extension import ExtensionManager
import time
def main():
seconds_passed = 0
extensions = ExtensionManager('pytimed', invoke_on_load=True)
while True:
for extension in extensions:
try:
seconds, callable = extension.obj except:
# Ignore failure
pass
else:
if seconds_passed % seconds == 0:
callable()
time.sleep(1)
seconds_passed += 1
stevedore 的 ExtensionManager 类提供了一种简单的方法来加载入口点组的所有扩展。名称作为第一个参数传递。参数 invoke_on_load=True 确保每个组的函数在被发现后都会被调用。这样,结果可以直接通过扩展的 obj 属性访问。
如果你查看stevedore的文档,你会看到ExtensionManager有多种子类,可以处理不同的情况,例如根据扩展名或函数结果加载特定扩展。所有这些常用模型可以应用到你的程序中,直接实现这些模式。
例如,我们可能只想从入口点组中加载并运行一个扩展。利用stevedore.driver.DriverManager类,我们可以实现这一点,如列出 5-13 所示。
from stevedore.driver import DriverManager
import time
def main(name):
seconds_passed = 0
seconds, callable = DriverManager('pytimed', name, invoke_on_load=True).
driver
while True:
if seconds_passed % seconds == 0:
callable()
time.sleep(1)
seconds_passed += 1
main("hello")
列出 5-13:使用 stevedore 从入口点运行单个扩展
在这种情况下,只有一个扩展按名称加载和选择。这使我们能够快速构建一个驱动程序系统,其中只有一个扩展被程序加载和使用。
总结
Python 的打包生态系统经历了曲折的历史;然而,现在局势正在逐步稳定。setuptools库提供了完整的打包解决方案,不仅可以以不同格式传输代码并上传到 PyPI,还可以通过入口点处理与其他软件和库的连接。
Nick Coghlan 谈打包
Nick 是 Red Hat 的 Python 核心开发者。他撰写了几份 PEP 提案,包括 PEP 426(Python 软件包元数据 2.0),并且担任我们的终身独裁者 Guido van Rossum(Python 的作者)的代表。
Python 的打包解决方案(distutils、setuptools、distutils2、distlib、bento、pbr 等)种类繁多。你认为这种碎片化和分歧的原因是什么?
简而言之,软件发布、分发和集成是一个复杂的问题,有足够的空间为不同的使用场景量身定制多种解决方案。在我最近的相关讲座中,我提到问题主要是由于时代差异,不同的打包工具诞生于不同的软件分发时代。
PEP 426,定义了 Python 包的新元数据格式,仍然相对较新,尚未获得批准。你认为它将如何解决当前的打包问题?
PEP 426 最初是作为Wheel格式定义的一部分开始的,但 Daniel Holth 意识到Wheel可以与setuptools定义的现有元数据格式兼容。因此,PEP 426 是对现有setuptools元数据的整合,并融合了distutils2及其他打包系统(如RPM和npm)的一些理念。它解决了现有工具所遇到的一些问题(例如,清晰地区分不同类型的依赖关系)。
主要的进展将是在 PyPI 上提供 REST API,提供完整的元数据访问,以及(希望)从上游元数据自动生成符合分发政策的打包文件。
Wheel格式相对较新,尚未广泛使用,但似乎很有前景。为什么它不是标准库的一部分?
事实证明,标准库并不是打包标准的合适场所:它的发展速度太慢,而且标准库的后续版本的新增内容无法与早期版本的 Python 一起使用。因此,在今年早些时候的 Python 语言峰会上,我们对 PEP 流程进行了调整,允许 distutils-sig 管理与打包相关的 PEP 的完整审批周期,而 python-dev 仅在涉及直接修改 CPython 的提案(例如 pip 引导程序)时参与。
Wheel 包的未来是什么?
在 Wheel 适用于 Linux 之前,我们仍需进行一些调整。不过,pip 已将 Wheel 作为 Egg 格式的替代方案,允许对构建进行本地缓存,以便快速创建虚拟环境,并且 PyPI 允许上传适用于 Windows 和 macOS 的 Wheel 档案。
第六章:单元测试

很多人觉得单元测试既繁琐又耗时,一些人和项目甚至没有测试政策。本章假设你理解单元测试的重要性!编写未经测试的代码是毫无意义的,因为无法确凿地证明它是否有效。如果你还需要说服自己,我建议你先阅读关于测试驱动开发的好处。
本章将介绍你可以用来构建完整测试套件的 Python 工具,从而使测试变得更简单、更自动化。我们将讨论如何使用工具确保你的软件稳定无回归。我们将涵盖创建可重用的测试对象、并行运行测试、揭示未测试的代码,以及使用虚拟环境确保测试清洁的内容,还会涉及一些其他的最佳实践方法和思路。
测试基础
在 Python 中,编写和运行单元测试并不复杂。这个过程既不干扰也不破坏现有的代码,单元测试将大大帮助你和其他开发人员维护软件。在这里,我将讨论一些测试的基本知识,以帮助你更轻松地进行测试。
一些简单的测试
首先,你应该将测试保存在应用程序或库的 tests 子模块中。这样做可以将测试作为模块的一部分一起发布,以便任何人都能运行或重用它们——即使在你的软件安装后,也不一定需要使用源代码包。将测试作为主模块的子模块,也可以防止它们被误安装在顶级的 tests 模块中。
在你的测试树中使用与模块树相同的层次结构,将使得测试更加易于管理。这意味着,涵盖 mylib/foobar.py 代码的测试应该保存在 mylib/tests/test_foobar.py 中。统一的命名法使得查找与特定文件相关的测试变得更简单。清单 6-1 显示了你可以编写的最简单的单元测试。
def test_true():
assert True
清单 6-1:在 test_true.py 中的一个非常简单的测试
这将简单地断言程序的行为是否符合预期。要运行此测试,你需要加载 test_true.py 文件并运行其中定义的 test_true() 函数。
然而,为你的每个测试文件和函数编写和运行单独的测试会非常麻烦。对于使用简单的小型项目,pytest 包就能派上用场——通过 pip 安装后,pytest 提供了 pytest 命令,它会加载所有文件名以 test_ 开头的文件,并执行其中所有以 test_ 开头的函数。
只要我们的源代码树中有 test_true.py 文件,运行 pytest 就会给出如下输出:
$ pytest -v test_true.py
========================== test session starts ===========================
platform darwin -- Python 3.6.4, pytest-3.3.2, py-1.5.2, pluggy-0.6.0 --
/usr/local/opt/python/bin/python3.6
cachedir: .cache
rootdir: examples, inifile:
collected 1 item
test_true.py::test_true PASSED [100%]
======================== 1 passed in 0.01 seconds ========================
-v选项告诉pytest以详细模式运行,并在单独的行上打印每个测试的名称。如果测试失败,输出会变更,显示失败信息,并伴随完整的回溯。
这次我们添加一个失败的测试,如清单 6-2 所示。
def test_false():
assert False
清单 6-2:test_true.py 中的失败测试
如果我们再次运行测试文件,结果如下:
$ pytest -v test_true.py
========================== test session starts ===========================
platform darwin -- Python 3.6.4, pytest-3.3.2, py-1.5.2, pluggy-0.6.0 -- /usr/
local/opt/python/bin/python3.6
cachedir: .cache
rootdir: examples, inifile:
collected 2 items
test_true.py::test_true PASSED [ 50%]
test_true.py::test_false FAILED [100%]
================================ FAILURES ================================
_______________________________ test_false _______________________________
def test_false():
> assert False
E assert False
test_true.py:5: AssertionError
=================== 1 failed, 1 passed in 0.07 seconds ===================
一旦抛出AssertionError异常,测试就会失败;当assert测试的参数被评估为假值(False、None、0 等)时,它会抛出AssertionError异常。如果抛出其他异常,测试也会报错。
很简单,不是吗?虽然简单,但很多小项目都使用这种方法,而且效果非常好。这些项目除了pytest外不需要任何工具或库,因此可以依赖简单的assert测试。
当你开始编写更复杂的测试时,pytest将帮助你理解失败测试中的问题。想象一下以下测试:
def test_key():
a = ['a', 'b']
b = ['b']
assert a == b
运行pytest时,它会显示如下输出:
$ pytest test_true.py
========================== test session starts =========================== platform darwin -- Python 3.6.4, pytest-3.3.2, py-1.5.2, pluggy-0.6.0
rootdir: /Users/jd/Source/python-book/examples, inifile:
plugins: celery-4.1.0
collected 1 item
test_true.py F [100%]
================================ FAILURES ================================
________________________________ test_key ________________________________
def test_key():
a = ['a', 'b']
b = ['b']
> assert a == b
E AssertionError: assert ['a', 'b'] == ['b']
E At index 0 diff: 'a' != 'b'
E Left contains more items, first extra item: 'b'
E Use -v to get the full diff
test_true.py:10: AssertionError
======================== 1 failed in 0.07 seconds ========================
这告诉我们a和b是不同的,并且该测试没有通过。它还告诉我们它们具体的不同之处,使得修复测试或代码变得容易。
跳过测试
如果一个测试无法运行,你可能想要跳过该测试——例如,你可能希望根据某个特定库的存在与否来有条件地运行测试。为此,你可以使用pytest.skip()函数,它会将测试标记为跳过,并继续执行下一个测试。pytest.mark.skip装饰器会无条件跳过被装饰的测试函数,因此当某个测试始终需要跳过时,你会使用它。清单 6-3 展示了如何使用这些方法跳过测试。
import pytest
try:
import mylib
except ImportError:
mylib = None
@pytest.mark.skip("Do not run this")
def test_fail():
assert False
@pytest.mark.skipif(mylib is None, reason="mylib is not available")
def test_mylib():
assert mylib.foobar() == 42 def test_skip_at_runtime():
if True:
pytest.skip("Finally I don't want to run it")
清单 6-3:跳过测试
执行时,这个测试文件将输出以下内容:
$ pytest -v examples/test_skip.py
========================== test session starts ===========================
platform darwin -- Python 3.6.4, pytest-3.3.2, py-1.5.2, pluggy-0.6.0 -- /usr/
local/opt/python/bin/python3.6
cachedir: .cache
rootdir: examples, inifile:
collected 3 items
examples/test_skip.py::test_fail SKIPPED
[ 33%]
examples/test_skip.py::test_mylib SKIPPED
[ 66%]
examples/test_skip.py::test_skip_at_runtime SKIPPED
[100%]
================= 3 skipped in 0.01 seconds =================
在清单 6-3 中的测试运行输出表明,在这种情况下,所有测试都被跳过了。这些信息让你可以确保没有意外跳过你期望运行的测试。
运行特定测试
使用pytest时,你通常只想运行某一特定子集的测试。你可以通过将目录或文件作为参数传递给pytest命令行来选择运行的测试。例如,调用pytest test_one.py将只运行test_one.py测试。pytest也接受目录作为参数,在这种情况下,它会递归扫描该目录并运行任何匹配test_.py*模式的文件。
你也可以在命令行中使用-k参数添加筛选器,以便仅执行与某个名称匹配的测试,如清单 6-4 所示。
$ pytest -v examples/test_skip.py -k test_fail
========================== test session starts ===========================
platform darwin -- Python 3.6.4, pytest-3.3.2, py-1.5.2, pluggy-0.6.0 -- /usr/
local/opt/python/bin/python3.6
cachedir: .cache
rootdir: examples, inifile:
collected 3 items
examples/test_skip.py::test_fail SKIPPED
[100%] === 2 tests deselected ===
=== 1 skipped, 2 deselected in 0.04 seconds ===
清单 6-4:按名称筛选运行的测试
名称并不总是过滤将要运行的测试的最佳方式。通常,开发人员会根据功能或类型将测试分组。Pytest 提供了一个动态标记系统,允许你使用关键字标记测试,并可以用作过滤器。要以这种方式标记测试,请使用-m选项。如果我们像这样设置几个测试:
import pytest
@pytest.mark.dicttest
def test_something():
a = ['a', 'b']
assert a == a
def test_something_else():
assert False
我们可以使用-m参数和pytest只运行其中一个测试:
$ pytest -v test_mark.py -m dicttest
=== test session starts ===
platform darwin -- Python 3.6.4, pytest-3.3.2, py-1.5.2, pluggy-0.6.0 -- /usr/
local/opt/python/bin/python3.6
cachedir: .cache
rootdir: examples, inifile:
collected 2 items
test_mark.py::test_something PASSED
[100%]
=== 1 tests deselected ===
=== 1 passed, 1 deselected in 0.01 seconds ===
-m标记接受更复杂的查询,因此我们也可以运行所有未标记的测试:
$ pytest test_mark.py -m 'not dicttest'
=== test session starts ===
platform darwin -- Python 3.6.4, pytest-3.3.2, py-1.5.2, pluggy-0.6.0
rootdir: examples, inifile:
collected 2 items
test_mark.py F
[100%]
=== FAILURES ===
test_something_else def test_something_else():
> assert False
E assert False
test_mark.py:10: AssertionError
=== 1 tests deselected ===
=== 1 failed, 1 deselected in 0.07 seconds ===
这里 pytest 执行了所有未标记为dicttest的测试——在这种情况下,test_something_else测试失败了。剩下的标记测试test_something没有执行,因此被列为deselected。
Pytest 接受由or、and和not关键字组成的复杂表达式,允许你进行更高级的过滤。
并行运行测试
测试套件可能需要很长时间才能运行。在大型软件项目中,完整的单元测试套件通常需要几十分钟才能运行。默认情况下,pytest 按顺序执行所有测试,顺序是未定义的。由于大多数计算机都有多个 CPU,你通常可以通过将测试列表拆分并在多个 CPU 上运行它们来加速测试过程。
为了处理这种方法,pytest 提供了插件pytest-xdist,你可以通过pip安装此插件。此插件通过--numprocesses参数(缩写为-n)扩展了 pytest 命令行,该参数的值为要使用的 CPU 数量。运行pytest -n 4将使用四个并行进程运行你的测试套件,在可用的 CPU 之间平衡负载。
因为 CPU 的数量可能因计算机而异,所以该插件还接受auto关键字作为值。在这种情况下,它会探测机器以获取可用的 CPU 数量,并启动相应数量的进程。
使用 Fixtures 创建用于测试的对象
在单元测试中,你通常需要在运行测试前后执行一组常见的指令,这些指令将使用某些组件。例如,你可能需要一个表示应用程序配置状态的对象,并且你可能希望在每个测试之前初始化该对象,然后在测试完成后将其重置为默认值。同样,如果你的测试依赖于临时创建一个文件,那么该文件必须在测试开始之前创建,并在测试完成后删除。这些组件被称为fixtures,在测试之前进行设置,并在测试完成后进行清理。
在 pytest 中,fixture 被定义为简单的函数。fixture 函数应返回所需的对象,以便使用该 fixture 的测试可以使用该对象。
这是一个简单的 fixture:
import pytest
@pytest.fixture
def database():
return <some database connection>
def test_insert(database):
database.insert(123)
数据库 fixture 会自动被任何在其参数列表中包含database的测试使用。test_insert()函数将接收database()函数的结果作为第一个参数,并根据需要使用该结果。当我们以这种方式使用 fixture 时,我们不需要重复多次数据库初始化代码。
代码测试的另一个常见功能是,在测试使用完 fixture 后进行清理。例如,你可能需要关闭数据库连接。将 fixture 实现为生成器允许我们添加清理功能,如示例 6-5 所示。
import pytest
@pytest.fixture
def database():
db = <some database connection>
yield db
db.close()
def test_insert(database):
database.insert(123)
示例 6-5:清理功能
因为我们使用了yield关键字并使database成为一个生成器,所以yield语句之后的代码将在测试完成后运行。该代码会在测试结束时关闭数据库连接。
然而,为每个测试关闭数据库连接可能会增加不必要的运行时开销,因为测试可能能够重用相同的连接。在这种情况下,你可以通过将scope参数传递给装饰器来指定 fixture 的作用范围:
import pytest
@pytest.fixture(scope="module")
def database():
db = <some database connection>
yield db
db.close()
def test_insert(database):
database.insert(123)
通过指定scope="module"参数,你可以在整个模块中初始化 fixture 一次,所有请求数据库连接的测试函数都会接收到相同的数据库连接。
最后,你可以在测试前后运行一些公共代码,通过使用autouse关键字将 fixtures 标记为自动使用,而不是将其作为每个测试函数的参数来指定。向pytest.fixture()函数指定autouse=True关键字参数,可以确保在模块或类中定义的任何测试运行之前,都会调用该 fixture,正如以下示例所示:
import os
import pytest
@pytest.fixture(autouse=True)
def change_user_env():
curuser = os.environ.get("USER")
os.environ["USER"] = "foobar"
yield
os.environ["USER"] = curuser
def test_user():
assert os.getenv("USER") == "foobar"
这些自动启用的功能很方便,但要确保不要滥用 fixtures:它们会在其作用范围内的每个测试之前运行,因此可能会显著减慢测试运行速度。
运行测试场景
在单元测试时,你可能希望使用几个不同的对象来运行相同的错误处理测试,这些对象会触发该错误,或者你可能希望在不同的驱动程序上运行整个测试套件。
在开发Gnocchi(一个时间序列数据库)时,我们在很大程度上依赖了这种后者的方法。Gnocchi 提供了一个我们称之为存储 API的抽象类。任何 Python 类都可以实现这个抽象基类并注册自己成为一个驱动程序。软件在需要时加载配置的存储驱动程序,并使用实现的存储 API 来存储或检索数据。在这种情况下,我们需要一个单元测试类,它可以在每个驱动程序上运行——从而对每个存储 API 的实现进行测试——以确保所有驱动程序符合调用者的期望。
实现这一点的简单方法是使用 参数化 fixtures,这些 fixtures 会多次运行所有使用它们的测试,每次使用不同的定义参数。清单 6-6 展示了一个使用参数化 fixtures 的示例,用不同的参数运行单个测试两次:一次用于 mysql,一次用于 postgresql。
import pytest
import myapp @pytest.fixture(params=["mysql", "postgresql"])
def database(request):
d = myapp.driver(request.param)
d.start()
yield d
d.stop()
def test_insert(database):
database.insert("somedata")
清单 6-6:使用参数化 fixtures 运行测试
在清单 6-6 中,driver fixture 使用了两个不同的值进行参数化,每个值都是应用程序支持的数据库驱动的名称。当运行 test_insert 时,它实际上会运行两次:一次使用 MySQL 数据库连接,另一次使用 PostgreSQL 数据库连接。这使我们能够在不同的场景下轻松重用相同的测试,而无需添加许多代码行。
使用模拟控制测试
模拟对象是模拟真实应用对象行为的对象,尤其是在特定和受控的方式下。这些对象在创建精确描述你希望测试代码的条件的环境时特别有用。你可以将所有对象替换为模拟对象,除了一个,用以隔离你关注对象的行为并为测试代码创建一个环境。
一个使用场景是在编写 HTTP 客户端时,因为可能不可能(或至少非常复杂)启动 HTTP 服务器并通过所有场景进行测试以返回每个可能的值。HTTP 客户端在所有故障场景下的测试尤其困难。
Python 中用于创建模拟对象的标准库是 mock。从 Python 3.3 开始,mock 已经合并到 Python 标准库中,成为 unittest.mock。因此,你可以使用如下片段来保持 Python 3.3 及更早版本之间的向后兼容性:
try:
from unittest import mock
except ImportError:
import mock
mock 库使用起来相当简单。任何访问 mock.Mock 对象的属性都会在运行时动态创建。可以将任何值设置为这样的属性。清单 6-7 显示了如何使用 mock 创建一个带有假属性的假对象。
>>> from unittest import mock
>>> m = mock.Mock()
>>> m.some_attribute = "hello world" >>> m.some_attribute
"hello world"
清单 6-7:访问 mock.Mock 属性
你还可以在一个可塑对象上动态创建方法,如清单 6-8 中所示,我们创建了一个始终返回 42 并接受任何参数的假方法。
>>> from unittest import mock
>>> m = mock.Mock()
>>> m.some_method.return_value = 42
>>> m.some_method()
42
>>> m.some_method("with", "arguments")
42
清单 6-8:在 mock.Mock 对象上创建方法
只需几行代码,你的 mock.Mock 对象现在就有了一个返回 42 的 some_method() 方法。它接受任何类型的参数,而且目前没有检查这些值是什么。
动态创建的方法也可以具有(故意的)副作用。它们不仅仅是返回值的模板方法,而是可以定义执行有用代码的功能。
清单 6-9 创建了一个假的方法,该方法具有打印 "hello world" 字符串的副作用。
>>> from unittest import mock
>>> m = mock.Mock()
>>> def print_hello():
... print("hello world!")
... return 43
...
➊ >>> m.some_method.side_effect = print_hello
>>> m.some_method()
hello world!
43
➋ >>> m.some_method.call_count
1
清单 6-9:在 mock.Mock 对象上创建带副作用的方法
我们将整个函数分配给 some_method 属性 ➊。这种技术使我们能够在测试中实现更复杂的场景,因为我们可以将任何需要的代码插入到模拟对象中进行测试。然后,只需将该模拟对象传递给需要它的函数。
call_count 属性 ➋ 是检查方法被调用次数的简单方法。
mock 库采用了动作/断言模式:这意味着在测试运行完毕后,由你来检查你所模拟的动作是否被正确执行。清单 6-10 使用 assert() 方法对我们的模拟对象进行这些检查。
>>> from unittest import mock
>>> m = mock.Mock()
➊ >>> m.some_method('foo', 'bar')
<Mock name='mock.some_method()' id='26144272'>
➋ >>> m.some_method.assert_called_once_with('foo', 'bar')
>>> m.some_method.assert_called_once_with('foo', ➌mock.ANY)
>>> m.some_method.assert_called_once_with('foo', 'baz')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib/python2.7/dist-packages/mock.py", line 846, in assert_called_
once_with
return self.assert_called_with(*args, **kwargs)
File "/usr/lib/python2.7/dist-packages/mock.py", line 835, in assert_called_
with
raise AssertionError(msg)
AssertionError: Expected call: some_method('foo', 'baz')
Actual call: some_method('foo', 'bar')
清单 6-10:检查方法调用
我们创建一个带有参数 foo 和 bar 的方法,通过调用该方法 ➊ 来作为我们的测试。检查对模拟对象的调用的常用方法是使用 assert_called() 方法,如 assert_called_once_with() ➋。对于这些方法,需要传递你期望调用者在调用模拟方法时使用的值。如果传递的值与预期不符,mock 会抛出 AssertionError。如果你不知道可能传递什么参数,可以使用 mock.ANY 作为值 ➌;这将匹配传递给模拟方法的任何参数。
mock 库还可以用于修补外部模块中的某些函数、方法或对象。在 清单 6-11 中,我们将 os.unlink() 函数替换为我们提供的假函数。
>>> from unittest import mock
>>> import os
>>> def fake_os_unlink(path):
... raise IOError("Testing!")
...
>>> with mock.patch('os.unlink', fake_os_unlink):
... os.unlink('foobar')
...
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
File "<stdin>", line 2, in fake_os_unlink
IOError: Testing!
清单 6-11:使用 mock.patch
当 mock.patch() 作为上下文管理器使用时,它会将目标函数替换为我们提供的函数,因此在上下文中执行的代码将使用该修补方法。使用 mock.patch() 方法,可以更改外部代码的任何部分,使其表现得符合你的测试需求,从而测试应用中的所有条件,如 清单 6-12 所示。
from unittest import mock
import pytest
import requests
class WhereIsPythonError(Exception):
pass
➊ def is_python_still_a_programming_language():
try:
r = requests.get("http://python.org")
except IOError:
pass
else:
if r.status_code == 200:
return 'Python is a programming language' in r.content
raise WhereIsPythonError("Something bad happened")
def get_fake_get(status_code, content):
m = mock.Mock()
m.status_code = status_code
m.content = content
def fake_get(url):
return m
return fake_get
def raise_get(url):
raise IOError("Unable to fetch url %s" % url)
➋ @mock.patch('requests.get', get_fake_get(
200, 'Python is a programming language for sure'))
def test_python_is():
assert is_python_still_a_programming_language() is True
@mock.patch('requests.get', get_fake_get(
200, 'Python is no more a programming language'))
def test_python_is_not():
assert is_python_still_a_programming_language() is False
@mock.patch('requests.get', get_fake_get(404, 'Whatever'))
def test_bad_status_code():
with pytest.raises(WhereIsPythonError):
is_python_still_a_programming_language()
@mock.patch('requests.get', raise_get)
def test_ioerror():
with pytest.raises(WhereIsPythonError):
is_python_still_a_programming_language()
清单 6-12:使用 mock.patch() 测试一组行为
清单 6-12 实现了一个测试套件,用于在 python.org/ 网页中搜索所有出现的字符串“Python is a programming language” ➊。无法测试负面场景(即该句子不在网页上)——显然我们无法修改网页本身。在这种情况下,我们使用 mock 来“作弊”并改变请求的行为,使其返回一个模拟的回复,其中没有该字符串。这使我们能够测试该负面场景,即 python.org/ 网页中不包含该句子,从而确保程序能够正确处理这种情况。
这个例子使用了 mock.patch() 的装饰器版本 ➋。使用装饰器不会改变模拟的行为,但当你需要在整个测试函数的上下文中使用模拟时,它更简单。
使用模拟(mocking),我们可以模拟任何问题,比如 Web 服务器返回 404 错误、I/O 错误或网络延迟问题。我们可以确保代码在每种情况下都返回正确的值或抛出正确的异常,确保我们的代码始终按预期行为运行。
揭示未测试的代码与覆盖率
coverage工具是单元测试的一个极好补充,它能识别出在测试过程中是否漏掉了某些代码。它使用代码分析工具和追踪钩子来确定哪些代码行已经执行;在单元测试运行时,它能显示出代码库的哪些部分已被覆盖,哪些部分没有。编写测试是有用的,但知道在测试过程中可能遗漏了哪些代码是锦上添花。
通过pip安装coverage Python 模块,即可在终端中访问coverage命令。
注意
如果你通过操作系统的软件包管理工具安装coverage,该命令也可能被命名为 python-coverage。例如,在 Debian 上就是如此。
使用coverage独立模式非常简单。它能显示程序中从未执行过的部分,以及哪些代码可能是“死代码”,即那些可以删除而不会影响程序正常工作流的代码。我们在本章讨论的所有测试工具都与coverage集成。
当使用pytest时,只需通过pip install pytest-pycov安装pytest-cov插件,并添加一些选项开关来生成详细的代码覆盖率输出,如清单 6-13 所示。
$ pytest --cov=gnocchiclient gnocchiclient/tests/unit
---------- coverage: platform darwin, python 3.6.4-final-0 -----------
Name Stmts Miss Branch BrPart Cover
---------------------------
gnocchiclient/__init__.py 0 0 0 0 100%
gnocchiclient/auth.py 51 23 6 0 49%
gnocchiclient/benchmark.py 175 175 36 0 0%
--snip--
---------------------------
TOTAL 2040 1868 424 6 8%
=== passed in 5.00 seconds ===
清单 6-13:在 pytest 中使用 coverage
--cov选项会在测试运行结束时启用覆盖率报告。你需要传递包名称作为参数,以便插件正确地过滤覆盖率报告。输出包括那些未执行的代码行,因此没有测试。现在你只需打开你最喜欢的文本编辑器,开始为那些代码编写测试。
然而,coverage更进一步,允许你生成清晰的 HTML 报告。只需添加--cov-report=html标志,然后运行该命令的htmlcov目录将会生成 HTML 页面。每个页面将显示你源代码中的哪些部分已被执行,哪些没有。
如果你想成为那种人,可以使用选项--cover-fail-under=COVER_MIN_PERCENTAGE,当测试套件运行时,如果代码的最低百分比未被执行,测试套件将失败。虽然有一个良好的覆盖率百分比是一个不错的目标,并且该工具有助于深入了解测试覆盖率的状态,但定义一个任意的百分比值并不能提供太多见解。图 6-1 展示了一个覆盖率报告的示例,顶部显示了百分比。
例如,100% 的代码覆盖率是一个值得尊敬的目标,但这并不一定意味着代码已经完全测试过,你可以松一口气。它仅仅证明了你的整个代码路径已经运行过;并没有说明每一个可能的条件都已经被测试过。
你应该使用覆盖率信息来巩固你的测试套件,并为当前没有运行的代码添加测试。这有助于后续的项目维护,并提高代码的整体质量。

图 6-1:ceilometer.publisher 的覆盖率
虚拟环境
我们之前提到过一个风险,那就是你的测试可能无法捕捉到依赖缺失的问题。任何较大的应用程序不可避免地依赖于外部库来提供应用程序所需的功能,但外部库可能在你的操作系统上引发很多问题。以下是其中一些:
-
你的系统没有打包你需要的库。
-
你的系统没有打包你所需的正确 版本 的库。
-
你需要两个不同版本的相同库来支持两个不同的应用程序。
这些问题可能发生在你首次部署应用程序时,或者稍后在应用程序运行过程中。通过系统管理器升级一个 Python 库,可能会在没有任何警告的情况下迅速破坏你的应用程序,原因可能只是库的 API 发生了变化,而该库正被应用程序使用。
解决方案是让每个应用程序使用一个包含所有应用程序依赖项的库目录。然后,该目录用于加载所需的 Python 模块,而不是系统安装的模块。
这样的目录被称为虚拟环境。
设置虚拟环境
工具 virtualenv 会为你自动处理虚拟环境。直到 Python 3.2,你可以通过安装 virtualenv 包来使用它,命令是 pip install virtualenv。如果你使用的是 Python 3.3 或更高版本,它可以通过 Python 的 venv 名称直接使用。
要使用该模块,像这样将其作为主程序加载,并以目标目录作为参数:
$ python3 -m venv myvenv
$ ls foobar
bin include lib pyvenv.cfg
一旦运行,venv 会创建一个 lib/pythonX.Y 目录,并使用它将 pip 安装到虚拟环境中,这对于安装后续的 Python 包非常有用。
然后,你可以通过“激活” activate 命令来激活虚拟环境。在 Posix 系统上,使用以下命令:
$ source myvenv/bin/activate
在 Windows 系统上,使用以下代码:
> \myvenv\Scripts\activate
一旦完成,你的 shell 提示符应该会显示虚拟环境的名称。执行 python 将调用已经复制到虚拟环境中的 Python 版本。你可以通过查看 sys.path 变量并确认它的第一个组件是虚拟环境目录来检查它是否正常工作。
你可以随时通过调用 deactivate 命令来停止并退出虚拟环境:
$ deactivate
就是这样。还要注意,如果你只想使用虚拟环境中安装的 Python 一次,你并不一定需要运行activate。直接调用python二进制文件也能工作:
$ myvenv/bin/python
现在,当我们处于激活的虚拟环境中时,我们无法访问主系统中安装并可用的任何模块。这正是使用虚拟环境的意义所在,但这也意味着我们可能需要安装所需的包。为此,使用标准的pip命令安装每个包,这些包将安装到正确的位置,而不会改变系统中的任何内容:
$ source myvenv/bin/activate
(myvenv) $ pip install six
Downloading/unpacking six
Downloading six-1.4.1.tar.gz
Running setup.py egg_info for package six
Installing collected packages: six
Running setup.py install for six
Successfully installed six
Cleaning up...
Voilà!我们可以安装所需的所有库,然后从这个虚拟环境中运行我们的应用程序,而不会破坏系统。很容易看出,我们可以编写脚本来自动化安装虚拟环境,基于依赖项列表,就像在清单 6-14 中所示。
virtualenv myappvenv
source myappvenv/bin/activate
pip install -r requirements.txt
deactivate
清单 6-14:自动虚拟环境创建
虽然通常不需要访问系统安装的包,但virtualenv允许你在创建虚拟环境时通过传递--system-site-packages标志启用它们。
在myvenv中,你会找到一个pyvenv.cfg文件,这是该环境的配置文件。默认情况下,它没有很多配置选项。你应该能识别出include-system-site-package,其作用与我们之前描述的virtualenv中的--system-site-packages相同。
正如你可能猜到的,虚拟环境对自动化运行单元测试套件非常有用。它们的使用如此广泛,以至于已经有一个专门的工具来解决这个问题。
使用 virtualenv 配合 tox
虚拟环境的主要用途之一是为运行单元测试提供一个干净的环境。如果你误以为测试正在正常工作,但它们没有遵守依赖项列表,那将是有害的。
确保考虑所有依赖项的一种方法是编写脚本来部署虚拟环境,安装setuptools,然后安装应用程序/库运行时和单元测试所需的所有依赖项。幸运的是,这是一个如此常见的用例,已经有一个专门用于此任务的应用程序:tox。
tox管理工具旨在自动化和标准化 Python 中的测试运行方式。为此,它提供了运行整个测试套件所需的一切,同时也安装你的应用程序以检查安装是否成功。
在使用tox之前,你需要提供一个名为tox.ini的配置文件,该文件应放置在项目的根目录中,与你的setup.py文件一起:
$ touch tox.ini
然后你可以成功地运行tox:
% tox
GLOB sdist-make: /home/jd/project/setup.py
python create: /home/jd/project/.tox/python
python inst: /home/jd/project/.tox/dist/project-1.zip
____________________ summary _____________________
python: commands succeeded
congratulations :)
在此实例中,tox 使用默认的 Python 版本在 .tox/python 中创建一个虚拟环境。它使用 setup.py 创建包的分发文件,然后将其安装到这个虚拟环境中。没有运行任何命令,因为我们没有在配置文件中指定任何命令。单单这样做并没有太大用处。
我们可以通过在测试环境中添加要运行的命令来改变这个默认行为。编辑 tox.ini 文件,添加以下内容:
[testenv]
commands=pytest
现在 tox 运行 pytest 命令。然而,由于我们在虚拟环境中没有安装 pytest,这个命令很可能会失败。我们需要将 pytest 列为一个依赖项以便安装:
[testenv]
deps=pytest
commands=pytest
现在运行时,tox 会重新创建环境,安装新的依赖项,并运行 pytest 命令,执行所有单元测试。要添加更多依赖项,你可以将它们列在 deps 配置选项中,如这里所示,或者使用 -rfile 语法从文件中读取。
重新创建环境
有时候你需要重新创建环境,例如,确保当一个新开发者克隆源代码仓库并首次运行 tox 时,所有东西能按预期工作。为此,tox 接受一个 --recreate 选项,它将根据你设定的参数从头开始重建虚拟环境。
你可以在 tox.ini 文件的 [testenv] 部分定义所有由 tox 管理的虚拟环境的参数。如前所述,tox 可以管理多个 Python 虚拟环境——事实上,你可以通过向 tox 传递 -e 标志来运行非默认 Python 版本下的测试,方法如下:
% tox -e py26
GLOB sdist-make: /home/jd/project/setup.py
py26 create: /home/jd/project/.tox/py26
py26 installdeps: nose
py26 inst: /home/jd/project/.tox/dist/rebuildd-1.zip
py26 runtests: commands[0] | pytests
--snip--
== test session starts ==
=== 5 passed in 4.87 seconds ====
默认情况下,tox 会模拟任何匹配现有 Python 版本的环境:py24、py25、py26、py27、py30、py31、py32、py33、py34、py35、py36、py37、jython 和 pypy!此外,你还可以定义自己的环境。只需要添加一个名为 [testenv:_envname_] 的新部分。如果你只想为某个特定环境运行某个命令,可以通过在 tox.ini 文件中列出以下内容来轻松实现:
[testenv]
deps=pytest
commands=pytest
[testenv:py36-coverage]
deps={[testenv]deps}
pytest-cov
commands=pytest --cov=myproject
通过在 py36-coverage 部分下使用 pytest --cov=myproject,如这里所示,你可以覆盖 py36-coverage 环境的命令,这意味着当你运行 tox -e py36-coverage 时,pytest 会作为依赖项安装,但实际运行的命令是带有覆盖选项的 pytest。为了使这一点生效,必须安装 pytest-cov 扩展:为此,我们将 deps 值替换为来自 testenv 的 deps,并添加 pytest-cov 依赖项。tox 也支持变量插值,因此你可以引用 tox.ini 文件中的任何其他字段并将其作为变量使用,语法为 {[env_name]variable_name}。这样可以避免重复相同的内容。
使用不同的 Python 版本
我们还可以通过在 tox.ini 中添加以下内容,立即创建一个不受支持版本的 Python 环境:
[testenv]
deps=pytest
commands=pytest
[testenv:py21]
basepython=python2.1
当我们运行它时,它现在会(尝试)使用 Python 2.1 来运行测试套件——尽管由于非常不可能你在系统上安装了这个古老的 Python 版本,我怀疑这对你来说会起作用!
你可能希望支持多个 Python 版本,在这种情况下,默认情况下让 tox 运行你想要支持的所有 Python 版本的所有测试会很有用。你可以通过在没有参数的情况下运行 tox 时指定你想使用的环境列表来做到这一点:
[tox]
envlist=py35,py36,pypy
[testenv]
deps=pytest
commands=pytest
当 tox 启动时没有其他参数,所有列出的四个环境都会被创建,填充依赖项和应用程序,然后用命令 pytest 运行它们。
集成其他测试
我们还可以使用 tox 集成像 flake8 这样的测试,如第一章所讨论的。以下 tox.ini 文件提供了一个 PEP 8 环境,将安装 flake8 并运行它:
[tox]
envlist=py35,py36,pypy,pep8
[testenv]
deps=pytest
commands=pytest
[testenv:pep8]
deps=flake8
commands=flake8
在这种情况下,pep8 环境使用默认版本的 Python 运行,这可能没问题,尽管如果你想更改它,仍然可以指定 basepython 选项。
运行 tox 时,你会注意到所有环境都是顺序构建并运行的。这可能会使过程非常漫长,但由于虚拟环境是隔离的,什么也不会阻止你并行运行 tox 命令。这正是 detox 包所做的,它提供了一个 detox 命令,能够并行运行 envlist 中的所有默认环境。你应该 pip install 它!
测试政策
在你的项目中嵌入测试代码是一个极好的主意,但代码的执行方式同样至关重要。太多项目中有一些测试代码,因为某些原因没有运行。这个话题不仅限于 Python,但我认为它足够重要,值得在这里强调:你应该对未经测试的代码实行零容忍政策。没有适当的单元测试覆盖的代码不应被合并。
你应该追求的最低目标是,你推送的每个提交都能通过所有测试。自动化这个过程会更好。例如,OpenStack 依赖于基于 Gerrit(一个基于 Web 的代码审查服务)和 Zuul(一个持续集成和交付服务)的特定工作流。每个提交都会通过 Gerrit 提供的代码审查系统,Zuul 负责运行一系列测试任务。Zuul 运行单元测试和各种高级功能测试。这些代码审查由几位开发人员执行,确保所有提交的代码都有相应的单元测试。
如果你使用流行的 GitHub 托管服务,Travis CI 是一个可以在每次推送或合并后,或在提交的拉取请求上运行测试的工具。虽然遗憾的是这些测试是在推送后进行的,但它仍然是跟踪回归的一个绝佳方式。Travis 开箱即支持所有主要的 Python 版本,并且可以进行显著的定制。一旦你通过 www.travis-ci.org/ 的 web 界面激活了 Travis,只需添加一个 .travis.yml 文件来确定如何运行测试。列表 6-15 展示了一个 .travis.yml 文件的示例。
language: python
python:
- "2.7"
- "3.6"
# command to install dependencies
install: "pip install -r requirements.txt --use-mirrors"
# command to run tests
script: pytest
列表 6-15:一个 .travis.yml 示例文件
在代码仓库中放置这个文件并启用 Travis 后,Travis 会启动一组作业,使用相关的单元测试来测试你的代码。你可以很容易地通过简单地添加依赖项和测试来定制它。Travis 是一个付费服务,但好消息是,对于开源项目,它完全免费!
tox-travis 包 (pypi.python.org/pypi/tox-travis/) 也值得关注,因为它会通过根据所使用的 Travis 环境运行正确的 tox 目标,来优化 tox 和 Travis 之间的集成。列表 6-16 展示了一个 .travis.yml 文件示例,该文件会在运行 tox 之前安装 tox-travis。
sudo: false
language: python
python:
- "2.7"
- "3.4"
install: pip install tox-travis
script: tox
列表 6-16:一个带有 tox-travis 的 .travis.yml 示例文件
使用 tox-travis,你只需在 Travis 上调用 tox 作为脚本,它将根据 .travis.yml 文件中指定的环境调用 tox,构建必要的虚拟环境,安装依赖项,并运行你在 tox.ini 中指定的命令。这使得在本地开发机器和 Travis 持续集成平台上使用相同的工作流程变得容易。
如今,无论你的代码托管在哪里,总是可以对软件进行一些自动化测试,确保你的项目在推进,而不是因为引入了 bug 而被拖慢进度。
罗伯特·柯林斯关于测试的观点
罗伯特·柯林斯不仅是 Bazaar 分布式版本控制系统的原始作者之一,还是 HP Cloud Services 的杰出技术专家,目前致力于 OpenStack 项目。罗伯特还是本书中描述的许多 Python 工具的作者,如 fixtures、testscenarios、testrepository,甚至是 python-subunit——你可能在不知情的情况下使用过他的某些程序!
你会建议使用什么样的测试策略?不进行代码测试是否可以接受?
我认为测试是一种工程折衷:你必须考虑失败未被发现而滑入生产环境的可能性、未发现的失败的成本和规模,以及执行工作的团队的凝聚力。以 OpenStack 为例,它有 1600 名贡献者:如此多的人各自有自己的意见,难以制定细致的政策。一般来说,一个项目需要一些自动化测试,以检查代码是否按预期运行,并且它的功能是否符合需求。通常这需要一些功能性测试,可能存在不同的代码库中。单元测试在速度和定位边界情况方面非常优秀。我认为,在有测试的前提下,测试风格之间的平衡是可以有所不同的。
在测试成本非常高且回报非常低的情况下,我认为做出不测试的明智决定是可以的,但这种情况相对较少:大多数事情可以以相对较低的成本进行测试,而及早发现错误的好处通常是相当高的。
在编写 Python 代码时,哪些策略能够帮助管理测试并提高代码质量?
将关注点分离,不要在同一个地方做多个事情;这样可以自然地实现重用,也使得放置测试替代物更容易。在可能的情况下,采取纯粹的函数式方法;例如,在一个方法中要么计算某些内容,要么更改某些状态,但避免两者都做。这样,你可以测试所有的计算行为,而不需要处理状态的变化,比如写入数据库或与 HTTP 服务器交互。这个好处也可以反向利用——你可以为测试替换计算逻辑,来引发边界情况的行为,并使用模拟和测试替代物来检查期望的状态传播是否按预期发生。最难测试的往往是深层次的堆栈,尤其是有复杂跨层行为依赖的情况。此时你需要让代码演变成层之间契约简单、可预测,并且——对测试最有用的是——可以替换的形式。
如何在源代码中组织单元测试最为合适?
拥有清晰的层次结构,例如 $ROOT/$PACKAGE/tests。我倾向于为整个源代码树做一个层次结构,例如 $ROOT/$PACKAGE/$SUBPACKAGE/tests。
在测试中,我通常会镜像源代码树的结构:$ROOT/$PACKAGE/foo.py 会在 $ROOT/$PACKAGE/tests/test_foo.py 中进行测试。
除非是顶层 __init__ 中的 test_suite/load_tests 函数,否则其他树层不应导入测试树。这使得在小型安装环境下轻松分离测试成为可能。
你如何看待 Python 中单元测试库和框架的未来?
我看到的主要挑战有:
-
在新机器(例如装有四个 CPU 的手机)中,并行能力的持续扩展。现有的单元测试内部 API 并不针对并行工作负载进行优化。我在 StreamResult Java 类上的工作直接旨在解决这个问题。
-
更复杂的调度支持——为类和模块作用域设置所解决的问题提供一个不那么丑陋的解决方案。
-
寻找一种方法来整合当今我们拥有的各种框架:对于集成测试而言,能够跨多个使用不同测试运行程序的项目获得一个整合视图将会非常有益。
第七章:方法和装饰器

Python 的装饰器是修改函数的便捷方式。装饰器首次在 Python 2.2 中引入,最初是 classmethod() 和 staticmethod() 装饰器,但后来进行了重构,使其更加灵活和易读。除了这两个原始的装饰器,Python 现在提供了一些现成的装饰器,并支持自定义装饰器的简单创建。但似乎大多数开发者并不理解它们背后的工作原理。
本章旨在改变这一点——我们将讨论什么是装饰器以及如何使用它们,并且还会介绍如何创建自己的装饰器。然后,我们将探讨如何使用装饰器创建静态方法、类方法和抽象方法,并深入了解 super() 函数,它允许你将可实现的代码放入抽象方法中。
装饰器及其使用时机
装饰器是一个接受另一个函数作为参数并将其替换为一个新的、修改后的函数的函数。装饰器的主要用法是在需要在多个函数之前、之后或周围调用的公共代码进行提取。如果你曾经编写过 Emacs Lisp 代码,你可能使用过 defadvice 装饰器,它允许你定义在函数周围调用的代码。如果你使用过 Common Lisp 对象系统(CLOS)中的方法组合,Python 的装饰器遵循相同的概念。我们将查看一些简单的装饰器定义,然后我们将研究一些常见的使用装饰器的场景。
创建装饰器
你很可能已经使用过装饰器来创建自己的包装函数。最简单的装饰器,也是最无聊的例子,就是 identity() 函数,它除了返回原始函数外什么也不做。以下是它的定义:
def identity(f):
return f
然后你可以像这样使用你的装饰器:
@identity
def foo():
return 'bar'
你输入装饰器的名称,前面加上 @ 符号,然后输入你想要使用装饰器的函数。这与以下代码是等效的:
def foo():
return 'bar'
foo = identity(foo)
这个装饰器没有用处,但它能正常工作。我们来看看另一个更有用的例子,参见清单 7-1。
_functions = {}
def register(f):
global _functions
_functions[f.__name__] = f
return f
@register
def foo():
return 'bar'
清单 7-1:用于将函数组织到字典中的装饰器
在清单 7-1 中,register 装饰器将装饰的函数名称存储到字典中。然后,可以使用函数名称访问 _functions 字典来检索函数:_functions['foo'] 指向 foo() 函数。
在接下来的章节中,我将解释如何编写自己的装饰器。然后我将介绍 Python 提供的内置装饰器是如何工作的,并解释如何(以及何时)使用它们。
编写装饰器
如前所述,装饰器通常用于重构围绕函数的重复代码。考虑以下一组函数,它们需要检查作为参数传入的用户名是否是管理员,如果不是管理员,则引发异常:
class Store(object):
def get_food(self, username, food):
if username != 'admin':
raise Exception("This user is not allowed to get food")
return self.storage.get(food)
def put_food(self, username, food):
if username != 'admin':
raise Exception("This user is not allowed to put food")
self.storage.put(food)
我们可以看到这里有一些重复的代码。使这段代码更高效的明显第一步是提取检查管理员状态的代码:
➊ def check_is_admin(username):
if username != 'admin':
raise Exception("This user is not allowed to get or put food")
class Store(object):
def get_food(self, username, food):
check_is_admin(username)
return self.storage.get(food)
def put_food(self, username, food):
check_is_admin(username)
self.storage.put(food)
我们已经将检查代码移到它自己的函数中➊。现在我们的代码看起来更简洁了,但如果使用装饰器的话,我们可以做得更好,如列表 7-2 所示。
def check_is_admin(f):
➊ def wrapper(*args, **kwargs):
if kwargs.get('username') != 'admin':
raise Exception("This user is not allowed to get or put food")
return f(*args, **kwargs) return wrapper
class Store(object):
@check_is_admin
def get_food(self, username, food):
return self.storage.get(food)
@check_is_admin
def put_food(self, username, food):
self.storage.put(food)
列表 7-2:将装饰器添加到提取的代码中
我们定义了check_is_admin装饰器➊,然后每当需要检查访问权限时就调用它。装饰器使用kwargs变量检查传递给函数的参数,并获取username参数,在调用实际函数之前执行用户名检查。像这样使用装饰器使得管理通用功能变得更加容易。对于有较多 Python 经验的人来说,这可能是司空见惯的事,但你可能没有意识到,这种实现装饰器的简单方法存在一些重大缺陷。
堆叠装饰器
你还可以在单个函数或方法上使用多个装饰器,如列表 7-3 所示。
def check_user_is_not(username):
def user_check_decorator(f):
def wrapper(*args, **kwargs):
if kwargs.get('username') == username:
raise Exception("This user is not allowed to get food")
return f(*args, **kwargs)
return wrapper
return user_check_decorator
class Store(object):
@check_user_is_not("admin")
@check_user_is_not("user123")
def get_food(self, username, food):
return self.storage.get(food)
列表 7-3:在单个函数上使用多个装饰器
这里,check_user_is_not()是我们的装饰器user_check_decorator()的工厂函数。它创建一个依赖于username变量的函数装饰器,然后返回该变量。函数user_check_decorator()将作为get_food()的函数装饰器。
函数get_food()被check_user_is_not()装饰了两次。这里的问题是应该先检查哪个用户名——admin还是user123?答案在下面的代码中,我将列表 7-3 翻译成了没有使用装饰器的等效代码。
class Store(object):
def get_food(self, username, food):
return self.storage.get(food)
Store.get_food = check_user_is_not("user123")(Store.get_food)
Store.get_food = check_user_is_not("admin")(Store.get_food)
装饰器列表是从上到下应用的,因此最靠近def关键字的装饰器会首先被应用并最后执行。在上面的示例中,程序会首先检查admin,然后检查user123。
编写类装饰器
也可以实现类装饰器,尽管这些在实际应用中使用得较少。类装饰器的工作方式与函数装饰器相同,但它们作用于类而不是函数。以下是一个类装饰器的示例,它为两个类设置属性:
import uuid
def set_class_name_and_id(klass):
klass.name = str(klass)
klass.random_id = uuid.uuid4()
return klass
@set_class_name_and_id
class SomeClass(object):
pass
当类被加载和定义时,它将设置name和random_id属性,如下所示:
>>> SomeClass.name
"<class '__main__.SomeClass'>"
>>> SomeClass.random_id
UUID('d244dc42-f0ca-451c-9670-732dc32417cd')
与函数装饰器一样,这对于提取处理类的通用代码非常有用。
类装饰器的另一个可能用途是用类来包装函数或类。例如,类装饰器通常用于包装存储状态的函数。以下示例包装了print()函数,用于检查它在一个会话中被调用了多少次:
class CountCalls(object):
def __init__(self, f):
self.f = f
self.called = 0 def __call__(self, *args, **kwargs):
self.called += 1
return self.f(*args, **kwargs)
@CountCalls
def print_hello():
print("hello")
然后我们可以使用它来检查print_hello()函数被调用了多少次:
>>> print_hello.called
0
>>> print_hello()
hello
>>> print_hello.called
1
使用 update_wrapper 装饰器获取原始属性
如前所述,装饰器会用一个新的函数替换原始函数,这个新函数是动态生成的。然而,这个新函数缺少了原始函数的许多属性,如文档字符串和名称。示例 7-4 展示了函数foobar()在被is_admin装饰器装饰后,失去了文档字符串和名称属性。
>>> def is_admin(f):
... def wrapper(*args, **kwargs):
... if kwargs.get('username') != 'admin':
... raise Exception("This user is not allowed to get food")
... return f(*args, **kwargs)
... return wrapper
...
>>> def foobar(username="someone"):
... """Do crazy stuff."""
... pass
...
>>> foobar.func_doc
'Do crazy stuff.'
>>> foobar.__name__
'foobar'
>>> @is_admin
... def foobar(username="someone"):
... """Do crazy stuff."""
... pass
...
>>> foobar.__doc__
>>> foobar.__name__
'wrapper'
示例 7-4:一个被装饰的函数失去了其文档字符串和名称属性。
在各种情况下,函数没有正确的文档字符串和名称属性可能会带来问题,例如在生成源代码文档时。
幸运的是,Python 标准库中的functools模块通过update_wrapper()函数解决了这个问题,该函数将原始函数丢失的属性复制到包装器本身。示例 7-5 展示了update_wrapper()的源代码。
WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__qualname__', '__doc__',
'__annotations__')
WRAPPER_UPDATES = ('__dict__',)
def update_wrapper(wrapper,
wrapped,
assigned = WRAPPER_ASSIGNMENTS,
updated = WRAPPER_UPDATES):
for attr in assigned:
try:
value = getattr(wrapped, attr)
except AttributeError:
pass
else:
setattr(wrapper, attr, value)
for attr in updated:
getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
# Issue #17482: set __wrapped__ last so we don't inadvertently copy it
# from the wrapped function when updating __dict__
wrapper.__wrapped__ = wrapped
# Return the wrapper so this can be used as a decorator via partial()
return wrapper
示例 7-5:update_wrapper()源代码
在示例 7-5 中,update_wrapper()源代码突出了在使用装饰器包装函数时哪些属性值得保存。默认情况下,__name__属性、__doc__属性以及其他一些属性会被复制。你也可以自定义哪些函数的属性被复制到装饰后的函数上。当我们使用update_wrapper()重写示例 7-4 中的例子时,效果更好:
>>> def foobar(username="someone"):
... """Do crazy stuff."""
... pass
...
>>> foobar = functools.update_wrapper(is_admin, foobar)
>>> foobar.__name__
'foobar'
>>> foobar.__doc__
'Do crazy stuff.'
现在,即使foobar()函数被is_admin装饰,它仍然拥有正确的名称和文档字符串。
wraps:为装饰器设计的装饰器
在创建装饰器时,手动使用update_wrapper()可能会变得很繁琐,因此functools提供了一个装饰器,用于装饰器本身,称为wraps。示例 7-6 展示了wraps装饰器的使用。
import functools
def check_is_admin(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
if kwargs.get('username') != 'admin':
raise Exception("This user is not allowed to get food")
return f(*args, **kwargs)
return wrapper
class Store(object):
@check_is_admin
def get_food(self, username, food):
"""Get food from storage."""
return self.storage.get(food)
示例 7-6:使用 functools 中的 wraps 更新我们的装饰器
使用functools.wrap时,返回wrapper()函数的装饰器函数check_is_admin()会负责从传入参数f的函数中复制文档字符串、名称函数以及其他信息。因此,被装饰的函数(在本例中是get_food())仍然保持不变的签名。
使用 inspect 提取相关信息
在我们迄今为止的例子中,我们假设被装饰的函数总是会接收到一个作为关键字参数传递的username,但实际情况可能并非如此。它可能会接收到一堆信息,我们需要从中提取出username来进行检查。考虑到这一点,我们将构建一个更智能的装饰器版本,能够查看被装饰函数的参数并提取所需的信息。
为此,Python 提供了inspect模块,它允许我们获取一个函数的签名并对其进行操作,如示例 7-7 所示。
import functools
import inspect
def check_is_admin(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
func_args = inspect.getcallargs(f, *args, **kwargs)
if func_args.get('username') != 'admin':
raise Exception("This user is not allowed to get food")
return f(*args, **kwargs)
return wrapper @check_is_admin
def get_food(username, type='chocolate'):
return type + " nom nom nom!"
示例 7-7:使用 inspect 模块中的工具来提取信息
执行繁重工作的函数是inspect.getcallargs(),它返回一个字典,字典包含了参数的名称和值,以键值对的形式。在我们的示例中,这个函数返回{'username': 'admin','type': 'chocolate'}。这意味着我们的装饰器不需要检查username参数是位置参数还是关键字参数;装饰器只需要在字典中查找username即可。
使用functools.wraps和inspect模块,你应该能够编写任何自定义装饰器。不过,不要滥用inspect模块:虽然能够猜测函数会接受什么样的参数听起来很方便,但这个功能可能是脆弱的,在函数签名发生变化时容易破坏。装饰器是实现开发者所珍视的不要重复自己(DRY)原则的绝佳方式。
Python 中的方法是如何工作的
方法的使用和理解相当简单,你很可能已经正确使用了它们,而无需深入探讨。但是,要理解某些装饰器的作用,你需要知道方法背后是如何运作的。
方法是作为类属性存储的函数。让我们看看当我们尝试直接访问这样的属性时会发生什么:
>>> class Pizza(object):
... def __init__(self, size):
... self.size = size
... def get_size(self):
... return self.size
...
>>> Pizza.get_size
<function Pizza.get_size at 0x7fdbfd1a8b90>
我们被告知get_size()是一个函数——但为什么会这样呢?原因是,在这个阶段,get_size()并没有绑定到任何特定的对象。因此,它被当作普通的函数来处理。如果我们直接调用它,Python 会抛出错误,像这样:
>>> Pizza.get_size()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: get_size() missing 1 required positional argument: 'self'
Python 会抱怨我们没有提供必要的self参数。确实,由于它没有绑定到任何对象,self参数无法自动设置。然而,我们不仅可以通过传递类的任意实例来调用get_size()函数,如果我们愿意,还可以传递任何对象,只要它具备方法所期待的属性。以下是一个示例:
>>> Pizza.get_size(Pizza(42))
42
这个调用是有效的,正如所承诺的那样。然而,它并不是很方便:每次我们想调用类的某个方法时,都需要提到类本身。
所以,Python 为我们做了额外的工作,通过将类的方法绑定到其实例上。换句话说,我们可以从任何Pizza实例访问get_size(),更重要的是,Python 会自动将对象本身传递给方法的self参数,像这样:
>>> Pizza(42).get_size
<bound method Pizza.get_size of <__main__.Pizza object at 0x7f3138827910>>
>>> Pizza(42).get_size()
42
正如预期的那样,我们不需要向get_size()提供任何参数,因为它是一个绑定方法:它的self参数会自动设置为我们的Pizza实例。这里有一个更清晰的示例:
>>> m = Pizza(42).get_size
>>> m()
42
只要你有绑定方法的引用,你甚至不需要保留对Pizza对象的引用。此外,如果你有方法的引用,但想知道它绑定到哪个对象,你可以直接检查方法的__self__属性,像这样:
>>> m = Pizza(42).get_size
>>> m.__self__
<__main__.Pizza object at 0x7f3138827910>
>>> m == m.__self__.get_size
True
显然,我们仍然可以访问我们的对象,如果需要,我们可以找到它。
静态方法
静态方法属于类,而不是类的实例,因此它们不会实际操作或影响类的实例。相反,静态方法操作的是它所接受的参数。静态方法通常用于创建实用函数,因为它们不依赖于类或其对象的状态。
例如,在示例 7-8 中,静态mix_ingredients()方法属于Pizza类,但实际上可以用于混合任何其他食物的配料。
class Pizza(object):
@staticmethod
def mix_ingredients(x, y):
return x + y
def cook(self):
return self.mix_ingredients(self.cheese, self.vegetables)
示例 7-8:将静态方法作为类的一部分创建
如果你愿意,你可以将mix_ingredients()编写为一个非静态方法,但它将需要一个self参数,而这个参数实际上从未被使用过。使用@staticmethod装饰器能为我们带来多个好处。
第一个优点是速度:Python 不必为我们创建的每个Pizza对象实例化一个绑定方法。绑定方法本身也是对象,创建它们会有 CPU 和内存的开销——即使开销很小。使用静态方法可以避免这种情况,像这样:
>>> Pizza().cook is Pizza().cook
False
>>> Pizza().mix_ingredients is Pizza.mix_ingredients
True
>>> Pizza().mix_ingredients is Pizza().mix_ingredients
True
其次,静态方法提高了代码的可读性。当我们看到@staticmethod时,我们就知道该方法不依赖于对象的状态。
第三,静态方法可以在子类中被重写。如果我们使用的是一个在模块顶部定义的mix_ingredients()函数,而不是静态方法,那么继承自Pizza类的子类就无法改变我们混合比萨配料的方式,除非重写cook()方法本身。使用静态方法时,子类可以根据自己的需求重写该方法。
不幸的是,Python 并不总是能够自己检测一个方法是否是静态的——我称之为语言设计的缺陷。一种可能的方法是添加一个检测此模式的检查,并使用flake8发出警告。我们将在《通过 AST 检查扩展flake8》一章中,查看如何实现这一点,详见第 140 页。
类方法
类方法是绑定到类本身,而不是其实例。这意味着这些方法无法访问对象的状态,而只能访问类的状态和方法。示例 7-9 展示了如何编写类方法。
>>> class Pizza(object):
... radius = 42
... @classmethod
... def get_radius(cls):
... return cls.radius
...
>>> Pizza.get_radius
<bound method type.get_radius of <class '__main__.Pizza'>>
>>> Pizza().get_radius
<bound method type.get_radius of <class '__main__.Pizza'>>
>>> Pizza.get_radius is Pizza().get_radius
True
>>> Pizza.get_radius()
42
示例 7-9:将类方法绑定到类上
如你所见,访问get_radius()类方法有多种方式,但无论你选择哪种方式,方法总是绑定到它所属的类上。此外,它的第一个参数必须是类本身。记住:类也是对象!
类方法主要用于创建工厂方法,这些方法使用不同于__init__的签名来实例化对象:
class Pizza(object):
def __init__(self, ingredients):
self.ingredients = ingredients
@classmethod
def from_fridge(cls, fridge):
return cls(fridge.get_cheese() + fridge.get_vegetables())
如果我们在这里使用@staticmethod而不是@classmethod,我们将不得不在方法中硬编码Pizza类名,这样任何继承自Pizza的类都无法使用我们的工厂方法。可是,在这种情况下,我们提供了一个from_fridge()工厂方法,允许我们传入一个Fridge对象。如果我们用类似Pizza.from_fridge(myfridge)的方式调用该方法,它将返回一个从myfridge中获取的全新Pizza。
每当你编写一个只关心对象的类而不关心对象状态的方法时,应该将其声明为类方法。
抽象方法
抽象方法定义在一个抽象基类中,该基类本身可能不提供任何实现。当一个类有抽象方法时,它无法被实例化。因此,抽象类(定义为至少有一个抽象方法的类)必须作为父类被另一个类继承。这个子类将负责实现抽象方法,从而使得可以实例化父类。
我们可以使用抽象基类来明确基类与其他连接类之间的关系,同时使得抽象基类本身无法实例化。通过使用抽象基类,你可以确保从基类派生的类实现基类的特定方法,否则会引发异常。以下示例展示了在 Python 中编写抽象方法的最简单方式:
class Pizza(object):
@staticmethod
def get_radius():
raise NotImplementedError
通过这个定义,任何继承自Pizza的类都必须实现并覆盖get_radius()方法;否则,调用该方法时会抛出如示例中的异常。这对于确保Pizza的每个子类都能实现并返回自己的半径计算方法非常有用。
这种实现抽象方法的方式有一个缺点:如果你编写一个继承自Pizza的类,但忘记实现get_radius(),只有在你尝试在运行时使用该方法时才会抛出错误。下面是一个例子:
>>> Pizza()
<__main__.Pizza object at 0x7fb747353d90>
>>> Pizza().get_radius()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in get_radius
NotImplementedError
由于Pizza是直接可实例化的,因此无法阻止这种情况的发生。确保在忘记实现和覆盖方法,或者尝试实例化包含抽象方法的对象时收到早期警告的一种方法是使用 Python 的内置abc(抽象基类)模块,示例如下:
import abc
class BasePizza(object, metaclass=abc.ABCMeta):
@abc.abstractmethod
def get_radius(self):
"""Method that should do something."""
abc模块提供了一组装饰器,可以用来装饰定义为抽象的那些方法,并且提供一个元类来支持这一点。当你使用abc及其特殊的metaclass时,如上所示,实例化一个没有覆盖get_radius()方法的BasePizza类或继承它的类会导致TypeError:
>>> BasePizza()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class BasePizza with abstract methods
get_radius
我们尝试实例化抽象类BasePizza,并立即被告知无法执行此操作!
虽然使用抽象方法不能保证方法一定会被用户实现,但这个装饰器帮助你更早地发现错误。当你提供必须由其他开发者实现的接口时,这非常有用;它是一个很好的文档提示。
混合静态方法、类方法和抽象方法
每个装饰器都有其独立的用处,但有时你可能需要将它们一起使用。
例如,你可以将工厂方法定义为类方法,同时强制要求在子类中实现。在这种情况下,你需要将类方法同时定义为抽象方法和类方法。本节提供了一些可以帮助你解决这个问题的技巧。
首先,抽象方法的原型并不是一成不变的。当你实现方法时,并没有什么阻止你根据需要扩展参数列表。示例 7-10 是一个代码示例,其中子类扩展了其父类抽象方法的签名。
import abc
class BasePizza(object, metaclass=abc.ABCMeta):
@abc.abstractmethod
def get_ingredients(self):
"""Returns the ingredient list."""
class Calzone(BasePizza):
def get_ingredients(self, with_egg=False):
egg = Egg() if with_egg else None
return self.ingredients + [egg]
示例 7-10:使用子类扩展其父类抽象方法的签名
我们定义了Calzone子类继承自BasePizza类。我们可以根据需要定义Calzone子类的方法,只要它们支持我们在BasePizza中定义的接口。这包括将方法实现为类方法或静态方法。以下代码在基类中定义了一个抽象的get_ingredients()方法,并在DietPizza子类中定义了一个静态的get_ingredients()方法:
import abc
class BasePizza(object, metaclass=abc.ABCMeta):
@abc.abstractmethod
def get_ingredients(self):
"""Returns the ingredient list."""
class DietPizza(BasePizza):
@staticmethod
def get_ingredients():
return None
即使我们的静态get_ingredients()方法并不基于对象的状态返回结果,它仍然支持我们抽象BasePizza类的接口,因此它是有效的。
你也可以在@abstractmethod上方使用@staticmethod和@classmethod装饰器,以表示一个方法例如既是静态的又是抽象的,如在示例 7-11 中所示。
import abc
class BasePizza(object, metaclass=abc.ABCMeta):
ingredients = ['cheese']
@classmethod
@abc.abstractmethod
def get_ingredients(cls):
"""Returns the ingredient list."""
return cls.ingredients
示例 7-11:在抽象方法中使用类方法装饰器
抽象方法get_ingredients()需要由子类实现,但它也是一个类方法,这意味着它将接收的第一个参数是类(而不是对象)。
请注意,通过像这样在BasePizza中将get_ingredients()定义为类方法,并不会强制任何子类将get_ingredients()定义为类方法——它可以是一个普通方法。如果我们将其定义为静态方法,同样不会强制子类将抽象方法实现为特定类型的方法。正如我们所见,子类在实现抽象方法时可以根据需要改变方法的签名。
将实现放入抽象方法中
等等:在 Listing 7-12 中,我们有一个实现 在 抽象方法中的例子。我们能这么做吗?答案是肯定的。Python 对此没有问题!你可以在抽象方法中写代码,并使用super()调用它,正如 Listing 7-12 所示。
import abc
class BasePizza(object, metaclass=abc.ABCMeta):
default_ingredients = ['cheese']
@classmethod
@abc.abstractmethod
def get_ingredients(cls):
"""Returns the default ingredient list."""
return cls.default_ingredients
class DietPizza(BasePizza):
def get_ingredients(self):
return [Egg()] + super(DietPizza, self).get_ingredients()
Listing 7-12:在抽象方法中使用实现
在这个例子中,每个继承自BasePizza的Pizza都必须重写get_ingredients()方法,但每个Pizza也可以访问基类提供的默认机制来获取配料列表。这个机制在提供接口实现的同时,也提供了可能对所有继承类有用的基础代码,非常有用。
关于 super 的真相
Python 一直允许开发者使用单继承和多重继承来扩展他们的类,但即使在今天,许多开发者似乎并不完全理解这些机制,以及与之相关的super()方法是如何工作的。要完全理解你的代码,你需要理解这些机制的权衡。
多重继承在许多地方都有使用,尤其是在涉及混入模式的代码中。混入是一个继承自两个或更多其他类的类,它结合了这些类的特性。
注意
单继承和多重继承、组合甚至鸭子类型的优缺点超出了本书的范围,因此我们不会在这里讨论所有内容。如果你不熟悉这些概念,我建议你阅读相关资料,形成自己的看法。
正如你现在应该已经知道的,类在 Python 中是对象。用于创建类的构造是一个你应该非常熟悉的特殊语句:class classname(继承的表达式)。
括号中的代码是一个 Python 表达式,它返回用于指定类的父类列表。通常,你会直接指定这些父类,但你也可以像这样写来指定父类对象的列表:
>>> def parent():
... return object
...
>>> class A(parent()):
... pass
...
>>> A.mro()
[<class '__main__.A'>, <type 'object'>]
这段代码按预期工作:我们声明类A,其父类是object。类方法mro()返回方法解析顺序,用于解析属性——它定义了如何通过类之间的继承树找到下一个要调用的方法。当前的 MRO 系统最早在 Python 2.3 中实现,其内部工作原理在 Python 2.3 的发布说明中有所描述。它定义了系统如何浏览类之间的继承树来找到要调用的方法。
我们已经看到,在父类中调用方法的标准方式是使用super()函数,但你可能不知道的是,super()实际上是一个构造函数,每次调用它时,你都会实例化一个super对象。它接受一个或两个参数:第一个参数是一个类,第二个参数是可选的,要么是第一个参数的子类,要么是第一个参数的实例。
构造函数返回的对象充当了第一个参数的父类代理。它有自己的 __getattribute__ 方法,遍历 MRO(方法解析顺序)列表并返回它找到的第一个匹配属性。当获取 super() 对象的属性时,会调用 __getattribute__ 方法,如 Listing 7-13 所示。
>>> class A(object):
... bar = 42
... def foo(self):
... pass
...
>>> class B(object):
... bar = 0
...
>>> class C(A, B):
... xyz = 'abc'
...
>>> C.mro()
[<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <type 'object'>]
>>> super(C, C()).bar
42
>>> super(C, C()).foo
<bound method C.foo of <__main__.C object at 0x7f0299255a90>>
>>> super(B).__self__ >>> super(B, B()).__self__
<__main__.B object at 0x1096717f0>
Listing 7-13:super() 函数是一个构造函数,用于实例化一个 super 对象。
当请求 C 类实例的 super 对象的属性时,super() 对象的 __getattribute__ 方法会遍历 MRO 列表,并返回它找到的第一个包含 super 属性的类的属性。
在 Listing 7-13 中,我们传递了两个参数调用了 super(),这意味着我们使用了一个 绑定 的 super 对象。如果我们仅用一个参数调用 super(),它将返回一个 未绑定 的 super 对象:
>>> super(C)
<super: <class 'C'>, NULL>
由于没有提供实例作为第二个参数,super 对象无法绑定到任何实例。因此,你不能使用这个未绑定的对象来访问类属性。如果你尝试这样做,你将得到以下错误:
>>> super(C).foo
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'super' object has no attribute 'foo'
>>> super(C).bar
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'super' object has no attribute 'bar'
>>> super(C).xyz
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'super' object has no attribute 'xyz'
乍一看,似乎这个未绑定的 super 对象毫无用处,但实际上,super 类通过实现描述符协议 __get__ 的方式,使得未绑定的 super 对象可以作为类属性使用:
>>> class D(C):
... sup = super(C)
...
>>> D().sup
<super: <class 'C'>, <D object>>
>>> D().sup.foo
<bound method D.foo of <__main__.D object at 0x7f0299255bd0>>
>>> D().sup.bar
42
未绑定的 super 对象的 __get__ 方法使用实例 super(C).__get__(D()) 和属性名 'foo' 作为参数调用,从而能够找到并解析 foo。
注意
即使你从未听说过描述符协议,你很可能已经通过 @property 装饰器使用过它,而你自己并未意识到。描述符协议是 Python 中的一种机制,允许存储为属性的对象返回除了自身之外的其他内容。本书中没有覆盖描述符协议的内容,但你可以在 Python 数据模型文档中找到更多相关信息。
使用 super() 可能会有很多棘手的情况,例如在继承链中处理不同的方法签名。不幸的是,并没有一种万能的解决方案来应对所有场合。最好的预防措施是使用技巧,例如让你的所有方法都接受 *args, **kwargs 作为参数。
从 Python 3 开始,super() 增添了一些魔法:它现在可以在方法内部不带任何参数地调用。当不向 super() 传递任何参数时,它会自动在栈帧中查找参数:
class B(A):
def foo(self):
super().foo()
访问子类父类属性的标准方式是 super(),你应该始终使用它。它可以在不引发意外的情况下协作调用父类方法,避免出现父类方法没有被调用或在多重继承时被调用两次的情况。
总结
凭借你在本章中学到的知识,你应该在涉及 Python 方法定义的所有问题上都能无敌了。装饰器在代码抽象化时至关重要,正确使用 Python 提供的内置装饰器可以极大提升 Python 代码的整洁性。抽象类在为其他开发者和服务提供 API 时尤其有用。
类继承往往不容易完全理解,了解语言内部机制的概览是全面理解其工作原理的好方法。现在这个话题应该没有什么你无法掌握的秘密了!
第八章:函数式编程

许多 Python 开发者并未意识到在 Python 中可以使用函数式编程的程度,这是很遗憾的:除了少数例外,函数式编程可以让你写出更加简洁和高效的代码。而且,Python 对函数式编程的支持非常广泛。
本章将介绍 Python 中的一些函数式编程方面,包括创建和使用生成器。你将了解一些最有用的函数式包和函数,并学习如何将它们结合使用以获得最高效的代码。
注意
如果你想认真学习函数式编程,以下是我的建议:暂时离开 Python,学习一门功能强大的函数式编程语言,比如 Lisp。我知道在一本 Python 书中谈 Lisp 可能听起来有些奇怪,但我在玩 Lisp 几年后学会了如何“函数式思考”。如果你所有的编程经验都来自命令式编程和面向对象编程,你可能不会发展出充分利用函数式编程所需的思维方式。Lisp 本身并不是纯函数式的,但它对函数式编程的关注比你在 Python 中看到的要多。
创建纯函数
当你使用函数式风格编写代码时,你的函数设计上不会有副作用:相反,它们接受输入并生成输出,而不会保持状态或修改任何返回值以外的东西。遵循这一理想的函数被称为纯函数。
让我们从一个常规的非纯函数示例开始,该函数用于移除列表中的最后一个元素:
def remove_last_item(mylist):
"""Removes the last item from a list."""
mylist.pop(-1) # This modifies mylist
以下是同一函数的纯粹版本:
def butlast(mylist):
return mylist[:-1] # This returns a copy of mylist
我们定义了一个 butlast() 函数,它的功能类似于 Lisp 中的 butlast,返回没有最后一个元素的列表,而不修改原始列表。相反,它返回一个已进行修改的列表副本,从而允许我们保留原始列表。
函数式编程的实际优势包括以下几点:
模块化 采用函数式编程风格强制在解决各个问题时进行一定程度的分离,使得代码的各个部分更容易在其他上下文中重用。由于函数不依赖于任何外部变量或状态,从不同的代码块中调用它非常直接。
简洁性 函数式编程通常比其他编程范式更加简洁。
并发性 纯函数是线程安全的,可以并发执行。一些函数式语言会自动实现这一点,这在你需要扩展应用程序时非常有帮助,尽管在 Python 中这还不完全是现状。
可测试性 测试一个函数式程序是非常简单的:你只需要一组输入和一组预期的输出。这些输出是幂等的,意味着使用相同的参数多次调用同一个函数将始终返回相同的结果。
生成器
生成器是一个行为类似于迭代器的对象,它在每次调用next()方法时生成并返回一个值,直到引发StopIteration为止。生成器首次在 PEP 255 中引入,提供了一种简单的方式来创建实现迭代器协议的对象。虽然以函数式风格编写生成器不是严格必要的,但这样做可以使它们更易于编写和调试,这也是一种常见的做法。
要创建生成器,只需编写一个常规的 Python 函数,并包含一个yield语句。Python 会检测到yield的使用,并将该函数标记为生成器。当执行到yield语句时,函数返回一个值,就像return语句一样,但有一个显著的区别:解释器会保存一个堆栈引用,这将用于在再次调用next()时恢复函数的执行。
当函数执行时,它们的执行链会产生一个堆栈——函数调用被认为是堆叠在一起的。当一个函数返回时,它会从堆栈中移除,并将返回值传递给调用函数。对于生成器来说,函数并不真正返回,而是生成。因此,Python 会保存函数的状态作为堆栈引用,当需要生成器的下一次迭代时,它会恢复生成器的执行,恢复到保存的点。
创建生成器
如前所述,您可以通过编写一个常规函数并在函数体内包含yield来创建生成器。示例 8-1 创建了一个名为mygenerator()的生成器,其中包含三个yield,意味着它将在接下来的三次next()调用时进行迭代。
>> def mygenerator():
... yield 1
... yield 2
... yield 'a'
...
>>> mygenerator()
<generator object mygenerator at 0x10d77fa50>
>>> g = mygenerator()
>>> next(g)
1
>>> next(g)
2
>>> next(g)
'a' >>> next(g)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
示例 8-1:创建一个具有三次迭代的生成器
当它耗尽yield语句时,下一次调用next()时会引发StopIteration。
在 Python 中,当函数产生某个值时,生成器会保留堆栈的引用,当再次调用next()时,它们会恢复这个堆栈。
在不使用生成器时,迭代任何数据的朴素方法是首先构建整个列表,这通常会浪费内存。
假设我们想找到 1 和 10,000,000 之间第一个等于 50,000 的数字。听起来很简单,对吧?让我们给自己一个挑战。我们将在 128MB 内存限制下运行 Python,并尝试首先构建整个列表的朴素方法:
$ ulimit -v 131072
$ python3
>>> a = list(range(10000000))
这种朴素方法首先尝试构建列表,但如果我们运行程序到这个阶段:
Traceback (most recent call last): File "<stdin>", line 1, in <module>
MemoryError
哎呀,结果我们不能仅凭 128MB 的内存构建一个包含 1000 万个项目的列表!
警告
在 Python 3 中,range()在迭代时返回一个生成器。要在 Python 2 中获取生成器,必须使用 xrange()。这个函数在 Python 3 中已不再存在,因为它是多余的。
让我们改用生成器试试看,同样的 128MB 限制:
$ ulimit -v 131072
$ python3
>>> for value in range(10000000):
... if value == 50000:
... print("Found it")
... break
...
Found it
这次,我们的程序没有出现问题。当它被迭代时,range()类返回一个生成器,该生成器动态地生成我们的整数列表。更好的是,由于我们只对第 50,000 个数字感兴趣,因此生成器只需要生成 50,000 个数字就会停止,而不是构建完整的列表。
通过动态生成值,生成器允许你以最小的内存和处理周期消耗处理大型数据集。每当你需要处理大量的值时,生成器可以帮助你高效地处理它们。
通过 yield 返回和传递值
yield语句还有一个不太常用的功能:它可以像函数调用一样返回一个值。这使得我们可以通过调用生成器的send()方法向生成器传递一个值。作为使用send()的例子,我们将编写一个名为shorten()的函数,它接受一个字符串列表,并返回一个由这些相同字符串构成的列表,只是每个字符串都被截断了(清单 8-2)。
def shorten(string_list):
length = len(string_list[0])
for s in string_list:
length = yield s[:length]
mystringlist = ['loremipsum', 'dolorsit', 'ametfoobar']
shortstringlist = shorten(mystringlist)
result = []
try:
s = next(shortstringlist)
result.append(s)
while True:
number_of_vowels = len(filter(lambda letter: letter in 'aeiou', s))
# Truncate the next string depending
# on the number of vowels in the previous one
s = shortstringlist.send(number_of_vowels)
result.append(s)
except StopIteration:
pass
清单 8-2:使用 send()返回和使用值
在这个例子中,我们写了一个名为shorten()的函数,它接受一个字符串列表,并返回一个由这些相同字符串构成的列表,只是每个字符串都被截断了。每个截断后的字符串长度等于前一个字符串中的元音字母数量:loremipsum有四个元音,因此生成器返回的第二个值将是dolorsit的前四个字母;dolo只有两个元音,因此ametfoobar将被截断为前两个字母am。然后生成器停止并引发StopIteration。因此,我们的生成器返回:
['loremipsum', 'dolo', 'am']
以这种方式使用yield和send()允许 Python 生成器像 Lua 和其他语言中的协程一样工作。
PEP 289 引入了生成器表达式,使得可以使用类似列表推导的语法构建单行生成器:
>>> (x.upper() for x in ['hello', 'world'])
<generator object <genexpr> at 0x7ffab3832fa0>
>>> gen = (x.upper() for x in ['hello', 'world'])
>>> list(gen)
['HELLO', 'WORLD']
在这个例子中,gen是一个生成器,就像我们使用yield语句一样。此时的yield是隐式的。
检查生成器
要判断一个函数是否被认为是生成器,可以使用inspect.isgeneratorfunction()。在清单 8-3 中,我们创建了一个简单的生成器并进行了检查。
>>> import inspect
>>> def mygenerator():
... yield 1
...
>>> inspect.isgeneratorfunction(mygenerator)
True
>>> inspect.isgeneratorfunction(sum)
False
清单 8-3:检查函数是否为生成器
导入inspect包来使用isgeneratorfunction(),然后只需将函数的名称传递给它进行检查。阅读inspect.isgeneratorfunction()的源代码可以帮助我们了解 Python 是如何标记函数为生成器的(见清单 8-4)。
def isgeneratorfunction(object):
"""Return true if the object is a user-defined generator function.
Generator function objects provides same attributes as functions.
See help(isfunction) for attributes listing."""
return bool((isfunction(object) or ismethod(object)) and
object.func_code.co_flags & CO_GENERATOR)
清单 8-4:inspect.isgeneratorfunction()的源代码
isgeneratorfunction()函数检查对象是否为函数或方法,并且其代码是否设置了CO_GENERATOR标志。这个例子展示了理解 Python 底层工作原理是多么简单。
inspect 包提供了 inspect.getgeneratorstate() 函数,可以返回生成器的当前状态。我们将在 mygenerator() 中的不同执行点上使用它:
>>> import inspect
>>> def mygenerator():
... yield 1
...
>>> gen = mygenerator()
>>> gen
<generator object mygenerator at 0x7f94b44fec30>
>>> inspect.getgeneratorstate(gen)
➊ 'GEN_CREATED'
>>> next(gen)
1
>>> inspect.getgeneratorstate(gen)
➋ 'GEN_SUSPENDED'
>>> next(gen)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
>>> inspect.getgeneratorstate(gen)
➌ 'GEN_CLOSED'
这使我们能够判断生成器是处于首次运行的等待状态(GEN_CREATED) ➊,等待通过调用next()恢复执行的状态(GEN_SUSPENDED) ➋,还是已经运行完毕的状态(GEN_CLOSED) ➌。这个功能在调试生成器时可能会很有用。
列表推导式
列表推导式,简称 listcomp,允许你在声明列表时直接定义其内容。要将列表转变为列表推导式,必须像平常一样将其放在方括号中,但还要包括一个表达式来生成列表中的项,并加上一个 for 循环来遍历这些项。
以下例子没有使用列表推导式创建列表:
>>> x = []
>>> for i in (1, 2, 3):
... x.append(i)
...
>>> x
[1, 2, 3]
下面这个例子使用列表推导式在一行代码中创建相同的列表:
>>> x = [i for i in (1, 2, 3)]
>>> x
[1, 2, 3]
使用列表推导式有两个优点:使用列表推导式编写的代码通常更简短,因此会减少 Python 执行的操作次数。与其反复创建列表并调用append,Python 可以直接创建包含所有元素的列表,并在一次操作中将它们移动到新列表中。
你可以将多个 for 语句结合使用,并使用 if 语句来过滤掉某些元素。这里我们创建了一个单词列表,使用列表推导式将每个单词首字母大写,将包含多个单词的项拆分成单个单词,并删除多余的 or:
x = [word.capitalize()
for line in ("hello world?", "world!", "or not")
for word in line.split()
if not word.startswith("or")]
>>> x
['Hello', 'World?', 'World!', 'Not']
这段代码有两个 for 循环:第一个循环遍历文本行,第二个循环遍历每行中的单词。最后一个 if 语句过滤掉以 or 开头的单词,将它们从最终的列表中排除。
使用列表推导式而非 for 循环是一种快速定义列表的简洁方法。由于我们仍在讨论函数式编程,值得注意的是,使用列表推导式创建的列表不应该依赖于改变程序的状态:在构建列表时,不应修改任何变量。这通常使得列表比没有使用列表推导式的列表更简洁、更易读。
请注意,也有类似的语法可以构建字典或集合,像这样:
>>> {x:x.upper() for x in ['hello', 'world']}
{'world': 'WORLD', 'hello': 'HELLO'}
>>> {x.upper() for x in ['hello', 'world']}
set(['WORLD', 'HELLO'])
功能性函数的运作
当使用函数式编程处理数据时,你可能会反复遇到相同的一组问题。为了帮助你高效应对这种情况,Python 包含了一些函数来支持函数式编程。本节将快速概述一些内置函数,它们使你能够构建完全函数式的程序。一旦你了解了可用的函数,我鼓励你进一步研究,并尝试将这些函数应用到你自己的代码中。
使用 map() 将函数应用于元素
map() 函数的形式为 map(function, iterable),并将 function 应用到 iterable 中的每一项,返回 Python 2 中的一个列表,或 Python 3 中的一个可迭代的 map 对象,如 列表 8-5 所示。
>>> map(lambda x: x + "bzz!", ["I think", "I'm good"])
<map object at 0x7fe7101abdd0>
>>> list(map(lambda x: x + "bzz!", ["I think", "I'm good"]))
['I thinkbzz!', "I'm goodbzz!"]
列表 8-5:在 Python 3 中使用 map()
你可以使用列表推导式编写一个等效的 map(),如下所示:
>>> (x + "bzz!" for x in ["I think", "I'm good"])
<generator object <genexpr> at 0x7f9a0d697dc0>
>>> [x + "bzz!" for x in ["I think", "I'm good"]]
['I thinkbzz!', "I'm goodbzz!"]
使用 filter() 过滤列表
filter() 函数的形式为 filter(function or None, iterable),它根据 function 返回的结果过滤 iterable 中的项。在 Python 2 中,这将返回一个列表,在 Python 3 中则返回一个可迭代的 filter 对象:
>>> filter(lambda x: x.startswith("I "), ["I think", "I'm good"])
<filter object at 0x7f9a0d636dd0>
>>> list(filter(lambda x: x.startswith("I "), ["I think", "I'm good"]))
['I think']
你也可以使用列表推导式编写一个等效的 filter(),如下所示:
>>> (x for x in ["I think", "I'm good"] if x.startswith("I "))
<generator object <genexpr> at 0x7f9a0d697dc0>
>>> [x for x in ["I think", "I'm good"] if x.startswith("I ")]
['I think']
使用 enumerate() 获取索引
enumerate() 函数的形式为 enumerate(iterable [,start]),并返回一个可迭代的对象,该对象提供一系列元组,每个元组包含一个整数索引(如果提供了 start,则从 start 开始)和 iterable 中对应的项。当你需要编写引用数组索引的代码时,这个函数非常有用。例如,代替编写如下代码:
i = 0
while i < len(mylist): print("Item %d: %s" % (i, mylist[i]))
i += 1
你可以更高效地使用 enumerate() 完成同样的事情,如下所示:
for i, item in enumerate(mylist):
print("Item %d: %s" % (i, item))
使用 sorted() 排序列表
sorted() 函数的形式为 sorted(iterable, key=None, reverse=False),并返回 iterable 的排序版本。key 参数允许你提供一个返回排序值的函数,如下所示:
>>> sorted([("a", 2), ("c", 1), ("d", 4)])
[('a', 2), ('c', 1), ('d', 4)]
>>> sorted([("a", 2), ("c", 1), ("d", 4)], key=lambda x: x[1])
[('c', 1), ('a', 2), ('d', 4)]
使用 any() 和 all() 查找满足条件的项
any(iterable) 和 all(iterable) 函数根据 iterable 返回的值返回布尔值。这些简单的函数等价于以下完整的 Python 代码:
def all(iterable):
for x in iterable:
if not x:
return False
return True
def any(iterable):
for x in iterable:
if x:
return True
return False
这些函数对于检查可迭代对象中的任何值或所有值是否满足给定条件非常有用。例如,以下代码检查一个列表是否满足两个条件:
mylist = [0, 1, 3, -1]
if all(map(lambda x: x > 0, mylist)):
print("All items are greater than 0")
if any(map(lambda x: x > 0, mylist)):
print("At least one item is greater than 0")
这里的区别是,any() 在至少一个元素满足条件时返回 True,而 all() 只有在每个元素都满足条件时才返回 True。all() 函数对于空的可迭代对象也会返回 True,因为没有元素为 False。
使用 zip() 合并列表
zip() 函数的形式为 zip(iter1 [,iter2 [...]])。它接受多个序列并将它们合并成元组。当你需要将一组键和一组值合并成字典时,这非常有用。与此处描述的其他函数一样,zip() 在 Python 2 中返回一个列表,在 Python 3 中返回一个可迭代的对象。这里我们将一组键映射到一组值以创建字典:
>>> keys = ["foobar", "barzz", "ba!"]
>>> map(len, keys)
<map object at 0x7fc1686100d0>
>>> zip(keys, map(len, keys))
<zip object at 0x7fc16860d440>
>>> list(zip(keys, map(len, keys)))
[('foobar', 6), ('barzz', 5), ('ba!', 3)]
>>> dict(zip(keys, map(len, keys)))
{'foobar': 6, 'barzz': 5, 'ba!': 3}
Python 2 和 3 中的函数式编程函数
你可能已经注意到 Python 2 和 Python 3 之间的返回类型差异。Python 的大多数纯函数式内置函数在 Python 2 中返回一个列表,而不是可迭代对象,这使得它们在内存使用上不如 Python 3 的x版本高效。如果你打算使用这些函数编写代码,请记住在 Python 3 中你将获得最大收益。如果你仍然使用 Python 2,不用担心:标准库中的itertools模块提供了这些函数的基于迭代器的版本(如itertools.izip()、itertools.imap()、itertools.ifilter()等)。
一个常见问题的解决方案
还有一个重要的工具需要介绍。在处理列表时,我们经常想找到第一个满足特定条件的项。我们将看看多种实现方法,并最终找到最有效的方法:first包。
使用简单代码找到项
我们或许能够通过这样的函数找到第一个满足条件的项:
def first_positive_number(numbers):
for n in numbers:
if n > 0:
return n
我们可以像这样以函数式风格重写first_positive_number()函数:
def first(predicate, items):
for item in items:
if predicate(item):
return item
first(lambda x: x > 0, [-1, 0, 1, 2])
通过使用函数式方法并将谓词作为参数传递,函数变得非常可复用。我们甚至可以像这样更简洁地编写它:
# Less efficient
list(filter(lambda x: x > 0, [-1, 0, 1, 2]))[0]
# Efficient
next(filter(lambda x: x > 0, [-1, 0, 1, 2]))
请注意,如果没有项满足条件,这可能会引发IndexError,导致list(filter())返回一个空列表。
对于简单的情况,你可以依赖next()来防止发生IndexError,像这样:
>>> a = range(10)
>>> next(x for x in a if x > 3)
4
示例 8-6 会在条件永远无法满足时抛出StopIteration。这也可以通过为next()添加第二个参数来解决,像这样:
>>> a = range(10)
>>> next((x for x in a if x > 10), 'default')
'default'
示例 8-6:在条件不满足时返回默认值
当条件无法满足时,这将返回一个默认值,而不是抛出错误。幸运的是,Python 提供了一个包来处理这一切。
使用 first()找到项
与其在所有程序中都编写示例 8-6 中的函数,不如引入一个小型 Python 包first。示例 8-7 展示了该包如何帮助你找到匹配条件的可迭代对象的第一个元素。
>>> from first import first
>>> first([0, False, None, [], (), 42])
42
>>> first([-1, 0, 1, 2])
-1
>>> first([-1, 0, 1, 2], key=lambda x: x > 0)
1
示例 8-7:找到列表中第一个满足条件的项
你可以看到,first()函数返回列表中第一个有效的、非空的项。
使用 lambda()与 functools
你会注意到,在本章的许多例子中,我们已经使用了lambda()。lambda()函数是为方便函数式编程而添加到 Python 中的,用于map()和filter()等函数,否则每次想要检查不同的条件时,你都需要编写一个全新的函数。示例 8-8 与示例 8-7 等效,但没有使用lambda()。
import operator
from first import first
def greater_than_zero(number):
return number > 0
first([-1, 0, 1, 2], key=greater_than_zero)
示例 8-8:找到满足条件的第一个项,不使用 lambda()
这段代码的工作原理与示例 8-7 中的代码相同,返回列表中满足条件的第一个非空值,但它要更加繁琐:如果我们想要得到序列中第一个超过 42 个项目的数字,我们就需要通过def来定义一个适当的函数,而不是直接在调用first()时内联定义它。
尽管lambda有助于我们避免类似的情况,但它仍然存在问题。first模块包含一个key参数,可以用于提供一个函数,该函数接收每个项作为参数并返回一个布尔值,表示是否满足条件。然而,我们不能传递key函数,因为它需要超过一行的代码:lambda语句不能跨越多行。这是lambda的一个重大限制。
相反,我们必须回到繁琐的方式,为每个我们需要的key编写新的函数定义。或者我们就不需要这样做吗?
functools包通过其partial()方法为我们提供了帮助,它为我们提供了一个比lambda更灵活的替代方案。functools.partial()方法允许我们创建一个带有“变形”的包装函数:它不是改变函数的行为,而是改变它接收的参数,就像这样:
from functools import partial
from first import first
➊ def greater_than(number, min=0):
return number > min
➋ first([-1, 0, 1, 2], key=partial(greater_than, min=42))
在这里,我们创建了一个新的greater_than()函数,它的默认行为与示例 8-8 中的旧版greater_than_zero()函数一样,但这个版本允许我们指定要与其比较的值,而之前的版本是硬编码的。在这里,我们将functools.partial()传递给我们的函数,并指定我们想要的min值➊,然后我们得到一个新的函数,这个函数将min设置为 42,正如我们想要的那样➋。换句话说,我们可以编写一个函数,并使用functools.partial()来定制我们新函数的行为,以适应任何特定情况下的需求。
即使是这个版本也可以简化。我们在这个例子中所做的只是比较两个数字,事实证明,operator模块正好为此提供了内置函数:
import operator
from functools import partial
from first import first
first([-1, 0, 1, 2], key=partial(operator.le, 0))
这是一个很好的例子,展示了functools.partial()如何与位置参数一起使用。在这个例子中,函数operator.le(a, b)接受两个数字并返回一个布尔值,告诉我们第一个数字是否小于或等于第二个数字。我们将其传递给functools.partial(),传递给functools.partial()的 0 被分配给a,而传递给functools.partial()返回的函数的参数则被分配给b。因此,这个例子与示例 8-8 中的代码完全相同,但不使用lambda或定义任何额外的函数。
注意
functools.partial()方法通常用于替代 lambda 表达式,并且应被视为一种更优秀的替代方案。lambda 函数在 Python 语言中算是一种例外,并且由于该函数的体积限制(只能写单行代码),Python 3 中曾考虑过完全去除它。
有用的 itertools 函数
最后,我们将看看 Python 标准库中 itertools 模块中的一些有用函数,你应该了解它们。很多程序员最终会编写这些函数的自定义版本,仅仅因为他们不知道 Python 提供了这些现成的函数。它们都旨在帮助你操作 iterator(这就是该模块名为 iter-tools 的原因),因此它们都是纯粹的函数式函数。我将在这里列出其中的一些,并简要介绍它们的功能,鼓励你在有需要时进一步研究它们。
-
accumulate(iterable[, func]) 返回来自可迭代对象的项目累积和序列。 -
chain(*iterables) 依次遍历多个可迭代对象,而无需构建一个包含所有项目的中间列表。 -
combinations(iterable, r) 生成从给定iterable中长度为 r 的所有组合。 -
compress(data, selectors) 将 selectors 中的布尔掩码应用于 data,只返回 data 中对应 selectors 元素为True的值。 -
count(start, step) 生成一个无限的数值序列,从 start 开始,每次调用时递增 step。 -
cycle(iterable) 循环遍历 iterable 中的值。 -
repeat(elem[, n]) 重复元素 elem,重复 n 次。 -
dropwhile(predicate, iterable) 从开始筛选可迭代对象的元素,直到谓词为False。 -
groupby(iterable, keyfunc) 创建一个迭代器,根据keyfunc()函数返回的结果对项目进行分组。 -
permutations(iterable[, r]) 返回 iterable 中项目的连续 r 长度排列。 -
product(*iterables) 返回一个可迭代对象,包含多个可迭代对象的笛卡尔积,而不需要使用嵌套的for循环。 -
takewhile(predicate, iterable) 从开始返回可迭代对象的元素,直到谓词为False。
这些函数在与 operator 模块结合使用时特别有用。将 itertools 和 operator 一起使用,可以处理大多数程序员通常依赖 lambda 的情况。以下是一个示例,展示了如何使用 operator.itemgetter() 来代替编写 lambda x: x['foo']:
>>> import itertools
>>> a = [{'foo': 'bar'}, {'foo': 'bar', 'x': 42}, {'foo': 'baz', 'y': 43}]
>>> import operator
>>> list(itertools.groupby(a, operator.itemgetter('foo')))
[('bar', <itertools._grouper object at 0xb000d0>), ('baz', <itertools._grouper object at
0xb00110>)]
>>> [(key, list(group)) for key, group in itertools.groupby(a, operator.itemgetter('foo'))]
[('bar', [{'foo': 'bar'}, {'x': 42, 'foo': 'bar'}]), ('baz', [{'y': 43, 'foo': 'baz'}])]
在这种情况下,我们也可以写作 lambda x: x['foo'],但是使用 operator 可以避免完全使用 lambda。
总结
虽然 Python 通常被宣传为面向对象的语言,但它也可以以非常函数化的方式使用。它的许多内建概念,如生成器和列表推导式,都是函数式的,并且与面向对象的方法不冲突。它们还减少了对程序全局状态的依赖,这对你来说是有益的。
使用函数式编程范式在 Python 中可以帮助你让程序更具可重用性,更容易测试和调试,支持“不要重复自己”(DRY)原则。在这个精神下,标准 Python 模块 itertools 和 operator 是改善函数式代码可读性的好工具。
第九章:抽象语法树、Hy 和类似 Lisp 的属性

抽象语法树(AST) 是任何编程语言源代码结构的表示。每种语言,包括 Python,都有一个特定的 AST;Python 的 AST 通过解析 Python 源文件来构建。像任何树一样,它由相互连接的节点构成。一个节点可以表示一个操作、一个语句、一个表达式,甚至是一个模块。每个节点都可以包含指向构成树的其他节点的引用。
Python 的 AST 并没有被充分文档化,因此乍一看可能很难处理,但理解 Python 构建的一些更深层次的方面有助于你掌握它的使用。
本章将检查一些简单的 Python 命令的 AST,以帮助你熟悉其结构和使用方式。熟悉 AST 后,我们将构建一个程序,使用 flake8 和 AST 来检查错误声明的方法。最后,我们将介绍 Hy,这是一种基于 Python AST 构建的 Python-Lisp 混合语言。
查看 AST
查看 Python AST 最简单的方法是解析一些 Python 代码并转储生成的 AST。为此,Python 的 ast 模块提供了你所需的一切,如 清单 9-1 所示。
>>> import ast
>>> ast.parse
<function parse at 0x7f062731d950>
>>> ast.parse("x = 42")
<_ast.Module object at 0x7f0628a5ad10>
>>> ast.dump(ast.parse("x = 42"))
"Module(body=[Assign(targets=[Name(id='x', ctx=Store())], value=Num(n=42))])"
清单 9-1:使用 ast 模块转储解析代码生成的 AST
ast.parse() 函数解析包含 Python 代码的任何字符串,并返回一个 _ast.Module 对象。这个对象实际上是树的根节点:你可以浏览它来发现构成树的每个节点。为了可视化树的结构,你可以使用 ast.dump() 函数,它将返回整个树的字符串表示。
在 清单 9-1 中,代码 x = 42 使用 ast.parse() 进行解析,并使用 ast.dump() 打印结果。这个抽象语法树可以渲染为 图 9-1 所示,展示了 Python assign 命令的结构。

图 9-1:Python 中赋值命令的 AST
AST 总是从根元素开始,通常是一个 _ast.Module 对象。这个模块对象包含一个要在其 body 属性中评估的语句或表达式列表,通常表示文件的内容。
正如你可能猜到的,图 9-1 中展示的 ast.Assign 对象表示一个 赋值,它映射到 Python 语法中的 = 符号。ast.Assign 对象有一个 targets 列表和一个 value,用于将目标设置为相应的值。在这个例子中,目标列表包含一个对象 ast.Name,它表示一个变量,其 ID 是 x。值是一个数字 n,它的值为(在此例中)42。ctx 属性存储一个 context,它可以是 ast.Store 或 ast.Load,取决于变量是用于读取还是写入。在此例中,变量正在被赋值,因此使用了 ast.Store 上下文。
我们可以将这个 AST 传递给 Python,通过内置的 compile() 函数进行编译和求值。该函数接受一个 AST 作为参数、源文件名以及模式(可以是 'exec'、'eval' 或 'single')。源文件名可以是任何你希望给 AST 起的名字;如果数据不是来自存储的文件,通常会使用字符串 <input> 作为源文件名,如 Listing 9-2 所示。
>>> compile(ast.parse("x = 42"), '<input>', 'exec')
<code object <module> at 0x111b3b0, file "<input>", line 1>
>>> eval(compile(ast.parse("x = 42"), '<input>', 'exec'))
>>> x
42
Listing 9-2: 使用 compile() 函数编译来自非存储文件的数据
模式分别代表执行(exec)、求值(eval)和单个语句(single)。模式应与传递给 ast.parse() 的内容匹配,默认模式是 exec。
-
exec模式是正常的 Python 模式,当 _ast.Module是树的根时使用此模式。 -
eval模式是一种特殊模式,期望树的根是单个ast.Expression。 -
最后,
single是另一种特殊模式,它期望一个单一的语句或表达式。如果它得到一个表达式,sys.displayhook()会被调用并显示结果,就像在交互式命令行中执行代码一样。
AST 的根是 ast.Interactive,其 body 属性是节点的列表。
我们可以使用 ast 模块提供的类手动构建 AST。显然,这是一种非常繁琐的编写 Python 代码的方式,并不是我推荐的方法!尽管如此,这样做很有趣,而且对学习 AST 有帮助。让我们看看使用 AST 编程会是什么样子。
使用 AST 编写程序
让我们通过手动构建抽象语法树(AST)来写一个经典的 "Hello world!" Python 程序。
➊ >>> hello_world = ast.Str(s='hello world!', lineno=1, col_offset=1)
➋ >>> print_name = ast.Name(id='print', ctx=ast.Load(), lineno=1, col_offset=1)
➌ >>> print_call = ast.Call(func=print_name, ctx=ast.Load(),
... args=[hello_world], keywords=[], lineno=1, col_offset=1)
➍ >>> module = ast.Module(body=[ast.Expr(print_call, ... lineno=1, col_offset=1)], lineno=1, col_offset=1)
➎ >>> code = compile(module, '', 'exec')
>>> eval(code)
hello world!
Listing 9-3: 使用 AST 编写 hello world!
在 Listing 9-3 中,我们一次构建一个叶子,每个叶子都是程序的一个元素(无论是值还是指令)。
第一个叶子是一个简单的字符串 ➊:ast.Str 代表一个字面量字符串,这里包含了 hello world! 文本。print_name 变量 ➋ 包含一个 ast.Name 对象,它指向一个变量——在此案例中,指向 print 变量,该变量指向 print() 函数。
print_call 变量 ➌ 包含一个函数调用。它指向要调用的函数名、传递给函数调用的常规参数和关键字参数。使用哪些参数取决于调用的函数。在这种情况下,由于它是 print() 函数,我们将传递我们制作并存储在 hello_world 中的字符串。
最后,我们创建一个 _ast.Module 对象 ➍ 来将所有这些代码作为一个表达式的列表。我们可以使用 compile() 函数 ➎ 编译 _ast.Module 对象,该函数会解析树并生成一个本地的 code 对象。这些 code 对象是编译后的 Python 代码,最终可以通过 Python 虚拟机使用 eval 执行!
整个过程正是你运行 Python 时在.py文件上发生的事情:一旦文本令牌被解析,它们会转换成ast对象的树,然后被编译并执行。
注意
参数 lineno 和 col_offset 分别表示用于生成 AST 的源代码的行号和列偏移。由于我们没有解析源文件,因此在此上下文中设置这些值没有太大意义,但能够找到生成 AST 的代码的位置是有用的。例如,Python 在生成回溯信息时会使用这些信息。事实上,Python 会拒绝编译不提供这些信息的 AST 对象,因此我们向这些值传递假数据。你还可以使用ast.fix_missing_locations()函数将缺失的值设置为父节点上设置的值。
AST 对象
你可以通过阅读_ast模块的文档(注意下划线)查看 AST 中所有可用的对象列表。
这些对象分为两大类:语句和表达式。语句包括assert、赋值(=)、增量赋值(+=、/=等)、global、def、if、return、for、class、pass、import、raise等类型。语句继承自ast.stmt;它们影响程序的控制流,并且通常由表达式组成。
表达式包括lambda、number、yield、name(变量)、compare和call等类型。表达式继承自ast.expr;它们与语句不同,通常会产生一个值并且不会影响程序的流程。
还有一些更小的类别,如ast.operator类,它定义了标准的运算符,如加法 (+)、除法 (/)和右移 (>>),以及ast.cmpop模块,它定义了比较运算符。
这里的简单示例应该能让你了解如何从零开始构建 AST。然后,你可以很容易地想象如何利用这个 AST 构建一个解析字符串并生成代码的编译器,从而实现你自己的 Python 语法!这正是促使 Hy 项目发展的原因,稍后我们将在本章中讨论它。
遍历 AST
要跟踪树的构建过程或访问特定节点,你有时需要遍历树,浏览并迭代节点。你可以使用ast.walk()函数来完成这项工作。或者,ast模块还提供了NodeTransformer类,你可以通过继承它来遍历 AST 并修改特定节点。使用NodeTransformer可以轻松地动态改变代码,如示例 9-4 所示。
import ast
class ReplaceBinOp(ast.NodeTransformer):
"""Replace operation by addition in binary operation"""
def visit_BinOp(self, node):
return ast.BinOp(left=node.left,
op=ast.Add(),
right=node.right)
➊ tree = ast.parse("x = 1/3")
ast.fix_missing_locations(tree)
eval(compile(tree, '', 'exec'))
print(ast.dump(tree))
➋ print(x)
➌ tree = ReplaceBinOp().visit(tree)
ast.fix_missing_locations(tree)
print(ast.dump(tree))
eval(compile(tree, '', 'exec'))
➍ print(x)
示例 9-4:使用 NodeTransformer 遍历树以更改节点
第一个tree对象➊是一个抽象语法树(AST),它表示表达式x = 1/3。一旦这段代码被编译和执行,函数结束时打印x的结果➋是0.33333,即1/3的预期结果。
第二个tree对象➌是ReplaceBinOp的一个实例,它继承自ast.NodeTransformer。它实现了自己版本的ast.NodeTransformer.visit()方法,并将任何ast.BinOp操作改为执行ast.Add的ast.BinOp。具体来说,这将任何二元操作符(+、-、/等)替换为+操作符。当第二棵树被编译和执行后➍,结果现在是4,这是1 + 3的结果,因为第一个对象中的/被替换成了+。
你可以在这里看到程序的执行:
Module(body=[Assign(targets=[Name(id='x', ctx=Store())],
value=BinOp(left=Num(n=1), op=Div(), right=Num(n=3)))])
0.3333333333333333
Module(body=[Assign(targets=[Name(id='x', ctx=Store())],
value=BinOp(left=Num(n=1), op=Add(), right=Num(n=3)))])
4
注意
如果你需要评估一个应返回简单数据类型的字符串,可以使用ast.literal_eval。作为eval的更安全替代,它防止输入的字符串执行任何代码。
通过 AST 检查扩展 flake8
在第七章中,你学到了不依赖于对象状态的方法应当使用@staticmethod装饰器声明为静态方法。问题在于,很多开发者常常忘记这么做。我个人也花了太多时间审查代码并要求别人修复这个问题。
我们已经看到如何使用flake8进行一些自动代码检查。实际上,flake8是可扩展的,可以提供更多的检查。我们将编写一个flake8扩展,利用 AST 检查是否省略了静态方法声明。
清单 9-5 展示了一个省略静态声明的类和一个正确包含静态声明的类。将此程序写出并保存为ast_ext.py;我们稍后将在其中编写扩展程序。
class Bad(object):
# self is not used, the method does not need
# to be bound, it should be declared static
def foo(self, a, b, c):
return a + b - c
class OK(object):
# This is correct
@staticmethod
def foo(a, b, c):
return a + b - c
清单 9-5:省略和包含@staticmethod
尽管Bad.foo方法可以正常工作,但严格来说,写成OK.foo更为正确(有关原因,请返回查看第七章)。为了检查 Python 文件中的所有方法是否都已正确声明,我们需要执行以下操作:
-
遍历 AST 的所有语句节点。
-
检查语句是否是类定义(
ast.ClassDef)。 -
遍历该类语句的所有函数定义(
ast.FunctionDef),检查它们是否已用@staticmethod声明。 -
如果方法没有声明为静态方法,检查方法中是否使用了第一个参数(
self)。如果self未被使用,则该方法可能被标记为写错了。
我们项目的名称将是ast_ext。为了在flake8中注册一个新插件,我们需要创建一个包含常规setup.py和setup.cfg文件的打包项目。然后,我们只需要在ast_ext项目的setup.cfg中添加一个入口点。
[entry_points]
flake8.extension =
--snip--
H904 = ast_ext:StaticmethodChecker
H905 = ast_ext:StaticmethodChecker
清单 9-6:允许 flake8 插件用于我们的章节
在 清单 9-6 中,我们还注册了两个 flake8 错误代码。正如你稍后会注意到的,我们在此过程中实际上会为我们的代码添加一个额外的检查!
下一步是编写插件。
编写类
由于我们正在编写一个针对 AST 的 flake8 检查,插件需要是一个遵循特定签名的类,如 清单 9-7 所示。
class StaticmethodChecker(object):
def __init__(self, tree, filename):
self.tree = tree
def run(self):
pass
清单 9-7:用于检查 AST 的类
默认模板很容易理解:它将树本地存储以便在 run() 方法中使用,该方法将 生成 发现的问题。生成的值必须遵循预期的 PEP 8 签名:一个元组,形式为 (lineno, col_offset, error_string, code)`。
忽略无关的代码
正如前面所述,ast 模块提供了 walk() 函数,允许你轻松地遍历树。我们将使用它来遍历 AST,找出需要检查的内容和不需要检查的内容。
首先,让我们编写一个循环,忽略那些不是类定义的语句。将此代码添加到你的 ast_ext 项目中,如 清单 9-8 所示;应该保持不变的代码会被灰色标出。
class StaticmethodChecker(object):
def __init__(self, tree, filename):
self.tree = tree
def run(self):
for stmt in ast.walk(self.tree):
# Ignore non-class
if not isinstance(stmt, ast.ClassDef):
continue
清单 9-8:忽略不是类定义的语句
清单 9-8 中的代码仍然没有进行任何检查,但现在它知道如何忽略不是类定义的语句。下一步是将我们的检查器设置为忽略任何不是函数定义的语句。
for stmt in ast.walk(self.tree):
# Ignore non-class
if not isinstance(stmt, ast.ClassDef):
continue
# If it's a class, iterate over its body member to find methods
for body_item in stmt.body:
# Not a method, skip
if not isinstance(body_item, ast.FunctionDef):
continue
清单 9-9:忽略不是函数定义的语句
在 清单 9-9 中,我们通过遍历类定义的属性来忽略无关的语句。
检查正确的装饰器
我们已准备好编写检查方法,该方法存储在 body_item 属性中。首先,我们需要检查被检查的方法是否已经声明为静态方法。如果是,则不需要进一步检查,可以直接退出。
for stmt in ast.walk(self.tree):
# Ignore non-class
if not isinstance(stmt, ast.ClassDef):
continue
# If it's a class, iterate over its body member to find methods
for body_item in stmt.body:
# Not a method, skip
if not isinstance(body_item, ast.FunctionDef):
continue
# Check that it has a decorator
for decorator in body_item.decorator_list:
if (isinstance(decorator, ast.Name)
and decorator.id == 'staticmethod'):
# It's a static function, it's OK
break else:
# Function is not static, we do nothing for now
Pass
清单 9-10:检查静态装饰器
请注意,在 清单 9-10 中,我们使用 Python 的特殊 for/else 形式,其中 else 会被评估,除非我们使用 break 退出 for 循环。到目前为止,我们已经能够检测方法是否被声明为静态方法。
寻找自我
下一步是检查没有声明为静态方法的方法是否使用了 self 参数。首先,检查方法是否包含任何参数,如 清单 9-11 所示。
--snip--
# Check that it has a decorator
for decorator in body_item.decorator_list:
if (isinstance(decorator, ast.Name)
and decorator.id == 'staticmethod'):
# It's a static function, it's OK
break
else:
try:
first_arg = body_item.args.args[0]
except IndexError:
yield (
body_item.lineno,
body_item.col_offset,
"H905: method misses first argument",
"H905",
)
# Check next method
Continue
清单 9-11:检查方法的参数
我们终于添加了一个检查!清单 9-11 中的try语句会从方法签名中获取第一个参数。如果代码无法从签名中获取第一个参数,因为没有第一个参数,我们就知道出现了问题:没有self参数,就不能有绑定方法。如果插件检测到这种情况,它会引发我们之前设置的H905错误代码,表示方法缺少第一个参数。
注意
PEP 8 错误代码遵循特定格式(字母后跟数字),但并没有规定应该选择哪个代码。你可以为这个错误创建任何其他代码,只要它没有被 PEP 8 或其他扩展使用。
现在你知道为什么我们在setup.cfg中注册了两个错误代码:我们有一个很好的机会一箭双雕。
下一步是检查方法代码中是否使用了self参数。
--snip--
try:
first_arg = body_item.args.args[0]
except IndexError:
yield (
body_item.lineno,
body_item.col_offset,
"H905: method misses first argument",
"H905",
)
# Check next method
continue
for func_stmt in ast.walk(body_item):
# The checking method must differ between Python 2 and Python 3
if six.PY3:
if (isinstance(func_stmt, ast.Name)
and first_arg.arg == func_stmt.id):
# The first argument is used, it's OK
break
else:
if (func_stmt != first_arg
and isinstance(func_stmt, ast.Name)
and func_stmt.id == first_arg.id):
# The first argument is used, it's OK
break
else:
yield (
body_item.lineno,
body_item.col_offset,
"H904: method should be declared static",
"H904",
)
清单 9-12:检查方法中的 self 参数
为了检查方法体中是否使用了self参数,清单 9-12 中的插件会递归地使用ast.walk遍历方法体,查找名为self的变量。如果未找到该变量,程序最终会返回H904错误代码。否则,什么都不会发生,代码被认为是有效的。
注意
正如你可能已经注意到的,代码多次遍历模块的 AST 定义。虽然可能有一定程度的优化空间,可以只遍历一次 AST,但考虑到工具的实际使用方式,我不确定这样做是否值得。我把这个练习留给你,亲爱的读者。
了解 Python AST 并非使用 Python 的必需知识,但它确实能提供关于语言构建和工作原理的强大洞察力。它让你更好地理解你写的代码是如何在幕后被使用的。
Hy 简介
现在你已经对 Python AST 的工作原理有了很好的理解,你可以开始构思为 Python 创建一种新的语法。你可以解析这种新语法,构建出一个 AST,并将其编译成 Python 代码。
这正是 Hy 所做的。Hy是一个 Lisp 方言,它解析类似 Lisp 的语言并将其转换为常规的 Python AST,使其与 Python 生态系统完全兼容。你可以把它与 Clojure 对 Java 的作用进行比较。Hy 本身可以填满一本书,所以我们只会略过它。Hy 使用 Lisp 家族语言的语法和一些特性:它是面向函数的,提供宏,并且易于扩展。
如果你还不熟悉 Lisp——你应该了解一下——Hy 的语法会显得很熟悉。一旦你安装了 Hy(通过运行pip install hy),启动hy解释器会给你一个标准的 REPL 提示符,你可以从这里开始与解释器进行交互,如清单 9-13 所示。
% hy
hy 0.9.10
=> (+ 1 2)
3
清单 9-13:与 Hy 解释器交互
对于那些不熟悉 Lisp 语法的人,括号用于构造列表。如果列表没有加引号,它会被求值:第一个元素必须是一个函数,列表中的其余项作为参数传递。在这里,代码(+ 1 2)相当于 Python 中的1 + 2。
在 Hy 中,大多数构造(如函数定义)都是直接从 Python 映射而来的。
=> (defn hello [name]
... (print "Hello world!")
... (print (% "Nice to meet you %s" name)))
=> (hello "jd")
Hello world!
Nice to meet you jd
示例 9-14:从 Python 映射函数定义
如示例 9-14 所示,Hy 内部解析提供的代码,将其转换为 Python 的 AST,进行编译并执行。幸运的是,Lisp 是一种易于解析的树结构:每一对括号代表树的一个节点,这意味着转换实际上比原生 Python 语法要容易!
类定义通过defclass构造函数来支持,这一构造灵感来源于通用 Lisp 对象系统(CLOS)。
(defclass A [object]
[[x 42] [y (fn [self value]
(+ self.x value))]])
示例 9-15:使用 defclass 定义类
示例 9-15 定义了一个名为 A 的类,它继承自 object,并且有一个类属性 x,其值为 42;接着,方法 y 返回 x 属性加上作为参数传递的值。
真正令人惊叹的是,你可以直接将 任何 Python 库 导入到 Hy 中,并且毫无性能损失地使用它。使用import()函数导入模块,如示例 9-16 所示,就像在常规 Python 中一样。
=> (import uuid)
=> (uuid.uuid4)
UUID('f823a749-a65a-4a62-b853-2687c69d0e1e')
=> (str (uuid.uuid4))
'4efa60f2-23a4-4fc1-8134-00f5c271f809'
示例 9-16:导入常规 Python 模块
Hy 还具有更高级的构造和宏。在示例 9-17 中,看看cond()函数是如何替代经典而冗长的if/elif/else的。
(cond
[(> somevar 50)
(print "That variable is too big!")]
[(< somevar 10)
(print "That variable is too small!")]
[true
(print "That variable is jusssst right!")])
示例 9-17:使用 cond 替代 if/elif/else
cond 宏具有以下签名:(cond [condition_expression return_expression] ...)。每个条件表达式都从第一个开始被求值:一旦某个条件表达式返回了一个真值,对应的返回表达式就会被求值并返回。如果没有提供返回表达式,则返回条件表达式的值。因此,cond 相当于 if/elif 结构,不同之处在于它可以返回条件表达式的值,而不必进行两次求值或存储在临时变量中!
Hy 让你在不离开舒适区的情况下进入 Lisp 世界,因为你依然在编写 Python 代码。hy2py 工具甚至可以向你展示你的 Hy 代码在转换为 Python 后的样子。虽然 Hy 使用不广泛,但它是一个展示 Python 语言潜力的好工具。如果你有兴趣了解更多,建议你查看在线文档并加入社区。
总结
就像其他任何编程语言一样,Python 源代码可以使用抽象语法树表示。你很少会直接使用 AST,但当你了解它是如何工作的时,它能为你提供有益的视角。
Paul Tagliamonte 论 AST 和 Hy
Paul 在 2013 年创建了 Hy,作为一个 Lisp 爱好者,我加入了他一起踏上了这段奇妙的冒险。Paul 目前是 Sunlight Foundation 的一名开发者。
你是如何正确使用 AST 的?对于想要了解它的人,你有什么建议吗?
AST 的文档非常不足,所以大多数知识来自于被逆向工程生成的 AST。通过编写简单的 Python 脚本,像 import ast; ast.dump(ast.parse("print foo")) 这样的方法可以生成一个等效的 AST 来帮助完成任务。凭借一些猜测和一定的坚持,通过这种方式建立一个基础的理解是完全可以做到的。
在某个时候,我会承担起记录我对 AST 模块理解的任务,但我发现写代码是学习 AST 的最好方式。
Python 的 AST 在不同版本和用途中有什么区别?
Python 的 AST 不是私有的,但它也不是一个公共接口。不同版本之间没有稳定性保证——实际上,Python 2 和 3 之间以及不同的 Python 3 版本之间都有一些相当烦人的差异。此外,不同的实现可能会以不同的方式解读 AST,甚至可能拥有独特的 AST。没有任何规定 Jython、PyPy 或 CPython 必须以相同的方式处理 Python AST。
比如,CPython 可以处理略微乱序的 AST 条目(通过 lineno 和 col_offset),而 PyPy 会抛出断言错误。尽管有时会让人烦恼,AST 通常还是合理的。构建一个能在大量 Python 实例上工作的 AST 并非不可能。通过几个条件判断,创建一个能在 CPython 2.6 到 3.3 和 PyPy 上都能工作的 AST 也并不会太麻烦,因此这个工具相当方便。
你创建 Hy 的过程是什么样的?
我在一次关于如果有一个编译到 Python 而不是 Java 的 JVM(类似 Clojure) Lisp 的对话后,开始了 Hy 项目的开发。几天之后,我就有了 Hy 的第一个版本。这个版本看起来像一个 Lisp,甚至在某些方面像一个真正的 Lisp,但它非常慢。我的意思是,真的非常慢。它的运行速度比原生 Python 慢了大约一个数量级,因为 Lisp 运行时本身是用 Python 实现的。
我感到沮丧,几乎想要放弃,但这时一个同事建议使用 AST 来实现运行时,而不是直接在 Python 中实现运行时。这个建议成为了整个项目的催化剂。我在 2012 年的整个假期里都在狂热地编写 Hy 代码。大约一周后,我有了一个类似当前 Hy 代码库的东西。
在将 Hy 开发到足以实现一个基本的 Flask 应用后,我在波士顿的 Python 会议上讲解了这个项目,受到了热烈的欢迎——如此热烈,实际上让我开始把 Hy 看作是一个很好的工具,可以帮助人们了解 Python 的内部机制,比如 REPL 的工作原理、PEP 302 的导入钩子和 Python 的 AST。这是代码生成代码概念的一个很好的介绍。
我重写了编译器的部分代码,以解决过程中一些哲学上的问题,从而得到了当前版本的代码库——它表现得相当稳健!
学习 Hy 也是理解如何阅读 Lisp 的好方法。用户可以在他们熟悉的环境中,利用已经在用的库,舒适地学习 s 表达式,从而平滑过渡到其他 Lisp,如 Common Lisp、Scheme 或 Clojure。
Hy 与 Python 的互操作性如何?
Hy 的互操作性非常强。强到 pdb 可以在不做任何修改的情况下正确调试 Hy。我已经用 Hy 写过 Flask 应用、Django 应用以及各种模块。Python 可以导入 Python,Hy 可以导入 Hy,Hy 可以导入 Python,Python 也可以导入 Hy。这才是 Hy 真正与众不同的地方;其他 Lisp 变种,比如 Clojure,都是单向的。Clojure 可以导入 Java,但 Java 却很难导入 Clojure。
Hy 的工作方式是将 Hy 代码(以 s 表达式的形式)几乎直接转换为 Python 的抽象语法树(AST)。这个编译步骤意味着生成的字节码相当合理,这也使得 Python 很难判断该模块根本不是用 Python 编写的。
常见的 Lisp 风格,比如 *earmuffs* 或 using-dashes,通过将其转换为 Python 等价物(在这个例子中,*earmuffs* 转变为 EARMUFFS,using-dashes 转变为 using_dashes)得到了完全支持,这意味着 Python 根本不会对它们使用有任何困难。
确保我们有非常好的互操作性是我们最优先的任务之一,所以如果你发现任何 bug——请提交!
选择 Hy 的优缺点是什么?
Hy 的一个优势是它拥有完整的宏系统,而这是 Python 所难以实现的。宏是特殊的函数,在编译步骤中修改代码。这使得创建新的领域特定语言变得容易,这些语言由基础语言(在本例中为 Hy/Python)和许多宏组成,宏可以使代码更具表现力和简洁性。
至于缺点,Hy 作为一个以 s 表达式书写的 Lisp,承受着学习、阅读或维护困难的偏见。人们可能会因为其复杂性而不愿参与使用 Hy 的项目。
Hy 是每个人都爱恨交加的 Lisp。Python 使用者可能不喜欢它的语法,而 Lisp 使用者可能会回避它,因为 Hy 直接使用 Python 对象,这意味着一些基础对象的行为对于经验丰富的 Lisp 用户来说有时会让人感到意外。
希望人们能超越它的语法,考虑探索 Python 之前未曾触及的部分。
第十章:性能与优化

优化通常不是开发过程中最先考虑的事情,但总有那么一刻,优化以提高性能会变得合适。这并不是说你应该以程序会慢为前提来编写程序,而是在没有首先弄清楚应该使用哪些工具并进行适当的性能分析的情况下考虑优化,是浪费时间。正如唐纳德·克努斯所写的,“过早的优化是万恶之源。”^(1)
在这里,我将向你展示如何使用正确的方法编写快速的代码,以及在需要更多优化时该从哪里着手。许多开发者试图猜测 Python 可能在哪些地方更慢或更快。与其猜测,本章将帮助你理解如何分析你的应用程序,以便你知道程序的哪些部分在拖慢速度,瓶颈在哪里。
数据结构
大多数编程问题可以通过正确的数据结构以简洁优雅的方式解决——Python 提供了许多数据结构供你选择。学会利用这些现有的数据结构,能比编写自定义数据结构提供更简洁、更稳定的解决方案。
例如,每个人都会使用dict,但是你多少次见到代码试图通过捕获KeyError异常来访问字典,如下所示:
def get_fruits(basket, fruit):
try:
return basket[fruit]
except KeyError:
return None
或者通过先检查键是否存在:
def get_fruits(basket, fruit):
if fruit in basket:
return basket[fruit]
如果你使用dict类已经提供的get()方法,你可以避免捕获异常或在一开始就检查键是否存在:
def get_fruits(basket, fruit):
return basket.get(fruit)
dict.get()方法还可以返回一个默认值,而不是None;只需传入第二个参数:
def get_fruits(basket, fruit):
# Return the fruit, or Banana if the fruit cannot be found.
return basket.get(fruit, Banana())
许多开发者在使用基础的 Python 数据结构时,常常没有意识到它们提供的所有方法。集合也是如此;集合数据结构中的方法可以解决许多本来需要编写嵌套for/if块来处理的问题。例如,开发者常常使用for/if循环来判断某个项是否在列表中,如下所示:
def has_invalid_fields(fields):
for field in fields: if field not in ['foo', 'bar']:
return True
return False
这个循环遍历列表中的每一项,检查所有项是否是foo或bar。但是,你可以通过更高效的方式编写代码,避免使用循环:
def has_invalid_fields(fields):
return bool(set(fields) - set(['foo', 'bar']))
这段代码将字段转换为集合,并通过从set(['foo', 'bar'])中减去其差集来获取其余部分。然后,它将集合转换为布尔值,表示是否剩下任何非foo和bar的项。通过使用集合,不再需要遍历任何列表逐一检查项。Python 内部对两个集合进行的单次操作要更快。
Python 还提供了更先进的数据结构,可以大大减轻代码维护的负担。例如,看看列表 10-1。
def add_animal_in_family(species, animal, family):
if family not in species:
species[family] = set()
species[family].add(animal)
species = {}
add_animal_in_family(species, 'cat', 'felidea')
列表 10-1:向集合字典中添加一个条目
这段代码是完全有效的,但你的程序会有多少次需要清单 10-1 的变种呢?几十次?几百次?
Python 提供了collections.defaultdict结构,它以优雅的方式解决了这个问题:
import collections
def add_animal_in_family(species, animal, family):
species[family].add(animal)
species = collections.defaultdict(set)
add_animal_in_family(species, 'cat', 'felidea')
每次你尝试从dict中访问一个不存在的项时,defaultdict会使用作为参数传递给其构造函数的函数来构建一个新值,而不是引发KeyError。在这种情况下,set()函数用于每次我们需要时构建一个新的set。
collections模块提供了几种额外的数据结构,可以用来解决其他类型的问题。例如,假设你想要统计一个可迭代对象中不同项的数量。让我们看一下collections.Counter()方法,它提供了解决这个问题的方法:
>>> import collections
>>> c = collections.Counter("Premature optimization is the root of all evil.")
>>> c
>>> c['P'] # Returns the name of occurrence of the letter 'P'
1
>>> c['e'] # Returns the name of occurrence of the letter 'e'
4
>>> c.most_common(2) # Returns the 2 most common letters
[(' ', 7), ('i', 5)]
collections.Counter对象适用于任何具有可哈希项的可迭代对象,免去了你编写自己的计数函数的需求。它可以轻松计算字符串中字母的数量,并返回可迭代对象中最常见的前n个项。如果你没有意识到 Python 标准库已经提供了这个功能,可能你会尝试自己实现类似的功能。
使用正确的数据结构、正确的方法,以及显然——足够的算法,你的程序应该表现良好。然而,如果它的表现不够好,获得有关程序在哪些地方可能变慢并需要优化的线索,最好的方法是对代码进行性能分析。
通过性能分析理解行为
性能分析是一种动态程序分析方法,它让我们了解程序的行为。它使我们能够确定哪些地方可能存在瓶颈并需要优化。程序的性能分析结果以一组统计数据的形式呈现,描述了程序各部分执行的频率和持续时间。
Python 提供了一些工具来分析程序的性能。其中一个是cProfile,它是 Python 标准库的一部分,不需要额外安装。我们还将查看dis模块,它可以将 Python 代码拆解成更小的部分,使我们更容易理解底层发生了什么。
cProfile
自 Python 2.5 起,Python 默认包括了cProfile。要使用cProfile,可以使用语法python –m cProfile <program>来调用它。这将加载并启用cProfile模块,然后按正常方式运行程序,并启用性能分析,如清单 10-2 所示。
$ python -m cProfile myscript.py
343 function calls (342 primitive calls) in 0.000 seconds
Ordered by: standard name ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.000 0.000 :0(_getframe)
1 0.000 0.000 0.000 0.000 :0(len)
104 0.000 0.000 0.000 0.000 :0(setattr)
1 0.000 0.000 0.000 0.000 :0(setprofile)
1 0.000 0.000 0.000 0.000 :0(startswith)
2/1 0.000 0.000 0.000 0.000 <string>:1(<module>)
1 0.000 0.000 0.000 0.000 StringIO.py:30(<module>)
1 0.000 0.000 0.000 0.000 StringIO.py:42(StringIO)
清单 10-2:使用 cProfile 对 Python 脚本进行的默认输出
清单 10-2 展示了使用cProfile运行一个简单脚本的输出。它告诉你程序中每个函数被调用的次数以及执行花费的时间。你还可以使用-s选项按其他字段排序;例如,-s time将按内部时间排序结果。
我们可以使用一个非常棒的工具叫做 KCacheGrind 来可视化 cProfile 生成的信息。这个工具最初是为处理 C 语言程序而设计的,但幸运的是,我们可以通过将数据转换为调用树,来将其用于 Python 数据。
cProfile 模块有一个 -o 选项,允许你保存分析数据,而 pyprof2calltree 可以将数据从一种格式转换为另一种格式。首先,使用以下命令安装转换器:
$ pip install pyprof2calltree
然后运行转换器,如清单 10-3 所示,既转换数据(-i 选项),又使用转换后的数据运行 KCacheGrind(-k 选项)。
$ python -m cProfile -o myscript.cprof myscript.py
$ pyprof2calltree -k -i myscript.cprof
清单 10-3:运行 cProfile 并启动 KCacheGrind
一旦 KCacheGrind 打开,它将显示类似于图 10-1 中的信息。通过这些可视化结果,你可以使用调用图来跟踪每个函数所消耗的时间百分比,从而确定程序中可能消耗过多资源的部分。
阅读 KCacheGrind 的最简单方法是从屏幕左侧的表格开始,表格列出了程序执行的所有函数和方法。你可以按执行时间对它们进行排序,然后找出消耗最多 CPU 时间的函数,点击它进行查看。
KCacheGrind 的右侧面板会显示调用该函数的其他函数以及调用的次数,还会展示该函数调用的其他函数。你可以轻松导航程序的调用图,包括每个部分的执行时间。
这样可以帮助你更好地理解哪些部分的代码可能需要优化。如何优化代码取决于你自己的选择,并且取决于你的程序需要实现的目标!

图 10-1:KCacheGrind 输出示例
虽然通过获取程序运行信息并进行可视化可以让你从宏观上了解程序,但你可能需要更细致地查看某些代码部分,以便更仔细地检查其元素。在这种情况下,我发现依赖 dis 模块来了解背后的运行机制是更好的选择。
使用 dis 模块进行反汇编
dis 模块是一个 Python 字节码反汇编器。将代码拆解开来可以帮助理解每一行背后的运行机制,从而更好地优化它。例如,清单 10-4 展示了 dis.dis() 函数,它反汇编传入的函数,并打印出该函数运行时的字节码指令列表。
>>> def x():
... return 42
...
>>> import dis
>>> dis.dis(x)
2 0 LOAD_CONST 1 (42)
3 RETURN_VALUE
清单 10-4:反汇编一个函数
在清单 10-4 中,函数 x 被反汇编,并打印出由字节码指令构成的组成部分。这里只有两个操作:加载常量(LOAD_CONST),它的值是 42,然后返回该值(RETURN_VALUE)。
为了看到dis的实际效果及其如何发挥作用,我们将定义两个执行相同操作的函数——连接三个字母——并对它们进行反汇编,以查看它们如何以不同方式完成任务:
abc = ('a', 'b', 'c')
def concat_a_1():
for letter in abc:
abc[0] + letter
def concat_a_2():
a = abc[0]
for letter in abc:
a + letter
这两个函数看起来做的事情相同,但如果我们使用dis.dis进行反汇编,正如在清单 10-5 中所示,我们会看到生成的字节码稍有不同。
>>> dis.dis(concat_a_1)
2 0 SETUP_LOOP 26 (to 29)
3 LOAD_GLOBAL 0 (abc)
6 GET_ITER
>> 7 FOR_ITER 18 (to 28)
10 STORE_FAST 0 (letter)
3 13 LOAD_GLOBAL 0 (abc)
16 LOAD_CONST 1 (0)
19 BINARY_SUBSCR
20 LOAD_FAST 0 (letter)
23 BINARY_ADD
24 POP_TOP
25 JUMP_ABSOLUTE 7
>> 28 POP_BLOCK
>> 29 LOAD_CONST 0 (None)
32 RETURN_VALUE
>>> dis.dis(concat_a_2)
2 0 LOAD_GLOBAL 0 (abc)
3 LOAD_CONST 1 (0)
6 BINARY_SUBSCR
7 STORE_FAST 0 (a)
3 10 SETUP_LOOP 22 (to 35)
13 LOAD_GLOBAL 0 (abc)
16 GET_ITER
>> 17 FOR_ITER 14 (to 34)
20 STORE_FAST 1 (letter)
4 23 LOAD_FAST 0 (a)
26 LOAD_FAST 1 (letter)
29 BINARY_ADD
30 POP_TOP
31 JUMP_ABSOLUTE 17
>> 34 POP_BLOCK >> 35 LOAD_CONST 0 (None)
38 RETURN_VALUE
清单 10-5:反汇编连接字符串的函数
在清单 10-5 中的第二个函数中,我们在运行循环之前将abc[0]存储在一个临时变量中。这使得在循环内执行的字节码比第一个函数的字节码稍微小一些,因为我们避免了在每次迭代中都要执行abc[0]查找。使用timeit测试后,第二个版本比第一个函数快 10%,执行时间少了一整微秒!显然,除非你调用这个函数达到数十亿次,否则这个微秒的优化并不值得,但这正是dis模块可以提供的洞察。
是否依赖于像在循环外存储值这样的“技巧”取决于具体情况——最终,应该由编译器来优化这种事情。另一方面,由于 Python 强烈依赖动态特性,编译器很难确定优化不会带来负面副作用。在清单 10-5 中,使用abc[0]会调用abc.__getitem__,如果它被继承覆盖,可能会产生副作用。根据你使用的函数版本,abc.__getitem__方法可能会被调用一次或多次,这可能会有所不同。因此,在编写和优化代码时要小心!
高效定义函数
在审查代码时,我发现的一个常见错误是函数内部定义函数。这是低效的,因为函数会被重复和无谓地重新定义。例如,清单 10-6 展示了y()函数被多次定义。
>> import dis
>>> def x():
... return 42
...
>>> dis.dis(x)
2 0 LOAD_CONST 1 (42)
3 RETURN_VALUE
>>> def x():
... def y():
... return 42
... return y()
...
>>> dis.dis(x)
2 0 LOAD_CONST 1 (<code object y at
x100ce7e30, file "<stdin>", line 2>)
3 MAKE_FUNCTION 0
6 STORE_FAST 0 (y)
4 9 LOAD_FAST 0 (y) 12 CALL_FUNCTION 0
15 RETURN_VALUE
清单 10-6:函数重定义
清单 10-6 展示了MAKE_FUNCTION、STORE_FAST、LOAD_FAST和CALL_FUNCTION的调用,它们所需的操作码比清单 10-4 中返回42所需的要多得多。
你唯一需要在函数内定义函数的情况是构建函数闭包,这是 Python 的操作码中一个明确的使用场景,通过LOAD_CLOSURE可以看到,正如在清单 10-7 中所示。
>>> def x():
... a = 42
... def y():
... return a
... return y()
...
>>> dis.dis(x)
2 0 LOAD_CONST 1 (42)
3 STORE_DEREF 0 (a)
3 6 LOAD_CLOSURE 0 (a)
9 BUILD_TUPLE 1
12 LOAD_CONST 2 (<code object y at
x100d139b0, file "<stdin>", line 3>)
15 MAKE_CLOSURE 0
18 STORE_FAST 0 (y)
5 21 LOAD_FAST 0 (y)
24 CALL_FUNCTION 0
27 RETURN_VALUE
清单 10-7:定义一个闭包
虽然你可能不会每天都用到它,但反汇编代码是一个非常有用的工具,当你想要更仔细地了解内部发生了什么时。
有序列表与二分法
接下来,我们来看看如何优化列表。如果一个列表是无序的,查找某一特定项在列表中的位置的最坏情况复杂度为O(n),这意味着在最坏的情况下,你需要遍历列表中的每一项才能找到目标项。
优化这个问题的常用解决方案是改用排序列表。排序列表使用二分查找算法来实现查找,时间复杂度为O(log n)。其思想是递归地将列表一分为二,看看项应该出现在左侧还是右侧,然后决定下一步应该查找哪一侧。
Python 提供了bisect模块,其中包含了一个二分查找算法,如清单 10-8 所示。
>>> farm = sorted(['haystack', 'needle', 'cow', 'pig'])
>>> bisect.bisect(farm, 'needle')
3
>>> bisect.bisect_left(farm, 'needle')
2
>>> bisect.bisect(farm, 'chicken')
0
>>> bisect.bisect_left(farm, 'chicken')
0
>>> bisect.bisect(farm, 'eggs')
1
>>> bisect.bisect_left(farm, 'eggs')
1
清单 10-8:使用 bisect 查找大海捞针
如清单 10-8 所示,bisect.bisect()函数返回一个元素应该插入的位置,以保持列表的有序。显然,这只有在列表已经正确排序的情况下才有效。初始排序允许我们获得一个项的理论索引:bisect()并不会返回该项是否在列表中,而是返回该项如果在列表中应该位于的位置。通过在此索引处检索项,我们可以回答该项是否存在于列表中的问题。
如果你希望立即将元素插入到正确的排序位置,bisect模块提供了insort_left()和insort_right()函数,如清单 10-9 所示。
>>> farm
['cow', 'haystack', 'needle', 'pig']
>>> bisect.insort(farm, 'eggs')
>>> farm
['cow', 'eggs', 'haystack', 'needle', 'pig']
>>> bisect.insort(farm, 'turkey')
>>> farm
['cow', 'eggs', 'haystack', 'needle', 'pig', 'turkey']
清单 10-9:在排序列表中插入一个项
使用bisect模块,你还可以创建一个特殊的SortedList类,继承自list,来创建一个始终保持排序的列表,如清单 10-10 所示:
import bisect
import unittest
class SortedList(list):
def __init__(self, iterable):
super(SortedList, self).__init__(sorted(iterable))
def insort(self, item):
bisect.insort(self, item) def extend(self, other):
for item in other:
self.insort(item)
@staticmethod
def append(o):
raise RuntimeError("Cannot append to a sorted list")
def index(self, value, start=None, stop=None):
place = bisect.bisect_left(self[start:stop], value)
if start:
place += start
end = stop or len(self)
if place < end and self[place] == value:
return place
raise ValueError("%s is not in list" % value)
class TestSortedList(unittest.TestCase):
def setUp(self):
self.mylist = SortedList(
['a', 'c', 'd', 'x', 'f', 'g', 'w']
)
def test_sorted_init(self):
self.assertEqual(sorted(['a', 'c', 'd', 'x', 'f', 'g', 'w']),
self.mylist)
def test_sorted_insort(self):
self.mylist.insort('z')
self.assertEqual(['a', 'c', 'd', 'f', 'g', 'w', 'x', 'z'],
self.mylist)
self.mylist.insort('b')
self.assertEqual(['a', 'b', 'c', 'd', 'f', 'g', 'w', 'x', 'z'],
self.mylist)
def test_index(self):
self.assertEqual(0, self.mylist.index('a'))
self.assertEqual(1, self.mylist.index('c'))
self.assertEqual(5, self.mylist.index('w'))
self.assertEqual(0, self.mylist.index('a', stop=0))
self.assertEqual(0, self.mylist.index('a', stop=2))
self.assertEqual(0, self.mylist.index('a', stop=20))
self.assertRaises(ValueError, self.mylist.index, 'w', stop=3)
self.assertRaises(ValueError, self.mylist.index, 'a', start=3)
self.assertRaises(ValueError, self.mylist.index, 'a', start=333)
def test_extend(self):
self.mylist.extend(['b', 'h', 'j', 'c'])
self.assertEqual(
['a', 'b', 'c', 'c', 'd', 'f', 'g', 'h', 'j', 'w', 'x']
self.mylist)
清单 10-10:一个 SortedList 对象的实现
使用像这样的list类,在插入项时稍微慢一些,因为程序需要寻找插入的正确位置。然而,这个类在使用index()方法时比其父类更快。显然,不应该在这个类上使用list.append()方法:你不能在列表的末尾添加项,否则它可能会变得无序!
许多 Python 库为更多的数据类型实现了多种版本的清单 10-10,例如二叉树或红黑树结构。blist和bintree Python 包包含可用于这些目的的代码,是实现和调试自己版本的便捷替代方案。
在接下来的章节中,我们将看到如何利用 Python 提供的原生元组数据类型,使你的 Python 代码变得更快。
namedtuple 和 Slots
在编程中,通常需要创建一些简单的对象,它们只有少数几个固定的属性。一个简单的实现可能是这样的:
class Point(object):
def __init__(self, x, y):
self.x = x
self.y = y
这肯定能完成任务。然而,这种方法也有一个缺点。在这里,我们创建了一个继承自对象类的类,因此通过使用这个Point类,你会实例化完整的对象并分配大量的内存。
在 Python 中,常规对象将其所有属性存储在一个字典中,而这个字典本身存储在__dict__属性中,如清单 10-11 所示。
>>> p = Point(1, 2)
>>> p.__dict__
{'y': 2, 'x': 1}
>>> p.z = 42
>>> p.z
42
>>> p.__dict__
{'y': 2, 'x': 1, 'z': 42}
清单 10-11:Python 对象中属性的内部存储方式
对于 Python 来说,使用dict的优势在于它允许你向对象添加任意数量的属性。缺点是,使用字典来存储这些属性在内存方面开销很大——你需要存储对象、键、值引用以及其他所有内容。这使得创建和操作变得缓慢,并且内存成本很高。
作为这种不必要内存使用的示例,请考虑以下简单的类:
class Foobar(object):
def __init__(self, x):
self.x = x
这创建了一个简单的Point对象,其中包含一个名为x的属性。让我们使用memory_profiler来检查这个类的内存使用情况,这是一个非常实用的 Python 包,允许我们逐行查看程序的内存使用情况,且有一个小脚本可以创建 100,000 个对象,如清单 10-12 所示。
$ python -m memory_profiler object.py
Filename: object.py
Line # Mem usage Increment Line Contents
5 @profile
6 9.879 MB 0.000 MB def main():
7 50.289 MB 40.410 MB f = [ Foobar(42) for i in range(100000) ]
清单 10-12:在使用对象的脚本中使用 memory_profiler
清单 10-12 演示了创建Foobar类的 100,000 个对象会消耗 40MB 的内存。虽然每个对象 400 字节可能不算大,但当你创建成千上万个对象时,内存消耗就会逐渐累积。
有一种方法可以避免使用dict的默认行为:Python 中的类可以定义一个__slots__属性,列出该类实例允许的属性。这样, instead of 分配整个字典对象来存储对象属性,你可以使用一个列表对象来存储它们。
如果你查看 CPython 的源代码并查看 Objects/typeobject.c 文件,理解当类上设置了__slots__时 Python 所做的事情会很容易。清单 10-13 是处理此功能的函数的简化版:
static PyObject *
type_new(PyTypeObject *metatype, PyObject *args, PyObject *kwds)
{
--snip--
/* Check for a __slots__ sequence variable in dict, and count it */
slots = _PyDict_GetItemId(dict, &PyId___slots__);
nslots = 0;
if (slots == NULL) {
if (may_add_dict)
add_dict++;
if (may_add_weak)
add_weak++;
} else {
/* Have slots */
/* Make it into a tuple */
if (PyUnicode_Check(slots))
slots = PyTuple_Pack(1, slots);
else
slots = PySequence_Tuple(slots);
/* Are slots allowed? */
nslots = PyTuple_GET_SIZE(slots);
if (nslots > 0 && base->tp_itemsize != 0) {
PyErr_Format(PyExc_TypeError,
"nonempty __slots__ "
"not supported for subtype of '%s'",
base->tp_name);
goto error;
}
/* Copy slots into a list, mangle names and sort them.
Sorted names are needed for __class__ assignment.
Convert them back to tuple at the end.
*/
newslots = PyList_New(nslots - add_dict - add_weak);
if (newslots == NULL)
goto error;
if (PyList_Sort(newslots) == -1) {
Py_DECREF(newslots);
goto error;
}
slots = PyList_AsTuple(newslots);
Py_DECREF(newslots);
if (slots == NULL)
goto error;
}
/* Allocate the type object */
type = (PyTypeObject *)metatype->tp_alloc(metatype, nslots);
--snip--
/* Keep name and slots alive in the extended type object */
et = (PyHeapTypeObject *)type;
Py_INCREF(name);
et->ht_name = name;
et->ht_slots = slots;
slots = NULL;
--snip--
return (PyObject *)type;
清单 10-13:来自 Objects/typeobject.c 的摘录
正如你在清单 10-13 中看到的,Python 会将__slots__的内容转换为元组,然后再转换为列表,之后它会构建并排序列表,然后再将列表转换回元组用于使用和存储在类中。通过这种方式,Python 能够快速检索值,而无需分配和使用整个字典。
声明并使用这样的类相当简单。你需要做的就是将__slots__属性设置为一个列出将要在类中定义的属性的列表:
class Foobar(object):
__slots__ = ('x',)
def __init__(self, x):
self.x = x
我们可以使用 memory_profiler Python 包来比较这两种方法的内存使用情况,如清单 10-14 所示。
% python -m memory_profiler slots.py
Filename: slots.py
Line # Mem usage Increment Line Contents
7 @profile
8 9.879 MB 0.000 MB def main():
9 21.609 MB 11.730 MB f = [ Foobar(42) for i in range(100000) ]
清单 10-14:在使用 slots 的脚本上运行 memory_profiler
清单 10-14 显示了这次创建 100,000 个对象所需的内存不到 12MB——每个对象少于 120 字节。因此,通过使用 Python 类的 __slots__ 属性,我们可以减少内存使用量,因此当我们创建大量简单对象时,__slots__ 属性是一个有效且高效的选择。然而,这种技巧不应当用于通过硬编码每个类的属性列表来执行静态类型检查:这样做不符合 Python 程序的精神。
这里的缺点是属性列表现在是固定的。在运行时,不能向 Foobar 类添加任何新属性。由于属性列表的固定特性,我们很容易想象出那些其属性始终有值且字段总是按某种方式排序的类。
这正是 collection 模块中的 namedtuple 类所实现的功能。这个 namedtuple 类允许我们动态创建一个类,该类将继承自元组类,从而共享诸如不可变和具有固定数量条目等特性。
与其通过索引引用它们,namedtuple 提供了通过引用命名属性来检索元组元素的能力。这使得元组对人类更易于访问,正如在 清单 10-15 中所示。
>>> import collections
>>> Foobar = collections.namedtuple('Foobar', ['x'])
>>> Foobar = collections.namedtuple('Foobar', ['x', 'y'])
>>> Foobar(42, 43) Foobar(x=42, y=43)
>>> Foobar(42, 43).x
42
>>> Foobar(42, 43).x = 44
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
>>> Foobar(42, 43).z = 0
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Foobar' object has no attribute 'z'
>>> list(Foobar(42, 43))
[42, 43]
清单 10-15:使用 namedtuple 来引用元组元素
清单 10-15 显示了如何用一行代码创建一个简单的类并实例化它。我们不能更改该类对象的任何属性或向其添加属性,既因为该类继承自 namedtuple,也因为 __slots__ 的值被设置为空元组,避免了 __dict__ 的创建。由于此类继承自元组,我们可以轻松地将其转换为列表。
清单 10-16 演示了 namedtuple 类工厂的内存使用情况。
% python -m memory_profiler namedtuple.py
Filename: namedtuple.py
Line # Mem usage Increment Line Contents
4 @profile
5 9.895 MB 0.000 MB def main():
6 23.184 MB 13.289 MB f = [ Foobar(42) for i in range(100000) ]
清单 10-16:使用 namedtuple 来运行 memory_profiler 对脚本进行分析
对于 100,000 个对象,内存大约是 13MB,使用 namedtuple 的效率略低于使用带 __slots__ 的对象,但其优势在于与元组类兼容。因此,它可以传递给许多期望可迭代对象作为参数的原生 Python 函数和库。namedtuple 类工厂还享受元组的各种优化:例如,包含少于 PyTuple_MAXSAVESIZE(默认值为 20)项的元组将在 CPython 中使用更快的内存分配器。
namedtuple类还提供了一些额外的方法,即使它们以下划线开头,实际上也打算公开使用。_asdict()方法可以将namedtuple转换为dict实例,_make()方法允许将现有的可迭代对象转换为此类,_replace()则返回一个新实例,并替换掉其中的一些字段。
命名元组是替代只包含少量属性且不需要自定义方法的小对象的绝佳选择—例如,可以考虑使用它们来替代字典。如果你的数据类型需要方法、具有固定的属性列表,并且可能会实例化成千上万次,那么使用__slots__创建一个自定义类可能是节省内存的好方法。
记忆化
记忆化是一种优化技术,通过缓存函数的结果来加速函数调用。只有当函数是纯净的(即没有副作用且不依赖任何全局状态)时,才可以缓存其结果。(有关纯函数的更多内容,请参见第八章。)
一个可以记忆化的简单函数是sin(),如列表 10-17 所示。
>>> import math
>>> _SIN_MEMOIZED_VALUES = {}
>>> def memoized_sin(x):
... if x not in _SIN_MEMOIZED_VALUES:
... _SIN_MEMOIZED_VALUES[x] = math.sin(x)
... return _SIN_MEMOIZED_VALUES[x]
>>> memoized_sin(1)
0.8414709848078965
>>> _SIN_MEMOIZED_VALUES
{1: 0.8414709848078965}
>>> memoized_sin(2)
0.9092974268256817
>>> memoized_sin(2)
0.9092974268256817
>>> _SIN_MEMOIZED_VALUES
{1: 0.8414709848078965, 2: 0.9092974268256817}
>>> memoized_sin(1)
0.8414709848078965
>>> _SIN_MEMOIZED_VALUES
{1: 0.8414709848078965, 2: 0.9092974268256817}
列表 10-17:一个记忆化的 sin()函数
在列表 10-17 中,当第一次调用memoized_sin()时,若其参数不在_SIN_MEMOIZED_VALUES字典中,该值将被计算并存储在该字典中。如果再次调用该函数并传入相同的值,结果将从字典中获取,而不是重新计算。虽然sin()的计算非常快速,但一些涉及复杂计算的高级函数可能会花费更长时间,这正是记忆化真正发挥作用的地方。
如果你已经阅读过装饰器的内容(如果没有,请参见《装饰器及其使用时机》,位于第 100 页),你可能会看到一个完美的机会在这里使用它们,而你是对的。PyPI 列出了几种通过装饰器实现记忆化的实现,从非常简单的案例到最复杂、最完整的实现。
从 Python 3.3 开始,functools模块提供了一个最近最少使用(LRU)缓存装饰器。它提供了与记忆化相同的功能,但其优势在于它限制了缓存中条目的数量,当缓存达到最大大小时,会删除最久未使用的条目。该模块还提供了缓存命中与未命中的统计数据(即某个数据是否存在于缓存中),以及其他一些数据。在我看来,这些统计数据在实现这种缓存时是必不可少的。使用记忆化或任何缓存技术的优势在于能够度量其使用情况和有效性。
列表 10-18 展示了如何使用functools.lru_cache()方法实现函数的记忆化。当函数被装饰后,它将获得一个cache_info()方法,可以调用该方法来获取缓存使用情况的统计信息。
>>> import functools
>>> import math
>>> @functools.lru_cache(maxsize=2)
... def memoized_sin(x):
... return math.sin(x)
...
>>> memoized_sin(2)
0.9092974268256817
>>> memoized_sin.cache_info()
CacheInfo(hits=0, misses=1, maxsize=2, currsize=1)
>>> memoized_sin(2)
0.9092974268256817
>>> memoized_sin.cache_info()
CacheInfo(hits=1, misses=1, maxsize=2, currsize=1)
>>> memoized_sin(3)
0.1411200080598672
>>> memoized_sin.cache_info()
CacheInfo(hits=1, misses=2, maxsize=2, currsize=2)
>>> memoized_sin(4)
-0.7568024953079282
>>> memoized_sin.cache_info()
CacheInfo(hits=1, misses=3, maxsize=2, currsize=2)
>>> memoized_sin(3)
0.1411200080598672
>>> memoized_sin.cache_info()
CacheInfo(hits=2, misses=3, maxsize=2, currsize=2)
>>> memoized_sin.cache_clear()
>>> memoized_sin.cache_info()
CacheInfo(hits=0, misses=0, maxsize=2, currsize=0)
清单 10-18:检查缓存统计信息
清单 10-18 展示了缓存是如何使用的,以及如何判断是否有优化空间。例如,如果在缓存未满时未命中的次数较高,则缓存可能是无效的,因为传递给函数的参数从未相同。这将有助于确定应该或不应该进行记忆化!
使用 PyPy 加速 Python
PyPy 是一种符合标准的高效 Python 实现:你应该能够用它运行任何 Python 程序。事实上,Python 的标准实现 CPython——因为它是用 C 写的——可能非常慢。PyPy 的理念是用 Python 本身编写一个 Python 解释器。随着时间的推移,它发展成用 RPython 编写,RPython 是 Python 语言的一个受限子集。
RPython 对 Python 语言施加了约束,使得变量的类型可以在编译时推断出来。RPython 代码被转换成 C 代码,然后编译生成解释器。当然,RPython 也可以用来实现其他语言,而不仅仅是 Python。
在 PyPy 中,除了技术挑战外,另一个有趣的地方是它现在已进入一个阶段,可以作为 CPython 的更快替代品。PyPy 内置了 即时编译(JIT) 编译器;换句话说,它通过将编译代码的速度与解释的灵活性相结合,使代码运行得更快。
有多快?这取决于情况,但对于纯算法代码,它要快得多。对于更一般的代码,PyPy 声称大多数情况下能够达到 CPython 的三倍速度。不幸的是,PyPy 也有 CPython 的一些限制,包括 全局解释器锁(GIL),它只允许一个线程在同一时间执行。
虽然严格来说这不是一种优化技术,但将 PyPy 作为你支持的 Python 实现之一可能是个好主意。为了使 PyPy 成为一个支持实现,你需要确保像在 CPython 下测试一样在 PyPy 下测试你的软件。在第六章中,我们讨论了 tox(请参阅“在 第 92 页 中使用 virtualenv 与 tox”),它支持使用 PyPy 构建虚拟环境,就像它支持任何版本的 CPython 一样,因此将 PyPy 支持加入到项目中应该是相当直接的。
在项目初期测试 PyPy 支持,将确保如果你决定希望能够使用 PyPy 运行你的软件,后期不会需要做太多工作。
注意
在 第九章中讨论的 Hy 项目,我们从一开始就成功采用了这一策略。Hy 一直支持 PyPy 和所有其他 CPython 版本,且几乎没有遇到什么麻烦。另一方面,OpenStack 未能为其项目做到这一点,因此现在受到各种代码路径和依赖的阻碍,这些依赖由于各种原因无法在 PyPy 上运行;它们在早期阶段并不要求进行完全测试。
PyPy 兼容 Python 2.7 和 Python 3.5,并且其 JIT 编译器支持 32 位和 64 位、x86 和 ARM 架构,以及多种操作系统(Linux、Windows 和 Mac OS X)。PyPy 在功能上通常落后于 CPython,但它会定期赶上。除非你的项目依赖于最新的 CPython 功能,否则这个滞后可能不会成为问题。
通过缓冲区协议实现零拷贝
通常程序需要处理大量的数据,这些数据以大数组字节的形式存在。一旦你开始通过复制、切片和修改数据来操作它,处理如此大量的输入数据在字符串中的效率会非常低。
让我们考虑一个小程序,它读取一个大型二进制数据文件,并将其部分复制到另一个文件中。为了检查该程序的内存使用情况,我们将像之前一样使用 memory_profiler。部分复制文件的脚本见 列表 10-19。
@profile
def read_random():
with open("/dev/urandom", "rb") as source:
content = source.read(1024 * 10000)
content_to_write = content[1024:]
print("Content length: %d, content to write length %d" %
(len(content), len(content_to_write)))
with open("/dev/null", "wb") as target:
target.write(content_to_write)
if __name__ == '__main__':
read_random()
列表 10-19:部分复制文件
使用 memory_profiler 运行 列表 10-19 中的程序,输出结果如下 列表 10-20 所示。
$ python -m memory_profiler memoryview/copy.py
Content length: 10240000, content to write length 10238976
Filename: memoryview/copy.py
Mem usage Increment Line Contents
@profile
9.883 MB 0.000 MB def read_random():
9.887 MB 0.004 MB with open("/dev/urandom", "rb") as source:
19.656 MB 9.770 MB content = source.read(1024 * 10000)➊
29.422 MB 9.766 MB content_to_write = content[1024:]➋
29.422 MB 0.000 MB print("Content length: %d, content to write length %d" %
29.434 MB 0.012 MB (len(content), len(content_to_write)))
29.434 MB 0.000 MB with open("/dev/null", "wb") as target:
29.434 MB 0.000 MB target.write(content_to_write)
列表 10-20:部分文件复制的内存分析
根据输出,程序从 _/dev/urandom 读取了 10MB 的数据 ➊。Python 需要分配大约 10MB 的内存来存储这些数据作为字符串。然后,它复制了整个数据块,去除了前 1KB ➋。
在 列表 10-20 中有趣的是,当构建变量 content_to_write 时,程序的内存使用量增加了大约 10MB。实际上,slice 操作符正在将整个内容(去除前 1KB)复制到一个新的字符串对象中,从而分配了 10MB 中的大块内存。
在大型字节数组上执行这种操作将是一场灾难,因为将分配和复制大量内存。如果你有写 C 代码的经验,你知道使用 memcpy() 函数在内存使用和性能方面都有显著的开销。
但作为一个 C 程序员,你也会知道字符串是字符数组,而且没有任何东西能阻止你只查看数组的一部分而不进行复制。你可以通过使用基本的指针算术来做到这一点,前提是整个字符串位于一个连续的内存区域中。
在 Python 中,通过实现缓冲区协议的对象也可以做到这一点。缓冲区协议在 PEP 3118 中定义,作为一个 C API,需要在不同类型上实现,才能提供该协议。例如,string 类实现了该协议。
当你在一个对象上实现这个协议时,你可以使用 memoryview 类构造函数来构建一个新的 memoryview 对象,引用原始对象的内存。例如,清单 10-21 展示了如何使用 memoryview 来访问字符串的切片,而不进行任何复制:
>>> s = b"abcdefgh"
>>> view = memoryview(s)
>>> view[1]
➊ 98 <1>
>>> limited = view[1:3]
>>> limited
<memory at 0x7fca18b8d460>
>>> bytes(view[1:3])
b'bc'
清单 10-21:使用 memoryview 避免复制数据
在 ➊ 处,你可以找到字母 b 的 ASCII 码。在清单 10-21 中,我们利用了 memoryview 对象的 slice 操作符本身返回一个 memoryview 对象的特性。这意味着它不复制任何数据,而只是引用它的某一部分,从而节省了复制所需的内存。图 10-2 展示了清单 10-21 中的操作。

图 10-2:在 memoryview 对象上使用切片
我们可以重写清单 10-19 中的程序,这次通过引用我们想要写入的数据,而不是分配一个新的字符串,使用一个 memoryview 对象。
@profile
def read_random():
with open("/dev/urandom", "rb") as source:
content = source.read(1024 * 10000)
content_to_write = memoryview(content)[1024:]
print("Content length: %d, content to write length %d" %
(len(content), len(content_to_write)))
with open("/dev/null", "wb") as target:
target.write(content_to_write)
if __name__ == '__main__':
read_random()
清单 10-22:使用 memoryview 部分复制文件
清单 10-22 中的程序使用的内存是清单 10-19 中的一半。我们可以通过再次使用 memory_profiler 来验证这一点,方法如下:
$ python -m memory_profiler memoryview/copy-memoryview.py
Content length: 10240000, content to write length 10238976
Filename: memoryview/copy-memoryview.py
Mem usage Increment Line Contents
@profile
9.887 MB 0.000 MB def read_random():
9.891 MB 0.004 MB ➊ with open("/dev/urandom", "rb") as source:
19.660 MB 9.770 MB ➋ content = source.read(1024 * 10000)
19.660 MB 0.000 MB content_to_write = memoryview(content)[1024:]
19.660 MB 0.000 MB print("Content length: %d, content to write length %d" %
19.672 MB 0.012 MB (len(content), len(content_to_write)))
19.672 MB 0.000 MB with open("/dev/null", "wb") as target:
19.672 MB 0.000 MB target.write(content_to_write)
这些结果表明,我们从/dev/urandom读取了 10,000KB 的数据,但并没有做太多处理 ➊。Python 需要分配 9.77MB 的内存来存储这段数据作为一个字符串 ➋。
我们引用了整个数据块,去掉了前面的第一个 KB,因为我们不会将该第一个 KB 写入目标文件。由于我们没有进行复制,因此没有占用更多内存!
这种技巧在处理套接字时特别有用。通过套接字发送数据时,数据可能会在多次调用中被分割发送,而不是在一次调用中全部发送:socket.send 方法返回网络实际能够发送的数据长度,这可能比原本想要发送的数据长度小。清单 10-23 展示了这种情况通常是如何处理的。
import socket
s = socket.socket(...)
s.connect(...)
➊ data = b"a" * (1024 * 100000) <1> while data:
sent = s.send(data)
➋ data = data[sent:] <2>
清单 10-23:通过套接字发送数据
首先,我们构建一个 bytes 对象,该对象包含超过 1 亿次的字母 a ➊。然后,我们去掉前面的 sent 字节 ➋。
使用清单 10-23 中实现的机制,程序会不断复制数据,直到套接字将所有数据发送完毕。
我们可以修改清单 10-23 中的程序,使用memoryview来实现相同的功能,且无需复制,从而提高性能,如清单 10-24 所示。
import socket
s = socket.socket(...)
s.connect(...)
➊ data = b"a" * (1024 * 100000) <1>
mv = memoryview(data)
while mv:
sent = s.send(mv)
➋ mv = mv[sent:] <2>
清单 10-24:通过 memoryview 发送数据到套接字
首先,我们构建一个包含超过 1 亿个字母a的bytes对象➊。然后,我们构建一个新的memoryview对象,指向剩余需要发送的数据,而不是复制这些数据➋。这个程序不会进行任何复制,因此它不会使用比最初为data变量所需的 100MB 更多的内存。
我们已经看到memoryview对象如何用于高效写入数据,同样的方法也可以用于读取数据。Python 中的大多数 I/O 操作都知道如何处理实现了缓冲区协议的对象:它们可以从中读取数据,也可以向其中写入数据。在这种情况下,我们不需要memoryview对象;我们可以直接要求 I/O 函数将数据写入我们预分配的对象,如清单 10-25 所示。
>>> ba = bytearray(8)
>>> ba
bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00')
>>> with open("/dev/urandom", "rb") as source:
... source.readinto(ba)
...
8
>>> ba
bytearray(b'`m.z\x8d\x0fp\xa1')
清单 10-25:写入预分配的 bytearray
在清单 10-25 中,通过使用已打开文件的readinto()方法,Python 可以直接从文件中读取数据并将其写入预分配的bytearray中。通过这种技术,预分配缓冲区变得容易(就像在 C 语言中为了减少对malloc()的调用而做的那样),并且可以根据需要填充它。使用memoryview,你可以将数据放置在内存区域的任何位置,如清单 10-26 所示。
>>> ba = bytearray(8)
➊ >>> ba_at_4 = memoryview(ba)[4:]
>>> with open("/dev/urandom", "rb") as source:
➋ ... source.readinto(ba_at_4)
...
4
>>> ba
bytearray(b'\x00\x00\x00\x00\x0b\x19\xae\xb2')
清单 10-26:写入 bytearray 的任意位置
我们从偏移量 4 开始引用bytearray直到末尾➊。然后,我们将来自/dev/urandom的内容从偏移量 4 开始写入bytearray的末尾,有效地只读取了 4 个字节➋。
缓冲区协议对实现低内存开销和优异性能至关重要。由于 Python 隐藏了所有的内存分配,开发人员往往会忽略底层发生的事情,这会大大影响程序的速度!
array模块中的对象和struct模块中的函数可以正确处理缓冲区协议,因此,在目标为零拷贝时,它们可以高效执行。
总结
正如我们在本章中所看到的,有很多方法可以加快 Python 代码的执行速度。选择正确的数据结构,并使用正确的方法来操作数据,在 CPU 和内存使用方面能产生巨大影响。这就是为什么理解 Python 内部发生的事情非常重要。
然而,优化永远不应该过早进行,必须先进行适当的性能分析。很容易浪费时间用更快的变体重写一些几乎不使用的代码,而忽略了关键的痛点。不要忽视全局的情况。
Victor Stinner 关于优化
Victor 是一个资深的 Python 黑客、核心贡献者以及许多 Python 模块的作者。他于 2013 年撰写了 PEP 454,提出了一个新的tracemalloc模块,用于跟踪 Python 内部的内存块分配,他还编写了一个名为 FAT 的简单 AST 优化器。他也定期为提升 CPython 的性能做出贡献。
优化 Python 代码的一个好的起步策略是什么?
策略在 Python 中与其他语言相同。首先,你需要一个明确的使用案例,以便获得稳定且可复现的基准测试。没有可靠的基准测试,尝试不同的优化可能会导致浪费时间和过早的优化。无用的优化可能使代码变得更差、更难以阅读,甚至更慢。一个有效的优化,必须至少能使程序加速 5% 才值得追求。
如果代码中的某个特定部分被识别为“慢”,那么应该在该部分代码上进行基准测试。对一个短小函数进行的基准测试通常称为 微基准测试。微基准测试的加速应该至少达到 20%,甚至 25%,才能证明优化是值得的。
在不同的计算机、操作系统或编译器上运行基准测试可能会很有趣。例如,realloc()的性能可能在 Linux 和 Windows 上有所不同。
你推荐的性能分析或优化 Python 代码的工具有哪些?
Python 3.3 提供了 time.perf_counter() 函数,用于测量基准测试的经过时间。它具有最佳的分辨率。
一个测试应该运行多次;三次是最少的,五次可能就足够了。重复测试可以填充磁盘缓存和 CPU 缓存。我更倾向于保留最小的运行时间;其他开发者可能更倾向于使用几何平均值。
对于微基准测试,timeit模块使用简单且能快速给出结果,但使用默认参数时,结果并不可靠。测试应当手动重复多次,以获得稳定的结果。
优化可能需要花费大量时间,因此最好专注于那些消耗最多 CPU 的函数。要找到这些函数,Python 提供了 cProfile 和 profile 模块来记录每个函数的耗时。
你有什么 Python 小技巧能提高性能吗?
你应该尽量重用标准库——它经过了充分测试,且通常效率很高。内建的 Python 类型是用 C 实现的,性能良好。使用正确的容器可以获得最佳的性能;Python 提供了多种不同类型的容器:dict、list、deque、set 等等。
有一些优化 Python 的技巧,但应该避免使用这些技巧,因为它们在带来轻微性能提升的同时,会让代码变得不那么可读。
《Python 之禅》(PEP 20)中写道:“应该有一种——而且最好只有一种——明显的方法来做这件事。” 实际上,写 Python 代码有多种方式,且性能表现也不相同。只应相信适用于你的使用场景的基准测试。
Python 哪些方面的性能最差,需要特别注意?
通常来说,在开发新应用时,我倾向于不去担心性能问题。过早优化是万恶之源。当你识别出慢的函数时,可以更换算法。如果算法和容器类型选择得当,你甚至可以将一些短小的函数用 C 重写,以获得最佳性能。
在 CPython 中,一个瓶颈是全局解释器锁(Global Interpreter Lock,简称 GIL)。两个线程不能同时执行 Python 字节码。然而,这个限制仅在两个线程执行纯 Python 代码时才会成为问题。如果大部分处理时间都花费在函数调用上,并且这些函数释放了 GIL,那么 GIL 就不是瓶颈。例如,大多数 I/O 函数都会释放 GIL。
multiprocessing 模块可以轻松地绕过 GIL。另一个选择是编写异步代码,尽管这种方法实现起来更复杂。以网络为导向的库,如 Twisted、Tornado 和 Tulip 项目,便采用了这一技术。
一些常见的性能问题是什么?
当对 Python 了解不足时,可能会写出低效的代码。例如,我曾见过不需要复制时误用 copy.deepcopy()。
另一个性能杀手是低效的数据结构。如果容器中的项数少于 100 个,则容器类型对性能没有影响。项数多了,就必须了解每个操作(add、get、delete)的复杂性及其影响。
第十一章:可扩展性与架构

迟早,你的开发过程将不得不考虑弹性和可扩展性。一个应用程序的可扩展性、并发性和并行性在很大程度上取决于其最初的架构和设计。正如我们在本章中所看到的,有些范式——例如多线程——在 Python 中并不适用,而其他技术,例如面向服务的架构,则效果更好。
完整地涵盖可扩展性需要一本书,而事实上已经有很多书对此进行了详细讨论。本章涵盖了可扩展性的基本原则,即使你不打算构建有百万用户的应用程序。
Python 中的多线程及其限制
默认情况下,Python 进程只在一个线程上运行,这个线程被称为主线程。这个线程在单个处理器上执行代码。多线程是一种编程技术,允许代码通过同时运行多个线程在单个 Python 进程内并发执行。这是我们在 Python 中引入并发的主要机制。如果计算机配备了多个处理器,你甚至可以使用并行 ism,在多个处理器上并行运行线程,以加快代码执行速度。
多线程最常用的场景(虽然不总是适用)是:
-
你需要在不停止主线程执行的情况下运行后台或 I/O 导向的任务。例如,图形用户界面的主循环正在忙着等待一个事件(例如用户点击或键盘输入),但代码还需要执行其他任务。
-
你需要将工作负载分配到多个 CPU 上。
第一个场景是多线程的一个很好的通用案例。虽然在这种情况下实现多线程会引入额外的复杂性,但控制多线程是可管理的,除非 CPU 工作负载非常密集,否则性能通常不会受到影响。当工作负载是 I/O 密集型时,使用并发的性能提升更为显著,特别是在 I/O 延迟较高时:你等待读取或写入的时间越长,做其他事情的好处就越大。
在第二种情况下,你可能希望为每个新请求启动一个新线程,而不是逐个处理它们。这看起来像是多线程的一个好用例。然而,如果你像这样分散工作负载,你将会遇到 Python 的全局解释器锁(GIL),它是每次 CPython 执行字节码时必须获得的锁。这个锁意味着在任何时候只有一个线程可以控制 Python 解释器。这个规则最初是为了防止竞争条件而引入的,但不幸的是,它意味着如果你试图通过让应用程序运行多个线程来扩展它,你将始终受到这个全局锁的限制。
因此,虽然使用线程看似是理想的解决方案,但大多数在多个线程中运行请求的应用程序难以达到 150% 的 CPU 使用率,或者相当于 1.5 个核心的使用率。大多数计算机有 4 或 8 个核心,服务器则提供 24 或 48 个核心,但 GIL 阻止了 Python 使用全部的 CPU。目前有一些移除 GIL 的计划,但这个努力非常复杂,因为它需要在性能和向后兼容性之间做出权衡。
尽管 CPython 是 Python 语言中最常用的实现,但也有其他实现没有 GIL。例如,Jython 可以高效地并行运行多个线程。不幸的是,像 Jython 这样的项目由于其自身的性质总是滞后于 CPython,因此并不是真正有用的目标;创新发生在 CPython 中,其他实现只是在追随 CPython 的步伐。
所以,让我们回顾一下现在所知道的两个用例,并找出一个更好的解决方案:
-
当你需要运行后台任务时,可以使用多线程,但更简单的解决方案是围绕事件循环构建应用程序。有很多 Python 模块可以实现这一点,现在的标准是
asyncio。还有一些框架,如 Twisted,也是围绕这个概念构建的。最先进的框架会让你通过信号、定时器和文件描述符活动来访问事件——我们将在本章的“事件驱动架构”中讨论这个问题,详见第 181 页。 -
当你需要分担工作负载时,使用多个进程是最有效的方法。我们将在下一节中探讨这种技术。
开发人员在使用多线程时应该三思而后行。例如,我曾经在几年前使用多线程来分配任务,在我写的 Debian 构建守护进程 rebuildd 中。虽然为每个正在运行的构建任务分配一个线程看起来很方便,但我很快就陷入了 Python 中线程并行性的陷阱。如果有机会重新开始,我会基于异步事件处理或多进程来构建,而不必担心 GIL。
多线程是复杂的,正确实现多线程应用程序很困难。你需要处理线程同步和锁定,这意味着有很多引入 bug 的机会。考虑到总体收益较小,最好在投入太多精力之前再三考虑。
多进程与多线程
由于 GIL 阻止了多线程成为一个良好的可扩展性解决方案,因此可以考虑使用 Python 的多进程包提供的替代方案。该包提供了与多线程模块相同类型的接口,不同之处在于它启动的是新的进程(通过 os.fork()),而不是新的系统线程。
示例 11-1 展示了一个简单的示例,其中一百万个随机整数被求和八次,这个活动在八个线程中同时进行。
import random
import threading
results = []
def compute():
results.append(sum(
[random.randint(1, 100) for i in range(1000000)]))
workers = [threading.Thread(target=compute) for x in range(8)] for worker in workers:
worker.start()
for worker in workers:
worker.join()
print("Results: %s" % results)
示例 11-1:使用多线程实现并发活动
在示例 11-1 中,我们使用threading.Thread类创建了八个线程,并将它们存储在workers数组中。这些线程将执行compute()函数。然后,它们使用start()方法开始执行。join()方法仅在线程执行完成后才会返回。此时,结果可以被打印出来。
运行此程序会返回以下结果:
$ time python worker.py
Results: [50517927, 50496846, 50494093, 50503078, 50512047, 50482863,
50543387, 50511493]
python worker.py 13.04s user 2.11s system 129% cpu 11.662 total
这是在一个空闲的四核 CPU 上运行的,这意味着 Python 最多可能使用 400%的 CPU。然而,这些结果表明,即使有八个线程并行运行,CPU 使用率也明显未能达到这个数值。相反,CPU 使用率达到了 129%,仅占硬件能力的 32%(129/400)。
现在,让我们使用多进程(multiprocessing)重写这个实现。对于像这样简单的情况,切换到多进程非常直接,如示例 11-2 所示。
import multiprocessing
import random
def compute(n):
return sum(
[random.randint(1, 100) for i in range(1000000)])
# Start 8 workers
pool = multiprocessing.Pool(processes=8)
print("Results: %s" % pool.map(compute, range(8)))
示例 11-2:使用多进程实现并发活动
multiprocessing模块提供了一个Pool对象,它接受一个参数,表示要启动的进程数。它的map()方法与原生的map()方法作用相同,只是不同的 Python 进程将负责执行compute()函数。
在相同条件下运行示例 11-2 程序,与示例 11-1 相比,得到以下结果:
$ time python workermp.py
Results: [50495989, 50566997, 50474532, 50531418, 50522470, 50488087, 0498016, 50537899]
python workermp.py 16.53s user 0.12s system 363% cpu 4.581 total
多进程将执行时间减少了 60%。此外,我们已经能够消耗最多 363%的 CPU 功率,这超过了计算机 CPU 容量的 90%以上(363/400)。
每次你认为可以并行化某些工作时,几乎总是更好地依赖多进程,并将任务分叉到多个 CPU 核心上来分担负载。这对于非常短的执行时间不是一个好方案,因为fork()调用的成本太高,但对于更大的计算需求,它效果很好。
事件驱动架构
事件驱动编程的特点是通过事件(如用户输入)来决定程序的控制流,它是组织程序流程的一个好方法。事件驱动程序会监听队列上发生的各种事件,并根据这些传入的事件作出反应。
假设你想构建一个应用程序,它监听套接字上的连接,然后处理接收到的连接。基本上,有三种方法可以解决这个问题:
-
每次建立新连接时,创建一个新进程,依赖像
multiprocessing模块这样的工具。 -
每当建立一个新连接时,就启动一个新线程,依赖于类似
threading模块的东西。 -
将这个新连接添加到你的事件循环中,并对它发生时将产生的事件做出反应。
确定现代计算机如何同时处理成千上万的连接被称为 C10K 问题。C10K 解决方案策略解释了如何使用事件循环来监听数百个事件源,远比每个连接使用一个线程的方式更具可扩展性。这并不意味着这两种技术不兼容,但确实意味着你通常可以用事件驱动机制替代多线程方式。
事件驱动架构使用事件循环:程序调用一个函数,直到接收到事件并准备好处理时才会阻塞执行。这个理念是,你的程序可以在等待输入输出完成时,继续忙于处理其他任务。最基本的事件是“数据准备好读取”和“数据准备好写入”。
在 Unix 中,用于构建此类事件循环的标准函数是系统调用 select(2) 或 poll(2)。这些函数期望接收一个文件描述符列表来进行监听,它们会在至少有一个文件描述符准备好进行读写操作时返回。
在 Python 中,我们可以通过 select 模块访问这些系统调用。利用这些调用构建事件驱动的系统非常简单,尽管这样做可能有点繁琐。Listing 11-3 展示了一个执行我们指定任务的事件驱动系统:监听套接字并处理它接收到的任何连接。
import select
import socket
server = socket.socket(socket.AF_INET,
socket.SOCK_STREAM)
# Never block on read/write operations
server.setblocking(0)
# Bind the socket to the port
server.bind(('localhost', 10000))
server.listen(8)
while True:
# select() returns 3 arrays containing the object (sockets, files...)
# that are ready to be read, written to or raised an error
inputs,
outputs, excepts = select.select([server], [], [server])
if server in inputs:
connection, client_address = server.accept()
connection.send("hello!\n")
Listing 11-3:一个监听和处理连接的事件驱动程序
在 Listing 11-3 中,创建了一个服务器套接字并将其设置为 非阻塞,意味着在该套接字上执行的任何读写操作都不会阻塞程序。如果程序在没有数据可读时尝试从套接字读取,套接字的 recv() 方法会引发 OSError,指示套接字尚未准备好。如果我们没有调用 setblocking(0),套接字将保持在阻塞模式,而不会引发错误,这不是我们希望的结果。然后,套接字会绑定到一个端口,并以最多八个连接的回退队列进行监听。
主循环是通过 select() 构建的,它接收我们希望读取的文件描述符列表(此例中为套接字),我们希望写入的文件描述符列表(此例中没有),以及我们希望获取异常的文件描述符列表(此例中为套接字)。select() 函数会在其中一个被选中的文件描述符准备好读取、写入或引发异常时返回。返回值是符合请求条件的文件描述符列表。接下来,检查我们的套接字是否在准备读取的列表中,如果是,接受连接并发送消息。
其他选项与 asyncio
另外,也有许多框架,如 Twisted 或 Tornado,它们提供了更为集成的此类功能;Twisted 在这方面多年来一直是事实上的标准。导出 Python 接口的 C 库,如 libevent、libev 或 libuv,也提供了非常高效的事件循环。
这些选项都解决了相同的问题。缺点是,尽管有多种选择,但大多数选项之间并不兼容。许多还采用 回调机制,这意味着在阅读代码时程序的流程并不十分清晰;你需要跳转到许多不同的地方才能通读程序。
另一种选择是使用 gevent 或 greenlet 库,它们避免使用回调。然而,它们的实现细节包括特定于 CPython x86 的代码和在运行时对标准函数的动态修改,这意味着你不愿意长期使用并维护使用这些库的代码。
2012 年,Guido Van Rossum 开始研究名为 tulip 的解决方案,并在 PEP 3156 中进行了文档说明(* www.python.org/dev/peps/pep-3156 *)。该软件包的目标是提供一个标准的事件循环接口,能够与所有框架和库兼容,并具备互操作性。
此后,tulip 代码被重命名并合并到 Python 3.4 中,成为 asyncio 模块,并且现在已成为事实上的标准。并非所有库都与 asyncio 兼容,大多数现有的绑定需要重写。
从 Python 3.6 开始,asyncio 已经如此完全集成,以至于它拥有了自己的 await 和 async 关键字,使得使用起来非常简便。清单 11-4 展示了如何使用提供异步 HTTP 绑定的 aiohttp 库与 asyncio 一起并发运行多个网页检索。
import aiohttp
import asyncio
async def get(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return response
loop = asyncio.get_event_loop()
coroutines = [get("http://example.com") for _ in range(8)]
results = loop.run_until_complete(asyncio.gather(*coroutines))
print("Results: %s" % results)
清单 11-4:使用 aiohttp 并发地获取网页
我们将 get() 函数定义为异步函数,因此它本质上是一个协程。get() 函数的两个步骤——连接和页面检索——被定义为异步操作,在准备好之前将控制权交还给调用者。这使得 asyncio 可以在任何时刻调度另一个协程。该模块在连接建立或页面准备好读取时会恢复协程的执行。这八个协程同时启动并交给事件循环,由 asyncio 负责高效调度它们。
asyncio 模块是编写异步代码和利用事件循环的一个优秀框架。它支持文件、套接字等,并且有许多第三方库支持各种协议。不要犹豫,尽管使用它!
面向服务的架构
避开 Python 的扩展性缺陷可能看起来很棘手。然而,Python 确实非常擅长实现 面向服务的架构(SOA),这是一种软件设计风格,其中不同的组件通过通信协议提供一组服务。例如,OpenStack 在其所有组件中都使用 SOA 架构。这些组件使用 HTTP REST 与外部客户端(最终用户)通信,并且构建在高级消息队列协议(AMQP)之上的抽象远程过程调用(RPC)机制。
在你的开发环境中,了解在这些模块之间使用哪些通信渠道,主要是了解你将与谁进行通信。
当将服务暴露给外部世界时,首选的通道是 HTTP,特别是对于无状态设计,如 REST 风格(REpresentational State Transfer 风格)架构。这种架构使得服务的实现、扩展、部署和理解变得更加容易。
然而,在内部暴露和使用 API 时,HTTP 可能不是最佳协议。还有许多其他通信协议,甚至单独描述一种协议可能就会填满一本书。
在 Python 中,有很多用于构建 RPC 系统的库。Kombu 很有意思,因为它提供了一个基于许多后端的 RPC 机制,其中 AMQ 协议是主要的支持协议。它还支持 Redis、MongoDB、Beanstalk、Amazon SQS、CouchDB 或 ZooKeeper。
最终,通过使用这种松耦合的架构,你可以间接地获得大量的性能提升。如果我们认为每个模块都提供并暴露 API,那么我们可以运行多个守护进程,这些守护进程也可以暴露 API,从而让多个进程——因此也让多个 CPU——来处理工作负载。例如,Apache httpd 会创建一个新的 worker,该 worker 使用一个新的系统进程来处理新的连接;我们可以将连接分配给同一节点上运行的不同 worker。为了做到这一点,我们只需要一个系统来将工作分配给我们的各个 workers,而这个 API 就提供了这样的功能。每个模块都会是一个不同的 Python 进程,正如我们之前所看到的,这种方法比多线程更适合分散工作负载。你将能够在每个节点上启动多个 workers。即使无状态模块不是绝对必要的,在你有选择的情况下,你也应该偏向使用它们。
使用 ZeroMQ 的进程间通信
正如我们刚刚讨论的,构建分布式系统时总是需要一个消息总线。你的进程需要相互通信以传递消息。ZeroMQ 是一个可以作为并发框架的套接字库。列表 11-5 实现了与 列表 11-1 中相同的 worker,但使用 ZeroMQ 作为分配工作和进程间通信的方式。
import multiprocessing
import random
import zmq
def compute():
return sum(
[random.randint(1, 100) for i in range(1000000)])
def worker():
context = zmq.Context()
work_receiver = context.socket(zmq.PULL)
work_receiver.connect("tcp://0.0.0.0:5555")
result_sender = context.socket(zmq.PUSH)
result_sender.connect("tcp://0.0.0.0:5556")
poller = zmq.Poller()
poller.register(work_receiver, zmq.POLLIN)
while True:
socks = dict(poller.poll())
if socks.get(work_receiver) == zmq.POLLIN:
obj = work_receiver.recv_pyobj()
result_sender.send_pyobj(obj())
context = zmq.Context()
# Build a channel to send work to be done
➊ work_sender = context.socket(zmq.PUSH)
work_sender.bind("tcp://0.0.0.0:5555")
# Build a channel to receive computed results
➋ result_receiver = context.socket(zmq.PULL)
result_receiver.bind("tcp://0.0.0.0:5556")
# Start 8 workers
processes = []
for x in range(8):
➌ p = multiprocessing.Process(target=worker)
p.start()
processes.append(p)
# Send 8 jobs
for x in range(8):
work_sender.send_pyobj(compute)
# Read 8 results
results = []
for x in range(8):
➍ results.append(result_receiver.recv_pyobj()) # Terminate all processes
for p in processes:
p.terminate()
print("Results: %s" % results)
列表 11-5:使用 ZeroMQ 的工作进程
我们创建了两个套接字,一个用于发送函数(work_sender) ➊,另一个用于接收任务(result_receiver) ➋。每个由multiprocessing.Process启动的worker ➌都会创建自己的套接字,并将其连接到主进程。然后,worker执行传递给它的任何函数,并返回结果。主进程只需要通过发送套接字发送八个任务,并等待通过接收套接字返回八个结果 ➍。
正如你所看到的,ZeroMQ提供了一种构建通信通道的简便方法。我选择在这里使用 TCP 传输层来说明我们可以通过网络运行这个。需要注意的是,ZeroMQ还提供了一种进程间通信通道,它通过使用 Unix 套接字在本地工作(不涉及任何网络层)。显然,为了简洁明了,本例中基于ZeroMQ构建的通信协议非常简单,但不难想象在其基础上构建一个更复杂的通信层。同样,也很容易想象构建一个完全分布式的应用程序,通过像 ZeroMQ 或 AMQP 这样的网络消息总线进行通信。
请注意,诸如 HTTP、ZeroMQ 和 AMQP 之类的协议是语言无关的:你可以使用不同的语言和平台来实现系统的每个部分。虽然我们都认为 Python 是一个很好的语言,但其他团队可能有其他偏好,或者在解决某个问题的某个部分时,其他语言可能是更好的选择。
最终,使用传输总线将应用程序解耦成多个部分是一个不错的选择。通过这种方法,你可以构建可以从一台计算机分发到数千台计算机的同步和异步 API。它不会将你束缚于特定的技术或语言,因此你可以在正确的方向上发展一切。
总结
Python 中的经验法则是仅在 I/O 密集型工作负载下使用线程,并在 CPU 密集型工作负载出现时尽早切换到多进程。将工作负载分布到更广泛的范围——例如在网络上构建分布式系统——需要外部库和协议。Python 支持这些内容,但它们是由外部提供的。
第十二章:管理关系型数据库

应用程序几乎总是需要存储某种数据,开发者通常会将关系型数据库管理系统(RDBMS)与某种类型的对象关系映射工具(ORM)结合使用。RDBMS 和 ORM 可能很复杂,并且对于许多开发者来说并不是他们最喜欢的话题,但迟早需要面对。
关系型数据库管理系统(RDBMS)、ORM 以及何时使用它们
RDBMS 是存储应用程序关系数据的数据库。开发者将使用类似 SQL(结构化查询语言)这样的语言来处理关系代数,也就是说,像这样的语言处理数据管理和数据之间的关系。两者结合使用,可以让你既能存储数据,又能尽可能高效地查询数据以获取特定的信息。对关系型数据库结构有良好的理解,例如如何正确使用规范化或不同类型的可串行性,可能会帮助你避免掉入许多陷阱。显然,这些主题值得写一本完整的书籍,而不会在本章中完全涵盖;相反,我们将专注于通过其常用的编程语言 SQL 来使用数据库。
开发者可能不愿意投入时间学习一门全新的编程语言来与 RDBMS 交互。如果是这样,他们通常会完全避免编写 SQL 查询,而是依赖于某个库来为他们完成这项工作。ORM 库在编程语言生态系统中非常常见,Python 也不例外。
ORM 的目的是通过抽象化创建查询的过程,使数据库系统更容易访问:它生成 SQL,这样你就不需要手动写了。不幸的是,这种抽象层可能会妨碍你执行一些更具体或底层的任务,这些任务是 ORM 根本无法做到的,比如编写复杂的查询。
在面向对象程序中使用 ORM 也有一组特定的难题,这些问题非常常见,通常被统称为 对象关系阻抗不匹配。这种阻抗不匹配发生在关系型数据库和面向对象程序对数据的表示不同,彼此之间无法正确映射:无论你怎么做,将 SQL 表映射到 Python 类都无法得到最佳结果。
理解 SQL 和 RDBMS 将使你能够编写自己的查询,而无需依赖于抽象层来做所有事情。
但这并不是说你应该完全避免使用 ORM。ORM 库可以帮助你快速原型化应用程序模型,一些库甚至提供有用的工具,比如架构升级和降级。重要的是要理解,使用 ORM 并不能替代你对 RDBMS 的真正理解:许多开发者尝试用自己选择的语言来解决问题,而不是使用它们的模型 API,而他们提出的解决方案充其量也只是勉强凑合。
注意
本章假设你了解基本的 SQL。引入 SQL 查询并讨论表的工作原理超出了本书的范围。如果你是 SQL 新手,建议在继续之前先学习基本内容。Anthony DeBarros 的《实用 SQL》(No Starch Press,2018)是一个很好的起点。
让我们看一个例子,演示为什么理解关系型数据库管理系统(RDBMS)可以帮助你编写更好的代码。假设你有一个用于跟踪消息的 SQL 表。这张表有一个名为id的列,表示消息发送者的 ID,这是主键,还有一个包含消息内容的字符串,像这样:
CREATE TABLE message (
id serial PRIMARY KEY,
content text
);
我们希望检测任何重复的消息并将其从数据库中排除。为此,典型的开发者可能会使用 ORM 编写 SQL,如列表 12-1 所示。
if query.select(Message).filter(Message.id == some_id):
# We already have the message, it's a duplicate, ignore and raise
raise DuplicateMessage(message)
else:
# Insert the message
query.insert(message)
列表 12-1:使用 ORM 检测并排除重复消息
这段代码适用于大多数情况,但它有一些主要的缺点:
-
重复约束已经在 SQL 模式中表达,因此这里存在一种代码重复:使用
PRIMARY KEY隐式定义了id字段的唯一性。 -
如果消息尚未在数据库中,这段代码会执行两条 SQL 查询:一条
SELECT语句,然后是INSERT语句。执行 SQL 查询可能需要很长时间,并且可能需要与 SQL 服务器进行来回通信,造成额外的延迟。 -
这段代码没有考虑到在我们调用
select_by_id()之后、调用insert()之前,其他人可能会插入重复的消息,这样会导致程序抛出异常。这个漏洞被称为竞态条件。
有一种更好的方式来编写这段代码,但它需要与 RDBMS 服务器协作。我们可以直接插入消息,并使用try...except块来捕捉重复冲突,而不是先检查消息是否存在然后再插入:
try:
# Insert the message
message_table.insert(message)
except UniqueViolationError:
# Duplicate
raise DuplicateMessage(message)
在这种情况下,如果消息尚不存在,直接将消息插入表中可以完美运行。如果已存在,ORM 会抛出一个异常,指示违反了唯一性约束。此方法与列表 12-1 达到相同的效果,但更加高效且没有任何竞态条件。这是一个非常简单的模式,并且它与任何 ORM 都不会冲突。问题在于开发者倾向于将 SQL 数据库视为“愚蠢的存储”而不是一个可以用来获得数据完整性和一致性的工具;因此,他们可能会在控制器代码中而不是在模型中重复写 SQL 中的约束。
将你的 SQL 后端当作模型 API 来使用是高效利用它的好方法。你可以通过用其自身的过程式语言编写简单的函数调用来操作存储在 RDBMS 中的数据。
数据库后端
ORM 支持多个数据库后端。没有哪个 ORM 库能够完全抽象所有 RDBMS 的特性,而将代码简化到最基础的 RDBMS 功能将使得在不打破抽象层的情况下使用任何高级 RDBMS 功能变得不可能。即使是一些在 SQL 中没有标准化的简单操作,比如处理时间戳操作,在使用 ORM 时也会变得很麻烦。如果你的代码是 RDBMS 无关的,这一点尤其如此。在选择应用程序的 RDBMS 时,记住这一点非常重要。
隔离 ORM 库(如在《外部库》中描述的,第 22 页)有助于减轻潜在问题。这个方法允许你在需要时轻松更换 ORM 库,并通过识别低效查询的地方来优化 SQL 使用,从而避免大部分 ORM 的样板代码。
例如,你可以在应用程序的一个模块中使用 ORM,比如myapp.storage,轻松实现这种隔离。该模块应该只导出允许你在高抽象级别操作数据的函数和方法。ORM 应仅在该模块中使用。任何时候,你都可以插入任何提供相同 API 的模块来替代myapp.storage。
Python 中最常用的 ORM 库(可以说是事实上的标准)是sqlalchemy。这个库支持大量的后端,并为大多数常见操作提供了抽象。模式升级可以通过第三方包来处理,例如alembic(pypi.python.org/pypi/alembic/)。
一些框架,比如 Django(www.djangoproject.com),提供了它们自己的 ORM 库。如果你选择使用框架,最好使用内建库,因为它通常与框架的集成效果要优于外部库。
警告
大多数框架依赖的模块视图控制器(MVC)架构很容易被滥用。这些框架直接在模型中实现(或方便实现)ORM,但没有足够地进行抽象:任何在视图和控制器中使用模型的代码,也将直接使用 ORM。你需要避免这种情况。你应该编写一个包含 ORM 库的数据模型,而不是由它构成。这样做可以提供更好的可测试性和隔离性,并使得用另一种存储技术替换 ORM 变得更加容易。
使用 Flask 和 PostgreSQL 进行数据流处理
在这里,我将向你展示如何利用PostgreSQL的高级特性构建一个 HTTP 事件流系统,帮助你掌握数据存储。
编写数据流处理应用程序
清单 12-2 中微型应用程序的目的是将消息存储在 SQL 表中,并通过 HTTP REST API 提供对这些消息的访问。每条消息由一个频道号、一个源字符串和一个内容字符串组成。
CREATE TABLE message (
id SERIAL PRIMARY KEY,
channel INTEGER NOT NULL,
source TEXT NOT NULL,
content TEXT NOT NULL
);
清单 12-2:用于存储消息的 SQL 表架构
我们还希望将这些消息流式传输到客户端,以便它可以实时处理它们。为此,我们将使用 PostgreSQL 的 LISTEN 和 NOTIFY 功能。这些功能允许我们监听 PostgreSQL 执行的函数发送的消息:
➊ CREATE OR REPLACE FUNCTION notify_on_insert() RETURNS trigger AS $$
➋ BEGIN
PERFORM pg_notify('channel_' || NEW.channel,
CAST(row_to_json(NEW) AS TEXT));
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
这段代码创建了一个用 pl/pgsql 编写的触发器函数,这是 PostgreSQL 独有的一种语言。请注意,我们也可以使用其他语言编写此函数,例如 Python 本身,因为 PostgreSQL 嵌入了 Python 解释器以提供 pl/python 语言。我们在这里执行的单一简单操作并不需要利用 Python,因此坚持使用 pl/pgsql 是一个明智的选择。
函数 notify_on_insert() ➊ 调用了 pg_notify() ➋,该函数实际上发送了通知。第一个参数是表示 频道 的字符串,而第二个参数是承载实际 有效载荷 的字符串。我们根据行中频道列的值动态定义频道。在这种情况下,有效载荷将是整行数据的 JSON 格式。是的,PostgreSQL 确实知道如何将一行数据转换为 JSON!
接下来,我们希望在每次在消息表中执行 INSERT 时发送通知消息,因此我们需要在此类事件上触发该函数:
CREATE TRIGGER notify_on_message_insert AFTER INSERT ON message
FOR EACH ROW EXECUTE PROCEDURE notify_on_insert();
该函数现在已经连接并将在每次在消息表中执行成功的INSERT时被执行。
我们可以通过在 psql 中使用 LISTEN 操作来检查它是否工作正常:
$ psql
psql (9.3rc1)
SSL connection (cipher: DHE-RSA-AES256-SHA, bits: 256)
Type "help" for help.
mydatabase=> LISTEN channel_1;
LISTEN
mydatabase=> INSERT INTO message(channel, source, content)
mydatabase-> VALUES(1, 'jd', 'hello world');
INSERT 0 1
Asynchronous notification "channel_1" with payload
"{"id":1,"channel":1,"source":"jd","content":"hello world"}"
received from server process with PID 26393.
一旦行被插入,通知便会被发送,并且我们能够通过 PostgreSQL 客户端接收到它。现在我们只需构建流式传输该事件的 Python 应用程序,如 清单 12-3 所示。
import psycopg2
import psycopg2.extensions
import select
conn = psycopg2.connect(database='mydatabase', user='myuser',
password='idkfa', host='localhost')
conn.set_isolation_level(
psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
curs = conn.cursor()
curs.execute("LISTEN channel_1;")
while True:
select.select([conn], [], [])
conn.poll()
while conn.notifies:
notify = conn.notifies.pop()
print("Got NOTIFY:", notify.pid, notify.channel,
notify.payload)
清单 12-3:监听并接收通知流
清单 12-3 使用 psycopg2 库连接到 PostgreSQL。psycopg2 库是一个 Python 模块,实现了 PostgreSQL 网络协议,并允许我们连接到 PostgreSQL 服务器发送 SQL 请求并接收结果。我们本可以使用提供抽象层的库,例如 sqlalchemy,但是抽象库并没有提供访问 PostgreSQL 的 LISTEN 和 NOTIFY 功能。需要注意的是,使用像 sqlalchemy 这样的库时,仍然可以访问底层数据库连接来执行代码,但对于这个示例来说这样做没有意义,因为我们不需要 ORM 库提供的其他功能。
程序监听 channel_1,一旦收到通知,就会将其打印到屏幕上。如果我们运行程序并在 message 表中插入一行数据,我们会得到以下输出:
$ python listen.py
Got NOTIFY: 28797 channel_1
{"id":10,"channel":1,"source":"jd","content":"hello world"}
一旦我们插入行,PostgreSQL 会运行触发器并发送通知。我们的程序接收到通知并打印通知负载;在这里,通知负载就是被序列化为 JSON 的行。现在,我们具备了接收插入数据库的数据的基本能力,而无需做任何额外的请求或工作。
构建应用程序
接下来,我们将使用 Flask,一个简单的 HTTP 微框架,来构建我们的应用程序。我们将构建一个 HTTP 服务器,使用 HTML5 定义的 Server-Sent Events 消息协议流式传输 insert 数据流。另一种选择是使用 HTTP/1.1 定义的 Transfer-Encoding: chunked:
import flask
import psycopg2
import psycopg2.extensions
import select
app = flask.Flask(__name__)
def stream_messages(channel):
conn = psycopg2.connect(database='mydatabase', user='mydatabase',
password='mydatabase', host='localhost')
conn.set_isolation_level(
psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
curs = conn.cursor()
curs.execute("LISTEN channel_%d;" % int(channel))
while True:
select.select([conn], [], [])
conn.poll()
while conn.notifies:
notify = conn.notifies.pop()
yield "data: " + notify.payload + "\n\n"
@app.route("/message/<channel>", methods=['GET'])
def get_messages(channel):
return flask.Response(stream_messages(channel),
mimetype='text/event-stream')
if __name__ == "__main__":
app.run()
这个应用程序足够简单,支持流式传输,但不支持其他数据检索操作。我们使用 Flask 将 HTTP 请求 GET /message/channel 路由到我们的流式代码。一旦调用该代码,应用程序会返回一个具有 text/event-stream MIME 类型的响应,并返回一个生成器函数,而不是一个字符串。Flask 会调用这个函数,并在每次生成器生成内容时发送结果。
生成器 stream_messages() 重用了我们之前写的代码来监听 PostgreSQL 通知。它接收频道标识符作为参数,监听该频道,然后返回负载。记住,我们在触发器函数中使用了 PostgreSQL 的 JSON 编码函数,所以我们已经从 PostgreSQL 接收到了 JSON 数据。我们不需要重新编码数据,因为直接将 JSON 数据发送给 HTTP 客户端是可以的。
注意
为了简化起见,这个示例应用程序是写在一个文件中的。如果这是一个真正的应用程序,我会将存储处理实现移动到它自己的 Python 模块中。
我们现在可以运行服务器:
$ python listen+http.py
* Running on http://127.0.0.1:5000/
在另一个终端中,我们可以连接并检索已输入的事件。连接时,没有数据接收,并且连接保持打开状态:
$ curl -v http://127.0.0.1:5000/message/1
* About to connect() to 127.0.0.1 port 5000 (#0)
* Trying 127.0.0.1...
* Adding handle: conn: 0x1d46e90
* Adding handle: send: 0
* Adding handle: recv: 0
* Curl_addHandleToPipeline: length: 1
* - Conn 0 (0x1d46e90) send_pipe: 1, recv_pipe: 0
* Connected to 127.0.0.1 (127.0.0.1) port 5000 (#0)
> GET /message/1 HTTP/1.1
> User-Agent: curl/7.32.0
> Host: 127.0.0.1:5000
> Accept: */*
>
但是,一旦我们在 message 表中插入一些行,我们会开始通过运行 curl 的终端看到数据流入。在第三个终端中,我们在数据库中插入一条消息:
mydatabase=> INSERT INTO message(channel, source, content)
mydatabase-> VALUES(1, 'jd', 'hello world');
INSERT 0 1
mydatabase=> INSERT INTO message(channel, source, content) mydatabase-> VALUES(1, 'jd', 'it works');
INSERT 0 1
这是数据输出:
data: {"id":71,"channel":1,"source":"jd","content":"hello world"}
data: {"id":72,"channel":1,"source":"jd","content":"it works"}
这些数据会打印到运行 curl 的终端中。这样就保持了 curl 与 HTTP 服务器的连接,同时等待下一个消息流的到来。我们创建了一个流式服务,而没有做任何轮询,建立了一个完全基于 推送 的系统,信息在一个点到另一个点之间无缝流动。
一个天真且可能更具可移植性的实现会反复循环执行 SELECT 语句,以轮询表中插入的新数据。这种方式适用于任何不支持发布-订阅模式的存储系统,而不是像本例中所示的这种方式。
Dimitri Fontaine 论数据库
Dimitri 是一个熟练的 PostgreSQL 主要贡献者,现任 Citus Data 的工程师,并在pgsql-hackers邮件列表上与其他数据库专家进行辩论。我们分享了许多开源冒险经历,他也很友善地回答了一些关于处理数据库时应该做什么的问题。
你会给使用 RDBMS 作为存储后端的开发者什么建议?
RDBMS 是在 70 年代发明的,用于解决当时困扰每个应用程序开发者的常见问题,RDBMS 实现的主要服务不仅仅是数据存储。
RDBMS 提供的主要服务实际上如下:
-
并发性: 你可以使用任意数量的并发执行线程来访问数据进行读取或写入——RDBMS 会正确处理这些操作。这正是你从 RDBMS 中希望获得的主要功能。
-
并发语义: 使用 RDBMS 时的并发行为细节,通过高层次的规范提出,涉及原子性和隔离性,可能是 ACID(原子性、一致性、隔离性、持久性)中最关键的部分。原子性是指,在你
BEGIN一个事务和事务完成时(无论是COMMIT还是ROLLBACK)之间,系统中其他任何并发活动都不能知道你在做什么——无论是什么。当使用一个合适的 RDBMS 时,还包括数据定义语言(DDL),例如CREATE TABLE或ALTER TABLE。隔离性则涉及你在自己事务内可以注意到的系统并发活动的内容。SQL 标准定义了四种隔离级别,如 PostgreSQL 文档中所述(*www.postgresql.org/docs/9.2/static/transaction-iso.html*)。
RDBMS 对你的数据负全责。因此,它允许开发者描述自己的一致性规则,然后它会在关键时刻检查这些规则是否有效,比如在事务提交或语句边界时,具体取决于约束声明的延迟性。
你可以对数据施加的第一个约束是其预期的输入和输出格式,使用正确的数据类型。关系数据库管理系统(RDBMS)能够处理比文本、数字和日期更多的内容,并且能够正确处理实际出现在今天日历中的日期。
数据类型不仅仅涉及输入和输出格式,它们还实现了行为和某种程度的多态性,因为我们都期望基本的相等性测试是特定于数据类型的:我们不会以相同的方式比较文本和数字、日期和 IP 地址、数组和范围等。
保护你的数据也意味着,RDBMS 的唯一选择是主动拒绝不符合一致性规则的数据,其中第一个规则就是你选择的数据类型。如果你认为处理像 0000-00-00 这样的日期是可以接受的,这个日期在日历中从未存在过,那么你需要重新考虑。
一致性保证的另一部分是通过约束来表达的,例如CHECK约束、NOT NULL约束和约束触发器,其中一个叫做外键。所有这些可以被看作是数据类型定义和行为的用户级扩展,主要区别在于你可以选择将约束检查的强制执行从每个语句的末尾推迟到当前事务的末尾,使用DEFER来实现这一点。
关系型数据库管理系统(RDBMS)的关系位主要是关于建模你的数据,并确保在关系中的所有元组共享一套共同的规则:结构和约束。当执行这些规则时,我们是在强制使用一个合适的显式模式来处理我们的数据。
为你的数据制定一个合适的模式被称为规范化,你可以在设计中追求几个细微不同的规范形式。然而,有时你可能需要比规范化过程的结果提供更多的灵活性。常见的做法是首先规范化你的数据模式,然后再修改它以恢复一些灵活性。实际上,你很可能并不需要更多的灵活性。
当你确实需要更多灵活性时,你可以使用 PostgreSQL 尝试多种反规范化选项:复合类型、记录、数组、H-Store、JSON 或 XML 等。
然而,反规范化有一个非常重要的缺点,那就是我们接下来要讨论的查询语言是为处理相对规范化的数据而设计的。当然,使用 PostgreSQL 时,查询语言已被扩展,以支持尽可能多的反规范化,特别是在使用复合类型、数组、H-Store,甚至最近发布的 JSON 时。
RDBMS 了解你的数据,并且可以帮助你实现一个非常细粒度的安全模型,若你需要的话。访问模式在关系和列级别进行管理,PostgreSQL 还实现了SECURITY DEFINER存储过程,允许你以一种非常受控的方式提供对敏感数据的访问,类似于使用保存的用户 ID(SUID)程序的方式。
RDBMS 通过 SQL 提供数据访问,这在 80 年代成为事实上的标准,并且现在由一个委员会推动。在 PostgreSQL 的情况下,增加了许多扩展,每个主要版本都会让你访问一个非常丰富的 DSL 语言。所有查询规划和优化的工作都由 RDBMS 为你完成,这样你就可以专注于一个声明式查询,在这个查询中你只需要描述你想从数据中得到的结果。
这也是为什么你需要特别关注这里的 NoSQL 产品,因为大多数这些流行的产品实际上并不是仅仅去除了 SQL,而是移除了你习惯期望的许多其他基础设施。
你会给使用 RDBMS 作为存储后端的开发人员什么建议?
我的建议是记住存储后端和 RDBMS 之间的区别。这两者是非常不同的服务,如果你所需要的仅仅是存储后端,或许可以考虑使用 RDBMS 以外的其他工具。
然而,最常见的需求是一个完整的关系数据库管理系统(RDBMS)。在这种情况下,最佳的选择是 PostgreSQL。去阅读它的文档(* www.postgresql.org/docs/*);查看它提供的数据类型、运算符、函数、特性和扩展。阅读一些博客中的使用示例。
然后,可以将 PostgreSQL 视为你在开发中可以利用的工具,并将其纳入你的应用架构中。你需要实现的服务部分最好在 RDBMS 层面提供,而 PostgreSQL 在作为整个实现中值得信赖的一部分方面表现出色。
使用或不使用 ORM 的最佳方式是什么?
ORM 最适合用于CRUD应用程序:创建、读取、更新和删除。读取部分应该限制在一个非常简单的SELECT语句上,目标是单一表,因为获取不必要的更多列会对查询性能和使用的资源产生重大影响。
从 RDBMS 中检索到的任何列,如果最终没有使用,都是对宝贵资源的浪费,这是一个初步的可扩展性杀手。即使你的 ORM 能够仅获取你请求的数据,你仍然需要以某种方式管理你在每种情况下需要的确切列列表,而不是使用一个简单的抽象方法,它会自动为你计算字段列表。
创建、更新和删除查询是简单的INSERT、UPDATE和DELETE语句。许多 RDBMS 提供了 ORM 没有利用的优化,例如在INSERT之后返回数据。
此外,在一般情况下,关系要么是一个表,要么是任何查询的结果。在使用 ORM 时,常见的做法是建立定义好的表与一些模型类或其他辅助代码之间的关系映射。
如果你考虑 SQL 语义的整体性,那么关系映射器实际上应该能够将任何查询映射到一个类。然后,你大概需要为每个你想运行的查询构建一个新的类。
在我们的案例中应用这个理念的方式是,你信任你的 ORM 比你自己写更高效的 SQL 查询,即使你没有提供足够的信息来确定你感兴趣的确切数据集。
的确,SQL 有时可能会变得相当复杂,但通过使用一个你无法控制的 API 到 SQL 生成器,你是无法接近简单性的。
然而,在两个情况下,你可以放松并使用 ORM,前提是你愿意接受以下妥协:稍后你可能需要从代码库中删除 ORM 的使用。
-
市场上市时间: 当你非常急需并希望尽快抢占市场份额时,唯一的方法就是发布应用程序和创意的第一个版本。如果你的团队更擅长使用 ORM 而非手动编写 SQL 查询,那就尽管使用它。你必须意识到,一旦你的应用成功,最先需要解决的扩展性问题之一就是 ORM 生成的查询非常糟糕。同时,你使用 ORM 的方式可能已经将你限制在了一个死胡同,导致了糟糕的代码设计决策。但如果你已经走到这一步,说明你的应用已经足够成功,足以投入一些重构的资金来移除对 ORM 的依赖,对吧?
-
CRUD 应用: 这是实际的操作,其中你一次只编辑一个元组,并且你并不关心性能,适用于基本的管理员应用界面。
在使用 Python 时,为什么选择 PostgreSQL 而不是其他数据库?
以下是我选择 PostgreSQL 作为开发者的主要原因:
-
社区支持: PostgreSQL 的社区非常庞大且对新用户友好,大家通常会花时间提供最佳答案。邮件列表仍然是与社区沟通的最佳方式。
-
数据完整性和持久性: 你发送到 PostgreSQL 的任何数据都能在其定义中安全存储,并且你能够在之后重新获取它。
-
数据类型、函数、操作符、数组和范围: PostgreSQL 提供了一套非常丰富的数据类型,并且伴随有许多操作符和函数。即使是使用数组或 JSON 数据类型进行反规范化,仍然可以编写高级查询,包括对这些数据进行连接。
-
规划器和优化器: 值得花时间了解这些组件的复杂性和强大功能。
-
事务性 DDL: 几乎可以
ROLLBACK任何命令。现在试试:只需打开psqlshell,连接到你拥有的数据库,然后输入BEGIN; DROP TABLE foo; ROLLBACK;,其中将 foo 替换为本地实例中存在的一个表的名称。很神奇,对吧? -
PL/Python(以及其他语言,如 C、SQL、JavaScript 或 Lua): 你可以在服务器上运行自己的 Python 代码,数据就在那里,这样就不必通过网络提取数据进行处理,再发回查询进行下一步的
JOIN操作。 -
特定索引(GiST、GIN、SP-GiST、部分和函数索引): 你可以在 PostgreSQL 内部创建 Python 函数来处理数据,然后索引该函数调用的结果。当你发出带有
WHERE子句的查询并调用该函数时,函数仅会使用查询中的数据执行一次,然后直接与索引内容进行匹配。
第十三章:写得更少,编码更多

在本章的最后,我汇总了我用来写出更好代码的 Python 一些高级特性。这些特性不仅限于 Python 标准库。我们将讨论如何让你的代码兼容 Python 2 和 3,如何创建类似 Lisp 的方法调度器,如何使用上下文管理器,以及如何使用attr模块创建类的模板。
使用 six 实现 Python 2 和 3 的支持
正如你可能知道的,Python 3 打破了与 Python 2 的兼容性,并且做了一些调整。然而,语言的基础在不同版本间并没有改变,这使得实现前向和后向兼容成为可能,从而架起了 Python 2 和 Python 3 之间的桥梁。
幸运的是,这个模块已经存在!它叫做six——因为 2 × 3 = 6。
six模块提供了有用的six.PY3变量,这是一个布尔值,指示你是否正在运行 Python 3。这是你代码库中的关键变量,适用于两个版本的代码:一个是 Python 2 版本,另一个是 Python 3 版本。然而,要小心不要滥用它;在代码中到处散布if six.PY3会让别人很难阅读和理解你的代码。
当我们在“生成器”一章的第 121 页讨论生成器时,我们看到 Python 3 有一个很棒的特性,它让各种内置函数,如map()或filter(),返回可迭代对象而不是列表。因此,Python 3 摒弃了像dict.iteritems()这样的旧方法,它是 Python 2 中dict.items()的可迭代版本,改为让dict.items()返回一个迭代器,而不是列表。这个方法及其返回类型的变化可能会导致你的 Python 2 代码出错。
six模块为这种情况提供了six.iteritems(),可以用来替代像下面这样的 Python 2 特定代码:
for k, v in mydict.iteritems():
print(k, v)
使用six,你可以将mydict.iteritems()代码替换为兼容 Python 2 和 3 的代码,如下所示:
import six
for k, v in six.iteritems(mydict):
print(k, v)
然后,瞧,Python 2 和 Python 3 的兼容性瞬间达成!six.iteritems()函数将根据你使用的 Python 版本,使用dict.iteritems()或dict.items()来返回一个生成器。six模块提供了许多类似的辅助函数,可以让你轻松支持多个 Python 版本。
另一个例子是six解决方案对于raise关键字的不同语法问题,在 Python 2 和 Python 3 之间有所不同。在 Python 2 中,raise接受多个参数,但在 Python 3 中,raise只接受一个异常作为参数,不能再有其他参数。在 Python 3 中,如果你写一个包含两个或三个参数的raise语句,会导致SyntaxError。
six模块在这里提供了一个解决方法,形式为函数six.reraise(),它允许你在任何版本的 Python 中重新抛出异常。
字符串与 Unicode
Python 3 增强的编码处理能力解决了 Python 2 中的字符串和 unicode 问题。在 Python 2 中,基本的字符串类型是str,只能处理基本的 ASCII 字符串。类型unicode是在 Python 2.5 中后来添加的,用于处理实际的文本字符串。
在 Python 3 中,基本的字符串类型依然是str,但它与 Python 2 中的unicode类共享属性,并能够处理高级编码。bytes类型取代了str类型,用于处理基本的字符流。
six模块再次提供了函数和常量,如six.u和six.string_types,以处理过渡。同样的兼容性也适用于整数,six.integer_types将处理已从 Python 3 中移除的long类型。
处理 Python 模块的迁移
在 Python 标准库中,一些模块在 Python 2 和 Python 3 之间已经迁移或更名。six模块提供了一个名为six.moves的模块,透明地处理了很多这些迁移。
例如,Python 2 中的ConfigParser模块在 Python 3 中已更名为configparser。列表 13-1 展示了如何通过使用six.moves将代码移植并兼容两个主要的 Python 版本:
from six.moves.configparser import ConfigParser
conf = ConfigParser()
列表 13-1:使用 six.moves 在 Python 2 和 Python 3 中使用 ConfigParser()
你还可以通过six.add_move添加自己的迁移处理,来处理six本身没有原生支持的代码过渡。
如果six库无法覆盖你所有的使用场景,可能值得构建一个封装six的兼容性模块,从而确保你能够增强该模块以适应未来的 Python 版本,或者在你希望停止支持某个特定版本的语言时,弃用(部分)模块。还需要注意的是,six是开源的,你可以为其贡献代码,而不是维护自己的“黑客”修改!
现代化模块
最后,有一个名为modernize的工具,它使用six模块来“现代化”你的代码,将其移植到 Python 3,而不是仅仅将 Python 2 的语法转换为 Python 3 的语法。它同时支持 Python 2 和 Python 3。modernize工具通过为你完成大部分繁重的工作,帮助你的移植工作顺利起步,因此比标准的2to3工具更值得选择。
像 Lisp 一样使用 Python 来创建单一的调度器
我喜欢说,Python 是 Lisp 编程语言的一个良好子集,随着时间的推移,我发现这一点越来越真实。PEP 443 证明了这一点:它描述了一种以类似于 Common Lisp 对象系统(CLOS)提供的方式来调度通用函数的方法。
如果你熟悉 Lisp,这对你来说并不是什么新闻。Lisp 对象系统是 Common Lisp 的基本组成部分之一,它提供了一种简单高效的方式来定义和处理方法分发。我将首先向你展示 Lisp 中的通用方法是如何工作的。
在 Lisp 中创建通用方法
首先,让我们在 Lisp 中定义几个非常简单的类,不包含任何父类或属性:
(defclass snare-drum ()
())
(defclass cymbal ()
())
(defclass stick ()
())
(defclass brushes ()
())
这定义了 snare-drum、cymbal、stick 和 brushes 类,这些类没有任何父类或属性。这些类组成了一个鼓组,我们可以将它们组合来发出声音。为此,我们定义了一个 play() 方法,它接受两个参数并返回一个表示声音的字符串:
(defgeneric play (instrument accessory)
(:documentation "Play sound with instrument and accessory."))
这仅定义了一个不附加到任何类上的通用方法,因此尚不能调用。在这一阶段,我们仅向对象系统说明该方法是通用的,可能会使用两个名为 instrument 和 accessory 的参数进行调用。在列表 13-2 中,我们将实现这个方法的版本,以模拟演奏我们的小军鼓。
(defmethod play ((instrument snare-drum) (accessory stick))
"POC!")
(defmethod play ((instrument snare-drum) (accessory brushes))
"SHHHH!")
(defmethod play ((instrument cymbal) (accessory brushes))
"FRCCCHHT!")
列表 13-2:在 Lisp 中定义独立于类的通用方法
现在我们已经在代码中定义了具体的方法。每个方法接受两个参数:instrument,它是 snare-drum 或 cymbal 的实例,以及 accessory,它是 stick 或 brushes 的实例。
在这一阶段,你应该能看到该系统与 Python(或类似)对象系统的第一个主要区别:方法并不绑定到任何特定类上。方法是通用的,可以为任何类实现。
让我们尝试一下。我们可以使用一些对象来调用 play() 方法:
* (play (make-instance 'snare-drum) (make-instance 'stick))
"POC!"
* (play (make-instance 'snare-drum) (make-instance 'brushes))
"SHHHH!"
如你所见,调用哪个函数取决于参数的类——对象系统会根据我们传递的参数类型,将函数调用调度到正确的函数。如果我们用一个没有定义方法的类实例调用 play(),则会抛出错误。
在 列表 13-3 中,play() 方法是使用 cymbal 和 stick 实例调用的;然而,由于从未为这些参数定义 play() 方法,因此它会抛出错误。
* (play (make-instance 'cymbal) (make-instance 'stick))
debugger invoked on a SIMPLE-ERROR in thread
#<THREAD "main thread" RUNNING {1002ADAF23}>:
There is no applicable method for the generic function
#<STANDARD-GENERIC-FUNCTION PLAY (2)>
when called with arguments
(#<CYMBAL {1002B801D3}> #<STICK {1002B82763}>).
Type HELP for debugger help, or (SB-EXT:EXIT) to exit from SBCL.
restarts (invokable by number or by possibly abbreviated name):
0: [RETRY] Retry calling the generic function.
1: [ABORT] Exit debugger, returning to top level.
((:METHOD NO-APPLICABLE-METHOD (T)) #<STANDARD-GENERIC-FUNCTION PLAY (2)>
#<CYMBAL {1002B801D3}> #<STICK {1002B82763}>) [fast-method]
列表 13-3:调用带有不可用签名的方法
CLOS 提供了更多功能,比如方法继承或基于对象的调度,而不是使用类。如果你对 CLOS 提供的众多功能非常好奇,我建议阅读 Jeff Dalton 的《CLOS 简明指南》作为入门 (www.aiai.ed.ac.uk/~jeff/clos-guide.html)。
Python 中的通用方法
Python 实现了该工作流程的简化版本,使用 singledispatch() 函数,该函数自 Python 3.4 起作为 functools 模块的一部分发布。在 2.6 到 3.3 版本中,singledispatch() 函数通过 Python 包索引提供;对于那些急于尝试的人,只需运行 pip install singledispatch。
列表 13-4 显示了我们在 列表 13-2 中构建的 Lisp 程序的大致等效代码。
import functools
class SnareDrum(object): pass
class Cymbal(object): pass
class Stick(object): pass
class Brushes(object): pass
@functools.singledispatch
def play(instrument, accessory):
raise NotImplementedError("Cannot play these")
➊ @play.register(SnareDrum)
def _(instrument, accessory):
if isinstance(accessory, Stick):
return "POC!"
if isinstance(accessory, Brushes):
return "SHHHH!"
raise NotImplementedError("Cannot play these")
@play.register(Cymbal)
def _(instrument, accessory):
if isinstance(accessory, Brushes):
return "FRCCCHHT!"
raise NotImplementedError("Cannot play these")
列表 13-4:使用 singledispatch 调度方法调用
这个列表定义了我们的四个类和一个基础的 play() 函数,它抛出 NotImplementedError,表示默认情况下我们不知道该怎么做。
然后我们为一个特定的乐器 SnareDrum ➊ 编写一个专门版本的 play() 函数。该函数检查传入的配件类型,并返回相应的声音,如果配件无法识别,则再次引发 NotImplementedError。
如果我们运行程序,它的工作方式如下:
>>> play(SnareDrum(), Stick())
'POC!'
>>> play(SnareDrum(), Brushes())
'SHHHH!'
>>> play(Cymbal(), Stick())
Traceback (most recent call last):
NotImplementedError: Cannot play these
>>> play(SnareDrum(), Cymbal())
NotImplementedError: Cannot play these
singledispatch 模块会检查传入的第一个参数的类,并调用适当版本的 play() 函数。对于 object 类,第一个定义的版本总是会被执行。因此,如果我们的乐器是一个我们没有注册的类的实例,那么将调用这个基础函数。
正如我们在 Lisp 版本的代码中看到的那样,CLOS 提供了一个多重调度器,可以根据方法原型中定义的 任何参数的类型 来调度,而不仅仅是第一个参数。Python 的调度器被称为 singledispatch 是有原因的:它只知道如何根据第一个参数进行调度。
此外,singledispatch 不提供直接调用父函数的方法。没有类似 Python super() 的功能;你需要使用各种技巧来绕过这一限制。
虽然 Python 正在改进其对象系统和调度机制,但它仍然缺乏一些像 CLOS 提供的那种更高级的功能。这使得在实际应用中遇到 singledispatch 相对罕见。尽管如此,知道它的存在仍然很有意思,因为你可能会在某个时刻自己实现类似的机制。
上下文管理器
Python 2.6 引入的 with 语句很可能会让老派的 Lisp 程序员想起该语言中常用的各种 with-* 宏。Python 提供了一个类似的机制,通过使用实现了 上下文管理协议 的对象来实现。
如果你从未使用过上下文管理协议,这里是它的工作原理。with 语句中包含的代码块被两个函数调用包围。with 语句中使用的对象决定了这两个函数调用。那些对象被称为实现了上下文管理协议。
像 open() 返回的这些对象支持该协议;这就是为什么你可以写出如下代码:
with open("myfile", "r") as f:
line = f.readline()
open() 返回的对象有两个方法:一个是 __enter__,另一个是 __exit__。这两个方法分别在 with 块的开始和结束时被调用。
一个简单的上下文对象实现如 示例 13-5 所示。
class MyContext(object):
def __enter__(self):
pass
def __exit__(self, exc_type, exc_value, traceback):
pass
示例 13-5:一个简单的上下文对象实现
该实现什么都不做,但它是有效的,并展示了需要定义的方法签名,以便提供一个遵循上下文协议的类。
当你在代码中识别到以下模式时,上下文管理协议可能是适合使用的。这种模式要求在调用方法 A 后,必须 始终调用方法 B:
-
调用方法
A。 -
执行一些代码。
-
调用方法
B。
open()函数很好地说明了这个模式:打开文件并在内部分配文件描述符的构造函数是方法A。释放文件描述符的close()方法对应方法B。显然,close()函数总是应该在你实例化文件对象之后调用*。
手动实现这个协议可能会很繁琐,因此contextlib标准库提供了contextmanager装饰器,使得实现变得更加容易。contextmanager装饰器应该应用于一个生成器函数。__enter__和__exit__方法会根据包裹yield语句的代码动态地为你实现。
在清单 13-6 中,MyContext被定义为一个上下文管理器。
import contextlib
@contextlib.contextmanager
def MyContext():
print("do something first")
yield
print("do something else")
with MyContext():
print("hello world")
清单 13-6:使用 contextlib.contextmanager
yield语句之前的代码将在with语句体执行之前运行;yield语句之后的代码将在with语句体结束后执行。执行时,这个程序会输出以下内容:
do something first
hello world
do something else
这里有几件事需要处理。首先,可能会在生成器中yield一些内容,作为with块的一部分使用。
清单 13-7 展示了如何将一个值传递给调用者。关键字as用于将该值存储在一个变量中。
import contextlib
@contextlib.contextmanager
def MyContext():
print("do something first")
yield 42
print("do something else")
with MyContext() as value:
print(value)
清单 13-7:定义一个返回值的上下文管理器
清单 13-7 展示了如何将一个值传递给调用者。关键字as用于将该值存储在一个变量中。执行时,代码会输出以下内容:
do something first
42
do something else
使用上下文管理器时,你可能需要处理在with代码块中引发的异常。可以通过将yield语句包裹在try...except块中来处理异常,正如在清单 13-8 中展示的那样。
import contextlib
@contextlib.contextmanager
def MyContext():
print("do something first")
try:
yield 42
finally:
print("do something else")
with MyContext() as value:
print("about to raise")
➊ raise ValueError("let's try it")
print(value)
清单 13-8:在上下文管理器中处理异常
在这里,ValueError在with代码块的开始处被引发 ➊;Python 会将这个错误传播回上下文管理器,并且yield语句似乎会引发这个异常。我们将yield语句封装在try和finally中,以确保最终的print()被执行。
执行时,清单 13-8 输出以下内容:
do something first
about to raise
do something else
Traceback (most recent call last):
File "<stdin>", line 3, in <module>
ValueError: let's try it
如你所见,错误被传播回上下文管理器,程序继续执行并完成,因为它通过try...finally块忽略了异常。
在某些情况下,可能需要同时使用多个上下文管理器,例如,在清单 13-9 中展示的那样,同时打开两个文件以复制它们的内容。
with open("file1", "r") as source:
with open("file2", "w") as destination:
destination.write(source.read())
清单 13-9:同时打开两个文件以复制内容
话虽如此,由于 with 语句支持多个参数,实际上使用单个 with 编写的版本效率更高,如 列表 13-10 所示。
with open("file1", "r") as source, open("file2", "w") as destination:
destination.write(source.read())
列表 13-10:使用一个 with 语句同时打开两个文件
上下文管理器是非常强大的设计模式,帮助确保你的代码流始终正确,无论可能发生什么异常。在许多需要由其他代码包装的代码场景中,它们能提供一致且干净的编程接口,contextlib.contextmanager 可以帮助你实现这一点。
减少模板代码,使用 attr
编写 Python 类可能会让人觉得繁琐。你经常会发现自己重复一些模式,因为没有其他选择。最常见的例子之一,如 列表 13-11 所示,是当使用几个传递给构造函数的属性来初始化一个对象时。
class Car(object):
def __init__(self, color, speed=0):
self.color = color
self.speed = speed
列表 13-11:常见的类初始化模板代码
这个过程始终是一样的:你将传递给 __init__ 函数的参数值复制到存储在对象中的一些属性中。有时你还需要检查传递的值,计算默认值,等等。
显然,你还希望当对象被打印时,它能够正确地表示自己,因此你需要实现一个 __repr__ 方法。如果你的某些类足够简单,能够转换为字典进行序列化,那么事情会更加复杂。谈到比较和可哈希性(能够对一个对象使用 hash 并将其存储在 set 中)时,情况就更复杂了。
实际上,大多数 Python 程序员并不会做这些工作,因为编写这些检查和方法的负担太重,尤其是在你并不总是确定是否需要它们的情况下。例如,你可能发现 __repr__ 只在你调试或追踪程序时才有用,决定在标准输出中打印对象——而不是其他时候。
attr 库通过为你的所有类提供通用模板代码,并为你生成大部分代码,旨在提供一个简单的解决方案。你可以使用命令 pip install attr 来安装 attr。准备好享受它吧!
安装完成后,attr.s 装饰器是进入 attr 神奇世界的入口。将其放在类声明之上,然后使用函数 attr.ib() 来声明类中的属性。列表 13-12 展示了如何使用 attr 重写 列表 13-11。
import attr
@attr.s
class Car(object):
color = attr.ib()
speed = attr.ib(default=0)
列表 13-12:使用 attr.ib() 声明属性
以这种方式声明时,类会自动获得一些有用的方法,例如 __repr__,该方法在 Python 解释器中打印对象时会被调用:
>>> Car("blue")
Car(color='blue', speed=0)
这个输出比 __repr__ 默认打印的内容更简洁:
<__main__.Car object at 0x104ba4cf8>.
你还可以通过使用 validator 和 converter 关键字参数来为属性添加更多验证。
示例 13-13 展示了如何使用attr.ib()函数声明带有一些约束的属性。
import attr
@attr.s
class Car(object):
color = attr.ib(converter=str)
speed = attr.ib(default=0)
@speed.validator
def speed_validator(self, attribute, value):
if value < 0:
raise ValueError("Value cannot be negative")
示例 13-13:使用带有转换器参数的 attr.ib()
converter 参数管理传递给构造函数的任何内容的转换。validator()函数可以作为参数传递给 attr.ib() 或作为装饰器使用,如 示例 13-13 所示。
attr模块提供了一些内置的验证器(例如,attr.validators.instance_of()用于检查属性的类型),因此在浪费时间自己实现之前,务必先查看它们。
attr 模块还提供了使对象可哈希的调整,使其可以用作集合或字典的键:只需将 frozen=True 传递给 attr.s(),使类实例变为不可变。
示例 13-14 展示了使用 frozen 参数如何改变类的行为。
>>> import attr
>>> @attr.s(frozen=True)
... class Car(object):
... color = attr.ib()
...
>>> {Car("blue"), Car("blue"), Car("red")}
{Car(color='red'), Car(color='blue')}
>>> Car("blue").color = "red"
attr.exceptions.FrozenInstanceError
示例 13-14:使用 frozen=True
示例 13-14 展示了使用 frozen 参数如何改变 Car 类的行为:它可以被哈希化,因此可以存储在集合中,但对象再也无法被修改了。
总之,attr提供了许多有用方法的实现,从而节省了你自己编写这些方法的时间。我强烈推荐在构建类和建模软件时充分利用attr的高效性。
总结
恭喜!你已经读完了这本书。你刚刚提升了你的 Python 技能,并且对如何编写高效且富有生产力的 Python 代码有了更清晰的认识。我希望你像我写这本书一样享受阅读它的过程。
Python 是一门非常棒的语言,可以应用于许多不同的领域,而且还有很多 Python 的内容我们在本书中没有涉及。但每本书总得有个结尾,对吧?
我强烈建议通过阅读现有的开源项目源代码并参与其中来从中获益。让其他开发者审查和讨论你的代码通常是学习的好方法。
快乐编程!


浙公网安备 33010602011771号