Python-专家级编程第二版(全)

Python 专家级编程第二版(全)

原文:zh.annas-archive.org/md5/4CC2EF9A4469C814CC3EEBD966D2E707

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Python 很棒!

从 20 世纪 80 年代末的最早版本到当前版本,它以相同的理念发展:提供一种多范式编程语言,考虑可读性和生产力。

人们过去常常将 Python 视为又一种脚本语言,并不认为它适合用来构建大型系统。然而,多年来,多亏了一些先锋公司,显而易见的是 Python 可以用来构建几乎任何类型的系统。

事实上,许多来自其他语言的开发人员都被 Python 所吸引,并将其作为自己的首选语言。

这是您可能已经知道的内容,所以无需进一步说服您使用这种语言的优点。

本书是根据多年使用 Python 构建各种应用程序的经验而写成的,从几小时内完成的小型系统脚本到由数十名开发人员在数年内编写的非常大型应用程序。

它描述了开发人员在使用 Python 时使用的最佳实践。

本书涵盖了一些不专注于语言本身的主题,而是关于用于处理它的工具和技术。

换句话说,这本书描述了高级 Python 开发人员每天的工作。

本书涵盖的内容

第一章,Python 的当前状态,展示了 Python 语言及其社区的当前状态。它展示了 Python 是如何不断变化的,为什么它在变化,以及为什么这些事实对任何想要称自己为 Python 专业人士的人都很重要。本章还介绍了在 Python 中工作的最流行和规范的方式——流行的生产工具和现在已经成为事实标准的约定。

第二章,语法最佳实践——类级别以下,以高级方式介绍了迭代器、生成器、描述符等。它还涵盖了关于 Python 成语和内部 CPython 类型实现的有用注释,以及它们的计算复杂性作为展示成语的理由。

第三章,语法最佳实践——类级别以上,解释了语法最佳实践,但侧重于类级别以上。它涵盖了 Python 中可用的更高级的面向对象的概念和机制。为了理解本章的最后一部分,需要掌握这些知识,该部分介绍了 Python 中元编程的不同方法。

第四章,选择良好的名称,涉及选择良好的名称。它是对 PEP 8 的扩展,包括命名最佳实践,还提供了有关设计良好 API 的建议。

第五章,编写包,解释了如何创建 Python 包以及使用哪些工具来正确在官方 Python 包索引或任何其他包存储库上分发它。关于包的信息还附有一个简要回顾,介绍了允许您从 Python 源代码创建独立可执行文件的工具。

第六章,部署代码,主要面向 Python 网络开发人员和后端工程师,因为它涉及代码部署。它解释了 Python 应用程序应该如何构建,以便轻松部署到远程服务器,以及您可以使用哪些工具来自动化该过程。这一章与第五章,编写包,相呼应,因为它展示了如何使用包和私有包存储库来简化应用程序部署。

第七章,“其他语言中的 Python 扩展”,解释了为什么有时为 Python 编写 C 扩展可能是一个好的解决方案。它还表明只要使用适当的工具,这并不像看起来那么困难。

第八章,“管理代码”,提供了一些关于如何管理项目代码库的见解,并解释了如何设置各种持续开发流程。

第九章,“记录您的项目”,涵盖了文档编写,并提供了有关技术写作以及如何记录 Python 项目的建议。

第十章,“测试驱动开发”,解释了测试驱动开发的基本原则以及可以在这种开发方法中使用的工具。

第十一章,“优化-一般原则和分析技术”,解释了优化。它提供了分析技术和优化策略指南。

第十二章,“优化-一些强大的技术”,扩展了第十一章,“优化-一般原则和分析技术”,提供了一些常见的解决方案,用于解决 Python 程序中经常出现的性能问题。

第十三章,“并发”,介绍了 Python 中广泛的并发主题。它解释了并发是什么,何时可能需要编写并发应用程序,以及 Python 程序员的并发主要方法。

第十四章,“有用的设计模式”,通过一组有用的设计模式和 Python 中的示例实现来结束本书。

您需要为本书做好准备

这本书是为那些在任何 Python 3 可用的操作系统下工作的开发人员编写的。

这不是一本面向初学者的书,所以我假设您已经在您的环境中安装了 Python,或者知道如何安装它。无论如何,这本书考虑到并非每个人都需要完全了解最新的 Python 功能或官方推荐的工具。这就是为什么第一章提供了常见实用工具(如虚拟环境和 pip)的回顾,这些现在被认为是专业 Python 开发人员的标准工具。

这本书是为谁写的

这本书是为希望在掌握 Python 方面更进一步的 Python 开发人员编写的。而且我所说的开发人员主要是专业人士,所以是以 Python 为生写软件的程序员。这是因为它主要关注的是对于在 Python 中创建高性能、可靠和可维护软件至关重要的工具和实践。

这并不意味着爱好者找不到有趣的东西。这本书对于任何对学习 Python 高级概念感兴趣的人来说都应该很棒。任何具有基本 Python 技能的人都应该能够理解本书的内容,尽管对于经验较少的程序员来说可能需要额外的努力。对于那些仍然使用 Python 2.7 或更早版本的人来说,这也应该是 Python 3.5 的一个很好的介绍。

最终,应该从阅读本书中受益最多的群体是 Web 开发人员和后端工程师。这是因为本书中有两个特别重要的主题:可靠的代码部署和并发。

惯例

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“使用str.encode(encoding, errors)方法,该方法使用注册的编码器对字符串进行编码。”

一块代码设置如下:

[print("hello world")
print "goodbye python2"

当我们希望引起您对代码块的特定部分的注意时,相关行或项目以粗体设置:

cdef long long fibonacci_cc(unsigned int n) nogil:
    if n < 2:
        return n
    else:
        return fibonacci_cc(n - 1) + fibonacci_cc(n - 2)

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

$ pip show pip
---
Metadata-Version: 2.0
Name: pip
Version: 7.1.2
Summary: The PyPA recommended tool for installing Python packages.
Home-page: https://pip.pypa.io/
Author: The pip developers
Author-email: python-virtualenv@groups.google.com
License: MIT
Location: /usr/lib/python2.7/site-packages
Requires:

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中出现在文本中,就像这样:“单击下一步按钮将您移动到下一个屏幕。”

注意

警告或重要说明显示在这样的框中。

提示

提示和技巧显示如下。

第一章:Python 的当前状态

Python 对开发人员来说是很好的。

无论您或您的客户使用什么操作系统,它都可以工作。除非您编写特定于平台的内容,或者使用特定于平台的库,您可以在 Linux 上工作,然后在其他系统上部署,例如。然而,这已经不再是不寻常的事情了(Ruby、Java 和许多其他语言也是这样工作的)。再加上我们将在本书中发现的其他优点,Python 成为公司主要开发语言的明智选择。

本书专注于 Python 的最新版本 3.5,并且所有的代码示例都是用这个版本的语言编写的,除非另一个版本被明确提到。因为这个版本还没有被广泛使用,本章包含了一些关于 Python 3 当前现状的描述,以便向读者介绍它,以及一些关于 Python 开发的现代方法的介绍信息。本章涵盖以下主题:

  • 如何在 Python 2 和 Python 3 之间保持兼容性

  • 如何在应用程序和操作系统级别上处理环境隔离问题,以便进行开发

  • 如何增强 Python 提示

  • 如何使用 pip 安装软件包

一本书总是以一些开胃菜开始的。因此,如果您已经熟悉 Python(尤其是最新的 3.x 分支),并且知道如何正确地隔离开发环境,您可以跳过本章的前两节,快速阅读其他部分。它们描述了一些非必要但可以极大提高 Python 生产力的工具和资源。但是一定要阅读有关应用级环境隔离和 pip 的部分,因为它们的安装对本书的其余部分是强制性的。

我们现在在哪里,我们将走向何方?

Python 的历史始于 20 世纪 80 年代末,但它的 1.0 发布日期是在 1994 年,所以它并不是一种非常年轻的语言。这里可以提到一系列主要的 Python 发布日期,但真正重要的是一个日期:2008 年 12 月 3 日——Python 3.0 的发布日期。

在撰写本文时,距离第一个 Python 3 发布已经过去了七年。自 PEP 404 的创建以来已经过去了四年,这是官方文件,它“取消发布”了 Python 2.8,并正式关闭了 2.x 分支。尽管已经过去了很长时间,但在 Python 社区中存在着一个特定的二分法——虽然语言发展非常迅速,但有一大群用户不愿意向前发展。

为什么以及 Python 如何改变?

答案很简单——Python 之所以改变,是因为有这样的需求。竞争是不会停止的。每隔几个月,就会有一种新语言突然冒出来,声称解决了所有先前语言的问题。大多数这样的项目很快就失去了开发人员的注意,它们的流行程度是由突然的热潮所推动的。

无论如何,这都是一些更大问题的迹象。人们设计新语言,是因为他们发现现有的语言无法以最佳方式解决他们的问题。不承认这种需求是愚蠢的。此外,Python 的越来越广泛的使用表明它在许多方面都可以,也应该得到改进。

Python 的许多改进通常是由其使用领域的特定需求驱动的。最重要的一个是 Web 开发,这需要改进来处理 Python 中的并发性。

一些变化仅仅是由于 Python 项目的年龄和成熟度所致。多年来,它已经收集了一些混乱的东西,例如非组织化和冗余的标准库模块或一些糟糕的设计决策。首先,Python 3 发布旨在为语言带来重大的清理和更新,但时间表明这个计划有些事与愿违。很长一段时间以来,它被许多开发人员视为一种好奇心,但希望情况正在改变。

了解最新变化-PEP 文档

Python 社区有一种处理变化的成熟方式。虽然关于 Python 语言的推测性想法大多在特定的邮件列表上讨论(<python-ideas@python.org>),但没有新的文件称为 PEP,就不会发生重大变化。PEPPython Enhancement Proposal的缩写。它是提出对 Python 的改变的文件,也是社区讨论的起点。这些文件的整个目的、格式和工作流程也是以 Python Enhancement Proposal 的形式标准化的,准确地说,是 PEP 1 文档(www.python.org/dev/peps/pep-0001)。

PEP 文档对 Python 非常重要,根据主题的不同,它们有不同的用途:

  • 通知:它们总结了核心 Python 开发人员所需的信息,并通知 Python 发布时间表

  • 标准化:它们提供代码风格、文档或其他指南

  • 设计:它们描述了提出的功能。

所有提出的 PEP 的列表都可以在一个文档中找到——PEP 0(www.python.org/dev/peps/)。由于它们在一个地方很容易获得,而且实际的 URL 也很容易猜到,它们通常在书中按编号引用。

对于那些想知道 Python 语言发展方向的人,但没有时间跟踪 Python 邮件列表上的讨论,PEP 0 文档可以是一个很好的信息来源。它显示了哪些文件已经被接受但尚未实施,以及哪些仍在考虑中。

PEP 还有其他用途。人们经常问类似的问题:

  • 为什么 A 功能以这种方式工作?

  • 为什么 Python 没有 B 功能?

在大多数这样的情况下,详细答案可以在特定的 PEP 文档中找到,其中已经提到了这样的功能。有很多 PEP 文档描述了提出但未被接受的 Python 语言功能。这些文件被留作历史参考。

写作本书时的 Python 3 采用情况

那么,由于新的激动人心的功能,Python 3 在其社区中被广泛采用吗?可惜,还没有。跟踪 Python 3 分支与大多数流行软件包兼容性的流行页面 Python 3 Wall of Superpowers(python3wos.appspot.com)直到不久前还被称为 Python 3 Wall of Shame。这种情况正在改变,提到的页面上列出的软件包表格每个月都在慢慢变得“更绿”。但是,这并不意味着所有构建应用程序的团队很快就会只使用 Python 3。当所有流行软件包都在 Python 3 上可用时,常见的借口——我们使用的软件包尚未移植——将不再有效。

这种情况的主要原因是,将现有应用程序从 Python 2 迁移到 Python 3 始终是一个挑战。有一些工具,比如 2to3 可以执行自动代码转换,但不能保证结果是 100%正确。此外,这样翻译的代码可能不如原始形式那样表现良好,需要手动调整。将现有复杂代码库移植到 Python 3 可能需要巨大的努力和成本,一些组织可能无法承担。但这样的成本可以分摊。一些良好的软件架构设计方法,如面向服务的架构或微服务,可以帮助逐步实现这一目标。新项目组件(服务或微服务)可以使用新技术编写,现有项目可以逐个移植。

从长远来看,转移到 Python 3 对项目只会产生有益的影响。根据 PEP-404,在 Python 2.x 分支中将不再发布 2.8 版本。此外,将来可能会有一段时间,像 Django、Flask 和 numpy 这样的所有主要项目都将放弃任何 2.x 兼容性,只能在 Python 3 上使用。

我对这个话题的个人看法可能是有争议的。我认为对于社区来说,最好的激励是在创建新软件包时完全放弃对 Python 2 的支持。当然,这大大限制了这类软件的影响范围,但这可能是改变那些坚持使用 Python 2.x 的人思维方式的唯一途径。

Python 3 和 Python 2 之间的主要区别

已经说过 Python 3 与 Python 2 破坏了向后兼容性。但这并不意味着完全重新设计。也不意味着每个为 2.x 版本编写的 Python 模块都将在 Python 3 下停止工作。可以编写完全跨兼容的代码,将在两个主要版本上运行,而无需额外的工具或技术,但通常只适用于简单的应用程序。

我为什么要在意?

尽管我在本章前面提到了我对 Python 2 兼容性的个人看法,但现在不可能就这样忘记它。仍然有一些有用的包(比如在第六章中提到的 fabric,部署代码)真的值得使用,但在不久的将来可能不会被移植。

有时候,我们可能会受到我们所在组织的限制。现有的遗留代码可能非常复杂,以至于移植它在经济上是不可行的。因此,即使我们决定从现在开始只在 Python 3 世界中生活,也不可能在一段时间内完全不使用 Python 2。

如今,要成为专业开发人员,很难不回馈社区,因此帮助开源开发者将现有软件包添加 Python 3 兼容性是偿还使用它们所带来的“道义债务”的好方法。当然,这是不可能做到的,而不知道 Python 2 和 Python 3 之间的差异。顺便说一句,这对于那些刚接触 Python 3 的人来说也是一个很好的练习。

主要的语法差异和常见陷阱

Python 文档是每个版本之间差异的最佳参考。无论如何,为了方便读者,本节总结了最重要的差异。这并不改变文档对于那些尚不熟悉 Python 3 的人来说是必读的事实(参见docs.python.org/3.0/whatsnew/3.0.html)。

Python 3 引入的破坏性更改通常可以分为几个组:

  • 语法更改,其中一些语法元素被移除/更改,其他元素被添加

  • 标准库的更改

  • 数据类型和集合的更改

语法更改

使现有代码难以运行的语法更改是最容易发现的——它们将导致代码根本无法运行。具有新语法元素的 Python 3 代码将无法在 Python 2 上运行,反之亦然。被移除的元素将使 Python 2 代码与 Python 3 明显不兼容。具有这些问题的运行代码将立即导致解释器失败,引发SyntaxError异常。以下是一个破损脚本的示例,其中恰好有两个语句,由于语法错误,都不会被执行:

print("hello world")
print "goodbye python2"

在 Python 3 上运行时的实际结果如下:

$ python3 script.py
 **File "script.py", line 2
 **print "goodbye python2"
 **^
SyntaxError: Missing parentheses in call to 'print'

这样的差异列表有点长,而且,任何新的 Python 3.x 版本可能会不时地添加新的语法元素,这些元素会在早期的 Python 版本(甚至在同一个 3.x 分支上)上引发错误。其中最重要的部分在第二章和第三章中都有所涵盖,因此这里没有必要列出所有这些内容。

从 Python 2.7 中删除或更改的事项列表较短,因此以下是最重要的事项:

  • print不再是语句,而是一个函数,因此括号现在是必需的。

  • 捕获异常从except exc, var变为except exc as var

  • <>比较运算符已被移除,改用!=

  • from module import *docs.python.org/3.0/reference/simple_stmts.html#import)现在只允许在模块级别上使用,不再在函数内部使用。

  • from .[module] import name现在是相对导入的唯一接受的语法。所有不以点字符开头的导入都被解释为绝对导入。

  • sort()函数和列表的sorted()方法不再接受cmp参数。应该使用key参数代替。

  • 整数的除法表达式,如 1/2 会返回浮点数。截断行为是通过//运算符实现的,比如1//2。好处是这也可以用于浮点数,所以5.0//2.0 == 2.0

标准库中的变化

标准库中的重大变化是在语法变化之后最容易捕捉到的。每个后续版本的 Python 都会添加、弃用、改进或完全删除标准库模块。这样的过程在 Python 的旧版本(1.x 和 2.x)中也是常见的,因此在 Python 3 中并不令人震惊。在大多数情况下,根据被移除或重新组织的模块(比如urlparse被移动到urllib.parse),它将在导入时立即引发异常。这使得这类问题很容易被捕捉到。无论如何,为了确保所有这类问题都得到覆盖,完整的测试代码覆盖是必不可少的。在某些情况下(例如,当使用延迟加载模块时),通常在导入时注意到的问题在一些模块作为函数调用的代码中使用之前不会出现。这就是为什么在测试套件中确保每行代码实际执行非常重要。

提示

延迟加载模块

延迟加载模块是在导入时不加载的模块。在 Python 中,import语句可以包含在函数内部,因此导入将在函数调用时发生,而不是在导入时发生。在某些情况下,这种模块的加载可能是一个合理的选择,但在大多数情况下,这是对设计不佳的模块结构的一种变通方法(例如,避免循环导入),并且通常应该避免。毫无疑问,没有理由去延迟加载标准库模块。

数据类型和集合的变化

Python 表示数据类型和集合的变化需要开发人员在尝试保持兼容性或简单地将现有代码移植到 Python 3 时付出最大的努力。虽然不兼容的语法或标准库变化很容易被注意到并且最容易修复,但集合和类型的变化要么不明显,要么需要大量重复的工作。这样的变化列表很长,再次,官方文档是最好的参考。

然而,这一部分必须涵盖 Python 3 中字符串文字处理方式的变化,因为尽管这是一个非常好的变化,现在使事情更加明确,但它似乎是 Python 3 中最具争议和讨论的变化。

所有字符串文字现在都是 Unicode,bytes文字需要bB前缀。对于 Python 3.0 和 3.1,使用u前缀(如u"foo")已被删除,并将引发语法错误。放弃该前缀是所有争议的主要原因。这使得在不同分支的 Python 版本中创建兼容的代码变得非常困难——版本 2.x 依赖于该前缀以创建 Unicode 文字。该前缀在 Python 3.3 中被重新引入以简化集成过程,尽管没有任何语法意义。

用于维护跨版本兼容性的流行工具和技术

在 Python 版本之间保持兼容性是一项挑战。这可能会增加很多额外的工作,具体取决于项目的规模,但绝对是可行的,也是值得做的。对于旨在在许多环境中重复使用的软件包,这是绝对必须的。没有明确定义和测试过的兼容性边界的开源软件包很不可能变得流行,但也是,从不离开公司网络的封闭的第三方代码可以从在不同环境中进行测试中获益。

值得注意的是,虽然本部分主要关注 Python 的各个版本之间的兼容性,但这些方法也适用于与外部依赖项(如不同的软件包版本、二进制库、系统或外部服务)保持兼容性。

整个过程可以分为三个主要领域,按重要性排序:

  • 定义和记录目标兼容性边界以及如何管理它们

  • 在每个环境和每个声明为兼容的依赖版本中进行测试

  • 实施实际的兼容性代码

定义什么被认为是兼容的是整个过程中最重要的部分,因为它为代码的用户(开发人员)提供了对其工作方式和未来可能发生变化的期望和假设的能力。我们的代码可以作为不同项目中的依赖项使用,这些项目可能也致力于管理兼容性,因此理解其行为方式的能力至关重要。

虽然本书试图总是提供几种选择,而不是对特定选项给出绝对建议,但这是少数例外之一。到目前为止,定义未来兼容性可能如何改变的最佳方法是使用语义化版本semver.org/),或简称 semver。它描述了一种广泛接受的标准,通过版本说明符仅由三个数字组成,标记了代码变化的范围。它还提供了一些建议,关于如何处理弃用策略。以下是其摘要的一部分:

给定版本号MAJOR.MINOR.PATCH,递增:

  • 当您进行不兼容的 API 更改时,使用MAJOR版本

  • 在向后兼容的方式中添加功能时的MINOR版本

  • 当您进行向后兼容的错误修复时,使用PATCH版本

预发布和构建元数据的附加标签可作为MAJOR.MINOR.PATCH格式的扩展。

当涉及测试时,令人沮丧的事实是,为了确保代码与每个声明的依赖版本和每个环境(这里是 Python 版本)兼容,必须在这些组合的每个组合中进行测试。当项目具有大量依赖项时,这当然可能是不可能的,因为随着每个新版本的依赖项,组合的数量会迅速增长。因此,通常需要做出一些权衡,以便运行完整的兼容性测试不会花费很长时间。在第十章中介绍了一些帮助测试所谓矩阵的工具,测试驱动开发,讨论了测试。

注意

使用遵循 semver 的项目的好处通常是只需要测试主要版本,因为次要和补丁版本保证不包含向后不兼容的更改。只有在这样的项目可以信任不违反这样的合同时才成立。不幸的是,每个人都会犯错误,并且许多项目甚至在补丁版本上也会发生向后不兼容的更改。然而,由于 semver 声明了次要和补丁版本更改的严格兼容性,违反它被认为是一个错误,因此可以在补丁版本中修复。

兼容性层的实现是最后的,也是最不重要的,如果该兼容性的边界被明确定义并经过严格测试。但是,仍然有一些工具和技术,每个对这样一个主题感兴趣的程序员都应该知道。

最基本的是 Python 的__future__模块。它将一些新版本 Python 的功能移回到旧版本,并采用 import 语句的形式:

from __future__ import <feature>

future语句提供的功能是与语法相关的元素,不能通过其他方式轻松处理。此语句仅影响其使用的模块。以下是 Python 2.7 交互会话的示例,它从 Python 3.0 中引入了 Unicode 文字:

Python 2.7.10 (default, May 23 2015, 09:40:32) [MSC v.1500 32 bit (Intel)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> type("foo")  # old literals
<type 'str'>
>>> from __future__ import unicode_literals
>>> type("foo")  # now is unicode
<type 'unicode'>

以下是所有可用的__future__语句选项的列表,关心 2/3 兼容性的开发人员应该知道:

  • division:这添加了 Python 3 的除法运算符(PEP 238)

  • absolute_import:这使得以点字符开头的import语句的每种形式都被解释为绝对导入(PEP 328)

  • print_function:这将把print语句改为函数调用,因此print周围的括号变得必需(PEP 3112)

  • unicode_literals:这使得每个字符串文字都被解释为 Unicode 文字(PEP 3112)

__future__语句选项的列表非常短,只涵盖了一些语法特性。像元类语法(这是一个高级特性,涵盖在第三章中,语法最佳实践-类级别以上)这样的其他更改,要维护起来就困难得多。可靠地处理多个标准库重组也不能通过future语句解决。幸运的是,有一些工具旨在提供一致的可用兼容性层。最常见的是 Six(pypi.python.org/pypi/six/),它提供了整个通用的 2/3 兼容性样板作为单个模块。另一个有前途但稍微不那么受欢迎的工具是 future 模块(python-future.org/)。

在某些情况下,开发人员可能不希望在一些小包中包含额外的依赖项。一个常见的做法是额外的模块,它收集所有兼容性代码,通常命名为compat.py。以下是从python-gmaps项目(github.com/swistakm/python-gmaps)中获取的这样一个compat模块的示例:

# -*- coding: utf-8 -*-
import sys

if sys.version_info < (3, 0, 0):
    import urlparse  # noqa

    def is_string(s):
        return isinstance(s, basestring)

else:
    from urllib import parse as urlparse  # noqa

    def is_string(s):
        return isinstance(s, str)

即使在依赖于 Six 进行 2/3 兼容性的项目中,这样的compat.py模块也很受欢迎,因为这是一种非常方便的方式来存储处理与用作依赖项的不同版本的包的兼容性的代码。

提示

下载示例代码

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

您可以按照以下步骤下载代码文件:

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

  • 将鼠标指针悬停在顶部的支持选项卡上。

  • 单击代码下载和勘误

  • 搜索框中输入书名。

  • 选择您要下载代码文件的书籍。

  • 从下拉菜单中选择您购买此书的位置。

  • 点击代码下载

下载文件后,请确保使用最新版本的解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Expert-Python-Programming_Second-Edition。我们还有其他丰富的书籍和视频代码包可供使用,网址为github.com/PacktPublishing/。去看看吧!

不仅仅是 CPython

主要的 Python 实现是用 C 语言编写的,称为CPython。当语言发展时,C 实现会相应地进行更改。除了 C,Python 还有其他几种实现,它们试图跟上主流。它们中的大多数都比 CPython 落后了几个里程碑,但为在特定环境中使用和推广该语言提供了绝佳的机会。

我为什么要在意呢?

有很多可用的替代 Python 实现。关于这个主题的 Python Wiki 页面(wiki.python.org/moin/PythonImplementations)列出了 20 多种不同的语言变体、方言或 Python 解释器的实现,这些实现都是用其他语言而不是 C 构建的。其中一些只实现了核心语言语法、特性和内置扩展的子集,但至少有一些几乎完全兼容 CPython。最重要的是要知道,虽然其中一些只是玩具项目或实验,但大多数是为了解决一些真正的问题而创建的——这些问题要么无法用 CPython 解决,要么需要开发人员付出太多的努力。这些问题的例子有:

  • 在嵌入式系统上运行 Python 代码

  • 与 Java 或.NET 等运行时框架编写的代码集成,或者与不同语言编写的代码集成

  • 在 Web 浏览器中运行 Python 代码

本节提供了一个主观上最受欢迎和最新的选择的简短描述,这些选择目前适用于 Python 程序员。

Stackless Python

Stackless Python 自称是 Python 的增强版本。Stackless 之所以被命名为 Stackless,是因为它避免依赖 C 调用堆栈来进行自己的堆栈。实际上,它是修改过的 CPython 代码,还添加了一些当时核心 Python 实现中缺失的新功能。其中最重要的是由解释器管理的微线程,它是普通线程的一种廉价而轻量级的替代,普通线程必须依赖于系统内核上下文切换和任务调度。

最新可用的版本是 2.7.9 和 3.3.5,分别实现了 Python 2.7 和 3.3 版本。Stackless 提供的所有附加功能都通过内置的stackless模块作为该发行版中的一个框架暴露出来。

Stackless 并不是 Python 的最受欢迎的替代实现,但它值得知道,因为它引入的想法对语言社区产生了很大的影响。核心切换功能是从 Stackless 中提取出来的,并发布为一个名为greenlet的独立包,现在已经成为许多有用的库和框架的基础。此外,它的大多数功能已经在 PyPy 中重新实现,PyPy 是另一个稍后将介绍的 Python 实现。参考stackless.readthedocs.org/

Jython

Jython 是语言的 Java 实现。它将代码编译成 Java 字节码,并允许开发人员在其 Python 模块中无缝使用 Java 类。Jython 允许人们在复杂的应用系统中使用 Python 作为顶级脚本语言,例如 J2EE。它还将 Java 应用程序引入 Python 世界。使 Apache Jackrabbit(这是一个基于 JCR 的文档存储库 API;请参见jackrabbit.apache.org)在 Python 程序中可用是 Jython 允许的一个很好的例子。

Jython 的最新可用版本是 Jython 2.7,对应于语言的 2.7 版本。它被宣传为几乎实现了所有核心 Python 标准库,并使用相同的回归测试套件。Jython 3.x 的版本正在开发中。

与 CPython 实现相比,Jython 的主要区别是:

  • 真正的 Java 垃圾回收,而不是引用计数

  • 缺乏全局解释器锁(GIL)允许更好地利用多核在多线程应用程序中

该语言实现的主要弱点是不支持 CPython 扩展 API,因此不支持用 C 编写的 Python 扩展将无法在 Jython 中运行。这可能会在未来发生变化,因为计划在 Jython 3.x 中支持 CPython 扩展 API。

一些 Python Web 框架,如 Pylons,被认为正在推动 Jython 的发展,使其在 Java 世界中可用。参见www.jython.org

IronPython

IronPython 将 Python 引入了.NET Framework。该项目得到了微软的支持,IronPython 的主要开发人员在这里工作。这对于推广一种语言来说是非常重要的实现。除了 Java,.NET 社区是最大的开发者社区之一。值得注意的是,微软提供了一套免费的开发工具,可以将 Visual Studio 变成一个功能齐全的 Python IDE。这被分发为名为PVTSVisual Studio 的 Python 工具)的 Visual Studio 插件,并且作为开源代码在 GitHub 上可用(microsoft.github.io/PTVS)。

最新的稳定版本是 2.7.5,与 Python 2.7 兼容。与 Jython 类似,Python 3.x 实现周围也有一些开发,但目前还没有稳定版本。尽管.NET 主要在 Microsoft Windows 上运行,但也可以在 Mac OS X 和 Linux 上运行 IronPython。这要归功于 Mono,一个跨平台的开源.NET 实现。

IronPython 相对于 CPython 的主要区别或优势如下:

  • 与 Jython 类似,缺乏全局解释器锁(GIL)允许更好地利用多核在多线程应用程序中

  • 用 C#和其他.NET 语言编写的代码可以轻松集成到 IronPython 中,反之亦然

  • 可以在所有主要的 Web 浏览器中通过 Silverlight 运行

IronPython 的弱点与 Jython 非常相似,因为它不支持 CPython 扩展 API。这对于希望使用诸如 numpy 之类的大部分基于 C 扩展的软件包的开发人员来说非常重要。有一个名为 ironclad 的项目(参见github.com/IronLanguages/ironclad),旨在允许使用这些扩展与 IronPython 无缝集成,尽管其最后已知支持的版本是 2.6,开发似乎已经停止。参见ironpython.net/

PyPy

PyPy 可能是最令人兴奋的实现,因为其目标是将 Python 重写为 Python。在 PyPy 中,Python 解释器本身就是用 Python 编写的。我们有一个 C 代码层来执行 Python 的 CPython 实现的基本工作。然而,在 PyPy 实现中,这个 C 代码层被重写为纯 Python。

这意味着您可以在执行时更改解释器的行为,并实现在 CPython 中无法轻松完成的代码模式。

PyPy 目前旨在与 Python 2.7 完全兼容,而 PyPy3 与 Python 3.2.5 版本兼容。

过去,PyPy 主要因理论原因而引人关注,它吸引了那些喜欢深入了解语言细节的人。它并不常用于生产,但这在多年来已经发生了改变。如今,许多基准测试显示,令人惊讶的是,PyPy 通常比 CPython 实现要快得多。该项目有自己的基准测试网站,跟踪每个版本的性能,使用数十种不同的基准测试进行测量(参见speed.pypy.org/)。这清楚地表明,启用 JIT 的 PyPy 至少比 CPython 快几倍。这和 PyPy 的其他特性使越来越多的开发人员决定在生产环境中切换到 PyPy。

PyPy 与 CPython 实现相比的主要区别是:

  • 使用垃圾收集而不是引用计数

  • 集成跟踪 JIT 编译器可以显著提高性能

  • 从 Stackless Python 借用的应用级 Stackless 功能

与几乎所有其他替代 Python 实现一样,PyPy 缺乏对 CPython 扩展 API 的全面官方支持。尽管如此,它至少通过其 CPyExt 子系统提供了对 C 扩展的某种支持,尽管文档贫乏且功能尚不完整。此外,社区正在努力将 NumPy 移植到 PyPy,因为这是最受欢迎的功能。参见pypy.org

Python 开发的现代方法

选择的编程语言的深入理解是作为专家最重要的事情。这对于任何技术来说都是真实的。然而,如果不了解特定语言社区内的常用工具和实践,要开发出优秀的软件是非常困难的。Python 没有任何一个特性是其他语言中找不到的。因此,在语法、表现力或性能的直接比较中,总会有一个或多个领域更好的解决方案。但 Python 真正脱颖而出的领域是围绕该语言构建的整个生态系统。多年来,Python 社区一直在完善标准实践和库,帮助更快地创建更可靠的软件。

提到的生态系统中最明显和重要的部分是大量解决各种问题的免费开源软件包。编写新软件总是一个昂贵且耗时的过程。能够重用现有代码而不是“重复造轮子”大大减少了开发的时间和成本。对一些公司来说,这是他们的项目经济可行的唯一原因。

因此,Python 开发人员花了很多精力来创建工具和标准,以处理他人创建的开源软件包。从虚拟隔离环境、改进的交互式 shell 和调试器,到帮助发现、搜索和分析PyPIPython 软件包索引)上可用的大量软件包的程序。

Python 环境的应用级隔离

如今,许多操作系统都将 Python 作为标准组件。大多数 Linux 发行版和基于 Unix 的系统,如 FreeBSD、NetBSD、OpenBSD 或 OS X,都默认安装了 Python,或者可以通过系统软件包存储库获得。其中许多甚至将其用作一些核心组件的一部分——Python 驱动 Ubuntu(Ubiquity)、Red Hat Linux(Anaconda)和 Fedora(再次是 Anaconda)的安装程序。

由于这个事实,PyPI 上的许多软件包也可以作为系统软件包管理工具(如apt-get(Debian,Ubuntu),rpm(Red Hat Linux)或emerge(Gentoo))管理的本地软件包。尽管应该记住,可用库的列表非常有限,而且与 PyPI 相比,它们大多已经过时。这就是为什么pip应该始终被用来获取最新版本的新软件包,作为PyPAPython Packaging Authority)的建议。尽管它是 CPython 2.7.9 和 3.4 版本的独立软件包,但它默认随每个新版本捆绑发布。安装新软件包就像这样简单:

pip install <package-name>

除其他功能外,pip允许强制使用特定版本的软件包(使用pip install package-name==version语法)并升级到最新可用版本(使用--upgrade开关)。本书中介绍的大多数命令行工具的完整使用说明可以通过简单地运行带有-h--help开关的命令来轻松获得,但这里有一个示例会话,演示了最常用的选项:

$ pip show pip
---
Metadata-Version: 2.0
Name: pip
Version: 7.1.2
Summary: The PyPA recommended tool for installing Python packages.
Home-page: https://pip.pypa.io/
Author: The pip developers
Author-email: python-virtualenv@groups.google.com
License: MIT
Location: /usr/lib/python2.7/site-packages
Requires:

$ pip install 'pip<7.0.0'
Collecting pip<7.0.0
 **Downloading pip-6.1.1-py2.py3-none-any.whl (1.1MB)
 **100% |████████████████████████████████| 1.1MB 242kB/s
Installing collected packages: pip
 **Found existing installation: pip 7.1.2
 **Uninstalling pip-7.1.2:
 **Successfully uninstalled pip-7.1.2
Successfully installed pip-6.1.1
You are using pip version 6.1.1, however version 7.1.2 is available.
You should consider upgrading via the 'pip install --upgrade pip' command.

$ pip install --upgrade pip
You are using pip version 6.1.1, however version 7.1.2 is available.
You should consider upgrading via the 'pip install --upgrade pip' command.
Collecting pip
 **Using cached pip-7.1.2-py2.py3-none-any.whl
Installing collected packages: pip
 **Found existing installation: pip 6.1.1
 **Uninstalling pip-6.1.1:
 **Successfully uninstalled pip-6.1.1
Successfully installed pip-7.1.2

在某些情况下,pip可能不是默认可用的。从 Python 3.4 版本开始(也是 Python 2.7.9),它始终可以使用ensurepip模块进行引导:

$ python -m ensurepip
Ignoring indexes: https://pypi.python.org/simple
Requirement already satisfied (use --upgrade to upgrade): setuptools in /usr/lib/python2.7/site-packages
Collecting pip
Installing collected packages: pip
Successfully installed pip-6.1.1

有关如何为旧版本的 Python 安装 pip 的最新信息,请访问项目的文档页面pip.pypa.io/en/stable/installing/

为什么要隔离?

pip可用于安装系统范围的软件包。在基于 Unix 和 Linux 的系统上,这将需要超级用户权限,因此实际调用将是:

sudo pip install <package-name>

请注意,这在 Windows 上不是必需的,因为它默认不提供 Python 解释器,通常由用户手动安装 Python 而不需要超级用户权限。

无论如何,不建议直接从 PyPI 全局安装系统范围的软件包,应该避免这样做。这似乎与之前的使用pip是 PyPA 建议的说法相矛盾,但这其中有一些严重的原因。如前所述,Python 往往是许多通过操作系统软件包存储库可用的软件包的重要组成部分,并且可能支持许多重要的服务。系统分发维护者在选择正确的软件包版本以匹配各种软件包依赖关系方面付出了很多努力。经常情况下,从系统软件包存储库中可用的 Python 软件包包含自定义补丁或者保持过时,只是为了确保与其他一些系统组件的兼容性。使用pip强制更新此类软件包到破坏某些向后兼容性的版本可能会破坏一些关键的系统服务。

仅在本地计算机上进行开发目的的这样做也不是一个好的借口。这样鲁莽地使用pip几乎总是在自找麻烦,并最终会导致非常难以调试的问题。这并不意味着全局安装来自 PyPI 的软件包是严格禁止的,但它应该始终是有意识地并且了解相关风险的情况下进行的。

幸运的是,有一个简单的解决方案——环境隔离。有各种工具可以在不同系统抽象级别上隔离 Python 运行时环境。主要思想是将项目依赖项与不同项目和/或系统服务所需的软件包隔离开来。这种方法的好处是:

  • 它解决了“项目 X 依赖于 1.x 版本,但项目 Y 需要 4.x”困境。开发人员可以在具有不同依赖关系的多个项目上工作,甚至可能发生冲突,而不会影响彼此。

  • 项目不再受限于系统分发存储库中提供的软件包版本。

  • 由于新的包版本只在这样的环境中可用,所以不会有破坏依赖于特定包版本的其他系统服务的风险。

  • 项目依赖的包列表可以很容易地被“冻结”,因此很容易重现它们。

最简单和最轻量级的隔离方法是使用应用级虚拟环境。它们只专注于隔离 Python 解释器和其中可用的包。它们非常容易设置,通常足以确保在开发小型项目和包时进行适当的隔离。

不幸的是,在某些情况下,这可能不足以确保足够的一致性和可重现性。对于这种情况,系统级别的隔离是工作流程的一个很好的补充,本章后面将解释一些可用的解决方案。

流行的解决方案

有几种隔离 Python 运行时的方法。最简单和最明显的方法,尽管最难维护,是手动更改PATHPYTHONPATH环境变量和/或将 Python 二进制文件移动到不同的位置,以影响它发现可用包的方式,并将其更改为我们想要存储项目依赖项的自定义位置。幸运的是,有几种可用的工具可以帮助维护虚拟环境以及安装包在系统中的存储方式。主要有:virtualenvvenvbuildout。它们在底层的操作实际上与我们手动操作的相同。实际的策略取决于具体的工具实现,但通常它们更方便使用并且可以提供额外的好处。

virtualenv

Virtualenv 是这个列表中迄今为止最受欢迎的工具。它的名字简单地代表虚拟环境。它不是标准 Python 发行版的一部分,因此需要使用pip来获取。它是值得系统范围内安装的包之一(在 Linux 和基于 Unix 的系统上使用sudo)。

一旦安装完成,可以使用以下命令创建一个新的虚拟环境:

virtualenv ENV

在这里,ENV应该被新环境的期望名称替换。这将在当前工作目录路径中创建一个新的ENV目录。它将包含几个新的目录:

  • bin/:这是存储新 Python 可执行文件和其他包提供的脚本/可执行文件的地方。

  • lib/include/:这些目录包含了虚拟环境中新 Python 的支持库文件。新的包将安装在ENV/lib/pythonX.Y/site-packages/中。

一旦创建了新的环境,就需要在当前 shell 会话中使用 Unix 的 source 命令激活它:

source ENV/bin/activate

这会通过影响其环境变量改变当前 shell 会话的状态。为了让用户意识到他已经激活了虚拟环境,它会通过在其开头添加(ENV)字符串来改变 shell 提示。以下是一个创建新环境并激活它的示例会话:

$ virtualenv example
New python executable in example/bin/python
Installing setuptools, pip, wheel...done.
$ source example/bin/activate
(example)$ deactivate
$** 

关于virtualenv的重要事情是,它完全依赖于存储在文件系统上的状态。它不提供任何额外的能力来跟踪应该安装在其中的包。这些虚拟环境不可移植,不应该移动到另一个系统/机器上。这意味着需要为每个新的应用部署从头开始创建新的虚拟环境。因此,virtualenv用户使用的一个良好的实践是将所有项目依赖项存储在requirements.txt文件中(这是命名约定),如下面的代码所示:

# lines followed by hash (#) are treated as a comments

# strict version names are best for reproducibility
eventlet==0.17.4
graceful==0.1.1

# for projects that are well tested with different
# dependency versions the relative version specifiers 
# are acceptable too
falcon>=0.3.0,<0.5.0

# packages without versions should be avoided unless
# latest release is always required/desired
pytz

有了这样的文件,所有依赖项都可以很容易地使用pip进行安装,因为它接受 requirements 文件作为其输出。

pip install -r requirements.txt

需要记住的是,要求文件并不总是理想的解决方案,因为它并没有定义确切的依赖项列表,只有要安装的依赖项。因此,整个项目在开发环境中可以正常工作,但如果要求文件过时并且不反映环境的实际状态,它将无法在其他环境中启动。当然,有pip freeze命令可以打印当前环境中的所有软件包,但不应该盲目使用它——它会输出所有内容,甚至是仅用于测试而不在项目中使用的软件包。书中提到的另一个工具buildout解决了这个问题,因此对于一些开发团队来说,它可能是更好的选择。

注意

对于 Windows 用户,在 Windows 下,virtualenv使用不同的命名方式来命名其目录的内部结构。您需要使用Scripts/Libs/Include/,而不是bin/lib/include/,以更好地匹配该操作系统上的开发约定。激活/停用环境的命令也不同;您需要使用ENV/Scripts/activate.batENV/Scripts/deactivate.bat,而不是在activatedeactivate脚本上使用source

venv

虚拟环境很快在社区内得到了很好的建立,并成为了一个受欢迎的工具。从 Python 3.3 开始,创建虚拟环境得到了标准库的支持。使用方式几乎与 Virtualenv 相同,尽管命令行选项的命名约定有很大不同。新的venv模块提供了一个pyvenv脚本来创建一个新的虚拟环境。

pyvenv ENV

这里,ENV应该被新环境的期望名称所替换。此外,现在可以直接从 Python 代码中创建新环境,因为所有功能都是从内置的venv模块中公开的。其他用法和实现细节,如环境目录的结构和激活/停用脚本,大部分与 Virtualenv 相同,因此迁移到这个解决方案应该是简单而无痛的。

对于使用较新版本 Python 的开发人员,建议使用venv而不是 Virtualenv。对于 Python 3.3,切换到venv可能需要更多的努力,因为在这个版本中,它不会默认在新环境中安装setuptoolspip,因此用户需要手动安装它们。幸运的是,这在 Python 3.4 中已经改变,而且由于venv的可定制性,可以覆盖其行为。详细信息在 Python 文档中有解释(参见docs.python.org/3.5/library/venv.html),但一些用户可能会发现它太棘手,会选择在特定版本的 Python 中继续使用 Virtualenv。

buildout

Buildout 是一个强大的用于引导和部署用 Python 编写的应用程序的工具。书中还将解释一些其高级功能。很长一段时间以来,它也被用作创建隔离的 Python 环境的工具。因为 Buildout 需要一个声明性的配置,必须在依赖关系发生变化时进行更改,而不是依赖于环境状态,因此这些环境更容易复制和管理。

很不幸,这已经改变了。自 2.0.0 版本以来,buildout软件包不再尝试提供与系统 Python 安装的任何级别的隔离。隔离处理留给其他工具,如 Virtualenv,因此仍然可以拥有隔离的 Buildouts,但事情变得有点复杂。必须在隔离的环境中初始化 Buildout 才能真正实现隔离。

与 Buildout 的旧版本相比,这有一个主要缺点,因为它依赖于其他解决方案进行隔离。编写此代码的开发人员不再能确定依赖关系描述是否完整,因为一些软件包可以通过绕过声明性配置进行安装。当然,这个问题可以通过适当的测试和发布程序来解决,但它给整个工作流程增加了一些复杂性。

总之,Buildout 不再是提供环境隔离的解决方案,但其声明性配置可以提高虚拟环境的可维护性和可重现性。

选择哪一个?

没有一种最佳解决方案适用于所有用例。在一个组织中适用的东西可能不适合其他团队的工作流程。此外,每个应用程序都有不同的需求。小型项目可以轻松依赖于单独的virtualenvvenv,但更大的项目可能需要buildout的额外帮助来执行更复杂的组装。

之前没有详细描述的是,Buildout 的旧版本(buildout<2.0.0)允许在与 Virtualenv 提供的类似结果的隔离环境中组装项目。不幸的是,该项目的 1.x 分支不再维护,因此不鼓励将其用于此目的。

我建议尽可能使用venv模块而不是 Virtualenv。因此,这应该是针对 Python 版本 3.4 及更高版本的项目的默认选择。在 Python 3.3 中使用venv可能有点不方便,因为缺乏对setuptoolspip的内置支持。对于针对更广泛的 Python 运行时(包括替代解释器和 2.x 分支)的项目,似乎 Virtualenv 是最佳选择。

系统级环境隔离

在大多数情况下,软件实现可以快速迭代,因为开发人员重用许多现有组件。不要重复自己——这是许多程序员的流行规则和座右铭。使用其他软件包和模块将它们包含在代码库中只是这种文化的一部分。可以被视为“重复使用组件”的还有二进制库、数据库、系统服务、第三方 API 等。甚至整个操作系统也应该被视为被重复使用。

基于 Web 的应用程序的后端服务是这类应用程序可以有多复杂的一个很好的例子。最简单的软件堆栈通常由几个层组成(从最低层开始):

  • 数据库或其他类型的存储设备

  • Python 实现的应用程序代码

  • 像 Apache 或 NGINX 这样的 HTTP 服务器

当然,这样的堆栈可能会更简单,但这是非常不可能的。事实上,大型应用程序通常非常复杂,很难区分单个层。大型应用程序可以使用许多不同的数据库,分为多个独立的进程,并使用许多其他系统服务进行缓存、排队、日志记录、服务发现等。遗憾的是,复杂性没有限制,代码似乎只是遵循热力学第二定律。

真正重要的是,并非所有软件堆栈元素都可以在 Python 运行时环境的级别上进行隔离。无论是 NGINX 这样的 HTTP 服务器还是 PostgreSQL 这样的 RDBMS,它们通常在不同系统上有不同版本。确保开发团队中的每个人使用每个组件的相同版本是非常困难的,没有适当的工具。理论上,一个团队中所有开发人员在一个项目上工作时能够在他们的开发环境中获得相同版本的服务。但是,如果他们不使用与生产环境相同的操作系统,所有这些努力都是徒劳的。而且,强迫程序员在他所钟爱的系统之外工作是不可能的。

问题在于可移植性仍然是一个巨大的挑战。并非所有服务在生产环境中都能像在开发者的机器上那样完全相同地工作,这种情况很可能不会改变。即使是 Python,尽管已经付出了很多工作来使其跨平台,但在不同的系统上可能会有不同的行为。通常,这些情况都有很好的文档记录,而且只会发生在直接依赖系统调用的地方,但依赖程序员记住一长串兼容性怪癖的能力是相当容易出错的策略。

解决这个问题的一个流行的解决方案是通过将整个系统隔离为应用程序环境。这通常是通过利用不同类型的系统虚拟化工具来实现的。虚拟化当然会降低性能,但对于具有硬件虚拟化支持的现代计算机来说,性能损失通常是可以忽略不计的。另一方面,可能获得的潜在收益列表非常长:

  • 开发环境可以完全匹配生产中使用的系统版本和服务,有助于解决兼容性问题

  • 系统配置工具(如 Puppet、Chef 或 Ansible)的定义(如果使用)可以被重用于配置开发环境

  • 如果创建这样的环境是自动化的,新加入的团队成员可以轻松地加入项目

  • 开发人员可以直接使用低级别的系统功能,这些功能可能在他们用于工作的操作系统上不可用,例如,在 Windows 中不可用的FUSE(用户空间文件系统)

使用 Vagrant 创建虚拟开发环境

Vagrant 目前似乎是提供创建和管理开发环境的最流行的工具。它适用于 Windows、Mac OS 和一些流行的 Linux 发行版(参见www.vagrantup.com)。它没有任何额外的依赖。Vagrant 以虚拟机或容器的形式创建新的开发环境。具体的实现取决于虚拟化提供者的选择。VirtualBox 是默认提供者,并且它已经与 Vagrant 安装程序捆绑在一起,但也有其他提供者可用。最显著的选择是 VMware、Docker、LXC(Linux 容器)和 Hyper-V。

Vagrant 中提供的最重要的配置是一个名为Vagrantfile的单个文件。它应该独立于每个项目。它提供的最重要的内容如下:

  • 虚拟化提供者的选择

  • 用作虚拟机镜像的 Box

  • 配置方法的选择

  • 虚拟机和虚拟机主机之间的共享存储

  • 需要在虚拟机和其主机之间转发的端口

Vagrantfile的语法语言是 Ruby。示例配置文件提供了一个很好的模板来启动项目,并且有很好的文档,因此不需要了解这种语言。可以使用一个命令创建模板配置:

vagrant init

这将在当前工作目录中创建一个名为Vagrantfile的新文件。通常最好将此文件存储在相关项目源的根目录。这个文件已经是一个有效的配置,将使用默认提供者和基础盒子镜像创建一个新的虚拟机。默认情况下不启用任何配置。添加了Vagrantfile后,可以使用以下命令启动新的虚拟机:

vagrant up

初始启动可能需要几分钟,因为实际的盒子必须从网络上下载。每次启动已经存在的虚拟机时,还会有一些初始化过程,这可能需要一些时间,具体取决于所使用的提供者、盒子和系统性能。通常,这只需要几秒钟。一旦新的 Vagrant 环境启动并运行,开发人员可以使用以下简写连接到 SSH:

vagrant ssh

这可以在Vagrantfile位置下的项目源树中的任何位置完成。为了开发者的方便起见,我们将在上面的目录中查找配置文件,并将其与相关的 VM 实例进行匹配。然后,它建立安全外壳连接,因此开发环境可以像任何普通的远程机器一样进行交互。唯一的区别是整个项目源树(根定义为Vagrantfile位置)在 VM 的文件系统下的/vagrant/下是可用的。

容器化与虚拟化

容器是完整机器虚拟化的替代方案。这是一种轻量级的虚拟化方法,其中内核和操作系统允许运行多个隔离的用户空间实例。操作系统在容器和主机之间共享,因此在理论上需要的开销比完整虚拟化要少。这样的容器只包含应用程序代码和其系统级依赖项,但从内部运行的进程的角度来看,它看起来像一个完全隔离的系统环境。

软件容器主要得益于 Docker 而变得流行;这是其中一种可用的实现。Docker 允许以称为Dockerfile的简单文本文档描述其容器。根据这些定义,可以构建和存储容器。它还支持增量更改,因此如果容器中添加了新内容,则不需要从头开始重新创建。

不同的工具,如 Docker 和 Vagrant,似乎在功能上有重叠,但它们之间的主要区别在于这些工具被构建的原因。如前所述,Vagrant 主要是作为开发工具构建的。它允许用单个命令引导整个虚拟机,但不允许简单地打包并部署或发布。另一方面,Docker 则是专门为此而构建的——准备完整的容器,可以作为一个整体包发送和部署到生产环境。如果实施得当,这可以极大地改善产品部署的过程。因此,在开发过程中使用 Docker 和类似的解决方案(例如 Rocket)只有在它也必须在生产环境中的部署过程中使用时才有意义。仅在开发过程中用于隔离目的可能会产生太多的开销,而且还有一个不一致的缺点。

流行的生产力工具

生产力工具是一个有点模糊的术语。一方面,几乎每个发布并在网上可用的开源代码包都是一种提高生产力的工具——它提供了现成的解决方案,使得没有人需要花时间去解决它(理想情况下)。另一方面,有人可能会说整个 Python 都是关于生产力的。这两者无疑都是真的。这种语言和围绕它的社区几乎所有的东西似乎都是为了使软件开发尽可能地高效。

这创造了一个积极的反馈循环。由于编写代码是有趣且容易的,许多程序员会利用业余时间创建使其更容易和有趣的工具。这个事实将被用作生产力工具的一个非常主观和非科学的定义的基础——一种使开发更容易和更有趣的软件。

自然而然,生产力工具主要关注开发过程的某些元素,如测试、调试和管理软件包,并不是它们帮助构建的产品的核心部分。在某些情况下,它们甚至可能根本没有在项目的代码库中被提及,尽管它们每天都在使用。

最重要的生产力工具pipvenv在本章的前面已经讨论过。其中一些工具有针对特定问题的软件包,如性能分析和测试,并在本书中有它们自己的章节。本节专门介绍了其他一些值得一提的工具,但在本书中没有专门的章节可以介绍它们。

自定义 Python shell - IPython,bpython,ptpython 等。

Python 程序员在交互式解释器会话中花费了大量时间。它非常适合测试小的代码片段,访问文档,甚至在运行时调试代码。默认的交互式 Python 会话非常简单,不提供诸如制表符补全或代码内省助手之类的许多功能。幸运的是,默认的 Python shell 可以很容易地扩展和自定义。

交互提示可以通过启动文件进行配置。启动时,它会查找PYTHONSTARTUP环境变量,并执行由该变量指向的文件中的代码。一些 Linux 发行版提供了一个默认的启动脚本,通常位于您的主目录中。它被称为.pythonstartup。制表符补全和命令历史记录通常会提供以增强提示,并且基于readline模块。(您需要readline库。)

如果您没有这样的文件,可以轻松创建一个。以下是一个添加了使用<Tab>键和历史记录的最简单启动文件的示例:

# python startup file
import readline
import rlcompleter
import atexit
import os

# tab completion
readline.parse_and_bind('tab: complete')

# history file
histfile = os.path.join(os.environ['HOME'], '.pythonhistory')
try:
    readline.read_history_file(histfile)

except IOError:
    pass

atexit.register(readline.write_history_file, histfile)
del os, histfile, readline, rlcompleter

在您的主目录中创建此文件,并将其命名为.pythonstartup。然后,在环境中添加一个PYTHONSTARTUP变量,使用您文件的路径:

设置PYTHONSTARTUP环境变量

如果您正在运行 Linux 或 Mac OS X,最简单的方法是在您的主文件夹中创建启动脚本。然后,将其链接到设置为系统 shell 启动脚本的PYTHONSTARTUP环境变量。例如,Bash 和 Korn shells 使用.profile文件,您可以插入一行如下:

export PYTHONSTARTUP=~/.pythonstartup

如果您正在运行 Windows,可以在系统首选项中以管理员身份设置新的环境变量,并将脚本保存在一个常用位置,而不是使用特定的用户位置。

编写PYTHONSTARTUP脚本可能是一个很好的练习,但独自创建一个良好的自定义 shell 是一项只有少数人能够抽出时间来完成的挑战。幸运的是,有一些自定义 Python shell 实现极大地改善了 Python 交互式会话的体验。

IPython

IPyhton(ipython.scipy.org)提供了一个扩展的 Python 命令行。提供的功能中,最有趣的是:

  • 动态对象内省

  • 从提示中访问系统 shell

  • 直接支持分析

  • 调试设施

现在,IPython 是一个名为 Jupyter 的更大项目的一部分,它提供了可以用许多不同语言编写的具有实时代码的交互式笔记本。

bpython

bpython(bpython-interpreter.org/)将自己宣传为 Python 解释器的时髦界面。以下是该项目页面上强调的一些内容:

  • 内联语法高亮

  • 类似 Readline 的自动完成,建议在您输入时显示

  • 任何 Python 函数的预期参数列表

  • 自动缩进

  • Python 3 支持

ptpython

ptpython(github.com/jonathanslenders/ptpython/)是另一种高级 Python shell 主题的方法。在这个项目中,核心提示工具的实现可作为一个名为prompt_toolkit的单独包使用(来自同一作者)。这使您可以轻松创建各种美观的交互式命令行界面。

它经常与 bpython 在功能上进行比较,但主要区别在于它启用了与 IPython 和其语法的兼容模式,从而启用了额外的功能,如%pdb%cpaste%profile

交互式调试器

代码调试是软件开发过程的一个重要组成部分。许多程序员可能会花费大部分时间仅使用广泛的日志记录和print语句作为他们的主要调试工具,但大多数专业开发人员更喜欢依赖某种调试器。

Python 已经内置了一个名为pdb的交互式调试器(参见docs.python.org/3/library/pdb.html)。它可以从命令行上调用现有的脚本,因此如果程序异常退出,Python 将进入事后调试:

python -m pdb script.py

事后调试虽然有用,但并不涵盖每种情况。它仅在应用程序以某种异常退出时有用。在许多情况下,有错误的代码只是表现异常,但并不会意外退出。在这种情况下,可以使用这个单行习惯用法在特定代码行上设置自定义断点:

import pdb; pdb.set_trace()

这将导致 Python 解释器在运行时在此行开始调试会话。

pdb对于追踪问题非常有用,乍一看,它可能看起来非常熟悉,就像著名的 GDB(GNU 调试器)。由于 Python 是一种动态语言,pdb会话非常类似于普通的解释器会话。这意味着开发人员不仅限于追踪代码执行,还可以调用任何代码,甚至执行模块导入。

遗憾的是,由于其根源(bdb),对pdb的第一次体验可能会有点压倒性,因为存在着诸如hbsnjr等神秘的短字母调试器命令。每当有疑问时,在调试器会话期间键入help pdb命令将提供广泛的用法和额外信息。

pdb 中的调试器会话也非常简单,不提供诸如制表符补全或代码高亮之类的附加功能。幸运的是,PyPI 上有一些包可提供这些功能,这些功能可以从前一节提到的替代 Python shell 中使用。最值得注意的例子有:

  • ipdb:这是一个基于ipython的独立包

  • ptpdb:这是一个基于ptpython的独立包

  • bpdb:这是与bpython捆绑在一起的

有用的资源

网络上充满了对 Python 开发人员有用的资源。最重要和明显的资源已经在前面提到过,但为了保持这个列表的一致性,这里重复一遍:

  • Python 文档

  • PyPI—Python 包索引

  • PEP 0—Python 增强提案索引

其他资源,如书籍和教程,虽然有用,但往往很快就会过时。不会过时的是由社区积极策划或定期发布的资源。其中最值得推荐的两个是:

这两个资源将为读者提供数月的额外阅读。

总结

本章从 Python 2 和 3 之间的差异开始,提出了如何处理当前情况的建议,其中大部分社区都在两个世界之间挣扎。然后,它涉及到了由于语言的两个主要版本之间的不幸分裂而出现的 Python 开发的现代方法。这些主要是环境隔离问题的不同解决方案。本章以对流行的生产工具和进一步参考的流行资源的简短总结结束。

第二章:语法最佳实践-类级别以下

编写高效的语法能力随着时间自然而然地产生。如果你回顾一下你的第一个程序,你可能会同意这一点。正确的语法会让你眼前一亮,而错误的语法会让人感到不安。

除了实现的算法和程序的架构设计,对程序的编写方式进行精心设计对其未来的发展至关重要。许多程序因其晦涩的语法、不清晰的 API 或非常规的标准而被抛弃并从头开始重写。

但是 Python 在过去几年里发生了很大的变化。因此,如果你被邻居(当地 Ruby 开发者用户组的一个嫉妒的家伙)绑架了一段时间并远离了新闻,你可能会对它的新功能感到惊讶。从最早的版本到当前版本(此时为 3.5),已经进行了许多增强,使语言更加清晰、干净和易于编写。Python 的基础并没有发生根本性的变化,但现在玩耍它们的工具更加人性化。

本章介绍了现代语法的最重要元素以及有关它们使用的提示:

  • 列表推导式

  • 迭代器和生成器

  • 描述符和属性

  • 装饰器

  • withcontextlib

有关代码性能提升或内存使用的速度改进的提示在第十一章 优化-一般原则和分析技术和第十二章 优化-一些强大的技术中有所涉及。

Python 的内置类型

Python 提供了一组很棒的数据类型。这对于数字类型和集合类型都是如此。关于数字类型,它们的语法没有什么特别之处。当然,对于每种类型的字面量定义和一些(也许)不太为人所知的运算符细节有一些差异,但对于开发人员来说,选择余地并不多。当涉及到集合和字符串时情况就不同了。尽管"应该只有一种方法来做某事"的信条,但 Python 开发人员确实有很多选择。对于初学者来说,一些看起来直观简单的代码模式经常被有经验的程序员认为是非Pythonic的,因为它们要么效率低下,要么太啰嗦。

解决常见问题的Pythonic模式(许多程序员称之为习语)通常看起来只是美学。这是完全错误的。大多数习语是由 Python 内部实现以及内置结构和模块的工作方式驱动的。了解更多这样的细节对于对语言的深入理解至关重要。此外,社区本身也不乏关于 Python 工作原理的神话和刻板印象。只有通过自己深入挖掘,你才能判断关于 Python 的流行说法中哪些是真的。

字符串和字节

对于只习惯于在 Python 2 中编程的程序员来说,字符串可能会带来一些困惑。在 Python 3 中,只有一种数据类型能够存储文本信息。它是str或者简单地说,字符串。它是一个存储 Unicode 码点的不可变序列。这是与 Python 2 的主要区别,其中str表示字节字符串-现在由bytes对象处理(但处理方式并不完全相同)。

Python 中的字符串是序列。这一事实应该足以将它们包括在涵盖其他容器类型的部分中,但它们与其他容器类型在一个重要的细节上有所不同。字符串对它们可以存储的数据类型有非常具体的限制,那就是 Unicode 文本。

字节及其可变替代品(bytearray)与str的不同之处在于只允许字节作为序列值——范围在0 <= x < 256的整数。这可能会在开始时造成困惑,因为打印时它们可能看起来与字符串非常相似:

>>> print(bytes([102, 111, 111]))
b'foo'

当将bytesbytearray转换为另一种序列类型(如listtuple)时,它们的真实性质就显露出来了:

>>> list(b'foo bar')
[102, 111, 111, 32, 98, 97, 114]
>>> tuple(b'foo bar')
(102, 111, 111, 32, 98, 97, 114)

Python 3 的许多争议都是关于打破字符串字面量的向后兼容性以及如何处理 Unicode。从 Python 3.0 开始,每个未加前缀的字符串字面量都是 Unicode。因此,用单引号(')、双引号(")或三引号(单引号或双引号)括起来的字面量代表str数据类型:

>>> type("some string")
<class 'str'>

在 Python 2 中,Unicode 字面量需要u前缀(如u"some string")。这个前缀仍然允许用于向后兼容(从 Python 3.3 开始),但在 Python 3 中没有任何语法意义。

在之前的一些示例中已经介绍了字节字面量,但为了保持一致,让我们明确介绍其语法。字节字面量也可以用单引号、双引号或三引号括起来,但必须以bB前缀开头:

>>> type(b"some bytes")
<class 'bytes'>

请注意,Python 语法中没有bytearray字面量。

最后但同样重要的是,Unicode 字符串包含与字节表示独立的“抽象”文本。这使它们无法在磁盘上保存或在网络上传输而不进行编码为二进制数据。有两种方法可以将字符串对象编码为字节序列:

  • 使用str.encode(encoding, errors)方法,使用注册的编解码器对字符串进行编码。编解码器使用encoding参数指定,默认为'utf-8'。第二个 errors 参数指定错误处理方案。它可以是'strict'(默认值)、'ignore''replace''xmlcharrefreplace'或任何其他已注册的处理程序(参考内置codecs模块文档)。

  • 使用bytes(source, encoding, errors)构造函数创建一个新的字节序列。当源是str类型时,encoding参数是必需的,且没有默认值。encodingerrors参数的使用与str.encode()方法相同。

bytes表示的二进制数据可以以类似的方式转换为字符串:

  • 使用bytes.decode(encoding, errors)方法,使用为编码注册的编解码器对字节进行解码。此方法的参数与str.encode()的参数具有相同的含义和默认值。

  • 使用str(source, encoding, error)构造函数创建一个新的字符串实例。与bytes()构造函数类似,str()调用中的encoding参数没有默认值,如果字节序列用作源,则必须提供。

提示

命名——字节与字节字符串

由于 Python 3 中的更改,一些人倾向于将bytes实例称为字节字符串。这主要是由于历史原因——Python 3 中的bytes是与 Python 2 中的str类型最接近的序列类型(但不完全相同)。但是,bytes实例是字节序列,也不需要表示文本数据。因此,为了避免混淆,最好总是将它们称为bytes或字节序列,尽管它们与字符串相似。在 Python 3 中,字符串的概念是保留给文本数据的,现在始终是str

实现细节

Python 字符串是不可变的。这也适用于字节序列。这是一个重要的事实,因为它既有优点又有缺点。它还影响了在 Python 中高效处理字符串的方式。由于不可变性,字符串可以用作字典键或set集合元素,因为一旦初始化,它们将永远不会改变其值。另一方面,每当需要修改字符串(即使只有微小的修改)时,都需要创建一个全新的实例。幸运的是,bytearray作为bytes的可变版本不会引入这样的问题。字节数组可以通过项目赋值进行就地修改(无需创建新对象),并且可以像列表一样动态调整大小 - 使用附加、弹出、插入等。

字符串连接

知道 Python 字符串是不可变的这个事实会在需要连接多个字符串实例时带来一些问题。如前所述,连接任何不可变序列都会导致创建一个新的序列对象。考虑到通过多个字符串的重复连接构建新字符串,如下所示:

s = ""
for substring in substrings:
    s += substring

这将导致总字符串长度的二次运行时成本。换句话说,这是非常低效的。为了处理这种情况,有str.join()方法可用。它接受字符串的可迭代对象作为参数并返回一个连接的字符串。因为它是方法,实际的习惯用法使用空字符串文字作为方法的来源:

s = "".join(substrings)

提供此方法的字符串将用作连接的子字符串之间的分隔符;请考虑以下示例:

>>> ','.join(['some', 'comma', 'separated', 'values'])
'some,comma,separated,values'

值得记住的是,仅仅因为它更快(特别是对于大型列表),并不意味着在每个需要连接两个字符串的情况下都应该使用join()方法。尽管它是一个广泛认可的习惯用法,但它并不会提高代码的可读性 - 可读性很重要!还有一些情况下,join()可能不如普通的加法连接表现得好。以下是一些例子:

  • 如果子字符串的数量很少,并且它们尚未被某个可迭代对象包含 - 在某些情况下,创建新序列的开销可能会掩盖使用join()的收益。

  • 在连接短文字时,由于 CPython 中的常量折叠,一些复杂的文字(不仅仅是字符串),例如'a' + 'b' + 'c''abc'可以在编译时转换为更短的形式。当然,这仅对相对较短的常量(文字)启用。

最终,如果字符串连接的数量事先已知,最佳的可读性是通过适当的字符串格式化来确保的,可以使用str.format()方法或%运算符。在性能不是关键或优化字符串连接的收益非常小的代码部分,建议使用字符串格式化作为最佳选择。

提示

常量折叠和窥孔优化器

CPython 在编译源代码上使用窥孔优化器以提高性能。该优化器直接在 Python 的字节码上实现了许多常见的优化。如前所述,常量折叠就是其中之一。生成的常量受硬编码值的长度限制。在 Python 3.5 中,它仍然不变,等于 20。无论如何,这个特定的细节更像是一个好奇心,而不是日常编程中可以依赖的东西。有关窥孔优化器执行的其他有趣优化的信息可以在 Python 源代码的Python/peephole.c文件中找到。

集合

Python 提供了一系列内置的数据集合,如果选择得当,可以有效地解决许多问题。您可能已经知道的类型是那些具有专用文字的类型:

  • 列表

  • 元组

  • 字典

  • 集合

当然,Python 不仅限于这四种选择,并通过其标准库扩展了可能的选择列表。在许多情况下,解决问题的解决方案可能就像为数据结构做出良好选择一样简单。本书的这一部分旨在通过深入了解可能的选择来简化这样的决定。

列表和元组

Python 中最基本的两种集合类型是列表和元组,它们都表示对象的序列。它们之间的基本区别对于任何花费了几个小时以上的 Python 用户来说应该是显而易见的—列表是动态的,因此可以改变其大小,而元组是不可变的(它们在创建后无法修改)。

尽管元组具有许多各种优化,使得小对象的分配/释放变得快速,但它们是存储元素位置本身信息的推荐数据类型。例如,元组可能是存储一对(x, y)坐标的良好选择。无论如何,关于元组的细节都相当无聊。在本章的范围内,它们唯一重要的事情是tuple不可变的,因此可散列的。这意味着什么将在字典部分中介绍。比元组更有趣的是它的动态对应物list,它是如何工作的,以及如何有效地处理它。

实现细节

许多程序员很容易将 Python 的list类型与其他语言的标准库中经常找到的链表概念混淆,比如 C、C++或 Java。实际上,在 CPython 中,列表根本不是列表。在 CPython 中,列表是作为可变长度数组实现的。尽管这些实现细节通常在这些项目中没有记录,但这对于 Jython 和 IronPython 等其他实现也是正确的。造成这种混淆的原因很明显。这种数据类型被命名为list,并且还具有可以从任何链表实现中预期的接口。

这为什么重要,意味着什么?列表是最流行的数据结构之一,它们的使用方式极大地影响了每个应用程序的性能。此外,CPython 是最流行和使用最广泛的实现,因此了解其内部实现细节至关重要。

具体来说,Python 中的列表是对其他对象的连续数组的引用。指向此数组的指针和长度存储在列表头结构中。这意味着每次添加或删除项目时,都需要调整引用数组的大小(重新分配)。幸运的是,在 Python 中,这些数组是以指数过分分配创建的,因此不是每个操作都需要调整大小。这就是为什么在复杂度方面,附加和弹出元素的摊销成本可以很低。不幸的是,在 Python 中,一些在普通链表中被认为是“便宜”的其他操作具有相对较高的计算复杂度:

  • 使用list.insert方法在任意位置插入项目—复杂度为 O(n)

  • 使用list.delete或使用del删除项目—复杂度为 O(n)

在这里,n是列表的长度。至少使用索引检索或设置元素是一个与列表大小无关的操作。以下是大多数列表操作的平均时间复杂度的完整表格:

操作 复杂度
- --- ---
- 复制 O(n)
- 添加 O(1)
- 插入 O(n)
- 获取项目 O(1)
- 删除项目 O(n)
- 迭代 O(n)
- 获取长度为k的切片 O(k)
- 删除切片 O(n)
- 设置长度为k的切片 O(k+n)
- 扩展 O(k)
- 乘以k O(nk)
- 测试存在性(element in list O(n)
- min()/max() O(n)
- 获取长度 O(1)

在需要真正的链表(或者简单地说,具有appendspop的数据结构,复杂度为 O(1))的情况下,Python 在collections内置模块中提供了deque。这是栈和队列的一般化,应该在需要双向链表的任何地方都能正常工作。

列表推导

正如您可能知道的,编写这样的代码是痛苦的:

>>> evens = []
>>> for i in range(10):
...     if i % 2 == 0:
...         evens.append(i)
...** 
>>> evens
[0, 2, 4, 6, 8]

这对于 C 语言可能有效,但实际上对于 Python 来说会使事情变慢,因为:

  • 它使解释器在每次循环中工作,以确定序列的哪一部分必须被更改

  • 它使您保持一个计数器来跟踪哪个元素必须被处理

  • 它需要在每次迭代时执行额外的函数查找,因为append()是列表的方法

列表推导是这种模式的正确答案。它使用了自动化前一种语法的部分的奇怪特性:

>>> [i for i in range(10) if i % 2 == 0]
[0, 2, 4, 6, 8]

除了这种写法更有效外,它更短,涉及的元素更少。在更大的程序中,这意味着更少的错误和更容易阅读和理解的代码。

提示

列表推导和内部数组调整

有一种迷思在一些 Python 程序员中流传,即列表推导可以是内部数组代表列表对象必须在每次添加时调整大小的一种变通方法。有人说数组将在恰到好处的大小时只分配一次。不幸的是,这是不正确的。

在计算推导时,解释器无法知道最终容器的大小,也无法为其预分配数组的最终大小。因此,内部数组的重新分配与for循环中的模式相同。然而,在许多情况下,使用推导创建列表既更清晰又更快,而不是使用普通循环。

其他习惯用法

Python 习惯用法的另一个典型例子是使用enumerate。这个内置函数提供了一种方便的方法,在循环中使用序列时获得索引。考虑以下代码片段作为例子:

>>> i = 0
>>> for element in ['one', 'two', 'three']:
...     print(i, element)
...     i += 1
...
0 one
1 two
2 three

这可以被以下更短的代码替换:

>>> for i, element in enumerate(['one', 'two', 'three']):
...     print(i, element)
...
0 one
1 two
2 three

当需要将多个列表(或任何可迭代对象)的元素以一对一的方式聚合时,可以使用内置的zip()函数。这是对两个相同大小的可迭代对象进行统一迭代的非常常见的模式:

>>> for item in zip([1, 2, 3], [4, 5, 6]):
...     print(item)
...** 
(1, 4)
(2, 5)
(3, 6)

请注意,zip()的结果可以通过另一个zip()调用进行反转:

>>> for item in zip(*zip([1, 2, 3], [4, 5, 6])):
...     print(item)
...** 
(1, 2, 3)
(4, 5, 6)

另一个流行的语法元素是序列解包。它不仅限于列表和元组,而且适用于任何序列类型(甚至字符串和字节序列)。它允许您将一系列元素解包到另一组变量中,只要在赋值运算符的左侧有与序列中元素数量相同的变量:

>>> first, second, third = "foo", "bar", 100
>>> first
'foo'
>>> second
'bar'
>>> third
100

解包还允许您使用星号表达式捕获单个变量中的多个元素,只要它可以被明确解释。解包也可以在嵌套序列上执行。当在由序列构建的一些复杂数据结构上进行迭代时,这可能会很方便。以下是一些更复杂解包的示例:

>>> # starred expression to capture rest of the sequence
>>> first, second, *rest = 0, 1, 2, 3
>>> first
0
>>> second
1
>>> rest
[2, 3]

>>> # starred expression to capture middle of the sequence
>>> first, *inner, last = 0, 1, 2, 3
>>> first
0
>>> inner
[1, 2]
>>> last
3

>>> # nested unpacking
>>> (a, b), (c, d) = (1, 2), (3, 4)
>>> a, b, c, d
(1, 2, 3, 4)

字典

字典是 Python 中最通用的数据结构之一。dict允许将一组唯一的键映射到值,如下所示:

{
    1: ' one',
    2: ' two',
    3: ' three',
}

字典文字是一件非常基本的事情,你应该已经知道它们。无论如何,Python 允许程序员使用类似于前面提到的列表推导的推导来创建一个新的字典。这是一个非常简单的例子:

squares = {number: number**2 for number in range(100)}

重要的是,使用列表推导的相同好处也适用于字典推导。因此,在许多情况下,它们更有效、更短、更清晰。对于更复杂的代码,当需要许多if语句或函数调用来创建字典时,简单的for循环可能是更好的选择,特别是如果它提高了可读性。

对于 Python 3 中的 Python 程序员,有一个关于迭代字典元素的重要说明。字典方法:keys()values()items()不再具有列表作为它们的返回值类型。此外,它们的对应方法iterkeys()itervalues()iteritems()在 Python 3 中已经消失,而不是返回迭代器。现在,keys()values()items()返回的是视图对象:

  • keys(): 这返回dict_keys对象,提供了字典的所有键的视图

  • values(): 这返回dict_values对象,提供了字典的所有值的视图

  • items(): 这返回dict_items对象,提供了字典的所有(key, value)两个元组的视图

视图对象以动态方式查看字典内容,因此每次字典发生更改,视图都会反映这些更改,如下例所示:

>>> words = {'foo': 'bar', 'fizz': 'bazz'}
>>> items = words.items()
>>> words['spam'] = 'eggs'
>>> items
dict_items([('spam', 'eggs'), ('fizz', 'bazz'), ('foo', 'bar')])

视图对象将旧方法的返回列表的行为与它们的“iter”对应方法返回的迭代器相结合。视图不需要在内存中冗余存储所有值(像列表一样),但仍然允许获取它们的长度(使用len)和测试成员资格(使用in子句)。视图当然是可迭代的。

最后一个重要的事情是keys()values()方法返回的两个视图确保了相同的键和值顺序。在 Python 2 中,如果要确保检索到的键和值的顺序相同,你不能在这两个调用之间修改字典内容。dict_keysdict_values现在是动态的,因此即使在keys()values()调用之间更改字典的内容,迭代的顺序也在这两个视图之间保持一致。

实现细节

CPython 使用伪随机探测的哈希表作为字典的底层数据结构。这似乎是一个非常深入的实现细节,但在不久的将来很不可能改变,因此对于程序员来说也是一个非常有趣的事实。

由于这个实现细节,只有可哈希的对象才能用作字典键。如果一个对象是可哈希的,那么它在其生命周期内的哈希值永远不会改变,并且可以与不同的对象进行比较。每个 Python 的内置类型都是不可变的,因此也是可哈希的。可变类型,如列表、字典和集合,是不可哈希的,因此不能用作字典键。定义类型是否可哈希的协议由两个方法组成:

  • __hash__: 这提供了内部dict实现所需的哈希值(作为整数)。对于用户定义类的实例对象,它是从它们的id()派生的。

  • __eq__: 这比较具有相同值的两个对象。默认情况下,所有用户定义类的实例对象都不相等,除了它们自己。

相等的两个对象必须具有相同的哈希值。反之则不需要成立。这意味着哈希碰撞是可能的——具有相同哈希的两个对象可能不相等。这是允许的,每个 Python 实现都必须能够解决哈希碰撞。CPython 使用开放寻址来解决这种碰撞(en.wikipedia.org/wiki/Open_addressing)。然而,碰撞的概率极大地影响性能,如果碰撞概率很高,字典将无法从其内部优化中受益。

虽然三个基本操作:添加、获取和删除项目的平均时间复杂度为 O(1),但它们的摊销最坏情况复杂度要高得多——O(n),其中n是当前字典大小。此外,如果将用户定义的类对象用作字典键,并且它们的哈希不当(存在高风险的碰撞),那么这将对字典的性能产生巨大的负面影响。CPython 字典的完整时间复杂度表如下:

操作 平均复杂度 分摊最坏情况复杂度
获取项 O(1) O(n)
集合项 O(1) O(n)
删除项 O(1) O(n)
复制 O(n) O(n)
迭代 O(n) O(n)

还有一点很重要,那就是复制和迭代字典的最坏情况复杂度中的n是字典曾经达到的最大大小,而不是当前的项数。换句话说,迭代曾经很大但在时间上大大缩小的字典可能需要花费出乎意料的长时间。因此,在某些情况下,如果需要经常迭代,可能最好创建一个新的字典对象,而不是仅仅从以前的字典中删除元素。

弱点和替代方案

使用字典的一个常见陷阱是它们不保留添加新键的顺序。在某些情况下,当字典键使用连续的键,其哈希值也是连续的值(例如使用整数)时,由于字典的内部实现,结果顺序可能是相同的:

>>> {number: None for number in range(5)}.keys()
dict_keys([0, 1, 2, 3, 4])

然而,使用其他哈希方式不同的数据类型表明顺序不会被保留。以下是 CPython 的一个例子:

>>> {str(number): None for number in range(5)}.keys()
dict_keys(['1', '2', '4', '0', '3'])
>>> {str(number): None for number in reversed(range(5))}.keys()
dict_keys(['2', '3', '1', '4', '0'])

如前面的代码所示,结果顺序既取决于对象的哈希,也取决于添加元素的顺序。这是不可靠的,因为它可能会随着不同的 Python 实现而变化。

然而,在某些情况下,开发人员可能需要保留添加顺序的字典。幸运的是,Python 标准库在collections模块中提供了一个有序字典OrderedDict。它可以选择接受一个可迭代对象作为初始化参数:

>>> from collections import OrderedDict
>>> OrderedDict((str(number), None) for number in range(5)).keys()
odict_keys(['0', '1', '2', '3', '4'])

它还具有一些额外的功能,比如使用popitem()方法从两端弹出项,或者使用move_to_end()方法将指定的元素移动到其中一个端点。有关该集合的完整参考,请参阅 Python 文档(参见docs.python.org/3/library/collections.html)。

另一个重要的注意事项是,在非常古老的代码库中,dict可能被用作保证元素唯一性的原始集合实现。虽然这会给出正确的结果,但除非针对的是低于 2.3 的 Python 版本,否则应该避免这样做。以这种方式使用字典在资源方面是浪费的。Python 有一个内置的set类型来实现这个目的。实际上,它在 CPython 中有一个非常相似的内部实现,但也提供了一些额外的功能以及特定的与集合相关的优化。

集合

集合是一种非常健壮的数据结构,主要在元素的顺序不如它们的唯一性和测试效率重要的情况下非常有用。它们与类似的数学概念非常相似。集合以两种形式作为内置类型提供:

  • set(): 这是一个可变的、无序的、有限的唯一不可变(可哈希)对象的集合

  • frozenset(): 这是一个不可变的、可哈希的、无序的唯一不可变(可哈希)对象的集合

frozenset()的不可变性使其可以用作字典键,也可以用作其他set()frozenset()元素。普通的可变set()不能在另一个集合或 frozenset 内容中使用,否则会引发TypeError

>>> set([set([1,2,3]), set([2,3,4])])
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'set'

以下的集合初始化是完全正确的:

>>> set([frozenset([1,2,3]), frozenset([2,3,4])])
{frozenset({1, 2, 3}), frozenset({2, 3, 4})}
>>> frozenset([frozenset([1,2,3]), frozenset([2,3,4])])
frozenset({frozenset({1, 2, 3}), frozenset({2, 3, 4})})

可变集合可以通过三种方式创建:

  • 使用接受可选可迭代对象作为初始化参数的set()调用,比如set([0, 1, 2])

  • 使用集合推导,例如{element for element in range(3)}

  • 使用集合字面量,例如{1, 2, 3}

请注意,对于集合,使用文字和理解需要额外小心,因为它们在形式上与字典文字和理解非常相似。此外,空集对象没有文字 - 空花括号{}保留用于空字典文字。

实现细节

在 CPython 中,集合与字典非常相似。事实上,它们是使用虚拟值实现的字典,其中只有键是实际的集合元素。此外,集合利用映射中缺少值的优化。

由于这一点,集合允许非常快速的添加、删除和检查元素是否存在,平均时间复杂度为 O(1)。然而,由于 CPython 中集合的实现依赖于类似的哈希表结构,这些操作的最坏情况复杂度为 O(n),其中n是集合的当前大小。

其他实现细节也适用。要包含在集合中的项目必须是可散列的,如果用户定义类的实例在集合中的哈希值很差,这将对性能产生负面影响。

基本集合之外 - collections 模块

每种数据结构都有其缺点。没有单一的集合可以适用于每个问题,而且四种基本类型(元组、列表、集合和字典)仍然不是一种广泛的选择。这些是最基本和重要的集合,具有专用的文字语法。幸运的是,Python 在其标准库中提供了更多选项,通过collections内置模块。其中一个已经提到了(deque)。以下是此模块提供的最重要的集合:

  • namedtuple():这是一个用于创建元组子类的工厂函数,其索引可以作为命名属性访问

  • deque:这是一个双端队列,类似于堆栈和队列的列表泛化,可以在两端快速添加和弹出

  • ChainMap:这是一个类似字典的类,用于创建多个映射的单个视图

  • Counter:这是一个用于计算可散列对象的字典子类

  • OrderedDict:这是一个保留条目添加顺序的字典子类

  • defaultdict:这是一个字典子类,可以使用提供的默认值提供缺失的值

注意

有关来自 collections 模块的选定集合的更多详细信息以及在何处值得使用它们的建议,请参见第十二章,“优化 - 一些强大的技术”。

高级语法

客观地说,很难判断语言语法的哪个元素是先进的。对于本章关于高级语法元素的目的,我们将考虑那些与任何特定的内置数据类型没有直接关系,并且在开始时相对难以理解的元素。可能难以理解的最常见的 Python 特性是:

  • 迭代器

  • 生成器

  • 装饰器

  • 上下文管理器

迭代器

迭代器只不过是实现迭代器协议的容器对象。它基于两种方法:

  • __next__:这返回容器的下一个项目

  • __iter__:这返回迭代器本身

可以使用iter内置函数从序列创建迭代器。考虑以下示例:

>>> i = iter('abc')
>>> next(i)
'a'
>>> next(i)
'b'
>>> next(i)
'c'
>>> next(i)
Traceback (most recent call last):
 **File "<input>", line 1, in <module>
StopIteration

当序列耗尽时,会引发StopIteration异常。它使迭代器与循环兼容,因为它们捕获此异常以停止循环。要创建自定义迭代器,可以编写一个具有__next__方法的类,只要它提供返回迭代器实例的特殊方法__iter__

class CountDown:def __init__(self, step):
        self.step = step
    def __next__(self):
        """Return the next element."""
        if self.step <= 0:
            raise StopIteration
        self.step -= 1
        return self.step
    def __iter__(self):
        """Return the iterator itself."""
        return self

以下是这种迭代器的示例用法:

>>> for element in CountDown(4):
...     print(element)
...** 
3
2
1
0

迭代器本身是一个低级特性和概念,程序可以没有它们。但是它们为一个更有趣的特性 - 生成器提供了基础。

yield 语句

生成器提供了一种优雅的方式来编写返回元素序列的简单高效的代码。基于yield语句,它们允许您暂停函数并返回中间结果。函数保存其执行上下文,如果必要的话可以稍后恢复。

例如,斐波那契数列可以用迭代器编写(这是关于迭代器的 PEP 中提供的示例):

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

您可以像使用next()函数或for循环一样从生成器中检索新值:

>>> fib = fibonacci()
>>> next(fib)
1
>>> next(fib)
1
>>> next(fib)
2
>>> [next(fib) for i in range(10)]
[3, 5, 8, 13, 21, 34, 55, 89, 144, 233]

这个函数返回一个generator对象,一个特殊的迭代器,它知道如何保存执行上下文。它可以被无限调用,每次产生套件的下一个元素。语法简洁,算法的无限性不再影响代码的可读性。它不必提供一种使函数可停止的方法。事实上,它看起来类似于伪代码中设计系列的方式。

在社区中,生成器并不经常使用,因为开发人员不习惯以这种方式思考。开发人员多年来一直习惯于使用直接函数。每当处理返回序列的函数或在循环中工作时,都应该考虑使用生成器。逐个返回元素可以提高整体性能,当它们被传递给另一个函数进行进一步处理时。

在这种情况下,用于计算一个元素的资源大部分时间不那么重要,而用于整个过程的资源更为重要。因此,它们可以保持较低,使程序更加高效。例如,斐波那契数列是无限的,但生成它的生成器不需要无限的内存来一次提供值。一个常见的用例是使用生成器流式传输数据缓冲区。它们可以被第三方代码暂停、恢复和停止,而不需要在开始处理之前加载所有数据。

例如,标准库中的tokenize模块可以从文本流中生成标记,并为每个处理的行返回一个iterator,可以传递给某些处理:

>>> import tokenize
>>> reader = open('hello.py').readline
>>> tokens = tokenize.generate_tokens(reader)
>>> next(tokens)
TokenInfo(type=57 (COMMENT), string='# -*- coding: utf-8 -*-', start=(1, 0), end=(1, 23), line='# -*- coding: utf-8 -*-\n')
>>> next(tokens)
TokenInfo(type=58 (NL), string='\n', start=(1, 23), end=(1, 24), line='# -*- coding: utf-8 -*-\n')
>>> next(tokens)
TokenInfo(type=1 (NAME), string='def', start=(2, 0), end=(2, 3), line='def hello_world():\n')

在这里,我们可以看到open迭代文件的行,generate_tokens在管道中迭代它们,执行额外的工作。生成器还可以帮助打破复杂性,并提高基于几个套件的一些数据转换算法的效率。将每个套件视为iterator,然后将它们组合成一个高级函数是避免一个庞大、丑陋和难以阅读的函数的好方法。此外,这可以为整个处理链提供实时反馈。

在下面的例子中,每个函数定义了对序列的转换。然后它们被链接并应用。每个函数调用处理一个元素并返回其结果:

def power(values):
    for value in values:
        print('powering %s' % value)
        yield value

def adder(values):
    for value in values:
        print('adding to %s' % value)
        if value % 2 == 0:
            yield value + 3
        else:
            yield value + 2

以下是使用这些生成器的可能结果:

>>> elements = [1, 4, 7, 9, 12, 19]
>>> results = adder(power(elements))
>>> next(results)
powering 1
adding to 1
3
>>> next(results)
powering 4
adding to 4
7
>>> next(results)
powering 7
adding to 7
9

提示

保持代码简单,而不是数据

最好有很多简单的可迭代函数,可以处理值序列,而不是一次计算整个集合的复杂函数。

关于generators,Python 中另一个重要的功能是能够使用next函数与代码进行交互。yield变成了一个表达式,可以通过一个称为send的新方法传递一个值:

def psychologist():
    print('Please tell me your problems')
    while True:
        answer = (yield)
        if answer is not None:
            if answer.endswith('?'):
                print("Don't ask yourself too much questions")
            elif 'good' in answer:
                print("Ahh that's good, go on")
            elif 'bad' in answer:
                print("Don't be so negative")

以下是使用我们的psychologist()函数的示例会话:

>>> free = psychologist()
>>> next(free)
Please tell me your problems
>>> free.send('I feel bad')
Don't be so negative
>>> free.send("Why I shouldn't ?")
Don't ask yourself too much questions
>>> free.send("ok then i should find what is good for me")
Ahh that's good, go on

send的作用类似于next,但使yield返回函数定义内传递的值。因此,函数可以根据客户端代码改变其行为。为了完成这种行为,还添加了另外两个函数——throwclose。它们将错误引发到生成器中:

  • throw:这允许客户端代码发送任何类型的异常来引发。

  • close:这样做的方式相同,但会引发特定的异常GeneratorExit。在这种情况下,生成器函数必须再次引发GeneratorExitStopIteration

注意

生成器是 Python 中其他概念的基础——协程和异步并发,这些概念在第十三章中有所涵盖,并发

装饰器

Python 中添加装饰器是为了使函数和方法包装(接收一个函数并返回一个增强的函数)更易于阅读和理解。最初的用例是能够在其定义的头部将方法定义为类方法或静态方法。没有装饰器语法,这将需要一个相当稀疏和重复的定义:

class WithoutDecorators:
    def some_static_method():
        print("this is static method")
    some_static_method = staticmethod(some_static_method)

    def some_class_method(cls):
        print("this is class method")
    some_class_method = classmethod(some_class_method)

如果装饰器语法用于相同的目的,代码会更短,更容易理解:

class WithDecorators:
    @staticmethod
    def some_static_method():
        print("this is static method")

    @classmethod
    def some_class_method(cls):
        print("this is class method")

一般语法和可能的实现

装饰器通常是一个命名对象(不允许lambda表达式),在调用时接受一个参数(它将是装饰的函数),并返回另一个可调用对象。这里使用“可调用”而不是“函数”是有预谋的。虽然装饰器经常在方法和函数的范围内讨论,但它们并不局限于它们。事实上,任何可调用的东西(任何实现__call__方法的对象都被认为是可调用的)都可以用作装饰器,而且它们返回的对象通常不是简单的函数,而是更复杂的类的实例,实现了自己的__call__方法。

装饰器语法只是一种语法糖。考虑以下装饰器的用法:

@some_decorator
def decorated_function():
    pass

这总是可以被显式的装饰器调用和函数重新分配替代:

def decorated_function():
    pass
decorated_function = some_decorator(decorated_function)

然而,后者不太可读,而且如果在单个函数上使用多个装饰器,很难理解。

提示

装饰器甚至不需要返回一个可调用对象!

事实上,任何函数都可以用作装饰器,因为 Python 不强制装饰器的返回类型。因此,使用一些函数作为装饰器,它接受一个参数但不返回可调用的,比如str,在语法上是完全有效的。如果用户尝试以这种方式调用装饰过的对象,最终会失败。无论如何,装饰器语法的这一部分为一些有趣的实验创造了一个领域。

作为一个函数

有许多编写自定义装饰器的方法,但最简单的方法是编写一个返回包装原始函数调用的子函数的函数。

通用模式如下:

def mydecorator(function):
    def wrapped(*args, **kwargs):     
        # do some stuff before the original
        # function gets called
        result = function(*args, **kwargs)
        # do some stuff after function call and
        # return the result
        return result
    # return wrapper as a decorated function
    return wrapped

作为类

虽然装饰器几乎总是可以使用函数来实现,但在某些情况下,使用用户定义的类是更好的选择。当装饰器需要复杂的参数化或依赖于特定状态时,这通常是正确的。

作为类的非参数化装饰器的通用模式如下:

class DecoratorAsClass:
    def __init__(self, function):
        self.function = function

    def __call__(self, *args, **kwargs):
        # do some stuff before the original
        # function gets called
        result = self.function(*args, **kwargs)
        # do some stuff after function call and
        # return the result
        return result

参数化装饰器

在实际代码中,通常需要使用可以带参数的装饰器。当函数用作装饰器时,解决方案很简单——必须使用第二层包装。这是装饰器的一个简单示例,它重复执行装饰函数指定的次数,每次调用时:

def repeat(number=3):
    """Cause decorated function to be repeated a number of times.

    Last value of original function call is returned as a result
    :param number: number of repetitions, 3 if not specified
    """
    def actual_decorator(function):
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(number):
                result = function(*args, **kwargs)
            return result
        return wrapper
    return actual_decorator

这种方式定义的装饰器可以接受参数:

>>> @repeat(2)
... def foo():
...     print("foo")
...** 
>>> foo()
foo
foo

请注意,即使带有默认值的参数化装饰器,其名称后面的括号也是必需的。使用具有默认参数的前述装饰器的正确方法如下:

>>> @repeat()
... def bar():
...     print("bar")
...** 
>>> bar()
bar
bar
bar

如果省略这些括号,当调用装饰函数时将导致以下错误:

>>> @repeat
... def bar():
...     pass
...** 
>>> bar()
Traceback (most recent call last):
 **File "<input>", line 1, in <module>
TypeError: actual_decorator() missing 1 required positional
argument: 'function'

保留内省的装饰器

使用装饰器的常见陷阱是在使用装饰器时不保留函数元数据(主要是文档字符串和原始名称)。所有先前的示例都有这个问题。它们通过组合创建了一个新函数,并返回了一个新对象,而没有尊重原始函数的身份。这使得以这种方式装饰的函数的调试更加困难,并且也会破坏大多数可能使用的自动文档工具,因为原始文档字符串和函数签名不再可访问。

但让我们详细看一下。假设我们有一些虚拟装饰器,除了装饰和一些其他函数被装饰以外,什么都不做:

def dummy_decorator(function):
    def wrapped(*args, **kwargs):
        """Internal wrapped function documentation."""
        return function(*args, **kwargs)
    return wrapped

@dummy_decorator
def function_with_important_docstring():
    """This is important docstring we do not want to lose."""

如果我们在 Python 交互会话中检查function_with_important_docstring(),我们会注意到它已经失去了原始名称和文档字符串:

>>> function_with_important_docstring.__name__
'wrapped'
>>> function_with_important_docstring.__doc__
'Internal wrapped function documentation.'

解决这个问题的一个合适的方法是使用functools模块提供的内置wraps()装饰器:

from functools import wraps

def preserving_decorator(function):
    @wraps(function)
    def wrapped(*args, **kwargs):
        """Internal wrapped function documentation."""
        return function(*args, **kwargs)
    return wrapped

@preserving_decorator
def function_with_important_docstring():
    """This is important docstring we do not want to lose."""

通过这种方式定义的装饰器,重要的函数元数据得到了保留:

>>> function_with_important_docstring.__name__
'function_with_important_docstring.'
>>> function_with_important_docstring.__doc__
'This is important docstring we do not want to lose.'

用法和有用的示例

由于装饰器在模块首次读取时由解释器加载,它们的使用应该限于可以通用应用的包装器。如果装饰器与方法的类或增强的函数签名相关联,应将其重构为常规可调用对象以避免复杂性。无论如何,当装饰器处理 API 时,一个良好的做法是将它们分组在一个易于维护的模块中。

装饰器的常见模式有:

  • 参数检查

  • 缓存

  • 代理

  • 上下文提供者

参数检查

检查函数接收或返回的参数在特定上下文中执行时可能是有用的。例如,如果一个函数要通过 XML-RPC 调用,Python 将无法像静态类型语言那样直接提供其完整签名。当 XML-RPC 客户端请求函数签名时,需要此功能来提供内省能力。

提示

XML-RPC 协议

XML-RPC 协议是一种轻量级的远程过程调用协议,它使用 XML 通过 HTTP 来编码调用。它经常用于简单的客户端-服务器交换而不是 SOAP。与提供列出所有可调用函数的页面的 SOAP 不同,XML-RPC 没有可用函数的目录。提出了一种允许发现服务器 API 的协议扩展,并且 Python 的xmlrpc模块实现了它(参考docs.python.org/3/library/xmlrpc.server.html)。

自定义装饰器可以提供这种类型的签名。它还可以确保输入和输出符合定义的签名参数:

rpc_info = {}

def xmlrpc(in_=(), out=(type(None),)):
    def _xmlrpc(function):
        # registering the signature
        func_name = function.__name__
        rpc_info[func_name] = (in_, out)
        def _check_types(elements, types):
            """Subfunction that checks the types."""
            if len(elements) != len(types):
                raise TypeError('argument count is wrong')
            typed = enumerate(zip(elements, types))
            for index, couple in typed:
                arg, of_the_right_type = couple
                if isinstance(arg, of_the_right_type):
                    continue
                raise TypeError(
                    'arg #%d should be %s' % (index, of_the_right_type))

        # wrapped function
        def __xmlrpc(*args):  # no keywords allowed
            # checking what goes in
            checkable_args = args[1:]  # removing self
            _check_types(checkable_args, in_)
            # running the function
            res = function(*args)
            # checking what goes out
            if not type(res) in (tuple, list):
                checkable_res = (res,)
            else:
                checkable_res = res
            _check_types(checkable_res, out)

            # the function and the type
            # checking succeeded
            return res
        return __xmlrpc
    return _xmlrpc

装饰器将函数注册到全局字典中,并保留其参数和返回值的类型列表。请注意,示例被大大简化以演示参数检查装饰器。

使用示例如下:

class RPCView:
    @xmlrpc((int, int))  # two int -> None
    def meth1(self, int1, int2):
        print('received %d and %d' % (int1, int2))

    @xmlrpc((str,), (int,))  # string -> int
    def meth2(self, phrase):
        print('received %s' % phrase)
        return 12

当它被读取时,这个类定义会填充rpc_infos字典,并且可以在特定环境中使用,其中检查参数类型:

>>> rpc_info
{'meth2': ((<class 'str'>,), (<class 'int'>,)), 'meth1': ((<class 'int'>, <class 'int'>), (<class 'NoneType'>,))}
>>> my = RPCView()
>>> my.meth1(1, 2)
received 1 and 2
>>> my.meth2(2)
Traceback (most recent call last):
 **File "<input>", line 1, in <module>
 **File "<input>", line 26, in __xmlrpc
 **File "<input>", line 20, in _check_types
TypeError: arg #0 should be <class 'str'>

缓存

缓存装饰器与参数检查非常相似,但侧重于那些内部状态不影响输出的函数。每组参数都可以与唯一的结果相关联。这种编程风格是函数式编程的特征(参考en.wikipedia.org/wiki/Functional_programming),并且可以在输入值集合是有限的情况下使用。

因此,缓存装饰器可以将输出与计算所需的参数一起保留,并在后续调用时直接返回。这种行为称为记忆化(参考en.wikipedia.org/wiki/Memoizing),作为装饰器实现起来非常简单:

import time
import hashlib
import pickle

cache = {}

def is_obsolete(entry, duration):
    return time.time() - entry['time']> duration

def compute_key(function, args, kw):
    key = pickle.dumps((function.__name__, args, kw))
    return hashlib.sha1(key).hexdigest()

def memoize(duration=10):
    def _memoize(function):
        def __memoize(*args, **kw):
            key = compute_key(function, args, kw)

            # do we have it already ?
            if (key in cache and
                not is_obsolete(cache[key], duration)):
                print('we got a winner')
                return cache[key]['value']

            # computing
            result = function(*args, **kw)
            # storing the result
            cache[key] = {
                'value': result,
                'time': time.time()
            }
            return result
        return __memoize
    return _memoize

使用有序参数值构建SHA哈希键,并将结果存储在全局字典中。哈希是使用 pickle 制作的,这是一个冻结传递的所有对象状态的快捷方式,确保所有参数都是良好的候选者。例如,如果线程或套接字被用作参数,将会发生PicklingError。(参见docs.python.org/3/library/pickle.html。)duration参数用于在上次函数调用后经过太长时间后使缓存值无效。

以下是一个使用示例:

>>> @memoize()
... def very_very_very_complex_stuff(a, b):
...     # if your computer gets too hot on this calculation
...     # consider stopping it
...     return a + b
...
>>> very_very_very_complex_stuff(2, 2)
4
>>> very_very_very_complex_stuff(2, 2)
we got a winner
4
>>> @memoize(1) # invalidates the cache after 1 second
... def very_very_very_complex_stuff(a, b):
...     return a + b
...
>>> very_very_very_complex_stuff(2, 2)
4
>>> very_very_very_complex_stuff(2, 2)
we got a winner
4
>>> cache
{'c2727f43c6e39b3694649ee0883234cf': {'value': 4, 'time':
1199734132.7102251)}
>>> time.sleep(2)
>>> very_very_very_complex_stuff(2, 2)
4

缓存昂贵的函数可以显著提高程序的整体性能,但必须小心使用。缓存的值也可以与函数本身绑定,以管理其范围和生命周期,而不是集中的字典。但无论如何,一个更有效的装饰器会使用基于高级缓存算法的专用缓存库。

注意

第十二章,优化-一些强大的技术,提供了关于缓存的详细信息和技术。

代理

代理装饰器用于标记和注册具有全局机制的函数。例如,一个保护代码访问的安全层,取决于当前用户,可以使用一个带有可调用的关联权限的集中检查器来实现。

class User(object):
    def __init__(self, roles):
        self.roles = roles

class Unauthorized(Exception):
    pass

def protect(role):
    def _protect(function):
        def __protect(*args, **kw):
            user = globals().get('user')
            if user is None or role not in user.roles:
                raise Unauthorized("I won't tell you")
            return function(*args, **kw)
        return __protect
    return _protect

这个模型经常被用在 Python 的 web 框架中来定义可发布类的安全性。例如,Django 提供了装饰器来保护函数的访问。

这是一个例子,其中当前用户保存在全局变量中。装饰器在访问方法时检查他或她的角色:

>>> tarek = User(('admin', 'user'))
>>> bill = User(('user',))
>>> class MySecrets(object):
...     @protect('admin')
...     def waffle_recipe(self):
...         print('use tons of butter!')
...
>>> these_are = MySecrets()
>>> user = tarek
>>> these_are.waffle_recipe()
use tons of butter!
>>> user = bill
>>> these_are.waffle_recipe()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 7, in wrap
__main__.Unauthorized: I won't tell you

上下文提供程序

上下文装饰器确保函数可以在正确的上下文中运行,或在函数之前和之后运行一些代码。换句话说,它设置并取消特定的执行环境。例如,当一个数据项必须在多个线程之间共享时,必须使用锁来确保它受到多重访问的保护。这个锁可以编码在装饰器中,如下所示:

from threading import RLock
lock = RLock()

def synchronized(function):
    def _synchronized(*args, **kw):
        lock.acquire()
        try:
            return function(*args, **kw)
        finally:
            lock.release()
    return _synchronized

@synchronized
def thread_safe():  # make sure it locks the resource
    pass

上下文装饰器更多地被上下文管理器(with语句)的使用所取代,这也在本章后面描述。

上下文管理器-with语句

try...finally语句对于确保一些清理代码即使发生错误也会运行是有用的。有许多这样的用例,比如:

  • 关闭文件

  • 释放锁

  • 制作临时代码补丁

  • 在特殊环境中运行受保护的代码

with语句通过提供一种简单的方式来包装一段代码来因素化这些用例。这允许您在块执行之前和之后调用一些代码,即使这个块引发异常。例如,通常是这样处理文件的:

>>> hosts = open('/etc/hosts')
>>> try:
...     for line in hosts:
...         if line.startswith('#'):
...             continue
...         print(line.strip())
... finally:
...     hosts.close()
...
127.0.0.1       localhost
255.255.255.255 broadcasthost
::1             localhost

注意

这个例子是特定于 Linux 的,因为它读取位于etc中的主机文件,但任何文本文件都可以以同样的方式在这里使用。

通过使用with语句,可以重写成这样:

>>> with open('/etc/hosts') as hosts:
...     for line in hosts:
...         if line.startswith('#'):
...             continue
...         print(line.strip )
...
127.0.0.1       localhost
255.255.255.255 broadcasthost
::1             localhost

在上面的例子中,open作为上下文管理器确保在执行for循环后文件将被关闭,即使发生异常。

与此语句兼容的其他项目是threading模块中的类:

  • threading.Lock

  • threading.RLock

  • threading.Condition

  • threading.Semaphore

  • threading.BoundedSemaphore

一般语法和可能的实现

最简单形式的with语句的一般语法是:

with context_manager:
    # block of code
    ...

此外,如果上下文管理器提供一个上下文变量,可以使用as子句在本地存储它:

with context_manager as context:
    # block of code
    ...

请注意,可以同时使用多个上下文管理器,如下所示:

with A() as a, B() as b:
    ...

这相当于将它们嵌套,如下所示:

with A() as a:
    with B() as b:
        ...

作为一个类

任何实现上下文管理器协议的对象都可以用作上下文管理器。这个协议包括两个特殊方法:

简而言之,with语句的执行如下:

  1. __enter__方法被调用。任何返回值都绑定到指定为子句的目标。

  2. 执行内部代码块。

  3. __exit__方法被调用。

__exit__接收三个参数,当代码块内发生错误时会填充这些参数。如果没有发生错误,所有三个参数都设置为None。当发生错误时,__exit__不应重新引发它,因为这是调用者的责任。它可以通过返回True来防止异常被引发。这是为了实现一些特定的用例,比如我们将在下一节中看到的contextmanager装饰器。但对于大多数用例,这个方法的正确行为是做一些清理,就像finally子句所做的那样;无论在块中发生了什么,它都不返回任何东西。

以下是一个实现了这个协议的一些上下文管理器的示例,以更好地说明它是如何工作的:

class ContextIllustration:
    def __enter__(self):
        print('entering context')

    def __exit__(self, exc_type, exc_value, traceback):
        print('leaving context')

        if exc_type is None:
            print('with no error')
        else:
            print('with an error (%s)' % exc_value)

当没有引发异常时,输出如下:

>>> with ContextIllustration():
...     print("inside")
...** 
entering context
inside
leaving context
with no error

当引发异常时,输出如下:

>>> with ContextIllustration():
...     raise RuntimeError("raised within 'with'")
...** 
entering context
leaving context
with an error (raised within 'with')
Traceback (most recent call last):
 **File "<input>", line 2, in <module>
RuntimeError: raised within 'with'

作为函数-上下文管理器模块

使用类似乎是实现 Python 语言中提供的任何协议的最灵活的方式,但对于许多用例来说可能是太多的样板文件。标准库中添加了一个contextlib模块,提供了一些与上下文管理器一起使用的帮助器。它最有用的部分是contextmanager装饰器。它允许您在单个函数中提供__enter____exit__部分,中间用yield语句分隔(请注意,这会使函数成为生成器)。使用这个装饰器编写的前面的示例将如下所示:

from contextlib import contextmanager

@contextmanager
def context_illustration():
    print('entering context')

    try:
        yield
    except Exception as e:
        print('leaving context')
        print('with an error (%s)' % e)
        # exception needs to be reraised
        raise
    else:
        print('leaving context')
        print('with no error')

如果发生任何异常,函数需要重新引发它以便传递它。请注意,context_illustration如果需要的话可以有一些参数,只要它们在调用中提供。这个小助手与基于类的迭代器 API 一样简化了正常的基于类的上下文 API。

这个模块提供的另外三个帮助器是:

  • closing(element):这会返回一个上下文管理器,在退出时调用元素的 close 方法。这对于处理流的类非常有用。

  • supress(*exceptions):如果在 with 语句的主体中发生指定的任何异常,则抑制它们。

  • redirect_stdout(new_target)redirect_stderr(new_target):这将代码块内的sys.stdoutsys.stderr输出重定向到另一个文件或类文件对象。

其他你可能还不知道的语法元素

Python 语法中有一些不太流行且很少使用的元素。这是因为它们要么提供的收益很少,要么它们的使用方法很难记住。因此,许多 Python 程序员(即使有多年的经验)根本不知道它们的存在。这些特性的最显著的例子如下:

  • for … else子句

  • 函数注释

for … else …语句

for循环之后使用else子句允许您仅在循环以“自然”方式结束而不是用break语句终止时执行代码块:

>>> for number in range(1):
...     break
... else:
...     print("no break")
...
>>>
>>> for number in range(1):
...     pass
... else:
...     print("break")
...
break

在某些情况下,这很方便,因为它有助于消除可能需要的一些“标记”变量,如果用户想要存储信息,以确定是否发生了break。这使得代码更清晰,但可能会让不熟悉这种语法的程序员感到困惑。有人说else子句的这种含义是违反直觉的,但这里有一个简单的提示,可以帮助您记住它的工作原理-记住for循环后的else子句只是表示“没有 break”。

函数注释

函数注释是 Python 3 最独特的功能之一。官方文档指出注释是关于用户定义函数使用的类型的完全可选的元数据信息,但实际上,它们并不局限于类型提示,Python 及其标准库也没有利用这样的注释。这就是为什么这个功能是独特的-它没有任何语法意义。注释可以简单地为函数定义,并且可以在运行时检索,但仅此而已。如何处理它们留给开发人员。

一般语法

Python 文档中略微修改的示例最好地展示了如何定义和检索函数注释:

>>> def f(ham: str, eggs: str = 'eggs') -> str:
...     pass
...** 
>>> print(f.__annotations__)
{'return': <class 'str'>, 'eggs': <class 'str'>, 'ham': <class 'str'>}

如所示,参数注释由表达式定义,该表达式评估为注释值,并在冒号之前。返回注释由冒号后的def语句结束和参数列表后面的->之间的表达式定义。

一旦定义,注释将作为函数对象的__annotations__属性以字典的形式可用,并且可以在应用运行时检索。

任何表达式都可以用作注释,并且它位于默认参数旁边,这允许创建一些令人困惑的函数定义,如下所示:

>>> def square(number: 0<=3 and 1=0) -> (\
...     +9000): return number**2
>>> square(10)
100

然而,这种注释的用法除了混淆之外没有其他目的,即使没有它们,编写难以阅读和维护的代码也相对容易。

可能的用途

尽管注释具有巨大潜力,但它们并不被广泛使用。一篇解释 Python 3 新增功能的文章(参见docs.python.org/3/whatsnew/3.0.html)表示,这一功能的目的是“通过元类、装饰器或框架鼓励实验”。另一方面,正式提出函数注释的PEP 3107列出了以下一系列可能的用例:

  • 提供类型信息

  • 类型检查

  • 让 IDE 显示函数期望和返回的类型

  • 函数重载/通用函数

  • 外语桥梁

  • 适应

  • 谓词逻辑函数

  • 数据库查询映射

  • RPC 参数编组

  • 其他信息

  • 参数和返回值的文档

尽管函数注释与 Python 3 一样古老,但仍然很难找到任何流行且积极维护的软件包,除了类型检查之外还使用它们。因此,函数注释仍然主要用于实验和玩耍-这是它们被包含在 Python 3 的初始版本中的初衷。

总结

本章涵盖了与 Python 类和面向对象编程无直接关系的各种最佳语法实践。本章的第一部分专门讨论了围绕 Python 序列和集合的语法特性,还讨论了字符串和字节相关序列。本章的其余部分涵盖了两组独立的语法元素-相对于初学者来说相对难以理解的元素(如迭代器、生成器和装饰器)和相对较少知名的元素(for…else子句和函数注释)。

第三章: 语法最佳实践-在类级别以上

现在,我们将专注于类的语法最佳实践。这里不打算涵盖设计模式,因为它们将在第十四章有用的设计模式中讨论。本章概述了高级 Python 语法,以操纵和增强类代码。

对象模型在 Python 2 的历史中发生了很大的变化。很长一段时间,我们生活在一个同一语言中两种面向对象编程范式的实现并存的世界中。这两种模型简单地被称为旧式新式类。Python 3 结束了这种二分法,只有新式类这种模型可供开发人员使用。无论如何,了解它们在 Python 2 中是如何工作的仍然很重要,因为它将帮助您移植旧代码并编写向后兼容的应用程序。了解对象模型的变化也将帮助您理解为什么它现在是这样设计的。这就是为什么下一章将有相对较多关于旧 Python 2 功能的注释,尽管本书的目标是最新的 Python 3 版本。

本章将讨论以下主题:

  • 子类内置类型

  • 从超类访问方法

  • 使用属性和插槽

  • 元编程

子类内置类型

在 Python 中,对内置类型进行子类化非常简单。名为object的内置类型是所有内置类型以及所有未明确指定父类的用户定义类的共同祖先。由于这个原因,每当需要实现一个行为几乎像内置类型之一的类时,最佳实践是对其进行子类型化。

现在,我们将向您展示一个名为distinctdict的类的代码,它使用了这种技术。它是通常的 Python dict类型的子类。这个新类在大多数方面的行为都像一个普通的 Python dict。但是,与其允许具有相同值的多个键不同,当有人尝试添加一个具有相同值的新条目时,它会引发一个带有帮助消息的ValueError子类:

class DistinctError(ValueError):
    """Raised when duplicate value is added to a distinctdict."""

class distinctdict(dict):
    """Dictionary that does not accept duplicate values."""
    def __setitem__(self, key, value):
        if value in self.values():
            if (
                (key in self and self[key] != value) or
                key not in self
            ):
                raise DistinctError(
                    "This value already exists for different key"
                )

        super().__setitem__(key, value)

以下是在交互会话中使用distictdict的示例:

>>> my = distinctdict()
>>> my['key'] = 'value'
>>> my['other_key'] = 'value'
Traceback (most recent call last):
 **File "<input>", line 1, in <module>
 **File "<input>", line 10, in __setitem__
DistinctError: This value already exists for different key
>>> my['other_key'] = 'value2'
>>> my
{'key': 'value', 'other_key': 'value2'}

如果您查看现有代码,可能会发现许多部分实现内置类型的类,并且可以作为子类型更快,更清洁。例如,list类型管理序列,并且可以在类内部使用序列时使用:

class Folder(list):
    def __init__(self, name):
        self.name = name

    def dir(self, nesting=0):
        offset = "  " * nesting
        print('%s%s/' % (offset, self.name))

        for element in self:
            if hasattr(element, 'dir'):
                element.dir(nesting + 1)
            else:
                print("%s  %s" % (offset, element))

以下是在交互会话中的一个示例用法:

>>> tree = Folder('project')
>>> tree.append('README.md')
>>> tree.dir()
project/
 **README.md
>>> src = Folder('src')
>>> src.append('script.py')
>>> tree.append(src)
>>> tree.dir()
project/
 **README.md
 **src/
 **script.py

提示

内置类型涵盖了大多数用例

当您要创建一个类似序列或映射的新类时,请考虑其特性并查看现有的内置类型。collections模块扩展了许多有用的容器的基本内置类型。您将大部分时间使用其中一个。

从超类访问方法

super是一个内置类,可用于访问属于对象的超类的属性。

注意

Python 官方文档将super列为内置函数。但它是一个内置类,即使它像一个函数一样使用:

>>> super
<class 'super'>

当您习惯于通过直接调用父类并将self作为第一个参数传递来访问类属性或方法时,其用法有点令人困惑。这是一个非常古老的模式,但在一些代码库中仍然可以找到(特别是在传统项目中)。请参阅以下代码:

class Mama:  # this is the old way
    def says(self):
        print('do your homework')

class Sister(Mama):
    def says(self):
        Mama.says(self)
        print('and clean your bedroom')

在解释器会话中运行时,会得到以下结果:

>>> Sister().says()
do your homework
and clean your bedroom

特别注意Mama.says(self)这一行,我们使用刚才描述的技术来调用超类(即Mama类)的says()方法,并将self作为参数传递。这意味着将调用属于Mamasays()方法。但是,它将被调用的实例作为self参数提供,这在这种情况下是Sister的一个实例。

相反,super的用法将是:

class Sister(Mama):def says(self):
 **super(Sister, self).says()
        print('and clean your bedroom')

或者,您也可以使用super()调用的更短形式:

class Sister(Mama):def says(self):
 **super().says()
        print('and clean your bedroom')

super的更短形式(不传递任何参数)允许在方法内部使用,但super不仅限于方法。它可以在任何需要调用给定实例的超类方法实现的代码中使用。但是,如果在方法内部不使用super,则其参数是必需的:

>>> anita = Sister()
>>> super(anita.__class__, anita).says()
do your homework

关于super最后且最重要的一点是,它的第二个参数是可选的。当只提供第一个参数时,super将返回一个无界类型。这在使用classmethod时特别有用:

class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings

    def __repr__(self):
        return "Pizza with " + " and ".join(self.toppings)

    @classmethod
    def recommend(cls):
        """Recommend some pizza with arbitrary toppings,"""
        return cls(['spam', 'ham', 'eggs'])

class VikingPizza(Pizza):
    @classmethod
    def recommend(cls):
        """Use same recommendation as super but add extra spam"""
        recommended = super(VikingPizza).recommend()
        recommended.toppings += ['spam'] * 5
        return recommended

请注意,方法装饰有classmethod装饰器的方法也允许使用零参数的super()形式。在这样的方法中,不带参数地调用super()被视为只定义了第一个参数。

前面介绍的用例非常简单易懂,但当您面对多重继承模式时,使用super就变得困难了。在解释这些问题之前,了解何时应避免使用super以及 Python 中方法解析顺序MRO)的工作原理是很重要的。

Python 2 中的旧式类和 super

Python 2 中的super()几乎完全相同。调用签名的唯一区别是,更短的零参数形式不可用,因此始终必须提供预期的参数之一。

对于想要编写跨版本兼容代码的程序员来说,另一个重要的事情是,Python 2 中的super仅适用于新式类。早期版本的 Python 没有以object形式的所有类的共同祖先。旧行为在每个 Python 2.x 分支版本中都保留了向后兼容性,因此在这些版本中,如果类定义没有指定祖先,它将被解释为旧式类,无法使用super

class OldStyle1:
    pass

class OldStyle2():
    pass

Python 2 中的新式类必须明确继承自 object 或其他新式类:

class NewStyleClass(object):
    pass

class NewStyleClassToo(NewStyleClass):
    pass

Python 3 不再维护旧式类的概念,因此任何不继承自其他类的类都隐式继承自object。这意味着明确声明一个类继承自object可能看起来是多余的。一般的良好实践是不包含多余的代码,但在这种情况下去除这种多余对于不再针对任何 Python 2 版本的项目来说是一个好方法。目标跨版本兼容性的 Python 代码必须始终将object作为基类的祖先包括在内,即使在 Python 3 中这是多余的。不这样做将导致这样的类被解释为旧式类,最终会导致非常难以诊断的问题。

理解 Python 的方法解析顺序

Python 的方法解析顺序基于C3,这是为 Dylan 编程语言构建的 MRO(opendylan.org)。Michele Simionato 撰写的参考文档位于www.python.org/download/releases/2.3/mro。它描述了 C3 如何构建类的线性化,也称为优先级,这是祖先的有序列表。这个列表用于寻找属性。稍后在本节中将更详细地描述 C3 算法。

MRO 的变化是为了解决引入共同基本类型(object)时出现的问题。在 C3 线性化方法改变之前,如果一个类有两个祖先(参见图 1),那么解析方法的顺序对于不使用多重继承模型的简单情况来说是非常简单的。以下是一个在 Python 2 下不使用 C3 作为方法解析顺序的代码示例:

class Base1:
    pass

class Base2:
    def method(self):
        print('Base2')

class MyClass(Base1, Base2):
    pass

交互式会话的以下转录显示了这种方法解析的工作方式:

>>> MyClass().method()
Base2

当调用MyClass().method()时,解释器首先在MyClass中查找方法,然后在Base1中查找,最终在Base2中找到:

理解 Python 的方法解析顺序

图 1 经典层次结构

当我们在两个基类(Base1Base2都继承自它,参见图 2)之上引入一些CommonBase类时,情况变得更加复杂。结果,按照从左到右深度优先规则行为的简单解析顺序在查看Base2类之前通过Base1类返回到顶部。这种算法会产生出人意料的输出。在某些情况下,执行的方法可能不是在继承树中最接近的方法。

当在 Python 2 中使用旧式类(不继承自object)时,仍然可以使用这种算法。以下是 Python 2 中使用旧式类的旧方法解析的示例:

class CommonBase:
    def method(self):
        print('CommonBase')

class Base1(CommonBase):
    pass

class Base2(CommonBase):
    def method(self):
        print('Base2')

class MyClass(Base1, Base2):
    pass

来自交互会话的以下转录显示,尽管Base2在类层次结构中比CommonBase更接近,但Base2.method()不会被调用:

>>> MyClass().method()
CommonBase

理解 Python 的方法解析顺序

图 2 钻石类层次结构

这种继承场景极为罕见,因此更多是理论问题而不是实践问题。标准库不以这种方式构造继承层次结构,许多开发人员认为这是一种不好的做法。但是,随着在类型层次结构顶部引入object,多重继承问题在语言的 C 端出现冲突。还要注意的是,Python 3 中的每个类现在都有相同的共同祖先。由于使其与现有的 MRO 正常工作涉及太多工作,新的 MRO 是一个更简单和更快的解决方案。

因此,在 Python 3 下运行相同的示例会产生不同的结果:

class CommonBase:
    def method(self):
        print('CommonBase')

class Base1(CommonBase):
    pass

class Base2(CommonBase):
    def method(self):
        print('Base2')

class MyClass(Base1, Base2):
    pass

以下是使用示例,显示 C3 序列化将选择最接近祖先的方法:

>>> MyClass().method()
Base2

提示

请注意,上述行为在 Python 2 中无法复制,除非CommonBase类明确继承自object。即使在 Python 3 中这是多余的,也有必要在 Python 3 中指定object作为类祖先的原因在前一节Python 2 中的旧式类和 super中提到。

Python MRO 基于对基类的递归调用。总结 Michele Simionato 在本节开头引用的论文,应用于我们示例的 C3 符号表示法是:

L[MyClass(Base1, Base2)] =
        MyClass + merge(L[Base1], L[Base2], Base1, Base2)

在这里,L[MyClass]MyClass类的线性化,merge是一个合并多个线性化结果的特定算法。

因此,Simionato 所说的合成描述是:

"C 的线性化是 C 的总和加上父类的线性化的合并和父类的列表"

merge算法负责删除重复项并保留正确的顺序。它在论文中描述如下(适用于我们的示例):

"取第一个列表的头,即 L[Base1][0];如果这个头不在其他列表的尾部,则将其添加到 MyClass 的线性化中并从合并的列表中删除它,否则查看下一个列表的头并取出它,如果它是一个好的头。

然后,重复操作,直到所有类都被移除或者无法找到好的头。在这种情况下,无法构造合并,Python 2.3 将拒绝创建MyClass类并引发异常。

head是列表的第一个元素,tail包含其余的元素。例如,在(Base1, Base2, ..., BaseN)中,Base1head(Base2, ..., BaseN)tail

换句话说,C3 对每个父类进行递归深度查找以获得一系列列表。然后,它计算一个从左到右的规则,以合并所有包含类的列表,并进行层次结构消歧义。

因此结果是:

def L(klass):
    return [k.__name__ for k in klass.__mro__]

>>> L(MyClass)
['MyClass', 'Base1', 'Base2', 'CommonBase', 'object']

提示

一个类的__mro__属性(只读)存储了线性化计算的结果,这是在类定义加载时完成的。

你也可以调用MyClass.mro()来计算并获取结果。这也是 Python 2 中的类应该额外小心的另一个原因。虽然 Python 2 中的旧式类有一定的方法解析顺序,但它们不提供__mro__属性和mro()方法。因此,尽管有解析顺序,但说它们有 MRO 是错误的。在大多数情况下,每当有人在 Python 中提到 MRO 时,他们指的是本节中描述的 C3 算法。

super 陷阱

回到super。在使用多重继承层次结构时,它可能非常危险,主要是因为类的初始化。在 Python 中,基类不会在__init__()中被隐式调用,因此开发人员需要调用它们。我们将看到一些例子。

混合使用 super 和显式类调用

在以下示例中,来自 James Knight 网站(fuhm.net/super-harmful)的一个示例,一个调用其基类使用__init__()方法的C类将使B类被调用两次:

class A:
    def __init__(self):
        print("A", end=" ")
        super().__init__()

class B:
    def __init__(self):
        print("B", end=" ")
        super().__init__()

class C(A, B):
    def __init__(self):
        print("C", end=" ")
        A.__init__(self)
        B.__init__(self)

以下是输出:

>>> print("MRO:", [x.__name__ for x in C.__mro__])
MRO: ['C', 'A', 'B', 'object']
>>> C()
C A B B <__main__.C object at 0x0000000001217C50>

这是由A.__init__(self)调用引起的,它是用C实例进行的,因此使得super(A, self).__init__()调用B.__init__()方法。换句话说,super应该在整个类层次结构中使用。问题在于有时这个层次结构的一部分位于第三方代码中。James 的网页上可以找到由多重继承引入的层次结构调用的许多相关陷阱。

不幸的是,你无法确定外部包在他们的代码中是否使用super()。每当你需要对第三方类进行子类化时,最好的方法是查看其代码以及 MRO 中其他类的代码。这可能会很繁琐,但作为奖励,你会得到有关该软件包提供的代码质量的一些信息,以及对其实现的更多理解。你可能会通过这种方式学到一些新东西。

异构参数

super用法的另一个问题是初始化中的参数传递。如果一个类调用其基类的__init__()代码,但它的签名不同,会导致以下问题:

class CommonBase:
    def __init__(self):
        print('CommonBase')
        super().__init__()

class Base1(CommonBase):
    def __init__(self):
        print('Base1')
        super().__init__()

class Base2(CommonBase):
    def __init__(self, arg):
        print('base2')
        super().__init__()

class MyClass(Base1 , Base2):
    def __init__(self, arg):
        print('my base')
        super().__init__(arg)

尝试创建MyClass实例将由于父类的__init__()签名不匹配而引发TypeError

>>> MyClass(10)
my base
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
 **File "<stdin>", line 4, in __init__
TypeError: __init__() takes 1 positional argument but 2 were given

一个解决方案是使用*args**kwargs魔术打包的参数和关键字参数,以便所有构造函数传递所有参数,即使它们不使用它们:

class CommonBase:
    def __init__(self, *args, **kwargs):
        print('CommonBase')
        super().__init__()

class Base1(CommonBase):
    def __init__(self, *args, **kwargs):
        print('Base1')
        super().__init__(*args, **kwargs)

class Base2(CommonBase):
    def __init__(self, *args, **kwargs):
        print('base2')
        super().__init__(*args, **kwargs)

class MyClass(Base1 , Base2):
    def __init__(self, arg):
        print('my base')
        super().__init__(arg)

使用这种方法,父类的签名将始终匹配:

>>> _ = MyClass(10)
my base
Base1
base2
CommonBase

然而,这是一个糟糕的修复方法,因为它使所有构造函数都接受任何类型的参数。这会导致代码质量低下,因为任何东西都可以被传递和执行。另一个解决方案是在MyClass中使用特定类的显式__init__()调用,但这将导致第一个陷阱。

最佳实践

为了避免所有提到的问题,并且在 Python 在这个领域发展之前,我们需要考虑以下几点:

  • 应避免多重继承:它可以用第十四章中介绍的一些设计模式来替代,有用的设计模式

  • 超级用法必须保持一致:在类层次结构中,super应该在所有地方或者都不使用。混合使用super和经典调用是一种令人困惑的做法。人们倾向于避免使用super,以使他们的代码更加明确。

  • 如果你的目标是 Python 2,那么在 Python 3 中明确从 object 继承:在 Python 2 中,没有指定任何祖先的类被识别为旧式类。在 Python 2 中应避免混合旧式类和新式类。

  • 在调用父类时必须查看类层次结构:为了避免任何问题,每次调用父类时,都必须快速查看涉及的 MRO(使用__mro__)。

高级属性访问模式

当许多 C++和 Java 程序员首次学习 Python 时,他们会对 Python 缺少private关键字感到惊讶。最接近的概念是名称混淆。每当属性以__为前缀时,解释器会即时重命名属性:

class MyClass:
    __secret_value = 1

通过其初始名称访问__secret_value属性将引发AttributeError异常:

>>> instance_of = MyClass()
>>> instance_of.__secret_value
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
AttributeError: 'MyClass' object has no attribute '__secret_value'
>>> dir(MyClass)
['_MyClass__secret_value', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__']
>>> instance_of._MyClass__secret_value
1

提供此功能是为了避免继承下的名称冲突,因为属性会以类名作为前缀进行重命名。这不是真正的锁,因为可以通过其组合名称访问属性。此功能可用于保护对某些属性的访问,但在实践中,不应该使用__。当属性不是公共的时,使用的约定是使用_前缀。这不会调用任何混淆算法,而只是将属性记录为类的私有元素,并且是主要的样式。

Python 中还有其他机制可用于构建类的公共部分以及私有代码。应该使用描述符和属性来设计一个干净的 API,这是面向对象设计的关键特性。

描述符

描述符允许您自定义在对象上引用属性时应执行的操作。

描述符是 Python 中复杂属性访问的基础。它们在内部用于实现属性、方法、类方法、静态方法和super类型。它们是定义另一个类的属性如何被访问的类。换句话说,一个类可以将属性的管理委托给另一个类。

描述符类基于三个形成描述符协议的特殊方法:

  • __set__(self, obj, type=None): 每当设置属性时调用此方法。在以下示例中,我们将其称为setter

  • __get__(self, obj, value): 每当读取属性时调用此方法(称为getter)。

  • __delete__(self, obj): 当对属性调用del时调用此方法。

实现__get__()__set__()的描述符称为数据描述符。如果只实现__get__(),则称为非数据描述符

实际上,该协议的方法是由对象的特殊__getattribute__()方法调用的(不要与具有不同目的的__getattr__()混淆),在每次属性查找时都会隐式调用__getattribute__()方法,并且它按以下顺序查找属性:

  1. 它验证属性是否是实例的类对象上的数据描述符。

  2. 如果没有,它会查看属性是否可以在实例对象的__dict__中找到。

  3. 最后,它查看属性是否是实例的类对象上的非数据描述符。

换句话说,数据描述符优先于__dict__查找,而__dict__查找优先于非数据描述符。

为了更清楚,这里有一个来自官方 Python 文档的示例,展示了描述符在实际代码中的工作原理:

class RevealAccess(object):
    """A data descriptor that sets and returns values
       normally and prints a message logging their access.
    """

    def __init__(self, initval=None, name='var'):
        self.val = initval
        self.name = name

    def __get__(self, obj, objtype):
        print('Retrieving', self.name)
        return self.val

    def __set__(self, obj, val):
        print('Updating', self.name)
        self.val = val

class MyClass(object):
    x = RevealAccess(10, 'var "x"')
    y = 5

以下是在交互会话中使用它的示例:

>>> m = MyClass()
>>> m.x
Retrieving var "x"
10
>>> m.x = 20
Updating var "x"
>>> m.x
Retrieving var "x"
20
>>> m.y
5

前面的例子清楚地表明,如果一个类具有给定属性的数据描述符,那么每次检索实例属性时都会调用描述符的__get__()方法来返回值,并且每当将值分配给这样的属性时都会调用__set__()。虽然在前面的例子中没有显示描述符的__del__方法的情况,但现在应该很明显:每当使用del instance.attribute语句或delattr(instance, 'attribute')调用删除实例属性时都会调用它。

数据和非数据描述符之间的区别是重要的,因为在开始时已经说明的事实。Python 已经使用描述符协议将类函数绑定到实例作为方法。它们还支持classmethodstaticmethod装饰器背后的机制。这是因为实际上,函数对象也是非数据描述符:

>>> def function(): pass
>>> hasattr(function, '__get__')
True
>>> hasattr(function, '__set__')
False

对于使用 lambda 表达式创建的函数也是如此:

>>> hasattr(lambda: None, '__get__')
True
>>> hasattr(lambda: None, '__set__')
False

因此,如果__dict__不优先于非数据描述符,我们将无法在运行时动态覆盖已构造实例上的特定方法。幸运的是,由于 Python 中描述符的工作方式。它是可用的,因此开发人员可以使用一种称为猴子补丁的流行技术来改变实例的工作方式,而无需子类化。

现实生活中的例子-延迟评估属性

描述符的一个例子用法可能是将类属性的初始化延迟到从实例中访问它的时刻。如果此类属性的初始化取决于全局应用上下文,则这可能是有用的。另一种情况是当这样的初始化只是昂贵的,但不知道在导入类时是否会使用它。这样的描述符可以实现如下:

class InitOnAccess:
    def __init__(self, klass, *args, **kwargs):
        self.klass = klass
        self.args = args
        self.kwargs = kwargs
        self._initialized = None

    def __get__(self, instance, owner):
        if self._initialized is None:
            print('initialized!')
            self._initialized = self.klass(*self.args, **self.kwargs)
        else:
            print('cached!')
        return self._initialized

以下是一个例子用法:

>>> class MyClass:
...     lazily_initialized = InitOnAccess(list, "argument")
...
>>> m = MyClass()
>>> m.lazily_initialized
initialized!
['a', 'r', 'g', 'u', 'm', 'e', 'n', 't']
>>> m.lazily_initialized
cached!
['a', 'r', 'g', 'u', 'm', 'e', 'n', 't']

PyPI 上提供的官方 OpenGL Python 库以PyOpenGL名称可用,使用类似的技术来实现lazy_property,它既是装饰器又是数据描述符:

class lazy_property(object):
    def __init__(self, function):
        self.fget = function

    def __get__(self, obj, cls):
        value = self.fget(obj)
        setattr(obj, self.fget.__name__, value)
        return value

这样的实现类似于使用property装饰器(稍后描述),但用它包装的函数只执行一次,然后类属性被此类属性返回的值替换。当开发人员需要同时满足以下两个要求时,这种技术通常很有用:

  • 对象实例需要存储为一个类属性,在其实例之间共享,以节省资源

  • 这个对象不能在导入时初始化,因为它的创建过程取决于一些全局应用状态/上下文

在使用 OpenGL 编写的应用程序中,这通常是很真实的。例如,在 OpenGL 中创建着色器是昂贵的,因为它需要编译用GLSLOpenGL 着色语言)编写的代码。只有在需要时才合理地创建它们,并将它们的定义包含在需要它们的类的紧密接近中。另一方面,没有初始化 OpenGL 上下文就无法执行着色器编译,因此很难在导入时在全局模块命名空间中可靠地定义和编译它们。

以下示例显示了在某个想象的基于 OpenGL 的应用程序中使用修改后的 PyOpenGL 的lazy_property装饰器(这里是lazy_class_attribute)的可能用法。对原始lazy_property装饰器的突出更改是为了允许在不同类实例之间共享属性:

import OpenGL.GL as gl
from OpenGL.GL import shaders

class lazy_class_attribute(object):
    def __init__(self, function):
        self.fget = function
 **def __get__(self, obj, cls):
 **value = self.fget(obj or cls)
 **# note: storing in class object not its instance
 **#       no matter if its a class-level or
 **#       instance-level access
 **setattr(cls, self.fget.__name__, value)
 **return value

class ObjectUsingShaderProgram(object):
    # trivial pass-through vertex shader implementation
    VERTEX_CODE = """
        #version 330 core
        layout(location = 0) in vec4 vertexPosition;
        void main(){
            gl_Position =  vertexPosition;
        }
    """
    # trivial fragment shader that results in everything
    # drawn with white color
    FRAGMENT_CODE = """
        #version 330 core
        out lowp vec4 out_color;
        void main(){
            out_color = vec4(1, 1, 1, 1);
        }
    """

    @lazy_class_attribute
    def shader_program(self):
        print("compiling!")
        return shaders.compileProgram(
            shaders.compileShader(
                self.VERTEX_CODE, gl.GL_VERTEX_SHADER
            ),
            shaders.compileShader(
                self.FRAGMENT_CODE, gl.GL_FRAGMENT_SHADER
            )
        )

像 Python 的每个高级语法特性一样,这个特性也应该谨慎使用,并在代码中进行良好的文档记录。对于经验不足的开发人员来说,修改后的类行为可能会非常令人困惑和意外,因为描述符会影响类行为的基本部分,比如属性访问。因此,非常重要的是确保所有团队成员都熟悉描述符,并且如果它在项目的代码库中起重要作用,他们对这个概念有很好的理解。

属性

属性提供了一种内置的描述符类型,知道如何将属性链接到一组方法。property接受四个可选参数:fgetfsetfdeldoc。最后一个可以提供用于定义与属性关联的docstring,就像它是一个方法一样。下面是一个Rectangle类的示例,可以通过直接访问存储两个角点的属性或使用widthheight属性来控制:

class Rectangle:
    def __init__(self, x1, y1, x2, y2):
        self.x1, self.y1 = x1, y1
        self.x2, self.y2 = x2, y2

    def _width_get(self):
        return self.x2 - self.x1

    def _width_set(self, value):
        self.x2 = self.x1 + value

    def _height_get(self):
        return self.y2 - self.y1

    def _height_set(self, value):
        self.y2 = self.y1 + value

    width = property(
        _width_get, _width_set,
        doc="rectangle width measured from left"
    )
    height = property(
        _height_get, _height_set,
        doc="rectangle height measured from top"
    )

    def __repr__(self):
        return "{}({}, {}, {}, {})".format(
            self.__class__.__name__,
            self.x1, self.y1, self.x2, self.y2
        )

在交互式会话中使用这些定义的属性的示例用法如下:

>>> rectangle = Rectangle(10, 10, 25, 34)
>>> rectangle.width, rectangle.height
(15, 24)
>>> rectangle.width = 100
>>> rectangle
Rectangle(10, 10, 110, 34)
>>> rectangle.height = 100
>>> rectangle
Rectangle(10, 10, 110, 110)
help(Rectangle)
Help on class Rectangle in module chapter3:

class Rectangle(builtins.object)
 **|  Methods defined here:
 **|** 
 **|  __init__(self, x1, y1, x2, y2)
 **|      Initialize self.  See help(type(self)) for accurate signature.
 **|** 
 **|  __repr__(self)
 **|      Return repr(self).
 **|** 
 **|  --------------------------------------------------------
 **|  Data descriptors defined here:
 **|  (...)
 **|** 
 **|  height
 **|      rectangle height measured from top
 **|** 
 **|  width
 **|      rectangle width measured from left

属性使得编写描述符更加容易,但在使用继承时必须小心处理。创建的属性是使用当前类的方法动态生成的,并不会使用在派生类中被覆盖的方法。

例如,以下示例将无法覆盖父类(Rectanglewidth属性的fget方法的实现:

>>> class MetricRectangle(Rectangle):
...     def _width_get(self):
...         return "{} meters".format(self.x2 - self.x1)
...** 
>>> Rectangle(0, 0, 100, 100).width
100

为了解决这个问题,只需要在派生类中简单地覆盖整个属性:

>>> class MetricRectangle(Rectangle):
...     def _width_get(self):
...         return "{} meters".format(self.x2 - self.x1)
...     width = property(_width_get, Rectangle.width.fset)
...** 
>>> MetricRectangle(0, 0, 100, 100).width
'100 meters'

不幸的是,前面的代码存在一些可维护性问题。如果开发人员决定更改父类,但忘记更新属性调用,这可能会成为一个问题。这就是为什么不建议仅覆盖属性行为的部分。与其依赖于父类的实现,不如在派生类中重写所有属性方法,如果需要更改它们的工作方式。在大多数情况下,这通常是唯一的选择,因为通常更改属性的setter行为意味着需要更改getter的行为。

由于前面的原因,创建属性的最佳语法是使用property作为装饰器。这将减少类内部方法签名的数量,并使代码更易读和易维护:

class Rectangle:
    def __init__(self, x1, y1, x2, y2):
        self.x1, self.y1 = x1, y1
        self.x2, self.y2 = x2, y2
    @property
    def width(self):
        """rectangle height measured from top"""
        return self.x2 - self.x1

    @width.setter
    def width(self, value):
        self.x2 = self.x1 + value

    @property
    def height(self):
        """rectangle height measured from top"""
        return self.y2 - self.y1

    @height.setter
    def height(self, value):
        self.y2 = self.y1 + value

Slots

开发人员几乎从不使用的一个有趣特性是 slots。它们允许您为给定类设置一个静态属性列表,使用__slots__属性,并跳过在每个类实例中创建__dict__字典。它们旨在为具有非常少属性的类节省内存空间,因为__dict__不会在每个实例中创建。

除此之外,它们可以帮助设计需要被冻结的类的签名。例如,如果您需要限制语言的动态特性在一个类上,定义 slots 可以帮助:

>>> class Frozen:
...     __slots__ = ['ice', 'cream']
...** 
>>> '__dict__' in dir(Frozen)
False
>>> 'ice' in dir(Frozen)
True
>>> frozen = Frozen()
>>> frozen.ice = True
>>> frozen.cream = None
>>> frozen.icy = True
Traceback (most recent call last):
 **File "<input>", line 1, in <module>
AttributeError: 'Frozen' object has no attribute 'icy'

这个特性应该谨慎使用。当使用__slots__限制一组可用属性时,动态地向对象添加内容就会变得更加困难。一些技术,比如猴子补丁,将无法用于具有定义 slots 的类的实例。幸运的是,如果派生类没有定义自己的 slots,新属性可以添加到派生类中:

>>> class Unfrozen(Frozen):
...     pass
...** 
>>> unfrozen = Unfrozen()
>>> unfrozen.icy = False
>>> unfrozen.icy
False

元编程

可能有一些学术论文中对元编程有很好的定义,可以在这里引用,但这更像是一本关于良好软件工艺的书,而不是关于计算机科学理论的书。这就是为什么我们将使用一个简单的定义:

“元编程是一种编写计算机程序的技术,可以在运行时将自身视为数据,因此可以自省、生成和/或修改自身。”

使用这个定义,我们可以区分 Python 中的两种主要元编程方法。

第一种方法集中在语言的能力上,可以自省其基本元素,如函数、类或类型,并在运行时创建或修改它们。Python 为开发人员在这个领域提供了很多工具。最简单的是装饰器,允许向现有的函数、方法或类添加额外的功能。接下来是类的特殊方法,允许您干预类实例的创建过程。最强大的是元类,允许程序员甚至完全重新设计面向对象编程范式的 Python 实现。在这里,我们还有一系列不同的工具,允许程序员直接使用代码,无论是以原始纯文本格式还是以更具编程访问性的抽象语法树(AST)形式。这第二种方法当然更复杂,更难处理,但允许实现非常特别的事情,比如扩展 Python 的语言语法,甚至创建自己的领域特定语言(DSL)。

装饰器-元编程的一种方法

装饰器语法在第二章中有解释,语法最佳实践-类级别以下,作为一个简单的模式:

def decorated_function():
    pass
decorated_function = some_decorator(decorated_function)

这清楚地展示了装饰器的作用。它接受一个函数对象,并在运行时对其进行修改。结果,基于先前的函数对象创建了一个新函数(或其他任何东西),名称相同。这甚至可能是一个复杂的操作,根据原始函数的实现方式给出不同的结果。这意味着装饰器可以被视为元编程工具。

这是个好消息。装饰器相对容易掌握,在大多数情况下可以使代码更简洁、更易读,也更便于维护。Python 中的其他元编程工具更难以理解和掌握。而且,它们可能根本不会使代码简单。

类装饰器

Python 的一个不太为人知的语法特性是类装饰器。它们的语法和工作方式与第二章中提到的函数装饰器完全相同,语法最佳实践-类级别以下。唯一的区别是它们期望返回一个类,而不是函数对象。以下是一个修改__repr__()方法以返回可打印对象表示的示例类装饰器,该表示被缩短为任意数量字符:

def short_repr(cls):
    cls.__repr__ = lambda self: super(cls, self).__repr__()[:8]
    return cls

@short_repr
class ClassWithRelativelyLongName:
    pass

以下是您将在输出中看到的内容:

>>> ClassWithRelativelyLongName()
<ClassWi

当然,前面的代码片段绝对不是一个好的代码示例,因为它太神秘了。但它展示了本章中解释的多种语言特性如何可以一起使用:

  • 不仅实例,类对象也可以在运行时修改

  • 函数也是描述符,因此它们可以在运行时添加到类中,因为实际的绑定实例是作为描述符协议的一部分进行属性查找的

  • super()调用可以在类定义范围之外使用,只要提供适当的参数

  • 最后,类装饰器可以用于类定义

编写函数装饰器的其他方面也适用于类装饰器。最重要的是,它们可以使用闭包并且可以被参数化。利用这些事实,前面的示例可以重写为更可读和可维护的形式:

def parametrized_short_repr(max_width=8):
    """Parametrized decorator that shortens representation"""
    def parametrized(cls):
        """Inner wrapper function that is actual decorator"""
        class ShortlyRepresented(cls):
            """Subclass that provides decorated behavior"""
            def __repr__(self):
                return super().__repr__()[:max_width]

        return ShortlyRepresented

    return parametrized

在类装饰器中使用闭包的主要缺点是,结果对象不再是被装饰的类的实例,而是在装饰器函数中动态创建的子类的实例。除其他外,这将影响类的__name____doc__属性:

@parametrized_short_repr(10)
class ClassWithLittleBitLongerLongName:
    pass

类装饰器的这种用法将导致对类元数据的以下更改:

>>> ClassWithLittleBitLongerLongName().__class__
<class 'ShortlyRepresented'>
>>> ClassWithLittleBitLongerLongName().__doc__
'Subclass that provides decorated behavior'

不幸的是,这不能像在第二章的保留内省装饰器部分中所解释的那样简单修复,语法最佳实践 - 类级别以下,使用额外的wraps装饰器。这使得在某些情况下,以这种形式使用类装饰器受到限制。如果没有额外的工作来保留旧类的元数据,那么这可能会破坏许多自动化文档生成工具的结果。

尽管有这个单一的警告,类装饰器仍然是流行的 mixin 类模式的一个简单而轻量的替代品。

在 Python 中,mixin 是一个不打算被实例化的类,而是用来为其他现有类提供一些可重用的 API 或功能。Mixin 类几乎总是以多重继承的形式添加的:

class SomeConcreteClass(MixinClass, SomeBaseClass):
    pass

Mixin 是一种有用的设计模式,在许多库中被使用。举个例子,Django 是其中一个广泛使用它们的框架。虽然有用且受欢迎,但如果设计不好,mixin 可能会引起一些麻烦,因为在大多数情况下,它们需要开发人员依赖多重继承。正如之前所说,Python 相对较好地处理多重继承,这要归功于 MRO。无论如何,如果不需要太多额外的工作并且使代码更简单,最好避免对多个类进行子类化。这就是为什么类装饰器可能是 mixin 的一个很好的替代品。

使用__new__()方法来重写实例创建过程

特殊方法__new__()是一个负责创建类实例的静态方法。它是特殊情况,因此不需要使用staticmethod装饰器将其声明为静态方法。这个__new__(cls, [,...])方法在__init__()初始化方法之前被调用。通常,重写的__new__()的实现会使用super().__new__()调用其超类版本,并使用合适的参数修改实例后返回它:

class InstanceCountingClass:
    instances_created = 0
    def __new__(cls, *args, **kwargs):
        print('__new__() called with:', cls, args, kwargs)
        instance = super().__new__(cls)
        instance.number = cls.instances_created
        cls.instances_created += 1

        return instance

    def __init__(self, attribute):
        print('__init__() called with:', self, attribute)
        self.attribute = attribute

这是一个示例交互会话的日志,展示了我们的InstanceCountingClass实现是如何工作的:

>>> instance1 = InstanceCountingClass('abc')
__new__() called with: <class '__main__.InstanceCountingClass'> ('abc',) {}
__init__() called with: <__main__.InstanceCountingClass object at 0x101259e10> abc
>>> instance2 = InstanceCountingClass('xyz')
__new__() called with: <class '__main__.InstanceCountingClass'> ('xyz',) {}
__init__() called with: <__main__.InstanceCountingClass object at 0x101259dd8> xyz
>>> instance1.number, instance1.instances_created
(0, 2)
>>> instance2.number, instance2.instances_created
(1, 2)

__new__()方法通常应该返回一个特色类的实例,但也可能返回其他类的实例。如果发生这种情况(返回不同的类实例),则对__init__()方法的调用将被跳过。当需要修改非可变类实例的创建行为时,这一事实是有用的,比如 Python 的一些内置类型:

class NonZero(int):
    def __new__(cls, value):
        return super().__new__(cls, value) if value != 0 else None

    def __init__(self, skipped_value):
        # implementation of __init__ could be skipped in this case
        # but it is left to present how it may be not called
        print("__init__() called")
        super().__init__()

让我们在交互式会话中看看这个:

>>> type(NonZero(-12))
__init__() called
<class '__main__.NonZero'>
>>> type(NonZero(0))
<class 'NoneType'>
>>> NonZero(-3.123)
__init__() called
-3

那么,什么时候使用__new__()呢?答案很简单:只有当__init__()不够用时。已经提到了这样的情况。这是对非可变的内置 Python 类型(如intstrfloatfrozenset等)进行子类化。这是因为一旦创建了这样一个不可变对象实例,就没有办法在__init__()方法中修改它。

一些程序员可能会认为__new__()对于执行重要的对象初始化可能是有用的,如果用户忘记使用super(),则可能会错过。__init__()调用是被重写的初始化方法。虽然听起来是合理的,但这有一个主要的缺点。如果使用这种方法,那么程序员更难以显式地跳过先前的初始化步骤,如果这已经是期望的行为。它也打破了__init__()中执行的所有初始化的一个不成文的规则。

因为__new__()不受限于返回相同的类实例,所以它很容易被滥用。对这种方法的不负责任使用可能对代码造成很大的伤害,因此应该始终小心使用,并配以广泛的文档支持。通常,最好是寻找可能可用于给定问题的其他解决方案,而不是以一种会破坏基本程序员期望的方式影响对象创建。甚至前面提到的不可变类型的重写初始化也可以用更可预测和成熟的设计模式来替代,比如工厂方法,它在第十四章中有描述,有用的设计模式

在 Python 编程中至少有一个方面,广泛使用__new__()方法是完全合理的。这些是在下一节中描述的元类。

元类

元类是 Python 的一个特性,被许多人认为是这种语言中最困难的东西,因此被许多开发人员所避免。实际上,一旦你理解了一些基本概念,它并不像听起来那么复杂。作为回报,了解这个特性可以做一些使用其他方法不可能做到的事情。

元类是定义其他类型(类)的类型(类)。要理解它们的工作原理,最重要的是要知道定义对象实例的类也是对象。因此,如果它们是对象,那么它们就有一个关联的类。每个类定义的基本类型只是内置的type类。下面是一个简单的图表,应该能够清楚地说明这一点:

元类

图 3 类型化类的方式

在 Python 中,可以用我们自己的类型替换类对象的元类。通常,新的元类仍然是type类的子类(参见图 4),因为不这样做会使得结果类在继承方面与其他类高度不兼容。

元类

图 4 自定义元类的通用实现

一般语法

对内置的type()类的调用可以用作类语句的动态等价物。它根据其名称、基类和包含其属性的映射创建一个新的类对象:

def method(self):
    return 1

klass = type('MyClass', (object,), {'method': method})

以下是输出:

>>> instance = klass()
>>> instance.method()
1

这相当于对类的显式定义:

class MyClass:
    def method(self):
        return 1

这是你将得到的:

>>> instance = MyClass()
>>> instance.method()
1

使用类语句隐式创建的每个类都将type作为其元类。通过在类语句中提供metaclass关键字参数,可以更改此默认行为:

class ClassWithAMetaclass(metaclass=type):
    pass

作为metaclass参数提供的值通常是另一个类对象,但它可以是任何其他可调用对象,接受与type类相同的参数,并且预期返回另一个类对象。调用签名是type(name, bases, namespace),如下所述:

  • name:这是将存储在__name__属性中的类的名称

  • bases:这是将成为__bases__属性并用于构造新创建类的 MRO 的父类列表

  • namespace:这是一个包含类体定义的命名空间(映射),将成为__dict__属性

思考元类的一种方式是__new__()方法,但在类定义的更高级别上。

尽管可以使用显式调用type()的函数来替代元类,但通常的方法是使用一个从type继承的不同类来实现这个目的。元类的通用模板如下:

class Metaclass(type):
    def __new__(mcs, name, bases, namespace):
        return super().__new__(mcs, name, bases, namespace)

    @classmethod
    def __prepare__(mcs, name, bases, **kwargs):
        return super().__prepare__(name, bases, **kwargs)

    def __init__(cls, name, bases, namespace, **kwargs):
        super().__init__(name, bases, namespace)

    def __call__(cls, *args, **kwargs):
        return super().__call__(*args, **kwargs)

namebasesnamespace参数的含义与前面解释的type()调用相同,但这四种方法中的每一种都可以有不同的目的:

  • __new__(mcs, name, bases, namespace): 这负责实际创建类对象,方式与普通类相同。第一个位置参数是一个元类对象。在前面的示例中,它将简单地是一个Metaclass。注意,mcs是这个参数的常用命名约定。

  • __prepare__(mcs, name, bases, **kwargs): 这创建一个空的命名空间对象。默认情况下,它返回一个空的dict,但可以被覆盖以返回任何其他映射类型。注意,它不接受namespace作为参数,因为在调用它之前命名空间不存在。

  • __init__(cls, name, bases, namespace, **kwargs): 这在元类实现中并不常见,但与普通类中的意义相同。它可以在使用__new__()创建类对象后执行额外的类对象初始化。第一个位置参数现在按照惯例命名为cls,以标记这已经是一个已创建的类对象(元类实例),而不是一个元类对象。当调用__init__()时,类已经被构建,因此这个方法可以做的事情比__new__()方法少。实现这样一个方法与使用类装饰器非常相似,但主要区别在于__init__()将被调用用于每个子类,而类装饰器不会被子类调用。

  • __call__(cls, *args, **kwargs): 当调用元类的实例时调用此方法。元类的实例是一个类对象(参见图 3);当创建类的新实例时调用它。这可以用来覆盖类实例被创建和初始化的默认方式。

前面的每个方法都可以接受额外的关键字参数,这里用**kwargs表示。这些参数可以通过在类定义中使用额外的关键字参数传递给元类对象,形式如下代码:

class Klass(metaclass=Metaclass, extra="value"):
    pass

在没有适当的示例的情况下,这么多的信息可能会让人感到不知所措,所以让我们通过一些print()调用来追踪元类、类和实例的创建:

class RevealingMeta(type):
    def __new__(mcs, name, bases, namespace, **kwargs):
        print(mcs, "__new__ called")
        return super().__new__(mcs, name, bases, namespace)

    @classmethod
    def __prepare__(mcs, name, bases, **kwargs):
        print(mcs, "__prepare__ called")
        return super().__prepare__(name, bases, **kwargs)

    def __init__(cls, name, bases, namespace, **kwargs):
        print(cls, "__init__ called")
        super().__init__(name, bases, namespace)

    def __call__(cls, *args, **kwargs):
        print(cls, "__call__ called")
        return super().__call__(*args, **kwargs)

使用RevealingMeta作为元类来创建一个新的类定义将在 Python 交互会话中给出以下输出:

>>> class RevealingClass(metaclass=RevealingMeta):
...     def __new__(cls):
...         print(cls, "__new__ called")
...         return super().__new__(cls)
...     def __init__(self):
...         print(self, "__init__ called")
...         super().__init__()
...** 
<class 'RevealingMeta'> __prepare__ called
<class 'RevealingMeta'> __new__ called
<class 'RevealingClass'> __init__ called
>>> instance = RevealingClass()
<class 'RevealingClass'> __call__ called
<class 'RevealingClass'> __new__ called
<RevealingClass object at 0x1032b9fd0> __init__ called

元类的新 Python 3 语法

元类不是一个新特性,在 Python 2.2 版本以来就可用。无论如何,这种语法的改变是显著的,这种改变既不向后兼容也不向前兼容。新的语法是:

class ClassWithAMetaclass(metaclass=type):
    pass

在 Python 2 中,这必须写成如下形式:

class ClassWithAMetaclass(object):
    __metaclass__ = type

Python 2 中的类语句不接受关键字参数,因此在 Python 3 中定义metaclasses的语法将在导入时引发SyntaxError异常。仍然可以编写使用元类的代码在两个 Python 版本上运行,但需要一些额外的工作。幸运的是,像six这样的与兼容性相关的包提供了这个问题的简单可重用的解决方案。

from six import with_metaclass

class Meta(type):
    pass

class Base(object):
    pass

class MyClass(with_metaclass(Meta, Base)):
    pass

另一个重要的区别是 Python 2 中元类缺少__prepare__()钩子。实现这样一个函数在 Python 2 中不会引发任何异常,但是没有意义,因为它不会被调用以提供一个干净的命名空间对象。这就是为什么需要保持 Python 2 兼容性的包需要依赖更复杂的技巧,如果它们想要实现使用__prepare__()更容易实现的功能。例如,Django REST Framework (www.django-rest-framework.org) 使用以下方法来保留属性添加到类的顺序:

class SerializerMetaclass(type):
    @classmethod
    def _get_declared_fields(cls, bases, attrs):
        fields = [(field_name, attrs.pop(field_name))
                  for field_name, obj in list(attrs.items())
                  if isinstance(obj, Field)]
        fields.sort(key=lambda x: x[1]._creation_counter)

        # If this class is subclassing another Serializer, add 
        # that Serializer's fields. 
        # Note that we loop over the bases in *reverse*. 
        # This is necessary in order to maintain the 
        # correct order of fields.
        for base in reversed(bases):
            if hasattr(base, '_declared_fields'):
                fields = list(base._declared_fields.items()) + fields

        return OrderedDict(fields)

    def __new__(cls, name, bases, attrs):
        attrs['_declared_fields'] = cls._get_declared_fields(
            bases, attrs
        )
        return super(SerializerMetaclass, cls).__new__(
            cls, name, bases, attrs
        )

这是默认命名空间类型,即dict,不能保证保留键值元组的顺序的解决方法。预期Field类的每个实例中都会有_creation_counter属性。Field.creation_counter属性的创建方式与__new__()方法部分中介绍的InstanceCountingClass.instance_number相同。这是一个相当复杂的解决方案,它通过在两个不同的类之间共享其实现来打破单一责任原则,以确保属性的可跟踪顺序。在 Python 3 中,这可能会更简单,因为__prepare__()可以返回其他映射类型,如OrderedDict

from collections import OrderedDict

class OrderedMeta(type):
    @classmethod
    def __prepare__(cls, name, bases, **kwargs):
        return OrderedDict()

    def __new__(mcs, name, bases, namespace):
        namespace['order_of_attributes'] = list(namespace.keys())
        return super().__new__(mcs, name, bases, namespace)

class ClassWithOrder(metaclass=OrderedMeta):
    first = 8
    second = 2

以下是您将看到的内容:

>>> ClassWithOrderedAttributes.order_of_attributes
['__module__', '__qualname__', 'first', 'second']
>>> ClassWithOrderedAttributes.__dict__.keys()
dict_keys(['__dict__', 'first', '__weakref__', 'second', 'order_of_attributes', '__module__', '__doc__'])

注意

有关更多示例,请参阅 David Mertz 在 Python 2 中的元类编程的介绍,网址为www.onlamp.com/pub/a/python/2003/04/17/metaclasses.html

元类用法

一旦掌握了元类,它就是一个强大的功能,但总是会使代码复杂化。它们可能还会使代码不够健壮,而这些代码本来是打算适用于任何类型的类。例如,当类中使用槽时,或者当某个基类已经实现了一个元类,与您的元类发生冲突时,可能会遇到不良的交互。它们只是无法很好地组合。

对于简单的事情,如更改读/写属性或添加新属性,可以避免使用元类,而使用更简单的解决方案,如属性、描述符或类装饰器。

同样可以肯定的是,元类经常可以用其他更简单的方法替代,但也有一些情况下很难在没有它们的情况下轻松完成。例如,很难想象没有广泛使用元类的 Django 的 ORM 实现。这可能是可能的,但结果解决方案很可能不会像使用那样容易。而框架是元类真正适合的地方。它们通常有很多复杂的解决方案,不容易理解和遵循,但最终允许其他程序员编写更简洁和可读的代码,以更高的抽象级别操作。

元类陷阱

与其他一些高级 Python 特性一样,元类非常灵活,很容易被滥用。虽然类的调用签名相当严格,但 Python 并不强制返回参数的类型。只要它在调用时接受传入的参数并在需要时具有所需的属性,它可以是任何东西。

可以是任何地方的一个对象是unittest.mock模块中提供的Mock类的实例。Mock不是一个元类,也不继承自type类。它在实例化时也不返回类对象。尽管如此,它可以作为类定义中的元类关键字参数包含在内,这不会引发任何问题,尽管这样做是没有意义的:

>>> from unittest.mock import Mock
>>> class Nonsense(metaclass=Mock):  # pointless, but illustrative
...     pass
...** 
>>> Nonsense
<Mock spec='str' id='4327214664'>

当然,前面的例子完全没有意义,任何尝试实例化这样一个Nonsense伪类都会失败。但重要的是要知道这样的事情是可能的,因为metaclass类型的问题有时很难发现和理解,不会导致type子类的创建。作为证明,这是我们尝试创建前面介绍的Nonsense类的新实例时引发的异常的回溯:

>>> Nonsense()
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
 **File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/mock.py", line 917, in __call__
 **return _mock_self._mock_call(*args, **kwargs)
 **File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/mock.py", line 976, in _mock_call
 **result = next(effect)
StopIteration

有关代码生成的一些建议

如前所述,动态代码生成是代码生成中最困难的方法。Python 中有一些工具可以让您生成和执行代码,甚至对已编译的代码对象进行一些修改。关于这个话题可以写一本完整的书,即使那样也无法完全穷尽这个话题。

诸如Hy(稍后提到)之类的各种项目表明,甚至可以使用代码生成技术在 Python 中重新实现整个语言。这证明了可能性几乎是无限的。知道这个话题有多广泛,以及它有多少各种各样的陷阱,我甚至不会尝试提供关于如何以这种方式创建代码或提供有用的代码示例的详细建议。

无论如何,如果你打算自己深入研究这个领域,知道什么是可能的可能对你有用。因此,只把这一部分当作进一步学习的可能起点的简短总结。大部分内容都带有许多警告,以防你急于在自己的项目中调用exec()eval()

exec、eval 和 compile

Python 提供了三个内置函数来手动执行、评估和编译任意 Python 代码:

  • exec(object, globals, locals): 这允许你动态执行 Python 代码。object应该是一个字符串或代码对象(参见compile()函数)。globalslocals参数为执行的代码提供全局和局部命名空间,是可选的。如果它们没有提供,那么代码将在当前范围内执行。如果提供了,globals必须是字典,而locals可以是任何映射对象;它总是返回None

  • eval(expression, globals, locals): 用于评估给定表达式并返回其值。它类似于exec(),但它接受expression应该是单个 Python 表达式而不是一系列语句。它返回评估表达式的值。

  • compile(source, filename, mode): 这将源代码编译为代码对象或 AST 对象。要编译的代码以字符串形式提供在源参数中。文件名应该是从中读取代码的文件。如果它没有关联的文件,因为它的源是动态创建的,那么<string>是通常使用的值。模式应该是exec(一系列语句)、eval(单个表达式)或single(单个交互式语句,如 Python 交互式会话中的语句)。

exec()eval()函数是在尝试动态生成代码时最容易开始的,因为它们可以操作字符串。如果你已经知道如何在 Python 中编程,那么你可能知道如何正确地以编程方式生成可工作的源代码。我希望你知道。

在元编程的上下文中最有用的显然是exec(),因为它允许我们执行任何一系列 Python 语句。而任何这个词对你来说应该是令人警惕的。即使是eval(),它只允许在熟练的程序员(在接收用户输入时)的手中评估表达式,也可能导致严重的安全漏洞。请注意,崩溃的 Python 解释器是你应该害怕的最不可怕的情景。由于不负责任地使用exec()eval()而引入远程执行漏洞可能会让你失去作为专业开发人员的形象,甚至失去工作。

即使在使用受信任的输入时,关于exec()eval()的一长串细节太长而无法在这里包括,但可能会影响你的应用程序的工作方式,这是你意想不到的。Armin Ronacher 有一篇很好的文章列出了其中最重要的一些,名为小心使用 Python 中的 exec 和 eval(参见lucumr.pocoo.org/2011/2/1/exec-in-python/)。

尽管有所有这些令人恐惧的警告,但也有自然情况下使用exec()eval()是真正合理的。关于何时必须使用它们的流行说法是:你会知道。换句话说,如果有一丁点疑虑,你就不应该使用它们,而应该尝试找到另一种解决方案。

提示

eval()和不受信任的输入

eval()函数的签名可能会让您认为,如果提供空的globalslocals命名空间,并用适当的try ... except语句包装它,那么它将是相当安全的。这是完全错误的。Ned Batchelder 在一篇非常好的文章中展示了如何在eval()调用中导致解释器分段错误,即使已经擦除了对所有 Python 内置函数的访问(nedbatchelder.com/blog/201206/eval_really_is_dangerous.html)。这是一个单一的证据,exec()eval()都不应该与不受信任的输入一起使用。

抽象语法树

Python 语法在编译成字节码之前会转换为抽象语法树AST)。这是源代码的抽象语法结构的树形表示。Python 语法的处理得益于内置的ast模块。可以使用带有ast.PyCF_ONLY_AST标志的compile()函数或使用ast.parse()助手来创建 Python 代码的原始 AST。直接逆向翻译并不那么简单,内置函数中也没有提供这样的功能。不过一些项目,比如 PyPy,确实会这样做。

ast模块提供了一些辅助函数,允许与 AST 一起工作:

>>> tree = ast.parse('def hello_world(): print("hello world!")')
>>> tree
<_ast.Module object at 0x00000000038E9588>
>>> ast.dump(tree)
"Module(
 **body=[
 **FunctionDef(
 **name='hello_world',** 
 **args=arguments(
 **args=[],** 
 **vararg=None,** 
 **kwonlyargs=[],** 
 **kw_defaults=[],** 
 **kwarg=None,** 
 **defaults=[]
 **),** 
 **body=[
 **Expr(
 **value=Call(
 **func=Name(id='print', ctx=Load()),** 
 **args=[Str(s='hello world!')],** 
 **keywords=[]
 **)
 **)
 **],** 
 **decorator_list=[],** 
 **returns=None
 **)
 **]
)"

在前面的示例中,ast.dump()的输出被重新格式化以增加可读性,并更好地显示 AST 的树状结构。重要的是要知道,在传递给compile()调用之前,AST 可以被修改,这给出了许多新的可能性。例如,可以使用新的语法节点进行额外的插装,比如测试覆盖率测量。还可以修改现有的代码树,以添加新的语义到现有的语法中。MacroPy 项目(github.com/lihaoyi/macropy)使用了这样的技术,以使用已经存在的语法向 Python 添加语法宏(参见图 5):

抽象语法树

图 5:MacroPy 在导入时如何向 Python 模块添加语法宏

AST 也可以以纯人工方式创建,根本不需要解析任何源代码。这使得 Python 程序员能够为自定义领域特定语言甚至完全实现其他现有编程语言在 Python VM 之上。

导入钩子

利用 MacroPy 修改原始 AST 的能力不像使用import macropy.activate语句那么容易,如果它不以某种方式覆盖 Python 的导入行为。幸运的是,Python 提供了使用两种类型的导入钩子拦截导入的方法:

  • 元钩子:这些在任何其他import处理发生之前被调用。使用元钩子,可以覆盖sys.path的处理方式,甚至冻结和内置模块。为了添加新的元钩子,必须将新的元路径查找器对象添加到sys.meta_path列表中。

  • 导入路径钩子:这些被称为sys.path处理的一部分。如果遇到与给定钩子相关联的路径项,则会使用它们。导入路径钩子是通过将新的路径查找器对象添加到sys.path_hooks列表来扩展的。

有关实现路径查找器和元路径查找器的详细信息在官方 Python 文档中有广泛的实现(docs.python.org/3/reference/import.html)。如果您想在这个层面上与导入交互,官方文档应该是您的主要资源。这是因为 Python 中的导入机制相当复杂,任何试图在几段文字中总结它的尝试都不可避免地会失败。请将本节视为这样的一个注释,即这样的事情是可能的,并作为更详细信息的参考。

使用代码生成模式的项目

真的很难找到一个真正可用的库实现,它依赖于代码生成模式,而不仅仅是一个实验或简单的概念证明。这种情况的原因是相当明显的:

  • exec()eval()函数的恐惧是理所当然的,因为如果不负责任地使用它们,可能会造成真正的灾难。

  • 成功的代码生成非常困难,因为它需要对所选语言有深刻的理解和一般的卓越编程技能

尽管存在这些困难,但仍有一些项目成功地采用了这种方法,以改善性能或实现其他方式无法实现的目标。

Falcon 的编译路由器

Falcon (falconframework.org/)是一个极简的 Python WSGI Web 框架,用于构建快速轻量级的 API。它强烈推崇 REST 架构风格,这在 Web 上目前非常流行。它是 Django 或 Pyramid 等其他相当沉重的框架的良好替代品。它也是其他旨在简化的微框架(如 Flask、Bottle 和 web2py)的强有力竞争对手。

它的一个特点是其非常简单的路由机制。它不像 Django 的urlconf提供的路由那样复杂,也不提供那么多功能,但在大多数情况下,对于遵循 REST 架构设计的任何 API 来说,它都足够了。关于 falcon 路由最有趣的是,实际的路由器是使用从提供给定义 API 配置的对象的路由列表生成的代码来实现的。这是为了使路由更快。

考虑一下从 falcon 的 Web 文档中摘取的这个非常简短的 API 示例:

# sample.py
import falcon
import json

class QuoteResource:
    def on_get(self, req, resp):
        """Handles GET requests"""
        quote = {
            'quote': 'I\'ve always been more interested in '
                     'the future than in the past.',
            'author': 'Grace Hopper'
        }

        resp.body = json.dumps(quote)

api = falcon.API()
api.add_route('/quote', QuoteResource())

在简短的话语中,对api.add_route()方法的突出调用意味着更新整个动态生成的路由器代码树,使用compile()进行编译,并使用eval()生成新的路由查找函数。查看api._router._find()函数的__code__属性显示,它是从字符串生成的,并且随着对api.add_route()的每次调用而更改:

>>> api._router._find.__code__
<code object find at 0x00000000033C29C0, file "<string>", line 1>
>>> api.add_route('/none', None)
>>> api._router._find.__code__
<code object find at 0x00000000033C2810, file "<string>", line 1>

Hy

Hy (docs.hylang.org/)是完全用 Python 编写的 Lisp 方言。许多类似的项目通常只尝试在 Python 中实现其他代码,只是将提供的纯文本代码标记化为文件对象或字符串,并将其解释为一系列明确的 Python 调用。与其他项目不同,Hy 可以被视为在 Python 运行时环境中完全运行的语言,就像 Python 一样。使用 Hy 编写的代码可以使用现有的内置模块和外部包,反之亦然。使用 Hy 编写的代码可以被导入回 Python。

为了在 Python 中嵌入 Lisp,Hy 直接将 Lisp 代码转换为 Python 抽象语法树。使用导入挂钩实现了导入互操作性,一旦在 Python 中导入 Hy 模块,就会注册该挂钩。具有.hy扩展名的每个模块都被视为 Hy 模块,并且可以像普通的 Python 模块一样导入。由于这个事实,下面的“hello world”程序是用这种 Lisp 方言编写的:

;; hyllo.hy
(defn hello [] (print "hello world!"))

它可以通过以下 Python 代码导入和执行:

>>> import hy
>>> import hyllo
>>> hyllo.hello()
hello world!

如果我们深入挖掘并尝试使用内置的dis模块来分解hyllo.hello,我们会注意到 Hy 函数的字节码与其纯 Python 对应物并没有显著的区别:

>>> import dis
>>> dis.dis(hyllo.hello)
 **2           0 LOAD_GLOBAL        0 (print)
 **3 LOAD_CONST         1 ('hello world!')
 **6 CALL_FUNCTION      1 (1 positional, 0 keyword pair)
 **9 RETURN_VALUE
>>> def hello(): print("hello world!")
>>> dis.dis(hello)
 **1           0 LOAD_GLOBAL        0 (print)
 **3 LOAD_CONST         1 ('hello world!')
 **6 CALL_FUNCTION      1 (1 positional, 0 keyword pair)
 **9 POP_TOP
 **10 LOAD_CONST         0 (None)
 **13 RETURN_VALUE

总结

本章介绍了与类相关的最佳语法实践。它从如何对内置类型进行子类化和调用超类方法的基本信息开始。之后,介绍了 Python 中面向对象编程的更高级概念。这些是有用的语法特性,侧重于实例属性访问:描述符和属性。它展示了它们如何用于创建更清晰和更易维护的代码。插槽也被介绍了,重要的是要谨慎使用它们。

本章的其余部分探讨了 Python 中元编程的广泛主题。详细描述了语法特性,有利于各种元编程模式,如装饰器和元类,并举了一些来自实际代码的例子。

元编程的另一个重要方面是以动态代码生成的形式进行描述,但由于篇幅有限,只是简单地描述了一下,因为这个主题实在是太广泛了。然而,这应该是一个很好的起点,它快速总结了该领域的可能选项。

第四章:选择好的名称

大部分标准库都是在考虑可用性的基础上构建的。例如,使用内置类型是自然而然的,并且旨在易于使用。在这种情况下,Python 可以与您在编写程序时可能考虑的伪代码进行比较。大部分代码都可以大声朗读出来。例如,任何人都应该能理解这段代码:

my_list = []

if 'd' not in my_list:
    my_list.append('d')

这就是为什么与其他语言相比,编写 Python 如此容易的原因之一。当您编写程序时,您的思维流很快就会转化为代码行。

本章重点介绍了编写易于理解和使用的代码的最佳实践,包括:

  • 使用在 PEP 8 中描述的命名约定

  • 一套命名最佳实践

  • 检查遵循样式指南的流行工具的简短摘要

PEP 8 和命名最佳实践

PEP 8(www.python.org/dev/peps/pep-0008)提供了编写 Python 代码的风格指南。除了一些基本规则,如空格缩进、最大行长度和其他有关代码布局的细节,PEP 8 还提供了一个关于命名约定的部分,大多数代码库都遵循这些约定。

本节提供了对本 PEP 的快速摘要,并为每种元素添加了命名最佳实践指南。您仍应考虑阅读 PEP 8 文件作为强制性要求。

何时以及为什么要遵循 PEP 8?

如果您正在创建一个打算开源的新软件包,那么答案很简单:始终如此。对于大多数 Python 开源软件来说,PEP 8 实际上是标准的代码风格。如果您希望接受其他程序员的任何合作,那么您绝对应该坚持 PEP 8,即使您对最佳代码风格指南有不同的看法。这样做的好处是使其他开发人员更容易直接参与到您的项目中。对于新手来说,代码将更容易阅读,因为它在风格上与大多数其他 Python 开源软件包保持一致。

此外,完全遵循 PEP 8 可以节省您未来的时间和麻烦。如果您想将代码发布给公众,最终您将不可避免地面临来自其他程序员的建议,要求您切换到 PEP 8。关于是否真的有必要为特定项目这样做的争论往往是无休止的争论,无法取胜。这是一个悲伤的事实,但您最终可能会被迫遵循这个风格指南,以保持一致性,以免失去贡献者。

此外,如果项目的整个代码库处于成熟的开发状态,重新设计可能需要大量的工作。在某些情况下,这种重新设计可能需要改变几乎每一行代码。虽然大部分更改可以自动化(缩进、换行和尾随空格),但这种大规模的代码改造通常会在基于分支的每个版本控制工作流程中引入许多冲突。同时一次性审查如此多的更改也非常困难。这就是为什么许多开源项目有一个规则,即样式修复更改应始终包含在不影响任何功能或错误的单独拉取/合并请求或补丁中的原因。

超越 PEP 8 - 面向团队的特定风格指南

尽管 PEP 8 提供了一套全面的风格指南,但仍然为开发人员留下了一些自由。特别是在嵌套数据文字和需要长参数列表的多行函数调用方面。一些团队可能决定他们需要额外的样式规则,最佳选择是将它们正式化为每个团队成员都可以访问的某种文档。

此外,在某些情况下,严格遵守 PEP 8 对于一些旧项目可能是不可能的,或者在经济上是不可行的,因为这些项目没有定义风格指南。即使这些项目不符合官方的 PEP 8 规则,它们仍将受益于实际编码约定的形式化。请记住,与 PEP 8 一致性更重要的是项目内的一致性。如果规则被形式化并且对每个程序员都可用作参考,那么在项目和组织内保持一致性就会更容易。

命名风格

Python 中使用的不同命名风格有:

  • 驼峰命名法

  • 混合大小写

  • 大写字母和下划线

  • 小写和下划线小写

  • 前导和尾随下划线,有时是双下划线

小写和大写元素通常是一个单词,有时是几个单词连接在一起。使用下划线时,它们通常是缩写短语。使用一个单词更好。前导和尾随下划线用于标记隐私和特殊元素。

这些风格适用于:

  • 变量

  • 函数和方法

  • 属性

  • 模块

变量

Python 中有两种类型的变量:

  • 常量

  • 公共和私有变量

常量

对于常量全局变量,使用大写字母和下划线。它告诉开发人员给定的变量表示一个常量值。

注意

Python 中没有像 C++ 中的 const 那样的真正的常量。您可以更改任何变量的值。这就是为什么 Python 使用命名约定来标记变量为常量的原因。

例如,doctest 模块提供了一系列选项标志和指令(docs.python.org/2/library/doctest.html),它们是简短的句子,清楚地定义了每个选项的用途:

from doctest import IGNORE_EXCEPTION_DETAIL
from doctest import REPORT_ONLY_FIRST_FAILURE

这些变量名似乎相当长,但清楚地描述它们是很重要的。它们的使用大多位于初始化代码中,而不是代码本身的主体,因此这种冗长并不令人讨厌。

注意

缩写名称大多数时候会使代码难以理解。当缩写不清晰时,不要害怕使用完整的单词。

有些常量的名称也受到底层技术的驱动。例如,os 模块使用一些在 C 侧定义的常量,例如 EX_XXX 系列,它定义了 Unix 退出代码数字。例如,相同的名称代码可以在系统的 sysexits.h C 头文件中找到:

import os
import sys

sys.exit(os.EX_SOFTWARE)

在使用常量时的另一个好的做法是将它们收集在使用它们的模块的顶部,并在它们用于此类操作时将它们组合在新变量下:

import doctest
TEST_OPTIONS = (doctest.ELLIPSIS |
                doctest.NORMALIZE_WHITESPACE | 
                doctest.REPORT_ONLY_FIRST_FAILURE)

命名和用法

常量用于定义程序依赖的一组值,例如默认配置文件名。

一个很好的做法是将所有常量收集到包中的单个文件中。例如 Django 就是这样工作的。一个名为 settings.py 的模块提供了所有常量:

# config.py
SQL_USER = 'tarek'
SQL_PASSWORD = 'secret'
SQL_URI = 'postgres://%s:%s@localhost/db' % (
    SQL_USER, SQL_PASSWORD
)
MAX_THREADS = 4

另一种方法是使用配置文件,可以使用 ConfigParser 模块解析,或者像 ZConfig 这样的高级工具,它是 Zope 中用于描述其配置文件的解析器。但有些人认为在 Python 这样的语言中使用另一种文件格式是一种过度使用,因为文件可以像文本文件一样容易地被编辑和更改。

对于像标志一样的选项,一种常见的做法是将它们与布尔运算结合使用,就像 doctestre 模块所做的那样。从 doctest 中采用的模式非常简单:

OPTIONS = {}

def register_option(name):
    return OPTIONS.setdefault(name, 1 << len(OPTIONS))

def has_option(options, name):
    return bool(options & name)

# now defining options
BLUE = register_option('BLUE')
RED = register_option('RED')
WHITE = register_option('WHITE')

您将获得:

>>> # let's try them
>>> SET = BLUE | RED
>>> has_option(SET, BLUE)
True
>>> has_option(SET, WHITE)
False

当创建这样一组新的常量时,避免为它们使用共同的前缀,除非模块有几组。模块名称本身就是一个共同的前缀。另一个解决方案是使用内置的enum模块中的Enum类,并简单地依赖于set集合而不是二进制运算符。不幸的是,Enum类在针对旧版本的 Python 的代码中应用有限,因为enum模块是在 Python 3.4 版本中提供的。

注意

在 Python 中,使用二进制位操作来组合选项是很常见的。包含 OR(|)运算符将允许您将多个选项组合成单个整数,而 AND(&)运算符将允许您检查该选项是否存在于整数中(参考has_option函数)。

公共和私有变量

对于可变且可以通过导入自由使用的全局变量,在需要保护时应使用带下划线的小写字母。但是这种类型的变量并不经常使用,因为模块通常提供 getter 和 setter 来处理它们在需要保护时。在这种情况下,前导下划线可以将变量标记为包的私有元素:

_observers = []

def add_observer(observer):
    _observers.append(observer)

def get_observers():
    """Makes sure _observers cannot be modified."""
    return tuple(_observers)

位于函数和方法中的变量遵循相同的规则,并且从不标记为私有,因为它们是局部的。

对于类或实例变量,只有在使变量成为公共签名的一部分不带来任何有用信息或是多余的情况下,才需要使用私有标记(前导下划线)。

换句话说,如果变量在方法中用于提供公共功能,并且专门用于此角色,则最好将其设置为私有。

例如,为属性提供动力的属性是良好的私有成员:

class Citizen(object):
    def __init__(self):
        self._message = 'Rosebud...'

    def _get_message(self):
        return self._message

    kane = property(_get_message)

另一个例子是保持内部状态的变量。这个值对于代码的其余部分并不有用,但参与类的行为:

class UnforgivingElephant(object):
    def __init__(self, name):
        self.name = name
        self._people_to_stomp_on = []

    def get_slapped_by(self, name):
        self._people_to_stomp_on.append(name)
        print('Ouch!')

    def revenge(self):
        print('10 years later...')
        for person in self._people_to_stomp_on:
            print('%s stomps on %s' % (self.name, person))

在交互式会话中,您将看到以下内容:

>>> joe = UnforgivingElephant('Joe')
>>> joe.get_slapped_by('Tarek')
Ouch!
>>> joe.get_slapped_by('Bill')
Ouch!
>>> joe.revenge()
10 years later...
Joe stomps on Tarek
Joe stomps on Bill

函数和方法

函数和方法应该使用小写和下划线。在旧标准库模块中,这条规则并不总是成立。Python 3 对标准库进行了大量重组,因此大多数函数和方法都具有一致的大小写。但是,对于一些模块,如threading,您可以访问使用mixedCase的旧函数名称(例如currentThread)。这是为了更容易地向后兼容,但如果您不需要在旧版本的 Python 中运行代码,那么您应该避免使用这些旧名称。

在小写规范成为标准之前,编写方法的方式是很常见的,并且一些框架,如 Zope 和 Twisted,也在使用mixedCase来命名方法。与他们一起工作的开发人员社区仍然相当庞大。因此,使用mixedCase和小写加下划线之间的选择绝对受到您使用的库的驱动。

作为 Zope 开发人员,要保持一致并不容易,因为构建一个同时混合纯 Python 模块和导入 Zope 代码的应用程序是困难的。在 Zope 中,一些类混合了这两种约定,因为代码库仍在不断发展,Zope 开发人员试图采纳被许多人接受的常见约定。

在这种库环境中的一个不错的做法是,仅对在框架中公开的元素使用mixedCase,并将其余代码保持在 PEP 8 风格中。

值得注意的是,Twisted 项目的开发人员对这个问题采取了完全不同的方法。Twisted 项目和 Zope 一样,早于 PEP 8 文档。它是在没有代码风格的官方指南时开始的,因此它有自己的指南。关于缩进、文档字符串、行长度等的风格规则可以很容易地采用。另一方面,将所有代码更新以匹配 PEP 8 的命名约定将导致完全破坏的向后兼容性。对于如此庞大的 Twisted 项目来说,这是不可行的。因此,Twisted 尽可能地采用了 PEP 8 的规范,并将mixedCase作为其自己的编码标准的一部分。这与 PEP 8 的建议完全兼容,因为它明确指出项目内的一致性比与 PEP 8 风格指南的一致性更重要。

私有争议

对于私有方法和函数,通常会添加一个前导下划线。这条规则曾经引起了很大的争议,因为 Python 中有名称修饰的特性。当一个方法有两个前导下划线时,解释器会即时将其重命名,以防止与任何子类的方法发生名称冲突。

因此,一些人倾向于在子类中使用双下划线来避免名称冲突:

class Base(object):
    def __secret(self):
        print("don't tell")

    def public(self):
        self.__secret()

class Derived(Base):
    def __secret(self):
        print("never ever")

你会看到:

>>> Base.__secret
Traceback (most recent call last):
 **File "<input>", line 1, in <module>
AttributeError: type object 'Base' has no attribute '__secret'
>>> dir(Base)
['_Base__secret', ..., 'public']
>>> Derived().public()
don't tell

Python 中名称修饰的最初动机不是为了提供类似 C++中的私有花招,而是为了确保一些基类在子类中隐式避免冲突,特别是在多重继承的情况下。但是,将其用于每个属性会使代码在私有部分变得模糊,这完全不符合 Python 的风格。

因此,一些人认为应始终使用显式名称修饰:

class Base:
    def _Base_secret(self):  # don't do this !!!
        print("you told it ?")

这在整个代码中重复了类名,因此应该优先使用__

但是,最佳实践是避免使用名称修饰,而是在编写子类方法之前查看类的__mro__(方法解析顺序)值。必须小心更改基类的私有方法。

有关此主题的更多信息,请参阅多年前在 Python-Dev 邮件列表中发生的有趣讨论,人们在那里讨论了名称修饰的实用性及其在语言中的命运。可以在mail.python.org/pipermail/python-dev/2005-December/058555.html找到。

特殊方法

特殊方法(docs.python.org/3/reference/datamodel.html#special-method-names)以双下划线开头和结尾,普通方法不应使用此约定。一些开发人员习惯称它们为dunder方法,这是双下划线的混成词。它们用于操作符重载、容器定义等。为了可读性,它们应该在类定义的开头收集起来:

class WeirdInt(int):
    def __add__(self, other):
        return int.__add__(self, other) + 1

    def __repr__(self):
        return '<weirdo %d>' % self

    # public API
    def do_this(self):
        print('this')

    def do_that(self):
        print('that')

对于普通方法,你不应该使用这种名称。因此,不要为方法发明这样的名称:

class BadHabits:
    def __my_method__(self):
        print('ok')

参数

参数使用小写,如果需要则使用下划线。它们遵循与变量相同的命名规则。

属性

属性的名称为小写,或者小写加下划线。大多数情况下,它们表示对象的状态,可以是名词、形容词,或者在需要时是一个小短语:

class Connection:
    _connected = []

    def connect(self, user):
        self._connected.append(user)

    @property

    def connected_people(self):
        return ', '.join(self._connected)

在交互式会话中运行时:

>>> connection = Connection()
>>> connection.connect('Tarek')
>>> connection.connect('Shannon')
>>> print(connection.connected_people)
Tarek, Shannon

类的名称始终为 CamelCase,并且如果它们对模块是私有的,则可能有一个前导下划线。

类和实例变量通常是名词短语,并且与动词短语的方法名称形成使用逻辑:

class Database:
    def open(self):
        pass

class User:
    pass

以下是交互式会话中的一个示例用法:

>>> user = User()
>>> db = Database()
>>> db.open()

模块和包

除了特殊模块__init__之外,模块名称都是小写的,没有下划线。

以下是标准库中的一些示例:

  • 操作系统

  • sys

  • shutil

当模块对包是私有的时,会添加一个前导下划线。编译的 C 或 C++模块通常以下划线命名,并在纯 Python 模块中导入。

包名称遵循相同的规则,因为它们的行为类似于更结构化的模块。

命名指南

一组通用的命名规则可以应用于变量、方法、函数和属性。类和模块的名称也对命名空间的构建以及代码的可读性起着重要作用。这个迷你指南提供了选择它们的名称的常见模式和反模式。

对布尔元素使用hasis前缀

当一个元素持有布尔值时,ishas前缀提供了一种使其在其命名空间中更易读的自然方式:

class DB:
    is_connected = False
    has_cache = False

对于持有集合的变量使用复数

当一个元素持有一个集合时,最好使用复数形式。当它们像序列一样被暴露时,一些映射也可以从中受益:

class DB:
    connected_users = ['Tarek']
    tables = {
        'Customer': ['id', 'first_name', 'last_name']
    }

对于字典使用显式名称

当一个变量持有一个映射时,尽可能使用显式名称。例如,如果一个dict持有一个人的地址,它可以被命名为persons_addresses

persons_addresses = {'Bill': '6565 Monty Road', 
                     'Pamela': '45 Python street'}
persons_addresses['Pamela']
'45 Python street'

避免使用通用名称

如果你的代码没有构建新的抽象数据类型,即使是对于局部变量,使用listdictsequenceelements等术语也是有害的。这使得代码难以阅读、理解和使用。还必须避免使用内置名称,以避免在当前命名空间中遮蔽它。通用动词也应该避免使用,除非它们在命名空间中有意义。

相反,应该使用特定于域的术语:

def compute(data):  # too generic
    for element in data:
        yield element ** 2

def squares(numbers):  # better
    for number in numbers:
        yield number ** 2

还有一些前缀和后缀的列表,尽管它们在编程中非常常见,但实际上在函数和类名称中应该避免使用:

  • 管理器

  • 对象

  • 做、处理或执行

原因是它们模糊、含糊不清,并且对实际名称没有任何价值。Discourse 和 Stack Overflow 的联合创始人 Jeff Atwood 在这个主题上有一篇非常好的文章,可以在他的博客上找到blog.codinghorror.com/i-shall-call-it-somethingmanager/

还有一些应该避免的包名称。一切不能给出关于其内容的线索的东西在长期内都会对项目造成很大的伤害。诸如misctoolsutilscommoncore这样的名称有很强的倾向成为各种无关代码片段的无尽袋,质量非常差,似乎呈指数增长。在大多数情况下,这样一个模块的存在是懒惰或者没有足够的设计努力的标志。这些模块名称的爱好者可以简单地预见未来,并将它们重命名为trashdumpster,因为这正是他们的队友最终会对待这样的模块的方式。

在大多数情况下,拥有更多小模块几乎总是更好的,即使内容很少,但名称很好地反映了内部内容。老实说,像utilscommon这样的名称并没有本质上的错误,而且可以负责任地使用它们。但现实表明,在许多情况下,它们反而成为危险的结构反模式的替代品,这些反模式扩散得非常快。如果你不够快地采取行动,你可能永远无法摆脱它们。因此,最好的方法就是简单地避免这种风险的组织模式,并在项目中由其他人引入时及时制止它们。

避免现有名称

在上下文中使用已经存在的名称是不好的做法,因为它会使阅读,特别是调试非常混乱:

>>> def bad_citizen():
...     os = 1
...     import pdb; pdb.set_trace()
...     return os
...
>>> bad_citizen()
> <stdin>(4)bad_citizen()
(Pdb) os
1
(Pdb) import os
(Pdb) c
<module 'os' from '/Library/Frameworks/Python.framework/Versions/2.5/lib/python2.5/os.pyc'>

在这个例子中,os名称被代码遮蔽了。应该避免使用内置的和标准库中的模块名称。

尝试创建原始名称,即使它们只在上下文中使用。对于关键字,使用尾随下划线是避免冲突的一种方法:

def xapian_query(terms, or_=True):
    """if or_ is true, terms are combined with the OR clause"""
    ...

注意,class通常被klasscls替换:

def factory(klass, *args, **kwargs):
    return klass(*args, **kwargs)

参数的最佳实践

函数和方法的签名是代码完整性的守护者。它们驱动其使用并构建其 API。除了我们之前看到的命名规则之外,对参数还必须特别小心。这可以通过三条简单的规则来实现:

  • 通过迭代设计构建参数

  • 相信参数和你的测试

  • 谨慎使用*args**kwargs魔术参数

通过迭代设计构建参数

为每个函数拥有一个固定和明确定义的参数列表使代码更加健壮。但这在第一个版本中无法完成,因此参数必须通过迭代设计构建。它们应反映元素创建的精确用例,并相应地发展。

例如,当添加一些参数时,应尽可能使用默认值,以避免任何回归:

class Service:  # version 1
    def _query(self, query, type):
        print('done')

    def execute(self, query):
        self._query(query, 'EXECUTE')

>>> Service().execute('my query')
done

import logging

class Service(object):  # version 2
    def _query(self, query, type, logger):
        logger('done')

    def execute(self, query, logger=logging.info):
        self._query(query, 'EXECUTE', logger)

>>> Service().execute('my query')    # old-style call
>>> Service().execute('my query', logging.warning)
WARNING:root:done

当需要更改公共元素的参数时,应使用逐步废弃的过程,该过程将在本节后面介绍。

相信参数和你的测试

鉴于 Python 的动态类型特性,一些开发人员在其函数和方法顶部使用断言来确保参数具有适当的内容:

def division(dividend, divisor):
    assert isinstance(dividend, (int, float))
    assert isinstance(divisor, (int, float))
    return dividend / divisor

>>> division(2, 4)
0.5
>>> division(2, None)
Traceback (most recent call last):
 **File "<input>", line 1, in <module>
 **File "<input>", line 3, in division
AssertionError

这通常是由习惯于静态类型的开发人员完成的,他们觉得 Python 中缺少了一些东西。

检查参数的这种方式是契约设计DbC,参见en.wikipedia.org/wiki/Design_By_Contract)编程风格的一部分,在实际运行代码之前检查前置条件。

这种方法的两个主要问题是:

  • DbC 的代码解释了它应该如何使用,使其不太可读

  • 这可能会使其变慢,因为每次调用都会进行断言

后者可以通过解释器的"-O"选项避免。在这种情况下,所有断言都会在生成字节码之前从代码中删除,因此检查会丢失。

无论如何,断言都必须小心使用,并且不应该用来将 Python 弯曲成静态类型的语言。唯一的用例是保护代码免受无意义的调用。

健康的测试驱动开发风格在大多数情况下提供了健壮的基础代码。在这里,功能和单元测试验证了代码创建的所有用例。

当库中的代码被外部元素使用时,进行断言可能是有用的,因为传入的数据可能会破坏事物,甚至造成损害。这种情况发生在处理数据库或文件系统的代码中。

另一种方法是模糊测试http://en.wikipedia.org/wiki/Fuzz_testing),其中随机数据片段被发送到程序以检测其弱点。当发现新的缺陷时,可以修复代码以解决该问题,并编写新的测试。

让我们确保遵循 TDD 方法的代码库朝着正确的方向发展,并且在每次出现新的失败时进行调整,从而变得越来越健壮。当以正确的方式完成时,测试中的断言列表在某种程度上类似于前置条件的列表。

谨慎使用*args**kwargs魔术参数

*args**kwargs参数可能会破坏函数或方法的健壮性。它们使签名变得模糊,代码往往开始构建一个小的参数解析器,而不应该这样做:

def fuzzy_thing(**kwargs):

    if 'do_this' in kwargs:
        print('ok i did')

    if 'do_that' in kwargs:
        print('that is done')

    print('errr... ok')

>>> fuzzy_thing(do_this=1)
ok i did
errr... ok
>>> fuzzy_thing(do_that=1)
that is done
errr... ok
>>> fuzzy_thing(hahaha=1)
errr... ok

如果参数列表变得又长又复杂,很容易添加魔术参数。但这更多地表明了一个弱函数或方法,应该被拆分或重构。

*args用于处理在函数中以相同方式处理的元素序列时,最好要求一个唯一的容器参数,比如iterator

def sum(*args):  # okay
    total = 0
    for arg in args:
        total += arg
    return total

def sum(sequence):  # better!
    total = 0
    for arg in sequence:
        total += arg
    return total

对于**kwargs,同样适用相同的规则。最好修复命名参数,使方法的签名有意义:

def make_sentence(**kwargs):
    noun = kwargs.get('noun', 'Bill')
    verb = kwargs.get('verb', 'is')
    adj = kwargs.get('adjective', 'happy')
    return '%s %s %s' % (noun, verb, adj)

def make_sentence(noun='Bill', verb='is', adjective='happy'):
    return '%s %s %s' % (noun, verb, adjective)

另一种有趣的方法是创建一个容器类,将几个相关参数分组以提供执行上下文。这种结构不同于*args**kwargs,因为它可以提供在值上工作的内部,并且可以独立地发展。使用它作为参数的代码将不必处理其内部。

例如,传递给函数的 Web 请求通常由类的实例表示。这个类负责保存 Web 服务器传递的数据:

def log_request(request):  # version 1
    print(request.get('HTTP_REFERER', 'No referer'))

def log_request(request):  # version 2
    print(request.get('HTTP_REFERER', 'No referer'))
    print(request.get('HTTP_HOST', 'No host'))

有时无法避免使用魔术参数,特别是在元编程中。例如,在创建适用于任何类型签名的函数的装饰器时,它们是不可或缺的。更全局地,在任何处理未知数据只是遍历函数的地方,魔术参数都很棒:

import logging

def log(**context):
    logging.info('Context is:\n%s\n' % str(context))

类名

类的名称必须简洁,准确,以便从中了解类的作用。一个常见的做法是使用一个后缀来通知其类型或性质,例如:

  • SQLEngine

  • MimeTypes

  • StringWidget

  • TestCase

对于基类或抽象类,可以使用BaseAbstract前缀,如下所示:

  • BaseCookie

  • AbstractFormatter

最重要的是要与类属性保持一致。例如,尽量避免类和其属性名称之间的冗余:

>>> SMTP.smtp_send()  # redundant information in the namespace
>>> SMTP.send()       # more readable and mnemonic** 

模块和包名称

模块和包名称告知其内容的目的。名称要简短,小写,不带下划线:

  • sqlite

  • postgres

  • sha1

如果它们实现了一个协议,通常会在后缀加上lib

import smtp**lib
import url**lib
import telnet**lib

它们还需要在命名空间内保持一致,以便更容易使用:

from widgets.stringwidgets import TextWidget  # bad
from widgets.strings import TextWidget        # better

同样,始终避免使用与标准库中模块之一的相同名称。

当一个模块变得复杂,并包含许多类时,最好的做法是创建一个包,并将模块的元素拆分到其他模块中。

__init__模块也可以用于将一些 API 放回顶层,因为它不会影响其使用,但会帮助重新组织代码为更小的部分。例如,考虑一个foo包中的__init__模块,其内容如下:

from .module1 import feature1, feature2
from .module2 import feature3

这将允许用户直接导入功能,如下面的代码所示:

from foo import feature1, feature2, feature3

但要注意,这可能会增加循环依赖的机会,并且添加到__init__模块中的代码将被实例化。因此要小心使用。

有用的工具

以前的惯例和做法的一部分可以通过以下工具来控制和解决:

  • Pylint:这是一个非常灵活的源代码分析器

  • pep8flake8:这是一个小的代码风格检查器,以及一个添加了一些更有用功能的包装器,如静态分析和复杂度测量

Pylint

除了一些质量保证指标外,Pylint 还允许您检查给定源代码是否遵循命名约定。其默认设置对应于 PEP 8,Pylint 脚本提供了一个 shell 报告输出。

要安装 Pylint,可以使用pip

$ pip install pylint

完成此步骤后,该命令可用,并且可以针对一个模块或多个模块使用通配符运行。让我们在 Buildout 的bootstrap.py脚本上尝试一下:

$ wget -O bootstrap.py https://bootstrap.pypa.io/bootstrap-buildout.py -q
$ pylint bootstrap.py
No config file found, using default configuration
************* Module bootstrap
C: 76, 0: Unnecessary parens after 'print' keyword (superfluous-parens)
C: 31, 0: Invalid constant name "tmpeggs" (invalid-name)
C: 33, 0: Invalid constant name "usage" (invalid-name)
C: 45, 0: Invalid constant name "parser" (invalid-name)
C: 74, 0: Invalid constant name "options" (invalid-name)
C: 74, 9: Invalid constant name "args" (invalid-name)
C: 84, 4: Import "from urllib.request import urlopen" should be placed at the top of the module (wrong-import-position)

...

Global evaluation
-----------------
Your code has been rated at 6.12/10

真正的 Pylint 输出有点长,在这里被截断了。

请注意,Pylint 可能会给出不良评价或投诉。例如,在某些情况下,模块本身的代码没有使用的导入语句是完全可以的(在命名空间中可用)。

调用使用 mixedCase 方法的库也可能降低您的评级。无论如何,全局评估并不重要。Pylint 只是一个指出可能改进的工具。

调整 Pylint 的第一件事是在项目目录中创建一个.pylinrc配置文件,使用–generate-rcfile选项:

$ pylint --generate-rcfile > .pylintrc

这个配置文件是自我记录的(每个可能的选项都有注释描述),并且应该已经包含了每个可用的配置选项。

除了检查是否符合某些武断的编码标准外,Pylint 还可以提供有关整体代码质量的其他信息,例如:

  • 代码重复度量

  • 未使用的变量和导入

  • 缺少函数、方法或类的文档字符串

  • 函数签名太长

默认启用的可用检查列表非常长。重要的是要知道,其中一些规则是武断的,并不容易适用于每个代码库。记住,一致性总是比遵守某些武断标准更有价值。幸运的是,Pylint 非常可调,所以如果您的团队使用的一些命名和编码约定与默认情况下假定的不同,您可以轻松配置它以检查这些约定的一致性。

pep8 和 flake8

pep8是一个只有一个目的的工具:它只提供对 PEP 8 标准的代码约定的样式检查。这是与 Pylint 的主要区别,后者具有许多其他功能。这是对于那些只对 PEP 8 标准的自动化代码样式检查感兴趣的程序员来说是最佳选择,而无需进行任何额外的工具配置,就像 Pylint 的情况一样。

pep8可以通过pip安装:

$ pip install pep8

当在 Buildout 的bootstrap.py脚本上运行时,它将给出一份代码样式违规的简短列表:

$ wget -O bootstrap.py https://bootstrap.pypa.io/bootstrap-buildout.py -q
$ pep8 bootstrap.py
bootstrap.py:118:1: E402 module level import not at top of file
bootstrap.py:119:1: E402 module level import not at top of file
bootstrap.py:190:1: E402 module level import not at top of file
bootstrap.py:200:1: E402 module level import not at top of file

与 Pylint 输出的主要区别在于其长度。pep8只集中在样式上,因此它不提供任何其他警告,例如未使用的变量、函数名太长或缺少文档字符串。它也不提供任何评级。这确实是有道理的,因为没有部分一致性这样的事情。任何,甚至是最轻微的,违反样式指南的行为都会立即使代码不一致。

pep8的输出比 Pylint 更简单,更容易解析,因此如果您想将其与一些持续集成解决方案(如 Jenkins)集成,它可能是更好的选择。如果您缺少一些静态分析功能,还有flake8包,它是pep8和其他几个工具的包装器,易于扩展,并提供更广泛的功能套件:

  • McCabe 复杂度测量

  • 通过pyflakes进行静态分析

  • 使用注释禁用整个文件或单行

摘要

本章通过指向官方 Python 样式指南(PEP 8 文档)来解释了最受欢迎的编码约定。官方样式指南还补充了一些命名建议,这些建议将使您未来的代码更加明确,还有一些在保持代码风格一致方面不可或缺的有用工具。

所有这些都为我们准备了本书的第一个实际主题——编写和分发软件包。在下一章中,我们将学习如何在公共 PyPI 存储库上发布我们自己的软件包,以及如何在您的私人组织中利用包装生态系统的力量。

第五章:编写包

本章重点介绍了编写和发布 Python 包的可重复过程。其意图是:

  • 在开始真正工作之前缩短设置所需的时间

  • 提供一种标准化的编写包的方式

  • 简化测试驱动开发方法的使用

  • 促进发布过程

它分为以下四个部分:

  • 所有包的常见模式,描述了所有 Python 包之间的相似之处,以及distutilssetuptools如何发挥核心作用

  • 什么是命名空间包以及它们为何有用

  • 如何在Python 包索引PyPI)中注册和上传包,重点放在安全性和常见陷阱上

  • 独立可执行文件作为打包和分发 Python 应用程序的替代方式

创建一个包

Python 打包一开始可能有点令人不知所措。这主要是因为对于创建 Python 包的适当工具的混乱。不管怎样,一旦您创建了第一个包,您会发现这并不像看起来那么困难。此外,了解适当的、最新的打包工具也会有很大帮助。

即使您不打算将代码作为开源分发,您也应该知道如何创建包。了解如何制作自己的包将使您更深入地了解打包生态系统,并有助于您使用 PyPI 上可用的第三方代码。

此外,将您的闭源项目或其组件作为源分发包可用,可以帮助您在不同环境中部署代码。利用 Python 打包生态系统在代码部署中的优势将在下一章中更详细地描述。在这里,我们将专注于创建这样的分发的适当工具和技术。

Python 打包工具的混乱状态

很长一段时间以来,Python 打包的状态非常混乱,花了很多年时间才将这个话题组织起来。一切都始于 1998 年引入的distutils包,后来在 2003 年由setuptools进行了增强。这两个项目开启了一个漫长而复杂的分叉、替代项目和完全重写的故事,试图一劳永逸地修复 Python 的打包生态系统。不幸的是,大多数尝试都没有成功。效果恰恰相反。每个旨在取代setuptoolsdistutils的新项目都增加了已经围绕打包工具的巨大混乱。一些这样的分叉被合并回它们的祖先(比如distributesetuptools的一个分叉),但有些被遗弃了(比如distutils2)。

幸运的是,这种状态正在逐渐改变。一个名为Python 打包管理机构PyPA)的组织成立,旨在恢复打包生态系统的秩序和组织。由 PyPA 维护的Python 打包用户指南packaging.python.org)是关于最新打包工具和最佳实践的权威信息来源。把它视为关于打包的最佳信息来源,以及本章的补充阅读。该指南还包含了与打包相关的更改和新项目的详细历史,因此如果您已经了解一些内容但想确保仍在使用适当的工具,它将非常有用。

远离其他流行的互联网资源,比如打包者指南。它已经过时,没有维护,大部分已经过时。它可能只是出于历史原因有趣,而 Python 打包用户指南实际上是这个旧资源的一个分支。

由于 PyPA,Python 打包的当前格局

除了为打包提供权威指南外,PyPA 还维护打包项目和新官方打包方面的标准化过程。PyPA 的所有项目都可以在 GitHub 的一个组织下找到:github.com/pypa

其中一些在书中已经提到。最显著的是:

  • pip

  • virtualenv

  • twine

  • warehouse

请注意,其中大多数是在该组织之外启动的,并且只有在成熟和广泛使用的解决方案下才移至 PyPA 赞助下。

由于 PyPA 的参与,逐渐放弃鸡蛋格式,转而使用 wheels 进行构建分发已经在进行中。未来可能会带来更多新的变化。PyPA 正在积极开发warehouse,旨在完全取代当前的 PyPI 实现。这将是包装历史上的一大步,因为pypi是一个如此古老和被忽视的项目,只有少数人能够想象在没有完全重写的情况下逐渐改进它。

工具推荐

Python Packaging User Guide 给出了一些建议,推荐使用一些工具来处理软件包。它们通常可以分为两组:用于安装软件包的工具和用于创建和分发软件包的工具。

PyPA 推荐的第一组工具已经在第一章中提到过,但为了保持一致,让我们在这里重复一下:

  • 使用pip从 PyPI 安装软件包

  • 使用virtualenvvenv来实现 Python 环境的应用级隔离

Python Packaging User Guide 给出了一些建议,推荐用于创建和分发软件包的工具如下:

  • 使用setuptools来定义项目并创建源分发

  • 使用wheels而不是eggs来创建构建分发

  • 使用twine将软件包分发上传到 PyPI

项目配置

显而易见,组织大型应用程序代码的最简单方法是将其拆分为几个软件包。这使得代码更简单,更易于理解,维护和更改。它还最大化了每个软件包的可重用性。它们就像组件一样。

setup.py

必须分发的软件包的根目录包含一个setup.py脚本。它定义了distutils模块中描述的所有元数据,作为对标准setup()函数的参数的组合。尽管distutils是一个标准库模块,但建议您使用setuptools包,它对标准distutils提供了几个增强功能。

因此,此文件的最小内容是:

from setuptools import setup

setup(
    name='mypackage',
)

name给出了软件包的完整名称。从那里,脚本提供了几个命令,可以使用--help-commands选项列出:

$ python3 setup.py --help-commands
Standard commands:
 **build             build everything needed to install
 **clean             clean up temporary files from 'build' command
 **install           install everything from build directory
 **sdist             create a source distribution (tarball, zip file)
 **register          register the distribution with the PyP
 **bdist             create a built (binary) distribution
 **check             perform some checks on the package
 **upload            upload binary package to PyPI

Extra commands:
 **develop           install package in 'development mode'
 **alias             define a shortcut to invoke one or more commands
 **test              run unit tests after in-place build
 **bdist_wheel       create a wheel distribution

usage: setup.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
 **or: setup.py --help [cmd1 cmd2 ...]
 **or: setup.py --help-commands
 **or: setup.py cmd --help

实际的命令列表更长,可以根据可用的setuptools扩展而变化。它被截断以仅显示对本章最重要和相关的命令。标准 命令distutils提供的内置命令,而额外 命令是由第三方软件包创建的命令,例如setuptools或定义和注册新命令的任何其他软件包。另一个软件包注册的额外命令是由wheel软件包提供的bdist_wheel

setup.cfg

setup.cfg文件包含setup.py脚本命令的默认选项。如果构建和分发软件包的过程更复杂,并且需要传递许多可选参数给setup.py命令,这将非常有用。这允许您在每个项目的代码中存储这些默认参数。这将使您的分发流程独立于项目,并且还可以提供关于如何构建和分发软件包给用户和其他团队成员的透明度。

setup.cfg文件的语法与内置的configparser模块提供的语法相同,因此类似于流行的 Microsoft Windows INI 文件。以下是一个设置配置文件的示例,其中提供了一些globalsdistbdist_wheel命令的默认值:

[global]
quiet=1

[sdist]
formats=zip,tar

[bdist_wheel]
universal=1

此示例配置将确保始终使用两种格式(ZIP 和 TAR)创建源分发,并且将创建通用轮(与 Python 版本无关)的构建轮分发。此外,通过全局quiet开关,每个命令的大部分输出都将被抑制。请注意,这仅用于演示目的,可能不是默认情况下抑制每个命令的输出的合理选择。

MANIFEST.in

使用sdist命令构建分发时,distutils浏览包目录,寻找要包含在存档中的文件。distutils将包括:

  • py_modulespackagesscripts选项隐含的所有 Python 源文件

  • ext_modules选项中列出的所有 C 源文件

与 glob 模式test/test*.py匹配的文件是:READMEREADME.txtsetup.pysetup.cfg

此外,如果您的包处于子版本或 CVS 下,sdist将浏览文件夹,如.svn,以寻找要包含的文件。还可以通过扩展与其他版本控制系统集成。sdist构建一个列出所有文件并将它们包含到存档中的MANIFEST文件。

假设您不使用这些版本控制系统,并且需要包含更多文件。现在,您可以在与setup.py相同的目录中定义一个名为MANIFEST.in的模板,用于MANIFEST文件,其中您指示sdist包含哪些文件。

此模板每行定义一个包含或排除规则,例如:

include HISTORY.txt
include README.txt
include CHANGES.txt
include CONTRIBUTORS.txt
include LICENSE
recursive-include *.txt *.py

MANIFEST.in的完整命令列表可以在官方distutils文档中找到。

最重要的元数据

除了要分发的软件包的名称和版本外,setup可以接收的最重要的参数是:

  • description: 这包括几句话来描述该包

  • long_description: 这包括一个可以使用 reStructuredText 的完整描述

  • keywords: 这是定义该包的关键字列表

  • author: 这是作者的姓名或组织

  • author_email: 这是联系电子邮件地址

  • url: 这是项目的 URL

  • license: 这是许可证(GPL,LGPL 等)

  • packages: 这是包中所有名称的列表;setuptools提供了一个称为find_packages的小函数来计算这个列表

  • namespace_packages: 这是命名空间包的列表

Trove classifiers

PyPI 和distutils提供了一组分类应用程序的解决方案,称为trove classifiers。所有分类器形成一个类似树状的结构。每个分类器都是一种字符串形式,其中每个命名空间都由::子字符串分隔。它们的列表作为classifiers参数提供给setup()函数的包定义。以下是 PyPI 上某个项目(这里是solrq)的分类器示例列表:

from setuptools import setup

setup(
    name="solrq",
    # (...)

    classifiers=[
        'Development Status :: 4 - Beta',
        'Intended Audience :: Developers',
        'License :: OSI Approved :: BSD License',
        'Operating System :: OS Independent',
        'Programming Language :: Python',
        'Programming Language :: Python :: 2',
        'Programming Language :: Python :: 2.6',
        'Programming Language :: Python :: 2.7',
        'Programming Language :: Python :: 3',
        'Programming Language :: Python :: 3.2',
        'Programming Language :: Python :: 3.3',
        'Programming Language :: Python :: 3.4',
        'Programming Language :: Python :: Implementation :: PyPy',
        'Topic :: Internet :: WWW/HTTP :: Indexing/Search',
    ],
)

它们在包定义中是完全可选的,但为setup()接口中可用的基本元数据提供了有用的扩展。除其他外,trove classifiers 可能提供有关支持的 Python 版本或系统、项目的开发阶段或代码发布的许可证的信息。许多 PyPI 用户通过分类搜索和浏览可用的软件包,因此适当的分类有助于软件包达到其目标。

Trove classifiers 在整个打包生态系统中起着重要作用,不应被忽视。没有组织验证软件包的分类,因此您有责任为您的软件包提供适当的分类器,并且不要给整个软件包索引引入混乱。

撰写本书时,PyPI 上有 608 个分类器,分为九个主要类别:

  • 开发状态

  • 环境

  • 框架

  • 预期受众

  • 许可证

  • 自然语言

  • 操作系统

  • 编程语言

  • 主题

新的分类器会不时地被添加,因此在您阅读时这些数字可能会有所不同。当前可用的所有 trove 分类器的完整列表可通过setup.py register --list-classifiers命令获得。

常见模式

为了分发而创建一个包对于经验不足的开发人员来说可能是一项繁琐的任务。setuptoolsdistuitls在它们的setup()函数调用中接受的大部分元数据可以手动提供,忽略了这些可能在项目的其他部分中可用的事实:

from setuptools import setup

setup(
    name="myproject",
    version="0.0.1",
    description="mypackage project short description",
    long_description="""
        Longer description of mypackage project
        possibly with some documentation and/or
        usage examples
    """,
    install_requires=[
        'dependency1',
        'dependency2',
        'etc',
    ]
)

虽然这肯定会起作用,但在长期内很难维护,并且留下了未来错误和不一致的可能性。setuptoolsdistutils都无法自动从项目源中提取各种元数据信息,因此您需要自己提供它们。在 Python 社区中有一些常见的模式用于解决最常见的问题,如依赖管理、版本/自述文件的包含等。至少了解其中一些是值得的,因为它们如此受欢迎,以至于它们可以被视为包装习语。

从包中自动包含版本字符串

PEP 440(版本标识和依赖规范)文档规定了版本和依赖规范的标准。这是一个长篇文档,涵盖了接受的版本规范方案以及 Python 包装工具中版本匹配和比较应该如何工作。如果您正在使用或计划使用复杂的项目版本编号方案,那么阅读这个文档是义不容辞的。如果您使用的是由点分隔的一个、两个、三个或更多数字组成的简单方案,那么您可以放弃阅读 PEP 440。如果您不知道如何选择适当的版本方案,我强烈建议遵循语义化版本控制,这已经在第一章中提到过了。

另一个问题是在包或模块中包含版本说明符的位置。有 PEP 396(模块版本号),它正好处理这个问题。请注意,它只是信息性的,并且具有延迟状态,因此它不是标准跟踪的一部分。无论如何,它描述了现在似乎是事实标准。根据 PEP 396,如果包或模块有指定的版本,它应该被包含为包根(__init__.py)或模块文件的__version__属性。另一个事实标准是还包括包含版本部分的VERSION属性的元组。这有助于用户编写兼容性代码,因为如果版本方案足够简单,这样的版本元组可以很容易地进行比较。

PyPI 上有很多包都遵循这两个标准。它们的__init__.py文件包含如下所示的版本属性:

# version as tuple for simple comparisons
VERSION = (0, 1, 1)
# string created from tuple to avoid inconsistency
__version__ = ".".join([str(x) for x in VERSION])

延迟 PEP 396 的另一个建议是,distutils 的setup()函数中提供的版本应该从__version__派生,或者反之亦然。Python 包装用户指南提供了单一源项目版本的多种模式,每种模式都有其自己的优点和局限性。我个人比较喜欢的是比较长的模式,它没有包含在 PyPA 的指南中,但它的优点是将复杂性限制在setup.py脚本中。这个样板假设版本说明符由包的__init__模块的VERSION属性提供,并提取这些数据以包含在setup()调用中。以下是一些虚构包的setup.py脚本的摘录,展示了这种方法:

from setuptools import setup
import os

def get_version(version_tuple):
    # additional handling of a,b,rc tags, this can
    # be simpler depending on your versioning scheme
    if not isinstance(version_tuple[-1], int):
        return '.'.join(
            map(str, version_tuple[:-1])
        ) + version_tuple[-1]

    return '.'.join(map(str, version_tuple))

# path to the packages __init__ module in project
# source tree
init = os.path.join(
    os.path.dirname(__file__), 'src', 'some_package', '__init__.py'
)

version_line = list(
    filter(lambda l: l.startswith('VERSION'), open(init))
)[0]

# VERSION is a tuple so we need to eval its line of code.
# We could simply import it from the package but we
# cannot be sure that this package is importable before
# finishing its installation
VERSION = get_version(eval(version_line.split('=')[-1]))

setup(
    name='some-package',
    version=VERSION,
    # ...
)

自述文件

Python Packaging Index 可以在 PyPI 门户网站的软件包页面上显示项目的 readme 或long_description的值。你可以使用 reStructuredText (docutils.sourceforge.net/rst.html)标记编写这个描述,因此在上传时它将被格式化为 HTML。不幸的是,目前只有 reStructuredText 作为 PyPI 上的文档标记可用。在不久的将来,这种情况不太可能改变。更有可能的是,当我们看到warehouse项目完全取代当前的 PyPI 实现时,将支持更多的标记语言。不幸的是,warehouse的最终发布日期仍然未知。

然而,许多开发人员出于各种原因希望使用不同的标记语言。最受欢迎的选择是 Markdown,这是 GitHub 上默认的标记语言——大多数开源 Python 开发目前都在这里进行。因此,通常,GitHub 和 Markdown 爱好者要么忽视这个问题,要么提供两个独立的文档文本。提供给 PyPI 的描述要么是项目 GitHub 页面上可用的简短版本,要么是在 PyPI 上呈现不佳的纯 Markdown 格式。

如果你想为你的项目的 README 使用不同于 reStructuredText 标记语言的东西,你仍然可以以可读的形式在 PyPI 页面上提供它作为项目描述。诀窍在于使用pypandoc软件包将你的其他标记语言转换为 reStructuredText,同时上传到 Python Package Index 时要有一个回退到你的 readme 文件的纯内容,这样如果用户没有安装pypandoc,安装就不会失败:

try:
    from pypandoc import convert

    def read_md(f):
        return convert(f, 'rst')

except ImportError:
    convert = None
    print(
        "warning: pypandoc module not found, could not convert Markdown to RST"
    )

    def read_md(f):
        return open(f, 'r').read()  # noqa

README = os.path.join(os.path.dirname(__file__), 'README.md')

setup(
    name='some-package',
    long_description=read_md(README),
    # ...
)

管理依赖

许多项目需要安装和/或使用一些外部软件包。当依赖列表非常长时,就会出现如何管理的问题。在大多数情况下,答案非常简单。不要过度设计问题。保持简单,并在你的setup.py脚本中明确提供依赖列表:

from setuptools import setup
setup(
    name='some-package',
    install_requires=['falcon', 'requests', 'delorean']
    # ...
)

一些 Python 开发人员喜欢使用requirements.txt文件来跟踪他们软件包的依赖列表。在某些情况下,你可能会找到理由这样做,但在大多数情况下,这是该项目代码未正确打包的遗留物。无论如何,即使像 Celery 这样的知名项目仍然坚持这种约定。因此,如果你不愿意改变你的习惯,或者你在某种程度上被迫使用要求文件,那么至少要做到正确。以下是从requirements.txt文件中读取依赖列表的一种流行习语:

from setuptools import setup
import os

def strip_comments(l):
    return l.split('#', 1)[0].strip()

def reqs(*f):
    return list(filter(None, [strip_comments(l) for l in open(
        os.path.join(os.getcwd(), *f)).readlines()]))

setup(
    name='some-package',
    install_requires=reqs('requirements.txt')
    # ...
)

自定义设置命令

distutils允许你创建新的命令。新的命令可以通过入口点进行注册,这是由setuptools引入的一种将软件包定义为插件的简单方法。

入口点是通过setuptools提供的一种通过一些 API 公开的类或函数的命名链接。任何应用程序都可以扫描所有已注册的软件包,并将链接的代码用作插件。

要链接新的命令,可以在设置调用中使用entry_points元数据:

setup(
    name="my.command",
    entry_points="""
        [distutils.commands]
        my_command  = my.command.module.Class
    """
)

所有命名链接都被收集在命名部分中。当distutils被加载时,它会扫描在distutils.commands下注册的链接。

这种机制被许多提供可扩展性的 Python 应用程序使用。

在开发过程中使用软件包

使用setuptools主要是关于构建和分发软件包。然而,你仍然需要知道如何使用它们直接从项目源安装软件包。原因很简单。在提交软件包到 PyPI 之前,测试包装代码是否正常工作是很重要的。测试的最简单方法是安装它。如果你将一个有问题的软件包发送到存储库,那么为了重新上传它,你需要增加版本号。

在最终分发之前测试代码是否打包正确可以避免不必要的版本号膨胀,显然也可以节省时间。此外,在同时处理多个相关包时,直接从自己的源代码使用setuptools进行安装可能是必不可少的。

setup.py install

install命令将包安装到 Python 环境中。如果之前没有进行构建,它将尝试构建包,然后将结果注入 Python 树中。当提供源分发时,可以将其解压缩到临时文件夹,然后使用此命令安装。install命令还将安装在install_requires元数据中定义的依赖项。这是通过查看 Python 包索引中的包来完成的。

在安装包时,除了使用裸setup.py脚本之外,还可以使用pip。由于它是 PyPA 推荐的工具,即使在本地环境中安装包用于开发目的时,也应该使用它。为了从本地源安装包,请运行以下命令:

pip install <project-path>

卸载包

令人惊讶的是,setuptoolsdistutils缺乏uninstall命令。幸运的是,可以使用pip卸载任何 Python 包:

pip uninstall <package-name>

在系统范围的包上尝试卸载可能是一种危险的操作。这是为什么对于任何开发都使用虚拟环境如此重要的另一个原因。

setup.py develop 或 pip -e

使用setup.py install安装的包将被复制到当前环境的 site-packages 目录中。这意味着每当您对该包的源代码进行更改时,都需要重新安装它。这在密集开发过程中经常是一个问题,因为很容易忘记需要再次进行安装。这就是为什么setuptools提供了额外的develop命令,允许我们以开发模式安装包。此命令在部署目录(site-packages)中创建对项目源代码的特殊链接,而不是将整个包复制到那里。包源代码可以在不需要重新安装的情况下进行编辑,并且可以像正常安装一样在sys.path中使用。

pip还允许以这种模式安装包。这种安装选项称为可编辑模式,可以在install命令中使用-e参数启用:

pip install -e <project-path>

命名空间包

Python 之禅,您可以通过在解释器会话中编写import this来阅读,关于命名空间说了以下内容:

命名空间是一个了不起的想法——让我们做更多这样的事情!

这可以以至少两种方式理解。第一种是在语言环境中的命名空间。我们都在不知不觉中使用命名空间:

  • 模块的全局命名空间

  • 函数或方法调用的本地命名空间

  • 内置名称的命名空间

另一种命名空间可以在打包级别提供。这些是命名空间包。这通常是一个被忽视的功能,可以在组织的包生态系统或非常庞大的项目中非常有用。

这有什么用呢?

命名空间包可以被理解为一种在元包级别以上对相关包或模块进行分组的方式,其中每个包都可以独立安装。

如果您的应用程序组件是独立开发、打包和版本化的,但您仍希望从相同的命名空间访问它们,命名空间包尤其有用。这有助于明确每个包属于哪个组织或项目。例如,对于一些虚构的 Acme 公司,通用命名空间可以是acme。结果可能会导致创建一个通用的acme命名空间包,用于容纳该组织的其他包。例如,如果 Acme 的某人想要贡献一个与 SQL 相关的库,他可以创建一个新的acme.sql包,并将其注册到acme中。

重要的是要了解普通包和命名空间包之间的区别以及它们解决的问题。通常(没有命名空间包),您将创建一个带有以下文件结构的acme包和sql子包/子模块:

$ tree acme/
acme/
├── acme
│   ├── __init__.py
│   └── sql
│       └── __init__.py
└── setup.py

2 directories, 3 files

每当您想要添加一个新的子包,比如templating,您都被迫将其包含在acme的源树中:

$ tree acme/
acme/
├── acme
│   ├── __init__.py
│   ├── sql
│   │   └── __init__.py
│   └── templating
│       └── __init__.py
└── setup.py

3 directories, 4 files

这种方法使得独立开发acme.sqlacme.templating几乎不可能。setup.py脚本还必须为每个子包指定所有的依赖关系,因此不可能(或者至少非常困难)只安装一些acme组件。而且,如果一些子包有冲突的要求,这是一个无法解决的问题。

使用命名空间包,您可以独立存储每个子包的源树:

$ tree acme.sql/
acme.sql/
├── acme
│   └── sql
│       └── __init__.py
└── setup.py

2 directories, 2 files

$ tree acme.templating/
acme.templating/
├── acme
│   └── templating
│       └── __init__.py
└── setup.py

2 directories, 2 files

您还可以在 PyPI 或您使用的任何包索引中独立注册它们。用户可以选择从acme命名空间安装哪些子包,但他们永远不会安装通用的acme包(它不存在):

$ pip install acme.sql acme.templating

请注意,独立的源树不足以在 Python 中创建命名空间包。如果您不希望您的包互相覆盖,您需要做一些额外的工作。此外,根据您的 Python 语言版本目标,正确的处理可能会有所不同。这方面的细节在接下来的两节中描述。

PEP 420 - 隐式命名空间包

如果您只使用和针对 Python 3,那么对您来说有个好消息。PEP 420(隐式命名空间包)引入了一种新的定义命名空间包的方法。它是标准跟踪的一部分,并且自 3.3 版本以来成为语言的官方部分。简而言之,如果一个目录包含 Python 包或模块(包括命名空间包),并且不包含__init__.py文件,则被视为命名空间包。因此,以下是在上一节中介绍的文件结构示例:

$ tree acme.sql/
acme.sql/
├── acme
│   └── sql
│       └── __init__.py
└── setup.py

2 directories, 2 files

$ tree acme.templating/
acme.templating/
├── acme
│   └── templating
│       └── __init__.py
└── setup.py

2 directories, 2 files

它们足以定义acme是 Python 3.3 及更高版本中的命名空间包。使用设置工具的最小setup.py脚本将如下所示:

from setuptools import setup

setup(
 **name='acme.templating',
 **packages=['acme.templating'],
)

不幸的是,在撰写本书时,setuptools.find_packages()不支持 PEP 420。无论如何,这在将来可能会改变。此外,明确定义包列表的要求似乎是易于集成命名空间包的一个非常小的代价。

在以前的 Python 版本中的命名空间包

PEP 420 布局中的命名空间包在 Python 3.3 之前的版本中无法工作。然而,这个概念非常古老,在像 Zope 这样的成熟项目中经常被使用,因此肯定可以使用它们,但不能进行隐式定义。在 Python 的旧版本中,有几种方法可以定义包应该被视为命名空间。

最简单的方法是为每个组件创建一个文件结构,类似于普通包布局而不是命名空间包,并将一切交给setuptools。因此,acme.sqlacme.templating的示例布局可能如下所示:

$ tree acme.sql/
acme.sql/
├── acme
│   ├── __init__.py
│   └── sql
│       └── __init__.py
└── setup.py

2 directories, 3 files

$ tree acme.templating/
acme.templating/
├── acme
│   ├── __init__.py
│   └── templating
│       └── __init__.py
└── setup.py

2 directories, 3 files

请注意,对于acme.sqlacme.templating,还有一个额外的源文件acme/__init__.py。这个文件必须保持空白。如果我们将这个名称作为setuptools.setup()函数的namespace_packages关键字参数的值提供,acme命名空间包将被创建:

from setuptools import setup

setup(
    name='acme.templating',
    packages=['acme.templating'],
    namespace_packages=['acme'],
)

最简单并不意味着最好。为了注册一个新的命名空间,setuptools将在您的__init__.py文件中调用pkg_resources.declare_namespace()函数。即使__init__.py文件是空的,也会发生这种情况。无论如何,正如官方文档所说,声明命名空间在__init__.py文件中是您自己的责任,setuptools的这种隐式行为可能会在将来被取消。为了安全和"未来证明",您需要在文件acme/__init__.py中添加以下行:

__import__('pkg_resources').declare_namespace(__name__)

上传软件包

没有组织的方式存储、上传和下载软件包将是无用的。Python 软件包索引是 Python 社区中开源软件包的主要来源。任何人都可以自由上传新软件包,唯一的要求是在 PyPI 网站上注册-pypi.python.org/pypi

当然,您不仅限于这个索引,所有打包工具都支持使用替代软件包存储库。这对于在内部组织中分发闭源代码或用于部署目的尤其有用。如何使用这样的打包工具以及如何创建自己的软件包索引的说明将在下一章中解释。在这里,我们只关注向 PyPI 上传开源软件,只简要提及如何指定替代存储库。

PyPI- Python 软件包索引

Python 软件包索引,如前所述,是开源软件包分发的官方来源。从中下载不需要任何帐户或权限。您唯一需要的是一个可以从 PyPI 下载新分发包的软件包管理器。您应该首选pip

上传到 PyPI-或其他软件包索引

任何人都可以注册并上传软件包到 PyPI,只要他或她已经注册了帐户。软件包与用户绑定,因此,默认情况下,只有注册软件包名称的用户是其管理员,并且可以上传新的分发包。这可能对于更大的项目来说是一个问题,因此有一个选项可以将其他用户设计为软件包维护者,以便他们能够上传新的分发包。

上传软件包的最简单方法是使用setup.py脚本的upload命令:

$ python setup.py <dist-commands> upload

在这里,<dist-commands>是一个创建要上传的分发包的命令列表。只有在同一次setup.py执行期间创建的分发包才会上传到存储库。因此,如果您要同时上传源分发包、构建分发包和 wheel 软件包,那么您需要发出以下命令:

$ python setup.py sdist bdist bdist_wheel upload

在使用setup.py上传时,您不能重复使用已构建的分发包,并且被迫在每次上传时重新构建它们。这可能有些合理,但对于大型或复杂的项目来说可能不方便,因为创建分发包可能需要相当长的时间。setup.py upload的另一个问题是,它可能在某些 Python 版本上使用明文 HTTP 或未经验证的 HTTPS 连接。这就是为什么建议使用twine作为setup.py upload的安全替代品。

Twine 是与 PyPI 交互的实用程序,目前只提供一个功能-安全地上传软件包到存储库。它支持任何打包格式,并始终确保连接是安全的。它还允许您上传已经创建的文件,因此您可以在发布之前测试分发包。twine的一个示例用法仍然需要调用setup.py来构建分发包:

$ python setup.py sdist bdist_wheel
$ twine upload dist/*

如果您尚未注册此软件包,则上传将失败,因为您需要先注册它。这也可以使用twine来完成:

$ twine register dist/*

.pypirc

.pypirc是一个存储有关 Python 软件包存储库信息的配置文件。它应该位于您的主目录中。该文件的格式如下:

[distutils]
index-servers =
    pypi
    other

[pypi]
repository: <repository-url>
username: <username>
password: <password>

[other]
repository: https://example.com/pypi
username: <username>
password: <password>

distutils部分应该有index-servers变量,列出所有描述所有可用存储库和其凭据的部分。对于每个存储库部分,只有三个变量可以修改:

  • 存储库:这是软件包存储库的 URL(默认为www.python.org/pypi

  • 用户名:这是在给定存储库中进行授权的用户名

  • 密码:这是用于授权的用户密码,以明文形式

请注意,以明文形式存储存储库密码可能不是明智的安全选择。您可以始终将其留空,并在必要时提示输入密码。

.pypirc文件应该受到为 Python 构建的每个打包工具的尊重。虽然这对于每个与打包相关的实用程序来说可能并不正确,但它得到了最重要的工具的支持,如piptwinedistutilssetuptools

源包与构建包

Python 软件包通常有两种类型的分发:

  • 源分发

  • 构建(二进制)分发

源分发是最简单和最独立于平台的。对于纯 Python 软件包,这是毫无疑问的。这种分发只包含 Python 源代码,这些源代码应该已经非常易于移植。

更复杂的情况是,当您的软件包引入一些扩展时,例如用 C 编写的扩展。只要软件包用户在其环境中具有适当的开发工具链,源分发仍将起作用。这主要包括编译器和适当的 C 头文件。对于这种情况,构建的分发格式可能更适合,因为它可能已经为特定平台提供了构建好的扩展。

sdist

sdist命令是最简单的可用命令。它创建一个发布树,其中复制了运行软件包所需的一切。然后将此树存档在一个或多个存档文件中(通常只创建一个 tarball)。存档基本上是源树的副本。

这个命令是从目标系统独立地分发软件包的最简单方法。它创建一个包含存档的dist文件夹,可以进行分发。为了使用它,必须向setup传递一个额外的参数来提供版本号。如果不给它一个version值,它将使用version = 0.0.0

from setuptools import setup

setup(name='acme.sql', version='0.1.1')

这个数字对于升级安装是有用的。每次发布软件包时,都会提高这个数字,以便目标系统知道它已经更改。

让我们使用这个额外的参数运行sdist命令:

$ python setup.py sdist
running sdist
...
creating dist
tar -cf dist/acme.sql-0.1.1.tar acme.sql-0.1.1
gzip -f9 dist/acme.sql-0.1.1.tar
removing 'acme.sql-0.1.1' (and everything under it)
$ ls dist/
acme.sql-0.1.1.tar.gz

注意

在 Windows 下,归档将是一个 ZIP 文件。

版本用于标记存档的名称,可以在任何安装了 Python 的系统上分发和安装。在sdist分发中,如果软件包包含 C 库或扩展,目标系统负责编译它们。这在基于 Linux 的系统或 Mac OS 中非常常见,因为它们通常提供编译器,但在 Windows 下很少见。这就是为什么当软件包打算在多个平台上运行时,应该始终使用预构建的分发进行分发。

bdist 和 wheels

为了能够分发预构建的分发,distutils提供了build命令,它在四个步骤中编译软件包:

  • build_py:这将通过对其进行字节编译并将其复制到构建文件夹中来构建纯 Python 模块。

  • build_clib:当软件包包含任何 C 库时,使用 C 编译器构建 C 库并在构建文件夹中创建一个静态库。

  • build_ext:这将构建 C 扩展并将结果放在构建文件夹中,如build_clib

  • build_scripts:这将构建标记为脚本的模块。当第一行被设置为(!#)时,它还会更改解释器路径,并修复文件模式,使其可执行。

这些步骤中的每一步都是可以独立调用的命令。编译过程的结果是一个包含了安装软件包所需的一切的构建文件夹。distutils包中还没有交叉编译器选项。这意味着命令的结果始终特定于它所构建的系统。

当需要创建一些 C 扩展时,构建过程使用系统编译器和 Python 头文件(Python.h)。这个include文件是从 Python 构建源代码时就可用的。对于打包的发行版,可能需要额外的系统发行版包。至少在流行的 Linux 发行版中,通常被命名为python-dev。它包含了构建 Python 扩展所需的所有必要的头文件。

使用的 C 编译器是系统编译器。对于基于 Linux 的系统或 Mac OS X,分别是gccclang。对于 Windows,可以使用 Microsoft Visual C++(有免费的命令行版本可用),也可以使用开源项目 MinGW。这可以在distutils中配置。

build命令由bdist命令用于构建二进制分发。它调用build和所有依赖的命令,然后以与sdist相同的方式创建存档。

让我们在 Mac OS X 下为acme.sql创建一个二进制发行版:

$ python setup.py bdist
running bdist
running bdist_dumb
running build
...
running install_scripts
tar -cf dist/acme.sql-0.1.1.macosx-10.3-fat.tar .
gzip -f9 acme.sql-0.1.1.macosx-10.3-fat.tar
removing 'build/bdist.macosx-10.3-fat/dumb' (and everything under it)
$ ls dist/
acme.sql-0.1.1.macosx-10.3-fat.tar.gz    acme.sql-0.1.1.tar.gz

请注意,新创建的存档名称包含了系统名称和它构建的发行版名称(Mac OS X 10.3)。

在 Windows 下调用相同的命令将创建一个特定的分发存档:

C:\acme.sql> python.exe setup.py bdist
...
C:\acme.sql> dir dist
25/02/2008  08:18    <DIR>          .
25/02/2008  08:18    <DIR>          ..
25/02/2008  08:24            16 055 acme.sql-0.1.win32.zip
 **1 File(s)         16 055 bytes
 **2 Dir(s)  22 239 752 192 bytes free

如果软件包包含 C 代码,除了源分发外,释放尽可能多的不同二进制分发是很重要的。至少,对于那些没有安装 C 编译器的人来说,Windows 二进制分发是很重要的。

二进制发行版包含一个可以直接复制到 Python 树中的树。它主要包含一个文件夹,该文件夹被复制到 Python 的site-packages文件夹中。它还可能包含缓存的字节码文件(在 Python 2 上为*.pyc文件,在 Python 3 上为__pycache__/*.pyc)。

另一种构建分发是由wheel包提供的“wheels”。当安装(例如,使用pip)时,wheel会向distutils添加一个新的bdist_wheel命令。它允许创建特定于平台的分发(目前仅适用于 Windows 和 Mac OS X),为普通的bdist分发提供了替代方案。它旨在取代setuptools早期引入的另一种分发——eggs。Eggs 现在已经过时,因此不会在这里介绍。使用 wheels 的优势列表非常长。以下是 Python Wheels 页面(pythonwheels.com/)中提到的优势:

  • 纯 Python 和本地 C 扩展包的更快安装

  • 避免安装时的任意代码执行。(避免setup.py

  • 在 Windows 或 OS X 上安装 C 扩展不需要编译器

  • 允许更好的缓存用于测试和持续集成

  • 创建.pyc文件作为安装的一部分,以确保它们与使用的 Python 解释器匹配

  • 跨平台和机器上的安装更一致

根据 PyPA 的建议,wheels 应该是您的默认分发格式。不幸的是,Linux 的特定平台 wheels 目前还不可用,因此如果您必须分发带有 C 扩展的软件包,那么您需要为 Linux 用户创建sdist分发。

独立可执行文件

创建独立的可执行文件是 Python 代码打包材料中常常被忽视的一个话题。这主要是因为 Python 在其标准库中缺乏适当的工具,允许程序员创建简单的可执行文件,用户可以在不需要安装 Python 解释器的情况下运行。

编译语言在一个重要方面比 Python 具有优势,那就是它们允许为给定的系统架构创建可执行应用程序,用户可以以一种不需要了解底层技术的方式运行。Python 代码在作为包分发时需要 Python 解释器才能运行。这给没有足够技术能力的用户带来了很大的不便。

开发人员友好的操作系统,比如 Mac OS X 或大多数 Linux 发行版,都预装了 Python。因此,对于他们的用户,基于 Python 的应用程序仍然可以作为依赖于主脚本文件中特定解释器指令的源代码包进行分发,这通常被称为shebang。对于大多数 Python 应用程序,这采用以下形式:

#!/usr/bin/env python

这样的指令,当作为脚本的第一行使用时,将默认标记为由给定环境的 Python 版本解释。当然,这可以更详细地表达,需要特定的 Python 版本,比如python3.4python3python2。请注意,这将在大多数流行的 POSIX 系统中工作,但根据定义,这在任何情况下都不具备可移植性。这个解决方案依赖于特定的 Python 版本的存在,以及env可执行文件确切地位于/usr/bin/env。这些假设都可能在某些操作系统上失败。另外,shebang 在 Windows 上根本不起作用。此外,即使对于经验丰富的开发人员,在 Windows 上启动 Python 环境也可能是一个挑战,因此你不能指望非技术用户能够自己做到这一点。

另一件要考虑的事情是在桌面环境中的简单用户体验。用户通常希望可以通过简单点击桌面上的应用程序来运行它们。并非每个桌面环境都支持将 Python 应用程序作为源代码分发后以这种方式运行。

因此,最好能够创建一个二进制分发,它可以像任何其他编译的可执行文件一样工作。幸运的是,可以创建一个既包含 Python 解释器又包含我们项目的可执行文件。这允许用户打开我们的应用程序,而不必关心 Python 或任何其他依赖项。

独立的可执行文件何时有用?

独立的可执行文件在用户体验的简单性比用户能够干预应用程序代码更重要的情况下是有用的。请注意,仅仅将应用程序作为可执行文件分发只会使代码阅读或修改变得更加困难,而不是不可能。这不是保护应用程序代码的方法,应该只用作使与应用程序交互的方式更简单的方法。

独立的可执行文件应该是为非技术终端用户分发应用程序的首选方式,似乎也是为 Windows 分发 Python 应用程序的唯一合理方式。

独立的可执行文件通常是一个不错的选择:

  • 依赖于目标操作系统上可能不容易获得的特定 Python 版本的应用程序

  • 依赖于修改后的预编译的 CPython 源代码的应用程序

  • 具有图形界面的应用程序

  • 具有许多用不同语言编写的二进制扩展的项目

  • 游戏

流行的工具

Python 没有任何内置支持来构建独立的可执行文件。幸运的是,有一些社区项目在解决这个问题,取得了不同程度的成功。最值得注意的四个是:

  • PyInstaller

  • cx_Freeze

  • py2exe

  • py2app

它们每一个在使用上都略有不同,而且每一个都有略微不同的限制。在选择工具之前,您需要决定要针对哪个平台,因为每个打包工具只能支持特定的操作系统集。

最好的情况是在项目的早期阶段就做出这样的决定。当然,这些工具都不需要在您的代码中进行深入的交互,但是如果您早期开始构建独立的软件包,您可以自动化整个过程,并节省未来的集成时间和成本。如果您把这个留到以后,您可能会发现项目构建得非常复杂,以至于没有任何可用的工具可以使用。为这样的项目提供一个独立的可执行文件将是困难的,并且会花费大量的时间。

PyInstaller

PyInstaller(www.pyinstaller.org/)是目前将 Python 软件包冻结为独立可执行文件的最先进的程序。它在目前所有可用的解决方案中提供了最广泛的多平台兼容性,因此是最推荐的。PyInstaller 支持的平台有:

  • Windows(32 位和 64 位)

  • Linux(32 位和 64 位)

  • Mac OS X(32 位和 64 位)

  • FreeBSD、Solaris 和 AIX

支持的 Python 版本是 Python 2.7 和 Python 3.3、3.4 和 3.5。它可以在 PyPI 上找到,因此可以使用pip在您的工作环境中安装它。如果您在安装时遇到问题,您可以随时从项目页面下载安装程序。

不幸的是,不支持跨平台构建(交叉编译),因此如果您想为特定平台构建独立的可执行文件,那么您需要在该平台上执行构建。随着许多虚拟化工具的出现,这在今天并不是一个大问题。如果您的计算机上没有安装特定的系统,您可以随时使用 Vagrant,它将为您提供所需的操作系统作为虚拟机。

简单应用程序的使用很容易。假设我们的应用程序包含在名为myscript.py的脚本中。这是一个简单的“Hello world!”应用程序。我们想为 Windows 用户创建一个独立的可执行文件,并且我们的源代码位于文件系统中的D://dev/app下。我们的应用程序可以使用以下简短的命令进行打包:

$ pyinstaller myscript.py

2121 INFO: PyInstaller: 3.1
2121 INFO: Python: 2.7.10
2121 INFO: Platform: Windows-7-6.1.7601-SP1
2121 INFO: wrote D:\dev\app\myscript.spec
2137 INFO: UPX is not available.
2138 INFO: Extending PYTHONPATH with paths
['D:\\dev\\app', 'D:\\dev\\app']
2138 INFO: checking Analysis
2138 INFO: Building Analysis because out00-Analysis.toc is non existent
2138 INFO: Initializing module dependency graph...
2154 INFO: Initializing module graph hooks...
2325 INFO: running Analysis out00-Analysis.toc
(...)
25884 INFO: Updating resource type 24 name 2 language 1033

PyInstaller 的标准输出即使对于简单的应用程序也非常长,因此为了简洁起见,在前面的示例中进行了截断。如果在 Windows 上运行,目录和文件的结果结构将如下所示:

$ tree /0066
│   myscript.py
│   myscript.spec
│
├───build
│   └───myscript
│           myscript.exe
│           myscript.exe.manifest
│           out00-Analysis.toc
│           out00-COLLECT.toc
│           out00-EXE.toc
│           out00-PKG.pkg
│           out00-PKG.toc
│           out00-PYZ.pyz
│           out00-PYZ.toc
│           warnmyscript.txt
│
└───dist
 **└───myscript
 **bz2.pyd
 **Microsoft.VC90.CRT.manifest
 **msvcm90.dll
 **msvcp90.dll
 **msvcr90.dll
 **myscript.exe
 **myscript.exe.manifest
 **python27.dll
 **select.pyd
 **unicodedata.pyd
 **_hashlib.pyd

dist/myscript目录包含了可以分发给用户的构建应用程序。请注意,整个目录必须被分发。它包含了运行我们的应用程序所需的所有附加文件(DLL、编译的扩展库等)。可以使用pyinstaller命令的--onefile开关获得更紧凑的分发:

$ pyinstaller --onefile myscript.py
(...)
$ tree /f
├───build
│   └───myscript
│           myscript.exe.manifest
│           out00-Analysis.toc
│           out00-EXE.toc
│           out00-PKG.pkg
│           out00-PKG.toc
│           out00-PYZ.pyz
│           out00-PYZ.toc
│           warnmyscript.txt
│
└───dist
 **myscript.exe

使用--onefile选项构建时,您需要分发给其他用户的唯一文件是dist目录中找到的单个可执行文件(这里是myscript.exe)。对于小型应用程序,这可能是首选选项。

运行pyinstaller命令的一个副作用是创建*.spec文件。这是一个自动生成的 Python 模块,包含了如何从您的源代码创建可执行文件的规范。例如,我们已经在以下代码中使用了这个:

# -*- mode: python -*-

block_cipher = None

a = Analysis(['myscript.py'],
             pathex=['D:\\dev\\app'],
             binaries=None,
             datas=None,
             hiddenimports=[],
             hookspath=[],
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data,
             cipher=block_cipher)
exe = EXE(pyz,
          a.scripts,
          a.binaries,
          a.zipfiles,
          a.datas,
          name='myscript',
          debug=False,
          strip=False,
          upx=True,
          console=True )

这个.spec文件包含了之前指定的所有pyinstaller参数。如果您对构建进行了大量的自定义,这将非常有用,因为这可以代替必须存储您的配置的构建脚本。创建后,您可以将其用作pyinstaller命令的参数,而不是您的 Python 脚本:

$ pyinstaller.exe myscript.spec

请注意,这是一个真正的 Python 模块,因此您可以使用自己已经了解的语言对其进行扩展并对构建过程进行更复杂的自定义。当您针对许多不同的平台时,自定义.spec文件尤其有用。此外,并非所有的pyinstaller选项都可以通过命令行参数使用,只有在修改.spec文件时才能使用。

PyInstaller 是一个功能强大的工具,使用起来对于大多数程序来说非常简单。无论如何,如果您有兴趣将其作为分发应用程序的工具,建议仔细阅读其文档。

cx_Freeze

cx_Freeze (cx-freeze.sourceforge.net/)是另一个用于创建独立可执行文件的工具。它比 PyInstaller 更简单,但也支持三个主要平台:

  • Windows

  • Linux

  • Mac OS X

与 PyInstaller 一样,它不允许我们执行跨平台构建,因此您需要在分发到的相同操作系统上创建您的可执行文件。cx_Freeze 的主要缺点是它不允许我们创建真正的单文件可执行文件。使用它构建的应用程序需要与相关的 DLL 文件和库一起分发。假设我们有与PyInstaller部分中的相同应用程序,那么示例用法也非常简单:

$ cxfreeze myscript.py

copying C:\Python27\lib\site-packages\cx_Freeze\bases\Console.exe -> D:\dev\app\dist\myscript.exe
copying C:\Windows\system32\python27.dll -> D:\dev\app\dist\python27.dll
writing zip file D:\dev\app\dist\myscript.exe
(...)
copying C:\Python27\DLLs\bz2.pyd -> D:\dev\app\dist\bz2.pyd
copying C:\Python27\DLLs\unicodedata.pyd -> D:\dev\app\dist\unicodedata.pyd

生成的文件结构如下:

$ tree /f
│   myscript.py
│
└───dist
 **bz2.pyd
 **myscript.exe
 **python27.dll
 **unicodedata.pyd

cx_Freeze 不是提供自己的构建规范格式(就像 PyInstaller 一样),而是扩展了distutils包。这意味着您可以使用熟悉的setup.py脚本配置独立可执行文件的构建方式。如果您已经使用setuptoolsdistutils来分发软件包,那么 cx_Freeze 非常方便,因为额外的集成只需要对setup.py脚本进行小的更改。以下是一个使用cx_Freeze.setup()创建 Windows 独立可执行文件的setup.py脚本示例:

import sys
from cx_Freeze import setup, Executable

# Dependencies are automatically detected, but it might need fine tuning.
build_exe_options = {"packages": ["os"], "excludes": ["tkinter"]}

setup(
    name="myscript",
    version="0.0.1",
    description="My Hello World application!",
    options={
        "build_exe": build_exe_options
    },
    executables=[Executable("myscript.py")]
)

有了这样一个文件,可以使用添加到setup.py脚本的新build_exe命令来创建新的可执行文件:

$ python setup.py build_exe

cx_Freeze 的使用似乎比 PyInstaller 和distutils集成更容易一些,这是一个非常有用的功能。不幸的是,这个项目可能会给经验不足的开发人员带来一些麻烦:

  • 在 Windows 下使用pip进行安装可能会有问题

  • 官方文档非常简短,某些地方缺乏说明

py2exe 和 py2app

py2exe (www.py2exe.org/)和 py2app (pythonhosted.org/py2app/)是另外两个集成到 Python 打包中的程序,可以通过distutilssetuptools创建独立可执行文件。它们被一起提到,因为它们在使用和限制方面非常相似。py2exe 和 py2app 的主要缺点是它们只针对单个平台:

  • py2exe 允许构建 Windows 可执行文件

  • py2app 允许构建 Mac OS X 应用程序

由于使用方法非常相似,只需要修改setup.py脚本,这些软件包似乎互补。py2app 项目的官方文档提供了以下setup.py脚本示例,可以根据所使用的平台使用正确的工具(py2exe 或 py2app)构建独立可执行文件:

import sys
from setuptools import setup

mainscript = 'MyApplication.py'

if sys.platform == 'darwin':
    extra_options = dict(
        setup_requires=['py2app'],
        app=[mainscript],
        # Cross-platform applications generally expect sys.argv to
        # be used for opening files.
        options=dict(py2app=dict(argv_emulation=True)),
    )
elif sys.platform == 'win32':
    extra_options = dict(
        setup_requires=['py2exe'],
        app=[mainscript],
    )
else:
    extra_options = dict(
        # Normally unix-like platforms will use "setup.py install"
        # and install the main script as such
        scripts=[mainscript],
    )

setup(
    name="MyApplication",
    **extra_options
)

使用这样的脚本,您可以使用python setup.py py2exe命令构建 Windows 可执行文件,并使用python setup.py py2app构建 Mac OS X 应用程序。当然,跨编译是不可能的。

尽管 cx_Freeze 的一些限制和弹性不如 PyInstaller 或 cx_Freeze,但了解总是有 py2exe 和 py2app 项目。在某些情况下,PyInstaller 或 cx_Freeze 可能无法正确地构建项目的可执行文件。在这种情况下,值得检查其他解决方案是否能够处理我们的代码。

可执行软件包中 Python 代码的安全性

重要的是要知道,独立可执行文件并不以任何方式使应用程序代码安全。从这种可执行文件中反编译嵌入的代码并不是一件容易的任务,但肯定是可行的。更重要的是,这种反编译的结果(如果使用适当的工具进行)可能看起来与原始源代码非常相似。

这个事实使得独立的 Python 可执行文件对于泄漏应用程序代码可能会损害组织的闭源项目来说并不是一个可行的解决方案。因此,如果你的整个业务可以通过简单地复制应用程序的源代码来复制,那么你应该考虑其他分发应用程序的方式。也许提供软件作为服务对你来说会是更好的选择。

使反编译变得更加困难

正如已经说过的,目前没有可靠的方法可以防止应用程序被反编译。但是,有一些方法可以使这个过程变得更加困难。但更困难并不意味着不太可能。对于我们中的一些人来说,最具诱惑力的挑战是最困难的挑战。我们都知道,这个挑战的最终奖励是非常高的:您试图保护的代码。

通常,反编译的过程包括几个步骤:

  1. 从独立可执行文件中提取项目的字节码的二进制表示。

  2. 将二进制表示映射到特定 Python 版本的字节码。

  3. 将字节码转换为 AST。

  4. 直接从 AST 重新创建源代码。

提供确切的解决方案来阻止开发人员对独立可执行文件进行逆向工程将是毫无意义的,因为这是显而易见的原因。因此,这里只提供了一些阻碍反编译过程或贬值其结果的想法:

  • 删除运行时可用的任何代码元数据(文档字符串),因此最终结果会变得不太可读

  • 修改 CPython 解释器使用的字节码值,以便从二进制转换为字节码,然后再转换为 AST 需要更多的工作

  • 使用经过复杂修改的 CPython 源代码版本,即使可用应用程序的反编译源代码也无法在没有反编译修改后的 CPython 二进制文件的情况下使用

  • 在将源代码捆绑成可执行文件之前,使用混淆脚本对源代码进行混淆,这样在反编译后源代码的价值就会降低

这些解决方案使开发过程变得更加困难。上述一些想法需要对 Python 运行时有很深的理解,但它们每一个都充满了许多陷阱和缺点。大多数情况下,它们只是推迟了不可避免的结果。一旦你的技巧被破解,所有额外的努力都将成为时间和资源的浪费。

不允许您的闭源代码以任何形式直接发货给用户是唯一可靠的方法。只有在您组织的其他方面保持严密的安全性时,这才是真实的。

摘要

本章描述了 Python 的打包生态系统的细节。现在,在阅读完本章之后,您应该知道哪些工具适合您的打包需求,以及您的项目需要哪些类型的分发。您还应该知道常见问题的流行技术以及如何为您的项目提供有用的元数据。

我们还讨论了独立可执行文件的话题,这些文件非常有用,特别是在分发桌面应用程序时。

下一章将广泛依赖我们在这里学到的知识,展示如何以可靠和自动化的方式有效处理代码部署。

第六章:部署代码

即使完美的代码(如果存在的话)如果不被运行,也是无用的。因此,为了发挥作用,我们的代码需要安装到目标机器(计算机)并执行。将特定版本的应用程序或服务提供给最终用户的过程称为部署。

对于桌面应用程序来说,这似乎很简单——你的工作就是提供一个可下载的包,并在必要时提供可选的安装程序。用户有责任在自己的环境中下载并安装它。你的责任是尽可能地使这个过程简单和方便。适当的打包仍然不是一项简单的任务,但一些工具已经在上一章中进行了解释。

令人惊讶的是,当你的代码不是产品本身时,情况会变得更加复杂。如果你的应用程序只提供向用户出售的服务,那么你有责任在自己的基础设施上运行它。这种情况对于 Web 应用程序或任何“X 作为服务”产品都很典型。在这种情况下,代码被部署到远程机器上,通常开发人员几乎无法物理接触到这些机器。如果你已经是云计算服务的用户,比如亚马逊网络服务(AWS)或 Heroku,这一点尤其真实。

在本章中,我们将集中讨论代码部署到远程主机的方面,因为 Python 在构建各种与网络相关的服务和产品领域非常受欢迎。尽管这种语言具有很高的可移植性,但它没有特定的特性,可以使其代码易于部署。最重要的是你的应用程序是如何构建的,以及你用什么流程将其部署到目标环境中。因此,本章将重点讨论以下主题:

  • 部署代码到远程环境的主要挑战是什么

  • 如何构建易于部署的 Python 应用程序

  • 如何在没有停机的情况下重新加载 Web 服务

  • 如何利用 Python 打包生态系统进行代码部署

  • 如何正确监控和调试远程运行的代码

十二要素应用

无痛部署的主要要求是以确保这个过程简单和尽可能流畅的方式构建你的应用程序。这主要是关于消除障碍和鼓励良好的做法。在只有特定人员负责开发(开发团队或简称为 Dev)的组织中,以及不同的人负责部署和维护执行环境(运维团队或简称为 Ops)的组织中,遵循这样的常见做法尤为重要。

与服务器维护、监控、部署、配置等相关的所有任务通常被放在一个袋子里,称为运维。即使在没有专门的运维团队的组织中,通常也只有一些开发人员被授权执行部署任务和维护远程服务器。这种职位的通用名称是 DevOps。此外,每个开发团队成员都负责运维并不是一种不寻常的情况,因此在这样的团队中,每个人都可以被称为 DevOps。无论你的组织结构如何,每个开发人员都应该知道运维工作以及代码如何部署到远程服务器,因为最终,执行环境及其配置是你正在构建的产品的隐藏部分。

以下的常见做法和约定主要是出于以下原因:

  • 在每家公司,员工会离职,新员工会入职。通过使用最佳方法,你可以让新团队成员更容易地加入项目。你永远无法确定新员工是否已经熟悉了系统配置和可靠运行应用程序的常见做法,但你至少可以让他们更有可能快速适应。

  • 在只有一些人负责部署的组织中,它简单地减少了运维和开发团队之间的摩擦。

鼓励构建易于部署应用程序的实践的一个很好的来源是一个名为十二要素应用的宣言。它是一个通用的、与语言无关的构建软件即服务应用程序的方法论。它的目的之一是使应用程序更容易部署,但它也强调了其他主题,比如可维护性和使应用程序更容易扩展。

正如其名称所示,十二要素应用由 12 条规则组成:

  • 代码库:一个代码库在版本控制中跟踪,多次部署

  • 依赖关系:明确声明和隔离依赖关系

  • 配置:将配置存储在环境中

  • 后端服务:将后端服务视为附加资源

  • 构建、发布、运行:严格区分构建和运行阶段

  • 进程:将应用程序作为一个或多个无状态进程执行

  • 端口绑定:通过端口绑定导出服务

  • 并发:通过进程模型进行扩展

  • 可处置性:通过快速启动和优雅关闭来最大化健壮性

  • 开发/生产一致性:尽量使开发、演示和生产环境尽可能相似

  • 日志:将日志视为事件流

  • 管理进程:将管理任务作为一次性进程运行

在这里扩展每个规则有点无意义,因为十二要素应用方法论的官方页面(12factor.net/)包含了每个应用要素的广泛原理,以及不同框架和环境的工具示例。

本章试图与上述宣言保持一致,因此我们将在必要时详细讨论其中一些。所呈现的技术和示例有时可能略微偏离这 12 个要素,但请记住,这些规则并非铁板一块。只要能达到目的,它们就是好的。最终,重要的是工作的应用程序(产品),而不是与某种任意方法论兼容。

使用 Fabric 进行部署自动化

对于非常小的项目,可能可以手动部署代码,也就是通过远程 shell 手动输入必要的命令序列来安装新版本的代码并在远程 shell 上执行。然而,即使对于一个中等大小的项目,这种方法容易出错,繁琐,并且应该被视为浪费你最宝贵的资源,也就是你自己的时间。

解决这个问题的方法是自动化。一个简单的经验法则是,如果你需要手动执行相同的任务至少两次,你应该自动化它,这样你就不需要第三次手动执行了。有各种工具可以让你自动化不同的事情:

  • 远程执行工具如 Fabric 用于按需在多个远程主机上自动执行代码。

  • 诸如 Chef、Puppet、CFEngine、Salt 和 Ansible 等配置管理工具旨在自动配置远程主机(执行环境)。它们可以用于设置后端服务(数据库、缓存等)、系统权限、用户等。它们大多也可以用作像 Fabric 这样的远程执行工具,但根据它们的架构,这可能更容易或更困难。

配置管理解决方案是一个复杂的话题,值得单独写一本书。事实上,最简单的远程执行框架具有最低的入门门槛,并且是最受欢迎的选择,至少对于小型项目来说是这样。事实上,每个配置管理工具都提供了一种声明性地指定机器配置的方式,深层内部都实现了远程执行层。

此外,根据某些工具的设计,由于它们的设计,它可能不适合实际的自动化代码部署。一个这样的例子是 Puppet,它确实不鼓励显式运行任何 shell 命令。这就是为什么许多人选择同时使用这两种类型的解决方案来相互补充:配置管理用于设置系统级环境,按需远程执行用于应用程序部署。

Fabric (www.fabfile.org/)到目前为止是 Python 开发人员用来自动化远程执行的最流行的解决方案。它是一个用于简化使用 SSH 进行应用程序部署或系统管理任务的 Python 库和命令行工具。我们将重点关注它,因为它相对容易上手。请注意,根据您的需求,它可能不是解决问题的最佳方案。无论如何,它是一个很好的工具,可以为您的操作添加一些自动化,如果您还没有的话。

提示

Fabric 和 Python 3

本书鼓励您只在 Python 3 中开发(如果可能的话),并提供有关旧语法特性和兼容性注意事项的注释,只是为了使最终版本切换更加轻松。不幸的是,在撰写本书时,Fabric 仍未正式移植到 Python 3。这个工具的爱好者们被告知至少有几年的时间正在开发 Fabric 2,将带来一个兼容性更新。据说这是一个完全重写,带有许多新功能,但目前还没有 Fabric 2 的官方开放存储库,几乎没有人看到过它的代码。核心 Fabric 开发人员不接受当前项目的 Python 3 兼容性的任何拉取请求,并关闭对其的每个功能请求。这种对流行开源项目的开发方式至少是令人不安的。这个问题的历史并不让我们看到 Fabric 2 的官方发布的机会很高。这种秘密开发新 Fabric 版本的做法引发了许多问题。

不管任何人的观点,这个事实并不会减少 Fabric 在当前状态下的实用性。因此,如果您已经决定坚持使用 Python 3,有两个选择:使用一个完全兼容且独立的分支(github.com/mathiasertl/fabric/)或者在 Python 3 中编写您的应用程序,并在 Python 2 中维护 Fabric 脚本。最好的方法是在一个单独的代码存储库中进行。

当然,您可以只使用 Bash 脚本来自动化所有工作,但这非常繁琐且容易出错。Python 有更方便的字符串处理方式,并鼓励代码模块化。事实上,Fabric 只是一个通过 SSH 粘合命令执行的工具,因此仍然需要一些关于命令行界面及其实用程序在您的环境中如何工作的知识。

使用 Fabric 开始工作,您需要安装fabric包(使用pip),并创建一个名为fabfile.py的脚本,通常位于项目的根目录中。请注意,fabfile可以被视为项目配置的一部分。因此,如果您想严格遵循十二要素应用程序方法论,您不应该在部署的应用程序源树中维护其代码。事实上,复杂的项目通常是由维护为单独代码库的各种组件构建而成,因此,将所有项目组件配置和 Fabric 脚本放在一个单独的存储库中是一个很好的方法。这样可以使不同服务的部署更加一致,并鼓励良好的代码重用。

一个定义了简单部署过程的示例fabfile将如下所示:

# -*- coding: utf-8 -*-
import os

from fabric.api import *  # noqa
from fabric.contrib.files import exists

# Let's assume we have private package repository created
# using 'devpi' project
PYPI_URL = 'http://devpi.webxample.example.com'

# This is arbitrary location for storing installed releases.
# Each release is a separate virtual environment directory
# which is named after project version. There is also a
# symbolic link 'current' that points to recently deployed
# version. This symlink is an actual path that will be used
# for configuring the process supervision tool e.g.:
# .
# ├── 0.0.1
# ├── 0.0.2
# ├── 0.0.3
# ├── 0.1.0
# └── current -> 0.1.0/

REMOTE_PROJECT_LOCATION = "/var/projects/webxample"

env.project_location = REMOTE_PROJECT_LOCATION

# roledefs map out environment types (staging/production)
env.roledefs = {
    'staging': [
        'staging.webxample.example.com',
    ],
    'production': [
        'prod1.webxample.example.com',
        'prod2.webxample.example.com',
    ],
}

def prepare_release():
    """ Prepare a new release by creating source distribution and uploading to out private package repository
    """
    local('python setup.py build sdist upload -r {}'.format(
        PYPI_URL
    ))

def get_version():
    """ Get current project version from setuptools """
    return local(
        'python setup.py --version', capture=True
    ).stdout.strip()

def switch_versions(version):
    """ Switch versions by replacing symlinks atomically """
    new_version_path = os.path.join(REMOTE_PROJECT_LOCATION, version)
    temporary = os.path.join(REMOTE_PROJECT_LOCATION, 'next')
    desired = os.path.join(REMOTE_PROJECT_LOCATION, 'current')

    # force symlink (-f) since probably there is a one already
    run(
        "ln -fsT {target} {symlink}"
        "".format(target=new_version_path, symlink=temporary)
    )
    # mv -T ensures atomicity of this operation
    run("mv -Tf {source} {destination}"
        "".format(source=temporary, destination=desired))

@task
def uptime():
    """
    Run uptime command on remote host - for testing connection.
    """
    run("uptime")

@task
def deploy():
    """ Deploy application with packaging in mind """
    version = get_version()
    pip_path = os.path.join(
        REMOTE_PROJECT_LOCATION, version, 'bin', 'pip'
    )

    prepare_release()

    if not exists(REMOTE_PROJECT_LOCATION):
        # it may not exist for initial deployment on fresh host
        run("mkdir -p {}".format(REMOTE_PROJECT_LOCATION))

    with cd(REMOTE_PROJECT_LOCATION):
        # create new virtual environment using venv
        run('python3 -m venv {}'.format(version))

        run("{} install webxample=={} --index-url {}".format(
            pip_path, version, PYPI_URL
        ))

    switch_versions(version)
    # let's assume that Circus is our process supervision tool
    # of choice.
    run('circusctl restart webxample')

每个使用@task装饰的函数都被视为fabric包提供的fab实用程序的可用子命令。您可以使用-l--list开关列出所有可用的子命令:

$ fab --list
Available commands:

 **deploy  Deploy application with packaging in mind
 **uptime  Run uptime command on remote host - for testing connection.

现在,您可以只需一个 shell 命令将应用程序部署到给定的环境类型:

$ fab –R production deploy

请注意,前面的fabfile仅用于举例说明。在您自己的代码中,您可能希望提供全面的故障处理,并尝试重新加载应用程序,而无需重新启动 Web 工作进程。此外,此处介绍的一些技术现在可能很明显,但稍后将在本章中进行解释。这些是:

  • 使用私有软件包存储库部署应用程序

  • 在远程主机上使用 Circus 进行进程监控

您自己的软件包索引或索引镜像

有三个主要原因您可能希望运行自己的 Python 软件包索引:

  • 官方 Python 软件包索引没有任何可用性保证。它由 Python 软件基金会运行,感谢众多捐赠。因此,这往往意味着该站点可能会关闭。您不希望由于 PyPI 中断而在中途停止部署或打包过程。

  • 即使对于永远不会公开发布的闭源代码,也有用处,因为它可以使用 Python 编写的可重用组件得到适当打包。这简化了代码库,因为公司中用于不同项目的软件包不需要被打包。您可以直接从存储库安装它们。这简化了这些共享代码的维护,并且如果公司有许多团队在不同项目上工作,可能会减少整个公司的开发成本。

  • 使用setuptools对整个项目进行打包是非常好的做法。然后,部署新应用程序版本通常只需运行pip install --update my-application

提示

代码打包

代码打包是将外部软件包的源代码包含在其他项目的源代码(存储库)中的做法。当项目的代码依赖于某个特定版本的外部软件包时,通常会这样做,该软件包也可能被其他软件包(以完全不同的版本)所需。例如,流行的requests软件包在其源代码树中打包了urllib3的某个版本,因为它与之紧密耦合,并且几乎不太可能与urllib3的其他版本一起使用。一些特别经常被其他人打包的模块的例子是six。它可以在许多流行项目的源代码中找到,例如 Django(django.utils.six),Boto(boto.vedored.six)或 Matplotlib(matplotlib.externals.six)。

尽管一些大型和成功的开源项目甚至也会使用打包,但如果可能的话应该避免。这只在某些情况下才有合理的用途,并且不应被视为软件包依赖管理的替代品。

PyPI 镜像

PyPI 中断的问题可以通过允许安装工具从其镜像之一下载软件包来在一定程度上得到缓解。事实上,官方 Python 软件包索引已经通过CDN内容传送网络)提供服务,因此它本质上是镜像的。这并不改变这样的事实,即它似乎偶尔会出现一些糟糕的日子,当任何尝试下载软件包失败时。在这里使用非官方镜像不是一个解决方案,因为这可能会引发一些安全顾虑。

最好的解决方案是拥有自己的 PyPI 镜像,其中包含您需要的所有软件包。唯一使用它的一方是您自己,因此更容易确保适当的可用性。另一个优势是,每当此服务关闭时,您无需依赖其他人来重新启动它。PyPA 维护和推荐的镜像工具是bandersnatchpypi.python.org/pypi/bandersnatch)。它允许您镜像 Python Package Index 的全部内容,并且可以作为.pypirc文件中存储库部分的index-url选项提供(如前一章中所述)。此镜像不接受上传,也没有 PyPI 的 Web 部分。无论如何,要小心!完整的镜像可能需要数百千兆字节的存储空间,并且其大小将随着时间的推移而继续增长。

但是,为什么要停留在一个简单的镜像上,而我们有一个更好的选择呢?您几乎不太可能需要整个软件包索引的镜像。即使是具有数百个依赖项的项目,它也只是所有可用软件包的一小部分。此外,无法上传自己的私有软件包是这种简单镜像的巨大局限性。似乎使用 bandersnatch 的附加价值与其高昂的价格相比非常低。在大多数情况下,这是正确的。如果软件包镜像仅用于单个或少数项目,那么使用devpidoc.devpi.net/)将是一个更好的方法。它是一个与 PyPI 兼容的软件包索引实现,提供以下两种功能:

  • 上传非公共软件包的私有索引

  • 索引镜像

devpi 相对于 bandersnatch 的主要优势在于它如何处理镜像。它当然可以像 bandersnatch 一样对其他索引进行完整的通用镜像,但这不是它的默认行为。它不是对整个存储库进行昂贵的备份,而是为已被客户端请求的软件包维护镜像。因此,每当安装工具(pipsetuptoolseasyinstall)请求软件包时,如果在本地镜像中不存在,devpi 服务器将尝试从镜像索引(通常是 PyPI)下载并提供。软件包下载后,devpi 将定期检查其更新,以保持镜像的新鲜状态。

镜像方法在您请求尚未被镜像的新软件包并且上游软件包索引中断时留下了轻微的失败风险。无论如何,由于在大多数部署中,您将仅依赖于已在索引中镜像的软件包,因此这种风险得到了减少。对于已经请求的软件包,镜像状态与 PyPI 具有最终一致性,并且新版本将自动下载。这似乎是一个非常合理的权衡。

使用软件包进行部署

现代 Web 应用程序有很多依赖项,并且通常需要许多步骤才能在远程主机上正确安装。例如,对于远程主机上的应用程序的新版本的典型引导过程包括以下步骤:

  • 为隔离创建新的虚拟环境

  • 将项目代码移动到执行环境

  • 安装最新的项目要求(通常来自requirements.txt文件)

  • 同步或迁移数据库架构

  • 从项目源和外部软件包收集静态文件到所需位置

  • 为可用于不同语言的应用程序编译本地化文件

对于更复杂的网站,可能会有许多与前端代码相关的附加任务:

  • 使用预处理器(如 SASS 或 LESS)生成 CSS 文件

  • 对静态文件(JavaScript 和 CSS 文件)进行缩小、混淆和/或合并

  • 编译用 JavaScript 超集语言(CoffeeScript、TypeScript 等)编写的代码到本机 JS

  • 预处理响应模板文件(缩小、内联样式等)

所有这些步骤都可以使用诸如 Bash、Fabric 或 Ansible 之类的工具轻松自动化,但在安装应用程序的远程主机上做所有事情并不是一个好主意。原因如下:

  • 一些用于处理静态资产的流行工具可能是 CPU 密集型或内存密集型。在生产环境中运行它们可能会使应用程序执行不稳定。

  • 这些工具通常需要额外的系统依赖项,这些依赖项可能不是项目的正常运行所必需的。这些主要是额外的运行时环境,如 JVM、Node 或 Ruby。这增加了配置管理的复杂性,并增加了整体维护成本。

  • 如果您将应用程序部署到多个服务器(十个、百个、千个),那么您只是在重复很多工作,这些工作本来可以只做一次。如果您有自己的基础设施,那么您可能不会经历巨大的成本增加,特别是如果您在低流量时段进行部署。但如果您在计费模型中运行云计算服务,该模型会额外收费用于负载峰值或一般执行时间,那么这些额外成本可能在适当的规模上是相当可观的。

  • 大多数这些步骤只是花费了很多时间。您正在将代码安装到远程服务器上,所以您最不希望的是在部署过程中由于某些网络问题而中断连接。通过保持部署过程快速,您可以降低部署中断的几率。

出于明显的原因,上述部署步骤的结果不能包含在应用程序代码存储库中。简单地说,有些事情必须在每个发布中完成,你无法改变这一点。显然这是一个适当自动化的地方,但关键是在正确的地方和正确的时间做。

大部分静态收集和代码/资产预处理等工作可以在本地或专用环境中完成,因此部署到远程服务器的实际代码只需要进行最少量的现场处理。在构建分发或安装包的过程中,最显著的部署步骤是:

  • 安装 Python 依赖项和传输静态资产(CSS 文件和 JavaScript)到所需位置可以作为setup.py脚本的install命令的一部分来处理

  • 预处理代码(处理 JavaScript 超集、资产的缩小/混淆/合并,以及运行 SASS 或 LESS)和诸如本地化文本编译(例如 Django 中的compilemessages)等工作可以作为setup.py脚本的sdist/bdist命令的一部分

包括除 Python 以外的预处理代码可以很容易地通过适当的MANIFEST.in文件处理。依赖项当然最好作为setuptools包的setup()函数调用的install_requires参数提供。

当然,打包整个应用程序将需要您进行一些额外的工作,比如提供自己的自定义setuptools命令或覆盖现有的命令,但这将为您带来许多优势,并使项目部署更快速和可靠。

让我们以一个基于 Django 的项目(在 Django 1.9 版本中)为例。我选择这个框架是因为它似乎是这种类型的最受欢迎的 Python 项目,所以你很有可能已经对它有所了解。这样的项目中文件的典型结构可能如下所示:

$ tree . -I __pycache__ --dirsfirst
.
├── webxample
│   ├── conf
│   │   ├── __init__.py
│   │   ├── settings.py
│   │   ├── urls.py
│   │   └── wsgi.py
│   ├── locale
│   │   ├── de
│   │   │   └── LC_MESSAGES
│   │   │       └── django.po
│   │   ├── en
│   │   │   └── LC_MESSAGES
│   │   │       └── django.po
│   │   └── pl
│   │       └── LC_MESSAGES
│   │           └── django.po
│   ├── myapp
│   │   ├── migrations
│   │   │   └── __init__.py
│   │   ├── static
│   │   │   ├── js
│   │   │   │   └── myapp.js
│   │   │   └── sass
│   │   │       └── myapp.scss
│   │   ├── templates
│   │   │   ├── index.html
│   │   │   └── some_view.html
│   │   ├── __init__.py
│   │   ├── admin.py
│   │   ├── apps.py
│   │   ├── models.py
│   │   ├── tests.py
│   │   └── views.py
│   ├── __init__.py
│   └── manage.py
├── MANIFEST.in
├── README.md
└── setup.py

15 directories, 23 files

请注意,这与通常的 Django 项目模板略有不同。默认情况下,包含 WSGI 应用程序、设置模块和 URL 配置的包与项目名称相同。因为我们决定采用打包的方法,这将被命名为webxample。这可能会引起一些混淆,所以最好将其重命名为conf

不要深入可能的实现细节,让我们只做一些简单的假设:

  • 我们的示例应用程序有一些外部依赖。在这里,将是两个流行的 Django 软件包:djangorestframeworkdjango-allauth,以及一个非 Django 软件包:gunicorn

  • djangorestframeworkdjango-allauth 被提供为 webexample.webexample.settings 模块中的 INSTALLED_APPS

  • 该应用程序在三种语言(德语、英语和波兰语)中进行了本地化,但我们不希望将编译的 gettext 消息存储在存储库中。

  • 我们厌倦了普通的 CSS 语法,所以我们决定使用更强大的 SCSS 语言,我们使用 SASS 将其转换为 CSS。

了解项目的结构后,我们可以编写我们的 setup.py 脚本,使 setuptools 处理:

  • webxample/myapp/static/scss 下编译 SCSS 文件

  • .po 格式编译 webexample/locale 下的 gettext 消息到 .mo 格式

  • 安装要求

  • 提供软件包的入口点的新脚本,这样我们将有自定义命令而不是 manage.py 脚本

我们在这里有点运气。 libsass 的 Python 绑定是 SASS 引擎的 C/C++端口,它与 setuptoolsdistutils 提供了很好的集成。只需进行少量配置,它就可以为运行 SASS 编译提供自定义的 setup.py 命令:

from setuptools import setup

setup(
    name='webxample',
    setup_requires=['libsass >= 0.6.0'],
    sass_manifests={
        'webxample.myapp': ('static/sass', 'static/css')
    },
)

因此,我们可以通过键入 python setup.py build_scss 来将我们的 SCSS 文件编译为 CSS,而不是手动运行 sass 命令或在 setup.py 脚本中执行子进程。这还不够。这让我们的生活变得更容易,但我们希望整个分发过程完全自动化,因此只需一个步骤即可创建新版本。为了实现这个目标,我们不得不稍微覆盖一些现有的 setuptools 分发命令。

处理一些项目准备步骤的 setup.py 文件示例可能如下所示:

import os

from setuptools import setup
from setuptools import find_packages
from distutils.cmd import Command
from distutils.command.build import build as _build

try:
    from django.core.management.commands.compilemessages \
        import Command as CompileCommand
except ImportError:
    # note: during installation django may not be available
    CompileCommand = None

# this environment is requires
os.environ.setdefault(
    "DJANGO_SETTINGS_MODULE", "webxample.conf.settings"
)

class build_messages(Command):
    """ Custom command for building gettext messages in Django
    """
    description = """compile gettext messages"""
    user_options = []

    def initialize_options(self):
        pass

    def finalize_options(self):

        pass

    def run(self):
        if CompileCommand:
            CompileCommand().handle(
                verbosity=2, locales=[], exclude=[]
            )
        else:
            raise RuntimeError("could not build translations")

class build(_build):
    """ Overriden build command that adds additional build steps
    """
    sub_commands = [
        ('build_messages', None),
        ('build_sass', None),
    ] + _build.sub_commands

setup(
    name='webxample',
    setup_requires=[
        'libsass >= 0.6.0',
        'django >= 1.9.2',
    ],
    install_requires=[
        'django >= 1.9.2',
        'gunicorn == 19.4.5',
        'djangorestframework == 3.3.2',
        'django-allauth == 0.24.1',
    ],
    packages=find_packages('.'),
    sass_manifests={
        'webxample.myapp': ('static/sass', 'static/css')
    },
    cmdclass={
        'build_messages': build_messages,
        'build': build,
    },
    entry_points={
        'console_scripts': {
            'webxample = webxample.manage:main',
        }
    }
)

通过这种实现,我们可以使用这个单一的终端命令构建所有资产并为 webxample 项目创建源分发的软件包:

$ python setup.py build sdist

如果您已经拥有自己的软件包索引(使用 devpi 创建),则可以添加 install 子命令或使用 twine,这样该软件包将可以在您的组织中使用 pip 进行安装。如果我们查看使用我们的 setup.py 脚本创建的源分发结构,我们可以看到它包含了从 SCSS 文件生成的编译的 gettext 消息和 CSS 样式表:

$ tar -xvzf dist/webxample-0.0.0.tar.gz 2> /dev/null
$ tree webxample-0.0.0/ -I __pycache__ --dirsfirst
webxample-0.0.0/
├── webxample
│   ├── conf
│   │   ├── __init__.py
│   │   ├── settings.py
│   │   ├── urls.py
│   │   └── wsgi.py
│   ├── locale
│   │   ├── de
│   │   │   └── LC_MESSAGES
│   │   │       ├── django.mo
│   │   │       └── django.po
│   │   ├── en
│   │   │   └── LC_MESSAGES
│   │   │       ├── django.mo
│   │   │       └── django.po
│   │   └── pl
│   │       └── LC_MESSAGES
│   │           ├── django.mo
│   │           └── django.po
│   ├── myapp
│   │   ├── migrations
│   │   │   └── __init__.py
│   │   ├── static
│   │   │   ├── css
│   │   │   │   └── myapp.scss.css
│   │   │   └── js
│   │   │       └── myapp.js
│   │   ├── templates
│   │   │   ├── index.html
│   │   │   └── some_view.html
│   │   ├── __init__.py
│   │   ├── admin.py
│   │   ├── apps.py
│   │   ├── models.py
│   │   ├── tests.py
│   │   └── views.py
│   ├── __init__.py
│   └── manage.py
├── webxample.egg-info
│   ├── PKG-INFO
│   ├── SOURCES.txt
│   ├── dependency_links.txt
│   ├── requires.txt
│   └── top_level.txt
├── MANIFEST.in
├── PKG-INFO
├── README.md
├── setup.cfg
└── setup.py

16 directories, 33 files

使用这种方法的额外好处是,我们能够在 Django 的默认 manage.py 脚本的位置提供我们自己的项目入口点。现在我们可以使用这个入口点运行任何 Django 管理命令,例如:

$ webxample migrate
$ webxample collectstatic
$ webxample runserver

这需要在 manage.py 脚本中进行一些小的更改,以便与 setup() 中的 entry_points 参数兼容,因此它的主要部分的代码被包装在 main() 函数调用中:

#!/usr/bin/env python3
import os
import sys

def main():
    os.environ.setdefault(
        "DJANGO_SETTINGS_MODULE", "webxample.conf.settings"
    )

    from django.core.management import execute_from_command_line

    execute_from_command_line(sys.argv)

if __name__ == "__main__":
    main()

不幸的是,许多框架(包括 Django)并不是以打包项目的方式设计的。这意味着根据应用程序的进展,将其转换为包可能需要进行许多更改。在 Django 中,这通常意味着重写许多隐式导入并更新设置文件中的许多配置变量。

另一个问题是使用 Python 打包创建的发布的一致性。如果不同的团队成员被授权创建应用程序分发,那么在相同可复制的环境中进行此过程至关重要,特别是当您进行大量资产预处理时;即使从相同的代码库创建,可能在两个不同的环境中创建的软件包看起来也不一样。这可能是由于在构建过程中使用了不同版本的工具。最佳实践是将分发责任移交给持续集成/交付系统,如 Jenkins 或 Buildbot。额外的好处是您可以断言软件包在分发之前通过了所有必需的测试。您甚至可以将自动部署作为这种持续交付系统的一部分。

尽管如此,使用setuptools将您的代码分发为 Python 软件包并不简单和轻松;它将极大简化您的部署,因此绝对值得一试。请注意,这也符合十二要素应用程序的第六条详细建议:将应用程序执行为一个或多个无状态进程(12factor.net/processes)。

常见的惯例和做法

有一套部署的常见惯例和做法,不是每个开发人员都可能知道,但对于任何曾经进行过一些操作的人来说都是显而易见的。正如在章节介绍中所解释的那样,即使您不负责代码部署和操作,至少了解其中一些对于在开发过程中做出更好的设计决策是至关重要的。

文件系统层次结构

您可能会想到的最明显的惯例可能是关于文件系统层次结构和用户命名的。如果您在这里寻找建议,那么您会感到失望。当然有一个文件系统层次结构标准,它定义了 Unix 和类 Unix 操作系统中的目录结构和目录内容,但真的很难找到一个完全符合 FHS 的实际操作系统发行版。如果系统设计师和程序员不能遵守这样的标准,那么很难期望管理员也能做到。根据我的经验,我几乎在可能的任何地方看到应用程序代码部署,包括在根文件系统级别的非标准自定义目录。几乎总是,做出这样决定的人都有非常充分的理由。在这方面我能给你的唯一建议是:

  • 明智选择,避免惊喜

  • 在项目的所有可用基础设施中保持一致

  • 尽量在您的组织(您所在的公司)中保持一致

真正有帮助的是为您的项目记录惯例。只需确保这些文件对每个感兴趣的团队成员都是可访问的,并且每个人都知道这样的文件存在。

隔离

隔离的原因以及推荐的工具已经在第一章中讨论过,Python 的当前状态。对于部署,只有一件重要的事情要补充。您应该始终为应用程序的每个发布版本隔离项目依赖关系。在实践中,这意味着每当您部署应用程序的新版本时,您应该为此版本创建一个新的隔离环境(使用virtualenvvenv)。旧环境应该在您的主机上保留一段时间,以便在出现问题时可以轻松地回滚到旧版本之一。

为每个发布创建新的环境有助于管理其干净状态并符合提供的依赖项列表。通过新环境,我们指的是在文件系统中创建一个新的目录树,而不是更新已经存在的文件。不幸的是,这可能会使一些事情变得更加困难,比如优雅地重新加载服务,如果环境是就地更新的话,这将更容易实现。

使用进程监控工具

远程服务器上的应用程序通常不会意外退出。如果是 Web 应用程序,其 HTTP 服务器进程将无限期地等待新的连接和请求,并且只有在发生一些无法恢复的错误时才会退出。

当然,无法在 shell 中手动运行它并保持一个永久的 SSH 连接。使用nohupscreentmux来半守护化进程也不是一个选择。这样做就像是在设计您的服务注定要失败。

您需要的是一些进程监控工具,可以启动和管理您的应用程序进程。在选择合适的工具之前,您需要确保它:

  • 如果服务退出,则重新启动服务

  • 可靠地跟踪其状态

  • 捕获其stdout/stderr流以进行日志记录

  • 以特定用户/组权限运行进程

  • 配置系统环境变量

大多数 Unix 和 Linux 发行版都有一些内置的进程监控工具/子系统,比如initd脚本、upstartrunit。不幸的是,在大多数情况下,它们不适合运行用户级应用程序代码,并且非常难以维护。特别是编写可靠的init.d脚本是一个真正的挑战,因为它需要大量的 Bash 脚本编写,这很难做到正确。一些 Linux 发行版,比如 Gentoo,对init.d脚本有了重新设计的方法,因此编写它们变得更容易。无论如何,为了一个单一的进程监控工具而将自己锁定到特定的操作系统发行版并不是一个好主意。

Python 社区中管理应用程序进程的两种流行工具是 Supervisor (supervisord.org)和 Circus (circus.readthedocs.org/en/latest/)。它们在配置和使用上都非常相似。Circus 比 Supervisor 稍微年轻一些,因为它是为了解决后者的一些弱点而创建的。它们都可以使用简单的 INI 格式进行配置。它们不仅限于运行 Python 进程,还可以配置为管理任何应用程序。很难说哪一个更好,因为它们都提供非常相似的功能。

无论如何,Supervisor 不支持 Python 3,因此我们不会推荐它。虽然在 Supervisor 的控制下运行 Python 3 进程不是问题,但我将以此为借口,只展示 Circus 配置的示例。

假设我们想要在 Circus 控制下使用gunicorn web 服务器运行 webxample 应用程序(在本章前面介绍过)。在生产环境中,我们可能会在适用的系统级进程监控工具(initdupstartrunit)下运行 Circus,特别是如果它是从系统软件包存储库安装的。为了简单起见,我们将在虚拟环境内本地运行。允许我们在 Circus 中运行应用程序的最小配置文件(这里命名为circus.ini)如下所示:

[watcher:webxample]
cmd = /path/to/venv/dir/bin/gunicorn webxample.conf.wsgi:application
numprocesses = 1

现在,circus进程可以使用这个配置文件作为执行参数来运行:

$ circusd circus.ini
2016-02-15 08:34:34 circus[1776] [INFO] Starting master on pid 1776
2016-02-15 08:34:34 circus[1776] [INFO] Arbiter now waiting for commands
2016-02-15 08:34:34 circus[1776] [INFO] webxample started
[2016-02-15 08:34:34 +0100] [1778] [INFO] Starting gunicorn 19.4.5
[2016-02-15 08:34:34 +0100] [1778] [INFO] Listening at: http://127.0.0.1:8000 (1778)
[2016-02-15 08:34:34 +0100] [1778] [INFO] Using worker: sync
[2016-02-15 08:34:34 +0100] [1781] [INFO] Booting worker with pid: 1781

现在,您可以使用circusctl命令来运行一个交互式会话,并使用简单的命令来控制所有受管进程。以下是这样一个会话的示例:

$ circusctl
circusctl 0.13.0
webxample: active
(circusctl) stop webxample
ok
(circusctl) status
webxample: stopped
(circusctl) start webxample
ok
(circusctl) status
webxample: active

当然,上述两种工具都有更多功能可用。它们的所有功能都在它们的文档中有解释,因此在做出选择之前,您应该仔细阅读它们。

应用代码应该在用户空间中运行

您的应用程序代码应始终在用户空间中运行。这意味着它不得以超级用户权限执行。如果您按照 Twelve-Factor App 设计应用程序,可以在几乎没有特权的用户下运行应用程序。拥有文件并且不属于特权组的用户的传统名称是nobody,但实际建议是为每个应用程序守护进程创建一个单独的用户。原因是系统安全性。这是为了限制恶意用户在控制应用程序进程后可能造成的损害。在 Linux 中,同一用户的进程可以相互交互,因此在用户级别上将不同的应用程序分开是很重要的。

使用反向 HTTP 代理

多个 Python 符合 WSGI 标准的 Web 服务器可以轻松地自行提供 HTTP 流量,无需在其上方使用任何其他 Web 服务器。然而,通常还是很常见将它们隐藏在 Nginx 等反向代理后面,原因有很多:

  • TLS/SSL 终止通常最好由顶级 Web 服务器(如 Nginx 和 Apache)处理。然后,Python 应用程序只能使用简单的 HTTP 协议(而不是 HTTPS),因此安全通信通道的复杂性和配置留给了反向代理。

  • 非特权用户无法绑定低端口(0-1000 范围内),但 HTTP 协议应该在端口 80 上为用户提供服务,HTTPS 应该在端口 443 上提供服务。为此,必须以超级用户权限运行进程。通常,更安全的做法是让应用程序在高端口上提供服务,或者在 Unix 域套接字上提供服务,并将其用作在更特权用户下运行的反向代理的上游。

  • 通常,Nginx 可以比 Python 代码更有效地提供静态资产(图像、JS、CSS 和其他媒体)。如果将其配置为反向代理,那么只需几行配置就可以通过它提供静态文件。

  • 当单个主机需要从不同域中的多个应用程序提供服务时,Apache 或 Nginx 是不可或缺的,用于为在同一端口上提供服务的不同域创建虚拟主机。

  • 反向代理可以通过添加额外的缓存层来提高性能,也可以配置为简单的负载均衡器。

一些 Web 服务器实际上建议在代理后运行,例如 Nginx。例如,gunicorn是一个非常强大的基于 WSGI 的服务器,如果其客户端速度很快,可以提供出色的性能结果。另一方面,它不能很好地处理慢速客户端,因此很容易受到基于慢速客户端连接的拒绝服务攻击的影响。使用能够缓冲慢速客户端的代理服务器是解决这个问题的最佳方法。

优雅地重新加载进程

Twelve-Factor App 方法论的第九条规则涉及进程的可处置性,并指出您应该通过快速启动时间和优雅的关闭来最大程度地提高鲁棒性。虽然快速启动时间相当不言自明,但优雅的关闭需要一些额外的讨论。

在 Web 应用程序范围内,如果以非优雅的方式终止服务器进程,它将立即退出,没有时间完成处理请求并向连接的客户端回复适当的响应。在最佳情况下,如果使用某种反向代理,那么代理可能会向连接的客户端回复一些通用的错误响应(例如 502 Bad Gateway),即使这并不是通知用户您已重新启动应用程序并部署新版本的正确方式。

根据 Twelve-Factor App,Web 服务器进程应能够在接收到 Unix SIGTERM信号(例如kill -TERM <process-id>)时优雅地退出。这意味着服务器应停止接受新连接,完成处理所有挂起的请求,然后在没有其他事情可做时以某种退出代码退出。

显然,当所有服务进程退出或开始其关闭过程时,您将无法再处理新请求。这意味着您的服务仍然会经历停机,因此您需要执行额外的步骤-启动新的工作进程,这些工作进程将能够在旧的工作进程优雅退出时接受新的连接。各种 Python WSGI 兼容的 Web 服务器实现允许在没有任何停机时间的情况下优雅地重新加载服务。最流行的是 Gunicorn 和 uWSGI:

  • Gunicorn 的主进程在接收到SIGHUP信号(kill -HUP <process-pid>)后,将启动新的工作进程(带有新的代码和配置),并尝试在旧的工作进程上进行优雅的关闭。

  • uWSGI 至少有三种独立的方案来进行优雅的重新加载。每一种都太复杂,无法简要解释,但它的官方文档提供了所有可能选项的完整信息。

优雅的重新加载在部署 Web 应用程序中已经成为标准。Gunicorn 似乎有一种最容易使用但也给您留下最少灵活性的方法。另一方面,uWSGI 中的优雅重新加载允许更好地控制重新加载,但需要更多的努力来自动化和设置。此外,您如何处理自动部署中的优雅重新加载也受到您使用的监视工具以及其配置方式的影响。例如,在 Gunicorn 中,优雅的重新加载就像这样简单:

kill -HUP <gunicorn-master-process-pid>

但是,如果您想通过为每个发布分离虚拟环境并使用符号链接配置进程监视来正确隔离项目分发(如之前在fabfile示例中提出的),您很快会注意到这并不像预期的那样工作。对于更复杂的部署,目前还没有可用的解决方案可以直接为您解决问题。您总是需要进行一些黑客攻击,有时这将需要对低级系统实现细节有相当高的了解。

代码仪器和监控

我们的工作并不仅仅是编写应用程序并将其部署到目标执行环境。可能编写一个应用程序后,部署后将不需要任何进一步的维护,尽管这是非常不太可能的。实际上,我们需要确保它被正确地观察以发现错误和性能问题。

为了确保我们的产品按预期工作,我们需要正确处理应用程序日志并监视必要的应用程序指标。这通常包括:

  • 监控 Web 应用程序访问日志以获取各种 HTTP 状态代码

  • 可能包含有关运行时错误和各种警告的进程日志的收集

  • 监控远程主机上的系统资源(CPU 负载、内存和网络流量),应用程序运行的地方

  • 监控业务绩效和指标的应用级性能(客户获取、收入等)

幸运的是,有很多免费的工具可用于仪器化您的代码并监视其性能。其中大多数都很容易集成。

记录错误-哨兵/乌鸦

无论您的应用程序经过多么精确的测试,事实是痛苦的。您的代码最终会在某个时候失败。这可能是任何事情-意外的异常、资源耗尽、某些后台服务崩溃、网络中断,或者只是外部库中的问题。一些可能的问题,如资源耗尽,可以通过适当的监控来预测和防止,但无论您如何努力,总会有一些事情会越过您的防线。

您可以做的是为这种情况做好充分准备,并确保没有错误被忽视。在大多数情况下,应用程序引发的任何意外故障场景都会导致异常,并通过日志系统记录。这可以是stdoutsderr、“文件”或您为日志记录配置的任何输出。根据您的实现,这可能会导致应用程序退出并带有一些系统退出代码,也可能不会。

当然,您可以仅依赖于存储在文件中的这些日志来查找和监视应用程序错误。不幸的是,观察文本日志中的错误非常痛苦,并且在除了在开发中运行代码之外的任何更复杂的情况下都无法很好地扩展。您最终将被迫使用一些专为日志收集和分析而设计的服务。适当的日志处理对于稍后将要解释的其他原因非常重要,但对于跟踪和调试生产错误并不起作用。原因很简单。错误日志的最常见形式只是 Python 堆栈跟踪。如果您仅停留在那里,您很快就会意识到这不足以找到问题的根本原因-特别是当错误以未知模式或在某些负载条件下发生时。

您真正需要的是尽可能多的关于错误发生的上下文信息。拥有在生产环境中发生的错误的完整历史记录,并且可以以某种便捷的方式浏览和搜索,也非常有用。提供这种功能的最常见工具之一是 Sentry(getsentry.com)。它是一个经过实战考验的用于跟踪异常和收集崩溃报告的服务。它作为开源软件提供,是用 Python 编写的,并起源于用于后端 Web 开发人员的工具。现在它已经超出了最初的野心,并支持了许多其他语言,包括 PHP、Ruby 和 JavaScript,但仍然是大多数 Python Web 开发人员的首选工具。

提示

Web 应用程序中的异常堆栈跟踪

通常,Web 应用程序不会在未处理的异常上退出,因为 HTTP 服务器有义务在发生任何服务器错误时返回一个 5XX 组的状态代码的错误响应。大多数 Python Web 框架默认情况下都会这样做。在这种情况下,实际上是在较低的框架级别处理异常。无论如何,在大多数情况下,这仍将导致异常堆栈跟踪被打印(通常在标准输出上)。

Sentry 以付费软件即服务模式提供,但它是开源的,因此可以免费托管在您自己的基础设施上。提供与 Sentry 集成的库是raven(可在 PyPI 上获得)。如果您尚未使用过它,想要测试它但无法访问自己的 Sentry 服务器,那么您可以轻松在 Sentry 的本地服务站点上注册免费试用。一旦您可以访问 Sentry 服务器并创建了一个新项目,您将获得一个称为 DSN 或数据源名称的字符串。这个 DSN 字符串是集成应用程序与 sentry 所需的最小配置设置。它以以下形式包含协议、凭据、服务器位置和您的组织/项目标识符:

'{PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}/{PATH}{PROJECT_ID}'

一旦您获得了 DSN,集成就非常简单:

from raven import Client

client = Client('https://<key>:<secret>@app.getsentry.com/<project>')

try:
    1 / 0
except ZeroDivisionError:
    client.captureException()

Raven 与最流行的 Python 框架(如 Django,Flask,Celery 和 Pyramid)有许多集成,以使集成更容易。这些集成将自动提供特定于给定框架的附加上下文。如果您选择的 Web 框架没有专门的支持,raven软件包提供了通用的 WSGI 中间件,使其与任何基于 WSGI 的 Web 服务器兼容:

from raven import Client
from raven.middleware import Sentry

# note: application is some WSGI application object defined earlier
application = Sentry(
    application,
    Client('https://<key>:<secret>@app.getsentry.com/<project>')
)

另一个值得注意的集成是跟踪通过 Python 内置的logging模块记录的消息的能力。启用此类支持仅需要几行额外的代码:

from raven.handlers.logging import SentryHandler
from raven.conf import setup_logging

client = Client('https://<key>:<secret>@app.getsentry.com/<project>')
handler = SentryHandler(client)
setup_logging(handler)

捕获logging消息可能会有一些不明显的注意事项,因此,如果您对此功能感兴趣,请确保阅读官方文档。这应该可以避免令人不快的惊喜。

最后一点是关于运行自己的 Sentry 以节省一些钱的方法。 "没有免费的午餐。"最终,您将支付额外的基础设施成本,而 Sentry 将只是另一个需要维护的服务。维护=额外工作=成本!随着您的应用程序增长,异常的数量也会增长,因此您将被迫在扩展产品的同时扩展 Sentry。幸运的是,这是一个非常强大的项目,但如果负载过重,它将无法为您提供任何价值。此外,保持 Sentry 准备好应对灾难性故障场景,其中可能会发送数千个崩溃报告,是一个真正的挑战。因此,您必须决定哪个选项对您来说真正更便宜,以及您是否有足够的资源和智慧来自己完成所有这些。当然,如果您的组织的安全政策禁止向第三方发送任何数据,那么就在自己的基础设施上托管它。当然会有成本,但这绝对是值得支付的成本。

监控系统和应用程序指标

在监控性能方面,可供选择的工具数量可能令人不知所措。如果您期望很高,那么可能需要同时使用其中的几个。

Munin(munin-monitoring.org)是许多组织使用的热门选择之一,无论它们使用什么技术栈。它是一个很好的工具,用于分析资源趋势,并且即使在默认安装时也提供了许多有用的信息,而无需额外配置。它的安装包括两个主要组件:

  • Munin 主机从其他节点收集指标并提供指标图

  • Munin 节点安装在受监视的主机上,它收集本地指标并将其发送到 Munin 主机

主机、节点和大多数插件都是用 Perl 编写的。还有其他语言的节点实现:munin-node-c是用 C 编写的(github.com/munin-monitoring/munin-c),munin-node-python是用 Python 编写的(github.com/agroszer/munin-node-python)。Munin 附带了大量插件,可在其contrib存储库中使用。这意味着它提供了对大多数流行的数据库和系统服务的开箱即用支持。甚至还有用于监视流行的 Python Web 服务器(如 uWSGI 和 Gunicorn)的插件。

Munin 的主要缺点是它将图形呈现为静态图像,并且实际的绘图配置包含在特定插件配置中。这并不利于创建灵活的监控仪表板,并在同一图表中比较来自不同来源的度量值。但这是我们为简单安装和多功能性所付出的代价。编写自己的插件非常简单。有一个munin-python包(python-munin.readthedocs.org/en/latest/),它可以帮助用 Python 编写 Munin 插件。

很遗憾,Munin 的架构假设每个主机上都有一个单独的监控守护进程负责收集指标,这可能不是监控自定义应用程序性能指标的最佳解决方案。编写自己的 Munin 插件确实非常容易,但前提是监控进程已经以某种方式报告其性能统计数据。如果您想收集一些自定义应用程序级别的指标,可能需要将它们聚合并存储在某些临时存储中,直到报告给自定义的 Munin 插件。这使得创建自定义指标变得更加复杂,因此您可能需要考虑其他解决方案。

另一个特别容易收集自定义指标的流行解决方案是 StatsD(github.com/etsy/statsd)。它是一个用 Node.js 编写的网络守护程序,监听各种统计数据,如计数器、计时器和量规。由于基于 UDP 的简单协议,它非常容易集成。还可以使用名为statsd的 Python 包将指标发送到 StatsD 守护程序:

>>> import statsd
>>> c = statsd.StatsClient('localhost', 8125)
>>> c.incr('foo')  # Increment the 'foo' counter.
>>> c.timing('stats.timed', 320)  # Record a 320ms 'stats.timed'.

由于 UDP 是无连接的,它对应用程序代码的性能开销非常低,因此非常适合跟踪和测量应用程序代码内的自定义事件。

不幸的是,StatsD 是唯一的指标收集守护程序,因此它不提供任何报告功能。您需要其他进程能够处理来自 StatsD 的数据,以查看实际的指标图。最受欢迎的选择是 Graphite(graphite.readthedocs.org)。它主要做两件事:

  • 存储数字时间序列数据

  • 根据需要呈现此数据的图形

Graphite 提供了保存高度可定制的图形预设的功能。您还可以将许多图形分组到主题仪表板中。与 Munin 类似,图形呈现为静态图像,但还有 JSON API 允许其他前端读取图形数据并以其他方式呈现。与 Graphite 集成的一个很棒的仪表板插件是 Grafana(grafana.org)。它真的值得一试,因为它比普通的 Graphite 仪表板具有更好的可用性。Grafana 提供的图形是完全交互式的,更容易管理。

不幸的是,Graphite 是一个有点复杂的项目。它不是一个单一的服务,而是由三个独立的组件组成:

  • Carbon:这是一个使用 Twisted 编写的守护程序,用于监听时间序列数据

  • whisper:这是一个简单的数据库库,用于存储时间序列数据

  • graphite webapp:这是一个 Django Web 应用程序,根据需要呈现静态图像(使用 Cairo 库)或 JSON 数据

当与 StatsD 项目一起使用时,statsd守护程序将其数据发送到carbon守护程序。这使得整个解决方案成为一个相当复杂的各种应用程序堆栈,每个应用程序都是使用完全不同的技术编写的。此外,没有预配置的图形、插件和仪表板可用,因此您需要自己配置所有内容。这在开始时需要很多工作,很容易忽略一些重要的东西。这就是为什么即使决定将 Graphite 作为核心监控服务,使用 Munin 作为监控备份也可能是一个好主意。

处理应用程序日志

虽然像 Sentry 这样的解决方案通常比存储在文件中的普通文本输出更强大,但日志永远不会消失。向标准输出或文件写入一些信息是应用程序可以做的最简单的事情之一,这绝对不应被低估。有可能 raven 发送到 Sentry 的消息不会被传递。网络可能会失败。Sentry 的存储可能会耗尽,或者可能无法处理传入的负载。在任何消息被发送之前,您的应用程序可能会崩溃(例如,出现分段错误)。这只是可能的情况之一。不太可能的是您的应用程序无法记录将要写入文件系统的消息。这仍然是可能的,但让我们诚实一点。如果您面临日志记录失败的情况,可能您有更多紧迫的问题,而不仅仅是一些丢失的日志消息。

记住,日志不仅仅是关于错误。许多开发人员过去认为日志只是在调试问题时有用的数据来源,或者可以用来进行某种取证。肯定有更少的人尝试将其用作生成应用程序指标的来源或进行一些统计分析。但是日志可能比这更有用。它们甚至可以成为产品实现的核心。一个很好的例子是亚马逊的一篇文章,介绍了一个实时竞价服务的示例架构,其中一切都围绕访问日志收集和处理。请参阅aws.amazon.com/blogs/aws/real-time-ad-impression-bids-using-dynamodb/

基本的低级日志实践

十二要素应用程序表示日志应被视为事件流。因此,日志文件本身并不是日志,而只是一种输出格式。它们是流的事实意味着它们代表按时间顺序排列的事件。在原始状态下,它们通常以文本格式呈现,每个事件一行,尽管在某些情况下它们可能跨越多行。这对于与运行时错误相关的任何回溯都是典型的。

根据十二要素应用程序方法论,应用程序不应知道日志存储的格式。这意味着写入文件,或者日志轮换和保留不应由应用程序代码维护。这些是应用程序运行的环境的责任。这可能令人困惑,因为许多框架提供了用于管理日志文件以及轮换、压缩和保留实用程序的函数和类。诱人的是使用它们,因为一切都可以包含在应用程序代码库中,但实际上这是一个应该真正避免的反模式。

处理日志的最佳约定可以归结为几条规则:

  • 应用程序应始终将日志无缓冲地写入标准输出(stdout

  • 执行环境应负责将日志收集和路由到最终目的地

所提到的执行环境的主要部分通常是某种进程监控工具。流行的 Python 解决方案,如 Supervisor 或 Circus,是处理日志收集和路由的第一责任方。如果日志要存储在本地文件系统中,那么只有它们应该写入实际的日志文件。

Supervisor 和 Circus 也能够处理受管进程的日志轮换和保留,但您确实应该考虑是否要走这条路。成功的操作大多是关于简单性和一致性。您自己应用程序的日志可能不是您想要处理和存档的唯一日志。如果您使用 Apache 或 Nginx 作为反向代理,您可能希望收集它们的访问日志。您可能还希望存储和处理缓存和数据库的日志。如果您正在运行一些流行的 Linux 发行版,那么每个这些服务都有它们自己的日志文件被名为logrotate的流行实用程序处理(轮换、压缩等)。我强烈建议您忘记 Supervisor 和 Circus 的日志轮换能力,以便与其他系统服务保持一致。logrotate更加可配置,还支持压缩。

提示

logrotate 和 Supervisor/Circus

在使用logrotate与 Supervisor 或 Circus 时,有一件重要的事情需要知道。日志的轮换将始终发生在 Supervisor 仍然具有对已轮换日志的打开描述符时。如果您不采取适当的对策,那么新事件仍将被写入已被logrotate删除的文件描述符。结果,文件系统中将不再存储任何内容。解决这个问题的方法非常简单。使用copytruncate选项为 Supervisor 或 Circus 管理的进程的日志文件配置logrotate。在旋转后,它将复制日志文件并在原地将原始文件截断为零大小。这种方法不会使任何现有的文件描述符无效,已经运行的进程可以不间断地写入日志文件。Supervisor 还可以接受SIGUSR2信号,这将使其重新打开所有文件描述符。它可以作为logrotate配置中的postrotate脚本包含在内。这种第二种方法在 I/O 操作方面更经济,但也更不可靠,更难维护。

日志处理工具

如果您没有处理大量日志的经验,那么当使用具有实质负载的产品时,您最终会获得这种经验。您很快会注意到,基于将它们存储在文件中并在某些持久存储中备份的简单方法是不够的。没有适当的工具,这将变得粗糙和昂贵。像logrotate这样的简单实用程序只能确保硬盘不会被不断增加的新事件所溢出,但是拆分和压缩日志文件只有在数据归档过程中才有帮助,但并不会使数据检索或分析变得更简单。

在处理跨多个节点的分布式系统时,很好地拥有一个单一的中心点,从中可以检索和分析所有日志。这需要一个远远超出简单压缩和备份的日志处理流程。幸运的是,这是一个众所周知的问题,因此有许多可用的工具旨在解决它。

许多开发人员中的一个受欢迎的选择是Logstash。这是一个日志收集守护程序,可以观察活动日志文件,解析日志条目并以结构化形式将它们发送到后端服务。后端的选择几乎总是相同的——Elasticsearch。Elasticsearch 是建立在 Lucene 之上的搜索引擎。除了文本搜索功能外,它还具有一个独特的数据聚合框架,非常适合用于日志分析的目的。

这对工具的另一个补充是Kibana。它是一个非常多才多艺的监控、分析和可视化平台,适用于 Elasticsearch。这三种工具如何相互补充的方式,是它们几乎总是作为单一堆栈一起用于日志处理的原因。

现有服务与 Logstash 的集成非常简单,因为它可以监听现有日志文件的更改,以便通过最小的日志配置更改获取新事件。它以文本形式解析日志,并且预先配置了对一些流行日志格式(如 Apache/Nginx 访问日志)的支持。Logstash 唯一的问题是它不能很好地处理日志轮换,这有点令人惊讶。通过发送已定义的 Unix 信号(通常是SIGHUPSIGUSR1)来强制进程重新打开其文件描述符是一个非常成熟的模式。似乎每个处理日志的应用程序都应该知道这一点,并且能够处理各种日志文件轮换场景。遗憾的是,Logstash 不是其中之一,因此如果您想使用logrotate实用程序管理日志保留,请记住要大量依赖其copytruncate选项。Logstash 进程无法处理原始日志文件被移动或删除的情况,因此在没有copytruncate选项的情况下,它将无法在日志轮换后接收新事件。当然,Logstash 可以处理不同的日志流输入,例如 UDP 数据包、TCP 连接或 HTTP 请求。

另一个似乎填补了一些 Logstash 空白的解决方案是 Fluentd。它是一种替代的日志收集守护程序,可以与 Logstash 在提到的日志监控堆栈中互换使用。它还有一个选项,可以直接监听和解析日志事件,所以最小的集成只需要一点点努力。与 Logstash 相比,它处理重新加载非常出色,甚至在日志文件轮换时也不需要信号。无论如何,最大的优势来自于使用其替代的日志收集选项,这将需要对应用程序中的日志配置进行一些重大更改。

Fluentd 真的将日志视为事件流(正如《十二要素应用程序》所推荐的)。基于文件的集成仍然是可能的,但它只是对将日志主要视为文件的传统应用程序的向后兼容性。每个日志条目都是一个事件,应该是结构化的。Fluentd 可以解析文本日志,并具有多个插件选项来处理:

  • 常见格式(Apache、Nginx 和 syslog)

  • 使用正则表达式指定的任意格式,或者使用自定义解析插件处理

  • 结构化消息的通用格式,例如 JSON

Fluentd 的最佳事件格式是 JSON,因为它增加的开销最少。 JSON 中的消息也可以几乎不经过任何更改地传递到 Elasticsearch 或数据库等后端服务。

Fluentd 的另一个非常有用的功能是能够使用除了写入磁盘的日志文件之外的其他传输方式传递事件流。最值得注意的内置输入插件有:

  • in_udp:使用此插件,每个日志事件都作为 UDP 数据包发送

  • in_tcp:使用此插件,事件通过 TCP 连接发送

  • in_unix:使用此插件,事件通过 Unix 域套接字(命名套接字)发送

  • in_http:使用此插件,事件作为 HTTP POST 请求发送

  • in_exec:使用此插件,Fluentd 进程会定期执行外部命令,以 JSON 或 MessagePack 格式获取事件

  • in_tail:使用此插件,Fluentd 进程会监听文本文件中的事件

对于日志事件的替代传输可能在需要处理机器存储的 I/O 性能较差的情况下特别有用。在云计算服务中,通常默认磁盘存储的 IOPS(每秒输入/输出操作次数)非常低,您需要花费大量资金以获得更好的磁盘性能。如果您的应用程序输出大量日志消息,即使数据量不是很大,也可能轻松饱和您的 I/O 能力。通过替代传输,您可以更有效地使用硬件,因为您只需将数据缓冲的责任留给单个进程——日志收集器。当配置为在内存中缓冲消息而不是磁盘时,甚至可以完全摆脱日志的磁盘写入,尽管这可能会大大降低收集日志的一致性保证。

使用不同的传输方式似乎略微违反了十二要素应用程序方法的第 11 条规则。详细解释时,将日志视为事件流表明应用程序应始终仅通过单个标准输出流(stdout)记录日志。仍然可以在不违反此规则的情况下使用替代传输方式。写入stdout并不一定意味着必须将此流写入文件。您可以保留应用程序以这种方式记录日志,并使用外部进程将其捕获并直接传递给 Logstash 或 Fluentd,而无需涉及文件系统。这是一种高级模式,可能并不适用于每个项目。它具有更高复杂性的明显缺点,因此您需要自行考虑是否真的值得这样做。

总结

代码部署并不是一个简单的话题,阅读本章后您应该已经知道这一点。对这个问题的广泛讨论很容易占据几本书。即使我们的范围仅限于 Web 应用程序,我们也只是触及了表面。本章以十二要素应用程序方法为基础。我们只详细讨论了其中的一些内容:日志处理、管理依赖关系和分离构建/运行阶段。

阅读本章后,您应该知道如何正确自动化部署过程,考虑最佳实践,并能够为在远程主机上运行的代码添加适当的仪器和监视。

第七章:其他语言中的 Python 扩展

在编写基于 Python 的应用程序时,您不仅限于 Python 语言。还有一些工具,比如 Hy,在第三章中简要提到,语法最佳实践-类级别以上。它允许您使用其他语言(Lisp 的方言)编写模块、包,甚至整个应用程序,这些应用程序将在 Python 虚拟机中运行。尽管它使您能够用完全不同的语法表达程序逻辑,但它仍然是相同的语言,因为它编译成相同的字节码。这意味着它具有与普通 Python 代码相同的限制:

  • 由于 GIL 的存在,线程的可用性大大降低

  • 它没有被编译

  • 它不提供静态类型和可能的优化

帮助克服这些核心限制的解决方案是完全用不同的语言编写的扩展,并通过 Python 扩展 API 公开它们的接口。

本章将讨论使用其他语言编写自己的扩展的主要原因,并向您介绍帮助创建它们的流行工具。您将学到:

  • 如何使用 Python/C API 编写简单的 C 扩展

  • 如何使用 Cython 做同样的事情

  • 扩展引入的主要挑战和问题是什么

  • 如何与编译的动态库进行接口,而不创建专用扩展,仅使用 Python 代码

不同的语言意味着-C 或 C++

当我们谈论不同语言的扩展时,我们几乎只考虑 C 和 C++。甚至像 Cython 或 Pyrex 这样的工具,它们提供 Python 语言的超集,仅用于扩展的目的,实际上是源到源编译器,从扩展的 Python-like 语法生成 C 代码。

如果只有这样的编译是可能的,那么确实可以在 Python 中使用任何语言编写的动态/共享库,因此它远远超出了 C 和 C++。但共享库本质上是通用的。它们可以在支持它们加载的任何语言中使用。因此,即使您用完全不同的语言(比如 Delphi 或 Prolog)编写这样的库,很难称这样的库为 Python 扩展,如果它不使用 Python/C API。

不幸的是,仅使用裸的 Python/C API 在 C 或 C++中编写自己的扩展是相当苛刻的。这不仅因为它需要对这两种相对难以掌握的语言之一有很好的理解,而且还因为它需要大量的样板文件。有很多重复的代码必须编写,只是为了提供一个接口,将您实现的逻辑与 Python 及其数据类型粘合在一起。无论如何,了解纯 C 扩展是如何构建的是很好的,因为:

  • 您将更好地了解 Python 的工作原理

  • 有一天,您可能需要调试或维护本机 C/C++扩展

  • 它有助于理解构建扩展的高级工具的工作原理

C 或 C++中的扩展是如何工作的

如果 Python 解释器能够使用 Python/C API 提供适当的接口,它就能从动态/共享库中加载扩展。这个 API 必须被合并到扩展的源代码中,使用与 Python 源代码一起分发的Python.h C 头文件。在许多 Linux 发行版中,这个头文件包含在一个单独的软件包中(例如,在 Debian/Ubuntu 中是python-dev),但在 Windows 下,默认情况下分发,并且可以在 Python 安装的includes/目录中找到。

Python/C API 通常会随着 Python 的每个版本发布而改变。在大多数情况下,这些只是对 API 的新功能的添加,因此通常是源代码兼容的。无论如何,在大多数情况下,它们不是二进制兼容的,因为应用程序二进制接口ABI)发生了变化。这意味着扩展必须为每个 Python 版本单独构建。还要注意,不同的操作系统具有不兼容的 ABI,因此这几乎不可能为每种可能的环境创建二进制分发。这就是为什么大多数 Python 扩展以源代码形式分发的原因。

自 Python 3.2 以来,已经定义了 Python/C API 的一个子集,具有稳定的 ABI。因此可以使用这个有限的 API(具有稳定的 ABI)构建扩展,因此扩展只需构建一次,就可以在任何高于或等于 3.2 的 Python 版本上工作,无需重新编译。无论如何,这限制了 API 功能的数量,并且不能解决旧版本 Python 或以二进制形式分发扩展到使用不同操作系统的环境的问题。因此这是一个权衡,稳定 ABI 的代价似乎有点高而收益很低。

你需要知道的一件事是,Python/C API 是限于 CPython 实现的功能。一些努力已经为 PyPI、Jython 或 IronPython 等替代实现带来了扩展支持,但目前似乎没有可行的解决方案。唯一一个应该轻松处理扩展的替代 Python 实现是 Stackless Python,因为它实际上只是 CPython 的修改版本。

Python 的 C 扩展需要在可用之前编译成共享/动态库,因为显然没有本地的方法可以直接从源代码将 C/C++代码导入 Python。幸运的是,distutilssetuptools提供了帮助,将编译的扩展定义为模块,因此可以使用setup.py脚本处理编译和分发,就像它们是普通的 Python 包一样。这是官方文档中处理带有构建扩展的简单包的setup.py脚本的一个示例:

from distutils.core import setup, Extension

module1 = Extension(
    'demo',
    sources=['demo.c']
)

setup(
    name='PackageName',
    version='1.0',
    description='This is a demo package',
    ext_modules=[module1]
)

准备好之后,你的分发流程还需要一个额外的步骤:

python setup.py build

这将根据ext_modules参数编译所有你的扩展,根据Extension()调用提供的所有额外编译器设置。将使用的编译器是你的环境的默认编译器。如果要分发源代码分发包,则不需要进行这个编译步骤。在这种情况下,你需要确保目标环境具有所有编译的先决条件,例如编译器、头文件和将链接到二进制文件的其他库(如果你的扩展需要)。有关打包 Python 扩展的更多细节将在挑战部分中解释。

为什么你可能想使用扩展

写 C/C++扩展是否明智的决定并不容易。一般的经验法则可能是,“除非别无选择,否则永远不要”。但这是一个非常主观的说法,留下了很多解释空间,关于在 Python 中做不到的事情。事实上,很难找到一件事情,纯 Python 代码做不到,但有一些问题,扩展可能特别有用:

  • 绕过 Python 线程模型中的全局解释器锁GIL

  • 改进关键代码部分的性能

  • 集成第三方动态库

  • 集成用不同语言编写的源代码

  • 创建自定义数据类型

例如,核心语言约束,如 GIL,可以通过不同的并发方法轻松克服,例如绿色线程或多进程,而不是线程模型。

改进关键代码部分的性能

让我们诚实一点。开发人员选择 Python 并不是因为性能。它执行速度不快,但可以让你快速开发。尽管我们作为程序员有多么高效,多亏了这种语言,有时我们可能会发现一些问题,这些问题可能无法使用纯 Python 有效解决。

在大多数情况下,解决性能问题实际上只是选择合适的算法和数据结构,而不是限制语言开销的常数因子。如果代码已经编写得很差或者没有使用适当的算法,依赖扩展来节省一些 CPU 周期实际上并不是一个好的解决方案。通常情况下,性能可以在不需要通过在堆栈中循环另一种语言来增加项目复杂性的情况下提高到可接受的水平。如果可能的话,应该首先这样做。无论如何,即使使用最先进的算法方法和最适合的数据结构,我们也很可能无法仅仅使用 Python 就满足一些任意的技术约束。

将一些对应用程序性能施加了明确定义限制的示例领域是实时竞价RTB)业务。简而言之,整个 RTB 都是关于以类似于真实拍卖或证券交易的方式购买和销售广告库存(广告位置)。交易通常通过一些广告交换服务进行,该服务向有兴趣购买它们的需求方平台DSP)发送有关可用库存的信息。这就是事情变得令人兴奋的地方。大多数广告交换使用基于 HTTP 的 OpenRTB 协议与潜在竞标者进行通信,其中 DSP 是负责对其 HTTP 请求提供响应的站点。广告交换总是对整个过程施加非常有限的时间限制(通常在 50 到 100 毫秒之间)——从接收到第一个 TPC 数据包到服务器写入的最后一个字节。为了增加趣味,DSP 平台通常每秒处理成千上万个请求并不罕见。能够将请求处理时间推迟几毫秒甚至是这个行业的生死攸关。这意味着即使是将微不足道的代码移植到 C 语言在这种情况下也是合理的,但前提是它是性能瓶颈的一部分,并且在算法上不能进一步改进。正如有人曾经说过的:

“你无法击败用 C 语言编写的循环。”

整合不同语言编写的现有代码

在计算机科学的短暂历史中,已经编写了许多有用的库。每次出现新的编程语言时忘记所有这些遗产将是一个巨大的损失,但也不可能可靠地将曾经编写的任何软件完全移植到任何可用的语言。

C 和 C++语言似乎是提供了许多库和实现的最重要的语言,你可能希望在应用程序代码中集成它们,而无需完全将它们移植到 Python。幸运的是,CPython 已经是用 C 编写的,因此通过自定义扩展是集成这样的代码的最自然的方式。

集成第三方动态库

使用不同技术编写的代码的集成并不仅限于 C/C++。许多库,特别是具有闭源的第三方软件,都是以编译后的二进制形式分发的。在 C 中,加载这样的共享/动态库并调用它们的函数非常容易。这意味着只要使用 Python/C API 包装它,就可以使用任何 C 库。

当然,这并不是唯一的解决方案,还有诸如ctypes或 CFFI 之类的工具,允许您使用纯 Python 与动态库进行交互,而无需编写 C 扩展。通常情况下,Python/C API 可能仍然是更好的选择,因为它在集成层(用 C 编写)和应用程序的其余部分之间提供了更好的分离。

创建自定义数据类型

Python 提供了非常多样化的内置数据类型。其中一些真正使用了最先进的内部实现(至少在 CPython 中),专门为在 Python 语言中使用而量身定制。基本类型和可用的集合数量对于新手来说可能看起来令人印象深刻,但显然它并不能涵盖我们所有可能的需求。

当然,您可以通过完全基于一些内置类型或从头开始构建全新类来在 Python 中创建许多自定义数据结构。不幸的是,对于一些可能严重依赖这些自定义数据结构的应用程序来说,性能可能不够。像dictset这样的复杂集合的全部功能来自它们的底层 C 实现。为什么不做同样的事情,也在 C 中实现一些自定义数据结构呢?

编写扩展

如前所述,编写扩展并不是一项简单的任务,但作为您辛勤工作的回报,它可以给您带来许多优势。编写自己扩展的最简单和推荐的方法是使用诸如 Cython 或 Pyrex 的工具,或者简单地使用ctypescffi集成现有的动态库。这些项目将提高您的生产力,还会使代码更易于开发、阅读和维护。

无论如何,如果您对这个主题还不熟悉,了解一点是好的,即您可以通过仅使用裸 C 代码和 Python/C API 编写一个扩展来开始您的扩展之旅。这将提高您对扩展工作原理的理解,并帮助您欣赏替代解决方案的优势。为了简单起见,我们将以一个简单的算法问题作为示例,并尝试使用三种不同的方法来实现它:

  • 编写纯 C 扩展

  • 使用 Cython

  • 使用 Pyrex

我们的问题将是找到斐波那契数列的第n个数字。很少有人会仅为了这个问题创建编译扩展,但它非常简单,因此它将作为将任何 C 函数连接到 Python/C API 的非常好的示例。我们的唯一目标是清晰和简单,因此我们不会试图提供最有效的解决方案。一旦我们知道这一点,我们在 Python 中实现的斐波那契函数的参考实现如下:

"""Python module that provides fibonacci sequence function"""

def fibonacci(n):
    """Return nth Fibonacci sequence number computed recursively.
    """
    if n < 2:
        return 1
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

请注意,这是fibonnaci()函数的最简单实现之一,可以对其进行许多改进。尽管如此,我们拒绝改进我们的实现(例如使用记忆化模式),因为这不是我们示例的目的。同样地,即使编译后的代码提供了更多的优化可能性,我们在讨论 C 或 Cython 中的实现时也不会优化我们的代码。

纯 C 扩展

在我们完全深入 C 编写的 Python 扩展的代码示例之前,这里有一个重要的警告。如果您想用 C 扩展 Python,您需要已经对这两种语言非常了解。这对于 C 尤其如此。对它的熟练程度不足可能会导致真正的灾难,因为它很容易被误用。

如果您已经决定需要为 Python 编写 C 扩展,我假设您已经对 C 语言有了足够的了解,可以完全理解所呈现的示例。这里将不会解释除 Python/C API 细节之外的任何内容。本书是关于 Python 而不是其他任何语言。如果您根本不懂 C,那么在获得足够的经验和技能之前,绝对不应该尝试用 C 编写自己的 Python 扩展。把它留给其他人,坚持使用 Cython 或 Pyrex,因为从初学者的角度来看,它们更安全得多。这主要是因为 Python/C API,尽管经过精心设计,但绝对不是 C 的良好入门。

如前所述,我们将尝试将fibonacci()函数移植到 C 并将其作为扩展暴露给 Python 代码。没有与 Python/C API 连接的裸实现,类似于前面的 Python 示例,大致如下:

long long fibonacci(unsigned int n) {
    if (n < 2) {
        return 1;
    } else {
        return fibonacci(n - 2) + fibonacci(n - 1);
    }
}

以下是一个完整、完全功能的扩展的示例,它在编译模块中公开了这个单一函数:

#include <Python.h>

long long fibonacci(unsigned int n) {
    if (n < 2) {
        return 1;
    } else {
        return fibonacci(n-2) + fibonacci(n-1);
    }
}

static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
    PyObject *result = NULL;
    long n;

    if (PyArg_ParseTuple(args, "l", &n)) {
        result = Py_BuildValue("L", fibonacci((unsigned int)n));
    }

    return result;
}

static char fibonacci_docs[] =
    "fibonacci(n): Return nth Fibonacci sequence number "
    "computed recursively\n";

static PyMethodDef fibonacci_module_methods[] = {
    {"fibonacci", (PyCFunction)fibonacci_py,
     METH_VARARGS, fibonacci_docs},
    {NULL, NULL, 0, NULL}
};

static struct PyModuleDef fibonacci_module_definition = {
    PyModuleDef_HEAD_INIT,
    "fibonacci",
    "Extension module that provides fibonacci sequence function",
    -1,
    fibonacci_module_methods
};

PyMODINIT_FUNC PyInit_fibonacci(void) {
    Py_Initialize();

    return PyModule_Create(&fibonacci_module_definition);
}

前面的例子乍一看可能有点令人不知所措,因为我们不得不添加四倍的代码才能让fibonacci() C 函数可以从 Python 中访问。我们稍后会讨论代码的每一部分,所以不用担心。但在我们讨论之前,让我们看看如何将其打包并在 Python 中执行。我们模块的最小setuptools配置需要使用setuptools.Extension类来指示解释器如何编译我们的扩展:

from setuptools import setup, Extension

setup(
    name='fibonacci',
    ext_modules=[
        Extension('fibonacci', ['fibonacci.c']),
    ]
)

扩展的构建过程可以通过 Python 的setup.py构建命令来初始化,但也会在包安装时自动执行。以下是在开发模式下安装的结果以及一个简单的交互会话,我们在其中检查和执行我们编译的fibonacci()函数:

$ ls -1a
fibonacci.c
setup.py

$ pip install -e .
Obtaining file:///Users/swistakm/dev/book/chapter7
Installing collected packages: fibonacci
 **Running setup.py develop for fibonacci
Successfully installed Fibonacci

$ ls -1ap
build/
fibonacci.c
fibonacci.cpython-35m-darwin.so
fibonacci.egg-info/
setup.py

$ python
Python 3.5.1 (v3.5.1:37a07cee5969, Dec  5 2015, 21:12:44)** 
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import fibonacci
>>> help(fibonacci.fibonacci)

Help on built-in function fibonacci in fibonacci:

fibonacci.fibonacci = fibonacci(...)
 **fibonacci(n): Return nth Fibonacci sequence number computed recursively

>>> [fibonacci.fibonacci(n) for n in range(10)]
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
>>>** 

对 Python/C API 的更详细了解

由于我们知道如何正确地打包、编译和安装自定义 C 扩展,并且确信它按预期工作,现在是讨论我们的代码的正确时间。

扩展模块以一个包含Python.h头文件的单个 C 预处理指令开始:

#include <Python.h>

这将引入整个 Python/C API,并且是您需要包含的一切,以便能够编写您的扩展。在更现实的情况下,您的代码将需要更多的预处理指令,以从 C 标准库函数中获益或集成其他源文件。我们的示例很简单,因此不需要更多的指令。

接下来是我们模块的核心:

long long fibonacci(unsigned int n) {
    if (n < 2) {
        return 1;
    } else {
        return fibonacci(n - 2) + fibonacci(n - 1);
    }
}

前面的fibonacci()函数是我们代码中唯一有用的部分。它是纯 C 实现,Python 默认情况下无法理解。我们的示例的其余部分将创建接口层,通过 Python/C API 将其暴露出来。

将此代码暴露给 Python 的第一步是创建与 CPython 解释器兼容的 C 函数。在 Python 中,一切都是对象。这意味着在 Python 中调用的 C 函数也需要返回真正的 Python 对象。Python/C API 提供了PyObject类型,每个可调用函数都必须返回指向它的指针。我们函数的签名是:

static PyObject* fibonacci_py(PyObject* self, PyObject* args)s

请注意,前面的签名并未指定确切的参数列表,而只是PyObject* args,它将保存指向包含提供的值元组的结构的指针。参数列表的实际验证必须在函数体内执行,这正是fibonacci_py()所做的。它解析args参数列表,假设它是单个unsigned int类型,并将该值用作fibonacci()函数的参数来检索斐波那契数列元素:

static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
    PyObject *result = NULL;
    long n;

    if (PyArg_ParseTuple(args, "l", &n)) {
        result = Py_BuildValue("L", fibonacci((unsigned int)n));
    }

    return result;
}

注意

前面的示例函数有一些严重的错误,有经验的开发人员的眼睛应该很容易发现。尝试找到它,作为使用 C 扩展的练习。现在,为了简洁起见,我们将它保留下来。在异常处理部分讨论处理错误的细节时,我们将尝试稍后修复它。

"l"字符串在PyArg_ParseTuple(args, "l", &n)调用中意味着我们希望args只包含一个long值。如果失败,它将返回NULL并在每个线程的解释器状态中存储有关异常的信息。关于异常处理的详细信息将在异常处理部分稍后描述。

解析函数的实际签名是int PyArg_ParseTuple(PyObject *args, const char *format, ...),在format字符串之后的是一个可变长度的参数列表,表示解析值输出(作为指针)。这类似于 C 标准库中的scanf()函数的工作方式。如果我们的假设失败,用户提供了不兼容的参数列表,那么PyArg_ParseTuple()将引发适当的异常。一旦你习惯了这种方式,这是一种非常方便的编码函数签名的方式,但与纯 Python 代码相比,它有一个巨大的缺点。由PyArg_ParseTuple()调用隐式定义的这种 Python 调用签名在 Python 解释器内部不能轻松地检查。在使用作为扩展提供的代码时,您需要记住这一点。

如前所述,Python 期望从可调用对象返回对象。这意味着我们不能将从fibonacci()函数获得的long long值作为fibonacci_py()的结果返回。这样的尝试甚至不会编译,基本 C 类型不会自动转换为 Python 对象。必须使用Py_BuildValue(*format, ...)函数。它是PyArg_ParseTuple()的对应物,并接受类似的格式字符串集。主要区别在于参数列表不是函数输出而是输入,因此必须提供实际值而不是指针。

在定义了fibonacci_py()之后,大部分繁重的工作都已完成。最后一步是执行模块初始化并向我们的函数添加元数据,这将使用户的使用变得更简单一些。这是我们扩展代码的样板部分,对于一些简单的例子,比如这个例子,可能会占用比我们想要公开的实际函数更多的空间。在大多数情况下,它只是由一些静态结构和一个初始化函数组成,该函数将由解释器在模块导入时执行。

首先,我们创建一个静态字符串,它将成为fibonacci_py()函数的 Python 文档字符串的内容:

static char fibonacci_docs[] =
    "fibonacci(n): Return nth Fibonacci sequence number "
    "computed recursively\n";

请注意,这可能会内联fibonacci_module_methods的某个地方,但将文档字符串分开并存储在与其引用的实际函数定义的附近是一个很好的做法。

我们定义的下一部分是PyMethodDef结构的数组,该数组定义了将在我们的模块中可用的方法(函数)。该结构包含四个字段:

  • char* ml_name: 这是方法的名称。

  • PyCFunction ml_meth: 这是指向函数的 C 实现的指针。

  • int ml_flags: 这包括指示调用约定或绑定约定的标志。后者仅适用于定义类方法。

  • char* ml_doc: 这是指向方法/函数文档字符串内容的指针。

这样的数组必须始终以{NULL, NULL, 0, NULL}的哨兵值结束,表示其结束。在我们的简单情况下,我们创建了static PyMethodDef fibonacci_module_methods[]数组,其中只包含两个元素(包括哨兵值):

static PyMethodDef fibonacci_module_methods[] = {
    {"fibonacci", (PyCFunction)fibonacci_py,
     METH_VARARGS, fibonacci_docs},
    {NULL, NULL, 0, NULL}
};

这就是第一个条目如何映射到PyMethodDef结构:

  • ml_name = "fibonacci": 在这里,fibonacci_py() C 函数将以fibonacci名称作为 Python 函数公开

  • ml_meth = (PyCFunction)fibonacci_py: 在这里,将PyCFunction转换仅仅是 Python/C API 所需的,并且由ml_flags中定义的调用约定决定

  • ml_flags = METH_VARARGS: 在这里,METH_VARARGS标志表示我们的函数的调用约定接受可变参数列表,不接受关键字参数

  • ml_doc = fibonacci_docs: 在这里,Python 函数将使用fibonacci_docs字符串的内容进行文档化

当函数定义数组完成时,我们可以创建另一个结构,其中包含整个模块的定义。它使用PyModuleDef类型进行描述,并包含多个字段。其中一些仅适用于需要对模块初始化过程进行细粒度控制的更复杂的情况。在这里,我们只对其中的前五个感兴趣:

  • PyModuleDef_Base m_base: 这应该始终用PyModuleDef_HEAD_INIT进行初始化。

  • char* m_name: 这是新创建模块的名称。在我们的例子中是fibonacci

  • char* m_doc: 这是模块的文档字符串内容的指针。通常在一个 C 源文件中只定义一个模块,因此将我们的文档字符串内联在整个结构中是可以的。

  • Py_ssize_t m_size: 这是分配给保持模块状态的内存的大小。只有在需要支持多个子解释器或多阶段初始化时才会使用。在大多数情况下,您不需要它,它的值为-1

  • PyMethodDef* m_methods: 这是指向包含由PyMethodDef值描述的模块级函数的数组的指针。如果模块不公开任何函数,则可以为NULL。在我们的情况下,它是fibonacci_module_methods

其他字段在官方 Python 文档中有详细解释(参考docs.python.org/3/c-api/module.html),但在我们的示例扩展中不需要。如果不需要,它们应该设置为NULL,当未指定时,它们将隐式地初始化为该值。这就是为什么我们的模块描述包含在fibonacci_module_definition变量中可以采用这种简单的五元素形式的原因:

static struct PyModuleDef fibonacci_module_definition = {
    PyModuleDef_HEAD_INIT,
    "fibonacci",
    "Extension module that provides fibonacci sequence function",
    -1,
    fibonacci_module_methods
};

最后一段代码是我们工作的巅峰,即模块初始化函数。这必须遵循非常特定的命名约定,以便 Python 解释器在加载动态/共享库时可以轻松地选择它。它应该被命名为PyInit_name,其中name是您的模块名称。因此,它与在PyModuleDef定义中用作m_base字段和setuptools.Extension()调用的第一个参数的字符串完全相同。如果您不需要对模块进行复杂的初始化过程,它将采用与我们示例中完全相同的非常简单的形式:

PyMODINIT_FUNC PyInit_fibonacci(void) {
    return PyModule_Create(&fibonacci_module_definition);
}

PyMODINIT_FUNC宏是一个预处理宏,它将声明此初始化函数的返回类型为PyObject*,并根据平台需要添加任何特殊的链接声明。

调用和绑定约定

深入了解 Python/C API部分所述,PyMethodDef结构的ml_flags位字段包含调用和绑定约定的标志。调用约定标志包括:

  • METH_VARARGS: 这是 Python 函数或方法的典型约定,只接受参数作为其参数。对于这样的函数,ml_meth字段提供的类型应该是PyCFunction。该函数将提供两个PyObject*类型的参数。第一个要么是self对象(对于方法),要么是module对象(对于模块函数)。具有该调用约定的 C 函数的典型签名是PyObject* function(PyObject* self, PyObject* args)

  • METH_KEYWORDS:这是 Python 函数在调用时接受关键字参数的约定。其关联的 C 类型是PyCFunctionWithKeywords。C 函数必须接受三个PyObject*类型的参数:selfargs和关键字参数的字典。如果与METH_VARARGS组合,前两个参数的含义与前一个调用约定相同,否则args将为NULL。典型的 C 函数签名是:PyObject* function(PyObject* self, PyObject* args, PyObject* keywds)

  • METH_NOARGS:这是 Python 函数不接受任何其他参数的约定。C 函数应该是PyCFunction类型,因此签名与METH_VARARGS约定相同(两个selfargs参数)。唯一的区别是args将始终为NULL,因此不需要调用PyArg_ParseTuple()。这不能与任何其他调用约定标志组合。

  • METH_O:这是接受单个对象参数的函数和方法的简写。C 函数的类型再次是PyCFunction,因此它接受两个PyObject*参数:selfargs。它与METH_VARARGS的区别在于不需要调用PyArg_ParseTuple(),因为作为args提供的PyObject*将已经表示在 Python 调用该函数时提供的单个参数。这也不能与任何其他调用约定标志组合。

接受关键字的函数可以用METH_KEYWORDS或者METH_VARARGS | METH_KEYWORDS的形式来描述。如果是这样,它应该使用PyArg_ParseTupleAndKeywords()来解析它的参数,而不是PyArg_ParseTuple()或者PyArg_UnpackTuple()。下面是一个示例模块,其中有一个返回None的函数,接受两个命名关键字参数,并将它们打印到标准输出:

#include <Python.h>

static PyObject* print_args(PyObject *self, PyObject *args, PyObject *keywds)
{
    char *first;
    char *second;

    static char *kwlist[] = {"first", "second", NULL};

    if (!PyArg_ParseTupleAndKeywords(args, keywds, "ss", kwlist,
                                     &first, &second))
        return NULL;

    printf("%s %s\n", first, second);

    Py_INCREF(Py_None);
    return Py_None;
}

static PyMethodDef module_methods[] = {
    {"print_args", (PyCFunction)print_args,
     METH_VARARGS | METH_KEYWORDS,
     "print provided arguments"},
    {NULL, NULL, 0, NULL}
};

static struct PyModuleDef module_definition = {
    PyModuleDef_HEAD_INIT,
    "kwargs",
    "Keyword argument processing example",
    -1,
    module_methods
};

PyMODINIT_FUNC PyInit_kwargs(void) {
    return PyModule_Create(&module_definition);
}

Python/C API 中的参数解析非常灵活,并且在官方文档中有详细描述。PyArg_ParseTuple()PyArg_ParseTupleAndKeywords()中的格式参数允许对参数数量和类型进行精细的控制。Python 中已知的每个高级调用约定都可以使用此 API 在 C 中编码,包括:

  • 带有默认参数值的函数

  • 指定为关键字参数的函数

  • 带有可变数量参数的函数

绑定约定标志METH_CLASSMETH_STATICMETH_COEXIST,它们保留给方法,并且不能用于描述模块函数。前两个相当不言自明。它们是classmethodstaticmethod装饰器的 C 对应物,并且改变了传递给 C 函数的self参数的含义。

METH_COEXIST允许在现有定义的位置加载一个方法。这很少有用。这主要是当您想要提供一个从已定义的类型的其他特性自动生成的 C 方法的实现时。Python 文档给出了__contains__()包装器方法的示例,如果类型定义了sq_contains槽,它将自动生成。不幸的是,使用 Python/C API 定义自己的类和类型超出了本入门章节的范围。在讨论 Cython 时,我们将在以后讨论创建自己的类型,因为在纯 C 中这样做需要太多样板代码,并且容易出错。

异常处理

与 Python 甚至 C++不同,C 没有语法来引发和捕获异常。所有错误处理通常通过函数返回值和可选的全局状态来处理,用于存储可以解释最后一次失败原因的细节。

Python/C API 中的异常处理建立在这个简单原则的基础上。有一个全局的每个线程指示器,用于描述 C API 中发生的最后一个错误。它被设置为描述问题的原因。还有一种标准化的方法,用于在调用期间通知函数的调用者是否更改了此状态:

  • 如果函数应返回指针,则返回NULL

  • 如果函数应返回int类型,则返回-1

在 Python/C API 中,前述规则的唯一例外是返回1表示成功,返回0表示失败的PyArg_*()函数。

为了了解这在实践中是如何工作的,让我们回顾一下前几节中示例中的fibonacci_py()函数:

static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
 **PyObject *result = NULL;
    long n;

 **if (PyArg_ParseTuple(args, "l", &n)) {
 **result = Py_BuildValue("L", fibonacci((unsigned int) n));
    }

 **return result;
}

以某种方式参与我们的错误处理的行已经被突出显示。它从初始化result变量开始,该变量应存储我们函数的返回值。它被初始化为NULL,正如我们已经知道的那样,这是一个错误指示器。这通常是您编写扩展的方式,假设错误是代码的默认状态。

稍后,我们有PyArg_ParseTuple()调用,如果发生异常,将设置错误信息并返回0。这是if语句的一部分,在这种情况下,我们不做任何其他操作并返回NULL。调用我们的函数的人将收到有关错误的通知。

Py_BuildValue()也可能引发异常。它应返回PyObject*(指针),因此在失败的情况下会返回NULL。我们可以简单地将其存储为我们的结果变量,并将其作为返回值传递。

但我们的工作并不仅仅是关心 Python/C API 调用引发的异常。很可能您需要通知扩展用户发生了其他类型的错误或失败。Python/C API 有多个函数可帮助您引发异常,但最常见的是PyErr_SetString()。它使用提供的附加字符串设置错误指示器和给定的异常类型作为错误原因的解释。此函数的完整签名是:

void PyErr_SetString(PyObject* type, const char* message)

我已经说过我们的fibonacci_py()函数的实现存在严重错误。现在是修复它的正确时机。幸运的是,我们有适当的工具来做到这一点。问题在于在以下行中将long类型不安全地转换为unsigned int

    if (PyArg_ParseTuple(args, "l", &n)) {
      result = Py_BuildValue("L", fibonacci((unsigned int) n));
    }

感谢PyArg_ParseTuple()调用,第一个且唯一的参数将被解释为long类型("l"指定符),并存储在本地n变量中。然后将其转换为unsigned int,因此如果用户使用负值从 Python 调用fibonacci()函数,则会出现问题。例如,作为有符号 32 位整数的-1在转换为无符号 32 位整数时将被解释为4294967295。这样的值将导致深度递归,并导致堆栈溢出和分段错误。请注意,如果用户提供任意大的正参数,也可能会发生相同的情况。我们无法在没有完全重新设计 C fibonacci()函数的情况下解决这个问题,但至少我们可以尝试确保传递的参数满足一些先决条件。在这里,我们检查n参数的值是否大于或等于零,如果不是,则引发ValueError异常:

static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
    PyObject *result = NULL;
    long n;
    long long fib;

    if (PyArg_ParseTuple(args, "l", &n)) {
        if (n<0) {
            PyErr_SetString(PyExc_ValueError,
                            "n must not be less than 0");
        } else {
            result = Py_BuildValue("L", fibonacci((unsigned int)n));
        }
    }

    return result;
}

最后一点是全局错误状态不会自行清除。您的 C 函数中可能会优雅地处理一些错误(就像在 Python 中使用try ... except子句一样),如果错误指示器不再有效,则需要能够清除错误指示器。用于此目的的函数是PyErr_Clear()

释放 GIL

我已经提到扩展可以是绕过 Python GIL 的一种方法。CPython 实现有一个著名的限制,即一次只能有一个线程执行 Python 代码。虽然多进程是绕过这个问题的建议方法,但对于一些高度可并行化的算法来说,由于运行额外进程的资源开销,这可能不是一个好的解决方案。

因为扩展主要用于在纯 C 中执行大部分工作而没有调用 Python/C API 的情况下,所以在一些应用程序部分释放 GIL 是可能的(甚至是建议的)。由于这一点,您仍然可以从拥有多个 CPU 核心和多线程应用程序设计中受益。您唯一需要做的就是使用 Python/C API 提供的特定宏将已知不使用任何 Python/C API 调用或 Python 结构的代码块进行包装。这两个预处理器宏旨在简化释放和重新获取全局解释器锁的整个过程:

  • Py_BEGIN_ALLOW_THREADS:这声明了隐藏的本地变量,保存了当前线程状态并释放了 GIL

  • Py_END_ALLOW_THREADS:这重新获取 GIL 并从使用前一个宏声明的本地变量恢复线程状态

当我们仔细观察我们的fibonacci扩展示例时,我们可以清楚地看到fibonacci()函数不执行任何 Python 代码,也不触及任何 Python 结构。这意味着简单包装fibonacci(n)执行的fibonacci_py()函数可以更新以在调用周围释放 GIL:

static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
    PyObject *result = NULL;
    long n;
    long long fib;

    if (PyArg_ParseTuple(args, "l", &n)) {
        if (n<0) {
            PyErr_SetString(PyExc_ValueError,
                            "n must not be less than 0");
        } else {
            Py_BEGIN_ALLOW_THREADS;
            fib = fibonacci(n);
            Py_END_ALLOW_THREADS;

            result = Py_BuildValue("L", fib);
        }}

    return result;
}

引用计数

最后,我们来到了 Python 中内存管理的重要主题。Python 有自己的垃圾回收器,但它只设计用来解决引用计数算法中的循环引用问题。引用计数是管理不再需要的对象的释放的主要方法。

Python/C API 文档引入了引用的所有权来解释它如何处理对象的释放。Python 中的对象从不被拥有,它们总是被共享。对象的实际创建由 Python 的内存管理器管理。这是 CPython 解释器的一个组件,负责为存储在私有堆中的对象分配和释放内存。可以拥有的是对对象的引用。

Python 中的每个对象,由一个引用(PyObject*指针)表示,都有一个关联的引用计数。当引用计数为零时,意味着没有人持有对象的有效引用,可以调用与其类型相关联的解分配器。Python/C API 提供了两个宏来增加和减少引用计数:Py_INCREF()Py_DECREF()。但在讨论它们的细节之前,我们需要了解与引用所有权相关的一些术语:

  • 所有权的传递:每当我们说函数传递了对引用的所有权时,这意味着它已经增加了引用计数,调用者有责任在不再需要对象的引用时减少计数。大多数返回新创建对象的函数,比如Py_BuildValue,都会这样做。如果该对象将从我们的函数返回给另一个调用者,那么所有权会再次传递。在这种情况下,我们不会减少引用计数,因为这不再是我们的责任。这就是为什么fibonacci_py()函数不在result变量上调用Py_DECREF()的原因。

  • 借用引用借用引用发生在函数将某个 Python 对象的引用作为参数接收时。在该函数中,除非在其范围内明确增加了引用计数,否则不应该减少此类引用的引用计数。在我们的fibonacci_py()函数中,selfargs参数就是这样的借用引用,因此我们不对它们调用PyDECREF()。Python/C API 的一些函数也可能返回借用引用。值得注意的例子是PyTuple_GetItem()PyList_GetItem()。通常说这样的引用是不受保护的。除非它将作为函数的返回值返回,否则不需要释放其所有权。在大多数情况下,如果我们将这样的借用引用用作其他 Python/C API 调用的参数,就需要额外小心。在某些情况下,可能需要在将其用作其他函数的参数之前,额外使用Py_INCREF()来保护这样的引用,然后在不再需要时调用Py_DECREF()

  • 窃取引用:Python/C API 函数还可以在提供为调用参数时窃取引用,而不是借用引用。这是确切的两个函数的情况:PyTuple_SetItem()PyList_SetItem()。它们完全承担了传递给它们的引用的责任。它们本身不增加引用计数,但在不再需要引用时会调用Py_DECREF()

在编写复杂的扩展时,监视引用计数是最困难的事情之一。一些不那么明显的问题可能直到在多线程设置中运行代码时才会被注意到。

另一个常见的问题是由 Python 对象模型的本质和一些函数返回借用引用的事实引起的。当引用计数变为零时,将执行解分配函数。对于用户定义的类,可以定义一个__del__()方法,在那时将被调用。这可以是任何 Python 代码,可能会影响其他对象及其引用计数。官方 Python 文档给出了以下可能受到此问题影响的代码示例:

void bug(PyObject *list) {
    PyObject *item = PyList_GetItem(list, 0);

    PyList_SetItem(list, 1, PyLong_FromLong(0L));
    PyObject_Print(item, stdout, 0); /* BUG! */
}

看起来完全无害,但问题实际上是我们无法知道list对象包含哪些元素。当PyList_SetItem()list[1]索引上设置一个新值时,之前存储在该索引处的对象的所有权被处理。如果它是唯一存在的引用,引用计数将变为 0,并且对象将被解分配。可能是某个用户定义的类,具有__del__()方法的自定义实现。如果在这样的__del__()执行的结果中,item[0]将从列表中移除,将会出现严重问题。请注意,PyList_GetItem()返回一个借用引用!在返回引用之前,它不会调用Py_INCREF()。因此,在该代码中,可能会调用PyObject_Print(),并且会使用一个不再存在的对象的引用。这将导致分段错误并使 Python 解释器崩溃。

正确的方法是在我们需要它们的整个时间内保护借用引用,因为有可能在其中的任何调用可能导致任何其他对象的解分配,即使它们看似无关:

void no_bug(PyObject *list) {
    PyObject *item = PyList_GetItem(list, 0);

    Py_INCREF(item);
    PyList_SetItem(list, 1, PyLong_FromLong(0L));
    PyObject_Print(item, stdout, 0);
    Py_DECREF(item);
}

Cython

Cython 既是一个优化的静态编译器,也是 Python 的超集编程语言的名称。作为编译器,它可以对本地 Python 代码和其 Cython 方言进行源到源编译,使用 Python/C API 将其转换为 Python C 扩展。它允许您结合 Python 和 C 的强大功能,而无需手动处理 Python/C API。

Cython 作为源到源编译器

使用 Cython 创建的扩展的主要优势是可以使用它提供的超集语言。无论如何,也可以使用源到源编译从纯 Python 代码创建扩展。这是 Cython 的最简单方法,因为它几乎不需要对代码进行任何更改,并且可以在非常低的开发成本下获得一些显著的性能改进。

Cython 提供了一个简单的cythonize实用函数,允许您轻松地将编译过程与distutilssetuptools集成。假设我们想将fibonacci()函数的纯 Python 实现编译为 C 扩展。如果它位于fibonacci模块中,最小的setup.py脚本可能如下所示:

from setuptools import setup
from Cython.Build import cythonize

setup(
    name='fibonacci',
    ext_modules=cythonize(['fibonacci.py'])
)

Cython 作为 Python 语言的源编译工具还有另一个好处。源到源编译到扩展可以是源分发安装过程的完全可选部分。如果需要安装包的环境没有 Cython 或任何其他构建先决条件,它可以像普通的纯 Python包一样安装。用户不应该注意到以这种方式分发的代码行为上的任何功能差异。

使用 Cython 构建的扩展的常见方法是包括 Python/Cython 源代码和从这些源文件生成的 C 代码。这样,该包可以根据构建先决条件的存在以三种不同的方式安装:

  • 如果安装环境中有 Cython 可用,则会从提供的 Python/Cython 源代码生成扩展 C 代码。

  • 如果 Cython 不可用,但存在构建先决条件(C 编译器,Python/C API 头文件),则从分发的预生成 C 文件构建扩展。

  • 如果前述的先决条件都不可用,但扩展是从纯 Python 源创建的,则模块将像普通的 Python 代码一样安装,并且跳过编译步骤。

请注意,Cython 文档表示,包括生成的 C 文件以及 Cython 源是分发 Cython 扩展的推荐方式。同样的文档表示,Cython 编译应该默认禁用,因为用户可能在他的环境中没有所需版本的 Cython,这可能导致意外的编译问题。无论如何,随着环境隔离的出现,这似乎是一个今天不太令人担忧的问题。此外,Cython 是一个有效的 Python 包,可以在 PyPI 上获得,因此可以很容易地在特定版本中定义为您项目的要求。当然,包括这样的先决条件是一个具有严重影响的决定,应该非常谨慎地考虑。更安全的解决方案是利用setuptools包中的extras_require功能的强大功能,并允许用户决定是否要使用特定环境变量来使用 Cython:

import os

from distutils.core import setup
from distutils.extension import Extension

try:
    # cython source to source compilation available
    # only when Cython is available
    import Cython
    # and specific environment variable says
    # explicitely that Cython should be used
    # to generate C sources
    USE_CYTHON = bool(os.environ.get("USE_CYTHON"))

except ImportError:
    USE_CYTHON = False

ext = '.pyx' if USE_CYTHON else '.c'

extensions = [Extension("fibonacci", ["fibonacci"+ext])]

if USE_CYTHON:
    from Cython.Build import cythonize
    extensions = cythonize(extensions)

setup(
    name='fibonacci',
    ext_modules=extensions,
    extras_require={
        # Cython will be set in that specific version
        # as a requirement if package will be intalled
        # with '[with-cython]' extra feature
        'cython': ['cython==0.23.4']
    }
)

pip安装工具支持通过在包名后添加[extra-name]后缀来使用extras选项安装包。对于前面的示例,可以使用以下命令启用从本地源安装时的可选 Cython 要求和编译:

$ USE_CYTHON=1 pip install .[with-cython]

Cython 作为一种语言

Cython 不仅是一个编译器,还是 Python 语言的超集。超集意味着任何有效的 Python 代码都是允许的,并且可以进一步更新为具有额外功能的代码,例如支持调用 C 函数或在变量和类属性上声明 C 类型。因此,任何用 Python 编写的代码也是用 Cython 编写的。这解释了为什么普通的 Python 模块可以如此轻松地使用 Cython 编译为 C。

但我们不会停留在这个简单的事实上。我们将尝试对我们的参考fibonacci()函数进行一些改进,而不是说它也是 Python 的超集中有效扩展的代码。这不会对我们的函数设计进行任何真正的优化,而是一些小的更新,使它能够从在 Cython 中编写的好处中受益。

Cython 源文件使用不同的文件扩展名。它是.pyx而不是.py。假设我们仍然想要实现我们的 Fibbonacci 序列。fibonacci.pyx的内容可能如下所示:

"""Cython module that provides fibonacci sequence function."""

def fibonacci(unsigned int n):
    """Return nth Fibonacci sequence number computed recursively."""
    if n < 2:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

正如您所看到的,真正改变的只是fibonacci()函数的签名。由于 Cython 中的可选静态类型,我们可以将n参数声明为unsigned int,这应该稍微改进了我们函数的工作方式。此外,它比我们以前手工编写扩展时做的事情要多得多。如果 Cython 函数的参数声明为静态类型,则扩展将自动处理转换和溢出错误,引发适当的异常:

>>> from fibonacci import fibonacci
>>> fibonacci(5)
5
>>> fibonacci(-1)
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
 **File "fibonacci.pyx", line 21, in fibonacci.fibonacci (fibonacci.c:704)
OverflowError: can't convert negative value to unsigned int
>>> fibonacci(10 ** 10)
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
 **File "fibonacci.pyx", line 21, in fibonacci.fibonacci (fibonacci.c:704)
OverflowError: value too large to convert to unsigned int

我们已经知道 Cython 只编译源到源,生成的代码使用与我们手工编写 C 代码扩展时相同的 Python/C API。请注意,fibonacci()是一个递归函数,因此它经常调用自身。这意味着尽管我们为输入参数声明了静态类型,在递归调用期间,它将像任何其他 Python 函数一样对待自己。因此,n-1n-2将被打包回 Python 对象,然后传递给内部fibonacci()实现的隐藏包装层,再次将其转换为unsigned int类型。这将一次又一次地发生,直到我们达到递归的最终深度。这不一定是一个问题,但涉及到比实际需要的更多的参数处理。

我们可以通过将更多的工作委托给一个纯 C 函数来削减 Python 函数调用和参数处理的开销。我们以前在使用纯 C 创建 C 扩展时就这样做过,我们在 Cython 中也可以这样做。我们可以使用cdef关键字声明只接受和返回 C 类型的 C 风格函数:

cdef long long fibonacci_cc(unsigned int n):
    if n < 2:
        return n
    else:
        return fibonacci_cc(n - 1) + fibonacci_cc(n - 2)

def fibonacci(unsigned int n):
    """ Return nth Fibonacci sequence number computed recursively
    """
    return fibonacci_cc(n)

我们甚至可以走得更远。通过一个简单的 C 示例,我们最终展示了如何在调用我们的纯 C 函数时释放 GIL,因此扩展对多线程应用程序来说更加友好。在以前的示例中,我们使用了 Python/C API 头文件中的Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS预处理器宏来标记代码段为无需 Python 调用。Cython 语法要简短得多,更容易记住。可以使用简单的with nogil语句在代码段周围释放 GIL:

def fibonacci(unsigned int n):
    """ Return nth Fibonacci sequence number computed recursively
    """
 **with nogil:
        result = fibonacci_cc(n)

    return fibonacci_cc(n)

您还可以将整个 C 风格函数标记为无需 GIL 即可调用:

cdef long long fibonacci_cc(unsigned int n) nogil:
    if n < 2:
        return n
    else:
        return fibonacci_cc(n - 1) + fibonacci_cc(n - 2)

重要的是要知道,这样的函数不能将 Python 对象作为参数或返回类型。每当标记为nogil的函数需要执行任何 Python/C API 调用时,它必须使用with gil语句获取 GIL。

挑战

老实说,我之所以开始接触 Python,只是因为我厌倦了用 C 和 C++编写软件的所有困难。事实上,程序员们意识到其他语言无法满足用户需求时,很常见的是开始学习 Python。与 C、C++或 Java 相比,用 Python 编程是一件轻而易举的事情。一切似乎都很简单而且设计良好。你可能会认为没有地方会让你绊倒,也不再需要其他编程语言了。

当然,这种想法是错误的。是的,Python 是一种令人惊叹的语言,具有许多很酷的功能,并且在许多领域中被使用。但这并不意味着它是完美的,也没有任何缺点。它易于理解和编写,但这种简单性是有代价的。它并不像许多人认为的那样慢,但永远不会像 C 那样快。它高度可移植,但它的解释器并不像其他语言的编译器那样在许多架构上都可用。我们可以永远列出这样的列表。

解决这个问题的一个方法是编写扩展,这样我们就可以将好老的 C的一些优点带回 Python。在大多数情况下,这样做效果很好。问题是:我们真的是因为想用 C 来扩展 Python 吗?答案是否定的。这只是在我们没有更好选择的情况下的一种不便的必要性。

额外的复杂性

毫无秘密,用许多不同的语言开发应用程序并不是一件容易的事情。Python 和 C 是完全不同的技术,很难找到它们共同之处。同样真实的是没有一个应用程序是没有 bug 的。如果在你的代码库中扩展变得很常见,调试可能会变得痛苦。不仅因为调试 C 代码需要完全不同的工作流程和工具,而且因为你需要经常在两种不同的语言之间切换上下文。

我们都是人类,都有有限的认知能力。当然,有些人可以有效地处理多层抽象和技术堆栈,但他们似乎是非常罕见的。无论你有多么有技巧,对于维护这样的混合解决方案,总是需要额外付出代价。这要么涉及额外的努力和时间来在 C 和 Python 之间切换,要么涉及额外的压力,最终会使你效率降低。

根据 TIOBE 指数,C 仍然是最流行的编程语言之一。尽管事实如此,Python 程序员很常见地对它知之甚少,甚至几乎一无所知。就我个人而言,我认为 C 应该是编程世界的通用语言,但我的观点在这个问题上很不可能改变任何事情。Python 也是如此诱人和易学,以至于许多程序员忘记了他们以前的所有经验,完全转向了新技术。而编程不像骑自行车。如果不经常使用和充分磨练,这种特定的技能会更快地消失。即使是具有扎实 C 背景的程序员,如果决定长时间深入 Python,也会逐渐失去他们以前的知识。以上所有情况都导致一个简单的结论——很难找到能够理解和扩展你的代码的人。对于开源软件包,这意味着更少的自愿贡献者。对于闭源软件,这意味着并非所有的队友都能够在不破坏东西的情况下开发和维护扩展。

调试

当涉及到失败时,扩展可能会出现严重故障。静态类型给你比 Python 更多的优势,并允许你在编译步骤中捕获很多问题,这些问题在 Python 中很难注意到,除非进行严格的测试例程和全面的测试覆盖。另一方面,所有内存管理必须手动执行。错误的内存管理是 C 中大多数编程错误的主要原因。在最好的情况下,这样的错误只会导致一些内存泄漏,逐渐消耗所有环境资源。最好的情况并不意味着容易处理。内存泄漏真的很难在不使用适当的外部工具(如 Valgrind)的情况下找到。无论如何,在大多数情况下,扩展代码中的内存管理问题将导致分段错误,在 Python 中无法恢复,并且会导致解释器崩溃而不引发任何异常。这意味着最终您将需要额外的工具,大多数 Python 程序员不需要使用。这给您的开发环境和工作流程增加了复杂性。

无需扩展即可与动态库进行接口

由于ctypes(标准库中的一个模块)或cffi(一个外部包),您可以在 Python 中集成几乎所有编译的动态/共享库,无论它是用什么语言编写的。而且您可以在纯 Python 中进行,无需任何编译步骤,因此这是编写 C 扩展的有趣替代方案。

这并不意味着您不需要了解 C。这两种解决方案都需要您对 C 有合理的理解,以及对动态库的工作原理有所了解。另一方面,它们消除了处理 Python 引用计数的负担,并大大减少了犯错误的风险。通过ctypescffi与 C 代码进行接口,比编写和编译 C 扩展模块更具可移植性。

ctypes

ctypes 是调用动态或共享库函数最流行的模块,无需编写自定义的 C 扩展。其原因是显而易见的。它是标准库的一部分,因此始终可用,不需要任何外部依赖。它是一个外部函数接口FFI)库,并提供了一个用于创建兼容 C 数据类型的 API。

加载库

ctypes中有四种类型的动态库加载器,以及两种使用它们的约定。表示动态和共享库的类有ctypes.CDLLctypes.PyDLLctypes.OleDLLctypes.WinDLL。最后两个仅在 Windows 上可用,因此我们不会在这里讨论它们。CDLLPyDLL之间的区别如下:

  • ctypes.CDLL:此类表示已加载的共享库。这些库中的函数使用标准调用约定,并假定返回int。在调用期间释放 GIL。

  • ctypes.PyDLL:此类与CDLL类似,但在调用期间不会释放 GIL。执行后,将检查 Python 错误标志,并在设置时引发异常。仅在直接从 Python/C API 调用函数时才有用。

要加载库,您可以使用前述类之一实例化,并使用适当的参数,或者调用与特定类相关联的子模块的LoadLibrary()函数:

  • ctypes.cdll.LoadLibrary() 用于 ctypes.CDLL

  • ctypes.pydll.LoadLibrary() 用于 ctypes.PyDLL

  • ctypes.windll.LoadLibrary() 用于 ctypes.WinDLL

  • ctypes.oledll.LoadLibrary() 用于 ctypes.OleDLL

在加载共享库时的主要挑战是如何以便携方式找到它们。不同的系统对共享库使用不同的后缀(Windows 上为.dll,OS X 上为.dylib,Linux 上为.so)并在不同的位置搜索它们。在这方面的主要问题是 Windows,它没有预定义的库命名方案。因此,我们不会讨论在这个系统上使用ctypes加载库的细节,而主要集中在处理这个问题的一致和类似方式的 Linux 和 Mac OS X 上。如果您对 Windows 平台感兴趣,可以参考官方的ctypes文档,其中有大量关于支持该系统的信息(参见docs.python.org/3.5/library/ctypes.html)。

加载库的两种约定(LoadLibrary()函数和特定的库类型类)都要求您使用完整的库名称。这意味着需要包括所有预定义的库前缀和后缀。例如,在 Linux 上加载 C 标准库,您需要编写以下内容:

>>> import ctypes
>>> ctypes.cdll.LoadLibrary('libc.so.6')
<CDLL 'libc.so.6', handle 7f0603e5f000 at 7f0603d4cbd0>

在这里,对于 Mac OS X,这将是:

>>> import ctypes
>>> ctypes.cdll.LoadLibrary('libc.dylib')

幸运的是,ctypes.util子模块提供了一个find_library()函数,允许使用其名称加载库,而无需任何前缀或后缀,并且将在具有预定义共享库命名方案的任何系统上工作:

>>> import ctypes
>>> from ctypes.util import find_library
>>> ctypes.cdll.LoadLibrary(find_library('c'))
<CDLL '/usr/lib/libc.dylib', handle 7fff69b97c98 at 0x101b73ac8>
>>> ctypes.cdll.LoadLibrary(find_library('bz2'))
<CDLL '/usr/lib/libbz2.dylib', handle 10042d170 at 0x101b6ee80>
>>> ctypes.cdll.LoadLibrary(find_library('AGL'))
<CDLL '/System/Library/Frameworks/AGL.framework/AGL', handle 101811610 at 0x101b73a58>

使用 ctypes 调用 C 函数

当成功加载库时,通常的模式是将其存储为与库同名的模块级变量。函数可以作为对象属性访问,因此调用它们就像调用来自任何其他已导入模块的 Python 函数一样:

>>> import ctypes
>>> from ctypes.util import find_library
>>>** 
>>> libc = ctypes.cdll.LoadLibrary(find_library('c'))
>>>** 
>>> libc.printf(b"Hello world!\n")
Hello world!
13

不幸的是,除了整数、字符串和字节之外,所有内置的 Python 类型都与 C 数据类型不兼容,因此必须包装在ctypes模块提供的相应类中。以下是来自ctypes文档的完整兼容数据类型列表:

ctypes 类型 C 类型 Python 类型
--- --- ---
c_bool _Bool bool(1)
c_char char 1 个字符的bytes对象
c_wchar wchar_t 1 个字符的string
c_byte char int
c_ubyte unsigned char int
c_short short int
c_ushort unsigned short int
c_int int int
c_uint unsigned int int
c_long long int
c_ulong unsigned long int
c_longlong __int64 或 long long int
c_ulonglong unsigned __int64 或 unsigned long long int
c_size_t size_t int
c_ssize_t ssize_t 或 Py_ssize_t int
c_float float float
c_double double float
c_longdouble long double float
c_char_p char *(NUL 终止) bytes对象或None
c_wchar_p wchar_t *(NUL 终止) stringNone
c_void_p void * intNone

正如您所看到的,上表中没有专门的类型来反映任何 Python 集合作为 C 数组。创建 C 数组类型的推荐方法是简单地使用所需的基本ctypes类型与乘法运算符:

>>> import ctypes
>>> IntArray5 = ctypes.c_int * 5
>>> c_int_array = IntArray5(1, 2, 3, 4, 5)
>>> FloatArray2 = ctypes.c_float * 2
>>> c_float_array = FloatArray2(0, 3.14)
>>> c_float_array[1]
3.140000104904175

将 Python 函数作为 C 回调传递

将函数实现的一部分委托给用户提供的自定义回调是一种非常流行的设计模式。C 标准库中接受此类回调的最知名函数是提供了Quicksort算法的qsort()函数。您可能不太可能使用此算法而不是更适合对 Python 集合进行排序的默认 Python Timsort。无论如何,qsort()似乎是一个高效排序算法和使用回调机制的 C API 的典型示例,在许多编程书籍中都可以找到。这就是为什么我们将尝试将其用作将 Python 函数作为 C 回调传递的示例。

普通的 Python 函数类型将不兼容qsort()规范所需的回调函数类型。以下是来自 BSD man页面的qsort()签名,其中还包含了接受的回调类型(compar参数)的类型:

void qsort(void *base, size_t nel, size_t width,
           int (*compar)(const void *, const void *));

因此,为了执行libc中的qsort(),您需要传递:

  • base:这是需要作为void*指针排序的数组。

  • nel:这是size_t类型的元素数量。

  • width:这是size_t类型的数组中单个元素的大小。

  • compar:这是指向应该返回int并接受两个void*指针的函数的指针。它指向比较正在排序的两个元素大小的函数。

我们已经从使用 ctypes 调用 C 函数部分知道了如何使用乘法运算符从其他ctypes类型构造 C 数组。nel应该是size_t,它映射到 Python int,因此不需要任何额外的包装,可以作为len(iterable)传递。一旦我们知道了base数组的类型,就可以使用ctypes.sizeof()函数获取width值。我们需要知道的最后一件事是如何创建与compar参数兼容的 Python 函数指针。

ctypes模块包含一个CFUNTYPE()工厂函数,允许我们将 Python 函数包装并表示为 C 可调用函数指针。第一个参数是包装函数应该返回的 C 返回类型。它后面是作为其参数接受的 C 类型的可变列表。与qsort()compar参数兼容的函数类型将是:

CMPFUNC = ctypes.CFUNCTYPE(
    # return type
    ctypes.c_int,
    # first argument type
    ctypes.POINTER(ctypes.c_int),
    # second argument type
    ctypes.POINTER(ctypes.c_int),
)

注意

CFUNTYPE()使用cdecl调用约定,因此只与CDLLPyDLL共享库兼容。在 Windows 上使用WinDLLOleDLL加载的动态库使用stdcall调用约定。这意味着必须使用其他工厂将 Python 函数包装为 C 可调用函数指针。在ctypes中,它是WINFUNCTYPE()

总结一切,假设我们想要使用标准 C 库中的qsort()函数对随机洗牌的整数列表进行排序。以下是一个示例脚本,展示了如何使用到目前为止我们学到的关于ctypes的一切来实现这一点:

from random import shuffle

import ctypes
from ctypes.util import find_library

libc = ctypes.cdll.LoadLibrary(find_library('c'))

CMPFUNC = ctypes.CFUNCTYPE(
    # return type
    ctypes.c_int,
    # first argument type
    ctypes.POINTER(ctypes.c_int),
    # second argument type
    ctypes.POINTER(ctypes.c_int),
)

def ctypes_int_compare(a, b):
    # arguments are pointers so we access using [0] index
    print(" %s cmp %s" % (a[0], b[0]))

    # according to qsort specification this should return:
    # * less than zero if a < b
    # * zero if a == b
    # * more than zero if a > b
    return a[0] - b[0]

def main():
    numbers = list(range(5))
    shuffle(numbers)
    print("shuffled: ", numbers)

    # create new type representing array with length
    # same as the length of numbers list
    NumbersArray = ctypes.c_int * len(numbers)
    # create new C array using a new type
    c_array = NumbersArray(*numbers)

    libc.qsort(
        # pointer to the sorted array
        c_array,
        # length of the array
        len(c_array),
        # size of single array element
        ctypes.sizeof(ctypes.c_int),
        # callback (pointer to the C comparison function)
        CMPFUNC(ctypes_int_compare)
    )
    print("sorted:   ", list(c_array))

if __name__ == "__main__":
    main()

作为回调提供的比较函数有一个额外的print语句,因此我们可以看到它在排序过程中是如何执行的:

$ python ctypes_qsort.py** 
shuffled:  [4, 3, 0, 1, 2]
 **4 cmp 3
 **4 cmp 0
 **3 cmp 0
 **4 cmp 1
 **3 cmp 1
 **0 cmp 1
 **4 cmp 2
 **3 cmp 2
 **1 cmp 2
sorted:    [0, 1, 2, 3, 4]

CFFI

CFFI 是 Python 的外部函数接口,是ctypes的一个有趣的替代方案。它不是标准库的一部分,但在 PyPI 上很容易获得作为cffi软件包。它与ctypes不同,因为它更注重重用纯 C 声明,而不是在单个模块中提供广泛的 Python API。它更加复杂,还具有一个功能,允许您自动将集成层的某些部分编译成扩展,使用 C 编译器。因此,它可以用作填补 C 扩展和ctypes之间差距的混合解决方案。

因为这是一个非常庞大的项目,不可能在几段话中简要介绍它。另一方面,不多说一些关于它的东西会很遗憾。我们已经讨论了使用ctypes集成标准库中的qsort()函数的一个例子。因此,展示这两种解决方案之间的主要区别的最佳方式将是使用cffi重新实现相同的例子。我希望一段代码能比几段文字更有价值:

from random import shuffle

from cffi import FFI

ffi = FFI()

ffi.cdef("""
void qsort(void *base, size_t nel, size_t width,
           int (*compar)(const void *, const void *));
""")
C = ffi.dlopen(None)

@ffi.callback("int(void*, void*)")
def cffi_int_compare(a, b):
    # Callback signature requires exact matching of types.
    # This involves less more magic than in ctypes
    # but also makes you more specific and requires
    # explicit casting
    int_a = ffi.cast('int*', a)[0]
    int_b = ffi.cast('int*', b)[0]
    print(" %s cmp %s" % (int_a, int_b))

    # according to qsort specification this should return:
    # * less than zero if a < b
    # * zero if a == b
    # * more than zero if a > b
    return int_a - int_b

def main():
    numbers = list(range(5))
    shuffle(numbers)
    print("shuffled: ", numbers)

    c_array = ffi.new("int[]", numbers)

    C.qsort(
        # pointer to the sorted array
        c_array,
        # length of the array
        len(c_array),
        # size of single array element
        ffi.sizeof('int'),
        # callback (pointer to the C comparison function)
        cffi_int_compare,
    )
    print("sorted:   ", list(c_array))

if __name__ == "__main__":
    main()

总结

本章解释了本书中最高级的主题之一。我们讨论了构建 Python 扩展的原因和工具。我们从编写纯 C 扩展开始,这些扩展仅依赖于 Python/C API,然后用 Cython 重新实现它们,以展示如果你选择合适的工具,它可以是多么容易。

仍然有一些理由可以以困难的方式做事,并且仅使用纯 C 编译器和Python.h头文件。无论如何,最好的建议是使用诸如 Cython 或 Pyrex(这里没有介绍)这样的工具,因为它将使您的代码库更易读和可维护。它还将使您免受由粗心的引用计数和内存管理引起的大部分问题的困扰。

我们对扩展的讨论以ctypes和 CFFI 作为集成共享库的替代方法的介绍结束。因为它们不需要编写自定义扩展来调用编译后的二进制文件中的函数,所以它们应该是你在这方面的首选工具,特别是如果你不需要使用自定义的 C 代码。

在下一章中,我们将从低级编程技术中短暂休息,并深入探讨同样重要的主题——代码管理和版本控制系统。

第八章:管理代码

在涉及多人的软件项目上工作是困难的。一切都变慢并变得更加困难。这是由于几个原因。本章将揭示这些原因,并尝试提供一些对抗它们的方法。

本章分为两部分,分别解释:

  • 如何使用版本控制系统

  • 如何建立持续开发流程

首先,代码库的演变非常重要,需要跟踪所有的更改,尤其是当许多开发人员在其上工作时。这就是版本控制系统的作用。

接下来,即使没有直接连接在一起的几个大脑仍然可以在同一个项目上工作。他们有不同的角色并且在不同的方面工作。因此,缺乏全局可见性会导致对其他人正在进行的工作和正在做的事情产生很多困惑。这是不可避免的,必须使用一些工具来提供持续的可见性并减轻问题。这是通过建立一系列持续开发流程的工具来实现的,如持续集成持续交付

现在我们将详细讨论这两个方面。

版本控制系统

版本控制系统VCS)提供了一种分享、同步和备份任何类型文件的方法。它们分为两个家族:

  • 集中式系统

  • 分布式系统

集中式系统

集中式版本控制系统基于一个保存文件并允许人们检入和检出对这些文件所做更改的单个服务器。原则非常简单——每个人都可以在自己的系统上获取文件的副本并对其进行操作。从那里,每个用户都可以将他/她的更改提交到服务器。它们将被应用并且修订号将被提升。然后其他用户将能够通过更新来同步他们的仓库副本以获取这些更改。

仓库通过所有的提交而发展,系统将所有修订版本存档到数据库中,以撤消任何更改或提供有关已完成的工作的信息:

集中式系统

图 1

在这种集中式配置中,每个用户都负责将他/她的本地仓库与主要仓库同步,以获取其他用户的更改。这意味着当本地修改的文件已被其他人更改并检入时,可能会发生一些冲突。在这种情况下,冲突解决机制是在用户系统上进行的,如下图所示:

集中式系统

图 2

这将帮助您更好地理解:

  1. Joe 提交了一个更改。

  2. Pamela 试图在同一个文件上进行更改检入。

  3. 服务器抱怨她的文件副本已经过时。

  4. Pamela 更新了她的本地副本。版本控制软件可能能够无缝地合并这两个版本(即,没有冲突)。

  5. Pamela 提交了一个包含 Joe 和她自己最新更改的新版本。

这个过程在涉及少数开发人员和少量文件的小型项目中是完全可以的。但对于更大的项目来说就会有问题。例如,复杂的更改涉及大量文件,这是耗时的,并且在整个工作完成之前将所有内容保留在本地是不可行的。这种方法的问题包括:

  • 这是危险的,因为用户可能会保留他/她的计算机更改,而这些更改不一定被备份

  • 在检查之前很难与其他人分享,而在完成之前分享它会使仓库处于不稳定状态,因此其他用户不会想要分享

集中式版本控制系统通过提供分支合并来解决了这个问题。可以从主要修订流中分叉出来,然后再回到主要流中。

图 3中,乔从修订版 2 开始创建一个新的分支来开发一个新功能。每次检入更改时,主流和他的分支中的修订版都会增加。在第 7 个修订版,乔完成了他的工作,并将更改提交到主干(主分支)。这通常需要一些冲突解决。

但是,尽管它们有优势,集中式版本控制系统也有一些缺陷:

  • 分支和合并是非常难处理的。它可能变成一场噩梦。

  • 由于系统是集中式的,离线提交更改是不可能的。这可能导致用户在重新联机时向服务器进行大量的单一提交。最后,对于像 Linux 这样的项目来说,它并不适用得很好,许多公司永久地维护着软件的自己的分支,并且没有每个人都有账户的中央仓库。

对于后者,一些工具使得离线工作成为可能,比如 SVK,但更根本的问题是集中式版本控制系统的工作方式。

集中式系统

图 3

尽管存在这些缺陷,集中式版本控制系统在许多公司中仍然非常受欢迎,主要是由于企业环境的惯性。许多组织使用的集中式版本控制系统的主要示例是SubversionSVN)和Concurrent Version SystemCVS)。集中式架构对版本控制系统的明显问题是为什么大多数开源社区已经转向更可靠的分布式版本控制系统DVCS)的架构。

分布式系统

分布式版本控制系统是对集中式版本控制系统缺陷的答案。它不依赖于人们使用的主服务器,而是依赖于点对点的原则。每个人都可以拥有和管理自己独立的项目仓库,并将其与其他仓库同步:

分布式系统

图 4

图 4中,我们可以看到这样一个系统的使用示例:

  1. 比尔从 HAL 的仓库拉取文件。

  2. 比尔对文件进行了一些更改。

  3. 阿米娜从比尔的仓库拉取文件。

  4. 阿米娜也改变了文件。

  5. 阿米娜推送更改到 HAL。

  6. 肯尼从 HAL拉取文件。

  7. 肯尼做出了改变。

  8. 肯尼定期推送他的更改到 HAL。

关键概念是人们推送拉取文件到其他仓库,这种行为会根据人们的工作方式和项目管理方式而改变。由于不再有主要仓库,项目的维护者需要为人们推送拉取更改定义一种策略。

此外,当人们使用多个仓库时,他们必须更加聪明。在大多数分布式版本控制系统中,修订号是针对每个仓库的,没有全局的修订号可以供任何人参考。因此,必须使用标签来使事情更清晰。它们是可以附加到修订版的文本标签。最后,用户需要负责备份他们自己的仓库,而在集中式基础设施中,通常是管理员设置备份策略。

分布式策略

当然,在公司环境中,如果所有人都朝着同一个目标努力工作,使用分布式版本控制系统仍然需要一个中央服务器。但是,该服务器的目的与集中式版本控制系统中的完全不同。它只是一个中心,允许所有开发人员在一个地方共享他们的更改,而不是在彼此的仓库之间进行拉取和推送。这样一个单一的中央仓库(通常称为上游)也作为所有团队成员个人仓库中跟踪的所有更改的备份。

可以采用不同的方法与 DVCS 中央存储库共享代码。最简单的方法是设置一个像常规集中式服务器一样运行的服务器,项目的每个成员都可以将自己的更改推送到一个公共流中。但这种方法有点简单化。它没有充分利用分布式系统,因为人们将使用推送和拉取命令的方式与集中式系统相同。

另一种方法是在服务器上提供几个具有不同访问级别的存储库:

  • 不稳定 存储库是每个人都可以推送更改的地方。

  • 稳定 存储库对于除发布经理之外的所有成员都是只读的。他们被允许从不稳定的存储库中拉取更改并决定应该合并什么。

  • 各种发布 存储库对应于发布,并且是只读的,正如我们将在本章后面看到的那样。

这使人们可以贡献,管理者可以审查更改,然后再将其提交到稳定的存储库。无论如何,根据所使用的工具,这可能是太多的开销。在许多分布式版本控制系统中,这也可以通过适当的分支策略来处理。

其他策略可以根据 DVCS 提供的无限组合进行制定。例如,使用 Git(git-scm.com/)的 Linux 内核基于星型模型,Linus Torvalds 维护官方存储库,并从一组他信任的开发人员那里拉取更改。在这种模型中,希望向内核推送更改的人将尝试将它们推送给受信任的开发人员,以便通过他们达到 Linus。

集中式还是分布式?

忘记集中式版本控制系统。

让我们诚实一点。集中式版本控制系统是过去的遗物。在大多数人都有全职远程工作的机会时,受到集中式 VCS 所有缺陷的限制是不合理的。例如,使用 CVS 或 SVN 时,您无法在离线时跟踪更改。这太愚蠢了。当您的工作场所的互联网连接暂时中断或中央存储库崩溃时,您该怎么办?您应该忘记所有的工作流程,只允许更改堆积直到情况改变,然后将其作为一个巨大的非结构化更新提交吗?不!

此外,大多数集中式版本控制系统无法有效处理分支方案。分支是一种非常有用的技术,可以让您在许多人在多个功能上工作的项目中限制合并冲突的数量。在 SVN 中,分支是如此荒谬,以至于大多数开发人员都尽量避免使用它。相反,大多数集中式 VCS 提供了一些文件锁定原语,应该被视为任何版本控制系统的反模式。关于每个版本控制工具的悲哀事实是,如果它包含危险的选项,您团队中的某个人最终将开始每天使用它。锁定是这样一个功能,它虽然减少了合并冲突,但会极大地降低整个团队的生产力。通过选择不允许这种糟糕工作流的版本控制系统,您正在创造一种更有可能使您的开发人员有效使用它的情况。

如果可以,请使用 Git

Git 目前是最流行的分布式版本控制系统。它是由 Linus Torvalds 创建的,用于维护 Linux 内核的版本,当其核心开发人员需要从之前使用的专有 BitKeeper 辞职时。

如果您尚未使用任何版本控制系统,则应从头开始使用 Git。如果您已经使用其他工具进行版本控制,请无论如何学习 Git。即使您的组织在不久的将来不愿切换到 Git,您也应该这样做,否则您可能会成为一个活化石。

我并不是说 Git 是最终和最好的 DVCS 版本控制系统。它肯定有一些缺点。最重要的是,它不是一个易于使用的工具,对新手来说非常具有挑战性。Git 的陡峭学习曲线已经成为网络上许多笑话的来源。可能有一些版本控制系统对许多项目表现更好,开源 Git 竞争者的完整列表会相当长。无论如何,Git 目前是最受欢迎的 DVCS,因此网络效应确实对它有利。

简而言之,网络效应导致使用流行工具的整体效益大于使用其他工具,即使稍微更好,也是因为其高度的流行(这就是 VHS 击败 Betamax 的原因)。很可能你的组织中的人,以及新员工,对 Git 都有一定的熟练程度,因此集成这个 DVCS 的成本会比尝试一些不那么流行的工具要低。

无论如何,了解更多并熟悉其他分布式版本控制系统总是好的。Git 最受欢迎的开源竞争对手是 Mercurial、Bazaar 和 Fossil。第一个特别好,因为它是用 Python 编写的,并且是 CPython 源代码的官方版本控制系统。有迹象表明,这种情况可能会在不久的将来发生变化,所以当你读到这本书的时候,CPython 开发人员可能已经在使用 Git 了。但这并不重要。这两个系统都很棒。如果没有 Git,或者它不那么受欢迎,我肯定会推荐 Mercurial。它的设计显然很美。它肯定没有 Git 那么强大,但对初学者来说更容易掌握。

Git flow 和 GitHub flow

与 Git 一起工作的非常流行和标准化的方法简称为Git flow。以下是该流程的主要规则的简要描述:

  • 通常有一个主要的工作分支,通常称为develop,所有最新版本应用的开发都在这里进行。

  • 新项目功能是在称为功能分支的单独分支上实现的,这些分支总是从develop分支开始。当功能完成并且代码经过适当测试后,该分支会合并回develop

  • develop中的代码稳定下来(没有已知的错误)并且需要发布新的应用程序版本时,会创建一个新的发布分支。这个发布分支通常需要额外的测试(广泛的 QA 测试、集成测试等),所以一定会发现新的错误。如果发布分支包括额外的更改(如错误修复),它们最终需要合并回develop分支。

  • 发布分支上的代码准备部署/发布时,它会合并到master分支,并且master上的最新提交会被标记为适当的版本标签。除了release分支,没有其他分支可以合并到master。唯一的例外是需要立即部署或发布的紧急修复。

  • 需要紧急发布的热修复总是在从master开始的单独分支上实现。修复完成后,它会合并到developmaster分支。热修复分支的合并就像普通的发布分支一样进行,因此必须正确标记,并相应地修改应用程序版本标识符。

图 5中展示了Git flow的视觉示例。对于那些从未以这种方式工作过,也从未使用过分布式版本控制系统的人来说,这可能有点压倒性。无论如何,如果你的组织没有任何正式的工作流程,值得尝试。它有多重好处,也解决了真正的问题。对于多名程序员团队,他们正在开发许多独立功能,并且需要为多个版本提供持续支持时,它尤其有用。

如果您想使用持续部署流程来实现持续交付,这种方法也很方便,因为在您的组织中始终清楚哪个代码版本代表了您的应用程序或服务的可交付版本。对于开源项目来说,它也是一个很好的工具,因为它为用户和活跃的贡献者提供了很好的透明度。

Git 流程和 GitHub 流程

图 5 展示了 Git 流程的视觉呈现

因此,如果您认为这个对Git 流程的简短总结有点意义,并且还没有吓到您,那么您应该深入研究该主题的在线资源。很难说出这个工作流的原始作者是谁,但大多数在线来源都指向 Vincent Driessen。因此,学习Git 流程的最佳起点材料是他的在线文章,标题为成功的 Git 分支模型(参考nvie.com/posts/a-successful-git-branching-model/)。

像其他流行的方法一样,Git 流程在互联网上受到了很多程序员的批评。Vincent Driessen 的文章中最受评论的事情是(严格技术性的)规则,即每次合并都应该创建一个代表该合并的新人工提交。Git 有一个选项可以进行快进合并,Vincent 不鼓励使用该选项。当然,这是一个无法解决的问题,因为执行合并的最佳方式完全是组织 Git 正在使用的主观问题。无论如何,Git 流程的真正问题在于它显然很复杂。完整的规则集非常长,因此很容易犯一些错误。您很可能希望选择一些更简单的东西。

GitHub 使用了这样的流程,并由 Scott Chacon 在他的博客上描述(参考scottchacon.com/2011/08/31/github-flow.html)。它被称为GitHub 流程,与Git 流程非常相似:

  • 主分支中的任何内容都可以部署

  • 新功能是在单独的分支上实现的

Git 流程的主要区别在于简单性。只有一个主要开发分支(master),它始终是稳定的(与Git 流程中的develop分支相反)。也没有发布分支,而且非常强调对代码进行标记。在 GitHub 上没有这样的需要,因为他们说,当某些东西合并到主分支时,通常会立即部署到生产环境。图 6 展示了 GitHub 流程示例的图表。

GitHub 流程似乎是一个适合希望为其项目设置持续部署流程的团队的良好且轻量级的工作流。当然,这样的工作流对于具有严格版本号概念的任何项目来说都是不可行的,至少没有进行任何修改。重要的是要知道始终可部署 master分支的主要假设是,没有适当的自动化测试和构建程序就无法保证。这就是持续集成系统要处理的问题,我们稍后会讨论这个问题。以下是一个展示 GitHub 流程示例的图表:

Git 流程和 GitHub 流程

图 6 展示了 GitHub 流程的视觉呈现

请注意,Git flowGitHub flow都只是分支策略,所以尽管它们的名字中都有Git,但它们并不局限于单一的分布式版本控制系统。Git flow的官方文章提到了在执行合并时应该使用的特定git命令参数,但这个基本思想几乎可以轻松应用于几乎任何其他分布式版本控制系统。事实上,由于它建议如何处理合并,Mercurial 似乎是更好的工具来使用这种特定的分支策略!GitHub flow也是一样。这是唯一一种带有一点特定开发文化的分支策略,因此它可以在任何允许你轻松创建和合并代码分支的版本控制系统中使用。

最后一点要记住的是,没有一种方法论是铁板一块,也没有人强迫你使用它。它们被创造出来是为了解决一些现有的问题,并防止你犯一些常见的错误。你可以接受它们的所有规则,或者根据自己的需要修改其中一些。它们是初学者的好工具,可以轻松地避开常见的陷阱。如果你不熟悉任何版本控制系统,那么你应该从像GitHub flow这样的轻量级方法开始,不做任何自定义修改。只有当你对 Git 或你选择的其他工具有足够的经验时,你才应该考虑更复杂的工作流。无论如何,随着你的熟练程度越来越高,你最终会意识到没有一种完美的工作流适用于每个项目。在一个组织中运行良好的东西不一定在其他组织中也能运行良好。

持续开发过程

有一些过程可以极大地简化你的开发,并减少将应用程序准备好发布或部署到生产环境所需的时间。它们的名字中经常带有continuous,我们将在本节讨论最重要和最受欢迎的过程。需要强调的是,它们是严格的技术过程,因此它们几乎与项目管理技术无关,尽管它们可以与后者高度契合。

我们将提到的最重要的过程是:

  • 持续集成

  • 持续交付

  • 持续部署

列出顺序很重要,因为它们中的每一个都是前一个的延伸。持续部署甚至可以简单地被认为是持续交付的变体。无论如何,我们将分别讨论它们,因为对一个组织来说只是一个微小的差异,对其他组织来说可能是至关重要的。

这些都是技术过程的事实意味着它们的实施严格依赖于适当工具的使用。它们背后的基本思想都相当简单,所以你可以构建自己的持续集成/交付/部署工具,但最好的方法是选择已经构建好的工具。这样,你就可以更多地专注于构建产品,而不是持续开发的工具链。

持续集成

持续集成,通常缩写为CI,是一种利用自动化测试和版本控制系统来提供完全自动化集成环境的过程。它可以与集中式版本控制系统一起使用,但在实践中,只有在使用良好的分布式版本控制系统来管理代码时,它才能充分发挥作用。

设置仓库是持续集成的第一步,这是一组从极限编程(XP)中出现的软件实践。这些原则在维基百科上清楚地描述了(en.wikipedia.org/wiki/Continuous_integration#The_Practices),并定义了一种确保软件易于构建、测试和交付的方式。

实施持续集成的第一个和最重要的要求是拥有一个完全自动化的工作流程,可以在给定的修订版中测试整个应用程序,以决定其是否在技术上正确。技术上正确意味着它没有已知的错误,并且所有功能都按预期工作。

CI 的一般理念是在合并到主流开发分支之前始终运行测试。这只能通过开发团队中的正式安排来处理,但实践表明这不是一种可靠的方法。问题在于,作为程序员,我们倾向于过于自信,无法对我们的代码进行批判性的审视。如果持续集成仅建立在团队安排上,它将不可避免地失败,因为一些开发人员最终会跳过他们的测试阶段,并将可能有缺陷的代码提交到应始终保持稳定的主流开发分支。而且,实际上,即使是简单的更改也可能引入关键问题。

明显的解决方案是利用专用构建服务器,它在代码库发生更改时自动运行所有必需的应用程序测试。有许多工具可以简化这个过程,并且它们可以很容易地集成到诸如 GitHub 或 Bitbucket 等版本控制托管服务以及 GitLab 等自托管服务中。使用这些工具的好处是开发人员可以在本地仅运行与他当前工作相关的选定测试子集,并将潜在耗时的整个集成测试套件留给构建服务器。这确实加快了开发速度,但仍然减少了新功能破坏主流代码分支中现有稳定代码的风险。

使用专用构建服务器的另一个好处是可以在接近生产环境的环境中运行测试。开发人员还应尽可能使用与生产环境尽可能匹配的环境,并且有很好的工具可以做到这一点(例如 Vagrant);然而,在任何组织中强制执行这一点是很困难的。您可以在一个专用的构建服务器上甚至在一个构建服务器集群上轻松实现这一点。许多 CI 工具通过利用各种虚拟化工具来确保测试始终在相同的、完全新鲜的测试环境中运行,使这一点变得更加不成问题。

拥有一个构建服务器对于创建必须以二进制形式交付给用户的桌面或移动应用程序也是必不可少的。显而易见的做法是始终在相同的环境中执行这样的构建过程。几乎每个 CI 系统都考虑到应用程序通常需要在测试/构建完成后以二进制形式下载。这样的构建结果通常被称为构建产物

因为 CI 工具起源于大多数应用程序都是用编译语言编写的时代,它们大多使用术语“构建”来描述它们的主要活动。对于诸如 C 或 C ++之类的语言,这是显而易见的,因为如果不构建(编译)应用程序,则无法运行和测试。对于 Python 来说,这就显得有点不合理,因为大多数程序以源代码形式分发,并且可以在没有任何额外构建步骤的情况下运行。因此,在我们的语境中,当谈论持续集成时,“构建”和“测试”这两个术语经常可以互换使用。

测试每次提交

持续集成的最佳方法是在每次更改推送到中央存储库时对整个测试套件进行测试。即使一个程序员在单个分支中推送了一系列多个提交,通常也有意义对每个更改进行单独测试。如果您决定仅测试单个存储库推送中的最新更改集,那么将更难找到可能在中间某个地方引入的潜在回归问题的源头。

当然,许多分布式版本控制系统,如 Git 或 Mercurial,允许你通过提供二分历史更改的命令来限制搜索回归源的时间,但实际上,将其作为持续集成过程的一部分自动完成会更加方便。

当然,还有一个问题是,一些测试套件运行时间非常长,可能需要数十分钟甚至数小时才能完成。一个服务器可能无法在给定时间内处理每次提交的所有构建。这将使等待结果的时间更长。事实上,长时间运行的测试本身就是一个问题,稍后将在问题 2-构建时间过长部分进行描述。现在,你应该知道,你应该始终努力测试推送到仓库的每次提交。如果你没有能力在单个服务器上做到这一点,那么就建立整个构建集群。如果你使用的是付费服务,那么就支付更高价格的计划,进行更多并行构建。硬件是便宜的,你开发人员的时间不是。最终,通过拥有更快的并行构建和更昂贵的 CI 设置,你将节省更多的钱,而不是通过跳过对选定更改的测试来节省钱。

通过 CI 进行合并测试

现实是复杂的。如果功能分支上的代码通过了所有测试,并不意味着当它合并到稳定主干分支时构建不会失败。在Git flowGitHub flow部分提到的两种流行的分支策略都假设合并到master分支的代码总是经过测试并可部署。但是如果你还没有执行合并,你怎么能确定这个假设是成立的呢?对于Git flow来说,这个问题相对较小(如果实施得当并且使用得当),因为它强调发布分支。但对于简单的GitHub flow来说,这是一个真正的问题,因为合并到master通常会导致冲突,并且很可能会引入测试回归。即使对于Git flow来说,这也是一个严重的问题。这是一个复杂的分支模型,所以当人们使用它时肯定会犯错误。因此,如果你不采取特殊预防措施,你永远无法确定合并后master上的代码是否会通过测试。

解决这个问题的一个方法是将合并功能分支到稳定主干分支的责任委托给你的 CI 系统。在许多 CI 工具中,你可以轻松地设置一个按需构建作业,该作业将在本地合并特定功能分支到稳定分支,并且只有在通过了所有测试后才将其推送到中央仓库。如果构建失败,那么这样的合并将被撤销,使稳定分支保持不变。当然,在快节奏的项目中,这种方法会变得更加复杂,因为同时开发许多功能分支会存在高风险的冲突,这些冲突无法被任何 CI 系统自动解决。当然,针对这个问题也有解决方案,比如在 Git 中进行变基。

如果你考虑进一步实施持续交付流程,或者如果你的工作流程严格规定稳定分支中的所有内容都是可发布的,那么将任何东西合并到版本控制系统的稳定分支中实际上是必须的。

矩阵测试

如果你的代码需要在不同的环境中进行测试,矩阵测试是一个非常有用的工具。根据你的项目需求,你的 CI 解决方案对这种功能的直接支持可能更或更少需要。

解释矩阵测试的最简单方法是以一些开源的 Python 软件包为例。例如,Django 是一个严格指定支持的 Python 语言版本的项目。1.9.3 版本列出了运行 Django 代码所需的 Python 2.7、Python 3.4 和 Python 3.5 版本。这意味着每次 Django 核心开发人员对项目进行更改时,必须在这三个 Python 版本上执行完整的测试套件,以支持这一说法。如果在一个环境中甚至有一个测试失败,整个构建必须标记为失败,因为可能违反了向后兼容性约束。对于这样一个简单的情况,你不需要 CI 的任何支持。有一个很棒的 Tox 工具(参见tox.readthedocs.org/),除了其他功能外,它还允许你在隔离的虚拟环境中轻松运行不同 Python 版本的测试套件。这个实用程序也可以很容易地用于本地开发。

但这只是最简单的例子。不少应用程序必须在多个环境中进行测试,其中必须测试完全不同的参数。举几个例子:

  • 不同的操作系统

  • 不同的数据库

  • 不同版本的后备服务

  • 不同类型的文件系统

完整的组合形成了一个多维环境参数矩阵,这就是为什么这样的设置被称为矩阵测试。当你需要这样一个深层测试工作流程时,很可能需要一些集成支持来进行矩阵测试。对于可能的组合数量很大,你还需要一个高度可并行化的构建过程,因为每次在矩阵上运行都需要大量的工作来自你的构建服务器。在某些情况下,如果你的测试矩阵有太多维度,你将被迫做一些权衡。

持续交付

持续交付是持续集成思想的一个简单延伸。这种软件工程方法旨在确保应用程序可以随时可靠地发布。持续交付的目标是在短时间内发布软件。它通常通过允许将应用程序的变更逐步交付到生产环境中来降低成本和发布软件的风险。

构建成功的持续交付过程的主要先决条件是:

  • 可靠的持续集成过程

  • 自动部署到生产环境的流程(如果项目有生产环境的概念)

  • 一个明确定义的版本控制系统工作流程或分支策略,允许你轻松定义哪个软件版本代表可发布的代码

在许多项目中,自动化测试并不足以可靠地告诉你软件的给定版本是否真的准备好发布。在这种情况下,通常由熟练的 QA 人员执行额外的手动用户验收测试。根据你的项目管理方法论,这可能还需要客户的批准。这并不意味着如果你的验收测试必须由人工手动执行,你就不能使用Git flowGitHub flow或类似的分支策略。这只是将你的稳定和发布分支的语义从准备部署更改为准备进行用户验收测试和批准

此外,前面的段落并不改变代码部署应始终自动化的事实。我们已经在第六章中讨论了一些工具和自动化的好处,部署代码。正如在那里所述,它将始终降低新版本发布的成本和风险。此外,大多数可用的 CI 工具都允许你设置特殊的构建目标,而不是测试,将为你执行自动化部署。在大多数持续交付过程中,这通常是由授权人员手动触发的,当他们确信已经获得了必要的批准并且所有验收测试都以成功结束时。

持续部署

持续部署是将持续交付推向更高水平的过程。对于所有验收测试都是自动化的项目来说,这是一个完美的方法,而且不需要客户的手动批准。简而言之,一旦代码合并到稳定分支(通常是master),它就会自动部署到生产环境。

这种方法似乎非常好和稳健,但并不经常使用,因为很难找到一个不需要在发布新版本之前进行手动 QA 测试和某人批准的项目。无论如何,这是可行的,一些公司声称他们正在以这种方式工作。

为了实现持续部署,你需要与持续交付过程相同的基本先决条件。此外,对合并到稳定分支的更加谨慎的方法通常是必需的。在持续集成中合并到master的内容通常会立即进入生产环境。因此,将合并任务交给你的 CI 系统是合理的,就像在通过 CI 进行合并测试部分中所解释的那样。

持续集成的流行工具

现在有大量的持续集成工具可供选择。它们在易用性和可用功能上有很大的差异,几乎每一个都有一些其他工具缺乏的独特功能。因此,很难给出一个好的一般性建议,因为每个项目的需求完全不同,开发工作流也不同。当然,有一些很棒的免费开源项目,但付费托管服务也值得研究。这是因为尽管像 Jenkins 或 Buildbot 这样的开源软件可以免费安装,但错误地认为它们是免费运行的。拥有自己的 CI 系统还需要硬件和维护成本。在某些情况下,支付这样的服务可能比支付额外的基础设施成本和花费时间解决开源 CI 软件中的任何问题更便宜。但是,你需要确保将代码发送到任何第三方服务是否符合公司的安全政策。

在这里,我们将回顾一些流行的免费开源工具,以及付费托管服务。我真的不想为任何供应商做广告,所以我们只讨论那些对开源项目免费提供的工具,以证明这种相当主观的选择。我们不会给出最佳建议,但我们会指出任何解决方案的优缺点。如果你还在犹豫不决,下一节描述常见持续集成陷阱的部分应该能帮助你做出明智的决定。

Jenkins

Jenkins (jenkins-ci.org) 似乎是最受欢迎的持续集成工具。它也是这一领域最古老的开源项目之一,与 Hudson 一起(这两个项目的开发分离,Jenkins 是 Hudson 的一个分支)。

Jenkins

图 7 Jenkins 主界面预览

Jenkins 是用 Java 编写的,最初主要用于构建用 Java 语言编写的项目。这意味着对于 Java 开发人员来说,它是一个完美的 CI 系统,但如果您想将其与其他技术栈一起使用,可能需要花费一些精力。

Jenkins 的一个重大优势是其非常广泛的功能列表,这些功能已经直接实现在 Jenkins 中。从 Python 程序员的角度来看,最重要的功能是能够理解测试结果。Jenkins 不仅提供有关构建成功的简单二进制信息,还能够以表格和图形的形式呈现运行期间执行的所有测试的结果。当然,这不会自动工作,您需要以特定格式提供这些结果(默认情况下,Jenkins 理解 JUnit 文件)在构建期间。幸运的是,许多 Python 测试框架能够以机器可读的格式导出结果。

以下是 Jenkins 在其 Web UI 中单元测试结果的示例演示:

Jenkins

图 8 展示了 Jenkins 中单元测试结果

以下截图说明了 Jenkins 如何呈现额外的构建信息,例如趋势或可下载的构建产物:

Jenkins

图 9 示例 Jenkins 项目上的测试结果趋势图

令人惊讶的是,Jenkins 的大部分功能并不来自其内置功能,而是来自一个庞大的免费插件库。从干净的安装中可用的内容对于 Java 开发人员可能很棒,但使用不同技术的程序员将需要花费大量时间使其适用于其项目。甚至对 Git 的支持也是由一些插件提供的。

Jenkins 如此易于扩展是很棒的,但这也有一些严重的缺点。您最终将依赖于安装的插件来驱动您的持续集成过程,这些插件是独立于 Jenkins 核心开发的。大多数流行插件的作者都会尽力使其与 Jenkins 的最新版本保持兼容并及时更新。然而,较小社区的扩展将更新频率较低,有一天您可能不得不放弃它们或推迟核心系统的更新。当需要紧急更新(例如安全修复)时,这可能是一个真正的问题,但您的 CI 过程中一些关键插件将无法与新版本一起使用。

提供主 CI 服务器的基本 Jenkins 安装也能够执行构建。这与其他 CI 系统不同,其他系统更加注重分发并严格区分主构建服务器和从构建服务器。这既有利也有弊。一方面,它允许您在几分钟内设置一个完全工作的 CI 服务器。当然,Jenkins 支持将工作推迟到构建从节点,因此在未来需要时可以进行扩展。另一方面,Jenkins 通常性能不佳,因为它部署在单服务器设置中,其用户抱怨性能问题而未为其提供足够的资源。向 Jenkins 集群添加新的构建节点并不困难。对于那些习惯于单服务器设置的人来说,这似乎更多是一种心理挑战而不是技术问题。

Buildbot

Buildbot (buildbot.net/)是一个用 Python 编写的软件,可以自动化任何类型的软件项目的编译和测试周期。它可以配置为对源代码存储库上的每个更改生成一些构建,启动一些测试,然后提供一些反馈:

Buildbot

图 10 CPython 3.x 分支的 Buildbot 瀑布视图

例如,CPython 核心使用此工具,可以在buildbot.python.org/all/waterfall?&category=3.x.stable中找到。

Buildbot 的默认构建结果表示是一个瀑布视图,如图 10所示。每一列对应一个构建,由步骤组成,并与一些构建 从机相关联。整个系统由构建主机驱动:

  • 构建主机集中和驱动一切

  • 构建是用于构建应用程序并对其运行测试的一系列步骤

  • 一个步骤是一个原子命令,例如:

  • 检出项目的文件

  • 构建应用程序

  • 运行测试

构建从机是负责运行构建的机器。只要它能够连接到构建主机,它可以位于任何位置。由于这种架构,Buildbot 的扩展性非常好。所有繁重的工作都是在构建从机上完成的,你可以拥有任意数量的构建从机。

Buildbot 的设计非常简单和清晰,使其非常灵活。每个构建步骤只是一个单独的命令。Buildbot 是用 Python 编写的,但它完全与语言无关。因此,构建步骤可以是任何东西。进程退出代码用于决定步骤是否以成功结束,步骤命令的所有标准输出默认情况下都会被捕获。大多数测试工具和编译器遵循良好的设计实践,并使用适当的退出代码指示失败,并在stdoutstderr输出流中返回可读的错误/警告消息。如果这不是真的,通常可以很容易地用 Bash 脚本包装它们。在大多数情况下,这是一个简单的任务。由于这个原因,许多项目可以只需很少的努力就可以与 Buildbot 集成。

Buildbot 的另一个优势是,它支持许多版本控制系统,无需安装任何额外的插件:

  • CVS

  • Subversion

  • Perforce

  • Bzr

  • Darcs

  • Git

  • Mercurial

  • Monotone

Buildbot 的主要缺点是缺乏用于呈现构建结果的高级呈现工具。例如,其他项目(如 Jenkins)可以考虑在构建过程中运行的单元测试。如果你用适当的格式(通常是 XML)呈现测试结果数据,它们可以以表格和图形的形式呈现所有测试。Buildbot 没有这样的内置功能,这是它为了灵活性和简单性所付出的代价。如果你需要一些额外的功能,你需要自己构建它们或者寻找一些定制的扩展。另一方面,由于这种简单性,更容易推理 Buildbot 的行为并维护它。因此,总是有一个权衡。

Travis CI

Travis CI (travis-ci.org/)是一个以软件即服务形式出售的持续集成系统。对企业来说是付费服务,但在 GitHub 上托管的开源项目中可以完全免费使用。

Travis CI

图 11 django-userena 项目的 Travis CI 页面显示了构建矩阵中的失败构建

当然,这是它定价计划中的免费部分,这使它非常受欢迎。目前,它是 GitHub 上托管的项目中最受欢迎的 CI 解决方案之一。但与 Buildbot 或 Jenkins 等旧项目相比,最大的优势在于构建配置的存储方式。所有构建定义都在项目存储库的根目录中的一个.travis.yml文件中提供。Travis 只与 GitHub 一起工作,因此如果你启用了这样的集成,你的项目将在每次提交时进行测试,只要有一个.travis.yml文件。

在项目的代码存储库中拥有整个 CI 配置确实是一个很好的方法。这使得整个过程对开发人员来说更加清晰,也允许更灵活性。在必须提供构建配置以单独构建服务器的系统中(使用 Web 界面或通过服务器配置),当需要向测试装置添加新内容时,总会有一些额外的摩擦。在一些只有选定员工被授权维护 CI 系统的组织中,这确实减慢了添加新构建步骤的过程。而且,有时需要使用完全不同的程序测试代码的不同分支。当构建配置在项目源代码中可用时,这样做就容易得多。

Travis 的另一个重要特性是它强调在干净的环境中运行构建。每个构建都在一个完全新的虚拟机中执行,因此没有一些持久状态会影响构建结果的风险。Travis 使用一个相当大的虚拟机镜像,因此您可以使用许多开源软件和编程环境,而无需额外安装。在这个隔离的环境中,您拥有完全的管理权限,因此可以下载和安装任何您需要执行构建的东西,而.travis.yml文件的语法使其非常容易。不幸的是,您对可用的操作系统没有太多选择。Travis 不允许提供自己的虚拟机镜像,因此您必须依赖提供的非常有限的选项。通常根本没有选择,所有构建都必须在某个版本的 Ubuntu 或 Mac OS X 中进行(在撰写本书时仍处于实验阶段)。有时可以选择系统的某个旧版本或新测试环境的预览,但这种可能性总是暂时的。总是有办法绕过这一点。您可以在 Travis 提供的虚拟机内运行另一个虚拟机。这应该是一些允许您在项目源代码中轻松编码虚拟机配置的东西,比如 Vagrant 或 Docker。但这将增加构建的时间,因此这不是您将采取的最佳方法。以这种方式堆叠虚拟机可能不是在不同操作系统下执行测试的最佳和最有效的方法。如果这对您很重要,那么这表明 Travis 不适合您。

Travis 最大的缺点是它完全锁定在 GitHub 上。如果您想在开源项目中使用它,那么这不是什么大问题。对于企业和闭源项目,这基本上是一个无法解决的问题。

GitLab CI

GitLab CI 是 GitLab 项目的一部分。它既可以作为付费服务(企业版)提供,也可以作为您自己基础设施上托管的开源项目(社区版)提供。开源版本缺少一些付费服务功能,但在大多数情况下,它是任何公司从管理版本控制存储库和持续集成的软件中所需要的一切。

GitLab CI 在功能集方面与 Travis 非常相似。它甚至使用存储在.gitlab-ci.yml文件中的非常相似的 YAML 语法进行配置。最大的区别在于,GitLab 企业版定价模型不为开源项目提供免费帐户。社区版本身是开源的,但您需要拥有一些自己的基础设施才能运行它。

与 Travis 相比,GitLab 在执行环境上具有明显的优势。不幸的是,在环境隔离方面,GitLab 的默认构建运行程序略逊一筹。名为 Gitlab Runner 的进程在相同的环境中执行所有构建步骤,因此它更像 Jenkins 或 Buildbot 的从属服务器。幸运的是,它与 Docker 兼容,因此你可以通过基于容器的虚拟化轻松添加更多隔离,但这需要一些努力和额外的设置。在 Travis 中,你可以立即获得完全隔离。

选择合适的工具和常见陷阱

正如前面所说,没有完美的 CI 工具适用于每个项目,更重要的是,适用于每个组织和使用的工作流。我只能为托管在 GitHub 上的开源项目提供一个建议。对于平台无关代码的小型代码库,Travis CI 似乎是最佳选择。它易于开始,并且几乎可以立即获得最小量的工作的满足感。

对于闭源项目来说,情况完全不同。可能需要在不同的设置中评估几个 CI 系统,直到能够决定哪一个最适合你。我们只讨论了四种流行的工具,但这应该是一个相当代表性的群体。为了让你的决定变得更容易一些,我们将讨论一些与持续集成系统相关的常见问题。在一些可用的 CI 系统中,可能会比其他系统更容易犯某些类型的错误。另一方面,一些问题可能对每个应用程序都不重要。我希望通过结合你的需求的知识和这个简短的总结,能够更容易地做出正确的第一个决定。

问题 1 - 构建策略太复杂

一些组织喜欢在合理的水平之外正式化和结构化事物。在创建计算机软件的公司中,这在两个领域尤其真实:项目管理工具和 CI 服务器上的构建策略。

过度配置项目管理工具通常会导致在 JIRA(或任何其他管理软件)上处理问题工作流程变得如此复杂,以至于无法用图表表示。如果你的经理有这种配置/控制狂,你可以和他谈谈,或者换一个经理(即:辞职)。不幸的是,这并不能可靠地保证在这方面有任何改进。

但是当涉及到 CI 时,我们可以做更多。持续集成工具通常由我们开发人员维护和配置。这些是我们的工具,应该改善我们的工作。如果有人对每个开关和旋钮都有无法抗拒的诱惑,那么他应该远离 CI 系统的配置,尤其是如果他的主要工作是整天说话和做决定。

没有必要制定复杂的策略来决定哪个提交或分支应该被测试。也不需要将测试限制在特定的标签上。也不需要排队提交以执行更大的构建。也不需要通过自定义提交消息禁用构建。你的持续集成过程应该简单易懂。测试一切!一直测试!就这样!如果没有足够的硬件资源来测试每个提交,那就增加更多的硬件。记住,程序员的时间比硅片更贵。

问题 2 - 构建时间太长

长时间的构建是任何开发人员的性能杀手。如果你需要等待几个小时才能知道你的工作是否做得正确,那么你就无法高效地工作。当然,在测试功能时有其他事情要做会有所帮助。无论如何,作为人类,我们真的很擅长多任务处理。在不同问题之间切换需要时间,并且最终会将我们的编程性能降至零。在同时处理多个问题时,保持专注是非常困难的。

解决方案非常简单:不惜一切代价保持构建速度快。首先,尝试找到瓶颈并对其进行优化。如果构建服务器的性能是问题,那么尝试扩展。如果这没有帮助,那么将每个构建拆分成较小的部分并并行化。

有很多解决方案可以加快缓慢的构建测试,但有时候这个问题无法解决。例如,如果你有自动化的浏览器测试或需要对外部服务进行长时间调用,那么很难在某个硬性限制之外提高性能。例如,当你的 CI 中自动接受测试的速度成为问题时,你可以放松测试一切,始终测试的规则。对程序员来说,最重要的通常是单元测试和静态分析。因此,根据你的工作流程,缓慢的浏览器测试有时可以推迟到准备发布时。

解决缓慢构建运行的另一个方法是重新思考应用程序的整体架构设计。如果测试应用程序需要很长时间,很多时候这是一个信号,表明它应该被拆分成几个可以独立开发和测试的组件。将软件编写为庞大的单体是通往失败的最短路径之一。通常,任何软件工程过程都会因为软件没有适当模块化而失败。

问题 3 - 外部作业定义

一些持续集成系统,特别是 Jenkins,允许你完全通过 Web UI 设置大部分构建配置和测试过程,而无需触及代码存储库。但你真的应该避免将除构建步骤/命令的简单入口之外的任何东西放入外部系统。这是一种可能会带来麻烦的 CI 反模式。

你的构建和测试过程通常与你的代码库紧密相关。如果你将其整个定义存储在 Jenkins 或 Buildbot 等外部系统中,那么要对该过程进行更改将非常困难。

举一个由全局外部构建定义引入的问题的例子,假设我们有一些开源项目。最初的开发很忙碌,我们并不关心任何样式指南。我们的项目很成功,所以开发需要另一个重大发布。过了一段时间,我们从0.x版本移动到1.0,并决定重新格式化所有代码以符合 PEP 8 指南。将静态分析检查作为 CI 构建的一部分是一个很好的方法,所以我们决定将pep8工具的执行添加到我们的构建定义中。如果我们只有一个全局外部构建配置,那么如果需要对旧版本的代码进行改进,就会出现问题。假设应用程序的两个分支:0.x1.y都需要修复一个关键的安全问题。我们知道 1.0 版本以下的任何内容都不符合样式指南,而新引入的针对 PEP 8 的检查将标记构建为失败。

解决问题的方法是尽可能将构建过程的定义与源代码保持接近。对于一些 CI 系统(如 Travis CI 和 GitLab CI),您默认就可以得到这样的工作流程。对于其他解决方案(如 Jenkins 和 Buildbot),您需要额外小心,以确保大部分构建过程都包含在您的代码中,而不是一些外部工具配置中。幸运的是,您有很多选择可以实现这种自动化。

  • Bash 脚本

  • Makefiles

  • Python 代码

问题 4 - 缺乏隔离

我们已经多次讨论了在 Python 编程时隔离的重要性。我们知道在包级别上隔离 Python 执行环境的最佳方法是使用 virtualenvpython -m venv。不幸的是,在测试代码以进行持续集成流程的目的时,通常还不够。测试环境应尽可能接近生产环境,而要在没有额外的系统级虚拟化的情况下实现这一点确实很困难。

在构建应用程序时,如果不确保适当的系统级隔离,可能会遇到的主要问题有:

  • 在构建之间持久存在的一些状态,无论是在文件系统上还是在后备服务中(缓存、数据库等)

  • 通过环境、文件系统或后备服务进行多个构建或测试的接口

  • 由于生产操作系统的特定特性而可能发生的问题没有在构建服务器上被捕捉到

如果您需要对同一应用程序执行并发构建,甚至并行化单个构建,上述问题尤为棘手。

一些 Python 框架(主要是 Django)为数据库提供了一些额外的隔离级别,试图确保在运行测试之前存储将被清理。py.test 还有一个非常有用的扩展叫做 pytest-dbfixtures(参见 github.com/ClearcodeHQ/pytest-dbfixtures),它甚至可以更可靠地实现这一点。无论如何,这样的解决方案会增加构建的复杂性,而不是减少它。始终在每次构建时清除虚拟机(类似于 Travis CI 的风格)似乎是一种更优雅、更简单的方法。

总结

我们在本章中学到了以下内容:

  • 集中式和分布式版本控制系统之间有什么区别

  • 为什么您应该更喜欢分布式版本控制系统而不是集中式

  • 为什么 Git 应该是您选择分布式版本控制系统的首选

  • Git 的常见工作流程和分支策略是什么

  • 什么是持续集成/交付/部署,以及允许您实施这些流程的流行工具是什么

下一章将解释如何清晰地记录您的代码。

第九章:记录你的项目

文档经常被开发者忽视,有时也被管理者忽视。这往往是由于在开发周期结束时缺乏时间,以及人们认为自己写作水平不佳。其中一些确实写得不好,但大多数人能够制作出良好的文档。

无论如何,结果都是由匆忙写成的文档组成的混乱文档。大多数时候,开发者都讨厌做这种工作。当需要更新现有文档时,情况变得更糟。许多项目只提供质量低劣、过时的文档,因为管理者不知道如何处理它。

但在项目开始时建立文档流程,并将文档视为代码模块,可以使文档编写变得更容易。遵循一些规则时,写作甚至可以成为一种乐趣。

本章提供了一些开始记录项目的提示:

  • 总结最佳实践的技术写作的七条规则

  • reStructuredText 入门,这是 Python 项目中使用的纯文本标记语法

  • 构建良好项目文档的指南

技术写作的七条规则

写好文档在许多方面比写代码更容易。大多数开发者认为这很难,但遵循一套简单的规则后,它变得非常容易。

我们这里讨论的不是写一本诗集,而是一篇全面的文本,可以用来理解设计、API 或构成代码库的任何内容。

每个开发者都能够制作这样的材料,本节提供了七条规则,可以在所有情况下应用:

  • 分两步写:先关注想法,然后再审查和塑造你的文本。

  • 针对读者群:谁会阅读它?

  • 使用简单的风格:保持简洁明了。使用良好的语法。

  • 限制信息的范围:一次引入一个概念。

  • 使用现实的代码示例:"Foos"和"bars"应该避免。

  • 使用轻量但足够的方法:你不是在写一本书!

  • 使用模板:帮助读者养成习惯。

这些规则大多受到 Andreas Rüping 的《敏捷文档:软件项目轻量级文档的模式指南》(Wiley)的启发和改编,该书侧重于在软件项目中制作最佳文档。

分两步写

Peter Elbow 在《写作的力量:掌握写作过程的技巧》(牛津大学出版社)中解释说,任何人几乎不可能一次写出完美的文本。问题在于,许多开发者写文档并试图直接得到一些完美的文本。他们成功的唯一方法是在每写两个句子后停下来阅读它们并做一些修改。这意味着他们同时关注文本的内容和风格。

这对大脑来说太难了,结果往往不如预期的那么好。在完全思考其含义之前,花费了大量时间和精力来打磨文本的风格和形状。

另一种方法是放弃文本的风格和组织,专注于其内容。所有想法都被记录在纸上,无论它们是如何书写的。开发者开始写一个连续的流,不会在犯语法错误或任何与内容无关的事情时停下来。例如,只要想法被写下来,句子几乎无法理解并不重要。他/她只是以粗略的组织写下他想说的话。

通过这样做,开发者专注于他/她想要表达的内容,可能会从他/她的头脑中得到比最初想象的更多的内容。

进行自由写作时的另一个副作用是,与主题无关的其他想法会很容易浮现在脑海中。一个好的做法是,当它们出现时,在第二张纸或屏幕上把它们写下来,这样它们就不会丢失,然后回到主要写作上。

第二步是回读整个文本,并对其进行润色,使其对每个人都能理解。润色文本意味着增强其风格,纠正其错误,稍微重新组织它,并删除任何多余的信息。

当写作文档的时间有限时,一个好的做法是将这段时间分成两半——一半用于写作内容,一半用于清理和组织文本。

注意

专注于内容,然后是风格和整洁。

针对读者群

在撰写内容时,作家应考虑一个简单的问题:谁会阅读它?

这并不总是显而易见,因为技术文本解释了软件的工作原理,并且通常是为可能获得和使用代码的每个人而写的。读者可能是正在寻找适当技术解决方案的研究人员,或者需要用它实现功能的开发人员。设计师也可能会阅读它,以了解包是否从架构的角度符合他/她的需求。

良好的文档应遵循一个简单的规则——每个文本只应有一种读者。

这种理念使写作变得更容易。作家清楚地知道自己正在与何种读者打交道。他/她可以提供简明而准确的文档,而不是模糊地面向各种读者。

一个好的做法是提供一个简短的介绍性文本,简要解释文档的内容,并引导读者到适当的部分:

Atomisator is a product that fetches RSS feeds and saves them in a database, with a filtering process.

If you are a developer, you might want to look at the API description (api.txt)

If you are a manager, you can read the features list and the FAQ (features.txt)

If you are a designer, you can read the architecture and infrastructure notes (arch.txt)

通过这种方式引导读者,你可能会产生更好的文档。

注意

在开始写作之前了解你的读者群。

使用简单的风格

塞思·戈丁是营销领域畅销书作家之一。你可能想阅读《Ideavirus 的释放》,哈希特图书,它可以在互联网上免费获取。

不久前,他在博客上进行了一项分析,试图理解为什么他的书卖得这么好。他列出了营销领域所有畅销书的清单,并比较了它们每句话的平均字数。

他意识到他的书每句话的字数最少(十三个字)。塞思解释说,这个简单的事实证明读者更喜欢简短而简单的句子,而不是长而时髦的句子。

通过保持句子简短和简单,你的写作将消耗更少的大脑力量来提取、处理和理解其内容。技术文档的撰写旨在为读者提供软件指南。它不是一部小说,应该更接近你的微波炉使用说明书,而不是最新的斯蒂芬·金小说。

要牢记的一些建议是:

  • 使用简单的句子。句子不应超过两行。

  • 每个段落应由三到四个句子组成,最多表达一个主要观点。让你的文本有呼吸空间。

  • 不要重复太多。避免新闻报道风格,其中的想法一遍又一遍地重复,以确保它们被理解。

  • 不要使用多种时态。大多数情况下,现在时就足够了。

  • 如果你不是一个真正优秀的作家,就不要在文本中开玩笑。在技术文本中搞笑真的很难,很少有作家能掌握。如果你真的想表达一些幽默,把它放在代码示例中,你就没问题了。

注意

你不是在写小说,所以尽量保持风格简单。

限制信息范围

在软件文档中有一个简单的坏迹象——你正在寻找一些你知道存在的信息,但找不到它。在阅读目录表一段时间后,你开始在文件中使用 grep 尝试几个单词组合,但找不到你要找的东西。

当作者没有按主题组织他们的文本时,就会发生这种情况。他们可能提供了大量的信息,但它只是以单一或非逻辑的方式聚集在一起。例如,如果读者正在寻找你的应用程序的整体情况,他或她不应该阅读 API 文档——那是一个低级的问题。

为了避免这种效果,段落应该被聚集在一个有意义的标题下,全局文档标题应该用简短的短语来概括内容。

目录可以由所有章节的标题组成。

组成标题的一个简单做法是问自己,“我会在 Google 中输入什么短语来找到这个部分?”

使用现实的代码示例

Foobar是不好的用法。当读者试图理解代码片段的工作方式时,如果有一个不切实际的例子,将会使理解变得更加困难。

为什么不使用一个真实的例子呢?一个常见的做法是确保每个代码示例都可以在真实的程序中剪切和粘贴。

为了展示一个糟糕的用法示例,让我们假设我们想展示如何使用parse()函数:

>>> from atomisator.parser import parse
>>> # Let's use it:
>>> stuff = parse('some-feed.xml')
>>> next(stuff)
{'title': 'foo', 'content': 'blabla'}

一个更好的例子是,当解析器知道如何使用 parse 函数返回一个 feed 内容时,它作为一个顶级函数可用:

>>> from atomisator.parser import parse
>>> # Let's use it:
>>> my_feed = parse('http://tarekziade.wordpress.com/feed')
>>> next(my_feed)
{'title': 'eight tips to start with python', 'content': 'The first tip is..., ...'}

这种细微的差别可能听起来有些过分,但事实上它会使你的文档更有用。读者可以将这些行复制到 shell 中,理解 parse 使用 URL 作为参数,并且它返回一个包含博客条目的迭代器。

当然,提供一个现实的例子并不总是可能或可行的。这对于非常通用的代码尤其如此。即使这本书中也有一些模糊的foobar字符串的出现,其中名称上下文并不重要。无论如何,你应该始终努力将这种不切实际的例子的数量减少到最低。

注意

代码示例应该直接在真实程序中可重用。

使用轻量但足够的方法

在大多数敏捷方法论中,文档不是第一位的。使软件正常工作比详细的文档更重要。因此,一个好的做法,正如 Scott Ambler 在他的书《敏捷建模:极限编程和统一过程的有效实践》中所解释的那样,是定义真正的文档需求,而不是创建详尽的文档集。

例如,让我们看一个简单项目的文档示例——ianitor——它在 GitHub 上可用github.com/ClearcodeHQ/ianitor。这是一个帮助在 Consul 服务发现集群中注册进程的工具,因此主要面向系统管理员。如果你看一下它的文档,你会意识到这只是一个单一的文档(README.md文件)。它只解释了它的工作原理和如何使用它。从管理员的角度来看,这是足够的。他们只需要知道如何配置和运行这个工具,没有其他人群预期使用ianitor。这个文档通过回答一个问题来限制其范围,“我如何在我的服务器上使用ianitor?”

使用模板

维基百科上的每一页都很相似。一侧有用于总结日期或事实的框。文件的开头是一个带有链接的目录,这些链接指向同一文本中的锚点。最后总是有一个参考部分。

用户习惯了。例如,他们知道他们可以快速查看目录,如果找不到所需信息,他们将直接转到参考部分,看看是否可以在该主题上找到另一个网站。这对维基百科上的任何页面都适用。你学会了维基百科方式,更有效率。

因此,使用模板强制了文档的通用模式,因此使人们更有效地使用它们。他们习惯了结构并知道如何快速阅读它。

为每种文档提供模板也为作者提供了快速入门。

reStructuredText 入门

reStructuredText 也被称为 reST(参见docutils.sourceforge.net/rst.html)。它是一种纯文本标记语言,在 Python 社区广泛用于文档化包。reST 的好处在于文本仍然可读,因为标记语法不像 LaTeX 那样混淆文本。

这是这样一个文档的示例:

=====
Title
=====

Section 1
=========
This *word* has emphasis.

Section 2
=========

Subsection
::::::::::

Text.

reST 包含在docutils中,该软件包提供了一套脚本,可将 reST 文件转换为各种格式,如 HTML、LaTeX、XML,甚至是 S5,Eric Meyer 的幻灯片系统(参见meyerweb.com/eric/tools/s5)。

作者可以专注于内容,然后根据需要决定如何呈现它。例如,Python 本身是用 reST 文档化的,然后呈现为 HTML 以构建docs.python.org,以及其他各种格式。

开始写 reST 所需了解的最少元素是:

  • 部分结构

  • 列表

  • 内联标记

  • 文字块

  • 链接

本节是语法的快速概述。更多信息可在以下网址找到快速参考:docutils.sourceforge.net/docs/user/rst/quickref.html,这是开始使用 reST 的好地方。

要安装 reStructuredText,安装docutils

$ pip install docutils

例如,由docutils包提供的rst2html脚本将根据 reST 文件生成 HTML 输出:

$ more text.txt
Title
=====

content.

$ rst2html.py text.txt
<?xml version="1.0" encoding="utf-8" ?>
...
<html ...>
<head>
...
</head>
<body>
<div class="document" id="title">
<h1 class="title">Title</h1>
<p>content.</p>
</div>
</body>
</html>

部分结构

文档的标题及其各节使用非字母数字字符进行下划线。它们可以被上下划线覆盖,并且一种常见的做法是为标题使用这种双重标记,并为各节保持简单的下划线。

用于下划线部分标题的最常用字符按优先顺序排列:=, -, _, :, #, +, ^

当一个字符用于一个部分时,它与其级别相关联,并且必须在整个文档中一致使用。

例如,考虑以下代码:

==============
Document title
==============

Introduction to the document content.

Section 1
=========

First document section with two subsections.

Note the ``=`` used as heading underline.

Subsection A
------------

First subsection (A) of Section 1.

Note the ``-`` used as heading underline.

Subsection B
------------
Second subsection (B) of Section 1.

Section 2
=========

Second section of document with one subsection.

Subsection C
------------

Subsection (C) of Section 2.

部分结构

图 1 reStructuredText 转换为 HTML 并在浏览器中呈现

列表

reST 为项目列表、编号列表和具有自动编号功能的定义列表提供可读的语法:

Bullet list:

- one
- two
- three

Enumerated list:

1\. one
2\. two
#. auto-enumerated

Definition list:

one
    one is a number.

two
    two is also a number.

列表

图 2 不同类型的列表呈现为 HTML

内联标记

文本可以使用内联标记进行样式化:

  • *强调*:斜体

  • **强调**:粗体

  • inline preformated:内联预格式化文本(通常是等宽的,类似终端)

  • ``带有链接的文本_:只要在文档中提供了它(请参阅链接部分),它将被替换为超链接

文字块

当您需要展示一些代码示例时,可以使用文字块。两个冒号用于标记块,这是一个缩进的段落:

This is a code example

::

    >>> 1 + 1
    2

Let's continue our text

注意

不要忘记在::后和块后添加空行,否则它将无法呈现。

请注意,冒号字符可以放在文本行中。在这种情况下,它们将在各种呈现格式中被替换为单个冒号:

This is a code example::

    >>> 1 + 1
    2

Let's continue our text

如果不想保留单个冒号,可以在前导文本和::之间插入一个空格。在这种情况下,::将被解释并完全删除。

文字块

图 3 reST 中呈现为 HTML 的代码示例

链接

只要提供在文档中,文本就可以通过以两个点开头的特殊行更改为外部链接:

Try `Plone CMS`_, it is great ! It is based on Zope_.

.. _`Plone CMS`: http://plone.org
.. _Zope: http://zope.org

通常的做法是将外部链接分组放在文档的末尾。当要链接的文本包含空格时,必须用`(反引号)字符括起来。

通过在文本中添加标记,也可以使用内部链接:


This is a code example

.. _example:

::

    >>> 1 + 1
    2

Let's continue our text, or maybe go back to
the example_.

还可以使用目标作为部分:


==============
Document title
==============

Introduction to the document content.


Section 1
=========

First document section.


Section 2
=========

-> go back to `Section 1`_

搭建文档

引导读者和作者更简单的方法是为每个人提供助手和指南,就像我们在本章的前一节中学到的那样。

从作者的角度来看,这是通过具有一组可重用的模板以及描述何时何地在项目中使用它们的指南来完成的。这被称为文档投资组合

从读者的角度来看,能够毫无困难地浏览文档并有效地查找信息是很重要的。通过构建一个文档景观来实现。

构建投资组合

软件项目可能有许多种类的文档,从直接参考代码的低级文档到提供应用程序高级概述的设计论文。

例如,Scott Ambler 在他的书 敏捷建模:极限编程和统一过程的有效实践 中定义了一个广泛的文档类型列表,约翰·威利和儿子。他从早期规格到操作文档构建了一个投资组合。甚至项目管理文档也包括在内,因此整个文档需求都建立在一套标准化的模板集合上。

由于完整的投资组合与用于构建软件的方法密切相关,本章将只关注你可以根据自己的特定需求完成的常见子集。构建高效的投资组合需要很长时间,因为它涵盖了你的工作习惯。

软件项目中的一组常见文档可以分为三类:

  • 设计:这包括所有提供架构信息和低级设计信息的文档,如类图或数据库图

  • 用法:这包括所有关于如何使用软件的文档;这可以是烹饪书和教程或模块级别的帮助

  • 操作:这提供了有关如何部署、升级或操作软件的指南

设计

创建此类文档的重要点是确保目标读者群体完全了解,内容范围受限。因此,设计文档的通用模板可以提供轻量级结构,并为作者提供一点建议。

这样的结构可能包括:

  • 标题

  • 作者

  • 标签(关键字)

  • 描述(摘要)

  • 目标(谁应该阅读这个?)

  • 内容(含图表)

  • 引用其他文档

打印时,内容应为三至四页,以确保范围受限。如果内容变得更大,应将其拆分为几个文档或进行摘要。

该模板还提供了作者的姓名和一系列标签,以管理其发展并便于分类。这将在本章后面介绍。

在 reST 中的示例设计文档模板可以如下所示:


=========================================
Design document title
=========================================

:Author: Document Author
:Tags: document tags separated with spaces

:abstract:

    Write here a small abstract about your design document.

.. contents ::


Audience
========

Explain here who is the target readership.


Content
=======

Write your document here. Do not hesitate to split it in several sections.


References
==========

Put here references, and links to other documents.

用法

使用文档描述了软件的特定部分如何使用。 此文档可以描述低级部分,例如函数的工作原理,但也可以描述高级部分,例如调用程序的命令行参数。 这是框架应用程序中文档的最重要部分,因为目标读者主要是将重用代码的开发人员。

三种主要类型的文档是:

  • 配方:这是一份简短的文档,解释如何做某事。 这种文档针对一个读者群,重点是一个特定主题。

  • 教程:这是一份逐步解释如何使用软件功能的文档。 这个文档可以参考配方,每个实例都针对一个读者群。

  • 模块助手:这是一份低级文档,解释模块包含什么内容。 例如,当您调用模块上的help内置时,可以显示此文档。

配方

配方回答了一个非常具体的问题,并提供了解决方案以解决它。 例如,ActiveState 在线提供了一个巨大的 Python 配方库,开发人员可以在其中描述如何在 Python 中做某事(参见code.activestate.com/recipes/langs/python/)。 这样一个与单一领域/项目相关的配方集合通常称为食谱

这些配方必须简短,结构如下:

  • 标题

  • 提交者

  • 最后更新

  • 版本

  • 类别

  • 描述

  • 来源(源代码)

  • 讨论(解释代码的文本)

  • 评论(来自 Web)

往往只有一个屏幕长,不会详细说明。 这种结构非常适合软件的需要,并且可以适应通用结构,在这个结构中,添加了目标读者,并用标签替换了类别:

  • 标题(简短的句子)

  • 作者

  • 标签(关键词)

  • 谁应该阅读这个?

  • 先决条件(要阅读的其他文档,例如)

  • 问题(简短描述)

  • 解决方案(主要内容,一个或两个屏幕)

  • 引用(链接到其他文档)

这里的日期和版本不太有用,因为项目文档应该像项目中的源代码一样管理。 这意味着最好的处理文档的方法是通过版本控制系统进行管理。 在大多数情况下,这与用于项目代码的代码存储库完全相同。

一个简单的可重用的模板,用于配方,可以如下所示:


===========
Recipe name
===========

:Author: Recipe Author
:Tags: document tags separated with spaces

:abstract:

    Write here a small abstract about your design document.

.. contents ::


Audience
========

Explain here who is the target readership.


Prerequisites
=============

Write the list of prerequisites for implementing this recipe. This can be additional documents, software, specific libraries, environment settings or just anything that is required beyond the obvious language interpreter.


Problem
=======

Explain the problem that this recipe is trying to solve.


Solution
========

Give solution to problem explained earlier. This is the core of a recipe.


References
==========

Put here references, and links to other documents.

教程

教程与配方在目的上有所不同。 它不是为了解决一个孤立的问题,而是描述如何逐步使用应用程序的功能。 这可能比配方长,并且可能涉及应用程序的许多部分。 例如,Django 在其网站上提供了一系列教程。 编写你的第一个 Django 应用程序,第一部分(参见docs.djangoproject.com/en/1.9/intro/tutorial01/)简要解释了如何使用 Django 构建应用程序的几个屏幕。

这种文档的结构将是:

  • 标题(简短的句子)

  • 作者

  • 标签 (单词)

  • 描述(摘要)

  • 谁应该阅读这个?

  • 先决条件(要阅读的其他文档,例如)

  • 教程(主要文本)

  • 参考文献 (链接到其他文档)

模块助手

我们收集的最后一个模板是模块助手模板。模块助手指的是单个模块,并提供其内容的描述以及用法示例。

一些工具可以通过提取文档字符串并使用pydoc来计算模块帮助来自动生成这样的文档,例如 Epydoc(参见 epydoc.sourceforge.net)。因此,可以基于 API 内省生成广泛的文档。这种类型的文档通常在 Python 框架中提供。例如,Plone 提供了一个 api.plone.org 服务器,保存了一个最新的模块助手集合。

这种方法的主要问题有:

  • 没有进行对真正有趣的模块的智能选择

  • 文档可以使代码变得晦涩难懂

此外,模块文档提供的示例有时涉及模块的几个部分,很难将其分割为函数和类文档字符串之间。模块文档字符串可以通过在模块顶部编写文本来用于这一目的。但这会导致具有一段文本而非代码块的混合文件。当代码占总长度的不到 50%时,这会导致混淆。如果你是作者,这很正常。但当人们尝试阅读代码(而不是文档)时,他们将不得不跳过文档字符串部分。

另一种方法是将文本分开存储在自己的文件中。然后可以进行手动选择,决定哪个 Python 模块将拥有自己的模块助手文件。然后,文档可以从代码库中分离出来,允许它们独立存在,就像我们将在下一部分看到的那样。这就是 Python 的文档方式。

许多开发人员对文档和代码分离是否比文档字符串更好持不同意见。这种方法意味着文档过程完全集成在开发周期中; 否则它将很快变得过时。文档字符串方法通过提供代码和使用示例之间的接近性来解决了这个问题,但并未将其提升到更高的水平——可以作为纯文档的一部分使用的文档。

模块助手的模板非常简单,因为在编写内容之前它只包含一些元数据。目标未定义,因为希望使用该模块的是开发人员:

  • 标题(模块名称)

  • 作者

  • 标签 (单词)

  • 内容

注意

下一章将涵盖使用 doctests 和模块助手进行测试驱动开发。

操作

操作文档用于描述如何操作软件。例如,请考虑以下几点:

  • 安装和部署文档

  • 管理文档

  • 常见问题(FAQ)文档

  • 解释人们如何贡献、寻求帮助或提供反馈的文档

这些文档非常具体,但它们可能可以使用在前面一节中定义的教程模板。

制作你自己的作品集

我们之前讨论的模板只是你可以用来记录软件的基础。随着时间的推移,你最终会开发出自己的模板和文档风格。但始终要记住轻量但足够的项目文档编写方法:每个添加的文档都应该有一个明确定义的目标读者群,并填补一个真实的需求。不增加真实价值的文档不应该被写入。

每个项目都是独特的,有不同的文档需求。例如,具有简单使用的小型终端工具绝对可以只使用单个README文件作为其文档景观。如果目标读者被精确定义并始终分组(例如系统管理员),那么采用这种最小单文档方法完全可以接受。

同样,不要过于严格地应用提供的模板。例如,在大型项目或严格规范化的团队中,提供的一些附加元数据作为示例真的很有用。例如,标签旨在提高大型文档中的文本搜索,但在只包含几个文档的文档景观中将不提供任何价值。

此外,包括文档作者并不总是一个好主意。这种方法在开源项目中可能尤其值得怀疑。在这类项目中,你会希望社区也为文档做出贡献。在大多数情况下,这样的文档在需要时会由任何人不断更新。人们往往也会将文档的 作者 视为文档的 所有者。如果每个文档都明确指定了作者,这可能会阻止人们更新文档。通常,版本控制软件提供了关于真实文档作者的更清晰、更透明的信息,而不是提供明确的元数据注释。确实建议明确指定作者的情况是各种设计文档,特别是在设计过程严格规范化的项目中。最好的例子是 Python 语言增强提案系列(PEP)文档。

构建景观

在前一节中构建的文档组合在文档级别提供了一个结构,但没有提供一种组织和分类来构建读者将拥有的文档。这就是安德烈亚斯·鲁平格所称的文档景观,指的是读者在浏览文档时使用的心智地图。他得出结论,组织文档的最佳方式是构建一个逻辑树。

换句话说,组成作品集的不同类型的文档需要在目录树中找到一个存放的位置。当作者创建文档时,这个位置对他们来说必须是明显的;当读者寻找文档时,这个位置对他们也必须是明显的。

浏览文档时一个很大的帮助是每个级别都有索引页面可以引导作者和读者。

构建文档景观有两个步骤:

  • 为制片人(作者)构建一个树

  • 在制片人树的基础上为消费者(读者)构建一个树

制片人和消费者之间的区别很重要,因为它们以不同的方式访问文档,而且使用不同的格式。

制片人布局

从制片人的角度来看,每个文档都要像 Python 模块一样处理。它应该存储在版本控制系统中,并且像代码一样工作。作者不关心他们的散文最终的外观和可用性,他们只是想确保他们在写一篇文档,因此它是有关主题的唯一真相来源。存储在文件夹树中的 reStructuredText 文件与软件代码一起存储在版本控制系统中,并且是制片人构建文档景观的方便解决方案。

按照惯例,docs文件夹被用作文档树的根:


$ cd my-project
$ find docs
docs
docs/source
docs/source/design
docs/source/operations
docs/source/usage
docs/source/usage/cookbook
docs/source/usage/modules
docs/source/usage/tutorial

注意,这个树位于一个source文件夹中,因为docs文件夹将被用作下一节中设置特殊工具的根文件夹。

从那里,可以在每个级别(除了根目录)添加一个index.txt文件,解释文件夹包含什么类型的文档或总结每个子文件夹包含的内容。这些索引文件可以定义它们所包含的文档列表。例如,operations文件夹可以包含一个可用的操作文档列表:


==========
Operations
==========

This section contains operations documents:

− How to install and run the project
− How to install and manage a database for the project
It is important to know that people tend to forget 

需要知道的是,人们往往会忘记更新这些文档列表和目录。因此最好是自动更新它们。在下一小节,我们将讨论一个工具,它除了许多其他功能之外,也可以处理这种情况。

消费者的布局

从消费者的角度来看,重要的是制作出索引文件,并以易于阅读和美观的格式呈现整个文档。网页是最好的选择,也很容易从 reStructuredText 文件中生成。

Sphinx (sphinx.pocoo.org) 是一组脚本和docutils扩展,可以用来从我们的文本树生成 HTML 结构。这个工具被用于(例如)构建 Python 文档,并且现在有许多项目都在用它来编写文档。其中内置的功能之一是,它生成了一个真正好用的浏览系统,还有一个轻量但足够的客户端 JavaScript 搜索引擎。它还使用pygments来渲染代码示例,因此产生了非常好的语法高亮。

Sphinx 可以轻松配置为与前一节中定义的文档方向保持一致。它可以使用pip轻松安装为Sphinx包。

与 Sphinx 一起工作的最简单方法是使用sphinx-quickstart脚本。此实用程序将生成一个脚本和Makefile,可用于在需要时生成 Web 文档。它将交互式地询问您一些问题,然后引导整个初始文档源树和配置文件。一旦完成,您可以随时轻松调整它。假设我们已经引导了整个 Sphinx 环境,并且我们想要查看其 HTML 表示。这可以通过使用make html命令轻松完成:


project/docs$ make html
sphinx-build -b html -d _build/doctrees   . _build/html
Running Sphinx v1.3.6
making output directory...
loading pickled environment... not yet created
building [mo]: targets for 0 po files that are out of date
building [html]: targets for 1 source files that are out of date
updating environment: 1 added, 0 changed, 0 removed
reading sources... [100%] index
looking for now-outdated files... none found
pickling environment... done
checking consistency... done
preparing documents... done
writing output... [100%] index
generating indices... genindex
writing additional pages... search
copying static files... done
copying extra files... done
dumping search index in English (code: en) ... done
dumping object inventory... done
build succeeded.
Build finished. The HTML pages are in _build/html.

消费者布局

图 4 使用 Sphinx 构建的文档的示例 HTML 版本 - graceful.readthedocs.org/en/latest/

除了文档的 HTML 版本外,该工具还构建了自动页面,例如模块列表和索引。Sphinx 提供了一些docutils扩展来驱动这些功能。主要的是:

  • 构建目录的指令

  • 可用于将文档注册为模块助手的标记

  • 添加索引中的元素的标记

处理索引页面

Sphinx 提供了一个toctree指令,可用于在文档中注入带有指向其他文档链接的目录。每行必须是具有其相对路径的文件,从当前文档开始。还可以提供 Glob 样式名称以添加匹配表达式的多个文件。

例如,cookbook文件夹中的索引文件,我们之前在生产者景观中定义的,可以是这样的:


========
Cookbook
========

Welcome to the Cookbook.

Available recipes:

.. toctree::
   :glob:
   *

使用这种语法,HTML 页面将显示cookbook文件夹中所有可用的 reStructuredText 文档的列表。此指令可用于所有索引文件中以构建可浏览的文档。

注册模块助手

对于模块助手,可以添加标记,以便它自动列在模块的索引页面中并可用:


=======
session
=======

.. module:: db.session

The module session...

注意,这里的db前缀可以用来避免模块冲突。Sphinx 将其用作模块类别,并将以db.开头的所有模块分组到此类别中。

添加索引标记

还可以使用另一个选项填充索引页面,将文档链接到条目:

=======
session
=======

.. module:: db.session

.. index::
   Database Access
   Session

The module session...

将在索引页面中添加两个新条目,Database AccessSession

交叉引用

最后,Sphinx 提供了一种内联标记来设置交叉引用。例如,可以这样链接到模块:


:mod:`db.session`

在这里,:mod:是模块标记的前缀,db.session是要链接到的模块的名称(如之前注册的);请记住,:mod:以及之前的元素都是 Sphinx 在 reSTructuredText 中引入的特定指令。

注意

Sphinx 提供了更多功能,您可以在其网站上发现。例如,autodoc功能是自动提取您的 doctests 以构建文档的一个很好的选项。请参阅sphinx.pocoo.org

文档构建和持续集成

Sphinx 确实提高了从消费者角度阅读文档的可读性和体验。正如前面所说的,当其部分与代码紧密耦合时,特别有帮助,比如 dosctrings 或模块助手。虽然这种方法确实使得确保文档的源版本与其所记录的代码匹配变得更容易,但并不能保证文档读者能够访问到最新和最新的编译版本。

如果文档的目标读者不熟练使用命令行工具,也不知道如何将其构建成可浏览和可读的形式,那么仅有最小的源表示也是不够的。这就是为什么在代码存储库发生任何更改时,自动将文档构建成消费者友好的形式非常重要。

使用 Sphinx 托管文档的最佳方式是生成 HTML 构建,并将其作为静态资源提供给您选择的 Web 服务器。Sphinx 提供了适当的Makefile来使用make html命令构建 HTML 文件。因为make是一个非常常见的实用工具,所以很容易将这个过程与第八章中讨论的任何持续集成系统集成,管理代码

如果您正在使用 Sphinx 记录一个开源项目,那么使用Read the Docsreadthedocs.org/)会让您的生活变得轻松很多。这是一个免费的服务,用于托管使用 Sphinx 的开源 Python 项目的文档。配置完全无忧,而且非常容易与两个流行的代码托管服务集成:GitHub 和 Bitbucket。实际上,如果您的账户正确连接并且代码存储库正确设置,启用 Read the Docs 上的文档托管只需要点击几下。

总结

本章详细解释了如何:

  • 使用一些高效写作的规则

  • 使用 reStructuredText,Python 程序员的 LaTeX

  • 构建文档组合和布局

  • 使用 Sphinx 生成有用的 Web 文档

在记录项目时最难的事情是保持准确和最新。将文档作为代码存储库的一部分使得这变得更容易。从那里,每当开发人员更改一个模块时,他或她也应该相应地更改文档。

在大型项目中可能会很困难,在这种情况下,在模块头部添加相关文档列表可以有所帮助。

确保文档始终准确的一个补充方法是通过 doctests 将文档与测试结合起来。这将在下一章中介绍,该章节将介绍测试驱动开发原则,然后是文档驱动开发。

第十章:测试驱动开发

测试驱动开发TDD)是一种生产高质量软件的简单技术。它在 Python 社区中被广泛使用,但在其他社区中也很受欢迎。

由于 Python 的动态特性,测试尤为重要。它缺乏静态类型,因此许多甚至微小的错误直到代码运行并执行每一行时才会被注意到。但问题不仅仅在于 Python 中类型的工作方式。请记住,大多数错误与不良语法使用无关,而是与逻辑错误和微妙的误解有关,这可能导致重大故障。

本章分为两个部分:

  • 我不测试,倡导 TDD 并快速描述如何使用标准库进行测试

  • 我进行测试,这是为那些进行测试并希望从中获得更多的开发人员设计的

我不测试

如果您已经被说服使用 TDD,您应该转到下一节。它将专注于高级技术和工具,以使您在处理测试时的生活更轻松。这部分主要是为那些不使用这种方法并试图倡导其使用的人而设计的。

测试驱动开发原则

测试驱动开发过程,最简单的形式包括三个步骤:

  1. 为尚未实现的新功能或改进编写自动化测试。

  2. 提供最小的代码,只需通过所有定义的测试即可。

  3. 重构代码以满足期望的质量标准。

关于这个开发周期最重要的事实是,在实现之前应该先编写测试。这对于经验不足的开发人员来说并不容易,但这是唯一保证您要编写的代码是可测试的方法。

例如,一个被要求编写一个检查给定数字是否为质数的函数的开发人员,会写一些关于如何使用它以及预期结果的示例:

assert is_prime(5)
assert is_prime(7)
assert not is_prime(8)

实现功能的开发人员不需要是唯一负责提供测试的人。示例也可以由其他人提供。例如,网络协议或密码算法的官方规范经常提供旨在验证实现正确性的测试向量。这些是测试用例的完美基础。

从那里,函数可以被实现,直到前面的示例起作用:

def is_prime(number):
    for element in range(2, number):
        if number % element == 0:
            return False
    return True

错误或意外结果是函数应该能够处理的新用法示例:

>>> assert not is_prime(1)
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
AssertionError

代码可以相应地更改,直到新的测试通过:

def is_prime(number):
    if number in (0, 1):
        return False

    for element in range(2, number):
        if number % element == 0:
            return False

    return True

还有更多情况表明实现仍然不完整:

>>> assert not is_prime(-3)** 
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
AssertionError

更新后的代码如下:

def is_prime(number):
    if number < 0 or number in (0, 1):
        return False

    for element in range(2, number):
        if number % element == 0:
            return False

    return True

从那里,所有测试可以被收集在一个测试函数中,每当代码发展时运行:

def test_is_prime():
    assert is_prime(5)
    assert is_prime(7)

    assert not is_prime(8)
    assert not is_prime(0)
    assert not is_prime(1)

    assert not is_prime(-1)
    assert not is_prime(-3)
    assert not is_prime(-6)

每当我们提出一个新的需求时,“test_is_prime()”函数应该首先更新以定义“is_prime()”函数的预期行为。然后,运行测试以检查实现是否提供了期望的结果。只有当已知测试失败时,才需要更新经过测试的函数的代码。

测试驱动开发提供了许多好处:

  • 它有助于防止软件回归

  • 它提高了软件质量

  • 它提供了代码行为的一种低级文档

  • 它允许您在短时间内更快地生成健壮的代码

处理测试的最佳约定是将它们全部收集在一个单独的模块或包中(通常命名为tests),并且有一种简单的方法可以使用单个 shell 命令运行整个测试套件。幸运的是,没有必要自己构建整个测试工具链。Python 标准库和 Python 软件包索引都提供了大量的测试框架和实用工具,可以让您以方便的方式构建、发现和运行测试。我们将在本章后面讨论这些包和模块中最值得注意的例子。

防止软件回归

我们在开发人员生活中都会面临软件回归问题。软件回归是由更改引入的新错误。它表现为在软件先前版本中已知的功能或功能在项目开发过程中的某个时刻出现故障并停止工作。

回归的主要原因是软件的复杂性。在某个时刻,不可能猜测代码库中的单个更改可能导致什么结果。更改某些代码可能会破坏其他功能,有时会导致恶意副作用,比如悄悄地损坏数据。高复杂性不仅是庞大代码库的问题。当然,代码量和复杂性之间存在明显的相关性,但即使是小型项目(几百/几千行代码)的架构也可能如此复杂,以至于很难预测相对较小的更改的所有后果。

为了避免回归,软件提供的整套功能应该在每次更改发生时进行测试。如果没有这样做,你将无法可靠地区分软件中一直存在的错误和最近在正确工作的部分引入的新错误。

向多个开发人员开放代码库会加剧这个问题,因为每个人都不会完全了解所有的开发活动。虽然版本控制系统可以防止冲突,但它并不能阻止所有不必要的交互。

TDD 有助于减少软件回归。每次更改后,整个软件都可以自动测试。只要每个功能都有适当的测试集,这种方法就有效。当 TDD 正确执行时,测试基础会随着代码基础一起增长。

由于完整的测试活动可能需要相当长的时间,将其委托给一些可以在后台执行工作的持续集成系统是一个好的做法。我们在第八章“管理代码”中已经讨论过这样的解决方案。然而,开发人员也应该手动执行测试的本地重新启动,至少对于相关模块来说是如此。仅依赖持续集成会对开发人员的生产力产生负面影响。程序员应该能够在其环境中轻松地运行测试的选择。这就是为什么你应该仔细选择项目的测试工具。

提高代码质量

当编写新的模块、类或函数时,开发人员会专注于如何编写以及如何产生最佳的代码。但是,当他们专注于算法时,他们可能会失去用户的视角:他们的函数将如何被使用?参数是否易于使用和合乎逻辑?API 的名称是否正确?

这是通过应用前几章描述的技巧来完成的,比如第四章,“选择好的名称”。但要高效地做到这一点,唯一的方法就是写使用示例。这是开发人员意识到他或她编写的代码是否合乎逻辑且易于使用的时刻。通常,在模块、类或函数完成后,第一次重构就会发生。

编写测试,这些测试是代码的用例,有助于从用户的角度进行思考。因此,当开发人员使用 TDD 时,通常会产生更好的代码。测试庞大的函数和庞大的单块类是困难的。考虑测试的代码往往更清晰、更模块化。

提供最佳的开发人员文档

测试是开发人员了解软件运行方式的最佳途径。它们是代码最初创建的用例。阅读它们可以快速深入地了解代码的运行方式。有时,一个例子胜过千言万语。

这些测试始终与代码库保持最新,使它们成为软件可以拥有的最佳开发人员文档。测试不会像文档一样过时,否则它们会失败。

更快地生成健壮的代码

没有测试的编写会导致长时间的调试会话。一个模块中的错误可能会在软件的完全不同部分表现出来。由于您不知道该责怪谁,您会花费大量时间进行调试。当测试失败时,最好一次只解决一个小错误,因为这样您会更好地了解真正的问题所在。测试通常比调试更有趣,因为它是编码。

如果您测量修复代码所花费的时间以及编写代码所花费的时间,通常会比 TDD 方法所需的时间长。当您开始编写新的代码时,这并不明显。这是因为设置测试环境并编写前几个测试所花费的时间与仅编写代码的时间相比极长。

但是,有些测试环境确实很难设置。例如,当您的代码与 LDAP 或 SQL 服务器交互时,编写测试根本不明显。这在本章的伪造和模拟部分中有所涵盖。

什么样的测试?

任何软件都可以进行几种测试。主要的是验收测试(或功能测试)和单元测试,这是大多数人在讨论软件测试主题时所考虑的。但是在您的项目中,还有一些其他测试类型可以使用。我们将在本节中简要讨论其中一些。

验收测试

验收测试侧重于功能,并处理软件就像黑匣子一样。它只是确保软件确实做了它应该做的事情,使用与用户相同的媒体并控制输出。这些测试通常是在开发周期之外编写的,以验证应用程序是否满足要求。它们通常作为软件的检查表运行。通常,这些测试不是通过 TDD 进行的,而是由经理、QA 人员甚至客户构建的。在这种情况下,它们通常被称为用户验收测试

但是,它们可以并且应该遵循 TDD 原则。在编写功能之前可以提供测试。开发人员通常会得到一堆验收测试,通常是由功能规格书制作的,他们的工作是确保代码能够通过所有这些测试。

编写这些测试所使用的工具取决于软件提供的用户界面。一些 Python 开发人员使用的流行工具包括:

应用程序类型 工具
Web 应用程序 Selenium(用于带有 JavaScript 的 Web UI)
Web 应用程序 zope.testbrowser(不测试 JS)
WSGI 应用程序 paste.test.fixture(不测试 JS)
Gnome 桌面应用程序 dogtail
Win32 桌面应用程序 pywinauto

注意

对于功能测试工具的广泛列表,Grig Gheorghiu 在wiki.python.org/moin/PythonTestingToolsTaxonomy上维护了一个 wiki 页面。

单元测试

单元测试是完全适合测试驱动开发的低级测试。顾名思义,它们专注于测试软件单元。软件单元可以理解为应用程序代码的最小可测试部分。根据应用程序的不同,大小可能从整个模块到单个方法或函数不等,但通常单元测试是针对可能的最小代码片段编写的。单元测试通常会将被测试的单元(模块、类、函数等)与应用程序的其余部分和其他单元隔离开来。当需要外部依赖项时,例如 Web API 或数据库,它们通常会被伪造对象或模拟替换。

功能测试

功能测试侧重于整个功能和功能,而不是小的代码单元。它们在目的上类似于验收测试。主要区别在于功能测试不一定需要使用用户相同的界面。例如,在测试 Web 应用程序时,一些用户交互(或其后果)可以通过合成的 HTTP 请求或直接数据库访问来模拟,而不是模拟真实页面加载和鼠标点击。

这种方法通常比使用用户验收测试中使用的工具进行测试更容易和更快。有限功能测试的缺点是它们往往不能涵盖应用程序的足够多的部分,其中不同的抽象层和组件相遇。侧重于这种相遇点的测试通常被称为集成测试。

集成测试

集成测试代表了比单元测试更高级的测试水平。它们测试代码的更大部分,并侧重于许多应用层或组件相遇和相互交互的情况。集成测试的形式和范围取决于项目的架构和复杂性。例如,在小型和单片项目中,这可能只是运行更复杂的功能测试,并允许它们与真实的后端服务(数据库、缓存等)进行交互,而不是模拟或伪造它们。对于复杂的场景或由多个服务构建的产品,真正的集成测试可能非常广泛,甚至需要在模拟生产环境的大型分布式环境中运行整个项目。

集成测试通常与功能测试非常相似,它们之间的边界非常模糊。很常见的是,集成测试也在逻辑上测试独立的功能和特性。

负载和性能测试

负载测试和性能测试提供的是关于代码效率而不是正确性的客观信息。负载测试和性能测试这两个术语有时可以互换使用,但实际上前者指的是性能的有限方面。负载测试侧重于衡量代码在某种人为需求(负载)下的行为。这是测试 Web 应用程序的一种非常流行的方式,其中负载被理解为来自真实用户或程序化客户端的 Web 流量。重要的是要注意,负载测试往往涵盖了对应用程序的整个请求,因此与集成和功能测试非常相似。这使得确保被测试的应用程序组件完全验证工作正常非常重要。性能测试通常是旨在衡量代码性能的所有测试,甚至可以针对代码的小单元。因此,负载测试只是性能测试的一个特定子类型。

它们是一种特殊类型的测试,因为它们不提供二进制结果(失败/成功),而只提供一些性能质量的测量。这意味着单个结果需要被解释和/或与不同测试运行的结果进行比较。在某些情况下,项目要求可能对代码设置一些严格的时间或资源约束,但这并不改变这些测试方法中总是涉及某种任意解释的事实。

负载性能测试是任何需要满足一些服务****级别协议的软件开发过程中的一个重要工具,因为它有助于降低关键代码路径性能受损的风险。无论如何,不应该过度使用。

代码质量测试

代码质量没有一个确定的任意刻度,可以明确地说它是好还是坏。不幸的是,代码质量这个抽象概念无法用数字形式来衡量和表达。但相反,我们可以测量与代码质量高度相关的软件的各种指标。举几个例子:

  • 代码风格违规的数量

  • 文档的数量

  • 复杂度度量,如 McCabe 的圈复杂度

  • 静态代码分析警告的数量

许多项目在其持续集成工作流程中使用代码质量测试。一个良好且流行的方法是至少测试基本指标(静态代码分析和代码风格违规),并且不允许将任何代码合并到主流中使这些指标降低。

Python 标准测试工具

Python 提供了标准库中的两个主要模块来编写测试:

unittest

unittest基本上提供了 Java 的 JUnit 所提供的功能。它提供了一个名为TestCase的基类,该类具有一系列广泛的方法来验证函数调用和语句的输出。

这个模块是为了编写单元测试而创建的,但只要测试使用了用户界面,也可以用它来编写验收测试。例如,一些测试框架提供了辅助工具来驱动诸如 Selenium 之类的工具,这些工具是建立在unittest之上的。

使用unittest为模块编写简单的单元测试是通过子类化TestCase并编写以test前缀开头的方法来完成的。测试驱动开发原则部分的最终示例将如下所示:

import unittest

from primes import is_prime

class MyTests(unittest.TestCase):
    def test_is_prime(self):
        self.assertTrue(is_prime(5))
        self.assertTrue(is_prime(7))

        self.assertFalse(is_prime(8))
        self.assertFalse(is_prime(0))
        self.assertFalse(is_prime(1))

        self.assertFalse(is_prime(-1))
        self.assertFalse(is_prime(-3))
        self.assertFalse(is_prime(-6))

if __name__ == "__main__":
    unittest.main()

unittest.main()函数是一个实用程序,允许将整个模块作为测试套件来执行:

$ python test_is_prime.py -v
test_is_prime (__main__.MyTests) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

unittest.main()函数扫描当前模块的上下文,并寻找子类为TestCase的类。它实例化它们,然后运行所有以test前缀开头的方法。

一个良好的测试套件遵循常见和一致的命名约定。例如,如果is_prime函数包含在primes.py模块中,测试类可以被称为PrimesTests,并放入test_primes.py文件中:

import unittest

from primes import is_prime

class PrimesTests(unittest.TestCase):
    def test_is_prime(self):
        self.assertTrue(is_prime(5))
        self.assertTrue(is_prime(7))

        self.assertFalse(is_prime(8))
        self.assertFalse(is_prime(0))
        self.assertFalse(is_prime(1))

        self.assertFalse(is_prime(-1))
        self.assertFalse(is_prime(-3))
        self.assertFalse(is_prime(-6))

if __name__ == '__main__':
    unittest.main()

从那时起,每当utils模块发展时,test_utils模块就会得到更多的测试。

为了工作,test_primes模块需要在上下文中有primes模块可用。这可以通过将两个模块放在同一个包中,或者通过将被测试的模块显式添加到 Python 路径中来实现。在实践中,setuptoolsdevelop命令在这里非常有帮助。

在整个应用程序上运行测试假设您有一个脚本,可以从所有测试模块构建一个测试运行unittest提供了一个TestSuite类,可以聚合测试并将它们作为一个测试运行来运行,只要它们都是TestCaseTestSuite的实例。

在 Python 的过去,有一个约定,测试模块提供一个返回TestSuite实例的test_suite函数,该实例在模块被命令提示符调用时在__main__部分中使用,或者由测试运行器使用:

import unittest

from primes import is_prime

class PrimesTests(unittest.TestCase):
    def test_is_prime(self):
        self.assertTrue(is_prime(5))

        self.assertTrue(is_prime(7))

        self.assertFalse(is_prime(8))
        self.assertFalse(is_prime(0))
        self.assertFalse(is_prime(1))

        self.assertFalse(is_prime(-1))
        self.assertFalse(is_prime(-3))
        self.assertFalse(is_prime(-6))

class OtherTests(unittest.TestCase):
    def test_true(self):
        self.assertTrue(True)

def test_suite():
    """builds the test suite."""
    suite = unittest.TestSuite()
    suite.addTests(unittest.makeSuite(PrimesTests))
    suite.addTests(unittest.makeSuite(OtherTests))

    return suite

if __name__ == '__main__':
    unittest.main(defaultTest='test_suite')

从 shell 中运行这个模块将打印测试运行结果:

$ python test_primes.py -v
test_is_prime (__main__.PrimesTests) ... ok
test_true (__main__.OtherTests) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

在旧版本的 Python 中,当unittest模块没有适当的测试发现工具时,需要使用前面的方法。通常,所有测试的运行是由一个全局脚本完成的,该脚本浏览代码树寻找测试并运行它们。这称为测试发现,稍后在本章中将更详细地介绍。现在,您只需要知道unittest提供了一个简单的命令,可以从带有test前缀的模块和包中发现所有测试:

$ python -m unittest -v
test_is_prime (test_primes.PrimesTests) ... ok
test_true (test_primes.OtherTests) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

如果您使用了前面的命令,那么就不需要手动定义__main__部分并调用unittest.main()函数。

doctest

doctest是一个模块,它从文档字符串或文本文件中提取交互式提示会话的片段,并重放它们以检查示例输出是否与真实输出相同。

例如,以下内容的文本文件可以作为测试运行:

Check addition of integers works as expected::

>>> 1 + 1
2

假设这个文档文件存储在文件系统中,文件名为test.rstdoctest模块提供了一些函数,用于从这样的文档中提取并运行测试:

>>> import doctest
>>> doctest.testfile('test.rst', verbose=True)
Trying:
 **1 + 1
Expecting:
 **2
ok
1 items passed all tests:
 **1 tests in test.rst
1 tests in 1 items.
1 passed and 0 failed.
Test passed.
TestResults(failed=0, attempted=1)

使用doctest有很多优点:

  • 包可以通过示例进行文档和测试

  • 文档示例始终是最新的

  • 使用 doctests 中的示例来编写一个包有助于保持用户的观点

然而,doctests 并不会使单元测试过时;它们只应该用于在文档中提供可读的示例。换句话说,当测试涉及低级问题或需要复杂的测试装置,这些测试装置会使文档变得晦涩时,就不应该使用它们。

一些 Python 框架,如 Zope,广泛使用 doctests,并且有时会受到对代码不熟悉的人的批评。有些 doctests 真的很难阅读和理解,因为这些示例违反了技术写作的规则之一——它们不能在简单的提示符下运行,并且需要广泛的知识。因此,那些本应帮助新手的文档变得很难阅读,因为基于复杂测试装置或特定测试 API 构建的 doctests 的代码示例很难阅读。

注意

如第九章中所解释的,项目文档,当你使用 doctests 作为你的包文档的一部分时,要小心遵循技术写作的七条规则。

在这个阶段,你应该对 TDD 带来的好处有一个很好的概述。如果你还不确定,你应该在几个模块上试一试。使用 TDD 编写一个包,并测量构建、调试和重构所花费的时间。你会很快发现它确实是优越的。

我进行测试

如果你来自我不测试部分,并且现在已经确信要进行测试驱动开发,那么恭喜你!你已经了解了测试驱动开发的基础知识,但在能够有效地使用这种方法之前,你还有一些东西需要学习。

本节描述了开发人员在编写测试时遇到的一些问题,以及解决这些问题的一些方法。它还提供了 Python 社区中流行的测试运行器和工具的快速回顾。

单元测试的缺陷

unittest模块是在 Python 2.1 中引入的,并且自那时以来一直被开发人员广泛使用。但是一些替代的测试框架由社区中一些对unittest的弱点和限制感到沮丧的人创建。

以下是经常提出的常见批评:

  • 框架使用起来很繁重,因为:

  • 你必须在TestCase的子类中编写所有测试

  • 你必须在方法名前加上test前缀

  • 鼓励使用TestCase提供的断言方法,而不是简单的assert语句,现有的方法可能无法覆盖每种情况

  • 这个框架很难扩展,因为它需要大量地对基类进行子类化或者使用装饰器等技巧。

  • 有时测试装置很难组织,因为setUptearDown设施与TestCase级别相关联,尽管它们每次测试运行时只运行一次。换句话说,如果一个测试装置涉及许多测试模块,那么组织它的创建和清理就不简单。

  • 在 Python 软件上运行测试活动并不容易。默认的测试运行器(python -m unittest)确实提供了一些测试发现,但并没有提供足够的过滤能力。实际上,需要编写额外的脚本来收集测试,汇总它们,然后以方便的方式运行它们。

需要一种更轻量的方法来编写测试,而不会受到太像其大型 Java 兄弟 JUnit 的框架的限制。由于 Python 不要求使用 100%基于类的环境,因此最好提供一个更符合 Python 风格的测试框架,而不是基于子类化。

一个常见的方法是:

  • 提供一种简单的方法来标记任何函数或任何类作为测试

  • 通过插件系统扩展框架

  • 为所有测试级别提供完整的测试装置环境:整个活动、模块级别的一组测试和测试级别

  • 基于测试发现提供测试运行器,具有广泛的选项集

unittest 替代方案

一些第三方工具尝试通过提供unittest扩展的形式来解决刚才提到的问题。

Python 维基提供了各种测试实用工具和框架的非常长的列表(参见wiki.python.org/moin/PythonTestingToolsTaxonomy),但只有两个项目特别受欢迎:

nose

nose主要是一个具有强大发现功能的测试运行器。它具有广泛的选项,允许在 Python 应用程序中运行各种测试活动。

它不是标准库的一部分,但可以在 PyPI 上找到,并可以使用 pip 轻松安装:

pip install nose

测试运行器

安装 nose 后,一个名为nosetests的新命令可以在提示符下使用。可以直接使用它来运行本章第一节中介绍的测试:

nosetests -v
test_true (test_primes.OtherTests) ... ok
test_is_prime (test_primes.PrimesTests) ... ok
builds the test suite. ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.009s

OK

nose通过递归浏览当前目录并自行构建测试套件来发现测试。乍一看,前面的例子看起来并不像简单的python -m unittest有什么改进。如果你使用--help开关运行此命令,你会注意到 nose 提供了数十个参数,允许你控制测试的发现和执行。

编写测试

nose更进一步,通过运行所有类和函数,其名称与正则表达式((?:^|[b_.-])[Tt]est)匹配的模块中的测试。大致上,所有以test开头并位于匹配该模式的模块中的可调用项也将作为测试执行。

例如,这个test_ok.py模块将被nose识别并运行:

$ more test_ok.py
def test_ok():
 **print('my test')
$ nosetests -v
test_ok.test_ok ... ok

-----------------------------------------------------------------
Ran 1 test in 0.071s

OK

还会执行常规的TestCase类和doctests

最后,nose提供了类似于TestCase方法的断言函数。但这些是作为遵循 PEP 8 命名约定的函数提供的,而不是使用unittest使用的 Java 约定(参见nose.readthedocs.org/)。

编写测试装置

nose支持三个级别的装置:

  • 包级别__init__.py模块中可以添加setupteardown函数,其中包含所有测试模块的测试包

  • 模块级别:测试模块可以有自己的setupteardown函数

  • 测试级别:可调用项也可以使用提供的with_setup装饰器具有装置函数

例如,要在模块和测试级别设置测试装置,请使用以下代码:

def setup():
    # setup code, launched for the whole module
    ...

def teardown():
    # teardown code, launched for the whole module
    ... 

def set_ok():
    # setup code launched only for test_ok
    ...

@with_setup(set_ok)
def test_ok():
    print('my test')

与 setuptools 的集成和插件系统

最后,nosesetuptools完美集成,因此可以使用test命令(python setup.py test)。这种集成是通过在setup.py脚本中添加test_suite元数据来完成的:

setup(
    #...
    test_suite='nose.collector',
)

nose还使用setuptool's入口机制,供开发人员编写nose插件。这允许你从测试发现到输出格式化覆盖或修改工具的每个方面。

注意

nose-plugins.jottit.com上维护了一个nose插件列表。

总结

nose是一个完整的测试工具,修复了unittest存在的许多问题。它仍然设计为使用测试的隐式前缀名称,这对一些开发人员来说仍然是一个约束。虽然这个前缀可以定制,但仍然需要遵循一定的约定。

这种约定优于配置的说法并不坏,比在unittest中需要的样板代码要好得多。但是,例如使用显式装饰器可能是摆脱test前缀的好方法。

此外,通过插件扩展nose的能力使其非常灵活,并允许开发人员定制工具以满足其需求。

如果您的测试工作流程需要覆盖很多 nose 参数,您可以在主目录或项目根目录中轻松添加.nosercnose.cfg文件。它将指定nosetests命令的默认选项集。例如,一个很好的做法是在测试运行期间自动查找 doctests。启用运行 doctests 的nose配置文件示例如下:

[nosetests]
with-doctest=1
doctest-extension=.txt

py.test

py.testnose非常相似。事实上,后者是受py.test启发的,因此我们将主要关注使这些工具彼此不同的细节。该工具诞生于一个名为py的更大软件包的一部分,但现在它们是分开开发的。

像本书中提到的每个第三方软件包一样,py.test可以在 PyPI 上获得,并且可以通过pip安装为pytest

$ pip install pytest

从那里,一个新的py.test命令在提示符下可用,可以像nosetests一样使用。该工具使用类似的模式匹配和测试发现算法来捕获要运行的测试。该模式比nose使用的模式更严格,只会捕获:

  • Test开头的类,在以test开头的文件中

  • test开头的函数,在以test开头的文件中

注意

要小心使用正确的字符大小写。如果一个函数以大写的“T”开头,它将被视为一个类,因此会被忽略。如果一个类以小写的“t”开头,py.test将会中断,因为它会尝试将其视为一个函数。

py.test的优点包括:

  • 轻松禁用一些测试类的能力

  • 处理 fixtures 的灵活和独特机制

  • 将测试分发到多台计算机的能力

编写测试 fixtures

py.test支持两种处理 fixtures 的机制。第一种是模仿 xUnit 框架的,类似于nose。当然,语义有些不同。py.test将在每个测试模块中查找三个级别的 fixtures,如下例所示:

def setup_module(module): 
    """ Setup up any state specific to the execution 
        of the given module.
    """

def teardown_module(module):    
    """ Teardown any state that was previously setup
        with a setup_module method.
    """

def setup_class(cls):    
    """ Setup up any state specific to the execution
        of the given class (which usually contains tests).
    """

def teardown_class(cls):    
    """ Teardown any state that was previously setup
        with a call to setup_class.
    """

def setup_method(self, method):
    """ Setup up any state tied to the execution of the given
        method in a class. setup_method is invoked for every
        test method of a class.
    """

def teardown_method(self, method):
    """ Teardown any state that was previously setup
        with a setup_method call.
    """

每个函数将以当前模块、类或方法作为参数。因此,测试 fixture 将能够在上下文中工作,而无需查找它,就像nose一样。

py.test编写 fixtures 的另一种机制是建立在依赖注入的概念上,允许以更模块化和可扩展的方式维护测试状态。非 xUnit 风格的 fixtures(setup/teardown 过程)总是具有唯一的名称,并且需要通过在类中的测试函数、方法和模块中声明它们的使用来显式激活它们。

fixtures 的最简单实现采用了使用pytest.fixture()装饰器声明的命名函数的形式。要将 fixture 标记为在测试中使用,需要将其声明为函数或方法参数。为了更清楚,考虑使用py.test fixtures 重写is_prime函数的测试模块的先前示例:

import pytest

from primes import is_prime

@pytest.fixture()
def prime_numbers():
    return [3, 5, 7]

@pytest.fixture()
def non_prime_numbers():
    return [8, 0, 1]

@pytest.fixture()
def negative_numbers():
    return [-1, -3, -6]

def test_is_prime_true(prime_numbers):
    for number in prime_numbers:
        assert is_prime(number)

def test_is_prime_false(non_prime_numbers, negative_numbers):
    for number in non_prime_numbers:
        assert not is_prime(number)

    for number in non_prime_numbers:
        assert not is_prime(number)

禁用测试函数和类

py.test 提供了一个简单的机制,可以在特定条件下禁用一些测试。这称为跳过,pytest 包提供了 .skipif 装饰器来实现这一目的。如果需要在特定条件下跳过单个测试函数或整个测试类装饰器,就需要使用这个装饰器,并提供一些值来验证是否满足了预期条件。以下是官方文档中跳过在 Windows 上运行整个测试用例类的示例:

import pytest

@pytest.mark.skipif(
    sys.platform == 'win32',
    reason="does not run on windows"
)
class TestPosixCalls:

    def test_function(self):
        """will not be setup or run under 'win32' platform"""

当然,您可以预先定义跳过条件,以便在测试模块之间共享:

import pytest

skipwindows = pytest.mark.skipif(
    sys.platform == 'win32',
    reason="does not run on windows"
)

@skip_windows
class TestPosixCalls:

    def test_function(self):
        """will not be setup or run under 'win32' platform"""

如果一个测试以这种方式标记,它将根本不会被执行。然而,在某些情况下,您希望运行这样的测试,并希望执行它,但是您知道,在已知条件下它应该失败。为此,提供了一个不同的装饰器。它是 @mark.xfail,确保测试始终运行,但如果预定义条件发生,它应该在某个时候失败:

import pytest

@pytest.mark.xfail(
sys.platform == 'win32',
    reason="does not run on windows"
)
class TestPosixCalls:

    def test_function(self):
        """it must fail under windows"""

使用 xfailskipif 更严格。测试始终会被执行,如果在预期情况下没有失败,那么整个 py.test 运行将会失败。

自动化分布式测试

py.test 的一个有趣特性是它能够将测试分布到多台计算机上。只要计算机可以通过 SSH 访问,py.test 就能够通过发送要执行的测试来驱动每台计算机。

然而,这一特性依赖于网络;如果连接中断,从属端将无法继续工作,因为它完全由主控端驱动。

当一个项目有长时间的测试活动时,Buildbot 或其他持续集成工具更可取。但是,当您在开发一个运行测试需要大量资源的应用程序时,py.test 分布模型可以用于临时分发测试。

总结

py.testnose 非常相似,因为它不需要聚合测试的样板代码。它还有一个很好的插件系统,并且在 PyPI 上有大量的扩展可用。

最后,py.test 专注于使测试运行速度快,与这一领域的其他工具相比确实更加优越。另一个显著特性是对夹具的原始处理方式,这确实有助于管理可重用的夹具库。有些人可能会认为其中涉及了太多魔法,但它确实简化了测试套件的开发。py.test 的这一单一优势使其成为我的首选工具,因此我真的推荐它。

测试覆盖

代码覆盖 是一个非常有用的度量标准,它提供了关于项目代码测试情况的客观信息。它只是衡量了在所有测试执行期间执行了多少行代码以及哪些行代码。通常以百分比表示,100% 的覆盖率意味着在测试期间执行了每一行代码。

最流行的代码覆盖工具简称为 coverage,并且可以在 PyPI 上免费获得。使用非常简单,只有两个步骤。第一步是在您的 shell 中运行 coverage run 命令,并将运行所有测试的脚本/程序的路径作为参数:

$ coverage run --source . `which py.test` -v
===================== test session starts ======================
platformdarwin -- Python 3.5.1, pytest-2.8.7, py-1.4.31, pluggy-0.3.1 -- /Users/swistakm/.envs/book/bin/python3
cachedir: .cache
rootdir: /Users/swistakm/dev/book/chapter10/pytest, inifile:** 
plugins: capturelog-0.7, codecheckers-0.2, cov-2.2.1, timeout-1.0.0
collected 6 items** 

primes.py::pyflakes PASSED
primes.py::pep8 PASSED
test_primes.py::pyflakes PASSED
test_primes.py::pep8 PASSED
test_primes.py::test_is_prime_true PASSED
test_primes.py::test_is_prime_false PASSED

========= 6 passed, 1 pytest-warnings in 0.10 seconds ==========

coverage run 还接受 -m 参数,该参数指定可运行的模块名称,而不是程序路径,这对于某些测试框架可能更好:

$ coverage run -m unittest
$ coverage run -m nose
$ coverage run -m pytest

下一步是从 .coverage 文件中缓存的结果生成可读的代码覆盖报告。coverage 包支持几种输出格式,最简单的一种只在您的终端中打印 ASCII 表格:

$ coverage report
Name             StmtsMiss  Cover
------------------------------------
primes.py            7      0   100%
test_primes.py      16      0   100%
------------------------------------
TOTAL               23      0   100%

另一个有用的覆盖报告格式是 HTML,可以在您的 Web 浏览器中浏览:

$ coverage html

此 HTML 报告的默认输出文件夹是您的工作目录中的 htmlcov/coverage html 输出的真正优势在于您可以浏览项目的带有缺失测试覆盖部分的注释源代码(如 图 1 所示):

测试覆盖

图 1 覆盖率 HTML 报告中带注释的源代码示例

您应该记住,虽然您应该始终努力确保 100%的测试覆盖率,但这并不意味着代码被完美测试,也不意味着代码不会出错的地方。这只意味着每行代码在执行过程中都被执行到了,但并不一定测试了每种可能的条件。实际上,确保完整的代码覆盖率可能相对容易,但确保每个代码分支都被执行到则非常困难。这对于可能具有多个if语句和特定语言构造(如list/dict/set推导)组合的函数的测试尤其如此。您应该始终关注良好的测试覆盖率,但您不应该将其测量视为测试套件质量的最终答案。

伪造和模拟

编写单元测试预设了对正在测试的代码单元进行隔离。测试通常会向函数或方法提供一些数据,并验证其返回值和/或执行的副作用。这主要是为了确保测试:

  • 涉及应用程序的一个原子部分,可以是函数、方法、类或接口

  • 提供确定性、可重现的结果

有时,程序组件的正确隔离并不明显。例如,如果代码发送电子邮件,它可能会调用 Python 的smtplib模块,该模块将通过网络连接与 SMTP 服务器进行通信。如果我们希望我们的测试是可重现的,并且只是测试电子邮件是否具有所需的内容,那么可能不应该发生这种情况。理想情况下,单元测试应该在任何计算机上运行,而不需要外部依赖和副作用。

由于 Python 的动态特性,可以使用monkey patching来修改测试装置中的运行时代码(即在运行时动态修改软件而不触及源代码)来伪造第三方代码或库的行为。

构建一个伪造

在测试中创建伪造行为可以通过发现测试代码与外部部分交互所需的最小交互集。然后,手动返回输出,或者使用先前记录的真实数据池。

这是通过启动一个空类或函数并将其用作替代来完成的。然后启动测试,并迭代更新伪造,直到其行为正确。这是由于 Python 类型系统的特性。只要对象的行为与预期的类型相匹配,并且不需要通过子类化成为其祖先,它就被认为与给定类型兼容。这种在 Python 中的类型化方法被称为鸭子类型——如果某物的行为像鸭子,那么它就可以被当作鸭子对待。

让我们以一个名为mailer的模块中的名为send的函数为例,该函数发送电子邮件:

import smtplib
import email.message

def send(
    sender, to,
    subject='None',
    body='None',
    server='localhost'
):
    """sends a message."""
    message = email.message.Message()
    message['To'] = to
    message['From'] = sender
    message['Subject'] = subject
    message.set_payload(body)

    server = smtplib.SMTP(server)
    try:
        return server.sendmail(sender, to, message.as_string())
    finally:
        server.quit()

注意

py.test将用于在本节中演示伪造和模拟。

相应的测试可以是:

from mailer import send

def test_send():
    res = send(
        'john.doe@example.com', 
        'john.doe@example.com', 
        'topic',
        'body'
    )
    assert res == {}

只要本地主机上有 SMTP 服务器,这个测试就会通过并工作。如果没有,它会失败,就像这样:

$ py.test --tb=short
========================= test session starts =========================
platform darwin -- Python 3.5.1, pytest-2.8.7, py-1.4.31, pluggy-0.3.1
rootdir: /Users/swistakm/dev/book/chapter10/mailer, inifile:** 
plugins: capturelog-0.7, codecheckers-0.2, cov-2.2.1, timeout-1.0.0
collected 5 items** 

mailer.py ..
test_mailer.py ..F

============================== FAILURES ===============================
______________________________ test_send ______________________________
test_mailer.py:10: in test_send
 **'body'
mailer.py:19: in send
 **server = smtplib.SMTP(server)
.../smtplib.py:251: in __init__
 **(code, msg) = self.connect(host, port)
.../smtplib.py:335: in connect
 **self.sock = self._get_socket(host, port, self.timeout)
.../smtplib.py:306: in _get_socket
 **self.source_address)
.../socket.py:711: in create_connection
 **raise err
.../socket.py:702: in create_connection
 **sock.connect(sa)
E   ConnectionRefusedError: [Errno 61] Connection refused
======== 1 failed, 4 passed, 1 pytest-warnings in 0.17 seconds ========

可以添加一个补丁来伪造 SMTP 类:

import smtplib
import pytest
from mailer import send

class FakeSMTP(object):
    pass

@pytest.yield_fixture()
def patch_smtplib():
    # setup step: monkey patch smtplib
    old_smtp = smtplib.SMTP
    smtplib.SMTP = FakeSMTP

    yield

    # teardown step: bring back smtplib to 
    # its former state
    smtplib.SMTP = old_smtp

def test_send(patch_smtplib):
    res = send(
        'john.doe@example.com',
        'john.doe@example.com',
        'topic',
        'body'
    )
    assert res == {}

在前面的代码中,我们使用了一个新的pytest.yield_fixture()装饰器。它允许我们使用生成器语法在单个 fixture 函数中提供设置和拆卸过程。现在我们的测试套件可以使用smtplib的修补版本再次运行:

$ py.test --tb=short -v
======================== test session starts ========================
platform darwin -- Python 3.5.1, pytest-2.8.7, py-1.4.31, pluggy-0.3.1 -- /Users/swistakm/.envs/book/bin/python3
cachedir: .cache
rootdir: /Users/swistakm/dev/book/chapter10/mailer, inifile:** 
plugins: capturelog-0.7, codecheckers-0.2, cov-2.2.1, timeout-1.0.0
collected 5 items** 

mailer.py::pyflakes PASSED
mailer.py::pep8 PASSED
test_mailer.py::pyflakes PASSED
test_mailer.py::pep8 PASSED
test_mailer.py::test_send FAILED

============================= FAILURES ==============================
_____________________________ test_send _____________________________
test_mailer.py:29: in test_send
 **'body'
mailer.py:19: in send
 **server = smtplib.SMTP(server)
E   TypeError: object() takes no parameters
======= 1 failed, 4 passed, 1 pytest-warnings in 0.09 seconds =======

从前面的对话记录中可以看出,我们的FakeSMTP类实现并不完整。我们需要更新其接口以匹配原始的 SMTP 类。根据鸭子类型原则,我们只需要提供被测试的send()函数所需的接口:

class FakeSMTP(object):
    def __init__(self, *args, **kw):
        # arguments are not important in our example
        pass

    def quit(self):
        pass

    def sendmail(self, *args, **kw):
        return {}

当然,虚假类可以随着新的测试而发展,以提供更复杂的行为。但它应该尽可能短小简单。相同的原则可以用于更复杂的输出,通过记录它们来通过虚假 API 返回它们。这通常用于 LDAP 或 SQL 等第三方服务器。

当猴子补丁任何内置或第三方模块时,需要特别小心。如果操作不当,这种方法可能会留下意想不到的副作用,会在测试之间传播。幸运的是,许多测试框架和工具提供了适当的实用工具,使得对任何代码单元进行补丁变得安全且容易。在我们的例子中,我们手动完成了所有操作,并提供了一个自定义的patch_smtplib() fixture 函数,其中包括了分离的设置和拆卸步骤。在py.test中的典型解决方案要简单得多。这个框架带有一个内置的猴子补丁 fixture,应该满足我们大部分的补丁需求。

import smtplib
from mailer import send

class FakeSMTP(object):
    def __init__(self, *args, **kw):
        # arguments are not important in our example
        pass

    def quit(self):
        pass

    def sendmail(self, *args, **kw):
        return {}

def test_send(monkeypatch):
    monkeypatch.setattr(smtplib, 'SMTP', FakeSMTP)

    res = send(
        'john.doe@example.com',
        'john.doe@example.com',
        'topic',
        'body'
    )
    assert res == {}

您应该知道,虚假有真正的局限性。如果决定虚假一个外部依赖,可能会引入真实服务器不会有的错误或意外行为,反之亦然。

使用模拟

模拟对象是通用的虚假对象,可以用来隔离被测试的代码。它们自动化了对象的输入和输出的构建过程。在静态类型的语言中,模拟对象的使用更多,因为猴子补丁更难,但它们在 Python 中仍然很有用,可以缩短代码以模拟外部 API。

Python 中有很多模拟库可用,但最受认可的是unittest.mock,它是标准库中提供的。它最初是作为第三方包创建的,而不是作为 Python 发行版的一部分,但很快就被包含到标准库中作为一个临时包(参见docs.python.org/dev/glossary.html#term-provisional-api)。对于早于 3.3 版本的 Python,您需要从 PyPI 安装它:

pip install Mock

在我们的例子中,使用unittest.mock来补丁 SMTP 比从头开始创建一个虚假对象要简单得多。

import smtplib
from unittest.mock import MagicMock
from mailer import send

def test_send(monkeypatch):
    smtp_mock = MagicMock()
    smtp_mock.sendmail.return_value = {}

    monkeypatch.setattr(
        smtplib, 'SMTP', MagicMock(return_value=smtp_mock)
    )

    res = send(
        'john.doe@example.com',
        'john.doe@example.com',
        'topic',
        'body'
    )
    assert res == {}

模拟对象或方法的return_value参数允许您定义调用返回的值。当使用模拟对象时,每次代码调用属性时,它都会即时为属性创建一个新的模拟对象。因此,不会引发异常。这就是我们之前编写的quit方法的情况,它不需要再定义了。

在前面的示例中,实际上我们创建了两个模拟对象:

  • 第一个模拟了 SMTP 类对象而不是它的实例。这使您可以轻松地创建一个新对象,而不管预期的__init__()方法是什么。如果将模拟对象视为可调用,默认情况下会返回新的Mock()对象。这就是为什么我们需要为其return_value关键字参数提供另一个模拟对象,以便对实例接口进行控制。

  • 第二个模拟了在补丁smtplib.SMTP()调用上返回的实际实例。在这个模拟中,我们控制了sendmail()方法的行为。

在我们的例子中,我们使用了py.test框架提供的猴子补丁实用程序,但unittest.mock提供了自己的补丁实用程序。在某些情况下(比如补丁类对象),使用它们可能比使用特定于框架的工具更简单更快。以下是使用unittest.mock模块提供的patch()上下文管理器进行猴子补丁的示例:

from unittest.mock import patch
from mailer import send

def test_send():
    with patch('smtplib.SMTP') as mock:
        instance = mock.return_value
        instance.sendmail.return_value = {}
        res = send(
            'john.doe@example.com',
            'john.doe@example.com',
            'topic',
            'body'
        )
        assert res == {}

测试环境和依赖兼容性

本书中已经多次提到了环境隔离的重要性。通过在应用程序级别(虚拟环境)和系统级别(系统虚拟化)上隔离执行环境,您可以确保您的测试在可重复的条件下运行。这样,您就可以保护自己免受由于损坏的依赖关系引起的罕见和隐晦的问题。

允许适当隔离测试环境的最佳方式是使用支持系统虚拟化的良好持续集成系统。对于开源项目,有很好的免费解决方案,比如 Travis CI(Linux 和 OS X)或 AppVeyor(Windows),但如果你需要为测试专有软件构建这样的解决方案,很可能需要花费一些时间在一些现有的开源 CI 工具(GitLab CI、Jenkins 和 Buildbot)的基础上构建这样的解决方案。

依赖矩阵测试

大多数情况下,针对开源 Python 项目的测试矩阵主要关注不同的 Python 版本,很少关注不同的操作系统。对于纯粹是 Python 的项目,没有预期的系统互操作性问题,不在不同系统上进行测试和构建是完全可以的。但是一些项目,特别是作为编译 Python 扩展进行分发的项目,绝对应该在各种目标操作系统上进行测试。对于开源项目,甚至可能被迫使用几个独立的 CI 系统,为仅仅提供三种最流行的系统(Windows、Linux 和 Mac OS X)的构建。如果你正在寻找一个很好的例子,可以看一下小型的 pyrilla 项目(参考github.com/swistakm/pyrilla),这是一个简单的用于 Python 的 C 音频扩展。它同时使用了 Travis CI 和 AppVeyor 来为 Windows 和 Mac OS X 提供编译构建,并支持大量的 CPython 版本。

但是测试矩阵的维度不仅仅局限于系统和 Python 版本。提供与其他软件集成的包,比如缓存、数据库或系统服务,往往应该在各种集成应用的版本上进行测试。一个很好的工具,可以让这样的测试变得容易,是 tox(参考tox.readthedocs.org)。它提供了一种简单的方式来配置多个测试环境,并通过单个tox命令运行所有测试。它是一个非常强大和灵活的工具,但也非常容易使用。展示其用法的最佳方式是向您展示一个配置文件的示例,实际上这个配置文件是 tox 的核心。以下是 django-userena 项目的tox.ini文件(参考github.com/bread-and-pepper/django-userena):

[tox]
downloadcache = {toxworkdir}/cache/

envlist =
    ; py26 support was dropped in django1.7
    py26-django{15,16},
    ; py27 still has the widest django support
    py27-django{15,16,17,18,19},
    ; py32, py33 support was officially introduced in django1.5
    ; py32, py33 support was dropped in django1.9
    py32-django{15,16,17,18},
    py33-django{15,16,17,18},
    ; py34 support was officially introduced in django1.7
    py34-django{17,18,19}
    ; py35 support was officially introduced in django1.8
    py35-django{18,19}

[testenv]
usedevelop = True
deps =
    django{15,16}: south
    django{15,16}: django-guardian<1.4.0
    django15: django==1.5.12
    django16: django==1.6.11
    django17: django==1.7.11
    django18: django==1.8.7
    django19: django==1.9
    coverage: django==1.9
    coverage: coverage==4.0.3
    coverage: coveralls==1.1

basepython =
    py35: python3.5
    py34: python3.4
    py33: python3.3
    py32: python3.2
    py27: python2.7
    py26: python2.6

commands={envpython} userena/runtests/runtests.py userenaumessages {posargs}

[testenv:coverage]
basepython = python2.7
passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH
commands=
    coverage run --source=userena userena/runtests/runtests.py userenaumessages {posargs}
    coveralls

这个配置允许在五个不同版本的 Django 和六个版本的 Python 上测试django-userena。并非每个 Django 版本都能在每个 Python 版本上运行,tox.ini文件使得定义这样的依赖约束相对容易。实际上,整个构建矩阵包括 21 个独特的环境(包括一个用于代码覆盖收集的特殊环境)。手动创建每个测试环境,甚至使用 shell 脚本,都需要巨大的工作量。

Tox 很棒,但是如果我们想要更改不是纯 Python 依赖的测试环境的其他元素,它的使用就会变得更加复杂。这是一个情况,当我们需要在不同版本的系统软件包和后备服务下进行测试时。解决这个问题的最佳方法是再次使用良好的持续集成系统,它允许您轻松地定义环境变量的矩阵,并在虚拟机上安装系统软件。使用 Travis CI 进行这样做的一个很好的例子是ianitor项目(参见github.com/ClearcodeHQ/ianitor/),它已经在第九章中提到过,记录您的项目。这是 Consul 发现服务的一个简单实用程序。Consul 项目有一个非常活跃的社区,每年都会发布许多新版本的代码。这使得对该服务的各种版本进行测试非常合理。这确保了ianitor项目仍然与该软件的最新版本保持最新,但也不会破坏与以前的 Consul 版本的兼容性。以下是 Travis CI 的.travis.yml配置文件的内容,它允许您对三个不同的 Consul 版本和四个 Python 解释器版本进行测试:

language: python

install: pip install tox --use-mirrors
env:
  matrix:
    # consul 0.4.1
    - TOX_ENV=py27     CONSUL_VERSION=0.4.1
    - TOX_ENV=py33     CONSUL_VERSION=0.4.1
    - TOX_ENV=py34     CONSUL_VERSION=0.4.1
    - TOX_ENV=py35     CONSUL_VERSION=0.4.1

    # consul 0.5.2
    - TOX_ENV=py27     CONSUL_VERSION=0.5.2
    - TOX_ENV=py33     CONSUL_VERSION=0.5.2
    - TOX_ENV=py34     CONSUL_VERSION=0.5.2
    - TOX_ENV=py35     CONSUL_VERSION=0.5.2

    # consul 0.6.4
    - TOX_ENV=py27     CONSUL_VERSION=0.6.4
    - TOX_ENV=py33     CONSUL_VERSION=0.6.4
    - TOX_ENV=py34     CONSUL_VERSION=0.6.4
    - TOX_ENV=py35     CONSUL_VERSION=0.6.4

    # coverage and style checks
    - TOX_ENV=pep8     CONSUL_VERSION=0.4.1
    - TOX_ENV=coverage CONSUL_VERSION=0.4.1

before_script:
  - wget https://releases.hashicorp.com/consul/${CONSUL_VERSION}/consul_${CONSUL_VERSION}_linux_amd64.zip
  - unzip consul_${CONSUL_VERSION}_linux_amd64.zip
  - start-stop-daemon --start --background --exec `pwd`/consul -- agent -server -data-dir /tmp/consul -bootstrap-expect=1

script:
  - tox -e $TOX_ENV

前面的例子为ianitor代码提供了 14 个独特的测试环境(包括pep8coverage构建)。这个配置还使用 tox 在 Travis VM 上创建实际的测试虚拟环境。这实际上是将 tox 与不同的 CI 系统集成的一种非常流行的方法。通过尽可能多地将测试环境配置移动到 tox,您可以减少将自己锁定到单个供应商的风险。像安装新服务或定义系统环境变量这样的事情,大多数 Travis CI 的竞争对手都支持,因此如果市场上有更好的产品可用,或者 Travis 会改变其针对开源项目的定价模式,切换到不同的服务提供商应该相对容易。

文档驱动开发

与其他语言相比,文档测试在 Python 中是一个真正的优势。文档可以使用代码示例,这些示例也可以作为测试运行,这改变了 TDD 的方式。例如,在开发周期中,文档的一部分可以通过doctests来完成。这种方法还确保提供的示例始终是最新的并且确实有效。

通过文档测试构建软件而不是常规单元测试被称为文档驱动开发DDD)。开发人员在实现代码时用简单的英语解释代码的功能。

写故事

在 DDD 中编写文档测试是通过构建关于代码如何工作和应该如何使用的故事来完成的。首先用简单的英语描述原则,然后在文本中分布一些代码使用示例。一个好的做法是先写关于代码如何工作的文本,然后添加一些代码示例。

要看一个实际的文档测试的例子,让我们看一下atomisator软件包(参见bitbucket.org/tarek/atomisator)。其atomisator.parser子软件包的文档文本(位于packages/atomisator.parser/atomisator/parser/docs/README.txt)如下:

=================
atomisator.parser
=================

The parser knows how to return a feed content, with
the `parse` function, available as a top-level function::

>>> from atomisator.parser import Parser

This function takes the feed url and returns an iterator
over its content. A second parameter can specify a maximum
number of entries to return. If not given, it is fixed to 10::

>>> import os
>>> res = Parser()(os.path.join(test_dir, 'sample.xml'))
>>> res
<itertools.imap ...>

Each item is a dictionary that contain the entry::

>>> entry = res.next()
>>> entry['title']
u'CSSEdit 2.0 Released'

The keys available are:

>>> keys = sorted(entry.keys())
>>> list(keys)
    ['id', 'link', 'links', 'summary', 'summary_detail', 'tags', 
     'title', 'title_detail']

Dates are changed into datetime::

>>> type(entry['date'])
>>>

随后,文档测试将会发展,以考虑新元素或所需的更改。这个文档测试也是开发人员想要使用该软件包的良好文档,并且应该根据这种用法进行更改。

在文档中编写测试的一个常见陷阱是将其转化为一段难以阅读的文本。如果发生这种情况,就不应再将其视为文档的一部分。

也就是说,一些开发人员专门通过 doctests 工作,通常将他们的 doctests 分为两类:可读和可用的,可以成为软件包文档的一部分,以及不可读的,仅用于构建和测试软件。

许多开发人员认为应该放弃后者,转而使用常规单元测试。其他人甚至为错误修复使用专门的 doctests。

因此,doctests 和常规测试之间的平衡是一种品味问题,由团队决定,只要 doctests 的已发布部分是可读的。

注意

在项目中使用 DDD 时,专注于可读性,并决定哪些 doctests 有资格成为已发布文档的一部分。

总结

本章提倡使用 TDD,并提供了更多关于:

  • unittest陷阱

  • 第三方工具:nosepy.test

  • 如何构建伪造和模拟

  • 文档驱动开发

由于我们已经知道如何构建、打包和测试软件,在接下来的两章中,我们将专注于寻找性能瓶颈并优化您的程序的方法。

第十一章:优化-一般原则和分析技术

"我们应该忘记小的效率,大约有 97%的时间:过早的优化是万恶之源。"
--唐纳德·克努斯

本章讨论了优化,并提供了一套通用原则和分析技术。它提供了每个开发人员都应该了解的三条优化规则,并提供了优化指南。最后,它着重介绍了如何找到瓶颈。

优化的三条规则

优化是有代价的,无论结果如何。当一段代码工作时,也许最好(有时)是不要试图不惜一切代价使其更快。在进行任何优化时,有一些规则需要牢记:

  • 首先使其工作

  • 从用户的角度出发

  • 保持代码可读

首先使其工作

一个非常常见的错误是在编写代码时尝试对其进行优化。这在大多数情况下是毫无意义的,因为真正的瓶颈通常出现在你从未想到的地方。

应用程序通常由非常复杂的交互组成,在真正使用之前,很难完全了解发生了什么。

当然,这并不是不尝试尽快使其运行的原因。您应该小心尽量降低其复杂性,并避免无用的重复。但第一个目标是使其工作。这个目标不应该被优化努力所阻碍。

对于代码的每一行,Python 的哲学是有一种,最好只有一种方法来做。因此,只要你遵循 Pythonic 的语法,描述在第二章和第三章中描述的语法最佳实践,你的代码应该没问题。通常情况下,写更少的代码比写更多的代码更好更快。

在你的代码能够工作并且你准备进行分析之前,不要做任何这些事情:

  • 首先编写一个全局字典来缓存函数的数据

  • 考虑将代码的一部分外部化为 C 或 Cython 等混合语言

  • 寻找外部库来进行基本计算

对于非常专业的领域,如科学计算或游戏,从一开始就使用专门的库和外部化可能是不可避免的。另一方面,使用像 NumPy 这样的库可能会简化特定功能的开发,并在最后产生更简单更快的代码。此外,如果有一个很好的库可以为你完成工作,你就不应该重写一个函数。

例如,Soya 3D 是一个基于 OpenGL 的游戏引擎(参见home.gna.org/oomadness/en/soya3d/index.html),在渲染实时 3D 时使用 C 和 Pyrex 进行快速矩阵运算。

注意

优化是在已经工作的程序上进行的。

正如 Kent Beck 所说,“先让它工作,然后让它正确,最后让它快。”

从用户的角度出发

我曾见过一些团队致力于优化应用服务器的启动时间,而当服务器已经运行良好时,他们可能更好(有时)是不要尝试不惜一切代价使其更快。在进行任何优化时,有一些规则需要牢记:

虽然使程序启动更快从绝对角度来看是件好事,但团队应该谨慎地优先考虑优化工作,并问自己以下问题:

  • 我被要求使其更快了吗?

  • 谁发现程序运行缓慢?

  • 真的很慢,还是可以接受?

  • 使其更快需要多少成本,是否值得?

  • 哪些部分需要快?

记住,优化是有成本的,开发人员的观点对客户来说毫无意义,除非您正在编写一个框架或库,而客户也是开发人员。

注意

优化不是一场游戏。只有在必要时才应该进行。

保持代码可读和易于维护

即使 Python 试图使常见的代码模式运行得最快,优化工作可能会使您的代码变得难以阅读。在产生可读且易于维护的代码与破坏代码以提高速度之间需要保持平衡。

当您达到 90%的优化目标,并且剩下的 10%使您的代码完全无法阅读时,最好停止工作或寻找其他解决方案。

注意

优化不应该使您的代码难以阅读。如果发生这种情况,您应该寻找替代解决方案,比如外部化或重新设计。要在可读性和速度之间寻找一个好的折衷方案。

优化策略

假设您的程序存在真正的速度问题需要解决。不要试图猜测如何使其更快。瓶颈通常很难通过查看代码来找到,需要一组工具来找到真正的问题。

一个良好的优化策略可以从以下三个步骤开始:

  • 找到另一个罪魁祸首:确保第三方服务器或资源没有故障

  • 扩展硬件:确保资源足够

  • 编写速度测试:创建具有速度目标的场景

找到另一个罪魁祸首

通常,性能问题发生在生产级别,客户通知您它的工作方式与软件测试时不同。性能问题可能是因为应用程序没有计划在现实世界中与大量用户和数据大小增加的情况下运行。

但是,如果应用程序与其他应用程序进行交互,首先要做的是检查瓶颈是否位于这些交互上。例如,数据库服务器或 LDAP 服务器可能会导致额外的开销,并使一切变慢。

应用程序之间的物理链接也应该被考虑。也许您的应用程序服务器与内部网络中的另一台服务器之间的网络链接由于错误配置或拥塞而变得非常缓慢。

设计文档应提供所有交互的图表和每个链接的性质,以便全面了解系统并在尝试解决速度问题时提供帮助。

注意

如果您的应用程序使用第三方服务器或资源,每次交互都应该经过审计,以确保瓶颈不在那里。

扩展硬件

当没有更多的易失性内存可用时,系统开始使用硬盘来存储数据。这就是交换。

这会带来很多额外开销,并且性能会急剧下降。从用户的角度来看,系统在这个阶段被认为已经死机。因此,扩展硬件以防止这种情况发生非常重要。

虽然系统上有足够的内存很重要,但确保应用程序不会表现出异常行为并占用过多内存也很重要。例如,如果一个程序处理几百兆大小的大型视频文件,它不应该完全将它们加载到内存中,而是应该分块处理或使用磁盘流。

磁盘使用也很重要。如果 I/O 错误隐藏在试图反复写入磁盘的代码中,分区已满可能会严重减慢应用程序。此外,即使代码只尝试写入一次,硬件和操作系统也可能尝试多次写入。

请注意,升级硬件(垂直扩展)有一些明显的限制。你无法将无限量的硬件放入一个机架中。此外,高效的硬件价格极其昂贵(收益递减定律),因此这种方法也有经济上的限制。从这个角度来看,总是更好的是拥有可以通过添加新的计算节点或工作节点(水平扩展)来扩展的系统。这样可以使用性价比最高的商品软件来扩展服务。

不幸的是,设计和维护高度可扩展的分布式系统既困难又昂贵。如果你的系统不能轻松地进行水平扩展,或者垂直扩展更快更便宜,那么最好选择这种方法,而不是在系统架构的全面重新设计上浪费时间和资源。请记住,硬件的性能和价格总是随时间变得更快更便宜。许多产品都处于这种甜蜜点,它们的扩展需求与提高硬件性能的趋势相一致。

编写速度测试

在开始优化工作时,重要的是使用类似于测试驱动开发的工作流程,而不是不断地运行一些手动测试。一个好的做法是在应用程序中专门设置一个测试模块,其中编写了需要优化的调用序列。有了这种情景,您在优化应用程序时将有助于跟踪您的进展。

甚至可以编写一些断言,设置一些速度目标。为了防止速度回归,这些测试可以在代码优化后留下:

>>> def test_speed():
...     import time
...     start = time.time()
...     the_code()
...     end = time.time() - start
...     assert end < 10, \
...     "sorry this code should not take 10 seconds !"
...** 

注意

测量执行速度取决于所使用的 CPU 的性能。但是我们将在下一节中看到如何编写通用的持续时间测量。

找到瓶颈

通过以下方式找到瓶颈:

  • 分析 CPU 使用情况

  • 分析内存使用情况

  • 分析网络使用情况

分析 CPU 使用情况

瓶颈的第一个来源是你的代码。标准库提供了执行代码分析所需的所有工具。它们基于确定性方法。

确定性分析器通过在最低级别添加计时器来测量每个函数中花费的时间。这会引入一些开销,但可以很好地了解时间消耗在哪里。另一方面,统计分析器对指令指针的使用进行采样,不会对代码进行仪器化。后者不够准确,但允许以全速运行目标程序。

有两种方法可以对代码进行分析:

  • 宏观分析:在程序运行时对整个程序进行分析并生成统计数据

  • 微观分析:通过手动对程序的精确部分进行仪器化来测量

宏观分析

宏观分析是通过以特殊模式运行应用程序来完成的,解释器被仪器化以收集代码使用统计信息。Python 提供了几种工具来实现这一点:

  • profile:这是一个纯 Python 实现

  • cProfile:这是一个 C 实现,提供了与profile工具相同的接口,但开销较小

对大多数 Python 程序员来说,由于其开销较小,推荐的选择是cProfile。无论如何,如果需要以某种方式扩展分析器,那么profile可能是更好的选择,因为它不使用 C 扩展。

这两种工具具有相同的接口和用法,因此我们将只使用其中一个来展示它们的工作原理。以下是一个myapp.py模块,其中包含一个我们将使用cProfile测试的主函数:

import time

def medium():
    time.sleep(0.01)

def light():
    time.sleep(0.001)

def heavy():
    for i in range(100):
        light()
        medium()
        medium()
    time.sleep(2)

def main():
    for i in range(2):
        heavy()

if __name__ == '__main__':
    main()

该模块可以直接从提示符中调用,并在此处总结结果:

$ python3 -m cProfile myapp.py
 **1208 function calls in 8.243 seconds

 **Ordered by: standard name

 **ncalls  tottime  percall  cumtime  percall filename:lineno(function)
 **2    0.001    0.000    8.243    4.121 myapp.py:13(heavy)
 **1    0.000    0.000    8.243    8.243 myapp.py:2(<module>)
 **1    0.000    0.000    8.243    8.243 myapp.py:21(main)
 **400    0.001    0.000    4.026    0.010 myapp.py:5(medium)
 **200    0.000    0.000    0.212    0.001 myapp.py:9(light)
 **1    0.000    0.000    8.243    8.243 {built-in method exec}
 **602    8.241    0.014    8.241    0.014 {built-in method sleep}

提供的统计数据是由分析器填充的统计对象的打印视图。可以手动调用该工具:

>>> import cProfile
>>> from myapp import main
>>> profiler = cProfile.Profile()
>>> profiler.runcall(main)
>>> profiler.print_stats()
 **1206 function calls in 8.243 seconds

 **Ordered by: standard name

 **ncalls  tottime  percall  cumtime  percall file:lineno(function)
 **2    0.001    0.000    8.243    4.121 myapp.py:13(heavy)
 **1    0.000    0.000    8.243    8.243 myapp.py:21(main)
 **400    0.001    0.000    4.026    0.010 myapp.py:5(medium)
 **200    0.000    0.000    0.212    0.001 myapp.py:9(light)
 **602    8.241    0.014    8.241    0.014 {built-in method sleep}

统计数据也可以保存在文件中,然后由pstats模块读取。该模块提供了一个知道如何处理分析文件并提供一些辅助功能的类的调用:

>>> import pstats
>>> import cProfile
>>> from myapp import main
>>> cProfile.run('main()', 'myapp.stats')
>>> stats = pstats.Stats('myapp.stats')
>>> stats.total_calls
1208
>>> stats.sort_stats('time').print_stats(3)
Mon Apr  4 21:44:36 2016    myapp.stats

 **1208 function calls in 8.243 seconds

 **Ordered by: internal time
 **List reduced from 8 to 3 due to restriction <3>

 **ncalls  tottime  percall  cumtime  percall file:lineno(function)
 **602    8.241    0.014    8.241    0.014 {built-in method sleep}
 **400    0.001    0.000    4.025    0.010 myapp.py:5(medium)
 **2    0.001    0.000    8.243    4.121 myapp.py:13(heavy)

从那里,您可以通过打印每个函数的调用者和被调用者来浏览代码:

>>> stats.print_callees('medium')
 **Ordered by: internal time
 **List reduced from 8 to 1 due to restriction <'medium'>

Function           called...
 **ncalls  tottime  cumtime
myapp.py:5(medium) ->  400    4.025    4.025  {built-in method sleep}

>>> stats.print_callees('light')
 **Ordered by: internal time
 **List reduced from 8 to 1 due to restriction <'light'>

Function           called...
 **ncalls  tottime  cumtime
myapp.py:9(light)  ->  200    0.212    0.212  {built-in method sleep}

能够对输出进行排序可以在不同的视图上查找瓶颈。例如,考虑以下情景:

  • 当调用次数非常高并且占用大部分全局时间时,该函数或方法可能在循环中。通过将此调用移动到不同的范围以减少操作次数,可能可以进行可能的优化

  • 当一个函数执行时间很长时,如果可能的话,缓存可能是一个不错的选择

从分析数据中可视化瓶颈的另一个好方法是将它们转换成图表(见图 1)。Gprof2Dotgithub.com/jrfonseca/gprof2dot)可以将分析器数据转换为点图。您可以使用pip从 PyPI 下载这个简单的脚本,并在安装了 Graphviz(参见www.graphviz.org/)的环境中使用它:

$ gprof2dot.py -f pstats myapp.stats | dot -Tpng -o output.png

gprof2dot的优势在于它试图成为一种语言无关的工具。它不仅限于 Python profilecProfile的输出,还可以从多个其他配置文件中读取,比如 Linux perf、xperf、gprof、Java HPROF 等等。

宏观分析

图 1 使用 gprof2dot 生成的分析概览图的示例

宏观分析是检测有问题的函数或者它的周边的一个好方法。当你找到它之后,你可以转向微观分析。

微观分析

当找到慢函数时,有时需要进行更多的分析工作,测试程序的一部分。这是通过手动在代码的一部分进行仪器化速度测试来完成的。

例如,可以使用cProfile模块作为装饰器:

>>> import tempfile, os, cProfile, pstats
>>> def profile(column='time', list=5):
...     def _profile(function):
...         def __profile(*args, **kw):
...             s = tempfile.mktemp()
...             profiler = cProfile.Profile()
...             profiler.runcall(function, *args, **kw)
...             profiler.dump_stats(s)
...             p = pstats.Stats(s)
...             p.sort_stats(column).print_stats(list)
...         return __profile
...     return _profile
...
>>> from myapp import main
>>> @profile('time', 6)
... def main_profiled():
...     return main()
...
>>> main_profiled()
Mon Apr  4 22:01:01 2016    /tmp/tmpvswuovz_

 **1207 function calls in 8.243 seconds

 **Ordered by: internal time
 **List reduced from 7 to 6 due to restriction <6>

 **ncalls  tottime  percall  cumtime  percall file:lineno(function)
 **602    8.241    0.014    8.241    0.014 {built-in method sleep}
 **400    0.001    0.000    4.026    0.010 myapp.py:5(medium)
 **2    0.001    0.000    8.243    4.121 myapp.py:13(heavy)
 **200    0.000    0.000    0.213    0.001 myapp.py:9(light)
 **1    0.000    0.000    8.243    8.243 myapp.py:21(main)
 **1    0.000    0.000    8.243    8.243 <stdin>:1(main_profiled)

>>> from myapp import light
>>> stats = profile()(light)
>>> stats()
Mon Apr  4 22:01:57 2016    /tmp/tmpnp_zk7dl

 **3 function calls in 0.001 seconds

 **Ordered by: internal time

 **ncalls  tottime  percall  cumtime  percall file:lineno(function)
 **1    0.001    0.001    0.001    0.001 {built-in method sleep}
 **1    0.000    0.000    0.001    0.001 myapp.py:9(light)

这种方法允许测试应用程序的部分,并锐化统计输出。但在这个阶段,拥有一个调用者列表可能并不有趣,因为函数已经被指出为需要优化的函数。唯一有趣的信息是知道它有多快,然后加以改进。

timeit更适合这种需求,它提供了一种简单的方法来测量小代码片段的执行时间,使用主机系统提供的最佳底层计时器(time.timetime.clock):

>>> from myapp import light
>>> import timeit
>>> t = timeit.Timer('main()')
>>> t.timeit(number=5)
10000000 loops, best of 3: 0.0269 usec per loop
10000000 loops, best of 3: 0.0268 usec per loop
10000000 loops, best of 3: 0.0269 usec per loop
10000000 loops, best of 3: 0.0268 usec per loop
10000000 loops, best of 3: 0.0269 usec per loop
5.6196951866149902

该模块允许您重复调用,并且旨在尝试独立的代码片段。这在应用程序上下文之外非常有用,比如在提示符中,但在现有应用程序中使用起来并不方便。

确定性分析器将根据计算机正在执行的操作提供结果,因此结果可能每次都会有所不同。多次重复相同的测试并进行平均值计算可以提供更准确的结果。此外,一些计算机具有特殊的 CPU 功能,例如SpeedStep,如果计算机在启动测试时处于空闲状态,可能会改变结果(参见en.wikipedia.org/wiki/SpeedStep)。因此,对小代码片段进行持续重复测试是一个好的做法。还有一些其他缓存需要记住,比如 DNS 缓存或 CPU 缓存。

timeit的结果应该谨慎使用。它是一个非常好的工具,可以客观比较两个短代码片段,但也容易让您犯下危险的错误,导致令人困惑的结论。例如,使用timeit模块比较两个无害的代码片段,可能会让您认为通过加法进行字符串连接比str.join()方法更快:

$ python3 -m timeit -s 'a = map(str, range(1000))' '"".join(a)'
1000000 loops, best of 3: 0.497 usec per loop

$ python3 -m timeit -s 'a = map(str, range(1000)); s=""' 'for i in a: s += i'
10000000 loops, best of 3: 0.0808 usec per loop

从第二章 语法最佳实践 - 类级别以下,我们知道通过加法进行字符串连接不是一个好的模式。尽管有一些微小的 CPython 微优化专门为这种用例设计,但最终会导致二次运行时间。问题在于timeitsetup参数(命令行中的-s参数)以及 Python 3 中范围的工作方式的细微差别。我不会讨论问题的细节,而是留给您作为练习。无论如何,以下是在 Python 3 中使用str.join()习惯用法来比较字符串连接的正确方法:

$ python3 -m timeit -s 'a = [str(i) for i in range(10000)]' 's="".join(a)'
10000 loops, best of 3: 128 usec per loop

$ python3 -m timeit -s 'a = [str(i) for i in range(10000)]' '
>s = ""
>for i in a:
>    s += i
>'
1000 loops, best of 3: 1.38 msec per loop

测量 Pystones

在测量执行时间时,结果取决于计算机硬件。为了能够产生一个通用的度量,最简单的方法是对一段固定的代码序列进行速度基准测试,并计算出一个比率。从那里,函数所花费的时间可以转换为一个通用值,可以在任何计算机上进行比较。

有很多用于测量计算机性能的通用基准测试工具。令人惊讶的是,一些很多年前创建的工具今天仍在使用。例如,Whetstone 是在 1972 年创建的,当时它提供了一种 Algol 60 的计算机性能分析器。它用于测量每秒 Whetstone 百万条指令MWIPS)。在freespace.virgin.net/roy.longbottom/whetstone%20results.htm上维护了一张旧 CPU 和现代 CPU 的结果表。

Python 在其test包中提供了一个基准测试工具,用于测量一系列精心选择的操作的持续时间。结果是计算机每秒能够执行的pystones数量,以及执行基准测试所用的时间,通常在现代硬件上大约为一秒:

>>> from test import pystone
>>> pystone.pystones()
(1.0500000000000007, 47619.047619047589)

速率可以用来将配置持续时间转换为一定数量的 pystones:

>>> from test import pystone
>>> benchtime, pystones = pystone.pystones()
>>> def seconds_to_kpystones(seconds):
...     return (pystones*seconds) / 1000** 
...** 
...** 
>>> seconds_to_kpystones(0.03)
1.4563106796116512
>>> seconds_to_kpystones(1)
48.543689320388381
>>> seconds_to_kpystones(2)
97.087378640776762

seconds_to_kpystones返回千 pystones的数量。如果您想对执行速度进行编码,这种转换可以包含在您的测试中。

拥有 pystones 将允许您在测试中使用这个装饰器,以便您可以对执行时间进行断言。这些测试将在任何计算机上都可以运行,并且将允许开发人员防止速度回归。当应用程序的一部分被优化后,他们将能够在测试中设置其最大执行时间,并确保它不会被进一步的更改所违反。这种方法当然不是理想的,也不是 100%准确的,但至少比将执行时间断言硬编码为以秒为单位的原始值要好。

内存使用情况

优化应用程序时可能遇到的另一个问题是内存消耗。如果程序开始占用太多内存,以至于系统开始交换,那么您的应用程序中可能存在太多对象被创建的地方,或者您并不打算保留的对象仍然被一些意外的引用保持活动。这通常很容易通过经典的分析来检测,因为消耗足够的内存使系统交换涉及到很多可以被检测到的 CPU 工作。但有时候这并不明显,内存使用情况必须进行分析。

Python 如何处理内存

当您使用 CPython 实现时,内存使用可能是 Python 中最难进行分析的事情。虽然像 C 这样的语言允许您获取任何元素的内存大小,但 Python 永远不会让您知道给定对象消耗了多少内存。这是由于语言的动态性质,以及内存管理不直接可访问给语言用户。

内存管理的一些原始细节已经在第七章中解释过了,其他语言中的 Python 扩展。我们已经知道 CPython 使用引用计数来管理对象分配。这是一种确定性算法,可以确保当对象的引用计数降至零时,将触发对象的释放。尽管是确定性的,但这个过程不容易在复杂的代码库中手动跟踪和推理。此外,根据 CPython 解释器的编译标志、系统环境或运行时上下文,内部内存管理器层可能决定留下一些空闲内存块以便将来重新分配,而不是完全释放它。

CPython 实现中的额外微优化也使得预测实际内存使用变得更加困难。例如,指向相同短字符串或小整数值的两个变量可能指向内存中的同一个对象实例,也可能不是。

尽管看起来相当可怕和复杂,但 Python 中的内存管理有很好的文档记录(参考docs.python.org/3/c-api/memory.html)。请注意,在调试内存问题时,大多数情况下可以忽略之前提到的微优化。此外,引用计数基本上是基于一个简单的陈述——如果给定对象不再被引用,它就会被移除。换句话说,在解释器之后,函数中的所有局部引用都会被移除。

  • 离开函数

  • 确保对象不再被使用

因此,仍然在内存中的对象有:

  • 全局对象

  • 仍然以某种方式被引用的对象

要小心参数 入站 出站的边缘情况。如果在参数中创建了一个对象,如果函数返回该对象,则参数引用仍然存在。如果将其用作默认值,可能会导致意外结果:

>>> def my_function(argument={}):  # bad practice
...     if '1' in argument:
...         argument['1'] = 2
...     argument['3'] = 4
...     return argument
...** 
>>> my_function()
{'3': 4}
>>> res = my_function()
>>> res['4'] = 'I am still alive!'
>>> print my_function()
{'3': 4, '4': 'I am still alive!'}

这就是为什么应该始终使用不可变对象的原因,就像这样:

>>> def my_function(argument=None):  # better practice
...     if argument is None:
...         argument = {}  # a fresh dict is created everytime
...     if '1' in argument:
...         argument['1'] = 2
...     argument['3'] = 4
...     return argument
...** 
>>> my_function()
{'3': 4}
>>> res = my_function()
>>> res['4'] = 'I am still alive!'
>>> print my_function()
{'3': 4}

Python 中的引用计数很方便,可以免除手动跟踪对象引用和手动销毁对象的义务。尽管这引入了另一个问题,即开发人员从不清理内存中的实例,如果开发人员不注意使用数据结构的方式,它可能会以不受控制的方式增长。

通常的内存占用者有:

  • 不受控制地增长的缓存

  • 全局注册实例的对象工厂,并且不跟踪它们的使用情况,比如每次调用查询时都会使用的数据库连接器创建者

  • 线程没有正确完成

  • 具有__del__方法并涉及循环的对象也会占用内存。在 Python 的旧版本(3.4 版本之前),垃圾收集器不会打破循环,因为它无法确定应该先删除哪个对象。因此,会造成内存泄漏。在大多数情况下,使用这种方法都是一个坏主意。

不幸的是,在使用 Python/C API 的 C 扩展中,必须手动管理引用计数和引用所有权,使用Py_INCREF()Py_DECREF()宏。我们在第七章中已经讨论了处理引用计数和引用所有权的注意事项,所以你应该已经知道这是一个充满各种陷阱的相当困难的话题。这就是为什么大多数内存问题是由没有正确编写的 C 扩展引起的。

内存分析

在开始解决 Python 中的内存问题之前,您应该知道 Python 中内存泄漏的性质是非常特殊的。在一些编译语言如 C 和 C++中,内存泄漏几乎完全是由不再被任何指针引用的分配的内存块引起的。如果您没有对内存的引用,就无法释放它,这种情况被称为内存泄漏。在 Python 中,用户没有低级内存管理,所以我们更多地处理泄漏的引用——对不再需要但未被移除的对象的引用。这会阻止解释器释放资源,但与 C 中的内存泄漏情况不同。当然,也总是有 C 扩展的特殊情况,但它们是一种完全不同类型的东西,需要完全不同的工具链,而且不能轻易从 Python 代码中检查。

因此,Python 中的内存问题主要是由意外或非计划的资源获取模式引起的。很少情况下,这是由于内存分配和释放例程的错误处理引起的真正错误。这样的例程只在 CPython 中在使用 Python/C API 编写 C 扩展时才对开发人员可用,而且很少会遇到。因此,Python 中所谓的内存泄漏主要是由软件的过度复杂性和其组件之间的次要交互引起的,这些问题很难追踪。为了发现和定位软件的这些缺陷,您需要了解程序中实际内存使用的情况。

获取有关由 Python 解释器控制的对象数量及其实际大小的信息有点棘手。例如,要知道给定对象的大小需要遍历其所有属性,处理交叉引用,然后将所有内容相加。如果考虑到对象相互引用的方式,这是一个相当困难的问题。gc模块没有为此提供高级函数,而且需要 Python 以调试模式编译才能获得完整的信息。

通常,程序员在执行给定操作之后和之前会询问系统关于其应用程序的内存使用情况。但这种测量是一种近似值,很大程度上取决于系统级别的内存管理方式。例如,在 Linux 下使用top命令或在 Windows 下使用任务管理器,可以在内存问题明显时检测到内存问题。但这种方法很费力,使得很难追踪到有问题的代码块。

幸运的是,有一些工具可以创建内存快照并计算加载对象的数量和大小。但让我们记住,Python 不会轻易释放内存,它更愿意保留内存以防再次需要。

有一段时间,调试 Python 中的内存问题和使用情况时最流行的工具之一是 Guppy-PE 及其 Heapy 组件。不幸的是,它似乎已不再维护,并且缺乏 Python 3 支持。幸运的是,还有一些其他替代方案在某种程度上与 Python 3 兼容:

请注意,前面的信息纯粹基于最新版本的特色软件包使用的 trove 分类器。这可能会在本书编写后的时间内轻松更改。尽管如此,目前有一个软件包支持最广泛的 Python 版本,并且也已知在 Python 3.5 下完美运行。它就是objgraph。它的 API 似乎有点笨拙,并且功能集非常有限。但它工作正常,做了它需要做的事情,并且非常容易使用。内存检测不是永久添加到生产代码中的东西,因此这个工具不需要很漂亮。由于它在 OS 独立性中支持 Python 版本的广泛支持,我们在讨论内存分析示例时将只关注objgraph。本节提到的其他工具也是令人兴奋的软件,但您需要自行研究它们。

objgraph

objgraph(参见mg.pov.lt/objgraph/)是一个简单的工具,用于创建对象引用的图表,应该在查找 Python 内存泄漏时非常有用。它可以在 PyPI 上找到,但它不是一个完全独立的工具,需要 Graphviz 来创建内存使用图表。对于像 Mac OS X 或 Linux 这样的开发人员友好的系统,您可以使用您喜欢的系统包管理器轻松获取它。对于 Windows,您需要从项目页面(参见www.graphviz.org/)下载 Graphviz 安装程序并手动安装。

objgraph 提供了多种实用工具,允许您列出和打印有关内存使用和对象计数的各种统计信息。以下是一个使用这些实用程序的示例,显示了解释器会话的转录。

>>> import objgraph
>>> objgraph.show_most_common_types()
function                   1910
dict                       1003
wrapper_descriptor         989
tuple                      837
weakref                    742
method_descriptor          683
builtin_function_or_method 666
getset_descriptor          338
set                        323
member_descriptor          305
>>> objgraph.count('list')
266
>>> objgraph.typestats(objgraph.get_leaking_objects())
{'Gt': 1, 'AugLoad': 1, 'GtE': 1, 'Pow': 1, 'tuple': 2, 'AugStore': 1, 'Store': 1, 'Or': 1, 'IsNot': 1, 'RecursionError': 1, 'Div': 1, 'LShift': 1, 'Mod': 1, 'Add': 1, 'Invert': 1, 'weakref': 1, 'Not': 1, 'Sub': 1, 'In': 1, 'NotIn': 1, 'Load': 1, 'NotEq': 1, 'BitAnd': 1, 'FloorDiv': 1, 'Is': 1, 'RShift': 1, 'MatMult': 1, 'Eq': 1, 'Lt': 1, 'dict': 341, 'list': 7, 'Param': 1, 'USub': 1, 'BitOr': 1, 'BitXor': 1, 'And': 1, 'Del': 1, 'UAdd': 1, 'Mult': 1, 'LtE': 1}

如前所述,objgraph允许您创建内存使用模式和交叉引用的图表。该库最有用的图表工具是objgraph.show_refs()objgraph.show_backrefs()。它们都接受对被检查对象的引用,并使用 Graphviz 包将图表图像保存到文件中。这些图的示例在图 2图 3中呈现。

以下是用于创建这些图表的代码:

import objgraph

def example():
    x = []
    y = [x, [x], dict(x=x)]

    objgraph.show_refs(
        (x, y),
        filename='show_refs.png',
        refcounts=True
    )
    objgraph.show_backrefs(
        (x, y),
        filename='show_backrefs.png',
        refcounts=True
    )

if __name__ == "__main__":
    example()

图 2显示了由xy对象持有的所有引用的图表。从上到下,从左到右,它确切地呈现了四个对象:

  • y = [x, [x], dict(x=x)] 列表实例

  • dict(x=x) 字典实例

  • [x] 列表实例

  • x = [] 列表实例

objgraph

图 2 show_refs() 函数的示例结果

图 3不仅显示了xy之间的引用,还显示了所有持有对这两个实例的引用的对象。这些被称为反向引用,对于找到阻止其他对象被释放的对象非常有帮助。

objgraph

图 3 show_backrefs() 函数的示例结果

为了展示objgraph如何在实践中使用,让我们回顾一些实际的例子。正如我们在本书中已经多次提到的,CPython 有自己的垃圾收集器,它独立于其引用计数方法存在。它不用于一般的内存管理,而仅用于解决循环引用的问题。在许多情况下,对象可能以一种使得使用简单的基于跟踪引用数量的技术无法删除它们的方式相互引用。以下是最简单的例子:

x = []
y = [x]
x.append(y)

这种情况在图 4中以可视化方式呈现。在前面的情况下,即使所有对xy对象的外部引用都将被移除(例如,通过从函数的局部范围返回),这两个对象也不能被移除,因为这两个对象仍然拥有的两个交叉引用。这是 Python 垃圾收集器介入的情况。它可以检测到对象的循环引用并在循环外没有其他有效引用时触发它们的释放。

objgraph

图 4 两个对象之间循环引用的示例图表

当这样的循环中至少有一个对象定义了自定义的__del__()方法时,真正的问题开始。这是一个自定义的释放处理程序,当对象的引用计数最终变为零时将被调用。它可以执行任意的 Python 代码,因此也可以创建对特色对象的新引用。这就是为什么在 Python 3.4 版本之前的垃圾收集器无法打破引用循环的原因,如果其中至少有一个对象提供了自定义的__del__()方法实现。PEP 442 引入了对 Python 的安全对象最终化,并成为 Python 3.4 版本开始的标准的一部分。无论如何,这对于担心向后兼容性并针对广泛的 Python 解释器版本的软件包仍可能是一个问题。以下代码片段向您展示了不同 Python 版本中循环垃圾收集器行为的差异:

import gc
import platform
import objgraph

class WithDel(list):
    """ list subclass with custom __del__ implementation """
    def __del__(self):
        pass

def main():
    x = WithDel()
    y = []
    z = []

    x.append(y)
    y.append(z)
    z.append(x)

    del x, y, z

    print("unreachable prior collection: %s" % gc.collect())
    print("unreachable after collection: %s" % len(gc.garbage))
    print("WithDel objects count:        %s" %
          objgraph.count('WithDel'))

if __name__ == "__main__":
    print("Python version: %s" % platform.python_version())
    print()
    main()

在 Python 3.3 下执行上述代码的输出显示,旧版本的 Python 中的循环垃圾收集器无法收集定义了__del__()方法的对象:

$ python3.3 with_del.py** 
Python version: 3.3.5

unreachable prior collection: 3
unreachable after collection: 1
WithDel objects count:        1

在较新版本的 Python 中,垃圾收集器可以安全地处理对象的最终化,即使它们定义了__del__()方法:

$ python3.5 with_del.py** 
Python version: 3.5.1

unreachable prior collection: 3
unreachable after collection: 0
WithDel objects count:        0

尽管在最新的 Python 版本中自定义最终化不再棘手,但对于需要在不同环境下工作的应用程序仍然是一个问题。如前所述,objgraph.show_refs()objgraph.show_backrefs()函数允许您轻松地发现有问题的类实例。例如,我们可以轻松修改main()函数以显示对WithDel实例的所有反向引用,以查看是否存在泄漏资源:

def main():
    x = WithDel()
    y = []
    z = []

    x.append(y)
    y.append(z)
    z.append(x)

    del x, y, z

    print("unreachable prior collection: %s" % gc.collect())
    print("unreachable after collection: %s" % len(gc.garbage))
    print("WithDel objects count:        %s" %
          objgraph.count('WithDel'))

    objgraph.show_backrefs(
        objgraph.by_type('WithDel'),
        filename='after-gc.png'
    )

在 Python 3.3 下运行上述示例将导致一个图表(见图 5),显示gc.collect()无法成功移除xyz对象实例。此外,objgraph突出显示了所有定义了自定义__del__()方法的对象,以便更容易地发现此类问题。

objgraph

图 5 显示在 Python 3.4 版本之前无法被 Python 垃圾收集器捕获的循环引用的示例图表

C 代码内存泄漏

如果 Python 代码看起来完全正常,当您循环执行隔离的函数时内存仍然增加,那么泄漏可能发生在 C 端。例如,当缺少Py_DECREF调用时会发生这种情况。

Python 核心代码非常健壮,并经过泄漏测试。如果您使用具有 C 扩展的软件包,它们可能是首先要查看的地方。因为您将处理的代码比 Python 的抽象级别低得多,您需要使用完全不同的工具来解决此类内存问题。

在 C 中进行内存调试并不容易,因此在深入研究扩展内部之前,请确保正确诊断问题的根源。隔离一个可疑的包并使用类似于单元测试的代码是一个非常流行的方法:

  • 为您怀疑泄漏内存的扩展的每个 API 单元或功能编写单独的测试

  • 在隔离中进行测试循环(每次运行一个测试)

  • 从外部观察被测试功能中哪些会随时间增加内存使用量

使用这种方法,您可以隔离扩展的故障部分,这将减少以后检查和修复其代码所需的时间。这个过程可能看起来很繁重,因为它需要大量额外的时间和编码,但从长远来看,它真的很值得。您可以通过重用一些测试工具来简化工作,这些工具在第十章中介绍,测试驱动开发。像 tox 这样的实用程序也许并不是专门为这种情况设计的,但它们至少可以减少在隔离环境中运行多个测试所需的时间。

希望您已经隔离了扩展中泄漏内存的部分,并最终可以开始实际调试。如果您很幸运,对源代码进行简单的手动检查可能会得到期望的结果。在许多情况下,问题就像添加丢失的Py_DECREF调用一样简单。然而,在大多数情况下,我们的工作并不那么简单。在这种情况下,您需要使用一些更强大的工具。在编译代码中对抗内存泄漏的一个显著通用工具是Valgrind,它应该是每个程序员的工具包中的一部分。它是一个用于构建动态分析工具的整个仪器框架。因此,它可能不容易学习和掌握,但您绝对应该了解基础知识。

分析网络使用情况

正如我之前所说,与数据库、缓存、Web 服务或 LDAP 服务器等第三方程序通信的应用程序在这些应用程序运行缓慢时可能会变慢。这可以通过应用程序端的常规代码分析方法进行跟踪。但是,如果第三方软件单独运行良好,那么问题很可能是网络。

问题可能是配置错误的中心、低带宽网络链接,甚至是大量的流量碰撞,导致计算机多次发送相同的数据包。

以下是一些要素,可以帮助您了解正在发生什么,首先需要调查三个领域:

如果您想进一步了解网络性能问题,您可能还想阅读网络性能开源工具包,作者 Richard Blum,Wiley。这本书介绍了调整大量使用网络的应用程序的策略,并提供了扫描复杂网络问题的教程。

高性能 MySQLO'Reilly Media,作者 Jeremy Zawodny 在编写使用 MySQL 的应用程序时也是一本不错的书。

总结

在本章中,我们已经看到:

  • 优化的三个规则:

  • 先让它工作

  • 以用户的角度看问题

  • 保持代码可读性

  • 基于编写具有速度目标的场景的优化策略

  • 如何分析 CPU 或内存使用情况以及一些网络分析的技巧

现在您知道如何定位性能问题,下一章将介绍一些流行和通用的策略来摆脱这些问题。

第十二章:优化-一些强大的技术

优化程序并不是一个神奇的过程。它是通过遵循一个简单的算法完成的,由 Stefan Schwarzer 在 Europython 2006 中合成的原始伪代码示例:

def optimize():
    """Recommended optimization"""
    assert got_architecture_right(), "fix architecture"
    assert made_code_work(bugs=None), "fix bugs"
    while code_is_too_slow():
        wbn = find_worst_bottleneck(just_guess=False,
                                    profile=True)
        is_faster = try_to_optimize(wbn,
                                    run_unit_tests=True,
                                    new_bugs=None)
        if not is_faster:
            undo_last_code_change()

# By Stefan Schwarzer, Europython 2006

这个例子可能不是最整洁和最清晰的例子,但基本上涵盖了组织优化过程的所有重要方面。我们从中学到的主要内容是:

  • 优化是一个迭代过程,不是每一次迭代都会带来更好的结果

  • 主要的前提是经过测试验证的代码能够正常工作

  • 您应该始终专注于优化当前的应用程序瓶颈

使您的代码运行更快并不是一件容易的事情。在抽象数学问题的情况下,解决方案当然在于选择正确的算法和适当的数据结构。但在这种情况下,很难提供一些通用的提示和技巧,可以用于解决算法问题的任何代码。当然,有一些通用的方法论用于设计新算法,甚至可以应用于各种问题的元启发式算法,但它们是相当与语言无关的,因此超出了本书的范围。

无论如何,一些性能问题只是由特定的代码质量缺陷或应用程序使用上下文引起的。例如,应用程序的速度可能会因为:

  • 基本内置类型的错误使用

  • 过于复杂

  • 硬件资源使用模式与执行环境不匹配

  • 等待第三方 API 或后台服务的响应时间过长

  • 在应用程序的时间关键部分做得太多

更多时候,解决这些性能问题并不需要高级的学术知识,而只需要良好的软件工艺。而工艺的一大部分就是知道何时使用适当的工具。幸运的是,有一些处理性能问题的众所周知的模式和解决方案。

在本章中,我们将讨论一些流行且可重复使用的解决方案,使您能够通过非算法优化程序:

  • 降低复杂性

  • 使用架构权衡

  • 缓存

降低复杂性

在我们进一步探讨优化技术之前,让我们明确定义我们要处理的内容。从本章的介绍中,我们知道专注于改进应用程序瓶颈对于成功的优化至关重要。瓶颈是严重限制程序或计算机系统容量的单个组件。每个具有性能问题的代码的一个重要特征是它通常只有一个瓶颈。我们在上一章中讨论了一些分析技术,所以您应该已经熟悉了定位和隔离这些地方所需的工具。如果您的分析结果显示有一些地方需要立即改进,那么您应该首先尝试将每个地方视为一个独立的组件并进行独立优化。

当然,如果没有明显的瓶颈,但您的应用程序仍然表现不符合您的期望,那么您真的处于一个糟糕的位置。优化过程的收益与优化瓶颈的性能影响成正比。优化每个不会对整体执行时间或资源消耗产生实质性贡献的小组件,只会让您在分析和优化上花费的时间获益微薄。如果您的应用程序似乎没有真正的瓶颈,有可能是您遗漏了某些东西。尝试使用不同的分析策略或工具,或者从不同的角度(内存、I/O 操作或网络吞吐量)来看待它。如果这并没有帮助,您应该真正考虑修改您的软件架构。

但是,如果您成功找到了限制应用程序性能的单个完整组件,那么您真的很幸运。很有可能,只需进行最小的代码改进,您就能真正提高代码执行时间和/或资源使用率。而优化的收益将再次与瓶颈的大小成正比。

在尝试提高应用程序性能时,首要和最明显的方面是复杂性。关于程序复杂性有很多定义,也有很多表达方式。一些复杂度度量标准可以提供关于代码行为的客观信息,有时这些信息可以推断出性能期望。有经验的程序员甚至可以可靠地猜测两种不同的实现在实践中的性能,知道它们的复杂性和现实的执行环境。

定义应用程序复杂性的两种流行方法是:

  • 圈复杂度经常与应用程序性能相关联

  • Landau 符号,也称为大 O 符号,是一种非常有用的算法分类方法,可以客观地评判性能。

从那里,优化过程有时可以理解为降低复杂性的过程。本节提供了简化循环的简单技巧。但首先,让我们学习如何测量复杂性。

圈复杂度

圈复杂度是由 Thomas J. McCabe 在 1976 年开发的一个度量标准。因为它的作者,它经常被称为 McCabe 的复杂度。它衡量了代码中的线性路径数量。所有的 if,for 和 while 循环都被计算出一个度量。

然后可以将代码分类如下:

圈复杂度 它的含义
1 到 10 不复杂
11 到 20 中等复杂
21 到 50 真的很复杂
大于 50 太复杂

圈复杂度更多是代码质量评分,而不是客观评判其性能的度量标准。它不能取代寻找性能瓶颈的代码性能分析的需要。无论如何,具有较高圈复杂度的代码往往倾向于使用相当复杂的算法,这些算法在输入数据较大时可能表现不佳。

尽管圈复杂度不是判断应用程序性能的可靠方法,但它有一个非常好的优势。它是一个源代码度量标准,因此可以用适当的工具来测量。这不能说是关于表达复杂性的其他方式——大 O 符号。由于可测量性,圈复杂度可能是对性能分析的有用补充,它可以为您提供有关软件问题部分的更多信息。在考虑根本性的代码架构重设计时,复杂的代码部分是首先要审查的。

在 Python 中测量 McCabe 的复杂度相对简单,因为它可以从其抽象语法树中推导出来。当然,你不需要自己做这个。一个为 Python 提供圈复杂度测量的流行工具是 flake8(带有 mccabe 插件),它已经在第四章“选择良好的名称”中介绍过。

大 O 符号

定义函数复杂性的最经典方法是大 O 符号。这个度量标准定义了算法如何受输入数据大小的影响。例如,算法是否与输入数据的大小成线性关系还是二次关系?

手动计算算法的大 O 符号是获得算法性能与输入数据大小关系概览的最佳方法。了解应用程序组件的复杂度使您能够检测并专注于真正减慢代码的部分。

为了衡量大 O 符号,所有常数和低阶项都被移除,以便专注于当输入数据增长时真正起作用的部分。这个想法是尝试将算法归类为这些类别中的一个,即使它是一个近似值:

符号 类型
O(1) 常数。不依赖于输入数据。
O(n) 线性。随着“n”的增长而增长。
O(n log n) 准线性。
O(n²) 二次复杂度。
O(n³) 立方复杂度。
O(n!) 阶乘复杂度。

例如,我们已经从第二章中知道,dict查找的平均复杂度是O(1)。无论dict中有多少元素,它都被认为是常数,而查找特定项的列表中的元素是O(n)

让我们来看另一个例子:

>>> def function(n):
...     for i in range(n):
...         print(i)
...

在这种情况下,打印语句将被执行n次。循环速度将取决于n,因此它的复杂度使用大 O 符号表示将是O(n)

如果函数有条件,保留的正确符号是最高的:

>>> def function(n):
...     if some_test:
...         print('something')
...     else:
...         for i in range(n):
...             print(i)
...** 

在这个例子中,函数可能是O(1)O(n),取决于测试。但最坏情况是O(n),所以整个函数的复杂度是O(n)

在讨论用大 O 符号表示的复杂度时,我们通常会考虑最坏情况。虽然这是在比较两个独立算法的复杂度时最好的方法,但在每种实际情况下可能不是最佳方法。许多算法会根据输入数据的统计特征改变运行时性能,或者通过巧妙的技巧摊销最坏情况操作的成本。这就是为什么在许多情况下,最好以平均复杂度摊销复杂度来审查你的实现。

例如,看一下将单个元素附加到 Python 的list类型实例的操作。我们知道 CPython 中的list使用具有内部存储的过度分配的数组,而不是链表。如果数组已满,附加新元素需要分配新数组,并将所有现有元素(引用)复制到内存中的新区域。如果从最坏情况复杂度的角度来看,很明显list.append()方法的复杂度是O(n)。与链表结构的典型实现相比,这有点昂贵。

但我们也知道 CPython 的list类型实现使用过度分配来减轻这种偶尔重新分配的复杂性。如果我们评估一系列操作的复杂性,我们会发现list.append()平均复杂度O(1),这实际上是一个很好的结果。

在解决问题时,我们通常对输入数据的许多细节有很多了解,比如它的大小或统计分布。在优化应用程序时,始终值得利用关于输入数据的每一个知识点。在这里,最坏情况复杂度的另一个问题开始显现出来。它旨在显示函数在输入趋向于大值或无穷大时的极限行为,而不是为真实数据提供可靠的性能近似值。渐近符号在定义函数的增长率时非常有用,但它不会对一个简单的问题给出可靠的答案:哪种实现会花费更少的时间?最坏情况复杂度会忽略关于你的实现和数据特征的所有细节,以显示你的程序在渐近上的行为。它适用于可能根本不需要考虑的任意大的输入。

例如,假设您有一个关于由n个独立元素组成的数据的问题要解决。再假设您知道两种不同的解决这个问题的方法——程序 A程序 B。您知道程序 A需要 100n²次操作才能完成,而程序 B需要 5n³次操作才能给出问题的解决方案。您会选择哪一个?当谈论非常大的输入时,程序 A当然是更好的选择,因为它在渐近上表现更好。它的复杂度是O(n²),而程序 B的复杂度是O(n³)

但是通过解决一个简单的 100 n² > 5 n³不等式,我们可以发现当n小于 20 时,程序 B将需要更少的操作。如果我们对输入范围有更多了解,我们可以做出稍微更好的决策。

简化

为了减少代码的复杂性,数据存储的方式是基础性的。您应该仔细选择数据结构。本节提供了一些简单代码片段的性能如何通过适当的数据类型来提高的示例。

在列表中搜索

由于 Python 中list类型的实现细节,搜索列表中特定值不是一个廉价的操作。list.index()方法的复杂度是O(n),其中n是列表元素的数量。如果不需要执行许多元素索引查找,这种线性复杂度并不特别糟糕,但如果需要执行许多这样的操作,它可能会产生负面的性能影响。

如果您需要在列表上进行快速搜索,可以尝试 Python 标准库中的bisect模块。该模块中的函数主要设计用于以保持已排序序列顺序的方式插入或查找给定值的插入索引。无论如何,它们可以用于使用二分算法有效地查找元素索引。以下是官方文档中使用二分搜索查找元素索引的函数的配方:

def index(a, x):
    'Locate the leftmost value exactly equal to x'
    i = bisect_left(a, x)
    if i != len(a) and a[i] == x:
        return i
    raise ValueError

请注意,bisect模块中的每个函数都需要一个排序好的序列才能工作。如果您的列表没有按正确的顺序排列,那么对其进行排序至少需要O(n log n)的复杂度。这是比O(n)更糟糕的类别,因此对整个列表进行排序仅进行单个搜索肯定不划算。但是,如果您需要在一个不经常改变的大列表中执行大量索引搜索,那么使用单个排序操作的bisect可能是一个非常好的折衷方案。

另外,如果您已经有一个排序好的列表,您可以使用bisect插入新的项目到该列表中,而无需重新排序。

使用set而不是列表

当您需要从给定序列中构建一系列不同值时,可能首先想到的算法是:

>>> sequence = ['a', 'a', 'b', 'c', 'c', 'd']
>>> result = []
>>> for element in sequence:
...     if element not in result:
...         result.append(element)
...** 
>>> result
['a', 'b', 'c', 'd']

复杂度是由在result列表中使用in运算符引入的,它的时间复杂度是O(n)。然后它在循环中使用,这将花费O(n)。因此,总体复杂度是二次的—O(n²)

对于相同的工作使用set类型将更快,因为存储的值使用哈希查找,就像dict类型一样。此外,set确保元素的唯一性,因此我们不需要做任何额外的工作,只需从我们的sequence对象创建一个新的集合。换句话说,对于sequence中的每个值,查看它是否已经在set中所花费的时间将是恒定的:

>>> sequence = ['a', 'a', 'b', 'c', 'c', 'd']
>>> result = set(sequence)
>>> result
set(['a', 'c', 'b', 'd'])

这将复杂度降低到O(n),这是set对象创建的复杂度。额外的优势是代码更短更明确。

注意

当您尝试降低算法的复杂度时,要仔细考虑您的数据结构。有各种内置类型,所以要选择合适的类型。

减少外部调用,减轻工作量

复杂性的一部分是由于调用其他函数、方法和类引入的。一般来说,尽可能多地将代码从循环中移出。对于嵌套循环来说,这一点尤为重要。不要一遍又一遍地重新计算那些在循环开始之前就可以计算出来的东西。内部循环应该是紧凑的。

使用 collections

collections 模块提供了高性能的替代内置容器类型。该模块中提供的主要类型有:

  • deque:带有额外功能的类似列表的类型

  • defaultdict:带有内置默认工厂功能的类似字典的类型

  • namedtuple:类似元组的类型,为成员分配键

deque

deque 是列表的另一种实现方式。列表基于数组,而 deque 基于双向链表。因此,当需要在中间或头部插入时,deque 要快得多,但是当需要访问任意索引时,deque 要慢得多。

当然,由于 Python list 类型中内部数组的过度分配,不是每次 list.append() 调用都需要内存重新分配,而这种方法的平均复杂度是 O(1)。但是,popsappends 在链表上执行时通常比在数组上执行要快。当元素需要添加到序列的任意点时,情况会发生戏剧性的变化。因为数组中新元素右侧的所有元素都需要移动,所以 list.insert() 的复杂度是 O(n)。如果需要执行大量的 pops、appends 和 inserts,那么使用 deque 而不是列表可能会提供显著的性能改进。但是在从 list 切换到 deque 之前,一定要对代码进行分析,因为在数组中快速的一些操作(例如访问任意索引)在链表中非常低效。

例如,如果我们使用 timeit 测量向序列添加一个元素并从中删除的时间,listdeque 之间的差异甚至可能不会被注意到:

$ python3 -m timeit \
> -s 'sequence=list(range(10))' \
> 'sequence.append(0); sequence.pop();'
1000000 loops, best of 3: 0.168 usec per loop

$ python3 -m timeit \** 
> -s 'from collections import deque; sequence=deque(range(10))' \
> 'sequence.append(0); sequence.pop();'
1000000 loops, best of 3: 0.168 usec per loop

但是,如果我们对想要添加和移除序列的第一个元素的情况进行类似的比较,性能差异是显著的:

$ python3 -m timeit \
> -s 'sequence=list(range(10))' \
> 'sequence.insert(0, 0); sequence.pop(0)'

1000000 loops, best of 3: 0.392 usec per loop
$ python3 -m timeit \
> -s 'from collections import deque; sequence=deque(range(10))' \
> 'sequence.appendleft(0); sequence.popleft()'
10000000 loops, best of 3: 0.172 usec per loop

而且,当序列的大小增长时,这种差异会变得更大。以下是对包含 10,000 个元素的列表执行相同测试的示例:

$ python3 -m timeit \
> -s 'sequence=list(range(10000))' \
> 'sequence.insert(0, 0); sequence.pop(0)'
100000 loops, best of 3: 14 usec per loop
$ python3 -m timeit \
> -s 'from collections import deque; sequence=deque(range(10000))' \** 
> 'sequence.appendleft(0); sequence.popleft()'
10000000 loops, best of 3: 0.168 usec per loop

由于高效的 append()pop() 方法可以同时从序列的两端以相同的速度工作,deque 是实现队列的完美类型。例如,使用 deque 而不是 list 来实现 FIFO(先进先出)队列将会更加高效。

注意

deque 在实现队列时效果很好。不过,从 Python 2.6 开始,Python 标准库中有一个单独的 queue 模块,提供了 FIFO、LIFO 和优先级队列的基本实现。如果要将队列用作线程间通信的机制,应该使用 queue 模块中的类,而不是 collections.deque。这是因为这些类提供了所有必要的锁定语义。如果不使用线程和不使用队列作为通信机制,那么 deque 应该足够提供队列实现的基础。

defaultdict

defaultdict 类型类似于 dict 类型,但为新键添加了一个默认工厂。这避免了编写额外的测试来初始化映射条目,并且比 dict.setdefault 方法更高效。

defaultdict 看起来只是 dict 上的语法糖,简单地允许您编写更短的代码。实际上,在失败的键查找时返回预定义值也比 dict.setdefault() 方法稍微快一些:

$ python3 -m timeit \
> -s 'd = {}'** 
> 'd.setdefault("x", None)'
10000000 loops, best of 3: 0.153 usec per loop
$ python3 -m timeit \** 
> -s 'from collections import defaultdict; d=defaultdict(lambda: None)' \
> 'd["x"]'
10000000 loops, best of 3: 0.0447 usec per loop

差异并不大,因为计算复杂度并没有改变。dict.setdefault方法包括两个步骤(键查找和键设置),这两个步骤的复杂度都是O(1),正如我们在第二章的字典部分中所看到的,语法最佳实践-类级别以下。没有办法使复杂度低于O(1)。但在某些情况下,它无疑更快,值得知道,因为在优化关键代码部分时,每一个小的速度提升都很重要。

defaultdict类型接受一个工厂作为参数,因此可以与不需要参数的内置类型或类一起使用其构造函数。以下是官方文档中的一个示例,展示了如何使用defaultdict进行计数:

>>> s = 'mississippi'
>>> d = defaultdict(int)
>>> for k in s:
...     d[k] += 1
...
>>> list(d.items())
[('i', 4), ('p', 2), ('s', 4), ('m', 1)]

namedtuple

namedtuple是一个类工厂,它接受一个类型名称和一个属性列表,并创建一个类。然后可以用这个类来实例化一个类似元组的对象,并为其元素提供访问器:

>>> from collections import namedtuple** 
>>> Customer = namedtuple(
...     'Customer',
...     'firstname lastname'
... )
>>> c = Customer('Tarek', 'Ziadé')
>>> c.firstname
'Tarek'

它可以用来创建比需要一些样板代码来初始化值的自定义类更容易编写的记录。另一方面,它基于元组,因此通过索引访问其元素非常快。生成的类可以被子类化以添加更多操作。

使用namedtuple而不是其他数据类型的收益一开始可能并不明显。主要优点是它比普通元组更容易使用、理解和解释。元组索引不携带任何语义,因此通过属性访问元组元素也很好。但是,你也可以从具有O(1)获取/设置操作平均复杂度的字典中获得相同的好处。

就性能而言,namedtuple的第一个优势是它仍然是tuple的一种。这意味着它是不可变的,因此底层数组存储被分配到了所需的大小。另一方面,字典需要使用内部哈希表的过度分配来确保获取/设置操作的平均复杂度较低。因此,namedtuple在内存效率方面胜过dict

namedtuple基于元组的事实也可能对性能有益。它的元素可以通过整数索引访问,就像另外两个简单的序列对象-列表和元组一样。这个操作既简单又快速。在dict或自定义类实例(也使用字典来存储属性)的情况下,元素访问需要哈希表查找。它经过高度优化,以确保不管集合大小如何,性能都很好,但提到的O(1)复杂度实际上只是平均复杂度dict在设置/获取操作的实际摊销最坏情况复杂度是O(n)。在对性能关键的代码部分,有时使用列表或元组而不是字典可能是明智的。这仅仅是因为它们在性能方面更可预测。

在这种情况下,namedtuple是一种很好的类型,它结合了字典和元组的优点:

  • 在更重视可读性的部分,可能更喜欢属性访问

  • 在性能关键的部分,元素可以通过它们的索引访问

注意

通过将数据存储在与算法使用方式良好匹配的高效数据结构中,可以实现降低复杂性。

也就是说,当解决方案不明显时,你应该考虑放弃并重写被指责的部分,而不是为了性能而破坏代码的可读性。

通常情况下,Python 代码既可以可读又可以快速。因此,尝试找到一种执行工作的好方法,而不是试图绕过有缺陷的设计。

使用架构权衡

当您的代码无法通过减少复杂性或选择适当的数据结构来进一步改进时,一个很好的方法可能是考虑做一些权衡。如果我们审查用户问题并定义对他们来说真正重要的是什么,我们可以放松一些应用要求。性能通常可以通过以下方式改进:

  • 用启发式和近似算法替换确切解算法

  • 将一些工作推迟到延迟任务队列

  • 使用概率数据结构

使用启发式和近似算法

有些算法问题根本没有可以在用户可接受的时间内运行的最先进解决方案。例如,考虑一个处理一些复杂优化问题的程序,如旅行商问题TSP)或车辆路径问题VRP)。这两个问题都是组合优化中的NP 难问题。这些问题的确切算法的复杂度较低是未知的。这意味着可以实际解决的问题规模受到极大限制。对于非常大的输入,很可能无法在用户可接受的时间内提供确切的解决方案。

幸运的是,用户很可能对最佳解决方案不感兴趣,而是对足够好且及时获得的解决方案感兴趣。因此,当启发式或近似算法提供可接受的结果质量时,使用它们确实是有意义的:

  • 启发式通过在速度上进行权衡优化给定问题,而不是完整性、准确性或精度。它们专注于速度,但可能很难证明它们的解决方案质量与确切算法的结果相比。

  • 近似算法与启发式类似,但与启发式不同的是,它们具有可证明的解决方案质量和运行时间界限。

例如,已知一些良好的启发式和近似问题可以在合理的时间内解决极大的 TSP 问题。它们还有很高的概率产生距最优解仅 2-5%的结果。

启发式的另一个好处是,它们并不总是需要针对您需要解决的每个新问题从头开始构建。它们的高级版本,称为元启发式,提供了解决数学优化问题的策略,这些策略不是特定于问题的,因此可以应用于许多情况。一些流行的元启发式算法包括:

  • 模拟退火

  • 遗传算法

  • 禁忌搜索

  • 蚁群优化

  • 进化计算

使用任务队列和延迟处理

有时并不是做很多事情,而是在正确的时间做事情。一个很好的例子是在网页应用中发送电子邮件。在这种情况下,增加的响应时间可能并不一定是您实现的结果。响应时间可能被某些第三方服务所主导,例如电子邮件服务器。如果您的应用程序大部分时间都在等待其他服务的回复,您能优化您的应用程序吗?

答案既是肯定的也是否定的。如果您无法控制服务,这是处理时间的主要贡献者,并且没有其他更快的解决方案可用,那么您当然无法进一步加快速度。您不能简单地跳过时间以获取您正在等待的回复。下图( 1)展示了处理 HTTP 请求并导致发送电子邮件的简单示例。您无法减少等待时间,但可以改变用户的感知方式!

使用任务队列和延迟处理

图 1 网页应用中同步发送电子邮件的示例

这种类型问题的通常模式是使用消息/任务队列。当您需要做一些可能需要不确定时间的事情时,只需将其添加到需要完成的工作队列中,并立即响应接受请求的用户。这里,我们来到为什么发送电子邮件是一个很好的例子的原因。电子邮件已经是任务队列!如果您使用 SMTP 协议向电子邮件服务器提交新消息,成功的响应并不意味着您的电子邮件已经传递给收件人。这意味着电子邮件已经传递给了电子邮件服务器,并且它将稍后尝试进一步传递。

因此,如果服务器的响应并不保证电子邮件是否已经传递,您无需等待它以生成用户的 HTTP 响应。使用任务队列处理请求的更新流程如下图所示:

使用任务队列和延迟处理

图 2 Web 应用程序中异步电子邮件传递的示例

当然,您的电子邮件服务器可能响应非常快,但您需要更多时间来生成需要发送的消息。也许您正在生成 XLS 格式的年度报告,或者在 PDF 文件中交付发票。如果您使用的是已经是异步的电子邮件传输,那么也将整个消息生成任务放到消息处理系统中。如果无法保证准确的交付时间,那么您不应该打扰同步生成您的交付物。

在应用程序的关键部分正确使用任务/消息队列还可以给您带来其他好处:

  • 为服务 HTTP 请求的 Web 工作者将从额外的工作中解脱出来,处理请求更快。这意味着您将能够使用相同的资源处理更多的请求,从而处理更大的负载。

  • 消息队列通常更不容易受到外部服务的瞬态故障的影响。例如,如果您的数据库或电子邮件服务器不时超时,您可以始终重新排队当前处理的任务并稍后重试。

  • 通过良好的消息队列实现,您可以轻松地将工作分布在多台机器上。这种方法可能提高应用程序某些组件的可扩展性。

如您在图 2中所见,将异步任务处理添加到应用程序中不可避免地增加了整个系统架构的复杂性。您将需要设置一些新的后备服务(例如 RabbitMQ 这样的消息队列)并创建能够处理这些异步作业的工作者。幸运的是,有一些流行的工具用于构建分布式任务队列。在 Python 开发人员中最受欢迎的是Celerywww.celeryproject.org/)。它是一个完整的任务队列框架,支持多个消息代理,还允许定期执行任务(可以替代您的cron作业)。如果您需要更简单的东西,那么 RQ(python-rq.org/)可能是一个不错的选择。它比 Celery 简单得多,并使用 Redis 键/值存储作为其消息代理(RQ实际上代表Redis Queue)。

尽管有一些经过良好测试的工具,您应该始终仔细考虑您对任务队列的方法。绝对不是每种工作都应该在队列中处理。它们擅长解决一些问题,但也引入了一大堆新问题:

  • 系统架构的复杂性增加

  • 处理“多次”交付

  • 更多需要维护和监控的服务

  • 更长的处理延迟

  • 更困难的日志记录

使用概率数据结构

概率数据结构是设计为以一种允许您在时间或资源约束内回答一些特定问题的方式存储值集合的结构,这是其他数据结构无法实现的。最重要的事实是答案只有可能是真实的或是真实值的近似。然而,可以很容易地估计正确答案的概率或准确性。因此,尽管不总是给出正确答案,如果我们接受一定程度的误差,它仍然可以是有用的。

有许多具有这种概率特性的数据结构。它们中的每一个都解决了一些特定的问题,并且由于它们的随机性质,不能在每种情况下使用。但是,为了举一个实际的例子,让我们谈谈其中一个特别受欢迎的——HyperLogLog。

HyperLogLog(参见en.wikipedia.org/wiki/HyperLogLog)是一种近似估计多重集中不同元素数量的算法。对于普通集合,您需要存储每个元素,这对于非常大的数据集可能非常不切实际。HLL 与实现集合的经典方式不同。不深入实现细节,我们可以说它只专注于提供集合基数的近似值。因此,实际值从不存储。它们不能被检索、迭代和测试成员资格。HyperLogLog 以时间复杂度和内存大小交换准确性和正确性。例如,Redis 实现的 HLL 只需要 12k 字节,标准误差为 0.81%,集合大小没有实际限制。

使用概率数据结构是解决性能问题的一种非常有趣的方式。在大多数情况下,这是在速度更快的处理或更好的资源使用之间进行一些准确性或正确性的权衡。但并不总是需要这样。概率数据结构在键/值存储系统中经常用于加速键查找。在这类系统中使用的一种流行技术称为近似成员查询(AMQ)。可以用于此目的的一个有趣的数据结构是 Bloom 过滤器(参见en.wikipedia.org/wiki/Bloom_filter)。

缓存

当您的应用程序函数计算时间过长时,可以考虑的有用技术是缓存。缓存无非是保存返回值以供将来参考。运行成本高昂的函数或方法的结果可以被缓存,只要:

  • 函数是确定性的,给定相同的输入,结果每次都是相同的值

  • 函数的返回值在一段时间内仍然有用且有效(非确定性)

换句话说,确定性函数对于相同的参数集始终返回相同的结果,而非确定性函数返回可能随时间变化的结果。这种方法通常大大减少了计算时间,并允许您节省大量计算资源。

任何缓存解决方案最重要的要求是具有允许您检索保存的值的存储,其速度明显快于计算它们所需的时间。通常适合缓存的是:

  • 可查询数据库的可调用结果

  • 来自呈现静态值的可调用的结果,例如文件内容、Web 请求或 PDF 呈现

  • 来自执行复杂计算的确定性可调用的结果

  • 全局映射,跟踪具有过期时间的值,例如 Web 会话对象

  • 需要经常快速访问的结果

缓存的另一个重要用例是保存通过 Web 提供的第三方 API 的结果。这可能通过减少网络延迟大大提高应用程序性能,但也可以让您节省金钱,如果您被要求对此类 API 的每个请求进行计费。

根据您的应用架构,缓存可以以许多种方式和各种复杂程度实现。提供缓存的方式有很多种,复杂的应用程序可以在应用程序架构堆栈的不同级别上使用不同的方法。有时,缓存可能只是一个保留在进程内存中的单个全局数据结构(通常是dict)。在其他情况下,您可能希望设置一个专用的缓存服务,该服务将在精心定制的硬件上运行。本节将为您提供有关最流行的缓存方法的基本信息,并指导您通过常见的用例和常见的陷阱。

确定性缓存

确定性函数是缓存的最简单和最安全的用例。确定性函数如果给定完全相同的输入,总是返回相同的值,因此通常可以无限期地存储它们的结果。唯一的限制是用于缓存的存储大小。缓存这些结果的最简单方法是将它们放入进程内存中,因为这通常是从中检索数据的最快地方。这样的技术通常被称为记忆化

在优化可能多次评估相同输入的递归函数时,记忆化非常有用。我们已经在第七章中讨论了斐波那契数列的递归实现,其他语言中的 Python 扩展。当时,我们尝试用 C 和 Cython 来改进我们的程序的性能。现在我们将尝试通过更简单的方法来实现相同的目标——借助缓存的帮助。但在这样做之前,让我们回顾一下fibonacci()函数的代码:

def fibonacci(n):
    """ Return nth Fibonacci sequence number computed recursively
    """
    if n < 2:
        return 1
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

正如我们所见,fibonacci()是一个递归函数,如果输入值大于两,它会调用自身两次。这使得它非常低效。运行时间复杂度为O(2^n),执行会创建一个非常深和广的调用树。对于大的值,这个函数将需要非常长的时间来执行,并且很有可能很快就会超过 Python 解释器的最大递归限制。

如果您仔细观察图 3,它展示了一个示例调用树,您会发现它多次评估许多中间结果。如果我们能够重用其中一些值,就可以节省大量时间和资源。

确定性缓存

图 3 fibonacci(5)执行的调用树

一个简单的记忆化尝试是将先前运行的结果存储在字典中,并在可用时检索它们。fibonacci()函数中的递归调用都包含在一行代码中:

return fibonacci(n - 1) + fibonacci(n - 2)

我们知道 Python 从左到右评估指令。这意味着,在这种情况下,具有更高参数值的函数调用将在具有较低参数的函数调用之前执行。由于这个原因,我们可以通过构建一个非常简单的装饰器来提供记忆化:

def memoize(function):
    """ Memoize the call to single-argument function
    """
    call_cache = {}

    def memoized(argument):
        try:
            return call_cache[argument]
        except KeyError:
            return call_cache.setdefault(argument, function(argument))

    return memoized

@memoize
def fibonacci(n):
    """ Return nth Fibonacci sequence number computed recursively
    """
    if n < 2:
        return 1
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

我们在memoize()装饰器的闭包上使用了字典作为缓存值的简单存储。将值保存和检索到这个数据结构的平均O(1)复杂度,因此这大大降低了记忆化函数的总体复杂度。每个唯一的函数调用将只被评估一次。这样更新的函数的调用树如图 4所示。在不进行数学证明的情况下,我们可以直观地推断,在不改变fibonacci()函数的核心的情况下,我们将复杂度从非常昂贵的O(2n)降低到线性的O(n)

确定性缓存

图 4 使用记忆化执行 fibonacci(5)的调用树

当然,我们的memoize()装饰器的实现并不完美。它在那个简单的例子中表现良好,但绝对不是可重用的软件。如果您需要记忆化具有多个参数的函数或想要限制缓存的大小,您需要更通用的东西。幸运的是,Python 标准库提供了一个非常简单和可重用的实用程序,它在大多数情况下都可以用于在内存中缓存确定性函数的结果。这就是functools模块中的lru_cache(maxsize, typed)装饰器。名称来自 LRU 缓存,代表最近最少使用。附加参数允许更精细地控制记忆化行为:

  • maxsize:这设置了缓存的最大大小。None值表示没有限制。

  • typed:这定义了不同类型的值是否应该被缓存为给出相同结果。

在我们的斐波那契数列示例中使用lru_cache的方法如下:

@lru_cache(None)
def fibonacci(n):
    """ Return nth Fibonacci sequence number computed recursively
    """
    if n < 2:
        return 1
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

非确定性缓存

对于非确定性函数的缓存比记忆化更加棘手。由于这样一个函数的每次执行可能会产生不同的结果,通常不可能在任意长的时间内使用先前的值。你需要做的是决定缓存值可以被视为有效的时间有多长。在经过一段时间后,存储的结果被视为过时,缓存需要通过新值进行刷新。

通常需要缓存的非确定性函数往往依赖于很难在应用程序代码内部跟踪的某些外部状态。典型的组件示例包括:

  • 关系数据库和通常任何类型的结构化数据存储引擎

  • 通过网络连接访问的第三方服务(Web API)

  • 文件系统

换句话说,非确定性缓存在任何情况下都可以使用,当您临时使用预先计算的结果时,而不确定它们是否代表与其他系统组件的状态一致的状态(通常是后备服务)。

请注意,这种缓存的实现显然是一种权衡。因此,它与我们在使用架构权衡部分中介绍的技术有一定关系。如果您放弃每次运行代码的一部分,而是使用过去保存的结果,您就有可能使用变得过时或代表系统不一致状态的数据。这样,您就在以速度和性能换取正确性和/或完整性。

当然,只要与缓存交互所花费的时间少于函数所花费的时间,这种缓存就是有效的。如果重新计算值更快,那就尽管这样做!这就是为什么只有在值得的情况下才需要设置缓存;正确设置缓存是有成本的。

通常缓存的实际内容通常是与系统的其他组件交互的整个结果。如果您想在与数据库通信时节省时间和资源,值得缓存昂贵的查询。如果您想减少 I/O 操作的数量,您可能希望缓存经常访问的文件的内容(例如配置文件)。

缓存非确定性函数的技术实际上与缓存确定性函数的技术非常相似。最显著的区别是它们通常需要通过其年龄来使缓存值失效的选项。这意味着functools模块中的lru_cache()装饰器在这种情况下的用途非常有限。扩展此功能以提供过期功能应该不是很难,但我会把它留给你作为一个练习。

缓存服务

我们说过,非确定性缓存可以使用本地进程内存来实现,但实际上很少这样做。这是因为本地进程内存在大型应用程序中作为缓存存储的效用非常有限。

如果你遇到非确定性缓存是你首选的解决性能问题的方案,通常你需要更多。通常,当你需要同时为多个用户提供数据或服务时,非确定性缓存是你必须要的解决方案。如果是这样,那么迟早你需要确保用户可以同时被服务。虽然本地内存提供了一种在多个线程之间共享数据的方式,但它可能不是每个应用程序的最佳并发模型。它的扩展性不好,所以最终你将需要将你的应用程序作为多个进程运行。

如果你足够幸运,你可能需要在数百甚至数千台机器上运行你的应用程序。如果你想要将缓存值存储在本地内存中,这意味着你的缓存需要在每个需要它的进程上进行复制。这不仅仅是对资源的浪费。如果每个进程都有自己的缓存,那就已经是速度和一致性之间的权衡,你如何保证所有的缓存与彼此一致呢?

在后续请求之间保持一致性是一个严重的问题(尤其是)对于具有分布式后端的 Web 应用程序。在复杂的分布式系统中,确保用户始终由托管在同一台机器上的同一进程一致地提供服务是非常困难的。当然,在一定程度上是可以做到的,但一旦解决了这个问题,就会出现十个其他问题。

如果你正在开发一个需要为多个并发用户提供服务的应用程序,那么处理非确定性缓存的最佳方式是使用专门的服务。通过使用 Redis 或 Memcached 等工具,你可以让所有的应用程序进程共享相同的缓存结果。这既减少了宝贵的计算资源的使用,也避免了由多个独立和不一致的缓存引起的问题。

Memcached

如果你想认真对待缓存,Memcached是一个非常流行且经过实战验证的解决方案。这个缓存服务器被像 Facebook 或 Wikipedia 这样的大型应用程序用来扩展他们的网站。除了简单的缓存功能,它还具有集群功能,可以在很短的时间内建立一个高效的分布式缓存系统。

这个工具是基于 Unix 的,但可以从任何平台和许多语言驱动。有许多略有不同的 Python 客户端,但基本用法通常是相同的。与 Memcached 的最简单交互几乎总是由三种方法组成:

  • set(key, value): 保存给定键的值

  • get(key): 如果存在,获取给定键的值

  • delete(key): 如果存在,删除给定键下的值

这里有一个与 Memcached 集成的示例,使用了流行的 Python 包之一——pymemcached

from pymemcache.client.base import Client

# setup Memcached client running under 11211 port on localhost
client = Client(('localhost', 11211))

# cache some value under some key and expire it after 10 seconds
client.set('some_key', 'some_value', expire=10)

# retrieve value for the same key
result = client.get('some_key')

Memcached 的一个缺点是它设计用于将值存储为字符串或二进制数据块,这与每种本地 Python 类型都不兼容。实际上,它只与一种类型兼容——字符串。这意味着更复杂的类型需要被序列化才能成功存储在 Memcached 中。对于简单数据结构来说,常见的序列化选择是 JSON。这里有一个使用 JSON 序列化与pymemcached的示例:

import json
from pymemcache.client.base import Client

def json_serializer(key, value):
     if type(value) == str:
         return value, 1
     return json.dumps(value), 2

def json_deserializer(key, value, flags):
    if flags == 1:
        return value
    if flags == 2:
        return json.loads(value)
    raise Exception("Unknown serialization format")

client = Client(('localhost', 11211), serializer=json_serializer,
                deserializer=json_deserializer)
client.set('key', {'a':'b', 'c':'d'})
result = client.get('key')

与每个基于键/值存储原则的缓存服务一起工作时,非常常见的另一个问题是如何选择键名。

对于缓存简单函数调用的情况,通常问题比较简单。您可以将函数名和其参数转换为字符串并将它们连接在一起。您唯一需要关心的是确保为应用程序的许多部分使用缓存时,为不同函数创建的键之间没有冲突。

更棘手的情况是当缓存函数具有由字典或自定义类组成的复杂参数时。在这种情况下,您需要找到一种方法以一致的方式将这样的调用签名转换为缓存键。

最后一个问题是,像许多其他缓存服务一样,Memcached 不太喜欢非常长的键字符串。通常,越短越好。长键可能会降低性能,或者根本不适合硬编码的服务限制。例如,如果你缓存整个 SQL 查询,查询字符串本身通常是可以用作键的良好唯一标识符。但另一方面,复杂的查询通常太长,无法存储在诸如 Memcached 之类的典型缓存服务中。一个常见的做法是计算MD5SHA或任何其他哈希函数,并将其用作缓存键。Python 标准库有一个hashlib模块,提供了几种流行的哈希算法的实现。

请记住,计算哈希是有代价的。然而,有时这是唯一可行的解决方案。在处理需要用于创建缓存键的复杂类型时,这也是一种非常有用的技术。在使用哈希函数时需要注意的一件重要事情是哈希冲突。没有哈希函数能保证冲突永远不会发生,所以一定要知道概率并注意这样的风险。

总结

在本章中,您已经学到了:

  • 如何定义代码的复杂性以及一些减少复杂性的方法

  • 如何利用一些架构上的权衡来提高性能

  • 缓存是什么以及如何使用它来提高应用程序性能

前面的方法集中了我们在单个进程内的优化工作。我们试图减少代码复杂性,选择更好的数据类型,或者重用旧的函数结果。如果这些都没有帮助,我们尝试做一些权衡,使用近似值,做得更少,或者留下工作以后再做。

在下一章中,我们将讨论一些 Python 中的并发和并行处理技术。

第十三章:并发

并发及其表现之一——并行处理——是软件工程领域中最广泛的主题之一。本书中的大部分章节也涵盖了广泛的领域,几乎所有这些章节都可以成为一本独立的书的大主题。但并发这个主题本身是如此庞大,以至于它可能需要数十个职位,我们仍然无法讨论其所有重要方面和模型。

这就是为什么我不会试图愚弄你,并且从一开始就声明我们几乎不会深入讨论这个话题。本章的目的是展示为什么你的应用程序可能需要并发,何时使用它,以及你可以在 Python 中使用的最重要的并发模型:

  • 多线程

  • 多处理

  • 异步编程

我们还将讨论一些语言特性、内置模块和第三方包,这些都可以让你在代码中实现这些模型。但我们不会详细讨论它们。把本章的内容当作你进一步研究和阅读的起点。它在这里是为了引导你了解基本的想法,并帮助你决定是否真的需要并发,以及哪种方法最适合你的需求。

为什么要并发?

在回答“为什么要并发”之前,我们需要问“并发到底是什么?”

对第二个问题的答案可能会让一些人感到惊讶,他们曾经认为这是并行处理的同义词。但并发不同于并行。并发不是应用程序实现的问题,而只是程序、算法或问题的属性。并行只是处理并发问题的可能方法之一。

1976 年,Leslie Lamport 在他的《分布式系统中的时间、时钟和事件排序》一文中说:

"如果两个事件互不影响,则它们是并发的。"

通过将事件推广到程序、算法或问题,我们可以说如果某事物可以被完全或部分分解为无序的组件(单元),那么它就是并发的。这些单元可以相互独立地进行处理,处理的顺序不会影响最终结果。这意味着它们也可以同时或并行处理。如果我们以这种方式处理信息,那么我们确实在处理并行处理。但这并非强制性的。

以分布式方式进行工作,最好利用多核处理器或计算集群的能力,是并发问题的自然结果。但这并不意味着这是处理并发的唯一有效方式。有很多用例,可以以非同步的方式处理并发问题,但不需要并行执行。

因此,一旦我们知道了并发到底是什么,就是时候解释这到底是怎么回事了。当问题是并发的时候,它给了你处理它的机会,以一种特殊的、更有效的方式。

我们经常习惯用经典的方式处理问题,通过一系列步骤来解决问题。这是我们大多数人思考和处理信息的方式——使用同步算法逐步进行。但这种信息处理方式并不适合解决大规模问题或需要同时满足多个用户或软件代理的需求:

  • 处理工作的时间受单个处理单元(单台机器、CPU 核心等)性能的限制

  • 在程序完成处理前,无法接受和处理新的输入

因此,通常处理并发问题的最佳方法是同时处理:

  • 问题的规模如此之大,以至于在可接受的时间范围内或在可用资源范围内处理它们的唯一方法是将执行分配给能够并行处理工作的多个处理单元。

  • 你的应用程序需要保持响应性(接受新输入),即使它还没有完成处理旧的输入

这涵盖了大多数情况下并发处理是一个合理选择的情况。第一组问题明显需要并行处理解决方案,因此通常使用多线程和多处理模型来解决。第二组问题不一定需要并行处理,因此实际解决方案取决于问题的细节。请注意,这组问题还涵盖了应用程序需要独立为多个客户(用户或软件代理)提供服务,而无需等待其他成功服务的情况。

另一件值得一提的事情是,前面两组并不是互斥的。很多时候,你需要保持应用程序的响应性,同时又无法在单个处理单元上处理输入。这就是为什么在并发性方面,不同的看似替代或冲突的方法经常同时使用的原因。这在开发 Web 服务器时尤其常见,可能需要使用异步事件循环,或者线程与多个进程的结合,以利用所有可用资源并在高负载下保持低延迟。

多线程

线程通常被开发人员认为是一个复杂的话题。虽然这种说法完全正确,但 Python 提供了高级类和函数,简化了线程的使用。CPython 对线程的实现带来了一些不便的细节,使它们比其他语言中的线程更少用。它们对于一些你可能想要解决的问题仍然完全合适,但不像在 C 或 Java 中那样多。在本节中,我们将讨论 CPython 中多线程的限制,以及 Python 线程是可行解决方案的常见并发问题。

什么是多线程?

线程是执行的线程的缩写。程序员可以将他或她的工作分成同时运行并共享相同内存上下文的线程。除非你的代码依赖于第三方资源,多线程在单核处理器上不会加快速度,甚至会增加一些线程管理的开销。多线程将受益于多处理器或多核机器,并将在每个 CPU 核心上并行执行每个线程,从而使程序更快。请注意,这是一个通用规则,对大多数编程语言都应该成立。在 Python 中,多核 CPU 上的多线程性能收益有一些限制,但我们将在后面讨论。为简单起见,现在假设这个说法是正确的。

相同上下文被线程共享的事实意味着你必须保护数据免受并发访问。如果两个线程在没有任何保护的情况下更新相同的数据,就会发生竞争条件。这被称为竞争危害,因为每个线程运行的代码对数据状态做出了错误的假设,可能会导致意外的结果发生。

锁机制有助于保护数据,线程编程一直是确保资源以安全方式被线程访问的问题。这可能非常困难,线程编程经常会导致难以调试的错误,因为它们很难重现。最糟糕的问题发生在由于糟糕的代码设计,两个线程锁定一个资源并尝试获取另一个线程已锁定的资源。它们将永远等待对方。这被称为死锁,非常难以调试。可重入锁通过确保线程不会尝试两次锁定资源来在一定程度上帮助解决这个问题。

然而,当线程用于专门为它们构建的工具的孤立需求时,它们可能会提高程序的速度。

多线程通常在系统内核级别得到支持。当计算机只有一个处理器和一个核心时,系统使用时间片机制。在这里,CPU 从一个线程快速切换到另一个线程,以至于产生线程同时运行的错觉。这也是在处理级别上完成的。在没有多个处理单元的情况下,并行性显然是虚拟的,并且在这样的硬件上运行多个线程并不会带来性能提升。无论如何,有时即使必须在单个核心上执行代码,实现代码的多线程仍然是有用的,我们稍后将看到一个可能的用例。

当执行环境具有多个处理器或多个 CPU 核心时,一切都会发生变化。即使使用时间片,进程和线程也会分布在 CPU 之间,从而提供更快地运行程序的能力。

Python 如何处理线程

与其他一些语言不同,Python 使用多个内核级别的线程,每个线程都可以运行解释器级别的任何线程。但是,语言的标准实现——CPython——存在重大限制,使得在许多情况下线程的可用性降低。所有访问 Python 对象的线程都由一个全局锁串行化。这是因为解释器的许多内部结构以及第三方 C 代码都不是线程安全的,需要受到保护。

这种机制称为全局解释器锁GIL),其在 Python/C API 级别的实现细节已经在第七章的释放 GIL部分中讨论过,其他语言中的 Python 扩展。GIL 的移除是 python-dev 电子邮件列表上偶尔出现的一个话题,并且被开发人员多次提出。遗憾的是,直到现在,没有人成功提供一个合理简单的解决方案,使我们能够摆脱这个限制。高度不可能在这个领域看到任何进展。更安全的假设是 GIL 将永远存在于 CPython 中。因此,我们需要学会如何与之共存。

那么在 Python 中使用多线程有什么意义呢?

当线程只包含纯 Python 代码时,使用线程加速程序几乎没有意义,因为 GIL 会串行化它。但请记住,GIL 只是强制只有一个线程可以在任何时候执行 Python 代码。在实践中,全局解释器锁会在许多阻塞系统调用上被释放,并且可以在不使用任何 Python/C API 函数的 C 扩展的部分中被释放。这意味着多个线程可以并行执行 I/O 操作或在某些第三方扩展中执行 C 代码。

对于使用外部资源或涉及 C 代码的非纯代码块,多线程对等待第三方资源返回结果是有用的。这是因为一个明确释放了 GIL 的休眠线程可以等待并在结果返回时唤醒。最后,每当程序需要提供响应式界面时,多线程都是答案,即使它使用时间片。程序可以在进行一些繁重的计算的同时与用户交互,所谓的后台。

请注意,GIL 并不是 Python 语言的每个实现都存在。这是 CPython、Stackless Python 和 PyPy 的限制,但在 Jython 和 IronPython 中并不存在(参见第一章,“Python 的当前状态”)。尽管 PyPy 也在开发无 GIL 版本,但在撰写本书时,它仍处于实验阶段,文档不完善。它基于软件事务内存,称为 PyPy-STM。很难说它何时(或是否)会正式发布为生产就绪的解释器。一切似乎表明这不会很快发生。

何时应该使用线程?

尽管有 GIL 的限制,但线程在某些情况下确实非常有用。它们可以帮助:

  • 构建响应式界面

  • 委托工作

  • 构建多用户应用程序

构建响应式界面

假设您要求系统通过图形用户界面将文件从一个文件夹复制到另一个文件夹。任务可能会被推送到后台,并且界面窗口将由主线程不断刷新。这样您就可以实时了解整个过程的进展。您还可以取消操作。这比原始的cpcopy shell 命令少了一些烦恼,因为它在所有工作完成之前不提供任何反馈。

响应式界面还允许用户同时处理多个任务。例如,Gimp 可以让您在处理一张图片的同时处理另一张图片,因为这两个任务是独立的。

在尝试实现这样的响应界面时,一个很好的方法是将长时间运行的任务推送到后台,或者至少尝试为用户提供持续的反馈。实现这一点的最简单方法是使用线程。在这种情况下,它们的目的不是为了提高性能,而只是确保用户即使需要处理一些数据较长时间,也可以继续操作界面。

如果这样的后台任务执行大量 I/O 操作,您仍然可以从多核 CPU 中获得一些好处。这是一个双赢的局面。

委托工作

如果您的进程依赖于第三方资源,线程可能会真正加快一切。

让我们考虑一个函数的情况,该函数索引文件夹中的文件并将构建的索引推送到数据库中。根据文件的类型,该函数调用不同的外部程序。例如,一个专门用于 PDF,另一个专门用于 OpenOffice 文件。

您的函数可以为每个转换器设置一个线程,并通过队列将要完成的工作推送给它们中的每一个,而不是按顺序处理每个文件,执行正确的程序,然后将结果存储到数据库中。函数所花费的总时间将更接近最慢转换器的处理时间,而不是所有工作的总和。

转换器线程可以从一开始就初始化,并且负责将结果推送到数据库的代码也可以是一个消耗队列中可用结果的线程。

请注意,这种方法在某种程度上是多线程和多进程的混合。如果您将工作委托给外部进程(例如,使用subprocess模块的run()函数),实际上是在多个进程中进行工作,因此具有多进程的特征。但在我们的情况下,我们在单独的线程中等待处理结果,因此从 Python 代码的角度来看,这仍然主要是多线程。

线程的另一个常见用例是执行对外部服务的多个 HTTP 请求。例如,如果您想从远程 Web API 获取多个结果,同步执行可能需要很长时间。如果您在进行新请求之前等待每个先前的响应,您将花费大量时间等待外部服务的响应,并且每个请求都会增加额外的往返时间延迟。如果您正在与一个高效的服务(例如 Google Maps API)通信,很可能它可以同时处理大部分请求而不影响单独请求的响应时间。因此,合理的做法是在单独的线程中执行多个查询。请记住,在进行 HTTP 请求时,大部分时间都花在从 TCP 套接字中读取数据上。这是一个阻塞的 I/O 操作,因此在执行recv() C 函数时,CPython 会释放 GIL。这可以极大地提高应用程序的性能。

多用户应用程序

线程也被用作多用户应用程序的并发基础。例如,Web 服务器将用户请求推送到一个新线程中,然后变为空闲状态,等待新的请求。每个请求都有一个专用的线程简化了很多工作,但需要开发人员注意锁定资源。但是,当所有共享数据都被推送到处理并发事项的关系型数据库中时,这就不是问题了。因此,在多用户应用程序中,线程几乎像独立的进程一样运行。它们在同一个进程下只是为了简化在应用程序级别的管理。

例如,Web 服务器可以将所有请求放入队列,并等待线程可用以将工作发送到线程。此外,它允许内存共享,可以提高一些工作并减少内存负载。两个非常流行的 Python 符合 WSGI 标准的 Web 服务器:Gunicorn(参考gunicorn.org/)和uWSGI(参考uwsgi-docs.readthedocs.org),允许您以符合这一原则的方式使用带有线程工作进程的 HTTP 请求。

在多用户应用程序中使用多线程实现并发性比使用多进程要便宜。单独的进程会消耗更多资源,因为每个进程都需要加载一个新的解释器。另一方面,拥有太多线程也是昂贵的。我们知道 GIL 对 I/O 密集型应用程序并不是问题,但总有一个时刻,您需要执行 Python 代码。由于无法仅使用裸线程并行化应用程序的所有部分,因此在具有多核 CPU 和单个 Python 进程的机器上,您永远无法利用所有资源。这就是为什么通常最佳解决方案是多进程和多线程的混合——多个工作进程(进程)与多个线程同时运行。幸运的是,许多符合 WSGI 标准的 Web 服务器都允许这样的设置。

但在将多线程与多进程结合之前,要考虑这种方法是否真的值得所有的成本。这种方法使用多进程来更好地利用资源,另外使用多线程来实现更多的并发,应该比运行多个进程更轻。但这并不一定是真的。也许摆脱线程,增加进程的数量并不像你想象的那么昂贵?在选择最佳设置时,你总是需要对应用程序进行负载测试(参见第十章中的负载和性能测试部分,测试驱动开发)。另外,使用多线程的副作用是,你会得到一个不太安全的环境,共享内存会导致数据损坏或可怕的死锁。也许更好的选择是使用一些异步的方法,比如事件循环、绿色线程或协程。我们将在异步编程部分后面介绍这些解决方案。同样,如果没有合理的负载测试和实验,你无法真正知道哪种方法在你的情况下效果最好。

一个多线程应用的示例

为了了解 Python 线程在实践中是如何工作的,让我们构建一个示例应用程序,可以从实现多线程中获益。我们将讨论一个简单的问题,你可能在职业实践中不时遇到——进行多个并行的 HTTP 查询。这个问题已经被提到作为多线程的常见用例。

假设我们需要使用多个查询从某个网络服务获取数据,这些查询不能被批量处理成一个大的 HTTP 请求。作为一个现实的例子,我们将使用 Google Maps API 的地理编码端点。选择这个服务的原因如下:

  • 它非常受欢迎,而且有很好的文档

  • 这个 API 有一个免费的层,不需要任何身份验证密钥

  • 在 PyPI 上有一个python-gmaps包,允许你与各种 Google Maps API 端点进行交互,非常容易使用

地理编码简单地意味着将地址或地点转换为坐标。我们将尝试将预定义的各种城市列表转换为纬度/经度元组,并在标准输出上显示结果与python-gmaps。就像下面的代码所示一样简单:

>>> from gmaps import Geocoding
>>> api = Geocoding()
>>> geocoded = api.geocode('Warsaw')[0]
>>> print("{:>25s}, {:6.2f}, {:6.2f}".format(
...         geocoded['formatted_address'],
...         geocoded['geometry']['location']['lat'],
...         geocoded['geometry']['location']['lng'],
...     ))
Warsaw, Poland,  52.23,  21.01

由于我们的目标是展示多线程解决并发问题与标准同步解决方案相比的效果,我们将从一个完全不使用线程的实现开始。下面是一个循环遍历城市列表、查询 Google Maps API 并以文本格式表格显示有关它们地址和坐标的信息的程序代码:

import time

from gmaps import Geocoding

api = Geocoding()

PLACES = (
    'Reykjavik', 'Vien', 'Zadar', 'Venice',
    'Wrocław', 'Bolognia', 'Berlin', 'Słubice',
    'New York', 'Dehli',
)

def fetch_place(place):
    geocoded = api.geocode(place)[0]

    print("{:>25s}, {:6.2f}, {:6.2f}".format(
        geocoded['formatted_address'],
        geocoded['geometry']['location']['lat'],
        geocoded['geometry']['location']['lng'],
    ))

def main():
    for place in PLACES:
        fetch_place(place)

if __name__ == "__main__":
    started = time.time()
    main()
    elapsed = time.time() - started

    print()
    print("time elapsed: {:.2f}s".format(elapsed))

main()函数的执行周围,我们添加了一些语句,用于测量完成工作所花费的时间。在我的电脑上,这个程序通常需要大约 2 到 3 秒才能完成任务:

$ python3 synchronous.py
 **Reykjavík, Iceland,  64.13, -21.82
 **Vienna, Austria,  48.21,  16.37
 **Zadar, Croatia,  44.12,  15.23
 **Venice, Italy,  45.44,  12.32
 **Wrocław, Poland,  51.11,  17.04
 **Bologna, Italy,  44.49,  11.34
 **Berlin, Germany,  52.52,  13.40
 **Slubice, Poland,  52.35,  14.56
 **New York, NY, USA,  40.71, -74.01
 **Dehli, Gujarat, India,  21.57,  73.22

time elapsed: 2.79s

注意

我们的脚本每次运行都会花费不同的时间,因为它主要取决于通过网络连接访问的远程服务。所以有很多不确定因素影响最终结果。最好的方法是进行更长时间的测试,多次重复,还要从测量中计算一些平均值。但为了简单起见,我们不会这样做。你将会看到,这种简化的方法对于说明目的来说已经足够了。

每个项目使用一个线程

现在是时候改进了。我们在 Python 中没有进行太多的处理,长时间执行是由与外部服务的通信引起的。我们向服务器发送 HTTP 请求,它计算答案,然后我们等待直到响应被传送回来。涉及了大量的 I/O,因此多线程似乎是一个可行的选择。我们可以在单独的线程中同时启动所有请求,然后等待它们接收数据。如果我们正在通信的服务能够并发处理我们的请求,我们应该肯定会看到性能的提升。

那么让我们从最简单的方法开始。Python 提供了清晰且易于使用的抽象,通过threading模块可以轻松地操作系统线程。这个标准库的核心是Thread类,代表一个单独的线程实例。下面是main()函数的修改版本,它为每个地点创建并启动一个新线程,然后等待直到所有线程都完成:

from threading import Thread

def main():
    threads = []
    for place in PLACES:
        thread = Thread(target=fetch_place, args=[place])
        thread.start()
        threads.append(thread)

    while threads:
        threads.pop().join()

这是一个快速而肮脏的改变,它有一些严重的问题,我们稍后会试图解决。它以一种有点轻率的方式解决问题,并不是编写可为成千上万甚至百万用户提供服务的可靠软件的方式。但嘿,它起作用:

$ python3 threaded.py
 **Wrocław, Poland,  51.11,  17.04
 **Vienna, Austria,  48.21,  16.37
 **Dehli, Gujarat, India,  21.57,  73.22
 **New York, NY, USA,  40.71, -74.01
 **Bologna, Italy,  44.49,  11.34
 **Reykjavík, Iceland,  64.13, -21.82
 **Zadar, Croatia,  44.12,  15.23
 **Berlin, Germany,  52.52,  13.40
 **Slubice, Poland,  52.35,  14.56
 **Venice, Italy,  45.44,  12.32

time elapsed: 1.05s

所以当我们知道线程对我们的应用有益时,是时候以稍微理智的方式使用它们了。首先我们需要找出前面代码中的问题:

  • 我们为每个参数启动一个新线程。线程初始化也需要一些时间,但这种小的开销并不是唯一的问题。线程还会消耗其他资源,比如内存和文件描述符。我们的示例输入有一个严格定义的项目数量,如果没有呢?你肯定不希望运行数量不受限制的线程,这取决于输入数据的任意大小。

  • 在线程中执行的fetch_place()函数调用了内置的print()函数,实际上,你很少会想在主应用程序线程之外这样做。首先,这是因为 Python 中标准输出的缓冲方式。当多个线程之间交错调用这个函数时,你可能会遇到格式不正确的输出。另外,print()函数被认为是慢的。如果在多个线程中滥用使用,它可能导致串行化,这将抵消多线程的所有好处。

  • 最后但同样重要的是,通过将每个函数调用委托给单独的线程,我们使得控制输入处理速率变得极其困难。是的,我们希望尽快完成工作,但很多时候外部服务会对单个客户端的请求速率设置严格限制。有时,合理设计程序以使其能够控制处理速率是很有必要的,这样你的应用就不会因滥用外部 API 的使用限制而被列入黑名单。

使用线程池

我们要解决的第一个问题是程序运行的线程数量没有限制。一个好的解决方案是建立一个具有严格定义大小的线程工作池,它将处理所有并行工作,并通过一些线程安全的数据结构与工作线程进行通信。通过使用这种线程池方法,我们也将更容易解决刚才提到的另外两个问题。

因此,一般的想法是启动一些预定义数量的线程,这些线程将从队列中消耗工作项,直到完成。当没有其他工作要做时,线程将返回,我们将能够退出程序。用于与工作线程通信的结构的一个很好的候选是内置queue模块中的Queue类。它是一个先进先出(FIFO)队列实现,非常类似于collections模块中的deque集合,并且专门设计用于处理线程间通信。以下是一个修改后的main()函数的版本,它只启动了有限数量的工作线程,并使用一个新的worker()函数作为目标,并使用线程安全的队列与它们进行通信:

from queue import Queue, Empty
from threading import Thread

THREAD_POOL_SIZE = 4

def worker(work_queue):
    while not work_queue.empty():
        try:
            item = work_queue.get(block=False)
        except Empty:
            break
        else:
            fetch_place(item)
            work_queue.task_done()

def main():
    work_queue = Queue()

    for place in PLACES:
        work_queue.put(place)

    threads = [
        Thread(target=worker, args=(work_queue,))
        for _ in range(THREAD_POOL_SIZE)
    ]

    for thread in threads:
        thread.start()

    work_queue.join()

    while threads:
        threads.pop().join()

运行修改后的程序的结果与之前的类似:

$ python threadpool.py** 
 **Reykjavík, Iceland,  64.13, -21.82
 **Venice, Italy,  45.44,  12.32
 **Vienna, Austria,  48.21,  16.37
 **Zadar, Croatia,  44.12,  15.23
 **Wrocław, Poland,  51.11,  17.04
 **Bologna, Italy,  44.49,  11.34
 **Slubice, Poland,  52.35,  14.56
 **Berlin, Germany,  52.52,  13.40
 **New York, NY, USA,  40.71, -74.01
 **Dehli, Gujarat, India,  21.57,  73.22

time elapsed: 1.20s

运行时间将比每个参数一个线程的情况慢,但至少现在不可能用任意长的输入耗尽所有的计算资源。此外,我们可以调整THREAD_POOL_SIZE参数以获得更好的资源/时间平衡。

使用双向队列

我们现在能够解决的另一个问题是线程中输出的潜在问题。最好将这样的责任留给启动其他线程的主线程。我们可以通过提供另一个队列来处理这个问题,该队列将负责从我们的工作线程中收集结果。以下是将所有内容与主要更改放在一起的完整代码:

import time
from queue import Queue, Empty
from threading import Thread

from gmaps import Geocoding

api = Geocoding()

PLACES = (
    'Reykjavik', 'Vien', 'Zadar', 'Venice',
    'Wrocław', 'Bolognia', 'Berlin', 'Słubice',
    'New York', 'Dehli',
)

THREAD_POOL_SIZE = 4

def fetch_place(place):
    return api.geocode(place)[0]

def present_result(geocoded):
 **print("{:>25s}, {:6.2f}, {:6.2f}".format(
 **geocoded['formatted_address'],
 **geocoded['geometry']['location']['lat'],
 **geocoded['geometry']['location']['lng'],
 **))

def worker(work_queue, results_queue):
    while not work_queue.empty():
        try:
            item = work_queue.get(block=False)
        except Empty:
            break
        else:
 **results_queue.put(
 **fetch_place(item)
 **)
            work_queue.task_done()

def main():
    work_queue = Queue()
 **results_queue = Queue()

    for place in PLACES:
        work_queue.put(place)

    threads = [
 **Thread(target=worker, args=(work_queue, results_queue))
        for _ in range(THREAD_POOL_SIZE)
    ]

    for thread in threads:
        thread.start()

    work_queue.join()

    while threads:
        threads.pop().join()

 **while not results_queue.empty():
 **present_result(results_queue.get())

if __name__ == "__main__":
    started = time.time()
    main()
    elapsed = time.time() - started

    print()
    print("time elapsed: {:.2f}s".format(elapsed))

这消除了输出格式不正确的风险,如果present_result()函数执行更多的print()语句或执行一些额外的计算,我们可能会遇到这种情况。我们不希望从这种方法中获得任何性能改进,但实际上,由于print()执行缓慢,我们还减少了线程串行化的风险。这是我们的最终输出:

$ python threadpool_with_results.py** 
 **Vienna, Austria,  48.21,  16.37
 **Reykjavík, Iceland,  64.13, -21.82
 **Zadar, Croatia,  44.12,  15.23
 **Venice, Italy,  45.44,  12.32
 **Wrocław, Poland,  51.11,  17.04
 **Bologna, Italy,  44.49,  11.34
 **Slubice, Poland,  52.35,  14.56
 **Berlin, Germany,  52.52,  13.40
 **New York, NY, USA,  40.71, -74.01
 **Dehli, Gujarat, India,  21.57,  73.22

time elapsed: 1.30s

处理错误和速率限制

之前提到的您在处理这些问题时可能遇到的最后一个问题是外部服务提供商施加的速率限制。在编写本书时,谷歌地图 API 的官方速率限制为每秒 10 次请求和每天 2500 次免费和非身份验证请求。使用多个线程很容易耗尽这样的限制。问题更加严重,因为我们尚未涵盖任何故障场景,并且在多线程 Python 代码中处理异常比通常要复杂一些。

api.geocode() 函数在客户端超过谷歌速率时会引发异常,这是个好消息。但是这个异常会单独引发,并不会使整个程序崩溃。工作线程当然会立即退出,但主线程会等待所有存储在work_queue上的任务完成(使用work_queue.join()调用)。这意味着我们的工作线程应该优雅地处理可能的异常,并确保队列中的所有项目都被处理。如果没有进一步的改进,我们可能会陷入一种情况,其中一些工作线程崩溃,程序将永远不会退出。

让我们对我们的代码进行一些微小的更改,以便为可能发生的任何问题做好准备。在工作线程中出现异常的情况下,我们可以将错误实例放入results_queue队列,并将当前任务标记为已完成,就像没有错误时一样。这样我们可以确保主线程在work_queue.join()中等待时不会无限期地锁定。然后主线程可能检查结果并重新引发在结果队列中找到的任何异常。以下是可以更安全地处理异常的worker()main()函数的改进版本:

def worker(work_queue, results_queue):
    while True:
        try:
            item = work_queue.get(block=False)
        except Empty:
            break
        else:
 **try:
 **result = fetch_place(item)
 **except Exception as err:
 **results_queue.put(err)
 **else:
 **results_queue.put(result)
 **finally:
 **work_queue.task_done()

def main():
    work_queue = Queue()
    results_queue = Queue()

    for place in PLACES:
        work_queue.put(place)

    threads = [
        Thread(target=worker, args=(work_queue, results_queue))
        for _ in range(THREAD_POOL_SIZE)
    ]

    for thread in threads:
        thread.start()

    work_queue.join()

    while threads:
        threads.pop().join()

 **while not results_queue.empty():
 **result = results_queue.get()

 **if isinstance(result, Exception):
 **raise result

        present_result(result)

当我们准备处理异常时,就是我们的代码中断并超过速率限制的时候了。我们可以通过修改一些初始条件来轻松实现这一点。让我们增加地理编码的位置数量和线程池的大小:

PLACES = (
    'Reykjavik', 'Vien', 'Zadar', 'Venice',
    'Wrocław', 'Bolognia', 'Berlin', 'Słubice',
    'New York', 'Dehli',
) * 10

THREAD_POOL_SIZE = 10

如果您的执行环境足够快,您应该很快就会收到类似的错误:

$ python3 threadpool_with_errors.py
 **New York, NY, USA,  40.71, -74.01
 **Berlin, Germany,  52.52,  13.40
 **Wrocław, Poland,  51.11,  17.04
 **Zadar, Croatia,  44.12,  15.23
 **Vienna, Austria,  48.21,  16.37
 **Bologna, Italy,  44.49,  11.34
 **Reykjavík, Iceland,  64.13, -21.82
 **Venice, Italy,  45.44,  12.32
 **Dehli, Gujarat, India,  21.57,  73.22
 **Slubice, Poland,  52.35,  14.56
 **Vienna, Austria,  48.21,  16.37
 **Zadar, Croatia,  44.12,  15.23
 **Venice, Italy,  45.44,  12.32
 **Reykjavík, Iceland,  64.13, -21.82
Traceback (most recent call last):
 **File "threadpool_with_errors.py", line 83, in <module>
 **main()
 **File "threadpool_with_errors.py", line 76, in main
 **raise result
 **File "threadpool_with_errors.py", line 43, in worker
 **result = fetch_place(item)
 **File "threadpool_with_errors.py", line 23, in fetch_place
 **return api.geocode(place)[0]
 **File "...\site-packages\gmaps\geocoding.py", line 37, in geocode
 **return self._make_request(self.GEOCODE_URL, parameters, "results")
 **File "...\site-packages\gmaps\client.py", line 89, in _make_request
 **)(response)
gmaps.errors.RateLimitExceeded: {'status': 'OVER_QUERY_LIMIT', 'results': [], 'error_message': 'You have exceeded your rate-limit for this API.', 'url': 'https://maps.googleapis.com/maps/api/geocode/json?address=Wroc%C5%82aw&sensor=false'}

前面的异常当然不是由于错误的代码造成的。这个程序对于这个免费服务来说太快了。它发出了太多的并发请求,为了正确工作,我们需要有一种限制它们速率的方法。

限制工作的速度通常被称为节流。PyPI 上有一些包可以让您限制任何类型工作的速率,并且非常容易使用。但是我们不会在这里使用任何外部代码。节流是一个很好的机会,可以引入一些用于线程的锁原语,因此我们将尝试从头开始构建一个解决方案。

我们将使用的算法有时被称为令牌桶,非常简单:

  1. 有一个预定义数量的令牌的桶。

  2. 每个令牌对应于处理一个工作项的单个权限。

  3. 每次工作线程请求单个或多个令牌(权限)时:

  • 我们测量了从上次我们重新填充桶以来花费了多少时间

  • 如果时间差允许,我们将用与此时间差相应的令牌数量重新填充桶

  • 如果存储的令牌数量大于或等于请求的数量,我们会减少存储的令牌数量并返回该值

  • 如果存储的令牌数量少于请求的数量,我们返回零

两个重要的事情是始终用零令牌初始化令牌桶,并且永远不允许它填充的令牌数量超过其速率可用的令牌数量,按照我们标准的时间量表达。如果我们不遵循这些预防措施,我们可能会以超过速率限制的突发方式释放令牌。因为在我们的情况下,速率限制以每秒请求的数量来表示,所以我们不需要处理任意的时间量。我们假设我们的测量基准是一秒,因此我们永远不会存储比该时间量允许的请求数量更多的令牌。以下是一个使用令牌桶算法进行节流的类的示例实现:

From threading import Lock

class Throttle:
    def __init__(self, rate):
        self._consume_lock = Lock()
        self.rate = rate
        self.tokens = 0
        self.last = 0

    def consume(self, amount=1):
        with self._consume_lock:
            now = time.time()

            # time measument is initialized on first
            # token request to avoid initial bursts
            if self.last == 0:
                self.last = now

            elapsed = now - self.last

            # make sure that quant of passed time is big
            # enough to add new tokens
            if int(elapsed * self.rate):
                self.tokens += int(elapsed * self.rate)
                self.last = now

            # never over-fill the bucket
            self.tokens = (
                self.rate
                if self.tokens > self.rate
                else self.tokens
            )

            # finally dispatch tokens if available
            if self.tokens >= amount:
                self.tokens -= amount
            else:
                amount = 0

            return amount

使用这个类非常简单。假设我们在主线程中只创建了一个Throttle实例(例如Throttle(10)),并将其作为位置参数传递给每个工作线程。在不同的线程中使用相同的数据结构是安全的,因为我们使用threading模块中的Lock类的实例来保护其内部状态的操作。现在我们可以更新worker()函数的实现,以便在每个项目之前等待节流释放一个新的令牌:

def worker(work_queue, results_queue, throttle):
    while True:
        try:
            item = work_queue.get(block=False)
        except Empty:
            break
        else:
 **while not throttle.consume():
 **pass

            try:
                result = fetch_place(item)
            except Exception as err:
                results_queue.put(err)
            else:
                results_queue.put(result)
            finally:
                work_queue.task_done()

多进程

坦率地说,多线程是具有挑战性的——我们在前一节已经看到了。最简单的方法只需要最少的工作。但是以明智和安全的方式处理线程需要大量的代码。

我们必须设置线程池和通信队列,优雅地处理来自线程的异常,并且在尝试提供速率限制功能时也要关心线程安全。只需十行代码就可以并行执行外部库中的一个函数!我们只是假设这是可以投入生产的,因为外部包的创建者承诺他的库是线程安全的。对于一个实际上只适用于执行 I/O 绑定任务的解决方案来说,这听起来像是一个很高的代价。

允许你实现并行的另一种方法是多进程。不受 GIL 约束的独立 Python 进程可以更好地利用资源。这对于在执行真正消耗 CPU 的任务的多核处理器上运行的应用程序尤为重要。目前,这是 Python 开发人员(使用 CPython 解释器)唯一可用的内置并发解决方案,可以让你利用多个处理器核心。

使用多个进程的另一个优势是它们不共享内存上下文。因此,更难破坏数据并引入死锁到你的应用程序中。不共享内存上下文意味着你需要额外的工作来在独立的进程之间传递数据,但幸运的是有许多很好的方法来实现可靠的进程间通信。事实上,Python 提供了一些原语,使进程间通信尽可能简单,就像线程之间一样。

在任何编程语言中启动新进程的最基本的方法通常是在某个时候fork程序。在 POSIX 系统(Unix、Mac OS 和 Linux)上,fork 是一个系统调用,在 Python 中通过os.fork()函数暴露出来,它将创建一个新的子进程。然后这两个进程在分叉后继续程序。下面是一个自我分叉一次的示例脚本:

import os

pid_list = []

def main():
    pid_list.append(os.getpid())
    child_pid = os.fork()

    if child_pid == 0:
        pid_list.append(os.getpid())
        print()
        print("CHLD: hey, I am the child process")
        print("CHLD: all the pids i know %s" % pid_list)

    else:
        pid_list.append(os.getpid())
        print()
        print("PRNT: hey, I am the parent")
        print("PRNT: the child is pid %d" % child_pid)
        print("PRNT: all the pids i know %s" % pid_list)

if __name__ == "__main__":
    main()

以下是在终端中运行它的示例:

$ python3 forks.py

PRNT: hey, I am the parent
PRNT: the child is pid 21916
PRNT: all the pids i know [21915, 21915]

CHLD: hey, I am the child process
CHLD: all the pids i know [21915, 21916]

请注意,在os.fork()调用之前,这两个进程的数据状态完全相同。它们都有相同的 PID 号(进程标识符)作为pid_list集合的第一个值。后来,两个状态分歧,我们可以看到子进程添加了21916的值,而父进程复制了它的21915 PID。这是因为这两个进程的内存上下文是不共享的。它们有相同的初始条件,但在os.fork()调用后不能相互影响。

在分叉内存上下文被复制到子进程后,每个进程都处理自己的地址空间。为了通信,进程需要使用系统范围的资源或使用低级工具,比如信号

不幸的是,在 Windows 下os.fork不可用,需要在新的解释器中生成一个新的进程来模拟 fork 功能。因此,它需要根据平台的不同而有所不同。os模块还公开了在 Windows 下生成新进程的函数,但最终你很少会使用它们。这对于os.fork()也是如此。Python 提供了一个很棒的multiprocessing模块,它为多进程提供了一个高级接口。这个模块的巨大优势在于它提供了一些我们在一个多线程应用程序示例部分中不得不从头编写的抽象。它允许你限制样板代码的数量,因此提高了应用程序的可维护性并减少了其复杂性。令人惊讶的是,尽管它的名字是multiprocessing模块,但它也为线程暴露了类似的接口,因此你可能希望对两种方法使用相同的接口。

内置的 multiprocessing 模块

multiprocessing提供了一种可移植的方式来处理进程,就像它们是线程一样。

这个模块包含一个Process类,它与Thread类非常相似,可以在任何平台上使用:

from multiprocessing import Process
import os

def work(identifier):
    print(
        'hey, i am a process {}, pid: {}'
        ''.format(identifier, os.getpid())
    )

def main():
    processes = [
        Process(target=work, args=(number,))
        for number in range(5)
    ]
    for process in processes:
        process.start()

    while processes:
        processes.pop().join()

if __name__ == "__main__":
    main()

执行前述脚本将得到以下结果:

$ python3 processing.py
hey, i am a process 1, pid: 9196
hey, i am a process 0, pid: 8356
hey, i am a process 3, pid: 9524
hey, i am a process 2, pid: 3456
hey, i am a process 4, pid: 6576

当进程被创建时,内存被分叉(在 POSIX 系统上)。进程的最有效使用方式是让它们在创建后独立工作,以避免开销,并从主线程检查它们的状态。除了复制的内存状态,Process类还在其构造函数中提供了额外的args参数,以便传递数据。

进程模块之间的通信需要一些额外的工作,因为它们的本地内存默认情况下不是共享的。为了简化这一点,多进程模块提供了一些进程之间通信的方式:

  • 使用multiprocessing.Queue类,它几乎与queue.Queue相同,之前用于线程之间通信

  • 使用multiprocessing.Pipe,这是一个类似套接字的双向通信通道

  • 使用multiprocessing.sharedctypes模块,允许您在进程之间共享的专用内存池中创建任意 C 类型(来自ctypes模块)

multiprocessing.Queuequeue.Queue类具有相同的接口。唯一的区别是,第一个是设计用于多进程环境,而不是多线程环境,因此它使用不同的内部传输和锁定原语。我们已经看到如何在一个多线程应用程序的示例部分中使用 Queue,因此我们不会对多进程做同样的事情。使用方式完全相同,因此这样的例子不会带来任何新东西。

现在提供的更有趣的模式是Pipe类。它是一个双工(双向)通信通道,概念上与 Unix 管道非常相似。Pipe 的接口也非常类似于内置socket模块中的简单套接字。与原始系统管道和套接字的区别在于它允许您发送任何可挑选的对象(使用pickle模块)而不仅仅是原始字节。这使得进程之间的通信变得更加容易,因为您可以发送几乎任何基本的 Python 类型:

from multiprocessing import Process, Pipe

class CustomClass:
    pass

def work(connection):
    while True:
        instance = connection.recv()

        if instance:
            print("CHLD: {}".format(instance))

        else:
            return

def main():
    parent_conn, child_conn = Pipe()

    child = Process(target=work, args=(child_conn,))

    for item in (
        42,
        'some string',
        {'one': 1},
        CustomClass(),
        None,
    ):
        print("PRNT: send {}:".format(item))
        parent_conn.send(item)

    child.start()
    child.join()

if __name__ == "__main__":
    main()

当查看前面脚本的示例输出时,您会发现您可以轻松传递自定义类实例,并且它们根据进程具有不同的地址:

PRNT: send: 42
PRNT: send: some string
PRNT: send: {'one': 1}
PRNT: send: <__main__.CustomClass object at 0x101cb5b00>
PRNT: send: None
CHLD: recv: 42
CHLD: recv: some string
CHLD: recv: {'one': 1}
CHLD: recv: <__main__.CustomClass object at 0x101cba400>

在进程之间共享状态的另一种方法是使用multiprocessing.sharedctypes中提供的类在共享内存池中使用原始类型。最基本的是ValueArray。以下是multiprocessing模块官方文档中的示例代码:

from multiprocessing import Process, Value, Array

def f(n, a):
    n.value = 3.1415927
    for i in range(len(a)):
        a[i] = -a[i]

if __name__ == '__main__':
    num = Value('d', 0.0)
    arr = Array('i', range(10))

    p = Process(target=f, args=(num, arr))
    p.start()
    p.join()

    print(num.value)
    print(arr[:])

这个例子将打印以下输出:

3.1415927
[0, -1, -2, -3, -4, -5, -6, -7, -8, -9]

在使用multiprocessing.sharedctypes时,您需要记住您正在处理共享内存,因此为了避免数据损坏的风险,您需要使用锁定原语。多进程提供了一些可用于线程的类,例如LockRLockSemaphore,来做到这一点。sharedctypes类的缺点是它们只允许您共享ctypes模块中的基本 C 类型。如果您需要传递更复杂的结构或类实例,则需要使用 Queue、Pipe 或其他进程间通信通道。在大多数情况下,理应避免使用sharedctypes中的类型,因为它们会增加代码复杂性,并带来来自多线程的所有已知危险。

使用进程池

使用多进程而不是线程会增加一些实质性的开销。主要是因为它增加了内存占用,因为每个进程都有自己独立的内存上下文。这意味着允许无限数量的子进程甚至比在多线程应用程序中更加棘手。

在依赖多进程进行更好资源利用的应用程序中控制资源使用的最佳模式是以类似于使用线程池部分描述的方式构建进程池。

multiprocessing模块最好的地方是它提供了一个现成的Pool类,可以为你处理管理多个进程工作者的所有复杂性。这个池实现大大减少了所需的样板代码量和与双向通信相关的问题数量。你也不需要手动使用join()方法,因为Pool可以作为上下文管理器使用(使用with语句)。以下是我们以前的一个线程示例,重写为使用multiprocessing模块中的Pool类:

from multiprocessing import Pool

from gmaps import Geocoding

api = Geocoding()

PLACES = (
    'Reykjavik', 'Vien', 'Zadar', 'Venice',
    'Wrocław', 'Bolognia', 'Berlin', 'Słubice',
    'New York', 'Dehli',
)

POOL_SIZE = 4

def fetch_place(place):
    return api.geocode(place)[0]

def present_result(geocoded):
    print("{:>25s}, {:6.2f}, {:6.2f}".format(
        geocoded['formatted_address'],
        geocoded['geometry']['location']['lat'],
        geocoded['geometry']['location']['lng'],
    ))

def main():
    with Pool(POOL_SIZE) as pool:
        results = pool.map(fetch_place, PLACES)

    for result in results:
        present_result(result)

if __name__ == "__main__":
    main()

正如你所看到的,现在代码要短得多。这意味着在出现问题时,现在更容易维护和调试。实际上,现在只有两行代码明确处理多进程。这是一个很大的改进,因为我们以前必须从头开始构建处理池。现在我们甚至不需要关心通信通道,因为它们是在Pool实现内部隐式创建的。

使用multiprocessing.dummy作为多线程接口

multiprocessing模块中的高级抽象,如Pool类,是比threading模块提供的简单工具更大的优势。但是,并不意味着多进程始终比多线程更好的方法。有很多情况下,线程可能是比进程更好的解决方案。特别是在需要低延迟和/或高资源效率的情况下。

但这并不意味着每当你想要使用线程而不是进程时,你就需要牺牲multiprocessing模块中的所有有用抽象。有multiprocessing.dummy模块,它复制了multiprocessing的 API,但使用多线程而不是 forking/spawning 新进程。

这使你可以减少代码中的样板,并且使接口更加可插拔。例如,让我们再次看一下我们以前示例中的main()函数。如果我们想要让用户控制他想要使用哪种处理后端(进程或线程),我们可以简单地替换Pool类:

from multiprocessing import Pool as ProcessPool
from multiprocessing.dummy import Pool as ThreadPool

def main(use_threads=False):
    if use_threads:
        pool_cls = ThreadPool
    else:
        pool_cls = ProcessPool

    with pool_cls(POOL_SIZE) as pool:
        results = pool.map(fetch_place, PLACES)

    for result in results:
        present_result(result)

异步编程

近年来,异步编程已经获得了很大的关注。在 Python 3.5 中,它最终获得了一些语法特性,巩固了异步执行的概念。但这并不意味着异步编程只能从 Python 3.5 开始。很多库和框架早在很久以前就提供了,大部分都起源于 Python 2 的旧版本。甚至有一个名为 Stackless 的 Python 的整个替代实现(见第一章,“Python 的当前状态”),它专注于这种单一的编程方法。其中一些解决方案,如 Twisted、Tornado 或 Eventlet,仍然拥有庞大和活跃的社区,并且真的值得了解。无论如何,从 Python 3.5 开始,异步编程比以往任何时候都更容易。因此,预计其内置的异步特性将取代较旧工具的大部分部分,或者外部项目将逐渐转变为基于 Python 内置的高级框架。

当试图解释什么是异步编程时,最简单的方法是将这种方法视为类似于线程但不涉及系统调度。这意味着异步程序可以并发处理问题,但其上下文在内部切换,而不是由系统调度程序切换。

但是,当然,我们不使用线程来同时处理异步程序中的工作。大多数解决方案使用一种不同的概念,根据实现的不同,它被命名为不同的名称。用来描述这种并发程序实体的一些示例名称是:

  • 绿色线程或 greenlets(greenlet、gevent 或 eventlet 项目)

  • 协程(Python 3.5 原生异步编程)

  • 任务(Stackless Python)

这些主要是相同的概念,但通常以稍微不同的方式实现。出于明显的原因,在本节中,我们将只集中讨论 Python 从版本 3.5 开始原生支持的协程。

合作式多任务处理和异步 I/O

合作式多任务处理是异步编程的核心。在这种计算机多任务处理风格中,操作系统不负责启动上下文切换(到另一个进程或线程),而是每个进程在空闲时自愿释放控制,以实现多个程序的同时执行。这就是为什么它被称为合作式。所有进程都需要合作才能实现平稳的多任务处理。

这种多任务处理模型有时在操作系统中使用,但现在几乎不再作为系统级解决方案。这是因为一个设计不良的服务很容易破坏整个系统的稳定性。现在,线程和进程调度以及由操作系统直接管理的上下文切换是系统级并发的主要方法。但在应用程序级别,合作式多任务处理仍然是一个很好的并发工具。

在应用程序级别讨论合作式多任务处理时,我们不需要处理需要释放控制的线程或进程,因为所有执行都包含在一个单一的进程和线程中。相反,我们有多个任务(协程、任务和绿色线程),它们释放控制给处理任务协调的单个函数。这个函数通常是某种事件循环。

为了避免以后混淆(由于 Python 术语),从现在开始我们将把这样的并发任务称为协程。合作式多任务处理中最重要的问题是何时释放控制。在大多数异步应用程序中,控制权在 I/O 操作时释放给调度器或事件循环。无论程序是从文件系统读取数据还是通过套接字进行通信,这样的 I/O 操作总是与进程变得空闲的等待时间相关。等待时间取决于外部资源,因此释放控制是一个很好的机会,这样其他协程就可以做他们的工作,直到它们也需要等待。

这使得这种方法在行为上与 Python 中的多线程实现方式有些相似。我们知道 GIL 会对 Python 线程进行串行化,但在每次 I/O 操作时会释放。主要区别在于 Python 中的线程是作为系统级线程实现的,因此操作系统可以在任何时间点抢占当前运行的线程,并将控制权交给另一个线程。在异步编程中,任务永远不会被主事件循环抢占。这就是为什么这种多任务处理风格也被称为非抢占式多任务处理

当然,每个 Python 应用程序都在一个操作系统上运行,那里有其他进程竞争资源。这意味着操作系统始终有权剥夺整个进程的控制权,并将控制权交给另一个进程。但当我们的异步应用程序恢复运行时,它会从系统调度器介入时暂停的地方继续运行。这就是为什么协程仍然被认为是非抢占式的。

Python 的 async 和 await 关键字

asyncawait关键字是 Python 异步编程的主要构建模块。

def语句之前使用的async关键字定义了一个新的协程。协程函数的执行可能在严格定义的情况下被暂停和恢复。它的语法和行为与生成器非常相似(参见第二章,“语法最佳实践-类级别下面”)。实际上,生成器需要在 Python 的旧版本中使用以实现协程。这是一个使用async关键字的函数声明的示例:

async def async_hello():
    print("hello, world!")

使用async关键字定义的函数是特殊的。当调用时,它们不执行内部的代码,而是返回一个协程对象:

>>> async def async_hello():
...     print("hello, world!")
...** 
>>> async_hello()
<coroutine object async_hello at 0x1014129e8>

协程对象在其执行被安排在事件循环中之前不会执行任何操作。asyncio模块可用于提供基本的事件循环实现,以及许多其他异步实用程序:

>>> import asyncio
>>> async def async_hello():
...     print("hello, world!")
...** 
>>> loop = asyncio.get_event_loop()
>>> loop.run_until_complete(async_hello())
hello, world!
>>> loop.close()

显然,由于我们只创建了一个简单的协程,所以在我们的程序中没有涉及并发。为了真正看到一些并发,我们需要创建更多的任务,这些任务将由事件循环执行。

可以通过调用loop.create_task()方法或使用asyncio.wait()函数提供另一个对象来等待来添加新任务到循环中。我们将使用后一种方法,并尝试异步打印使用range()函数生成的一系列数字:

import asyncio

async def print_number(number):
    print(number)

if __name__ == "__main__":
    loop = asyncio.get_event_loop()

    loop.run_until_complete(
        asyncio.wait([
            print_number(number)
            for number in range(10)
        ])
    )
    loop.close()

asyncio.wait()函数接受一个协程对象的列表并立即返回。结果是一个生成器,产生表示未来结果(futures)的对象。正如其名称所示,它用于等待所有提供的协程完成。它返回生成器而不是协程对象的原因是为了与 Python 的先前版本向后兼容,这将在后面解释。运行此脚本的结果可能如下:

$ python asyncprint.py** 
0
7
8
3
9
4
1
5
2
6

正如我们所看到的,数字的打印顺序与我们创建协程的顺序不同。但这正是我们想要实现的。

Python 3.5 中添加的第二个重要关键字是await。它用于等待协程或未来结果(稍后解释)的结果,并将执行控制权释放给事件循环。为了更好地理解它的工作原理,我们需要回顾一个更复杂的代码示例。

假设我们想创建两个协程,它们将在循环中执行一些简单的任务:

  • 等待随机秒数

  • 打印一些作为参数提供的文本和在睡眠中花费的时间

让我们从一个简单的实现开始,它存在一些并发问题,我们稍后将尝试使用额外的await使用来改进它:

import time
import random
import asyncio

async def waiter(name):
    for _ in range(4):
        time_to_sleep = random.randint(1, 3) / 4
        time.sleep(time_to_sleep)
        print(
            "{} waited {} seconds"
            "".format(name, time_to_sleep)
        )

async def main():
    await asyncio.wait([waiter("foo"), waiter("bar")])

if __name__ == "__main__":
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()

在终端中执行(使用time命令来测量时间),可能会得到以下输出:

$ time python corowait.py** 
bar waited 0.25 seconds
bar waited 0.25 seconds
bar waited 0.5 seconds
bar waited 0.5 seconds
foo waited 0.75 seconds
foo waited 0.75 seconds
foo waited 0.25 seconds
foo waited 0.25 seconds

real	0m3.734s
user	0m0.153s
sys	0m0.028s

正如我们所看到的,这两个协程都完成了它们的执行,但不是以异步的方式。原因是它们都使用了time.sleep()函数,这是阻塞的,但没有释放控制给事件循环。这在多线程设置中可能效果更好,但我们现在不想使用线程。那么我们该如何解决这个问题呢?

答案是使用asyncio.sleep(),这是time.sleep()的异步版本,并使用await关键字等待其结果。我们已经在main()函数的第一个版本中使用了这个语句,但这只是为了提高代码的清晰度。显然,这并没有使我们的实现更加并发。让我们看一个改进的waiter()协程的版本,它使用await asyncio.sleep()

async def waiter(name):
    for _ in range(4):
        time_to_sleep = random.randint(1, 3) / 4
        await asyncio.sleep(time_to_sleep)
        print(
            "{} waited {} seconds"
            "".format(name, time_to_sleep)
        )

如果我们运行更新后的脚本,我们可以看到两个函数的输出如何交错:

$ time python corowait_improved.py** 
bar waited 0.25 seconds
foo waited 0.25 seconds
bar waited 0.25 seconds
foo waited 0.5 seconds
foo waited 0.25 seconds
bar waited 0.75 seconds
foo waited 0.25 seconds
bar waited 0.5 seconds

real  0m1.953s
user  0m0.149s
sys   0m0.026s

这个简单改进的额外优势是代码运行得更快。总体执行时间小于所有睡眠时间的总和,因为协程合作地释放控制。

旧版本 Python 中的 asyncio

asyncio模块出现在 Python 3.4 中。因此,它是在 Python 3.5 之前唯一支持异步编程的版本。不幸的是,看起来这两个后续版本刚好足够引入兼容性问题。

无论喜欢与否,Python 中的异步编程核心早于支持此模式的语法元素。迟做总比不做好,但这造成了一种情况,即有两种语法可用于处理协程。

从 Python 3.5 开始,你可以使用asyncawait

async def main():
    await asyncio.sleep(0)

但对于 Python 3.4,你需要使用asyncio.coroutine装饰器和yield from语句:

@asyncio.couroutine
def main():
    yield from asyncio.sleep(0)

另一个有用的事实是,yield from语句是在 Python 3.3 中引入的,并且在 PyPI 上有一个asyncio的后备。这意味着你也可以在 Python 3.3 中使用这个协作式多任务处理的实现。

异步编程的实际示例

正如本章中已经多次提到的那样,异步编程是处理 I/O 绑定操作的强大工具。所以现在是时候构建比简单打印序列或异步等待更实际的东西了。

为了保持一致,我们将尝试处理与多线程和多进程帮助解决的相同问题。因此,我们将尝试通过网络连接异步获取一些来自外部资源的数据。如果我们可以像在前面的部分中那样使用相同的python-gmaps包,那就太好了。不幸的是,我们不能。

python-gmaps的创建者有点懒,走了捷径。为了简化开发,他选择了requests包作为他的首选 HTTP 客户端库。不幸的是,requests不支持asyncawait的异步 I/O。还有一些其他项目旨在为requests项目提供一些并发性,但它们要么依赖于 Gevent(grequests,参见github.com/kennethreitz/grequests),要么依赖于线程/进程池执行(requests-futures,参见github.com/ross/requests-futures)。这两者都不能解决我们的问题。

注意

在你因为我在责备一个无辜的开源开发者而生气之前,冷静下来。python-gmaps包背后的人就是我。依赖项的选择不当是这个项目的问题之一。我只是喜欢偶尔公开批评自己。这对我来说应该是一个痛苦的教训,因为在我写这本书的时候,python-gmaps在其最新版本(0.3.1)中不能轻松地与 Python 的异步 I/O 集成。无论如何,这可能会在未来发生变化,所以一切都没有丢失。

知道在前面的示例中很容易使用的库的限制,我们需要构建一些填补这一空白的东西。Google Maps API 非常容易使用,所以我们将构建一个快速而简陋的异步实用程序,仅用于说明目的。Python 3.5 版本的标准库仍然缺少一个使异步 HTTP 请求像调用urllib.urlopen()一样简单的库。我们绝对不想从头开始构建整个协议支持,所以我们将从 PyPI 上可用的aiohttp包中得到一点帮助。这是一个非常有前途的库,为异步 HTTP 添加了客户端和服务器实现。这是一个建立在aiohttp之上的小模块,它创建了一个名为geocode()的辅助函数,用于向 Google Maps API 服务发出地理编码请求:

import aiohttp

session = aiohttp.ClientSession()

async def geocode(place):
    params = {
        'sensor': 'false',
        'address': place
    }
    async with session.get(
        'https://maps.googleapis.com/maps/api/geocode/json',
        params=params
    ) as response:
        result = await response.json()
        return result['results']

假设这段代码存储在名为asyncgmaps的模块中,我们稍后会用到它。现在我们准备重写在讨论多线程和多进程时使用的示例。以前,我们习惯将整个操作分为两个独立的步骤:

  1. 使用fetch_place()函数并行执行对外部服务的所有请求。

  2. 使用present_result()函数在循环中显示所有结果。

但是,因为协作式多任务处理与使用多个进程或线程完全不同,我们可以稍微修改我们的方法。在“使用一个线程处理一个项目”部分提出的大部分问题不再是我们的关注点。协程是非抢占式的,因此我们可以在等待 HTTP 响应后立即显示结果。这将简化我们的代码并使其更清晰。

import asyncio
# note: local module introduced earlier
from asyncgmaps import geocode, session

PLACES = (
    'Reykjavik', 'Vien', 'Zadar', 'Venice',
    'Wrocław', 'Bolognia', 'Berlin', 'Słubice',
    'New York', 'Dehli',
)

async def fetch_place(place):
    return (await geocode(place))[0]

async def present_result(result):
    geocoded = await result
    print("{:>25s}, {:6.2f}, {:6.2f}".format(
        geocoded['formatted_address'],
        geocoded['geometry']['location']['lat'],
        geocoded['geometry']['location']['lng'],
    ))

async def main():
    await asyncio.wait([
        present_result(fetch_place(place))
        for place in PLACES
    ])

if __name__ == "__main__":
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

    # aiohttp will raise issue about unclosed
    # ClientSession so we perform cleanup manually
    loop.run_until_complete(session.close())
    loop.close()

使用期货将非异步代码与异步集成

异步编程很棒,特别是对于对构建可扩展应用程序感兴趣的后端开发人员。实际上,这是构建高度并发服务器的最重要工具之一。

但现实是痛苦的。许多处理 I/O 绑定问题的流行软件包并不适用于异步代码。主要原因是:

  • Python 3 及其一些高级功能的采用率仍然较低

  • Python 初学者对各种并发概念的理解较低

这意味着现有的同步多线程应用程序和软件包的迁移通常是不可能的(由于架构约束)或成本太高。许多项目可以从合并异步多任务处理方式中受益,但最终只有少数项目会这样做。

这意味着现在,当尝试从头开始构建异步应用程序时,您将遇到许多困难。在大多数情况下,这将类似于“异步编程的实际示例”部分中提到的问题 - 接口不兼容和 I/O 操作的非异步阻塞。

当您遇到这种不兼容性时,您有时可以放弃await并同步获取所需的资源。但这将在等待结果时阻止其他协程执行其代码。从技术上讲,这是有效的,但也破坏了异步编程的所有收益。因此,最终,将异步 I/O 与同步 I/O 结合起来不是一个选择。这是一种“全有或全无”的游戏。

另一个问题是长时间运行的 CPU 绑定操作。当您执行 I/O 操作时,释放控制权不是一个问题。当从文件系统或套接字中读取/写入时,您最终会等待,因此使用await是您能做的最好的事情。但是当您需要实际计算某些东西并且知道这将需要一段时间时该怎么办?当然,您可以将问题切分成几部分,并在每次推进工作时释放控制权。但很快您会发现这不是一个好的模式。这样做可能会使代码混乱,也不能保证良好的结果。时间切片应该是解释器或操作系统的责任。

那么,如果您有一些使长时间同步 I/O 操作的代码,而您无法或不愿意重写。或者当您需要在主要设计为异步 I/O 的应用程序中进行一些重型 CPU 绑定操作时该怎么办?嗯...您需要使用一种变通方法。我所说的变通方法是多线程或多进程。

这可能听起来不好,但有时最好的解决方案可能是我们试图逃避的解决方案。在 Python 中,对 CPU 密集型任务的并行处理总是使用多进程更好。如果设置正确并小心处理,多线程可以同样好地处理 I/O 操作(快速且没有太多资源开销)如asyncawait

所以有时当你不知道该怎么办,当某些东西简单地不适合你的异步应用程序时,使用一段代码将它推迟到单独的线程或进程。你可以假装这是一个协程,释放控制权给事件循环,最终在结果准备好时处理结果。幸运的是,Python 标准库提供了concurrent.futures模块,它也与asyncio模块集成。这两个模块一起允许你安排在线程或额外进程中执行的阻塞函数,就像它们是异步非阻塞的协程一样。

执行者和未来

在我们看到如何将线程或进程注入异步事件循环之前,我们将更仔细地看一下concurrent.futures模块,这将成为我们所谓的变通方法的主要组成部分。

concurrent.futures模块中最重要的类是ExecutorFuture

Executor代表一个可以并行处理工作项的资源池。这在目的上似乎与multiprocessing模块的Pooldummy.Pool类非常相似,但它有完全不同的接口和语义。它是一个不打算实例化的基类,并且有两个具体的实现:

  • ThreadPoolExecutor:这个代表一个线程池

  • ProcessPoolExecutor:这个代表一个进程池

每个执行者提供三种方法:

  • submit(fn, *args, **kwargs):这个方法安排fn函数在资源池上执行,并返回代表可调用执行的Future对象

  • map(func, *iterables, timeout=None, chunksize=1):这个方法以类似于multiprocessing.Pool.map()方法的方式在可迭代对象上执行 func 函数

  • shutdown(wait=True):这个方法关闭执行者并释放它的所有资源

最有趣的方法是submit(),因为它返回一个Future对象。它代表一个可调用的异步执行,间接代表它的结果。为了获得提交的可调用的实际返回值,你需要调用Future.result()方法。如果可调用已经完成,result()方法不会阻塞它,只会返回函数的输出。如果不是这样,它会阻塞直到结果准备好。把它当作一个结果的承诺(实际上它和 JavaScript 中的 promise 概念是一样的)。你不需要立即在接收到它后解包它(用result()方法),但如果你试图这样做,它保证最终会返回一些东西:

>>> def loudy_return():
...     print("processing")
...     return 42
...** 
>>> from concurrent.futures import ThreadPoolExecutor
>>> with ThreadPoolExecutor(1) as executor:
...     future = executor.submit(loudy_return)
...** 
processing
>>> future
<Future at 0x33cbf98 state=finished returned int>
>>> future.result()
42

如果你想使用Executor.map()方法,它在用法上与multiprocessing模块的Pool类的map()方法没有区别:

def main():
    with ThreadPoolExecutor(POOL_SIZE) as pool:
        results = pool.map(fetch_place, PLACES)

    for result in results:
        present_result(result)

在事件循环中使用执行者

Executor.submit()方法返回的Future类实例在概念上与异步编程中使用的协程非常接近。这就是为什么我们可以使用执行者来实现协作式多任务和多进程或多线程的混合。

这个变通方法的核心是事件循环类的BaseEventLoop.run_in_executor(executor, func, *args)方法。它允许你在由executor参数表示的进程或线程池中安排func函数的执行。这个方法最重要的一点是它返回一个新的awaitable(一个可以用await语句await的对象)。因此,由于这个方法,你可以执行一个阻塞函数,它不是一个协程,就像它是一个协程一样,无论它需要多长时间来完成,它都不会阻塞。它只会阻止等待这样一个调用结果的函数,但整个事件循环仍然会继续运转。

一个有用的事实是,您甚至不需要创建自己的执行器实例。如果将None作为执行器参数传递,将使用ThreadPoolExecutor类以默认线程数(对于 Python 3.5,它是处理器数量乘以 5)。

因此,让我们假设我们不想重写导致我们头疼的python-gmaps包的有问题的部分。我们可以通过loop.run_in_executor()调用轻松地将阻塞调用推迟到单独的线程,同时将fetch_place()函数保留为可等待的协程:

async def fetch_place(place):
    coro = loop.run_in_executor(None, api.geocode, place)
    result = await coro
    return result[0]

这样的解决方案并不像拥有完全异步库来完成工作那样好,但您知道半瓶水总比没有水好

总结

这是一段漫长的旅程,但我们成功地克服了 Python 程序员可用的并发编程的最基本方法。

在解释并发到底是什么之后,我们迅速行动起来,通过多线程的帮助解剖了典型的并发问题之一。在确定了我们代码的基本缺陷并加以修复后,我们转向了多进程,看看它在我们的情况下会如何运作。

我们发现,使用multiprocessing模块比使用threading的基本线程要容易得多。但就在那之后,我们意识到我们也可以使用相同的 API 来处理线程,多亏了multiprocessing.dummy。因此,现在在多进程和多线程之间的选择只是更适合问题的解决方案,而不是哪种解决方案具有更好的接口。

说到问题的适应性,我们最终尝试了异步编程,这应该是 I/O 密集型应用程序的最佳解决方案,只是意识到我们不能完全忘记线程和进程。所以我们又回到了起点!

这就引出了本章的最终结论。并没有银弹。有一些方法可能更受您喜欢。有一些方法可能更适合特定的问题集,但您需要了解它们,以便取得成功。在现实场景中,您可能会发现自己在单个应用程序中使用整套并发工具和风格,这并不罕见。

上述结论是下一章第十四章有用的设计模式主题的绝佳引言。这是因为没有单一的模式可以解决您所有的问题。您应该尽可能了解尽可能多的模式,因为最终您将每天都使用它们。

第十四章:有用的设计模式

设计模式是软件设计中常见问题的可重用的、有些特定于语言的解决方案。关于这个主题最流行的书是设计模式:可复用面向对象软件的元素Addison-Wesley Professional,由 Gamma、Helm、Johnson 和 Vlissides 编写,也被称为四人帮GoF。它被认为是这一领域的重要著作,并提供了 23 种设计模式的目录,其中包括 SmallTalk 和 C++的示例。

在设计应用程序代码时,这些模式有助于解决常见问题。它们向所有开发人员发出警报,因为它们描述了经过验证的开发范例。但是应该根据使用的语言来学习它们,因为其中一些在某些语言中没有意义或者已经内置。

本章描述了 Python 中最有用的模式或者有趣讨论的模式,并提供了实现示例。以下是三个部分,对应于 GoF 定义的设计模式类别:

  • 创建模式:这些模式用于生成具有特定行为的对象

  • 结构模式:这些模式有助于为特定用例构建代码结构

  • 行为模式:这些模式有助于分配责任和封装行为

创建模式

创建模式处理对象实例化机制。这样的模式可能定义了对象实例的创建方式,甚至类的构造方式。

这些模式在编译语言(如 C 或 C++)中非常重要,因为在运行时更难以按需生成类型。

但是在 Python 中,运行时创建新类型非常简单。内置的type函数允许您通过代码定义一个新的类型对象:

>>> MyType = type('MyType', (object,), {'a': 1})
>>> ob = MyType()
>>> type(ob)
<class '__main__.MyType'>
>>> ob.a
1
>>> isinstance(ob, object)
True

类和类型是内置的工厂。我们已经处理了新类对象的创建,您可以使用元类与类和对象生成进行交互。这些功能是实现工厂设计模式的基础,但我们不会在本节进一步描述它,因为我们已经在第三章中广泛涵盖了类和对象创建的主题,语法最佳实践 - 类级别以上

除了工厂,GoF 中另一个有趣的创建设计模式是单例。

单例

单例将类的实例化限制为仅一个对象实例。

单例模式确保给定的类在应用程序中始终只有一个活动实例。例如,当您希望将资源访问限制为进程中仅有一个内存上下文时,可以使用此模式。例如,数据库连接器类可以是一个单例,它处理同步并在内存中管理数据。它假设与数据库交互的同时没有其他实例。

这种模式可以简化应用程序中处理并发的方式。提供应用程序范围功能的实用程序通常被声明为单例。例如,在 Web 应用程序中,负责保留唯一文档 ID 的类将受益于单例模式。应该有一个且仅有一个执行此工作的实用程序。

在 Python 中,有一种流行的半成语是通过覆盖类的__new__()方法来创建单例:

class Singleton:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__new__(cls, *args, **kwargs)

        return cls._instance

如果您尝试创建该类的多个实例并比较它们的 ID,您会发现它们都代表同一个对象:

>>> instance_a = Singleton()
>>> instance_b = Singleton()
>>> id(instance_a) == id(instance_b)
True
>>> instance_a == instance_b
True

我将其称为半成语,因为这是一个非常危险的模式。问题在于当您尝试对基本单例类进行子类化并创建此新子类的实例时,如果您已经创建了基类的实例,则问题就开始了。

>>> class ConcreteClass(Singleton): pass
>>> Singleton()
<Singleton object at 0x000000000306B470>
>>> ConcreteClass()
<Singleton object at 0x000000000306B470>

这可能会变得更加棘手,当你注意到这种行为受到实例创建顺序的影响时。根据你的类使用顺序,你可能会得到相同的结果,也可能不会。让我们看看如果你首先创建子类实例,然后创建基类的实例,结果会是什么样的:

>>> class ConcreteClass(Singleton): pass
>>> ConcreteClass()
<ConcreteClass object at 0x00000000030615F8>
>>> Singleton()
<Singleton object at 0x000000000304BCF8>

正如你所看到的,行为完全不同,非常难以预测。在大型应用程序中,这可能导致非常危险且难以调试的问题。根据运行时上下文,您可能会或者不会使用您本来打算使用的类。由于这种行为真的很难预测和控制,应用程序可能会因为改变的导入顺序甚至用户输入而崩溃。如果您的单例不打算被子类化,那么以这种方式实现可能相对安全。无论如何,这是一个定时炸弹。如果将来有人忽视风险并决定从您的单例对象创建一个子类,一切都可能会爆炸。避免使用这种特定的实现,使用另一种替代方案会更安全。

使用更高级的技术——元类是更安全的。通过重写元类的__call__()方法,您可以影响自定义类的创建。这允许创建可重用的单例代码:

class Singleton(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

通过将Singleton用作自定义类的元类,您可以获得安全的可子类化的单例,并且不受实例创建顺序的影响:

>>> ConcreteClass() == ConcreteClass()
True
>>> ConcreteSubclass() == ConcreteSubclass()
True
>>> ConcreteClass()
<ConcreteClass object at 0x000000000307AF98>
>>> ConcreteSubclass()
<ConcreteSubclass object at 0x000000000307A3C8>

克服单例实现问题的另一种方法是使用 Alex Martelli 提出的方法。他提出了一种与单例类似但在结构上完全不同的方法。这不是来自 GoF 书籍的经典设计模式,但似乎在 Python 开发人员中很常见。它被称为BorgMonostate

这个想法非常简单。单例模式中真正重要的不是一个类有多少个实例,而是它们始终共享相同的状态。因此,Alex Martelli 提出了一个使类的所有实例共享相同__dict__的类:

class Borg(object):
    _state = {}

    def __new__(cls, *args, **kwargs):
        ob = super().__new__(cls, *args, **kwargs)
        ob.__dict__ = cls._state
        return ob

这解决了子类化问题,但仍取决于子类代码的工作方式。例如,如果重写了__getattr__,则可能会破坏模式。

然而,单例不应该有多层继承。标记为单例的类已经是特定的。

也就是说,许多开发人员认为这种模式是处理应用程序中的唯一性的一种繁重方式。如果需要单例,为什么不使用具有函数的模块,因为 Python 模块已经是单例了呢?最常见的模式是将模块级变量定义为需要是单例的类的实例。这样,你也不会限制开发人员对你的初始设计。

注意

单例工厂是处理应用程序唯一性的隐式方式。你可以不用它。除非你在类似 Java 的框架中工作,这种模式是必需的,否则请使用模块而不是类。

结构模式

结构模式在大型应用程序中非常重要。它们决定了代码的组织方式,并为开发人员提供了如何与应用程序的每个部分进行交互的指南。

长期以来,Python 世界中许多结构模式的最著名实现是 Zope 项目的Zope 组件架构ZCA)。它实现了本节中描述的大多数模式,并提供了一套丰富的工具来处理它们。ZCA 旨在不仅在 Zope 框架中运行,还在其他框架中运行,如 Twisted。它提供了接口和适配器的实现,以及其他功能。

不幸的是(或者不是),Zope 几乎失去了所有的动力,不再像以前那样受欢迎。但是它的 ZCA 可能仍然是 Python 中实现结构模式的一个很好的参考。Baiju Muthukadan 创建了Zope 组件架构综合指南。它可以打印和免费在线获取(参考muthukadan.net/docs/zca.html)。它是在 2009 年写的,所以它没有涵盖 Python 的最新版本,但应该是一个很好的阅读,因为它为一些提到的模式提供了很多合理性。

Python 已经通过其语法提供了一些流行的结构模式。例如,类和函数装饰器可以被认为是装饰器模式的一种变体。此外,创建和导入模块的支持是模块模式的一种表现。

常见结构模式的列表实际上相当长。原始的设计模式书中有多达七种,后来的文献中还扩展了这个列表。我们不会讨论所有这些模式,而只会专注于最受欢迎和公认的三种模式,它们是:

  • 适配器

  • 代理

  • 外观

适配器

适配器模式允许使用现有类的接口从另一个接口中使用。换句话说,适配器包装了一个类或对象A,使其在预期用于类或对象B的上下文中工作。

在 Python 中创建适配器实际上非常简单,因为这种语言的类型系统是如何工作的。Python 中的类型哲学通常被称为鸭子类型:

“如果它走起来像鸭子,说起来像鸭子,那么它就是鸭子!”

根据这个规则,如果一个函数或方法接受一个值,决定不应该基于它的类型,而应该基于它的接口。因此,只要对象的行为符合预期,即具有适当的方法签名和属性,它的类型就被认为是兼容的。这与许多静态类型的语言完全不同,在这些语言中很少有这样的事情。

在实践中,当一些代码打算与给定类一起工作时,只要它们提供了代码使用的方法和属性,就可以用另一个类的对象来提供它。当然,这假设代码不会调用instance来验证实例是否属于特定类。

适配器模式基于这种哲学,定义了一种包装机制,其中一个类或对象被包装以使其在最初不打算用于它的上下文中工作。StringIO就是一个典型的例子,因为它适应了str类型,所以它可以被用作file类型:

>>> from io import StringIO
>>> my_file = StringIO('some content')
>>> my_file.read()
'some content'
>>> my_file.seek(0)
>>> my_f
ile.read(1)
's'

让我们举另一个例子。DublinCoreInfos类知道如何显示给定文档的一些 Dublin Core 信息子集的摘要(参见dublincore.org/),并提供为dict提供。它读取一些字段,比如作者的名字或标题,并打印它们。为了能够显示文件的 Dublin Core,它必须以与StringIO相同的方式进行适配。下图显示了这种适配器模式实现的类似 UML 的图。

适配器

图 2 简单适配器模式示例的 UML 图

DublinCoreAdapter包装了一个文件实例,并提供了对其元数据的访问:

from os.path import split, splitext

class DublinCoreAdapter:
    def __init__(self, filename):
        self._filename = filename

    @property
    def title(self):
        return splitext(split(self._filename)[-1])[0]

    @property
    def languages(self):
        return ('en',)

    def __getitem__(self, item):
        return getattr(self, item, 'Unknown')

class DublinCoreInfo(object):
    def summary(self, dc_dict):
        print('Title: %s' % dc_dict['title'])
        print('Creator: %s' % dc_dict['creator'])
        print('Languages: %s' % ', '.join(dc_dict['languages']))

以下是示例用法:

>>> adapted = DublinCoreAdapter('example.txt')
>>> infos = DublinCoreInfo()
>>> infos.summary(adapted)
Title: example
Creator: Unknown
Languages: en

除了允许替换的事实之外,适配器模式还可以改变开发人员的工作方式。将对象适应特定上下文的假设是对象的类根本不重要。重要的是这个类实现了DublinCoreInfo等待的内容,并且这种行为由适配器固定或完成。因此,代码可以简单地告诉它是否与实现特定行为的对象兼容。这可以通过接口来表达。

接口

接口是 API 的定义。它描述了一个类应该具有的方法和属性列表,以实现所需的行为。这个描述不实现任何代码,只是为希望实现接口的任何类定义了一个明确的合同。然后任何类都可以以任何方式实现一个或多个接口。

虽然 Python 更喜欢鸭子类型而不是明确的接口定义,但有时使用它们可能更好。例如,明确的接口定义使框架更容易定义接口上的功能。

好处在于类之间松散耦合,这被认为是一种良好的实践。例如,要执行给定的过程,类A不依赖于类B,而是依赖于接口I。类B实现了I,但它可以是任何其他类。

许多静态类型语言(如 Java 或 Go)内置了对这种技术的支持。接口允许函数或方法限制实现给定接口的可接受参数对象的范围,无论它来自哪种类。这比将参数限制为给定类型或其子类更灵活。这就像鸭子类型行为的显式版本:Java 使用接口在编译时验证类型安全,而不是在运行时使用鸭子类型将事物绑在一起。

Python 对接口的类型哲学与 Java 完全不同,因此它没有原生支持接口。无论如何,如果您想对应用程序接口有更明确的控制,通常有两种选择:

  • 使用一些第三方框架添加接口的概念

  • 使用一些高级语言特性来构建处理接口的方法论。

使用 zope.interface

有一些框架允许您在 Python 中构建明确的接口。最值得注意的是 Zope 项目的一部分。它是zope.interface包。尽管如今 Zope 不像以前那样受欢迎,但zope.interface包仍然是 Twisted 框架的主要组件之一。

zope.interface包的核心类是Interface类。它允许您通过子类化来明确定义一个新的接口。假设我们想为矩形的每个实现定义一个强制性接口:

from zope.interface import Interface, Attribute

class IRectangle(Interface):
    width = Attribute("The width of rectangle")
    height = Attribute("The height of rectangle")

    def area():
        """ Return area of rectangle
        """

    def perimeter():
        """ Return perimeter of rectangle
        """

使用zope.interface定义接口时需要记住的一些重要事项如下:

  • 接口的常见命名约定是使用I作为名称后缀。

  • 接口的方法不得带有self参数。

  • 由于接口不提供具体实现,因此它应该只包含空方法。您可以使用pass语句,引发NotImplementedError,或提供文档字符串(首选)。

  • 接口还可以使用Attribute类指定所需的属性。

当您定义了这样的合同后,您可以定义提供IRectangle接口实现的新具体类。为此,您需要使用implementer()类装饰器并实现所有定义的方法和属性:

@implementer(IRectangle)
class Square:
    """ Concrete implementation of square with rectangle interface
    """

    def __init__(self, size):
        self.size = size

    @property
    def width(self):
        return self.size

    @property
    def height(self):
        return self.size

    def area(self):
        return self.size ** 2

    def perimeter(self):
        return 4 * self.size

@implementer(IRectangle)
class Rectangle:
    """ Concrete implementation of rectangle
    """
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return self.width * 2 + self.height * 2

通常说接口定义了具体实现需要满足的合同。这种设计模式的主要好处是能够在对象被使用之前验证合同和实现之间的一致性。使用普通的鸭子类型方法,只有在运行时缺少属性或方法时才会发现不一致性。使用zope.interface,您可以使用zope.interface.verify模块的两种方法来提前检查实际实现中的不一致性:

  • verifyClass(interface, class_object): 这会验证类对象是否存在方法,并检查其签名的正确性,而不会查找属性

  • verifyObject(interface, instance): 这验证实际对象实例的方法、它们的签名和属性

由于我们已经定义了我们的接口和两个具体的实现,让我们在交互式会话中验证它们的契约:

>>> from zope.interface.verify import verifyClass, verifyObject
>>> verifyObject(IRectangle, Square(2))
True
>>> verifyClass(IRectangle, Square)
True
>>> verifyObject(IRectangle, Rectangle(2, 2))
True
>>> verifyClass(IRectangle, Rectangle)
True

没有什么令人印象深刻的。RectangleSquare类仔细遵循了定义的契约,因此除了成功的验证外,没有更多的东西可见。但是当我们犯错时会发生什么?让我们看一个未能提供完整IRectangle接口实现的两个类的示例:

@implementer(IRectangle)
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

@implementer(IRectangle)
class Circle:
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return math.pi * self.radius ** 2

    def perimeter(self):
        return 2 * math.pi * self.radius

Point类没有提供IRectangle接口的任何方法或属性,因此它的验证将在类级别上显示不一致性:

>>> verifyClass(IRectangle, Point)

Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
 **File "zope/interface/verify.py", line 102, in verifyClass
 **return _verify(iface, candidate, tentative, vtype='c')
 **File "zope/interface/verify.py", line 62, in _verify
 **raise BrokenImplementation(iface, name)
zope.interface.exceptions.BrokenImplementation: An object has failed to implement interface <InterfaceClass __main__.IRectangle>

 **The perimeter attribute was not provided.

Circle类有点棘手。它定义了所有接口方法,但在实例属性级别上违反了契约。这就是为什么在大多数情况下,您需要使用verifyObject()函数来完全验证接口实现的原因:

>>> verifyObject(IRectangle, Circle(2))

Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
 **File "zope/interface/verify.py", line 105, in verifyObject
 **return _verify(iface, candidate, tentative, vtype='o')
 **File "zope/interface/verify.py", line 62, in _verify
 **raise BrokenImplementation(iface, name)
zope.interface.exceptions.BrokenImplementation: An object has failed to implement interface <InterfaceClass __main__.IRectangle>

 **The width attribute was not provided.

使用zope.inteface是一种有趣的解耦应用程序的方式。它允许您强制执行正确的对象接口,而无需多重继承的过度复杂性,并且还可以及早捕获不一致性。然而,这种方法最大的缺点是要求您明确定义给定类遵循某个接口才能进行验证。如果您需要验证来自内置库的外部类的实例,这将特别麻烦。zope.interface为该问题提供了一些解决方案,当然您也可以使用适配器模式或甚至猴子补丁来处理这些问题。无论如何,这些解决方案的简单性至少是值得商榷的。

使用函数注释和抽象基类

设计模式的目的是使问题解决变得更容易,而不是为您提供更多的复杂层次。zope.interface是一个很好的概念,可能非常适合某些项目,但它并不是万能解决方案。使用它,您可能很快就会发现自己花费更多时间修复与第三方类的不兼容接口的问题,并提供无休止的适配器层,而不是编写实际的实现。如果您有这种感觉,那么这是某种问题出现的迹象。幸运的是,Python 支持构建轻量级的接口替代方案。它不像zope.interface或其替代方案那样是一个成熟的解决方案,但通常提供更灵活的应用程序。您可能需要编写更多的代码,但最终您将拥有更具可扩展性,更好地处理外部类型,并且可能更具未来性的东西。

请注意,Python 在其核心中没有接口的明确概念,可能永远不会有,但具有一些功能,允许您构建类似接口功能的东西。这些功能包括:

  • 抽象基类ABCs

  • 函数注释

  • 类型注释

我们解决方案的核心是抽象基类,所以我们将首先介绍它们。

如您可能知道的那样,直接的类型比较被认为是有害的,而且不是pythonic。您应该始终避免以下比较:

assert type(instance) == list

在函数或方法中比较类型的方式完全破坏了将类子类型作为参数传递给函数的能力。稍微更好的方法是使用isinstance()函数,它会考虑继承关系:

assert isinstance(instance, list)

isinstance()的额外优势是您可以使用更广泛的类型来检查类型兼容性。例如,如果您的函数期望接收某种序列作为参数,您可以与基本类型的列表进行比较:

assert isinstance(instance, (list, tuple, range))

这种类型兼容性检查的方式在某些情况下是可以的,但仍然不完美。它将适用于listtuplerange的任何子类,但如果用户传递的是与这些序列类型完全相同但不继承自任何一个的东西,它将失败。例如,让我们放宽要求,说你想接受任何类型的可迭代对象作为参数。你会怎么做?可迭代的基本类型列表实际上相当长。你需要涵盖 list、tuple、range、str、bytes、dict、set、生成器等等。适用的内置类型列表很长,即使你覆盖了所有这些类型,它仍然不允许你检查是否与定义了__iter__()方法的自定义类兼容,而是直接继承自object

这是抽象基类(ABC)是适当解决方案的情况。ABC 是一个类,不需要提供具体的实现,而是定义了一个类的蓝图,可以用来检查类型的兼容性。这个概念与 C++语言中的抽象类和虚方法的概念非常相似。

抽象基类用于两个目的:

  • 检查实现的完整性

  • 检查隐式接口兼容性

因此,让我们假设我们想定义一个接口,确保一个类具有push()方法。我们需要使用特殊的ABCMeta元类和标准abc模块中的abstractmethod()装饰器创建一个新的抽象基类:

from abc import ABCMeta, abstractmethod

class Pushable(metaclass=ABCMeta):

    @abstractmethod
    def push(self, x):
        """ Push argument no matter what it means
        """

abc模块还提供了一个可以用来代替元类语法的 ABC 基类:

from abc import ABCMeta, abstractmethod

class Pushable(metaclass=ABCMeta):
    @abstractmethod
    def push(self, x):
        """ Push argument no matter what it means
        """

一旦完成,我们可以将Pushable类用作具体实现的基类,并且它将阻止我们实例化具有不完整实现的对象。让我们定义DummyPushable,它实现了所有接口方法和IncompletePushable,它违反了预期的合同:

class DummyPushable(Pushable):
    def push(self, x):
        return

class IncompletePushable(Pushable):
    pass

如果你想获得DummyPushable实例,那就没有问题,因为它实现了唯一需要的push()方法:

>>> DummyPushable()
<__main__.DummyPushable object at 0x10142bef0>

但是,如果你尝试实例化IncompletePushable,你会得到TypeError,因为缺少interface()方法的实现:

>>> IncompletePushable()
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class IncompletePushable with abstract methods push

前面的方法是确保基类实现完整性的好方法,但与zope.interface替代方案一样明确。DummyPushable实例当然也是Pushable的实例,因为 Dummy 是Pushable的子类。但是其他具有相同方法但不是Pushable的后代的类呢?让我们创建一个并看看:

>>> class SomethingWithPush:
...     def push(self, x):
...         pass
...** 
>>> isinstance(SomethingWithPush(), Pushable)
False

还有一些东西缺失。SomethingWithPush类明确具有兼容的接口,但尚未被视为Pushable的实例。那么,缺少什么?答案是__subclasshook__(subclass)方法,它允许你将自己的逻辑注入到确定对象是否是给定类的实例的过程中。不幸的是,你需要自己提供它,因为abc的创建者不希望限制开发人员覆盖整个isinstance()机制。我们对它有完全的控制权,但我们被迫写一些样板代码。

虽然你可以做任何你想做的事情,但通常在__subclasshook__()方法中唯一合理的事情是遵循常见的模式。标准程序是检查定义的方法集是否在给定类的 MRO 中的某个地方可用:

from abc import ABCMeta, abstractmethod

class Pushable(metaclass=ABCMeta):

    @abstractmethod
    def push(self, x):
        """ Push argument no matter what it means
        """

    @classmethod
    def __subclasshook__(cls, C):
        if cls is Pushable:
            if any("push" in B.__dict__ for B in C.__mro__):
                return True
        return NotImplemented

通过这种方式定义__subclasshook__()方法,现在可以确认隐式实现接口的实例也被视为接口的实例:

>>> class SomethingWithPush:
...     def push(self, x):
...         pass
...** 
>>> isinstance(SomethingWithPush(), Pushable)
True

不幸的是,这种验证类型兼容性和实现完整性的方法并未考虑类方法的签名。因此,如果实现中预期的参数数量不同,它仍将被视为兼容。在大多数情况下,这不是问题,但如果您需要对接口进行如此精细的控制,zope.interface包允许这样做。正如前面所说,__subclasshook__()方法不会限制您在isinstance()函数的逻辑中添加更多复杂性,以实现类似的控制水平。

补充抽象基类的另外两个特性是函数注释和类型提示。函数注释是在第二章中简要描述的语法元素,语法最佳实践-类级别以下。它允许您使用任意表达式对函数及其参数进行注释。正如第二章中所解释的,语法最佳实践-类级别以下,这只是一个不提供任何语法意义的功能存根。标准库中没有使用此功能来强制执行任何行为。无论如何,您可以将其用作通知开发人员预期参数接口的便捷且轻量级的方式。例如,考虑从zope.interface重写的IRectangle接口以抽象基类的形式:

from abc import (
    ABCMeta,
    abstractmethod,
    abstractproperty
)

class IRectangle(metaclass=ABCMeta):

    @abstractproperty
    def width(self):
        return

    @abstractproperty
    def height(self):
        return

    @abstractmethod
    def area(self):
        """ Return rectangle area
        """

    @abstractmethod
    def perimeter(self):
        """ Return rectangle perimeter
        """

    @classmethod
    def __subclasshook__(cls, C):
        if cls is IRectangle:
            if all([
                any("area" in B.__dict__ for B in C.__mro__),
                any("perimeter" in B.__dict__ for B in C.__mro__),
                any("width" in B.__dict__ for B in C.__mro__),
                any("height" in B.__dict__ for B in C.__mro__),
            ]):
                return True
        return NotImplemented

如果您有一个仅适用于矩形的函数,比如draw_rectangle(),您可以将预期参数的接口注释如下:

def draw_rectangle(rectangle: IRectange):
    ...

这只是为开发人员提供有关预期信息的信息。即使这是通过非正式合同完成的,因为正如我们所知,裸注释不包含任何语法意义。但是,它们在运行时是可访问的,因此我们可以做更多的事情。以下是一个通用装饰器的示例实现,它能够验证函数注释中提供的接口是否使用抽象基类:

def ensure_interface(function):
    signature = inspect.signature(function)
    parameters = signature.parameters

    @wraps(function)
    def wrapped(*args, **kwargs):
        bound = signature.bind(*args, **kwargs)
        for name, value in bound.arguments.items():
            annotation = parameters[name].annotation

            if not isinstance(annotation, ABCMeta):
                continue

            if not isinstance(value, annotation):
                raise TypeError(
                    "{} does not implement {} interface"
                    "".format(value, annotation)
                )

        function(*args, **kwargs)

    return wrapped

一旦完成,我们可以创建一些具体的类,它们隐式地实现了IRectangle接口(而不是继承自IRectangle),并更新draw_rectangle()函数的实现,以查看整个解决方案的工作原理:

class ImplicitRectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    @property
    def width(self):
        return self._width

    @property
    def height(self):
        return self._height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return self.width * 2 + self.height * 2

@ensure_interface
def draw_rectangle(rectangle: IRectangle):
    print(
        "{} x {} rectangle drawing"
        "".format(rectangle.width, rectangle.height)
    )

如果我们使用不兼容的对象来调用draw_rectangle()函数,它现在将引发TypeError并提供有意义的解释:

>>> draw_rectangle('foo')
Traceback (most recent call last):
 **File "<input>", line 1, in <module>
 **File "<input>", line 101, in wrapped
TypeError: foo does not implement <class 'IRectangle'> interface

但是,如果我们使用ImplicitRectangle或任何其他类似IRectangle接口的对象,该函数将按预期执行:

>>> draw_rectangle(ImplicitRectangle(2, 10))
2 x 10 rectangle drawing

我们的ensure_interface()的示例实现是基于typeannotations项目中的typechecked()装饰器,该项目试图提供运行时检查功能(请参阅github.com/ceronman/typeannotations)。它的源代码可能会给您一些有趣的想法,关于如何处理类型注释以确保运行时接口检查。

可以用来补充这种接口模式的最后一个特性是类型提示。类型提示在 PEP 484 中有详细描述,并且是最近添加到语言中的。它们在新的typing模块中公开,并且从 Python 3.5 开始可用。类型提示建立在函数注释的基础上,并重用了 Python 3 中略微被遗忘的语法特性。它们旨在指导类型提示并检查各种尚未出现的 Python 类型检查器。typing模块和 PEP 484 文档旨在提供一种用于描述类型注释的标准类型和类的层次结构。

然而,类型提示似乎并不是什么革命性的东西,因为这个特性并没有内置任何类型检查器到标准库中。如果你想在你的代码中使用类型检查或者强制严格的接口兼容性,你需要创建自己的工具,因为目前还没有值得推荐的工具。这就是为什么我们不会深入研究 PEP 484 的细节。无论如何,类型提示和描述它们的文档是值得一提的,因为如果在 Python 的类型检查领域出现了一些非凡的解决方案,它很可能是基于 PEP 484 的。

使用 collections.abc

抽象基类就像创建更高级抽象的小积木。它们允许你实现真正可用的接口,但非常通用,设计用于处理远远超出这个单一设计模式的东西。你可以释放你的创造力,做出神奇的事情,但构建一些通用的、真正可用的东西可能需要大量的工作。这可能永远得不到回报。

这就是为什么自定义抽象基类并不经常使用。尽管如此,collections.abc模块提供了许多预定义的 ABCs,允许验证许多基本 Python 类型的接口兼容性。使用这个模块提供的基类,你可以检查一个给定的对象是否可调用、映射,或者是否支持迭代。使用它们与isinstance()函数比较要比与基本的 Python 类型比较要好得多。即使你不想使用ABCMeta定义自己的自定义接口,你也应该知道如何使用这些基类。

你会时不时地使用collections.abc中最常见的抽象基类:

  • Container:这个接口意味着对象支持in操作符,并实现了__contains__()方法

  • Iterable:这个接口意味着对象支持迭代,并实现了__iter__()方法

  • Callable:这个接口意味着它可以像函数一样被调用,并实现了__call__()方法

  • Hashable:这个接口意味着对象是可散列的(可以包含在集合中并作为字典中的键),并实现了__hash__方法

  • Sized:这个接口意味着对象有大小(可以使用len()函数)并实现了__len__()方法

collections.abc模块中可用的抽象基类的完整列表可以在官方 Python 文档中找到(参见docs.python.org/3/library/collections.abc.html)。

代理

代理提供了对昂贵或远程资源的间接访问。代理位于客户端主体之间,如下图所示:

代理

如果 Subject 的访问是昂贵的,这是为了优化 Subject 的访问。例如,在第十二章中描述的memoize()lru_cache()装饰器,优化-一些强大的技术,可以被视为代理。

代理也可以用来提供对主体的智能访问。例如,大型视频文件可以被包装成代理,以避免在用户只要求它们的标题时将它们加载到内存中。

urllib.request模块提供了一个例子。urlopen是一个代理,用于访问远程 URL 上的内容。当它被创建时,可以独立于内容本身检索头部,而无需读取响应的其余部分:

>>> class Url(object):
...     def __init__(self, location):
...         self._url = urlopen(location)
...     def headers(self):
...         return dict(self._url.headers.items())
...     def get(self):
...         return self._url.read()
...** 
>>> python_org = Url('http://python.org')
>>> python_org.headers().keys()
dict_keys(['Accept-Ranges', 'Via', 'Age', 'Public-Key-Pins', 'X-Clacks-Overhead', 'X-Cache-Hits', 'X-Cache', 'Content-Type', 'Content-Length', 'Vary', 'X-Served-By', 'Strict-Transport-Security', 'Server', 'Date', 'Connection', 'X-Frame-Options'])

这可以用来决定在获取页面主体之前是否已经更改了页面,通过查看last-modified头部。让我们用一个大文件举个例子:

>>> ubuntu_iso = Url('http://ubuntu.mirrors.proxad.net/hardy/ubuntu-8.04-desktop-i386.iso')
>>> ubuntu_iso.headers()['Last-Modified']
'Wed, 23 Apr 2008 01:03:34 GMT'

代理的另一个用例是数据唯一性

例如,让我们考虑一个网站,在几个位置上呈现相同的文档。特定于每个位置的额外字段被附加到文档中,例如点击计数器和一些权限设置。在这种情况下,可以使用代理来处理特定于位置的问题,并指向原始文档,而不是复制它。因此,给定的文档可以有许多代理,如果其内容发生变化,所有位置都将受益,而无需处理版本同步。

一般来说,代理模式对于实现可能存在于其他地方的某些东西的本地处理很有用:

  • 加快流程

  • 避免外部资源访问

  • 减少内存负载

  • 确保数据的唯一性

Facade

Facade提供了对子系统的高级、简单的访问。

Facade 只是一个快捷方式,用于使用应用程序的功能,而不必处理子系统的底层复杂性。例如,可以通过在包级别提供高级功能来实现这一点。

Facade 通常是在现有系统上完成的,其中包的频繁使用被合成为高级功能。通常,不需要类来提供这样的模式,__init__.py模块中的简单函数就足够了。

一个提供了一个大的外观覆盖复杂和复杂接口的项目的很好的例子是requests包(参考docs.python-requests.org/)。它通过提供一个清晰的 API,使得在 Python 中处理 HTTP 请求和响应的疯狂变得简单,这对开发人员来说非常容易阅读。它实际上甚至被宣传为“人类的 HTTP”。这种易用性总是以一定的代价为代价,但最终的权衡和额外的开销并不会吓倒大多数人使用 Requests 项目作为他们选择的 HTTP 工具。最终,它使我们能够更快地完成项目,而开发人员的时间通常比硬件更昂贵。

注意

Facade 简化了您的包的使用。在几次迭代后,通常会添加 Facade 以获得使用反馈。

行为模式

行为模式旨在通过结构化它们的交互过程来简化类之间的交互。

本节提供了三个流行的行为模式的示例,您在编写 Python 代码时可能需要考虑:

  • 观察者

  • 访问者

  • 模板

观察者

观察者模式用于通知一系列对象观察组件的状态变化。

观察者允许以可插拔的方式向应用程序添加功能,通过将新功能与现有代码库解耦。事件框架是观察者模式的典型实现,并在接下来的图中描述。每当发生事件时,所有观察者都会收到触发此事件的主题的通知。

事件是发生某事时创建的。在图形用户界面应用程序中,事件驱动编程(参见en.wikipedia.org/wiki/Event-driven_programming)通常用于将代码与用户操作链接起来。例如,可以将函数链接到MouseMove事件,以便在鼠标在窗口上移动时调用它。

在 GUI 应用程序的情况下,将代码与窗口管理内部解耦会大大简化工作。函数是分开编写的,然后注册为事件观察者。这种方法存在于微软的 MFC 框架的最早版本中(参见en.wikipedia.org/wiki/Microsoft_Foundation_Class_Library),以及 Qt 或 GTK 等所有 GUI 开发工具中。许多框架使用信号的概念,但它们只是观察者模式的另一种表现。

代码也可以生成事件。例如,在一个将文档存储在数据库中的应用程序中,DocumentCreatedDocumentModifiedDocumentDeleted可以是代码提供的三个事件。一个在文档上工作的新功能可以注册自己作为观察者,每当文档被创建、修改或删除时得到通知,并进行适当的工作。这样就可以在应用程序中添加一个文档索引器。当然,这要求负责创建、修改或删除文档的所有代码都触发事件。但这比在整个应用程序代码库中添加索引挂钩要容易得多!一个遵循这种模式的流行 Web 框架是 Django,它具有信号机制。

可以通过在类级别上工作来实现 Python 中观察者的注册的Event类:

class Event:
    _observers = []

    def __init__(self, subject):
        self.subject = subject

    @classmethod
    def register(cls, observer):
        if observer not in cls._observers:
            cls._observers.append(observer)

    @classmethod
    def unregister(cls, observer):
        if observer in cls._observers:
            cls._observers.remove(observer)

    @classmethod
    def notify(cls, subject):
        event = cls(subject)
        for observer in cls._observers:
            observer(event)

观察者使用Event类方法注册自己,并通过携带触发它们的主题的Event实例得到通知。以下是一个具体的Event子类的示例,其中一些观察者订阅了它的通知:

class WriteEvent(Event):
    def __repr__(self):
        return 'WriteEvent'

def log(event):
    print(
        '{!r} was fired with subject "{}"'
        ''.format(event, event.subject)
    )

class AnotherObserver(object):
    def __call__(self, event):
        print(
            "{!r} trigerred {}'s action"
            "".format(event, self.__class__.__name__)
        )

WriteEvent.register(log)
WriteEvent.register(AnotherObserver())

这里是使用WriteEvent.notify()方法触发事件的示例结果:

>>> WriteEvent.notify("something happened")
WriteEvent was fired with subject "something happened"
WriteEvent trigerred AnotherObserver's action

这个实现很简单,只是作为说明目的。要使其完全功能,可以通过以下方式加以增强:

  • 允许开发人员更改事件的顺序

  • 使事件对象携带的信息不仅仅是主题

解耦你的代码是有趣的,观察者是正确的模式。它将你的应用程序组件化,并使其更具可扩展性。如果你想使用现有的工具,可以尝试Blinker(参见pythonhosted.org/blinker/)。它为 Python 对象提供快速简单的对象到对象和广播信号。

访问者

访问者有助于将算法与数据结构分离,其目标与观察者模式类似。它允许扩展给定类的功能,而不改变其代码。但是访问者通过定义一个负责保存数据并将算法推送到其他类(称为Visitors)的类,更进一步。每个访问者专门负责一个算法,并可以在数据上应用它。

这种行为与 MVC 范式非常相似(参见en.wikipedia.org/wiki/Model-view-controller),其中文档是被动容器,通过控制器推送到视图,或者模型包含被控制器改变的数据。

访问者模式是通过在数据类中提供一个入口点来实现的,所有类型的访问者都可以访问。一个通用的描述是一个接受Visitor实例并调用它们的Visitable类,如下图所示:

访问者

Visitable类决定如何调用Visitor类,例如,决定调用哪个方法。例如,负责打印内置类型内容的访问者可以实现visit_TYPENAME()方法,每个这些类型可以在其accept()方法中调用给定的方法:

class VisitableList(list):
    def accept(self, visitor):
        visitor.visit_list(self)

class VisitableDict(dict):
    def accept(self, visitor):
        visitor.visit_dict(self)

class Printer(object):
    def visit_list(self, instance):
        print('list content: {}'.format(instance))

    def visit_dict(self, instance):
        print('dict keys: {}'.format(
            ', '.join(instance.keys()))
        )

这是在下面的例子中所做的:

>>> visitable_list = VisitableList([1, 2, 5])
>>> visitable_list.accept(Printer())
list content: [1, 2, 5]
>>> visitable_dict = VisitableDict({'one': 1, 'two': 2, 'three': 3})
>>> visitable_dict.accept(Printer())
dict keys: two, one, three

但这种模式意味着每个被访问的类都需要有一个accept方法来被访问,这是相当痛苦的。

由于 Python 允许代码内省,一个更好的主意是自动链接访问者和被访问的类:

>>> def visit(visited, visitor):
...     cls = visited.__class__.__name__
...     method_name = 'visit_%s' % cls
...     method = getattr(visitor, method_name, None)
...     if isinstance(method, Callable):
...         method(visited)
...     else:
...         raise AttributeError(
...             "No suitable '{}' method in visitor"
...             "".format(method_name)
...         )
...** 
>>> visit([1,2,3], Printer())
list content: [1, 2, 3]
>>> visit({'one': 1, 'two': 2, 'three': 3}, Printer())
dict keys: two, one, three
>>> visit((1, 2, 3), Printer())
Traceback (most recent call last):
 **File "<input>", line 1, in <module>
 **File "<input>", line 10, in visit
AttributeError: No suitable 'visit_tuple' method in visitor

这种模式在ast模块中以这种方式使用,例如,通过NodeVisitor类调用编译代码树的每个节点的访问者。这是因为 Python 没有像 Haskell 那样的匹配操作符。

另一个例子是一个目录遍历器,根据文件扩展名调用访问者方法:

>>> def visit(directory, visitor):
...     for root, dirs, files in os.walk(directory):
...         for file in files:
...             # foo.txt → .txt
...             ext = os.path.splitext(file)[-1][1:]
...             if hasattr(visitor, ext):
...                 getattr(visitor, ext)(file)
...
>>> class FileReader(object):
...     def pdf(self, filename):
...         print('processing: {}'.format(filename))
...
>>> walker = visit('/Users/tarek/Desktop', FileReader())
processing slides.pdf
processing sholl23.pdf

如果您的应用程序具有多个算法访问的数据结构,则访问者模式将有助于分离关注点。数据容器最好只专注于提供对数据的访问和保存,而不做其他事情。

模板

模板通过定义在子类中实现的抽象步骤来设计通用算法。这种模式使用Liskov 替换原则,由维基百科定义为:

“如果 S 是 T 的子类型,则程序中类型 T 的对象可以替换为类型 S 的对象,而不会改变该程序的任何理想属性。”

换句话说,抽象类可以通过在具体类中实现的步骤来定义算法的工作方式。抽象类还可以为算法提供基本或部分实现,并让开发人员覆盖其部分。例如,queue模块中的Queue类的一些方法可以被覆盖以使其行为变化。

让我们实现一个示例,如下图所示。

模板

Indexer是一个索引器类,它在五个步骤中处理文本,无论使用何种索引技术,这些步骤都是常见的:

  • 文本规范化

  • 文本拆分

  • 停用词去除

  • 词干词

  • 频率

Indexer为处理算法提供了部分实现,但需要在子类中实现_remove_stop_words_stem_wordsBasicIndexer实现了严格的最小值,而LocalIndex使用了停用词文件和词干词数据库。 FastIndexer实现了所有步骤,并可以基于快速索引器(如 Xapian 或 Lucene)。

一个玩具实现可以是:

from collections import Counter

class Indexer:
    def process(self, text):
        text = self._normalize_text(text)
        words = self._split_text(text)
        words = self._remove_stop_words(words)
        stemmed_words = self._stem_words(words)

        return self._frequency(stemmed_words)

    def _normalize_text(self, text):
        return text.lower().strip()

    def _split_text(self, text):
        return text.split()

    def _remove_stop_words(self, words):
        raise NotImplementedError

    def _stem_words(self, words):
        raise NotImplementedError

    def _frequency(self, words):
        return Counter(words)

从那里,BasicIndexer实现可以是:

class BasicIndexer(Indexer):
    _stop_words = {'he', 'she', 'is', 'and', 'or', 'the'}

    def _remove_stop_words(self, words):
        return (
            word for word in words
            if word not in self._stop_words
        )

    def _stem_words(self, words):
        return (
            (
                len(word) > 3 and
                word.rstrip('aeiouy') or
                word
            )
            for word in words
        )

而且,像往常一样,这是前面示例代码的一个使用示例:

>>> indexer = BasicIndexer()
>>> indexer.process("Just like Johnny Flynn said\nThe breath I've taken and the one I must to go on")
Counter({"i'v": 1, 'johnn': 1, 'breath': 1, 'to': 1, 'said': 1, 'go': 1, 'flynn': 1, 'taken': 1, 'on': 1, 'must': 1, 'just': 1, 'one': 1, 'i': 1, 'lik': 1})

应该考虑模板,以便设计可能变化并可以表达为孤立子步骤的算法。这可能是 Python 中最常用的模式,并且不总是需要通过子类实现。例如,许多内置的 Python 函数处理算法问题,接受允许您将部分实现委托给外部实现的参数。例如,“sorted()”函数允许使用后续由排序算法使用的可选key关键字参数。对于在给定集合中查找最小值和最大值的“min()”和“max()”函数也是如此。

总结

设计模式是可重用的,与语言有关的解决方案,用于软件设计中的常见问题。无论使用何种语言,它们都是所有开发人员文化的一部分。

因此,使用给定语言中最常用模式的实现示例是记录的好方法。在网络和其他书籍中,您将很容易找到 GoF 书籍中提到的每个设计模式的实现。这就是为什么我们只集中在 Python 语言上下文中最常见和流行的模式上。

posted @ 2024-05-04 21:30  绝不原创的飞龙  阅读(25)  评论(0编辑  收藏  举报