Daniel-Arbuckle-的精通-Python-全-

Daniel Arbuckle 的精通 Python(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎阅读 Daniel Arbuckle 的 Python 精通。Python 是 C 语言家族的一员,如 C++和 Java;然而,Python 更像是一个远亲,因为 Python 的设计者如果认为有更好的方式,会很乐意以不同的方式做事。因此,如果你熟悉 C 或其衍生语言之一,你会发现 Python 相对熟悉。

本书的目标是帮助您从 Python 新手过渡到能够使用一系列高级技术。与 C、C++和 Java 一样,Python 是每个人都应该精通的语言之一。在我看来,Python 是最佳的全能语言,而且它也非常有趣!

我们将大致从入门级到高级水平进行学习,但大部分章节都是相互独立的。您可以自由地跳到需要学习的内容,或者按顺序完成课程以跟上进度。所以,让我们开始吧。

本书涵盖内容

第一章,Python 入门,是关于 Python 语言语法和语义的快速入门。

第二章,设置环境,是关于安装和使 Python 运行时可用。

第三章,创建包,展示了如何创建 Python 源代码包。

第四章,基本最佳实践,涵盖了包括源代码格式化规则和使用版本控制工具(如虚拟环境中的版本控制)在内的最佳实践。

第五章,创建命令行工具,解释了如何创建一个完整的文本模式实用程序。

第六章,并行处理,展示了如何通过并行处理来提高 CPU 密集型程序的性能。

第七章,协程和异步 I/O,解释了如何使用异步 I/O 来提高 I/O 密集型程序的性能。

第八章,元编程,介绍了多种从我们自己的源代码中程序化控制 Python 语法或语义的不同方法。

第九章,单元测试,讨论了自动化单元测试和测试驱动开发。

第十章,响应式编程,是关于响应式编程和 RxPY 框架。

第十一章,微服务,是关于创建微服务。

第十二章,扩展模块和编译代码,讨论了将 Python 代码与用 C 编写的系统级代码链接。

本书所需条件

您将需要 Ubuntu 16.04 和 Python(版本 3.6)。您也可以在 Windows 和 macOS 上运行代码示例。

您可以选择使用 VirtualBox 来测试书中的代码。

本书面向对象

如果您是程序员并且熟悉 Python 的基础知识,并且想要扩展您的知识库以更好地更快地开发项目,这本书就是为您准备的。即使您不熟悉 Python,我们的书从基础知识开始,带您踏上成为技术专家的旅程。

规范

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名将如下所示:“使用name变量查找存储的值是一个表达式;运行函数也是如此。”

代码块将如下设置:

def example_function(name, radius):
    area = math.pi * radius ** 2
    return "The area of {} is {}" .format(name, area)

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

def example_function(name, radius):
    area = math.pi * radius ** 2
    return "The area of {} is {}" .format(name, area)

任何命令行输入或输出都应如下编写:

python3 example_1_2_3.py

新术语重要词汇将以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,将以如下方式显示:“点击环境变量...”

警告或重要注意事项将以如下框的形式出现。

技巧和窍门将以如下方式显示。

读者反馈

我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。要发送一般反馈,请简单地通过电子邮件发送至feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题领域有专业知识,并且有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 图书的骄傲拥有者,我们有一些东西可以帮助您充分利用您的购买。

下载示例代码

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

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

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

  3. 点击“代码下载与勘误”。

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

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

  6. 从下拉菜单中选择您购买这本书的地方。

  7. 点击“代码下载”。

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

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

该书的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Daniel-Arbuckles-Mastering-Python。我们还有其他丰富的书籍和视频代码包,可在github.com/PacktPublishing/找到。请查看它们!

错误清单

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

盗版

互联网上对版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以追究补救措施。请通过copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问答

如果您在这本书的任何方面遇到问题,您可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。

第一章:Python 入门

在本章中,我们将介绍 Python 的基本语法、其内置数据结构、函数、类、其标准库以及 Python 最新版本的新特性。如果您需要快速掌握这门语言,这里就是我们要去的地方。我们将逐步进行,涵盖以下主题:

  • Python 基本语法和块结构

  • 内置数据结构和推导式

  • 首等函数和类

  • 丰富的标准库

  • Python 的新特性

Python 基本语法和块结构

本节主要提供对 Python 语言结构的初步理解。如果您觉得自己已经对 Python 有了一个坚实的掌握,请随时跳过。

让我们深入到细节。

一个 Python 程序以源代码的形式编写在一个或多个 .py 文件中,并包含如下截图所示的语句和表达式:

图片

语句和表达式都告诉 Python 做某事。区别在于表达式可以组合成更复杂的表达式,而语句可以与表达式组合,但不能与其他语句组合。

例如,一个语句看起来是这样的:

if 2 > 1: 

一个表达式看起来是这样的:

print ("One is the loneliest number") 

Python 源代码文件在 Python 运行时加载后从上到下执行。这意味着对于简单的程序,我们只需在 .py 文件中编写一系列语句,然后告诉 Python 运行它们。在先前的例子中,ifelse 部分是语句或两个部分的单一语句,如果你愿意这样想的话。其余的都是表达式。对于更复杂的程序,我们需要更结构化的方法。

与大多数编程语言一样,Python 允许我们创建函数和类来组织我们的代码。

如果您不知道函数或类是什么,可以将函数视为可以作为更大程序构建块的微型程序,而类则是函数和数据组合以创建新类型数据的组合。

基本构建块

为了组织我们的代码,我们可以将其分为四个基本构建块。我们将分别讨论这些构建块,以了解它们在 Python 代码中的作用和重要性。以下是这些构建块:

  • 函数

  • 变量

  • 表达式

函数

我们将从简要了解函数开始。函数是通过使用 def 语句创建的,这是一个使用 def 关键字作为其标识组件的语句。正如我之前所说的,Python 从 .py 文件顶部开始执行语句,如下面的截图所示:

图片

当 Python 执行一个 def 语句时,它创建一个函数作为结果。这意味着在 def 语句之前运行的代码看不到该函数,因为它还不存在。def 行中括号内的部分称为参数列表

example_function(name, radius): 

参数列表是作为输入传递给函数的数据值的内部名称列表。在函数外部,这些值可能具有不同的名称或根本没有名称,但在内部,它们将存储在这些变量中。

def行之后立即缩进的代码块被称为函数体,你可以将其视为函数的源代码:

def example_function(name, radius): 
    area = math.pi * radius ** 2 
    return "The area of {} is {}" .format(name, area) 

以下截图显示了前面示例的输出:

图片

函数体内的代码是关于从文件顶部到底部运行 Python 代码的规则的例外。这段代码被存储起来,然后在告诉函数运行时执行。

与文件中的代码一样,函数中的代码从上到下逐行或逐个语句或表达式运行。

如果你更熟悉 C++或 Java,你可能想知道函数的参数类型返回类型在哪里。在 Python 中,数据类型是每个数据值固有的,因此运行时始终知道我们正在处理哪种类型的数据,以及我们尝试执行的操作是否对该数据类型是有效的操作。因此,在大多数情况下,我们不需要显式的数据类型。

Python 程序员有时会谈论鸭子类型,这是对以下说法的引用:

如果它像鸭子一样叫,它可能就是一只鸭子。

他们所说的意思是,如果我们试图对一个数据值执行的操作是有效的,那么它是否正好是我们预期的数据类型并不重要。它可能足够接近。如果它们不起作用,Python 会告诉我们出了什么问题以及在哪里,这通常比仅通过比较数据类型所能确定的信息更有用。

对于我们想要或需要指定数据类型的情况,我们可以使用函数注解和标准库的typing模块。

我们将在后续章节中讨论的函数装饰器可以提供一种方便的方式来强制执行这些注解。

变量

Python 程序的第二大构建块被称为变量。变量基本上就是一个用于存储数据值的盒子。变量有一个名称,我们可以使用该名称来访问变量中存储的数据或用新值替换数据。

在前面的例子中,函数参数是变量,area也是如此:

(name, radius):  

要设置存储在变量中的数据,我们使用赋值语句。赋值是一个语句,所以请记住这意味着它不能与其他任何语句组合。它获得一行源代码,以及它包含的表达式。

赋值语句由等号左侧的变量名和右侧我们想要存储在变量中的值组成,如下面的代码所示:

outer = "Hello world"  

如果变量之前不存在,它将被创建。无论变量之前是否存在,值都将存储在变量中。

在函数内部创建的变量只在该函数内部可见,并且每次函数运行时都会创建一个新的。

以下代码提供了一个实际应用的例子:

outer = "Hello world"  
def example_function(param): 
    inner = "Hello function: {}".format(param) 
    print(inner, outer) 
example_function("first") 
example_function("second") 
print(inner) 

前一个示例的最后一条语句表明,在函数内部创建的变量对于函数外部的代码是不存在的,如下面的代码输出所示:

图片

这个代码示例还展示了当我们试图让 Python 做不可能的事情时会发生什么。它告诉我们我们做错了什么,并提供了有关问题发生位置和如何到达那里的信息。

表达式

Python 程序的主要组成部分的第三个是表达式。到目前为止,我们在每个例子中都看到了表达式,因为几乎不可能在 Python 中不使用表达式就做任何事情。

表达式由数据值和在这些数据值上执行的操作组成。非常简单的表达式是一个单一的数据值,没有任何操作,例如,一个单独的数字。更复杂的表达式至少包含一个操作,可能还有更多的数据值,例如,将两个数字相加或计算面积,如下面的代码示例所示:

import math 

def example_function(name: str, radius: float) -> str: 
  area = math.pi * radius ** 2 
  return "The area of {} is {}" .format(name, area)  

print(example_function('Bob', 5)) 

所有的表达式都会产生某种类型的结果数据值;例如,将两个数字相加会产生另一个数字作为和,而将两个文本字符串连接起来会产生另一个文本字符串作为连接。使用name变量来查找存储的值是一个表达式,运行一个函数也是如此。

如果函数没有明确返回一个值,结果将是一个特殊值,称为none

在我们需要值的地方,我们可以使用任何产生所需值的表达式。无论是简单的数字,如55,变量名,值的复杂组合和运算符,函数调用,还是任何其他表达式,这都不重要。至少,从最终结果的角度来看,这并不重要。某些表达式比其他表达式执行时间短,所以速度可能是一个因素。

在本节中,我们将讨论的最后一种基本构建块是。类这个词是类别或类型的同义词;在这种情况下,它指的是数据值。

类通过描述该类型数据值的内部数据和操作来定义一种新的数据值。这主要是通过定义一组构成类的函数来完成的。一个特殊函数__init__用于设置新数据值的内部数据,其余的函数定义了该类型现有数据值的操作:

class Frood: 
    def __init__(self, age): 
        self.age = age 
        print("Frood initialized") 

    def anniversary(self): 
        self.age += 1 
        print("Frood is now {} years old".format(self.age)) 

f1 = Frood(12) 
f2 = Frood(97) 
f1.anniversary() 
f2.anniversary() 
f1.anniversary() 
f2.anniversary() 

类的所有函数都接收一个名为 self 的参数,如前面类代码示例所示。这个参数是正在操作的数据值。这与 C++ 或 Java 不同,因为尽管那些语言基本上做同样的事情,但参数是隐式的,而不是函数参数列表的显式部分。

类函数,包括 __init__,在它们想要操作与之关联的数据值时,应该从 self 中存储和检索数据。

类支持 继承多重继承,但在此书的这一部分我们不会详细讨论。

在前面的例子中,我们创建了一个新的数据类型 Frood,然后创建了两个该类型的数据值。然后,我们使用作为类一部分创建的 anniversary 函数来修改它们。

类的代码示例输出如下:

图片

这两个实例保持它们内部变量不同的值,如前面输出所示。

流程控制语句

Python 有几个流程控制语句,这些语句对于熟悉 C 家族语言的用户来说很熟悉。例如,Python 有循环和 ifelifelse 分支(如下面的代码示例所示):

selector = 5 

if selector < 3: 
    print("less than three") 
elif selector < 6: 
    print("less than six") 
else: 
    print("six or more") 
while selector > 0" 
    print('selector is {}' .format(selector)) 
    selector -=1 

for x in ['a', 'b', 'c', 'd']: 
    print(x) 
for x in range(5): 
    print(x) 

Python 也有 for 循环语句,但它与 C、C++ 或 Java 中的 for 循环不同。for 循环不是通过计数数字,而是遍历值。如果我们实际上想用 for 循环计数数字,那很容易通过 range 迭代器来完成,如下面代码示例的输出截图所示:

图片

在我们结束本节之前,还有最后一件事我应该评论一下,那就是 Python 对表示块结构的 缩进 的看法。

缩进

大多数其他编程语言都有明确的符号来表示块的开始和结束。然而,在所有这些语言中,缩进块是一种常见的做法,以便人类更容易阅读代码。实际上,不这样做通常被视为程序员是业余水平的标志。这意味着大多数语言中的块结构实际上以两种不同的方式表示:符号和缩进。通过将缩进纳入语法而不需要显式符号,Python 既可以消除这种重复,又确保代码可读。

有了这些,我们就结束了本节。在下一节中,我们将探讨一些 Python 的内置数据结构和数据处理语法。

Python 的内置数据结构和推导式

现在,让我们来看看 Python 的核心数据结构类型。当然,这些并不是唯一可用的数据结构,因为使用类创建数据结构相对容易。然而,这些数据结构直接构建在 Python 的核心中,并且效率很高,因此熟悉它们是个好主意。

首先要理解的是,数据结构本身也是类似文件柜的数据值——它们是包含许多事物的一个东西。像任何其他数据值一样,它们可以被存储在变量中或用作表达式的一部分。

字典

我们将要探讨的第一个数据结构是 Python 的字典。字典由任意数量的键值对组成。键可以用来获取或设置值,或者从字典中完全删除这个键值对。

其他语言中的类似数据结构有时被称为映射或哈希表。

在 Python 中创建字典有几种方法。最简单的是使用字典表达式,它就是一对花括号,包围着我们想要在字典中包含的键值对。每个键值对由键和值之间的冒号标记,每个键值对由逗号分隔,如下面的代码示例所示:

example_dict = {'a' :1, 'b' :2, 'c' :3} 

当这个表达式运行时,结果是包含键及其值的字典对象。我们也可以使用dict类来创建字典对象:

another_dict = dict() 

如果我们不想使用特殊语法来访问字典中存储的某个值,我们可以使用查找表达式。这意味着我们在给出字典的表达式之后放置一对方括号,包含我们想要查找的键。通常情况下,这意味着包含字典的变量的名称,一个开方括号,一个给出键的子表达式,然后是一个闭方括号:

example_dict['b'] 
2 

如果我们更喜欢不使用特殊语法,我们也可以使用dict.get函数:

example_dict.get('c') 
3 

列表

我们接下来要探讨的数据类型是列表,它可以通过列表表达式来创建。列表表达式就是一对方括号,包围着我们想要存储在列表中的数据值,每个值之间用逗号分隔。并不要求每个值必须是同一类型。以下是一个列表的代码示例:

图片

在前面的例子中,它们是字符串,但它们可以是数字,或者是一个列表,或者任何其他类型的数据混合在一起。我们可以使用查找表达式来检索数据值。

与字典不同,列表的键是整数。这是因为列表不是将键值与数据值关联,而是按顺序存储其数据值。列表中第一个项目的键是0。下一个项目的键是1,依此类推。我们也可以使用负整数作为键。我们仍然可以得到一个数据值,但它是从列表的末尾而不是从开头计算的,其中-1是列表中的最后一个项目。

我们可以使用list.append函数在列表末尾添加新项目,或使用其insert函数在任何位置添加新项目,如下面的代码所示:

图片

列表会自动增长到足够大,以容纳我们放入其中的所有数据。

元组

我们将要查看的下一个数据结构是元组。元组表达式是任何由逗号分隔的值表达式序列,如果它出现在语言原本不期望看到逗号的位置。

然而,将括号放在大多数元组表达式周围是常见且明智的,因为它避免了歧义。元组的代码示例如下:

图片

与列表一样,可以使用数字从元组中检索数据值。然而,我们无法向元组添加更多数据,也无法用另一个数据值替换一个数据值。

我们为什么要想要这样的数据结构呢?

好吧,有几个原因。我们可以如下列出:

  • 首先,因为它们是常量,元组可以作为良好的字典键或集合成员,但我们会稍后再讨论这一点。

  • 第二,它们在概念上扮演着与列表不同的角色。我们倾向于期望列表的每个成员都是相同类型的,例如名字列表或年龄列表。从某种意义上说,列表就像数据库的列。我们倾向于期望元组的每个元素包含不同类型的数据,但它们彼此之间是相关的,例如第一个元素是名字,第二个元素是年龄。继续我们的类比,元组就像数据库的行。

  • 第三,元组在计算机处理时通常在时间和内存使用上稍微高效一些。因此,在优化情况下,当它们足以完成任务时,它们比列表更可取。

集合

我们将要查看的最后一个数据结构是集合。集合是一组没有键的数据值;就像列表,但没有特定的顺序,就像字典。我们可以使用集合表达式创建一个集合,它是一对花括号包围的逗号分隔的值,如下面的代码示例所示:

图片

在集合中定位特定值很快,添加或删除值也是如此,如下面的示例所示:

图片

每个值只能出现在集合中一次。集合支持一系列数学运算,如并集和交集,并且通常比一开始看起来更有用,尽管我们在这里的章节中无法真正证明这一点。

推导式

Python 有一种特殊的表达式,称为推导式。推导式是创建字典、列表和集合的特殊语法的变体。

让我们看看一些例子。这里我们看到一个列表推导式:

capitals = [x.upper() for x in example_list] 

这个表达式的作用是创建一个新列表,包含旧列表中单词的大写版本。

开方括号后的第一部分是一个x.upper()表达式。这个表达式描述了如何从旧列表的成员推导出新列表的成员。之后是for关键字,然后是我们在第一个表达式中使用的x变量的名称。然后,关键字后面跟着example_list表达式,它给出了旧列表,最后是闭方括号。代码输出如下:

字典和集合推导式非常相似。如果我们想在推导式中使用现有字典的键和值,我们需要使用dict.items函数,并且字典推导式需要指定键和值,用冒号分隔,如本例所示:

squares = {k: v ** 2 for k, v in example_dict.items()} 

如以下截图所示,请注意,结果数据类型取决于我们使用了哪种推导式,而不是我们使用了哪种数据结构作为数据源:

我们可以使用列表推导式从字典的值创建数据列表,例如,或者,就像我们在这里做的那样,我们可以使用集合推导式创建集合。

元组略有不同,但只是略有不同。元组推导式看起来就像一个名为生成器表达式的不同语法元素。元组推导式的代码示例如下:

Python 的设计者讨厌歧义;因此,如果我们想要元组推导式的等效物,我们就将生成器表达式传递给元组构造函数。

这就是本快速介绍 Python 内置数据结构的全部内容。在下一节中,我们将探讨函数和类的一些有用但可能令人惊讶的特性,这些特性与 C、C++或 Java 有显著不同。

一等函数和类

在 Python 中,函数和类是一等对象。短语一等对象是一种说法,意味着数据值可以被程序访问、修改、存储和在其他方面被操作。在 Python 中,函数就像文本字符串一样,是一个数据值。类也是如此。

当一个函数定义语句被执行时,它会将生成的函数存储在一个变量中,该变量的名称是在 def 语句中指定的,如下面的截图所示:

图片

这个变量并不特殊;它就像任何其他持有值的变量一样。这意味着我们可以在表达式中使用它,将值赋给其他值,或者甚至用不同的值替换原始函数。

函数值本身包含相当多的属性变量,我们可以访问它们。更有用的情况是,大多数时候,我们可以向 function 对象添加属性,这样我们就可以将有关函数的定制信息作为函数的一部分存储,并在以后访问这些信息,如下面的代码示例所示:

图片

首类函数使一个常见任务变得简单,那就是为事件分配处理程序。要在 Python 中将 handler 函数绑定到事件,我们只需在调用 binding 函数时将 function 对象作为参数传递,如下所示:

图片

这比 C++ 或 Java 强加给我们的类似操作要灵活得多。作为函数定义语句,类定义语句创建一个类对象并将其存储在一个变量中。这可能会让人一开始感到困惑。类描述了对象的类型,它们怎么能是对象本身呢?

想象一下——一栋房子的蓝图描述了建筑类型,但蓝图本身仍然是一个东西,对吧?类对象也是这样。这意味着,就像函数对象一样,类对象可以被存储在变量中,并且可以像数据值一样被处理。最有趣的是,它们可以用作函数调用的参数。

defaultdict

作为为什么这有趣的例子,考虑一下——Python 的标准库包含一个名为 defaultdict 的数据结构类,它就像字典一样,但是当我们尝试查找字典中尚未存在的键时,它会创建一个新的值并将其添加到字典中,然后再将其返回给尝试查找的代码,如下所示:

图片

defaultdict 类是如何知道如何创建默认值的?

defaultdict 类之所以知道如何操作,是因为我们在创建 defaultdict 类时给它传递了 class 作为参数。因此,如果我们想要一个列表的字典,我们可以给 defaultdict 类传递列表类,作为其 如何设置默认值 参数。顺便提一下,defaultdict 也可以与函数一起工作,作为其 如何设置默认值 参数。

defaultdict类实际上并不关心这个参数是什么,只要我们传递的对象能够在defaultdict类需要新默认值时创建一个新对象。这是我们在上一节中提到的鸭子类型的一个例子。参数是函数、类还是其他任何东西,只要它表现正常即可。如果它表现不正常,我们会被告知出了什么问题以及在哪里。

属性

我们之前讨论过,我们可以向函数对象添加属性,这通常很有用。我们也可以用类似的方法处理类,但有一个很大的区别——我们添加到函数中的属性只能被访问该函数对象的代码看到,通常不包括函数本身的代码,但我们添加到类对象中的属性则可以被任何访问类对象或由该类描述的对象类型的代码看到。

这意味着如果我们向类添加一个属性,该类中定义的函数将能够通过self参数访问该属性,如下面的代码示例所示:

图片

在向类添加属性时,我们需要小心,因为我们可能会不小心覆盖类的一个属性,从而破坏类。

我们对类的操作能力比函数要强。因此,我们需要更谨慎地使用这种能力。此外,请注意,在这个例子中,我们添加到类中的一个属性是一个函数,该函数随后开始像它从一开始就被定义为类的一部分那样工作。

接下来,让我们简要地浏览一下 Python 标准库的一些亮点。

标准库

预先安装在 Python 中的代码库非常广泛,所以我们不会深入细节。这里的目的是让我们了解我们可用的优质工具的广度,这样如果将来需要它们,我们就知道去哪里找。因此,我们将简要地触及许多有用的东西。您可以在docs.python.org/3/library/index.html找到标准库的官方文档。

不同类型的包

索引页面包含了一个列表,列出了 Python 标准库中可用的不同包。让我们简要地按顺序浏览它们。

首先,有Collections包,它包含更多的数据结构:docs.python.org/3/library/collections.html

Collections 包包含我们在上一节中提到的 defaultdict 类。Collections 包还包含一个 OrderedDict 参数,它记录了项目插入的顺序,并在迭代时以相同的顺序返回它们。deque 类是元组的变体,使用名称来访问元素,还有一个 PseudoDict 参数,它提供了对几个其他字典的复合视图。

其中还有一些其他的数据结构。从集合包中缺失的一个常见数据结构是 PriorityQueue 参数,但这仅仅是因为它有一个自己的包叫做 heapq

docs.python.org/3/library/heapq.html

Python 的 PriorityQueue 操作是通过与内置列表一起工作的函数实现的,这些函数根据 属性添加和删除项目。

存储和检索数据是程序的一个极其常见的需求,pickle 包使得这变得很容易:

docs.python.org/3/library/pickle.html

pickle 包中包含了一些类和函数,它们可以方便地将任意 Python 数据转换为可以存储在文件中、通过网络发送或满足其他需求的字节序列。pickle 包还提供了工具来逆转这个过程,将那些字节转换回完整的 Python 数据对象。

此外,在存储数据方面,sqlite3 包提供了对 SQLite 数据库管理器的完全访问,使我们能够利用完整的交易性关系数据库:

docs.python.org/3/library/sqlite.html

访问其他数据库系统的第三方包遵循几乎相同的接口,因此如果需要,很容易切换到不同的数据库。

json 包也与数据处理相关。它解析或生成事实上的标准 互联网数据交换IDX)格式:

docs.python.org/3/library/json.html

json 包非常智能,因此它以合理的方式处理 JSONJavaScript 对象表示法)对象、数组、字符串、数字、null 值等。

将它们映射到适当的 Python 数据类型,base64 包将字节编码为 base64,或将 base64 解码为字节:

docs.python.org/3/library/base64.html

还有几个类似的包用于 binhexuu 编码等。

htmlxml 包提供了处理主要互联网标记语言的各种实用工具,包括解析器和文档对象模型:

docs.python.org/3/library/html.html

urllib 包为我们提供了方便的方式来从 URL 获取数据或向其发送数据:

docs.python.org/3/library/urllib.html

特别是,urllib.request.url 打开函数非常实用。

itertoolsfunctools 包提供了一系列与函数式编程范式相关的实用工具:

docs.python.org/3/library/itertools.html

特别是,functools 包允许我们创建部分应用函数,而 itertools 包则允许我们连接迭代器。

enum 包包含创建和使用命名枚举的支持:

docs.python.org/3/library/enum.html

每个枚举都是一个独立的数据类型,就像一个类。

pathlib 包包含提供跨平台文件和文件路径操作抽象的类和函数:

docs.python.org/3/library/pathlib.html

inspect 包非常有趣且非常有用。它为我们提供了可以用来收集关于数据对象信息的函数,尤其是关于函数和类的信息。如果我们想了解函数、参数的名称,或者想访问对象的文档,或者做任何类似的事情,inspect 包将帮助我们实现这些功能:

docs.python.org/3/library/inspect.html

我们刚才提到的包绝不是标准库中所有可用内容的完整列表,但希望它们能让我们对通过安装 Python 所获得的深度和广度有一个大致的了解。强烈建议想要充分利用 Python 的人查看www.python.org/上的库文档。有一些特别有用的包我们没有提到,那是因为在本书的后面部分有专门的部分介绍它们。

因此,这就带我们结束了对标准库的概述。

现代 Python 的新特性

在本节中,我们将探讨 Python 最新版本中的一些变化,特别是我们将关注以下内容:

  • 语法上的变化

  • 包的变化

  • 其他变化

让我们开始吧!

语法上的变化

自从 3.5 版本以来,Python 有了三组新的语法版本。这些组中的第一组是引入了用于描述协程的关键字。Python 已经支持协程,但关键字使得事情更加清晰,有时也更加简单。我们将在后面的章节中深入讨论协程,所以现在不会进一步讨论这个问题。

新语法中的第二部分是引入@符号作为中缀二元运算符。这意味着现在在两个子表达式之间放置一个@符号是一个有效的 Python 表达式,就像在子表达式之间放置一个+符号一样,如下截图所示:

然而,由于尚无内置数据类型支持@符号运算符,我们在这本书中不太可能找到很多用途。@符号的预期语义是它应该代表矩阵乘法,并且它被添加以提高实现矩阵和矩阵运算的第三方包之间的互操作性。

新语法的第三部分是对 Python 现有语法进行扩展,以便在调用函数时提供参数值。

以前,可以在值列表之前放置一个星号(*)来表示这些值应按列表中出现的顺序分配给参数。以下是一个单个星号的代码示例:

类似地,在两个值之前使用*表示字典中具有文本字符串键的值应按名称分配给函数的参数,如下所示:

新语法只是我们现在可以使用多个列表或字典以这种方式使用,并且我们可以使用相同的星号和双星号语法来构建元组、列表、字典和集合。

我们之前提到,虽然 Python 将数据类型附加到数据值而不是变量上,但可以使用函数注解来描述函数参数返回值的预期类型。

包中的更改

Python 现在在标准库中包含了一个名为typing的包,其中包含支持使用类型提示的类和函数。

Python 还在标准库中包含了一个名为zipapp的包。

要进行打字,请访问以下网站:

docs.python.org/3/library/typing.html 要了解zipapp,请访问此网站:

docs.python.org/3/library/zipapp.html

zipapp包使得构建.pyz文件变得简单。.pyz文件是一个包含 Python 代码和任意只读数据的存档文件,Python 运行时能够将其作为一个自包含程序执行。一旦程序调试完成并准备分发,将其打包成.pyz文件是将程序提供给用户的一种简单而智能的方式。

Python 包中的其他更改

自 Python 3.5 版本以来,Python 进行了一些底层改进,例如更快地读取filesystem目录、自动重试中断的操作系统调用以及一个math.isclose函数,用于检查两个数字是否近似相等。

除了对标准库进行的一些更多细微的改进,这些改进与早期的 Python 3 版本完全兼容。

在极少数情况下,如果添加的内容破坏了向后兼容性,则默认情况下不会启用。对于此类更改,如果我们想使用它,我们必须明确标记我们的代码以支持该更改。这些更改将在两个版本之后成为标准,因此 Python 3.5 中的破坏性更改将不会在 Python 3.7 中成为默认设置,Python 3.5 和 3.6 在遇到依赖于更改功能的代码时会发出警告。

在 Python 3.5 中,只有一个这样的更改——迭代协议的一个小而聪明的修改。它不应该对正常工作的代码有任何影响,但从技术上讲,它是一个接口的更改,因此它得到了完整的等待两个版本的处理。

如果您想了解更多关于我提到的这些更改的详细信息,或者如果您想了解 Python 各个版本之间的变化,docs.python.org/3/ 上的文档总是包含一份“新特性”文档,该文档详细介绍了新特性并提供链接到完整文档。

关于 Python 3.6 的“新特性”文档的详细信息,请访问以下链接:

docs.python.org/3/whatsnew/3.6.html

我总是期待阅读 Python 每个版本的“新特性”文档,以了解我刚刚获得的新玩具。

因此,我们现在已经从高层次上了解了 Python 标准库,介绍了其中一些更有用的项目。这标志着我们 Python 入门课程的结束。

摘要

在本章中,我们探讨了 Python 编程语言的一些基础知识。我们看到了如何创建和访问这些数据结构,以及如何使用推导式根据现有数据结构创建和转换数据结构。

我们简要地探讨了 Python 具有第一类函数和类意味着什么,以及这如何影响我们作为程序员的可能选择。

我们简要地讨论了 Python 标准库的一些亮点。我们还快速介绍了 Python 编程语言的语法、基本假设和基本工具。

在下一章中,我们将了解如何设置 Python 编程环境,以便我们在课程剩余部分进行工作,并学习如何集成第三方代码。

第二章:设置环境

在上一章中,我们简要地游览了 Python 编程语言。在这一章中,我们将探讨各种下载和安装正确版本的 Python 的方法,然后我们将看到如何运行 Python 代码。在本章的最后几节中,我们将看到如何开始利用互联网上广泛可用的各种 Python 代码。

本章涵盖的主题如下:

  • 下载和安装 Python

  • 使用命令行和交互式外壳

  • 使用 pip 安装包

  • 在 Python 包索引中查找包

下载和安装 Python

本节的主题是下载和安装 Python 运行时和标准库。为此,让我们首先查看下载页面,www.python.org/,这是当然的,是找到 Python 的权威地方。你将了解一些关于存在哪些 Python 版本以及我们应该为这本书选择哪个版本的信息。然后,我们将继续了解如何为这本书设置 Python。最后,我们将检查一切是否按预期工作。

在我们真正开始使用 Python 之前,我们需要确保我们已经正确安装了语言解释器和库。为此,第一步是决定要安装哪个版本的 Python。

选择合适的版本

目前有两种常见的 Python 版本在使用。其中之一是 Python 2.7,它是 Python 2 系列的最终版本。Python 社区承诺将独立维护 2.7 版本,使其成为开发的一个非常稳定的靶子。另一个常见的版本是 Python 3,在撰写本书时,它是 3.6 版本。

Python 3 是 Python 社区进行创新的地方。发布版本始终与早期 3 版本的版本向后兼容,但会定期添加新的令人兴奋的功能。在版本 3 的转换过程中,库中的语言在细微之处发生了变化,正如你在以下两个代码片段的比较中可以看到:

图片

在前面的屏幕截图中,左侧是 Python 2,右侧你看到的是 Python 3 的等效代码。它们几乎相同,但有一些差异,例如括号的放置、几个关键字以及与标准库略有不同的结构。你可以自由选择你自己的项目想要的任何版本或版本,但在这本书中,我们将使用 Python 版本 3。

现在我们已经选择了版本,让我们来安装它。

安装 Python

如果你使用 Windows 或 Mac,可以直接从 Python 网站下载安装程序(www.python.org/downloads/)。选择适合你电脑的安装程序,下载并运行它。我们还有选择下载源代码、编译它并通过这种方式安装 Python 的选项。

Unix 和 Linux 用户,以及喜欢它们的 Mac 用户,可以选择通过他们的包管理器安装 Python。对于集成了包管理器的系统,这可能是最好和最简单的方法。如果我们使用包管理器,这部分可能已经完成,否则我们需要确保 Python 程序可以从命令行运行。

在 macOS 和类 Unix 操作系统中,我们只需要在我们的主目录中的配置文件或bashrc文件中添加一行:

  • macOS X(编辑~/.profile):
    export  PATH=<pydir>:$PATH

  • Unix/Linux(编辑~/.bashrc):
    export PATH=<pydir>:$PATH

  • Windows:
  1. 在控制面板中打开高级系统设置。

  2. 点击环境变量...

  3. 编辑 PATH。

  4. 在末尾添加;<pydir>

Windows 的设置稍微复杂一些,因为你需要打开控制面板并找到环境变量屏幕。在每个先前的例子中,pydir是你安装 Python 的目录——例如C:\python36

一旦我们设置了路径环境变量,我们就应该可以开始了。为了检查这一点,请打开终端窗口(Windows 上的命令提示符)并输入Python,然后按Enter。如果你不知道如何打开终端,不要担心,我们将在下一章中更详细地讨论这个问题。

此外,如果你是 Unix 用户并且没有得到正确的结果,那可能是因为bashrc文件或配置文件还没有执行。你可能需要注销并重新登录。

如果我们在终端中输入python时 Python 交互式 shell 启动了,那么我们就准备好了。如果没有,请返回检查我们修改的路径环境变量,因为这是告诉操作系统在哪里查找程序的部件。

设置工作就到这里了。

如果你感到好奇,可以尝试使用我们刚刚启动的交互式 shell 进行实验。试着输入数学表达式,看看会发生什么。在下一节中,我们将更详细地探讨如何使用命令行和交互式 shell 运行 Python 代码。

使用命令行和交互式 shell

既然我们已经看了如何安装 Python,那么让我们尝试使用文本界面让 Python 真正做一些事情。

基于文本的用户界面对程序员非常有用;它们提供了在开发过程中快速轻松地与程序交互、实验代码(毕竟,代码是文本)以及访问文档的便捷方式。

打开命令行窗口

打开命令行窗口的方法取决于你使用的操作系统。

  • 在 Windows 7 中,打开开始菜单,在运行框中输入CMD

  • 在 Windows 8 上,按 Windows 键,然后输入 CMD 并选择命令提示符。

  • 在 Windows 10 上,按 Windows 键并选择命令提示符。

  • 在 macOS 上,导航到应用程序 | 工具 | 终端。

  • 在 Linux 或其他类 Unix 操作系统上,打开命令行窗口的精确机制各不相同,但它们都有这个能力;寻找 xterm终端shell 等词语。

Python 交互式外壳

现在我们已经打开了一个命令行窗口,我们将直接进入 Python 交互式外壳。我们通过在命令行窗口中输入 python 来做到这一点。

如果你安装了多个版本的 Python,并且我们想要与指定版本进行交互,我们可以在命令行中通过输入该版本名称来显式选择版本。例如,如果我们输入 python3,我们将显式启动某个 Python 3.X 版本:

图片

python3 命令用于 Linux 用户。Windows 用户应该输入 python 命令行来工作。

现在,真正的乐趣才刚刚开始!

当我们看到 >>> 提示符时,我们可以输入任何 Python 表达式或语句并立即看到结果(如下面的代码示例截图所示):

图片

这非常实用,因为它意味着我们不必记住函数如何工作的每一个细节,类成员如何被调用,在什么情况下会引发哪些异常等等。当我们对某事不确定时,我们只需打开一个交互式外壳并查找答案。所以,让我们用一个简单的例子来讨论这个问题。

让我们假设我们正在开发一个使用 Python 的 set 数据类型的应用程序,并且我们不确定会引发什么异常。当我们尝试将一个集合添加到自身时,我们可能需要查阅文档,但直接在交互式外壳中创建一个集合并尝试将其添加到自身会更快捷、更简单:

图片

立即,系统告诉我们将一个集合添加到自身会引发 TypeError 异常。有时,在交互式外壳中快速进行实验是我们获取所需信息的最快方式,但文档也很不错。

幸运的是,Python 有一个非常好的文档系统,我们可以通过调用 help 函数直接从交互式外壳中访问它。我们可以将任何对象作为 help 函数的参数传递,它将为我们打印出该对象的文档。所以,如果我们想了解 functools.wraps,我们只需使用以下两个命令将其传递给 help 并阅读所有相关信息(参考以下截图):

import functools 
help(functools.wraps) 

图片

help 函数也可以使用以下代码代替对象本身来读取对象的名称:

help('collections.defaultdict') 

这种格式可以节省我们在交互式外壳中输入 import 语句的时间:

图片

不同之处在于help参数是一个字符串,而不是一个评估为我们感兴趣的对象的表达式。

使用 pip 安装包

在本节中,我们将探讨使用 Python 包管理器来安装和管理第三方代码,现在,我们将回到操作系统命令行。我们将看到如何轻松地从 Python 包索引中安装第三方代码。

虽然 Python 自带了电池(即已经安装的标准库包含了许多非常有用的功能),但仍然有许多事情它做不到。不过,很可能某个人在某处已经为我们发明了轮子,如果这样的话,我们很可能在 Python 包索引中找到它。

包的 pip 工具

从 Python 3.4 版本开始,Python 通过名为pip的工具进行安装,它可以与 Python 包索引接口,自动查找、下载和安装 Python 包。如果你已经知道你想要安装的包名,并且你有权限写入 Python 的库目录,那么这个相对简单的命令就可以将其完全安装并准备好使用。

在这种情况下,我们安装了一个名为banknumber的包,该包检查某个人的银行账号是否是有效的银行账号或只是一个随机数。为此,只需添加python3 -m pip install banknumber命令并按Enter;我们就会得到以下截图所示的信息:

图片

如果我们没有权限访问 Python 的库目录,不必担心。Python 会寻找第二个用户特定的库目录,由于那个库目录属于我们,我们总是能够在那里安装包。

要告诉 pip 我们将要安装到个人库目录中,只需在install命令后直接添加--user即可。在下面的截图里,我们正在将requests包安装到我们的个人目录中:

图片

管理已安装的包

pip工具不仅能安装包,还能提供以下功能:

  • 使用-m pip list命令查看当前已安装的包列表:

图片

  • 使用-m pip install --upgrade命令升级当前已安装的包到最新版本:

图片

  • 使用-m pip uninstall命令卸载我们不再需要的包。例如,如果我们想卸载banknumber包,我们可以通过以下命令来完成,如截图所示:

图片

简而言之,这是一个适用于 Python 包的完整跨平台管理工具。

一些 Python 包要求我们能够编译用 C 编程语言编写的扩展才能安装,但幸运的是这种情况越来越少了。通常,如果需要编译的扩展,pip 将能够自动找到并安装适当的预编译版本。大多数可用的包都是纯 Python,不需要编译。

pip 工具有许多更多优秀的选项和命令行开关,但我们迄今为止所看到的已经很好地覆盖了常见情况。如果您想进一步了解,pip 的 help 命令将提供详细信息。例如,考虑以下命令:

pip help install  

上述命令打印出有关 pip install 选项的所有可能信息:

图片

那么,既然我们已经知道了如何使用 pip 安装第三方包,我们该如何去寻找最初要安装的包呢?

在 Python 包索引中查找包

之前,我们讨论了从 Python 包索引中安装包,但如果我们没有需要安装的特定包怎么办?如果我们只需要一个库来帮助我们完成任务,但不知道具体需要哪个呢?嗯,正如本节标题所暗示的,Python 包索引实际上是一个包索引,它根据多个参数对包进行分类。索引方便地托管在 pypi.python.org/pypi。我们可以通过多种方式搜索可用的包。让我们详细讨论一下。

使用关键字

可能,访问索引最有用的方法就是简单地输入关键字到搜索框,看看它会输出什么。

如果我们要求搜索 asyncio,我们会得到一系列与 asyncio 有关的包名。当然,这些名称是链接到每个包在索引中的详细描述,我们可以使用这些描述来决定哪个包最适合我们的需求。

有另一种访问索引的方法,这种方法通常与关键字搜索一样有用,有时甚至更有用。

使用包索引

Python 包索引支持通过类别浏览其包索引

您可以通过点击菜单中的“浏览包”链接开始浏览,这将带您到不同类别的列表。从那里,您可以点击它们来选择一个或多个类别,如果列表足够短,您将看到属于您所选所有类别的包列表。

如果您没有获得包列表,那是因为列表会如此之长,以至于对您没有任何帮助,您应该选择更多类别来缩小范围。如果在您选择的过程中某个类别从列表中消失,这意味着没有任何包能够在您所选的所有类别中运行。

Python 不仅是用英语编写的或被喜欢这种语言的人使用,而且一些包也提供了对其他语言的良好支持。这个列表是找到它们的好方法,而关键字搜索可能无法捕捉到这种细节。

使用 pip 搜索包索引

如果我们不希望打开浏览器去搜索索引,我们也可以通过命令行使用 pip 来完成,如下面的命令所示:

$ python3 -m pip search asyncio   

此命令执行了我们在网页界面中之前所做的相同搜索:

图片

以这种方式操作通常更快;但如您在先前的截图中所见,它只提供了每个包的名称和简要描述。这对于快速提醒包名称非常完美,但对于更深入的研究则不太适用。

Python 包索引的合法性和许可证

最后,关于合法性和许可证的简要说明!

Python 包索引中的绝大多数包都受到开源机构(Open Source InstituteOSI)认证的开源许可证的约束。这意味着基本上它们可以自由地作为其他开源项目的一部分被使用和分发。

在大多数情况下,许可证比这更宽松,允许我们将软件作为我们项目的一部分使用,即使我们不开源我们的代码。

大多数并不意味着全部,然而一些包不在 OSI 认证的许可证之下,而一些 OSI 许可证的包在封闭源代码项目中不可用。

因此,如果您打算分发您的软件,请花点时间确保许可证与您的目标一致。

摘要

在本章中,我们学习了如何安装 Python 并到达一个可以开始编写真实代码的位置。我们探讨了如何在命令行窗口中运行 Python 以及如何用它来进行实验和计算。我们还研究了如何充分利用 Python 的命令行及其广泛的 help 库。

我们学习了如何使用 pip 安装、卸载和升级包。我们还对如何通过 Python 包索引找到第三方代码以帮助我们推进项目有了相当的了解。

在下一章中,我们将逐步学习如何创建和使用我们自己的 Python 代码包。

第三章:创建包

在上一章中,我们看到了如何安装 Python 和我们可以与 Python 一起使用的第三方代码包。在本章中,我们将看到包在计算机文件系统中的表示。我们将探讨如何在包内部添加代码模块,如何使这些代码模块在包内部相互交互,以及如何访问包含在我们包中的非代码文件中的数据。

到本章结束时,你将很好地了解如何创建自己的 Python 代码包。包将成为程序的基础,并帮助你使代码模块化。

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

  • 创建一个空包

  • 向包中添加模块

  • 从其他模块访问代码

  • 向包中添加静态数据文件

创建一个空包

本章的第一部分将处理创建一个简单的空包,目前它不会做任何事情,但当我们完成时,我们将能够将空包导入 Python shell。

简单的 Python 项目可能只包含一个代码模块,但通常是将多个模块组合成一个包。一个包可以包含我们需要的任何数量的模块。包从文件系统上的文件夹开始,这意味着我们可以像创建任何其他文件夹一样创建它们。

如果你更喜欢使用操作系统的文件浏览器来创建文件夹,那也行,但我通常使用命令行。例如,让我们运行一个演示包:

$ mkdir demopackage 

这在以下屏幕截图中有显示:

图片

将普通文件夹变成包

有两个东西将普通文件夹变成包。以下是对它们的解释:

第一点是它在哪,即文件夹的位置。Python 只在特定位置查找包,如果你的文件夹不在正确的位置,Python 将不会注意到它。

sys.path变量包含 Python 将查找包的所有位置列表。sys.path变量相当稀疏,但用户配置可以使它变得更为广泛,如下面的屏幕截图所示:

图片

注意列表中的第一个条目是一个空字符串。这代表当前工作目录。

对于使用命令行的人来说,当前工作目录只是我们当前所在的文件夹。

我们可以使用cd命令来更改当前工作目录:

cd demopackage
cd ..  

上一段代码中的cd..命令表示返回到上一个目录或父目录。因此,在这种情况下,我进入了demopackage并退了出来。

当前工作目录在路径中对于开发来说很方便;这意味着我们可以将当前工作目录设置为我们进行开发的地方,这样我们的所有包都可用,至少在我们也使用命令行启动 Python 时是这样。

将普通文件夹转换为包的第二个原因是存在 __init__.py 文件,尽管从 Python 3.3 及更高版本开始,这并非严格必要。一个 init 文件将文件夹标记为包,这使得它加载更高效,同时也为我们提供了一个放置与包整体接口相关的信息和代码的地方。

虽然 __init__.py 文件通常是空的,仅作为标记,但有一个语言特性,除非我们在文件中添加一些代码,否则将不会得到支持。

这个特性是能够使用 import* 语法导入包的所有模块,如下所示:

图片

导入所有包模块

如果我们想让 Python 能够使用 import* 语法导入所有包的模块,我们必须告诉它所有这些模块的名称。

要这样做,我们需要将模块名称添加到名为 __all__ 的列表中,如以下代码所示:

图片

如果你系统上没有安装 emacs,你可以使用以下命令进行安装:

**sudo apt install emacs24** 在前面的屏幕截图中,我使用了 Ubuntu,因此编辑器的背景是白色的,然而在 Windows OS 和 macOS 的情况下,编辑器的背景可能不同。

你可能想知道为什么我们需要手动执行此操作,而不是让 Python 直接扫描文件系统以查找模块文件。

好吧,有几个原因:

  • 首先,Python 尽量不假设文件名是否区分大小写。在某些操作系统上,文件名是区分大小写的,而在其他操作系统上则不是。模块名是变量,因此它们最好在源代码中起源,而不是依赖于可能根据代码运行的位置而改变的外部因素。

  • 手动执行此操作的第二原因是导入模块会使代码执行。

想象我们有一个播放音轨的包。除了通用代码之外,我们还有许多处理各种系统音频输出的模块。

允许用户使用 import* 将他们的包编程接口导入到模块中是相当合理的;然而,我们不想加载所有输出模块,只想加载适合我们正在运行的系统的那个模块。尝试加载其他任何模块很可能会在用户的代码中触发异常。现在 __all__ 的工作方式,我们可以排除输出模块从 import* 中,从而实现两全其美。

好的,在我们继续下一部分之前,即如何将源代码模块添加到包中之前,让我们确保 Python 愿意导入我们的演示包。

添加模块到包

现在,让我们看看如何将实际代码添加到包中,并注意一些需要避免的陷阱。

Python 模块的名称与它们作为文件名所对应的对象名称相同,只是没有.py后缀。这意味着文件名需要是有效的 Python 变量名,并且它们应该使用在不同操作系统上可靠可用的字母和符号。以下截图展示了这一示例:

图片

因此,模块名称不应以数字开头,因为 Python 变量不允许以数字开头。也不应使用大写字母,因为一些常见的操作系统不区分包含大写字母的文件名和全部小写的文件名。只要我们遵守 Python 变量命名指南并记得使用.py后缀,我们就可以随意命名我们的模块。

因此,我们只需选择一个文件名,然后在package文件夹中以该名称创建一个文件,并将 Python 代码写入其中。这种简单场景也是常见情况,但还有一种可能性。

使用命名空间包进行模块加载

如前所述,从 Python 3.3 开始,可以有一个不包含init文件的package文件夹。省略init文件意味着我们无法支持import*或其他我们将在后续发现其中的技巧。但不仅如此。以下截图展示了这一示例的代码:

图片

当缺少init文件时,文件夹成为namespace package文件夹的一部分。当 Python 导入时,它会将找到的所有具有相同名称的namespace package文件夹组合成一个逻辑包,如下面的截图所示:

图片

这种行为意味着在选择模块文件名时,Python 仍然遵循相同的规则,我们可能将文件放置在任何数量的namespace package文件夹中,而不是单一的实体package文件夹中。

我们能从中得到什么?通常什么也没有!

如我之前提到的,带有init的包加载速度更快,在许多情况下,命名空间包的额外抽象并没有给我们带来任何好处。

然而,在某些情况下,我们希望同一包的不同部分被分别分发或管理,在这种情况下,namespace packages可以满足这一需求。例如,再次想象我们正在为一个播放音轨的包工作。如果我们为音频编解码器创建一个namespace package文件夹,每个编解码器都可以使用pip或操作系统的常规包管理工具单独安装和删除。

在一个稍微不同的话题上,现在让我们谈谈包的结构和它应该为外部代码使用提供的接口之间的区别。

包结构及接口

为了方便和保持我们作为包开发者的理智,通常最好是将包中的代码拆分成许多不同的模块,所有这些模块都包含一组概念上相关的代码;这就是包的结构。一般来说,每当我们认为可能需要将代码拆分成更多模块时,我们可能应该跟随这种冲动。

另一方面,当外部代码调用我们的包时,最好能够仅通过一个或两个导入语句来利用我们的代码,这些语句引入了少量函数或类。这是包的接口,一般来说,它应该尽可能简单,同时保留完整的功能,如下面的代码示例所示:

from example.foo import Foo
from example.bar import BarFactory
from example.baz import Baz

MAIN_BAZ = BAZ()

__all__ = ['Foo', 'BarFactory', 'MAIN_BAZ']

幸运的是,我们可以同时拥有我们的蛋糕并享用它!

我们可以按照自己的意愿将代码拆分,然后将最有用的元素导入到我们的init文件中,这样它们就成为包的路由命名空间的一部分。如果init文件中只有import语句,我们不需要__all__变量。

import*语句将抓取init文件的内容,除了以下划线开头的变量。然而,如果我们定义或导入在init文件中不应成为公共接口的一部分的内容,我们可以使用__all__变量来缩小范围并控制我们导出的内容。

只需记住,如果我们有一个所有列表,它需要列出包接口的所有部分,无论是模块、类、函数还是变量。

包中其余的模块仍然可以显式导入。我们只是使访问那些最有可能在我们自己的包外部有用的部分变得方便。

现在我们已经很好地了解了如何命名我们的模块,将它们放在哪里以便它们成为我们包的一部分,以及如何为我们的包提供一个方便的接口。接下来,我们将继续探讨如何使包中的模块相互交互。

从其他模块访问代码

我们将从理解绝对导入和相对导入之间的区别开始本节,然后继续编写这些导入,最后我们将查看循环依赖。

当我们从包外部导入包的一个模块时,只有一种合理的方式可以工作——我们告诉 Python 我们想要的包和模块,如果它找到了并导入了它,或者如果找不到则抛出异常。简单!

import packagename.modulename  

当我们已经在包内部时,情况更为模糊,因为import name可能意味着“在这个包内寻找name”或“在 Python 搜索路径中寻找name。”Python 通过定义import name意味着应该搜索 Python 搜索路径中的名为name的包或模块来消除这种歧义:

import name  

此外,它还为我们提供了一种指定相对import的方法,如果我们更愿意让它只查找当前包。我们可以通过在要导入的模块名称前放置一个点来指定相对import,如下面的代码所示:

import .name  

当 Python 看到这一点时,它将在我们的代码正在运行的模块所在的同一包中寻找名为name的模块。

通常,我们只需要从另一个模块中导入一个或两个对象,直接将这些对象导入我们的全局作用域比导入整个模块并访问其内容更方便。Python 允许我们通过在import语法上稍作变化来实现这一点:

from .name import Foo  

最后,有时在我们导入对象时,我们可能想要将其重命名。我们可以通过使用as关键字修改我们的导入语句来实现这一点:

from .name import Foo as Bar 

在前面的例子中,即使对象在name模块中被称为Foo,在我们的当前模块中,它被称为Bar。顺便说一下,这个技巧对绝对导入也适用。

在我们继续之前,让我们注意一下 Python 2 在决定在哪里查找导入代码时使用了一个不同的规则。在 Python 2 中,它首先尝试在当前包中找到导入的目标。然后,如果没有找到匹配的模块,它就会在搜索路径上查找。这种方法通常做对了,但偶尔由于含义不明确而引起问题;这也意味着我们无法拥有一些包、子包或模块,它们的名称与标准库或其他已安装包中的任何内容相同。因此,在 Python 3 中改变了这种行为。

导入循环依赖

当我们导入一个与同一包共享的模块时,可能会遇到一些问题。有时,我们正在导入的模块也希望导入我们。这种情况被称为循环依赖。当我们尝试导入循环依赖时,我们几乎总是会得到一个属性错误异常,如下面的例子所示:

图片

这是因为当我们请求 Python 导入第一个模块时,Python 立即为它创建一个模块对象,并开始执行模块中的代码。

这没有问题,但是,当 Python 到达这个循环中下一个模块的import语句时,它会暂停第一个模块中的代码执行,使其未完全初始化。尽管这通常不是问题,因为 Python 会在稍后回来完成初始化。

然而,当第二个模块请求导入第一个模块时,Python 只是将已经分配但尚未完全初始化的模块对象传递给它。当第二个模块尝试访问第一个对象中存储的变量时,其中许多变量尚未创建。因此,会引发一个属性错误。

解决由于循环依赖引起的属性错误

解决属性错误有两种常见的方法。第一种通常被认为是最好的。这种方法是通过将某个模块中的部分代码移入一个第三模块,这样其他模块就可以导入它而不会造成循环。在下面的示例中,如果我们把A类移入它自己的模块,就不会有循环,一切都会顺利:

图片

解决这个问题的另一种方法是移动造成循环的import语句,就像以下截图所示:

图片

如果我们将import语句向下移动,就像前面示例中那样,直到它位于其他模块需要的所有变量定义之下,当其他模块导入时,模块将初始化足够,Python 会在稍后回来完成初始化。

将静态数据文件添加到包中

如果我们要将静态数据文件添加到包中,我们应该把它们放在哪里?

我们可以在package文件夹内的任何方便的地方放置它们,但通常创建一个专门用于存放数据文件的子文件夹是个好主意。这可以将数据文件与源代码分开,并且通常使它们更容易处理。

包的一部分数据文件应假定是只读的。

有许多原因可能导致文件在运行时不可写。因此,如果我们想在代码运行时写入数据,我们需要选择其他地方来存储它。只有不改变文件才适合包含在包中:

ls example/
__init__.py data
ls example/data
datafile.txt
cat example/data/datafile.txt
Hello world of data

所以,这么说来,我们想要在我们的包中包含一个数据文件,只需要将其放入我们的包中,然后使用标准库中util包的get_data函数来访问数据:

from pkgutil import get_data
get_data('example', 'data/datafile.txt')
b'Hello world of data\n'

get_data函数接受两个参数:

  • 我们想要从该包获取数据的包名

  • 包内数据文件的相对路径

使用正斜杠分隔路径组件,我们传递这两条信息,它就会返回一个包含文件内容的字节对象给我们。

如果我们想要一个文本字符串而不是字节,这很容易做到。我们只需要将适当的字符串解码器应用到字节对象上,我们就会得到一个 Unicode 文本字符串。即使我们的包已经被压缩成 ZIP 文件或其他方式隐藏起来,这种技术仍然有效,因为它使用了 Python 加载模块源代码相同的底层机制。

如果 Python 能够找到代码,它同样可以找到数据文件。这就是与打包在代码旁边的静态数据一起工作的全部内容。简单且实用。

摘要

在本章中,我们学习了如何在文件系统中创建一个 Python 包作为目录,以及如何通过一个__init__.py文件来标记它,以便导入高效并且我们可以添加包元数据。我们探讨了如何向包中添加代码模块。我们看到了同一包内的代码模块是如何交互的。

我们学习了如何组合一个 Python 代码包,它可以被用于程序中或分发给其他程序员使用。很快,我们将看到如何将一个包转换成一个完整的程序。在下一章中,我们将稍微退后一步,讨论一些与 Python 代码一起工作的最佳实践。

第四章:基本最佳实践

在上一章中,我们看到了如何组合 Python 代码和数据包。在本章中,我们将探讨一些可以使我们作为 Python 程序员的生活更加简单的事情。我们将转换方向,探讨版本控制,这将帮助我们与其他程序员协作,并在整个项目生命周期中作为撤销缓冲。我们将查看 Python 内置的虚拟环境工具 venv,它允许我们将我们的程序和依赖项彼此分离,以及与系统上安装的软件分离。

您将学习如何为最大效用结构化我们的文档字符串,如何向它们添加富文本格式,以及如何将它们导出为超链接 HTML 文档,以便在网页浏览器中查看。您还将看到通过实际执行我们在文档中包含的示例并确保它们与代码的实际行为一致,我们可以从文档字符串中获得的一个额外的好处。

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

  • PEP 8 和编写可读的代码

  • 使用版本控制

  • 使用 venv 创建一个稳定且隔离的工作区域

  • 充分利用文档字符串

PEP 8 和编写可读的代码

在本节中,我们将快速浏览如何格式化我们的代码,以便在稍后日期返回时或当其他人需要处理它时易于阅读。我们将特别关注缩进规则、Python 代码风格指南,以及最终的命名约定。

Python 增强提案PEPs是建立 Python 社区标准的文档。大多数 PEP 描述了 Python 或 Python 标准库的新功能,但其中一些较为模糊。PEP 8 就是其中之一;它告诉我们 Python 社区认为什么样的代码是写得好的、可读的代码。

PEP 8 — Python 代码指南

PEP 8 引入的第一条规则是,PEP 8 中的规则/指南仅在它们使我们的代码更容易阅读时才适用。这意味着我们应该应用 PEP 8 来提高代码的可读性,并使其更简单。例如,如果我们正在处理一个已经使用不同编码风格(即已经易于阅读)的项目,我们应该为新代码使用该项目的风格。如果 PEP 8 规则在某种程度上使代码更难以阅读或在编写代码时使其复杂,我们应该忽略这些规则。正如 Python 的创造者 Guido Van Rossum 所指出的:

代码 被阅读的次数比被编写的次数多

代码应该始终以促进可读性的方式编写。

如需了解更多关于 PEP 8 规则和指南的信息,您可以参考以下链接:

www.python.org/dev/peps/pep-0008/

要知道何时忽略特定的指南,您可以参考以下链接中的“A Foolish Consistency is the Hobgoblin of Little Minds”文章:

www.python.org/dev/peps/pep-0008/#a-foolish-consistency-is-the-hobgoblin-of-little-minds.

代码缩进

作为程序员,当我们阅读代码时,我们会查看缩进来告诉我们代码块是如何嵌套的。然而,大多数其他编程语言使用实际的符号来告诉语言解析器一个块从哪里开始和结束。在编码中,在两个不同地方提供相同的信息是任何编程语言的基本最佳实践的一种违反。因此,Python 省略了开始和结束块标记,并使用缩进(如下面的代码截图所示)来通知解析器和程序员:

图片

尽管如此,还是有一个问题出现!

在文本文件中编码缩进有不同的方式。具体如下:

  • 使用空格字符

  • 制表符字符

  • 结合使用两种方式

我们在前面代码图像中看到的代码混合了空格和制表符,这在 Python 2 中是有效的,但这是一个糟糕的想法,而在 Python 3 中,这是一个语法错误。我已经配置了编辑器以彩色突出显示制表符字符,这样我们就可以轻松地看到哪些缩进来自空格,哪些来自制表符,以了解为什么即使允许混合空格和制表符,它也不是一个好的做法。

我们所需要做的只是更改制表符宽度,它将看起来像下面的代码图像:

图片

尽管在前一个代码图像中缩进看起来很好,但现在显然是错误的。如果所有的缩进都来自制表符字符,那么就不会有歧义。所以,即使在 Python 3 中,只使用制表符也是有效的。然而,PEP 8 和 Python 社区的建议是我们始终使用恰好四个空格来表示一个缩进级别。任何半数以上的编辑器都可以在我们按下 Tab 键时为我们插入这些空格。还有更多的建议,我们将在下一小节中快速浏览。

格式化建议

下面的代码截图展示了几乎所有的 PEP 8 格式化建议:

图片

我现在将逐一介绍这些建议:

  • PEP 8 建议单行代码不应超过 79 个字符的宽度

虽然这与在标准文本模式界面显示代码是一致的,但在现代宽屏和可调整大小的窗口的世界中,这个规则的主要原因是它有助于阅读。即使在与编程无关的上下文中,布局设计师也更喜欢限制行宽。

  • 导入语句应放在文件顶部,首先是标准库导入,然后是第三方导入,最后是同一项目中的其他模块导入

  • 每组导入之间应该有一个空行

  • 顶级类和函数之间应该有两行空白来分隔。

  • 类中的方法应该用一行空白来分隔它们。

  • 在函数或方法内部,空白行应该用来表示代码概念分组之间的分隔。

  • 不要在括号、方括号或花括号前后插入额外的空格;也不要在逗号或冒号前插入空格。

  • 总是在二元运算符(如+/)的两侧放置单个空格。

  • 不要在同一行上放置多个语句,尽管偶尔可能这样做,但这从来不是一个好主意。

  • 注释应该用人类语言编写,使用该语言的正确语法。

如果你要将源代码发布到野外,那么最好使用英语,因为这种语言是大多数 Python 程序员的通用语言。

  • 注释也应该在描述的代码部分之前,并且应该缩进到相同的级别。

  • 每个公开的模块、类、函数或方法都应该有一个格式正确的文档字符串。

我们将在本章的“充分利用文档字符串”部分中查看格式正确的文档字符串意味着什么。

让我们继续选择变量、函数、方法、类、模块、包等的名称。

命名约定

Python 命名约定的主导规则是,对象的命名风格应该清楚地表明对象的使用方式,而不是对象本身是什么。这意味着,例如,一个顶级函数,它被调用以创建新对象,因此表现得像类,应该像类一样命名。

  • 包和模块:这些应该有合理短的名字,完全由小写字母组成,在模块的情况下,可以使用下划线。

  • :这些应该使用首字母大写和每个新单词开头的首字母大写来命名。这有时也被称为驼峰式命名。例外情况是类,因此它们应该遵循类命名约定,但它们也应该以单词Error结尾。

  • 函数方法实例变量全局变量:这些都应该使用小写字母,单词之间用下划线分隔。如果它们打算作为内部组件而不是公共接口的一部分,那么它们的名称应该以单个下划线开头。

    • 实例方法的第一个参数应该始终命名为self。命名的常量值应该全部大写,单词之间用下划线分隔。

这就是 PEP 8 和大多数 Python 程序员期望其他人遵循的格式规则。现在,让我们通过讨论版本控制来谈谈细节。

使用版本控制

版本控制是现代程序员的基本工具之一。它以某种方式帮助我们处理项目的几乎所有方面。有众多版本控制系统,每个系统本身就是一个主题,因此我们将缩小我们的关注点,讨论如何使用名为 Git 的特定版本控制系统执行一些特别有用的事情。

初始化 Git

在安装 Git 之后,我们需要做的第一件事是设置一个文件夹作为我们的Git 仓库。这只需要在命令行上执行几个命令,如下所示:

图片

之后,我们将移动到我们希望存储仓库的文件夹中,即执行git initgit add。一旦我们初始化了仓库,我们就可以使用git add命令将我们已创建的任何文件添加到其中。然后,我们使用git commit -a命令在代码中创建第一个安全点,如下所示:

git commit -a 

Git 中的更改提交

git commit -a提交命令告诉 Git 提交所有已做的更改:

图片

在未来,如果我们向项目中添加新文件,我们也应该使用git add来告诉 Git 开始跟踪它们。任何我们想让 Git 记住项目当前状态的时候,我们再次运行git commit -a

撤销更改

保存旧项目状态的好处是我们可以回到它们。

例如,假设我们已经在file.txt文件中对moo cow进行了更改,改为moo aardvark,如下面的截图所示:

图片图片

如果我们想将文件恢复到之前的某个提交状态,撤销自特定提交以来对该文件所做的所有更改,我们只需使用git log命令找到该提交的标识符:

git log

这将带我们回到我们的提交,如下所示:

图片

然后,我们使用git checkout命令撤销我们的更改。要使用git checkout命令,我们只需输入提交和文件名,就可以撤销更改,如下所示:

图片

如果我们后来改变主意,我们可以以同样的方式重做更改。在项目层面上撤销更改的能力很棒,但更有用的是对代码进行临时更改,然后在更改完成后决定是否真的想在主代码中保留它们。这正是分支的作用。

分支

我们可以使用git checkout -b创建一个新的分支,这将自动创建分支并切换到它:

图片

当我们在分支上时,我们做的任何代码更改都与分支相关联,当我们离开分支时,它们就会消失。以下是一个示例。

假设我们想要将 moo cow 改为 moo horse。为此,我们将运行以下命令以打开 Emacs 并编辑文本文件:

$ emacs file.txt

图片

我们将在文件中进行我们想要的更改:

图片

然后,我们可以使用 git commit -a 命令将文件提交到分支,并添加一条提交信息以跟踪我们所做的更改:

图片

您将看到这些变更的记录,以显示已提交的内容:

图片

然后,您可以使用 git checkout master 命令切换回主版本,查看未做任何更改的原始文件,当我们重新进入分支时,那些更改会回来:

图片

这意味着我们可以在自己的开发分支上工作新特性时,仍然可以在主分支上修复 bug。例如,在这里我们可以进入相同的文本文件,将动物改为狗,并留下注释说明我们做了什么:

图片

然后,当我们提交该文件时,我们也会留下一条消息,说明我们刚刚进行了 bug 修复:

图片

和往常一样,我们使用 git commit -a 命令提交文件,然后会看到变更记录,如下所示:

图片

当我们最终对某个特性满意时,我们可以将其合并到主分支。

代码合并

要从不同的分支合并代码,我们只需在我们要合并到的分支内部使用 git merge 命令,并给出我们要合并的分支名称。如果两个分支不能自动合并,Git 会为我们处理:

图片

这通常就是全部,但有时两个分支都有重叠的更改;当这种情况发生时,Git 知道它自己不足以处理合并。

当我们运行 git merge 时,它会通知我们存在冲突。然后,我们可以使用 git mergetool 来启动合并解决工具:

图片

合并工具命令

合并解决工具让我们能够利用我们的更高智能来解决冲突,如下所示:

图片

应将 moo cow 改为 moo horse 以避免冲突:

图片

在完成必要的更改后,转到文件并退出界面。

一旦完成,我们使用 git commit 命令来最终确定我们的更改,如下面的截图所示。

图片

mergetool 命令是一个智能命令,它会寻找存在于各种操作系统上的多个不同工具,并从中选择一个它认为最适合您的工具。

在当前情况下,它为我选择了一个名为 meld 的工具,这个工具偶然也是用 Python 编写的,用于修复歧义。

拉取命令

Git 可以做类似的事情,将其他存储库中的代码合并到我们的存储库中。该命令是 git pull,而不是 git merge,并且不是分支名称,而是提供不同存储库的 URL 或路径名,如上图所示:

图片

但除此之外,它的功能是一样的。这是一个极其有用的功能,因为它允许我们轻松地与其他本地和全球的程序员协作。

使用 venv 创建稳定且独立的作业区域

当我们在处理一个项目时,我们通常希望项目中的非我们自己的代码部分保持不变。我们可能有一个很好的理由在我们的系统上安装 Python 的新版本或更新库,但我们真的不希望这些事情在我们的开发环境中发生变化。更不用说,我们很容易发现自己针对完全不同且不兼容的系统配置进行不同的项目。我们需要为每个项目设置一个独立且可以针对该项目特定需求进行配置的区域。这就是我们所说的虚拟环境。

Python 3.3 及以后的版本内置了 venv 工具,它为我们创建虚拟环境。每个由 venv 创建的虚拟环境都知道它应该使用哪个版本的 Python,并且拥有自己的包库,这意味着对于 Python 代码来说,它基本上与系统其他部分是断开的。

我们可以在系统级别和虚拟环境内部的代码上安装、卸载和更新包,而虚拟环境内部的代码甚至不会注意到。我们可以安装 Python 的新版本,而虚拟环境内部的代码也不会注意到。我们唯一不能在系统级别安全做的事情是卸载虚拟环境基于的 Python 版本。

创建虚拟环境

为新项目创建虚拟环境非常简单;我们只需打开命令行,转到我们希望项目文件夹所在的文件夹。然后,我们使用我们想要虚拟环境使用的 Python 版本来运行 venv 工具:

图片

当我们调用 venv 时,我们告诉它我们想要为项目文件夹命名的名称。venv 工具将创建项目文件夹,并用支持虚拟环境的所需文件填充它:

图片

每次我们实际上想在虚拟环境中工作,我们都应该激活它。这将使需要进行的任何更改,以便虚拟环境的内容覆盖系统级别的默认设置。

激活虚拟环境

要激活虚拟环境,我们打开命令行并转到包含虚拟环境的文件夹:

图片

然后,执行激活命令。我们使用的具体激活命令取决于操作系统。

在大多数 Unix 风格的系统上,包括 Macintosh,我们使用$ source bin\activate命令(如前一个屏幕截图所示)。在 Windows 上,我们运行Scripts\activate.bat

我们正在激活的虚拟环境中操作,pip 自动知道它应该管理该环境的包:

图片

虚拟环境中的 pip

初始时,虚拟环境仅包含 Python 标准库和一些实用工具,包括 pip 本身。

然而,我们可以使用pip安装项目中将使用的第三方包。当我们查看 pip 时,你学习了它的--user命令行选项,该选项将包安装到个人包库而不是系统库中。

当在虚拟环境中安装时,将包安装到个人包库而不是系统库中是永远不必要的,因为虚拟环境已经改变了默认安装位置。

现在一切准备就绪,我们可以开始工作了。我们不应该删除 venv 或 pip 创建的任何文件或文件夹。然而,除了这一点,我们可以根据项目需要自由创建文件和文件夹。创建一个子目录来包含我们的工作代码通常很有用。

现在你已经学会了如何使用简单但有用的 venv 工具来隔离我们的编码项目,以及从我们的开发系统的大部分更改中隔离,让我们转向另一个有用的最佳实践,即文档字符串。

充分利用文档字符串

在本节中,我们将探讨如何格式化文档字符串以实现最佳可读性,以及如何将它们转换为结构化和格式化的文档。我们还将探讨如何使文档中的示例可测试,以确保文档始终保持最新。

PEP 257 和 docutils

PEP 257 记录了 Python 程序员和工具对文档字符串的期望。基本规则相当简单。如下所示:

文档可在www.python.org/dev/peps/pep-0257/找到。

  • 使用三引号来界定文档字符串。三引号是 Python 表达多行文本字符串概念的方式。

  • 如果你的文档字符串超过一行,则关闭的三引号应单独占一行。

  • 第一行应提供一个关于所记录内容的简短描述,例如“返回点 a 和点 b 之间的距离。”

在第一行之后,我们既可以结束文档字符串,也可以插入一个空白行,然后插入对所记录对象的更深入描述,包括参数、属性、使用语义示例等(参见图表):

图片

这种布局的原因是许多工具会将文档字符串的第一行显示为弹出窗口、工具提示或以其他方式作为快速参考。当请求详细文档时,空白行之后的文本会被显示出来。文档字符串处理工具对缩进很智能,所以安全并鼓励我们对文档字符串进行缩进,以便它们与它们所描述的代码块的其他部分相匹配。

这些基本规则是我们使文档字符串与 Python 的 pydoc 和帮助工具以及 IDE 等交互所必需的,但它们并没有给我们提供创建格式良好的独立文档的方法。这就是 Sphinx 发挥作用的地方。

Sphinx

Sphinx 是一个工具,可以处理 Python 源代码和独立的文档文件,并以多种格式生成格式良好的文档,尤其是 HTML。Sphinx 与其他 Python 文档工具一样,识别一种名为 reStructuredText 的标记语言,这种语言故意设计得易于阅读和提供信息,即使其标记以纯文本形式呈现而不是被解释。

reStructuredText 可以在以下链接找到:docs.python.org/devguide/documenting.html#reStructuredText-primer

reStructuredText 文档在简单的文本编辑器中仍然可读,但当我们通过像 Sphinx 这样的工具处理它们时,最终结果会更加丰富。reStructuredText 语法基于在电子邮件和实时聊天中实际富文本广泛可用之前在互联网上发展起来的约定。

例如,单词通过在每个端点放置一个星号(*text*)来强调,并通过将其变成两个星号(**text**)来进一步强调。段落通过在它们之间放置一个空白行来标记。

列表、标题等语法在文本模式下都很容易阅读。这些语法在 Python 开发者指南中有很好的描述,但我们将集中讨论一些可以大大增强模块和包文档的标记。我们实际上之前已经看到了这些标记之一。

当我们的文档字符串引用局部变量,例如函数参数时,该引用应该用星号括起来以强调它,并使其与文档的正常文本区分开来。模块、类、函数、方法、属性、异常、全局变量和常量的名称都应该正确标记,以便 Sphinx 可以交叉引用它们并创建到其文档的链接。

所有这些对象类型都共享类似的语法,即我们在冒号之间放置一个类型标识关键字,紧接着是对象名称,用反引号括起来:

正如你在前面的示例中看到的那样,对于类和方法,我们使用了class关键字和meth关键字。其他可用的关键字包括:mod用于模块,func用于函数,attr用于属性,data用于变量,const用于常量。

这些 reStructuredText 语法已经足够让我们在文档质量上产生重大差异。因此,我们将在这里停止,并继续讨论如何实际使用 Sphinx 工具。如果它尚未安装,我们可以使用pip来安装它,如下面的命令所示:

(sphinx) $ python3 -m pip install sphinx

Sphinx 可以做更多的事情,所以值得通过教程来学习

www.sphinx-doc.org/en/stable/tutorial.html

这相当不错。

然而,对我们自己的目的来说,我们感兴趣的只是将 docstrings 转换为 HTML。因此,我们将在下一小节中详细介绍这个过程。

将 docstrings 转换为 HTML

我们将为我们的示例包生成 HTML 文档,它只是一个 docstring 示例。我们首先进入包含我们的包目录的目录。一旦进入,我们运行sphinx-quickstart来设置一切。

图片

sphinx-quickstart首先询问文档的根路径应该是什么。我发现使用名为docs的文件夹效果很好,所以我建议输入docs作为根路径:

图片

sphinx-quickstart命令将询问更多问题,我们可以按自己的意愿回答,然后最终它将询问我们是否想要启用 autodoc 插件;我们将通过输入 yes(y)来启用它:

图片

其余的问题对我们来说并不那么重要。在回答所有问题后,sphinx-quickstart命令将如以下截图所示完成:

图片

一旦sphinx-quickstart完成,我们将想要运行sphinx-apidoc -o与我们的目录一起,以自动生成描述我们的包的 Sphinx 或 Sphinx 源文件:

图片

我们的 docstrings 被提取并整合到这些源文件中。

最后,当我们想要生成 HTML 时,我们可以通过在命令行中运行make html来构建它。Sphinx 将翻译其源文件,包括由sphinx-apidoc自动生成的文件,并将结果存储在我们通过sphinx-quickstart问题中指定的构建目录中。

如果我们编辑源代码并希望更新 HTML 文档,我们通常只需再次运行以下命令即可:

(sphinx) $ make html 

如果我们对包结构进行了重大更改,我们还需要再次运行sphinx-apidoc命令:

(sphinx) $ sphinx-apidoc -o docs/ example/ 

因此,Sphinx 可以用作文档编译器,它将我们的 docstrings 源代码转换为 HTML 编译代码。

使用 doctest 测试文档示例

在我们的 docstrings 中包含使用示例很常见,但如果我们不小心,这些示例可能会在代码更改时被遗漏,而错误的文档比没有文档更糟。幸运的是,我们有一个工具可以通过运行我们的文档中的示例来检查文档是否与代码一致。这个工具叫做 doctest

要使 doctest 能够识别我们的示例为可以测试的内容,我们只需要在我们的示例中包含 Python 交互式 shell 提示符。在最简单和最常见的情况下,这意味着在每个语句前放置一个 >>> 符号,如下面的截图所示。对于多行语句,我们在续行前加上一个 ... 符号:

图片

在每个语句之后,我们应该写下该语句的预期结果,其书写方式应与交互式 shell 相同。换句话说,如果我们直接从交互式 shell 复制粘贴,我们就创建了一个 doctest 可以识别的示例。因此,编写 doctest 非常简单。

将 doctest 编写到我们的 docstrings 中,即使我们不知道 doctest 工具的存在,也有相当大的可能性。作为额外的奖励,Sphinx 也能识别 doctest 并适当地格式化它。因此,如果我们打算在我们的 docstrings 中包含代码示例,其方法将在以下部分中解释。

使用 doctest 测试示例

要使用 doctest 测试代码示例,我们只需运行以下命令:

$ python3 -m doctest example.py -v

我们将得到以下输出:

图片

让 doctest 运行文件中的所有示例并报告它们是否成功非常简单。我们只需从命令行运行 doctest 工具,并告诉它我们感兴趣的文件。命令中的 -v 选项会提取额外信息,这不是必需的,但通常很有帮助。

在前面的示例中,我们看到的是错误消息的缺失,而不是任何确认一切工作正常或已测试的信息。

运行 doctests 的其他方法包括一个 Sphinx 插件,sphinx-quickstart 会询问我们是否想要使用。还可以将 doctests 集成到 Python 标准单元测试库的测试套件中,或者使用一个名为 nos 的集成测试运行器来执行它们;在这种情况下,直接使用 doctests 也足够了。

当代码示例失败时意味着什么

现在,如果 doctest 失败了,但我们在查看后发现文档和示例实际上是正确的呢?这意味着我们的代码是错误的。信不信由你,这其实是一件好事。我并不是说有错误是好事,但无论我们是否发现了这个错误,它都存在。然而,找到错误并且有一个测试在手,可以让我们检查是否成功修复了它,这绝对是好事。

因此,doctest 将我们的代码和文档联系起来,确保它们保持同步并相互校验。这是一个非常实用的文档字符串技巧。

摘要

在本章中,你学习了如何编写可读的代码,使用版本控制来跟踪我们的代码,以及 venv 工具及其创建的隔离虚拟环境。你学习了如何格式化我们的文档字符串以利用自动化工具,例如 IDE 和 Sphinx 文档编译器。你还学习了如何在我们的文档中编写示例,以及如何使用 doctest 工具来检查示例和代码是否同步

在下一章中,你将学习如何将一个包转换成一个可以从命令行运行的程序。

第五章:创建命令行实用工具

在上一章中,我们了解了一些有助于我们长期使用 Python 的最佳实践。在本章中,我们将探讨如何创建 Python 命令行程序以及使这些程序更易于使用和更有用的特性。我们将学习如何在包中创建代码执行入口点,以及如何将包作为程序运行。

我们还将了解如何使程序从其命令行读取数据,以及如何轻松处理从程序命令行参数读取数据。我们还将探讨如何在代码内部实际运行其他程序。

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

  • 通过 Python -m 使包可执行

  • 使用 argparse 处理命令行参数

  • Python 工具用于与用户交互

  • 使用 subprocess 执行其他程序

  • 使用 shell 脚本或批处理文件来运行我们的程序

通过 Python -m 使包可执行

在上一章中,我们通过输入 python3 -m 命令后跟要运行的工具名称来运行命令行工具,如 doctestvenv

当我们这样做时,我们实际上要求 Python 做什么?

Python 的 -m 命令行选项告诉它运行一个模块。它使用与使用模块名称的 import 语句相同的机制来查找模块,然后执行它。

然而,venv 不是一个模块,而是一个包。那么,当我们使用 python -m venv 时发生了什么?我们给了 Python 一个包名,但没有给它一个包内应该运行的模块名。在这种情况下,Python 会查找包中的名为 __main__ 的模块并运行它:

因此,python -m venvpython -m venv.__main__ 意义相同。

任何打算包含程序入口点的模块都存在问题,因为简单地导入模块也会运行代码。在最坏的情况下,这可能会很烦人或有麻烦,但当我们使用像 Sphinx 或 doctest 这样的工具时,这些工具需要导入模块来完成工作,但实际上不应该作为程序运行模块代码,这时就变得无法接受了。

幸运的是,有一个简单的解决方案,因为 Python 解释器本身知道它被指示启动运行的模块,并将其标记为这样的模块。所有模块都自动赋予一个名为 __name__ 的变量,该变量包含模块的名称。也就是说,所有模块都如此,除了程序入口点。

程序入口点始终命名为 __main__,即使其文件名完全不同:

因此,我们可以通过检查这个,__name__ == '__main__' 来检查我们的代码是否是程序入口点。如果我们的代码确实是程序入口点,那么我们应该像上面的例子那样运行程序。如果不是,我们将像普通代码一样导入它,不应将程序作为主代码运行。

这区分了包的 __main__ 模块的导入和运行。因为当我们导入它时,名称变量包含包名 __main__,而不仅仅是 __main__。在本章接下来的部分,我们将通过构建一个名为 Pipeline 的完整实用程序的过程进行操作。

管道程序

管道程序将是一个文本模式程序,可以配置为运行一系列其他程序,并将每个程序的数据馈送到下一个程序。在每个部分,我们将进一步开发管道程序。

到目前为止,本章中我们讨论的是如何使程序能够通过 python -m 运行,但在前面的章节中,我们看到了如何创建工作环境,如何创建包,以及如何在包的模块中布局代码以提高可读性。

因此,让我们应用这些课程。

在虚拟环境中创建一个文件夹作为包,并命名为 pipeline。在 package 文件夹中放置一个 __init__.py 文件,它可以空着,以及一个 __main__.py 文件:

图片

目前,__main__.py 文件的内容可以非常简单,如下面的屏幕截图所示:

图片

__main__.py 文件的内容如下:

  • 模块的文档字符串

  • 如果模块是程序入口点,则应该调用的函数

  • 决定是否调用启动函数if 语句

启动函数没有文档字符串,这是允许的,因为它以名称的第一个字母为下划线标记为非公共的。启动函数目前也没有做什么有趣的事情;它只是使用打印函数告诉我们它已成功执行。

让我们运行它,以便我们可以亲自看到。

  1. 打开一个命令行窗口,

  2. 前往我们创建包的虚拟环境

  3. 激活虚拟环境。

  4. 然后,输入以下命令:

      (pipeline) $ python -m pipeline

输出如下:

图片

我们应该看到打印出的消息(如前一个屏幕截图所示),然后程序将结束。

使用 argparse 处理命令行参数

在本节中,我们将了解如何使程序从其命令行读取数据,这是各种程序中常见的功能。大多数命令行程序以及令人惊讶数量的图形用户界面程序都可以在调用程序的命令之后在命令行中提供额外信息。这些额外信息被称为参数,它们作为字符串列表传递给 Python 程序。结果是,将参数列表转换为有用信息涉及相当多的代码,尤其是如果我们希望使程序尽可能方便用户的话。幸运的是,很多代码可以从程序到程序通用,Python 标准库的argparse模块为我们处理了大部分工作。

创建一个 ArgumentParser 对象

argparse模块的主要组件是ArgumentParser类。在最小化情况下,使用argparse只需要三行代码。我们需要导入它,创建一个参数解析器实例,并调用该实例的parse_args()函数:

图片

通常情况下,最小化情况下并不很有用。程序唯一会响应的参数是-h--help,任一都会打印出自动生成的如何使用此程序信息然后退出程序。

要使ArgumentParser类更有用,我们可以在其构造函数的description参数中提供一个值,如下面的截图所示:

图片

ArgumentParser自动生成的帮助信息将解释程序期望的所有参数,但除非我们提供描述,否则它不会说明程序实际应该做什么。

我们可以通过告诉ArgumentParser程序名称来改进ArgumentParser的行为。如果我们不提供名称,它将尽力做出合理的猜测,但最好还是我们自己提供。我认为python3 -m命令(参考以下代码示例)是我自己程序的规范名称,所以我们将使用它作为我们的示例:

图片

设置参数名称

我们通过将字符串传递给ArgumentParser构造函数的prog参数来设置一个参数的名称。这些更改使程序的帮助输出更美观、更有用,但实际上并没有给程序带来任何新功能。

我们需要开始向解析器添加参数规范,这样它就可以在参数列表中检查它们。

我们通过调用ArgumentParser实例的add_argument方法来实现这一点:

图片

argparse模块识别两种类型的参数:

  • 可选参数

  • 位置参数

正如其名所示,可选参数不是必需的,但如果用户选择包含它们,它们就有意义。另一方面,位置参数默认是必需的,尽管我们可以修改这种行为。可选参数的名称以 - 字符开头。同一个参数可以有多个备选名称。这些名称作为参数传递给 add_argument 方法,如前例中所示,其中 -p--print 是同一个可选参数的备选名称。

当我们将参数添加到解析器时,有几种配置参数的方法,这些方法在库参考中的 argparse 文档中有详细说明,可以在 docs.python.org/3/ 查阅。

对于像我们的打印示例这样的选项,重要的配置项是 actiondefault

  • action 参数告诉 argparse 在命令行上找到参数时应该做什么

  • default 参数告诉它在命令行上找不到参数时应该做什么

action 参数可以是包含已知操作名称的字符串,例如 store_true,或者它可以是 argparse.action 类的子类:

图片

default 参数可以是任何任意值,该值将作为参数的值存储。如果我们实际上请求 parser 解析参数值时它缺失,那么另一种类型的参数必须只有一个名称,并且不能以 - 字符开头。

默认情况下,这些参数收集命令行中作为可选参数添加到解析器的单词,按照它们添加到解析器的顺序。如果我们使用 nargs 选项配置参数,我们可以更改参数收集的单词数量。

nargs

如果我们将 nargs 设置为一个数字,那么将为该参数收集这么多单词。我们还可以将 nargs 设置为 * 表示任意数量的单词或 + 表示至少一个单词。我们还可以设置一些其他的 nargs 值,但在这里的这部分我们不会讨论它们。

让我们回顾一下我们的 Pipeline 程序。我认为我们希望它理解两个参数——一个告诉它继续进行的 optional 参数,即使其中一个程序返回错误代码,以及一个文件名,其中它应该加载和存储管道配置:

图片

在调用 parse_args 之后,我们有一个名为 args 的对象,它包含一个设置为 truefalsekeep_going 属性和一个包含字符串的 filename 属性。

注意,参数对象的属性是 keep_going,而不是 keep-going。Python 不允许在属性名称中包含 - 字符,而 argparse 足够智能,可以自动为我们修复这个问题。

如果我们想要手动设置参数对象属性的名称,我们可以在 add_argument 方法中将其作为最佳参数传递我们想要的名称。

Python 与用户交互的工具

在前面的章节中,我们看到了如何在命令行中从用户那里获取信息,但当我们需要更动态的交互形式时,我们该怎么办?所以,让我们来看看 Python 的一些工具,用于向用户发送信息并从用户那里获取信息。

Python 的内置函数——printinput

交互性的基础很简单。我们需要能够告诉用户事情,并且我们需要用户能够告诉我们事情。为了实现这两个目标,Python 提供了两个内置函数。这些是printinput

创建一个包含以下代码的simple.py文件:

图片

print函数接受任意数量的 Python 对象作为参数,并将它们打印到屏幕上。input函数接受一个字符串提示作为其参数,将其打印出来,然后读取文本,直到用户按下Enter键,并返回用户输入的字符串。

运行以下命令以查看printinput函数的工作方式:

python3 simple.py  

交互性可以简单到这种程度。以下截图显示了前面命令的输出:

图片

printinput函数可以做很多事情,但有几个边缘情况它们处理得不是很好。

getpass

我刚才提到的那些边缘情况之一是我们想要用户输入密码。如果我们使用input函数来读取密码,它就会显示在屏幕上,任何人都可以阅读。getpass包包含一个名为getpass的函数,它的工作方式与input类似,但不会显示用户输入的文本。

图片

我还想提到的另一个边缘情况是,尽管print函数可以打印出任何 Python 对象,但它并不擅长展示复杂的数据结构。

pprint

来自同名包的pprint函数使复杂的数据结构更容易阅读。如果我们想显示字典列表,pprint会比print函数做得更好。

创建一个包含以下代码的special.py文件:

图片

运行以下命令来执行special.py文件:

python3 special.py   

以下截图是前面命令的输出:

图片

在这四个函数——printinputgetpasspprint——之间,我们可以创建广泛范围的用户界面,但它们都是非常基本的工具。我们将会重新发明很多轮子,浪费时间。幸运的是,得益于 Python 的batteries included哲学,我们不必这样做。相反,我们将使用标准库中的cmd包来快速构建我们的用户界面。

cmd

我们所需要做的就是从cmd类继承并定义实现用户可以输入的命令的方法。

创建一个包含以下代码的usecmd.py文件:

我们将提示属性设置为一个字符串,该字符串将用于提示用户输入命令。运行以下命令以执行usecmd.py文件:

$ python3 usecmd.py

上述命令的输出如下:

接下来,我们将创建我们的Interface类的一个实例(如下面的截图所示),并将其称为cmdloop方法。 presto!即时界面:

在这个例子中,我们看到cmd类负责显示提示、读取命令和调用正确的方法,但这只是它所做的一切。如果我们想在命令处理器方法内部显示数据,我们仍然需要print函数。

cmd类不是唯一的。它为我们做了很多工作,但如果我们需要,我们仍然可以直接使用printinput函数。这就是关于 Python 中文本模式交互的所有内容,至少如果我们希望我们的程序能够在不同平台上移植的话。

管道用户界面

另有一个名为curses的标准库模块,它能够实现更复杂的文本加载操作;然而,由于curses模块无法移植到 Windows,我们不会在本章中详细介绍。

相反,让我们看看如何定义我们的管道程序的界面:

现在我们似乎想要能够将程序添加到管道中,并以各种方式操作程序之间的数据传递:

当我们对管道满意时,我们还想能够保存它,并能够实际运行它。最后,我们还想能够使用quit命令退出程序。

一个help命令也会很有帮助,但幸运的是,cmd类将自动使用命令处理函数的docstrings来提供这个功能:

我们在这里只是简单地设置命令处理器,因为我们现在专注于接口实现。所以,我们得到了一个相当不错的用户界面,而我们几乎不需要做太多工作。这就结束了我们对文本模式交互的探讨。现在,让我们关注一下程序控制的一些选项。

使用子进程执行其他程序

在本节中,你将学习如何在我们的程序内部执行和控制其他程序,从而让我们能够创建示例程序的精髓。这种能力对于各种系统自动化任务非常有用。创建一个包含以下内容的echo.py文件:

以前让其他程序运行总是一件有点混乱的事情。不同的平台有不同的机制,有方便的机制,也有不同的安全机制。幸运的是,自从 Python 2.4 版本引入 subprocess 模块以来,所有这些都发生了变化,该模块抽象了平台之间的差异,并使得一些更安全的范式更容易使用。

子进程及其变体

subprocess 包中有六个对象特别引人注目。其中三个是 callcheck_callcheck_output 函数,它们都是基于相同基本思想的变体:

from subprocess import call, check_call, check_output

这三个对象运行程序,等待其终止,然后告诉我们程序做了什么:

  • call 函数返回程序的退出代码,这是一个具有程序定义意义的整数,通常用来判断程序是否成功或失败。

  • check_call 函数如果程序的退出代码非零,则抛出异常,按照惯例,这意味着程序以错误退出。

  • check_output 函数返回程序打印的任何文本,如果程序以非零退出代码退出,则抛出异常。抛出的异常有一个名为 output 的属性,包含程序的文本输出。因此,即使程序以错误代码退出,我们仍然可以获取输出(如果我们想要的话)。

run 函数与我们刚才提到的 call 函数类似:

from subprocess import run  

实际上,run 函数能够做任何任何人会做的事情,结合在一起。然而,它不一定可以在其他项目中使用,至少目前还不能。

这三个 call 函数和 run 函数的调用方式在很大程度上是相同的。每个函数都通过一个包含程序名称及其参数的列表传递。我们在屏幕上看到这个 'ls','-l' 函数的输出,它并不是来自调用,因为 'ls','-l' 在运行时会打印出一些内容。

输入以下语句:

call(['ls', '-l', '/dev/null']) 

以下是对前面语句的输出:

但是返回的退出代码是 0,所以这就是你的调用。如果你想要从 'ls' 程序中获取打印输出并将其用于我们的程序,我们需要捕获它。

check_output 函数有一个值得注意的关键字参数,称为 universal_newlines,默认值为 False。如果我们将 universal_newlines 设置为 True,程序文本输出将被解码为 Unicode 字符,使用系统默认的文本编码器,并且新行字符将被标准化。

如果我们将 universal_newlines 设置为其默认值 False,程序输出将以字节形式返回,我们需要自己进行解码并处理当前系统认为的新行字符序列:

lines = check_output(['ls', '-l', '/dev/'], universal_newlines = True).split('\n')

现在输入以下内容并按 Enter

lines 

在这个代码示例中,我们确实将 universal_newlines 设置为 True,然后使用标准换行符 \n 来分割它,这给了我们程序输出的行列表:

图片

如果我们想要在运行其他程序时更加复杂,我们将想要使用 Popen 类的实例——subprocess 包中的第四个有趣的东西,它为我们提供了对其他程序执行的大量灵活性和控制。

使用 Popen 子进程

在增加一点复杂度的代价下,Popen 构造函数接受与调用函数相同的程序和命令行参数列表,以及一个非常大的可选关键字参数列表,包括 universal_newlines

我们将关注 Popen 构造函数的一个特定用途,这个用途很有用,但超出了我们使用调用函数所能达到的范围。我们将看到如何在我们的代码也在运行的同时在后台运行一个程序,并在两个程序之间发送和接收数据。

PIPE 常量

为了管理这些,我们需要使用 subprocess 包中的第六个有趣的东西——PIPE 常量。PIPE 常量与 Popen 构造函数的 stdinstdoutstderr 关键字参数一起使用:

p = Popen(['python3', 'echo.py'], stdin = PIPE, stout = PIPE, bufsize = 0) 

这些参数代表其他程序的文本输入、输出和错误报告。我们将它们中的任何一个设置为 PIPE 的都将被重定向到我们的程序。在前面的例子中,我们将程序的输入和输出重定向到我们自己,这给了我们与其他程序的双向数据通道。

现在你看到了,我们正在以编程方式与屏幕上最初这个示例中的代码进行交互。然而,由于它设置了一个循环,输入,然后输出。此外,每当有输入时,一旦我们设置了 PIPE,我们就可以通过在 Popen 对象的 stdin 属性上调用 write 方法将数据发送到其他程序:

图片

我们可以通过调用 Popen 对象的 stdout 属性上的 read 方法或其相关方法来读取数据,如前例代码所示。

尽管通过将 bufsize = 0 参数传递给 Popen 构造函数来禁用了缓冲,但在向 stdin 写入后调用 flush 方法,以及在从 stdout 读取之前调用 flush 方法通常是个好主意。我们可以继续发送和接收数据,直到两个程序都在运行。然而,如果我们完成交互并只想等待其他程序终止,我们可以调用 Popen 对象的 wait 方法来实现这一点。

wait 方法

wait 方法将在其他程序运行完成后返回其退出代码。与 PIPEstdinstdout 一起工作的许多复杂性都封装在 Popen 类的 communicate 方法中,该方法接受输入作为参数并返回输出:

图片

通信简单,但有些受限,因为每个 Popen 对象只能调用一次,并且只有在另一个程序完成之后才会返回。这对需要相互通信的两个程序来说并不好,但应该非常适合我们的管道程序,其中每个程序都将前一个程序的输出作为其输入。

完成我们的代码示例

我们将创建一系列类来表示管道中的不同步骤,并将这些类集成到界面中。一旦我们完成了这些,程序就具有功能性了,尽管还有很大的改进空间。

我们特别关注以下截图右上角的 ExecStep 类,它使用 Popen 来实际执行程序并摆脱输出。

图片

我们现在有了示例程序,所以我们将继续本章的最后部分。

设置 shell 脚本或批处理文件以启动程序

在本节中,我们将通过使其易于启动来总结本章的示例程序。只要它安装在了系统的 PYTHONPATH 中,或者我们已经激活了包含它的虚拟环境,我们就可以使用 python -m 来运行我们的程序,如下所示:

图片

为我们的程序创建启动项

一旦程序稳定,我们真正想要的只是能够输入其名称或双击它,然后运行。实现这一点的便捷方式是使用 shell 脚本或批处理文件:

  • 在包括 macOS 在内的 Unix 风格操作系统上,shell 脚本是以 #bang/bin/sh 开头的文本文件,并且已被标记为可执行

  • 在 Windows 上,批处理文件是以 .bat 结尾的文件

Shell 脚本和批处理文件都是包含一系列命令行命令的文本文件,每行一个命令。当我们输入脚本或批处理文件的名字时,这些命令会依次执行,就像我们逐个在命令行中输入一样。

同样,如果我们通过图形用户界面触发脚本或批处理文件,命令仍然会像我们逐个在命令行中输入一样执行。考虑以下示例:

图片

对于我们的目的,这意味着我们可以将启动程序所需的任何命令序列放入 shell 脚本或批处理文件中。之后,我们可以将那个脚本当作程序本身来处理。这就是简单情况的全部。

Shell 脚本能够表示更多的复杂性,但 Python 在这方面是更好的工具。因此,这里的简单情况,就是我们真正需要的所有。

概述

在本章的前几节中,你学习了如何将我们的 Python 包转换为程序,从命令行获取数据,与用户交互,以及以子进程运行其他程序。我们看到了如何使我们的 Python 程序像任何其他程序一样,无论是从 GUI 还是命令行都可以轻松观察。我们在构建 Pipeline 程序的用户界面过程中,还了解了几种 Python 的文本模式工具。

在下一章中,我们将探讨如何利用并行处理来充分利用具有多个处理器或核心的计算机。

第六章:并行处理

在上一章中,我们创建了一个文本模式实用程序,并学习了 Python 的几个内置包。在这一章中,我们将看到如何使用前面提到的两个包:高级的 concurrent.futures 包和低级的 multiprocessing 包来帮助我们编写并行程序。这两个包都是 Python 标准库的一部分。

我们将详细介绍以下两个主题:

  • 使用 concurrent.futures

  • 使用 multiprocessing 包

使用 concurrent.futures 包

在本节中,我们专注于 concurrent.futures,这是前面提到的两个包中更抽象且更容易使用的包。我们的重点将放在 concurrent.futures 的四个主要操作上。然后我们将继续介绍未来对象的使用,并以进程间数据传输机制的影响结束。

有些程序是我们所说的 CPU 密集型,这意味着决定程序完成任务所需时间的主要因素是计算机运行其指令的速度有多快。有趣的是,我们日常使用的许多程序都不是 CPU 密集型。然而,对于那些是 CPU 密集型的,我们通常可以通过将它们分解成单独的进程来加速它们。

这种差异可以如下说明:

图片

在前面的图中,左侧是一个 CPU 密集型程序。它有很多事情要做,用圆圈表示,执行速度取决于 CPU 处理速度的快慢。右侧是一个非 CPU 密集型程序,这意味着,大多数时候,它正在等待执行。

进程可以在不同的 CPU 核心上同时运行,甚至可以在完全独立的 CPU 上运行。这总体上增加了每秒执行的程序指令数,这意味着 CPU 密集型程序比非 CPU 密集型程序运行得更快。

在某些编程语言中,我们可以通过为单个程序运行多个线程来获得相同的好处。然而,如我之前提到的,大多数程序都不是 CPU 密集型,因此 Python 的创建者选择了优化 Python 的线程系统以适应常见情况,这产生了副作用,使得 Python 线程对于提高 CPU 密集型程序的运行速度并不十分有用。

此外,操作系统优化多个进程的执行比优化进程内的多个线程更容易。因此,即使线程是一个可行的选项,对于 CPU 密集型程序来说,多个进程仍然是一个更好的选择。

我们在讨论subprocess模块时已经看到了一种非常低级的启动进程和与之通信的方法(请参阅第五章中关于使用 subprocess 执行其他程序的部分,制作命令行工具)。然而,对于我们的程序被拆分成一组协同工作的进程的情况,Python 为我们提供了一些高级工具包,使事情变得更容易。Python 并行处理工具包中更抽象的一个被称为concurrent.futures

concurrent.futures模块

concurrent.futures模块是为那些可以由一个控制进程和几个工作进程组成的程序设计的,其中控制进程将任务分配给工作进程,然后收集和整理结果。以下是一个使用concurrent.futures模块的简单 CPU 密集型任务示例:

图片

这是一个相当通用的模型,尤其是对于 CPU 密集型程序。因此,concurrent.futures模块既易于使用,又非常适用,前面的代码示例也展示了它的简单性。

基本用法是只需导入它,创建一个ProcessPoolExecutor对象,然后调用该对象的mapsubmit方法将工作发送到工作进程。当我们完全完成ProcessPoolExecutor并且知道我们再也不需要它时,我们可以调用它的shutdown方法,或者允许with语句为我们完成它。ProcessPoolExecutor对象将负责创建和与工作进程通信的所有琐碎细节。

在继续使用mapsubmit方法之前,让我们更深入地了解ProcessPoolExecutor以及它的功能。

调用ProcessPoolExecutor

当我们调用ProcessPoolExecutormapsubmit方法(我们将在本节后面讨论),我们是在要求它使用给定的参数调用一个函数。但我们希望这个函数调用发生在工作进程中。这有一些可能不那么明显的含义:

  • 首先,这意味着函数及其参数需要是可序列化的,这另一种说法是 Python 需要知道如何将它们转换成一个字节字符串,它可以发送到工作进程。

对于函数,这基本上意味着任何函数都可以,除非它是另一个函数体内的定义。

对于参数来说,这意味着大多数对象都可以工作,但生成器和一些其他类型的特殊对象则不能传递。

意识到函数及其传递给它的参数在它们被序列化以与工作进程通信时可能会附带我们无意发送的信息是很重要的。

如果我们发送给ProcessPoolExecutor对象的对象引用了其他对象,那么这些对象也会被序列化并发送。最终可能会发送我们程序的大部分状态。当请求运行的函数是某个对象的方法时,这一点尤其值得注意。

如果函数是对象的方法,整个对象将被序列化并发送到工作进程,这意味着函数调用将使用原始对象的副本作为其 self 参数,而不是原始对象。

  • 第二,函数的返回值将被序列化并返回到控制进程。所有关于传递参数给被调用函数的警告也适用于返回值。

例如,如果函数不能返回生成器对象,并且其返回值包含对多个对象的引用,那么这些对象的副本最终会被发送到控制进程。

  • 第三也是最后一点,运行在工作进程中的concurrent.futures代码需要能够导入我们的原始代码所加载的模块。

这意味着我们可能需要使用if __name__ == '__main__'技巧来防止工作进程陷入运行我们程序完整副本的状态,而他们实际上只想导入模块并找到我们请求运行的函数。

我们已经在我们的例子中看到了ProcessPoolExecutormap方法,但让我们更仔细地看看。

使用map方法

map方法将其第一个参数作为函数。我们还可以传递一个或多个区间,这些区间将被用来确定函数每次调用的参数:

图片

参考前面的代码示例,如果我们要求poolfoo函数映射到列表[1, 2, 3][4, 5, 6],结果是foo函数将用14作为参数被调用,再次用25作为参数被调用,第三次用36作为参数被调用。

没有任何保证这三个调用发生的顺序。毕竟,它们可能在每个不同的进程中运行,进程调度与墙钟时间的关系部分取决于不可预测的因素。

map方法通过等待所有调用完成并生成结果,然后按正确顺序返回这些结果的迭代器来隐藏这个事实。

使用submit方法

有时候,map方法过于简单。如果我们想在每个工作进程生成结果时处理结果,而不是等待所有工作进程完成,怎么办?如果我们决定最终不运行函数,怎么办?如果我们想在工作进程中同时运行不同的函数,怎么办?至于传递关键字参数给函数,我们可以使用submit方法做到所有这些以及更多。

每次调用submit方法都对应于对作为submit方法第一个参数传递的函数的单次调用。我们传递给submit的其余参数和关键字参数在发送到工作进程后传递给函数。

让我们看看submit方法的一个示例:

因此,每次我们调用submit时,一个工作进程都会用一个参数集调用一个函数。submit方法在返回之前不会等待工作进程完成函数的运行。实际上,它甚至不会等待工作进程开始运行函数,因此submit不会返回被调用函数的结果。相反,它返回一个future对象。

在某种意义上,future对象是函数结果的 IOU。如果我们有一个future对象,我们可以使用它来检查工作进程是否已经运行完函数以获取函数返回的结果,甚至可以设置一个回调,当函数最终完成运行时将被调用。我们甚至可以使用future对象从应该分发给工作进程的作业队列中移除函数调用。

我们最常使用的是future对象的doneresult方法。

doneresult方法

done方法在作业完成时返回true,如果没有完成则返回false。如果作业被取消、引发异常或作业函数已返回,则作业完成,如下面的代码示例所示:

如果作业函数成功完成,result方法返回作业函数的返回值。如果作业函数引发异常而不是返回值,工作进程将捕获异常并将其作为作业的结果返回给控制进程。

在前面的代码示例中,调用result方法将重新引发异常,因此可以正确处理。

超时参数

超时参数是result方法中的一个重要参数。如果我们想在作业完成之前调用result方法,它将非常有用。

如果在作业函数完成之前调用result方法,那么result方法将在返回之前等待作业完成。这可能会非常有用,但有时我们不想无限期地等待。如果作业没有很快完成,我们想要继续做其他事情一段时间。

在这种情况下,我们应该将我们愿意等待的秒数传递给result方法的timeout参数,如下面的代码示例所示:

除了timeout参数外,我们还将添加一个TimeoutError异常。如果在没有产生结果的情况下timeout参数到期,将引发超时错误。

waitas_completed函数

concurrent.futures 包中有一对函数允许同时等待多个 future。它们被称为 waitas_completed。以下代码示例表示 wait 函数:

图片

wait 函数等待所有 futures 准备好提供结果或直到超时到期。然后,它返回一组已完成和一组未完成的 futures。相比之下,as_completed 函数返回一个迭代器,它会逐个产生准备提供结果的 futures

在罕见的情况下,futuredoneresult 方法以及 concurrent.futures 包的 waitas_completed 函数不足以让程序在适当的时间处理 futures

对于这种情况,可以通过将函数传递给 futureadd done callback 方法,让 future 在结果可用时调用一个函数。

添加完成回调函数

future 对象会记住该函数,当 job 函数完成时,callback 函数将使用 future 对象作为其唯一参数被调用。然后,callback 函数中的代码可以调用 futureresult 方法来获取作业产生的返回值或异常。

callback 函数总是在控制过程中被调用,但它可能不会在程序主要部分的同一线程中被调用。

当我们使用 add done callback 时,需要小心线程同步问题,这也是尽可能选择 waitas_completed 函数的一个主要原因。future 对象也有一个 cancel 方法。

取消方法

cancel 方法试图告诉系统我们最终不希望发生调用(参考以下代码示例):

图片

这个代码示例不能保证工作,因为如果工作进程已经开始了一个作业,那么这个作业就不再可取消了。

  • 如果与 future 对象连接的作业无法取消,cancel 方法返回 false

  • 如果取消成功,cancel 方法返回 true

concurrent.futures 模块非常适合将计算任务分配给多个进程,以利用多核和多处理器计算机的 CPU 功率。对于这类任务,通常只需要 mapsubmitwaitas_completed 函数。

使用多进程包

在上一节中,我们看到了 concurrent.futures 包使得将计算作业分配给工作进程变得非常简单。如果所需的程序不适合 发送作业并收集结果 模型,我们可能更倾向于在较低级别的抽象层面上工作。

因此,现在让我们继续看看另一个帮助我们处理不符合该模型的并发程序的包,但这些组件之间只有部分是相互独立的。它们时不时地需要在彼此之间传递信息,而不仅仅是返回到控制进程。我们不能用concurrent.futures来做这件事,因为它根本不适合concurrent.futures用来描述并行处理的模型。

另一方面,如果我们需要在工作进程开始运行作业后能够取消作业,这又不符合concurrent.futures模型。concurrent.futures模型功能强大,但其强大之处在于其简单性,因此不难想象它无法处理的场景。

当我们需要构建自己的并行处理模型时,我们可以将multiprocessing模块作为基础。

multiprocessing模块中的进程类

multiprocessing模块包含一个名为Process的类,它表示在单独的进程中运行代码的能力。

使用Process类最简单的方法是将其子类化并重写run方法,这是其他进程中的代码的入口点,如以下代码示例所示:

图片

在前面的例子中,我们创建了一种特定类型的进程,用于计算一些平方数。然后,我们创建了一个实例并开始运行它。当我们调用start时,multiprocessing模块执行了必要的工作,以确保run方法在新进程中执行。

顺便说一句,我们在concurrent.futures模块部分(使用concurrent.futures包)中讨论的所有关于序列化和导入模块的警告也适用于multiprocessing模块。当涉及到在进程之间移动数据和导入代码时,它们以相同的方式工作。

到目前为止,我们还没有看到任何我们无法用concurrent.futures做得更好的事情,但当我们开始使用队列管道管理器时,情况就改变了。让我们详细看看它们。

队列

队列是适用于合作进程之间一对一、多对一和多对多通信的通信通道。根据它们的使用方式,这使得它们非常适合在不需要关心哪个工作进程最终执行任务时向工作进程发布任务,以及收集多个工作进程的结果。

任何进程都可以将可序列化的对象放入队列中,任何进程都可以从队列中移除下一个可用的对象。

队列是先进先出FIFO)数据结构,这意味着对象是从队列中以它们被添加的相同顺序被移除的。《JoinableQueue》类添加了一个方法,允许进程等待直到其他进程清空队列。好的,让我们更详细地看看queue对象:

图片

请参阅前面的代码示例;有三个方法主要是有用的:putgetget_nowait

  • 当我们调用 put 时,一个对象被放置在队列的后面。

  • 当我们调用 get 时,一个对象将从队列中移除并返回,除非队列是空的。如果队列是空的,调用 get 的进程将等待直到能够移除并返回一个对象,这将在其他进程将对象放入队列之后发生。

  • 当我们在另一端调用 get_nowait 时,它要么移除并返回队列前面的对象,要么引发一个 q.empty 异常。

  • 最后,我们可以将 timeout 参数传递给 get 方法,在这种情况下,它将在那么多的秒内移除并返回一个对象,或者引发 q.empty

我们可以通过将队列对象作为进程初始数据的一部分,或者通过通过现有的队列或管道发送它们,甚至通过将它们存储在管理器中,在进程之间传递队列对象。队列被设计成可以在进程之间共享。继续前进,让我们看看管道。

管道

管道是一对一的通信通道。当我们调用管道时,我们得到一对对象,每个对象都作为通信流的一端。如果我们将每一端给一对进程,它们可以通过管道来回发送消息和数据。

管道的每一端都有相同的方法。有趣的方法是 sendrecvpoll。考虑以下代码示例:

在前面的代码示例中,我们看到这些:

  • send 方法接受一个对象作为其参数并将其发送到另一个端点。

  • recv 方法等待从另一个端点发送来的数据,然后返回它。

  • poll 方法如果可以接收到的对象存在则返回 true,如果不存在则返回 false

poll 方法可以接受一个 timeout 参数。如果我们给它一个超时时间,并且当前没有等待接收的数据,poll 函数将等待最多那么多的秒数以等待数据到达,然后返回 true。如果在超时到期之前没有数据到达,poll 方法将返回 false

如果我们将 None 作为 poll 方法的 timeout 参数传递,它将在返回之前等待数据到达,无论需要多长时间。像队列对象一样,管道端点可以在进程启动时或运行后通过其他队列、管道等发送给其他进程。

通常认为使用队列和管道作为进程之间唯一的连接是最好的,因为这最大化了进程并行工作的能力。

如果有可能以那种方式组织并行程序,那么应该这样做。如果我们发现需要在几个进程之间共享一些变量,我们可以使用 Manager 对象来实现。现在让我们看看管理器。

管理器

管理器代表一个具有单一任务的特殊进程——跟踪其他进程需要的变量。

访问存储在管理者中的变量比访问进程的局部变量要慢得多,这可能导致尝试同时访问变量的进程相互减慢。另一方面,如果我们确实需要共享变量,至少管理者能够正确且尽可能高效地处理它们。

现在,管理者可以处理许多类型的数据,但我们将关注它们存储 字典列表命名空间 的能力。

通常,当我们把一个对象发送到另一个进程时,另一个进程实际上得到该对象的一个副本;这意味着如果另一个进程改变了它接收到的对象,我们不会在原始进程中看到这些变化。

管理者让我们能够创建更类似队列的对象,如果我们把对象发送到另一个进程,并且该进程改变了对象,我们确实会在原始进程或任何其他可以访问该对象的进程中看到这些变化。考虑以下代码示例:

在前面的代码示例中,manager.dict()manager.list() 方法创建了一些特殊的字典或列表,这些字典或列表可以在进程之间共享。Namespace 方法确实以大写 N 开头,它创建了一个更通用的共享对象,我们可以在其中设置属性以在进程之间共享。

当我们拥有多个执行流正在访问共享数据时,这正是管理者提供的,我们必须小心保持数据访问的同步。为了帮助做到这一点,管理者还可以创建一些标准的同步原语,例如 事件条件信号量

锁对象

锁对象是同步工具中最简单的。它们有一对方法称为 acquirerelease,如下面的代码示例所示:

在一个进程调用 acquire 后,任何其他调用 acquire 的进程都必须等待,直到第一个进程调用 release。然后,等待进程中的一个 acquire 调用返回,允许该进程继续执行。换句话说,在 acquire 调用和 release 调用之间的代码可以确信是唯一访问 lock 对象所保护数据的代码。

注意,lock 对象并不知道它保护什么数据。这取决于我们作为程序员在自己的脑海中定义并尊重这种关联。

事件对象

事件对象允许进程在标志为 true 时立即继续,或者等待标志变为 true

通过调用 event.clear 方法将标志设置为 false 或通过调用其 event.set 方法将其设置为 true,如下面的代码示例所示:

当我们调用 event.wait 方法时,如果标志为 true,它将立即返回;否则,它将暂停执行,直到另一个进程调用 set 并然后返回。

事件对象对于使进程暂停,直到某个特定事件发生非常有用。

条件对象

条件对象结合了lockevent对象的一些特性。像lock一样,它们有acquirerelease方法,可以用来保护数据免受同时访问。

然而,condition对象还有一个wait方法和一个notify方法,可以用来等待直到其他进程执行某些操作,并唤醒一个等待的进程,如下面的代码示例所示:

图片

条件对象对于创建同步访问其内容的数据结构以及在它们没有数据返回时等待非常有用。queue类的getput方法可以使用条件对象来实现。

semaphore对象

semaphore对象看起来与lock对象非常相似。区别在于,lock对象总是确保在给定时间只有一个进程获取了锁,而semaphore对象确保不会超过固定数量的进程同时获取它。

这可以通过以下代码示例来查看:

图片

这对于执行诸如限制同时访问硬盘的工作进程数量等操作非常有用。

摘要

在本章中,你学习了如何使用concurrent.futures模块使一个特别常见的多进程案例变得极其简单。我们还看到了如何使用multiprocessing包来定义工作进程执行的操作以及它们如何交互。

因此,现在我们相当了解如何帮助 CPU 密集型程序利用多核和多处理器硬件来运行得更快。尽管如此,大多数程序并不是 CPU 密集型的,它们是 I/O 密集型的,这意味着它们大部分时间都在等待来自各种来源的输入。在这种情况下,并行处理没有帮助,但异步 I/O 可以,这是我们下一章的主题。

第七章:协程与异步 I/O

在上一章中,我们探讨了如何使用多个进程来提高我们程序中数据处理的速度。这对于 CPU 密集型程序来说非常好,因为它允许它们使用多个 CPU。

在本章中,我们将探讨这种情况的逆过程;我们将使用单个 CPU 在单个进程中同时处理多个数据处理任务,这对于 I/O 密集型程序来说非常棒。我们将了解一些使用 asyncio 的细节。我们还将讨论 asyncio 的future类及其使用方法。然后我们将继续讨论异步协程任务之间的同步和通信。最后,我们将看看如何使用 asyncio 和协程编写一个客户端-服务器程序,以通过网络进行通信。

我们将涵盖以下主题:

  • 异步处理与并行处理之间的区别

  • 使用 asyncio 事件循环和协程调度器

  • 等待数据可用

  • 同步多个任务

  • 网络通信

异步处理与并行处理之间的区别

当我们在第六章中与concurrent.futures模块一起工作时,我们看到了一种让两个或更多代码流同时运行的方法。为了参考,请查看我们在上一章中使用的代码示例:

图片

这段代码帮助你创建一个执行器对象。现在,如果你希望并行运行一些代码,你只需告诉执行器去做。执行器会给我们一个未来对象,我们可以在稍后使用它来获取代码的结果,并且它会然后在单独的进程中运行代码。我们的原始代码将在原始进程中继续运行。

我们讨论了如何提高 CPU 密集型程序的性能——将代码分割成多个计算机核心。因此,它是一种在其他许多情况下都方便的技术。

能够告诉计算机“去做这件事,完成后通知我”是非常方便的。

这种能力似乎特别有用的一处是网络服务器程序,其中为每个连接的客户端拥有一个独立的执行流,这使得代码的逻辑和结构更容易理解;以这种方式结构化时,编写无 bug 的服务器更容易。

多线程对于服务器来说并不好

有方法可以编写只使用单个执行流的服务器,但如果我们有方法可以编写可能引入更少 bug 的服务器,为什么不呢?问题是资源开销。

当然,运行在计算机上的每个进程都会消耗内存和 CPU 时间;然而,除了内存和 CPU 时间外,进程还需要运行其代码。操作系统还需要消耗一些资源来管理进程。实际上,在进程之间切换所花费的时间是相当大的。

内存开销已经足够;它成为多进程服务器可以同时处理多少客户端的限制因素;其他内部操作系统资源可能限制得更多。

对于计算密集型程序,有一个产生最佳结果的甜点,即程序有一个进程对应一个 CPU 核心。对于 I/O 密集型程序,大多数服务器都是这样的,第一个进程之后的任何进程都只是开销。正如之前提到的,有方法可以编写单进程服务器,可以同时处理多个客户端,并且每个连接客户端的开销都更低。

这些技术允许服务器同时处理比多进程服务器程序能够管理的更多的客户端。即使在不全速运行的情况下,单进程服务器也会让计算机的大部分资源可用于其他用途。

因此,一方面,我们有编写服务器的方法,逻辑结构清晰且不易出错,但浪费资源。另一方面,我们有编写服务器的方法,资源效率高,但容易出错。

我们能否以某种方式获得两个世界的最佳之处?答案是,可以!

幸运的是,Python 的标准 asyncio 模块结合了低级技术,允许单个进程通过协作式协程调度器服务多个客户端。参考以下代码示例:

图片

最终结果是,一个编程接口看起来和表现得很像 concurrent.futures,但每个代码执行流的开销要低得多。那很好,但什么是协作式协程调度器?就这个话题而言,什么是协程?

协作式协程调度器与协程的比较

在我们深入细节之前,让我们定义这两个术语:

  • 协程是计算机科学中的一个概念,是一个可以在其中暂停和恢复的函数。每次它暂停时,它会发送数据,每次它恢复时,它会接收数据。

Python 程序可以使用 asyncawait 关键字定义协程,并且 asyncio 广泛使用它们。

  • 协作式协程调度器是一段代码,每次协程暂停时都会拾起执行,并决定下一个要运行的协程。它被称为调度器,因为它跟踪多个执行流,并决定在任何给定时间哪个流可以运行。

它被称为协作式,因为调度器不能在第一个协程仍在运行时从第一个协程切换到另一个协程。它必须等待正在运行的协程自己暂停,然后才能选择另一个协程来运行。

Python 协程

在 Python 协程中,暂停和恢复点是 await 表达式;这就是我们调用其他协程的方式。每次我们想要执行一个函数时,我们都会在另一个协程内部调用一个协程。我们等待我们想要调用的协程。

代码的语义就像它们是从另一个函数内部调用一个函数一样工作。其他协程会一直运行,直到它返回,我们得到返回值。这就是代码的行为方式,但实际上它所做的事情要有趣得多。

协程调度器

使用协程调度器,代码的行为如下:

  • 发生第一件事是我们正在运行的协程被暂停。我们想要调用的协程被交给调度器,调度器将其放入它需要运行的协程列表中。

  • 然后,调度器检查协程是否在等待以及为什么:例如,来自网络的新数据,或者一个被返回的协程;如果是后者,它也将等待的协程添加到列表中。

  • 然后,调度器选择需要运行的协程之一并恢复其执行。这意味着如果有一个协程包含一个长时间运行的循环且不包含任何await表达式,它将阻止其他协程的运行。它也会阻止程序检查新的传入数据,并防止其他各种输入和输出操作得到服务。

如果我们有一个这样的循环,并且没有理由在其中调用任何其他协程,我们可以在循环体中放置await asyncio.sleep(0)语句,这仅仅给调度器一个机会去做它的事情。

由于需要await表达式而产生的这种额外的复杂性是协同调度的代价,但鉴于回报是针对 I/O 密集型程序的效率高且逻辑清晰的代码,这通常是很值得的。

使用 asyncio 事件循环和协程调度器

到目前为止,你已经学习了 Python 的协程以及关于协同协程调度器的一些知识。现在,让我们尝试使用 Python 协程和 asyncio 编写一些异步代码。我们首先创建一个协程。

创建一个协程

创建协程很容易——我们只需要在函数上使用async关键字,并在需要调用其他协程时使用await,如下面的代码示例所示:

图片

然而,一旦我们有了协程,我们并不能直接调用它来启动它。如果我们尝试调用它,它将立即返回一个coroutine对象,如下面的代码示例所示——这并没有什么用处:

图片

相反,我们需要将协程添加到 asyncio 的调度器中作为一个新的任务。接下来,调度器安排协程执行和处理输入输出事件。

asyncio 调度器 - 事件循环

asyncio包自动创建一个默认的调度器,也称为event_loop

虽然可以创建新的 event_loop 对象或替换默认的,但就我们的目的而言,默认的 event_loop 调度器将完全足够。我们可以通过调用 asyncio 的 get_event_loop 函数来获取它的引用,告诉调度器我们想要它启动一个新任务,如下所示:

图片

当我们运行前面的协程时,我们调用 asyncio 的 ensure_future 函数。默认情况下,这将创建一个任务在默认调度器中。

ensure_future

我们还可以通过将显式的 event_loop 调度器传递给 ensure_future 函数的 loop 关键字参数来覆盖默认调度器。

f = asyncio.ensure_future(coroutine.example(),  loop = scheduler)

注意,我们并没有仅仅将 coroutine 函数传递给 ensure_future;我们实际上在 ensure_future 参数内部调用了它。我们这样做是因为 ensure_future 函数实际上并不想引用 coroutine 函数。ensure_future 函数只对我们在之前看到的 coroutine 函数返回的 coroutine 对象感兴趣。ensure_future 这个名字可能有些奇怪。如果它用于启动任务,为什么叫这个名字呢?

实际上,启动任务基本上只是函数概念上所做事情的一个副作用,即包装。如果需要,将函数的参数包装在未来的对象中。碰巧的是,如果协程从未被安排运行,那么为协程的返回值拥有一个未来对象将毫无用处;ensure_future 确保了这一点。

ensure_future 函数无论在普通代码中还是在协程内部调用,都会向调度器添加一个新任务。这意味着每次我们想要代码在自己的执行流中运行时,我们都可以使用 ensure_future 来启动它。

即使在先前的代码示例中,我们添加了一个协程到调度器作为一个新任务,也没有发生任何事情。这是因为调度器本身还没有运行。然而,这是一个容易解决的问题。我们只需要调用 run_foreverrun_until_complete 方法之一。最终,我们的协程将实际执行,如下所示:

scheduler.run_until_complete(f)
5

run_forever/run_until_complete 方法

如其名所示,run_forever 使得 event_loop 永远运行或至少直到它被显式地通过调用其 stop 方法停止。另一方面,run_until_complete 方法使得循环继续运行,直到特定的未来对象准备好提供一个值(参考以下代码示例):

图片

ensure_future 的返回值是一个 future 对象,因此你可以轻松地运行调度器,直到特定任务完成。前面的代码示例在同一个调度器中将两个协程作为两个单独的任务同时运行。coro1() 协程包含一个无限循环,所以它永远不会完成;然而,coro2() 协程不仅完成,还导致 event_loop 停止方法(loop.stop ())最终强制 run_forever 终止。这将在以下代码示例中显示:

图片

上述例子行为完全相同,只是它使用 run_until_complete 自动在 coro2 完成后停止调度器,而不是显式调用 stop

代码看起来这样更整洁。所以,作为一个经验法则,可能最好只在出现某种错误,需要从 event_loop 中退出时才使用 stop。在我们刚刚看到的两个例子中,都有一行代码将日志级别设置为关键。这是因为如果 event_loop 在有任务(如 coro1)仍在运行时被停止,它会发出错误信息。在这种情况下,我们知道它仍在运行,我们并不关心,所以我们抑制了消息。

通常,最好安排所有运行的任务干净地退出,而不是简单地杀死它们。这就是为什么打印出错误信息。但是,在我们的情况下,没有问题,所以我们只是阻止消息打印。

无论我们如何选择运行和停止 event_loop,一旦我们完全完成它,我们应该调用它的 close 方法。它会关闭 event_loop 管理的任何打开的文件、网络套接字和其他 I/O 通道,并通常自行清理。

关闭 event_loop

关闭 event_loop 的一个好方法是使用 contextlib.closing 上下文管理器,它保证一旦 with 块结束,就会调用 close 方法。以下代码示例显示了 event_loop 的关闭:

图片

即使在错误情况下,当我们完全完成 event_loop 时,也应该调用 close 方法,但这并不一定意味着应该在 run_foreverrun_until_complete 调用完成后立即调用。在那个点上,event_loop 仍然处于有效状态,例如添加一些新任务或再次启动循环是完全正常的。

你可能已经注意到了,asyncio event_loop 对象基本上与 concurrent.futures executor 对象扮演相同的角色。从编程接口的角度来看,这并不是唯一的相似之处。

等待数据可用

asyncio 的 future 对象看起来和表现几乎与concurrent.futures future 对象相同,但它们不是可互换的。它们在行为上有细微的差异,当然,在它们与底层系统的交互方式上有重大差异,这些系统是完全不同的。尽管如此,每个 future 都是引用可能尚未计算出的值的途径,如果需要,等待该值变得可用的途径。

asyncio 的 future 对象

future 对象最常用的功能是等待其值确定,然后检索它。对于 asyncio 的 future 对象,这可以通过简单地等待 future 来实现,如下面的代码示例所示:

图片

这将告诉调度器暂停协程,直到 future 的值变得可用,之后 future 的值在协程中作为await表达式的结果被设置。

如果 future 表示一个抛出的异常而不是值,那么这个异常将从await表达式中再次抛出,如前面的代码示例所示。如果我们不想等待,我们可以调用done方法来检查 future 是否已准备好;如果是,我们可以调用result方法来检索值。

因此,语法和语义略有不同,但 asyncio 和concurrent.futures中 future 的基本思想是相同的。当我们使用 asyncio 时,我们在所有相同的地方使用 future 对象,就像在concurrent.futures中一样。

有一种情况,即使使用 future 对象,等待数据也并不简单;这就是当我们应该处理到达的数据流时,如下所示:

图片

当然,我们可以遍历 future 对象的迭代器并等待每个对象准备好提供它们的值,但这很笨拙,并且存在关于何时停止迭代的问题。

相反,Python 为我们提供了一个异步迭代协议,允许我们将获取下一个值函数作为一个协程来编写或获取。这意味着迭代器可以等待每个值到达,然后简单地返回它。现在我们的循环将正常工作,我们将避免所有关于何时停止的困惑。

异步迭代

为什么我们需要为循环语句和单独的异步迭代协议提供特殊的异步迭代?

这是因为异步迭代只适用于协程内部。有一个单独的循环语句和协议可以防止我们陷入模糊的情况,在这种情况下,计算机不确定我们想要它做什么。

同步多个任务

在本节中,我们将探讨更多在任务之间共享数据以及同步它们操作的方法。

同步原语

asyncio 包提供了 locksemaphoreeventcondition 类,它们与我们在 concurrent.futures 上下文中查看的类非常相似。它们提供相同的方法名称并履行相同的角色。重要的区别是,对于 asyncio 的版本,其中一些方法是协程,而另一些则不是,如下所示:

图片

具体来说,在每种情况下,如果存在,acquirewait 方法都是必须通过 await 调用的协程。

这是因为它们需要能够暂停直到某些特定的事情发生,而只有协程可以暂停并将控制权交给调度器。提到锁和其他类后,我想指出,虽然它们有时是必要的,但在 asyncio 中比在 concurrent.futures 或其他提供多个执行流系统的场景中需要的频率要低。

这是因为 asyncio 的调度是合作的。只有在执行流和 await 表达式之间切换时才有可能,这意味着如果在代码的临界部分没有 await 表达式,它就不能被中断。

没有其他任务可以在同一时间修改相同的数据,因为没有其他任务有机会在那时运行任何代码。此外,Lock 和其他类仅在代码的临界部分确实至少使用一次 await 时才需要。

我们在上一章讨论 concurrent.futures 时已经见过 as_completedwait 函数。asyncio 的版本是协程,因为它们也需要挂起直到继续执行的时间,但我们在使用它们的方式上并没有太大的区别。

等待协程

等待协程仍然会等待一组未来任务以可选的超时结束,并返回一个已准备好的未来任务列表和一个在超时时刻尚未准备好的未来任务列表。as_completed 函数仍然接受一个未来任务列表,并按结果可用顺序产生结果的未来任务。然后,我们通过等待从未来任务中提取实际值,我们就准备好了。

如以下代码示例所示,无法预测结果将按何种顺序可用;然而,每次有值可用时,它都会被打印出来:

图片

asyncio 提供了一些其他有趣的协程来从未来任务中收集数据,特别是:wait_forgather

wait_for 协程

wait_for 协程允许我们等待另一个协程完成,但带有超时。以下代码示例中的前两个协程做的是同样的事情,只不过如果 foo5 秒内没有完成,第二个版本将引发 asyncio 超时错误:

图片

在第三个代码块中,我们仍在做同样的事情,只不过如果 foo 超时,我们会打印一条消息。然后,还有 gather 协程。

gather 协程

gather 协程所做的是,它接受一系列未来,并将它们转换成一个单一的未来,当所有子未来都完成时,这个未来将完成,并且子未来的结果将作为子未来结果列表中的结果,如下面的代码示例所示:

图片

这样的事情有很多用途,但我们可以用它来构建传递给 run_until_complete 的未来,这是一件非常棒的事情。

实际上,我们是在告诉 asyncio 它应该运行,直到所有这些未来都完成。未来非常适合在任务之间传递一次性值,而事件对象非常适合发送简单的信号。然而,有时我们想要一个功能齐全的通信通道。幸运的是,asyncio 为我们提供了 Queue 类及其一些基于它的变体。

asyncio 队列类

asyncio 的 Queue 具有作为协程的 putget 方法。因此,我们需要用 await 调用它们,并且我们必须已经在协程中调用它们,除非我们实际上使用 ensure_future 函数将它们作为单独的任务启动,如下面的 Queue 类代码示例所示:

图片

然而,Queue 类也有名为 put_nowaitget_nowait 的方法,它们不是协程,可以从任何地方调用。这使得 Queue 类对于将新数据传达给协程系统以及协程任务之间的数据传输非常有用。

队列类型

asyncio 提供了几种变体的 Queue 类型,它们以不同的顺序返回它们存储的值。

当我们调用 getget_nowait 时,PriorityQueue 的实例会根据小于比较返回它们包含的最小对象。所以,如果我们的优先级队列包含 342597,调用它的 get 协程将返回 2。下一次,它将返回 5,然后是 34,最后是 97

另一方面,LifoQueue 方法总是返回最近添加的对象。换句话说,它是一个栈数据结构。asyncio 也提供了一个可连接的 Queue 类,它增加了一个额外的连接协程和一个名为 task_done 的方法。通过一点额外的工作,使用可连接的队列可以让协程暂停并等待直到队列被清空。

网络通信

因此,我们已经介绍了 asyncio 的工作原理以及一些可以用来管理多代码流执行的工具。这些都很好,但用 asyncio 做一些实际的 I/O 呢?

人们使用异步 I/O 的主要动机是因为它在编写网络客户端和服务器时很有帮助,尽管这当然不是唯一可能的用途。所以,asyncio 不仅使网络通信高效,而且使它们变得简单。

在 asyncio 中创建一个简单的客户端

这里,我们有简单客户端-服务器程序对的代码(参考下面的代码示例):

它们不仅一次又一次地读取和写入相同的几个字节,而且还有助于展示通过网络进行通信所需的一切。

关于客户端的信息将很少神秘。

运行以下命令:

python3 client.py

它只运行一个任务,该任务使用 asyncio 的高级 API 打开一个连接,然后通过它发送和接收数据。数据只是一串数字,如下所示:

在 asyncio 中创建一个简单的服务器

服务器可以同时处理来自许多客户端的连接,因为我们调用的start_server协程每次有客户端连接到服务器时都会启动一个新的任务来运行start_serve协程。

每个任务负责处理单个客户端的连接,因此服务器协程几乎和客户端协程一样简单。

有一些额外的代码来处理连接重置错误,这是当客户端在服务器试图从它读取数据时突然断开连接时引发的异常,还有一些额外的代码来处理请求是空字符串的类,readline 协程只能在客户端以不那么突然的方式关闭连接时产生。

处理客户端断开连接

在这两种情况下,我们希望服务器停止关注特定的客户端,我们可以简单地通过从客户端处理协程中返回来实现。运行协程的任务完成,事情就这样了。

在服务器上启动的协程中,我们调用了另一个名为wait_closed的协程。这基本上就是它所说的那样——等待服务器关闭。如果没有这个调用,我们启动的协程将立即终止,由于我们使用了run_until_complete,整个程序将在之后立即终止。

这将发生是因为start_server启动了一个后台任务然后返回,而不是直接管理服务器,这就是全部。

asyncio 提供了一个更低级的通信 API,但在绝大多数情况下,这个低级 API 是不必要的。asyncio 使网络通信变得简单。

摘要

在本章的早期部分,我们学习了关于协程、协程任务之间的数据交换和异步操作的内容。我们查看了一下如何使用 future 等待单个值或异步迭代器,这些迭代器可能内部使用 future 等待一系列值。我们还查看了一些我们可以用来向异步协程任务发送和接收数据并在必要时对它们进行同步的工具。

现在我们已经看到了如何使用这些工具从协程和异步操作中获得回报来编写网络客户端或服务器。在下一章中,我们将探讨 Python 程序源代码中可以重新定义的各个部分以及如何使用它们。

第八章:元编程

在上一章中,我们讨论了异步 I/O 和协程。在本章中,我们将注意力转向元编程和可编程语法。我们将讨论 Python 允许我们控制或更改语法元素意义的各种方法,并有益地使用这些特性。

我们将探讨 Python 的另一个可编程语法特性,它与函数装饰器很好地结合在一起。我们还将讨论类装饰器以及它们与函数装饰器的相似之处和不同之处。然后我们将看到使用元类以不同方式程序化修改类的方法。接下来,我们将转向一个不那么神秘的课题,讨论上下文管理器。最后,当我们查看描述符时,我们将探讨另一种编程 Python 基本操作语义的方法。

元编程是一个总称,用于描述程序使用程序代码或直接从程序代码构建的数据结构作为数据来操作的技术。Python 有许多不同的特性,可以被认为是元编程。

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

  • 使用函数装饰器

  • 函数注解

  • 类装饰器

  • 元类

  • 上下文管理器

  • 描述符

使用函数装饰器

在本节中,我们将探讨最普遍的函数装饰器之一。我们将看到如何构建一个装饰器,如何使用它,以及它是如何工作的。

装饰器的基本定义很简单。它只是一个接受另一个函数作为输入,对其进行一些操作,然后返回其操作结果的函数,如下所示:

图片

返回值替换了原始输入函数,因此装饰器可以做出的更改可能是相当剧烈的。一个完全不进行任何更改的装饰器是一个接受一个参数并立即返回它的函数。

在函数装饰器中使用@语法

Python 有一种特殊的语法来将装饰器应用于函数,使用@语法。使用这种语法,我们只需写一个@符号,后跟一个评估为装饰器函数的表达式。我们将它放在我们想要装饰的函数定义之前的行上,如下面的代码示例所示:

图片

这种语法意味着一旦我们完成函数的定义,我们就调用装饰器函数,然后将装饰器的返回值赋给原本将包含函数的变量,如下所示:

图片

全局装饰器 - @staticmethod

Python 在全局命名空间中包含了一些装饰器,并在标准库中包含了一个不断增长的装饰器列表。最常用的全局装饰器是 @staticmethod。它使得类成员函数可以通过类而不是实例来调用,就像其他语言中的 @staticmethod 装饰器一样。以下截图展示了 @staticmethod 的代码示例:

图片

还可以在 def 前使用多个 @ 行;这样,多个装饰器将被调用。最接近 def 的装饰器将首先被调用,然后其返回值将传递给下一个最接近的装饰器,依此类推。最终,最顶层装饰器的返回值将被分配给包含作用域中函数的名称,如下面的代码示例所示:

图片

属性

装饰器最常见的一个用途——实际上,这就是它们被称为装饰器的原因——是向函数对象添加属性。这些属性可以被程序其他部分的代码用来区分装饰过的函数和未装饰的函数。

添加属性很容易。在装饰器内部,将一个属性分配给函数,就像我们为任何其他对象做的那样,然后返回它。然后,在代码的其他地方,检查该属性并根据需要进行响应。

这一切都很不错,而且通常非常有用,但我们还可以用装饰器做更多的事情。例如,我们可以在调用函数前后包裹一个执行某些计算的包装器。

在包装器中包裹函数

要在包装器中包裹一个函数,首先我们希望函数在装饰器内部找到包装器(参考以下代码示例)。如果你之前没有见过,它看起来就是这样——wrapper 函数的定义实际上位于 @ints_only 内部。所以当 @ints_only 被调用时,它定义并返回 wrapper 函数。

每次调用 @ints_only 时,它都会定义一个新的 wrapper 函数。

当一个函数在另一个函数内部定义时,包含函数的局部变量仍然对内部函数可用。参考以下代码示例:

图片

在前面的例子中,wrapper 对函数参数进行了一些操作,然后调用被包装的函数并返回其结果。我们在前面的例子中导入了并使用了名为 @wraps 的装饰器。

@wraps 装饰器的工作非常直接;它使包装器看起来像被包装的函数,以便于 pydoc 等工具使用。然而,@wraps 接受一个参数。

如果装饰器总是只接受一个参数,并且这个参数是它被应用到的函数,那么它将如何工作?让我们来看看。

@wraps 装饰器

关键是 @ 符号后面不是跟一个装饰器的名字;它后面跟着一个表达式,这个表达式会评估为一个装饰器。所以,wraps 实际上不是一个装饰器。严格来说,它是一个返回装饰器的函数。

当 Python 评估函数调用表达式时,@wraps 返回一个装饰器函数,然后将其应用于我们的包装器。

唯一函数

如果我们要修改我们的 @ints_only 装饰器,以便我们可以指定一个可以应用于所有参数的任意函数,它看起来就像前面的例子。所以现在我们有一个名为 only 的函数,它返回一个装饰器,然后装饰器再返回一个包装器。这个包装器调用原始函数。

这可能看起来效率极低,但实际上,唯一的开销来自于调用包装器。每次我们调用函数时,外层的两层代码在函数定义时只会运行一次。所以这就是使用函数装饰器和体验我们可以用它们做什么的方法。

函数注释

在本节中,我们将探讨如何将元数据与函数关联起来,而不仅仅是与我们在第四章“基本最佳实践”中讨论的文档字符串关联。在前一节中,我们的一个示例是一个自动将所有装饰函数的参数通过适配器传递的装饰器。

这很酷,但如果我们想对每个参数进行不同的处理怎么办?

当然,我们可以向包装器传递一大堆适配器,但随着我们开始处理接受更多参数的函数,这会变得丑陋而笨拙。我们真正想做的就是直接将元数据附加到函数的参数上。幸运的是,这正是函数注释的作用。

函数注释语法

以下代码示例展示了 Python 的函数注释语法:

图片

为了将一个值与参数关联起来,我们在参数名后放一个冒号(:),然后写一个表达式。这个表达式在定义函数时会被评估,并将结果与参数名一起存储。

我们还可以通过在函数参数列表后写一个 -> 箭头符号,然后是一个表达式来注释函数的返回值,这个表达式在定义函数时也会被评估。结果会与单词 return 一起存储。因为 return 是一个关键字,所以它不会与参数名冲突。

访问注释数据

所有注释都存储在一个名为 __annotations__ 的字典中,这是函数本身的属性:

图片

如前述代码示例所示,注释不是类型声明,尽管它们当然可以用作这种目的,并且它们与某些其他语言中使用的类型语法相似,如下所示:

图片

它们是任意表达式,这意味着可以在__annotations__字典中存储任意值。它们对 Python 本身没有增加任何意义,除了应该存储这些值。话虽如此,定义参数和返回类型是函数注解的常见用途。

@no_type_check装饰器

如果你使用的是一个假设注解是类型声明的工具,但你希望将它们用于其他目的,请使用标准的@no_type_check装饰器来免除你的函数进行此类处理,如下所示:

通常情况下,这并不是必需的,因为大多数使用注解的工具都有一种识别它们的方法。装饰器是为了保护那些情况模糊的边缘情况。

将注解作为函数装饰器的输入

注解与装饰器很好地结合在一起,因为注解值是向装饰器提供输入的好方法,而装饰器生成的包装器是放置赋予注解意义代码的好地方。

例如,让我们重写上一节中的装饰器示例。我们将只接受关键字参数,以保持示例相对简单:

因此,adapted装饰器将函数封装在一个wrapper中。这个wrapper只接受关键字参数,这意味着即使原始函数可以接受位置参数,它们也必须通过名称指定。

一旦函数被包装,wrapper也会在函数的参数注解中查找适配器,并在将参数传递给实际函数之前应用它们。

一旦函数返回,包装器会检查是否存在返回值适配器;如果找到,它会在最终返回之前将适配器应用于返回值。

当我们考虑这里发生的事情的影响时,它们相当令人印象深刻。我们实际上修改了向函数传递参数或返回值的意义。

关键字参数

让我们看看另一个例子(参考以下示例)。有时,一个方法的一个或多个参数不需要任何处理,除了将它们分配给 self 的一个属性。我们能否使用装饰器和注解来使这种情况自动发生?当然可以。

假设一个参数被注解为一个字符串,那么分配给该参数的值将被分配给 self 的一个属性,使用字符串作为属性名。如果参数被注解为 true,则属性将具有与参数相同的名称。如果没有注解,或者注解既不是字符串也不是 true,则不会发生任何操作。

再次,为了简单起见,让我们限制自己只使用关键字参数。正如前一个示例中所示,注解简化了代码库中所有类型的代码操作。在这里,我们基本上使用了与上一个示例相同的技巧,但我们用它们做了完全不同的事情。

我们一直将装饰器视为函数注解的主要消费者,但这并不一定是事实。任何使用函数对象的代码都可能被编写为从注解中受益。这意味着,在任何我们传递函数作为回调的地方,我们都有可能使用函数注解数据来使代码更智能地处理函数。

以下列表中展示了一些可能性:

  • 事件处理器可以用处理器想要接收的值的名称进行注解

  • 依赖注入可以以类似的方式自动化

  • 基于约束的系统可以提供可以应用于每个参数的约束

  • 概率推理系统可以用先验概率分布进行注解

  • 参数可以用适当的用户界面元素进行注解,以便显示,以便用户输入该参数的值

检查包签名函数

在我们结束本节之前,我想指出一个可能对未来有帮助的事情。我们之前使用的示例装饰器都是限于关键字参数以保持简单:

图片

然而,如果你发现自己想要做类似的事情,同时还要处理各种参数,inspect 包的 signature 函数将显著简化这个过程。

因此,函数注解是向函数添加元数据的好方法;然而,它们可能会以各种方式影响后续对函数的处理。

类装饰器

在本节中,我们将探讨类装饰器,它们在概念上与函数装饰器相似,但打开了不同的途径。

类装饰器的工作方式与函数装饰器基本相同。类装饰器接收类作为其唯一的参数,它返回的任何内容都将替换那个类。这在下图中得到了说明:

图片

返回值不必是相同的类,甚至根本不需要是类,但它应该是具有意义的东西。当它绑定到类的名称时,装饰器返回空值通常是没有用的。

类似于函数装饰器,类装饰器可以修改类的属性或用包装代码包围整个类。然而,修改类的属性实际上与修改源代码中的类是相同的。这意味着与函数不同,类装饰器实际上可以改变装饰代码的结构,而不仅仅是包装它。

修改类属性

修改类属性很简单;我们只需使用内置的getattrsetattrdelattr函数,如下面的代码示例所示:

图片

在前面的例子中,我们看到一个简单的类装饰器,它使装饰类的属性可以通过[]语法读取;同时,它确保类不允许你通过[]语法设置或删除值。虽然通过装饰器重写类可以是一个强大的技术,但它并不复杂或令人惊讶,所以对此没有太多可说的。

我们也可以完全封装类。这种技术的一个常见用途是它有助于用factory函数替换类。使用factory函数作为创建类实例的接口,让我们可以选择何时返回现有对象,如果有一个我们认为更合适的接口,而不是实际创建一个新实例。

工厂函数

调用一个factory函数意味着给我这些参数的正确对象,而不是给我这些参数的新对象。让我们看看一个用工厂函数替换类对象的示例类装饰器。

对于这个类的实例,我们将假设任何使用相同参数创建的两个实例实际上应该是同一个对象,如下所示:

图片

在前面的例子中,我们使用了WeakValueDictionary来跟踪类的现有实例以及与之构造的参数。

这与类装饰器本身没有关系;相反,我们这样做是因为我们不希望缓存阻止实例被垃圾回收。这是一个好的实践!

每当我们创建一个factory函数时,它会跟踪它创建的实例。在这个例子中,我们还展示了一个好的实践,即我们决定将类本身作为factory函数的属性。这意味着如果真的需要,factory函数外部的代码仍然可以访问类对象。

工厂构造函数

那么,让我们看看我们的工厂装饰器在实际中的应用。参考以下截图:

图片

注意,名为Unique的东西实际上是创建给Unique类的factory函数,而不是Unique类本身。实际的类最终被命名为Unique.type。此外,注意u1u3不仅相等,而且是同一个对象;而u2,它使用不同的参数创建,是不同的。

类定义

现在我们将看看一些真正疯狂的事情。我们用于定义类的语法相当通用;它可以用来表示各种不同的数据结构。那么,为什么不用类装饰器将类定义转换为各种类型的对象呢?

通过这样做,我们可以实现一种侧向装饰性编程范式。以我们的示例(接下来将要提到的那个)为例,假设我们想要连接到一个 sqlite 数据库,并在它不存在的情况下创建一些表。我们可以利用 Python 的类语法来方便地表达这个想法:

图片

我们希望使用方式类似于以下代码示例,其中类结构和属性提供了构建和配置数据库连接所需的信息。最终结果应该是一个connection对象,我们可以使用它来根据 Python 数据库 API 发出查询。

这个例子忽略了或以简单方式处理了许多细节,但它捕捉了基本概念。Python 在评估这些语句时自动创建的类对象,用于向名为@database的装饰器提供结构化数据输入,然后被丢弃。

以下代码示例展示了@database装饰器:

图片

@database装饰器返回一个打开的 Python 数据库 API 连接对象,而不是任何类型的类。

元类

在本节中,我们将探讨元类,它从一开始就影响类对象的创建。

类似于类装饰器,元类是我们用来调整类基本意义的一种工具。在概念上,它们非常不同。类装饰器接受一个已经创建的类,并以某种方式对其进行转换。另一方面,元类可以影响类的创建方式、行为方式,甚至影响从修改后的类继承的类的创建和行为。

要理解元类,首先我们必须掌握一个概念,即类是对象,而且不仅仅是对象,它们是另一个名为type的类的实例。每次我们创建一个新的类时,我们都会创建一个type的实例,除非该类有一个元类,如下所示:

图片

如果我们创建的元类被指定或从其祖先继承了一个元类,那么新类是元类的一个实例,而不是type的直接实例。

这听起来我们可以通过提供一个不寻常的元类来完全改变类的行为,但实际上所有元类都必须类似于type,否则 Python 无法正确使用它们。大多数情况下,元类实际上是type的子类,这使得事情变得简单。

我们可以用元类做什么?

首先,我们可以在class块内的代码被评估之前,为每个类的元类实例运行代码。我们通过将元类设置为__prepare__方法来实现这一点,这应该是一个类方法或静态方法,因为它将在实例创建之前被调用。

__prepare__方法

__prepare__ 方法会传递新类的名称、其父类列表以及用户提供的任何关键字参数。它可以执行我们想要的任何操作,但它应该返回一个字典或类似的对象,可以用来存储类的属性(参考前面的示例)。

我们可以在 __prepare__ 内部预先分配属性字典的值,这样我们实际上可以在类存在之前就分配属性。这带我们来到了元类可以轻松控制的第二件事——类的命名空间

在我们之前的示例中,我们从 __prepare__ 返回了一个 dict() 实例,因此这个元类的实例在代码评估期间使用正常字典来存储它们的属性;然而,我们可以从 __prepare__ 返回任何类似字典的对象。例如,如果我们想跟踪属性创建的顺序,我们可以返回 OrderedDict,或者如果我们想所有属性都有一个默认值,我们可以返回 DefaultDict

我们甚至可以使用 WeakValueDictionary,如果出于某种原因,我们希望类在评估期间不保护其属性不被垃圾回收。当然,WeakValueDictionary 是一个存在于标准库中的类似字典的类。

我们还可以从 __prepare__ 返回一个自定义的类似字典的类,这几乎可以做任何事情。如果我们想要一个在代码评估时忽略属性名称大小写的类,我们可以做到这一点。

__new__ 方法

我之所以一直说 在代码被评估时,是有原因的。在调用 __prepare__ 之后,类块内的代码会被执行,并使用 __prepare__ 返回的字典作为其命名空间,如下所示:

图片

然而,之后会调用元类的 __new__ 方法。__new__ 需要做的一件事是调用 type.__new__ 来实际分配和初始化一块内存以包含类数据,而 type.__new__ 做的一件事是将我们传递给对象命名空间的任何内容转换为正常的 dict

这意味着如果我们想保留 namespace 对象知道的特殊信息,我们需要将其存储在我们可以稍后找到的地方。

我们可以对类的内部进行任何我们想要的更改,如下面的代码示例所示:

图片

一旦我们在元类的 __new__ 方法中创建了类对象,我们就可以像在类装饰器中一样编程地添加、删除、替换或包装类内容。我们也可以返回实际上根本不是类对象的东西,就像我们可以在类装饰器中做的那样。

除了多写一点之外,区别在于具有元类的类的子类也会继承那个元类,而类装饰器则不会继承。

这意味着,使用元类,我们可以使我们的非同寻常的行为可继承。

在这个例子中,你了解到任何从最初应用元类的类派生的类都可以找到所有其他也从该祖先派生的类。

上下文管理器

在本节中,我们将探讨可能是 Python 最常用的可编程语义元素——上下文管理器。

上下文管理器是代码片段,可以插入到 Python 的 with 语句中。一个 with 语句包含一个代码块,上下文管理器能够在该代码块执行前后运行自己的代码,以及保证无论代码块中发生什么都会运行的代码。

Python 标准库大量使用了上下文管理器:

  • open 文件可以用作上下文管理器,这保证了文件将在代码块结束时关闭:

图片

  • lock 对象可以用作上下文管理器,在这种情况下,它们在代码块开始前获取锁,并在代码块执行完毕后释放锁:

图片

  • SQLite 数据库连接可以用作上下文管理器,允许它们在代码块结束时自动提交或回滚事务:

图片

还有其他示例。我们已经在前面的示例中看到了上下文管理器是多么有用。它们通过组合设置和清理来简化代码,并通过保证它们将运行清理代码来改进代码。

将上下文管理器定义为生成器

那么,我们如何编写自己的上下文管理器呢?有两种方法。

最简单的方法是在生成器函数上使用 @contextlib.contextmanager 装饰器,如下面的示例所示:

图片

以这种方式创建上下文管理器时,我们可以将其编写为一整段代码。我们可以将 yield 语句视为 with 语句包含的整个代码块的代理。

如果这个块引发异常,它将把上下文管理器代码视为如果 yield 语句负责引发那个异常,因此我们可以用 try 语句包裹它来处理可能发生的任何异常。

当我们使用文件打开作为上下文管理器的示例时,我们看到了 with 语句的 as 子句(请参阅 open 文件的代码示例);它允许我们将上下文管理器返回的值赋给 with 块内可访问的变量。如果我们从上下文管理器代码中产生一个值,那么这个值将通过 as 赋值。

在前面的示例中,我们产生一个打印单词 during 的函数,以便 with 语句的整个结果按顺序打印 beforeduringafter

将上下文管理器行为添加到类中

我们还可以通过向对象添加__enter____exit__方法来编写上下文管理器。任何正确实现这些方法的对象都可以用作上下文管理器,这就是为什么像打开文件和数据库连接这样的对象能够作为上下文管理器额外工作。

基于同步协程的上下文管理器

以下是一个示例,其中我们创建了一个字典的专用版本,它可以作为上下文管理器使用:

图片

with块的作用域内,我们可以通过从__enter__返回的对象读取和写入数据,但这些更改只会应用于主字典。如果块退出而没有引发异常,则内部方法的返回值将由with语句用于通过as子句分配的值。

参考以下代码示例,变量trans包含ChainMap实例。ChainMap对象是具有父字典的字典。如果在ChainMap中查找'a'失败,它会尝试在其父字典中查找相同的键。

图片

__exit__方法需要接受指定类型exc_typeexc_valtb的参数,如果在with块中引发异常。如果没有引发异常,所有这些参数都将包含None。如果引发异常,我们需要决定上下文管理器是否以及如何处理它们。

在我们前面的示例中,我们决定根据是否引发异常来应用更改到主字典;否则,我们会忽略异常。如果我们想让 Python 考虑异常已被处理,我们可以从__exit__方法返回true

这在功能上等同于使用try-except语句捕获函数。还有基于类的上下文管理器的另一种变体,它支持基于异步协程的上下文管理。

基于异步协程的上下文管理器

对于异步协议,__enter____exit__方法被替换为__aenter____aexit__协程方法,上下文管理器通过async with语句调用,如下所示:

图片

这个小小的改动让我们能够使__enter____exit__方法调用其他协程,等待从网络传入数据,并在基于asyncio的程序中表现得很好。

描述符

在本节中,我们将探讨一种最后一种改变基于 Python 语法的语义的方法,即使用描述符。读取和写入变量是编程中最基本的部分之一。Python 的描述符允许我们改变其工作方式。

描述符是一个存储在类中的对象,它控制了对于该类的实例来说获取、设置和删除特定单个属性的含义。如果我们想要对多个属性有这种控制,我们只需为每个我们想要控制的属性向类中添加一个描述符。

使用@property创建描述符

Python 的内置@property装饰器提供了一种简单的方式来创建描述符。让我们考虑一个示例(参考以下代码示例)来阐述这一点:

图片

在前面的代码示例中,我们编写的第一个prop方法告诉 Python 如何确定一个名为prop的属性的值,在这种情况下,这仅仅意味着从另一个属性中获取它并打印其值。

后两个prop方法被装饰以将它们转换为prop属性的setterdeleter。这意味着将值分配给一个属性实际上意味着调用setter方法,而删除一个属性实际上意味着调用deleter方法。

这两种方法对于属性都是可选的。省略它们会使属性描述的属性成为只读属性。

将描述符作为类编写

属性简化了常见情况下描述符的构建,但也有一些用例需要我们创建属性无法很好地处理的描述符。例如,如果我们计划创建一个表示远程数据的类,并且希望其属性从远程源推送和拉取数据,我们可以使用属性来实现,但最终我们会反复编写非常相似的代码来实现每个属性。

最好有一个RemoteResource描述符类,并仅向我们的本地存根类添加大量实例。让我们使用RemoteResource描述符作为示例继续这样做;参考以下代码示例:

图片

实际上,与网络交互需要相当多的代码,所以能够避免反复重复它是件好事。

在前面的示例中,我们拥有的RemoteResource类有__get____set____delete__方法,这些方法决定了当由该类的实例控制的属性被访问时会发生什么。

  • __get__方法可能令人惊讶地接受两个参数——通过该参数访问属性实例和通过该参数访问属性的类。这样做是为了我们可以处理instance属性访问和class属性访问。

    • 当访问一个class属性时,instance参数是None。在我们的情况下,我们只是返回了描述符,以防有人试图将属性作为类成员而不是实例成员来访问,这在很多情况下是一个合理的默认值。
  • __set__方法接收一个实例和值作为参数,在概念上表示将该实例的控制属性设置为该值。与__get__不同,它不支持设置class属性,因此实例永远不会是None,我们也不需要class_parameter

  • __delete__方法只是传递一个实例,表示从该实例中删除控制属性。

没有任何描述符方法被告知它们代表哪个属性。假设它们的self参数会以某种方式指定这一点。

在我们的代码(在先前的代码示例中),我们选择将必要的信息传递给描述符的构造函数,并将其存储为self的一个属性。但在其他情况下,我们可能会使用selfselfs ID作为字典中的键来存储描述符的每个实例状态,或者将实例作为存储在self中的字典的键,如下面的代码示例所示:

class Record:
    name = RemoteResource('name')
    age = RemoteInt('age')
    def __init__(self, pk, reader, writer):
        self.out = writer
        self.in = reader
        self.pk = pk

尽管如此,我们并不能仅仅将每个实例的数据存储为self的属性。描述符是类的属性,而不是实例的属性,因此它们的self值被包含它们的类的所有实例共享。无论如何,我们可以控制获取、设置或删除实例属性的含义。

一旦我们有了RemoteResource类,创建具有远程属性的类就变得容易了,正如前面图像中显示的Record类所示。

摘要

在本章中,我们看到了几种改变 Python 代码含义和执行方式的其他方法,使我们能够使语言符合我们的特殊需求。

我们看到了函数装饰器如何使用函数作为操作的数据输入。我们考察了函数注解,特别是它们与函数装饰器的交互。我们看到了类装饰器是如何像函数装饰器一样工作的,但由于它们操作的是类,所以可能性非常不同。我们看到了如何使用装饰器修改类、包装它们或甚至用装饰器替换它们。我们讨论了如何使用元类来影响类对象的构建,以及如何通过将其作为类元类的一部分来使异常行为可继承。我们考察了上下文管理器,包括同步和异步的上下文管理器。我们看到了上下文管理器是如何工作的,并学习了如何为我们自己的同步或异步代码创建上下文管理器。我们看到了如何使用property函数创建简单的描述符,以及如何创建更复杂的描述符作为类。

在下一章中,我们将探讨自动化单元测试——测试一组可能显著提高编写程序过程的技术。

第九章:单元测试

在上一章中,我们看到了 Python 中元编程和可编程语法的各种方法。在这一章中,我们将探讨单元测试背后的理念,然后转向我们可以使用的一些测试自动化工具,使我们的测试更容易、更有用。我们将关注单元测试是什么,以及推动它的理念。我们还将讨论 Python 的标准unittest包及其工作原理。

最后,你将学习如何使用unittest.mock来控制测试代码运行的 环境,以确保测试能够专注于确保一件事情正常工作。

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

  • 理解单元测试的原则

  • 使用 unittest 包

  • 使用 unittest.mock

  • 使用 unittest 的测试发现

  • 使用 nose 进行统一的测试发现和报告

理解单元测试的原则

测试通常是程序员事后才考虑的事情,因为它往往既费力又令人烦恼。此外,我们通常对自己的工作有很高的信心,测试似乎是不必要的。然而,这也是一个事实,即这种信心往往被误放。源代码是一种复杂而微妙的语言,在编写它时很容易出错,甚至没有注意到。我们从经验中都知道这一点,但这并不使为这种费力、令人烦恼且感觉不必要的事情腾出时间变得容易。以下流程图说明了测试的简单示例:

因此,关于测试的第一个问题是,我们如何以不感觉像是一种痛苦的浪费时间的方式来进行测试? 找到克服这种心理障碍的方法是创建一种对许多程序员真正有效的测试方法的第一步。单元测试通过减少运行测试所需的努力,将测试与开发过程集成,并使测试本身变得明显有用来实现这一点。

什么是单元测试?

首先,让我们弄清楚什么是单元测试。单元测试是一小段测试代码,它测试一个隔离的小块程序代码中的正确行为或单个特定的缺陷。

每个定义部分都有原因。单元测试是源代码,因为单元测试的一个秘密是我们将尽可能多的测试努力放在计算机上,这是它应该属于的地方。

测试代码告诉计算机如何执行测试,这使得我们能够经常且容易地执行测试。单元测试之所以小,是因为大测试几乎不可避免地会测试多个事物。

这可以总结为:

  • 简单、简单的代码

  • 检查程序的一小部分

  • 回答关于程序功能的一个是或否问题

如果我们要测试多个事物,我们应该编写多个测试。

单元测试有两个规则。这些是:

  • 单元测试只检查程序代码的一个方面,因为当测试失败时,我们希望它能确切地告诉我们问题是什么。

  • 单元测试只涉及程序代码的一个狭窄区域,因为当测试失败时,我们希望它能确切地告诉我们问题所在。

如果我们编写了一组遵循这些规则的测试,它们就被称为单元测试套件

使用适当的工具,我们可以用单个命令运行整个测试套件,这个命令的输出将立即告诉我们代码相对于测试的状态。如果测试失败,它会告诉我们接下来需要做什么。如果测试成功,它给了我们一个理由来增强我们对测试代码的信心。

自动化单元测试的可用性导致了一种称为测试驱动开发TDD)的编程范式,如下面的图示所示:

图片

TDD 的基本思想是,由于失败的测试告诉我们下一步该做什么,因此我们不应该在除了想要让失败的测试通过之外的情况下编写程序代码。如果所有当前可用的测试都通过了,而程序还没有完成,我们首先向测试套件中添加另一个测试,然后编写程序代码使其通过。

以这种方式做事可以确保有测试覆盖了大部分或所有源代码,并且测试经常运行,这使得错误和回归很难在不被注意的情况下悄悄进入代码。这也让我们可以将开发过程分解成一系列短期目标,当我们实现这些目标时,会产生明显的成果。

这在心理上是有益的,因为它使编程过程感觉更有成效,而且执行感觉有回报的任务要容易得多。此外,调试往往占据项目所需的大部分时间,而 TDD 减少了处理错误所需的时间。

因此,当正确应用时,单元测试的原则和工具帮助我们产生更好的代码,更快地执行测试,并享受这个过程。这是一个全面的胜利。

到目前为止,我们已经讨论了自动化单元测试和 TDD 的原因和好处。Python 包含了一个自动化单元测试的框架,我们将在下一节中对其进行探讨。

使用 unittest 包

在本节中,我们将探讨 Python 的标准unittest包。我们将讨论如何构建测试文件,如何编写测试,以及在这些测试中实际发生的情况与应该发生的情况之间的比较。让我们直接进入正题!

构建测试文件

unittest 模块包含了一个执行自动化单元测试的框架。大部分功能都是围绕unittest.TestCase类构建的,我们将从这个类继承以创建自己的测试。

在以下示例中,我们可以看到TestCase的基本功能在实际操作中的表现,以及测试和断言方法:

图片

我们使用从TestCase类继承的类定义的任何方法,并且其名称以单词test开头,都被假定为单元测试。在先前的例子中,这意味着test_addition方法是一个单元测试;然而,如果我们向类中添加另一个名为connect的方法,unittest 模块不会将其视为单元测试。

TestCase类可以包含多个单元测试,并且应该在那些测试在逻辑上相关且需要相同的运行环境时运行。

assert 方法

在我们的单元测试test_addition方法中,我们使用了一个名为assertEqual的方法来实际检查代码的结果是否符合预期。TestCase提供了一系列这些 assert 方法,用于测试我们的结果和预期之间的各种关系。这在下述代码示例中显示:

让我们更仔细地看看这些 assert 方法实际上做了什么:

  • 我们已经在之前的代码示例中看到了assertEqual方法;它检查两个值是否相等,如果不相等则使测试失败。assertNotEqual方法执行相反的操作,检查两个值是否相等,如果相等则使测试失败。

  • assertAlmostEqualassertNotAlmostEqual方法用于浮点数。

计算机处理浮点数的方式表明,本应完全相等的数字实际上在最低有效位上有所不同。例如,如果我们平方七的平方根,结果不是正好七,所以assertEqual会将其视为不相等。然而,assertAlmostEqual会认识到这两个数字在实用目的上是相同的。

  • assertGreaterEqualassertLessassertLessEqual方法检查它们参数之间的顺序关系。

  • assertIsassertIsNot方法检查它们的参数是否是相同对象的引用。

  • assertIsNoneassertIsNotNone方法是assertIsassertIsNot方法的特例,并检查它们的单个参数是否实际上是None

  • assertIsInstanceassertIsNotInstance方法检查第一个参数中的对象是否是其第二个参数中类型的实例。

  • assertInassertNotIn检查第一个参数中的对象是否是第二个参数中容器的一个成员。

  • assertCountEqual方法很有趣。如果我们想检查两个序列是否相同,我们可以直接使用assertEqual,但assertCountEqual是在我们想检查两个序列包含相同的值但不在乎顺序时使用的。

如果任一序列中的任何成员在另一个序列中出现的次数不同,该方法将导致测试失败。因此,如果a在第一个序列中出现两次,它必须在第二个序列中也出现两次,但我们不在乎它在哪。

  • 最后,我们有assertRaises,它的工作方式略有不同,因为它需要捕获运行某些代码时抛出的异常。这是一个非常适合上下文管理器的情况,这就是assertRaises的作用。

with语句中使用,如果with块内的代码没有抛出预期的异常,assertRaises会使测试失败。这看起来可能有些反直觉,但这是正确的。如果预期的异常没有被抛出,测试就会失败。有时抛出异常是正确的行为。例如,将None传递给构造函数的末尾应该抛出一个类型错误,如果没有抛出,那就是一个错误。

比较单元测试中发生的情况和应该发生的情况

我顺便提了一下,一个TestCase类中的所有单元测试都应该共享相同的操作环境。这意味着什么?

这意味着它们中的每一个都期望它们访问的任何外部数据都处于相同的状态。例如,每个测试都访问一个特定的文件,并且每个测试都期望在文件中找到相同的信息。

让我们看看一个代码示例:

图片

在前面的示例中,我们有两个测试,它们都在同一个文本文件中读写。它们都期望在开始运行时包含相同的具体信息。换句话说,它们对它们的操作环境有相同的期望。

当我们有多组具有相同期望且逻辑上相关的测试时,我们应该将它们组合成一个单独的TestCase类。然后,我们应该给这个类提供一个setUp方法,该方法是负责确保这些共享期望得到满足的,可能还有一个tearDown方法,该方法是清理setup可能做出的任何更改或测试留下的任何更改。

类的名称本身并不重要;仅仅从TestCase继承就足以识别它们。

setUp方法在TestCase中的每个单元测试之前运行。因此,在我们的代码示例中,它有两个单元测试,setUp运行两次。同样,tearDown在每个单元测试之后运行。这样,一个测试可能对操作环境所做的更改在下一个测试运行之前就会被清除。

TestCase中的每个单元测试的起始环境都是相同的。因此,这就是 Python 单元测试框架在编写测试方面的基本机制。

要运行测试,我们只需要从命令行调用unittest包。我们告诉它我们想要从中运行测试的模块的名称,它会找到该模块中的TestCase类,创建它们的实例,运行所有测试,并给我们一个报告,告诉我们哪些测试通过了,哪些失败了。

在本节中,我们看到了如何编写基本的单元测试并运行它们。还有更简单的方式来运行测试,但我们在检查单元测试模拟对象之后会讨论它们。

使用 unittest.mock

在本节中,我们将探讨一个单元测试子包,称为mockmock包中的工具帮助我们保持测试的隔离性,因此它们不是基于代码的行为来成功或失败,而这些代码本不应该被测试覆盖。

我们讨论了单元测试只与一小部分代码交互的重要性,但当我们有这么多代码与来自整个源树的代码和函数交互时,我们如何安排这种情况?一个答案是我们可以用模拟对象替换那些对象和函数。

什么是模拟对象?

模拟对象是一段巧妙的代码;它可以假装成几乎任何类型的对象或函数,但它不会执行原始对象所做的任何事情,而是记录与之交互的内容,以便我们稍后进行检查。让我们暂时玩一下模拟对象,以了解它们:

图片

参考前面的截图。我们可以访问模拟对象的几乎所有属性,而无需事先定义它。结果是另一个模拟对象。同样,我们可以调用几乎任何我们想要的方法,而无需事先定义它,结果是另一个模拟对象,如下所示:

图片

仅此一项就足以让模拟对象替代我们测试代码可能与之交互的大量函数和对象。但是,如果我们花时间预先配置我们的模拟对象,我们还可以更进一步。

预配置模拟对象

我们可以将非模拟对象分配给模拟对象的属性,这样当我们访问属性时,我们会得到一个特定的值,而不是一个通用的模拟对象。以下简单的代码示例说明了这一点:

图片

我们还可以将自定义的模拟对象分配给方法,这样我们就可以使模拟方法更像原始方法,但以某种方式,这是由测试控制的。我们通过将返回值参数传递给mock构造函数来实现这一点,这告诉模拟对象每次被调用时都应该返回这个值,如下面的代码示例所示:

图片

如果我们希望模拟每次调用时返回不同的值,我们使用构造函数的另一个参数,称为side_effect,如下所示:

图片

我们必须知道测试将多少次将模拟对象作为函数调用,这样我们才能为每次调用提供返回值;否则,这不会构成困难。

我们还可以通过传递异常作为side_effectside_effect序列的成员来使模拟对象引发异常,如下面的代码示例所示:

图片

这基本上涵盖了如何创建一个模拟对象,在测试运行期间,以受控的方式代替真实对象和代码。然而,为了真正支持测试,我们还需要能够检查模拟对象并确认它是否按预期使用。

模拟对象的断言方法

我们已经看到了模拟对象使用的method_calls属性,用于跟踪它们的交互,但模拟对象也有它们自己的断言方法,通常比直接访问方法调用列表更容易使用。

最有用的模拟对象断言方法是assert_called_with(请参考以下代码示例):

图片

它检查最近对模拟对象的调用是否使用了指定的参数,以及assert_any_call,它检查模拟对象是否曾经使用指定的参数被调用。

因此,我们知道模拟对象的作用,如何创建它们,以及如何检查对它们的操作记录。这足以用模拟对象替换测试函数的参数。

我们甚至可以通过类而不是真实实例来调用方法,从而替换方法的self参数:

图片

unittest.mock 的 patch 函数

然而,当我们测试的代码自动向系统发出请求并访问我们想要用模拟对象替换的内容时,我们该怎么办呢?例如,如果我们正在测试的代码调用了time.time,那会怎样?这就是unittest.mock中的patch函数发挥作用的地方。

patch函数是一个上下文管理器,它可以临时用模拟对象替换任何包或模块中的任何对象。一旦退出 with 块,真实对象就会恢复到其位置,如下面的代码示例所示:

图片

需要注意的是,patch 不会替换对目标对象的每个引用,它只替换我们在第一个参数中指定的单个引用。

在前面的示例中,任何通过在time模块中查找引用来访问时间函数的代码都将得到我们的模拟对象;然而,如果有任何代码使用了from time import time来创建对time函数的局部引用,那么这个引用仍然会指向真实的时间函数。如果我们想为有局部引用的代码修补时间函数,我们需要将那个局部引用的路径传递给 patch。

好的,我们现在对模拟对象已经相当熟悉了。这意味着我们知道我们需要的一切来轻松编写强大的测试。我们剩下要做的就是找出如何运行我们的测试套件,这是我们下一个话题。

使用 unittest 的测试发现

在本节中,我们将探讨unittest包使用单个命令同时运行多个测试的能力。

我们已经看到了如何轻松地运行特定文件中的所有测试,但对于大型项目来说,将所有测试放入单个文件中将会很麻烦。它们需要根据逻辑分组分离到不同的文件中,否则测试套件将变得难以管理。另一方面,如果我们需要手动告诉 unittests 运行测试和一大堆文件,这将是一件痛苦的事情。

幸运的是,我们可以将测试套件拆分成多个文件,同时仍然可以通过简单的命令来运行它们,如下面的代码所示:

图片

我们使用支持测试发现的单元测试工具。这基本上意味着它会查看可用的文件,并自行决定哪些看起来像测试文件;然后它从这些文件中加载测试并运行。

Unittest 的发现工具

unittest 包有一个基本但有用的内置测试发现工具。当我们运行 python -m unittest discover 时,它会搜索当前目录中的 Python S,其名称以单词 test 开头。此外,它还会递归地对包含 init.py 文件的任何子目录执行相同的扫描。一旦收集到所有匹配模块的名称,它就会像我们自己在命令行上指定模块一样运行测试。这可以通过以下代码示例来说明:

图片

单元测试发现中的命令行选项

我们可以使用一些命令行选项来调整单元测试发现的行为。第一个,我们在之前的代码示例中看到了,是 -v 开关。此开关使得测试报告更加详细。我们在之前的代码中使用它,以便可以看到发现是否正常工作。

我们还可以使用 -p 命令行选项(如下面的代码示例所示)来更改用于识别测试文件的模式:

图片

在这里,我们将其修改为将以单词 one.py 结尾的文件识别为测试文件。

unittest discover 代码还识别 -s 来指定测试搜索应该开始的目录。这如下面的代码示例所示:

图片

注意,通过使套件作为搜索的起始目录,我们阻止了它被识别为包含测试的包。如果这成问题,我们可以通过添加 -t 选项来补充 -s 选项(参考以下代码示例),这会告诉你在哪里找到此运行的顶层目录:

图片

使用 -s-t 两个选项,我们能够将测试搜索缩小到特定的子目录,同时仍然在父目录的上下文中运行测试。

在使用单元测试发现代码或任何通过导入模块来检查是否包含测试的其他测试发现时,需要注意一些陷阱。这个陷阱是模块被导入。

大多数情况下,这不会成为问题,但如果一段测试发现代码导入了本应作为程序入口点的模块,它可能会实际运行程序,这不是我们期望的行为。当我们编写入口点时,通过将入口点代码包裹在if '__name__' == '__main__'语句中,很容易避免这个问题。

然而,如果我们或其他人跳过了这个检查,而单元测试认为文件看起来像是一个测试文件,运行单元测试发现代码将会得到令人惊讶的结果。这就是关于 unittest 测试发现工具的所有内容。它没有很多功能,但它确实拥有大家需要的功能,而且有很大可能性,对于我们的大多数项目来说,这已经足够了。

对于我们需要从测试发现工具中获得更多功能的情况,我们可以使用nose,我们将在下一节中探讨。

使用 nose 进行统一的测试发现和报告

注意,nose是一个通过pip和 Python 包索引提供的第三方工具。它基本上与 unittest 的discover命令做相同的工作,但它提供了更多的控制和定制,以及识别更广泛的测试。可以使用以下命令行安装:

python3 -m pip install nose

图片

使用 nose 运行我们的测试

我们将探讨 nose 提供的众多功能中的两个特定功能。这些是:

  • 它可以生成一个代码覆盖率报告,告诉我们我们的测试实际上测试了多少代码

  • 它可以在多个进程中运行测试,允许它们在多个 CPU 上并行执行

为了获取覆盖率报告,我们首先需要确保覆盖率模块已安装。我们可以使用简单的pip命令来完成,如下所示:

python3 -m pip install coverage

图片

一旦我们有了coverage模块,我们只需使用几个 nose 的命令行选项,就可以为我们的测试启用覆盖率报告。

严格来说,只需要--with-coverage选项来启用覆盖率报告,如下面的代码示例所示:

图片

然而,如果我们没有包括--cover-erase,之前的测试运行中的覆盖率数据将与当前的运行混合,这将使结果更难解释。

cover-package 选项

有一个与覆盖率相关的命令行选项有时很有用。它是cover-package选项;它将代码覆盖率报告限制为仅针对特定包,如下面的代码示例所示:

图片

以这种方式聚焦报告可以使阅读和提取有用信息变得更容易。

测试多个工作进程

我们将要探讨的另一个 nose 功能是能够将测试分配给多个工作进程,并将它们分散到可用的 CPU 核心上。要测试多个工作进程,我们只需提供--processes=命令行选项,并告诉它要使用多少个进程。如果我们传递-1来表示进程数,它将使用检测到的 CPU 核心数,这可能是我们想要的(参考以下代码示例):

图片

因此,除非我们有特定的理由要这样做,否则我们应该始终只使用-1

如果我们仔细观察前面的代码示例,我们可以看到在多个进程中运行我们的测试套件实际上花费了更长的时间。这是因为执行测试本身涉及到的努力很小,但当涉及到启动工作进程时,情况就不同了。幸运的是,这是一个固定成本,所以当我们开始运行包含更多昂贵测试的大型测试套件时,我们开始看到并行执行的好处。

这只是对 nose 支持的功能类型的一个尝鲜,而且这还不包括我们编写自己的 nose 插件来进一步定制它的情况。这是一个非常强大的系统,所以如果我们发现自己需要测试运行器中的特定功能,一个好的第一步是看看 nose 是否已经具备该功能。

摘要

在本章中,我们学习了如何使用unittestunittest.mock包来编写自动化测试;我们还学习了测试驱动开发的过程。接下来,我们看到了如何使用unittest.mock来控制测试代码运行的环境,以便测试可以保持专注于确保某件事正确工作。在此之后,我们学习了如何使用 Python 内置的单元测试工具运行测试,最后,我们讨论了如何利用 nose 测试运行器的几个功能。

在下一章中,我们将探讨响应式编程范式和 RxPY。

第十章:响应式编程

在上一章中,你学习了单元测试和unittest.mock包。在本章中,你将掌握响应式编程的概念,然后了解 RxPY 响应式编程框架。我们将巩固你对响应式编程的概念理解,并从头开始构建一个非常基础的响应式编程系统。

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

  • 响应式编程是什么意思?

  • 构建简单的响应式编程框架

  • 使用Python 的响应式扩展RxPY

响应式编程的概念

我可以以很多不同的和有效的方式来定义响应式编程。这取决于视角和焦点。哪一种定义最好?在本节中,我们将介绍几种定义。

也许,响应式编程最基本、至少在实现响应式编程系统时需要考虑的定义是它是一种事件处理发布/订阅模型。以下图表说明了基本的响应式事件处理:

图片

在传统的响应式编程命名法中,有可观察对象观察者,它们分别封装了事件发布者和事件订阅者的行为。在一个响应式编程系统中,一切,或者至少尽可能多的事物,都是一个可观察对象或观察者。到目前为止,一切都很顺利,但仅仅依靠发布/订阅模型本身并不那么令人兴奋。

当我们认识到可观察对象在概念上与列表非常相似时,响应式编程才真正发挥其优势,这意味着函数式编程工具,如 map 和 reduce,有强大的类似功能可以应用于可观察对象。因此,响应式编程的第二种定义是它是一种事件驱动的函数式编程。

比如说,我们可以取两个可观察对象,对一个中的对象应用一个函数,将结果与另一个合并,然后将合并序列减少到一个新值,这是一个强大的想法。我们可以提前描述我们想要执行的所有操作,每当根可观察对象之一产生新值时,它将通过我们的整个处理链级联,而无需我们进一步的努力。

函数式编程操作大多是状态无关的,当它们不是状态无关时,状态至少是容易定义和保持的。这意味着我们的以事件驱动为主、状态无关的响应式编程系统非常适合异步或并行执行。因此,我们对响应式编程的第三种定义是它是一种编写高性能异步或并行代码的系统化方法。

那么,什么是响应式编程呢?让我们把这些定义放在一起。响应式编程是一种事件驱动的范式,其中事件源可以对其应用功能操作符以创建新的事件源。这是可取的,因为它导致程序在异步或并行执行环境中表现良好。

构建一个简单的响应式编程框架

您已经从理论上理解了响应式编程的含义。现在,我们将非常具体地构建一个简单的响应式编程系统,然后构建一个演示,以便我们可以观察其运行情况。形式化响应式编程的根源在于静态类型语言,尤其是 C#。对于我们这种基于动态类型语言的用户来说,这并不重要,但它确实意味着这些想法的标准表述与类型、模板、接口和匿名函数紧密交织。在这里,我们将稍微不那么 Pythonic,也许会稍微多一些 C#风格。话虽如此,让我们继续编码。

观察者

可以说,响应式编程系统中最基本的元素是观察者接口。如何通知一个对象序列中的下一个项目正在观察它的定义是可用的。在下面的代码示例中,我们从一个抽象基类开始,这是 Python 中与 C#接口最接近的等价物:

图片

我们的Observer类根本不定义任何功能,只定义方法名称和签名,并保证从它继承的类必须实现至少on_event方法。为了实现完整的功能,它们还必须实现on_exceptionon_complete,但这不是必需的。

因此,意图是Observer类将为序列中的每个元素调用一次其on_event方法,如果观察者在观察它时序列终止,则随后调用其on_complete方法。如果出现意外情况,将调用on_exception方法代替。

为了使Observer类有用,还必须有一个Observable类。那么,让我们看看这个类的开头。

可观察对象

Observer类似,Observable也是一个抽象基类,尽管在这种情况下,我们提供了所有功能的有意义默认实现。

在下面的代码示例中展示的subscribe方法,是Observer类连接到Observable类的方式,将自己注册为Observable类发出的事件的消费者:

图片

发射事件

有三种方法负责处理事件的发射。具体如下:

  • 用于发送正常事件的一个

  • 用于发送异常的一个

  • 用于发送“此序列已结束”事件的一个

这些在下面的代码示例中展示:

图片

在每种情况下,它们都会进行一些错误检查,然后循环遍历已注册的观察者并调用适当的方法。这些方法以单个下划线(_)为前缀命名,表明它们不是Observable类的公共接口的一部分。它们是帮助子类更容易编写的辅助工具。

这不可能是一个完整的响应式编程系统,对吧?是的,也不是。它从根本上说是完整的,但它缺乏许多改进,并且绝对还没有准备好进入生产环境。不过,它将很好地作为我们的演示程序的脊梁,所以让我们继续吧。

构建可观察序列

对于我们的演示,我们将创建一个程序,打印出大致代表我们可能在动物园中听到的声音的消息。动物将被表示为可观察的,它们在随机的时间间隔内发出事件,代表声音。我们将使用合并和映射来组合和修改事件序列,最后打印出结果序列。

因此,首先,我们有我们的Animal类,它是一个可观察者,以及AnimalEvent辅助类。Animal类包含一些基本信息和一个协程,该协程将异步运行,并偶尔向Animal类的观察者发送事件,如下面的代码示例所示:

图片

通过查看前面的代码,我们可以看到动物实际上是一系列噪音事件,然后是一个掷骰子的事件,紧接着是序列的完成。

我们希望某些动物能够发出响亮的声音。我们不会将这种能力添加到Animal类中,而是将在事件序列上创建一个映射,用响亮的声音事件替换随机选择的噪音事件。

这个映射既是观察者,因此它可以订阅事件序列,同时也是一个可观察者,因为修改后的事件序列仍然是一个事件序列,如果另一个可观察者无法订阅它,那么它就没有什么用处。

这就是我们在对可观察序列应用操作符以创建新的可观察序列时,任何响应式编程系统所发生的根本性事件。然而,在几乎所有情况下,一个真实的响应式系统都为我们提供了一个更快、更简单、通常也更有效的方法来做这件事。

确实创建一个既是观察者又是可观察者的类是非常罕见的!

展示一系列动物事件

在我们开始组合事物之前,我们还需要一个显示动物事件流的方法。对于这个任务,另一个观察者是一个明显的选择,并且正如你将在下面的代码示例中看到的那样,这实际上非常简单:

图片

新观察者的代码与我们之前看到的类似;我们需要的只是一个构造函数和一个适当的on_event方法。

组合可观察序列

现在我们已经拥有了所有部件,我们该如何将它们组合起来以实现我们的目标呢?嗯,首先我们创建我们的动物对象,然后使用SometimesLoudOutput类来创建我们的修改后的复合序列,并像以下animals.py代码示例中所示那样显示它:

然后,我们需要通过asyncio调度每个动物的运行方法以进行异步执行,这在以下示例中是隐式发生的,当我们把它们作为参数一起传递到__main__.py文件中时:

我们的__main__.py文件实际上运行了asyncio事件循环。所以,现在我们只需坐下来,观察我们想象中的动物园的伪混乱,如下面的输出窗口所示:

你注意到我们的程序的核心被简化为一行代码了吗?

当然,我们有一个整个文件是关于这个框架的,但这可以重用。我们还有SometimesLoudOutput类,但它们存在的唯一原因是我们可以看到这个程序每个步骤中确切发生了什么。

在一个真实系统中,OutputSometimesLoud将使用内置功能将函数映射到序列上,正如我们将在下一节中看到的。所有这些都让我们只剩下一行代码,这行代码组合了多个可观察序列和转换,并定义了程序的大部分行为。这一行代码展示了响应式编程的力量。

使用 Python 的响应式扩展(RxPY)

现在我们已经对响应式编程有了基本的了解,让我们看看更广泛使用的响应式编程框架之一,称为响应式扩展,通常简称为ReactiveX,或者简单地称为Rx

Rx 不是 Python 标准安装的一部分,因此我们需要使用pip来安装它。没问题;这只是一个命令,如果你更喜欢将安装到 Python 系统库而不是用户库,或者你正在使用由--user命令创建的虚拟环境中,如下所示:

一旦我们安装了 Rx,我们就可以继续进行有趣的部分。

将我们的动物园演示转换为 Rx

就像我们上一节中的示例一样,Rx 提供了ObserverObservable类,并且它们包含相同的基本功能。

最明显的区别是 Rx 的Observable类有许多工厂方法,可以用来构建特殊用途的可观察对象,特别是基于一个或多个其他可观察序列生成序列的可观察对象。换句话说,我们将使用的大多数操作符和操作都是Observable类的方法。

让我们花点时间重写上一节中的演示,并在 Rx 中看看这意味着什么。

AnimalEvent类可以保持不变,因为它只是一个数据结构,并不知道谁在使用它以及用于什么。我们的Animal类变化很大。运行协程方法消失了,取而代之的是稍微简单一点的generate_event方法(参考下面的代码示例):

顺便说一下,那个名字并不重要;它只是我选择的一个合理的名字,因为它描述了该方法的功能。

如前述代码所示,generate_event方法本身并不包含旧运行协程方法的所有功能。它知道如何发出事件,但不知道如何等待一段时间后再做。这就是as_observable方法发挥作用的地方。

可观察的工厂方法

as_observable方法使用Observable类的工厂方法之一来创建一个可观察的序列。这个序列在功能上是一个生成器,尽管它并没有作为生成器实现,因为 Rx 可移植到的不是每种语言都存在这个概念。

因此,我们不是提供一个真正的生成器,而是提供一个状态变量,在这个例子中是动物实例,以及它可以调用的函数来检查序列是否继续,更新状态,获取序列中的下一个值,或者确定在产生下一个值之前要等待多长时间。工厂方法还接受一个调度器对象,我们将在本节稍后讨论。所以,在这段代码中,我们要求的是一个产生动物事件的可观察序列,这些事件在 0 到 10 秒的随机间隔内。现在,Animal类可能比以前简单一些;没有太大的区别。

然而,让我们看看SometimesLoudOutput类发生了什么(参考下面的代码示例);它们不再是类,只是函数,并且要简单得多:

sometimes_loud函数接收一个事件并返回一个事件,我们将用它将一个事件的可观察序列映射到一个新的序列中,就像在函数式编程环境中预期的那样。output函数接收一个事件并返回 none,这在函数式系统中对side_effect的预期也是一样的。

解释事件的可观察序列

现在,我们有了我们的可观察工厂方法和一个用于接收和返回事件的函数;我们需要做什么来把它们全部组合起来?首先,我们将创建一个asyncio调度器。接下来是更有趣的部分,我们将告诉计算机如何组合和处理这些可观察序列。

创建一个 asyncio 调度器

我们可以使用一个简单的命令创建一个asyncio调度器,如下所示:

scheduler = rx.concurrency.AsyncIOScheduler ()  

这是一个专门与asyncio事件循环集成的 Rx 调度器。Rx 包含许多不同的调度器实现,它们与 Python 中可用的各种事件循环集成,以及一个使用 Python 线程来实现调度的实现。

不论我们使用哪个调度器,调度器的任务将是决定我们事件管道中基于时间的元素何时发生。这意味着在这个例子中,调度器将决定我们的动物可观察对象何时产生新值。

在创建调度器之后,我们创建动物对象及其事件的可观察序列。动物对象是容易的部分,如下所示:

组合和处理可观察序列

对于组合和处理可观察序列,我们有三个步骤要遵循。这些解释如下:

  • 首先,我们将大象和狮子序列合并成一个单一的组合序列,并通过我们的sometimes_loud函数处理该序列以创建一个新的序列,我们称之为louder,如下面的代码示例所示:
      louder = rx.Observable.merge(elephant,
      lion).select(sometimes_loud) 

在该行中使用的select方法在函数式编程环境中是 map 函数的直接等价物。

  • 接下来,我们将louder序列与剩余的动物序列合并,并告诉系统,每当新值到达合并序列的前端时,它应该调用该值上的输出函数:
      out = rx.Observable.merge(fox, snake, louder)
      .do_action(on_next = output) 

在本例中使用的do_action方法与 map 不等价,因为它不转换序列;它只是在序列的每个元素上执行一个操作。

do_action方法用于副作用。

  • 最后,如以下所示,我们订阅了输出可观察序列上的on_completed事件,这与将所有事件流合并为一个序列的序列相同,因为do_action操作返回其输入序列不变:
      done = asyncio.Future() 
      out.subscribe(on_completed = (lambda:
      done.set_result(True))) 
      return done 

当序列完成时,我们在done未来上设置一个结果值。由于我们在main.py文件中使用了该特性作为运行直到完成的边界,设置其结果值将终止asyncio事件循环,我们的程序结束。

本节中animals.py文件的完整代码列表如下:

杂项可观察工厂方法

我们刚刚完成的工作演示了Observablemergemerge.selectgenerate_with_relative_time工厂方法,但这只是冰山一角。

有如此多的可观察工厂(以下图像是其中的一部分)的样本,仅简要描述每个就需要比我们拥有的时间还要多:

每个方法都为我们提供了一种构建可观察对象的有用方式,通常是基于一个或多个其他可观察对象,但并不总是如此。

Python 的交互式外壳和 help 函数是我们的朋友。通过在 Observable 类中探索,我们可以学到很多东西。同时,我们将讨论一些我们尚未看到的非常好的 observable 工厂方法。

Observable.create 方法

在这个列表的第一位是以下代码示例中显示的 Observable.create。这是创建完全自定义 observable 的推荐方法:

rx.Observable.create((lambda obs: obs.on_next('Hi!'))) 

create 方法以可调用为参数,并在观察者订阅 observable 时调用该可调用。

在前面的代码示例中,我们创建了一个当观察者订阅时说 Hi! 的 observable,然后不再产生其他值;这不是最有用的序列,但它有助于说明这个想法。

我们可以从基本框架中构建一个具有任何所需行为的 observable,而无需对 observable 类进行子类化,也无需重新实现,可能不正确地实现保持 observables 在异步或并行环境中同步和正常工作的内部机制。

Observable.select_many 方法

接下来是 Observable.select_many。这次,让我们看看 Python 的 help 函数可以显示什么,使用以下命令:

help(rx.Observable.select_many)  

这应该给出以下描述:

图片

这个 observable 工厂方法是 select 的一个更通用版本。在这里,select 对序列的每个成员应用一个函数,从函数的返回值创建一个新的序列,而 select_many 期望一个返回 observable 序列的函数并将这些序列连接起来。

这意味着 select_many 应用函数可以通过返回一个空序列来从序列中删除元素,也可以通过返回包含多个元素的序列来插入值。

与 select 类似,添加到结果中的值也不一定是传递给函数的相同值,因此 select_many 可以产生比输入序列包含更多或更少的值的序列,并且值可以按我们的选择确定。

Empty, return_value, 和 from_iterable 工厂方法

使用 emptyreturn_value 工厂方法分别可以轻松创建空序列和只有一个值的序列。这些可以通过两个命令来展示,分别用它们的帮助页面进行说明。

help (rx.Observable.empty) 

此命令将带我们到以下帮助页面:

图片

类似地,在 return_value 的情况下,我们可以使用以下命令:

help (rx.Observable.return_value)  

我们将获得以下帮助页面,解释如何使用该方法:

图片

类似地,使用 offrom_iterable 工厂方法可以轻松构建任何已知对象序列的 observable 序列。

Where 工厂方法

虽然我们可以使用 select_many 从可观察序列中移除不需要的事件,但使用 where 方法会更简单。让我们看看 where 方法的帮助信息:

help (rx.Observable.where)  

此方法将一个可调用对象应用于输入可观察序列的每个元素,并返回一个只包含那些可调用对象返回 true 的元素的观察序列。以下图像显示了帮助描述:

现在我们已经看到,有基本的方法来添加和删除可观察序列,那么关于处理它们呢?我们本可以使用 selectselect_many 来完成所有的处理,但 Rx 为我们提供了许多更多的方法,例如 minmaxaveragedistinctslicezip,仅举几个我们可用的工具。我强烈建议您更详细地研究 Rx 框架。

摘要

在本章中,我们讨论了响应式编程是什么,并实现了一个基本的响应式框架,并使用它来实现一个演示程序,以帮助我们掌握这些概念。我们研究了 Python 的响应式扩展,并使用它们重新实现了我们的动物园演示。最后,我们探讨了 Rx 框架的一些更广泛的可能性。

在下一章中,我们将探讨微服务,它们是非常小的服务器进程,旨在协同工作以产生所需的结果。

第十一章:微服务

在上一章中,我们探讨了响应式编程和 ReactiveX 框架。在本章中,我们将探讨什么是微服务,为什么我们可能想要将我们的程序结构化为微服务,以及如何使用一些常见的 Python 工具来创建它们。你将学习如何使用 Flask 包快速轻松地构建一个使用 HTTP 和表示状态传输REST)来提供其接口的微服务。我们还将探讨使用 nameko 包来创建使用远程过程调用而不是 HTTP 方法进行通信的微服务。

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

  • 微服务与进程隔离的优势

  • 使用 Flask 构建高级微服务

  • 使用 nameko 构建高级微服务

微服务与进程隔离的优势

在本节中,我们将从概念角度探讨微服务。当我们需要一个项目的新功能时,有一种诱惑就是直接将其添加到项目的主程序中并继续下去。有时,这样做是完全合适的,但在许多情况下,实际上更好的做法是将该功能作为一个独立的程序。

微服务架构的优势

有几个原因说明为什么一个功能在系统集成不那么紧密时可能更好。其中最重要的原因是灵活性可伸缩性耐久性

理解灵活性优势很容易。模块化程序本质上由许多模块组成,我们可以在未来重用它们。因此,每次我们将代码编写为具有良好定义接口的独立模块时,我们都在进行一项投资,这将使它更容易适应未来的变化。

当我们的模块实际上是单独的进程时,可伸缩性的优势就会发挥作用,允许在多个处理器上运行单独的实例,并在它们之间进行负载均衡。

当模块是进程时,耐久性的优势也发挥作用,因为进程大多可以免受其他进程中出现的问题的影响,也因为一个失败的进程通常可以在不需要关闭整个系统的情况下重新启动。

将微服务架构应用于 Web 服务器

灵活性、可伸缩性和耐久性是推动 20 世纪 80 年代微内核操作系统发展的相同优势,但术语微服务(参见图)具体指的是将它们应用于 Web 应用程序:

图片

这意味着我们不会编写一个处理我们 Web 应用程序所有逻辑的服务器程序,而是编写一个或多个服务器,它们处理大部分前端工作,并调用一些不同的专用服务器来处理所有后端工作以及前端剩余的部分。

每个专用服务器都应该尽可能做好一个明确定义的工作,而不关心其他任何事情。这些专用服务器是微服务,采用微服务设计可以给我们带来更好的正常运行时间。正常运行时间让我们能够扩展到利用服务器农场或云托管系统,并帮助我们更快地适应不断变化的网络。

因此,为了总结,当我们的服务器实际上是一组服务器,每个服务器都有明确和狭窄的工作要做,并且只做那件事时,我们正在使用微服务架构。作为额外的优势,我们的微服务之间的接口构成了应用程序编程接口。因此,如果我们达到一个想要向世界公开 API 的位置,我们只需要调整我们的身份验证和授权代码,以及可能的路由器,以允许外部实体访问这些接口的一些接口。

使用 Flask 构建高级微服务

因此,我们已经了解了微服务是什么以及为什么将我们的服务器结构化为微服务集合是有帮助的。现在,让我们看看实际操作,并使用 Flask 构建一个功能性的微服务。

微服务可以很容易地分为那些使用 Web 技术(如 HTTP)相互通信的微服务,以及那些使用专用进程间通信或远程过程调用机制进行通信的微服务。

每种框架都有其优势,这取决于项目的具体需求,并且它们在本质上并不容易使用。然而,我将把使用 Web 技术进行通信的微服务称为高级,因为它们本质上更接近用户操作的水平。相反,我将把使用专用协议的微服务称为低级。在本节中,我们将探讨高级微服务,这些服务通常使用 HTTP 进行通信,并提供基于 REST 的编程接口。

使用无状态协议进行通信使得这些微服务易于负载均衡,也便于在维护时进行替换。对于这类微服务,我们可以使用 Python 的多个生产级 Web 应用程序框架或工具包,但我们将使用 Flask。

安装 Flask

Flask 专注于使编写 HTTP 请求处理程序变得容易,实际上并不做其他任何事情。这使得它非常适合编写处理少量特定请求的微服务,同时使用最少的资源。

Flask 不是 Python 标准库的一部分,但可以通过pip轻松安装,如下面的命令行所示:

$python3 -m pip install flask

如往常一样,您可以在命令中添加--user以将 Flask 安装到您的个人 Python 包库中,或者如果您愿意,可以将其安装到虚拟环境中。

在 Flask 中创建 RESTful API 的端点

Flask 被设计用来通过Web Server Gateway InterfaceWSGI)与前端 Web 服务器或代理进行接口交互,这是 Python Web 应用程序的标准。然而,在我们的简单演示中,我们只会使用其内置的开发服务器。那么,我们应该构建什么呢?

构建一个维护数据库的微服务

让我们构建一个维护有关人员信息的数据库的微服务——首先名字、姓氏、年龄,以及出于兴趣,他们是否是某个特定俱乐部的成员。

我们将使用 HTTP 的POSTGETPUTDELETE方法,允许微服务的客户端创建、访问、更新和从数据库中删除记录。此外,我们将以 JSON 格式提供数据,但使用正常的 HTTP 表单编码来接收输入。

这些选择都非常正常。唯一稍微不那么常见的选项是,我们还需要将传入的数据格式化为 JSON,我们可以通过在需要数据时调用 Flask 的request.get_json函数来满足这一需求。

那么,我们需要哪些样板代码才能让 Flask 微服务启动运行?实际上并不多。以下两个命令行就足以将 Flask 系统设置到位:

import flask 
app = flask.Flask('Demo Flask') 

这些行实际上并没有做任何事情,除了对任何请求返回404 Not Found错误,但它们会响应。那么,我们如何让 Flask 处理请求呢?让我们看看。

让 Flask 处理请求

实际上,有两种方法可以让 Flask 处理请求:一种方法非常简单,另一种则更加灵活且封装得更好。

简单的方法是使用@app.route装饰器告诉 Flask,特定的函数将处理给定路径的请求,如下面的代码示例所示:

@app.route('/example') 
def example(): 
  return "This is an example" 

除了让函数真正做一些有用的事情之外,这就足够了。

然而,对于我们的微服务,我们希望在同一路径上使用 HTTP 方法来产生不同的结果。我们可以使用app.route装饰器和函数中的多个ifelse if块来处理这个问题,但有一个更好的方法,这将在以下代码示例中解释,你可以在下载包中的endpoint.py文件中找到:

图片

Flask 支持可插拔视图类,并且特别有一个MethodView类,它为每个 HTTP 方法都有一个不同的处理函数,正如你在我们类中的前四个函数定义中可以看到的那样。适当的函数根据使用的 HTTP 方法处理每个请求。

这有一点复杂,因为我们并不总是想使用相同的路径。有时,我们想在路径中有一个对象id,有时则不需要。但这只是一个小的复杂问题,因为我们可以在多个不同的路径和方法组合上注册相同的可插拔视图,这正是endpoint.py中的endpoint.register函数所做的事情,正如你在代码示例中可以看到的那样。

view的每个注册都包含对app.add_url_rule的一个调用:

  • 第一个注册了没有对象idGET方法,并在调用GET函数时填充用于id参数的值。

  • 第二个注册了没有id和默认值的POST方法,因为我们的POST函数根本不接受id参数。

  • 第三个注册了当存在id参数时的GETPUTDELETE方法。

这些注册涵盖了 REST 单个编程接口端点常见的所有用例。

使用 Flask 运行和连接到我们的微服务

现在我们已经准备好了方法分发,那么我们如何组合实际处理人员对象的处理器呢?我们可以使用service.py文件中的以下代码来完成:

图片

对于详细的代码,请参阅代码文件。

从前面的代码示例中,我们发现以下内容:

  • 我们看到我们的PersonAPI类,它为我们在person数据库上想要启用的每个操作都有一个函数。

  • 对于POSTPUT函数,我们使用request.form对象从请求中获取数据,这是一个类似于字典的对象,包含从请求体中解码的数据。

这看起来似乎不是线程安全的,但实际上它是。Flask 中的所有内容都是多线程和多进程安全的;它只是被一个简化语义层所包装,模拟了一个单线程单进程系统。

  • POST函数没有处理缺失数据的代码,除了成员值。这是因为如果我们尝试从request.form访问缺失的值,将引发异常,导致 Flask 返回一个400 Bad Request错误,这正是这种情况下的正确做法。

  • 另一方面,PUT函数会自行处理这些异常,因此它可以决定哪些值需要更新,哪些可以保持不变。

  • GET函数可能被一个整数idNone作为id参数调用,并需要处理这两种情况。这可以通过一个if语句轻松完成。

  • id参数是数字的情况下,GET函数应该返回具有该id参数的对象的状态。如果idNone,它应该返回所有对象的列表。

所有方法都返回 JSON 格式的数据,这是通过使用 Python 的内置json包和特定的json.dumps函数来实现的,该函数将 Python 数据结构转换为 JSON 格式的字符串。

我们还需要提供内容类型头,其值为application/json,这是良好的实践。在返回语句中的这两件事之间,我们还提供了 HTTP 状态码。这可以省略,但由于我们在某些地方返回错误代码,所以在不是错误的情况下包含状态码也是有意义的。

测试运行微服务

要对我们的微服务进行测试运行,我们需要启动 Flask(使用以下命令)并告诉它为我们提供微服务:

对于 Unix/Linux 和 macOS,运行以下命令:

export FLASK_APP=demo_flaskpython3 -m flask run

对于 Windows,运行以下命令:

set FLASK_APP=demo_flaskpython3 -m flask run

demo_flask 包还包含一个名为 test.py 的模块,我们可以将其用作连接到我们的微服务并对其进行测试的客户端。它将添加、删除、列出和修改一些数据库条目,并尝试一个错误的 POST 请求以显示错误处理正在工作。

还有另一个 Flask 示例微服务的一部分我们没有讨论,也不会详细讨论。那就是 person.py 文件,它包含一个与 SQLite3 数据库的简单接口,用于实际存储和检索对象。当然,您可以随意查看它,但它与本章的主题并不特别相关,并且生产系统可能应该使用 SQLAlchemy、Redis、CouchDB 等等。

使用 nameko 构建高级微服务

在本节中,我们将探讨 nameko,它将帮助我们构建一个使用 高级消息队列协议AMQP)进行通信的微服务,我们可以安全地将其视为 远程过程调用RPC)协议,尽管它实际上只做了其中的一部分。

安装 nameko

使用 HTTP 定义我们的微服务接口具有熟悉性和与网络技术的良好集成优势,但将请求和输入数据映射到对我们真正有意义的函数和参数上涉及一定量的开销。

当然,我们可以想出一些抽象来隐藏这个过程的一部分。这正是 nameko 的作者所做的事情,尽管他们使用 AMQP 而不是 HTTP 来传输数据和事件。

安装 nameko 本身很简单。使用我们通常使用的几乎相同的 pip 命令,如下所示:

python3 -m pip install nameko

上述命令的输出如下:

图片

然而,请注意,nameko 除非我们安装其他一些软件,否则实际上不会工作。更多关于这一点的内容在 使用 nameko 之前需要了解的事项 部分有所涉及。

使用 nameko 运行和连接微服务

观察我们的 person 服务的 nameko 版本,很明显,我们的努力得到了回报。我们的服务由一个具有 name 属性的类定义,并且有几个用 RPC 装饰的成员函数,如下面的代码示例所示:

图片

成员函数与我们在上一节中使用过的相同的数据库接口类进行接口。以一种非常直接的方式,甚至可以将这两个合并为一个类,这就是我们使用 nameko 的微服务的完整定义。听起来很棒,对吧?确实如此,但每朵云都有银边,我们很快就会看到。

使用 nameko 之前需要了解的事项

Nameko 很棒,但在选择 nameko 或任何类似工具之前,我们需要注意一些事情。

首先要注意的是,nameko 不提供完整的 AMQP 基础设施,它只是连接到它。

AMQP 基础设施负责以快速和可靠的方式在连接的程序之间传递消息。

这意味着我们需要一个 Nameko 可以找到的 AMQP 服务器,以及一个微服务用户可以访问的 AMQP 服务器,并且这些服务器需要相互连接。

当然,它们可以是同一个服务器,但不必是。Nameko 推荐使用 RabbitMQ AMQP 服务器,可以从其官方网站下载(www.rabbitmq.com)。安装相对简单,网站上还有详细的说明。

与我们的微服务交互

现在我们已经安装并运行了 AMQP 服务器,我们能否连接或服务并操作人员对象?技术上是可以的,但我们需要使用 nameko 来编写客户端。

AMQP 不像 HTTP 那样简单易用,RPC 机制增加了额外的复杂性层。我们不希望直接处理这些原始数据。

与我们的微服务交互有两种方式。让我们更详细地考察它们。

使用 nameko shell 手动与微服务交互

我们与微服务交互的第一种方式是手动使用 nameko shell,这是一个增强的 Python shell。首先,我们必须运行微服务,我们将在它的命令窗口中运行以下命令:

nameko run demo_nameko.service

然后,我们将启动nameko shell,并使用末端的RPC对象来访问我们的微服务的功能,如下面的代码示例所示:

这里,看起来我们只是在调用函数,但实际上我们是通过 AMQP 协议与微服务进行通信的。

通过创建另一个微服务与微服务交互

我们与微服务交互的第二种方式是创建另一个依赖于它的微服务,如下面的代码示例所示,该示例位于test.py文件中:

TestService类定期对person服务进行测试。它还演示了如何将一个服务链接到另一个服务,以便其中一个能够访问另一个。关键是这一行,它在类上创建了一个RpcProxy实例:

person = RpcProxy('person')

当服务运行时,它能够通过该对象访问指定远程服务的功能。为了运行我们的测试,我们需要启动person微服务和test微服务,我们可以使用单个命令完成,如下面的代码所示:

nameko run demo_nameko.service demo_nameko.test

你可能已经注意到,我们从create函数返回对象id,而不是创建一个person对象。这是因为我们不能返回person对象。

Nameko 函数可以返回任何可以表示为 JSON 的数据,但这不包括任意类的实例。对于传递给微服务函数的参数也是如此;它们需要限制在 JSON 的范围内。

这实际上并不比我们在 Flask 微服务(在上一节中)看到的限制更严格。只是在使用 Flask 时,很明显我们正在以 JSON 格式通过网络发送数据,因此这种限制是显而易见的。在 nameko 中,要求是相同的,但原因更容易被忽视。

摘要

在本章的开头,我们探讨了微服务的哲学意义及其优势,并使用 Flask 实现了一个面向 HTTP 的微服务。

我们随后探讨了基于流程的模块化的优势,并了解了将这些原则应用于 Web 应用将如何导致微服务架构。我们研究了使用 Flask 创建 RESTful 微服务所需的细节,并通过构建一个简单的个人管理微服务来应用这些知识。

接下来,我们探讨了使用 nameko 的 RPC 机制来实现微服务,这显著简化了代码,但代价是需要我们设置 AMQP 基础设施,并且与 AMQP 网络外部的系统接口更加困难。

在下一章中,我们将探讨如何将 Python 与编译代码接口,以优化代码中的性能瓶颈,并访问用其他编程语言编写的库。

第十二章:扩展模块和编译代码

在本章中,我们将讨论如何将编译代码集成到 Python 程序中。我们将探讨编译代码的优缺点,并查看两种将 Python 的管理环境与直接在硬件上运行的代码连接起来的方法。

我们将了解如何使用 ctypes 包并将其与 C 动态库的接口绑定,从我们的 Python 代码中调用其函数并接收它们的输出。我们还将探讨一种简单的方法来编写编译代码模块,以便它们可以直接从 Python 中导入和调用。

我们将详细介绍以下主题:

  • 编译代码的优点和缺点

  • 使用 ctypes 访问动态库

  • 使用 Cython 与 C 代码接口

编译代码的优点和缺点

使用编译代码有许多实际优点。Python 是一种非常高效的编程语言,但它可能无法满足很多人的需求。有时,我们需要与用不同语言编写的代码进行接口。这样做的一个原因可能是在我们需要访问一些用不同语言编写的功能,而这些功能在 Python 中不存在。

只要相关代码是在具有 C 兼容接口的动态库中,使用 Python 的标准 ctypes 包提供的外部函数接口(FFI)从 Python 内部调用代码就相对简单,我们将在下一节中讨论这个包。

或者,我们可能需要编写一些接近金属(CTM)运行的代码,要么是为了最大化我们项目中已证明是瓶颈的算法的性能,要么是直接与某些硬件接口。在这种情况下,我们需要编译自定义代码并将其链接到 Python 环境。你可以使用一个名为 Cython 的工具轻松完成此操作(更多详情请参阅 cython.org),我们将在本章的第三部分中讨论它。

编译代码的缺点

虽然使用编译代码通常是非常好的,但可能会有一些显著的缺点。让我们来看看它们:

  • 首先要考虑的是,我们在编译代码的这个级别上工作时很容易创建极其奇怪的 bug。ctypes 和 Cython 都允许我们在程序的内存中制造混乱,可能产生任何可想象到的 bug 或错误。以下图表展示了一个潜在的底层 bug 示例。你可以想象调试这样的 bug 会有多困难。

如果我们很幸运,那个错误或 bug 会导致程序直接终止,也就是说,如果它违反了操作系统受保护内存管理器的限制。

我认为我们会很幸运,因为如果这种情况没有发生,那就意味着我们对程序状态的一部分进行了有效的随机更改,谁知道那会带来什么后果。

  • 第二个缺点是它使得分发我们的程序变得更加困难。对于一个普通的 Python 程序,我们可以向用户分发单个 .pyz 文件,或者上传一系列工具、兼容的源代码包或中立的操作系统 wheel 文件到 Python 包索引PyPI)。使用编译代码意味着我们必须担心我们的用户使用的是哪种操作系统和硬件架构,并为我们想要支持的每种组合提供单独的包。

  • 第三个缺点仅适用于我们编写自己的编译代码,主要用于那些 Cython 最有用的用例。问题是实际上安装并使编译器工作可能很复杂,尤其是对于不习惯与编译器打交道的人来说。不仅如此,如果我们以源代码的形式分发我们的项目,我们的用户也需要经历同样的麻烦。

在奇怪错误的危险和创建和使用编译代码的项目所带来的烦恼之间,我们应该通常等到我们有充分的理由去采取创建或与编译代码接口的步骤。

现在我们已经很好地掌握了与编译代码接口的好处:它使我们能够访问用其他语言编写的与 C 兼容的库。这段代码让我们能够优化关键算法的性能,并使我们能够直接与硬件或低级驱动程序接口。

同样,我们知道其缺点是什么,即潜在的看似无法解释的错误以及在整个开发和分发过程中普遍较高的烦恼和难度。现在,让我们看看如何将我们获得的一些知识付诸实践。

使用 ctypes 访问动态库

在本节中,我们将将我们的重点缩小到 Python 标准库的 ctypes 包,它允许我们在 Python 中与动态库交互。

定位和链接动态库

很可能,与编译代码交互的最常见需求是当有一个库正好符合我们的需求,但它不是 Python 库。也许它原本是为 C 编写的,或者它有一个 C 接口。

我们不会让这样一个小问题阻止我们。相反,我们将使用 ctypes 为库创建一个接口模块。对于基本使用,ctypes 非常简单。我们只需要导入它,通过文件名加载动态库,并调用该库中的函数,如下面的代码示例所示:

图片

在这个例子中,我们调用的 CDLL 构造函数创建了一个代表包含 C 函数的动态库的 Python 对象。当然,由于不同的操作系统有不同的库命名约定,我们在定义库的文件名时需要格外小心。在我们的例子中,libc.so.6 属性是当前 Linux 版本上 C 标准库的文件名。

ctypes 包含一个名为 ctypes.util.find_library 的实用函数,用于帮助解决这个难题。如果我们向 find_library 传递一个基本名称,它将尝试找到系统上安装的完整版本名称,如下面的代码示例所示:

图片

ctypes.util.find_library 在 Linux 和 Mac OS X 上非常有用,但在 Windows 上则不那么有用,因为 Windows 动态库以非常不同的方式处理多个版本。

值得注意的是,当我们向 find_library 传递 C 字符串时,我们将在 Linux 和 Mac OS X 上找到 C 标准库。在 Windows 上,相同的库被任意地称为 msvcrt

对于跨平台库加载,我们需要能够指定几个库的备用名称;尝试在这些名称上使用 find_library(参考以下代码示例),如果 find_library 失败,则回退到尝试原始名称。

图片

与本书一起提供的 demo_ctype/libc.py 文件中有一个 load_library 函数,它也演示了这一点。

访问库中定义的函数

一旦我们加载了库,我们就能够访问从该库导出的函数,这些函数在 Python 中作为库对象的属性公开。我们在上一节中调用 C 的 printf 函数时看到了这一点。

现在,重要的是要知道,C 共享库中没有内容告诉库的用户函数的参数类型是什么,或者返回类型,甚至函数有多少个参数。这些信息在编译库时使用,但不是最终结果的一部分。这意味着我们必须知道函数应该如何使用。例如,C 库中包含一个名为 qsort 的快速排序函数,它旨在接受多个参数。让我们看看如果这些参数没有提供会发生什么:

图片

如前述代码示例所示,如果我们省略了参数,ctypes 就无法知道我们是否犯了错误,并且没有任何警告的情况下发生不良事件。

将属性分配给函数

如果我们计划系统地使用外部函数,或者特别是如果我们打算将其作为我们编写的模块接口的一部分公开,建议告诉 ctypes 关于函数签名的信息。我们可以通过将 argtypesrestype 属性分配给函数来实现这一点。

argtypes 属性应该是 ctypes 包中定义的 C 数据类型列表,而 restype 应该是这些包之一,如下面的代码示例所示:

图片

如代码所示,向 ctypes 提供这些信息可以显著提高外部函数的错误处理能力。atof 函数返回一个双精度浮点数,但如果我们不告诉它,ctypes 就不知道这一点。当 ctypes 没有信息时,它就假设返回值是整数;这在许多情况下是可行的,但在这个特定的例子中就毫无用处了。

使用指针作为函数的参数

C 函数接受指针作为参数并在指针指向的地址填充值是很常见的。自然地,ctypes 允许我们通过创建代表内存位置的对象并使用指针将此对象传递给调用函数的接口来处理这种类型的接口。C 的 scanf 函数的工作方式可以通过以下代码示例来说明:

在这个例子中,我们创建了两个 C 风格的变量,分别称为 integerdecimal;然后,我们使用 scanf 函数根据用户输入填充它们。byref 函数告诉 ctypes 我们不是传递变量的值给函数,而是传递其内存地址,这样函数就可以在那里存储某些内容。

提供函数签名

C 函数的另一种常见行为是将字节填充到字符缓冲区中。我们可以使用 ctypescreate_string_buffer 函数来分配这样的缓冲区,然后将其结果用作需要字符串缓冲区的函数的参数,如下面的代码示例所示:

让我们来看看这段代码。这里我们不需要使用 byref 函数,因为字符缓冲区本身是按引用提供的。在 C 中没有其他方法可以做到这一点。

这里有一个陷阱:这是几十年来困扰 C 程序员的陷阱之一。我们的字符串缓冲区有特定的长度,但我们传递给它的函数并不知道这个长度。如果它开始向缓冲区写入并超出缓冲区末尾,程序要么崩溃,要么开始表现出异常行为。始终确保您的缓冲区至少足够大,以便写入其中的任何内容。

提供数据结构布局

在 C 共享库中不可用的一件事是函数使用的数据结构的布局,如下面的代码示例所示:

再次,ctypes 给我们一种填充缺失信息的方法。用于表示日期和时间信息的 C 的 tm 结构看起来如下所示:

如此例所示,将其翻译成 ctypes 是直接的。我们需要创建一个从 ctypes.Structure 继承的类,并在该类中创建一个名为 fields 的列表,其中包含字段名称和字段类型的元组。

一旦我们掌握了这些,我们就可以创建类的实例,并像在 Python 中预期的那样分配属性。但是,我们也可以将其作为参数直接或通过引用传递给 C 函数,我们还可以将其用作外部函数签名的一部分。

ctypes 包提供了对 C 语言几乎所有特性的支持,但我们已经看到了调用库函数和使用其结果最有用的那些。

使用 Cython 与 C 代码接口

在本节中,我们将探讨一个名为 Cython 的第三方工具,它是另一个用于弥合 Python 与编译成机器码的软件之间差距的工具。

如果我们想在编译代码中实现项目的一部分,我们可以通过创建包含代码的动态库并使用 ctypes 调用它来实现。然而,这是一个绕弯路的方法,我们最终会写很多代码两次:一次是为了我们的编译器,然后再次告诉 ctypes 关于函数签名和数据结构等细节。

现在这样做是不高效的,违反了编程最重要的原则之一——不要重复自己。有一个工具更适合这种情况,你可能已经猜到了,那就是 Cython。

使用 Cython 进行工作

Cython 所做的是将 Python 源代码文件转换为包含对 Python C API 等效调用的 C 源代码文件;然后它将其包装在必要的模板中,以将其转换为 Python 二进制模块。

仅凭这一点就足够有用,但 Cython 还允许我们将调用注入 C 函数和低级数据访问操作到模块中。最终结果是,我们几乎可以像编写 Python 代码一样编写我们的编译代码,同时仍然获得最初促使我们编译它的速度或低级访问。

与直接使用 Python API 相比,这为我们节省了大量工作量。本课程附带的一个 100 行 Python 示例在编译后转换为超过 5,000 行 C 代码。让我们逐个分析这个示例,并在与纯 Python 进行比较时讨论差异。

Cython 的附加导入方法

Cython 允许除了 Python 包提供的常规导入机制之外,还有两种导入机制。让我们详细了解一下这些机制。

第一种机制称为 cimport,它从预先准备好的库中导入编译函数和数据结构的签名,如下面的命令行所示:

from cpython.mem cimport PyMem_Malloc, PyMem_Realloc, PyMem_Free 

在这种情况下,由于 Cython 随带了一个所有 Python C API 函数的预准备库,我们能够使用这个机制来报告关于 Python 的低级内存分配和释放函数所需的信息。

第二种额外的导入机制是 cdef extern from。当我们还没有编译函数的签名可用时,我们使用这种语法使其在 Cython 代码中可用。

缩进的块可以包含任意数量的函数声明、类型定义、结构定义等,如下面的代码示例所示。请注意,这些不需要完全正确,只要足够接近,使得 Cython 能够生成正确的 C 代码:

cdef extern from "string.h": 
    void *memcpy(void *str1, const void *str2, size_t n) 

一个 cdef extern from 块引用一个 C 头文件,这个头文件会被自动包含在生成的 C 代码中。虽然我们可以简单地使用正常的 Python 语法来编写 class 语句,但在前面加上 cdef 可以使我们能够在类的实例预定义变量成员中存储原始数据值,如下面的代码示例所示:

cdef class StatisticalArray: 
    cdef double* values 
    cdef int num_values 
    cdef int max_values 

在 Cython 中编写扩展模块

在前面的代码示例中,num_values 变量只是一个存储表示整数值的位的内存块。它没有将它们转换成 Python 对象所需的任何额外数据。

这在某种程度上是一个坏事情,因为它意味着 Python 不能做任何帮助我们快速编写好代码的智能事情。如果没有让我们这些工具为我们做这些工作的原因,我们不会使用这些工具。

因此,为了解决这个问题,让我们定义一个名为 StatisticalArray 的扩展类型(参考以下代码示例),它包含一个指向内存位置的指针和两个整数。这个内存位置应该包含双精度浮点数。这些是原始的机器级值,与 Python 值相比虽然比较原始,但它们非常快,因为它们可以直接输入 CPU 操作。

图片

在这个代码示例中,我们有设置和销毁 StatisticalArray 类实例的函数。

注意,设置函数被命名为 __cinit__,而不是 __init__。实际上,Cython 的 cdef 类可以同时拥有这两个。__cinit__ 函数的职责是为类实例设置原始变量,而正常的 __init__ 函数应该设置它包含的任何正常 Python 变量。

__cinit__ 函数首先被调用,并且不能保证 self 本身就是一个有效的 Python 对象,因此它应该仅限于初始化原始变量。__cinit__ 接受额外的位置参数和关键字参数的原因是,如果 Python 类从我们的类继承并添加了更多参数到 __init__ 的签名中,__cinit__ 函数仍然可以工作。

在过程的另一端,我们有__dealloc__函数,它需要释放与类实例关联的任何特殊分配的资源。在前面的例子中,我们在__cinit__中分配了一块内存,因此我们需要在__dealloc__函数中释放这块内存。

提高 Python 代码执行速度的方法

现在我们将定义一些 Python 的常规魔法方法,这将使我们能够遍历存储在我们对象中的值并通过索引访问它们。为此,请参考以下代码示例:

图片

在这个代码示例中需要注意的一点是,与我们在纯 Python 实现中所做的工作相比,变化有多小。唯一的真正区别是我们为每个参数和局部变量提供的类型定义。

我们实际上不必为每个参数和局部变量提供类型定义。类型定义让 Cython 为我们执行的大多数操作生成纯 C 代码,这意味着我们直接使用 CPU 而不是 Python 虚拟机。我们是在用速度换取灵活性。

之前的所有函数都是使用def关键字定义的,就像 Python 中的常规模块一样,这些函数的操作就像它们是在常规 Python 模块中定义的一样。

Cython 还给我们提供了两种其他选项。我们不是使用def,而是可以使用cdef;在这种情况下,函数调用速度更快,但它仅适用于其他 Cython 代码。这对于内部帮助函数等来说非常棒。或者,我们可以使用cpdef(如以下代码示例所示);在这种情况下,函数可以从正常的 Python 代码中访问,但它在从 Cython 代码调用时几乎与cdef函数一样快:

图片

在我们的情况下,我们选择了cpdef,因为我们希望向使用我们编译模块的任何 Python 代码公开完整的特性集,但我们还期望在 Cython 代码中使用相同的功能。

在 Cython 类中使用 cpdef

在以下代码示例中,您将看到我们为什么将我们的类命名为StatisticalArray。我们使用类实例中存储的值实现了几个离散的统计计算。您不必担心meanvariancecovariance实际上做什么,但让我们看看它们是如何实际工作的。

图片

mean函数中,这里我们看到一个循环,它计算数组中存储的值的总和。如果我们在这个 Python 中运行这段代码,每次循环都会涉及几个字典查找和函数调用。Cython 为这个操作生成了四行 C 代码,这转化为只有几个机器代码操作。

这是因为我们告诉 Cython,index 变量应该是一个 C 整数,而且 Cython 在处理使用 C 整数变量的循环方面非常聪明,因此我们在 variancecovariance 函数中都能获得类似的收益。再次强调,我们只是使用 Python 语法,并给 Cython 提供额外的信息,以便它可以用来优化生成的代码。

然而,关于 covariance 有一个不同寻常的注意事项。在以下截图的第一行末尾,我们看到 except? -200.0

cpdef double covariance(StatisticalArray self, Statistical other) except? -200.0 

那是什么?好吧,就像这些函数中的大多数一样,我们给 covariance 函数指定了一个显式的 C 返回类型 double。这在速度方面是一个很大的提升。但是当调用函数的代码也是 Cython,并且将返回值存储在一个 cdef double 变量中时,存在一个缺点。通常,Cython 会使用返回值来指示已引发异常,但当我们改变了返回类型时,它应该如何做呢?因为它无法知道在那个点哪些值是有效的?

好吧,我们会告诉您。添加 except? -200.0 表示如果返回值是 -200,Cython 会检查是否已引发异常。如果我们省略了 ?,那么 -200 总是意味着存在异常,这会稍微快一点;然而,我们不能走得太远,因为 -200 仍然是一个可能的有效返回变量。

注意,我们实际上并没有返回 -200;我们只是像平常一样引发一个异常。Cython 会处理其余的部分。以下是一些使用早期函数作为构建块构建的函数的更多示例:

由于我们使用了 cpdef,当我们定义 variancecovariance 函数时,从这里调用这些函数所涉及的开销非常小。

在 Python 中编译扩展模块

所以,假设我们已经编写了一个有点有用的 Cython 类;现在,我们如何使其对 Python 代码可用?好吧,我们必须编译它。

首先,这意味着我们需要有一个编译器。在 cython.org 的文档中有一个关于此过程的教程条目和附录,如果您系统上还没有编译器,我建议您参考它们。如果您已经有了编译器,安装 Cython 只是一个请求 pip 为我们获取它的问题:

python3.5 -m pip install Cython

安装可能有点慢,因为它需要在安装过程中进行一些编译;只需耐心等待它完成。

一旦安装了 Cython,我们需要创建一个 setup.py 文件来描述如何构建我们的扩展,如下面的代码示例所示。对于基本案例,这个过程非常直接。

我们正在工作的源代码保存在 statistics.pyx 中,如下面的代码示例所示。.pyx 扩展名是 Cython 源代码文件的标准文件扩展名。

一旦我们这样做,最后一步就变得简单了。一旦build_ext命令完成,我们应该在我们的.pyx文件旁边有一个编译好的扩展。

现在,我们只需要导入它,并按照以下方式使用:

图片

注意,当我们导致编译代码中的异常时,跟踪信息会完全填充,并指向问题的原因和位置。这是 Cython 的一个特性,也是一个很好的优势。

摘要

在本章中,我们讨论了使用编译代码的优缺点。我们探讨了使用 Python 的标准ctypes包来访问存储在编译的 C 兼容动态库中的函数,这是一种快速获取其他语言编写的功能的方法。

我们还看到了如何使用 Cython 在 Python 的高层和 C 的低层抽象之间操作,而在这个过程中,桥接差距的痛苦出奇地少。我们可以利用这些知识来优化程序中的瓶颈或访问仅在接近硬件操作时才可用的功能。

就这样,我们结束了这门课程。我希望你已经学到了很多,并且对 Python 有了巨大的知识收获。继续学习!

posted @ 2025-09-18 12:47  绝不原创的飞龙  阅读(19)  评论(0)    收藏  举报